R6A-R4-R1 evidence-first qualitative quote invocation

#40
core/generation_runner.py CHANGED
@@ -271,6 +271,237 @@ class GenerationRunner:
271
  raise RuntimeError(f"Qualitative quotations lack a Findings owner: {missing[:8]}")
272
  return owners
273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  @staticmethod
275
  def _redact_qualitative_quotes(text, data_metadata):
276
  """Hide prior verbatim quote text from cross-unit continuity context."""
@@ -723,10 +954,19 @@ class GenerationRunner:
723
  )
724
  updated_sections = dict(translation_sections or {})
725
  quote_ledger_record = dict(updated_sections.get("__quote_ledger__") or {})
 
 
 
 
 
 
 
 
 
 
 
726
  quote_ledger = (
727
- dict(quote_ledger_record.get("translations") or {})
728
- if quote_ledger_record.get("target_lang") in {None, "", target_lang}
729
- else {}
730
  )
731
  translation_source, pending_quote_tags, reused_quote_tags = self._prepare_qualitative_translation_source(
732
  source, getattr(self, "_qualitative_quote_sources", {}), quote_ledger
@@ -768,7 +1008,12 @@ class GenerationRunner:
768
  result = dict(result)
769
  result['text'] = translated_text
770
  updated_sections['__quote_ledger__'] = {
 
 
 
771
  'target_lang': target_lang,
 
 
772
  'translations': dict(quote_ledger),
773
  'updated_at': datetime.now(timezone.utc).isoformat(),
774
  }
@@ -1118,6 +1363,10 @@ class GenerationRunner:
1118
  adaptive_structure = agent.build_generation_tasks(structure, body_target_words, safe_chunk_words)
1119
  for task in adaptive_structure:
1120
  task["is_first"] = bool(task.get("section_first"))
 
 
 
 
1121
  jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
1122
  if jit_texts:
1123
  rag_engine._initialize_chunks([jit_texts])
@@ -1139,6 +1388,7 @@ class GenerationRunner:
1139
  state.update(
1140
  {
1141
  "adaptive_structure": adaptive_structure,
 
1142
  "citation_plan": citation_plan,
1143
  "research_logic_contract": research_logic_contract,
1144
  "working_abstract": working_abstract,
@@ -1271,11 +1521,19 @@ class GenerationRunner:
1271
  )
1272
  state["citation_plan"] = citation_plan
1273
 
1274
- quote_ownership = (
1275
- self._build_qualitative_quote_ownership(adaptive_structure, data_metadata)
1276
- if mode == "asq"
1277
- else {}
1278
  )
 
 
 
 
 
 
 
 
 
 
1279
 
1280
  total_steps = len(adaptive_structure)
1281
  for sec in adaptive_structure:
@@ -1352,20 +1610,22 @@ class GenerationRunner:
1352
  )
1353
  concepts = ", ".join(sec.get("concepts") or [])
1354
  theme_evidence = [item for item in (sec.get("theme_evidence") or []) if isinstance(item, dict)]
1355
- evidence_labels = []
1356
  for item in theme_evidence:
1357
- tag = str(item.get("tag") or "").strip()
1358
  stage = str(item.get("stage") or "Not specified").strip()
1359
  role = str(item.get("evidence_role") or "typical").strip()
1360
  case_label = str(item.get("case_label") or "Not specified").strip()
1361
- if tag:
1362
- evidence_labels.append(f"{tag} [stage={stage}; case={case_label}; role={role}]")
 
 
 
1363
  qualitative_rule = (
1364
- f"Assigned qualitative concepts: {concepts}. Assigned quotation tags: {', '.join(evidence_labels)}. "
1365
- "Use only these assigned quotation tags in this Findings unit and do not repeat a tag used elsewhere. "
1366
- "When invoking a quotation, output only its [QUOTE-XX] tag at the exact position; do not reproduce or paraphrase the quotation because the system inserts the frozen verbatim text. "
1367
- "Preserve any frozen stage/case/deviant role and use deviant evidence to qualify rather than erase the pattern."
1368
- if concepts or evidence_labels else ""
1369
  )
1370
 
1371
  scoped_data_md = user_data_md
@@ -1383,6 +1643,7 @@ class GenerationRunner:
1383
  theme_titles=[current_theme] if current_theme else None,
1384
  assigned_evidence=theme_evidence,
1385
  include_quotes=bool(theme_evidence),
 
1386
  )
1387
  elif section_id == "discussion":
