Spaces:
Running
Running
U1-R2C-G1 Delivery-First Completion
#129
by GuXiaopeng - opened
- app.py +15 -0
- core/generation_runner.py +838 -119
- core/pipeline_agent.py +68 -23
- core/production_guard.py +426 -53
- core/quantitative_engine.py +7 -1
- i18n.py +8 -0
- task_executor.py +64 -18
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4068 |
-
|
| 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 |
-
|
| 4081 |
-
|
| 4082 |
-
|
| 4083 |
-
|
| 4084 |
-
|
|
|
|
|
|
|
|
|
|
| 4085 |
)
|
| 4086 |
-
|
| 4087 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4088 |
research_logic_contract = re.sub(
|
| 4089 |
r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
|
| 4090 |
).strip()
|
| 4091 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4099 |
raise RuntimeError(
|
| 4100 |
-
f"
|
| 4101 |
)
|
| 4102 |
-
|
| 4103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4104 |
agent.global_memory["research_logic_contract"] = research_logic_contract
|
| 4105 |
|
| 4106 |
self._progress(14, "working_front_matter")
|
| 4107 |
-
|
| 4108 |
-
|
| 4109 |
-
|
| 4110 |
-
|
| 4111 |
-
|
| 4112 |
-
|
| 4113 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4140 |
-
|
| 4141 |
-
|
| 4142 |
-
|
| 4143 |
-
|
| 4144 |
-
|
| 4145 |
-
|
| 4146 |
-
|
| 4147 |
-
|
| 4148 |
-
|
| 4149 |
-
|
| 4150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4404 |
raise RuntimeError(
|
| 4405 |
-
f"
|
| 4406 |
)
|
| 4407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4473 |
-
|
| 4474 |
-
|
| 4475 |
-
|
| 4476 |
-
|
| 4477 |
-
|
| 4478 |
-
|
| 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 |
-
|
| 4486 |
-
|
| 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 |
-
|
| 4702 |
-
|
| 4703 |
-
|
| 4704 |
-
|
| 4705 |
-
|
| 4706 |
-
|
| 4707 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4729 |
-
|
| 4730 |
-
|
| 4731 |
-
|
| 4732 |
-
|
| 4733 |
-
|
| 4734 |
-
|
| 4735 |
-
|
| 4736 |
-
|
| 4737 |
-
|
| 4738 |
-
|
| 4739 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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,
|
|
|
|
| 5171 |
)
|
| 5172 |
retry_capability_context = self._section_citation_capability_context(agent, planned_items)
|
| 5173 |
-
candidate_user_prompt =
|
| 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,
|
|
|
|
| 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.
|
| 5762 |
-
|
| 5763 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 5878 |
-
|
| 5879 |
-
|
| 5880 |
-
|
| 5881 |
-
|
| 5882 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 5913 |
-
|
| 5914 |
-
|
| 5915 |
-
|
| 5916 |
-
|
| 5917 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 5972 |
-
|
| 5973 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 6012 |
-
|
| 6013 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 2978 |
-
|
| 2979 |
-
|
| 2980 |
-
|
| 2981 |
-
|
| 2982 |
-
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3431 |
-
|
| 3432 |
-
|
| 3433 |
-
|
| 3434 |
-
|
| 3435 |
-
|
| 3436 |
-
|
| 3437 |
-
|
| 3438 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
try:
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 130 |
-
"""Classify
|
| 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",
|
| 142 |
-
"ModelAuthenticationError", "ModelBalanceError",
|
| 143 |
-
"ModelProviderUnavailableError", "ModelGatewayError",
|
| 144 |
}:
|
| 145 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)):
|
| 147 |
-
return
|
| 148 |
-
if
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|