GuXiaopeng commited on
Commit
8f31d2f
·
verified ·
1 Parent(s): d517067

R14-TQ2-PQ1-R1 Claim-Anchored Citation Recovery + Chinese Final Display + Completion Download Navigation

Browse files
Files changed (3) hide show
  1. app.py +99 -2
  2. core/generation_runner.py +58 -14
  3. core/pipeline_agent.py +183 -80
app.py CHANGED
@@ -273,6 +273,7 @@ for key, default in {
273
  "sp_task_recovery_initialized": False,
274
  "sp_last_ui_task_status": "",
275
  "sp_scroll_to_generation_action": False,
 
276
  }.items():
277
  if key not in st.session_state:
278
  st.session_state[key] = default
@@ -833,15 +834,77 @@ def _load_confirmed_checkpoint(task: dict) -> dict | None:
833
  return None
834
 
835
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
836
  def _render_checkpoint_preview(task: dict, lang_code: str) -> None:
837
  """Render stable bilingual DOM targets; browser mutates them in place."""
838
  checkpoint = _load_confirmed_checkpoint(task)
839
  english = str((checkpoint or {}).get("full_draft_en") or "").strip()
840
  target = str((checkpoint or {}).get("full_draft_translated") or "").strip()
841
- english_initial = english
842
  english_status_initial = "" if english else tr(lang_code, "draft_preparing")
843
  english_status_display = "none" if english else "block"
844
- target_initial = target if target else tr(lang_code, "translation_waiting")
845
  english_placeholder = "0" if english else "1"
846
  target_placeholder = "0" if target else "1"
847
  left, right = st.columns([1, 1], gap="medium")
@@ -871,6 +934,35 @@ def _render_checkpoint_preview(task: dict, lang_code: str) -> None:
871
  st.markdown("<style>@keyframes spBlink { 0%,49% {opacity:1} 50%,100% {opacity:0} }</style>", unsafe_allow_html=True)
872
 
873
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
874
  def _render_task_status_once(lang_code: str) -> None:
875
  task = st.session_state.get("sp_generation_task") or {}
876
  task_id = str(task.get("task_id") or "").strip()
@@ -902,8 +994,13 @@ def _render_task_status_once(lang_code: str) -> None:
902
  elif status == "completed":
903
  output_path = _task_output_path(task)
904
  if output_path and output_path.is_file():
 
 
 
 
905
  st.success(tr(lang_code, "success"))
906
  st.download_button(label=tr(lang_code, "download"), data=output_path.read_bytes(), file_name=output_path.name, mime="application/zip")
 
907
  else:
908
  st.warning("The task completed, but the local output file is unavailable after runtime replacement.")
909
  elif status == "failed":
 
273
  "sp_task_recovery_initialized": False,
274
  "sp_last_ui_task_status": "",
275
  "sp_scroll_to_generation_action": False,
276
+ "sp_completed_download_scroll_task_id": "",
277
  }.items():
278
  if key not in st.session_state:
279
  st.session_state[key] = default
 
834
  return None
835
 
836
 
