U1-R2C-G1 Delivery-First Completion

#129
app.py CHANGED
@@ -1333,6 +1333,21 @@ def _render_task_status_once(lang_code: str) -> None:
1333
  st.error(error_message)
1334
  if translation_note:
1335
  st.warning(translation_note)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1336
  elif status == "cancelled":
1337
  detail = str(task.get("safe_error_detail") or "")
1338
  if detail:
 
1333
  st.error(error_message)
1334
  if translation_note:
1335
  st.warning(translation_note)
1336
+ recovery_path = _task_output_path(task)
1337
+ if (
1338
+ recovery_path
1339
+ and recovery_path.is_file()
1340
+ and recovery_path.name.startswith("SmartPaper_Recovery_")
1341
+ and recovery_path.suffix.lower() == ".zip"
1342
+ ):
1343
+ st.warning(tr(lang_code, "recovery_notice"))
1344
+ st.download_button(
1345
+ label=tr(lang_code, "recovery_download"),
1346
+ data=recovery_path.read_bytes(),
1347
+ file_name=recovery_path.name,
1348
+ mime="application/zip",
1349
+ key=f"sp_recovery_download_{task_id}",
1350
+ )
1351
  elif status == "cancelled":
1352
  detail = str(task.get("safe_error_detail") or "")
1353
  if detail:
core/generation_runner.py CHANGED
@@ -28,7 +28,7 @@ from core.checkpoint_store import TaskCheckpointStore
28
  from core.meta_extractor import extract_golden_metadata
29
  from core.model_gateway import resolve_plan_policy_snapshot
30
  from core.pipeline_agent import SSciPipelineAgent, PipelineCancelledError
31
- from core.production_guard import build_validated_package, replace_abstract
32
  from core.qualitative_engine import get_qualitative_engine
33
  from core.quantitative_engine import get_quantitative_engine
34
  from core.rag_engine import initialize_rag
@@ -91,6 +91,20 @@ def _clean_markdown_headings(text):
91
  return re.sub(r"(?m)^#+\s+(.*)$", r"**\1**", str(text or ""))
92
 
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _canonical_request_hash(request_data: dict[str, Any]) -> str:
95
  material = dict(request_data or {})
96
  material.pop("request_hash", None)
@@ -1364,6 +1378,268 @@ class GenerationRunner:
1364
  })
1365
  return current, diagnostics
1366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1367
  @classmethod
1368
  def _final_abstract_finding_violations(
1369
  cls, mode: str, text: str, data_metadata: dict, *, hypothesis_ledger: dict | None = None
@@ -2320,6 +2596,55 @@ class GenerationRunner:
2320
  "sha256": cls._section_integrity_hash(section_text),
2321
  }
2322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2323
  @staticmethod
2324
  def _replace_top_level_section(manuscript: str, title: str, replacement: str) -> str:
2325
  value = str(manuscript or "")
@@ -2436,7 +2761,13 @@ class GenerationRunner:
2436
  rendered = re.sub(r"\s+", " ", str(title or "")).strip()
2437
  if not rendered:
2438
  return value
2439
- updated, count = re.subn(r"(?m)^#\s+.+$", f"# {rendered}", value, count=1)
 
 
 
 
 
 
2440
  if count == 1:
2441
  return updated
2442
  logging.warning("Manuscript title heading missing during final reconciliation; reconstructing canonical H1 instead of failing generation.")
@@ -4064,9 +4395,28 @@ class GenerationRunner:
4064
  "but do not invent citations or bibliographic metadata and do not present general model knowledge as retrieved evidence."
4065
  )
4066
  else:
4067
- synthesis_result = self._stream_text(
4068
- agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)
4069
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4070
  agent.record_prewrite_stage("literature_synthesis", prewrite_started, prewrite_calls)
4071
  synthesis_result = re.sub(r"<think>.*?</think>", "", synthesis_result, flags=re.DOTALL).strip()
4072
  state.update({"synthesis_result": synthesis_result, "agent_state": agent.export_state()})
@@ -4077,43 +4427,93 @@ class GenerationRunner:
4077
  self._progress(14, "research_logic")
4078
  prewrite_started = time.monotonic()
4079
  prewrite_calls = agent.ds_calls
4080
- research_logic_response = self._stream_text(
4081
- agent.stream_generate_research_logic(
4082
- topic, keywords, synthesis_result, refs_text,
4083
- data_evidence=planning_data_md, mode=mode,
4084
- hypothesis_contract=hypothesis_contract,
 
 
 
4085
  )
4086
- )
4087
- agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
 
 
 
 
 
 
 
 
 
4088
  research_logic_contract = re.sub(
4089
  r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
4090
  ).strip()
4091
- if hypothesis_ledger:
 
4092
  research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
4093
  research_logic_contract, hypothesis_ledger
4094
  )
4095
  logic_hypothesis_rows = self._hypothesis_contract_violations(
4096
  research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
4097
  )
4098
- if logic_hypothesis_rows:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4099
  raise RuntimeError(
4100
- f"research_logic_hypothesis_contract_invalid:{logic_hypothesis_rows[:3]}"
4101
  )
4102
- if not research_logic_contract:
4103
- raise RuntimeError("research_logic_contract_missing")
 
 
 
 
 
 
 
 
 
4104
  agent.global_memory["research_logic_contract"] = research_logic_contract
4105
 
4106
  self._progress(14, "working_front_matter")
4107
- working_front = agent.generate_working_front_matter(
4108
- topic, keywords, research_logic_contract, synthesis_result,
4109
- data_evidence=planning_data_md, mode=mode,
4110
- hypothesis_contract=hypothesis_contract,
4111
- authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
4112
- keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4113
- fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
4114
- )
 
 
 
 
 
 
 
 
 
 
 
4115
  working_abstract = str(working_front.get("abstract") or "").strip()
4116
- if hypothesis_ledger:
4117
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
4118
  working_abstract, hypothesis_ledger
4119
  )
@@ -4121,7 +4521,22 @@ class GenerationRunner:
4121
  mode, list(working_front.get("keywords") or []), data_metadata
4122
  )
4123
  if not working_abstract or not working_keywords:
4124
- raise RuntimeError("working_front_matter_missing")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4125
  _, front_fact_rows = self._repair_validate_study_fact_contract(
4126
  mode, agent, quant_engine, working_abstract, data_metadata,
4127
  section_name="Working Abstract",
@@ -4136,22 +4551,32 @@ class GenerationRunner:
4136
  "Working Abstract fact validation retry | mode=%s count=%s types=%s",
4137
  mode, len(front_fact_rows), sorted({str(row.get("type") or "") for row in front_fact_rows}),
4138
  )
4139
- working_front = agent.generate_working_front_matter(
4140
- topic, keywords, research_logic_contract, synthesis_result,
4141
- data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
4142
- hypothesis_contract=hypothesis_contract,
4143
- authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
4144
- keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4145
- fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
4146
- )
4147
- working_abstract = str(working_front.get("abstract") or "").strip()
4148
- if hypothesis_ledger:
4149
- working_abstract = self._normalize_hypothesis_ordinal_aliases(
4150
- working_abstract, hypothesis_ledger
 
 
 
 
 
 
 
 
 
 
 
 
 
4151
  )
4152
- working_keywords = self._canonical_quantitative_front_matter_keywords(
4153
- mode, list(working_front.get("keywords") or []), data_metadata
4154
- )
4155
  _, remaining_front_rows = self._repair_validate_study_fact_contract(
4156
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
4157
  )
@@ -4172,8 +4597,23 @@ class GenerationRunner:
4172
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
4173
  "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
4174
  })
4175
- if not working_abstract:
4176
- raise RuntimeError("working_front_matter_empty_after_validation")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4177
  agent.global_memory["working_abstract"] = working_abstract
4178
 
4179
  self._progress(14, "outline_planning")
@@ -4393,6 +4833,7 @@ class GenerationRunner:
4393
  research_logic_contract = str(
4394
  state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
4395
  )
 
4396
  if hypothesis_ledger and research_logic_contract:
4397
  research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
4398
  research_logic_contract, hypothesis_ledger
@@ -4400,11 +4841,33 @@ class GenerationRunner:
4400
  resumed_logic_rows = self._hypothesis_contract_violations(
4401
  research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
4402
  )
4403
- if resumed_logic_rows:
 
 
 
 
 
 
 
 
 
4404
  raise RuntimeError(
4405
- f"research_logic_hypothesis_contract_invalid_on_resume:{resumed_logic_rows[:3]}"
4406
  )
4407
- agent.global_memory["research_logic_contract"] = research_logic_contract
 
 
 
 
 
 
 
 
 
 
 
 
 
4408
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
4409
  citation_minimums = dict(state.get("citation_minimums") or {})
4410
  citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
@@ -4469,22 +4932,41 @@ class GenerationRunner:
4469
  ))
4470
  if resumed_front_rows:
4471
  feedback = self._section_retry_feedback(resumed_front_rows)
4472
- working_front = agent.generate_working_front_matter(
4473
- topic, keywords, research_logic_contract, synthesis_result,
4474
- data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
4475
- hypothesis_contract=hypothesis_contract,
4476
- authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
4477
- keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4478
- fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
4479
- )
4480
- candidate_abstract = str(working_front.get("abstract") or "").strip()
4481
- if hypothesis_ledger:
4482
- candidate_abstract = self._normalize_hypothesis_ordinal_aliases(
4483
- candidate_abstract, hypothesis_ledger
4484
  )
4485
- candidate_keywords = self._canonical_quantitative_front_matter_keywords(
4486
- mode, list(working_front.get("keywords") or []), data_metadata
4487
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4488
  _, remaining = self._repair_validate_study_fact_contract(
4489
  mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
4490
  )
@@ -4501,6 +4983,12 @@ class GenerationRunner:
4501
  )
4502
  if residual_front_rows:
4503
  logging.warning("Adaptive resumed Working Abstract accepted with residual diagnostics | count=%s", len(residual_front_rows))
 
 
 
 
 
 
4504
  working_abstract = candidate_abstract
4505
  working_keywords = candidate_keywords or working_keywords
4506
  state["working_abstract"] = working_abstract
@@ -4698,16 +5186,33 @@ class GenerationRunner:
4698
  # an outline but no Working Abstract yet. Generate it once, checkpoint it,
4699
  # and reuse it thereafter; never regenerate an unchanged anchor on resume.
4700
  if not working_abstract and not state.get("english_finalized"):
4701
- working_front = agent.generate_working_front_matter(
4702
- topic, keywords, research_logic_contract, synthesis_result,
4703
- data_evidence=planning_data_md, mode=mode,
4704
- hypothesis_contract=hypothesis_contract,
4705
- authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
4706
- keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4707
- fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
4708
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4709
  working_abstract = str(working_front.get("abstract") or "").strip()
4710
- if hypothesis_ledger:
4711
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
4712
  working_abstract, hypothesis_ledger
4713
  )
@@ -4715,7 +5220,17 @@ class GenerationRunner:
4715
  mode, list(working_front.get("keywords") or []), data_metadata
4716
  )
4717
  if not working_abstract or not working_keywords:
4718
- raise RuntimeError("working_front_matter_missing")
 
 
 
 
 
 
 
 
 
 
4719
  _, front_fact_rows = self._repair_validate_study_fact_contract(
4720
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
4721
  )
@@ -4725,22 +5240,32 @@ class GenerationRunner:
4725
  ))
4726
  if front_fact_rows:
4727
  feedback = self._section_retry_feedback(front_fact_rows)
4728
- working_front = agent.generate_working_front_matter(
4729
- topic, keywords, research_logic_contract, synthesis_result,
4730
- data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
4731
- hypothesis_contract=hypothesis_contract,
4732
- authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
4733
- keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4734
- fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
4735
- )
4736
- working_abstract = str(working_front.get("abstract") or "").strip()
4737
- if hypothesis_ledger:
4738
- working_abstract = self._normalize_hypothesis_ordinal_aliases(
4739
- working_abstract, hypothesis_ledger
 
 
 
 
 
 
 
 
 
 
 
 
 
4740
  )
4741
- working_keywords = self._canonical_quantitative_front_matter_keywords(
4742
- mode, list(working_front.get("keywords") or []), data_metadata
4743
- )
4744
  _, remaining = self._repair_validate_study_fact_contract(
4745
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
4746
  )
@@ -4761,8 +5286,13 @@ class GenerationRunner:
4761
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
4762
  "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
4763
  })
4764
- if not working_abstract:
4765
- raise RuntimeError("working_front_matter_empty_after_validation")
 
 
 
 
 
4766
  agent.global_memory["working_abstract"] = working_abstract
4767
  full_draft_en = replace_abstract(full_draft_en, working_abstract)
4768
  full_draft_en = self._replace_keywords(full_draft_en, working_keywords)
@@ -4771,6 +5301,7 @@ class GenerationRunner:
4771
  "working_keywords": working_keywords,
4772
  "front_matter_phase": "working",
4773
  "full_draft_en": full_draft_en,
 
4774
  "agent_state": agent.export_state(),
4775
  })
4776
  self._publish_preview(full_draft_en, "working_front_matter", force=True)
