L2 front matter language authority and delivery-first hardening

#131
core/generation_runner.py CHANGED
@@ -1810,6 +1810,62 @@ class GenerationRunner:
1810
 
1811
  return rows
1812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1813
  @staticmethod
1814
  def _section_retry_feedback(violations, *, limit=8) -> str:
1815
  rows = []
@@ -1898,10 +1954,11 @@ class GenerationRunner:
1898
  ))
1899
  return "FATAL" if core_citation_section else "ADVISORY"
1900
 
1901
- # Unknown validation types remain fail-closed. This keeps C5.5 from
1902
- # silently downgrading a future integrity contract merely because it is
1903
- # new to the severity registry.
1904
- return "FATAL"
 
1905
 
1906
  @staticmethod
1907
  def _section_violation_lineage(row) -> str:
@@ -2687,6 +2744,73 @@ class GenerationRunner:
2687
  """
2688
  return str(topic or "").strip(), str(keywords or "").strip()
2689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2690
  @staticmethod
2691
  def _resolved_front_matter_keyword_policy(data_metadata: dict):
2692
  """Resolve one policy object for Working/Final/retry/resume/fallback paths."""
@@ -2698,8 +2822,12 @@ class GenerationRunner:
2698
 
2699
  @staticmethod
2700
  def _front_matter_keyword_fallback_terms(data_metadata: dict):
2701
- """Derive recoverable generated keywords only from frozen study-design authority."""
2702
  meta = dict(data_metadata or {})
 
 
 
 
2703
  contract = dict(meta.get("study_design_contract") or {})
2704
  values = []
2705
  quant = dict(contract.get("quantitative") or {})
@@ -2733,10 +2861,12 @@ class GenerationRunner:
2733
  policy = keyword_policy_from_contract(contract)
2734
  if str(policy.get("authority_source") or "").upper() != "USER_EXPLICIT":
2735
  return []
 
 
2736
  return apply_keyword_policy(
2737
- intent.get("source_keywords") or [],
2738
  policy,
2739
- authoritative=intent.get("source_keywords") or [],
2740
  )
2741
 
2742
  @staticmethod
@@ -2746,7 +2876,8 @@ class GenerationRunner:
2746
  contract = dict(meta.get("study_design_contract") or meta.get("front_matter_contract") or {})
2747
  intent = dict(contract.get("user_research_intent") or meta.get("user_research_intent") or {})
2748
  policy = keyword_policy_from_contract(contract)
2749
- authoritative = list(intent.get("source_keywords") or [])
 
2750
  values = apply_keyword_policy(
2751
  candidate_keywords,
2752
  policy,
@@ -2798,13 +2929,26 @@ class GenerationRunner:
2798
 
2799
 
2800
  @staticmethod
2801
- def _target_keyword_heading_aliases(target_lang: str) -> list[str]:
2802
- code = str(target_lang or '').strip().casefold().replace('_', '-')
2803
- if code.startswith('zh'):
 
 
 
 
 
 
 
 
 
 
 
 
 
2804
  return ['关键词', '关键字']
2805
- if code.startswith('ru'):
2806
  return ['Ключевые слова']
2807
- if code.startswith('kk') or code.startswith('kz'):
2808
  return ['Түйін сөздер', 'Кілт сөздер']
2809
  return ['Keywords']
2810
 
@@ -2815,7 +2959,13 @@ class GenerationRunner:
2815
  meta = dict(data_metadata or {})
2816
  contract = dict(meta.get('study_design_contract') or {})
2817
  intent = dict(contract.get('user_research_intent') or meta.get('user_research_intent') or {})
2818
- source_keywords = list(intent.get('source_keywords') or contract.get('source_keywords') or [])
 
 
 
 
 
 
2819
  mapping = dict(glossary or {})
2820
  diagnostics = []
2821
 
@@ -2847,7 +2997,7 @@ class GenerationRunner:
2847
  values = [str(row.get('label') or '') for row in construct_rows if role in (row.get('roles') or []) and row.get('label')]
2848
  if len(values) == 1:
2849
  role_label[role] = re.sub(r'\s+', ' ', str(mapping.get(values[0]) or values[0])).strip()
2850
- code = str(target_lang or '').strip().casefold().replace('_', '-')
2851
  suffixes = []
2852
  if 'moderation' in missing:
2853
  label = role_label.get('moderator') or ''
@@ -2958,7 +3108,8 @@ class GenerationRunner:
2958
  # R7-R2C-R5: user keywords are research-intent identities, not expendable
2959
  # planning seeds. Freeze them for multilingual inheritance while still
2960
  # distinguishing them from empirical construct roles.
2961
- intent_keywords = list(user_intent.get('source_keywords') or [])
 
2962
  if not intent_keywords:
2963
  intent_keywords = parse_keyword_terms(keywords, explicit_user=True)
2964
  for seed in intent_keywords:
@@ -4329,6 +4480,15 @@ class GenerationRunner:
4329
  state.pop("effective_topic", None)
4330
  state.pop("effective_keywords", None)
4331
 
 
 
 
 
 
 
 
 
 
4332
  hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {}) if mode == "amj" else {}
4333
  hypothesis_contract = self._hypothesis_contract_directive(hypothesis_ledger)
4334
  if hypothesis_ledger:
@@ -4496,7 +4656,7 @@ class GenerationRunner:
4496
  working_front_error = None
4497
  try:
4498
  working_front = agent.generate_working_front_matter(
4499
- topic, keywords, research_logic_contract, synthesis_result,
4500
  data_evidence=planning_data_md, mode=mode,
4501
  hypothesis_contract=hypothesis_contract,
4502
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
@@ -4522,7 +4682,7 @@ class GenerationRunner:
4522
  )
4523
  if not working_abstract or not working_keywords:
4524
  fallback = self._deterministic_working_front_matter(
4525
- mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
4526
  )
4527
  working_abstract = str(fallback.get("abstract") or "").strip()
4528
  working_keywords = list(fallback.get("keywords") or [])
@@ -4553,7 +4713,7 @@ class GenerationRunner:
4553
  )
4554
  try:
4555
  working_front = agent.generate_working_front_matter(
4556
- topic, keywords, research_logic_contract, synthesis_result,
4557
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
4558
  hypothesis_contract=hypothesis_contract,
4559
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
@@ -4599,7 +4759,7 @@ class GenerationRunner:
4599
  })
4600
  if not working_abstract or not working_keywords:
4601
  fallback = self._deterministic_working_front_matter(
4602
- mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
4603
  )
4604
  working_abstract = str(fallback.get("abstract") or "").strip()
4605
  working_keywords = list(fallback.get("keywords") or [])
@@ -4620,7 +4780,7 @@ class GenerationRunner:
4620
  plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
4621
  # User title is reader-facing authority. Outline planning may adapt
4622
  # structure but may not silently rename the study.
4623
- en_topic = str(source_topic or topic).strip()
4624
  structure = plan.get("structure", [])
4625
  full_draft_en = (
4626
  f"# {en_topic}\n\n## Abstract\n{working_abstract}\n\n"
@@ -4815,7 +4975,7 @@ class GenerationRunner:
4815
  else:
4816
  full_draft_en = str(state.get("full_draft_en") or "")
4817
  if str(data_metadata.get("source_type") or "") == "system_generated" and mode == "amj":
4818
- full_draft_en = self._replace_title(full_draft_en, topic)
4819
  canonical_resume_keywords = self._canonical_quantitative_front_matter_keywords(
4820
  mode, [], data_metadata
4821
  )
@@ -4934,7 +5094,7 @@ class GenerationRunner:
4934
  feedback = self._section_retry_feedback(resumed_front_rows)
4935
  try:
4936
  working_front = agent.generate_working_front_matter(
4937
- topic, keywords, research_logic_contract, synthesis_result,
4938
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
4939
  hypothesis_contract=hypothesis_contract,
4940
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
@@ -4957,13 +5117,13 @@ class GenerationRunner:
4957
  type(exc).__name__,
4958
  )
4959
  fallback = self._deterministic_working_front_matter(
4960
- mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
4961
  )
4962
  candidate_abstract = str(fallback.get("abstract") or "").strip()
4963
  candidate_keywords = list(fallback.get("keywords") or [])
4964
  if not candidate_abstract or not candidate_keywords:
4965
  fallback = self._deterministic_working_front_matter(
4966
- mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
4967
  )
4968
  candidate_abstract = str(fallback.get("abstract") or "").strip()
4969
  candidate_keywords = list(fallback.get("keywords") or [])
@@ -4985,7 +5145,7 @@ class GenerationRunner:
4985
  logging.warning("Adaptive resumed Working Abstract accepted with residual diagnostics | count=%s", len(residual_front_rows))
4986
  if not candidate_abstract:
4987
  fallback = self._deterministic_working_front_matter(
4988
- mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
4989
  )
4990
  candidate_abstract = str(fallback.get("abstract") or "").strip()
4991
  candidate_keywords = list(fallback.get("keywords") or candidate_keywords)
@@ -5188,7 +5348,7 @@ class GenerationRunner:
5188
  if not working_abstract and not state.get("english_finalized"):
5189
  try:
5190
  working_front = agent.generate_working_front_matter(
5191
- topic, keywords, research_logic_contract, synthesis_result,
5192
  data_evidence=planning_data_md, mode=mode,
5193
  hypothesis_contract=hypothesis_contract,
5194
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
@@ -5203,7 +5363,7 @@ class GenerationRunner:
5203
  type(exc).__name__,
5204
  )
5205
  working_front = self._deterministic_working_front_matter(
5206
- mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
5207
  )
5208
  quality_diagnostics.append({
5209
  "scope": "delivery_first_working_front_matter_fallback",
@@ -5221,7 +5381,7 @@ class GenerationRunner:
5221
  )
5222
  if not working_abstract or not working_keywords:
5223
  working_front = self._deterministic_working_front_matter(
5224
- mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
5225
  )
5226
  working_abstract = str(working_front.get("abstract") or "").strip()
5227
  working_keywords = list(working_front.get("keywords") or [])
@@ -5242,7 +5402,7 @@ class GenerationRunner:
5242
  feedback = self._section_retry_feedback(front_fact_rows)
5243
  try:
5244
  working_front = agent.generate_working_front_matter(
5245
- topic, keywords, research_logic_contract, synthesis_result,
5246
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
5247
  hypothesis_contract=hypothesis_contract,
5248
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
@@ -5288,7 +5448,7 @@ class GenerationRunner:
5288
  })
5289
  if not working_abstract or not working_keywords:
5290
  working_front = self._deterministic_working_front_matter(
5291
- mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
5292
  )
5293
  working_abstract = str(working_front.get("abstract") or "").strip()
5294
  working_keywords = list(working_front.get("keywords") or [])
@@ -6440,7 +6600,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6440
  self._progress(92, "final_front_matter")
6441
  try:
6442
  final_front = agent.generate_final_front_matter(
6443
- topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
6444
  hypothesis_contract=hypothesis_contract,
6445
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
6446
  keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
@@ -6452,10 +6612,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6452
  )
6453
  final_front = {
6454
  "abstract": self._deterministic_final_abstract(
6455
- mode, source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6456
  ),
6457
  "keywords": self._canonical_quantitative_front_matter_keywords(
6458
- mode, parse_keyword_terms(source_keywords or keywords, explicit_user=True), data_metadata
6459
  ) or self._front_matter_keyword_fallback_terms(data_metadata),
6460
  }
6461
  self._record_adaptive_completion_residual(
@@ -6474,10 +6634,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6474
  )
6475
  if not final_abstract or not final_keywords:
6476
  final_abstract = final_abstract or self._deterministic_final_abstract(
6477
- mode, source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6478
  )
6479
  final_keywords = final_keywords or self._canonical_quantitative_front_matter_keywords(
6480
- mode, parse_keyword_terms(source_keywords or keywords, explicit_user=True), data_metadata
6481
  ) or self._front_matter_keyword_fallback_terms(data_metadata)
6482
  self._record_adaptive_completion_residual(
6483
  quality_notes, [{
@@ -6505,7 +6665,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6505
  )
6506
  try:
6507
  final_front = agent.generate_final_front_matter(
6508
- topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
6509
  validation_feedback=feedback, hypothesis_contract=hypothesis_contract,
6510
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
6511
  keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
@@ -6517,10 +6677,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6517
  )
6518
  final_front = {
6519
  "abstract": self._deterministic_final_abstract(
6520
- mode, source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6521
  ),
6522
  "keywords": final_keywords or self._canonical_quantitative_front_matter_keywords(
6523
- mode, parse_keyword_terms(source_keywords or keywords, explicit_user=True), data_metadata
6524
  ) or self._front_matter_keyword_fallback_terms(data_metadata),
6525
  }
6526
  final_abstract = str(final_front.get("abstract") or "").strip()
@@ -6533,10 +6693,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6533
  )
6534
  if not final_abstract or not final_keywords:
6535
  final_abstract = final_abstract or self._deterministic_final_abstract(
6536
- mode, source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6537
  )
6538
  final_keywords = final_keywords or self._canonical_quantitative_front_matter_keywords(
6539
- mode, parse_keyword_terms(source_keywords or keywords, explicit_user=True), data_metadata
6540
  ) or self._front_matter_keyword_fallback_terms(data_metadata)
6541
  self._record_adaptive_completion_residual(
6542
  quality_notes, [{
@@ -6587,7 +6747,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6587
  )
6588
  if residual_final_rows:
6589
  deterministic_abstract = self._deterministic_final_abstract(
6590
- mode, source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6591
  )
6592
  hard_rows = self._study_fact_violations(
6593
  mode, agent, quant_engine, deterministic_abstract, data_metadata, section_name="Final Abstract"
@@ -6616,12 +6776,12 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6616
  "examples":final_front_fact_rows[:3],
6617
  })
6618
  state.pop("final_abstract_retry_exhausted", None)
6619
- final_title = str(source_topic or topic or "").strip()
6620
  if mode == "amj" and str(data_metadata.get("source_type") or "") == "system_generated":
6621
  semantic_contract = dict(data_metadata.get("front_matter_contract") or {})
6622
  reconciled_front = agent.reconcile_quantitative_reader_front_matter(
6623
  topic=source_topic, keywords=source_keywords, contract=semantic_contract,
6624
- manuscript=body_only,
6625
  )
6626
  candidate_title = re.sub(r"\s+", " ", str(reconciled_front.get("title") or "")).strip()
6627
  candidate_keywords = self._canonical_quantitative_front_matter_keywords(
@@ -6640,6 +6800,17 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6640
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
6641
  full_draft_en = self._replace_title(full_draft_en, final_title)
6642
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
 
 
 
 
 
 
 
 
 
 
 
6643
  if mode == "amj" and hypothesis_ledger:
6644
  post_front_hypothesis_rows = self._hypothesis_contract_violations(
6645
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
 
1810
 
1811
  return rows
1812
 
1813
+ def _delivery_first_final_abstract_language_guard(
1814
+ self, *, mode: str, agent, quant_engine, final_abstract: str, reader_topic_en: str,
1815
+ final_title: str, data_metadata: dict, hypothesis_ledger: dict | None, quality_notes: list,
1816
+ ) -> tuple[str, bool]:
1817
+ """Repair residual non-English script without turning a reader-surface defect into P0 failure.
1818
+
1819
+ Returns ``(abstract, rebuilt)``. Explicit P1 integrity validators remain
1820
+ authoritative; only the language-surface defect is downgraded when a
1821
+ provider-neutral clean reconstruction cannot be proven safe.
1822
+ """
1823
+ current = str(final_abstract or "").strip()
1824
+ if not agent._english_reader_surface_has_foreign_script(current):
1825
+ return current, False
1826
+
1827
+ language_fallback = self._deterministic_final_abstract(
1828
+ mode, reader_topic_en or final_title, data_metadata, hypothesis_ledger=hypothesis_ledger
1829
+ )
1830
+ language_rows = self._study_fact_violations(
1831
+ mode, agent, quant_engine, language_fallback, data_metadata, section_name="Final Abstract"
1832
+ )
1833
+ if hypothesis_ledger:
1834
+ language_rows.extend(self._hypothesis_contract_violations(
1835
+ language_fallback, hypothesis_ledger, section_name="Final Abstract"
1836
+ ))
1837
+ language_rows.extend(self._final_abstract_finding_violations(
1838
+ mode, language_fallback, data_metadata, hypothesis_ledger=hypothesis_ledger
1839
+ ))
1840
+ fatal_language_rows, advisory_language_rows = self._partition_section_violations(
1841
+ language_rows, section_name="Final Abstract", mode=mode, exhausted=True
1842
+ )
1843
+ if (
1844
+ language_fallback
1845
+ and not agent._english_reader_surface_has_foreign_script(language_fallback)
1846
+ and not fatal_language_rows
1847
+ ):
1848
+ self._record_adaptive_completion_residual(
1849
+ quality_notes,
1850
+ advisory_language_rows + [{
1851
+ "type": "final_english_front_matter_language_fallback",
1852
+ "reason": "Residual non-English script in the Final Abstract was replaced by a deterministic English reconstruction from frozen research authority."
1853
+ }],
1854
+ section_name="Final Abstract",
1855
+ stage="delivery_first_front_matter_language_fallback",
1856
+ )
1857
+ return language_fallback, True
1858
+
1859
+ self._record_adaptive_completion_residual(
1860
+ quality_notes, [{
1861
+ "type": "final_english_front_matter_language_warning",
1862
+ "reason": "Final Abstract still contains non-English script after deterministic language fallback; delivery was preserved under the P0 Delivery-First contract."
1863
+ }],
1864
+ section_name="Final Abstract",
1865
+ stage="delivery_first_front_matter_language_warning",
1866
+ )
1867
+ return current, False
1868
+
1869
  @staticmethod
1870
  def _section_retry_feedback(violations, *, limit=8) -> str:
1871
  rows = []
 
1954
  ))
1955
  return "FATAL" if core_citation_section else "ADVISORY"
1956
 
1957
+ # Delivery-First hardening: unknown/new validators are not allowed to
1958
+ # create an accidental terminal path merely because a developer forgot
1959
+ # to register their severity. Only explicit P1/hard-failure identities
1960
+ # above may remain fatal after bounded repair.
1961
+ return "ADVISORY"
1962
 
1963
  @staticmethod
1964
  def _section_violation_lineage(row) -> str:
 
2744
  """
