Spaces:
Running
Running
Step 4A-6-C-R3.2.8 Citation Singularity Word Streaming Bilingual Translation and Structure Gate Fix
#18
by GuXiaopeng - opened
- app.py +42 -16
- core/generation_runner.py +199 -9
- core/pipeline_agent.py +424 -49
- i18n.py +33 -1
- smartpaper_restore_bridge/frontend/index.html +137 -39
app.py
CHANGED
|
@@ -693,6 +693,17 @@ def _watch_task_status_live(task: dict) -> dict | None:
|
|
| 693 |
"outline_planning": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_outline"),
|
| 694 |
"citation_planning": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_citation"),
|
| 695 |
"writing": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_writing"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
},
|
| 697 |
},
|
| 698 |
key=f"sp_task_watch_{_safe_key(task_id)}",
|
|
@@ -737,23 +748,38 @@ def _load_confirmed_checkpoint(task: dict) -> dict | None:
|
|
| 737 |
|
| 738 |
|
| 739 |
def _render_checkpoint_preview(task: dict, lang_code: str) -> None:
|
| 740 |
-
"""Render
|
| 741 |
checkpoint = _load_confirmed_checkpoint(task)
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
|
| 758 |
|
| 759 |
def _render_task_status_once(lang_code: str) -> None:
|
|
|
|
| 693 |
"outline_planning": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_outline"),
|
| 694 |
"citation_planning": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_citation"),
|
| 695 |
"writing": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_stage_writing"),
|
| 696 |
+
"progress": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_detail_progress"),
|
| 697 |
+
"s2": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_detail_s2"),
|
| 698 |
+
"oa": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_detail_oa"),
|
| 699 |
+
"current": tr(str(st.session_state.get("sp_lang_code") or "zh"), "draft_detail_current"),
|
| 700 |
+
},
|
| 701 |
+
"target_status_labels": {
|
| 702 |
+
"waiting": tr(str(st.session_state.get("sp_lang_code") or "zh"), "translation_waiting"),
|
| 703 |
+
"translating": tr(str(st.session_state.get("sp_lang_code") or "zh"), "translation_section_running"),
|
| 704 |
+
"qa": tr(str(st.session_state.get("sp_lang_code") or "zh"), "translation_section_qa"),
|
| 705 |
+
"ready": tr(str(st.session_state.get("sp_lang_code") or "zh"), "translation_section_ready"),
|
| 706 |
+
"complete": tr(str(st.session_state.get("sp_lang_code") or "zh"), "translation_complete_label"),
|
| 707 |
},
|
| 708 |
},
|
| 709 |
key=f"sp_task_watch_{_safe_key(task_id)}",
|
|
|
|
| 748 |
|
| 749 |
|
| 750 |
def _render_checkpoint_preview(task: dict, lang_code: str) -> None:
|
| 751 |
+
"""Render stable bilingual DOM targets; browser mutates them in place."""
|
| 752 |
checkpoint = _load_confirmed_checkpoint(task)
|
| 753 |
+
english = str((checkpoint or {}).get("full_draft_en") or "").strip()
|
| 754 |
+
target = str((checkpoint or {}).get("full_draft_translated") or "").strip()
|
| 755 |
+
english_initial = english if english else tr(lang_code, "draft_preparing")
|
| 756 |
+
target_initial = target if target else tr(lang_code, "translation_waiting")
|
| 757 |
+
english_placeholder = "0" if english else "1"
|
| 758 |
+
target_placeholder = "0" if target else "1"
|
| 759 |
+
left, right = st.columns([1, 1], gap="medium")
|
| 760 |
+
with left:
|
| 761 |
+
st.markdown(tr(lang_code, "english_draft"))
|
| 762 |
+
st.markdown(
|
| 763 |
+
f"""
|
| 764 |
+
<div id='sp-live-manuscript-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:220px;max-height:68vh;overflow:auto;background:#ffffff;'>
|
| 765 |
+
<div id='sp-live-manuscript-preview' data-sp-placeholder='{english_placeholder}' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{html.escape(english_initial)}</div>
|
| 766 |
+
<span id='sp-live-manuscript-cursor' aria-hidden='true' style='display:inline-block;width:7px;height:1.05em;background:#111827;margin-left:2px;vertical-align:-0.12em;animation:spBlink 1s steps(1,end) infinite;'></span>
|
| 767 |
+
</div>
|
| 768 |
+
""",
|
| 769 |
+
unsafe_allow_html=True,
|
| 770 |
+
)
|
| 771 |
+
with right:
|
| 772 |
+
st.markdown(tr(lang_code, "target_draft", flag=get_target_flag(lang_code)))
|
| 773 |
+
st.markdown(
|
| 774 |
+
f"""
|
| 775 |
+
<div id='sp-live-translation-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:220px;max-height:68vh;overflow:auto;background:#ffffff;'>
|
| 776 |
+
<div id='sp-live-translation-preview' data-sp-placeholder='{target_placeholder}' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{html.escape(target_initial)}</div>
|
| 777 |
+
<span id='sp-live-translation-cursor' aria-hidden='true' style='display:inline-block;width:7px;height:1.05em;background:#111827;margin-left:2px;vertical-align:-0.12em;animation:spBlink 1s steps(1,end) infinite;'></span>
|
| 778 |
+
</div>
|
| 779 |
+
""",
|
| 780 |
+
unsafe_allow_html=True,
|
| 781 |
+
)
|
| 782 |
+
st.markdown("<style>@keyframes spBlink { 0%,49% {opacity:1} 50%,100% {opacity:0} }</style>", unsafe_allow_html=True)
|
| 783 |
|
| 784 |
|
| 785 |
def _render_task_status_once(lang_code: str) -> None:
|
core/generation_runner.py
CHANGED
|
@@ -82,6 +82,12 @@ class GenerationRunner:
|
|
| 82 |
self.preview_token = preview_token if re.fullmatch(r"[A-Za-z0-9_-]{32,128}", preview_token) else ""
|
| 83 |
self.preview_path = None
|
| 84 |
self._last_preview_write = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
if self.preview_token:
|
| 86 |
preview_dir = Path(__file__).resolve().parents[1] / "static" / "previews"
|
| 87 |
preview_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -129,11 +135,15 @@ class GenerationRunner:
|
|
| 129 |
@staticmethod
|
| 130 |
def _preview_visible_text(value: str) -> str:
|
| 131 |
text = str(value or "")
|
| 132 |
-
# Never expose hidden reasoning
|
|
|
|
|
|
|
| 133 |
text = re.sub(r"(?is)<think>.*?(?:</think>|$)", "", text)
|
|
|
|
|
|
|
| 134 |
return text.strip()
|
| 135 |
|
| 136 |
-
def
|
| 137 |
if self.preview_path is None:
|
| 138 |
return
|
| 139 |
now = time.monotonic()
|
|
@@ -143,9 +153,12 @@ class GenerationRunner:
|
|
| 143 |
"task_id": str(self.request.get("task_id") or ""),
|
| 144 |
"request_hash": self.request_hash,
|
| 145 |
"execution_epoch": self.execution_epoch,
|
| 146 |
-
"stage": str(
|
| 147 |
-
"text": self._preview_visible_text(
|
| 148 |
-
"complete": bool(
|
|
|
|
|
|
|
|
|
|
| 149 |
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 150 |
}
|
| 151 |
temporary = self.preview_path.with_suffix(".json.tmp")
|
|
@@ -153,6 +166,18 @@ class GenerationRunner:
|
|
| 153 |
os.replace(temporary, self.preview_path)
|
| 154 |
self._last_preview_write = now
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
def _stream_text(self, generator, *, preview_prefix: str | None = None, preview_stage: str = "") -> str:
|
| 157 |
chunks = []
|
| 158 |
for chunk in generator:
|
|
@@ -167,6 +192,102 @@ class GenerationRunner:
|
|
| 167 |
self._publish_preview(preview_prefix + text, preview_stage, force=True)
|
| 168 |
return text
|
| 169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
@staticmethod
|
| 171 |
def _bounded_target(base_target: int, accumulated_deviation: float, remaining_including_current: int, safe_chunk: int) -> int:
|
| 172 |
base = max(80, int(base_target))
|
|
@@ -234,6 +355,8 @@ class GenerationRunner:
|
|
| 234 |
|
| 235 |
state = self.checkpoints.load_latest() or {}
|
| 236 |
if state:
|
|
|
|
|
|
|
| 237 |
logging.info(
|
| 238 |
"[SP_EXECUTOR] resume checkpoint seq=%s stage=%s",
|
| 239 |
state.get("checkpoint_seq"), state.get("checkpoint_stage"),
|
|
@@ -378,6 +501,8 @@ class GenerationRunner:
|
|
| 378 |
"body_target_words": body_target_words,
|
| 379 |
"full_draft_en": full_draft_en,
|
| 380 |
"full_draft_translated": "",
|
|
|
|
|
|
|
| 381 |
"completed_sections": [],
|
| 382 |
"context_tail": "",
|
| 383 |
"accumulated_deviation": 0.0,
|
|
@@ -399,6 +524,10 @@ class GenerationRunner:
|
|
| 399 |
state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
|
| 400 |
)
|
| 401 |
citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
if not citation_plan:
|
| 403 |
self._progress(15, "citation_planning")
|
| 404 |
citation_plan = agent.build_claim_evidence_plan(
|
|
@@ -615,11 +744,23 @@ Output only final academic prose. Do not report the word count."""
|
|
| 615 |
actual_words = self._english_count_for_target(en_resolved)
|
| 616 |
accumulated_deviation += actual_words - adjusted_target
|
| 617 |
completed_sections.append(display_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
state.update(
|
| 619 |
{
|
| 620 |
"completed_sections": completed_sections,
|
| 621 |
"full_draft_en": full_draft_en,
|
| 622 |
-
"full_draft_translated":
|
|
|
|
|
|
|
| 623 |
"context_tail": context_tail,
|
| 624 |
"accumulated_deviation": accumulated_deviation,
|
| 625 |
"agent_state": agent.export_state(),
|
|
@@ -661,12 +802,19 @@ Output only final academic prose. Do not report the word count."""
|
|
| 661 |
full_draft_en = agent.rebalance_discussion_length(
|
| 662 |
full_draft_en, target_words=target_words, mode=mode, actual_words=actual_words
|
| 663 |
)
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
self._validate_target_length(full_draft_en, target_words)
|
| 666 |
self._publish_preview(full_draft_en, "english_complete", force=True, complete=True)
|
| 667 |
state.update(
|
| 668 |
{
|
| 669 |
"full_draft_en": full_draft_en,
|
|
|
|
|
|
|
|
|
|
| 670 |
"english_finalized": True,
|
| 671 |
"agent_state": agent.export_state(),
|
| 672 |
}
|
|
@@ -676,13 +824,55 @@ Output only final academic prose. Do not report the word count."""
|
|
| 676 |
full_draft_en = str(state.get("full_draft_en") or "")
|
| 677 |
|
| 678 |
full_draft_translated = str(state.get("full_draft_translated") or "")
|
|
|
|
|
|
|
| 679 |
if not state.get("translation_complete") or not full_draft_translated:
|
| 680 |
-
self._progress(
|
| 681 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
agent.validate_translation_alignment(full_draft_en, full_draft_translated)
|
|
|
|
|
|
|
|
|
|
| 683 |
state.update(
|
| 684 |
{
|
| 685 |
"full_draft_translated": full_draft_translated,
|
|
|
|
|
|
|
| 686 |
"translation_complete": True,
|
| 687 |
"agent_state": agent.export_state(),
|
| 688 |
}
|
|
|
|
| 82 |
self.preview_token = preview_token if re.fullmatch(r"[A-Za-z0-9_-]{32,128}", preview_token) else ""
|
| 83 |
self.preview_path = None
|
| 84 |
self._last_preview_write = 0.0
|
| 85 |
+
self._preview_english_text = ""
|
| 86 |
+
self._preview_english_stage = ""
|
| 87 |
+
self._preview_english_complete = False
|
| 88 |
+
self._preview_target_text = ""
|
| 89 |
+
self._preview_target_stage = "translation_waiting"
|
| 90 |
+
self._preview_target_complete = False
|
| 91 |
if self.preview_token:
|
| 92 |
preview_dir = Path(__file__).resolve().parents[1] / "static" / "previews"
|
| 93 |
preview_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 135 |
@staticmethod
|
| 136 |
def _preview_visible_text(value: str) -> str:
|
| 137 |
text = str(value or "")
|
| 138 |
+
# Never expose hidden reasoning. Raw CITE placeholders are also hidden
|
| 139 |
+
# from the user because they have not passed semantic citation QA yet;
|
| 140 |
+
# the authoritative manuscript buffer remains untouched.
|
| 141 |
text = re.sub(r"(?is)<think>.*?(?:</think>|$)", "", text)
|
| 142 |
+
text = re.sub(r"\s*\[?CITE-\d+\]?\s*", " ", text, flags=re.I)
|
| 143 |
+
text = re.sub(r"[ \t]{2,}", " ", text)
|
| 144 |
return text.strip()
|
| 145 |
|
| 146 |
+
def _write_preview_payload(self, *, force: bool = False) -> None:
|
| 147 |
if self.preview_path is None:
|
| 148 |
return
|
| 149 |
now = time.monotonic()
|
|
|
|
| 153 |
"task_id": str(self.request.get("task_id") or ""),
|
| 154 |
"request_hash": self.request_hash,
|
| 155 |
"execution_epoch": self.execution_epoch,
|
| 156 |
+
"stage": str(self._preview_english_stage or "")[:240],
|
| 157 |
+
"text": self._preview_visible_text(self._preview_english_text),
|
| 158 |
+
"complete": bool(self._preview_english_complete),
|
| 159 |
+
"target_stage": str(self._preview_target_stage or "")[:240],
|
| 160 |
+
"target_text": self._preview_visible_text(self._preview_target_text),
|
| 161 |
+
"target_complete": bool(self._preview_target_complete),
|
| 162 |
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 163 |
}
|
| 164 |
temporary = self.preview_path.with_suffix(".json.tmp")
|
|
|
|
| 166 |
os.replace(temporary, self.preview_path)
|
| 167 |
self._last_preview_write = now
|
| 168 |
|
| 169 |
+
def _publish_preview(self, text: str, stage: str, *, force: bool = False, complete: bool = False) -> None:
|
| 170 |
+
self._preview_english_text = str(text or "")
|
| 171 |
+
self._preview_english_stage = str(stage or "")
|
| 172 |
+
self._preview_english_complete = bool(complete)
|
| 173 |
+
self._write_preview_payload(force=force)
|
| 174 |
+
|
| 175 |
+
def _publish_target_preview(self, text: str, stage: str, *, force: bool = True, complete: bool = False) -> None:
|
| 176 |
+
self._preview_target_text = str(text or "")
|
| 177 |
+
self._preview_target_stage = str(stage or "")
|
| 178 |
+
self._preview_target_complete = bool(complete)
|
| 179 |
+
self._write_preview_payload(force=force)
|
| 180 |
+
|
| 181 |
def _stream_text(self, generator, *, preview_prefix: str | None = None, preview_stage: str = "") -> str:
|
| 182 |
chunks = []
|
| 183 |
for chunk in generator:
|
|
|
|
| 192 |
self._publish_preview(preview_prefix + text, preview_stage, force=True)
|
| 193 |
return text
|
| 194 |
|
| 195 |
+
@staticmethod
|
| 196 |
+
def _text_hash(value: str) -> str:
|
| 197 |
+
normalized = re.sub(r'\s+', ' ', str(value or '')).strip()
|
| 198 |
+
return hashlib.sha256(normalized.encode('utf-8')).hexdigest()
|
| 199 |
+
|
| 200 |
+
@staticmethod
|
| 201 |
+
def _unique_top_level_sections(tasks):
|
| 202 |
+
output = []
|
| 203 |
+
seen = set()
|
| 204 |
+
for task in tasks or []:
|
| 205 |
+
key = str(task.get('section_id') or task.get('section') or '').strip()
|
| 206 |
+
title = str(task.get('section') or '').strip()
|
| 207 |
+
if key and title and key not in seen:
|
| 208 |
+
seen.add(key); output.append((key, title))
|
| 209 |
+
return output
|
| 210 |
+
|
| 211 |
+
@staticmethod
|
| 212 |
+
def _extract_top_level_section(manuscript: str, title: str) -> str:
|
| 213 |
+
value = str(manuscript or '')
|
| 214 |
+
match = re.search(
|
| 215 |
+
rf'(?ms)^#\s+{re.escape(str(title))}\s*$\n(.*?)(?=^#\s+[^#].*?$|\Z)', value
|
| 216 |
+
)
|
| 217 |
+
if not match:
|
| 218 |
+
return ''
|
| 219 |
+
return f"# {title}\n" + match.group(1).strip()
|
| 220 |
+
|
| 221 |
+
@staticmethod
|
| 222 |
+
def _extract_front_matter(manuscript: str, first_section_title: str) -> str:
|
| 223 |
+
value = str(manuscript or '')
|
| 224 |
+
match = re.search(rf'(?m)^#\s+{re.escape(str(first_section_title))}\s*$', value)
|
| 225 |
+
return value[:match.start()].strip() if match else ''
|
| 226 |
+
|
| 227 |
+
@staticmethod
|
| 228 |
+
def _section_is_complete(tasks, completed_display_names, section_key: str) -> bool:
|
| 229 |
+
relevant = [
|
| 230 |
+
str(task.get('display_name') or '') for task in (tasks or [])
|
| 231 |
+
if str(task.get('section_id') or task.get('section') or '') == str(section_key)
|
| 232 |
+
]
|
| 233 |
+
done = set(str(item) for item in (completed_display_names or []))
|
| 234 |
+
return bool(relevant) and all(name in done for name in relevant)
|
| 235 |
+
|
| 236 |
+
@staticmethod
|
| 237 |
+
def _glossary_version(glossary) -> str:
|
| 238 |
+
encoded = json.dumps(dict(glossary or {}), ensure_ascii=False, sort_keys=True).encode('utf-8')
|
| 239 |
+
return hashlib.sha256(encoded).hexdigest()[:16]
|
| 240 |
+
|
| 241 |
+
@staticmethod
|
| 242 |
+
def _assemble_partial_translation(translation_sections, adaptive_structure, *, include_front=False) -> str:
|
| 243 |
+
mapping = translation_sections if isinstance(translation_sections, dict) else {}
|
| 244 |
+
parts = []
|
| 245 |
+
if include_front:
|
| 246 |
+
record = mapping.get('__front_matter__') or {}
|
| 247 |
+
if record.get('qa_status') == 'passed' and record.get('translated_text'):
|
| 248 |
+
parts.append(str(record['translated_text']).strip())
|
| 249 |
+
for key, _title in GenerationRunner._unique_top_level_sections(adaptive_structure):
|
| 250 |
+
record = mapping.get(key) or {}
|
| 251 |
+
if record.get('qa_status') == 'passed' and record.get('translated_text'):
|
| 252 |
+
parts.append(str(record['translated_text']).strip())
|
| 253 |
+
return '\n\n'.join(part for part in parts if part).strip()
|
| 254 |
+
|
| 255 |
+
def _translate_frozen_unit(
|
| 256 |
+
self, agent, *, unit_key: str, unit_name: str, source_text: str, target_lang: str,
|
| 257 |
+
translation_sections: dict, translation_glossary: dict, adaptive_structure, include_front=False,
|
| 258 |
+
):
|
| 259 |
+
source = str(source_text or '').strip()
|
| 260 |
+
if not source:
|
| 261 |
+
return translation_sections, translation_glossary
|
| 262 |
+
source_hash = self._text_hash(source)
|
| 263 |
+
existing = dict((translation_sections or {}).get(unit_key) or {})
|
| 264 |
+
if (
|
| 265 |
+
existing.get('qa_status') == 'passed'
|
| 266 |
+
and existing.get('source_hash') == source_hash
|
| 267 |
+
and existing.get('target_lang') == target_lang
|
| 268 |
+
and existing.get('translated_text')
|
| 269 |
+
):
|
| 270 |
+
return translation_sections, translation_glossary
|
| 271 |
+
partial = self._assemble_partial_translation(translation_sections, adaptive_structure, include_front=include_front)
|
| 272 |
+
self._publish_target_preview(partial, f'translating:{unit_name}', force=True, complete=False)
|
| 273 |
+
result = agent.translate_frozen_section(
|
| 274 |
+
source, target_lang, glossary=translation_glossary, section_name=unit_name, max_attempts=2
|
| 275 |
+
)
|
| 276 |
+
updated_glossary = dict(result.get('glossary') or translation_glossary or {})
|
| 277 |
+
updated_sections = dict(translation_sections or {})
|
| 278 |
+
updated_sections[unit_key] = {
|
| 279 |
+
'source_hash': source_hash,
|
| 280 |
+
'target_lang': target_lang,
|
| 281 |
+
'glossary_version': self._glossary_version(updated_glossary),
|
| 282 |
+
'translated_text': str(result.get('text') or '').strip(),
|
| 283 |
+
'qa_status': 'passed',
|
| 284 |
+
'attempts': int(result.get('attempts') or 1),
|
| 285 |
+
'completed_at': datetime.now(timezone.utc).isoformat(),
|
| 286 |
+
}
|
| 287 |
+
partial = self._assemble_partial_translation(updated_sections, adaptive_structure, include_front=include_front)
|
| 288 |
+
self._publish_target_preview(partial, f'translation_ready:{unit_name}', force=True, complete=False)
|
| 289 |
+
return updated_sections, updated_glossary
|
| 290 |
+
|
| 291 |
@staticmethod
|
| 292 |
def _bounded_target(base_target: int, accumulated_deviation: float, remaining_including_current: int, safe_chunk: int) -> int:
|
| 293 |
base = max(80, int(base_target))
|
|
|
|
| 355 |
|
| 356 |
state = self.checkpoints.load_latest() or {}
|
| 357 |
if state:
|
| 358 |
+
self._preview_english_text = str(state.get('full_draft_en') or '')
|
| 359 |
+
self._preview_target_text = str(state.get('full_draft_translated') or '')
|
| 360 |
logging.info(
|
| 361 |
"[SP_EXECUTOR] resume checkpoint seq=%s stage=%s",
|
| 362 |
state.get("checkpoint_seq"), state.get("checkpoint_stage"),
|
|
|
|
| 501 |
"body_target_words": body_target_words,
|
| 502 |
"full_draft_en": full_draft_en,
|
| 503 |
"full_draft_translated": "",
|
| 504 |
+
"translation_sections": {},
|
| 505 |
+
"translation_glossary": {},
|
| 506 |
"completed_sections": [],
|
| 507 |
"context_tail": "",
|
| 508 |
"accumulated_deviation": 0.0,
|
|
|
|
| 524 |
state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
|
| 525 |
)
|
| 526 |
citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
|
| 527 |
+
translation_sections = dict(state.get('translation_sections') or {})
|
| 528 |
+
translation_glossary = dict(state.get('translation_glossary') or {})
|
| 529 |
+
if self._preview_target_text == '':
|
| 530 |
+
self._preview_target_text = str(state.get('full_draft_translated') or '')
|
| 531 |
if not citation_plan:
|
| 532 |
self._progress(15, "citation_planning")
|
| 533 |
citation_plan = agent.build_claim_evidence_plan(
|
|
|
|
| 744 |
actual_words = self._english_count_for_target(en_resolved)
|
| 745 |
accumulated_deviation += actual_words - adjusted_target
|
| 746 |
completed_sections.append(display_name)
|
| 747 |
+
section_key = str(sec.get('section_id') or sec_title)
|
| 748 |
+
if self._section_is_complete(adaptive_structure, completed_sections, section_key):
|
| 749 |
+
frozen_section = self._extract_top_level_section(full_draft_en, sec_title)
|
| 750 |
+
translation_sections, translation_glossary = self._translate_frozen_unit(
|
| 751 |
+
agent, unit_key=section_key, unit_name=sec_title, source_text=frozen_section,
|
| 752 |
+
target_lang=target_lang, translation_sections=translation_sections,
|
| 753 |
+
translation_glossary=translation_glossary, adaptive_structure=adaptive_structure,
|
| 754 |
+
include_front=False,
|
| 755 |
+
)
|
| 756 |
+
partial_translation = self._assemble_partial_translation(translation_sections, adaptive_structure)
|
| 757 |
state.update(
|
| 758 |
{
|
| 759 |
"completed_sections": completed_sections,
|
| 760 |
"full_draft_en": full_draft_en,
|
| 761 |
+
"full_draft_translated": partial_translation,
|
| 762 |
+
"translation_sections": translation_sections,
|
| 763 |
+
"translation_glossary": translation_glossary,
|
| 764 |
"context_tail": context_tail,
|
| 765 |
"accumulated_deviation": accumulated_deviation,
|
| 766 |
"agent_state": agent.export_state(),
|
|
|
|
| 802 |
full_draft_en = agent.rebalance_discussion_length(
|
| 803 |
full_draft_en, target_words=target_words, mode=mode, actual_words=actual_words
|
| 804 |
)
|
| 805 |
+
body_only, _reference_tail = agent.validate_reference_tail(
|
| 806 |
+
full_draft_en, minimum_references=MIN_FINAL_REFERENCES
|
| 807 |
+
)
|
| 808 |
+
agent.validate_manuscript_structure(body_only, adaptive_structure, translated=False)
|
| 809 |
+
agent.enforce_resolved_claim_specific_citations(body_only)
|
| 810 |
self._validate_target_length(full_draft_en, target_words)
|
| 811 |
self._publish_preview(full_draft_en, "english_complete", force=True, complete=True)
|
| 812 |
state.update(
|
| 813 |
{
|
| 814 |
"full_draft_en": full_draft_en,
|
| 815 |
+
"full_draft_translated": self._assemble_partial_translation(translation_sections, adaptive_structure),
|
| 816 |
+
"translation_sections": translation_sections,
|
| 817 |
+
"translation_glossary": translation_glossary,
|
| 818 |
"english_finalized": True,
|
| 819 |
"agent_state": agent.export_state(),
|
| 820 |
}
|
|
|
|
| 824 |
full_draft_en = str(state.get("full_draft_en") or "")
|
| 825 |
|
| 826 |
full_draft_translated = str(state.get("full_draft_translated") or "")
|
| 827 |
+
translation_sections = dict(state.get('translation_sections') or translation_sections or {})
|
| 828 |
+
translation_glossary = dict(state.get('translation_glossary') or translation_glossary or {})
|
| 829 |
if not state.get("translation_complete") or not full_draft_translated:
|
| 830 |
+
self._progress(94, "translation")
|
| 831 |
+
body_only, reference_tail = agent.validate_reference_tail(
|
| 832 |
+
full_draft_en, minimum_references=MIN_FINAL_REFERENCES
|
| 833 |
+
)
|
| 834 |
+
section_order = self._unique_top_level_sections(adaptive_structure)
|
| 835 |
+
if not section_order:
|
| 836 |
+
raise RuntimeError('translation_section_plan_missing')
|
| 837 |
+
front_matter = self._extract_front_matter(body_only, section_order[0][1])
|
| 838 |
+
final_units = [('__front_matter__', 'Front Matter', front_matter)]
|
| 839 |
+
for key, title in section_order:
|
| 840 |
+
final_units.append((key, title, self._extract_top_level_section(body_only, title)))
|
| 841 |
+
for index, (unit_key, unit_name, source_text) in enumerate(final_units, start=1):
|
| 842 |
+
translation_sections, translation_glossary = self._translate_frozen_unit(
|
| 843 |
+
agent, unit_key=unit_key, unit_name=unit_name, source_text=source_text,
|
| 844 |
+
target_lang=target_lang, translation_sections=translation_sections,
|
| 845 |
+
translation_glossary=translation_glossary, adaptive_structure=adaptive_structure,
|
| 846 |
+
include_front=True,
|
| 847 |
+
)
|
| 848 |
+
partial = self._assemble_partial_translation(
|
| 849 |
+
translation_sections, adaptive_structure, include_front=True
|
| 850 |
+
)
|
| 851 |
+
state.update({
|
| 852 |
+
'full_draft_en': full_draft_en,
|
| 853 |
+
'full_draft_translated': partial,
|
| 854 |
+
'translation_sections': translation_sections,
|
| 855 |
+
'translation_glossary': translation_glossary,
|
| 856 |
+
'agent_state': agent.export_state(),
|
| 857 |
+
})
|
| 858 |
+
pct = min(96, 94 + int(index * 2 / max(1, len(final_units))))
|
| 859 |
+
self._checkpoint(f'translation_{unit_key}', state, pct)
|
| 860 |
+
|
| 861 |
+
full_draft_translated = self._assemble_partial_translation(
|
| 862 |
+
translation_sections, adaptive_structure, include_front=True
|
| 863 |
+
).rstrip()
|
| 864 |
+
full_draft_translated += (
|
| 865 |
+
f"\n\n# {agent._localized_reference_heading(target_lang)}\n" + reference_tail.strip()
|
| 866 |
+
)
|
| 867 |
agent.validate_translation_alignment(full_draft_en, full_draft_translated)
|
| 868 |
+
self._publish_target_preview(
|
| 869 |
+
full_draft_translated, 'translation_complete', force=True, complete=True
|
| 870 |
+
)
|
| 871 |
state.update(
|
| 872 |
{
|
| 873 |
"full_draft_translated": full_draft_translated,
|
| 874 |
+
"translation_sections": translation_sections,
|
| 875 |
+
"translation_glossary": translation_glossary,
|
| 876 |
"translation_complete": True,
|
| 877 |
"agent_state": agent.export_state(),
|
| 878 |
}
|
core/pipeline_agent.py
CHANGED
|
@@ -1421,6 +1421,36 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
|
|
| 1421 |
)
|
| 1422 |
return True
|
| 1423 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1424 |
@staticmethod
|
| 1425 |
def find_repeated_qualitative_quotes(text, data_metadata):
|
| 1426 |
violations = []
|
|
@@ -3066,6 +3096,9 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
|
|
| 3066 |
if Counter(re.findall(cite_pattern, original, re.I)) != Counter(re.findall(cite_pattern, revised, re.I)):
|
| 3067 |
logging.warning('Word-budget Discussion repair rejected because citations changed.')
|
| 3068 |
return value
|
|
|
|
|
|
|
|
|
|
| 3069 |
return value[:match.start(1)] + revised + '\n\n' + value[match.end(1):]
|
| 3070 |
|
| 3071 |
@staticmethod
|
|
@@ -3154,7 +3187,7 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
|
|
| 3154 |
|
| 3155 |
@staticmethod
|
| 3156 |
def _translation_prompt(target_lang):
|
| 3157 |
-
common = """Translate the supplied blocks from the locked English manuscript faithfully. Preserve every marker
|
| 3158 |
return common + ' ' + SSciPipelineAgent._translation_language_rule(target_lang)
|
| 3159 |
|
| 3160 |
@staticmethod
|
|
@@ -3192,6 +3225,216 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
|
|
| 3192 |
)
|
| 3193 |
)
|
| 3194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3195 |
@staticmethod
|
| 3196 |
def _translation_body_units(body):
|
| 3197 |
units = []
|
|
@@ -3387,27 +3630,35 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
|
|
| 3387 |
block_id, source, system_prompt, target_lang
|
| 3388 |
)
|
| 3389 |
|
| 3390 |
-
def _translate_marked_batch_with_recovery(self, batch, system_prompt, target_lang):
|
| 3391 |
-
"""Translate one marked batch with
|
| 3392 |
|
| 3393 |
-
|
| 3394 |
-
|
| 3395 |
-
|
| 3396 |
-
|
| 3397 |
-
but it must still pass the existing deterministic translation guards.
|
| 3398 |
"""
|
| 3399 |
expected_ids = [block_id for block_id, _ in batch]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3400 |
marked_source = '\n\n'.join(
|
| 3401 |
-
f'[[SP-P{block_id}]]\n{
|
|
|
|
| 3402 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3403 |
result = self.call_deepseek(
|
| 3404 |
-
|
| 3405 |
-
marked_source,
|
| 3406 |
max_tokens=max(1800, min(9000, self._count_tokens(marked_source) * 2 + 1200)),
|
| 3407 |
-
temp=0.0,
|
| 3408 |
-
model_role='translation',
|
| 3409 |
-
thinking=False,
|
| 3410 |
-
allow_fallback=False,
|
| 3411 |
)
|
| 3412 |
result = self._remove_ai_meta_traces(result)
|
| 3413 |
|
|
@@ -3416,53 +3667,177 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
|
|
| 3416 |
except DeepSeekRequestError as error:
|
| 3417 |
if not str(error).startswith('Translation marker mismatch:'):
|
| 3418 |
raise
|
| 3419 |
-
|
| 3420 |
if len(batch) > 1:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3421 |
logging.warning(
|
| 3422 |
-
'Translation marker recovery: batch_size=%s expected=%s;
|
| 3423 |
-
len(batch),
|
| 3424 |
-
expected_ids,
|
| 3425 |
)
|
| 3426 |
recovered = {}
|
| 3427 |
-
|
| 3428 |
-
|
| 3429 |
-
self._translate_marked_batch_with_recovery(
|
| 3430 |
-
[item], system_prompt, target_lang
|
| 3431 |
-
)
|
| 3432 |
-
)
|
| 3433 |
return recovered
|
| 3434 |
-
|
| 3435 |
block_id, source = batch[0]
|
| 3436 |
-
unexpected_markers = re.findall(
|
| 3437 |
-
r'(?m)^\[\[SP-P(\d{6})\]\]\s*$',
|
| 3438 |
-
str(result or ''),
|
| 3439 |
-
)
|
| 3440 |
if unexpected_markers:
|
| 3441 |
raise DeepSeekRequestError(
|
| 3442 |
'Single-block translation returned an unexpected marker: '
|
| 3443 |
f'expected={block_id}, got={unexpected_markers}.'
|
| 3444 |
) from error
|
| 3445 |
-
|
| 3446 |
translated = str(result or '').strip()
|
| 3447 |
if not translated:
|
| 3448 |
-
raise DeepSeekRequestError(
|
| 3449 |
-
|
| 3450 |
-
|
| 3451 |
-
translated = self._validate_translation_block_with_abbreviation_recovery(
|
| 3452 |
-
block_id, source, translated, system_prompt, target_lang
|
| 3453 |
-
)
|
| 3454 |
-
logging.info(
|
| 3455 |
-
'Translation marker recovery accepted marker-free single block: SP-P%s',
|
| 3456 |
-
block_id,
|
| 3457 |
-
)
|
| 3458 |
-
return {block_id: translated}
|
| 3459 |
|
| 3460 |
-
|
| 3461 |
-
|
| 3462 |
-
|
| 3463 |
-
|
| 3464 |
-
|
| 3465 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3466 |
|
| 3467 |
def translate_locked_manuscript(self, manuscript, target_lang):
|
| 3468 |
value = str(manuscript or '').strip()
|
|
@@ -3605,7 +3980,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
|
|
| 3605 |
violations = []
|
| 3606 |
raw_tag_pattern = re.compile(r"CITE-\d+", re.IGNORECASE)
|
| 3607 |
resolved_citation_pattern = re.compile(
|
| 3608 |
-
r"\((?
|
| 3609 |
re.IGNORECASE,
|
| 3610 |
)
|
| 3611 |
for _, _, sentence in SSciPipelineAgent._sentence_spans(text):
|
|
|
|
| 1421 |
)
|
| 1422 |
return True
|
| 1423 |
|
| 1424 |
+
@staticmethod
|
| 1425 |
+
def validate_reference_tail(text, minimum_references=1):
|
| 1426 |
+
"""Validate the required English References tail independently of body H1s.
|
| 1427 |
+
|
| 1428 |
+
Canonical manuscript structure intentionally excludes References. The
|
| 1429 |
+
tail is therefore validated here instead of weakening the body schema.
|
| 1430 |
+
"""
|
| 1431 |
+
value = str(text or '').strip()
|
| 1432 |
+
matches = list(re.finditer(r'(?m)^#\s+References\s*$', value))
|
| 1433 |
+
if len(matches) != 1:
|
| 1434 |
+
raise DeepSeekAPIError(
|
| 1435 |
+
f'References tail validation failed: heading_count={len(matches)}, expected=1.'
|
| 1436 |
+
)
|
| 1437 |
+
match = matches[0]
|
| 1438 |
+
body = value[:match.start()].rstrip()
|
| 1439 |
+
tail = value[match.end():].strip()
|
| 1440 |
+
if not body:
|
| 1441 |
+
raise DeepSeekAPIError('References tail validation failed: manuscript body is empty.')
|
| 1442 |
+
if not tail:
|
| 1443 |
+
raise DeepSeekAPIError('References tail validation failed: References is empty.')
|
| 1444 |
+
if re.search(r'(?m)^#\s+', tail):
|
| 1445 |
+
raise DeepSeekAPIError('References tail validation failed: another H1 appears after References.')
|
| 1446 |
+
refs = [line.strip() for line in tail.splitlines() if line.strip().startswith('- ')]
|
| 1447 |
+
required = max(1, int(minimum_references or 1))
|
| 1448 |
+
if len(refs) < required:
|
| 1449 |
+
raise DeepSeekAPIError(
|
| 1450 |
+
f'References tail validation failed: references={len(refs)}, required>={required}.'
|
| 1451 |
+
)
|
| 1452 |
+
return body, tail
|
| 1453 |
+
|
| 1454 |
@staticmethod
|
| 1455 |
def find_repeated_qualitative_quotes(text, data_metadata):
|
| 1456 |
violations = []
|
|
|
|
| 3096 |
if Counter(re.findall(cite_pattern, original, re.I)) != Counter(re.findall(cite_pattern, revised, re.I)):
|
| 3097 |
logging.warning('Word-budget Discussion repair rejected because citations changed.')
|
| 3098 |
return value
|
| 3099 |
+
if self.find_multi_citation_violations(revised):
|
| 3100 |
+
logging.warning('Word-budget Discussion repair rejected because it stacked multiple sources in one sentence.')
|
| 3101 |
+
return value
|
| 3102 |
return value[:match.start(1)] + revised + '\n\n' + value[match.end(1):]
|
| 3103 |
|
| 3104 |
@staticmethod
|
|
|
|
| 3187 |
|
| 3188 |
@staticmethod
|
| 3189 |
def _translation_prompt(target_lang):
|
| 3190 |
+
common = """Translate the supplied blocks from the locked English manuscript faithfully. Preserve every [[SP-Pxxxxxx]] paragraph marker and every [[SP-Txxxxxx]] protected token exactly once and in the same order. Protected tokens represent citations, statistics, identifiers, formulas, URLs/DOIs, hypotheses, abbreviations, or other immutable research objects and must never be translated or normalized. Preserve quotation meaning and conclusion direction. Do not add or remove claims. Use concise, accurate, rigorous academic language. Avoid metaphors, analogies, rhetorical questions, promotional language, formulaic transitions, and mechanical repetition. Output only the marked translations."""
|
| 3191 |
return common + ' ' + SSciPipelineAgent._translation_language_rule(target_lang)
|
| 3192 |
|
| 3193 |
@staticmethod
|
|
|
|
| 3225 |
)
|
| 3226 |
)
|
| 3227 |
|
| 3228 |
+
@staticmethod
|
| 3229 |
+
def _translation_protected_pattern():
|
| 3230 |
+
# One-pass alternation prevents a placeholder created for a citation or
|
| 3231 |
+
# URL from being matched again as an acronym/number. Keep this list
|
| 3232 |
+
# conservative: protect immutable research objects, not prose.
|
| 3233 |
+
return re.compile(
|
| 3234 |
+
r'(?i:https?://[^\s)>]+)'
|
| 3235 |
+
r'|(?i:\bdoi:\s*[^\s)>]+)'
|
| 3236 |
+
r'|(?i:\b10\.\d{4,9}/[-._;()/:A-Z0-9]+)'
|
| 3237 |
+
r'|(?i:\b(?:p|t|f|z|r)\s*(?:<=|>=|=|<|>|≤|≥)\s*[-+]?(?:\d+(?:\.\d+)?|\.\d+))'
|
| 3238 |
+
r'|(?:β|χ²|R²|ΔR²)\s*(?:<=|>=|=|<|>|≤|≥)\s*[-+]?(?:\d+(?:\.\d+)?|\.\d+)'
|
| 3239 |
+
r'|\([^()\n]*?(?:19|20)\d{2}[a-z]?[^()\n]*?\)'
|
| 3240 |
+
r'|`[^`\n]+`'
|
| 3241 |
+
r'|\$[^$\n]+\$'
|
| 3242 |
+
r'|(?i:\bH\d+[A-Za-z]?\b)'
|
| 3243 |
+
r'|\b(?:fsQCA|SmartPLS|Mplus|OpenAlex|DeepSeek|ChatGPT|RStudio|SPSS|AMOS|Stata)\b'
|
| 3244 |
+
r'|\b[A-Z][A-Z0-9-]{1,11}(?:s|es)?\b'
|
| 3245 |
+
r'|(?:χ²|R²|ΔR²|η²|ω²|[αβγδΔχλ��ρστφηωΩ])'
|
| 3246 |
+
r'|\b(?:QUOTE|CASE|RESP|P)-\d+\b'
|
| 3247 |
+
r'|(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?'
|
| 3248 |
+
r'|\|'
|
| 3249 |
+
)
|
| 3250 |
+
|
| 3251 |
+
@classmethod
|
| 3252 |
+
def _protect_translation_tokens(cls, value):
|
| 3253 |
+
mapping = {}
|
| 3254 |
+
counter = 0
|
| 3255 |
+
pattern = cls._translation_protected_pattern()
|
| 3256 |
+
|
| 3257 |
+
def replace(match):
|
| 3258 |
+
nonlocal counter
|
| 3259 |
+
counter += 1
|
| 3260 |
+
placeholder = f'[[SP-T{counter:06d}]]'
|
| 3261 |
+
mapping[placeholder] = match.group(0)
|
| 3262 |
+
return placeholder
|
| 3263 |
+
|
| 3264 |
+
return pattern.sub(replace, str(value or '')), mapping
|
| 3265 |
+
|
| 3266 |
+
@staticmethod
|
| 3267 |
+
def _restore_translation_tokens(value, mapping, block_id=''):
|
| 3268 |
+
text = str(value or '')
|
| 3269 |
+
found = Counter(re.findall(r'\[\[SP-T\d{6}\]\]', text))
|
| 3270 |
+
expected = Counter(mapping.keys())
|
| 3271 |
+
if found != expected:
|
| 3272 |
+
missing = list((expected - found).elements())[:8]
|
| 3273 |
+
added = list((found - expected).elements())[:8]
|
| 3274 |
+
raise DeepSeekRequestError(
|
| 3275 |
+
f'Translation protected placeholder mismatch in {block_id}: missing={missing}, added={added}.'
|
| 3276 |
+
)
|
| 3277 |
+
for placeholder, original in mapping.items():
|
| 3278 |
+
text = text.replace(placeholder, original)
|
| 3279 |
+
if re.search(r'\[\[SP-T\d{6}\]\]', text):
|
| 3280 |
+
raise DeepSeekRequestError(f'Translation left unresolved protected placeholders in {block_id}.')
|
| 3281 |
+
return text
|
| 3282 |
+
|
| 3283 |
+
def extend_translation_glossary(self, body, target_lang, existing=None):
|
| 3284 |
+
"""Extend a task-level glossary without remapping an existing term."""
|
| 3285 |
+
mapping = dict(existing or {})
|
| 3286 |
+
headings = [title for _, title in self._markdown_heading_tree(body)]
|
| 3287 |
+
acronyms = sorted(self._translation_acronym_tokens(body))
|
| 3288 |
+
phrases = re.findall(r'\b(?:[A-Z][a-z]+(?:[- ][A-Z][a-z]+){1,3})\b', str(body or ''))
|
| 3289 |
+
recurring = [term for term, count in Counter(phrases).most_common() if count >= 2]
|
| 3290 |
+
term_limit = max(10, int(APP_CONFIG.get('translation_glossary_term_limit', 40)))
|
| 3291 |
+
sources = list(dict.fromkeys(headings + recurring + acronyms))[:term_limit]
|
| 3292 |
+
missing = [term for term in sources if not str(mapping.get(term) or '').strip()]
|
| 3293 |
+
if missing:
|
| 3294 |
+
language_rule = self._translation_language_rule(target_lang)
|
| 3295 |
+
response = self.call_deepseek(
|
| 3296 |
+
f"Create one consistent academic terminology glossary for translation into {target_lang}. {language_rule} Return valid JSON only as {{\"terms\":[{{\"source\":\"...\",\"target\":\"...\"}}]}}. Return every supplied source exactly once. Existing terminology is frozen and is not being supplied for remapping.",
|
| 3297 |
+
json.dumps({'terms': missing}, ensure_ascii=False),
|
| 3298 |
+
max_tokens=max(1200, min(5000, len(missing) * 80)),
|
| 3299 |
+
temp=0.0, model_role='translation', thinking=False, allow_fallback=False,
|
| 3300 |
+
)
|
| 3301 |
+
parsed = self._extract_json_from_text(response)
|
| 3302 |
+
items = parsed.get('terms', []) if isinstance(parsed, dict) else []
|
| 3303 |
+
new_map = {
|
| 3304 |
+
str(item.get('source') or '').strip(): str(item.get('target') or '').strip()
|
| 3305 |
+
for item in items if isinstance(item, dict)
|
| 3306 |
+
}
|
| 3307 |
+
omitted = [term for term in missing if not new_map.get(term)]
|
| 3308 |
+
if omitted:
|
| 3309 |
+
raise DeepSeekRequestError('Translation glossary omitted term(s): ' + ', '.join(omitted[:5]))
|
| 3310 |
+
for term in missing:
|
| 3311 |
+
mapping.setdefault(term, new_map[term])
|
| 3312 |
+
for acronym in acronyms:
|
| 3313 |
+
mapping.setdefault(acronym, acronym)
|
| 3314 |
+
return mapping
|
| 3315 |
+
|
| 3316 |
+
@staticmethod
|
| 3317 |
+
def _validate_translation_language_quality(translated, target_lang):
|
| 3318 |
+
text = str(translated or '')
|
| 3319 |
+
compact = re.sub(r'\s+', '', text)
|
| 3320 |
+
if len(compact) < 80:
|
| 3321 |
+
return True
|
| 3322 |
+
if 'Chinese' in target_lang:
|
| 3323 |
+
if len(re.findall(r'[\u4e00-\u9fff]', text)) < max(12, int(len(compact) * 0.08)):
|
| 3324 |
+
raise DeepSeekRequestError('Chinese translation quality validation failed: insufficient Chinese text.')
|
| 3325 |
+
return True
|
| 3326 |
+
cyrillic = re.findall(r'[А-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]', text)
|
| 3327 |
+
if not cyrillic:
|
| 3328 |
+
raise DeepSeekRequestError('Target-language translation contains no Cyrillic text.')
|
| 3329 |
+
kazakh_specific = re.findall(r'[ӘәҒғҚқҢңӨөҰұҮүҺһІі]', text)
|
| 3330 |
+
if 'Kazakh' in target_lang and len(cyrillic) >= 200:
|
| 3331 |
+
minimum = max(2, int(len(cyrillic) * 0.002))
|
| 3332 |
+
if len(kazakh_specific) < minimum:
|
| 3333 |
+
raise DeepSeekRequestError('Kazakh translation quality validation failed: insufficient Kazakh-specific letters.')
|
| 3334 |
+
if 'Russian' in target_lang and cyrillic and len(kazakh_specific) / len(cyrillic) > 0.02:
|
| 3335 |
+
raise DeepSeekRequestError('Russian translation quality validation failed: excessive Kazakh-specific letters.')
|
| 3336 |
+
return True
|
| 3337 |
+
|
| 3338 |
+
def validate_translation_semantic_equivalence(self, source, translated, target_lang, section_name=''):
|
| 3339 |
+
"""Fail closed when translation changes claim meaning or evidence strength."""
|
| 3340 |
+
source_value = str(source or '').strip()
|
| 3341 |
+
target_value = str(translated or '').strip()
|
| 3342 |
+
if len(source_value) < 120 or len(target_value) < 80:
|
| 3343 |
+
return True
|
| 3344 |
+
prompt = f"""Audit whether the TARGET is a faithful academic translation of the ENGLISH source into {target_lang}.
|
| 3345 |
+
Check only semantic equivalence. Reject if any substantive claim is omitted or added; negation/polarity changes; association becomes causation; uncertainty/modality becomes stronger; a hypothesis/result direction or supported/unsupported conclusion changes; a limitation disappears; or quotation meaning changes. Do not rewrite.
|
| 3346 |
+
Return JSON only: {{\"ok\":true|false,\"issues\":[\"short issue\"]}}.
|
| 3347 |
+
SECTION: {section_name}
|
| 3348 |
+
<ENGLISH>
|
| 3349 |
+
{source_value}
|
| 3350 |
+
</ENGLISH>
|
| 3351 |
+
<TARGET>
|
| 3352 |
+
{target_value}
|
| 3353 |
+
</TARGET>"""
|
| 3354 |
+
response = self.call_deepseek(
|
| 3355 |
+
'You are a bilingual academic translation equivalence auditor. Be conservative and evidence-preserving.',
|
| 3356 |
+
prompt, max_tokens=1200, temp=0.0, model_role='reasoning', thinking=False, allow_fallback=False,
|
| 3357 |
+
)
|
| 3358 |
+
parsed = self._extract_json_from_text(response)
|
| 3359 |
+
ok = parsed.get('ok') if isinstance(parsed, dict) else None
|
| 3360 |
+
issues = parsed.get('issues', []) if isinstance(parsed, dict) else []
|
| 3361 |
+
if ok is not True:
|
| 3362 |
+
detail = '; '.join(str(item) for item in (issues or [])[:4]) or 'semantic equivalence not confirmed'
|
| 3363 |
+
raise DeepSeekRequestError(
|
| 3364 |
+
f'Translation semantic equivalence failed for {section_name or "section"}: {detail[:600]}'
|
| 3365 |
+
)
|
| 3366 |
+
return True
|
| 3367 |
+
|
| 3368 |
+
def audit_translation_blocks(self, sources, translations, target_lang, glossary=None, section_name=''):
|
| 3369 |
+
"""Return block-localized translation QA issues without rewriting good blocks.
|
| 3370 |
+
|
| 3371 |
+
This combines semantic-equivalence and target-language quality review so
|
| 3372 |
+
a failed paragraph can be regenerated independently rather than forcing
|
| 3373 |
+
a whole-section translation retry.
|
| 3374 |
+
"""
|
| 3375 |
+
source_map = {str(key): str(value or '').strip() for key, value in dict(sources or {}).items()}
|
| 3376 |
+
target_map = {str(key): str(value or '').strip() for key, value in dict(translations or {}).items()}
|
| 3377 |
+
expected_ids = [key for key in source_map if key in target_map]
|
| 3378 |
+
if set(expected_ids) != set(source_map) or set(expected_ids) != set(target_map):
|
| 3379 |
+
raise DeepSeekRequestError('Translation block QA source/target ids differ.')
|
| 3380 |
+
if not expected_ids:
|
| 3381 |
+
return {}
|
| 3382 |
+
|
| 3383 |
+
glossary_value = dict(glossary or {})
|
| 3384 |
+
batch_size = 6
|
| 3385 |
+
issues = {}
|
| 3386 |
+
for offset in range(0, len(expected_ids), batch_size):
|
| 3387 |
+
batch_ids = expected_ids[offset:offset + batch_size]
|
| 3388 |
+
relevant_glossary = {}
|
| 3389 |
+
joined_source = '\n'.join(source_map[block_id] for block_id in batch_ids)
|
| 3390 |
+
for source_term, target_term in glossary_value.items():
|
| 3391 |
+
if source_term and str(source_term) in joined_source:
|
| 3392 |
+
relevant_glossary[str(source_term)] = str(target_term)
|
| 3393 |
+
payload = {
|
| 3394 |
+
'section': str(section_name or 'section'),
|
| 3395 |
+
'target_language': target_lang,
|
| 3396 |
+
'glossary': relevant_glossary,
|
| 3397 |
+
'items': [
|
| 3398 |
+
{
|
| 3399 |
+
'id': f'SP-P{block_id}',
|
| 3400 |
+
'english': source_map[block_id],
|
| 3401 |
+
'target': target_map[block_id],
|
| 3402 |
+
}
|
| 3403 |
+
for block_id in batch_ids
|
| 3404 |
+
],
|
| 3405 |
+
}
|
| 3406 |
+
system_prompt = f"""You are a bilingual academic translation QA reviewer for {target_lang}.
|
| 3407 |
+
Review EACH supplied block independently. Do not rewrite. Check: (1) no substantive claim is omitted or added; (2) negation, direction, uncertainty, association-versus-causation strength, hypothesis/result status, limitations, and quotation meaning are preserved; (3) the target is natural, grammatically correct academic {target_lang}, not language-contaminated; (4) frozen glossary terms are used consistently, allowing only grammatical inflection where the target language requires it.
|
| 3408 |
+
For Russian, reject noticeable Kazakh-language contamination. For Kazakh, reject Russian wording/syntax masquerading as Kazakh and require natural Kazakh morphology. For Simplified Chinese, require natural academic Chinese while preserving protected research objects.
|
| 3409 |
+
Return valid JSON only: {{"items":[{{"id":"SP-P000001","ok":true,"issues":[]}}]}}. Return every supplied id exactly once."""
|
| 3410 |
+
response = self.call_deepseek(
|
| 3411 |
+
system_prompt,
|
| 3412 |
+
json.dumps(payload, ensure_ascii=False),
|
| 3413 |
+
max_tokens=max(1200, min(3600, 700 + 420 * len(batch_ids))),
|
| 3414 |
+
temp=0.0, model_role='reasoning', thinking=False, allow_fallback=False,
|
| 3415 |
+
)
|
| 3416 |
+
parsed = self._extract_json_from_text(response)
|
| 3417 |
+
rows = parsed.get('items', []) if isinstance(parsed, dict) else []
|
| 3418 |
+
by_id = {}
|
| 3419 |
+
for row in rows:
|
| 3420 |
+
if not isinstance(row, dict):
|
| 3421 |
+
continue
|
| 3422 |
+
raw_id = str(row.get('id') or '').strip()
|
| 3423 |
+
match = re.fullmatch(r'SP-P(\d{6})', raw_id)
|
| 3424 |
+
if match and match.group(1) in batch_ids and match.group(1) not in by_id:
|
| 3425 |
+
by_id[match.group(1)] = row
|
| 3426 |
+
if set(by_id) != set(batch_ids):
|
| 3427 |
+
raise DeepSeekRequestError(
|
| 3428 |
+
f'Translation block QA id mismatch: expected={batch_ids}, got={sorted(by_id)}.'
|
| 3429 |
+
)
|
| 3430 |
+
for block_id in batch_ids:
|
| 3431 |
+
row = by_id[block_id]
|
| 3432 |
+
if row.get('ok') is True:
|
| 3433 |
+
continue
|
| 3434 |
+
detail = [str(item).strip() for item in (row.get('issues') or []) if str(item).strip()]
|
| 3435 |
+
issues[block_id] = detail[:5] or ['translation quality was not confirmed']
|
| 3436 |
+
return issues
|
| 3437 |
+
|
| 3438 |
@staticmethod
|
| 3439 |
def _translation_body_units(body):
|
| 3440 |
units = []
|
|
|
|
| 3630 |
block_id, source, system_prompt, target_lang
|
| 3631 |
)
|
| 3632 |
|
| 3633 |
+
def _translate_marked_batch_with_recovery(self, batch, system_prompt, target_lang, _strict=False):
|
| 3634 |
+
"""Translate one marked batch with protected tokens and bounded recovery.
|
| 3635 |
|
| 3636 |
+
Multi-block marker/protected-token failures are binary-split first. A
|
| 3637 |
+
single block may accept a marker-free response only when it is the sole
|
| 3638 |
+
source, after which every protected object and existing integrity gate
|
| 3639 |
+
must still pass.
|
|
|
|
| 3640 |
"""
|
| 3641 |
expected_ids = [block_id for block_id, _ in batch]
|
| 3642 |
+
protected_batch = []
|
| 3643 |
+
protected_maps = {}
|
| 3644 |
+
for block_id, source in batch:
|
| 3645 |
+
protected_source, token_map = self._protect_translation_tokens(source)
|
| 3646 |
+
protected_batch.append((block_id, source, protected_source))
|
| 3647 |
+
protected_maps[block_id] = token_map
|
| 3648 |
marked_source = '\n\n'.join(
|
| 3649 |
+
f'[[SP-P{block_id}]]\n{protected_source}'
|
| 3650 |
+
for block_id, _, protected_source in protected_batch
|
| 3651 |
)
|
| 3652 |
+
effective_prompt = system_prompt
|
| 3653 |
+
if _strict:
|
| 3654 |
+
effective_prompt += (
|
| 3655 |
+
'\nSTRICT RETRY: Copy every SP-P and SP-T marker byte-for-byte. Do not merge, drop, reorder, '
|
| 3656 |
+
'translate, pluralize, or normalize any protected token.'
|
| 3657 |
+
)
|
| 3658 |
result = self.call_deepseek(
|
| 3659 |
+
effective_prompt, marked_source,
|
|
|
|
| 3660 |
max_tokens=max(1800, min(9000, self._count_tokens(marked_source) * 2 + 1200)),
|
| 3661 |
+
temp=0.0, model_role='translation', thinking=False, allow_fallback=False,
|
|
|
|
|
|
|
|
|
|
| 3662 |
)
|
| 3663 |
result = self._remove_ai_meta_traces(result)
|
| 3664 |
|
|
|
|
| 3667 |
except DeepSeekRequestError as error:
|
| 3668 |
if not str(error).startswith('Translation marker mismatch:'):
|
| 3669 |
raise
|
|
|
|
| 3670 |
if len(batch) > 1:
|
| 3671 |
+
found_ids = set(re.findall(r'(?m)^\[\[SP-P(\d{6})\]\]\s*$', str(result or '')))
|
| 3672 |
+
if not found_ids:
|
| 3673 |
+
logging.warning(
|
| 3674 |
+
'Translation marker recovery: batch_size=%s expected=%s; all markers were lost, retrying as isolated single blocks.',
|
| 3675 |
+
len(batch), expected_ids,
|
| 3676 |
+
)
|
| 3677 |
+
recovered = {}
|
| 3678 |
+
for single in batch:
|
| 3679 |
+
recovered.update(self._translate_marked_batch_with_recovery([single], system_prompt, target_lang))
|
| 3680 |
+
return recovered
|
| 3681 |
+
middle = max(1, len(batch) // 2)
|
| 3682 |
logging.warning(
|
| 3683 |
+
'Translation marker recovery: batch_size=%s expected=%s found=%s; partial marker damage, binary splitting.',
|
| 3684 |
+
len(batch), expected_ids, sorted(found_ids),
|
|
|
|
| 3685 |
)
|
| 3686 |
recovered = {}
|
| 3687 |
+
recovered.update(self._translate_marked_batch_with_recovery(batch[:middle], system_prompt, target_lang))
|
| 3688 |
+
recovered.update(self._translate_marked_batch_with_recovery(batch[middle:], system_prompt, target_lang))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3689 |
return recovered
|
|
|
|
| 3690 |
block_id, source = batch[0]
|
| 3691 |
+
unexpected_markers = re.findall(r'(?m)^\[\[SP-P(\d{6})\]\]\s*$', str(result or ''))
|
|
|
|
|
|
|
|
|
|
| 3692 |
if unexpected_markers:
|
| 3693 |
raise DeepSeekRequestError(
|
| 3694 |
'Single-block translation returned an unexpected marker: '
|
| 3695 |
f'expected={block_id}, got={unexpected_markers}.'
|
| 3696 |
) from error
|
|
|
|
| 3697 |
translated = str(result or '').strip()
|
| 3698 |
if not translated:
|
| 3699 |
+
raise DeepSeekRequestError(f'Single-block translation recovery returned empty text for SP-P{block_id}.') from error
|
| 3700 |
+
parsed = {block_id: translated}
|
| 3701 |
+
logging.info('Translation marker recovery accepted marker-free single block: SP-P%s', block_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3702 |
|
| 3703 |
+
restored = {}
|
| 3704 |
+
try:
|
| 3705 |
+
for block_id, source in batch:
|
| 3706 |
+
translated = self._restore_translation_tokens(
|
| 3707 |
+
parsed[block_id], protected_maps.get(block_id, {}), f'SP-P{block_id}'
|
| 3708 |
+
)
|
| 3709 |
+
translated = self._validate_translation_block_with_abbreviation_recovery(
|
| 3710 |
+
block_id, source, translated, system_prompt, target_lang
|
| 3711 |
+
)
|
| 3712 |
+
restored[block_id] = translated
|
| 3713 |
+
except DeepSeekRequestError as error:
|
| 3714 |
+
protected_failure = 'protected placeholder mismatch' in str(error) or 'unresolved protected placeholders' in str(error)
|
| 3715 |
+
if protected_failure and len(batch) > 1:
|
| 3716 |
+
middle = max(1, len(batch) // 2)
|
| 3717 |
+
logging.warning('Translation protected-token recovery: batch_size=%s; binary splitting.', len(batch))
|
| 3718 |
+
recovered = {}
|
| 3719 |
+
recovered.update(self._translate_marked_batch_with_recovery(batch[:middle], system_prompt, target_lang))
|
| 3720 |
+
recovered.update(self._translate_marked_batch_with_recovery(batch[middle:], system_prompt, target_lang))
|
| 3721 |
+
return recovered
|
| 3722 |
+
if protected_failure and len(batch) == 1 and not _strict:
|
| 3723 |
+
logging.warning('Translation protected-token recovery: strict single-block retry for SP-P%s.', batch[0][0])
|
| 3724 |
+
return self._translate_marked_batch_with_recovery(batch, system_prompt, target_lang, _strict=True)
|
| 3725 |
+
raise
|
| 3726 |
+
return restored
|
| 3727 |
+
|
| 3728 |
+
def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2):
|
| 3729 |
+
"""Translate one frozen English unit and publish only after all QA passes."""
|
| 3730 |
+
value = str(section_text or '').strip()
|
| 3731 |
+
if not value:
|
| 3732 |
+
raise DeepSeekRequestError('Frozen English translation unit is empty.')
|
| 3733 |
+
if re.search(r'(?m)^#\s+References\s*$', value):
|
| 3734 |
+
raise DeepSeekRequestError('References must not be sent to the translation model.')
|
| 3735 |
+
frozen_glossary = self.extend_translation_glossary(value, target_lang, glossary)
|
| 3736 |
+
units = self._translation_body_units(value)
|
| 3737 |
+
body_units = [unit for unit in units if unit['kind'] == 'body']
|
| 3738 |
+
if not body_units:
|
| 3739 |
+
raise DeepSeekRequestError('Frozen English translation unit contains no translatable body.')
|
| 3740 |
+
token_budget = max(800, min(2000, int(APP_CONFIG.get('translation_batch_tokens', 2500))))
|
| 3741 |
+
batches, current, current_tokens = [], [], 0
|
| 3742 |
+
for index, unit in enumerate(body_units, start=1):
|
| 3743 |
+
block_id = f'{index:06d}'
|
| 3744 |
+
tokens = max(1, self._count_tokens(unit['text']))
|
| 3745 |
+
if current and current_tokens + tokens > token_budget:
|
| 3746 |
+
batches.append(current); current = []; current_tokens = 0
|
| 3747 |
+
current.append((block_id, unit['text'])); current_tokens += tokens
|
| 3748 |
+
if current:
|
| 3749 |
+
batches.append(current)
|
| 3750 |
+
system_prompt = (
|
| 3751 |
+
self._translation_prompt(target_lang)
|
| 3752 |
+
+ '\nUse this frozen terminology glossary consistently. Never remap an existing term:\n'
|
| 3753 |
+
+ json.dumps(frozen_glossary, ensure_ascii=False)
|
| 3754 |
+
)
|
| 3755 |
+
attempts = max(1, int(max_attempts or 1))
|
| 3756 |
+
last_error = None
|
| 3757 |
+
for attempt in range(1, attempts + 1):
|
| 3758 |
+
self._check_cancelled()
|
| 3759 |
+
translations = {}
|
| 3760 |
+
try:
|
| 3761 |
+
for batch in batches:
|
| 3762 |
+
translations.update(self._translate_marked_batch_with_recovery(batch, system_prompt, target_lang))
|
| 3763 |
+
source_blocks = {
|
| 3764 |
+
block_id: source
|
| 3765 |
+
for batch in batches
|
| 3766 |
+
for block_id, source in batch
|
| 3767 |
+
}
|
| 3768 |
+
quality_issues = self.audit_translation_blocks(
|
| 3769 |
+
source_blocks, translations, target_lang, frozen_glossary,
|
| 3770 |
+
section_name=section_name or 'frozen section',
|
| 3771 |
+
)
|
| 3772 |
+
local_round = 0
|
| 3773 |
+
while quality_issues and local_round < 2:
|
| 3774 |
+
local_round += 1
|
| 3775 |
+
failed_ids = sorted(quality_issues)
|
| 3776 |
+
logging.warning(
|
| 3777 |
+
'Translation block-local QA recovery: section=%s round=%s failed=%s',
|
| 3778 |
+
section_name or 'section', local_round, failed_ids,
|
| 3779 |
+
)
|
| 3780 |
+
for block_id in failed_ids:
|
| 3781 |
+
details = '; '.join(quality_issues.get(block_id) or [])[:700]
|
| 3782 |
+
quality_prompt = (
|
| 3783 |
+
system_prompt
|
| 3784 |
+
+ '\nQUALITY RETRY FOR ONE BLOCK: Correct only the translation-quality issue(s) listed below. '
|
| 3785 |
+
'Preserve every protected placeholder and SP-P marker exactly. Do not add claims.\n'
|
| 3786 |
+
+ details
|
| 3787 |
+
)
|
| 3788 |
+
retry = self._translate_marked_batch_with_recovery(
|
| 3789 |
+
[(block_id, source_blocks[block_id])], quality_prompt, target_lang, _strict=True
|
| 3790 |
+
)
|
| 3791 |
+
translations[block_id] = retry[block_id]
|
| 3792 |
+
quality_issues = self.audit_translation_blocks(
|
| 3793 |
+
{block_id: source_blocks[block_id] for block_id in failed_ids},
|
| 3794 |
+
{block_id: translations[block_id] for block_id in failed_ids},
|
| 3795 |
+
target_lang, frozen_glossary, section_name=section_name or 'frozen section',
|
| 3796 |
+
)
|
| 3797 |
+
if quality_issues:
|
| 3798 |
+
detail = '; '.join(
|
| 3799 |
+
f'SP-P{block_id}: {"; ".join(messages)}'
|
| 3800 |
+
for block_id, messages in sorted(quality_issues.items())
|
| 3801 |
+
)
|
| 3802 |
+
raise DeepSeekRequestError(
|
| 3803 |
+
f'Translation block-local QA failed for {section_name or "section"}: {detail[:1000]}'
|
| 3804 |
+
)
|
| 3805 |
+
|
| 3806 |
+
# Rebuild after any localized repairs, then apply deterministic
|
| 3807 |
+
# whole-unit integrity/language checks before publication.
|
| 3808 |
+
output_units = []
|
| 3809 |
+
body_index = 0
|
| 3810 |
+
for unit in units:
|
| 3811 |
+
if unit['kind'] == 'heading':
|
| 3812 |
+
translated_heading = str(frozen_glossary.get(unit['text']) or '').strip()
|
| 3813 |
+
if not translated_heading:
|
| 3814 |
+
raise DeepSeekRequestError(f'Translation glossary missing heading: {unit["text"]}')
|
| 3815 |
+
output_units.append('#' * int(unit['level']) + ' ' + translated_heading)
|
| 3816 |
+
else:
|
| 3817 |
+
body_index += 1
|
| 3818 |
+
output_units.append(translations[f'{body_index:06d}'])
|
| 3819 |
+
output = '\n\n'.join(output_units).strip()
|
| 3820 |
+
self.validate_translation_alignment(value, output)
|
| 3821 |
+
self._validate_translation_language_quality(output, target_lang)
|
| 3822 |
+
return {
|
| 3823 |
+
'text': output,
|
| 3824 |
+
'glossary': frozen_glossary,
|
| 3825 |
+
'attempts': attempt,
|
| 3826 |
+
'qa_status': 'passed',
|
| 3827 |
+
}
|
| 3828 |
+
except PipelineCancelledError:
|
| 3829 |
+
raise
|
| 3830 |
+
except Exception as error:
|
| 3831 |
+
last_error = error
|
| 3832 |
+
if str(error).startswith('Translation block-local QA failed'):
|
| 3833 |
+
raise
|
| 3834 |
+
logging.warning(
|
| 3835 |
+
'Translation section technical retry: section=%s attempt=%s/%s error=%s',
|
| 3836 |
+
section_name or 'section', attempt, attempts, self._safe_source_error(error),
|
| 3837 |
+
)
|
| 3838 |
+
if attempt >= attempts:
|
| 3839 |
+
raise
|
| 3840 |
+
raise last_error or DeepSeekRequestError('Translation section QA failed.')
|
| 3841 |
|
| 3842 |
def translate_locked_manuscript(self, manuscript, target_lang):
|
| 3843 |
value = str(manuscript or '').strip()
|
|
|
|
| 3980 |
violations = []
|
| 3981 |
raw_tag_pattern = re.compile(r"CITE-\d+", re.IGNORECASE)
|
| 3982 |
resolved_citation_pattern = re.compile(
|
| 3983 |
+
r"\((?=[^()\n]*[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+(?:\s+(?:&\s+[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+|et\s+al\.))?\s*,\s*(?:19|20)\d{2}[a-z]?\b)[^()\n]+\)",
|
| 3984 |
re.IGNORECASE,
|
| 3985 |
)
|
| 3986 |
for _, _, sentence in SSciPipelineAgent._sentence_spans(text):
|
i18n.py
CHANGED
|
@@ -19,7 +19,7 @@ LANGUAGE_OPTIONS = {
|
|
| 19 |
|
| 20 |
TARGET_LANGUAGE = {
|
| 21 |
"zh": "Simplified Chinese (简体中文)",
|
| 22 |
-
# Approved product rule: English UI still delivers English
|
| 23 |
"en": "Simplified Chinese (简体中文)",
|
| 24 |
"ru": "Russian (Русский)",
|
| 25 |
"kk": "Kazakh (Қазақша)",
|
|
@@ -226,6 +226,14 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 226 |
"draft_stage_outline": "正在规划论文结构…",
|
| 227 |
"draft_stage_citation": "正在建立论点与引用关系…",
|
| 228 |
"draft_stage_writing": "正在准备正文写作…",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
"busy_short": "⏳ 有用户正在生成论文,预计25–35分钟后可用。",
|
| 230 |
"tab_amj": "✨ 定量研究(AMJ 引擎)",
|
| 231 |
"tab_asq": "📝 定性研究(ASQ 引擎)",
|
|
@@ -388,6 +396,14 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 388 |
"draft_stage_outline": "Planning the manuscript structure…",
|
| 389 |
"draft_stage_citation": "Mapping claims to supporting sources…",
|
| 390 |
"draft_stage_writing": "Preparing to draft the manuscript…",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
"busy_short": "⏳ Another user is generating a paper. Please try again in about 25–35 minutes.",
|
| 392 |
"tab_amj": "✨ Quantitative Research (AMJ Engine)",
|
| 393 |
"tab_asq": "📝 Qualitative Research (ASQ Engine)",
|
|
@@ -550,6 +566,14 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 550 |
"draft_stage_outline": "Планирование структуры статьи…",
|
| 551 |
"draft_stage_citation": "Связывание тезисов с источниками…",
|
| 552 |
"draft_stage_writing": "Подготовка к написанию текста…",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
"busy_short": "⏳ Другой пользователь формирует статью. Повторите попытку примерно через 25–35 минут.",
|
| 554 |
"tab_amj": "✨ Количественное исследование (движок AMJ)",
|
| 555 |
"tab_asq": "📝 Качественное исследование (движок ASQ)",
|
|
@@ -712,6 +736,14 @@ TEXTS: dict[str, dict[str, str]] = {
|
|
| 712 |
"draft_stage_outline": "Мақала құрылымы жоспарлануда…",
|
| 713 |
"draft_stage_citation": "Тұжырымдар дереккөздермен сәйкестендірілуде…",
|
| 714 |
"draft_stage_writing": "Негізгі мәтінді жазуға дайындалуда…",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
"busy_short": "⏳ Басқа пайдаланушы мақала дайындап жатыр. Шамамен 25–35 минуттан кейін қайталап көріңіз.",
|
| 716 |
"tab_amj": "✨ Сандық зерттеу (AMJ қозғалтқышы)",
|
| 717 |
"tab_asq": "📝 Сапалық зерттеу (ASQ қозғалтқышы)",
|
|
|
|
| 19 |
|
| 20 |
TARGET_LANGUAGE = {
|
| 21 |
"zh": "Simplified Chinese (简体中文)",
|
| 22 |
+
# Approved product rule: English UI still delivers English draft + Chinese paper.
|
| 23 |
"en": "Simplified Chinese (简体中文)",
|
| 24 |
"ru": "Russian (Русский)",
|
| 25 |
"kk": "Kazakh (Қазақша)",
|
|
|
|
| 226 |
"draft_stage_outline": "正在规划论文结构…",
|
| 227 |
"draft_stage_citation": "正在建立论点与引用关系…",
|
| 228 |
"draft_stage_writing": "正在准备正文写作…",
|
| 229 |
+
"draft_detail_progress": "论文进度:{progress}%",
|
| 230 |
+
"draft_detail_s2": "Semantic Scholar:{count} 篇有效文献",
|
| 231 |
+
"draft_detail_oa": "OpenAlex:{count} 篇有效文献",
|
| 232 |
+
"draft_detail_current": "当前阶段:{stage}",
|
| 233 |
+
"translation_section_running": "正在翻译【{name}】…",
|
| 234 |
+
"translation_section_qa": "正在审核【{name}】的翻译完整性与语义一致性…",
|
| 235 |
+
"translation_section_ready": "【{name}】翻译审核通过,正在显示译文…",
|
| 236 |
+
"translation_complete_label": "目标语言论文翻译完成。",
|
| 237 |
"busy_short": "⏳ 有用户正在生成论文,预计25–35分钟后可用。",
|
| 238 |
"tab_amj": "✨ 定量研究(AMJ 引擎)",
|
| 239 |
"tab_asq": "📝 定性研究(ASQ 引擎)",
|
|
|
|
| 396 |
"draft_stage_outline": "Planning the manuscript structure…",
|
| 397 |
"draft_stage_citation": "Mapping claims to supporting sources…",
|
| 398 |
"draft_stage_writing": "Preparing to draft the manuscript…",
|
| 399 |
+
"draft_detail_progress": "Paper progress: {progress}%",
|
| 400 |
+
"draft_detail_s2": "Semantic Scholar: {count} eligible papers",
|
| 401 |
+
"draft_detail_oa": "OpenAlex: {count} eligible papers",
|
| 402 |
+
"draft_detail_current": "Current stage: {stage}",
|
| 403 |
+
"translation_section_running": "Translating 【{name}】…",
|
| 404 |
+
"translation_section_qa": "Checking translation integrity and semantic equivalence for 【{name}】…",
|
| 405 |
+
"translation_section_ready": "Translation of 【{name}】 passed QA and is being displayed…",
|
| 406 |
+
"translation_complete_label": "The target-language paper translation is complete.",
|
| 407 |
"busy_short": "⏳ Another user is generating a paper. Please try again in about 25–35 minutes.",
|
| 408 |
"tab_amj": "✨ Quantitative Research (AMJ Engine)",
|
| 409 |
"tab_asq": "📝 Qualitative Research (ASQ Engine)",
|
|
|
|
| 566 |
"draft_stage_outline": "Планирование структуры статьи…",
|
| 567 |
"draft_stage_citation": "Связывание тезисов с источниками…",
|
| 568 |
"draft_stage_writing": "Подготовка к написанию текста…",
|
| 569 |
+
"draft_detail_progress": "Прогресс статьи: {progress}%",
|
| 570 |
+
"draft_detail_s2": "Semantic Scholar: {count} подходящих источников",
|
| 571 |
+
"draft_detail_oa": "OpenAlex: {count} подходящих источников",
|
| 572 |
+
"draft_detail_current": "Текущий этап: {stage}",
|
| 573 |
+
"translation_section_running": "Перевод раздела 【{name}】…",
|
| 574 |
+
"translation_section_qa": "Проверка полноты и смысловой эквивалентности перевода раздела 【{name}】…",
|
| 575 |
+
"translation_section_ready": "Перевод раздела 【{name}】 прошёл проверку и отображается…",
|
| 576 |
+
"translation_complete_label": "Перевод статьи на целевой язык завершён.",
|
| 577 |
"busy_short": "⏳ Другой пользователь формирует статью. Повторите попытку примерно через 25–35 минут.",
|
| 578 |
"tab_amj": "✨ Количественное исследование (движок AMJ)",
|
| 579 |
"tab_asq": "📝 Качественное исследование (движок ASQ)",
|
|
|
|
| 736 |
"draft_stage_outline": "Мақала құрылымы жоспарлануда…",
|
| 737 |
"draft_stage_citation": "Тұжырымдар дереккөздермен сәйкестендірілуде…",
|
| 738 |
"draft_stage_writing": "Негізгі мәтінді жазуға дайындалуда…",
|
| 739 |
+
"draft_detail_progress": "Мақала барысы: {progress}%",
|
| 740 |
+
"draft_detail_s2": "Semantic Scholar: {count} жарамды дереккөз",
|
| 741 |
+
"draft_detail_oa": "OpenAlex: {count} жарамды дереккөз",
|
| 742 |
+
"draft_detail_current": "Ағымдағы кезең: {stage}",
|
| 743 |
+
"translation_section_running": "【{name}】 бөлімі аударылуда…",
|
| 744 |
+
"translation_section_qa": "【{name}】 аудармасының толықтығы м��н мағыналық сәйкестігі тексерілуде…",
|
| 745 |
+
"translation_section_ready": "【{name}】 аудармасы тексеруден өтті және көрсетілуде…",
|
| 746 |
+
"translation_complete_label": "Мақаланың мақсатты тілдегі аудармасы аяқталды.",
|
| 747 |
"busy_short": "⏳ Басқа пайдаланушы мақала дайындап жатыр. Шамамен 25–35 минуттан кейін қайталап көріңіз.",
|
| 748 |
"tab_amj": "✨ Сандық зерттеу (AMJ қозғалтқышы)",
|
| 749 |
"tab_asq": "📝 Сапалық зерттеу (ASQ қозғалтқышы)",
|
smartpaper_restore_bridge/frontend/index.html
CHANGED
|
@@ -25,8 +25,12 @@
|
|
| 25 |
let lastMonitorNode = "";
|
| 26 |
let lastMonitorCheckpointSeq = 0;
|
| 27 |
let lastPreviewText = "";
|
| 28 |
-
let
|
| 29 |
let pageExitCancelCleanup = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
const ACTION_PATHS = Object.freeze({
|
| 32 |
task_start: "/edge/task/start",
|
|
@@ -221,24 +225,67 @@
|
|
| 221 |
}
|
| 222 |
}
|
| 223 |
|
| 224 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
const target = parentDocument.getElementById("sp-live-manuscript-preview");
|
| 226 |
-
|
|
|
|
| 227 |
const map = labels && typeof labels === "object" ? labels : {};
|
| 228 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
if (!value) return;
|
| 230 |
target.dataset.spPlaceholder = "1";
|
| 231 |
target.textContent = value;
|
| 232 |
}
|
| 233 |
|
| 234 |
-
function
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
.replace(/\r/g, "")
|
| 241 |
.replace(/^#{1,3}\s+/gm, "");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
let stream = target.querySelector("[data-sp-live-stream='1']");
|
| 243 |
if (!stream) {
|
| 244 |
stream = parentDocument.createElement("div");
|
|
@@ -247,49 +294,95 @@
|
|
| 247 |
stream.style.overflowWrap = "anywhere";
|
| 248 |
stream.style.lineHeight = "1.72";
|
| 249 |
target.replaceChildren(stream);
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
}
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
} else {
|
| 256 |
-
//
|
| 257 |
-
// prose.
|
|
|
|
|
|
|
| 258 |
stream.textContent = displayText;
|
|
|
|
| 259 |
}
|
| 260 |
-
|
|
|
|
| 261 |
}
|
| 262 |
|
| 263 |
-
function previewShouldFollow(parentWindow, shell) {
|
| 264 |
if (!shell) return false;
|
| 265 |
-
if (!
|
| 266 |
try {
|
| 267 |
const rect = shell.getBoundingClientRect();
|
| 268 |
const viewport = parentWindow.innerHeight || 0;
|
| 269 |
-
// Follow only while the preview is already on-screen or immediately
|
| 270 |
-
// below it. A user who scrolls upward is never pulled back down.
|
| 271 |
return rect.top < viewport * 0.92 && rect.bottom > -80;
|
| 272 |
} catch (_) { return false; }
|
| 273 |
}
|
| 274 |
|
| 275 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
let parentDocument, parentWindow;
|
| 277 |
try { parentDocument = window.parent.document; parentWindow = window.parent; }
|
| 278 |
catch (_) { return; }
|
| 279 |
-
const
|
| 280 |
-
const
|
| 281 |
-
if (
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
// Keep the manuscript viewport pinned to the newest prose without moving
|
| 289 |
-
// the whole browser page. Whole-page scrolling is reserved for the single
|
| 290 |
-
// post-launch positioning action in app.py.
|
| 291 |
-
shell.scrollTop = shell.scrollHeight;
|
| 292 |
}
|
|
|
|
| 293 |
}
|
| 294 |
|
| 295 |
async function fetchPreview(watch) {
|
|
@@ -303,7 +396,7 @@
|
|
| 303 |
if (!payload || String(payload.task_id || "") !== watch.taskId) return;
|
| 304 |
const epoch = asCount(payload.execution_epoch);
|
| 305 |
if (watch.expectedEpoch > 0 && epoch !== watch.expectedEpoch) return;
|
| 306 |
-
updateManuscriptPreview(payload);
|
| 307 |
} catch (_) {}
|
| 308 |
}
|
| 309 |
|
|
@@ -326,7 +419,7 @@
|
|
| 326 |
updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${node}`);
|
| 327 |
const fill = parentDocument.getElementById("sp-live-progress-fill");
|
| 328 |
if (fill) fill.style.width = `${progress}%`;
|
| 329 |
-
updateDraftPreparation(parentDocument, node, watch && watch.draftStatusLabels);
|
| 330 |
|
| 331 |
if (node && node !== lastMonitorNode) {
|
| 332 |
appendTaskActivity(parentDocument, node, false);
|
|
@@ -410,6 +503,7 @@
|
|
| 410 |
if (watch && watch.controller) {
|
| 411 |
try { watch.controller.abort(); } catch (_) {}
|
| 412 |
}
|
|
|
|
| 413 |
}
|
| 414 |
|
| 415 |
function startTaskWatch(commandId, args) {
|
|
@@ -430,12 +524,16 @@
|
|
| 430 |
}
|
| 431 |
|
| 432 |
const draftStatusLabels = payload.draft_status_labels && typeof payload.draft_status_labels === "object" ? payload.draft_status_labels : {};
|
|
|
|
| 433 |
const bestEffortPageExitCancel = payload.best_effort_page_exit_cancel === true;
|
| 434 |
-
const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, draftStatusLabels, bestEffortPageExitCancel, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
|
| 435 |
activeTaskWatch = watch;
|
| 436 |
installPageExitCancel(watch);
|
| 437 |
lastPreviewText = "";
|
| 438 |
-
|
|
|
|
|
|
|
|
|
|
| 439 |
lastMonitorCheckpointSeq = Math.max(lastMonitorCheckpointSeq, initialCheckpointSeq);
|
| 440 |
|
| 441 |
const schedulePreview = () => {
|
|
|
|
| 25 |
let lastMonitorNode = "";
|
| 26 |
let lastMonitorCheckpointSeq = 0;
|
| 27 |
let lastPreviewText = "";
|
| 28 |
+
let lastTargetPreviewText = "";
|
| 29 |
let pageExitCancelCleanup = null;
|
| 30 |
+
const renderChannels = {
|
| 31 |
+
english:{ authoritative:"", displayed:"", queue:[], timer:null },
|
| 32 |
+
target:{ authoritative:"", displayed:"", queue:[], timer:null }
|
| 33 |
+
};
|
| 34 |
|
| 35 |
const ACTION_PATHS = Object.freeze({
|
| 36 |
task_start: "/edge/task/start",
|
|
|
|
| 225 |
}
|
| 226 |
}
|
| 227 |
|
| 228 |
+
function formatTemplate(template, values) {
|
| 229 |
+
let output = String(template || "");
|
| 230 |
+
for (const [key, value] of Object.entries(values || {})) {
|
| 231 |
+
output = output.split(`{${key}}`).join(String(value ?? ""));
|
| 232 |
+
}
|
| 233 |
+
return output;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
function updateDraftPreparation(parentDocument, node, labels, task=null) {
|
| 237 |
const target = parentDocument.getElementById("sp-live-manuscript-preview");
|
| 238 |
+
const channel = renderChannels.english;
|
| 239 |
+
if (!target || channel.authoritative || target.querySelector("[data-sp-live-stream='1']")) return;
|
| 240 |
const map = labels && typeof labels === "object" ? labels : {};
|
| 241 |
+
const progress = Math.max(0, Math.min(100, asCount(task && task.progress_pct)));
|
| 242 |
+
const s2 = asCount(task && task.s2_calls);
|
| 243 |
+
const oa = asCount(task && task.oa_calls);
|
| 244 |
+
const stage = String(map[node] || map.writing || map.default || node || "").trim();
|
| 245 |
+
const lines = [String(map.default || "").trim()];
|
| 246 |
+
if (stage) lines.push(formatTemplate(map.current || "{stage}", {stage}));
|
| 247 |
+
if (map.progress) lines.push(formatTemplate(map.progress, {progress}));
|
| 248 |
+
if (map.s2) lines.push(formatTemplate(map.s2, {count:s2}));
|
| 249 |
+
if (map.oa) lines.push(formatTemplate(map.oa, {count:oa}));
|
| 250 |
+
const value = lines.filter(Boolean).join("\n");
|
| 251 |
if (!value) return;
|
| 252 |
target.dataset.spPlaceholder = "1";
|
| 253 |
target.textContent = value;
|
| 254 |
}
|
| 255 |
|
| 256 |
+
function updateTargetPreparation(parentDocument, payload, labels) {
|
| 257 |
+
const target = parentDocument.getElementById("sp-live-translation-preview");
|
| 258 |
+
const channel = renderChannels.target;
|
| 259 |
+
if (!target || channel.authoritative || target.querySelector("[data-sp-live-stream='1']")) return;
|
| 260 |
+
const map = labels && typeof labels === "object" ? labels : {};
|
| 261 |
+
const stage = String(payload && payload.target_stage || "");
|
| 262 |
+
let value = String(map.waiting || "");
|
| 263 |
+
if (stage.startsWith("translating:")) {
|
| 264 |
+
value = formatTemplate(map.translating || value, {name:stage.slice("translating:".length)});
|
| 265 |
+
} else if (stage.startsWith("translation_qa:")) {
|
| 266 |
+
value = formatTemplate(map.qa || value, {name:stage.slice("translation_qa:".length)});
|
| 267 |
+
} else if (stage.startsWith("translation_ready:")) {
|
| 268 |
+
value = formatTemplate(map.ready || value, {name:stage.slice("translation_ready:".length)});
|
| 269 |
+
} else if (stage === "translation_complete") {
|
| 270 |
+
value = String(map.complete || value);
|
| 271 |
+
}
|
| 272 |
+
if (!value) return;
|
| 273 |
+
target.dataset.spPlaceholder = "1";
|
| 274 |
+
target.textContent = value;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function normalizePreviewDisplay(text) {
|
| 278 |
+
return String(text || "")
|
| 279 |
.replace(/\r/g, "")
|
| 280 |
.replace(/^#{1,3}\s+/gm, "");
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
function wordLikeUnits(value) {
|
| 284 |
+
const matches = String(value || "").match(/[\u4e00-\u9fff]{1,2}|\s+|[^\s\u4e00-\u9fff]+/g);
|
| 285 |
+
return matches || [];
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
function ensureStreamNode(parentDocument, target, channel) {
|
| 289 |
let stream = target.querySelector("[data-sp-live-stream='1']");
|
| 290 |
if (!stream) {
|
| 291 |
stream = parentDocument.createElement("div");
|
|
|
|
| 294 |
stream.style.overflowWrap = "anywhere";
|
| 295 |
stream.style.lineHeight = "1.72";
|
| 296 |
target.replaceChildren(stream);
|
| 297 |
+
channel.displayed = "";
|
| 298 |
+
}
|
| 299 |
+
target.dataset.spPlaceholder = "0";
|
| 300 |
+
return stream;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function stopRenderTimer(channel) {
|
| 304 |
+
if (channel && channel.timer) window.clearTimeout(channel.timer);
|
| 305 |
+
if (channel) channel.timer = null;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function pumpRenderQueue(parentDocument, target, shell, channel) {
|
| 309 |
+
if (!target || !channel) return;
|
| 310 |
+
stopRenderTimer(channel);
|
| 311 |
+
if (!channel.queue.length) return;
|
| 312 |
+
const stream = ensureStreamNode(parentDocument, target, channel);
|
| 313 |
+
const backlog = channel.queue.length;
|
| 314 |
+
const perTick = backlog > 260 ? 5 : backlog > 140 ? 3 : backlog > 70 ? 2 : 1;
|
| 315 |
+
let chunk = "";
|
| 316 |
+
for (let i=0; i<perTick && channel.queue.length; i++) chunk += channel.queue.shift();
|
| 317 |
+
if (chunk) {
|
| 318 |
+
stream.appendChild(parentDocument.createTextNode(chunk));
|
| 319 |
+
channel.displayed += chunk;
|
| 320 |
+
if (shell && shell.dataset.spAutoFollow !== "0") shell.scrollTop = shell.scrollHeight;
|
| 321 |
}
|
| 322 |
+
channel.timer = window.setTimeout(() => pumpRenderQueue(parentDocument, target, shell, channel), 34);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
function renderBufferedPreview(parentDocument, target, shell, text, channel) {
|
| 326 |
+
const displayText = normalizePreviewDisplay(text);
|
| 327 |
+
if (displayText === channel.authoritative) return;
|
| 328 |
+
const stream = ensureStreamNode(parentDocument, target, channel);
|
| 329 |
+
if (displayText.startsWith(channel.authoritative)) {
|
| 330 |
+
const delta = displayText.slice(channel.authoritative.length);
|
| 331 |
+
if (delta) channel.queue.push(...wordLikeUnits(delta));
|
| 332 |
} else {
|
| 333 |
+
// A bounded server-side semantic/citation revision invalidates queued raw
|
| 334 |
+
// prose. Reconcile immediately to the audited authoritative text.
|
| 335 |
+
stopRenderTimer(channel);
|
| 336 |
+
channel.queue = [];
|
| 337 |
stream.textContent = displayText;
|
| 338 |
+
channel.displayed = displayText;
|
| 339 |
}
|
| 340 |
+
channel.authoritative = displayText;
|
| 341 |
+
pumpRenderQueue(parentDocument, target, shell, channel);
|
| 342 |
}
|
| 343 |
|
| 344 |
+
function previewShouldFollow(parentWindow, shell, hasText) {
|
| 345 |
if (!shell) return false;
|
| 346 |
+
if (!hasText) return true;
|
| 347 |
try {
|
| 348 |
const rect = shell.getBoundingClientRect();
|
| 349 |
const viewport = parentWindow.innerHeight || 0;
|
|
|
|
|
|
|
| 350 |
return rect.top < viewport * 0.92 && rect.bottom > -80;
|
| 351 |
} catch (_) { return false; }
|
| 352 |
}
|
| 353 |
|
| 354 |
+
function updateOnePreview(parentDocument, parentWindow, id, shellId, text, channel) {
|
| 355 |
+
const target = parentDocument.getElementById(id);
|
| 356 |
+
const shell = parentDocument.getElementById(shellId);
|
| 357 |
+
if (!target || !shell || !text) return;
|
| 358 |
+
if (shell.dataset.spScrollGuard !== "1") {
|
| 359 |
+
shell.dataset.spScrollGuard = "1";
|
| 360 |
+
shell.addEventListener("scroll", () => {
|
| 361 |
+
const distance = shell.scrollHeight - shell.scrollTop - shell.clientHeight;
|
| 362 |
+
shell.dataset.spAutoFollow = distance <= 48 ? "1" : "0";
|
| 363 |
+
}, {passive:true});
|
| 364 |
+
}
|
| 365 |
+
if (!channel.authoritative && previewShouldFollow(parentWindow, shell, false)) {
|
| 366 |
+
shell.dataset.spAutoFollow = "1";
|
| 367 |
+
}
|
| 368 |
+
renderBufferedPreview(parentDocument, target, shell, text, channel);
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
function updateManuscriptPreview(payload, watch=null) {
|
| 372 |
let parentDocument, parentWindow;
|
| 373 |
try { parentDocument = window.parent.document; parentWindow = window.parent; }
|
| 374 |
catch (_) { return; }
|
| 375 |
+
const englishText = String(payload && payload.text || "");
|
| 376 |
+
const targetText = String(payload && payload.target_text || "");
|
| 377 |
+
if (englishText && englishText !== lastPreviewText) {
|
| 378 |
+
updateOnePreview(parentDocument, parentWindow, "sp-live-manuscript-preview", "sp-live-manuscript-shell", englishText, renderChannels.english);
|
| 379 |
+
lastPreviewText = englishText;
|
| 380 |
+
}
|
| 381 |
+
if (targetText && targetText !== lastTargetPreviewText) {
|
| 382 |
+
updateOnePreview(parentDocument, parentWindow, "sp-live-translation-preview", "sp-live-translation-shell", targetText, renderChannels.target);
|
| 383 |
+
lastTargetPreviewText = targetText;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
}
|
| 385 |
+
if (!targetText) updateTargetPreparation(parentDocument, payload, watch && watch.targetStatusLabels);
|
| 386 |
}
|
| 387 |
|
| 388 |
async function fetchPreview(watch) {
|
|
|
|
| 396 |
if (!payload || String(payload.task_id || "") !== watch.taskId) return;
|
| 397 |
const epoch = asCount(payload.execution_epoch);
|
| 398 |
if (watch.expectedEpoch > 0 && epoch !== watch.expectedEpoch) return;
|
| 399 |
+
updateManuscriptPreview(payload, watch);
|
| 400 |
} catch (_) {}
|
| 401 |
}
|
| 402 |
|
|
|
|
| 419 |
updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${node}`);
|
| 420 |
const fill = parentDocument.getElementById("sp-live-progress-fill");
|
| 421 |
if (fill) fill.style.width = `${progress}%`;
|
| 422 |
+
updateDraftPreparation(parentDocument, node, watch && watch.draftStatusLabels, task);
|
| 423 |
|
| 424 |
if (node && node !== lastMonitorNode) {
|
| 425 |
appendTaskActivity(parentDocument, node, false);
|
|
|
|
| 503 |
if (watch && watch.controller) {
|
| 504 |
try { watch.controller.abort(); } catch (_) {}
|
| 505 |
}
|
| 506 |
+
for (const channel of Object.values(renderChannels)) stopRenderTimer(channel);
|
| 507 |
}
|
| 508 |
|
| 509 |
function startTaskWatch(commandId, args) {
|
|
|
|
| 524 |
}
|
| 525 |
|
| 526 |
const draftStatusLabels = payload.draft_status_labels && typeof payload.draft_status_labels === "object" ? payload.draft_status_labels : {};
|
| 527 |
+
const targetStatusLabels = payload.target_status_labels && typeof payload.target_status_labels === "object" ? payload.target_status_labels : {};
|
| 528 |
const bestEffortPageExitCancel = payload.best_effort_page_exit_cancel === true;
|
| 529 |
+
const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, draftStatusLabels, targetStatusLabels, bestEffortPageExitCancel, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
|
| 530 |
activeTaskWatch = watch;
|
| 531 |
installPageExitCancel(watch);
|
| 532 |
lastPreviewText = "";
|
| 533 |
+
lastTargetPreviewText = "";
|
| 534 |
+
for (const channel of Object.values(renderChannels)) {
|
| 535 |
+
stopRenderTimer(channel); channel.authoritative = ""; channel.displayed = ""; channel.queue = [];
|
| 536 |
+
}
|
| 537 |
lastMonitorCheckpointSeq = Math.max(lastMonitorCheckpointSeq, initialCheckpointSeq);
|
| 538 |
|
| 539 |
const schedulePreview = () => {
|