@@ -5164,17 +5695,19 @@ Output only final academic prose. Do not report the word count."""
5164
  candidate_user_prompt = user_prompt
5165
  if retry_excluded_tags:
5166
  retry_claim_plan = agent.format_claim_evidence_plan(planned_items)
5167
- candidate_user_prompt = re.sub(
5168
  r"<CLAIM_EVIDENCE_PLAN>.*?</CLAIM_EVIDENCE_PLAN>",
5169
  f"<CLAIM_EVIDENCE_PLAN>\n{retry_claim_plan}\n</CLAIM_EVIDENCE_PLAN>",
5170
- candidate_user_prompt, flags=re.S,
 
5171
  )
5172
  retry_capability_context = self._section_citation_capability_context(agent, planned_items)
5173
- candidate_user_prompt = re.sub(
5174
  r"\n<TARGETED_RAG>.*?</TARGETED_RAG>\n",
5175
  (f"\n<TARGETED_RAG>\n{retry_capability_context}\n</TARGETED_RAG>\n"
5176
  if retry_capability_context else "\n"),
5177
- candidate_user_prompt, flags=re.S,
 
5178
  )
5179
  if attempt_feedback:
5180
  excluded_text = ", ".join(sorted(retry_excluded_tags, key=agent._citation_tag_number)) or "None"
@@ -5758,10 +6291,41 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5758
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
5759
  )
5760
  if hypothesis_rows:
5761
- self._raise_if_fatal_completion_residual(
5762
- hypothesis_rows, section_name="Full Manuscript", mode=mode,
5763
- error_code="final_hypothesis_contract_unresolved"
 
 
5764
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5765
  self._record_adaptive_completion_residual(
5766
  quality_notes, hypothesis_rows, section_name="Full Manuscript", stage="final_hypothesis_review"
5767
  )
@@ -5874,13 +6438,32 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5874
  # from the completed manuscript/evidence, then replace the provisional
5875
  # front matter deterministically. No body rebalance may occur afterward.
5876
  self._progress(92, "final_front_matter")
5877
- final_front = agent.generate_final_front_matter(
5878
- topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
5879
- hypothesis_contract=hypothesis_contract,
5880
- authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
5881
- keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
5882
- fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
5883
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5884
  final_abstract = str(final_front.get("abstract") or "").strip()
5885
  if hypothesis_ledger:
5886
  final_abstract = self._normalize_hypothesis_ordinal_aliases(
@@ -5890,7 +6473,18 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5890
  mode, list(final_front.get("keywords") or []), data_metadata
5891
  )
5892
  if not final_abstract or not final_keywords:
5893
- raise RuntimeError("final_front_matter_missing")
 
 
 
 
 
 
 
 
 
 
 
5894
  final_front_fact_rows = self._study_fact_violations(
5895
  mode, agent, quant_engine, final_abstract, data_metadata,
5896
  section_name="Final Abstract",
@@ -5909,13 +6503,26 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5909
  mode, len(final_front_fact_rows),
5910
  sorted({str(row.get("type") or "") for row in final_front_fact_rows if isinstance(row, dict)}),
5911
  )
5912
- final_front = agent.generate_final_front_matter(
5913
- topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
5914
- validation_feedback=feedback, hypothesis_contract=hypothesis_contract,
5915
- authoritative_keywords=self._user_authoritative_front_matter_keywords(data_metadata),
5916
- keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
5917
- fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
5918
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
5919
  final_abstract = str(final_front.get("abstract") or "").strip()
5920
  if hypothesis_ledger:
5921
  final_abstract = self._normalize_hypothesis_ordinal_aliases(
@@ -5925,7 +6532,18 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5925
  mode, list(final_front.get("keywords") or []), data_metadata
5926
  )
5927
  if not final_abstract or not final_keywords:
5928
- raise RuntimeError("final_front_matter_missing_after_retry")
 
 
 
 
 
 
 
 
 
 
 
5929
  remaining_final_fact_rows = self._study_fact_violations(
5930
  mode, agent, quant_engine, final_abstract, data_metadata,
5931
  section_name="Final Abstract",
@@ -5968,11 +6586,30 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5968
  quality_notes=quality_notes, label="Final Abstract",
5969
  )
5970
  if residual_final_rows:
5971
- self._raise_if_fatal_completion_residual(
5972
- residual_final_rows, section_name="Final Abstract", mode=mode,
5973
- error_code="final_abstract_finding_contract_unresolved"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5974
  )
5975
- logging.warning("Adaptive Final Abstract accepted with residual diagnostics | count=%s", len(residual_final_rows))
5976
  quality_diagnostics.append({
5977
  "scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",
5978
  "status":"front_matter_retry_resolved", "count":len(final_front_fact_rows),
@@ -6008,10 +6645,41 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6008
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
6009
  )
6010
  if post_front_hypothesis_rows:
6011
- self._raise_if_fatal_completion_residual(
6012
- post_front_hypothesis_rows, section_name="Full Manuscript", mode=mode,
6013
- error_code="post_front_matter_hypothesis_contract_unresolved"
 
 
6014
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6015
  self._record_adaptive_completion_residual(
6016
  quality_notes, post_front_hypothesis_rows, section_name="Final Abstract",
6017
  stage="post_front_matter_hypothesis_review"
@@ -6387,6 +7055,57 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6387
  })
6388
  return result
6389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6390
  def run(self) -> dict[str, Any]:
6391
  try:
6392
  return self._run_impl()
 
28
  from core.meta_extractor import extract_golden_metadata
29
  from core.model_gateway import resolve_plan_policy_snapshot
30
  from core.pipeline_agent import SSciPipelineAgent, PipelineCancelledError
31
+ from core.production_guard import build_validated_package, build_recovery_package, replace_abstract
32
  from core.qualitative_engine import get_qualitative_engine
33
  from core.quantitative_engine import get_quantitative_engine
34
  from core.rag_engine import initialize_rag
 
91
  return re.sub(r"(?m)^#+\s+(.*)$", r"**\1**", str(text or ""))
92
 
93
 
94
+ def _regex_sub_literal(pattern, replacement, text, *, flags=0, count=0):
95
+ r"""Run regex matching while treating replacement bytes as literal text.
96
+
97
+ Dynamic evidence/RAG payloads may legitimately contain backslashes such as
98
+ ``\h`` or LaTeX commands. Passing those strings directly as ``re.sub``'s
99
+ replacement template can raise ``re.error: bad escape``. A callable
100
+ replacement preserves the payload byte-for-byte and disables replacement
101
+ template interpretation without changing regex matching semantics.
102
+ """
103
+ literal = str(replacement if replacement is not None else "")
104
+ source = str(text if text is not None else "")
105
+ return re.sub(pattern, lambda _match, value=literal: value, source, count=count, flags=flags)
106
+
107
+
108
  def _canonical_request_hash(request_data: dict[str, Any]) -> str:
109
  material = dict(request_data or {})
110
  material.pop("request_hash", None)
 
1378
  })
1379
  return current, diagnostics
1380
 
1381
+ @classmethod
1382
+ def _deterministic_final_abstract(
1383
+ cls, mode: str, topic: str, data_metadata: dict, *, hypothesis_ledger: dict | None = None,
1384
+ ) -> str:
1385
+ """Build a conservative provider-free Final Abstract from frozen authority.
1386
+
1387
+ Delivery-first rule: this fallback is used only after bounded model/front-
1388
+ matter repair has failed. It never invents coefficients, sample sizes,
1389
+ methods, or causal claims; it uses the user topic plus frozen finding/theme
1390
+ ledgers already owned by the completed manuscript.
1391
+ """
1392
+ clean_topic = re.sub(r"\s+", " ", str(topic or "")).strip().rstrip(".")
1393
+ if str(mode or "").strip().lower() == "amj":
1394
+ lead = f"This survey-based quantitative study examines {clean_topic}." if clean_topic else (
1395
+ "This survey-based quantitative study examines the associations specified in the frozen research model."
1396
+ )
1397
+ method = (
1398
+ "The analysis evaluates the prespecified construct associations using the completed study evidence and reports the results conservatively."
1399
+ )
1400
+ wanted = {
1401
+ "final_abstract_missing_empirical_finding",
1402
+ "final_abstract_missing_unsupported_finding",
1403
+ "final_abstract_missing_moderation_finding",
1404
+ "final_abstract_missing_indirect_finding",
1405
+ }
1406
+ else:
1407
+ lead = f"This qualitative study examines {clean_topic}." if clean_topic else (
1408
+ "This qualitative study examines the focal management phenomenon defined by the completed manuscript."
1409
+ )
1410
+ method = (
1411
+ "The analysis develops an evidence-bounded account from the completed qualitative study and preserves the study's documented design and evidence identities."
1412
+ )
1413
+ wanted = {
1414
+ "final_abstract_missing_qualitative_finding",
1415
+ "final_abstract_missing_qualitative_theme_findings",
1416
+ }
1417
+ findings = cls._canonical_final_abstract_findings(
1418
+ mode, data_metadata, hypothesis_ledger=hypothesis_ledger, violation_types=wanted,
1419
+ )
1420
+ if not findings:
1421
+ findings = [
1422
+ "The principal findings and their boundary conditions are reported directly from the completed analysis without adding unsupported numerical or causal claims."
1423
+ ]
1424
+ contribution = (
1425
+ "The study clarifies the focal relationships and boundary conditions in a form that remains aligned with the completed evidence and research design."
1426
+ if str(mode or "").strip().lower() == "amj"
1427
+ else "The study offers a structured interpretation of the focal process while keeping the claims aligned with the completed qualitative evidence."
1428
+ )
1429
+ return " ".join([lead, method, *findings, contribution]).strip()
1430
+
1431
+ @classmethod
1432
+ def _deterministic_research_logic_contract(
1433
+ cls, mode: str, topic: str, keywords: str, data_metadata: dict, *, hypothesis_ledger: dict | None = None,
1434
+ ) -> str:
1435
+ """Provider-free planning contract used when research-logic generation fails.
1436
+
1437
+ This is an internal planning anchor, not reader-facing manuscript prose. It
1438
+ intentionally stays conservative: no result direction, coefficient, sample
1439
+ value, causal claim, or bibliographic assertion is introduced. Quantitative
1440
+ H-identities are copied only from the frozen ledger.
1441
+ """
1442
+ clean_topic = re.sub(r"\s+", " ", str(topic or "")).strip()
1443
+ clean_keywords = ", ".join(parse_keyword_terms(keywords, explicit_user=True))
1444
+ if str(mode or "").strip().casefold() == "amj":
1445
+ parts = [
1446
+ f"Research focus: {clean_topic}." if clean_topic else "Research focus: the management problem defined by the frozen user request.",
1447
+ "Design: survey-based quantitative research.",
1448
+ "Planning rule: develop the management problem and theoretical rationale without predicting coefficients, significance, support, or causal effects.",
1449
+ "Evidence rule: use only the documented study design and completed analysis as authority for empirical facts and results.",
1450
+ ]
1451
+ items = [item for item in (dict(hypothesis_ledger or {}).get("items") or []) if isinstance(item, dict)]
1452
+ for item in items:
1453
+ hid = re.sub(r"\s+", " ", str(item.get("hypothesis_id") or "")).strip().upper()
1454
+ predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1455
+ outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1456
+ moderator = re.sub(r"\s+", " ", str(item.get("moderator") or "")).strip()
1457
+ relation = str(item.get("relation") or "association").strip().casefold()
1458
+ if not hid or not predictor or not outcome:
1459
+ continue
1460
+ if relation == "moderation" and moderator:
1461
+ parts.append(
1462
+ f"{hid} defines {moderator} as the moderator of the association between {predictor} and {outcome}."
1463
+ )
1464
+ else:
1465
+ parts.append(f"{hid} defines the association between {predictor} and {outcome}.")
1466
+ else:
1467
+ parts = [
1468
+ f"Research focus: {clean_topic}." if clean_topic else "Research focus: the management phenomenon defined by the frozen user request.",
1469
+ "Design: qualitative research.",
1470
+ "Planning rule: examine the organizational process, meanings, mechanisms, and boundary conditions without predicting an emergent resolution.",
1471
+ "Evidence rule: use only the documented qualitative setting, data sources, and completed evidence; do not invent participants, quotations, counts, or findings.",
1472
+ ]
1473
+ if clean_keywords:
1474
+ parts.insert(1, f"User keywords: {clean_keywords}.")
1475
+ return " ".join(part for part in parts if str(part or "").strip()).strip()
1476
+
1477
+ @classmethod
1478
+ def _deterministic_working_front_matter(
1479
+ cls, mode: str, topic: str, keywords: str, data_metadata: dict, *, hypothesis_ledger: dict | None = None,
1480
+ ) -> dict:
1481
+ """Build provisional front matter without a provider call.
1482
+
1483
+ Working front matter is only a continuity anchor before body drafting. The
1484
+ fallback therefore avoids empirical findings entirely and prefers frozen
1485
+ user keyword authority. Final front matter remains independently rebuilt
1486
+ from the completed manuscript later.
1487
+ """
1488
+ clean_topic = re.sub(r"\s+", " ", str(topic or "")).strip().rstrip(".")
1489
+ candidate_keywords = parse_keyword_terms(keywords, explicit_user=True)
1490
+ resolved_keywords = cls._canonical_quantitative_front_matter_keywords(
1491
+ mode, candidate_keywords, data_metadata
1492
+ )
1493
+ if not resolved_keywords:
1494
+ resolved_keywords = cls._front_matter_keyword_fallback_terms(data_metadata)
1495
+ if not resolved_keywords:
1496
+ resolved_keywords = ["Management Research"]
1497
+
1498
+ if str(mode or "").strip().casefold() == "amj":
1499
+ lead = (
1500
+ f"This survey-based quantitative study examines {clean_topic}."
1501
+ if clean_topic else
1502
+ "This survey-based quantitative study examines the management relationships defined by the user request."
1503
+ )
1504
+ abstract = " ".join([
1505
+ lead,
1506
+ "The manuscript develops the theoretical rationale for the prespecified research model and evaluates the documented associations using the completed study evidence.",
1507
+ "At this planning stage, no coefficient, significance test, hypothesis support status, or causal conclusion is anticipated.",
1508
+ "The final abstract will be reconstructed from the completed analysis and frozen evidence before delivery.",
1509
+ ])
1510
+ else:
1511
+ lead = (
1512
+ f"This qualitative study examines {clean_topic}."
1513
+ if clean_topic else
1514
+ "This qualitative study examines the management phenomenon defined by the user request."
1515
+ )
1516
+ abstract = " ".join([
1517
+ lead,
1518
+ "The manuscript uses the documented qualitative study design to examine the focal process, meanings, mechanisms, and boundary conditions.",
1519
+ "At this planning stage, no theme, quotation, participant detail, or substantive finding is anticipated beyond the frozen study design.",
1520
+ "The final abstract will be reconstructed from the completed qualitative evidence before delivery.",
1521
+ ])
1522
+ return {"abstract": abstract.strip(), "keywords": list(resolved_keywords)}
1523
+
1524
+ @classmethod
1525
+ def _deterministic_hypothesis_reconcile(cls, text: str, ledger: dict) -> tuple[str, list[dict]]:
1526
+ """Reconcile H-identities without model rewriting or result-driven remapping.
1527
+
1528
+ Unsafe H-labelled sentences are removed rather than semantically rewritten;
1529
+ missing canonical identities are then appended from the frozen ledger. This
1530
+ keeps P1 identity integrity while avoiding a task kill for mechanically
1531
+ repairable H-label defects.
1532
+ """
1533
+ value = cls._normalize_hypothesis_ordinal_aliases(str(text or ""), ledger)
1534
+ rows = cls._hypothesis_contract_violations(value, ledger, section_name="Full Manuscript", require_all=True)
1535
+ diagnostics: list[dict] = []
1536
+ if not rows:
1537
+ return value, diagnostics
1538
+
1539
+ # Remove whole sentences whose H label is unknown or attached to the wrong
1540
+ # construct pairing. Violation previews are intentionally capped, so
1541
+ # match them as normalized prefixes against the original sentence spans
1542
+ # rather than deleting only the truncated preview and leaving a prose tail.
1543
+ bad_sentence_previews = []
1544
+ unknown_ids = set()
1545
+ for row in rows:
1546
+ kind = str(row.get("type") or "")
1547
+ if kind == "hypothesis_contract_pairing_mismatch" and str(row.get("sentence") or "").strip():
1548
+ bad_sentence_previews.append(re.sub(r"\s+", " ", str(row.get("sentence") or "")).strip())
1549
+ if kind == "hypothesis_contract_unknown_id":
1550
+ unknown_ids.update(str(x or "").upper() for x in (row.get("observed") or []) if str(x or "").strip())
1551
+
1552
+ spans_to_remove = []
1553
+ for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
1554
+ normalized_sentence = re.sub(r"\s+", " ", str(sentence or "")).strip()
1555
+ pairing_match = any(
1556
+ normalized_sentence == preview
1557
+ or normalized_sentence.startswith(preview)
1558
+ or preview.startswith(normalized_sentence)
1559
+ for preview in bad_sentence_previews if preview
1560
+ )
1561
+ unknown_match = bool(unknown_ids) and any(
1562
+ re.search(rf"\b{re.escape(hid)}\b", sentence, flags=re.I) for hid in unknown_ids
1563
+ )
1564
+ if pairing_match or unknown_match:
1565
+ spans_to_remove.append((start, end, pairing_match, unknown_match))
1566
+
1567
+ removed_pairing = 0
1568
+ removed_unknown = 0
1569
+ for start, end, pairing_match, unknown_match in reversed(spans_to_remove):
1570
+ value = value[:start] + value[end:]
1571
+ removed_pairing += int(pairing_match)
1572
+ removed_unknown += int(unknown_match)
1573
+ if removed_pairing:
1574
+ diagnostics.append({
1575
+ "type": "hypothesis_pairing_sentence_removed",
1576
+ "count": removed_pairing,
1577
+ "reason": "Sentence(s) attaching a frozen H-identifier to the wrong construct pairing were removed before deterministic H-identity reconstruction.",
1578
+ })
1579
+ if removed_unknown:
1580
+ diagnostics.append({
1581
+ "type": "unknown_hypothesis_sentence_removed",
1582
+ "count": removed_unknown,
1583
+ "reason": "Sentence(s) containing H-identifiers outside the frozen hypothesis ledger were removed before deterministic H-identity reconstruction.",
1584
+ })
1585
+
1586
+ # A residual unknown token can still exist in a heading/range that the
1587
+ # sentence scanner did not classify as prose. Remove the identifier only
1588
+ # as a final mechanical cleanup; substantive H-content is never rewritten.
1589
+ if unknown_ids:
1590
+ pattern = r"\b(?:" + "|".join(re.escape(x) for x in sorted(unknown_ids, key=len, reverse=True)) + r")\b"
1591
+ value, count = re.subn(pattern, "", value, flags=re.I)
1592
+ if count:
1593
+ diagnostics.append({
1594
+ "type": "unknown_hypothesis_identifier_removed",
1595
+ "count": count,
1596
+ "reason": "Residual unknown H-identifiers were removed; only the frozen hypothesis ledger may own H-label identity.",
1597
+ })
1598
+
1599
+ value = re.sub(r"[ \t]{2,}", " ", value)
1600
+ value = re.sub(r"\n{3,}", "\n\n", value).strip()
1601
+ remaining = cls._hypothesis_contract_violations(value, ledger, section_name="Full Manuscript", require_all=True)
1602
+ missing = []
1603
+ for row in remaining:
1604
+ if str(row.get("type") or "") == "hypothesis_contract_missing_id":
1605
+ missing.extend(str(x or "").upper() for x in (row.get("expected") or []) if str(x or "").strip())
1606
+ observed = {str(x or "").upper() for x in (row.get("observed") or []) if str(x or "").strip()}
1607
+ missing = [x for x in missing if x not in observed]
1608
+ if missing:
1609
+ items = {
1610
+ str(item.get("hypothesis_id") or "").strip().upper(): item
1611
+ for item in (dict(ledger or {}).get("items") or []) if isinstance(item, dict)
1612
+ }
1613
+ lines = []
1614
+ for hid in dict.fromkeys(missing):
1615
+ item = dict(items.get(hid) or {})
1616
+ predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1617
+ outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1618
+ moderator = re.sub(r"\s+", " ", str(item.get("moderator") or "")).strip()
1619
+ relation = str(item.get("relation") or "association").strip().lower()
1620
+ if not predictor or not outcome:
1621
+ continue
1622
+ if relation == "moderation" and moderator:
1623
+ lines.append(f"{hid} states that {moderator} moderates the association between {predictor} and {outcome}.")
1624
+ else:
1625
+ lines.append(f"{hid} states that {predictor} is associated with {outcome}.")
1626
+ if lines:
1627
+ paragraph = "Hypothesis identity summary: " + " ".join(lines)
1628
+ # Put the deterministic summary at the end of the theory section
1629
+ # when that structural boundary is available; otherwise append to
1630
+ # the frozen body. No new empirical result is introduced.
1631
+ match = re.search(r"(?m)^#\s+(?:Methodology|Methods|Research Setting and Methods)\s*$", value)
1632
+ if match:
1633
+ value = value[:match.start()].rstrip() + "\n\n" + paragraph + "\n\n" + value[match.start():].lstrip()
1634
+ else:
1635
+ value = value.rstrip() + "\n\n" + paragraph
1636
+ diagnostics.append({
1637
+ "type": "missing_hypothesis_identity_reconstructed",
1638
+ "count": len(lines),
1639
+ "reason": "Missing H-identifiers were reconstructed only from the frozen hypothesis ledger.",
1640
+ })
1641
+ return value, diagnostics
1642
+
1643
  @classmethod
1644
  def _final_abstract_finding_violations(
1645
  cls, mode: str, text: str, data_metadata: dict, *, hypothesis_ledger: dict | None = None
 
2596
  "sha256": cls._section_integrity_hash(section_text),
2597
  }
2598
 
2599
+ def _refresh_section_integrity_seals_after_delivery_repair(
2600
+ self, manuscript: str, section_order, section_integrity_seals: dict,
2601
+ ) -> list[tuple[str, str, str]]:
2602
+ """Reseal only body sections changed by a deterministic P0 repair.
2603
+
2604
+ G1 permits a narrow deterministic hypothesis-identity repair after the
2605
+ ordinary section seals have been created. Any changed section must be
2606
+ resealed immediately so resume/checkpoint authority never points at stale
2607
+ bytes. The returned section text also lets the caller invalidate/requeue
2608
+ target translation from the exact repaired English source.
2609
+ """
2610
+ changed: list[tuple[str, str, str]] = []
2611
+ seals = section_integrity_seals
2612
+ for section_key, section_title in list(section_order or []):
2613
+ section_text = self._extract_top_level_section(manuscript, section_title)
2614
+ if not section_text:
2615
+ raise RuntimeError(f"delivery_first_reseal_source_missing:{section_title}")
2616
+ current = dict(seals.get(section_key) or {})
2617
+ updated = self._build_section_integrity_seal(section_key, section_title, section_text)
2618
+ if str(current.get("sha256") or "") != str(updated.get("sha256") or ""):
2619
+ seals[section_key] = updated
2620
+ changed.append((str(section_key), str(section_title), section_text))
2621
+ return changed
2622
+
2623
+ def _requeue_resealed_translation_sections(
2624
+ self, *, changed_sections, mode: str, quant_engine, data_metadata: dict,
2625
+ translation_agent, translation_sections: dict, english_commit_text: str,
2626
+ ) -> int:
2627
+ """Requeue translations whose frozen English source changed after repair."""
2628
+ queued = 0
2629
+ for section_key, section_title, section_text in list(changed_sections or []):
2630
+ source_text = section_text
2631
+ if (
2632
+ str(mode or "").strip().casefold() == "amj"
2633
+ and str(section_key).strip().casefold() == "results"
2634
+ and dict(data_metadata or {}).get("quantitative_table_manifest")
2635
+ ):
2636
+ source_text = quant_engine._strip_target_quantitative_markdown_tables(section_text)
2637
+ if self._enqueue_translation_unit(
2638
+ translation_agent,
2639
+ unit_key=section_key,
2640
+ unit_name=section_title,
2641
+ source_text=source_text,
2642
+ translation_sections=translation_sections,
2643
+ english_commit_text=english_commit_text,
2644
+ ):
2645
+ queued += 1
2646
+ return queued
2647
+
2648
  @staticmethod
2649
  def _replace_top_level_section(manuscript: str, title: str, replacement: str) -> str:
2650
  value = str(manuscript or "")
 
2761
  rendered = re.sub(r"\s+", " ", str(title or "")).strip()
2762
  if not rendered:
2763
  return value
2764
+ title_replacement = f"# {rendered}"
2765
+ updated, count = re.subn(
2766
+ r"(?m)^#\s+.+$",
2767
+ lambda _match, replacement=title_replacement: replacement,
2768
+ value,
2769
+ count=1,
2770
+ )
2771
  if count == 1:
2772
  return updated
2773
  logging.warning("Manuscript title heading missing during final reconciliation; reconstructing canonical H1 instead of failing generation.")
 
4395
  "but do not invent citations or bibliographic metadata and do not present general model knowledge as retrieved evidence."
4396
  )
4397
  else:
4398
+ try:
4399
+ synthesis_result = self._stream_text(
4400
+ agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)
4401
+ )
4402
+ except PipelineCancelledError:
4403
+ raise
4404
+ except Exception as exc:
4405
+ synthesis_result = (
4406
+ "LITERATURE-SYNTHESIS FALLBACK: retrieved literature remains available through the citation/RAG layer, "
4407
+ "but the narrative synthesis provider step was unavailable. Continue from the frozen study evidence and "
4408
+ "claim-specific retrieved sources; do not invent bibliographic facts, expected findings, or causal claims."
4409
+ )
4410
+ quality_diagnostics.append({
4411
+ "scope": "delivery_first_literature_synthesis_fallback",
4412
+ "name": "Literature Synthesis",
4413
+ "status": "deterministic_fallback",
4414
+ "reason": type(exc).__name__,
4415
+ })
4416
+ logging.warning(
4417
+ "Delivery-first literature synthesis fallback activated | error=%s",
4418
+ type(exc).__name__,
4419
+ )
4420
  agent.record_prewrite_stage("literature_synthesis", prewrite_started, prewrite_calls)
4421
  synthesis_result = re.sub(r"<think>.*?</think>", "", synthesis_result, flags=re.DOTALL).strip()
4422
  state.update({"synthesis_result": synthesis_result, "agent_state": agent.export_state()})
 
4427
  self._progress(14, "research_logic")
4428
  prewrite_started = time.monotonic()
4429
  prewrite_calls = agent.ds_calls
4430
+ research_logic_error = None
4431
+ try:
4432
+ research_logic_response = self._stream_text(
4433
+ agent.stream_generate_research_logic(
4434
+ topic, keywords, synthesis_result, refs_text,
4435
+ data_evidence=planning_data_md, mode=mode,
4436
+ hypothesis_contract=hypothesis_contract,
4437
+ )
4438
  )
4439
+ except PipelineCancelledError:
4440
+ raise
4441
+ except Exception as exc:
4442
+ research_logic_error = exc
4443
+ research_logic_response = ""
4444
+ logging.warning(
4445
+ "Delivery-first research-logic provider path unavailable; using deterministic planning fallback | error=%s",
4446
+ type(exc).__name__,
4447
+ )
4448
+ finally:
4449
+ agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
4450
  research_logic_contract = re.sub(
4451
  r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
4452
  ).strip()
4453
+ logic_hypothesis_rows = []
4454
+ if hypothesis_ledger and research_logic_contract:
4455
  research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
4456
  research_logic_contract, hypothesis_ledger
4457
  )
4458
  logic_hypothesis_rows = self._hypothesis_contract_violations(
4459
  research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
4460
  )
4461
+ if not research_logic_contract or logic_hypothesis_rows:
4462
+ fallback_reason = (
4463
+ "provider_error" if research_logic_error is not None
4464
+ else "empty_contract" if not research_logic_contract
4465
+ else "hypothesis_identity_mismatch"
4466
+ )
4467
+ research_logic_contract = self._deterministic_research_logic_contract(
4468
+ mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
4469
+ )
4470
+ fallback_rows = (
4471
+ self._hypothesis_contract_violations(
4472
+ research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
4473
+ ) if hypothesis_ledger else []
4474
+ )
4475
+ if fallback_rows:
4476
+ # This is an internal deterministic invariant failure, not a
4477
+ # model-quality rejection. Do not continue with a corrupted
4478
+ # H-identity planning contract.
4479
  raise RuntimeError(
4480
+ f"delivery_first_research_logic_fallback_invalid:{fallback_rows[:3]}"
4481
  )
4482
+ quality_diagnostics.append({
4483
+ "scope": "delivery_first_research_logic_fallback",
4484
+ "name": "Research Logic Contract",
4485
+ "status": "deterministic_fallback",
4486
+ "reason": fallback_reason,
4487
+ "prior_violations": logic_hypothesis_rows[:3],
4488
+ })
4489
+ logging.warning(
4490
+ "Delivery-first Research Logic fallback activated | reason=%s prior_violations=%s",
4491
+ fallback_reason, len(logic_hypothesis_rows),
4492
+ )
4493
  agent.global_memory["research_logic_contract"] = research_logic_contract
4494
 
4495
  self._progress(14, "working_front_matter")
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),
4503
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4504
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
4505
+ )
4506
+ except PipelineCancelledError:
4507
+ raise
4508
+ except Exception as exc:
4509
+ working_front_error = exc
4510
+ working_front = {}
4511
+ logging.warning(
4512
+ "Delivery-first Working Front Matter provider path unavailable; preparing deterministic fallback | error=%s",
4513
+ type(exc).__name__,
4514
+ )
4515
  working_abstract = str(working_front.get("abstract") or "").strip()
4516
+ if hypothesis_ledger and working_abstract:
4517
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
4518
  working_abstract, hypothesis_ledger
4519
  )
 
4521
  mode, list(working_front.get("keywords") or []), data_metadata
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 [])
4529
+ quality_diagnostics.append({
4530
+ "scope": "delivery_first_working_front_matter_fallback",
4531
+ "name": "Working Abstract",
4532
+ "status": "deterministic_fallback",
4533
+ "reason": "provider_error" if working_front_error is not None else "missing_component",
4534
+ })
4535
+ logging.warning(
4536
+ "Delivery-first Working Front Matter fallback activated | reason=%s",
4537
+ "provider_error" if working_front_error is not None else "missing_component",
4538
+ )
4539
+
4540
  _, front_fact_rows = self._repair_validate_study_fact_contract(
4541
  mode, agent, quant_engine, working_abstract, data_metadata,
4542
  section_name="Working Abstract",
 
4551
  "Working Abstract fact validation retry | mode=%s count=%s types=%s",
4552
  mode, len(front_fact_rows), sorted({str(row.get("type") or "") for row in front_fact_rows}),
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),
4560
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4561
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
4562
+ )
4563
+ retry_abstract = str(working_front.get("abstract") or "").strip()
4564
+ if hypothesis_ledger and retry_abstract:
4565
+ retry_abstract = self._normalize_hypothesis_ordinal_aliases(
4566
+ retry_abstract, hypothesis_ledger
4567
+ )
4568
+ retry_keywords = self._canonical_quantitative_front_matter_keywords(
4569
+ mode, list(working_front.get("keywords") or []), data_metadata
4570
+ )
4571
+ if retry_abstract and retry_keywords:
4572
+ working_abstract, working_keywords = retry_abstract, retry_keywords
4573
+ except PipelineCancelledError:
4574
+ raise
4575
+ except Exception as exc:
4576
+ logging.warning(
4577
+ "Working Front Matter validation retry unavailable; continuing to deterministic/adaptive fallback | error=%s",
4578
+ type(exc).__name__,
4579
  )
 
 
 
4580
  _, remaining_front_rows = self._repair_validate_study_fact_contract(
4581
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
4582
  )
 
4597
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
4598
  "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
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 [])
4606
+ quality_diagnostics.append({
4607
+ "scope": "delivery_first_working_front_matter_fallback",
4608
+ "name": "Working Abstract",
4609
+ "status": "deterministic_fallback_after_validation",
4610
+ "reason": "empty_after_repair",
4611
+ })
4612
+ logging.warning(
4613
+ "Delivery-first Working Front Matter fallback activated after validation repair exhaustion."
4614
+ )
4615
+ agent.global_memory["working_abstract"] = working_abstract
4616
+
4617
  agent.global_memory["working_abstract"] = working_abstract
4618
 
4619
  self._progress(14, "outline_planning")
 
4833
  research_logic_contract = str(
4834
  state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
4835
  )
4836
+ resumed_logic_rows = []
4837
  if hypothesis_ledger and research_logic_contract:
4838
  research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
4839
  research_logic_contract, hypothesis_ledger
 
4841
  resumed_logic_rows = self._hypothesis_contract_violations(
4842
  research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
4843
  )
4844
+ if not research_logic_contract or resumed_logic_rows:
4845
+ research_logic_contract = self._deterministic_research_logic_contract(
4846
+ mode, topic, keywords, data_metadata, hypothesis_ledger=hypothesis_ledger
4847
+ )
4848
+ fallback_rows = (
4849
+ self._hypothesis_contract_violations(
4850
+ research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
4851
+ ) if hypothesis_ledger else []
4852
+ )
4853
+ if fallback_rows:
4854
  raise RuntimeError(
4855
+ f"delivery_first_research_logic_fallback_invalid_on_resume:{fallback_rows[:3]}"
4856
  )
4857
+ quality_diagnostics.append({
4858
+ "scope": "delivery_first_research_logic_fallback",
4859
+ "name": "Research Logic Contract",
4860
+ "status": "deterministic_resume_fallback",
4861
+ "reason": "missing_contract" if not str(state.get("research_logic_contract") or "").strip() else "hypothesis_identity_mismatch",
4862
+ "prior_violations": resumed_logic_rows[:3],
4863
+ })
4864
+ state["research_logic_contract"] = research_logic_contract
4865
+ state["quality_diagnostics"] = quality_diagnostics
4866
+ logging.warning(
4867
+ "Delivery-first Research Logic resume fallback activated | prior_violations=%s",
4868
+ len(resumed_logic_rows),
4869
+ )
4870
+ agent.global_memory["research_logic_contract"] = research_logic_contract
4871
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
4872
  citation_minimums = dict(state.get("citation_minimums") or {})
4873
  citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
 
4932
  ))
4933
  if resumed_front_rows:
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),
4941
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
4942
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
 
 
 
 
4943
  )
4944
+ candidate_abstract = str(working_front.get("abstract") or "").strip()
4945
+ if hypothesis_ledger and candidate_abstract:
4946
+ candidate_abstract = self._normalize_hypothesis_ordinal_aliases(
4947
+ candidate_abstract, hypothesis_ledger
4948
+ )
4949
+ candidate_keywords = self._canonical_quantitative_front_matter_keywords(
4950
+ mode, list(working_front.get("keywords") or []), data_metadata
4951
+ )
4952
+ except PipelineCancelledError:
4953
+ raise
4954
+ except Exception as exc:
4955
+ logging.warning(
4956
+ "Resumed Working Front Matter repair unavailable; using deterministic fallback | error=%s",
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 [])
4970
  _, remaining = self._repair_validate_study_fact_contract(
4971
  mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
4972
  )
 
4983
  )
4984
  if residual_front_rows:
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)
4992
  working_abstract = candidate_abstract
4993
  working_keywords = candidate_keywords or working_keywords
4994
  state["working_abstract"] = working_abstract
 
5186
  # an outline but no Working Abstract yet. Generate it once, checkpoint it,
5187
  # and reuse it thereafter; never regenerate an unchanged anchor on resume.
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),
5195
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
5196
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
5197
+ )
5198
+ except PipelineCancelledError:
5199
+ raise
5200
+ except Exception as exc:
5201
+ logging.warning(
5202
+ "Migrated Working Front Matter provider path unavailable; using deterministic fallback | error=%s",
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",
5210
+ "name": "Working Abstract",
5211
+ "status": "deterministic_resume_migration_fallback",
5212
+ "reason": "provider_error",
5213
+ })
5214
  working_abstract = str(working_front.get("abstract") or "").strip()
5215
+ if hypothesis_ledger and working_abstract:
5216
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
5217
  working_abstract, hypothesis_ledger
5218
  )
 
5220
  mode, list(working_front.get("keywords") or []), data_metadata
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 [])
5228
+ quality_diagnostics.append({
5229
+ "scope": "delivery_first_working_front_matter_fallback",
5230
+ "name": "Working Abstract",
5231
+ "status": "deterministic_resume_migration_fallback",
5232
+ "reason": "missing_component",
5233
+ })
5234
  _, front_fact_rows = self._repair_validate_study_fact_contract(
5235
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
5236
  )
 
5240
  ))
5241
  if front_fact_rows:
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),
5249
+ keyword_policy=self._resolved_front_matter_keyword_policy(data_metadata),
5250
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
5251
+ )
5252
+ retry_abstract = str(working_front.get("abstract") or "").strip()
5253
+ if hypothesis_ledger and retry_abstract:
5254
+ retry_abstract = self._normalize_hypothesis_ordinal_aliases(
5255
+ retry_abstract, hypothesis_ledger
5256
+ )
5257
+ retry_keywords = self._canonical_quantitative_front_matter_keywords(
5258
+ mode, list(working_front.get("keywords") or []), data_metadata
5259
+ )
5260
+ if retry_abstract and retry_keywords:
5261
+ working_abstract, working_keywords = retry_abstract, retry_keywords
5262
+ except PipelineCancelledError:
5263
+ raise
5264
+ except Exception as exc:
5265
+ logging.warning(
5266
+ "Migrated Working Front Matter validation retry unavailable; preserving deterministic-safe candidate | error=%s",
5267
+ type(exc).__name__,
5268
  )
 
 
 
5269
  _, remaining = self._repair_validate_study_fact_contract(
5270
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
5271
  )
 
5286
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
5287
  "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
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 [])
5295
+ logging.warning("Delivery-first migrated Working Front Matter fallback activated after repair exhaustion.")
5296
  agent.global_memory["working_abstract"] = working_abstract
5297
  full_draft_en = replace_abstract(full_draft_en, working_abstract)
5298
  full_draft_en = self._replace_keywords(full_draft_en, working_keywords)
 
5301
  "working_keywords": working_keywords,
5302
  "front_matter_phase": "working",
5303
  "full_draft_en": full_draft_en,
5304
+ "quality_diagnostics": quality_diagnostics,
5305
  "agent_state": agent.export_state(),
5306
  })
5307
  self._publish_preview(full_draft_en, "working_front_matter", force=True)
 
5695
  candidate_user_prompt = user_prompt
5696
  if retry_excluded_tags:
5697
  retry_claim_plan = agent.format_claim_evidence_plan(planned_items)
5698
+ candidate_user_prompt = _regex_sub_literal(
5699
  r"<CLAIM_EVIDENCE_PLAN>.*?</CLAIM_EVIDENCE_PLAN>",
5700
  f"<CLAIM_EVIDENCE_PLAN>\n{retry_claim_plan}\n</CLAIM_EVIDENCE_PLAN>",
5701
+ candidate_user_prompt,
5702
+ flags=re.S,
5703
  )
5704
  retry_capability_context = self._section_citation_capability_context(agent, planned_items)
5705
+ candidate_user_prompt = _regex_sub_literal(
5706
  r"\n<TARGETED_RAG>.*?</TARGETED_RAG>\n",
5707
  (f"\n<TARGETED_RAG>\n{retry_capability_context}\n</TARGETED_RAG>\n"
5708
  if retry_capability_context else "\n"),
5709
+ candidate_user_prompt,
5710
+ flags=re.S,
5711
  )
5712
  if attempt_feedback:
5713
  excluded_text = ", ".join(sorted(retry_excluded_tags, key=agent._citation_tag_number)) or "None"
 
6291
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
6292
  )
6293
  if hypothesis_rows:
6294
+ reconciled, reconciliation_notes = self._deterministic_hypothesis_reconcile(
6295
+ full_draft_en, hypothesis_ledger
6296
+ )
6297
+ reconciled_rows = self._hypothesis_contract_violations(
6298
+ reconciled, hypothesis_ledger, section_name="Full Manuscript", require_all=True
6299
  )
6300
+ if reconciliation_notes:
6301
+ self._record_adaptive_completion_residual(
6302
+ quality_notes, reconciliation_notes, section_name="Full Manuscript",
6303
+ stage="delivery_first_hypothesis_reconciliation"
6304
+ )
6305
+ if reconciled_rows:
6306
+ self._raise_if_fatal_completion_residual(
6307
+ reconciled_rows, section_name="Full Manuscript", mode=mode,
6308
+ error_code="final_hypothesis_contract_unresolved"
6309
+ )
6310
+ else:
6311
+ full_draft_en = reconciled
6312
+ changed_sections = self._refresh_section_integrity_seals_after_delivery_repair(
6313
+ full_draft_en, section_order_for_fact, section_integrity_seals
6314
+ )
6315
+ if changed_sections:
6316
+ requeued = self._requeue_resealed_translation_sections(
6317
+ changed_sections=changed_sections, mode=mode, quant_engine=quant_engine,
6318
+ data_metadata=data_metadata, translation_agent=translation_agent,
6319
+ translation_sections=translation_sections, english_commit_text=full_draft_en,
6320
+ )
6321
+ self._record_adaptive_completion_residual(
6322
+ quality_notes, [{
6323
+ "type": "delivery_first_section_resealed",
6324
+ "count": len(changed_sections),
6325
+ "requeued_translations": requeued,
6326
+ "reason": "Deterministic H-identity repair changed sealed English bytes; section seals and target translation source authority were refreshed before finalization.",
6327
+ }], section_name="Full Manuscript", stage="delivery_first_hypothesis_reseal"
6328
+ )
6329
  self._record_adaptive_completion_residual(
6330
  quality_notes, hypothesis_rows, section_name="Full Manuscript", stage="final_hypothesis_review"
6331
  )
 
6438
  # from the completed manuscript/evidence, then replace the provisional
6439
  # front matter deterministically. No body rebalance may occur afterward.
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),
6447
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
6448
+ )
6449
+ except Exception as exc:
6450
+ logging.warning(
6451
+ "Delivery-first Final Front Matter fallback | mode=%s error=%s", mode, type(exc).__name__
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(
6462
+ quality_notes, [{
6463
+ "type": "final_front_matter_provider_fallback",
6464
+ "reason": f"Final Front Matter model/validation failed ({type(exc).__name__}); deterministic frozen-authority fallback was used."
6465
+ }], section_name="Final Abstract", stage="delivery_first_front_matter_fallback"
6466
+ )
6467
  final_abstract = str(final_front.get("abstract") or "").strip()
6468
  if hypothesis_ledger:
6469
  final_abstract = self._normalize_hypothesis_ordinal_aliases(
 
6473
  mode, list(final_front.get("keywords") or []), data_metadata
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, [{
6484
+ "type": "final_front_matter_missing_fallback",
6485
+ "reason": "Missing Final Front Matter component was reconstructed deterministically from frozen title/keyword/finding authority."
6486
+ }], section_name="Final Abstract", stage="delivery_first_front_matter_fallback"
6487
+ )
6488
  final_front_fact_rows = self._study_fact_violations(
6489
  mode, agent, quant_engine, final_abstract, data_metadata,
6490
  section_name="Final Abstract",
 
6503
  mode, len(final_front_fact_rows),
6504
  sorted({str(row.get("type") or "") for row in final_front_fact_rows if isinstance(row, dict)}),
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),
6512
+ fallback_keywords=self._front_matter_keyword_fallback_terms(data_metadata),
6513
+ )
6514
+ except Exception as exc:
6515
+ logging.warning(
6516
+ "Delivery-first Final Front Matter retry fallback | mode=%s error=%s", mode, type(exc).__name__
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()
6527
  if hypothesis_ledger:
6528
  final_abstract = self._normalize_hypothesis_ordinal_aliases(
 
6532
  mode, list(final_front.get("keywords") or []), data_metadata
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, [{
6543
+ "type": "final_front_matter_retry_exhausted_fallback",
6544
+ "reason": "Final Front Matter remained incomplete after retry; deterministic frozen-authority fallback was used."
6545
+ }], section_name="Final Abstract", stage="delivery_first_front_matter_fallback"
6546
+ )
6547
  remaining_final_fact_rows = self._study_fact_violations(
6548
  mode, agent, quant_engine, final_abstract, data_metadata,
6549
  section_name="Final Abstract",
 
6586
  quality_notes=quality_notes, label="Final Abstract",
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"
6594
+ )
6595
+ if hypothesis_ledger:
6596
+ hard_rows.extend(self._hypothesis_contract_violations(
6597
+ deterministic_abstract, hypothesis_ledger, section_name="Final Abstract"
6598
+ ))
6599
+ if hard_rows:
6600
+ self._raise_if_fatal_completion_residual(
6601
+ hard_rows, section_name="Final Abstract", mode=mode,
6602
+ error_code="final_abstract_integrity_unresolved"
6603
+ )
6604
+ final_abstract = deterministic_abstract
6605
+ self._record_adaptive_completion_residual(
6606
+ quality_notes, residual_final_rows, section_name="Final Abstract",
6607
+ stage="delivery_first_final_abstract_fallback"
6608
+ )
6609
+ logging.warning(
6610
+ "Delivery-first Final Abstract deterministic fallback accepted | residual_diagnostics=%s",
6611
+ len(residual_final_rows)
6612
  )
 
6613
  quality_diagnostics.append({
6614
  "scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",
6615
  "status":"front_matter_retry_resolved", "count":len(final_front_fact_rows),
 
6645
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
6646
  )
6647
  if post_front_hypothesis_rows:
6648
+ reconciled, reconciliation_notes = self._deterministic_hypothesis_reconcile(
6649
+ full_draft_en, hypothesis_ledger
6650
+ )
6651
+ remaining_post_rows = self._hypothesis_contract_violations(
6652
+ reconciled, hypothesis_ledger, section_name="Full Manuscript", require_all=True
6653
  )
6654
+ if reconciliation_notes:
6655
+ self._record_adaptive_completion_residual(
6656
+ quality_notes, reconciliation_notes, section_name="Final Abstract",
6657
+ stage="delivery_first_post_front_hypothesis_reconciliation"
6658
+ )
6659
+ if remaining_post_rows:
6660
+ self._raise_if_fatal_completion_residual(
6661
+ remaining_post_rows, section_name="Full Manuscript", mode=mode,
6662
+ error_code="post_front_matter_hypothesis_contract_unresolved"
6663
+ )
6664
+ else:
6665
+ full_draft_en = reconciled
6666
+ changed_sections = self._refresh_section_integrity_seals_after_delivery_repair(
6667
+ full_draft_en, section_order_for_fact, section_integrity_seals
6668
+ )
6669
+ if changed_sections:
6670
+ requeued = self._requeue_resealed_translation_sections(
6671
+ changed_sections=changed_sections, mode=mode, quant_engine=quant_engine,
6672
+ data_metadata=data_metadata, translation_agent=translation_agent,
6673
+ translation_sections=translation_sections, english_commit_text=full_draft_en,
6674
+ )
6675
+ self._record_adaptive_completion_residual(
6676
+ quality_notes, [{
6677
+ "type": "delivery_first_section_resealed",
6678
+ "count": len(changed_sections),
6679
+ "requeued_translations": requeued,
6680
+ "reason": "Post-front-matter H-identity repair changed sealed English bytes; section seals and target translation source authority were refreshed before checkpointing.",
6681
+ }], section_name="Full Manuscript", stage="delivery_first_post_front_reseal"
6682
+ )
6683
  self._record_adaptive_completion_residual(
6684
  quality_notes, post_front_hypothesis_rows, section_name="Final Abstract",
6685
  stage="post_front_matter_hypothesis_review"
 
7055
  })
7056
  return result
7057
 
7058
+ def build_recovery_artifact(self, error: Exception) -> dict[str, str]:
7059
+ """Best-effort failed/refunded download when English authority is already frozen.
7060
+
7061
+ This method never changes task success semantics. The Executor may expose
7062
+ the returned ZIP only on a FAILED task after quota release.
7063
+ """
7064
+ if "cancel" in str(error or "").casefold():
7065
+ return {}
7066
+ latest_path = self.task_dir / "checkpoints" / "latest.json"
7067
+ try:
7068
+ payload = json.loads(latest_path.read_text(encoding="utf-8"))
7069
+ except Exception:
7070
+ return {}
7071
+ if not isinstance(payload, dict) or payload.get("request_hash") != self.request_hash:
7072
+ return {}
7073
+ if not bool(payload.get("english_finalized")):
7074
+ return {}
7075
+ english = str(payload.get("full_draft_en") or "").strip()
7076
+ if len(english) < 1500:
7077
+ return {}
7078
+ translated = str(payload.get("full_draft_translated") or "").strip()
7079
+ target_lang = str(self.request.get("target_lang") or "").strip()
7080
+ output_suffix = str(self.request.get("output_suffix") or "Target").strip()
7081
+ target_file_language = (
7082
+ "Chinese" if "chinese" in target_lang.casefold() or "中文" in target_lang
7083
+ else "Russian" if "russian" in target_lang.casefold() or "рус" in target_lang.casefold()
7084
+ else "Kazakh" if "kazakh" in target_lang.casefold() or "қазақ" in target_lang.casefold()
7085
+ else re.sub(r"[^A-Za-z]+", "", output_suffix) or "Target"
7086
+ )
7087
+ try:
7088
+ package = build_recovery_package(
7089
+ english_markdown=english,
7090
+ translated_markdown=translated,
7091
+ english_filename="Paper_English.docx",
7092
+ translated_filename=f"Paper_{target_file_language}_Partial.docx",
7093
+ error_detail=str(error or "")[:1000],
7094
+ )
7095
+ except Exception as recovery_exc:
7096
+ logging.warning(
7097
+ "Delivery-first recovery package unavailable | error=%s original=%s",
7098
+ type(recovery_exc).__name__, type(error).__name__,
7099
+ )
7100
+ return {}
7101
+ bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
7102
+ output_filename = f"SmartPaper_Recovery_{bj_time}.zip"
7103
+ pending = self.output_dir / (".pending_" + output_filename)
7104
+ pending.write_bytes(package.zip_bytes)
7105
+ final = self.output_dir / output_filename
7106
+ os.replace(pending, final)
7107
+ return {"output_filename": output_filename, "output_path": str(final)}
7108
+
7109
  def run(self) -> dict[str, Any]:
7110
  try:
7111
  return self._run_impl()
core/pipeline_agent.py CHANGED
@@ -2931,8 +2931,17 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2931
  future_lane[executor.submit(self._s2_worker, query, 0)] = 'method_authority'
2932
  future_lane[executor.submit(self._oa_worker, query, 1, "language:en,type:article,is_paratext:false")] = 'method_authority'
2933
  for future in concurrent.futures.as_completed(future_lane):
2934
- results = future.result()
2935
  lane = future_lane[future]
 
 
 
 
 
 
 
 
 
 
2936
  with self.lock:
2937
  for res in results:
2938
  result_keys = self._reference_keys(res)
@@ -2966,28 +2975,44 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2966
  sys_prompt = """You are an expert academic librarian. Generate exactly 3 concise English search queries.
