R14-TQ2-PQ1-R2 Top-Level Claim Evidence Sufficiency + Study-Owned Boundary + Four-Language Failure Localization

#63
Files changed (4) hide show
  1. app.py +18 -1
  2. core/generation_runner.py +159 -50
  3. core/pipeline_agent.py +67 -0
  4. i18n.py +8 -0
app.py CHANGED
@@ -1004,13 +1004,30 @@ def _render_task_status_once(lang_code: str) -> None:
1004
  else:
1005
  st.warning("The task completed, but the local output file is unavailable after runtime replacement.")
1006
  elif status == "failed":
1007
- st.error(str(task.get("safe_error_detail") or task.get("safe_error_code") or tr(lang_code, "node_error")))
 
 
 
1008
  elif status == "cancelled":
1009
  detail = str(task.get("safe_error_detail") or "")
1010
  if detail:
1011
  st.warning(detail)
1012
 
1013
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1014
  def _inject_generation_action_scroll(mode: str) -> None:
1015
  """Scroll immediately to the active generation action without a server wait."""
1016
  target_id = "sp-generation-action-asq" if str(mode or "").lower() == "asq" else "sp-generation-action-amj"
 
1004
  else:
1005
  st.warning("The task completed, but the local output file is unavailable after runtime replacement.")
1006
  elif status == "failed":
1007
+ error_message, translation_note = _localized_terminal_task_error(task, lang_code)
1008
+ st.error(error_message)
1009
+ if translation_note:
1010
+ st.warning(translation_note)
1011
  elif status == "cancelled":
1012
  detail = str(task.get("safe_error_detail") or "")
1013
  if detail:
1014
  st.warning(detail)
1015
 
1016
 
1017
+ def _localized_terminal_task_error(task: dict, lang_code: str) -> tuple[str, str]:
1018
+ """Map stable business error codes to UI-language messages.
1019
+
1020
+ Engineering details remain in task/log state; the user-facing terminal view
1021
+ does not expose a long English runtime exception in non-English interfaces.
1022
+ """
1023
+ code = str(task.get("safe_error_code") or "").strip()
1024
+ detail = str(task.get("safe_error_detail") or "").strip()
1025
+ raw = f"{code} {detail}"
1026
+ if "E_LITERATURE_INSUFFICIENT" in raw:
1027
+ return tr(lang_code, "literature_insufficient"), tr(lang_code, "upstream_translation_stopped")
1028
+ return detail or code or tr(lang_code, "node_error"), ""
1029
+
1030
+
1031
  def _inject_generation_action_scroll(mode: str) -> None:
1032
  """Scroll immediately to the active generation action without a server wait."""
1033
  target_id = "sp-generation-action-asq" if str(mode or "").lower() == "asq" else "sp-generation-action-amj"
core/generation_runner.py CHANGED
@@ -1579,6 +1579,58 @@ class GenerationRunner:
1579
  reserve_targets[display_name] = reserve
1580
  return minimums, reserve_targets
1581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1582
  @staticmethod
1583
  def _adaptive_literature_candidate_target(target_words: int, agent) -> int:
1584
  """Adaptive retrieval aspiration; never a final reference-count quota."""
@@ -1818,7 +1870,11 @@ class GenerationRunner:
1818
  citation_minimums, citation_reserve_targets = self._adaptive_citation_requirements(
1819
  adaptive_structure, mode
1820
  )