837
+ _PREVIEW_UNNUMBERED_HEADINGS = {
838
+ "abstract", "keyword", "keywords", "references", "reference list",
839
+ "摘要", "关键词", "关键字", "参考文献",
840
+ "аннотация", "ключевое слово", "ключевые слова", "литература", "список литературы",
841
+ "аңдатпа", "түйіндеме", "түйін сөздер", "түйінді сөздер", "кілт сөздер",
842
+ "әдебиеттер", "пайдаланылған әдебиеттер",
843
+ }
844
+ _PREVIEW_UNNUMBERED_PREFIXES = ("appendix", "附录", "приложение", "қосымша")
845
+
846
+
847
+ def _normalize_preview_display_text(value: str) -> str:
848
+ """Mirror the browser heading presentation for initial/terminal rerenders.
849
+
850
+ Live updates are normalized by the browser bridge. A terminal Streamlit
851
+ rerender used to fall back to raw Markdown, so completed pages could show
852
+ ``#`` markers or lose the Arabic numbering users saw during generation.
853
+ Keep one deterministic server-side equivalent for the initial/terminal DOM.
854
+ """
855
+ lines = str(value or "").replace("\r", "").split("\n")
856
+ heading_rows = []
857
+ for index, line in enumerate(lines):
858
+ match = re.match(r"^(#{1,3})\s+(.+?)\s*$", line)
859
+ if not match:
860
+ continue
861
+ title = re.sub(r"^\d+(?:\.\d+){0,2}\s+", "", match.group(2).strip())
862
+ heading_rows.append((index, len(match.group(1)), title))
863
+ if not heading_rows:
864
+ return "\n".join(lines)
865
+
866
+ def unnumbered(title: str) -> bool:
867
+ key = str(title or "").strip().casefold()
868
+ return key in _PREVIEW_UNNUMBERED_HEADINGS or any(key.startswith(prefix) for prefix in _PREVIEW_UNNUMBERED_PREFIXES)
869
+
870
+ has_front_matter = any(unnumbered(title) for _index, _level, title in heading_rows)
871
+ first_h1 = next((row for row in heading_rows if row[1] == 1), None)
872
+ counters = [0, 0, 0]
873
+ for index, level, title in heading_rows:
874
+ is_known_unnumbered = unnumbered(title)
875
+ is_title = bool(has_front_matter and first_h1 and index == first_h1[0] and not is_known_unnumbered)
876
+ if is_known_unnumbered or is_title:
877
+ lines[index] = title
878
+ continue
879
+ if level == 1:
880
+ counters[0] += 1
881
+ counters[1] = counters[2] = 0
882
+ lines[index] = f"{counters[0]} {title}"
883
+ elif level == 2:
884
+ if counters[0] == 0:
885
+ counters[0] = 1
886
+ counters[1] += 1
887
+ counters[2] = 0
888
+ lines[index] = f"{counters[0]}.{counters[1]} {title}"
889
+ else:
890
+ if counters[0] == 0:
891
+ counters[0] = 1
892
+ if counters[1] == 0:
893
+ counters[1] = 1
894
+ counters[2] += 1
895
+ lines[index] = f"{counters[0]}.{counters[1]}.{counters[2]} {title}"
896
+ return "\n".join(lines)
897
+
898
+
899
  def _render_checkpoint_preview(task: dict, lang_code: str) -> None:
900
  """Render stable bilingual DOM targets; browser mutates them in place."""
901
  checkpoint = _load_confirmed_checkpoint(task)
902
  english = str((checkpoint or {}).get("full_draft_en") or "").strip()
903
  target = str((checkpoint or {}).get("full_draft_translated") or "").strip()
904
+ english_initial = _normalize_preview_display_text(english)
905
  english_status_initial = "" if english else tr(lang_code, "draft_preparing")
906
  english_status_display = "none" if english else "block"
907
+ target_initial = _normalize_preview_display_text(target) if target else tr(lang_code, "translation_waiting")
908
  english_placeholder = "0" if english else "1"
909
  target_placeholder = "0" if target else "1"
910
  left, right = st.columns([1, 1], gap="medium")
 
934
  st.markdown("<style>@keyframes spBlink { 0%,49% {opacity:1} 50%,100% {opacity:0} }</style>", unsafe_allow_html=True)
935
 
936
 
937
+ def _scroll_to_completed_download_once(task_id: str) -> None:
938
+ """Move the page to the existing download area once per completed task."""
939
+ task_id = str(task_id or "").strip()
940
+ if not task_id or st.session_state.get("sp_completed_download_scroll_task_id") == task_id:
941
+ return
942
+ st.session_state["sp_completed_download_scroll_task_id"] = task_id
943
+ components.html(
944
+ f"""
945
+ <script>
946
+ (() => {{
947
+ const parentDocument = window.parent.document;
948
+ const expectedTaskId = {json.dumps(task_id)};
949
+ let done = false;
950
+ const locate = () => {{
951
+ if (done) return;
952
+ const target = parentDocument.getElementById('sp-completed-download-anchor');
953
+ if (!target || target.dataset.spTaskId !== expectedTaskId) return;
954
+ done = true;
955
+ target.scrollIntoView({{behavior:'smooth', block:'center'}});
956
+ }};
957
+ [0, 60, 140, 300, 650, 1000].forEach(delay => window.setTimeout(locate, delay));
958
+ }})();
959
+ </script>
960
+ """,
961
+ height=0,
962
+ width=0,
963
+ )
964
+
965
+
966
  def _render_task_status_once(lang_code: str) -> None:
967
  task = st.session_state.get("sp_generation_task") or {}
968
  task_id = str(task.get("task_id") or "").strip()
 
994
  elif status == "completed":
995
  output_path = _task_output_path(task)
996
  if output_path and output_path.is_file():
997
+ st.markdown(
998
+ f"<div id='sp-completed-download-anchor' data-sp-task-id='{html.escape(task_id, quote=True)}'></div>",
999
+ unsafe_allow_html=True,
1000
+ )
1001
  st.success(tr(lang_code, "success"))
1002
  st.download_button(label=tr(lang_code, "download"), data=output_path.read_bytes(), file_name=output_path.name, mime="application/zip")
1003
+ _scroll_to_completed_download_once(task_id)
1004
  else:
1005
  st.warning("The task completed, but the local output file is unavailable after runtime replacement.")
1006
  elif status == "failed":
core/generation_runner.py CHANGED
@@ -65,6 +65,37 @@ def _canonical_request_hash(request_data: dict[str, Any]) -> str:
65
  return hashlib.sha256(encoded).hexdigest()
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  class _OrderedTranslationWorker:
69
  """One daemon worker owned by the headless Executor process.
70
 
@@ -2088,8 +2119,9 @@ class GenerationRunner:
2088
  )
2089
  else:
2090
  citation_rule = (
2091
- "Use the citation-first claim-evidence plan below. Every listed item has already been assigned to this unit on evidence relevance: use each assigned item exactly once unless it would contradict the frozen research evidence. "
2092
- "For each item, write one distinct claim no broader than the supplied evidence and attach exactly its assigned [CITE-XX] tag. "
 
2093
  "Never stack sources, substitute tags, or use literature as evidence for this study's own facts.\n"
2094
  f"<CLAIM_EVIDENCE_PLAN>\n{claim_plan_text}\n</CLAIM_EVIDENCE_PLAN>"
2095
  )
@@ -2301,19 +2333,21 @@ Output only final academic prose. Do not report the word count."""
2301
  planned_items.append(recovery_item)
2302
  planned_tags_for_unit.add(tag)
2303
  citation_plan[display_name] = planned_items
2304
- supplemental_raw = agent.build_citation_recovery_paragraph(
2305
- recovery_items[:needed], section_name=display_name, section_context=en_resolved
 
 
 
 
 
 
 
 
2306
  )
2307
- if supplemental_raw:
2308
- supplemental_resolved = agent.audit_and_resolve_citations(
2309
- supplemental_raw,
2310
- section_name=f"{display_name} — citation recovery",
2311
- quarantine=False,
2312
- )
2313
- if supplemental_resolved.strip():
2314
- en_resolved = en_resolved.rstrip() + "\n\n" + supplemental_resolved.strip()
2315
- used_after = set(agent.used_citations)
2316
- survived = used_after - used_before
2317
  if assigned_tag_list and not is_quarantined:
2318
  adaptive_minimum = max(1, int(citation_minimums.get(display_name, 1)))
2319
  if len(survived) < adaptive_minimum:
@@ -2555,6 +2589,9 @@ Output only final academic prose. Do not report the word count."""
2555
  full_draft_translated += (
2556
  f"\n\n# {agent._localized_reference_heading(target_lang)}\n" + reference_tail.strip()
2557
  )
 
 
 
2558
  agent.validate_translation_alignment(full_draft_en, full_draft_translated)
2559
  self._publish_target_preview(
2560
  full_draft_translated, 'translation_complete', force=True, complete=True
@@ -2570,6 +2607,13 @@ Output only final academic prose. Do not report the word count."""
2570
  )
2571
  self._checkpoint("translation_complete", state, 96)
2572
 
 
 
 
 
 
 
 
2573
  existing_output = str(state.get("output_filename") or "").strip()
2574
  if state.get("package_validated") and existing_output:
2575
  existing_path = self.output_dir / os.path.basename(existing_output)
 
65
  return hashlib.sha256(encoded).hexdigest()
66
 
67
 
68
+ def _normalize_target_manuscript_typography(text: str, target_lang: str) -> str:
69
+ """Apply only deterministic language-local typography after semantic QA.
70
+
71
+ R14-TQ2-PQ1-R1 fixes English-style spaces around Chinese full-width
72
+ punctuation without touching Russian/Kazakh spacing, references, semantic
73
+ content, or code-fenced material. This runs only after the target manuscript
74
+ has passed translation QA; it is presentation normalization, not translation.
75
+ """
76
+ value = str(text or "")
77
+ language = str(target_lang or "")
78
+ if "chinese" not in language.casefold() and "中文" not in language:
79
+ return value
80
+ value = value.replace("\r\n", "\n").replace("\r", "\n")
81
+ lines = value.split("\n")
82
+ normalized = []
83
+ in_fence = False
84
+ for line in lines:
85
+ stripped = line.lstrip()
86
+ if stripped.startswith("```"):
87
+ in_fence = not in_fence
88
+ normalized.append(line)
89
+ continue
90
+ if in_fence:
91
+ normalized.append(line)
92
+ continue
93
+ current = re.sub(r"[ \t]+([,。!?;:])", r"\1", line)
94
+ current = re.sub(r"([,。!?;:])[ \t]+(?=\S)", r"\1", current)
95
+ normalized.append(current)
96
+ return "\n".join(normalized)
97
+
98
+
99
  class _OrderedTranslationWorker:
100
  """One daemon worker owned by the headless Executor process.
101
 
 
2119
  )
2120
  else:
2121
  citation_rule = (
2122
+ "Use the citation-first claim-evidence plan below as bounded evidence options, not as a claim quota. "
2123
+ "Do not create a new claim merely to consume an assigned citation. Use a source only when it directly supports a claim independently required by the frozen research logic, section role, or already established manuscript argument; omit any item that would introduce another application domain or mutate the study's facts. "
2124
+ "When a source is used, keep the claim no broader than the supplied evidence and attach exactly its assigned [CITE-XX] tag. "
2125
  "Never stack sources, substitute tags, or use literature as evidence for this study's own facts.\n"
2126
  f"<CLAIM_EVIDENCE_PLAN>\n{claim_plan_text}\n</CLAIM_EVIDENCE_PLAN>"
2127
  )
 
2333
  planned_items.append(recovery_item)
2334
  planned_tags_for_unit.add(tag)
2335
  citation_plan[display_name] = planned_items
2336
+ # R14-TQ2-PQ1-R1: recovery may attach evidence only to
2337
+ # an already-written claim. It is never authorized to create a
2338
+ # supplemental paragraph from a source merely to satisfy citation
2339
+ # survival. If no existing claim can be validated, leave prose
2340
+ # unchanged and let the literature-sufficiency Gate fail closed.
2341
+ recovered = agent.recover_citations_on_existing_claims(
2342
+ en_resolved,
2343
+ recovery_items[:needed],
2344
+ section_name=display_name,
2345
+ needed=needed,
2346
  )
2347
+ if recovered != en_resolved:
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:
 
2589
  full_draft_translated += (
2590
  f"\n\n# {agent._localized_reference_heading(target_lang)}\n" + reference_tail.strip()
2591
  )
2592
+ full_draft_translated = _normalize_target_manuscript_typography(
2593
+ full_draft_translated, target_lang
2594
+ )
2595
  agent.validate_translation_alignment(full_draft_en, full_draft_translated)
2596
  self._publish_target_preview(
2597
  full_draft_translated, 'translation_complete', force=True, complete=True
 
2607
  )
2608
  self._checkpoint("translation_complete", state, 96)
2609
 
2610
+ normalized_target = _normalize_target_manuscript_typography(
2611
+ full_draft_translated, target_lang
2612
+ )
2613
+ if normalized_target != full_draft_translated:
2614
+ full_draft_translated = normalized_target
2615
+ state["full_draft_translated"] = full_draft_translated
2616
+
2617
  existing_output = str(state.get("output_filename") or "").strip()
2618
  if state.get("package_validated") and existing_output:
2619
  existing_path = self.output_dir / os.path.basename(existing_output)
core/pipeline_agent.py CHANGED
@@ -2546,6 +2546,18 @@ INPUT:
2546
  evidence = str((self.citation_map.get(tag) or {}).get('evidence') or '')
2547
  if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
2548
  continue
 
 
 
 
 
 
 
 
 
 
 
 
2549
  used_tags.add(tag)
2550
  best.append({'tag': tag, 'section': section, 'claim': claim})
2551
  section_loads[section] += 1
@@ -2557,46 +2569,11 @@ INPUT:
2557
  batch_index // batch_size + 1, len(batch), accepted, len(best), target,
2558
  )
2559
 
2560
- # Evidence-derived recovery is deliberately local and conservative. It
2561
- # never invents a new empirical fact: the claim is copied from abstract
2562
- # evidence, and the section is selected by lexical relevance + capacity.
2563
- if len(best) < target:
2564
- loads = dict(section_loads)
2565
- for candidate in candidates:
2566
- if candidate['tag'] in used_tags:
2567
- continue
2568
- evidence = str(candidate.get('evidence') or '')
2569
- abstract = evidence.split('Abstract evidence:', 1)[-1].strip() if 'Abstract evidence:' in evidence else ''
2570
- if len(abstract.split()) < 8:
2571
- continue
2572
- sentence_parts = re.split(r'(?<=[.!?])\s+', abstract)
2573
- claim = next((part.strip() for part in sentence_parts if 8 <= len(part.split()) <= 55), '')
2574
- if not claim:
2575
- words = abstract.split()
2576
- if len(words) < 8:
2577
- continue
2578
- claim = ' '.join(words[:55]).rstrip(' ,;:') + '.'
2579
- evidence_terms = self._citation_terms(evidence)
2580
- ranked = []
2581
- for section in sections:
2582
- name = section['name']
2583
- if loads[name] >= capacities.get(name, target):
2584
- continue
2585
- section_terms = self._citation_terms(name + ' ' + section['role'])
2586
- overlap = len(evidence_terms & section_terms)
2587
- if overlap <= 0:
2588
- continue
2589
- load_ratio = loads[name] / max(1, capacities.get(name, 1))
2590
- ranked.append((overlap, -load_ratio, section['target_words'], name))
2591
- if not ranked:
2592
- continue
2593
- ranked.sort(reverse=True)
2594
- chosen = ranked[0][3]
2595
- best.append({'tag': candidate['tag'], 'section': chosen, 'claim': claim})
2596
- used_tags.add(candidate['tag'])
2597
- loads[chosen] += 1
2598
- if len(best) >= target:
2599
- break
2600
 
2601
  plan = {item['name']: [] for item in sections}
2602
  for row in best:
@@ -2647,7 +2624,11 @@ INPUT:
2647
  overlap, distinctive = cls._citation_recovery_overlap(
2648
  section_name, section_context, claim, evidence
2649
  )
2650
- return distinctive >= 2 or (distinctive >= 1 and overlap >= 3)
 
 
 
 
2651
 
2652
  @classmethod
2653
  def _best_contextual_evidence_claim(cls, evidence, section_name='', section_context=''):
@@ -2661,7 +2642,7 @@ INPUT:
2661
  overlap, distinctive = cls._citation_recovery_overlap(
2662
  section_name, section_context, sentence, raw
2663
  )
2664
- if distinctive >= 2 or (distinctive >= 1 and overlap >= 3):
2665
  ranked.append((distinctive, overlap, -len(sentence.split()), sentence))
2666
  if not ranked:
2667
  return ''
@@ -2822,47 +2803,169 @@ INPUT:
2822
  logging.info('Contextual citation JIT accepted: section=%s accepted=%s/%s', section_name, len(selected), needed)
2823
  return selected
2824
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2825
  def build_citation_recovery_paragraph(self, items, section_name='', section_context=''):
2826
- """Create one bounded literature paragraph from already planned evidence.
2827
 