2745
  return str(topic or "").strip(), str(keywords or "").strip()
2746
 
2747
+ @staticmethod
2748
+ def _front_matter_language_projection_hash(topic: str, keywords) -> str:
2749
+ terms = parse_keyword_terms(keywords, explicit_user=True)
2750
+ payload = json.dumps(
2751
+ {"title": str(topic or "").strip(), "keywords": terms},
2752
+ ensure_ascii=False, sort_keys=True, separators=(",", ":"),
2753
+ ).encode("utf-8")
2754
+ return hashlib.sha256(payload).hexdigest()
2755
+
2756
+ def _resolve_english_front_matter_projection(
2757
+ self, agent, state: dict, data_metadata: dict, source_topic: str, source_keywords
2758
+ ) -> dict:
2759
+ """Resolve/cache a language-only English projection without mutating user intent."""
2760
+ meta = dict(data_metadata or {})
2761
+ contract = dict(meta.get("study_design_contract") or meta.get("front_matter_contract") or {})
2762
+ intent = dict(contract.get("user_research_intent") or meta.get("user_research_intent") or {})
2763
+ semantic_title = str(intent.get("source_topic") or source_topic or "").strip()
2764
+ semantic_keywords = list(intent.get("source_keywords") or parse_keyword_terms(source_keywords, explicit_user=True))
2765
+ source_hash = self._front_matter_language_projection_hash(semantic_title, semantic_keywords)
2766
+ expected_count = len(semantic_keywords)
2767
+
2768
+ cached = dict(
2769
+ state.get("front_matter_language_projection")
2770
+ or meta.get("front_matter_language_projection")
2771
+ or {}
2772
+ )
2773
+ if (
2774
+ cached.get("version") == "front-matter-language-authority-v1"
2775
+ and cached.get("source_hash") == source_hash
2776
+ ):
2777
+ try:
2778
+ validated = agent.validate_reader_facing_english_projection(
2779
+ cached, expected_keyword_count=expected_count
2780
+ )
2781
+ return {
2782
+ "version": "front-matter-language-authority-v1",
2783
+ "source_hash": source_hash,
2784
+ "english_title": validated["title"],
2785
+ "english_keywords": list(validated["keywords"]),
2786
+ "translation_applied": bool(cached.get("translation_applied")),
2787
+ }
2788
+ except Exception as exc:
2789
+ logging.warning(
2790
+ "Cached English front-matter language projection invalid; rebuilding | error=%s",
2791
+ type(exc).__name__,
2792
+ )
2793
+
2794
+ resolved = agent.normalize_reader_facing_front_matter_to_english(
2795
+ semantic_title, semantic_keywords
2796
+ )
2797
+ validated = agent.validate_reader_facing_english_projection(
2798
+ resolved, expected_keyword_count=expected_count
2799
+ )
2800
+ projection = {
2801
+ "version": "front-matter-language-authority-v1",
2802
+ "source_hash": source_hash,
2803
+ "english_title": validated["title"],
2804
+ "english_keywords": list(validated["keywords"]),
2805
+ "translation_applied": bool(resolved.get("translation_applied")),
2806
+ }
2807
+ # Presentation projection is additive metadata only. Original source_topic
2808
+ # and source_keywords inside user_research_intent remain byte-for-byte authority.
2809
+ data_metadata["front_matter_language_projection"] = dict(projection)
2810
+ state["front_matter_language_projection"] = dict(projection)
2811
+ state["data_metadata"] = data_metadata
2812
+ return projection
2813
+
2814
  @staticmethod
