R14-TQ2-PQ1-R4-R3 Final Citation Ledger English References Preservation

#68
Files changed (2) hide show
  1. core/generation_runner.py +32 -11
  2. core/pipeline_agent.py +151 -0
core/generation_runner.py CHANGED
@@ -44,6 +44,7 @@ CITATION_ROLE_RULES = {
44
  "discussion": (2, 220),
45
  }
46
  TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
 
47
 
48
 
49
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
@@ -78,6 +79,13 @@ def _normalize_target_manuscript_typography(text: str, target_lang: str) -> str:
78
  if "chinese" not in language.casefold() and "中文" not in language:
79
  return value
80
  value = value.replace("\r\n", "\n").replace("\r", "\n")
 
 
 
 
 
 
 
81
  lines = value.split("\n")
82
  normalized = []
83
  in_fence = False
@@ -93,7 +101,8 @@ def _normalize_target_manuscript_typography(text: str, target_lang: str) -> str:
93
  current = re.sub(r"[ \t]+([,。!?;:])", r"\1", line)
94
  current = re.sub(r"([,。!?;:])[ \t]+(?=\S)", r"\1", current)
95
  normalized.append(current)
96
- return "\n".join(normalized)
 
97
 
98
 
99
  class _OrderedTranslationWorker:
@@ -1859,6 +1868,15 @@ class GenerationRunner:
1859
  # Restore agent state before continuing any dependent step.
1860
  if isinstance(state.get("agent_state"), dict):
1861
  agent.load_state(state["agent_state"])
 
 
 
 
 
 
 
 
 
1862
 
1863
  user_lit_context = str(state.get("user_lit_context") or "")
1864
  user_data_md = str(state.get("user_data_md") or "")
@@ -2725,15 +2743,10 @@ Output only final academic prose. Do not report the word count."""
2725
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
2726
  if repeated_quotes:
2727
  raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
2728
- actual_used_tags = {f"[{tag.upper()}]" for tag in re.findall(r"CITE-\d+", full_draft_en, flags=re.I)}
2729
- agent.used_citations = set(tag for tag in actual_used_tags if tag in agent.citation_map)
2730
- references = []
2731
- seen_refs = set()
2732
- for tag in sorted(agent.used_citations, key=agent._citation_tag_number):
2733
- ref = str((agent.citation_map.get(tag) or {}).get("ref") or "").strip()
2734
- if ref and ref not in seen_refs:
2735
- references.append(f"- {ref}")
2736
- seen_refs.add(ref)
2737
  if not references:
2738
  self._record_evidence_coverage_event(
2739
  state, [("final validated references", 0, 1)],
@@ -2754,7 +2767,7 @@ Output only final academic prose. Do not report the word count."""
2754
  citation_required_claim_counts_by_unit.get(unit_name, 0) or 0
2755
  )
2756
  method_section_id = 'methods' if mode == 'asq' else 'methodology'
2757
- method_validated_tags = set(validated_by_top_level.get(method_section_id) or []) & set(agent.used_citations)
2758
  method_authority_tags = {
2759
  tag for tag in method_validated_tags
2760
  if 'method_authority' in set((agent.citation_map.get(tag) or {}).get('evidence_lanes') or [])
@@ -2813,6 +2826,7 @@ Output only final academic prose. Do not report the word count."""
2813
  )
2814
  agent.validate_manuscript_structure(body_only, adaptive_structure, translated=False)
2815
  agent.enforce_resolved_claim_specific_citations(body_only)
 
2816
 
2817
  # Body is now frozen. Generate both Final Abstract and Final Keywords
2818
  # from the completed manuscript/evidence, then replace the provisional
@@ -2832,6 +2846,10 @@ Output only final academic prose. Do not report the word count."""
2832
  )
2833
  agent.validate_manuscript_structure(body_only, adaptive_structure, translated=False)
2834
  agent.enforce_resolved_claim_specific_citations(body_only)
 
 
 
 