2828
- This is invoked only when semantic validation removed too many planned
2829
- citations from a unit. It never invents new sources or study facts.
2830
  """
2831
- selected = [
2832
- item for item in (items or [])
2833
- if item.get('tag') and item.get('claim') and item.get('evidence')
2834
- and self._citation_recovery_item_relevant(item, section_name, section_context)
2835
- ][:5]
2836
- if not selected:
2837
- return ''
2838
- plan_text = self.format_claim_evidence_plan(selected)
2839
- expected = [str(item['tag']) for item in selected]
2840
- prompt = f"""Write ONE cohesive top-journal literature paragraph for the named manuscript unit using ONLY the planned claim-evidence items below.
2841
- Rules:
2842
- 1. Use every assigned [CITE-XX] tag exactly once, attached only to its own directly supported claim.
2843
- 2. Do not broaden a claim beyond its evidence and do not add any study-specific fact, statistic, setting, result, quotation, hypothesis, or new source.
2844
- 3. Do not stack citations. Keep each source's claim in a distinct sentence or clause.
2845
- 4. Avoid generic literature-listing prose and contribution-counting templates; connect the claims through one clear theoretical point relevant to the unit.
2846
- 5. Output only the paragraph, no heading or commentary.
2847
- SECTION: {section_name}
2848
- <CLAIM_EVIDENCE_PLAN>
2849
- {plan_text}
2850
- </CLAIM_EVIDENCE_PLAN>"""
2851
- raw = self.call_deepseek(
2852
- 'You are a precise management-journal evidence integrator.',
2853
- prompt,
2854
- max_tokens=1400,
2855
- temp=0.1,
2856
- model_role='writing',
2857
- thinking=False,
2858
- allow_fallback=False,
2859
- ).strip()
2860
- actual = Counter(re.findall(r'\[CITE-\d+\]', raw, re.I))
2861
- expected_counter = Counter(expected)
2862
- if actual != expected_counter:
2863
- logging.warning('Citation recovery paragraph model output rejected; using evidence-plan fallback: section=%s', section_name)
2864
- raw = ' '.join(f"{str(item['claim']).rstrip('.')} {item['tag']}." for item in selected)
2865
- return raw.strip()
2866
 
2867
  def _candidate_citation_tags(self, sentence, required_tags, limit):
2868
  valid_required = [
 
2546
  evidence = str((self.citation_map.get(tag) or {}).get('evidence') or '')
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
+ ])
2553
+ if not self._citation_recovery_item_relevant(
2554
+ {'claim': claim, 'evidence': evidence}, section, section_context
2555
+ ):
2556
+ logging.info(
2557
+ 'Citation planner rejected off-scope source: section=%s tag=%s',
2558
+ section, tag,
2559
+ )
2560
+ continue
2561
  used_tags.add(tag)
2562
  best.append({'tag': tag, 'section': section, 'claim': claim})
2563
  section_loads[section] += 1
 
2569
  batch_index // batch_size + 1, len(batch), accepted, len(best), target,
2570
  )
2571
 
2572
+ # R14-TQ2-PQ1-R1: citation planning is fail-closed rather than quota-driven.
2573
+ # Do not manufacture a manuscript claim by copying an abstract sentence merely
2574
+ # to fill the citation target. Unit-level contextual source selection below may
2575
+ # still find genuinely relevant evidence; otherwise the adaptive literature
2576
+ # sufficiency Gate fails before drafting instead of introducing another domain.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2577
 
2578
  plan = {item['name']: [] for item in sections}
2579
  for row in best:
 
2624
  overlap, distinctive = cls._citation_recovery_overlap(
2625
  section_name, section_context, claim, evidence
2626
  )
2627
+ # One distinctive token plus generic words (e.g. chatbot + research + users)
2628
+ # is not enough to authorize a recovery source. Requiring two substantive
2629
+ # overlaps is intentionally conservative: insufficient evidence must fail
2630
+ # rather than import a different application domain.
2631
+ return distinctive >= 2
2632
 
2633
  @classmethod
2634
  def _best_contextual_evidence_claim(cls, evidence, section_name='', section_context=''):
 
2642
  overlap, distinctive = cls._citation_recovery_overlap(
2643
  section_name, section_context, sentence, raw
2644
  )
2645
+ if distinctive >= 2:
2646
  ranked.append((distinctive, overlap, -len(sentence.split()), sentence))
2647
  if not ranked:
2648
  return ''
 
2803
  logging.info('Contextual citation JIT accepted: section=%s accepted=%s/%s', section_name, len(selected), needed)
2804
  return selected
2805
 
2806
+ @staticmethod
2807
+ def _citation_recovery_prose_signature(value):
2808
+ """Canonical prose identity with only raw citation tags removed.
2809
+
2810
+ Recovery is allowed to add a citation to an existing claim, never to rewrite,
2811
+ split, delete, or invent claim prose. Whitespace normalization is used only for
2812
+ the comparison so an audit model cannot create a factual mutation silently.
2813
+ """
2814
+ stripped = re.sub(
2815
+ r'\s*\[?CITE-\d+\]?\s*(?=[.!?。!?])', '', str(value or ''), flags=re.I
2816
+ )
2817
+ stripped = re.sub(r'\s*\[?CITE-\d+\]?\s*', ' ', stripped, flags=re.I)
2818
+ return re.sub(r'\s+', ' ', stripped).strip()
2819
+
2820
+ @classmethod
2821
+ def _citation_recovery_sentence_has_citation(cls, sentence):
2822
+ value = str(sentence or '')
2823
+ if re.search(r'CITE-\d+', value, re.I):
2824
+ return True
2825
+ return bool(re.search(
2826
+ r"\((?=[^()\n]*[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+(?:\s+(?:&\s+[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+|et\s+al\.))?\s*,\s*(?:19|20)\d{2}[a-z]?\b)[^()\n]+\)",
2827
+ value,
2828
+ re.I,
2829
+ ))
2830
+
2831
+ @staticmethod
2832
+ def _append_raw_citation_to_sentence(sentence, tag):
2833
+ raw = str(sentence or '')
2834
+ trailing_match = re.search(r'(\s*)$', raw)
2835
+ trailing = trailing_match.group(1) if trailing_match else ''
2836
+ core = raw[:-len(trailing)] if trailing else raw
2837
+ punctuation = ''
2838
+ if core and core[-1] in '.!?。!?':
2839
+ punctuation = core[-1]
2840
+ core = core[:-1]
2841
+ return f"{core.rstrip()} {tag}{punctuation}{trailing}"
2842
+
2843
+ def recover_citations_on_existing_claims(
2844
+ self, text, items, *, section_name='', needed=1,
2845
+ ):
2846
+ """Attach recovery citations only to claims already present in the unit.
2847
+
2848
+ R14-TQ2-PQ1-R1 forbids citation-driven prose generation. A candidate source
2849
+ must have at least two distinctive lexical overlaps with one citation-free
2850
+ existing sentence. The proposed attachment is then passed through the same
2851
+ semantic citation auditor. If that auditor rewrites/removes/splits any prose,
2852
+ changes the selected tag set, or cannot validate technically, the whole
2853
+ recovery proposal is rejected and the original manuscript text is retained.
2854
+ The downstream literature-sufficiency Gate may then fail; unrelated filler is
2855
+ never substituted merely to meet a citation quota.
2856
+ """
2857
+ value = str(text or '')
2858
+ needed = max(0, int(needed or 0))
2859
+ if not value.strip() or needed <= 0:
2860
+ return value
2861
+
2862
+ candidates = []
2863
+ for index, (start, end, sentence) in enumerate(self._sentence_spans(value)):
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
2870
+ candidates.append((index, start, end, sentence, sentence_terms))
2871
+ if not candidates:
2872
+ return value
2873
+
2874
+ ranked_assignments = []
2875
+ occupied_sentences = set()
2876
+ used_tags = set()
2877
+ for item in items or []:
2878
+ if not isinstance(item, dict):
2879
+ continue
2880
+ tag = str(item.get('tag') or '').strip()
2881
+ claim = str(item.get('claim') or '').strip()
2882
+ evidence = str(item.get('evidence') or '').strip()
2883
+ if not tag or tag not in self.citation_map or tag in used_tags or not evidence:
2884
+ continue
2885
+ support_terms = self._citation_terms(f"{claim} {evidence}")
2886
+ if not support_terms:
2887
+ continue
2888
+ scored = []
2889
+ for candidate in candidates:
2890
+ index, start, end, sentence, sentence_terms = candidate
2891
+ if index in occupied_sentences:
2892
+ continue
2893
+ overlap_terms = sentence_terms & support_terms
2894
+ distinctive_terms = overlap_terms - self._CITATION_RECOVERY_GENERIC_TERMS
2895
+ overlap = len(overlap_terms)
2896
+ distinctive = len(distinctive_terms)
2897
+ coverage = overlap / max(1, len(sentence_terms))
2898
+ if distinctive < 2 or overlap < 2 or coverage < 0.08:
2899
+ continue
2900
+ scored.append((distinctive, overlap, coverage, -index, candidate))
2901
+ if not scored:
2902
+ continue
2903
+ scored.sort(reverse=True, key=lambda row: (row[0], row[1], row[2], row[3]))
2904
+ _distinctive, _overlap, _coverage, _order, candidate = scored[0]
2905
+ index, start, end, sentence, _terms = candidate
2906
+ occupied_sentences.add(index)
2907
+ used_tags.add(tag)
2908
+ ranked_assignments.append((start, end, sentence, tag))
2909
+ if len(ranked_assignments) >= needed:
2910
+ break
2911
+
2912
+ if not ranked_assignments:
2913
+ return value
2914
+
2915
+ proposed = value
2916
+ for start, end, sentence, tag in sorted(ranked_assignments, key=lambda row: row[0], reverse=True):
2917
+ replacement = self._append_raw_citation_to_sentence(sentence, tag)
2918
+ proposed = proposed[:start] + replacement + proposed[end:]
2919
+
2920
+ expected_tags = {tag for _start, _end, _sentence, tag in ranked_assignments}
2921
+ audit_len = len(self.citation_audit_log)
2922
+ try:
2923
+ audited = self.semantic_audit_claim_specific_citations(
2924
+ proposed, section_name=f"{section_name} — claim-anchored citation recovery"
2925
+ )
2926
+ except PipelineCancelledError:
2927
+ raise
2928
+ except Exception as exc:
2929
+ del self.citation_audit_log[audit_len:]
2930
+ logging.warning(
2931
+ 'Claim-anchored citation recovery rejected after audit failure: section=%s error=%s',
2932
+ section_name, self._safe_source_error(exc),
2933
+ )
2934
+ return value
2935
+
2936
+ actual_tags = {
2937
+ f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', audited, flags=re.I)
2938
+ }
2939
+ if (
2940
+ actual_tags != expected_tags
2941
+ or self._citation_recovery_prose_signature(audited) != self._citation_recovery_prose_signature(value)
2942
+ ):
2943
+ del self.citation_audit_log[audit_len:]
2944
+ logging.warning(
2945
+ 'Claim-anchored citation recovery rejected prose/tag mutation: section=%s expected=%s actual=%s',
2946
+ section_name, sorted(expected_tags), sorted(actual_tags),
2947
+ )
2948
+ return value
2949
+
2950
+ resolved = self.resolve_citations(audited, quarantine=False)
2951
+ logging.info(
2952
+ 'Claim-anchored citation recovery accepted: section=%s attached=%s',
2953
+ section_name, len(expected_tags),
2954
+ )
2955
+ return resolved
2956
+
2957
  def build_citation_recovery_paragraph(self, items, section_name='', section_context=''):
2958
+ """Deprecated fail-closed compatibility shim.
2959
 
2960
+ Citation recovery is no longer authorized to create prose from a source or
2961
+ abstract sentence. Call ``recover_citations_on_existing_claims`` instead.
2962
  """
2963
+ if items:
2964
+ logging.warning(
2965
+ 'Citation recovery paragraph suppressed by claim-anchored policy: section=%s items=%s',
2966
+ section_name, len(items),
2967
+ )
2968
+ return ''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2969
 
2970
  def _candidate_citation_tags(self, sentence, required_tags, limit):
2971
  valid_required = [