2815
  def _resolved_front_matter_keyword_policy(data_metadata: dict):
2816
  """Resolve one policy object for Working/Final/retry/resume/fallback paths."""
 
2822
 
2823
  @staticmethod
2824
  def _front_matter_keyword_fallback_terms(data_metadata: dict):
2825
+ """Derive recoverable English reader keywords from frozen study-design authority."""
2826
  meta = dict(data_metadata or {})
2827
+ projection = dict(meta.get("front_matter_language_projection") or {})
2828
+ projected = [str(item).strip() for item in (projection.get("english_keywords") or []) if str(item).strip()]
2829
+ if projected:
2830
+ return projected
2831
  contract = dict(meta.get("study_design_contract") or {})
2832
  values = []
2833
  quant = dict(contract.get("quantitative") or {})
 
2861
  policy = keyword_policy_from_contract(contract)
2862
  if str(policy.get("authority_source") or "").upper() != "USER_EXPLICIT":
2863
  return []
2864
+ projection = dict(meta.get("front_matter_language_projection") or {})
2865
+ authoritative = list(projection.get("english_keywords") or intent.get("source_keywords") or [])
2866
  return apply_keyword_policy(
2867
+ authoritative,
2868
  policy,
2869
+ authoritative=authoritative,
2870
  )
2871
 
2872
  @staticmethod
 
2876
  contract = dict(meta.get("study_design_contract") or meta.get("front_matter_contract") or {})
2877
  intent = dict(contract.get("user_research_intent") or meta.get("user_research_intent") or {})
2878
  policy = keyword_policy_from_contract(contract)
2879
+ projection = dict(meta.get("front_matter_language_projection") or {})
2880
+ authoritative = list(projection.get("english_keywords") or intent.get("source_keywords") or [])
2881
  values = apply_keyword_policy(
2882
  candidate_keywords,
2883
  policy,
 
2929
 
2930
 
2931
  @staticmethod
2932
+ def _target_front_matter_language_code(target_lang: str) -> str:
2933
+ value = str(target_lang or '').strip().casefold().replace('_', '-')
2934
+ if value.startswith('zh') or 'chinese' in value or '中文' in value or '简体中文' in value:
2935
+ return 'zh'
2936
+ if value.startswith('ru') or 'russian' in value or 'рус' in value:
2937
+ return 'ru'
2938
+ if value.startswith('kk') or value.startswith('kz') or 'kazakh' in value or 'қазақ' in value:
2939
+ return 'kk'
2940
+ if value.startswith('en') or 'english' in value:
2941
+ return 'en'
2942
+ return value
2943
+
2944
+ @classmethod
2945
+ def _target_keyword_heading_aliases(cls, target_lang: str) -> list[str]:
2946
+ code = cls._target_front_matter_language_code(target_lang)
2947
+ if code == 'zh':
2948
  return ['关键词', '关键字']
2949
+ if code == 'ru':
2950
  return ['Ключевые слова']
2951
+ if code == 'kk':
2952
  return ['Түйін сөздер', 'Кілт сөздер']
2953
  return ['Keywords']
2954
 
 
2959
  meta = dict(data_metadata or {})
2960
  contract = dict(meta.get('study_design_contract') or {})
2961
  intent = dict(contract.get('user_research_intent') or meta.get('user_research_intent') or {})
2962
+ projection = dict(meta.get('front_matter_language_projection') or {})
2963
+ source_keywords = list(
2964
+ projection.get('english_keywords')
2965
+ or intent.get('source_keywords')
2966
+ or contract.get('source_keywords')
2967
+ or []
2968
+ )
2969
  mapping = dict(glossary or {})
2970
  diagnostics = []
2971
 
 
2997
  values = [str(row.get('label') or '') for row in construct_rows if role in (row.get('roles') or []) and row.get('label')]
2998
  if len(values) == 1:
2999
  role_label[role] = re.sub(r'\s+', ' ', str(mapping.get(values[0]) or values[0])).strip()
3000
+ code = cls._target_front_matter_language_code(target_lang)
3001
  suffixes = []
3002
  if 'moderation' in missing:
3003
  label = role_label.get('moderator') or ''
 
3108
  # R7-R2C-R5: user keywords are research-intent identities, not expendable
3109
  # planning seeds. Freeze them for multilingual inheritance while still
3110
  # distinguishing them from empirical construct roles.
3111
+ projection = dict(meta.get('front_matter_language_projection') or {})
3112
+ intent_keywords = list(projection.get('english_keywords') or user_intent.get('source_keywords') or [])
3113
  if not intent_keywords:
3114
  intent_keywords = parse_keyword_terms(keywords, explicit_user=True)
3115
  for seed in intent_keywords:
 
4480
  state.pop("effective_topic", None)
4481
  state.pop("effective_keywords", None)
4482
 
4483
+ front_matter_projection = self._resolve_english_front_matter_projection(
4484
+ agent, state, data_metadata, source_topic, source_keywords
4485
+ )
4486
+ reader_topic_en = str(front_matter_projection.get("english_title") or source_topic or topic or "").strip()
4487
+ reader_keywords_en = list(front_matter_projection.get("english_keywords") or [])
4488
+ reader_keywords_prompt = "; ".join(reader_keywords_en)
4489
+ data_metadata["front_matter_language_projection"] = dict(front_matter_projection)
4490
+ state["data_metadata"] = data_metadata
4491
+
4492
  hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {}) if mode == "amj" else {}