2835
  self._upsert_quality_diagnostic(
2836
  quality_diagnostics, self._validate_target_length(full_draft_en, target_words)
2837
  )
@@ -2866,6 +2884,8 @@ Output only final academic prose. Do not report the word count."""
2866
  "final_keywords": final_keywords,
2867
  "front_matter_phase": "final",
2868
  "final_reference_count": final_reference_count,
 
 
2869
  "evidence_status": str(state.get("evidence_status") or "adequate"),
2870
  "evidence_risk_detected": bool(state.get("evidence_risk_detected")),
2871
  "evidence_coverage_events": list(state.get("evidence_coverage_events") or []),
@@ -2951,6 +2971,7 @@ Output only final academic prose. Do not report the word count."""
2951
  full_draft_translated, target_lang
2952
  )
2953
  agent.validate_translation_alignment(full_draft_en, full_draft_translated)
 
2954
  self._publish_target_preview(
2955
  full_draft_translated, 'translation_complete', force=True, complete=True
2956
  )
 
44
  "discussion": (2, 220),
45
  }
46
  TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
47
+ FINAL_CITATION_LEDGER_VERSION = 'resolved-final-ledger-v1'
48
 
49
 
50
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
 
79
  if "chinese" not in language.casefold() and "中文" not in language:
80
  return value
81
  value = value.replace("\r\n", "\n").replace("\r", "\n")
82
+ # The English References tail is immutable data, not target-language prose.
83
+ # Chinese punctuation normalization therefore stops before References.
84
+ reference_match = re.search(r"(?m)^#\s+参考文献\s*$", value)
85
+ frozen_reference_tail = ""
86
+ if reference_match:
87
+ frozen_reference_tail = value[reference_match.start():]
88
+ value = value[:reference_match.start()]
89
  lines = value.split("\n")
90
  normalized = []
91
  in_fence = False
 
101
  current = re.sub(r"[ \t]+([,。!?;:])", r"\1", line)
102
  current = re.sub(r"([,。!?;:])[ \t]+(?=\S)", r"\1", current)
103
  normalized.append(current)
104
+ normalized_body = "\n".join(normalized)
105
+ return normalized_body + frozen_reference_tail
106
 
107
 
108
  class _OrderedTranslationWorker:
 
1868
  # Restore agent state before continuing any dependent step.
1869
  if isinstance(state.get("agent_state"), dict):
1870
  agent.load_state(state["agent_state"])
1871
+ if state.get("english_finalized") and state.get("final_citation_ledger_version") != FINAL_CITATION_LEDGER_VERSION:
1872
+ # R4-R3: an R4-R2 checkpoint may contain a correct resolved body but
1873
+ # an empty References tail caused by rescanning already-resolved CITE
1874
+ # tags. Re-enter only finalization/translation/package gates; completed
1875
+ # English units and the same task/reservation remain authoritative.
1876
+ logging.warning("Final citation ledger contract advanced; re-running finalization from frozen completed units.")
1877
+ state["english_finalized"] = False
1878
+ state["translation_complete"] = False
1879
+ state["package_validated"] = False
1880
 
1881
  user_lit_context = str(state.get("user_lit_context") or "")
1882
  user_data_md = str(state.get("user_data_md") or "")
 
2743
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
2744
  if repeated_quotes:
2745
  raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
2746
+ # R4-R3: the body is already citation-resolved at this point. Never
2747
+ # rescan it for raw CITE tags; that destroys a valid citation ledger.
2748
+ final_citation_ledger = agent.finalize_resolved_citation_ledger(full_draft_en)
2749
+ references = agent.build_final_reference_lines(final_citation_ledger)
 
 
 
 
 
2750
  if not references:
2751
  self._record_evidence_coverage_event(
2752
  state, [("final validated references", 0, 1)],
 
2767
  citation_required_claim_counts_by_unit.get(unit_name, 0) or 0
2768
  )
2769
  method_section_id = 'methods' if mode == 'asq' else 'methodology'
