R14-TQ2-PQ1-R4-R1 Production Readiness Consolidated Fix

#66
app.py CHANGED
@@ -759,8 +759,8 @@ def _render_monitor_box(task: dict, lang_code: str) -> None:
759
  """
760
 
761
  st.markdown(f"""
762
- {progress_markup}
763
  <div class='log-box' id='sp-live-monitor'>
 
764
  <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> <span id='sp-live-status'>{html.escape(status_label)}</span></div>
765
  <div><b>{tr(lang_code, 'monitor_node')}:</b> <span id='sp-live-node'>{html.escape(node_label)}</span></div>
766
  <div><b>{tr(lang_code, 'monitor_log')}:</b> <span id='sp-live-checkpoint-stage'>{html.escape(tr(lang_code, 'monitor_checkpoint_saved', seq=checkpoint_seq))}</span><span id='sp-live-checkpoint-seq' style='display:none'>{checkpoint_seq}</span></div>
@@ -794,6 +794,14 @@ def _watch_task_status_live(task: dict) -> dict | None:
794
  if not api_origin:
795
  logging.warning("[SP_UI] live_watch_missing_api_origin task_id=%s", task_id)
796
  return None
 
 
 
 
 
 
 
 
797
  try:
798
  watch_result = edge_bridge(
799
  action="task_watch",
@@ -805,8 +813,9 @@ def _watch_task_status_live(task: dict) -> dict | None:
805
  "preview_interval_ms": 700,
806
  "preview_path": _task_preview_path(task_id),
807
  "expected_execution_epoch": int(task.get("execution_epoch") or 0),
 
808
  "initial_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
809
- "best_effort_page_exit_cancel": True,
810
  "draft_status_labels": {
811
  "default": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_preparing"),
812
  "inputs_and_evidence": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_inputs"),
 
759
  """
760
 
761
  st.markdown(f"""
 
762
  <div class='log-box' id='sp-live-monitor'>
763
+ {progress_markup}
764
  <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> <span id='sp-live-status'>{html.escape(status_label)}</span></div>
765
  <div><b>{tr(lang_code, 'monitor_node')}:</b> <span id='sp-live-node'>{html.escape(node_label)}</span></div>
766
  <div><b>{tr(lang_code, 'monitor_log')}:</b> <span id='sp-live-checkpoint-stage'>{html.escape(tr(lang_code, 'monitor_checkpoint_saved', seq=checkpoint_seq))}</span><span id='sp-live-checkpoint-seq' style='display:none'>{checkpoint_seq}</span></div>
 
794
  if not api_origin:
795
  logging.warning("[SP_UI] live_watch_missing_api_origin task_id=%s", task_id)
796
  return None
797
+ expected_request_hash = str(task.get("request_hash") or "").strip().lower()
798
+ if not expected_request_hash:
799
+ try:
800
+ request_path = _task_dir(task_id) / "request.json"
801
+ if request_path.is_file():
802
+ expected_request_hash = str(json.loads(request_path.read_text(encoding="utf-8")).get("request_hash") or "").strip().lower()
803
+ except Exception:
804
+ expected_request_hash = ""
805
  try:
806
  watch_result = edge_bridge(
807
  action="task_watch",
 
813
  "preview_interval_ms": 700,
814
  "preview_path": _task_preview_path(task_id),
815
  "expected_execution_epoch": int(task.get("execution_epoch") or 0),
816
+ "expected_request_hash": expected_request_hash,
817
  "initial_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
818
+ "best_effort_page_exit_cancel": False,
819
  "draft_status_labels": {
820
  "default": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_preparing"),
821
  "inputs_and_evidence": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_inputs"),
config.py CHANGED
@@ -15,6 +15,17 @@ def load_config():
15
  "s2_max_logical_searches": max(1, min(6, int(os.environ.get("S2_MAX_LOGICAL_SEARCHES", "3")))),
16
  "citation_planner_batch_size": max(8, min(24, int(os.environ.get("CITATION_PLANNER_BATCH_SIZE", "18")))),
17
  "citation_planner_max_tokens": max(1200, min(3200, int(os.environ.get("CITATION_PLANNER_MAX_TOKENS", "2200")))),
 
 
 
 
 
 
 
 
 
 
 
18
  "chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
19
  "target_words_min": 5000,
20
  "target_words_default": 5000,
 
15
  "s2_max_logical_searches": max(1, min(6, int(os.environ.get("S2_MAX_LOGICAL_SEARCHES", "3")))),
16
  "citation_planner_batch_size": max(8, min(24, int(os.environ.get("CITATION_PLANNER_BATCH_SIZE", "18")))),
17
  "citation_planner_max_tokens": max(1200, min(3200, int(os.environ.get("CITATION_PLANNER_MAX_TOKENS", "2200")))),
18
+ # R14-TQ2-PQ1-R4-R1: pre-draft reasoning is bounded and observable.
19
+ # These are diagnostic SLA budgets, not manuscript-failure authorities.
20
+ "prewrite_reasoning_max_tokens": {
21
+ "literature_synthesis": max(1200, min(3200, int(os.environ.get("PREWRITE_SYNTHESIS_REASONING_MAX_TOKENS", "2400")))),
22
+ "qualitative_packet": max(1200, min(3200, int(os.environ.get("PREWRITE_QUAL_PACKET_REASONING_MAX_TOKENS", "2400")))),
23
+ },
24
+ "prewrite_sla_seconds": {
25
+ "literature_synthesis": max(30, int(os.environ.get("PREWRITE_SYNTHESIS_SLA_SECONDS", "90"))),
26
+ "research_logic": max(15, int(os.environ.get("PREWRITE_RESEARCH_LOGIC_SLA_SECONDS", "30"))),
27
+ "qualitative_packet": max(30, int(os.environ.get("PREWRITE_QUAL_PACKET_SLA_SECONDS", "75"))),
28
+ },
29
  "chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
30
  "target_words_min": 5000,
31
  "target_words_default": 5000,
core/generation_runner.py CHANGED
@@ -1721,7 +1721,7 @@ class GenerationRunner:
1721
  )
1722
 
1723
  @staticmethod
1724
- def _evidence_coverage_summary(*, status, early_risk, final_reference_count, minimums, validated_by_top_level, claims_by_top_level, events):
1725
  sections = []
1726
  for section_id, planned_minimum in dict(minimums or {}).items():
1727
  validated = len(set((validated_by_top_level or {}).get(section_id) or []))
@@ -1739,9 +1739,52 @@ class GenerationRunner:
1739
  "early_risk_detected": bool(early_risk),
1740
  "final_reference_count": max(0, int(final_reference_count or 0)),
1741
  "sections": sections,
 
 
 
 
1742
  "events": list(events or []),
1743
  }
1744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1745
  def _resolve_paths(self, items) -> list[str]:
1746
  output = []
1747
  for item in items or []:
@@ -1834,7 +1877,10 @@ class GenerationRunner:
1834
  )
1835
  else:
1836
  raw_interviews = extract_text_from_files(interview_paths[:1]) if interview_paths else ""
 
 
1837
  user_data_md, _, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
 
1838
  state.update(
1839
  {
1840
  "topic": topic,
@@ -1894,9 +1940,12 @@ class GenerationRunner:
1894
  synthesis_result = str(state.get("synthesis_result") or "")
1895
  if not synthesis_result:
1896
  self._progress(12, "literature_synthesis")
 
 
1897
  synthesis_result = self._stream_text(
1898
  agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)
1899
  )
 
1900
  synthesis_result = re.sub(r"<think>.*?</think>", "", synthesis_result, flags=re.DOTALL).strip()
1901
  state.update({"synthesis_result": synthesis_result, "agent_state": agent.export_state()})
1902
  self._checkpoint("synthesis_ready", state, 13)
@@ -1904,12 +1953,15 @@ class GenerationRunner:
1904
  adaptive_structure = state.get("adaptive_structure")
1905
  if not isinstance(adaptive_structure, list) or not adaptive_structure:
1906
  self._progress(14, "research_logic")
 
 
1907
  research_logic_response = self._stream_text(
1908
  agent.stream_generate_research_logic(
1909
  topic, keywords, synthesis_result, refs_text,
1910
  data_evidence=planning_data_md, mode=mode,
1911
  )
1912
  )
 
1913
  research_logic_contract = re.sub(
1914
  r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
1915
  ).strip()
@@ -2416,6 +2468,8 @@ class GenerationRunner:
2416
  f"Already completed: {completed_label}. Do not restate their background, gap, or contribution."
2417
  if completed_label else ""
2418
  )
 
 
2419
  voice = str(APP_CONFIG.get("journal_voice_profiles", {}).get(mode) or "")
2420
  sys_prompt = (
2421
  APP_CONFIG.get("sys_academic_writer", "") + "\n"
@@ -2575,6 +2629,10 @@ Output only final academic prose. Do not report the word count."""
2575
  data_metadata=data_metadata,
2576
  quote_invocation_plan=quote_invocation_plan,
2577
  )
 
 
 
 
2578
  full_draft_en += en_resolved + "\n\n"
2579
  if assigned_tag_list:
2580
  logging.info(
@@ -2647,6 +2705,8 @@ Output only final academic prose. Do not report the word count."""
2647
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
2648
  if repeated_quotes:
2649
  raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
 
 
2650
  references = []
2651
  seen_refs = set()
2652
  for tag in sorted(agent.used_citations, key=agent._citation_tag_number):
@@ -2667,6 +2727,13 @@ Output only final academic prose. Do not report the word count."""
2667
  claims_by_top_level[section_id] = claims_by_top_level.get(section_id, 0) + int(
2668
  citation_required_claim_counts_by_unit.get(unit_name, 0) or 0
2669
  )
 
 
 
 
 
 
 
2670
  final_shortfalls = []
2671
  for section_id, planned_minimum in citation_top_level_minimums.items():
2672
  validated_tags = set(validated_by_top_level.get(section_id) or [])
@@ -2704,6 +2771,8 @@ Output only final academic prose. Do not report the word count."""
2704
  validated_by_top_level=validated_by_top_level,
2705
  claims_by_top_level=claims_by_top_level,
2706
  events=state.get("evidence_coverage_events") or self._evidence_coverage_events,
 
 
2707
  )
2708
  state["evidence_status"] = evidence_status
2709
  state["evidence_coverage_summary"] = evidence_coverage_summary
 
1721
  )
1722
 
1723
  @staticmethod
1724
+ def _evidence_coverage_summary(*, status, early_risk, final_reference_count, minimums, validated_by_top_level, claims_by_top_level, events, method_authority_count=0, method_external_claims=0):
1725
  sections = []
1726
  for section_id, planned_minimum in dict(minimums or {}).items():
1727
  validated = len(set((validated_by_top_level or {}).get(section_id) or []))
 
1739
  "early_risk_detected": bool(early_risk),
1740
  "final_reference_count": max(0, int(final_reference_count or 0)),
1741
  "sections": sections,
1742
+ "methodology_authority": {
1743
+ "validated_method_authority_citations": max(0, int(method_authority_count or 0)),
1744
+ "methodology_external_claims": max(0, int(method_external_claims or 0)),
1745
+ },
1746
  "events": list(events or []),
1747
  }
1748
 