2967
  [CRITICAL SEARCH QUERY RULES]
2968
  1. DO NOT use complex, deeply nested boolean logic (e.g., Avoid `(A OR B) AND (C OR D)`).
2969
- 2. DO NOT use excessive quotation marks for long phrases.
2970
- 3. Output simple, space-separated keyword combinations or simple AND logic.
2971
  Good: "educational chatbot" "recommendation system" context-aware
2972
  Bad: ("educational chatbot" OR "pedagogical agent") AND ("recommendation system")
2973
  4. Keep the query strictly UNDER 5 terms to avoid triggering server API timeouts.
2974
  Return ONLY a valid JSON array of strings."""
2975
  user_prompt = f"Topic: {topic}\nKeywords: {keywords}"
2976
  for attempt in range(3):
2977
- queries = self._extract_json_from_text(self.call_deepseek(
2978
- sys_prompt,
2979
- user_prompt,
2980
- temp=0.1 + attempt * 0.1,
2981
- model_role="fast",
2982
- logical_node='search_jit',
2983
- ))
 
 
 
 
 
 
 
 
 
2984
  if queries and isinstance(queries, list) and len(queries) > 0:
2985
  final_queries = [str(q).strip() for q in queries if self._is_english_dominant(str(q).strip())]
2986
- if final_queries:
2987
  return final_queries[:3]
2988
  time.sleep(1)
2989
-
2990
- clean_chunks = [c.strip() for c in re.findall(r'[a-zA-Z0-9\s\-]{4,}', f"{topic} {keywords}") if self._is_english_dominant(c.strip())]
 
 
 
 
 
 
 
2991
  return clean_chunks[:3] if clean_chunks else []
2992
 
2993
  @staticmethod
@@ -3418,7 +3443,18 @@ Return ONLY a valid JSON array of strings."""
3418
  for future in concurrent.futures.as_completed(future_sources):