4493
  hypothesis_contract = self._hypothesis_contract_directive(hypothesis_ledger)
4494
  if hypothesis_ledger:
 
4656
  working_front_error = None
4657
  try:
4658
  working_front = agent.generate_working_front_matter(
4659
+ reader_topic_en, reader_keywords_prompt, research_logic_contract, synthesis_result,
4660
  data_evidence=planning_data_md, mode=mode,
4661
  hypothesis_contract=hypothesis_contract,
4662
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
 
4682
  )
4683
  if not working_abstract or not working_keywords:
4684
  fallback = self._deterministic_working_front_matter(
4685
+ mode, reader_topic_en, reader_keywords_prompt, data_metadata, hypothesis_ledger=hypothesis_ledger
4686
  )
4687
  working_abstract = str(fallback.get("abstract") or "").strip()
4688
  working_keywords = list(fallback.get("keywords") or [])
 
4713
  )
4714
  try:
4715
  working_front = agent.generate_working_front_matter(
4716
+ reader_topic_en, reader_keywords_prompt, research_logic_contract, synthesis_result,
4717
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
4718
  hypothesis_contract=hypothesis_contract,
4719
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
 
4759
  })
4760
  if not working_abstract or not working_keywords:
4761
  fallback = self._deterministic_working_front_matter(
4762
+ mode, reader_topic_en, reader_keywords_prompt, data_metadata, hypothesis_ledger=hypothesis_ledger
4763
  )
4764
  working_abstract = str(fallback.get("abstract") or "").strip()
4765
  working_keywords = list(fallback.get("keywords") or [])
 
4780
  plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
4781
  # User title is reader-facing authority. Outline planning may adapt
4782
  # structure but may not silently rename the study.
4783
+ en_topic = str(reader_topic_en or source_topic or topic).strip()
4784
  structure = plan.get("structure", [])
