Spaces:
Running
Running
R3.2.8.6-R1.3C-KK1-R6A-R2 Qualitative Quote Ownership and Empirical-Literature Evidence Boundary Fix
#38
by GuXiaopeng - opened
- core/generation_runner.py +198 -15
- core/qualitative_engine.py +129 -0
core/generation_runner.py
CHANGED
|
@@ -239,6 +239,132 @@ class GenerationRunner:
|
|
| 239 |
text = re.sub(r"[ \t]{2,}", " ", text)
|
| 240 |
return text.strip()
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
def _write_preview_payload(self, *, force: bool = False) -> None:
|
| 243 |
with self._preview_lock:
|
| 244 |
if self.preview_path is None:
|
|
@@ -797,6 +923,16 @@ class GenerationRunner:
|
|
| 797 |
)
|
| 798 |
self._checkpoint("evidence_ready", state, 6)
|
| 799 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
refs_text = str(state.get("refs_text") or "")
|
| 801 |
if not refs_text:
|
| 802 |
self._progress(8, "S2 + OpenAlex")
|
|
@@ -838,7 +974,7 @@ class GenerationRunner:
|
|
| 838 |
research_logic_response = self._stream_text(
|
| 839 |
agent.stream_generate_research_logic(
|
| 840 |
topic, keywords, synthesis_result, refs_text,
|
| 841 |
-
data_evidence=
|
| 842 |
)
|
| 843 |
)
|
| 844 |
research_logic_contract = re.sub(
|
|
@@ -851,7 +987,7 @@ class GenerationRunner:
|
|
| 851 |
self._progress(14, "working_front_matter")
|
| 852 |
working_front = agent.generate_working_front_matter(
|
| 853 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 854 |
-
data_evidence=
|
| 855 |
)
|
| 856 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 857 |
working_keywords = list(working_front.get("keywords") or [])
|
|
@@ -964,7 +1100,7 @@ class GenerationRunner:
|
|
| 964 |
if not working_abstract and not state.get("english_finalized"):
|
| 965 |
working_front = agent.generate_working_front_matter(
|
| 966 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 967 |
-
data_evidence=
|
| 968 |
)
|
| 969 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 970 |
working_keywords = list(working_front.get("keywords") or [])
|
|
@@ -1029,6 +1165,12 @@ class GenerationRunner:
|
|
| 1029 |
)
|
| 1030 |
state["citation_plan"] = citation_plan
|
| 1031 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
total_steps = len(adaptive_structure)
|
| 1033 |
for sec in adaptive_structure:
|
| 1034 |
display_name = sec["display_name"]
|
|
@@ -1075,6 +1217,11 @@ class GenerationRunner:
|
|
| 1075 |
"Never stack sources, substitute tags, or use literature as evidence for this study's own facts.\n"
|
| 1076 |
f"<CLAIM_EVIDENCE_PLAN>\n{claim_plan_text}\n</CLAIM_EVIDENCE_PLAN>"
|
| 1077 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1078 |
memory_context = agent._build_memory_context(sec_title, 2500)
|
| 1079 |
memory_block = f"\n<GLOBAL_MEMORY>\n{memory_context}\n</GLOBAL_MEMORY>\n" if memory_context else ""
|
| 1080 |
snippets = rag_engine.get_relevant_context(display_name) if rag_engine is not None else ""
|
|
@@ -1114,9 +1261,36 @@ class GenerationRunner:
|
|
| 1114 |
"Preserve any frozen stage/case/deviant role and use deviant evidence to qualify rather than erase the pattern."
|
| 1115 |
if concepts or evidence_labels else ""
|
| 1116 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1117 |
evidence_packet = (
|
| 1118 |
-
f"{base_evidence_contract}\n<FROZEN_RESEARCH_EVIDENCE>\n{
|
| 1119 |
-
if
|
| 1120 |
)
|
| 1121 |
completed_label = ", ".join(completed_sections)
|
| 1122 |
anti_repeat = (
|
|
@@ -1132,6 +1306,9 @@ class GenerationRunner:
|
|
| 1132 |
word_budget = APP_CONFIG.get("word_budget", {})
|
| 1133 |
min_ratio = float(word_budget.get("section_min_ratio", 0.92))
|
| 1134 |
max_ratio = float(word_budget.get("section_max_ratio", 1.08))
|
|
|
|
|
|
|
|
|
|
| 1135 |
user_prompt = f"""Write the assigned English manuscript unit.
|
| 1136 |
Section: {sec_title}
|
| 1137 |
Subsection: {subsection or 'None'}
|
|
@@ -1154,9 +1331,9 @@ ANCHOR PRECEDENCE: Frozen research evidence and the PROVISIONAL_RESEARCH_LOGIC o
|
|
| 1154 |
{memory_block}
|
| 1155 |
Literature synthesis: {synthesis_result}
|
| 1156 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 1157 |
-
{
|
| 1158 |
</FROZEN_RESEARCH_EVIDENCE>
|
| 1159 |
-
Previous tail: {
|
| 1160 |
Output only final academic prose. Do not report the word count."""
|
| 1161 |
if agent._count_tokens(sys_prompt) + agent._count_tokens(user_prompt) > 60000:
|
| 1162 |
user_prompt = agent._truncate_context(user_prompt, 60000 - agent._count_tokens(sys_prompt))
|
|
@@ -1189,14 +1366,20 @@ Output only final academic prose. Do not report the word count."""
|
|
| 1189 |
evidence_contract=evidence_packet,
|
| 1190 |
)
|
| 1191 |
en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False, mode=mode)
|
| 1192 |
-
#
|
| 1193 |
-
#
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1200 |
en_raw = agent.reinforce_claim_specific_citation_coverage(
|
| 1201 |
en_raw, assigned_tag_list, section_name=display_name
|
| 1202 |
)
|
|
|
|
| 239 |
text = re.sub(r"[ \t]{2,}", " ", text)
|
| 240 |
return text.strip()
|
| 241 |
|
| 242 |
+
@staticmethod
|
| 243 |
+
def _build_qualitative_quote_ownership(tasks, data_metadata):
|
| 244 |
+
"""Freeze one and only one Findings owner for every qualitative quote."""
|
| 245 |
+
evidence_by_tag = {
|
| 246 |
+
str(item.get("tag") or "").strip(): dict(item)
|
| 247 |
+
for item in (data_metadata or {}).get("evidence", [])
|
| 248 |
+
if isinstance(item, dict) and str(item.get("tag") or "").strip()
|
| 249 |
+
}
|
| 250 |
+
owners = {}
|
| 251 |
+
for task in tasks or []:
|
| 252 |
+
if str(task.get("section_id") or "") != "findings":
|
| 253 |
+
continue
|
| 254 |
+
owner = str(task.get("display_name") or "").strip()
|
| 255 |
+
for item in task.get("theme_evidence") or []:
|
| 256 |
+
if not isinstance(item, dict):
|
| 257 |
+
continue
|
| 258 |
+
tag = str(item.get("tag") or "").strip()
|
| 259 |
+
if not tag:
|
| 260 |
+
continue
|
| 261 |
+
if tag not in evidence_by_tag:
|
| 262 |
+
raise RuntimeError(f"Qualitative quotation ownership references unknown tag: {tag}")
|
| 263 |
+
if tag in owners:
|
| 264 |
+
raise RuntimeError(
|
| 265 |
+
f"Qualitative quotation has multiple Findings owners: {tag} -> {owners[tag]!r}, {owner!r}"
|
| 266 |
+
)
|
| 267 |
+
owners[tag] = owner
|
| 268 |
+
missing = sorted(set(evidence_by_tag) - set(owners))
|
| 269 |
+
if missing:
|
| 270 |
+
raise RuntimeError(f"Qualitative quotations lack a Findings owner: {missing[:8]}")
|
| 271 |
+
return owners
|
| 272 |
+
|
| 273 |
+
@staticmethod
|
| 274 |
+
def _redact_qualitative_quotes(text, data_metadata):
|
| 275 |
+
"""Hide prior verbatim quote text from cross-unit continuity context."""
|
| 276 |
+
value = str(text or "")
|
| 277 |
+
quotes = sorted(
|
| 278 |
+
{
|
| 279 |
+
str(item.get("quote") or "").strip()
|
| 280 |
+
for item in (data_metadata or {}).get("evidence", [])
|
| 281 |
+
if isinstance(item, dict) and str(item.get("quote") or "").strip()
|
| 282 |
+
},
|
| 283 |
+
key=len,
|
| 284 |
+
reverse=True,
|
| 285 |
+
)
|
| 286 |
+
for quote in quotes:
|
| 287 |
+
value = value.replace(f'"{quote}"', "[prior qualitative quotation omitted]")
|
| 288 |
+
value = value.replace(quote, "[prior qualitative quotation omitted]")
|
| 289 |
+
return value
|
| 290 |
+
|
| 291 |
+
@classmethod
|
| 292 |
+
def _bind_owned_qualitative_quotes(
|
| 293 |
+
cls,
|
| 294 |
+
text,
|
| 295 |
+
*,
|
| 296 |
+
display_name,
|
| 297 |
+
data_metadata,
|
| 298 |
+
quote_ownership,
|
| 299 |
+
prior_manuscript="",
|
| 300 |
+
):
|
| 301 |
+
"""Bind only the current Findings unit's owned quotation tags.
|
| 302 |
+
|
| 303 |
+
Other manuscript units are not allowed to contain frozen quote text or
|
| 304 |
+
quote tags. A direct verbatim reproduction of an owned quote is
|
| 305 |
+
normalized back to its tag before deterministic binding, preserving the
|
| 306 |
+
frozen wording without letting model prose create a second copy.
|
| 307 |
+
"""
|
| 308 |
+
value = str(text or "")
|
| 309 |
+
evidence_by_tag = {
|
| 310 |
+
str(item.get("tag") or "").strip(): dict(item)
|
| 311 |
+
for item in (data_metadata or {}).get("evidence", [])
|
| 312 |
+
if isinstance(item, dict) and str(item.get("tag") or "").strip()
|
| 313 |
+
}
|
| 314 |
+
allowed = {
|
| 315 |
+
tag for tag, owner in (quote_ownership or {}).items()
|
| 316 |
+
if str(owner) == str(display_name)
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
raw_tags = set(re.findall(r"\bQUOTE-\d+\b", value))
|
| 320 |
+
unauthorized_tags = sorted(raw_tags - allowed)
|
| 321 |
+
if unauthorized_tags:
|
| 322 |
+
raise RuntimeError(
|
| 323 |
+
f"Qualitative quotation tag used outside owning Findings unit {display_name!r}: {unauthorized_tags[:8]}"
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
# Quote text itself is also fenced. Owned verbatim text is converted
|
| 327 |
+
# back to a tag and then bound once; unowned verbatim text fails early.
|
| 328 |
+
for tag, item in evidence_by_tag.items():
|
| 329 |
+
quote = str(item.get("quote") or "").strip()
|
| 330 |
+
if not quote or quote not in value:
|
| 331 |
+
continue
|
| 332 |
+
if tag not in allowed:
|
| 333 |
+
raise RuntimeError(
|
| 334 |
+
f"Qualitative quotation text used outside owning Findings unit: {tag} in {display_name!r}"
|
| 335 |
+
)
|
| 336 |
+
value = value.replace(f'"{quote}"', tag)
|
| 337 |
+
value = value.replace(quote, tag)
|
| 338 |
+
|
| 339 |
+
# A quote already present in frozen prior prose cannot be rebound later,
|
| 340 |
+
# even after resume. This makes ownership idempotent without new state.
|
| 341 |
+
normalized_prior = re.sub(r"\s+", " ", str(prior_manuscript or "")).casefold()
|
| 342 |
+
for tag in sorted(allowed):
|
| 343 |
+
quote = str((evidence_by_tag.get(tag) or {}).get("quote") or "").strip()
|
| 344 |
+
if quote and re.sub(r"\s+", " ", quote).casefold() in normalized_prior:
|
| 345 |
+
raise RuntimeError(f"Qualitative quotation already used before owning unit: {tag}")
|
| 346 |
+
|
| 347 |
+
for tag in sorted(allowed):
|
| 348 |
+
count = len(re.findall(rf"\b{re.escape(tag)}\b", value))
|
| 349 |
+
if count > 1:
|
| 350 |
+
raise RuntimeError(
|
| 351 |
+
f"Qualitative quotation repeated within owning Findings unit: {tag} count={count}"
|
| 352 |
+
)
|
| 353 |
+
if count == 1:
|
| 354 |
+
quote = str((evidence_by_tag.get(tag) or {}).get("quote") or "").strip()
|
| 355 |
+
value = re.sub(
|
| 356 |
+
rf"\b{re.escape(tag)}\b",
|
| 357 |
+
lambda _match, frozen_quote=quote: f'"{frozen_quote}"',
|
| 358 |
+
value,
|
| 359 |
+
count=1,
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
# No unresolved quote tag may escape into citation QA or final output.
|
| 363 |
+
unresolved = sorted(set(re.findall(r"\bQUOTE-\d+\b", value)))
|
| 364 |
+
if unresolved:
|
| 365 |
+
raise RuntimeError(f"Unresolved qualitative quotation tags: {unresolved[:8]}")
|
| 366 |
+
return value
|
| 367 |
+
|
| 368 |
def _write_preview_payload(self, *, force: bool = False) -> None:
|
| 369 |
with self._preview_lock:
|
| 370 |
if self.preview_path is None:
|
|
|
|
| 923 |
)
|
| 924 |
self._checkpoint("evidence_ready", state, 6)
|
| 925 |
|
| 926 |
+
# ASQ planning never needs access to verbatim quotations. Keep the
|
| 927 |
+
# research-logic/front-matter stage on a quote-free internal-evidence
|
| 928 |
+
# summary; verbatim text is disclosed later only to its owning Findings
|
| 929 |
+
# unit (or the appendix).
|
| 930 |
+
planning_data_md = (
|
| 931 |
+
qual_engine.build_scoped_evidence_md(data_metadata, include_themes=True)
|
| 932 |
+
if mode == "asq"
|
| 933 |
+
else user_data_md
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
refs_text = str(state.get("refs_text") or "")
|
| 937 |
if not refs_text:
|
| 938 |
self._progress(8, "S2 + OpenAlex")
|
|
|
|
| 974 |
research_logic_response = self._stream_text(
|
| 975 |
agent.stream_generate_research_logic(
|
| 976 |
topic, keywords, synthesis_result, refs_text,
|
| 977 |
+
data_evidence=planning_data_md, mode=mode,
|
| 978 |
)
|
| 979 |
)
|
| 980 |
research_logic_contract = re.sub(
|
|
|
|
| 987 |
self._progress(14, "working_front_matter")
|
| 988 |
working_front = agent.generate_working_front_matter(
|
| 989 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 990 |
+
data_evidence=planning_data_md, mode=mode,
|
| 991 |
)
|
| 992 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 993 |
working_keywords = list(working_front.get("keywords") or [])
|
|
|
|
| 1100 |
if not working_abstract and not state.get("english_finalized"):
|
| 1101 |
working_front = agent.generate_working_front_matter(
|
| 1102 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 1103 |
+
data_evidence=planning_data_md, mode=mode,
|
| 1104 |
)
|
| 1105 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 1106 |
working_keywords = list(working_front.get("keywords") or [])
|
|
|
|
| 1165 |
)
|
| 1166 |
state["citation_plan"] = citation_plan
|
| 1167 |
|
| 1168 |
+
quote_ownership = (
|
| 1169 |
+
self._build_qualitative_quote_ownership(adaptive_structure, data_metadata)
|
| 1170 |
+
if mode == "asq"
|
| 1171 |
+
else {}
|
| 1172 |
+
)
|
| 1173 |
+
|
| 1174 |
total_steps = len(adaptive_structure)
|
| 1175 |
for sec in adaptive_structure:
|
| 1176 |
display_name = sec["display_name"]
|
|
|
|
| 1217 |
"Never stack sources, substitute tags, or use literature as evidence for this study's own facts.\n"
|
| 1218 |
f"<CLAIM_EVIDENCE_PLAN>\n{claim_plan_text}\n</CLAIM_EVIDENCE_PLAN>"
|
| 1219 |
)
|
| 1220 |
+
if mode == "asq" and section_id not in {"findings", "appendix"}:
|
| 1221 |
+
citation_rule += (
|
| 1222 |
+
"\nASQ EVIDENCE-CHANNEL RULE: A sentence whose factual subject is this study's setting, participants, interviews, data sources, cases, actors, observations, events, quotations, analytic steps, or empirical findings must not carry an external [CITE-XX] tag. "
|
| 1223 |
+
"Place any prior-literature or methodological-rationale claim in a separate sentence and cite only that external claim."
|
| 1224 |
+
)
|
| 1225 |
memory_context = agent._build_memory_context(sec_title, 2500)
|
| 1226 |
memory_block = f"\n<GLOBAL_MEMORY>\n{memory_context}\n</GLOBAL_MEMORY>\n" if memory_context else ""
|
| 1227 |
snippets = rag_engine.get_relevant_context(display_name) if rag_engine is not None else ""
|
|
|
|
| 1261 |
"Preserve any frozen stage/case/deviant role and use deviant evidence to qualify rather than erase the pattern."
|
| 1262 |
if concepts or evidence_labels else ""
|
| 1263 |
)
|
| 1264 |
+
|
| 1265 |
+
scoped_data_md = user_data_md
|
| 1266 |
+
if mode == "asq":
|
| 1267 |
+
if section_id == "appendix":
|
| 1268 |
+
# The appendix is the only non-owning unit allowed to see
|
| 1269 |
+
# the full frozen evidence table. It is excluded from the
|
| 1270 |
+
# body duplicate-quote gate by design.
|
| 1271 |
+
scoped_data_md = user_data_md
|
| 1272 |
+
elif section_id == "findings":
|
| 1273 |
+
current_theme = str(sec.get("theme_title") or "").strip()
|
| 1274 |
+
scoped_data_md = qual_engine.build_scoped_evidence_md(
|
| 1275 |
+
data_metadata,
|
| 1276 |
+
include_themes=True,
|
| 1277 |
+
theme_titles=[current_theme] if current_theme else None,
|
| 1278 |
+
assigned_evidence=theme_evidence,
|
| 1279 |
+
include_quotes=bool(theme_evidence),
|
| 1280 |
+
)
|
| 1281 |
+
elif section_id == "discussion":
|
| 1282 |
+
scoped_data_md = qual_engine.build_scoped_evidence_md(
|
| 1283 |
+
data_metadata,
|
| 1284 |
+
include_themes=True,
|
| 1285 |
+
)
|
| 1286 |
+
else:
|
| 1287 |
+
scoped_data_md = qual_engine.build_scoped_evidence_md(
|
| 1288 |
+
data_metadata,
|
| 1289 |
+
include_themes=False,
|
| 1290 |
+
)
|
| 1291 |
evidence_packet = (
|
| 1292 |
+
f"{base_evidence_contract}\n<FROZEN_RESEARCH_EVIDENCE>\n{scoped_data_md}\n</FROZEN_RESEARCH_EVIDENCE>"
|
| 1293 |
+
if scoped_data_md else base_evidence_contract
|
| 1294 |
)
|
| 1295 |
completed_label = ", ".join(completed_sections)
|
| 1296 |
anti_repeat = (
|
|
|
|
| 1306 |
word_budget = APP_CONFIG.get("word_budget", {})
|
| 1307 |
min_ratio = float(word_budget.get("section_min_ratio", 0.92))
|
| 1308 |
max_ratio = float(word_budget.get("section_max_ratio", 1.08))
|
| 1309 |
+
prompt_context_tail = context_tail[-2500:]
|
| 1310 |
+
if mode == "asq":
|
| 1311 |
+
prompt_context_tail = self._redact_qualitative_quotes(prompt_context_tail, data_metadata)
|
| 1312 |
user_prompt = f"""Write the assigned English manuscript unit.
|
| 1313 |
Section: {sec_title}
|
| 1314 |
Subsection: {subsection or 'None'}
|
|
|
|
| 1331 |
{memory_block}
|
| 1332 |
Literature synthesis: {synthesis_result}
|
| 1333 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 1334 |
+
{scoped_data_md}
|
| 1335 |
</FROZEN_RESEARCH_EVIDENCE>
|
| 1336 |
+
Previous tail: {prompt_context_tail}
|
| 1337 |
Output only final academic prose. Do not report the word count."""
|
| 1338 |
if agent._count_tokens(sys_prompt) + agent._count_tokens(user_prompt) > 60000:
|
| 1339 |
user_prompt = agent._truncate_context(user_prompt, 60000 - agent._count_tokens(sys_prompt))
|
|
|
|
| 1366 |
evidence_contract=evidence_packet,
|
| 1367 |
)
|
| 1368 |
en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False, mode=mode)
|
| 1369 |
+
# Bind only the current Findings unit's frozen quotation ownership.
|
| 1370 |
+
# Other ASQ units cannot see or reproduce verbatim quote text.
|
| 1371 |
+
if mode == "asq" and "appendix" not in sec_title.lower():
|
| 1372 |
+
en_raw = self._bind_owned_qualitative_quotes(
|
| 1373 |
+
en_raw,
|
| 1374 |
+
display_name=display_name,
|
| 1375 |
+
data_metadata=data_metadata,
|
| 1376 |
+
quote_ownership=quote_ownership,
|
| 1377 |
+
prior_manuscript=full_draft_en,
|
| 1378 |
+
)
|
| 1379 |
+
allow_deterministic_citation_reinforcement = not (
|
| 1380 |
+
mode == "asq" and section_id in {"methods", "discussion"}
|
| 1381 |
+
)
|
| 1382 |
+
if not is_quarantined and assigned_tag_list and allow_deterministic_citation_reinforcement:
|
| 1383 |
en_raw = agent.reinforce_claim_specific_citation_coverage(
|
| 1384 |
en_raw, assigned_tag_list, section_name=display_name
|
| 1385 |
)
|
core/qualitative_engine.py
CHANGED
|
@@ -35,6 +35,135 @@ class QualitativeEngine:
|
|
| 35 |
lookup = {item.lower(): item for item in cls.ALLOWED_ANALYTIC_DESIGNS}
|
| 36 |
return lookup.get(cleaned.lower(), "Iterative thematic/process analysis")
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
def _build_request(self, topic, keywords, raw_interviews):
|
| 39 |
schema = """Return ONLY one valid JSON object with this schema:
|
| 40 |
{
|
|
|
|
| 35 |
lookup = {item.lower(): item for item in cls.ALLOWED_ANALYTIC_DESIGNS}
|
| 36 |
return lookup.get(cleaned.lower(), "Iterative thematic/process analysis")
|
| 37 |
|
| 38 |
+
@classmethod
|
| 39 |
+
def build_scoped_evidence_md(
|
| 40 |
+
cls,
|
| 41 |
+
metadata,
|
| 42 |
+
*,
|
| 43 |
+
include_themes=True,
|
| 44 |
+
theme_titles=None,
|
| 45 |
+
assigned_evidence=None,
|
| 46 |
+
include_quotes=False,
|
| 47 |
+
):
|
| 48 |
+
"""Render the minimum qualitative evidence needed by one drafting unit.
|
| 49 |
+
|
| 50 |
+
Study facts/theme summaries are an internal empirical channel. Verbatim
|
| 51 |
+
quotations are a separate channel and are exposed only to their owning
|
| 52 |
+
Findings unit (or the final appendix when explicitly requested by the
|
| 53 |
+
caller). This prevents a quotation visible in one unit from being
|
| 54 |
+
opportunistically reused elsewhere and keeps external literature from
|
| 55 |
+
becoming evidence for this study's own facts.
|
| 56 |
+
"""
|
| 57 |
+
metadata = metadata or {}
|
| 58 |
+
facts = metadata.get("study_facts") or {}
|
| 59 |
+
sources = facts.get("data_sources") or ["Not specified"]
|
| 60 |
+
if not isinstance(sources, list):
|
| 61 |
+
sources = [sources]
|
| 62 |
+
clean_sources = [cls._clean_cell(item) for item in sources]
|
| 63 |
+
participants = facts.get("participant_count")
|
| 64 |
+
interviews = facts.get("interview_count")
|
| 65 |
+
analytic_design = cls._normalize_analytic_design(
|
| 66 |
+
facts.get("analytic_design") or facts.get("analysis_method")
|
| 67 |
+
)
|
| 68 |
+
analytic_logic = cls._clean_cell(
|
| 69 |
+
facts.get("analytic_logic"),
|
| 70 |
+
"Iteratively compare empirical observations to refine a process/mechanism explanation.",
|
| 71 |
+
)
|
| 72 |
+
temporal_structure = cls._clean_cell(facts.get("temporal_structure"))
|
| 73 |
+
case_structure = cls._clean_cell(facts.get("case_structure"))
|
| 74 |
+
setting = cls._clean_cell(facts.get("research_setting"))
|
| 75 |
+
|
| 76 |
+
lines = [
|
| 77 |
+
"### Frozen Qualitative Study Facts",
|
| 78 |
+
"| Field | Frozen value |",
|
| 79 |
+
"| :--- | :--- |",
|
| 80 |
+
f"| Research setting | {setting} |",
|
| 81 |
+
f"| Data sources | {', '.join(clean_sources)} |",
|
| 82 |
+
f"| Participants | {participants if participants is not None else 'Not specified'} |",
|
| 83 |
+
f"| Interviews | {interviews if interviews is not None else 'Not specified'} |",
|
| 84 |
+
f"| Analytic design | {analytic_design} |",
|
| 85 |
+
f"| Analytic logic | {analytic_logic} |",
|
| 86 |
+
f"| Temporal structure | {temporal_structure} |",
|
| 87 |
+
f"| Case structure | {case_structure} |",
|
| 88 |
+
"",
|
| 89 |
+
"[QUALITATIVE EVIDENCE CHANNELS]",
|
| 90 |
+
"1. STUDY FACTS and THEME SUMMARIES below are this study's internal empirical evidence. Never attach an external [CITE-XX] tag to a sentence merely because it reports or interprets these frozen facts.",
|
| 91 |
+
"2. External literature may support only prior-theory, prior-evidence, or methodological-rationale claims. Keep those claims in distinct sentences from this study's setting, sample, data, events, quotations, and findings.",
|
| 92 |
+
"3. ASSIGNED QUOTATIONS, when present, are verbatim evidence owned by the current Findings unit. Invoke them only by their [QUOTE-XX] tag; do not reuse a quotation owned by another unit.",
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
allowed_theme_titles = None
|
| 96 |
+
if theme_titles:
|
| 97 |
+
allowed_theme_titles = {
|
| 98 |
+
re.sub(r"\s+", " ", str(item or "")).strip().casefold()
|
| 99 |
+
for item in theme_titles
|
| 100 |
+
if str(item or "").strip()
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
if include_themes:
|
| 104 |
+
theme_rows = []
|
| 105 |
+
for theme in metadata.get("themes") or []:
|
| 106 |
+
if not isinstance(theme, dict):
|
| 107 |
+
continue
|
| 108 |
+
title = cls._clean_cell(theme.get("title"), "Emergent pattern")
|
| 109 |
+
if allowed_theme_titles is not None and title.casefold() not in allowed_theme_titles:
|
| 110 |
+
continue
|
| 111 |
+
concepts = ", ".join(
|
| 112 |
+
cls._clean_cell(item, "")
|
| 113 |
+
for item in (theme.get("concepts") or [])
|
| 114 |
+
if cls._clean_cell(item, "")
|
| 115 |
+
) or "Not specified"
|
| 116 |
+
stages = ", ".join(
|
| 117 |
+
cls._clean_cell(item, "")
|
| 118 |
+
for item in (theme.get("stages") or [])
|
| 119 |
+
if cls._clean_cell(item, "") and cls._clean_cell(item, "") != "Not specified"
|
| 120 |
+
) or "Not specified"
|
| 121 |
+
deviant = "yes" if theme.get("has_deviant_evidence") else "no"
|
| 122 |
+
theme_rows.append((title, concepts, stages, deviant))
|
| 123 |
+
if theme_rows:
|
| 124 |
+
lines.extend([
|
| 125 |
+
"",
|
| 126 |
+
"### Frozen Qualitative Theme Summary",
|
| 127 |
+
"| Higher-order Theme | Empirical Concepts | Process Stages | Deviant/Boundary Evidence Present |",
|
| 128 |
+
"| :--- | :--- | :--- | :--- |",
|
| 129 |
+
])
|
| 130 |
+
lines.extend(
|
| 131 |
+
f"| {title} | {concepts} | {stages} | {deviant} |"
|
| 132 |
+
for title, concepts, stages, deviant in theme_rows
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
if include_quotes:
|
| 136 |
+
evidence_items = assigned_evidence
|
| 137 |
+
if evidence_items is None:
|
| 138 |
+
evidence_items = metadata.get("evidence") or []
|
| 139 |
+
quote_rows = []
|
| 140 |
+
for item in evidence_items or []:
|
| 141 |
+
if not isinstance(item, dict):
|
| 142 |
+
continue
|
| 143 |
+
tag = cls._clean_cell(item.get("tag"), "")
|
| 144 |
+
quote = cls._clean_cell(item.get("quote"), "").replace('"', "'")
|
| 145 |
+
if not tag or not quote:
|
| 146 |
+
continue
|
| 147 |
+
stage = cls._clean_cell(item.get("stage"))
|
| 148 |
+
case_label = cls._clean_cell(item.get("case_label"))
|
| 149 |
+
evidence_role = str(item.get("evidence_role") or "typical").strip().lower()
|
| 150 |
+
if evidence_role not in {"typical", "deviant"}:
|
| 151 |
+
evidence_role = "typical"
|
| 152 |
+
quote_rows.append((tag, stage, case_label, evidence_role, quote))
|
| 153 |
+
if quote_rows:
|
| 154 |
+
lines.extend([
|
| 155 |
+
"",
|
| 156 |
+
"### Assigned Qualitative Quotations",
|
| 157 |
+
"| Tag | Process Stage | Case/Actor | Evidence Role | Verbatim Quote |",
|
| 158 |
+
"| :--- | :--- | :--- | :--- | :--- |",
|
| 159 |
+
])
|
| 160 |
+
lines.extend(
|
| 161 |
+
f'| {tag} | {stage} | {case_label} | {role} | "{quote}" |'
|
| 162 |
+
for tag, stage, case_label, role, quote in quote_rows
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
return "\n".join(lines).strip()
|
| 166 |
+
|
| 167 |
def _build_request(self, topic, keywords, raw_interviews):
|
| 168 |
schema = """Return ONLY one valid JSON object with this schema:
|
| 169 |
{
|