3419
  self._check_cancelled()
3420
  source = future_sources[future]
3421
- collected[source].extend(future.result())
 
 
 
 
 
 
 
 
 
 
 
3422
  if progress_callback:
3423
  progress_callback()
3424
 
@@ -3427,15 +3463,24 @@ Return ONLY a valid JSON array of strings."""
3427
  if phase == 0:
3428
  terms = self.generate_search_queries(topic, keywords)
3429
  else:
3430
- response = self.call_deepseek(
3431
- 'You are an academic librarian. Return only a JSON array with three new concise English search queries, each under five terms.',
3432
- f"Topic: {topic}\nKeywords: {keywords}\nUsed: {sorted(all_used_terms)}",
3433
- temp=0.4,
3434
- model_role='fast',
3435
- logical_node='search_jit',
3436
- )
3437
- parsed = self._extract_json_from_text(response)
3438
- terms = parsed if isinstance(parsed, list) else []
 
 
 
 
 
 
 
 
 
3439
 
3440
  normalized_terms = []
3441
  for value in terms:
 
2931
  future_lane[executor.submit(self._s2_worker, query, 0)] = 'method_authority'
2932
  future_lane[executor.submit(self._oa_worker, query, 1, "language:en,type:article,is_paratext:false")] = 'method_authority'
2933
  for future in concurrent.futures.as_completed(future_lane):
 
2934
  lane = future_lane[future]
2935
+ try:
2936
+ results = future.result()
2937
+ except PipelineCancelledError:
2938
+ raise
2939
+ except Exception as exc:
2940
+ logging.warning(
2941
+ "Delivery-first METHOD_AUTHORITY source skipped | lane=%s error=%s",
2942
+ lane, type(exc).__name__,
2943
+ )
2944
+ continue
2945
  with self.lock:
2946
  for res in results:
2947
  result_keys = self._reference_keys(res)
 
2975
  sys_prompt = """You are an expert academic librarian. Generate exactly 3 concise English search queries.