1749
+ @staticmethod
1750
+ def _paragraph_token_set(value):
1751
+ text = re.sub(r'\[CITE-\d+\]', ' ', str(value or ''), flags=re.I)
1752
+ return set(re.findall(r'[A-Za-z][A-Za-z0-9_-]{2,}', text.lower()))
1753
+
1754
+ @classmethod
1755
+ def _remove_severe_cross_unit_duplicates(cls, candidate, prior_text):
1756
+ """Drop only very high-confidence cross-unit duplicate paragraphs."""
1757
+ previous = [p.strip() for p in re.split(r'\n\s*\n', str(prior_text or '')) if len(p.split()) >= 55 and not p.lstrip().startswith('#')]
1758
+ kept, diagnostics = [], []
1759
+ for paragraph in re.split(r'(\n\s*\n)', str(candidate or '')):
1760
+ if re.fullmatch(r'\n\s*\n', paragraph or ''):
1761
+ kept.append(paragraph); continue
1762
+ value = str(paragraph or '').strip()
1763
+ if len(value.split()) < 55 or value.startswith('#'):
1764
+ kept.append(paragraph); continue
1765
+ tokens = cls._paragraph_token_set(value)
1766
+ citations = set(re.findall(r'\[CITE-\d+\]', value, flags=re.I))
1767
+ numerics = set(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])', value))
1768
+ duplicate = None
1769
+ for old in previous:
1770
+ old_tokens = cls._paragraph_token_set(old)
1771
+ union = tokens | old_tokens
1772
+ similarity = len(tokens & old_tokens) / max(1, len(union))
1773
+ if similarity < 0.90:
1774
+ continue
1775
+ old_citations = set(re.findall(r'\[CITE-\d+\]', old, flags=re.I))
1776
+ old_numerics = set(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])', old))
1777
+ # Never delete a paragraph carrying different evidence/facts.
1778
+ if citations == old_citations and numerics == old_numerics:
1779
+ duplicate = (similarity, old[:180])
1780
+ break
1781
+ if duplicate:
1782
+ diagnostics.append({"similarity": round(duplicate[0], 4), "removed_preview": value[:180]})
1783
+ continue
1784
+ kept.append(paragraph)
1785
+ previous.append(value)
1786
+ return ''.join(kept).strip(), diagnostics
1787
+
1788
  def _resolve_paths(self, items) -> list[str]:
1789
  output = []
1790
  for item in items or []:
 
1877
  )
1878
  else:
1879
  raw_interviews = extract_text_from_files(interview_paths[:1]) if interview_paths else ""
1880
+ prewrite_started = time.monotonic()
1881
+ prewrite_calls = agent.ds_calls
1882
  user_data_md, _, data_metadata = qual_engine.resolve_qualitative_data(topic, keywords, raw_interviews)
1883
+ agent.record_prewrite_stage("qualitative_packet", prewrite_started, prewrite_calls)
1884
  state.update(
1885
  {
1886
  "topic": topic,
 
1940
  synthesis_result = str(state.get("synthesis_result") or "")
1941
  if not synthesis_result:
1942
  self._progress(12, "literature_synthesis")
1943
+ prewrite_started = time.monotonic()
1944
+ prewrite_calls = agent.ds_calls
1945
  synthesis_result = self._stream_text(
1946
  agent.stream_synthesize_literature(topic, refs_text, user_core_literature=user_lit_context)
1947
  )
1948
+ agent.record_prewrite_stage("literature_synthesis", prewrite_started, prewrite_calls)
1949
  synthesis_result = re.sub(r"<think>.*?</think>", "", synthesis_result, flags=re.DOTALL).strip()
1950
  state.update({"synthesis_result": synthesis_result, "agent_state": agent.export_state()})
1951
  self._checkpoint("synthesis_ready", state, 13)
 
1953
  adaptive_structure = state.get("adaptive_structure")
1954
  if not isinstance(adaptive_structure, list) or not adaptive_structure:
1955
  self._progress(14, "research_logic")
1956
+ prewrite_started = time.monotonic()
1957
+ prewrite_calls = agent.ds_calls
1958
  research_logic_response = self._stream_text(
1959
  agent.stream_generate_research_logic(
1960
  topic, keywords, synthesis_result, refs_text,
1961
  data_evidence=planning_data_md, mode=mode,
1962
  )
1963
  )
1964
+ agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
1965
  research_logic_contract = re.sub(
1966
  r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
1967
  ).strip()
 
2468
  f"Already completed: {completed_label}. Do not restate their background, gap, or contribution."
2469
  if completed_label else ""
2470
  )
2471
+ if re.search(r"\(Part\s+2/2\)", display_name, flags=re.I):
2472
+ anti_repeat += " Part 2/2 must extend and complement Part 1/2; do not recap its background, mechanism explanation, evidence, or contribution framing."
2473
  voice = str(APP_CONFIG.get("journal_voice_profiles", {}).get(mode) or "")
2474
  sys_prompt = (
2475
  APP_CONFIG.get("sys_academic_writer", "") + "\n"
 
2629
  data_metadata=data_metadata,
2630
  quote_invocation_plan=quote_invocation_plan,
2631
  )
2632
+ en_resolved, duplicate_rows = self._remove_severe_cross_unit_duplicates(en_resolved, full_draft_en)
2633
+ if duplicate_rows:
2634
+ logging.warning("Severe cross-unit redundancy removed | unit=%s count=%s max_similarity=%s", display_name, len(duplicate_rows), max(row["similarity"] for row in duplicate_rows))
2635
+ quality_diagnostics.append({"scope":"manuscript_redundancy","name":display_name,"status":"bounded_duplicate_removed","count":len(duplicate_rows),"examples":duplicate_rows[:3]})
2636
  full_draft_en += en_resolved + "\n\n"
2637
  if assigned_tag_list:
2638
  logging.info(
 
2705
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
2706
  if repeated_quotes:
2707
  raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
2708
+ actual_used_tags = {f"[{tag.upper()}]" for tag in re.findall(r"CITE-\d+", full_draft_en, flags=re.I)}
2709
+ agent.used_citations = set(tag for tag in actual_used_tags if tag in agent.citation_map)
2710
  references = []
2711
  seen_refs = set()
2712
  for tag in sorted(agent.used_citations, key=agent._citation_tag_number):
 
2727
  claims_by_top_level[section_id] = claims_by_top_level.get(section_id, 0) + int(
2728
  citation_required_claim_counts_by_unit.get(unit_name, 0) or 0
2729
  )
2730
+ method_section_id = 'methods' if mode == 'asq' else 'methodology'
2731
+ method_validated_tags = set(validated_by_top_level.get(method_section_id) or []) & set(agent.used_citations)
2732
+ method_authority_tags = {
2733
+ tag for tag in method_validated_tags
2734
+ if 'method_authority' in set((agent.citation_map.get(tag) or {}).get('evidence_lanes') or [])
2735
+ }
2736
+ method_external_claims = max(0, int(claims_by_top_level.get(method_section_id, 0) or 0))
2737
  final_shortfalls = []
2738
  for section_id, planned_minimum in citation_top_level_minimums.items():
2739
  validated_tags = set(validated_by_top_level.get(section_id) or [])
 
2771
  validated_by_top_level=validated_by_top_level,
2772
  claims_by_top_level=claims_by_top_level,
2773
  events=state.get("evidence_coverage_events") or self._evidence_coverage_events,
2774
+ method_authority_count=len(method_authority_tags),
2775
+ method_external_claims=method_external_claims,
2776
  )
2777
  state["evidence_status"] = evidence_status
2778
  state["evidence_coverage_summary"] = evidence_coverage_summary
core/pipeline_agent.py CHANGED
@@ -139,6 +139,7 @@ class SSciPipelineAgent:
139
  # R3.2.8.6: translation validators emit hierarchical assurance evidence.
140
  # Low-level diagnostics cannot independently terminate a manuscript.
141
  self.translation_assurance_log = []
 
142
  self.lock = threading.RLock()
143
 
144
  self.lit_target_papers = APP_CONFIG["lit_target_papers"]
@@ -223,6 +224,25 @@ class SSciPipelineAgent:
223
  type(exc).__name__,
224
  )
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  def _check_cancelled(self):
227
  if self._cancel_checker is not None:
228
  result = self._cancel_checker()
@@ -293,6 +313,7 @@ class SSciPipelineAgent:
293
  "used_citations": list(self.used_citations),
294
  "citation_audit_log": list(self.citation_audit_log),
295
  "translation_assurance_log": list(self.translation_assurance_log),
 
296
  "global_memory": self.global_memory,
297
  "s2_access_mode": self._s2_access_mode,
298
  "s2_logical_searches": int(self._s2_logical_searches),
@@ -315,6 +336,7 @@ class SSciPipelineAgent:
315
  self.used_citations = set(state_dict.get("used_citations", []))
316
  self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
317
  self.translation_assurance_log = list(state_dict.get("translation_assurance_log", []))
 
318
  self.global_memory = state_dict.get("global_memory", self.global_memory)
319
  loaded_mode = str(state_dict.get("s2_access_mode") or "").strip().lower()
320
  expected_mode = "key" if self.s2_key else "anonymous"
@@ -1227,9 +1249,11 @@ class SSciPipelineAgent:
1227
 
1228
  def _stream_with_reasoning_exhaustion_recovery():
1229
  try:
 