2770
+ method_validated_tags = set(validated_by_top_level.get(method_section_id) or []) & set(final_citation_ledger)
2771
  method_authority_tags = {
2772
  tag for tag in method_validated_tags
2773
  if 'method_authority' in set((agent.citation_map.get(tag) or {}).get('evidence_lanes') or [])
 
2826
  )
2827
  agent.validate_manuscript_structure(body_only, adaptive_structure, translated=False)
2828
  agent.enforce_resolved_claim_specific_citations(body_only)
2829
+ agent.validate_final_citation_bijection(body_only, _reference_tail, final_citation_ledger)
2830
 
2831
  # Body is now frozen. Generate both Final Abstract and Final Keywords
2832
  # from the completed manuscript/evidence, then replace the provisional
 
2846
  )
2847
  agent.validate_manuscript_structure(body_only, adaptive_structure, translated=False)
2848
  agent.enforce_resolved_claim_specific_citations(body_only)
2849
+ post_front_ledger = agent.finalize_resolved_citation_ledger(body_only)
2850
+ if set(post_front_ledger) != set(final_citation_ledger):
2851
+ raise RuntimeError("final_front_matter_changed_citation_ledger")
2852
+ agent.validate_final_citation_bijection(body_only, _reference_tail, final_citation_ledger)
2853
  self._upsert_quality_diagnostic(
2854
  quality_diagnostics, self._validate_target_length(full_draft_en, target_words)
2855
  )
 
2884
  "final_keywords": final_keywords,
2885
  "front_matter_phase": "final",
2886
  "final_reference_count": final_reference_count,
2887
+ "final_citation_ledger": list(final_citation_ledger),
2888
+ "final_citation_ledger_version": FINAL_CITATION_LEDGER_VERSION,
2889
  "evidence_status": str(state.get("evidence_status") or "adequate"),
2890
  "evidence_risk_detected": bool(state.get("evidence_risk_detected")),
2891
  "evidence_coverage_events": list(state.get("evidence_coverage_events") or []),
 
2971
  full_draft_translated, target_lang
2972
  )
2973
  agent.validate_translation_alignment(full_draft_en, full_draft_translated)
2974
+ agent.validate_reference_preservation(full_draft_en, full_draft_translated, target_lang)
2975
  self._publish_target_preview(
2976
  full_draft_translated, 'translation_complete', force=True, complete=True
2977
  )
core/pipeline_agent.py CHANGED
@@ -1794,6 +1794,151 @@ USER KEYWORD SEEDS: {keywords}
1794
  )
1795
  return body, tail
1796
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1797
  @staticmethod
1798
  def find_repeated_qualitative_quotes(text, data_metadata):
1799
  violations = []
@@ -7400,6 +7545,12 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
7400
  'Russian whole-unit language signal diagnostic | kazakh_specific=%s cyrillic=%s | nonfatal=true',
7401
  len(kazakh_specific), len(cyrillic),
7402
  )
 
 
 
 
 
 
7403
  return True
7404
 
7405
  def enforce_claim_specific_citations(self, text):
 
1794
  )
1795
  return body, tail
1796
 
