GuXiaopeng commited on
Commit
438fa5b
·
1 Parent(s): f16664f

Step 4A-6-C-R3.2.8 Citation Singularity Word Streaming Bilingual Translation and Structure Gate Fix (#18)

Browse files

- Step 4A-6-C-R3.2.8 Citation Singularity Word Streaming Bilingual Translation and Structure Gate Fix (e95bfefb68ee6c51de6e81468676be31afe5967a)

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 one stable DOM target; browser preview updates mutate it in place."""
741
  checkpoint = _load_confirmed_checkpoint(task)
742
- draft = str((checkpoint or {}).get("full_draft_en") or "").strip()
743
- initial_text = draft if draft else tr(lang_code, "draft_preparing")
744
- escaped = html.escape(initial_text)
745
- placeholder = "0" if draft else "1"
746
- st.markdown(tr(lang_code, "english_draft"))
747
- st.markdown(
748
- f"""
749
- <div id='sp-live-manuscript-shell' style='border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;min-height:180px;max-height:68vh;overflow:auto;background:#ffffff;'>
750
- <div id='sp-live-manuscript-preview' data-sp-placeholder='{placeholder}' style='white-space:pre-wrap;overflow-wrap:anywhere;margin:0;font-family:inherit;line-height:1.72;color:#111827;'>{escaped}</div>
751
- <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>
752
- </div>
753
- <style>@keyframes spBlink {{ 0%,49% {{opacity:1}} 50%,100% {{opacity:0}} }}</style>
754
- """,
755
- unsafe_allow_html=True,
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; only final manuscript prose is previewed.
 
 
133
  text = re.sub(r"(?is)<think>.*?(?:</think>|$)", "", text)
 
 
134
  return text.strip()
135
 
136
- def _publish_preview(self, text: str, stage: str, *, force: bool = False, complete: bool = False) -> None:
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(stage or "")[:240],
147
- "text": self._preview_visible_text(text),
148
- "complete": bool(complete),
 
 
 
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
- agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
 
 
 
 
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(95, "translation")
681
- full_draft_translated = agent.translate_locked_manuscript(full_draft_en, target_lang)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, citation, number, percentage, coefficient, p-value, confidence interval, DOI, hypothesis label, variable abbreviation, 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. Return every marker exactly once and in the same order. Output only the marked translations."""
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 a conservative marker-recovery path.
3392
 
3393
- A multi-block response must preserve every marker exactly. If the model
3394
- omits or reorders markers, retry each source block independently so no
3395
- paragraph-to-translation mapping is guessed. For a single-source retry,
3396
- a marker-free response can be bound safely to that sole source block,
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{source}' for block_id, source in batch
 
3402
  )
 
 
 
 
 
 
3403
  result = self.call_deepseek(
3404
- system_prompt,
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; retrying blocks independently.',
3423
- len(batch),
3424
- expected_ids,
3425
  )
3426
  recovered = {}
3427
- for item in batch:
3428
- recovered.update(
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
- f'Single-block translation recovery returned empty text for SP-P{block_id}.'
3450
- ) from error
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
- for block_id, source in batch:
3461
- translated = self._validate_translation_block_with_abbreviation_recovery(
3462
- block_id, source, parsed[block_id], system_prompt, target_lang
3463
- )
3464
- parsed[block_id] = translated
3465
- return parsed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"\((?:[^()\n]*?)(?:19|20)\d{2}[a-z]?[^()\n]*?\)",
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 evidence draft + Chinese paper.
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 lastPreviewDisplayText = "";
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 updateDraftPreparation(parentDocument, node, labels) {
 
 
 
 
 
 
 
 
225
  const target = parentDocument.getElementById("sp-live-manuscript-preview");
226
- if (!target || lastPreviewText || target.querySelector("[data-sp-live-stream='1']")) return;
 
227
  const map = labels && typeof labels === "object" ? labels : {};
228
- const value = String(map[node] || map.writing || map.default || "").trim();
 
 
 
 
 
 
 
 
 
229
  if (!value) return;
230
  target.dataset.spPlaceholder = "1";
231
  target.textContent = value;
232
  }
233
 
234
- function renderPreviewContent(parentDocument, target, text) {
235
- // Keep one stable text node and append only the newly generated suffix.
236
- target.dataset.spPlaceholder = "0";
237
- // Rebuilding the whole manuscript DOM on every preview poll caused visible
238
- // repaint/flicker and did not feel like ChatGPT-style streaming.
239
- const displayText = String(text || "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- lastPreviewDisplayText = "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  }
252
- if (displayText.startsWith(lastPreviewDisplayText)) {
253
- const delta = displayText.slice(lastPreviewDisplayText.length);
254
- if (delta) stream.appendChild(parentDocument.createTextNode(delta));
 
 
 
 
 
 
 
255
  } else {
256
- // Resume/recovery or a bounded server-side revision can replace prior
257
- // prose. Update the existing node in place rather than replacing the DOM.
 
 
258
  stream.textContent = displayText;
 
259
  }
260
- lastPreviewDisplayText = displayText;
 
261
  }
262
 
263
- function previewShouldFollow(parentWindow, shell) {
264
  if (!shell) return false;
265
- if (!lastPreviewText) return true;
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 updateManuscriptPreview(payload) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  let parentDocument, parentWindow;
277
  try { parentDocument = window.parent.document; parentWindow = window.parent; }
278
  catch (_) { return; }
279
- const target = parentDocument.getElementById("sp-live-manuscript-preview");
280
- const shell = parentDocument.getElementById("sp-live-manuscript-shell");
281
- if (!target || !shell) return;
282
- const text = String(payload && payload.text || "");
283
- if (!text || text === lastPreviewText) return;
284
- const shouldFollow = previewShouldFollow(parentWindow, shell);
285
- renderPreviewContent(parentDocument, target, text);
286
- lastPreviewText = text;
287
- if (shouldFollow) {
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
- lastPreviewDisplayText = "";
 
 
 
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 = () => {