1821
- citation_plan_target = sum(citation_reserve_targets.values())
 
 
 
 
1822
  citation_plan = agent.build_claim_evidence_plan(
1823
  adaptive_structure, topic, synthesis_result, mode=mode,
1824
  target_unique=max(1, citation_plan_target),
@@ -1854,34 +1910,47 @@ class GenerationRunner:
1854
  if tag and tag not in planned_tags:
1855
  planned_items.append(item)
1856
  planned_tags.add(tag)
1857
- # External JIT is not used merely to hit the reserve target.
1858
- # It is triggered only when the unit remains below its actual
1859
- # evidence-demand minimum after exhausting already retrieved
1860
- # relevant literature.
1861
- minimum_shortfall = max(
1862
- 0, int(citation_minimums.get(display_name, 0)) - len(planned_items)
 
 
 
 
 
 
 
 
 
 
 
 
1863
  )
1864
- if minimum_shortfall > 0:
1865
- jit_items = agent.fetch_contextual_jit_recovery_items(
1866
- topic=topic, section_name=display_name, section_context=section_context,
1867
- needed=minimum_shortfall, excluded_tags=planned_tags,
1868
- )
1869
- for item in jit_items:
1870
- tag = str(item.get("tag") or "")
1871
- if tag and tag not in planned_tags:
1872
- planned_items.append(item)
1873
- planned_tags.add(tag)
 
1874
 
1875
  shortfalls = [
1876
- (name, len(citation_plan.get(name) or []), required)
1877
- for name, required in citation_minimums.items()
1878
- if len(citation_plan.get(name) or []) < required
1879
  ]
1880
  if shortfalls:
1881
  raise self._literature_insufficient_error(shortfalls)
1882
  logging.info(
1883
- "Adaptive citation feasibility passed: minimum_total=%s reserve_target=%s planned_unique=%s units=%s",
1884
- sum(citation_minimums.values()), citation_plan_target, len(planned_tags), len(citation_minimums),
1885
  )
1886
  canonical_concept_ledger = self._build_canonical_concept_ledger(
1887
  mode, data_metadata, keywords=keywords, adaptive_structure=adaptive_structure,
@@ -1894,7 +1963,11 @@ class GenerationRunner:
1894
  "citation_plan": citation_plan,
1895
  "citation_minimums": citation_minimums,
1896
  "citation_reserve_targets": citation_reserve_targets,
 
 
 
1897
  "citation_validated_tags_by_unit": {},
 
1898
  "research_logic_contract": research_logic_contract,
1899
  "working_abstract": working_abstract,
1900
  "working_keywords": working_keywords,
@@ -1928,6 +2001,13 @@ class GenerationRunner:
1928
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
1929
  citation_minimums = dict(state.get("citation_minimums") or {})
1930
  citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
 
 
 
 
 
 
 
1931
  citation_validated_tags_by_unit = {
1932
  str(name): list(tags or [])
1933
  for name, tags in dict(state.get("citation_validated_tags_by_unit") or {}).items()
@@ -1936,6 +2016,12 @@ class GenerationRunner:
1936
  citation_minimums, citation_reserve_targets = self._adaptive_citation_requirements(
1937
  adaptive_structure, mode
1938
  )
 
 
 
 
 
 
1939
  translation_sections = dict(state.get('translation_sections') or {})
1940
  translation_glossary = dict(state.get('translation_glossary') or {})
1941
  canonical_concept_ledger = state.get('canonical_concept_ledger')
@@ -2050,18 +2136,23 @@ class GenerationRunner:
2050
  self._progress(15, "citation_planning")
2051
  citation_plan = agent.build_claim_evidence_plan(
2052
  adaptive_structure, topic, synthesis_result, mode=mode,
2053
- target_unique=max(1, sum(citation_reserve_targets.values())),
2054
  )
 
2055
  shortfalls = [
2056
- (name, len(citation_plan.get(name) or []), required)
2057
- for name, required in citation_minimums.items()
2058
- if len(citation_plan.get(name) or []) < required
2059
  ]
2060
  if shortfalls:
2061
  raise self._literature_insufficient_error(shortfalls)
2062
  state["citation_plan"] = citation_plan
2063
  state["citation_minimums"] = citation_minimums
2064
  state["citation_reserve_targets"] = citation_reserve_targets
 
 
 
 
2065
  state["citation_validated_tags_by_unit"] = citation_validated_tags_by_unit
2066
 
2067
  quote_invocation_plan = (
@@ -2300,13 +2391,9 @@ Output only final academic prose. Do not report the word count."""
2300
  used_after = set(agent.used_citations)
2301
  survived = used_after - used_before
2302
  if assigned_tag_list and not is_quarantined:
2303
- adaptive_minimum = max(1, int(citation_minimums.get(display_name, 1)))
2304
  required_survival = min(
2305
  len(assigned_tag_list),
2306
- max(
2307
- adaptive_minimum,
2308
- int(math.ceil(len(assigned_tag_list) * CITATION_SEMANTIC_SURVIVAL_RATIO)),
2309
- ),
2310
  )
2311
  if len(survived) < required_survival:
2312
  missing_items = [
@@ -2348,14 +2435,18 @@ Output only final academic prose. Do not report the word count."""
2348
  en_resolved = recovered
2349
  used_after = set(agent.used_citations)
2350
  survived = used_after - used_before
2351
- if assigned_tag_list and not is_quarantined:
2352
- adaptive_minimum = max(1, int(citation_minimums.get(display_name, 1)))
2353
- if len(survived) < adaptive_minimum:
2354
- raise self._literature_insufficient_error(
2355
- [(display_name, len(survived), adaptive_minimum)]
 
 
2356
  )
2357
- citation_validated_tags_by_unit[display_name] = sorted(
2358
- survived, key=agent._citation_tag_number
 
 
2359
  )
2360
  if mode == "asq" and section_id == "findings":
2361
  en_resolved = self._render_planned_qualitative_quotes(
@@ -2408,6 +2499,10 @@ Output only final academic prose. Do not report the word count."""
2408
  "citation_plan": citation_plan,
2409
  "citation_minimums": citation_minimums,
2410
  "citation_reserve_targets": citation_reserve_targets,
 
 
 
 
2411
  "citation_validated_tags_by_unit": citation_validated_tags_by_unit,
2412
  "agent_state": agent.export_state(),
2413
  "data_metadata": data_metadata,
@@ -2438,23 +2533,33 @@ Output only final academic prose. Do not report the word count."""
2438
  seen_refs.add(ref)
2439
  if not references:
2440
  raise self._literature_insufficient_error([("final validated references", 0, 1)])
 
 
 
 
 
 
 
 
 
 
 
2441
  final_shortfalls = []
2442
- for name, minimum in citation_minimums.items():
2443
- validated_tags = set(citation_validated_tags_by_unit.get(name) or [])
2444
- if not validated_tags:
2445
- validated_tags = {
2446
- str(item.get("tag") or "")
2447
- for item in (citation_plan.get(name) or [])
2448
- if str(item.get("tag") or "") in agent.used_citations
2449
- }
2450
- if len(validated_tags) < int(minimum):
2451
- final_shortfalls.append((name, len(validated_tags), int(minimum)))
2452
  if final_shortfalls:
2453
  raise self._literature_insufficient_error(final_shortfalls)
2454
  final_reference_count = len(references)
2455
  logging.info(
2456
- "Adaptive citation final Gate passed: validated_references=%s units=%s",
2457
- final_reference_count, len(citation_minimums),
2458
  )
2459
  full_draft_en = full_draft_en.rstrip() + "\n\n# References\n" + "\n\n".join(references)
2460
  actual_words = self._english_count_for_target(full_draft_en)
@@ -2522,6 +2627,10 @@ Output only final academic prose. Do not report the word count."""
2522
  "citation_plan": citation_plan,
2523
  "citation_minimums": citation_minimums,
2524
  "citation_reserve_targets": citation_reserve_targets,
 
 
 
 
2525
  "citation_validated_tags_by_unit": citation_validated_tags_by_unit,
2526
  "quality_diagnostics": quality_diagnostics,
2527
  "english_finalized": True,
 
1579
  reserve_targets[display_name] = reserve
1580
  return minimums, reserve_targets
1581
 
1582
+ @classmethod
1583
+ def _top_level_citation_requirements(cls, tasks, mode: str):
1584
+ """Aggregate citation authority by academic section, never by technical chunk.
1585
+
1586
+ ``Introduction (Part 1/2)`` and ``Introduction (Part 2/2)`` are generation
1587
+ chunks, not separate scholarly evidence authorities. Planning may remain
1588
+ unit-local, but fatal literature sufficiency is evaluated on the canonical
1589
+ top-level academic section.
1590
+ """
1591
+ allowed = cls._citation_eligible_section_ids(mode)
1592
+ words_by_section = {}
1593
+ for task in tasks or []:
1594
+ section_id = str(task.get('section_id') or '')
1595
+ if section_id not in allowed:
1596
+ continue
1597
+ words_by_section[section_id] = words_by_section.get(section_id, 0) + max(
1598
+ 1, int(task.get('target_words') or 1)
1599
+ )
1600
+ minimums = {}
1601
+ reserve_targets = {}
1602
+ for section_id, target_words in words_by_section.items():
1603
+ base, words_per_source = CITATION_ROLE_RULES.get(section_id, (2, 240))
1604
+ minimum = max(base, int(math.ceil(target_words / max(1, words_per_source))))
1605
+ reserve = max(
1606
+ minimum,
1607
+ int(math.ceil(minimum / max(0.01, CITATION_SEMANTIC_SURVIVAL_RATIO))),
1608
+ )
1609
+ minimums[section_id] = minimum
1610
+ reserve_targets[section_id] = reserve
1611
+ return minimums, reserve_targets
1612
+
1613
+ @staticmethod
1614
+ def _citation_unit_section_map(tasks):
1615
+ return {
1616
+ str(task.get('display_name') or ''): str(task.get('section_id') or '')
1617
+ for task in (tasks or []) if str(task.get('display_name') or '').strip()
1618
+ }
1619
+
1620
+ @staticmethod
1621
+ def _top_level_unique_tag_counts(citation_plan, unit_section_map):
1622
+ tags_by_section = {}
1623
+ for unit_name, items in dict(citation_plan or {}).items():
1624
+ section_id = str(unit_section_map.get(str(unit_name)) or '')
1625
+ if not section_id:
1626
+ continue
1627
+ bucket = tags_by_section.setdefault(section_id, set())
1628
+ for item in items or []:
1629
+ tag = str((item or {}).get('tag') or '')
1630
+ if tag:
1631
+ bucket.add(tag)
1632
+ return {section_id: len(tags) for section_id, tags in tags_by_section.items()}
1633
+
1634
  @staticmethod
1635
  def _adaptive_literature_candidate_target(target_words: int, agent) -> int:
1636
  """Adaptive retrieval aspiration; never a final reference-count quota."""
 
1870
  citation_minimums, citation_reserve_targets = self._adaptive_citation_requirements(
1871
  adaptive_structure, mode
1872
  )
1873
+ citation_top_level_minimums, citation_top_level_reserve_targets = self._top_level_citation_requirements(
1874
+ adaptive_structure, mode
1875
+ )
1876
+ citation_unit_section_map = self._citation_unit_section_map(adaptive_structure)
1877
+ citation_plan_target = sum(citation_top_level_reserve_targets.values())
1878
  citation_plan = agent.build_claim_evidence_plan(
1879
  adaptive_structure, topic, synthesis_result, mode=mode,
1880
  target_unique=max(1, citation_plan_target),
 
1910
  if tag and tag not in planned_tags:
1911
  planned_items.append(item)
1912
  planned_tags.add(tag)
1913
+ # Do not trigger external retrieval merely because a technical
1914
+ # generation chunk is below a word-derived target. Fatal evidence
1915
+ # authority belongs to the canonical top-level academic section.
1916
+
1917
+ top_level_counts = self._top_level_unique_tag_counts(citation_plan, citation_unit_section_map)
1918
+ for section_id, required in citation_top_level_minimums.items():
1919
+ deficit = max(0, int(required) - int(top_level_counts.get(section_id, 0)))
1920
+ if deficit <= 0:
1921
+ continue
1922
+ section_units = [
1923
+ name for name, mapped in citation_unit_section_map.items() if mapped == section_id
1924
+ ]
1925
+ if not section_units:
1926
+ continue
1927
+ primary_unit = section_units[0]
1928
+ section_context = " ".join(
1929
+ self._citation_task_context(task_by_name.get(name, {}), research_logic_contract, synthesis_result)
1930
+ for name in section_units
1931
  )
1932
+ jit_items = agent.fetch_contextual_jit_recovery_items(
1933
+ topic=topic, section_name=section_id, section_context=section_context,
1934
+ needed=deficit, excluded_tags=planned_tags,
1935
+ )
1936
+ planned_items = citation_plan.setdefault(primary_unit, [])
1937
+ for item in jit_items:
1938
+ tag = str(item.get("tag") or "")
1939
+ if tag and tag not in planned_tags:
1940
+ planned_items.append(item)
1941
+ planned_tags.add(tag)
1942
+ top_level_counts = self._top_level_unique_tag_counts(citation_plan, citation_unit_section_map)
1943
 
1944
  shortfalls = [
1945
+ (section_id, int(top_level_counts.get(section_id, 0)), required)
1946
+ for section_id, required in citation_top_level_minimums.items()
1947
+ if int(top_level_counts.get(section_id, 0)) < int(required)
1948
  ]
1949
  if shortfalls:
1950
  raise self._literature_insufficient_error(shortfalls)
1951
  logging.info(
1952
+ "Top-level citation feasibility passed: minimum_total=%s reserve_target=%s planned_unique=%s sections=%s",
1953
+ sum(citation_top_level_minimums.values()), citation_plan_target, len(planned_tags), len(citation_top_level_minimums),
1954
  )
1955
  canonical_concept_ledger = self._build_canonical_concept_ledger(
1956
  mode, data_metadata, keywords=keywords, adaptive_structure=adaptive_structure,
 
1963
  "citation_plan": citation_plan,
1964
  "citation_minimums": citation_minimums,
1965
  "citation_reserve_targets": citation_reserve_targets,
1966
+ "citation_top_level_minimums": citation_top_level_minimums,
1967
+ "citation_top_level_reserve_targets": citation_top_level_reserve_targets,
1968
+ "citation_unit_section_map": citation_unit_section_map,
1969
  "citation_validated_tags_by_unit": {},
1970
+ "citation_required_claim_counts_by_unit": {},
1971
  "research_logic_contract": research_logic_contract,
1972
  "working_abstract": working_abstract,
1973
  "working_keywords": working_keywords,
 
2001
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
2002
  citation_minimums = dict(state.get("citation_minimums") or {})
2003
  citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
2004
+ citation_top_level_minimums = dict(state.get("citation_top_level_minimums") or {})
2005
+ citation_top_level_reserve_targets = dict(state.get("citation_top_level_reserve_targets") or {})
2006
+ citation_unit_section_map = dict(state.get("citation_unit_section_map") or {})
2007
+ citation_required_claim_counts_by_unit = {
2008
+ str(name): int(count or 0)
2009
+ for name, count in dict(state.get("citation_required_claim_counts_by_unit") or {}).items()
2010
+ }
2011
  citation_validated_tags_by_unit = {
2012
  str(name): list(tags or [])
2013
  for name, tags in dict(state.get("citation_validated_tags_by_unit") or {}).items()
 
2016
  citation_minimums, citation_reserve_targets = self._adaptive_citation_requirements(
2017
  adaptive_structure, mode
2018
  )
2019
+ if not citation_top_level_minimums:
2020
+ citation_top_level_minimums, citation_top_level_reserve_targets = self._top_level_citation_requirements(
2021
+ adaptive_structure, mode
2022
+ )
2023
+ if not citation_unit_section_map:
2024
+ citation_unit_section_map = self._citation_unit_section_map(adaptive_structure)
2025
  translation_sections = dict(state.get('translation_sections') or {})
2026
  translation_glossary = dict(state.get('translation_glossary') or {})
2027
  canonical_concept_ledger = state.get('canonical_concept_ledger')
 
2136
  self._progress(15, "citation_planning")
2137
  citation_plan = agent.build_claim_evidence_plan(
2138
  adaptive_structure, topic, synthesis_result, mode=mode,
2139
+ target_unique=max(1, sum(citation_top_level_reserve_targets.values())),
2140
  )
2141
+ top_level_counts = self._top_level_unique_tag_counts(citation_plan, citation_unit_section_map)
2142
  shortfalls = [
2143
+ (section_id, int(top_level_counts.get(section_id, 0)), required)
2144
+ for section_id, required in citation_top_level_minimums.items()
2145
+ if int(top_level_counts.get(section_id, 0)) < int(required)
2146
  ]
2147
  if shortfalls:
2148
  raise self._literature_insufficient_error(shortfalls)
2149
  state["citation_plan"] = citation_plan
2150
  state["citation_minimums"] = citation_minimums
2151
  state["citation_reserve_targets"] = citation_reserve_targets
2152
+ state["citation_top_level_minimums"] = citation_top_level_minimums
2153
+ state["citation_top_level_reserve_targets"] = citation_top_level_reserve_targets
2154
+ state["citation_unit_section_map"] = citation_unit_section_map
2155
+ state["citation_required_claim_counts_by_unit"] = citation_required_claim_counts_by_unit
2156
  state["citation_validated_tags_by_unit"] = citation_validated_tags_by_unit
2157
 
2158
  quote_invocation_plan = (
 
2391
  used_after = set(agent.used_citations)
2392
  survived = used_after - used_before
2393
  if assigned_tag_list and not is_quarantined:
 
2394
  required_survival = min(
2395
  len(assigned_tag_list),
2396
+ max(1, int(math.ceil(len(assigned_tag_list) * CITATION_SEMANTIC_SURVIVAL_RATIO))),
 
 
 
2397
  )
2398
  if len(survived) < required_survival:
2399
  missing_items = [
 
2435
  en_resolved = recovered
2436
  used_after = set(agent.used_citations)
2437
  survived = used_after - used_before
2438
+ if not is_quarantined and section_id in self._citation_eligible_section_ids(mode):
2439
+ citation_required_claim_counts_by_unit[display_name] = agent.count_external_literature_claims(
2440
+ en_resolved, section_name=display_name
2441
+ )
2442
+ if assigned_tag_list:
2443
+ citation_validated_tags_by_unit[display_name] = sorted(
2444
+ survived, key=agent._citation_tag_number
2445
  )
2446
+ logging.info(
2447
+ "Top-level citation accumulation: unit=%s section=%s validated=%s external_claims=%s",
2448
+ display_name, citation_unit_section_map.get(display_name, section_id),
2449
+ len(survived), citation_required_claim_counts_by_unit[display_name],
2450
  )
2451
  if mode == "asq" and section_id == "findings":
2452
  en_resolved = self._render_planned_qualitative_quotes(
 
2499
  "citation_plan": citation_plan,
2500
  "citation_minimums": citation_minimums,
2501
  "citation_reserve_targets": citation_reserve_targets,
2502
+ "citation_top_level_minimums": citation_top_level_minimums,
2503
+ "citation_top_level_reserve_targets": citation_top_level_reserve_targets,
2504
+ "citation_unit_section_map": citation_unit_section_map,
2505
+ "citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
2506
  "citation_validated_tags_by_unit": citation_validated_tags_by_unit,
2507
  "agent_state": agent.export_state(),
2508
  "data_metadata": data_metadata,
 
2533
  seen_refs.add(ref)
2534
  if not references:
2535
  raise self._literature_insufficient_error([("final validated references", 0, 1)])
2536
+ validated_by_top_level = {}
2537
+ claims_by_top_level = {}
2538
+ for unit_name, section_id in citation_unit_section_map.items():
2539
+ if section_id not in citation_top_level_minimums:
2540
+ continue
2541
+ validated_by_top_level.setdefault(section_id, set()).update(
2542
+ citation_validated_tags_by_unit.get(unit_name) or []
2543
+ )
2544
+ claims_by_top_level[section_id] = claims_by_top_level.get(section_id, 0) + int(
2545
+ citation_required_claim_counts_by_unit.get(unit_name, 0) or 0
2546
+ )
2547
  final_shortfalls = []
2548
+ for section_id, planned_minimum in citation_top_level_minimums.items():
2549
+ validated_tags = set(validated_by_top_level.get(section_id) or [])
2550
+ external_claims = max(0, int(claims_by_top_level.get(section_id, 0)))
2551
+ # Fatal authority is evidence need, not a word-count quota. The
2552
+ # top-level planning minimum is therefore capped by the number of
2553
+ # externally-oriented claims that actually survived drafting.
2554
+ required = min(int(planned_minimum), external_claims) if external_claims else 0
2555
+ if len(validated_tags) < required:
2556
+ final_shortfalls.append((section_id, len(validated_tags), required))
 
2557
  if final_shortfalls:
2558
  raise self._literature_insufficient_error(final_shortfalls)
2559
  final_reference_count = len(references)
2560
  logging.info(
2561
+ "Top-level claim-evidence final Gate passed: validated_references=%s sections=%s",
2562
+ final_reference_count, len(citation_top_level_minimums),
2563
  )
2564
  full_draft_en = full_draft_en.rstrip() + "\n\n# References\n" + "\n\n".join(references)
2565
  actual_words = self._english_count_for_target(full_draft_en)
 
2627
  "citation_plan": citation_plan,
2628
  "citation_minimums": citation_minimums,
2629
  "citation_reserve_targets": citation_reserve_targets,
2630
+ "citation_top_level_minimums": citation_top_level_minimums,
2631
+ "citation_top_level_reserve_targets": citation_top_level_reserve_targets,
2632
+ "citation_unit_section_map": citation_unit_section_map,
2633
+ "citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
2634
  "citation_validated_tags_by_unit": citation_validated_tags_by_unit,
2635
  "quality_diagnostics": quality_diagnostics,
2636
  "english_finalized": True,
core/pipeline_agent.py CHANGED
@@ -2828,6 +2828,67 @@ INPUT:
2828
  re.I,
2829
  ))
2830
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2831
  @staticmethod
2832
  def _append_raw_citation_to_sentence(sentence, tag):
2833
  raw = str(sentence or '')
@@ -2864,6 +2925,12 @@ INPUT:
2864
  stripped = str(sentence or '').strip()
2865
  if not stripped or stripped.startswith('#') or self._citation_recovery_sentence_has_citation(stripped):
2866
  continue
 
 
 
 
 
 
2867
  sentence_terms = self._citation_terms(stripped)
2868
  if len(sentence_terms) < 4:
2869
  continue
 
2828
  re.I,
2829
  ))
2830
 
2831
+ @staticmethod
2832
+ def _citation_claim_provenance(sentence, section_name=''):
2833
+ """Classify whether an existing sentence is eligible for external-literature recovery.
2834
+
2835
+ This is deliberately conservative and deterministic. The recovery path may
2836
+ only add evidence to externally-oriented claims; it must not attach a source
2837
+ merely to satisfy a citation target when the sentence is owned by the current
2838
+ study, reports the current study's results, states the authors' positioning,
2839
+ or serves only as structure/transition.
2840
+ """
2841
+ text = re.sub(r'\s+', ' ', str(sentence or '')).strip()
2842
+ lower = text.lower()
2843
+ section = str(section_name or '').lower()
2844
+ if not text:
2845
+ return 'STRUCTURAL'
2846
+ if re.match(r'^(?:h\d+[a-z]?\s*[:.\-]|hypothesis\s+\d+\b)', lower):
2847
+ return 'HYPOTHESIS_STATEMENT'
2848
+ if re.search(r'\b(?:we hypothesi[sz]e|we propose|we predict|we expect)\b', lower):
2849
+ return 'HYPOTHESIS_STATEMENT'
2850
+ if re.search(
2851
+ r'\b(?:the present study|this study|our study|the current study|the present research|this research)\b',
2852
+ lower,
2853
+ ):
2854
+ if re.search(r'\b(?:contribut|position|ask|research question|does not assume|focus|aim|seek|examine|investigat|test)\w*\b', lower):
2855
+ return 'STUDY_POSITIONING'
2856
+ return 'STUDY_OWNED_FACT'
2857
+ if re.search(
2858
+ r'\b(?:we collected|we recruited|we interviewed|we observed|we conducted|we analyzed|we analysed|'
2859
+ r'we used|we employed|our sample|our data|our participants|sample consisted|participants included|'
2860
+ r'data were collected|data was collected|to answer this question,? (?:we|the study))\b',
2861
+ lower,
2862
+ ):
2863
+ return 'STUDY_OWNED_FACT'
2864
+ if any(token in section for token in ('result', 'finding')) and re.search(
2865
+ r'\b(?:we found|we find|our findings|our results|the results|the findings|coefficient|p\s*[<=>]|r\s*[²2]|β|beta)\b',
2866
+ lower,
2867
+ ):
2868
+ return 'RESULT_OWNED_FACT'
2869
+ if len(re.findall(r'[A-Za-z]{3,}', text)) < 4 and not re.search(r'[\u4e00-\u9fff\u0400-\u04ff]', text):
2870
+ return 'STRUCTURAL'
2871
+ return 'EXTERNAL_LITERATURE_CLAIM'
2872
+
2873
+ @classmethod
2874
+ def _citation_recovery_eligible_claim(cls, sentence, section_name=''):
2875
+ return cls._citation_claim_provenance(sentence, section_name) == 'EXTERNAL_LITERATURE_CLAIM'
2876
+
2877
+ @classmethod
2878
+ def count_external_literature_claims(cls, text, section_name=''):
2879
+ return sum(
2880
+ 1 for _start, _end, sentence in cls._sentence_spans(str(text or ''))
2881
+ if cls._citation_recovery_eligible_claim(sentence, section_name)
2882
+ )
2883
+
2884
+ @classmethod
2885
+ def count_citation_recovery_eligible_claims(cls, text, section_name=''):
2886
+ return sum(
2887
+ 1 for _start, _end, sentence in cls._sentence_spans(str(text or ''))
2888
+ if cls._citation_recovery_eligible_claim(sentence, section_name)
2889
+ and not cls._citation_recovery_sentence_has_citation(str(sentence or ''))
2890
+ )
2891
+
2892
  @staticmethod
2893
  def _append_raw_citation_to_sentence(sentence, tag):
2894
  raw = str(sentence or '')
 
2925
  stripped = str(sentence or '').strip()
2926
  if not stripped or stripped.startswith('#') or self._citation_recovery_sentence_has_citation(stripped):
2927
  continue
2928
+ if not self._citation_recovery_eligible_claim(stripped, section_name):
2929
+ logging.info(
2930
+ 'Claim-anchored citation recovery skipped non-literature claim: section=%s provenance=%s',
2931
+ section_name, self._citation_claim_provenance(stripped, section_name),
2932
+ )
2933
+ continue
2934
  sentence_terms = self._citation_terms(stripped)
2935
  if len(sentence_terms) < 4:
2936
  continue
i18n.py CHANGED
@@ -305,6 +305,8 @@ TEXTS: dict[str, dict[str, str]] = {
305
  "word_count": "📊 本章节实际字数:{actual}(目标:{target}),累计偏差:{deviation:+d}",
306
  "translation_running": "⏳ 英文已完成,正在后台异步翻译……",
307
  "node_error": "🚨 节点异常已中止。",
 
 
308
  "node_error_detail": "错误代码:{code}|{error}|系统已保存断点,可继续恢复。",
309
  "task_cancelled": "任务已取消,断点和临时文件已删除。",
310
  "task_start_failed": "无法启动论文任务:{error}",
@@ -481,6 +483,8 @@ TEXTS: dict[str, dict[str, str]] = {
481
  "word_count": "📊 Section word count: {actual} (target: {target}); cumulative deviation: {deviation:+d}",
482
  "translation_running": "⏳ English draft complete. Background translation is running…",
483
  "node_error": "🚨 The node stopped because of an error.",
 
 
484
  "node_error_detail": "Error code: {code} | {error} | A checkpoint has been saved and can be resumed.",
485
  "task_cancelled": "The task was cancelled. Its checkpoint and temporary files were deleted.",
486
  "task_start_failed": "The paper task could not be started: {error}",
@@ -657,6 +661,8 @@ TEXTS: dict[str, dict[str, str]] = {
657
  "word_count": "📊 Объём раздела: {actual} слов (цель: {target}); накопленное отклонение: {deviation:+d}",
658
  "translation_running": "⏳ Английский текст завершён. Выполняется фоновый перевод…",
659
  "node_error": "🚨 Выполнение узла остановлено из-за ошибки.",
 
 
660
  "node_error_detail": "Код ошибки: {code} | {error} | Контрольная точка сохранена; работу можно продолжить.",
661
  "task_cancelled": "Задача отменена. Контрольная точка и временные файлы удалены.",
662
  "task_start_failed": "Не удалось запустить подготовку статьи: {error}",
@@ -833,6 +839,8 @@ TEXTS: dict[str, dict[str, str]] = {
833
  "word_count": "📊 Бөлім көлемі: {actual} сөз (мақсат: {target}); жиынтық ауытқу: {deviation:+d}",
834
  "translation_running": "⏳ Ағылшын мәтіні аяқталды. Фондық аударма орындалуда…",
835
  "node_error": "🚨 Қате салдарынан түйін тоқтатылды.",
 
 
836
  "node_error_detail": "Қате коды: {code} | {error} | Бақылау нүктесі сақталды; жұмысты жалғастыруға болады.",
837
  "task_cancelled": "Тапсырма тоқтатылды. Бақылау нүктесі мен уақытша файлдар жойылды.",
838
  "task_start_failed": "Мақала тапсырмасын бастау мүмкін болмады: {error}",
 
305
  "word_count": "📊 本章节实际字数:{actual}(目标:{target}),累计偏差:{deviation:+d}",
306
  "translation_running": "⏳ 英文已完成,正在后台异步翻译……",
307
  "node_error": "🚨 节点异常已中止。",
308
+ "literature_insufficient": "当前高相关文献不足以直接支撑部分核心论断。系统没有使用无关文献凑数。请调整研究题目或关键词,或上传更直接相关的核心文献后重试。",
309
+ "upstream_translation_stopped": "英文母稿的证据检查未通过,后续翻译已停止;当前页面中已显示的译文不是完整论文。",
310
  "node_error_detail": "错误代码:{code}|{error}|系统已保存断点,可继续恢复。",
311
  "task_cancelled": "任务已取消,断点和临时文件已删除。",
312
  "task_start_failed": "无法启动论文任务:{error}",
 
483
  "word_count": "📊 Section word count: {actual} (target: {target}); cumulative deviation: {deviation:+d}",
484
  "translation_running": "⏳ English draft complete. Background translation is running…",
485
  "node_error": "🚨 The node stopped because of an error.",
486
+ "literature_insufficient": "The available high-relevance literature is insufficient to support some core manuscript claims. Unrelated sources were not used to fill a quota. Revise the research topic or keywords, or provide more directly relevant core literature, and try again.",
487
+ "upstream_translation_stopped": "The English source manuscript did not pass the evidence gate, so downstream translation stopped. Any translated text currently shown is incomplete and is not a final paper.",
488
  "node_error_detail": "Error code: {code} | {error} | A checkpoint has been saved and can be resumed.",
489
  "task_cancelled": "The task was cancelled. Its checkpoint and temporary files were deleted.",
490
  "task_start_failed": "The paper task could not be started: {error}",
 
661
  "word_count": "📊 Объём раздела: {actual} слов (цель: {target}); накопленное отклонение: {deviation:+d}",
662
  "translation_running": "⏳ Английский текст завершён. Выполняется фоновый перевод…",
663
  "node_error": "🚨 Выполнение узла остановлено из-за ошибки.",
664
+ "literature_insufficient": "Доступной высокорелевантной литературы недостаточно для прямой поддержки части ключевых утверждений рукописи. Система не использовала нерелевантные источники ради количества. Измените тему или ключевые слова либо добавьте более релевантные основные источники и повторите попытку.",
665
+ "upstream_translation_stopped": "Английская исходная рукопись не прошла проверку доказательной базы, поэтому дальнейший перевод остановлен. Уже показанный перевод неполный и не является окончательной статьёй.",
666
  "node_error_detail": "Код ошибки: {code} | {error} | Контрольная точка сохранена; работу можно продолжить.",
667
  "task_cancelled": "Задача отменена. Контрольная точка и временные файлы удалены.",
668
  "task_start_failed": "Не удалось запустить подготовку статьи: {error}",
 
839
  "word_count": "📊 Бөлім көлемі: {actual} сөз (мақсат: {target}); жиынтық ауытқу: {deviation:+d}",
840
  "translation_running": "⏳ Ағылшын мәтіні аяқталды. Фондық аударма орындалуда…",
841
  "node_error": "🚨 Қате салдарынан түйін тоқтатылды.",
842
+ "literature_insufficient": "Қолжетімді жоғары релевантты әдебиет қолжазбаның кейбір негізгі тұжырымдарын тікелей қолдауға жеткіліксіз. Жүйе санды толтыру үшін қатысы жоқ дереккөздерді пайдаланбады. Зерттеу тақырыбын немесе кілт сөздерді өзгертіңіз не тікелей релевантты негізгі әдебиетті қосып, қайта көріңіз.",
843
+ "upstream_translation_stopped": "Ағылшын тіліндегі бастапқы қолжазба дәлелдер тексерісінен өтпеді, сонд��қтан кейінгі аударма тоқтатылды. Қазір көрсетілген аударма толық емес және соңғы мақала болып саналмайды.",
844
  "node_error_detail": "Қате коды: {code} | {error} | Бақылау нүктесі сақталды; жұмысты жалғастыруға болады.",
845
  "task_cancelled": "Тапсырма тоқтатылды. Бақылау нүктесі мен уақытша файлдар жойылды.",
846
  "task_start_failed": "Мақала тапсырмасын бастау мүмкін болмады: {error}",