1797
+ @staticmethod
1798
+ def _resolved_parenthetical_citations(text):
1799
+ """Return resolved parenthetical author-year citation markers in prose.
1800
+
1801
+ This deliberately excludes bare years such as ``(2026)``. SmartPaper's
1802
+ claim-specific citation compiler emits parenthetical author-year markers,
1803
+ so any such marker in the frozen body must be traceable to citation_map.
1804
+ """
1805
+ pattern = re.compile(
1806
+ r"\((?=[^()\n]*[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+(?:\s+(?:&\s+[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+|et\s+al\.))?\s*,\s*(?:19|20)\d{2}[a-z]?\b)[^()\n]+\)",
1807
+ re.IGNORECASE,
1808
+ )
1809
+ return [match.group(0).strip() for match in pattern.finditer(str(text or ''))]
1810
+
1811
+ def finalize_resolved_citation_ledger(self, resolved_body):
1812
+ """Freeze the final citation ledger from the *resolved* manuscript body.
1813
+
1814
+ R4-R3 authority rule: once ``[CITE-XX]`` has been compiled to
1815
+ ``(Author, year)``, raw CITE tags are no longer a valid source of truth.
1816
+ The final ledger is rebuilt from the resolved markers that are actually
1817
+ present in the frozen body and the immutable citation_map metadata.
1818
+ Stale pre-deletion citations are dropped; orphan/ambiguous citations fail
1819
+ closed rather than producing a misleading References list.
1820
+ """
1821
+ body = str(resolved_body or '').strip()
1822
+ if re.search(r'CITE-\d+', body, re.I):
1823
+ raise DeepSeekRequestError('Final citation ledger received unresolved CITE tags.')
1824
+
1825
+ marker_index = {}
1826
+ for tag, metadata in (self.citation_map or {}).items():
1827
+ marker = str((metadata or {}).get('in_text') or '').strip()
1828
+ ref = str((metadata or {}).get('ref') or '').strip()
1829
+ if not marker or not ref:
1830
+ continue
1831
+ marker_index.setdefault(marker, []).append(str(tag))
1832
+
1833
+ observed = self._resolved_parenthetical_citations(body)
1834
+ orphan = sorted({marker for marker in observed if marker not in marker_index})
1835
+ if orphan:
1836
+ raise DeepSeekRequestError(
1837
+ 'Final citation ledger found resolved citation(s) without authoritative metadata: '
1838
+ + '; '.join(orphan[:5])
1839
+ )
1840
+
1841
+ ledger = set()
1842
+ ambiguous = []
1843
+ for marker in sorted(set(observed)):
1844
+ tags = marker_index.get(marker) or []
1845
+ if len(tags) == 1:
1846
+ ledger.add(tags[0])
1847
+ continue
1848
+ distinct_refs = {
1849
+ str((self.citation_map.get(tag) or {}).get('ref') or '').strip()
1850
+ for tag in tags
1851
+ if str((self.citation_map.get(tag) or {}).get('ref') or '').strip()
1852
+ }
1853
+ previously_used = [tag for tag in tags if tag in set(self.used_citations or set())]
1854
+ if len(distinct_refs) == 1:
1855
+ ledger.add(sorted(tags, key=self._citation_tag_number)[0])
1856
+ elif len(previously_used) == 1:
1857
+ ledger.add(previously_used[0])
1858
+ else:
1859
+ ambiguous.append((marker, sorted(tags, key=self._citation_tag_number)))
1860
+ if ambiguous:
1861
+ raise DeepSeekRequestError(
1862
+ 'Final citation ledger found ambiguous author-year marker(s): '
1863
+ + '; '.join(f"{marker} -> {tags}" for marker, tags in ambiguous[:5])
1864
+ )
1865
+
1866
+ self.used_citations = set(ledger)
1867
+ logging.info(
1868
+ 'Final citation ledger frozen: resolved_markers=%s unique_references=%s',
1869
+ len(observed), len(ledger),
1870
+ )
1871
+ return sorted(ledger, key=self._citation_tag_number)
1872
+
1873
+ def build_final_reference_lines(self, ledger_tags):
1874
+ """Build one English reference entry per authoritative ledger source."""
1875
+ references = {}
1876
+ for tag in ledger_tags or []:
1877
+ metadata = self.citation_map.get(str(tag)) or {}
1878
+ ref = str(metadata.get('ref') or '').strip()
1879
+ if not ref:
1880
+ raise DeepSeekRequestError(f'Final citation ledger metadata missing reference text: {tag}')
1881
+ references.setdefault(re.sub(r'\s+', ' ', ref).casefold(), ref)
1882
+ # Reader-facing References follow the conventional alphabetical order.
1883
+ return [f"- {ref}" for ref in sorted(references.values(), key=lambda value: value.casefold())]
1884
+
1885
+ def validate_final_citation_bijection(self, resolved_body, reference_tail, ledger_tags):
1886
+ """Require exact bidirectional consistency between prose and References."""
1887
+ body = str(resolved_body or '').strip()
1888
+ tail = str(reference_tail or '').strip()
1889
+ ledger = list(ledger_tags or [])
1890
+ expected = []
1891
+ for tag in ledger:
1892
+ metadata = self.citation_map.get(str(tag)) or {}
1893
+ marker = str(metadata.get('in_text') or '').strip()
1894
+ ref = str(metadata.get('ref') or '').strip()
1895
+ if not marker or not ref:
1896
+ raise DeepSeekRequestError(f'Final citation ledger is incomplete: {tag}')
1897
+ if marker not in body:
1898
+ raise DeepSeekRequestError(f'Final citation ledger contains a citation absent from body: {tag}')
1899
+ expected.append(re.sub(r'\s+', ' ', ref).strip())
1900
+
1901
+ actual = [
1902
+ re.sub(r'\s+', ' ', line[2:]).strip()
1903
+ for line in tail.splitlines()
1904
+ if line.strip().startswith('- ')
1905
+ ]
1906
+ if len(actual) != len(set(actual)):
1907
+ raise DeepSeekRequestError('Final References contains duplicate entries.')
1908
+ if set(actual) != set(expected):
1909
+ missing = sorted(set(expected) - set(actual))[:5]
1910
+ extra = sorted(set(actual) - set(expected))[:5]
1911
+ raise DeepSeekRequestError(
1912
+ f'Final citation-reference bijection failed: missing={missing}, extra={extra}.'
1913
+ )
1914
+
1915
+ frozen = self.finalize_resolved_citation_ledger(body)
1916
+ if set(frozen) != set(ledger):
1917
+ raise DeepSeekRequestError(
1918
+ 'Final citation-reference bijection failed: resolved-body ledger changed after References assembly.'
1919
+ )
1920
+ return True
1921
+
1922
+ @classmethod
1923
+ def validate_reference_preservation(cls, english_text, translated_text, target_lang):
1924
+ """References are immutable English data in ZH/RU/KK deliverables."""
1925
+ source = str(english_text or '').strip()
1926
+ target = str(translated_text or '').strip()
1927
+ source_parts = re.split(r'(?m)^#\s+References\s*$', source, maxsplit=1)
1928
+ if len(source_parts) != 2:
1929
+ raise DeepSeekRequestError('Reference preservation requires one English References tail.')
1930
+ source_tail = source_parts[1].strip()
1931
+ heading = re.escape(cls._localized_reference_heading(target_lang))
1932
+ target_parts = re.split(rf'(?m)^#\s+{heading}\s*$', target, maxsplit=1)
1933
+ if len(target_parts) != 2:
1934
+ raise DeepSeekRequestError('Target manuscript is missing the localized References heading.')
1935
+ target_tail = target_parts[1].strip()
1936
+ if source_tail != target_tail:
1937
+ raise DeepSeekRequestError(
1938
+ 'Target-language References changed from the frozen English References tail.'
1939
+ )
1940
+ return True
1941
+
1942
  @staticmethod
1943
  def find_repeated_qualitative_quotes(text, data_metadata):
1944
  violations = []
 
7545
  'Russian whole-unit language signal diagnostic | kazakh_specific=%s cyrillic=%s | nonfatal=true',
7546
  len(kazakh_specific), len(cyrillic),
7547
  )
7548
+ if re.search(r'(?m)^#\s+References\s*$', source):
7549
+ cls.validate_reference_preservation(source, target, target_lang=
7550
+ 'Russian (Русский)' if re.search(r'(?m)^#\s+Литература\s*$', target) else
7551
+ 'Kazakh (Қазақша)' if re.search(r'(?m)^#\s+Пайдаланылған әдебиеттер\s*$', target) else
7552
+ 'Simplified Chinese (简体中文)'
7553
+ )
7554
  return True
7555
 
7556
  def enforce_claim_specific_citations(self, text):