1230
  yield from self.stream_deepseek(
1231
  sys_prompt,
1232
  user_prompt,
 
1233
  model_role="reasoning",
1234
  thinking=True,
1235
  allow_fallback=False,
@@ -1252,6 +1276,7 @@ class SSciPipelineAgent:
1252
  yield from self.stream_deepseek(
1253
  recovery_system,
1254
  user_prompt,
 
1255
  model_role="reasoning",
1256
  thinking=False,
1257
  allow_fallback=False,
@@ -1291,34 +1316,18 @@ Literature Synthesis:
1291
  {evidence_block}
1292
  {mode_directive}
1293
  Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
1294
- def _stream_with_reasoning_exhaustion_recovery():
1295
- try:
1296
- yield from self.stream_deepseek(
1297
- sys_prompt,
1298
- user_prompt,
1299
- max_tokens=2200,
1300
- model_role="reasoning",
1301
- thinking=True,
1302
- allow_fallback=False,
1303
- reasoning_only_recovery=True,
1304
- )
1305
- return
1306
- except DeepSeekReasoningOnlyExhaustionError as exc:
1307
- logging.warning(
1308
- "Research logic reasoning-only exhaustion; retrying once with thinking disabled: %s",
1309
- exc,
1310
- )
1311
- self._touch_execution_progress("research_logic_direct_recovery")
1312
-
1313
- recovery_system = (
1314
  sys_prompt
1315
- + "\n\nRECOVERY MODE: Produce the final provisional research logic contract directly. "
1316
- "Do not perform extended hidden reasoning. Preserve the original topic, literature synthesis, "
1317
- "research evidence, uncertainty, evidence-strength boundaries, and mode-specific planning constraints. "
1318
- "Do not invent findings, coefficients, causal claims, or facts."
1319
  )
1320
  yield from self.stream_deepseek(
1321
- recovery_system,
1322
  user_prompt,
1323
  max_tokens=2200,
1324
  model_role="reasoning",
@@ -1326,7 +1335,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1326
  allow_fallback=False,
1327
  )
1328
 
1329
- return _stream_with_reasoning_exhaustion_recovery()
1330
 
1331
  @staticmethod
1332
  def _front_matter_keywords(value, minimum=4, maximum=6):
@@ -1821,56 +1830,87 @@ USER KEYWORD SEEDS: {keywords}
1821
  violations.append({"tag": item.get("tag"), "count": count})
1822
  return violations
1823
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1824
  def prefetch_jit_literature(self, topic, keywords, structure):
 
1825
  jit_rag_texts = []
1826
- queries_to_fetch = set()
1827
-
1828
- for sec in structure:
1829
- sec_title = sec.get("section", "")
1830
- if any(kw in sec_title.lower() for kw in ["method", "methodology", "analysis", "result", "finding", "setting"]):
1831
- jit_prompt = f"Topic: '{topic}'. Keywords: '{keywords}'. Section: '{sec_title}'. Generate exactly 1 highly specific English search query (e.g., a specific statistical method, measurement scale, or analytical framework needed). Keep it UNDER 5 terms. NO complex boolean logic. Return ONLY the query string, no quotes."
1832
- query = self.call_deepseek("You are an expert academic librarian.", jit_prompt, max_tokens=50, temp=0.2, model_role="fast").strip(' "\'')
1833
- if query:
1834
- queries_to_fetch.add(query)
1835
-
1836
  if not queries_to_fetch:
1837
  return ""
1838
-
1839
- logging.info(f"🔍 [Pre-fetch JIT] 生成了 {len(queries_to_fetch)} 个定向检索词: {queries_to_fetch}")
1840
-
1841
  with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor:
1842
- futures = []
1843
  for query in queries_to_fetch:
1844
- futures.append(executor.submit(self._s2_worker, query, 0))
1845
- futures.append(executor.submit(self._oa_worker, query, 1, "language:en,type:article,is_paratext:false"))
1846
-
1847
- for future in concurrent.futures.as_completed(futures):
1848
  results = future.result()
 
1849
  with self.lock:
1850
  for res in results:
1851
  result_keys = self._reference_keys(res)
1852
  existing_keys = {
1853
- key
1854
- for metadata in self.citation_map.values()
1855
- for key in self._reference_keys(metadata)
1856
  }
1857
  if result_keys and any(key in existing_keys for key in result_keys):
 
 
 
 
 
 
 
1858
  continue
1859
  cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
1860
  self.citation_map[cite_tag] = {
1861
- "in_text": res["in_text"],
1862
- "ref": res["ref"],
1863
  "id": result_keys[0] if result_keys else ('title_year', res.get('title'), res.get('year')),
1864
- "doi": res.get("doi", ""),
1865
- "external_id": res.get("external_id", ""),
1866
- "title": res.get("title", ""),
1867
- "year": res.get("year", ""),
1868
- "evidence": res.get("rag_text", ""),
1869
- "sources": [res.get("source", "jit")],
1870
  }
1871
  jit_rag_texts.append(f"{cite_tag} {res.get('rag_text', '')}")
1872
-
1873
- logging.info(f"✅ [Pre-fetch JIT] 成功抓取并注入了 {len(jit_rag_texts)} 篇底层支撑文献。")
1874
  return "\n".join(jit_rag_texts)
1875
 
1876
  def generate_search_queries(self, topic, keywords):
@@ -2008,6 +2048,36 @@ Return ONLY a valid JSON array of strings."""
2008
  before_request=self._s2_before_http_attempt,
2009
  )
2010
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2011
  def _s2_worker(self, term, offset):
2012
  # Serialize complete logical searches. This is deliberately separate
2013
  # from the HTTP-attempt gate: OpenAlex can still run concurrently, while
@@ -2084,15 +2154,9 @@ Return ONLY a valid JSON array of strings."""
2084
  if not title or not year or not self._is_english_dominant(title):
2085
  continue
2086
  authors = paper.get('authors') or []
2087
- last_names, formatted_authors = [], []
2088
- for author in authors:
2089
- name = str(author.get('name') or '').strip()
2090
- if not name:
2091
- continue
2092
- parts = name.split()
2093
- last = parts[-1]
2094
- last_names.append(last)
2095
- formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last)
2096
  if not last_names:
2097
  continue
2098
  if len(last_names) == 1:
@@ -2188,14 +2252,10 @@ Return ONLY a valid JSON array of strings."""
2188
  if not title or not self._is_english_dominant(title):
2189
  continue
2190
  year = item.get('publication_year') or 'n.d.'
2191
- formatted_authors, last_names = [], []
2192
- for authorship in item.get('authorships') or []:
2193
- name = str((authorship.get('author') or {}).get('display_name') or '').strip()
2194
- if not name:
2195
- continue
2196
- parts = name.split(); last = parts[-1]
2197
- last_names.append(last)
2198
- formatted_authors.append(f"{last}, {parts[0][0]}." if len(parts) > 1 else last)
2199
  if not formatted_authors:
2200
  continue
2201
  if len(last_names) == 1:
@@ -2448,6 +2508,7 @@ Return ONLY a valid JSON array of strings."""
2448
  'title': str(meta.get('title') or '')[:240],
2449
  'year': str(meta.get('year') or ''),
2450
  'evidence': evidence[:900],
 
2451
  })
2452
  target = min(max(1, int(target_unique)), len(candidates))
2453
  if not candidates:
@@ -2476,7 +2537,8 @@ Return ONLY a valid JSON array of strings."""
2476
  "Use only the supplied title/abstract evidence. Never use a source to support this study's "
2477
  "own sample, coefficients, events, quotations, or findings. Each source may be assigned at "
2478
  "most once. Each planned claim must be narrow enough that the supplied evidence directly "
2479
- "supports it. Do not manufacture a claim merely to hit a quota. Return only valid JSON."
 
2480
  )
2481
 
2482
  for batch_index in range(0, len(candidates), batch_size):
@@ -2547,6 +2609,10 @@ INPUT:
2547
  if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
2548
  continue
2549
  section_meta = next((item for item in sections if item['name'] == section), {})
 
 
 
 
2550
  section_context = ' '.join([
2551
  str(topic or ''), section, str(section_meta.get('role') or ''),
2552
  ])
@@ -2707,6 +2773,32 @@ INPUT:
2707
  break
2708
  return selected
2709
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2710
  def fetch_contextual_jit_recovery_items(
2711
  self, *, topic, section_name, section_context, needed=1, excluded_tags=None,
2712
  ):
@@ -2722,27 +2814,9 @@ INPUT:
2722
  if needed <= 0:
2723
  return []
2724
  excluded = {str(tag or '').strip() for tag in (excluded_tags or set()) if str(tag or '').strip()}
2725
- context_words = [
2726
- term for term in sorted(self._citation_terms(section_context))
2727
- if term not in self._CITATION_RECOVERY_GENERIC_TERMS
2728
- ][:12]
2729
- prompt = (
2730
- "Generate exactly one concise English academic search query of 3-5 terms. "
2731
- "It must target the named manuscript unit and must not introduce a different application domain. "
2732
- "Return only the query.\n"
2733
- f"Topic: {str(topic or '')[:300]}\nSection: {section_name}\n"
2734
- f"Context terms: {' '.join(context_words)}"
2735
- )
2736
- try:
2737
- query = self.call_deepseek(
2738
- 'You are a precise academic librarian.', prompt, max_tokens=50, temp=0.1,
2739
- model_role='fast', thinking=False, allow_fallback=False,
2740
- ).strip(' "\'')
2741
- query = self._normalize_search_query(query)
2742
- except Exception as exc:
2743
- logging.warning('Contextual citation JIT query generation failed: section=%s error=%s', section_name, type(exc).__name__)
2744
- return []
2745
  if not query:
 
2746
  return []
2747
  logging.info('Contextual citation JIT: section=%s query=%r needed=%s', section_name, query, needed)
2748
  results = []
@@ -3161,6 +3235,41 @@ INPUT:
3161
  passages.extend(re.findall(pattern, str(value)))
3162
  return [passage for passage in passages if len(passage.strip()) >= 8]
3163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3164
  def semantic_audit_claim_specific_citations(
3165
  self,
3166
  text,
@@ -3439,6 +3548,14 @@ Return exactly:
3439
  audited = "".join(rebuilt)
3440
  audited = re.sub(r"[ \t]+\n", "\n", audited)
3441
  audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
 
 
 
 
 
 
 
 
3442
  violations = self.find_multi_citation_violations(audited)
3443
  if violations:
3444
  raise DeepSeekRequestError(
@@ -4948,6 +5065,114 @@ Output only the repaired section."""
4948
  and "{'numeric':" in message
4949
  )
4950
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4951
  @classmethod
4952
  def _protect_translation_tokens(cls, value, claim_id='C000001'):
4953
  """Protect one claim while keeping factual values in a private server ledger."""
@@ -4969,6 +5194,7 @@ Output only the repaired section."""
4969
  'canonical': cls._translation_fact_canonical(original, anchor_type),
4970
  'render_policy': cls._translation_fact_render_policy(anchor_type),
4971
  'claim_id': str(claim_id or 'C000001'),
 
4972
  # Kept only as diagnostic compatibility; never authoritative.
4973
  'sentence_index': 0,
4974
  }
@@ -5003,6 +5229,7 @@ Output only the repaired section."""
5003
  'canonical': cls._translation_fact_canonical(original, anchor_type),
5004
  'render_policy': cls._translation_fact_render_policy(anchor_type),
5005
  'claim_id': _claim_id,
 
5006
  'sentence_index': 0,
5007
  }
5008
  return placeholder
@@ -5022,6 +5249,7 @@ Output only the repaired section."""
5022
  'fact_id': str(item.get('fact_id') or placeholder),
5023
  'type': str(item.get('type') or 'IMMUTABLE'),
5024
  'claim_id': str(item.get('claim_id') or 'C000001'),
 
5025
  'mobility': 'same_claim',
5026
  })
5027
  return safe
@@ -5039,6 +5267,7 @@ Output only the repaired section."""
5039
  'type': str(item.get('type') or 'IMMUTABLE'),
5040
  'canonical': str(item.get('canonical') or item.get('source') or ''),
5041
  'render_policy': str(item.get('render_policy') or 'exact'),
 
5042
  })