2976
  [CRITICAL SEARCH QUERY RULES]
2977
  1. DO NOT use complex, deeply nested boolean logic (e.g., Avoid `(A OR B) AND (C OR D)`).
2978
+ 2. DO NOT use excessive quotation marks for long phrases.
2979
+ 3. Output simple, space-separated keyword combinations or simple AND logic.
2980
  Good: "educational chatbot" "recommendation system" context-aware
2981
  Bad: ("educational chatbot" OR "pedagogical agent") AND ("recommendation system")
2982
  4. Keep the query strictly UNDER 5 terms to avoid triggering server API timeouts.
2983
  Return ONLY a valid JSON array of strings."""
2984
  user_prompt = f"Topic: {topic}\nKeywords: {keywords}"
2985
  for attempt in range(3):
2986
+ try:
2987
+ queries = self._extract_json_from_text(self.call_deepseek(
2988
+ sys_prompt,
2989
+ user_prompt,
2990
+ temp=0.1 + attempt * 0.1,
2991
+ model_role="fast",
2992
+ logical_node='search_jit',
2993
+ ))
2994
+ except PipelineCancelledError:
2995
+ raise
2996
+ except Exception as exc:
2997
+ logging.warning(
2998
+ "Delivery-first search-query generation fallback | attempt=%s/3 error=%s",
2999
+ attempt + 1, type(exc).__name__,
3000
+ )
3001
+ queries = []
3002
  if queries and isinstance(queries, list) and len(queries) > 0:
3003
  final_queries = [str(q).strip() for q in queries if self._is_english_dominant(str(q).strip())]
3004
+ if final_queries:
3005
  return final_queries[:3]
3006
  time.sleep(1)
3007
+
3008
+ clean_chunks = [
3009
+ c.strip() for c in re.findall(r'[a-zA-Z0-9\s\-]{4,}', f"{topic} {keywords}")
3010
+ if self._is_english_dominant(c.strip())
3011
+ ]
3012
+ if clean_chunks:
3013
+ logging.warning(
3014
+ "Delivery-first search-query provider exhausted; using deterministic English user-text query fallback."
3015
+ )
3016
  return clean_chunks[:3] if clean_chunks else []
3017
 
3018
  @staticmethod
 
3443
  for future in concurrent.futures.as_completed(future_sources):
3444
  self._check_cancelled()
3445
  source = future_sources[future]
3446
+ try:
3447
+ rows = future.result()
3448
+ except PipelineCancelledError:
3449
+ raise
3450
+ except Exception as exc:
3451
+ self._record_source_error(source, 'RETRIEVAL_FUTURE_ERROR', detail=exc)
3452
+ logging.warning(
3453
+ "Delivery-first literature source future skipped | source=%s error=%s",
3454
+ source, type(exc).__name__,
3455
+ )
3456
+ rows = []
3457
+ collected[source].extend(rows)
3458
  if progress_callback:
3459
  progress_callback()
3460
 
 
3463
  if phase == 0:
3464
  terms = self.generate_search_queries(topic, keywords)
3465
  else:
3466
+ try:
3467
+ response = self.call_deepseek(
3468
+ 'You are an academic librarian. Return only a JSON array with three new concise English search queries, each under five terms.',
3469
+ f"Topic: {topic}\nKeywords: {keywords}\nUsed: {sorted(all_used_terms)}",
3470
+ temp=0.4,
3471
+ model_role='fast',
3472
+ logical_node='search_jit',
3473
+ )
3474
+ parsed = self._extract_json_from_text(response)
3475
+ terms = parsed if isinstance(parsed, list) else []
3476
+ except PipelineCancelledError:
3477
+ raise
3478
+ except Exception as exc:
3479
+ logging.warning(
3480
+ 'Delivery-first auxiliary search-query phase skipped | phase=%s error=%s',
3481
+ phase, type(exc).__name__,
3482
+ )
3483
+ terms = []
3484
 
3485
  normalized_terms = []
3486
  for value in terms:
core/production_guard.py CHANGED
@@ -33,7 +33,7 @@ def replace_abstract(manuscript: str, abstract: str) -> str:
33
  replacement = f"## Abstract\n{str(abstract or '').strip()}\n\n"
34
  updated, count = re.subn(
35
  r"(?ms)^##\s+Abstract\s*$\n.*?(?=^##\s+Keywords\s*$)",
36
- replacement,
37
  value,
38
  count=1,
39
  )
@@ -469,6 +469,65 @@ def find_ai_writing_trace_findings(text: str) -> tuple[dict[str, Any], ...]:
469
  return tuple(findings)
470
 
471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  def validate_markdown_manuscript(text: str, *, language_name: str) -> None:
473
  value = str(text or "").strip()
474
  if len(value) < 1000:
@@ -672,68 +731,125 @@ def _manifest(entries: dict[str, bytes]) -> str:
672
  return "\n".join(rows) + "\n"
673
 
674
 
675
- def build_validated_package(
676
- *,
677
- english_markdown: str,
678
- translated_markdown: str,
679
- english_filename: str,
680
- translated_filename: str,
681
- data_metadata: dict[str, Any],
682
- mode: str,
683
- citation_audit: list[dict[str, Any]],
684
- literature_metrics: dict[str, Any],
685
- quality_diagnostics: list[dict[str, Any]] | None = None,
686
- quality_notes: list[dict[str, Any]] | None = None,
687
- evidence_coverage: dict[str, Any] | None = None,
688
- organize_appendix: bool = False,
689
- ) -> PackageResult:
690
- validate_markdown_manuscript(english_markdown, language_name="English")
691
- validate_markdown_manuscript(translated_markdown, language_name="Target-language")
692
 
693
- qualitative_mode = str(mode or "").strip().casefold() == "asq"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  try:
695
- english_docx = text_to_docx(
696
- english_markdown, qualitative_appendix=qualitative_mode
697
- ).getvalue()
698
- translated_docx = text_to_docx(
699
- translated_markdown, qualitative_appendix=qualitative_mode
700
  ).getvalue()
701
  except DocxBuildError as exc:
702
  raise OutputValidationError(str(exc)) from exc
703
- validate_docx_bytes(english_docx)
704
- validate_docx_bytes(translated_docx)
705
- quantitative_manifest = (data_metadata or {}).get("quantitative_table_manifest") if str(mode or "").strip().casefold() == "amj" else None
706
- if quantitative_manifest:
707
- validate_quantitative_table_contract(
708
- english_markdown, english_docx, quantitative_manifest, label="English DOCX"
709
- )
710
- validate_quantitative_table_contract(
711
- translated_markdown, translated_docx, quantitative_manifest, label="Target-language DOCX"
712
- )
713
- if qualitative_mode:
714
- validate_qualitative_appendix_docx_layout(english_docx, label="English DOCX")
715
- validate_qualitative_appendix_docx_layout(translated_docx, label="Target-language DOCX")
716
 
717
- appendix_prefix = "Appendix/" if organize_appendix else ""
718
- entries: dict[str, bytes] = {
719
- english_filename: english_docx,
720
- translated_filename: translated_docx,
721
- appendix_prefix + "GENERATION_NOTES.txt": build_generation_notes(data_metadata, mode).encode("utf-8"),
722
- appendix_prefix + "CITATION_AUDIT.json": json.dumps(citation_audit or [], ensure_ascii=False, indent=2).encode("utf-8"),
723
- appendix_prefix + "LITERATURE_METRICS.json": json.dumps(literature_metrics or {}, ensure_ascii=False, indent=2).encode("utf-8"),
724
- appendix_prefix + "WORD_COUNT_QUALITY_REPORT.txt": build_word_count_quality_report(quality_diagnostics).encode("utf-8"),
725
- appendix_prefix + "SmartPaper_Quality_Notes.txt": build_quality_notes(quality_notes).encode("utf-8"),
726
- appendix_prefix + "EVIDENCE_COVERAGE_REPORT.txt": build_evidence_coverage_report(evidence_coverage).encode("utf-8"),
727
- }
728
- manifest = _manifest(entries)
729
- entries[appendix_prefix + "FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8")
730
 
 
731
  buffer = io.BytesIO()
732
  with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
733
  for name, content in entries.items():
734
  archive.writestr(name, content)
735
- zip_bytes = buffer.getvalue()
736
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737
  try:
738
  with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive:
739
  bad = archive.testzip()
@@ -754,7 +870,7 @@ def build_validated_package(
754
  archived_markdown, archived_docx, quantitative_manifest,
755
  label="Archived English DOCX" if name == english_filename else "Archived target-language DOCX",
756
  )
757
- if qualitative_mode:
758
  validate_qualitative_appendix_docx_layout(
759
  archived_docx,
760
  label="Archived English DOCX" if name == english_filename else "Archived target-language DOCX",
@@ -764,4 +880,261 @@ def build_validated_package(
764
  except Exception as exc:
765
  raise OutputValidationError(f"ZIP cannot be reopened: {exc}") from exc
766
 
767
- return PackageResult(zip_bytes=zip_bytes, file_names=tuple(sorted(entries)), manifest=manifest)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  replacement = f"## Abstract\n{str(abstract or '').strip()}\n\n"
34
  updated, count = re.subn(
35
  r"(?ms)^##\s+Abstract\s*$\n.*?(?=^##\s+Keywords\s*$)",
36
+ lambda _match, value=replacement: value,
37
  value,
38
  count=1,
39
  )
 
469
  return tuple(findings)
470
 
471
 
472
+ def repair_markdown_for_delivery(text: str, *, language_name: str) -> tuple[str, list[dict[str, Any]]]:
473
+ """Deterministically remove noncritical process/meta leakage before packaging.
474
+
475
+ Unresolved citation identities are intentionally not repaired here because
476
+ they are P1 authority. High-confidence authorial/process leakage is removed
477
+ at paragraph granularity and recorded outside the manuscript.
478
+ """
479
+ value = str(text or "").strip()
480
+ notes: list[dict[str, Any]] = []
481
+ findings = [
482
+ dict(item) for item in find_ai_writing_trace_findings(value)
483
+ if str(item.get("relation") or "") != "unresolved_citation"
484
+ and str(item.get("kind") or "") in {"prompt_leak", "meta"}
485
+ ]
486
+ if findings:
487
+ spans = []
488
+ for item in findings:
489
+ start = max(0, int(item.get("start") or 0))
490
+ end = min(len(value), int(item.get("end") or start))
491
+ block_start = value.rfind("\n\n", 0, start)
492
+ block_start = 0 if block_start < 0 else block_start + 2
493
+ block_end = value.find("\n\n", end)
494
+ block_end = len(value) if block_end < 0 else block_end
495
+ spans.append((block_start, block_end))
496
+ merged = []
497
+ for start, end in sorted(spans):
498
+ if merged and start <= merged[-1][1]:
499
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
500
+ else:
501
+ merged.append((start, end))
502
+ for start, end in reversed(merged):
503
+ value = value[:start] + value[end:]
504
+ value = re.sub(r"\n{3,}", "\n\n", value).strip()
505
+ notes.append({
506
+ "severity": "ADVISORY",
507
+ "section": language_name,
508
+ "issue_type": "delivery_first_reader_facing_leak_removed",
509
+ "detail": (
510
+ f"{language_name} packaging removed {len(findings)} high-confidence internal/process or AI-meta item(s) "
511
+ "deterministically instead of terminating an otherwise complete manuscript."
512
+ ),
513
+ "user_action": "Review the affected location during final editorial polishing if desired.",
514
+ })
515
+ if value and not re.search(r"(?m)^#\s+", value):
516
+ lines = value.splitlines()
517
+ first_index = next((i for i, line in enumerate(lines) if line.strip()), None)
518
+ if first_index is not None:
519
+ lines[first_index] = "# " + re.sub(r"^#+\s*", "", lines[first_index]).strip()
520
+ value = "\n".join(lines).strip()
521
+ notes.append({
522
+ "severity": "ADVISORY",
523
+ "section": language_name,
524
+ "issue_type": "delivery_first_heading_reconstructed",
525
+ "detail": f"{language_name} manuscript had no Markdown heading marker; the first nonempty line was rebound as the title heading.",
526
+ "user_action": "Verify title styling before formal submission.",
527
+ })
528
+ return value, notes
529
+
530
+
531
  def validate_markdown_manuscript(text: str, *, language_name: str) -> None:
532
  value = str(text or "").strip()
533
  if len(value) < 1000:
 
731
  return "\n".join(rows) + "\n"
732
 
733
 
734
+ def _safe_markdown_docx(markdown: str) -> bytes:
735
+ """Independent minimal DOCX renderer for the P0 safe-rebuild path.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
 