4785
  full_draft_en = (
4786
  f"# {en_topic}\n\n## Abstract\n{working_abstract}\n\n"
 
4975
  else:
4976
  full_draft_en = str(state.get("full_draft_en") or "")
4977
  if str(data_metadata.get("source_type") or "") == "system_generated" and mode == "amj":
4978
+ full_draft_en = self._replace_title(full_draft_en, reader_topic_en)
4979
  canonical_resume_keywords = self._canonical_quantitative_front_matter_keywords(
4980
  mode, [], data_metadata
4981
  )
 
5094
  feedback = self._section_retry_feedback(resumed_front_rows)
5095
  try:
5096
  working_front = agent.generate_working_front_matter(
5097
+ reader_topic_en, reader_keywords_prompt, research_logic_contract, synthesis_result,
5098
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
5099
  hypothesis_contract=hypothesis_contract,
5100
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
 
5117
  type(exc).__name__,
5118
  )
5119
  fallback = self._deterministic_working_front_matter(
5120
+ mode, reader_topic_en, reader_keywords_prompt, data_metadata, hypothesis_ledger=hypothesis_ledger
5121
  )
5122
  candidate_abstract = str(fallback.get("abstract") or "").strip()
5123
  candidate_keywords = list(fallback.get("keywords") or [])
5124
  if not candidate_abstract or not candidate_keywords:
5125
  fallback = self._deterministic_working_front_matter(
5126
+ mode, reader_topic_en, reader_keywords_prompt, data_metadata, hypothesis_ledger=hypothesis_ledger
5127
  )
5128
  candidate_abstract = str(fallback.get("abstract") or "").strip()
5129
  candidate_keywords = list(fallback.get("keywords") or [])
 
5145
  logging.warning("Adaptive resumed Working Abstract accepted with residual diagnostics | count=%s", len(residual_front_rows))
5146
  if not candidate_abstract:
5147
  fallback = self._deterministic_working_front_matter(
5148
+ mode, reader_topic_en, reader_keywords_prompt, data_metadata, hypothesis_ledger=hypothesis_ledger
5149
  )
5150
  candidate_abstract = str(fallback.get("abstract") or "").strip()
5151
  candidate_keywords = list(fallback.get("keywords") or candidate_keywords)
 
5348
  if not working_abstract and not state.get("english_finalized"):
5349
  try:
5350
  working_front = agent.generate_working_front_matter(
5351
+ reader_topic_en, reader_keywords_prompt, research_logic_contract, synthesis_result,
5352
  data_evidence=planning_data_md, mode=mode,
5353
  hypothesis_contract=hypothesis_contract,
5354
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
 
5363
  type(exc).__name__,
5364
  )
5365
  working_front = self._deterministic_working_front_matter(
5366
+ mode, reader_topic_en, reader_keywords_prompt, data_metadata, hypothesis_ledger=hypothesis_ledger
5367
  )
5368
  quality_diagnostics.append({
5369
  "scope": "delivery_first_working_front_matter_fallback",
 
5381
  )
5382
  if not working_abstract or not working_keywords:
5383
  working_front = self._deterministic_working_front_matter(
5384
+ mode, reader_topic_en, reader_keywords_prompt, data_metadata, hypothesis_ledger=hypothesis_ledger
5385
  )
5386
  working_abstract = str(working_front.get("abstract") or "").strip()
5387
  working_keywords = list(working_front.get("keywords") or [])
 
5402
  feedback = self._section_retry_feedback(front_fact_rows)
5403
  try:
5404
  working_front = agent.generate_working_front_matter(
5405
+ reader_topic_en, reader_keywords_prompt, research_logic_contract, synthesis_result,
5406
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
5407
  hypothesis_contract=hypothesis_contract,
5408
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
 
5448
  })
5449
  if not working_abstract or not working_keywords:
5450
  working_front = self._deterministic_working_front_matter(
5451
+ mode, reader_topic_en, reader_keywords_prompt, data_metadata, hypothesis_ledger=hypothesis_ledger
5452
  )
5453
  working_abstract = str(working_front.get("abstract") or "").strip()
5454
  working_keywords = list(working_front.get("keywords") or [])
 
6600
  self._progress(92, "final_front_matter")
6601
  try:
6602
  final_front = agent.generate_final_front_matter(
6603
+ reader_topic_en, reader_keywords_prompt, body_only, data_evidence=user_data_md, mode=mode,
6604
  hypothesis_contract=hypothesis_contract,
6605
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
6606
  keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
 
6612
  )
6613
  final_front = {
6614
  "abstract": self._deterministic_final_abstract(
6615
+ mode, reader_topic_en or source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6616
  ),
6617
  "keywords": self._canonical_quantitative_front_matter_keywords(
6618
+ mode, list(reader_keywords_en), data_metadata
6619
  ) or self._front_matter_keyword_fallback_terms(data_metadata),
6620
  }
6621
  self._record_adaptive_completion_residual(
 
6634
  )
6635
  if not final_abstract or not final_keywords:
6636
  final_abstract = final_abstract or self._deterministic_final_abstract(
6637
+ mode, reader_topic_en or source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6638
  )
6639
  final_keywords = final_keywords or self._canonical_quantitative_front_matter_keywords(
6640
+ mode, list(reader_keywords_en), data_metadata
6641
  ) or self._front_matter_keyword_fallback_terms(data_metadata)
6642
  self._record_adaptive_completion_residual(
6643
  quality_notes, [{
 
6665
  )
6666
  try:
6667
  final_front = agent.generate_final_front_matter(
6668
+ reader_topic_en, reader_keywords_prompt, body_only, data_evidence=user_data_md, mode=mode,
6669
  validation_feedback=feedback, hypothesis_contract=hypothesis_contract,
6670
  authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
6671
  keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
 
6677
  )
6678
  final_front = {
6679
  "abstract": self._deterministic_final_abstract(
6680
+ mode, reader_topic_en or source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6681
  ),
6682
  "keywords": final_keywords or self._canonical_quantitative_front_matter_keywords(
6683
+ mode, list(reader_keywords_en), data_metadata
6684
  ) or self._front_matter_keyword_fallback_terms(data_metadata),
6685
  }
6686
  final_abstract = str(final_front.get("abstract") or "").strip()
 
6693
  )
6694
  if not final_abstract or not final_keywords:
6695
  final_abstract = final_abstract or self._deterministic_final_abstract(
6696
+ mode, reader_topic_en or source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6697
  )
6698
  final_keywords = final_keywords or self._canonical_quantitative_front_matter_keywords(
6699
+ mode, list(reader_keywords_en), data_metadata
6700
  ) or self._front_matter_keyword_fallback_terms(data_metadata)
6701
  self._record_adaptive_completion_residual(
6702
  quality_notes, [{
 
6747
  )
6748
  if residual_final_rows:
6749
  deterministic_abstract = self._deterministic_final_abstract(
6750
+ mode, reader_topic_en or source_topic or topic, data_metadata, hypothesis_ledger=hypothesis_ledger
6751
  )
6752
  hard_rows = self._study_fact_violations(
6753
  mode, agent, quant_engine, deterministic_abstract, data_metadata, section_name="Final Abstract"
 
6776
  "examples":final_front_fact_rows[:3],
6777
  })
6778
  state.pop("final_abstract_retry_exhausted", None)
6779
+ final_title = str(reader_topic_en or source_topic or topic or "").strip()
6780
  if mode == "amj" and str(data_metadata.get("source_type") or "") == "system_generated":
6781
  semantic_contract = dict(data_metadata.get("front_matter_contract") or {})
6782
  reconciled_front = agent.reconcile_quantitative_reader_front_matter(
6783
  topic=source_topic, keywords=source_keywords, contract=semantic_contract,
6784
+ manuscript=body_only, reader_title=reader_topic_en, reader_keywords=reader_keywords_en,
6785
  )
6786
  candidate_title = re.sub(r"\s+", " ", str(reconciled_front.get("title") or "")).strip()
6787
  candidate_keywords = self._canonical_quantitative_front_matter_keywords(
 
6800
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
6801
  full_draft_en = self._replace_title(full_draft_en, final_title)
6802
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
6803
+ agent.validate_reader_facing_english_projection(
6804
+ {"title": final_title, "keywords": final_keywords},
6805
+ expected_keyword_count=(len(reader_keywords_en) if reader_keywords_en else None),
6806
+ )
6807
+ final_abstract, language_rebuilt = self._delivery_first_final_abstract_language_guard(
6808
+ mode=mode, agent=agent, quant_engine=quant_engine, final_abstract=final_abstract,
6809
+ reader_topic_en=reader_topic_en, final_title=final_title, data_metadata=data_metadata,
6810
+ hypothesis_ledger=hypothesis_ledger, quality_notes=quality_notes,
6811
+ )
6812
+ if language_rebuilt:
6813
+ full_draft_en = replace_abstract(full_draft_en, final_abstract)
6814
  if mode == "amj" and hypothesis_ledger:
6815
  post_front_hypothesis_rows = self._hypothesis_contract_violations(
6816
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
core/pipeline_agent.py CHANGED
@@ -1640,6 +1640,117 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1640
  return cleaned
1641
 
1642
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1643
  @staticmethod
1644
  def _front_matter_has_citation(value):
1645
  text = str(value or '')
@@ -1679,6 +1790,8 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1679
  word_count = cls._working_abstract_word_count(text)
1680
  if cls._front_matter_has_citation(text):
1681
  raise DeepSeekRequestError('Working Abstract must not contain citations.')
 
 
1682
  forbidden = (
1683
  ('RESULT_ASSERTION', r'\bwe\s+(?:find|found|show|demonstrate|observe|establish)\b'),
1684
  ('RESULT_ASSERTION', r'\b(?:results?|findings?)\s+(?:show|indicate|reveal|demonstrate|confirm|support)\b'),
@@ -1875,24 +1988,28 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1875
  return issues
1876
 
1877
 
1878
- def reconcile_quantitative_reader_front_matter(self, *, topic, keywords, contract, manuscript=""):
1879
- """Preserve R5 user intent while retaining legacy contract reconciliation.
 
 
1880
 
1881
- Contracts that carry ``user_research_intent`` are authoritative: the
1882
- finalizer must not repair an upstream design mismatch by silently
1883
- renaming the user study. Older contracts without that explicit R5
1884
- authority retain the pre-R5 bounded reconciliation path so historical
1885
- checkpoints and compatibility behavior remain stable.
1886
  """
1887
  record = dict(contract or {})
1888
  intent = dict(record.get("user_research_intent") or {})
1889
  policy = keyword_policy_from_contract(record, fallback_keywords=keywords)
1890
  source_topic = str(intent.get("source_topic") or record.get("source_topic") or topic or "").strip()
1891
  source_keywords = list(intent.get("source_keywords") or [])
 
 
1892
  if str(policy.get("authority_source") or "").upper() == "USER_EXPLICIT" and source_keywords:
 
1893
  return {
1894
- "title": source_topic,
1895
- "keywords": apply_keyword_policy(source_keywords, policy, authoritative=source_keywords),
1896
  }
1897
 
1898
  # No explicit user keyword authority: retain user-authoritative title when
@@ -1910,7 +2027,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1910
  logging.warning("Front-matter semantic contract missing frozen constructs; using degraded fallback.")
1911
  fallback = self._quantitative_front_matter_fallback(record)
1912
  if intent and source_topic:
1913
- fallback["title"] = source_topic
1914
  return fallback
1915
 
1916
  source_keywords = list(record.get("source_keywords") or [])
@@ -1999,7 +2116,7 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
1999
  extra = [str(item).strip() for item in ((critic or {}).get("unmeasured_focal_constructs") or []) if str(item).strip()]
2000
  missing = [str(item).strip() for item in ((critic or {}).get("missing_frozen_constructs") or []) if str(item).strip()]
2001
  if not invalid and not extra and not missing:
2002
- resolved_title = source_topic if intent else re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip()
2003
  return {
2004
  "title": resolved_title,
2005
  "keywords": apply_keyword_policy(candidate.get("keywords") or [], policy),
@@ -2013,7 +2130,11 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
2013
 
2014
  fallback = self._quantitative_front_matter_fallback(record)
2015
  if intent and source_topic:
2016
- fallback["title"] = source_topic
 
 
 
 
2017
  logging.warning("Adaptive front-matter reconciliation exhausted; using deterministic degraded fallback instead of failing manuscript generation.")
2018
  return fallback
2019
 
@@ -2028,7 +2149,7 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
2028
  prompt = f"""Create WORKING front matter for an empirical {str(mode).upper()}-oriented manuscript before body drafting.
2029
  Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
2030
  Aim for approximately 120-180 English words, have no citations, and function only as a continuity anchor. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor over mechanical compliance. It may state the research problem, theoretical tension, focal concepts, question/hypothesis architecture, design or method class, and intended contribution domain. It MUST NOT state coefficients, p-values, fit statistics, sample statistics unless already indispensable to define the design, supported/unsupported hypotheses, result direction, substantive findings, or a conclusion that has not yet been frozen. Do not predict findings.
2031
- Preserve the user keyword identities and research scope. Do not silently replace, delete, or reinterpret an explicit construct/relation because direct literature support is sparse. The user-supplied keyword list is authoritative for reader-facing keyword identity; wording changes belong only to target-language translation.
2032
  TOPIC: {topic}
2033
  USER KEYWORDS: {keywords}
2034
  <PROVISIONAL_RESEARCH_LOGIC>
@@ -2065,7 +2186,7 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2065
  prompt = f"""Write FINAL English front matter for this completed {str(mode).upper()} manuscript.
2066
  Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
2067
  Aim for a concise Abstract of roughly 120-200 words, use no citations, and accurately state the research problem, validated design, main frozen finding(s), and contribution. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Do not infer beyond the frozen evidence or completed manuscript.
2068
- Preserve the user keyword identities and research scope. Do not silently replace, delete, or reinterpret an explicit construct/relation because direct literature support is sparse. The user-supplied keyword list is authoritative for reader-facing keyword identity; wording changes belong only to target-language translation.
2069
  TOPIC: {topic}
2070
  USER KEYWORDS: {keywords}
2071
  <FROZEN_RESEARCH_EVIDENCE>
@@ -2085,6 +2206,8 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2085
  abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', str(parsed.get('abstract') or '')).strip()
2086
  if self._front_matter_has_citation(abstract):
2087
  raise DeepSeekRequestError('Final Abstract must not contain citations.')
 
 
2088
  meta_findings = self.inspect_authorial_ai_meta(abstract)
2089
  if meta_findings:
2090
  raise DeepSeekRequestError('Final Abstract contains high-confidence authorial AI-writing meta-commentary.')
 
1640
  return cleaned
1641
 
1642
 
1643
+ @staticmethod
1644
+ def _english_reader_surface_has_foreign_script(value):
1645
+ """Return True only for scripts that cannot remain in an English reader-facing surface.
1646
+
1647
+ User intent may be entered in Chinese, Russian, or Kazakh, but the English
1648
+ manuscript projection must not leak Han or Cyrillic source text. Latin
1649
+ acronyms, numerals, punctuation, and ordinary English remain untouched.
1650
+ """
1651
+ return bool(re.search(r'[\u3400-\u9fff\u3040-\u30ff\u0400-\u052f]', str(value or '')))
1652
+
1653
+ @classmethod
1654
+ def validate_reader_facing_english_projection(cls, projection, *, expected_keyword_count=None):
1655
+ """Validate a language projection without changing the user's semantic authority."""
1656
+ record = dict(projection or {})
1657
+ title = str(record.get('title') or record.get('english_title') or '').strip()
1658
+ raw_keywords = record.get('keywords')
1659
+ if raw_keywords is None:
1660
+ raw_keywords = record.get('english_keywords')
1661
+ keywords = [str(item).strip() for item in (raw_keywords or []) if str(item).strip()]
1662
+ if not title:
1663
+ raise DeepSeekRequestError('English reader-facing title is empty.')
1664
+ if expected_keyword_count is not None and len(keywords) != int(expected_keyword_count):
1665
+ raise DeepSeekRequestError(
1666
+ f'English reader-facing keyword cardinality changed: expected={int(expected_keyword_count)} observed={len(keywords)}.'
1667
+ )
1668
+ combined = ' '.join([title] + keywords)
1669
+ if cls._english_reader_surface_has_foreign_script(combined):
1670
+ raise DeepSeekRequestError('English reader-facing title/keywords contain untranslated Han or Cyrillic source text.')
1671
+ return {'title': title, 'keywords': keywords}
1672
+
1673
+ def normalize_reader_facing_front_matter_to_english(self, topic, keywords):
1674
+ """Create a faithful English language projection of user Title/Keywords.
1675
+
1676
+ The original user strings remain semantic authority in the study contract.
1677
+ This method is presentation-only: English input is passed through without a
1678
+ model call; non-English/mixed-script input is translated, never optimized.
1679
+ Keyword cardinality and order are invariant.
1680
+ """
1681
+ source_title = str(topic or '').strip()
1682
+ source_keywords = parse_keyword_terms(keywords, explicit_user=True)
1683
+ if not source_title:
1684
+ raise DeepSeekRequestError('User research title is empty before English language projection.')
1685
+
1686
+ source_surface = ' '.join([source_title] + source_keywords)
1687
+ if not self._english_reader_surface_has_foreign_script(source_surface):
1688
+ return {
1689
+ 'title': source_title,
1690
+ 'keywords': list(source_keywords),
1691
+ 'translation_applied': False,
1692
+ }
1693
+
1694
+ indexed_keywords = [
1695
+ {'index': index, 'text': value}
1696
+ for index, value in enumerate(source_keywords, start=1)
1697
+ ]
1698
+ payload = {'title': source_title, 'keywords': indexed_keywords}
1699
+ base_prompt = f"""Translate ONLY the supplied user research Title and Keywords into academic English.
1700
+ Return JSON only: {{"title":"...","keywords":["..."]}}.
1701
+
1702
+ LANGUAGE-AUTHORITY CONTRACT:
1703
+ 1. This is translation, not title optimization, rewriting, topic expansion, or keyword generation.
1704
+ 2. Preserve the complete research meaning: focal constructs, direction/relationship wording, mediator/moderator roles, population/context, method qualifiers, acronyms, numbers, and scope.
1705
+ 3. Do not add, delete, merge, split, reorder, generalize, specialize, or reinterpret any keyword. Return exactly {len(source_keywords)} keywords in the supplied order.
1706
+ 4. Preserve existing English technical identities and acronyms. Translate/transliterate Han or Cyrillic reader-facing text so the returned Title/Keywords contain no Chinese, Russian, or Kazakh script.
1707
+ 5. Do not mention the translation process and do not wrap JSON in Markdown.
1708
+
1709
+ <SOURCE_USER_FRONT_MATTER>
1710
+ {json.dumps(payload, ensure_ascii=False)}
1711
+ </SOURCE_USER_FRONT_MATTER>"""
1712
+ current_prompt = base_prompt
1713
+ last_error = None
1714
+ for local_attempt in range(2):
1715
+ response = self.call_deepseek(
1716
+ 'You are a precise academic translator. Preserve research semantics exactly and change only language surface.',
1717
+ current_prompt,
1718
+ max_tokens=max(700, min(1600, 500 + len(source_keywords) * 90)),
1719
+ temp=0.0,
1720
+ model_role='writing',
1721
+ thinking=False,
1722
+ allow_fallback=False,
1723
+ response_format={'type': 'json_object'},
1724
+ logical_node='front_matter_writing',
1725
+ )
1726
+ try:
1727
+ parsed = self._extract_json_from_text(response)
1728
+ if not isinstance(parsed, dict):
1729
+ raise DeepSeekRequestError('English front-matter language projection was not valid JSON.')
1730
+ candidate = self.validate_reader_facing_english_projection(
1731
+ parsed, expected_keyword_count=len(source_keywords)
1732
+ )
1733
+ return {
1734
+ 'title': candidate['title'],
1735
+ 'keywords': candidate['keywords'],
1736
+ 'translation_applied': True,
1737
+ }
1738
+ except DeepSeekRequestError as exc:
1739
+ last_error = exc
1740
+ if local_attempt >= 1:
1741
+ raise
1742
+ logging.warning(
1743
+ 'English front-matter language projection retry | attempt=%s/2 | reason=%s',
1744
+ local_attempt + 1, self._safe_source_error(exc),
1745
+ )
1746
+ current_prompt = base_prompt + (
1747
+ '\n\nLOCAL VALIDATION RETRY: The prior response violated the language/cardinality contract. '
1748
+ 'Return a fresh complete JSON object only. Translate the same Title and the same indexed Keywords '
1749
+ 'faithfully into English, preserve keyword order/count exactly, and remove all Han/Cyrillic source text. '
1750
+ 'Do not optimize or rewrite the research intent.'
1751
+ )
1752
+ raise DeepSeekRequestError(f'English front-matter language projection failed: {last_error}')
1753
+
1754
  @staticmethod
1755
  def _front_matter_has_citation(value):
1756
  text = str(value or '')
 
1790
  word_count = cls._working_abstract_word_count(text)
1791
  if cls._front_matter_has_citation(text):
1792
  raise DeepSeekRequestError('Working Abstract must not contain citations.')
1793
+ if cls._english_reader_surface_has_foreign_script(text):
1794
+ raise DeepSeekRequestError('Working Abstract contains untranslated Han or Cyrillic source text.')
1795
  forbidden = (
1796
  ('RESULT_ASSERTION', r'\bwe\s+(?:find|found|show|demonstrate|observe|establish)\b'),
1797
  ('RESULT_ASSERTION', r'\b(?:results?|findings?)\s+(?:show|indicate|reveal|demonstrate|confirm|support)\b'),
 
1988
  return issues
1989
 
1990
 
1991
+ def reconcile_quantitative_reader_front_matter(
1992
+ self, *, topic, keywords, contract, manuscript="", reader_title=None, reader_keywords=None
1993
+ ):
1994
+ """Preserve source semantic authority while allowing a faithful English surface.
1995
 
1996
+ ``user_research_intent`` remains the immutable semantic authority. When
1997
+ ``reader_title``/``reader_keywords`` are supplied they are a language-only
1998
+ English projection of that same authority; they may change script/language
1999
+ but not constructs, relations, scope, order, or keyword cardinality.
 
2000
  """
2001
  record = dict(contract or {})
2002
  intent = dict(record.get("user_research_intent") or {})
2003
  policy = keyword_policy_from_contract(record, fallback_keywords=keywords)
2004
  source_topic = str(intent.get("source_topic") or record.get("source_topic") or topic or "").strip()
2005
  source_keywords = list(intent.get("source_keywords") or [])
2006
+ reader_source_topic = str(reader_title or source_topic or topic or "").strip()
2007
+ reader_source_keywords = parse_keyword_terms(reader_keywords, explicit_user=True) if reader_keywords is not None else list(source_keywords)
2008
  if str(policy.get("authority_source") or "").upper() == "USER_EXPLICIT" and source_keywords:
2009
+ authoritative_reader = reader_source_keywords or source_keywords
2010
  return {
2011
+ "title": reader_source_topic,
2012
+ "keywords": apply_keyword_policy(authoritative_reader, policy, authoritative=authoritative_reader),
2013
  }
2014
 
2015
  # No explicit user keyword authority: retain user-authoritative title when
 
2027
  logging.warning("Front-matter semantic contract missing frozen constructs; using degraded fallback.")
2028
  fallback = self._quantitative_front_matter_fallback(record)
2029
  if intent and source_topic:
2030
+ fallback["title"] = reader_source_topic
2031
  return fallback
2032
 
2033
  source_keywords = list(record.get("source_keywords") or [])
 
2116
  extra = [str(item).strip() for item in ((critic or {}).get("unmeasured_focal_constructs") or []) if str(item).strip()]
2117
  missing = [str(item).strip() for item in ((critic or {}).get("missing_frozen_constructs") or []) if str(item).strip()]
2118
  if not invalid and not extra and not missing:
2119
+ resolved_title = reader_source_topic if intent else re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip()
2120
  return {
2121
  "title": resolved_title,
2122
  "keywords": apply_keyword_policy(candidate.get("keywords") or [], policy),
 
2130
 
2131
  fallback = self._quantitative_front_matter_fallback(record)
2132
  if intent and source_topic:
2133
+ fallback["title"] = reader_source_topic
2134
+ if str(policy.get("authority_source") or "").upper() == "USER_EXPLICIT" and reader_source_keywords:
2135
+ fallback["keywords"] = apply_keyword_policy(
2136
+ reader_source_keywords, policy, authoritative=reader_source_keywords
2137
+ )
2138
  logging.warning("Adaptive front-matter reconciliation exhausted; using deterministic degraded fallback instead of failing manuscript generation.")
2139
  return fallback
2140
 
 
2149
  prompt = f"""Create WORKING front matter for an empirical {str(mode).upper()}-oriented manuscript before body drafting.
2150
  Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
2151
  Aim for approximately 120-180 English words, have no citations, and function only as a continuity anchor. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor over mechanical compliance. It may state the research problem, theoretical tension, focal concepts, question/hypothesis architecture, design or method class, and intended contribution domain. It MUST NOT state coefficients, p-values, fit statistics, sample statistics unless already indispensable to define the design, supported/unsupported hypotheses, result direction, substantive findings, or a conclusion that has not yet been frozen. Do not predict findings.
2152
+ Preserve the supplied English keyword identities and research scope. They are a faithful language projection of the user input. Do not silently replace, delete, reorder, or reinterpret an explicit construct/relation because direct literature support is sparse. Do not translate them back into the source input language.
2153
  TOPIC: {topic}
2154
  USER KEYWORDS: {keywords}
2155
  <PROVISIONAL_RESEARCH_LOGIC>
 
2186
  prompt = f"""Write FINAL English front matter for this completed {str(mode).upper()} manuscript.
2187
  Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
2188
  Aim for a concise Abstract of roughly 120-200 words, use no citations, and accurately state the research problem, validated design, main frozen finding(s), and contribution. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Do not infer beyond the frozen evidence or completed manuscript.
2189
+ Preserve the supplied English keyword identities and research scope. They are a faithful language projection of the user input. Do not silently replace, delete, reorder, or reinterpret an explicit construct/relation because direct literature support is sparse. Do not translate them back into the source input language.
2190
  TOPIC: {topic}
2191
  USER KEYWORDS: {keywords}
2192
  <FROZEN_RESEARCH_EVIDENCE>
 
2206
  abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', str(parsed.get('abstract') or '')).strip()
2207
  if self._front_matter_has_citation(abstract):
2208
  raise DeepSeekRequestError('Final Abstract must not contain citations.')
2209
+ if self._english_reader_surface_has_foreign_script(abstract):
2210
+ raise DeepSeekRequestError('Final Abstract contains untranslated Han or Cyrillic source text.')
2211
  meta_findings = self.inspect_authorial_ai_meta(abstract)
2212
  if meta_findings:
2213
  raise DeepSeekRequestError('Final Abstract contains high-confidence authorial AI-writing meta-commentary.')
core/production_guard.py CHANGED
@@ -848,6 +848,40 @@ def _aigc_visible_label_for_docx(filename: str) -> str:
848
  return visible_label_for_language(filename)
849
 
850
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
851
  def _validate_zip_bytes(
852
  zip_bytes: bytes,
853
  *,
@@ -859,6 +893,7 @@ def _validate_zip_bytes(
859
  quantitative_manifest: dict[str, Any] | None,
860
  qualitative_mode: bool,
861
  safe_mode: bool,
 
862
  ) -> None:
863
  try:
864
  with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive:
@@ -874,12 +909,13 @@ def _validate_zip_bytes(
874
  for name in (english_filename, translated_filename):
875
  archived_docx = archive.read(name)
876
  validate_docx_bytes(archived_docx)
877
- try:
878
- validate_aigc_output_label_resilient(
879
- archived_docx, expected_visible_label=_aigc_visible_label_for_docx(name)
880
- )
881
- except Exception as exc:
882
- raise OutputValidationError(f"Archived DOCX AI output label invalid: {exc}") from exc
 
883
  if quantitative_manifest:
884
  archived_markdown = english_markdown if name == english_filename else translated_markdown
885
  validate_quantitative_table_contract(
@@ -920,22 +956,21 @@ def _validated_package_attempt(
920
  translated_docx = _render_delivery_docx(
921
  translated_markdown, qualitative_mode=qualitative_mode, safe_mode=safe_mode,
922
  )
923
- # E3-B: label only the already-rendered delivery artifact. Manuscript
924
- # Markdown, generation, translation and academic-content gates stay frozen.
925
- try:
926
- english_label = _aigc_visible_label_for_docx(english_filename)
927
- translated_label = _aigc_visible_label_for_docx(translated_filename)
928
- english_docx = ensure_aigc_output_label(english_docx, visible_label=english_label)
929
- translated_docx = ensure_aigc_output_label(translated_docx, visible_label=translated_label)
930
- except Exception as exc:
931
- raise OutputValidationError(f"Post-generation AI output labeling failed: {exc}") from exc
932
  validate_docx_bytes(english_docx)
933
  validate_docx_bytes(translated_docx)
934
- try:
935
- validate_aigc_output_label_resilient(english_docx, expected_visible_label=english_label)
936
- validate_aigc_output_label_resilient(translated_docx, expected_visible_label=translated_label)
937
- except Exception as exc:
938
- raise OutputValidationError(f"Post-generation AI output label validation failed: {exc}") from exc
939
 
940
  quantitative_manifest = (
941
  (data_metadata or {}).get("quantitative_table_manifest")
@@ -955,6 +990,9 @@ def _validated_package_attempt(
955
 
956
  appendix_prefix = "Appendix/" if organize_appendix else ""
957
  notes = list(quality_notes or [])
 
 
 
958
  if safe_mode:
959
  notes.append({
960
  "severity": "ADVISORY",
@@ -995,6 +1033,7 @@ def _validated_package_attempt(
995
  quantitative_manifest=quantitative_manifest,
996
  qualitative_mode=qualitative_mode,
997
  safe_mode=safe_mode,
 
998
  )
999
  return PackageResult(
1000
  zip_bytes=zip_bytes,
@@ -1118,37 +1157,36 @@ def build_recovery_package(
1118
  raise OutputValidationError("Recovery package requires a materially complete frozen English manuscript.")
1119
  try:
1120
  english_docx = _render_delivery_docx(english, qualitative_mode=False, safe_mode=True)
1121
- english_label = _aigc_visible_label_for_docx(english_filename)
1122
- english_docx = ensure_aigc_output_label(english_docx, visible_label=english_label)
1123
  validate_docx_bytes(english_docx)
1124
- validate_aigc_output_label_resilient(english_docx, expected_visible_label=english_label)
1125
  except Exception:
1126
  english_docx = _plain_recovery_docx(english)
1127
- english_label = _aigc_visible_label_for_docx(english_filename)
1128
- english_docx = ensure_aigc_output_label(english_docx, visible_label=english_label)
1129
  validate_docx_bytes(english_docx)
1130
- validate_aigc_output_label_resilient(english_docx, expected_visible_label=english_label)
 
 
1131
 
1132
  entries: dict[str, bytes] = {english_filename: english_docx}
 
 
1133
  target = str(translated_markdown or "").strip()
1134
  if len(target) >= 800:
1135
  try:
1136
  target_docx = _render_delivery_docx(target, qualitative_mode=False, safe_mode=True)
1137
- target_label = _aigc_visible_label_for_docx(translated_filename)
1138
- target_docx = ensure_aigc_output_label(target_docx, visible_label=target_label)
1139
  validate_docx_bytes(target_docx)
1140
- validate_aigc_output_label_resilient(target_docx, expected_visible_label=target_label)
1141
  except Exception:
1142
  try:
1143
  target_docx = _plain_recovery_docx(target)
1144
- target_label = _aigc_visible_label_for_docx(translated_filename)
1145
- target_docx = ensure_aigc_output_label(target_docx, visible_label=target_label)
1146
  validate_docx_bytes(target_docx)
1147
- validate_aigc_output_label_resilient(target_docx, expected_visible_label=target_label)
1148
  except Exception:
1149
  target_docx = b""
1150
  if target_docx:
 
 
 
1151
  entries[translated_filename] = target_docx
 
 
 
1152
 
1153
  note = (
1154
  "SmartPaper Recovery Package\n\n"
@@ -1157,6 +1195,12 @@ def build_recovery_package(
1157
  "This recovery artifact must not be represented as a completed paid paper.\n\n"
1158
  f"Terminal detail: {str(error_detail or '')[:1000]}\n"
1159
  )
 
 
 
 
 
 
1160
  entries["RECOVERY_NOTES.txt"] = note.encode("utf-8")
1161
  manifest = _manifest(entries)
1162
  entries["FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8")
@@ -1171,15 +1215,17 @@ def build_recovery_package(
1171
  raise OutputValidationError(f"Recovery ZIP integrity failed at {bad}.")
1172
  archived_english = archive.read(english_filename)
1173
  validate_docx_bytes(archived_english)
1174
- validate_aigc_output_label_resilient(
1175
- archived_english, expected_visible_label=_aigc_visible_label_for_docx(english_filename)
1176
- )
 
1177
  if translated_filename in archive.namelist():
1178
  archived_target = archive.read(translated_filename)
1179
  validate_docx_bytes(archived_target)
1180
- validate_aigc_output_label_resilient(
1181
- archived_target, expected_visible_label=_aigc_visible_label_for_docx(translated_filename)
1182
- )
 
1183
  return PackageResult(
1184
  zip_bytes=zip_bytes,
1185
  file_names=tuple(sorted(entries)),
 
848
  return visible_label_for_language(filename)
849
 
850
 
851
+ def _apply_aigc_label_delivery_first(docx_bytes: bytes, filename: str):
852
+ """Apply/validate the AI-output label without making it a delivery blocker.
853
+
854
+ The label subsystem already has two independent writer/validator paths. If
855
+ both mechanisms fail unexpectedly, P0 Delivery-First keeps the already
856
+ validated DOCX bytes and records an advisory. Academic manuscript text and
857
+ P1 result/reference identity are never changed by this fallback.
858
+ """
859
+ original = bytes(docx_bytes or b"")
860
+ validate_docx_bytes(original)
861
+ label = _aigc_visible_label_for_docx(filename)
862
+ try:
863
+ labeled = ensure_aigc_output_label(original, visible_label=label)
864
+ validate_docx_bytes(labeled)
865
+ validate_aigc_output_label_resilient(labeled, expected_visible_label=label)
866
+ return labeled, label, True, None
867
+ except Exception as exc:
868
+ # Never keep a partially-mutated artifact after label failure. Reuse the
869
+ # pre-label DOCX only after independently proving it still opens.
870
+ validate_docx_bytes(original)
871
+ warning = {
872
+ "severity": "ADVISORY",
873
+ "section": "Output package",
874
+ "issue_type": "aigc_output_label_best_effort_fallback",
875
+ "detail": (
876
+ "AI output labeling could not be completed after the independent label "
877
+ f"fallbacks ({type(exc).__name__}). The validated pre-label DOCX was preserved "
878
+ "so generation/download was not interrupted."
879
+ ),
880
+ "user_action": "Add/verify the required AI-output label before external distribution if this warning appears.",
881
+ }
882
+ return original, label, False, warning
883
+
884
+
885
  def _validate_zip_bytes(
886
  zip_bytes: bytes,
887
  *,
 
893
  quantitative_manifest: dict[str, Any] | None,
894
  qualitative_mode: bool,
895
  safe_mode: bool,
896
+ aigc_label_status: dict[str, bool] | None = None,
897
  ) -> None:
898
  try:
899
  with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive:
 
909
  for name in (english_filename, translated_filename):
910
  archived_docx = archive.read(name)
911
  validate_docx_bytes(archived_docx)
912
+ if (aigc_label_status or {}).get(name, True):
913
+ try:
914
+ validate_aigc_output_label_resilient(
915
+ archived_docx, expected_visible_label=_aigc_visible_label_for_docx(name)
916
+ )
917
+ except Exception as exc:
918
+ raise OutputValidationError(f"Archived DOCX AI output label invalid: {exc}") from exc
919
  if quantitative_manifest:
920
  archived_markdown = english_markdown if name == english_filename else translated_markdown
921
  validate_quantitative_table_contract(
 
956
  translated_docx = _render_delivery_docx(
957
  translated_markdown, qualitative_mode=qualitative_mode, safe_mode=safe_mode,
958
  )
959
+ # E3-B/E3-C + Delivery-First hardening: label only the already-rendered
960
+ # delivery artifact. A total label-subsystem failure is advisory rather than
961
+ # a reason to destroy an otherwise valid downloadable paper.
962
+ english_docx, english_label, english_labeled, english_label_warning = _apply_aigc_label_delivery_first(
963
+ english_docx, english_filename
964
+ )
965
+ translated_docx, translated_label, translated_labeled, translated_label_warning = _apply_aigc_label_delivery_first(
966
+ translated_docx, translated_filename
967
+ )
968
  validate_docx_bytes(english_docx)
969
  validate_docx_bytes(translated_docx)
970
+ aigc_label_status = {
971
+ english_filename: bool(english_labeled),
972
+ translated_filename: bool(translated_labeled),
973
+ }
 
974
 
975
  quantitative_manifest = (
976
  (data_metadata or {}).get("quantitative_table_manifest")
 
990
 
991
  appendix_prefix = "Appendix/" if organize_appendix else ""
992
  notes = list(quality_notes or [])
993
+ for label_warning in (english_label_warning, translated_label_warning):
994
+ if label_warning:
995
+ notes.append(label_warning)
996
  if safe_mode:
997
  notes.append({
998
  "severity": "ADVISORY",
 
1033
  quantitative_manifest=quantitative_manifest,
1034
  qualitative_mode=qualitative_mode,
1035
  safe_mode=safe_mode,
1036
+ aigc_label_status=aigc_label_status,
1037
  )
1038
  return PackageResult(
1039
  zip_bytes=zip_bytes,
 
1157
  raise OutputValidationError("Recovery package requires a materially complete frozen English manuscript.")
1158
  try:
1159
  english_docx = _render_delivery_docx(english, qualitative_mode=False, safe_mode=True)
 
 
1160
  validate_docx_bytes(english_docx)
 
1161
  except Exception:
1162
  english_docx = _plain_recovery_docx(english)
 
 
1163
  validate_docx_bytes(english_docx)
1164
+ english_docx, english_label, english_labeled, english_label_warning = _apply_aigc_label_delivery_first(
1165
+ english_docx, english_filename
1166
+ )
1167
 
1168
  entries: dict[str, bytes] = {english_filename: english_docx}
1169
+ recovery_label_status = {english_filename: bool(english_labeled)}
1170
+ recovery_label_warnings = [english_label_warning] if english_label_warning else []
1171
  target = str(translated_markdown or "").strip()
1172
  if len(target) >= 800:
1173
  try:
1174
  target_docx = _render_delivery_docx(target, qualitative_mode=False, safe_mode=True)
 
 
1175
  validate_docx_bytes(target_docx)
 
1176
  except Exception:
1177
  try:
1178
  target_docx = _plain_recovery_docx(target)
 
 
1179
  validate_docx_bytes(target_docx)
 
1180
  except Exception:
1181
  target_docx = b""
1182
  if target_docx:
1183
+ target_docx, target_label, target_labeled, target_label_warning = _apply_aigc_label_delivery_first(
1184
+ target_docx, translated_filename
1185
+ )
1186
  entries[translated_filename] = target_docx
1187
+ recovery_label_status[translated_filename] = bool(target_labeled)
1188
+ if target_label_warning:
1189
+ recovery_label_warnings.append(target_label_warning)
1190
 
1191
  note = (
1192
  "SmartPaper Recovery Package\n\n"
 
1195
  "This recovery artifact must not be represented as a completed paid paper.\n\n"
1196
  f"Terminal detail: {str(error_detail or '')[:1000]}\n"
1197
  )
1198
+ if recovery_label_warnings:
1199
+ note += (
1200
+ "\nAI-output labeling warning: the label subsystem failed after independent fallbacks. "
1201
+ "The validated pre-label DOCX was preserved so the recovery download remains usable. "
1202
+ "Add/verify the required AI-output label before external distribution.\n"
1203
+ )
1204
  entries["RECOVERY_NOTES.txt"] = note.encode("utf-8")
1205
  manifest = _manifest(entries)
1206
  entries["FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8")
 
1215
  raise OutputValidationError(f"Recovery ZIP integrity failed at {bad}.")
1216
  archived_english = archive.read(english_filename)
1217
  validate_docx_bytes(archived_english)
1218
+ if recovery_label_status.get(english_filename, True):
1219
+ validate_aigc_output_label_resilient(
1220
+ archived_english, expected_visible_label=_aigc_visible_label_for_docx(english_filename)
1221
+ )
1222
  if translated_filename in archive.namelist():
1223
  archived_target = archive.read(translated_filename)
1224
  validate_docx_bytes(archived_target)
1225
+ if recovery_label_status.get(translated_filename, True):
1226
+ validate_aigc_output_label_resilient(
1227
+ archived_target, expected_visible_label=_aigc_visible_label_for_docx(translated_filename)
1228
+ )
1229
  return PackageResult(
1230
  zip_bytes=zip_bytes,
1231
  file_names=tuple(sorted(entries)),