1388
  scoped_data_md = qual_engine.build_scoped_evidence_md(
@@ -1452,36 +1713,34 @@ Output only final academic prose. Do not report the word count."""
1452
  prefix += f"# {sec_title}\n\n"
1453
  if sec.get("subsection_first") and subsection:
1454
  prefix += f"## {subsection}\n\n"
1455
- en_raw = self._stream_text(
1456
- agent.stream_deepseek(
1457
- sys_prompt, user_prompt, max_tokens=7000,
1458
- model_role="writing", thinking=False, allow_fallback=False,
1459
- ),
1460
- preview_prefix=full_draft_en + prefix,
1461
- preview_stage=display_name,
1462
- )
1463
- en_raw = re.sub(r"<think>.*?</think>", "", en_raw, flags=re.S).strip()
1464
- en_raw = re.sub(rf"(?im)^#\s*{re.escape(sec_title)}\s*\n+", "", en_raw).strip()
1465
- if subsection:
1466
- en_raw = re.sub(rf"(?im)^##\s*{re.escape(subsection)}\s*\n+", "", en_raw).strip()
1467
- en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
1468
- en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
1469
- en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
1470
- en_raw = agent.critique_and_repair_section(
1471
- en_raw, section_id=section_id, section_name=display_name, mode=mode,
1472
- evidence_contract=evidence_packet,
1473
- )
1474
- en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False, mode=mode)
1475
- # Bind only the current Findings unit's frozen quotation ownership.
1476
- # Other ASQ units cannot see or reproduce verbatim quote text.
1477
- if mode == "asq" and "appendix" not in sec_title.lower():
1478
- en_raw = self._bind_owned_qualitative_quotes(
1479
- en_raw,
1480
- display_name=display_name,
1481
- data_metadata=data_metadata,
1482
- quote_ownership=quote_ownership,
1483
- prior_manuscript=full_draft_en,
1484
  )
 
 
 
 
1485
  allow_deterministic_citation_reinforcement = not (
1486
  mode == "asq" and section_id in {"methods", "discussion"}
1487
  )
@@ -1531,6 +1790,13 @@ Output only final academic prose. Do not report the word count."""
1531
  en_resolved = en_resolved.rstrip() + "\n\n" + supplemental_resolved.strip()
1532
  used_after = set(agent.used_citations)
1533
  survived = used_after - used_before
 
 
 
 
 
 
 
1534
  full_draft_en += en_resolved + "\n\n"
1535
  if assigned_tag_list:
1536
  logging.info(
@@ -1586,6 +1852,9 @@ Output only final academic prose. Do not report the word count."""
1586
  # sections, citation cleanup, and any Discussion rebalance are frozen.
1587
  agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
1588
  if mode == "asq":
 
 
 
1589
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
1590
  if repeated_quotes:
1591
  raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
 
271
  raise RuntimeError(f"Qualitative quotations lack a Findings owner: {missing[:8]}")
272
  return owners
273
 
274
+ @staticmethod
275
+ def _qualitative_task_key(task):
276
+ task = task or {}
277
+ explicit = str(task.get("task_key") or "").strip()
278
+ if explicit:
279
+ return explicit
280
+ section_id = str(task.get("section_id") or task.get("section") or "section").strip()
281
+ stable_unit = str(task.get("theme_id") or task.get("subsection_id") or section_id).strip()
282
+ part_index = int(task.get("part_index") or 0) + 1
283
+ return f"{section_id}:{stable_unit}:part:{part_index:02d}"
284
+
285
+ @classmethod
286
+ def _build_qualitative_quote_invocation_plan(cls, tasks, data_metadata):
287
+ """Freeze one deterministic body invocation for every retained quote.
288
+
289
+ Logical ownership is a stable Theme object. Runtime Part chunks only
290
+ select where the already-owned quote is rendered; they never become the
291
+ quote owner and can never change cardinality.
292
+ """
293
+ metadata = data_metadata or {}
294
+ evidence_by_tag = {
295
+ str(item.get("tag") or "").strip(): dict(item)
296
+ for item in metadata.get("evidence", [])
297
+ if isinstance(item, dict) and str(item.get("tag") or "").strip()
298
+ }
299
+ theme_id_by_title = {}
300
+ for index, theme in enumerate(metadata.get("themes") or [], start=1):
301
+ if not isinstance(theme, dict):
302
+ continue
303
+ title = re.sub(r"\s+", " ", str(theme.get("title") or "")).strip().casefold()
304
+ if title:
305
+ theme_id_by_title[title] = str(theme.get("theme_id") or f"THEME-{index:02d}").strip()
306
+
307
+ plan = {}
308
+ task_slots = {}
309
+ for task in tasks or []:
310
+ if str(task.get("section_id") or "") != "findings":
311
+ continue
312
+ task_key = cls._qualitative_task_key(task)
313
+ task_theme_id = str(task.get("theme_id") or "").strip()
314
+ task_theme_title = str(task.get("theme_title") or task.get("subsection") or "").strip()
315
+ for item in task.get("theme_evidence") or []:
316
+ if not isinstance(item, dict):
317
+ continue
318
+ tag = str(item.get("tag") or "").strip()
319
+ if not tag:
320
+ continue
321
+ evidence = evidence_by_tag.get(tag)
322
+ if evidence is None:
323
+ raise RuntimeError(f"Qualitative quote plan references unknown evidence: {tag}")
324
+ if tag in plan:
325
+ raise RuntimeError(f"Qualitative quote plan has duplicate render assignment: {tag}")
326
+ owner_theme_id = str(
327
+ task_theme_id
328
+ or evidence.get("theme_id")
329
+ or theme_id_by_title.get(
330
+ re.sub(r"\s+", " ", str(evidence.get("theme") or task_theme_title)).strip().casefold()
331
+ )
332
+ or ""
333
+ ).strip()
334
+ if not owner_theme_id:
335
+ raise RuntimeError(f"Qualitative quote lacks a stable theme owner: {tag}")
336
+ quote = str(evidence.get("quote") or "").strip()
337
+ if not quote:
338
+ raise RuntimeError(f"Qualitative quote is empty: {tag}")
339
+ quote_hash = str(evidence.get("quote_hash") or "").strip() or hashlib.sha256(
340
+ re.sub(r"\s+", " ", quote).strip().encode("utf-8")
341
+ ).hexdigest()
342
+ slot_ordinal = task_slots.get(task_key, 0) + 1
343
+ task_slots[task_key] = slot_ordinal
344
+ plan[tag] = {
345
+ "quote_id": tag,
346
+ "quote_hash": quote_hash,
347
+ "owner_theme_id": owner_theme_id,
348
+ "owner_theme_title": str(evidence.get("theme") or task_theme_title).strip(),
349
+ "render_task_key": task_key,
350
+ "render_part_index": int(task.get("part_index") or 0),
351
+ "render_part_count": int(task.get("part_count") or 1),
352
+ "slot_ordinal": slot_ordinal,
353
+ "cardinality": 1,
354
+ }
355
+
356
+ missing = sorted(set(evidence_by_tag) - set(plan))
357
+ extra = sorted(set(plan) - set(evidence_by_tag))
358
+ if missing or extra:
359
+ raise RuntimeError(
360
+ f"Qualitative quote invocation plan mismatch: missing={missing[:8]} extra={extra[:8]}"
361
+ )
362
+ cls._validate_qualitative_quote_invocation_plan(plan, tasks, metadata)
363
+ return plan
364
+
365
+ @classmethod
366
+ def _validate_qualitative_quote_invocation_plan(cls, plan, tasks, data_metadata):
367
+ metadata = data_metadata or {}
368
+ evidence_by_tag = {
369
+ str(item.get("tag") or "").strip(): dict(item)
370
+ for item in metadata.get("evidence", [])
371
+ if isinstance(item, dict) and str(item.get("tag") or "").strip()
372
+ }
373
+ task_by_key = {cls._qualitative_task_key(task): task for task in tasks or []}
374
+ if set(plan or {}) != set(evidence_by_tag):
375
+ raise RuntimeError("Qualitative quote invocation plan does not match the frozen evidence ledger.")
376
+ for tag, record in (plan or {}).items():
377
+ if int(record.get("cardinality") or 0) != 1:
378
+ raise RuntimeError(f"Qualitative quote cardinality must equal one: {tag}")
379
+ owner_theme_id = str(record.get("owner_theme_id") or "").strip()
380
+ if not owner_theme_id or "part " in owner_theme_id.casefold():
381
+ raise RuntimeError(f"Qualitative quote owner is not a stable Theme object: {tag}")
382
+ task_key = str(record.get("render_task_key") or "").strip()
383
+ task = task_by_key.get(task_key)
384
+ if not task or str(task.get("section_id") or "") != "findings":
385
+ raise RuntimeError(f"Qualitative quote render task is invalid: {tag} -> {task_key!r}")
386
+ task_theme_id = str(task.get("theme_id") or "").strip()
387
+ if task_theme_id and task_theme_id != owner_theme_id:
388
+ raise RuntimeError(f"Qualitative quote render task crossed theme ownership: {tag}")
389
+ evidence = evidence_by_tag[tag]
390
+ quote = str(evidence.get("quote") or "").strip()
391
+ expected_hash = str(evidence.get("quote_hash") or "").strip() or hashlib.sha256(
392
+ re.sub(r"\s+", " ", quote).strip().encode("utf-8")
393
+ ).hexdigest()
394
+ if str(record.get("quote_hash") or "") != expected_hash:
395
+ raise RuntimeError(f"Qualitative quote hash drifted after planning: {tag}")
396
+ return True
397
+
398
+ @staticmethod
399
+ def _strip_model_controlled_qualitative_quotes(text, data_metadata):
400
+ """Remove model-emitted quote controls/literals before Executor rendering.
401
+
402
+ This is a containment boundary, not the quote control plane: quotation
403
+ presence and cardinality come exclusively from the frozen invocation
404
+ plan. The model's attempted tags/literals therefore have zero authority.
405
+ """
406
+ value = str(text or "")
407
+ value = re.sub(r"(?<!\w)\[?QUOTE-\d+\]?(?!\w)", "", value, flags=re.I)
408
+ quotes = sorted(
409
+ {
410
+ str(item.get("quote") or "").strip()
411
+ for item in (data_metadata or {}).get("evidence", [])
412
+ if isinstance(item, dict) and str(item.get("quote") or "").strip()
413
+ },
414
+ key=len,
415
+ reverse=True,
416
+ )
417
+ for quote in quotes:
418
+ for wrapped in (f'"{quote}"', f'“{quote}”', f'«{quote}»', quote):
419
+ value = value.replace(wrapped, "")
420
+ value = re.sub(r"[ \t]+([,.;:!?])", r"\1", value)
421
+ value = re.sub(r"([,;:])\s*([.!?])", r"\2", value)
422
+ value = re.sub(r"[ \t]{2,}", " ", value)
423
+ value = re.sub(r"\n[ \t]+", "\n", value)
424
+ return value.strip()
425
+
426
+ @classmethod
427
+ def _render_planned_qualitative_quotes(cls, text, *, task, data_metadata, quote_invocation_plan):
428
+ """Deterministically insert frozen quotations into one rendered task."""
429
+ value = cls._strip_model_controlled_qualitative_quotes(text, data_metadata)
430
+ task_key = cls._qualitative_task_key(task)
431
+ scheduled = [
432
+ dict(record)
433
+ for record in (quote_invocation_plan or {}).values()
434
+ if str(record.get("render_task_key") or "") == task_key
435
+ ]
436
+ scheduled.sort(key=lambda record: (int(record.get("slot_ordinal") or 0), str(record.get("quote_id") or "")))
437
+ if not scheduled:
438
+ return value
439
+
440
+ evidence_by_tag = {
441
+ str(item.get("tag") or "").strip(): dict(item)
442
+ for item in (data_metadata or {}).get("evidence", [])
443
+ if isinstance(item, dict) and str(item.get("tag") or "").strip()
444
+ }
445
+ blocks = [block.strip() for block in re.split(r"\n\s*\n", value) if block.strip()]
446
+ anchor_indices = [
447
+ index for index, block in enumerate(blocks)
448
+ if not re.match(r"^(?:#{1,6}\s|\||[-*+]\s|>)", block)
449
+ ]
450
+ if not anchor_indices:
451
+ anchor_indices = [len(blocks) - 1] if blocks else []
452
+
453
+ insertions = {}
454
+ for index, record in enumerate(scheduled):
455
+ tag = str(record.get("quote_id") or "").strip()
456
+ quote = str((evidence_by_tag.get(tag) or {}).get("quote") or "").strip()
457
+ if not quote:
458
+ raise RuntimeError(f"Planned qualitative quotation is unavailable at render time: {tag}")
459
+ if anchor_indices:
460
+ anchor_pos = min(
461
+ len(anchor_indices) - 1,
462
+ max(0, int(((index + 1) * len(anchor_indices)) / (len(scheduled) + 1))),
463
+ )
464
+ block_index = anchor_indices[anchor_pos]
465
+ insertions.setdefault(block_index, []).append(f'"{quote}"')
466
+ else:
467
+ blocks.append(f'"{quote}"')
468
+
469
+ rendered_blocks = []
470
+ for index, block in enumerate(blocks):
471
+ rendered_blocks.append(block)
472
+ rendered_blocks.extend(insertions.get(index, []))
473
+ rendered = "\n\n".join(rendered_blocks).strip()
474
+ for record in scheduled:
475
+ tag = str(record.get("quote_id") or "").strip()
476
+ quote = str((evidence_by_tag.get(tag) or {}).get("quote") or "").strip()
477
+ if rendered.count(quote) != 1:
478
+ raise RuntimeError(f"Qualitative deterministic quote rendering failed: {tag}")
479
+ return rendered
480
+
481
+ @staticmethod
482
+ def _validate_qualitative_quote_rendering(text, quote_invocation_plan, data_metadata):
483
+ body_text = re.split(
484
+ r"(?m)^#\s+Appendix(?:[:\s].*)?$", str(text or ""), maxsplit=1
485
+ )[0]
486
+ unresolved = sorted(set(re.findall(r"\bQUOTE-\d+\b", body_text, flags=re.I)))
487
+ if unresolved:
488
+ raise RuntimeError(f"Unresolved qualitative quote controls in body: {unresolved[:8]}")
489
+ normalized_body = re.sub(r"\s+", " ", body_text).casefold()
490
+ evidence_by_tag = {
491
+ str(item.get("tag") or "").strip(): dict(item)
492
+ for item in (data_metadata or {}).get("evidence", [])
493
+ if isinstance(item, dict) and str(item.get("tag") or "").strip()
494
+ }
495
+ for tag, record in (quote_invocation_plan or {}).items():
496
+ quote = re.sub(r"\s+", " ", str((evidence_by_tag.get(tag) or {}).get("quote") or "")).strip().casefold()
497
+ expected = int(record.get("cardinality") or 0)
498
+ actual = normalized_body.count(quote) if quote else 0
499
+ if actual != expected:
500
+ raise RuntimeError(
501
+ f"Qualitative quote invocation invariant failed: {tag} actual={actual} planned={expected}"
502
+ )
503
+ return True
504
+
505
  @staticmethod
506
  def _redact_qualitative_quotes(text, data_metadata):
507
  """Hide prior verbatim quote text from cross-unit continuity context."""
 
954
  )
955
  updated_sections = dict(translation_sections or {})
956
  quote_ledger_record = dict(updated_sections.get("__quote_ledger__") or {})
957
+ current_quote_hashes = {
958
+ tag: hashlib.sha256(re.sub(r"\s+", " ", quote).strip().encode("utf-8")).hexdigest()
959
+ for tag, quote in getattr(self, "_qualitative_quote_sources", {}).items()
960
+ }
961
+ current_request_hash = str(getattr(self, "request_hash", "") or "")
962
+ stored_quote_hashes = quote_ledger_record.get("source_quote_hashes")
963
+ ledger_identity_ok = (
964
+ quote_ledger_record.get("target_lang") in {None, "", target_lang}
965
+ and quote_ledger_record.get("request_hash") in {None, "", current_request_hash}
966
+ and (stored_quote_hashes in {None, ""} or stored_quote_hashes == current_quote_hashes)
967
+ )
968
  quote_ledger = (
969
+ dict(quote_ledger_record.get("translations") or {}) if ledger_identity_ok else {}
 
 
970
  )
971
  translation_source, pending_quote_tags, reused_quote_tags = self._prepare_qualitative_translation_source(
972
  source, getattr(self, "_qualitative_quote_sources", {}), quote_ledger
 
1008
  result = dict(result)
1009
  result['text'] = translated_text
1010
  updated_sections['__quote_ledger__'] = {
1011
+ 'task_id': getattr(getattr(self, "task_dir", None), "name", ""),
1012
+ 'request_hash': current_request_hash,
1013
+ 'writer_execution_epoch': int(getattr(self, "execution_epoch", 0) or 0),
1014
  'target_lang': target_lang,
1015
+ 'translation_contract_version': TRANSLATION_CONTRACT_VERSION,
1016
+ 'source_quote_hashes': current_quote_hashes,
1017
  'translations': dict(quote_ledger),
1018
  'updated_at': datetime.now(timezone.utc).isoformat(),
1019
  }
 
1363
  adaptive_structure = agent.build_generation_tasks(structure, body_target_words, safe_chunk_words)
1364
  for task in adaptive_structure:
1365
  task["is_first"] = bool(task.get("section_first"))
1366
+ quote_invocation_plan = (
1367
+ self._build_qualitative_quote_invocation_plan(adaptive_structure, data_metadata)
1368
+ if mode == "asq" else {}
1369
+ )
1370
  jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
1371
  if jit_texts:
1372
  rag_engine._initialize_chunks([jit_texts])
 
1388
  state.update(
1389
  {
1390
  "adaptive_structure": adaptive_structure,
1391
+ "quote_invocation_plan": quote_invocation_plan,
1392
  "citation_plan": citation_plan,
1393
  "research_logic_contract": research_logic_contract,
1394
  "working_abstract": working_abstract,
 
1521
  )
1522
  state["citation_plan"] = citation_plan
1523
 
1524
+ quote_invocation_plan = (
1525
+ dict(state.get("quote_invocation_plan") or {}) if mode == "asq" else {}
 
 
1526
  )
1527
+ if mode == "asq":
1528
+ if not quote_invocation_plan:
1529
+ quote_invocation_plan = self._build_qualitative_quote_invocation_plan(
1530
+ adaptive_structure, data_metadata
1531
+ )
1532
+ state["quote_invocation_plan"] = quote_invocation_plan
1533
+ self._checkpoint("quote_plan_ready", state, 15)
1534
+ self._validate_qualitative_quote_invocation_plan(
1535
+ quote_invocation_plan, adaptive_structure, data_metadata
1536
+ )
1537
 
1538
  total_steps = len(adaptive_structure)
1539
  for sec in adaptive_structure:
 
1610
  )
1611
  concepts = ", ".join(sec.get("concepts") or [])
1612
  theme_evidence = [item for item in (sec.get("theme_evidence") or []) if isinstance(item, dict)]
1613
+ evidence_profiles = []
1614
  for item in theme_evidence:
 
1615
  stage = str(item.get("stage") or "Not specified").strip()
1616
  role = str(item.get("evidence_role") or "typical").strip()
1617
  case_label = str(item.get("case_label") or "Not specified").strip()
1618
+ evidence_profiles.append(f"stage={stage}; case={case_label}; role={role}")
1619
+ planned_quote_count = sum(
1620
+ 1 for record in quote_invocation_plan.values()
1621
+ if str(record.get("render_task_key") or "") == self._qualitative_task_key(sec)
1622
+ )
1623
  qualitative_rule = (
1624
+ f"Assigned qualitative concepts: {concepts}. Assigned evidence profiles: {' | '.join(evidence_profiles)}. "
1625
+ f"The Executor has frozen {planned_quote_count} quotation invocation(s) for this task. "
1626
+ "Use the assigned evidence analytically, but output narrative prose only: do not reproduce any verbatim quotation and do not output any QUOTE-XX identifier. "
1627
+ "The Executor inserts the frozen quotations deterministically after narrative/citation QA. Preserve stage/case/deviant meaning and use deviant evidence to qualify rather than erase the pattern."
1628
+ if concepts or evidence_profiles else ""
1629
  )
1630
 
1631
  scoped_data_md = user_data_md
 
1643
  theme_titles=[current_theme] if current_theme else None,
1644
  assigned_evidence=theme_evidence,
1645
  include_quotes=bool(theme_evidence),
1646
+ include_quote_ids=False,
1647
  )
1648
  elif section_id == "discussion":
1649
  scoped_data_md = qual_engine.build_scoped_evidence_md(
 
1713
  prefix += f"# {sec_title}\n\n"
1714
  if sec.get("subsection_first") and subsection:
1715
  prefix += f"## {subsection}\n\n"
1716
+ if mode == "asq" and section_id == "appendix":
1717
+ # R6A-R4: Appendix is a deterministic reader-facing projection
1718
+ # of the Evidence Ledger. It is never regenerated by the LLM.
1719
+ en_raw = qual_engine.build_appendix_evidence_md(data_metadata)
1720
+ else:
1721
+ en_raw = self._stream_text(
1722
+ agent.stream_deepseek(
1723
+ sys_prompt, user_prompt, max_tokens=7000,
1724
+ model_role="writing", thinking=False, allow_fallback=False,
1725
+ ),
1726
+ preview_prefix=full_draft_en + prefix,
1727
+ preview_stage=display_name,
1728
+ )
1729
+ en_raw = re.sub(r"<think>.*?</think>", "", en_raw, flags=re.S).strip()
1730
+ en_raw = re.sub(rf"(?im)^#\s*{re.escape(sec_title)}\s*\n+", "", en_raw).strip()
1731
+ if subsection:
1732
+ en_raw = re.sub(rf"(?im)^##\s*{re.escape(subsection)}\s*\n+", "", en_raw).strip()
1733
+ en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
1734
+ en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
1735
+ en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
1736
+ en_raw = agent.critique_and_repair_section(
1737
+ en_raw, section_id=section_id, section_name=display_name, mode=mode,
1738
+ evidence_contract=evidence_packet,
 
 
 
 
 
 
1739
  )
1740
+ en_raw = agent.polish_academic_section(en_raw, display_name, abstract=False, mode=mode)
1741
+ if mode == "asq":
1742
+ # Model prose has no authority over quote presence/count.
1743
+ en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
1744
  allow_deterministic_citation_reinforcement = not (
1745
  mode == "asq" and section_id in {"methods", "discussion"}
1746
  )
 
1790
  en_resolved = en_resolved.rstrip() + "\n\n" + supplemental_resolved.strip()
1791
  used_after = set(agent.used_citations)
1792
  survived = used_after - used_before
1793
+ if mode == "asq" and section_id == "findings":
1794
+ en_resolved = self._render_planned_qualitative_quotes(
1795
+ en_resolved,
1796
+ task=sec,
1797
+ data_metadata=data_metadata,
1798
+ quote_invocation_plan=quote_invocation_plan,
1799
+ )
1800
  full_draft_en += en_resolved + "\n\n"
1801
  if assigned_tag_list:
1802
  logging.info(
 
1852
  # sections, citation cleanup, and any Discussion rebalance are frozen.
1853
  agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
1854
  if mode == "asq":
1855
+ self._validate_qualitative_quote_rendering(
1856
+ full_draft_en, quote_invocation_plan, data_metadata
1857
+ )
1858
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
1859
  if repeated_quotes:
1860
  raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
core/pipeline_agent.py CHANGED
@@ -1496,6 +1496,7 @@ USER KEYWORD SEEDS: {keywords}
1496
  seen_ids = set()
1497
  for index, theme in enumerate(themes, start=1):
1498
  title = str(theme.get("title") or f"Theme {index}").strip()
 
1499
  base_id = self._slugify_heading(title)
1500
  subsection_id = base_id
1501
  suffix = 2
@@ -1505,6 +1506,9 @@ USER KEYWORD SEEDS: {keywords}
1505
  seen_ids.add(subsection_id)
1506
  concepts = [str(item).strip() for item in theme.get("concepts", []) if str(item).strip()]
1507
  theme_evidence = [dict(item) for item in (theme.get("evidence") or []) if isinstance(item, dict)]
 
 
 
1508
  stages = [str(item).strip() for item in (theme.get("stages") or []) if str(item).strip()]
1509
  has_deviant = bool(theme.get("has_deviant_evidence"))
1510
  dynamic_subsections.append(
@@ -1512,11 +1516,12 @@ USER KEYWORD SEEDS: {keywords}
1512
  "subsection_id": subsection_id,
1513
  "title": title,
1514
  "share": per_theme_share,
 
1515
  "theme_title": title,
1516
  "concepts": concepts,
1517
  "theme_evidence": theme_evidence,
1518
  "required_content": (
1519
- "Explain this theme using only its assigned concepts and tagged representative quotations. "
1520
  + (f"Preserve the frozen process order across these stages: {', '.join(stages)}. " if stages else "")
1521
  + ("Use the assigned deviant/boundary evidence to qualify the pattern rather than hiding it. " if has_deviant else "")
1522
  + "Do not reuse evidence assigned to another theme or invent a temporal sequence."
@@ -1549,6 +1554,7 @@ USER KEYWORD SEEDS: {keywords}
1549
  subsection_id = subsection.get("subsection_id") or self._slugify_heading(subsection.get("title"))
1550
  subsection_title = subsection.get("title")
1551
  required_content = subsection.get("required_content", "")
 
1552
  theme_title = subsection.get("theme_title")
1553
  concepts = subsection.get("concepts", [])
1554
  theme_evidence = subsection.get("theme_evidence", [])
@@ -1557,6 +1563,7 @@ USER KEYWORD SEEDS: {keywords}
1557
  subsection_id = None
1558
  subsection_title = None
1559
  required_content = ""
 
1560
  theme_title = None
1561
  concepts = []
1562
  theme_evidence = []
@@ -1574,18 +1581,25 @@ USER KEYWORD SEEDS: {keywords}
1574
  label += f" > {subsection_title}"
1575
  if part_count > 1:
1576
  label += f" (Part {part_index + 1}/{part_count})"
 
 
 
1577
  tasks.append(
1578
  {
1579
- "section_id": section.get("section_id") or self._slugify_heading(section["section"]),
1580
  "section": section["section"],
1581
  "subsection_id": subsection_id,
1582
  "subsection": subsection_title,
1583
  "display_name": label,
 
 
 
1584
  "target_words": part_target,
1585
  "section_first": unit_index == 0 and part_index == 0,
1586
  "subsection_first": bool(subsection_title) and part_index == 0,
1587
  "continuation": part_index > 0,
1588
  "required_content": required_content,
 
1589
  "theme_title": theme_title,
1590
  "concepts": concepts,
1591
  "theme_evidence": part_theme_evidence,
 
1496
  seen_ids = set()
1497
  for index, theme in enumerate(themes, start=1):
1498
  title = str(theme.get("title") or f"Theme {index}").strip()
1499
+ theme_id = str(theme.get("theme_id") or f"THEME-{index:02d}").strip()
1500
  base_id = self._slugify_heading(title)
1501
  subsection_id = base_id
1502
  suffix = 2
 
1506
  seen_ids.add(subsection_id)
1507
  concepts = [str(item).strip() for item in theme.get("concepts", []) if str(item).strip()]
1508
  theme_evidence = [dict(item) for item in (theme.get("evidence") or []) if isinstance(item, dict)]
1509
+ for evidence_item in theme_evidence:
1510
+ evidence_item.setdefault("theme_id", theme_id)
1511
+ evidence_item.setdefault("theme", title)
1512
  stages = [str(item).strip() for item in (theme.get("stages") or []) if str(item).strip()]
1513
  has_deviant = bool(theme.get("has_deviant_evidence"))
1514
  dynamic_subsections.append(
 
1516
  "subsection_id": subsection_id,
1517
  "title": title,
1518
  "share": per_theme_share,
1519
+ "theme_id": theme_id,
1520
  "theme_title": title,
1521
  "concepts": concepts,
1522
  "theme_evidence": theme_evidence,
1523
  "required_content": (
1524
+ "Explain this theme using only its assigned concepts and representative evidence. The Executor owns verbatim quotation rendering; do not reproduce quotation wording or internal quotation identifiers. "
1525
  + (f"Preserve the frozen process order across these stages: {', '.join(stages)}. " if stages else "")
1526
  + ("Use the assigned deviant/boundary evidence to qualify the pattern rather than hiding it. " if has_deviant else "")
1527
  + "Do not reuse evidence assigned to another theme or invent a temporal sequence."
 
1554
  subsection_id = subsection.get("subsection_id") or self._slugify_heading(subsection.get("title"))
1555
  subsection_title = subsection.get("title")
1556
  required_content = subsection.get("required_content", "")
1557
+ theme_id = subsection.get("theme_id")
1558
  theme_title = subsection.get("theme_title")
1559
  concepts = subsection.get("concepts", [])
1560
  theme_evidence = subsection.get("theme_evidence", [])
 
1563
  subsection_id = None
1564
  subsection_title = None
1565
  required_content = ""
1566
+ theme_id = None
1567
  theme_title = None
1568
  concepts = []
1569
  theme_evidence = []
 
1581
  label += f" > {subsection_title}"
1582
  if part_count > 1:
1583
  label += f" (Part {part_index + 1}/{part_count})"
1584
+ section_id = section.get("section_id") or self._slugify_heading(section["section"])
1585
+ stable_unit_id = str(theme_id or subsection_id or section_id or "section")
1586
+ task_key = f"{section_id}:{stable_unit_id}:part:{part_index + 1:02d}"
1587
  tasks.append(
1588
  {
1589
+ "section_id": section_id,
1590
  "section": section["section"],
1591
  "subsection_id": subsection_id,
1592
  "subsection": subsection_title,
1593
  "display_name": label,
1594
+ "task_key": task_key,
1595
+ "part_index": part_index,
1596
+ "part_count": part_count,
1597
  "target_words": part_target,
1598
  "section_first": unit_index == 0 and part_index == 0,
1599
  "subsection_first": bool(subsection_title) and part_index == 0,
1600
  "continuation": part_index > 0,
1601
  "required_content": required_content,
1602
+ "theme_id": theme_id,
1603
  "theme_title": theme_title,
1604
  "concepts": concepts,
1605
  "theme_evidence": part_theme_evidence,
core/qualitative_engine.py CHANGED
@@ -3,6 +3,7 @@
3
 
4
  from __future__ import annotations
5
 
 
6
  import logging
7
  import re
8
 
@@ -44,6 +45,7 @@ class QualitativeEngine:
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
 
@@ -89,7 +91,7 @@ class QualitativeEngine:
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
@@ -151,17 +153,61 @@ class QualitativeEngine:
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):
@@ -329,6 +375,7 @@ Rules:
329
  "theme": theme,
330
  "concept": concept,
331
  "quote": quote,
 
332
  "tag": tag,
333
  "stage": stage,
334
  "case_label": case_label,
@@ -337,10 +384,15 @@ Rules:
337
  )
338
 
339
  theme_map = {}
 
340
  for item in clean_evidence:
 
 
 
341
  theme_entry = theme_map.setdefault(
342
  item["theme"],
343
  {
 
344
  "title": item["theme"],
345
  "concepts": [],
346
  "evidence": [],
@@ -356,8 +408,10 @@ Rules:
356
  theme_entry["has_deviant_evidence"] = True
357
  theme_entry["evidence"].append(
358
  {
 
359
  "tag": item["tag"],
360
  "quote": item["quote"],
 
361
  "stage": item["stage"],
362
  "case_label": item["case_label"],
363
  "evidence_role": item["evidence_role"],
 
3
 
4
  from __future__ import annotations
5
 
6
+ import hashlib
7
  import logging
8
  import re
9
 
 
45
  theme_titles=None,
46
  assigned_evidence=None,
47
  include_quotes=False,
48
+ include_quote_ids=True,
49
  ):
50
  """Render the minimum qualitative evidence needed by one drafting unit.
51
 
 
91
  "[QUALITATIVE EVIDENCE CHANNELS]",
92
  "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.",
93
  "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.",
94
+ "3. ASSIGNED QUOTATIONS, when present, are analysis-only verbatim evidence owned by the current stable theme. Do not reproduce the verbatim wording or any internal quotation identifier in generated prose; the Executor renders frozen quotations deterministically after narrative QA.",
95
  ]
96
 
97
  allowed_theme_titles = None
 
153
  evidence_role = "typical"
154
  quote_rows.append((tag, stage, case_label, evidence_role, quote))
155
  if quote_rows:
156
+ lines.extend(["", "### Assigned Qualitative Quotations"])
157
+ if include_quote_ids:
158
+ lines.extend([
159
+ "| Tag | Process Stage | Case/Actor | Evidence Role | Verbatim Quote |",
160
+ "| :--- | :--- | :--- | :--- | :--- |",
161
+ ])
162
+ lines.extend(
163
+ f'| {tag} | {stage} | {case_label} | {role} | "{quote}" |'
164
+ for tag, stage, case_label, role, quote in quote_rows
165
+ )
166
+ else:
167
+ lines.extend([
168
+ "| Process Stage | Case/Actor | Evidence Role | Verbatim Quote |",
169
+ "| :--- | :--- | :--- | :--- |",
170
+ ])
171
+ lines.extend(
172
+ f'| {stage} | {case_label} | {role} | "{quote}" |'
173
+ for _tag, stage, case_label, role, quote in quote_rows
174
+ )
175
+
176
+ return "\n".join(lines).strip()
177
+
178
+ @classmethod
179
+ def build_appendix_evidence_md(cls, metadata):
180
+ """Render the reader-facing qualitative evidence appendix deterministically.
181
+
182
+ Internal QUOTE identifiers are deliberately omitted. The same frozen
183
+ quotation literals are reused by the translation ledger, so Findings
184
+ and Appendix share one target-language rendering per quote.
185
+ """
186
+ metadata = metadata or {}
187
+
188
+ def cell(value, default="Not specified"):
189
+ return cls._clean_cell(value, default).replace("|", r"\|")
190
 
191
+ lines = [
192
+ "| Higher-order Theme | Empirical Concept | Process Stage | Case/Actor | Evidence Role | Representative Quote |",
193
+ "| :--- | :--- | :--- | :--- | :--- | :--- |",
194
+ ]
195
+ for item in metadata.get("evidence") or []:
196
+ if not isinstance(item, dict):
197
+ continue
198
+ quote = cls._clean_cell(item.get("quote"), "").replace('"', "'")
199
+ if not quote:
200
+ continue
201
+ lines.append(
202
+ "| {theme} | {concept} | {stage} | {case_label} | {role} | \"{quote}\" |".format(
203
+ theme=cell(item.get("theme"), "Emergent pattern"),
204
+ concept=cell(item.get("concept"), "Empirical observation"),
205
+ stage=cell(item.get("stage")),
206
+ case_label=cell(item.get("case_label")),
207
+ role=cell(str(item.get("evidence_role") or "typical").strip().lower(), "typical"),
208
+ quote=quote.replace("|", r"\|"),
209
+ )
210
+ )
211
  return "\n".join(lines).strip()
212
 
213
  def _build_request(self, topic, keywords, raw_interviews):
 
375
  "theme": theme,
376
  "concept": concept,
377
  "quote": quote,
378
+ "quote_hash": hashlib.sha256(re.sub(r"\s+", " ", quote).strip().encode("utf-8")).hexdigest(),
379
  "tag": tag,
380
  "stage": stage,
381
  "case_label": case_label,
 
384
  )
385
 
386
  theme_map = {}
387
+ theme_ids = {}
388
  for item in clean_evidence:
389
+ theme_key = re.sub(r"\s+", " ", item["theme"]).strip().casefold()
390
+ theme_id = theme_ids.setdefault(theme_key, f"THEME-{len(theme_ids) + 1:02d}")
391
+ item["theme_id"] = theme_id
392
  theme_entry = theme_map.setdefault(
393
  item["theme"],
394
  {
395
+ "theme_id": theme_id,
396
  "title": item["theme"],
397
  "concepts": [],
398
  "evidence": [],
 
408
  theme_entry["has_deviant_evidence"] = True
409
  theme_entry["evidence"].append(
410
  {
411
+ "theme_id": item["theme_id"],
412
  "tag": item["tag"],
413
  "quote": item["quote"],
414
+ "quote_hash": item["quote_hash"],
415
  "stage": item["stage"],
416
  "case_label": item["case_label"],
417
  "evidence_role": item["evidence_role"],