R3.2.8.6-R1.3C-KK1-R6A-R2 Qualitative Quote Ownership and Empirical-Literature Evidence Boundary Fix

#38
Files changed (2) hide show
  1. core/generation_runner.py +198 -15
  2. 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=user_data_md, mode=mode,
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=user_data_md, mode=mode,
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=user_data_md, mode=mode,
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{user_data_md}\n</FROZEN_RESEARCH_EVIDENCE>"
1119
- if user_data_md else base_evidence_contract
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
- {user_data_md}
1158
  </FROZEN_RESEARCH_EVIDENCE>
1159
- Previous tail: {context_tail[-2500:]}
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
- # Keep QUOTE tags immutable through critic/style editing, then bind
1193
- # each tag to its frozen verbatim quotation exactly once. This
1194
- # prevents editorial passes from silently paraphrasing evidence.
1195
- if mode == "asq" and user_data_md and "appendix" not in sec_title.lower():
1196
- quote_dict = {tag: quote for quote, tag in re.findall(r'"([^"]+)"\s*\[(QUOTE-\d+)\]', user_data_md)}
1197
- for tag, quote in quote_dict.items():
1198
- en_raw = en_raw.replace(tag, f'"{quote}"')
1199
- if not is_quarantined and assigned_tag_list:
 
 
 
 
 
 
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
  {