5043
  payload = json.dumps(records, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
5044
  return hashlib.sha256(payload).hexdigest()
@@ -5212,10 +5441,11 @@ Output only the repaired section."""
5212
  return (
5213
  message.startswith('Translation order-sensitive relation changed')
5214
  or message.startswith('Translation fact order changed')
 
5215
  )
5216
 
5217
  @classmethod
5218
- def _validate_translation_skeleton(cls, value, mapping, block_id='', claim_sources=None, glossary=None):
5219
  text = str(value or '')
5220
  found = Counter(re.findall(r'\[\[SP-T\d{6}\]\]', text))
5221
  expected = Counter((mapping or {}).keys())
@@ -5240,10 +5470,16 @@ Output only the repaired section."""
5240
  for placeholder, raw_manifest in (mapping or {}).items():
5241
  manifest = raw_manifest if isinstance(raw_manifest, dict) else {'type':'IMMUTABLE'}
5242
  claim_id = str(manifest.get('claim_id') or 'C000001')
5243
- if placeholder not in str(target_claims.get(claim_id) or ''):
 
5244
  raise DeepSeekRequestError(
5245
  f'Translation fact moved across claim boundary in {block_id}: {manifest.get("fact_id") or placeholder}.'
5246
  )
 
 
 
 
 
5247
 
5248
  for claim_id in claim_order:
5249
  target_claim = str(target_claims.get(claim_id) or '')
@@ -5298,7 +5534,7 @@ Output only the repaired section."""
5298
  ):
5299
  text = str(value or '')
5300
  cls._validate_translation_skeleton(
5301
- text, mapping, block_id, claim_sources=claim_sources, glossary=glossary
5302
  )
5303
  for placeholder, manifest in (mapping or {}).items():
5304
  text = text.replace(placeholder, cls._render_translation_fact(manifest, target_lang))
 
139
  # R3.2.8.6: translation validators emit hierarchical assurance evidence.
140
  # Low-level diagnostics cannot independently terminate a manuscript.
141
  self.translation_assurance_log = []
142
+ self.prewrite_metrics = {}
143
  self.lock = threading.RLock()
144
 
145
  self.lit_target_papers = APP_CONFIG["lit_target_papers"]
 
224
  type(exc).__name__,
225
  )
226
 
227
+ def record_prewrite_stage(self, stage, started_at, start_ds_calls):
228
+ """Record bounded non-secret latency/call diagnostics for pre-draft stages."""
229
+ key = str(stage or "unknown")
230
+ duration = max(0.0, time.monotonic() - float(started_at or time.monotonic()))
231
+ calls = max(0, int(self.ds_calls) - int(start_ds_calls or 0))
232
+ sla = float((APP_CONFIG.get("prewrite_sla_seconds") or {}).get(key, 0) or 0)
233
+ row = {
234
+ "duration_seconds": round(duration, 3),
235
+ "deepseek_calls": calls,
236
+ "sla_seconds": sla,
237
+ "status": "within_sla" if not sla or duration <= sla else "over_sla",
238
+ }
239
+ self.prewrite_metrics[key] = row
240
+ logging.info(
241
+ "Prewrite stage metric | stage=%s duration=%.3fs deepseek_calls=%s sla=%.1fs status=%s",
242
+ key, duration, calls, sla, row["status"],
243
+ )
244
+ return dict(row)
245
+
246
  def _check_cancelled(self):
247
  if self._cancel_checker is not None:
248
  result = self._cancel_checker()
 
313
  "used_citations": list(self.used_citations),
314
  "citation_audit_log": list(self.citation_audit_log),
315
  "translation_assurance_log": list(self.translation_assurance_log),
316
+ "prewrite_metrics": dict(self.prewrite_metrics),
317
  "global_memory": self.global_memory,
318
  "s2_access_mode": self._s2_access_mode,
319
  "s2_logical_searches": int(self._s2_logical_searches),
 
336
  self.used_citations = set(state_dict.get("used_citations", []))
337
  self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
338
  self.translation_assurance_log = list(state_dict.get("translation_assurance_log", []))
339
+ self.prewrite_metrics = dict(state_dict.get("prewrite_metrics") or {})
340
  self.global_memory = state_dict.get("global_memory", self.global_memory)
341
  loaded_mode = str(state_dict.get("s2_access_mode") or "").strip().lower()
342
  expected_mode = "key" if self.s2_key else "anonymous"
 
1249
 
1250
  def _stream_with_reasoning_exhaustion_recovery():
1251
  try:
1252
+ reasoning_budget = int((APP_CONFIG.get("prewrite_reasoning_max_tokens") or {}).get("literature_synthesis", 2400))
1253
  yield from self.stream_deepseek(
1254
  sys_prompt,
1255
  user_prompt,
1256
+ max_tokens=reasoning_budget,
1257
  model_role="reasoning",
1258
  thinking=True,
1259
  allow_fallback=False,
 
1276
  yield from self.stream_deepseek(
1277
  recovery_system,
1278
  user_prompt,
1279
+ max_tokens=8000,
1280
  model_role="reasoning",
1281
  thinking=False,
1282
  allow_fallback=False,
 
1316
  {evidence_block}
1317
  {mode_directive}
1318
  Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
1319
+ def _stream_direct_research_logic():
1320
+ # R4-A: research logic is a concise planning contract. A reasoning-first
1321
+ # request repeatedly spent the whole hidden-reasoning budget before any
1322
+ # usable content, so direct final-output mode is now the default.
1323
+ direct_system = (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1324
  sys_prompt
1325
+ + "\n\nDIRECT PLANNING MODE: Produce the final provisional research logic contract directly. "
1326
+ "Do not perform extended hidden reasoning. Preserve uncertainty and evidence-strength boundaries; "
1327
+ "do not invent findings, coefficients, causal claims, or facts."
 
1328
  )
1329
  yield from self.stream_deepseek(
1330
+ direct_system,
1331
  user_prompt,
1332
  max_tokens=2200,
1333
  model_role="reasoning",
 
1335
  allow_fallback=False,
1336
  )
1337
 
1338
+ return _stream_direct_research_logic()
1339
 
1340
  @staticmethod
1341
  def _front_matter_keywords(value, minimum=4, maximum=6):
 
1830
  violations.append({"tag": item.get("tag"), "count": count})
1831
  return violations
1832
 
1833
+ @staticmethod
1834
+ def _method_authority_queries(structure):
1835
+ """Derive bounded methodology queries from design language, never topic keywords."""
1836
+ text_parts = []
1837
+ for sec in structure or []:
1838
+ if not isinstance(sec, dict):
1839
+ continue
1840
+ section = str(sec.get('section') or sec.get('section_id') or '')
1841
+ if not re.search(r'(?i)method|methodology', section):
1842
+ continue
1843
+ text_parts.extend([
1844
+ section,
1845
+ str(sec.get('required_content') or ''),
1846
+ str(sec.get('subsection') or ''),
1847
+ ])
1848
+ for child in sec.get('subsections') or []:
1849
+ if isinstance(child, dict):
1850
+ text_parts.extend([str(child.get('subsection') or ''), str(child.get('required_content') or '')])
1851
+ else:
1852
+ text_parts.append(str(child or ''))
1853
+ text = ' '.join(text_parts).lower()
1854
+ if not text.strip():
1855
+ return []
1856
+ queries = []
1857
+ if re.search(r'interview|qualitative|case study|coding|thematic|process analys|triangulat|reflexiv|gioia|grounded theor', text):
1858
+ queries.extend([
1859
+ 'qualitative case study methodology',
1860
+ 'qualitative process analysis methodology',
1861
+ 'qualitative triangulation reflexivity',
1862
+ ])
1863
+ if re.search(r'regression|structural equation|sem\b|pls|mediat|moderation|bootstrap|reliab|validity|factor analys|panel data', text):
1864
+ queries.extend([
1865
+ 'structural equation modeling methodology',
1866
+ 'bootstrap mediation methodology',
1867
+ 'measurement validity methodology',
1868
+ ])
1869
+ if not queries:
1870
+ queries.append('empirical research design methodology')
1871
+ return list(dict.fromkeys(queries))[:3]
1872
+
1873
  def prefetch_jit_literature(self, topic, keywords, structure):
1874
+ """Retrieve a dedicated methodology-authority lane without an LLM query call."""
1875
  jit_rag_texts = []
1876
+ queries_to_fetch = self._method_authority_queries(structure)
 
 
 
 
 
 
 
 
 
1877
  if not queries_to_fetch:
1878
  return ""
1879
+ logging.info("METHOD_AUTHORITY retrieval | queries=%s", queries_to_fetch)
 
 
1880
  with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor:
1881
+ future_lane = {}
1882
  for query in queries_to_fetch:
1883
+ future_lane[executor.submit(self._s2_worker, query, 0)] = 'method_authority'
1884
+ future_lane[executor.submit(self._oa_worker, query, 1, "language:en,type:article,is_paratext:false")] = 'method_authority'
1885
+ for future in concurrent.futures.as_completed(future_lane):
 
1886
  results = future.result()
1887
+ lane = future_lane[future]
1888
  with self.lock:
1889
  for res in results:
1890
  result_keys = self._reference_keys(res)
1891
  existing_keys = {
1892
+ key for metadata in self.citation_map.values() for key in self._reference_keys(metadata)
 
 
1893
  }
1894
  if result_keys and any(key in existing_keys for key in result_keys):
1895
+ # If already present, promote the existing source to the
1896
+ # methodology lane without duplicating the reference.
1897
+ for metadata in self.citation_map.values():
1898
+ if any(key in self._reference_keys(metadata) for key in result_keys):
1899
+ metadata.setdefault('evidence_lanes', [])
1900
+ if lane not in metadata['evidence_lanes']:
1901
+ metadata['evidence_lanes'].append(lane)
1902
  continue
1903
  cite_tag = f"[CITE-{len(self.citation_map)+1:02d}]"
1904
  self.citation_map[cite_tag] = {
1905
+ "in_text": res["in_text"], "ref": res["ref"],
 
1906
  "id": result_keys[0] if result_keys else ('title_year', res.get('title'), res.get('year')),
1907
+ "doi": res.get("doi", ""), "external_id": res.get("external_id", ""),
1908
+ "title": res.get("title", ""), "year": res.get("year", ""),
1909
+ "evidence": res.get("rag_text", ""), "sources": [res.get("source", "jit")],
1910
+ "evidence_lanes": [lane],
 
 
1911
  }
1912
  jit_rag_texts.append(f"{cite_tag} {res.get('rag_text', '')}")
1913
+ logging.info("METHOD_AUTHORITY retrieval complete | accepted=%s", len(jit_rag_texts))
 
1914
  return "\n".join(jit_rag_texts)
1915
 
1916
  def generate_search_queries(self, topic, keywords):
 
2048
  before_request=self._s2_before_http_attempt,
2049
  )
2050
 
2051
+ @staticmethod
2052
+ def _format_reference_authors(names):
2053
+ """Return deterministic author display data or reject clearly malformed records."""
2054
+ last_names, formatted = [], []
2055
+ for raw in names or []:
2056
+ name = re.sub(r"\s+", " ", str(raw or "")).strip(" ,;")
2057
+ if not name:
2058
+ continue
2059
+ parts = [part.strip(" ,.;") for part in name.split() if part.strip(" ,.;")]
2060
+ if not parts:
2061
+ continue
2062
+ # API metadata occasionally collapses a full author list into initials
2063
+ # (e.g. J., M., J., K.). Such data is not repairable without invention.
2064
+ substantive = [part for part in parts if sum(ch.isalpha() for ch in part) >= 2]
2065
+ if not substantive:
2066
+ continue
2067
+ if sum(ch.isalpha() for ch in parts[-1]) >= 2:
2068
+ last = parts[-1]
2069
+ given = parts[0] if len(parts) > 1 else ""
2070
+ elif sum(ch.isalpha() for ch in parts[0]) >= 2 and len(parts) >= 2:
2071
+ # Bounded support for surname-first metadata such as "Li J".
2072
+ last = parts[0]
2073
+ given = parts[-1]
2074
+ else:
2075
+ continue
2076
+ first_alpha = next((ch for ch in given if ch.isalpha()), "")
2077
+ last_names.append(last)
2078
+ formatted.append(f"{last}, {first_alpha.upper()}." if first_alpha else last)
2079
+ return last_names, formatted
2080
+
2081
  def _s2_worker(self, term, offset):
2082
  # Serialize complete logical searches. This is deliberately separate
2083
  # from the HTTP-attempt gate: OpenAlex can still run concurrently, while
 
2154
  if not title or not year or not self._is_english_dominant(title):
2155
  continue
2156
  authors = paper.get('authors') or []
2157
+ last_names, formatted_authors = self._format_reference_authors(
2158
+ [str(author.get('name') or '') for author in authors if isinstance(author, dict)]
2159
+ )
 
 
 
 
 
 
2160
  if not last_names:
2161
  continue
2162
  if len(last_names) == 1:
 
2252
  if not title or not self._is_english_dominant(title):
2253
  continue
2254
  year = item.get('publication_year') or 'n.d.'
2255
+ last_names, formatted_authors = self._format_reference_authors([
2256
+ str((authorship.get('author') or {}).get('display_name') or '')
2257
+ for authorship in (item.get('authorships') or []) if isinstance(authorship, dict)
2258
+ ])
 
 
 
 
2259
  if not formatted_authors:
2260
  continue
2261
  if len(last_names) == 1:
 
2508
  'title': str(meta.get('title') or '')[:240],
2509
  'year': str(meta.get('year') or ''),
2510
  'evidence': evidence[:900],
2511
+ 'evidence_lanes': list(meta.get('evidence_lanes') or []),
2512
  })
2513
  target = min(max(1, int(target_unique)), len(candidates))
2514
  if not candidates:
 
2537
  "Use only the supplied title/abstract evidence. Never use a source to support this study's "
2538
  "own sample, coefficients, events, quotations, or findings. Each source may be assigned at "
2539
  "most once. Each planned claim must be narrow enough that the supplied evidence directly "
2540
+ "supports it. Sources marked method_authority may be used only in Methods/Methodology and only "
2541
+ "for methodological rationale, never for this study's own facts. Do not manufacture a claim merely to hit a quota. Return only valid JSON."
2542
  )
2543
 
2544
  for batch_index in range(0, len(candidates), batch_size):
 
2609
  if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
2610
  continue
2611
  section_meta = next((item for item in sections if item['name'] == section), {})
2612
+ lanes = set((self.citation_map.get(tag) or {}).get('evidence_lanes') or [])
2613
+ if 'method_authority' in lanes and str(section_meta.get('section_id') or '') not in {'methods', 'methodology'}:
2614
+ logging.info('Citation planner rejected METHOD_AUTHORITY outside methods: section=%s tag=%s', section, tag)
2615
+ continue
2616
  section_context = ' '.join([
2617
  str(topic or ''), section, str(section_meta.get('role') or ''),
2618
  ])
 
2773
  break
2774
  return selected
2775
 
2776
+ def _deterministic_contextual_jit_query(self, topic, section_name, section_context):
2777
+ """Build one bounded section-aware recovery query without a model call."""
2778
+ generic = set(self._CITATION_RECOVERY_GENERIC_TERMS)
2779
+ context_terms = [
2780
+ term for term in sorted(self._citation_terms(section_context))
2781
+ if term not in generic and len(term) >= 3
2782
+ ]
2783
+ topic_terms = [
2784
+ term for term in sorted(self._citation_terms(topic))
2785
+ if term not in generic and len(term) >= 3
2786
+ ]
2787
+ section_terms = [
2788
+ term for term in re.findall(r"[A-Za-z][A-Za-z-]{2,}", str(section_name or '').lower())
2789
+ if term not in generic
2790
+ ]
2791
+ selected = []
2792
+ # Context is authoritative because JIT exists to repair one current unit,
2793
+ # not to broaden the manuscript into another application domain.
2794
+ for term in context_terms + section_terms + topic_terms:
2795
+ normalized = self._normalize_search_query(term)
2796
+ if normalized and normalized not in selected:
2797
+ selected.append(normalized)
2798
+ if len(selected) >= 5:
2799
+ break
2800
+ return ' '.join(selected[:5]) if len(selected) >= 3 else ''
2801
+
2802
  def fetch_contextual_jit_recovery_items(
2803
  self, *, topic, section_name, section_context, needed=1, excluded_tags=None,
2804
  ):
 
2814
  if needed <= 0:
2815
  return []
2816
  excluded = {str(tag or '').strip() for tag in (excluded_tags or set()) if str(tag or '').strip()}
2817
+ query = self._deterministic_contextual_jit_query(topic, section_name, section_context)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2818
  if not query:
2819
+ logging.info('Contextual citation JIT skipped: no bounded deterministic query for section=%s', section_name)
2820
  return []
2821
  logging.info('Contextual citation JIT: section=%s query=%r needed=%s', section_name, query, needed)
2822
  results = []
 
3235
  passages.extend(re.findall(pattern, str(value)))
3236
  return [passage for passage in passages if len(passage.strip()) >= 8]
3237
 
3238
+ @classmethod
3239
+ def _repair_local_discourse_after_citation_removal(cls, original, audited, section_name=''):
3240
+ """Repair only enumeration/ordinal framing after fail-closed sentence removal."""
3241
+ before = str(original or '')
3242
+ value = str(audited or '')
3243
+ if not value or value == before:
3244
+ return value
3245
+ ordinal_words = {'first':1, 'second':2, 'third':3, 'fourth':4, 'fifth':5, 'sixth':6}
3246
+ ordinal_matches = list(re.finditer(r'\bThe\s+(first|second|third|fourth|fifth|sixth)\b', value, flags=re.I))
3247
+ if len(ordinal_matches) < 2:
3248
+ return value
3249
+ nums = [ordinal_words[m.group(1).lower()] for m in ordinal_matches]
3250
+ if nums == list(range(1, len(nums)+1)):
3251
+ return value
3252
+ words = {1:'first',2:'second',3:'third',4:'fourth',5:'fifth',6:'sixth'}
3253
+ index = 0
3254
+ def repl(match):
3255
+ nonlocal index
3256
+ index += 1
3257
+ return 'The ' + words[index]
3258
+ repaired = re.sub(r'\bThe\s+(?:first|second|third|fourth|fifth|sixth)\b', repl, value, flags=re.I)
3259
+ count_words = {'two':2,'three':3,'four':4,'five':5,'six':6}
3260
+ reverse = {2:'two',3:'three',4:'four',5:'five',6:'six'}
3261
+ def count_repl(match):
3262
+ old_n = count_words.get(match.group(1).lower(), 0)
3263
+ noun = match.group(2)
3264
+ if old_n >= max(nums) and len(nums) in reverse:
3265
+ return f"{reverse[len(nums)]} {noun}"
3266
+ return match.group(0)
3267
+ repaired = re.sub(
3268
+ r'\b(two|three|four|five|six)\s+(process\s+domains?|mechanisms?|dimensions?|stages?|patterns?|components?)\b',
3269
+ count_repl, repaired, flags=re.I,
3270
+ )
3271
+ return repaired
3272
+
3273
  def semantic_audit_claim_specific_citations(
3274
  self,
3275
  text,
 
3548
  audited = "".join(rebuilt)
3549
  audited = re.sub(r"[ \t]+\n", "\n", audited)
3550
  audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
3551
+ if unsupported:
3552
+ repaired = self._repair_local_discourse_after_citation_removal(text, audited, section_name)
3553
+ if repaired != audited:
3554
+ self._append_citation_audit(
3555
+ section_name, "repair_local_discourse_framing", audited, repaired,
3556
+ "Renumbered surviving ordinal/enumeration framing after unsupported claim removal; no external claim added.",
3557
+ )
3558
+ audited = repaired
3559
  violations = self.find_multi_citation_violations(audited)
3560
  if violations:
3561
  raise DeepSeekRequestError(
 
5065
  and "{'numeric':" in message
5066
  )
5067
 
5068
+ _TRANSLATION_NUMERIC_ROLE_PATTERNS = (
5069
+ ('INTERVIEW_COUNT', r'\b(?:interviews?|interview sessions?)\b'),
5070
+ ('PARTICIPANT_COUNT', r'\b(?:participants?|respondents?|informants?)\b'),
5071
+ ('SAMPLE_SIZE', r'\b(?:samples?|sample size|observations?|respondents?)\b'),
5072
+ ('WAVE_COUNT', r'\b(?:waves?)\b'),
5073
+ ('GROUP_COUNT', r'\b(?:groups?|conditions?|arms?)\b'),
5074
+ ('ITEM_COUNT', r'\b(?:items?|questions?|scale items?)\b'),
5075
+ ('CASE_COUNT', r'\b(?:cases?|sites?|organizations?|organisations?)\b'),
5076
+ ('TEAM_COUNT', r'\b(?:teams?)\b'),
5077
+ )
5078
+
5079
+ @classmethod
5080
+ def _translation_numeric_semantic_role(cls, source, start, end, anchor_type):
5081
+ if str(anchor_type or '') not in {'NUMBER', 'QUANTITY'}:
5082
+ return ''
5083
+ text = str(source or '')
5084
+ left = text[max(0, int(start)-36):int(start)]
5085
+ right = text[int(end):min(len(text), int(end)+52)]
5086
+ # Prefer the noun immediately following the quantity ("44 interviews").
5087
+ for role, pattern in cls._TRANSLATION_NUMERIC_ROLE_PATTERNS:
5088
+ if re.match(rf'^[\s,;:()\-]*(?:[A-Za-z-]+\s+)?{pattern}', right, flags=re.I):
5089
+ return role
5090
+ # Then allow bounded noun-before-number constructions ("participants: 38").
5091
+ for role, pattern in cls._TRANSLATION_NUMERIC_ROLE_PATTERNS:
5092
+ if re.search(rf'{pattern}[\s:=,()\-]*$', left, flags=re.I):
5093
+ return role
5094
+ return ''
5095
+
5096
+ @classmethod
5097
+ def _translation_role_lexemes(cls, target_lang, role):
5098
+ lang = str(target_lang or '').lower()
5099
+ lexicons = {
5100
+ 'INTERVIEW_COUNT': {
5101
+ 'zh': (r'访谈', r'采访'),
5102
+ 'ru': (r'интервью', r'бесед'),
5103
+ 'kk': (r'сұхбат', r'интервью'),
5104
+ },
5105
+ 'PARTICIPANT_COUNT': {
5106
+ 'zh': (r'参与者', r'受访者'),
5107
+ 'ru': (r'участник', r'респондент', r'информант'),
5108
+ 'kk': (r'қатысуш', r'респондент', r'ақпарат беруш'),
5109
+ },
5110
+ 'SAMPLE_SIZE': {
5111
+ 'zh': (r'样本', r'观测'),
5112
+ 'ru': (r'выборк', r'наблюден'),
5113
+ 'kk': (r'іріктем', r'таңдам', r'бақыла'),
5114
+ },
5115
+ 'WAVE_COUNT': {'zh': (r'轮', r'波'), 'ru': (r'волн', r'этап'), 'kk': (r'толқын', r'кезең')},
5116
+ 'GROUP_COUNT': {'zh': (r'组', r'条件'), 'ru': (r'групп', r'услов'), 'kk': (r'топ', r'шарт')},
5117
+ 'ITEM_COUNT': {'zh': (r'题项', r'项目', r'条目'), 'ru': (r'пункт', r'элемент', r'вопрос'), 'kk': (r'тармақ', r'элемент', r'сұрақ')},
5118
+ 'CASE_COUNT': {'zh': (r'案例', r'个案', r'站点', r'组织'), 'ru': (r'случа', r'кейс', r'организац', r'сайт'), 'kk': (r'жағдай', r'кейс', r'ұйым', r'алаң')},
5119
+ 'TEAM_COUNT': {'zh': (r'团队',), 'ru': (r'команд',), 'kk': (r'команда', r'топ')},
5120
+ }
5121
+ key = 'zh' if ('chinese' in lang or '中文' in lang or '简体' in lang) else 'ru' if ('russian' in lang or 'рус' in lang) else 'kk' if ('kazakh' in lang or 'қазақ' in lang) else ''
5122
+ localized = tuple((lexicons.get(str(role or '')) or {}).get(key) or ())
5123
+ english = tuple(pattern for role_key, pattern in cls._TRANSLATION_NUMERIC_ROLE_PATTERNS if role_key == str(role or ''))
5124
+ # Source-language role words are accepted only for role binding. The
5125
+ # independent deterministic lexical-purity Gate still rejects ordinary
5126
+ # English residue in a finished ZH/RU/KK translation.
5127
+ return localized + english
5128
+
5129
+ @classmethod
5130
+ def _translation_semantic_role_matches(cls, target_claim, placeholder, role, target_lang):
5131
+ """Bind each numeric anchor to the nearest recognized quantitative-role noun."""
5132
+ expected = str(role or '')
5133
+ if not cls._translation_role_lexemes(target_lang, expected):
5134
+ return True
5135
+ value = str(target_claim or '')
5136
+ marker = str(placeholder or '')
5137
+ pos = value.find(marker)
5138
+ if pos < 0:
5139
+ return False
5140
+ end = pos + len(marker)
5141
+ # First bind inside the local punctuation-delimited proposition. This
5142
+ # prevents a noun in the following clause (for example after a Chinese
5143
+ # comma) from stealing ownership from a noun-before-number construction
5144
+ # such as ``团队数为20,参与者总数为300``.
5145
+ clause_left = max(
5146
+ value.rfind(",", 0, pos), value.rfind(",", 0, pos),
5147
+ value.rfind(";", 0, pos), value.rfind(";", 0, pos),
5148
+ value.rfind("。", 0, pos), value.rfind(".", 0, pos),
5149
+ value.rfind("\n", 0, pos),
5150
+ ) + 1
5151
+ right_hits = [idx for token in (",", ",", ";", ";", "。", ".", "\n")
5152
+ if (idx := value.find(token, end)) >= 0]
5153
+ clause_right = min(right_hits) if right_hits else len(value)
5154
+ window = value[clause_left:clause_right]
5155
+ marker_start = pos - clause_left
5156
+ marker_end = end - clause_left
5157
+ candidates = []
5158
+ for candidate_role, _pattern in cls._TRANSLATION_NUMERIC_ROLE_PATTERNS:
5159
+ for pattern in cls._translation_role_lexemes(target_lang, candidate_role):
5160
+ for match in re.finditer(pattern, window, flags=re.I):
5161
+ if match.end() <= marker_start:
5162
+ distance = marker_start - match.end()
5163
+ elif match.start() >= marker_end:
5164
+ distance = match.start() - marker_end
5165
+ else:
5166
+ distance = 0
5167
+ candidates.append((distance, candidate_role))
5168
+ if not candidates:
5169
+ # No deterministic role noun is visible. Defer this claim to the
5170
+ # existing semantic QA rather than guessing from script/word order.
5171
+ return True
5172
+ minimum = min(distance for distance, _candidate_role in candidates)
5173
+ nearest_roles = {candidate_role for distance, candidate_role in candidates if distance == minimum}
5174
+ return expected in nearest_roles
5175
+
5176
  @classmethod
5177
  def _protect_translation_tokens(cls, value, claim_id='C000001'):
5178
  """Protect one claim while keeping factual values in a private server ledger."""
 
5194
  'canonical': cls._translation_fact_canonical(original, anchor_type),
5195
  'render_policy': cls._translation_fact_render_policy(anchor_type),
5196
  'claim_id': str(claim_id or 'C000001'),
5197
+ 'semantic_role': cls._translation_numeric_semantic_role(source, match.start(), match.end(), anchor_type),
5198
  # Kept only as diagnostic compatibility; never authoritative.
5199
  'sentence_index': 0,
5200
  }
 
5229
  'canonical': cls._translation_fact_canonical(original, anchor_type),
5230
  'render_policy': cls._translation_fact_render_policy(anchor_type),
5231
  'claim_id': _claim_id,
5232
+ 'semantic_role': cls._translation_numeric_semantic_role(claim_source, match.start(), match.end(), anchor_type),
5233
  'sentence_index': 0,
5234
  }
5235
  return placeholder
 
5249
  'fact_id': str(item.get('fact_id') or placeholder),
5250
  'type': str(item.get('type') or 'IMMUTABLE'),
5251
  'claim_id': str(item.get('claim_id') or 'C000001'),
5252
+ 'semantic_role': str(item.get('semantic_role') or ''),
5253
  'mobility': 'same_claim',
5254
  })
5255
  return safe
 
5267
  'type': str(item.get('type') or 'IMMUTABLE'),
5268
  'canonical': str(item.get('canonical') or item.get('source') or ''),
5269
  'render_policy': str(item.get('render_policy') or 'exact'),
5270
+ 'semantic_role': str(item.get('semantic_role') or ''),
5271
  })
5272
  payload = json.dumps(records, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
5273
  return hashlib.sha256(payload).hexdigest()
 
5441
  return (
5442
  message.startswith('Translation order-sensitive relation changed')
5443
  or message.startswith('Translation fact order changed')
5444
+ or message.startswith('Translation semantic-role relation changed')
5445
  )
5446
 
5447
  @classmethod
5448
+ def _validate_translation_skeleton(cls, value, mapping, block_id='', claim_sources=None, glossary=None, target_lang=''):
5449
  text = str(value or '')
5450
  found = Counter(re.findall(r'\[\[SP-T\d{6}\]\]', text))
5451
  expected = Counter((mapping or {}).keys())
 
5470
  for placeholder, raw_manifest in (mapping or {}).items():
5471
  manifest = raw_manifest if isinstance(raw_manifest, dict) else {'type':'IMMUTABLE'}
5472
  claim_id = str(manifest.get('claim_id') or 'C000001')
5473
+ target_claim = str(target_claims.get(claim_id) or '')
5474
+ if placeholder not in target_claim:
5475
  raise DeepSeekRequestError(
5476
  f'Translation fact moved across claim boundary in {block_id}: {manifest.get("fact_id") or placeholder}.'
5477
  )
5478
+ role = str(manifest.get('semantic_role') or '')
5479
+ if role and not cls._translation_semantic_role_matches(target_claim, placeholder, role, target_lang):
5480
+ raise DeepSeekRequestError(
5481
+ f'Translation semantic-role relation changed in {block_id}: {manifest.get("fact_id") or placeholder} role={role}.'
5482
+ )
5483
 
5484
  for claim_id in claim_order:
5485
  target_claim = str(target_claims.get(claim_id) or '')
 
5534
  ):
5535
  text = str(value or '')
5536
  cls._validate_translation_skeleton(
5537
+ text, mapping, block_id, claim_sources=claim_sources, glossary=glossary, target_lang=target_lang
5538
  )
5539
  for placeholder, manifest in (mapping or {}).items():
5540
  text = text.replace(placeholder, cls._render_translation_fact(manifest, target_lang))
core/production_guard.py CHANGED
@@ -532,6 +532,14 @@ def build_evidence_coverage_report(summary: dict[str, Any] | None) -> str:
532
  lines.append(
533
  f"- {section}: validated direct citations={validated}; evidence requirement={required}; externally oriented claims={external_claims}."
534
  )
 
 
 
 
 
 
 
 
535
  if limited:
536
  lines.extend([
537
  "",
 
532
  lines.append(
533
  f"- {section}: validated direct citations={validated}; evidence requirement={required}; externally oriented claims={external_claims}."
534
  )
535
+ method = data.get("methodology_authority") or {}
536
+ if isinstance(method, dict):
537
+ lines.extend([
538
+ "",
539
+ "Methodology-authority coverage:",
540
+ f"- validated method-authority citations={max(0, int(method.get('validated_method_authority_citations') or 0))}; methodology external claims={max(0, int(method.get('methodology_external_claims') or 0))}.",
541
+ "- Methodology-authority sources support methodological rationale only; they are not evidence for this study's own sample, procedures, or results.",
542
+ ])
543
  if limited:
544
  lines.extend([
545
  "",
core/qualitative_engine.py CHANGED
@@ -335,16 +335,30 @@ Rules:
335
  # content retries here. Only a completed but locally invalid packet
336
  # reaches the second attempt.
337
  if local_attempt == 0:
338
- response = "".join(
339
- str(chunk or "")
340
- for chunk in self.agent.stream_deepseek(
341
- system_prompt,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  base_prompt,
343
- model_role="reasoning",
344
- thinking=True,
345
- allow_fallback=False,
346
  )
347
- )
348
  else:
349
  retry_prompt = base_prompt + (
350
  "\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
 
335
  # content retries here. Only a completed but locally invalid packet
336
  # reaches the second attempt.
337
  if local_attempt == 0:
338
+ reasoning_budget = int((APP_CONFIG.get("prewrite_reasoning_max_tokens") or {}).get("qualitative_packet", 2400))
339
+ try:
340
+ response = "".join(
341
+ str(chunk or "")
342
+ for chunk in self.agent.stream_deepseek(
343
+ system_prompt,
344
+ base_prompt,
345
+ max_tokens=reasoning_budget,
346
+ model_role="reasoning",
347
+ thinking=True,
348
+ allow_fallback=False,
349
+ reasoning_only_recovery=True,
350
+ )
351
+ )
352
+ except Exception as exc:
353
+ if exc.__class__.__name__ != "DeepSeekReasoningOnlyExhaustionError":
354
+ raise
355
+ logging.warning("Qualitative packet reasoning-only exhaustion; switching once to direct final-output mode.")
356
+ response = self.agent.call_deepseek(
357
+ system_prompt + "\n\nDIRECT OUTPUT MODE: Produce the complete evidence packet directly without extended hidden reasoning.",
358
  base_prompt,
359
+ max_tokens=8000, temp=0.0, model_role="reasoning",
360
+ thinking=False, allow_fallback=False, response_format={"type": "json_object"},
 
361
  )
 
362
  else:
363
  retry_prompt = base_prompt + (
364
  "\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
i18n.py CHANGED
@@ -351,7 +351,7 @@ TEXTS: dict[str, dict[str, str]] = {
351
  "qualitative_ready": "✅ 定性材料已准备完成。",
352
  "core_literature_parsing": "🧠 正在深度解析用户上传的核心文献……",
353
  "section_complete_progress": "🇬🇧 【{name}】英文完成,翻译任务已进入后台。",
354
- "login_title": "SmartPaper 登录",
355
  "service_description": "提供中文、英语、俄语、哈萨克语四种语言服务。",
356
  "secure_login_notice": "安全登录仅支持支付宝 App 扫码。登录后系统对数据加密,保护您的隐私。",
357
  "secure_login_title": "支付宝安全登录",
@@ -551,7 +551,7 @@ TEXTS: dict[str, dict[str, str]] = {
551
  "qualitative_ready": "✅ Qualitative materials are ready.",
552
  "core_literature_parsing": "🧠 Parsing the uploaded core literature in depth…",
553
  "section_complete_progress": "🇬🇧 English section 【{name}】 complete; translation is running in the background.",
554
- "login_title": "Sign in to SmartPaper",
555
  "service_description": "Services are available in Chinese, English, Russian, and Kazakh.",
556
  "secure_login_notice": "Secure sign-in supports the Alipay app only. After sign-in, the system encrypts data to protect your privacy.",
557
  "secure_login_title": "Secure Sign-in with Alipay",
@@ -751,7 +751,7 @@ TEXTS: dict[str, dict[str, str]] = {
751
  "qualitative_ready": "✅ Качественные материалы подготовлены.",
752
  "core_literature_parsing": "🧠 Выполняется углублённая обработка загруженной основной литературы…",
753
  "section_complete_progress": "🇬🇧 Английский раздел 【{name}】 завершён; перевод выполняется в фоне.",
754
- "login_title": "Вход в SmartPaper",
755
  "service_description": "Услуги доступны на китайском, английском, русском и казахском языках.",
756
  "secure_login_notice": "Безопасный вход поддерживается только в приложении Alipay. После входа система шифрует данные для защиты вашей конфиденциальности.",
757
  "secure_login_title": "Безопасный вход через Alipay",
@@ -951,7 +951,7 @@ TEXTS: dict[str, dict[str, str]] = {
951
  "qualitative_ready": "✅ Сапалық материалдар дайындалды.",
952
  "core_literature_parsing": "🧠 Жүктелген негізгі әдебиеттер терең өңделуде…",
953
  "section_complete_progress": "🇬🇧 【{name}】 ағылшын бөлімі аяқталды; аударма фондық режимде орындалуда.",
954
- "login_title": "SmartPaper жүйесіне кіру",
955
  "service_description": "Қызмет қытай, ағылшын, орыс және қазақ тілдерінде қолжетімді.",
956
  "secure_login_notice": "Қауіпсіз кіру тек Alipay қолданбасы арқылы жүзеге асырылады. Кіргеннен кейін жүйе құпиялылығыңызды қорғау үшін деректерді шифрлайды.",
957
  "secure_login_title": "Alipay арқылы қауіпсіз кіру",
 
351
  "qualitative_ready": "✅ 定性材料已准备完成。",
352
  "core_literature_parsing": "🧠 正在深度解析用户上传的核心文献……",
353
  "section_complete_progress": "🇬🇧 【{name}】英文完成,翻译任务已进入后台。",
354
+ "login_title": "SmartPaper",
355
  "service_description": "提供中文、英语、俄语、哈萨克语四种语言服务。",
356
  "secure_login_notice": "安全登录仅支持支付宝 App 扫码。登录后系统对数据加密,保护您的隐私。",
357
  "secure_login_title": "支付宝安全登录",
 
551
  "qualitative_ready": "✅ Qualitative materials are ready.",
552
  "core_literature_parsing": "🧠 Parsing the uploaded core literature in depth…",
553
  "section_complete_progress": "🇬🇧 English section 【{name}】 complete; translation is running in the background.",
554
+ "login_title": "SmartPaper",
555
  "service_description": "Services are available in Chinese, English, Russian, and Kazakh.",
556
  "secure_login_notice": "Secure sign-in supports the Alipay app only. After sign-in, the system encrypts data to protect your privacy.",
557
  "secure_login_title": "Secure Sign-in with Alipay",
 
751
  "qualitative_ready": "✅ Качественные материалы подготовлены.",
752
  "core_literature_parsing": "🧠 Выполняется углублённая обработка загруженной основной литературы…",
753
  "section_complete_progress": "🇬🇧 Английский раздел 【{name}】 завершён; перевод выполняется в фоне.",
754
+ "login_title": "SmartPaper",
755
  "service_description": "Услуги доступны на китайском, английском, русском и казахском языках.",
756
  "secure_login_notice": "Безопасный вход поддерживается только в приложении Alipay. После входа система шифрует данные для защиты вашей конфиденциальности.",
757
  "secure_login_title": "Безопасный вход через Alipay",
 
951
  "qualitative_ready": "✅ Сапалық материалдар дайындалды.",
952
  "core_literature_parsing": "🧠 Жүктелген негізгі әдебиеттер терең өңделуде…",
953
  "section_complete_progress": "🇬🇧 【{name}】 ағылшын бөлімі аяқталды; аударма фондық режимде орындалуда.",
954
+ "login_title": "SmartPaper",
955
  "service_description": "Қызмет қытай, ағылшын, орыс және қазақ тілдерінде қолжетімді.",
956
  "secure_login_notice": "Қауіпсіз кіру тек Alipay қолданбасы арқылы жүзеге асырылады. Кіргеннен кейін жүйе құпиялылығыңызды қорғау үшін деректерді шифрлайды.",
957
  "secure_login_title": "Alipay арқылы қауіпсіз кіру",
smartpaper_restore_bridge/frontend/index.html CHANGED
@@ -29,12 +29,71 @@
29
  let lastPreviewText = "";
30
  let lastTargetPreviewText = "";
31
  let pageExitCancelCleanup = null;
 
32
  const renderChannels = {
33
  english:{ authoritative:"", displayed:"", queue:[], timer:null },
34
  target:{ authoritative:"", displayed:"", queue:[], timer:null }
35
  };
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  function readSessionJson(key, fallback=null) {
39
  try {
40
  const raw = window.sessionStorage.getItem(key);
@@ -271,12 +330,12 @@
271
  }
272
 
273
  function updateText(parentDocument, id, value) {
274
- const element = parentDocument.getElementById(id);
275
  if (element) element.textContent = String(value ?? "");
276
  }
277
 
278
  function appendTaskActivity(parentDocument, text, complete=false) {
279
- const feed = parentDocument.getElementById("sp-live-activity-feed");
280
  const value = String(text || "").trim();
281
  if (!feed || !value) return;
282
  const row = parentDocument.createElement("div");
@@ -362,7 +421,7 @@
362
  }
363
 
364
  function updateEvidenceWarning(parentDocument, payload, labels) {
365
- const node = parentDocument.getElementById("sp-live-evidence-warning");
366
  if (!node) return;
367
  const status = String(payload && payload.evidence_status || "").trim().toLowerCase();
368
  const map = labels && typeof labels === "object" ? labels : {};
@@ -376,7 +435,7 @@
376
  }
377
 
378
  function updateDraftPreparation(parentDocument, node, labels, task=null) {
379
- const status = parentDocument.getElementById("sp-live-manuscript-status");
380
  const channel = renderChannels.english;
381
  if (!status) return;
382
  if (channel.authoritative) {
@@ -402,9 +461,10 @@
402
 
403
  function ensureTargetStatusNode(parentDocument) {
404
  const shell = parentDocument.getElementById("sp-live-translation-shell");
405
- const target = parentDocument.getElementById("sp-live-translation-preview");
406
- if (!shell || !target) return null;
407
- let status = parentDocument.getElementById("sp-live-translation-status");
 
408
  if (!status) {
409
  status = parentDocument.createElement("div");
410
  status.id = "sp-live-translation-status";
@@ -423,13 +483,13 @@
423
  status.style.zIndex = "1";
424
  status.style.background = "#ffffff";
425
  status.style.display = "none";
426
- shell.insertBefore(status, target);
427
  }
428
  return status;
429
  }
430
 
431
  function updateTargetPreparation(parentDocument, payload, labels) {
432
- const target = parentDocument.getElementById("sp-live-translation-preview");
433
  const channel = renderChannels.target;
434
  if (!target) return;
435
  const map = labels && typeof labels === "object" ? labels : {};
@@ -610,7 +670,7 @@
610
  const commit = normalizePreviewDisplay(commitText);
611
  if (!commit) return;
612
  const channel = renderChannels.english;
613
- const target = parentDocument.getElementById("sp-live-manuscript-preview");
614
  const shell = parentDocument.getElementById("sp-live-manuscript-shell");
615
  if (!target || !shell) return;
616
  if (channel.displayed.startsWith(commit)) return;
@@ -639,7 +699,7 @@
639
  }
640
 
641
  function updateOnePreview(parentDocument, parentWindow, id, shellId, text, channel) {
642
- const target = parentDocument.getElementById(id);
643
  const shell = parentDocument.getElementById(shellId);
644
  if (!target || !shell || !text) return;
645
  if (shell.dataset.spScrollGuard !== "1") {
@@ -664,7 +724,7 @@
664
  const targetEnglishCommitText = String(payload && payload.target_english_commit_text || "");
665
  if (englishText && englishText !== lastPreviewText) {
666
  updateOnePreview(parentDocument, parentWindow, "sp-live-manuscript-preview", "sp-live-manuscript-shell", englishText, renderChannels.english);
667
- const englishStatus = parentDocument.getElementById("sp-live-manuscript-status");
668
  if (englishStatus) {
669
  englishStatus.textContent = "";
670
  englishStatus.style.display = "none";
@@ -686,13 +746,32 @@
686
  const response = await fetch(watch.previewUrl + `?t=${Date.now()}`, {
687
  method:"GET", cache:"no-store", credentials:"same-origin", headers:{"Accept":"application/json"}
688
  });
689
- if (!response.ok) return;
690
- const payload = await response.json();
691
- if (!payload || String(payload.task_id || "") !== watch.taskId) return;
 
 
 
 
 
 
 
 
692
  const epoch = asCount(payload.execution_epoch);
693
- if (watch.expectedEpoch > 0 && epoch !== watch.expectedEpoch) return;
 
 
 
 
 
 
 
 
694
  updateManuscriptPreview(payload, watch);
695
- } catch (_) {}
 
 
 
696
  }
697
 
698
  function updateTaskMonitor(task, watch=null) {
@@ -718,7 +797,7 @@
718
  updateText(parentDocument, "sp-live-s2", asCount(task && task.s2_calls));
719
  updateText(parentDocument, "sp-live-oa", asCount(task && task.oa_calls));
720
  updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${nodeLabel}`);
721
- const fill = parentDocument.getElementById("sp-live-progress-fill");
722
  if (fill) fill.style.width = `${progress}%`;
723
  updateDraftPreparation(parentDocument, node, labels, task);
724
 
@@ -744,59 +823,13 @@
744
  }
745
 
746
  function installPageExitCancel(watch) {
 
 
 
747
  removePageExitCancel();
748
- if (!watch || !watch.bestEffortPageExitCancel) return;
749
- let parentWindow;
750
- try { parentWindow = window.parent; } catch (_) { return; }
751
- let sent = false;
752
- let suppressForReload = false;
753
- const navigationHandler = (event) => {
754
- if (event && String(event.navigationType || "") === "reload") suppressForReload = true;
755
- };
756
- const keyHandler = (event) => {
757
- const key = String(event && event.key || "").toLowerCase();
758
- if (key === "f5" || ((event.ctrlKey || event.metaKey) && key === "r")) suppressForReload = true;
759
- };
760
- try {
761
- if (parentWindow.navigation && typeof parentWindow.navigation.addEventListener === "function") {
762
- parentWindow.navigation.addEventListener("navigate", navigationHandler);
763
- }
764
- parentWindow.addEventListener("keydown", keyHandler, true);
765
- } catch (_) {}
766
- const handler = (event) => {
767
- if (event && event.persisted) return;
768
- if (suppressForReload) return;
769
- if (sent || activeTaskWatch !== watch || watch.stopped) return;
770
- sent = true;
771
- const url = new URL(ACTION_PATHS.task_cancel, watch.apiOrigin).toString();
772
- const body = JSON.stringify({ task_id:watch.taskId });
773
- let beaconSent = false;
774
- try {
775
- if (parentWindow.navigator && typeof parentWindow.navigator.sendBeacon === "function") {
776
- const blob = new Blob([body], { type:"application/json" });
777
- beaconSent = parentWindow.navigator.sendBeacon(url, blob);
778
- }
779
- } catch (_) {}
780
- if (!beaconSent) {
781
- try {
782
- parentWindow.fetch(url, {
783
- method:"POST", credentials:"include", cache:"no-store", keepalive:true,
784
- headers:{ "Accept":"application/json", "Content-Type":"application/json", "X-SmartPaper-Client":"browser-exit-v1" },
785
- body
786
- }).catch(() => {});
787
- } catch (_) {}
788
- }
789
- };
790
- parentWindow.addEventListener("pagehide", handler, { capture:true });
791
- pageExitCancelCleanup = () => {
792
- parentWindow.removeEventListener("pagehide", handler, { capture:true });
793
- parentWindow.removeEventListener("keydown", keyHandler, true);
794
- try {
795
- if (parentWindow.navigation && typeof parentWindow.navigation.removeEventListener === "function") {
796
- parentWindow.navigation.removeEventListener("navigate", navigationHandler);
797
- }
798
- } catch (_) {}
799
- };
800
  }
801
 
802
  function stopTaskWatch() {
@@ -822,6 +855,7 @@
822
  const previewIntervalMs = Math.max(400, Math.min(2500, Number.isFinite(requestedPreviewInterval) ? requestedPreviewInterval : 700));
823
  const resolvedPreviewUrl = previewUrl(payload.preview_path || "");
824
  const expectedEpoch = asCount(payload.expected_execution_epoch);
 
825
  const initialCheckpointSeq = asCount(payload.initial_checkpoint_seq);
826
  if (!apiOrigin || !taskId) {
827
  sendValue({ ok:false, action:"task_watch", command_id:commandId, error:"invalid_task_watch" });
@@ -831,7 +865,7 @@
831
  const draftStatusLabels = payload.draft_status_labels && typeof payload.draft_status_labels === "object" ? payload.draft_status_labels : {};
832
  const targetStatusLabels = payload.target_status_labels && typeof payload.target_status_labels === "object" ? payload.target_status_labels : {};
833
  const bestEffortPageExitCancel = payload.best_effort_page_exit_cancel === true;
834
- const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, draftStatusLabels, targetStatusLabels, bestEffortPageExitCancel, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
835
  activeTaskWatch = watch;
836
  recordNavDiagnostic("task_watch_start");
837
  installPageExitCancel(watch);
 
29
  let lastPreviewText = "";
30
  let lastTargetPreviewText = "";
31
  let pageExitCancelCleanup = null;
32
+ const ownedShadowHosts = new WeakSet();
33
  const renderChannels = {
34
  english:{ authoritative:"", displayed:"", queue:[], timer:null },
35
  target:{ authoritative:"", displayed:"", queue:[], timer:null }
36
  };
37
 
38
 
39
+ const LIVE_HOST_BY_ID = Object.freeze({
40
+ "sp-live-progress-wrap":"sp-live-monitor",
41
+ "sp-live-progress-text":"sp-live-monitor",
42
+ "sp-live-progress-fill":"sp-live-monitor",
43
+ "sp-live-status":"sp-live-monitor",
44
+ "sp-live-node":"sp-live-monitor",
45
+ "sp-live-checkpoint-stage":"sp-live-monitor",
46
+ "sp-live-checkpoint-seq":"sp-live-monitor",
47
+ "sp-live-evidence-warning":"sp-live-monitor",
48
+ "sp-live-activity-feed":"sp-live-monitor",
49
+ "sp-live-ds":"sp-live-monitor",
50
+ "sp-live-s2":"sp-live-monitor",
51
+ "sp-live-oa":"sp-live-monitor",
52
+ "sp-live-manuscript-status":"sp-live-manuscript-shell",
53
+ "sp-live-manuscript-preview":"sp-live-manuscript-shell",
54
+ "sp-live-manuscript-cursor":"sp-live-manuscript-shell",
55
+ "sp-live-translation-status":"sp-live-translation-shell",
56
+ "sp-live-translation-preview":"sp-live-translation-shell",
57
+ "sp-live-translation-cursor":"sp-live-translation-shell"
58
+ });
59
+
60
+ function ensureOwnedShadow(parentDocument, hostId) {
61
+ const host = parentDocument && parentDocument.getElementById ? parentDocument.getElementById(hostId) : null;
62
+ if (!host) return null;
63
+ let root = host.shadowRoot;
64
+ if (!root) {
65
+ try {
66
+ root = host.attachShadow({mode:"open"});
67
+ root.innerHTML = host.innerHTML;
68
+ ownedShadowHosts.add(host);
69
+ recordNavDiagnostic("live_shadow_owner_mounted", {host:String(hostId || "").slice(0,80)});
70
+ } catch (_) { return null; }
71
+ }
72
+ return root;
73
+ }
74
+
75
+ function liveGetById(parentDocument, id) {
76
+ const hostId = LIVE_HOST_BY_ID[String(id || "")];
77
+ if (hostId) {
78
+ const root = ensureOwnedShadow(parentDocument, hostId);
79
+ const node = root && root.getElementById ? root.getElementById(id) : null;
80
+ if (node) return node;
81
+ }
82
+ return parentDocument && parentDocument.getElementById ? parentDocument.getElementById(id) : null;
83
+ }
84
+
85
+ function liveRootForHost(parentDocument, hostId) {
86
+ return ensureOwnedShadow(parentDocument, hostId);
87
+ }
88
+
89
+ function recordPreviewDiagnostic(code, detail={}) {
90
+ const safe = { code:String(code || "preview_error").slice(0,80) };
91
+ if (detail && Number.isFinite(Number(detail.http_status))) safe.http_status = Number(detail.http_status);
92
+ if (detail && typeof detail.stage === "string") safe.stage = detail.stage.slice(0,80);
93
+ recordNavDiagnostic("preview_transport", safe);
94
+ }
95
+
96
+
97
  function readSessionJson(key, fallback=null) {
98
  try {
99
  const raw = window.sessionStorage.getItem(key);
 
330
  }
331
 
332
  function updateText(parentDocument, id, value) {
333
+ const element = liveGetById(parentDocument, id);
334
  if (element) element.textContent = String(value ?? "");
335
  }
336
 
337
  function appendTaskActivity(parentDocument, text, complete=false) {
338
+ const feed = liveGetById(parentDocument, "sp-live-activity-feed");
339
  const value = String(text || "").trim();
340
  if (!feed || !value) return;
341
  const row = parentDocument.createElement("div");
 
421
  }
422
 
423
  function updateEvidenceWarning(parentDocument, payload, labels) {
424
+ const node = liveGetById(parentDocument, "sp-live-evidence-warning");
425
  if (!node) return;
426
  const status = String(payload && payload.evidence_status || "").trim().toLowerCase();
427
  const map = labels && typeof labels === "object" ? labels : {};
 
435
  }
436
 
437
  function updateDraftPreparation(parentDocument, node, labels, task=null) {
438
+ const status = liveGetById(parentDocument, "sp-live-manuscript-status");
439
  const channel = renderChannels.english;
440
  if (!status) return;
441
  if (channel.authoritative) {
 
461
 
462
  function ensureTargetStatusNode(parentDocument) {
463
  const shell = parentDocument.getElementById("sp-live-translation-shell");
464
+ const root = liveRootForHost(parentDocument, "sp-live-translation-shell");
465
+ const target = liveGetById(parentDocument, "sp-live-translation-preview");
466
+ if (!shell || !root || !target) return null;
467
+ let status = liveGetById(parentDocument, "sp-live-translation-status");
468
  if (!status) {
469
  status = parentDocument.createElement("div");
470
  status.id = "sp-live-translation-status";
 
483
  status.style.zIndex = "1";
484
  status.style.background = "#ffffff";
485
  status.style.display = "none";
486
+ root.insertBefore(status, target);
487
  }
488
  return status;
489
  }
490
 
491
  function updateTargetPreparation(parentDocument, payload, labels) {
492
+ const target = liveGetById(parentDocument, "sp-live-translation-preview");
493
  const channel = renderChannels.target;
494
  if (!target) return;
495
  const map = labels && typeof labels === "object" ? labels : {};
 
670
  const commit = normalizePreviewDisplay(commitText);
671
  if (!commit) return;
672
  const channel = renderChannels.english;
673
+ const target = liveGetById(parentDocument, "sp-live-manuscript-preview");
674
  const shell = parentDocument.getElementById("sp-live-manuscript-shell");
675
  if (!target || !shell) return;
676
  if (channel.displayed.startsWith(commit)) return;
 
699
  }
700
 
701
  function updateOnePreview(parentDocument, parentWindow, id, shellId, text, channel) {
702
+ const target = liveGetById(parentDocument, id);
703
  const shell = parentDocument.getElementById(shellId);
704
  if (!target || !shell || !text) return;
705
  if (shell.dataset.spScrollGuard !== "1") {
 
724
  const targetEnglishCommitText = String(payload && payload.target_english_commit_text || "");
725
  if (englishText && englishText !== lastPreviewText) {
726
  updateOnePreview(parentDocument, parentWindow, "sp-live-manuscript-preview", "sp-live-manuscript-shell", englishText, renderChannels.english);
727
+ const englishStatus = liveGetById(parentDocument, "sp-live-manuscript-status");
728
  if (englishStatus) {
729
  englishStatus.textContent = "";
730
  englishStatus.style.display = "none";
 
746
  const response = await fetch(watch.previewUrl + `?t=${Date.now()}`, {
747
  method:"GET", cache:"no-store", credentials:"same-origin", headers:{"Accept":"application/json"}
748
  });
749
+ if (!response.ok) {
750
+ recordPreviewDiagnostic("HTTP_STATUS", {http_status:response.status});
751
+ return;
752
+ }
753
+ let payload;
754
+ try { payload = await response.json(); }
755
+ catch (_) { recordPreviewDiagnostic("INVALID_JSON", {http_status:response.status}); return; }
756
+ if (!payload || String(payload.task_id || "") !== watch.taskId) {
757
+ recordPreviewDiagnostic("TASK_ID_MISMATCH");
758
+ return;
759
+ }
760
  const epoch = asCount(payload.execution_epoch);
761
+ if (watch.expectedEpoch > 0 && epoch !== watch.expectedEpoch) {
762
+ recordPreviewDiagnostic("EXECUTION_EPOCH_MISMATCH");
763
+ return;
764
+ }
765
+ const requestHash = String(payload.request_hash || "").trim().toLowerCase();
766
+ if (watch.expectedRequestHash && requestHash !== watch.expectedRequestHash) {
767
+ recordPreviewDiagnostic("REQUEST_HASH_MISMATCH");
768
+ return;
769
+ }
770
  updateManuscriptPreview(payload, watch);
771
+ recordPreviewDiagnostic("OK", {http_status:response.status, stage:String(payload.stage || "")});
772
+ } catch (_) {
773
+ recordPreviewDiagnostic("FETCH_FAILED");
774
+ }
775
  }
776
 
777
  function updateTaskMonitor(task, watch=null) {
 
797
  updateText(parentDocument, "sp-live-s2", asCount(task && task.s2_calls));
798
  updateText(parentDocument, "sp-live-oa", asCount(task && task.oa_calls));
799
  updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${nodeLabel}`);
800
+ const fill = liveGetById(parentDocument, "sp-live-progress-fill");
801
  if (fill) fill.style.width = `${progress}%`;
802
  updateDraftPreparation(parentDocument, node, labels, task);
803
 
 
823
  }
824
 
825
  function installPageExitCancel(watch) {
826
+ // R4-R1: browser lifecycle events are observability only. Explicit Cancel
827
+ // owns cancellation; refresh/tab close/pagehide must never stop the
828
+ // independent Executor or consume a second reservation on reattach.
829
  removePageExitCancel();
830
+ if (watch && watch.bestEffortPageExitCancel) {
831
+ recordNavDiagnostic("page_exit_cancel_ignored", { reason:"explicit_cancel_only" });
832
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833
  }
834
 
835
  function stopTaskWatch() {
 
855
  const previewIntervalMs = Math.max(400, Math.min(2500, Number.isFinite(requestedPreviewInterval) ? requestedPreviewInterval : 700));
856
  const resolvedPreviewUrl = previewUrl(payload.preview_path || "");
857
  const expectedEpoch = asCount(payload.expected_execution_epoch);
858
+ const expectedRequestHash = String(payload.expected_request_hash || "").trim().toLowerCase();
859
  const initialCheckpointSeq = asCount(payload.initial_checkpoint_seq);
860
  if (!apiOrigin || !taskId) {
861
  sendValue({ ok:false, action:"task_watch", command_id:commandId, error:"invalid_task_watch" });
 
865
  const draftStatusLabels = payload.draft_status_labels && typeof payload.draft_status_labels === "object" ? payload.draft_status_labels : {};
866
  const targetStatusLabels = payload.target_status_labels && typeof payload.target_status_labels === "object" ? payload.target_status_labels : {};
867
  const bestEffortPageExitCancel = payload.best_effort_page_exit_cancel === true;
868
+ const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, expectedRequestHash, draftStatusLabels, targetStatusLabels, bestEffortPageExitCancel, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
869
  activeTaskWatch = watch;
870
  recordNavDiagnostic("task_watch_start");
871
  installPageExitCancel(watch);