Spaces:
Running
Running
R14-TQ2-PQ1-R1 Claim-Anchored Citation Recovery + Chinese Final Display + Completion Download Navigation
Browse files- app.py +99 -2
- core/generation_runner.py +58 -14
- 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
|
| 2092 |
-
"
|
|
|
|
| 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 |
-
|
| 2305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2306 |
)
|
| 2307 |
-
if
|
| 2308 |
-
|
| 2309 |
-
|
| 2310 |
-
|
| 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 |
-
#
|
| 2561 |
-
#
|
| 2562 |
-
#
|
| 2563 |
-
|
| 2564 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
"""
|
| 2827 |
|
| 2828 |
-
|
| 2829 |
-
|
| 2830 |
"""
|
| 2831 |
-
|
| 2832 |
-
|
| 2833 |
-
|
| 2834 |
-
|
| 2835 |
-
|
| 2836 |
-
|
| 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 = [
|