737
+ The ordinary renderer owns publication styling. This fallback owns only
738
+ delivery: headings, paragraph text and Markdown tables are preserved in a
739
+ standards-compliant DOCX without advanced section/layout transformations.
740
+ It deliberately keeps tables so quantitative table-identity validation can
741
+ still run unchanged.
742
+ """
743
+ document = Document()
744
+ lines = str(markdown or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
745
+
746
+ def table_cells(line: str) -> list[str]:
747
+ value = str(line or "").strip()
748
+ if value.startswith("|"):
749
+ value = value[1:]
750
+ if value.endswith("|"):
751
+ value = value[:-1]
752
+ return [cell.strip() for cell in value.split("|")]
753
+
754
+ def is_separator(line: str) -> bool:
755
+ cells = table_cells(line)
756
+ return bool(cells) and all(bool(re.fullmatch(r":?-{3,}:?", cell.replace(" ", ""))) for cell in cells)
757
+
758
+ index = 0
759
+ while index < len(lines):
760
+ raw = lines[index]
761
+ line = raw.strip()
762
+ if not line:
763
+ index += 1
764
+ continue
765
+ heading = re.match(r"^(#{1,6})\s+(.+)$", line)
766
+ if heading:
767
+ document.add_heading(heading.group(2).strip(), level=min(6, len(heading.group(1))))
768
+ index += 1
769
+ continue
770
+ if (
771
+ "|" in line
772
+ and index + 1 < len(lines)
773
+ and "|" in lines[index + 1]
774
+ and is_separator(lines[index + 1])
775
+ ):
776
+ header = table_cells(line)
777
+ rows: list[list[str]] = []
778
+ index += 2
779
+ while index < len(lines):
780
+ candidate = lines[index].strip()
781
+ if not candidate or "|" not in candidate:
782
+ break
783
+ if is_separator(candidate):
784
+ index += 1
785
+ continue
786
+ cells = table_cells(candidate)
787
+ if len(cells) != len(header):
788
+ break
789
+ rows.append(cells)
790
+ index += 1
791
+ table = document.add_table(rows=1, cols=max(1, len(header)))
792
+ for col, cell in enumerate(header):
793
+ table.rows[0].cells[col].text = re.sub(r"[*_`]+", "", cell)
794
+ for row in rows:
795
+ cells = table.add_row().cells
796
+ for col, cell in enumerate(row):
797
+ cells[col].text = re.sub(r"[*_`]+", "", cell)
798
+ continue
799
+ bullet = re.match(r"^[-*+]\s+(.+)$", line)
800
+ if bullet:
801
+ document.add_paragraph(re.sub(r"[*_`]+", "", bullet.group(1)), style="List Bullet")
802
+ else:
803
+ document.add_paragraph(re.sub(r"[*_`]+", "", line))
804
+ index += 1
805
+
806
+ stream = io.BytesIO()
807
+ document.save(stream)
808
+ return stream.getvalue()
809
+
810
+
811
+ def _render_delivery_docx(markdown: str, *, qualitative_mode: bool, safe_mode: bool = False) -> bytes:
812
+ """Render normal publication DOCX, then use an independent safe renderer.
813
+
814
+ Safe mode changes presentation only: manuscript text and Markdown table
815
+ identity remain authoritative, while advanced Appendix/layout logic is
816
+ intentionally omitted. Quantitative table validation still runs after the
817
+ safe rebuild, so P1 result identity cannot be bypassed.
818
+ """
819
+ if safe_mode:
820
+ try:
821
+ return _safe_markdown_docx(markdown)
822
+ except Exception as exc:
823
+ raise OutputValidationError(f"Safe DOCX rebuild failed: {exc}") from exc
824
  try:
825
+ return text_to_docx(
826
+ markdown,
827
+ qualitative_appendix=bool(qualitative_mode),
 
 
828
  ).getvalue()
829
  except DocxBuildError as exc:
830
  raise OutputValidationError(str(exc)) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
831
 
 
 
 
 
 
 
 
 
 
 
 
 
 
832
 
833
+ def _build_zip_bytes(entries: dict[str, bytes]) -> bytes:
834
  buffer = io.BytesIO()
835
  with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
836
  for name, content in entries.items():
837
  archive.writestr(name, content)
838
+ return buffer.getvalue()
839
 
840
+
841
+ def _validate_zip_bytes(
842
+ zip_bytes: bytes,
843
+ *,
844
+ entries: dict[str, bytes],
845
+ english_filename: str,
846
+ translated_filename: str,
847
+ english_markdown: str,
848
+ translated_markdown: str,
849
+ quantitative_manifest: dict[str, Any] | None,
850
+ qualitative_mode: bool,
851
+ safe_mode: bool,
852
+ ) -> None:
853
  try:
854
  with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive:
855
  bad = archive.testzip()
 
870
  archived_markdown, archived_docx, quantitative_manifest,
871
  label="Archived English DOCX" if name == english_filename else "Archived target-language DOCX",
872
  )
873
+ if qualitative_mode and not safe_mode:
874
  validate_qualitative_appendix_docx_layout(
875
  archived_docx,
876
  label="Archived English DOCX" if name == english_filename else "Archived target-language DOCX",
 
880
  except Exception as exc:
881
  raise OutputValidationError(f"ZIP cannot be reopened: {exc}") from exc
882
 
883
+
884
+ def _validated_package_attempt(
885
+ *,
886
+ english_markdown: str,
887
+ translated_markdown: str,
888
+ english_filename: str,
889
+ translated_filename: str,
890
+ data_metadata: dict[str, Any],
891
+ mode: str,
892
+ citation_audit: list[dict[str, Any]],
893
+ literature_metrics: dict[str, Any],
894
+ quality_diagnostics: list[dict[str, Any]] | None,
895
+ quality_notes: list[dict[str, Any]] | None,
896
+ evidence_coverage: dict[str, Any] | None,
897
+ organize_appendix: bool,
898
+ safe_mode: bool,
899
+ ) -> PackageResult:
900
+ qualitative_mode = str(mode or "").strip().casefold() == "asq"
901
+ english_docx = _render_delivery_docx(
902
+ english_markdown, qualitative_mode=qualitative_mode, safe_mode=safe_mode,
903
+ )
904
+ translated_docx = _render_delivery_docx(
905
+ translated_markdown, qualitative_mode=qualitative_mode, safe_mode=safe_mode,
906
+ )
907
+ validate_docx_bytes(english_docx)
908
+ validate_docx_bytes(translated_docx)
909
+
910
+ quantitative_manifest = (
911
+ (data_metadata or {}).get("quantitative_table_manifest")
912
+ if str(mode or "").strip().casefold() == "amj" else None
913
+ )
914
+ if quantitative_manifest:
915
+ # P1 identity gate remains strict in both normal and safe-format modes.
916
+ validate_quantitative_table_contract(
917
+ english_markdown, english_docx, quantitative_manifest, label="English DOCX"
918
+ )
919
+ validate_quantitative_table_contract(
920
+ translated_markdown, translated_docx, quantitative_manifest, label="Target-language DOCX"
921
+ )
922
+ if qualitative_mode and not safe_mode:
923
+ validate_qualitative_appendix_docx_layout(english_docx, label="English DOCX")
924
+ validate_qualitative_appendix_docx_layout(translated_docx, label="Target-language DOCX")
925
+
926
+ appendix_prefix = "Appendix/" if organize_appendix else ""
927
+ notes = list(quality_notes or [])
928
+ if safe_mode:
929
+ notes.append({
930
+ "severity": "ADVISORY",
931
+ "section": "Output package",
932
+ "issue_type": "delivery_first_safe_docx_rebuild",
933
+ "detail": (
934
+ "The normal rich-output package did not pass final output validation. "
935
+ "SmartPaper rebuilt the DOCX package in safe-format mode without changing manuscript text, evidence, or quantitative table identity."
936
+ ),
937
+ "user_action": "Review final page orientation and table layout before formal submission.",
938
+ })
939
+ entries: dict[str, bytes] = {
940
+ english_filename: english_docx,
941
+ translated_filename: translated_docx,
942
+ appendix_prefix + "GENERATION_NOTES.txt": build_generation_notes(data_metadata, mode).encode("utf-8"),
943
+ appendix_prefix + "CITATION_AUDIT.json": json.dumps(citation_audit or [], ensure_ascii=False, indent=2).encode("utf-8"),
944
+ appendix_prefix + "LITERATURE_METRICS.json": json.dumps(literature_metrics or {}, ensure_ascii=False, indent=2).encode("utf-8"),
945
+ appendix_prefix + "WORD_COUNT_QUALITY_REPORT.txt": build_word_count_quality_report(quality_diagnostics).encode("utf-8"),
946
+ appendix_prefix + "SmartPaper_Quality_Notes.txt": build_quality_notes(notes).encode("utf-8"),
947
+ appendix_prefix + "EVIDENCE_COVERAGE_REPORT.txt": build_evidence_coverage_report(evidence_coverage).encode("utf-8"),
948
+ }
949
+ manifest = _manifest(entries)
950
+ entries[appendix_prefix + "FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8")
951
+
952
+ # Delivery-first ZIP recovery: a clean second build is mandatory before
953
+ # declaring output corruption terminal.
954
+ last_error: Exception | None = None
955
+ for _attempt in range(2):
956
+ zip_bytes = _build_zip_bytes(entries)
957
+ try:
958
+ _validate_zip_bytes(
959
+ zip_bytes,
960
+ entries=entries,
961
+ english_filename=english_filename,
962
+ translated_filename=translated_filename,
963
+ english_markdown=english_markdown,
964
+ translated_markdown=translated_markdown,
965
+ quantitative_manifest=quantitative_manifest,
966
+ qualitative_mode=qualitative_mode,
967
+ safe_mode=safe_mode,
968
+ )
969
+ return PackageResult(
970
+ zip_bytes=zip_bytes,
971
+ file_names=tuple(sorted(entries)),
972
+ manifest=manifest,
973
+ )
974
+ except OutputValidationError as exc:
975
+ last_error = exc
976
+ raise OutputValidationError(
977
+ f"ZIP validation failed after clean rebuild: {last_error}"
978
+ ) from last_error
979
+
980
+
981
+ def build_validated_package(
982
+ *,
983
+ english_markdown: str,
984
+ translated_markdown: str,
985
+ english_filename: str,
986
+ translated_filename: str,
987
+ data_metadata: dict[str, Any],
988
+ mode: str,
989
+ citation_audit: list[dict[str, Any]],
990
+ literature_metrics: dict[str, Any],
991
+ quality_diagnostics: list[dict[str, Any]] | None = None,
992
+ quality_notes: list[dict[str, Any]] | None = None,
993
+ evidence_coverage: dict[str, Any] | None = None,
994
+ organize_appendix: bool = False,
995
+ ) -> PackageResult:
996
+ # Delivery-first cleanup removes only high-confidence process/meta leakage
997
+ # and repairs missing heading markup. Citation identity remains fail-closed.
998
+ english_markdown, english_repair_notes = repair_markdown_for_delivery(
999
+ english_markdown, language_name="English"
1000
+ )
1001
+ translated_markdown, target_repair_notes = repair_markdown_for_delivery(
1002
+ translated_markdown, language_name="Target-language"
1003
+ )
1004
+ merged_quality_notes = list(quality_notes or []) + english_repair_notes + target_repair_notes
1005
+
1006
+ # Content completeness/integrity remains P1/P0 authority and is never
1007
+ # bypassed by safe-format packaging.
1008
+ validate_markdown_manuscript(english_markdown, language_name="English")
1009
+ validate_markdown_manuscript(translated_markdown, language_name="Target-language")
1010
+
1011
+ try:
1012
+ return _validated_package_attempt(
1013
+ english_markdown=english_markdown,
1014
+ translated_markdown=translated_markdown,
1015
+ english_filename=english_filename,
1016
+ translated_filename=translated_filename,
1017
+ data_metadata=data_metadata,
1018
+ mode=mode,
1019
+ citation_audit=citation_audit,
1020
+ literature_metrics=literature_metrics,
1021
+ quality_diagnostics=quality_diagnostics,
1022
+ quality_notes=merged_quality_notes,
1023
+ evidence_coverage=evidence_coverage,
1024
+ organize_appendix=organize_appendix,
1025
+ safe_mode=False,
1026
+ )
1027
+ except OutputValidationError as first_error:
1028
+ # P0 delivery-first: presentation/build failures get one deterministic
1029
+ # safe-format rebuild before the task may fail. Quantitative table
1030
+ # identity, DOCX reopenability and ZIP integrity remain strict in the
1031
+ # second attempt.
1032
+ try:
1033
+ return _validated_package_attempt(
1034
+ english_markdown=english_markdown,
1035
+ translated_markdown=translated_markdown,
1036
+ english_filename=english_filename,
1037
+ translated_filename=translated_filename,
1038
+ data_metadata=data_metadata,
1039
+ mode=mode,
1040
+ citation_audit=citation_audit,
1041
+ literature_metrics=literature_metrics,
1042
+ quality_diagnostics=quality_diagnostics,
1043
+ quality_notes=merged_quality_notes,
1044
+ evidence_coverage=evidence_coverage,
1045
+ organize_appendix=organize_appendix,
1046
+ safe_mode=True,
1047
+ )
1048
+ except OutputValidationError as second_error:
1049
+ raise OutputValidationError(
1050
+ "Validated package failed normal and safe-format rebuilds: "
1051
+ f"normal={first_error}; safe={second_error}"
1052
+ ) from second_error
1053
+
1054
+
1055
+ def _plain_recovery_docx(markdown: str) -> bytes:
1056
+ """Last-resort readable DOCX for FAILED/refunded Recovery Packages only."""
1057
+ document = Document()
1058
+ for raw_line in str(markdown or "").replace("\r\n", "\n").replace("\r", "\n").split("\n"):
1059
+ line = raw_line.strip()
1060
+ if not line:
1061
+ continue
1062
+ heading = re.match(r"^(#{1,6})\s+(.+)$", line)
1063
+ if heading:
1064
+ document.add_heading(heading.group(2).strip(), level=min(6, len(heading.group(1))))
1065
+ else:
1066
+ document.add_paragraph(re.sub(r"[*_`]+", "", line))
1067
+ stream = io.BytesIO()
1068
+ document.save(stream)
1069
+ return stream.getvalue()
1070
+
1071
+
1072
+ def build_recovery_package(
1073
+ *,
1074
+ english_markdown: str,
1075
+ translated_markdown: str = "",
1076
+ english_filename: str = "Paper_English.docx",
1077
+ translated_filename: str = "Paper_Target_Partial.docx",
1078
+ error_detail: str = "",
1079
+ ) -> PackageResult:
1080
+ """Build a non-success Recovery Package from already-frozen manuscript text.
1081
+
1082
+ This artifact is intentionally independent from the Success Contract. It is
1083
+ used only on FAILED/refunded tasks and therefore may omit an incomplete target
1084
+ manuscript, but the English DOCX must still reopen successfully.
1085
+ """
1086
+ english = str(english_markdown or "").strip()
1087
+ if len(english) < 1500:
1088
+ raise OutputValidationError("Recovery package requires a materially complete frozen English manuscript.")
1089
+ try:
1090
+ english_docx = _render_delivery_docx(english, qualitative_mode=False, safe_mode=True)
1091
+ validate_docx_bytes(english_docx)
1092
+ except Exception:
1093
+ english_docx = _plain_recovery_docx(english)
1094
+ validate_docx_bytes(english_docx)
1095
+
1096
+ entries: dict[str, bytes] = {english_filename: english_docx}
1097
+ target = str(translated_markdown or "").strip()
1098
+ if len(target) >= 800:
1099
+ try:
1100
+ target_docx = _render_delivery_docx(target, qualitative_mode=False, safe_mode=True)
1101
+ validate_docx_bytes(target_docx)
1102
+ except Exception:
1103
+ try:
1104
+ target_docx = _plain_recovery_docx(target)
1105
+ validate_docx_bytes(target_docx)
1106
+ except Exception:
1107
+ target_docx = b""
1108
+ if target_docx:
1109
+ entries[translated_filename] = target_docx
1110
+
1111
+ note = (
1112
+ "SmartPaper Recovery Package\n\n"
1113
+ "This package was created after the full Success Contract could not be completed.\n"
1114
+ "The task must remain FAILED and any reserved quota must be released/refunded.\n"
1115
+ "This recovery artifact must not be represented as a completed paid paper.\n\n"
1116
+ f"Terminal detail: {str(error_detail or '')[:1000]}\n"
1117
+ )
1118
+ entries["RECOVERY_NOTES.txt"] = note.encode("utf-8")
1119
+ manifest = _manifest(entries)
1120
+ entries["FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8")
1121
+
1122
+ last_error: Exception | None = None
1123
+ for _attempt in range(2):
1124
+ zip_bytes = _build_zip_bytes(entries)
1125
+ try:
1126
+ with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive:
1127
+ bad = archive.testzip()
1128
+ if bad:
1129
+ raise OutputValidationError(f"Recovery ZIP integrity failed at {bad}.")
1130
+ validate_docx_bytes(archive.read(english_filename))
1131
+ return PackageResult(
1132
+ zip_bytes=zip_bytes,
1133
+ file_names=tuple(sorted(entries)),
1134
+ manifest=manifest,
1135
+ )
1136
+ except Exception as exc:
1137
+ last_error = exc
1138
+ raise OutputValidationError(
1139
+ f"Recovery ZIP could not be validated after rebuild: {last_error}"
1140
+ ) from last_error
core/quantitative_engine.py CHANGED
@@ -270,7 +270,13 @@ class QuantitativeEngine:
270
  for source, target in sorted(
271
  dict(profile.get("phrases") or {}).items(), key=lambda row: len(row[0]), reverse=True
272
  ):
273
- text = re.sub(re.escape(source), str(target), text, flags=re.I)
 
 
 
 
 
 
274
  return text
275
 
276
  @classmethod
 
270
  for source, target in sorted(
271
  dict(profile.get("phrases") or {}).items(), key=lambda row: len(row[0]), reverse=True
272
  ):
273
+ replacement = str(target)
274
+ text = re.sub(
275
+ re.escape(source),
276
+ lambda _match, value=replacement: value,
277
+ text,
278
+ flags=re.I,
279
+ )
280
  return text
281
 
282
  @classmethod
i18n.py CHANGED
@@ -216,6 +216,8 @@ TEXTS: dict[str, dict[str, str]] = {
216
  "product_generation_not_active": "产品版任务生命周期尚未启用,请勿继续生成。",
217
  "success": "🎉 流水线推演完毕!双语文档已就绪。",
218
  "download": "📥 下载文件",
 
 
219
  "system_busy_title": "⏳ 系统忙碌",
220
  "system_busy_warning": "🚨 有用户正在生成论文",
221
  "system_busy_info": "💡 预计25–35分钟后可正常使用",
@@ -450,6 +452,8 @@ TEXTS: dict[str, dict[str, str]] = {
450
  "product_generation_not_active": "The product-tier task lifecycle is not active yet. Generation has been blocked to protect your balance.",
451
  "success": "🎉 The pipeline is complete. The bilingual documents are ready.",
452
  "download": "📥 Download Files",
 
 
453
  "system_busy_title": "⏳ System Busy",
454
  "system_busy_warning": "🚨 Another user is generating a paper",
455
  "system_busy_info": "💡 The system is expected to be available in 25–35 minutes",
@@ -684,6 +688,8 @@ TEXTS: dict[str, dict[str, str]] = {
684
  "product_generation_not_active": "Жизненный цикл задач продуктовых версий ещё не активирован. Генерация заблокирована для защиты баланса.",
685
  "success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.",
686
  "download": "📥 Скачать файлы",
 
 
687
  "system_busy_title": "⏳ Система занята",
688
  "system_busy_warning": "🚨 Другой пользователь формирует статью",
689
  "system_busy_info": "💡 Ожидаемое время доступности — через 25–35 минут",
@@ -918,6 +924,8 @@ TEXTS: dict[str, dict[str, str]] = {
918
  "product_generation_not_active": "Өнімдік нұсқалардың тапсырма өмірлік циклі әлі іске қосылған жоқ. Балансты қорғау үшін генерация бұғатталды.",
919
  "success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.",
920
  "download": "📥 Файлдарды жүктеу",
 
 
921
  "system_busy_title": "⏳ Жүйе бос емес",
922
  "system_busy_warning": "🚨 Басқа пайдаланушы мақала дайындап жатыр",
923
  "system_busy_info": "💡 Жүйе шамамен 25–35 минуттан кейін қолжетімді болады",
 
216
  "product_generation_not_active": "产品版任务生命周期尚未启用,请勿继续生成。",
217
  "success": "🎉 流水线推演完毕!双语文档已就绪。",
218
  "download": "📥 下载文件",
219
+ "recovery_download": "📥 下载恢复文件",
220
+ "recovery_notice": "本次任务未满足完整成功条件,额度将按失败规则处理。系统已保留可恢复的论文文件供下载;该恢复文件不视为成功生成的付费论文。",
221
  "system_busy_title": "⏳ 系统忙碌",
222
  "system_busy_warning": "🚨 有用户正在生成论文",
223
  "system_busy_info": "💡 预计25–35分钟后可正常使用",
 
452
  "product_generation_not_active": "The product-tier task lifecycle is not active yet. Generation has been blocked to protect your balance.",
453
  "success": "🎉 The pipeline is complete. The bilingual documents are ready.",
454
  "download": "📥 Download Files",
455
+ "recovery_download": "📥 Download Recovery Files",
456
+ "recovery_notice": "This task did not satisfy the full success contract, so quota is handled under the failure/refund rule. SmartPaper preserved recoverable manuscript files for download; this recovery package is not counted as a successfully generated paid paper.",
457
  "system_busy_title": "⏳ System Busy",
458
  "system_busy_warning": "🚨 Another user is generating a paper",
459
  "system_busy_info": "💡 The system is expected to be available in 25–35 minutes",
 
688
  "product_generation_not_active": "Жизненный цикл задач продуктовых версий ещё не активирован. Генерация заблокирована для защиты баланса.",
689
  "success": "🎉 Работа конвейера завершена. Двуязычные документы готовы.",
690
  "download": "📥 Скачать файлы",
691
+ "recovery_download": "📥 Скачать восстановленные файлы",
692
+ "recovery_notice": "Задача не выполнила полный контракт успешного завершения, поэтому квота обрабатывается по правилу сбоя/возврата. SmartPaper сохранил доступные для восстановления материалы статьи; этот пакет не считается успешно сгенерированной оплаченной статьёй.",
693
  "system_busy_title": "⏳ Система занята",
694
  "system_busy_warning": "🚨 Другой пользователь формирует статью",
695
  "system_busy_info": "💡 Ожидаемое время доступности — через 25–35 минут",
 
924
  "product_generation_not_active": "Өнімдік нұсқалардың тапсырма өмірлік циклі әлі іске қосылған жоқ. Балансты қорғау үшін генерация бұғатталды.",
925
  "success": "🎉 Жұмыс үдерісі аяқталды. Екі тілдегі құжаттар дайын.",
926
  "download": "📥 Файлдарды жүктеу",
927
+ "recovery_download": "📥 Қалпына келтіру файлдарын жүктеу",
928
+ "recovery_notice": "Бұл тапсырма толық сәтті аяқталу талаптарына сәйкес келмеді, сондықтан квота сәтсіздік/қайтару ережесі бойынша өңделеді. SmartPaper қалпына келтіруге болатын мақала материалдарын жүктеу үшін сақтады; бұл пакет сәтті жасалған ақылы мақала ретінде есептелмейді.",
929
  "system_busy_title": "⏳ Жүйе бос емес",
930
  "system_busy_warning": "🚨 Басқа пайдаланушы мақала дайындап жатыр",
931
  "system_busy_info": "💡 Жүйе шамамен 25–35 минуттан кейін қолжетімді болады",
task_executor.py CHANGED
@@ -126,34 +126,69 @@ def _is_local_checkpoint_authority_failure(exc: Exception) -> bool:
126
  return isinstance(exc, RuntimeError) and str(exc or "").startswith("checkpoint_authority_")
127
 
128
 
129
- def _is_recoverable_generation_failure(exc: Exception) -> bool:
130
- """Classify only post-claim execution failures.
131
-
132
- This deliberately avoids importing the generation pipeline at module import
133
- time. Provider/model exception class names are inspected only after
134
- claim/bootstrap has succeeded, so future provider adapters do not require
135
- executor imports or vendor-specific branching.
136
- """
137
  if isinstance(exc, ExecutorApiError):
138
- return exc.status_code == 429 or exc.status_code >= 500
139
  class_name = exc.__class__.__name__
 
 
140
  if class_name in {
141
- "DeepSeekAuthenticationError", "DeepSeekBalanceError", "DeepSeekRequestError",
142
- "ModelAuthenticationError", "ModelBalanceError", "ModelRequestError",
143
- "ModelProviderUnavailableError", "ModelGatewayError",
144
  }:
145
- return False
 
 
 
 
 
 
 
 
146
  if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)):
147
- return True
148
- if class_name == "DeepSeekAPIError":
149
- return True
150
- text = str(exc or "").lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  transient_markers = (
152
  "connection", "timed out", "timeout", "temporarily unavailable",
153
  "streaming failed after", "api failed after", "stream was interrupted",
154
  "insufficient eligible references",
155
  )
156
- return any(marker in text for marker in transient_markers)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
 
159
  class ExecutorApi:
@@ -694,6 +729,7 @@ def main() -> int:
694
  state.stop_heartbeat()
695
  return 2
696
 
 
697
  try:
698
  # Heavy paper modules are deliberately imported only after D1 claim and
699
  # the local claim handshake. Any import/init failure is now observable
@@ -761,6 +797,15 @@ def main() -> int:
761
  logging.exception("[SP_EXECUTOR] recoverable_interruption task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
762
  return 75
763
  logging.exception("[SP_EXECUTOR] failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
 
 
 
 
 
 
 
 
 
764
  try:
765
  api.finish(
766
  {
@@ -772,6 +817,7 @@ def main() -> int:
772
  "safe_error_code": "E_GENERATION_FAILED",
773
  "safe_error_detail": _safe(exc, 800),
774
  "current_node": "generation_failed",
 
775
  }
776
  )
777
  except Exception as finish_exc:
 
126
  return isinstance(exc, RuntimeError) and str(exc or "").startswith("checkpoint_authority_")
127
 
128
 
129
+ def _generation_failure_family(exc: Exception) -> str:
130
+ """Classify post-claim execution failures for the P0 delivery-first contract."""
 
 
 
 
 
 
131
  if isinstance(exc, ExecutorApiError):
132
+ return "TRANSPORT" if exc.status_code == 429 or exc.status_code >= 500 else "AUTH_POLICY_SECURITY"
133
  class_name = exc.__class__.__name__
134
+ text = str(exc or "").casefold()
135
+
136
  if class_name in {
137
+ "DeepSeekAuthenticationError", "DeepSeekBalanceError",
138
+ "ModelAuthenticationError", "ModelBalanceError",
 
139
  }:
140
+ return "AUTH_POLICY_SECURITY"
141
+ stable_policy_markers = (
142
+ "trusted_product_policy", "trusted_product_route_", "request_hash_mismatch",
143
+ "policy_snapshot", "entitlement", "authentication", "balance is insufficient",
144
+ "rejected the request format (400)", "not found (404)", "parameters are invalid (422)",
145
+ "unknown or unconfigured deepseek model role", "no deepseek model configured",
146
+ )
147
+ if any(marker in text for marker in stable_policy_markers):
148
+ return "AUTH_POLICY_SECURITY"
149
  if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)):
150
+ return "TRANSPORT"
151
+ if isinstance(exc, requests.exceptions.HTTPError):
152
+ response = getattr(exc, "response", None)
153
+ status = int(getattr(response, "status_code", 0) or 0)
154
+ if status in {408, 409, 425, 429, 500, 502, 503, 504}:
155
+ return "TRANSPORT"
156
+ if class_name in {"ModelProviderUnavailableError", "DeepSeekAPIError"}:
157
+ return "TRANSPORT"
158
+ if class_name == "DeepSeekRequestError":
159
+ content_validation_markers = (
160
+ "final abstract", "front matter", "translation", "citation", "hypothesis",
161
+ "manuscript", "qualitative", "glossary", "marker mismatch", "semantic",
162
+ "must not contain", "empty after validation", "structured validation",
163
+ )
164
+ return "CONTENT_VALIDATION" if any(marker in text for marker in content_validation_markers) else "AUTH_POLICY_SECURITY"
165
+ if class_name == "ModelRequestError":
166
+ # Gateway request errors are trusted route/request-shape failures; provider
167
+ # availability has its own recoverable exception class.
168
+ return "AUTH_POLICY_SECURITY"
169
+ if class_name == "ModelGatewayError":
170
+ transient_markers = ("unavailable", "timeout", "timed out", "connection", "provider")
171
+ return "TRANSPORT" if any(marker in text for marker in transient_markers) else "CONTENT_VALIDATION"
172
  transient_markers = (
173
  "connection", "timed out", "timeout", "temporarily unavailable",
174
  "streaming failed after", "api failed after", "stream was interrupted",
175
  "insufficient eligible references",
176
  )
177
+ if any(marker in text for marker in transient_markers):
178
+ return "TRANSPORT"
179
+ return "TERMINAL_INTEGRITY"
180
+
181
+
182
+ def _is_recoverable_generation_failure(exc: Exception) -> bool:
183
+ """Return True only for failures that should resume instead of refund/fail.
184
+
185
+ CONTENT_VALIDATION reaches this layer only after the runner's local repair/
186
+ fallback paths were unable to complete the current attempt. A new executor
187
+ attempt is still preferable to destroying the paper because model output is
188
+ nondeterministic; stable auth/policy/request-shape faults remain terminal.
189
+ """
190
+ return _generation_failure_family(exc) in {"TRANSPORT", "CONTENT_VALIDATION"}
191
+
192
 
193
 
194
  class ExecutorApi:
 
729
  state.stop_heartbeat()
730
  return 2
731
 
732
+ runner = None
733
  try:
734
  # Heavy paper modules are deliberately imported only after D1 claim and
735
  # the local claim handshake. Any import/init failure is now observable
 
797
  logging.exception("[SP_EXECUTOR] recoverable_interruption task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
798
  return 75
799
  logging.exception("[SP_EXECUTOR] failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
800
+ recovery = {}
801
+ if runner is not None:
802
+ try:
803
+ recovery = dict(runner.build_recovery_artifact(exc) or {})
804
+ except Exception as recovery_exc:
805
+ logging.warning(
806
+ "[SP_EXECUTOR] recovery_package_failed task_id=%s error=%s",
807
+ args.task_id, _safe(recovery_exc, 220),
808
+ )
809
  try:
810
  api.finish(
811
  {
 
817
  "safe_error_code": "E_GENERATION_FAILED",
818
  "safe_error_detail": _safe(exc, 800),
819
  "current_node": "generation_failed",
820
+ "output_filename": str(recovery.get("output_filename") or "")[:240],
821
  }
822
  )
823
  except Exception as finish_exc: