R3.2.8.6-R1.3C-RU2 Russian Contamination Adjudication and Target Preview State

#31
core/generation_runner.py CHANGED
@@ -8,6 +8,7 @@ connections. Checkpoints are task-scoped, immutable-by-sequence JSON snapshots.
8
  from __future__ import annotations
9
 
10
  import hashlib
 
11
  import json
12
  import logging
13
  import math
@@ -33,7 +34,7 @@ ProgressCallback = Callable[..., None]
33
  MIN_FINAL_REFERENCES = 50
34
  LITERATURE_EVIDENCE_TARGET = 75
35
  CITATION_PLAN_TARGET = 70
36
- TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v1'
37
 
38
 
39
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
@@ -299,10 +300,31 @@ class GenerationRunner:
299
  partial = self._assemble_partial_translation(translation_sections, adaptive_structure, include_front=include_front)
300
  self._publish_target_preview(partial, f'translating:{unit_name}', force=True, complete=False)
301
  updated_sections = dict(translation_sections or {})
 
 
 
 
 
 
 
 
 
302
  try:
303
- result = agent.translate_frozen_section(
304
- source, target_lang, glossary=translation_glossary, section_name=unit_name, max_attempts=2
305
- )
 
 
 
 
 
 
 
 
 
 
 
 
306
  except PipelineCancelledError:
307
  raise
308
  except Exception as error:
@@ -326,6 +348,9 @@ class GenerationRunner:
326
  'Translation deferred until final gate | unit=%s code=%s error=%s',
327
  unit_name, error_code, type(error).__name__,
328
  )
 
 
 
329
  return updated_sections, translation_glossary
330
  updated_glossary = dict(result.get('glossary') or translation_glossary or {})
331
  updated_sections[unit_key] = {
@@ -720,7 +745,13 @@ class GenerationRunner:
720
  if working_abstract and not state.get("english_finalized"):
721
  section_order_for_front = self._unique_top_level_sections(adaptive_structure)
722
  if section_order_for_front:
723
- working_front_source = self._extract_front_matter(full_draft_en, section_order_for_front[0][1])
 
 
 
 
 
 
724
  translation_sections, translation_glossary = self._translate_frozen_unit(
725
  agent, unit_key='__front_matter__', unit_name='Working Front Matter',
726
  source_text=working_front_source, target_lang=target_lang,
 
8
  from __future__ import annotations
9
 
10
  import hashlib
11
+ import inspect
12
  import json
13
  import logging
14
  import math
 
34
  MIN_FINAL_REFERENCES = 50
35
  LITERATURE_EVIDENCE_TARGET = 75
36
  CITATION_PLAN_TARGET = 70
37
+ TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v2'
38
 
39
 
40
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
 
300
  partial = self._assemble_partial_translation(translation_sections, adaptive_structure, include_front=include_front)
301
  self._publish_target_preview(partial, f'translating:{unit_name}', force=True, complete=False)
302
  updated_sections = dict(translation_sections or {})
303
+
304
+ def publish_translation_stage(stage):
305
+ value = str(stage or '').strip().casefold()
306
+ if value in {'qa', 'repairing'}:
307
+ preview_stage = f'translation_qa:{unit_name}'
308
+ else:
309
+ preview_stage = f'translating:{unit_name}'
310
+ self._publish_target_preview(partial, preview_stage, force=True, complete=False)
311
+
312
  try:
313
+ translate_method = agent.translate_frozen_section
314
+ call_kwargs = {
315
+ 'glossary': translation_glossary, 'section_name': unit_name, 'max_attempts': 2,
316
+ }
317
+ try:
318
+ parameters = inspect.signature(translate_method).parameters.values()
319
+ supports_stage_callback = any(
320
+ item.name == 'stage_callback' or item.kind == inspect.Parameter.VAR_KEYWORD
321
+ for item in parameters
322
+ )
323
+ except (TypeError, ValueError):
324
+ supports_stage_callback = False
325
+ if supports_stage_callback:
326
+ call_kwargs['stage_callback'] = publish_translation_stage
327
+ result = translate_method(source, target_lang, **call_kwargs)
328
  except PipelineCancelledError:
329
  raise
330
  except Exception as error:
 
348
  'Translation deferred until final gate | unit=%s code=%s error=%s',
349
  unit_name, error_code, type(error).__name__,
350
  )
351
+ self._publish_target_preview(
352
+ partial, f'translation_repair_pending:{unit_name}', force=True, complete=False
353
+ )
354
  return updated_sections, translation_glossary
355
  updated_glossary = dict(result.get('glossary') or translation_glossary or {})
356
  updated_sections[unit_key] = {
 
745
  if working_abstract and not state.get("english_finalized"):
746
  section_order_for_front = self._unique_top_level_sections(adaptive_structure)
747
  if section_order_for_front:
748
+ first_section_title = section_order_for_front[0][1]
749
+ working_front_source = self._extract_front_matter(full_draft_en, first_section_title)
750
+ if not working_front_source and not completed_sections:
751
+ # Fresh tasks contain only Title/Abstract/Keywords here; the
752
+ # first body heading does not exist yet. Preserve legacy
753
+ # resume behavior when completed body sections are present.
754
+ working_front_source = str(full_draft_en or '').strip()
755
  translation_sections, translation_glossary = self._translate_frozen_unit(
756
  agent, unit_key='__front_matter__', unit_name='Working Front Matter',
757
  source_text=working_front_source, target_lang=target_lang,
core/pipeline_agent.py CHANGED
@@ -61,7 +61,7 @@ class CitationAuditError(DeepSeekRequestError):
61
 
62
 
63
  class SSciPipelineAgent:
64
- TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v1'
65
 
66
  def __init__(self, ds_key, s2_key=""):
67
  self.ds_key = str(ds_key or "").strip()
@@ -3459,6 +3459,123 @@ Output only the repaired section."""
3459
  core = 'Use formal academic Simplified Chinese with explicit concepts, natural Chinese syntax, clear hierarchy, and logically connected claims. Avoid carrying English nominalization and clause order into Chinese mechanically.'
3460
  return core + ' ' + profile['term_policy'] + ' ' + profile['contamination_policy']
3461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3462
  @classmethod
3463
  def _compile_translation_ir(cls, source):
3464
  """Compile a private, language-neutral translation intermediate representation."""
@@ -4302,7 +4419,7 @@ SECTION: {section_name}
4302
  system_prompt = f"""You are a bilingual academic translation QA classifier for {target_lang}.
4303
  Review EACH claim independently. Do not rewrite and do not provide free-form explanations. Check semantic equivalence and language quality: omissions/additions, negation, uncertainty, association-versus-causation strength, terminology, grammar, language contamination, citation attachment, and acronym attachment.
4304
  LANGUAGE ADAPTER: {profile['adapter_id']}; morphology={profile['morphology']}. {profile['term_policy']} {profile['contamination_policy']}
4305
- For Russian, do not flag a terminology error merely because a translated term is inflected for grammatical case/number/gender. For Kazakh, do not flag a terminology error merely because productive suffixes attach to the correct term stem. For Kazakh language identity, absence of Kazakh-specific letters in one short claim is not sufficient evidence of Russian contamination.
4306
  Return valid JSON only in this exact form: {{"items":[{{"id":"SP-P000001-C000001","ok":true,"codes":[]}}]}}.
4307
  If ok=false, codes MUST contain only values from allowed_issue_codes. Never return numbered prose, examples, quotes, or corrective wording. Return every supplied id exactly once."""
4308
  response = self.call_deepseek(
@@ -4319,15 +4436,42 @@ If ok=false, codes MUST contain only values from allowed_issue_codes. Never retu
4319
  f'Translation claim QA id mismatch: expected={expected_row_ids}, got={sorted(by_id)}.'
4320
  )
4321
  row_lookup = {row['id']: row for row in rows}
 
4322
  for row_id in expected_row_ids:
4323
  returned_row = by_id[row_id]
4324
- if returned_row.get('ok') is True:
4325
- continue
4326
  raw_codes = [str(code or '').strip().upper() for code in (returned_row.get('codes') or [])]
4327
  codes = [code for code in raw_codes if code in allowed_codes]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4328
  if not codes:
 
 
 
 
 
 
 
4329
  codes = ['SEMANTIC_DRIFT']
4330
- meta = row_lookup[row_id]
4331
  issues.setdefault(meta['block_id'], {})[meta['claim_id']] = list(dict.fromkeys(codes))[:5]
4332
  return issues
4333
 
@@ -4685,7 +4829,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4685
  )
4686
  return {}
4687
 
4688
- def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2):
4689
  """Translate one frozen English unit and publish only after all QA passes."""
4690
  value = str(section_text or '').strip()
4691
  if not value:
@@ -4716,8 +4860,21 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4716
  )
4717
  attempts = max(1, int(max_attempts or 1))
4718
  last_error = None
 
 
 
 
 
 
 
 
 
 
 
 
4719
  for attempt in range(1, attempts + 1):
4720
  self._check_cancelled()
 
4721
  translations = {}
4722
  try:
4723
  for batch in batches:
@@ -4727,6 +4884,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4727
  for batch in batches
4728
  for block_id, source in batch
4729
  }
 
4730
  quality_issues = self._audit_translation_blocks_with_retry(
4731
  source_blocks, translations, target_lang, frozen_glossary,
4732
  section_name=section_name or 'frozen section', max_attempts=2,
@@ -4742,8 +4900,12 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4742
  logging.warning(
4743
  'Translation claim-local QA recovery: section=%s round=%s failed=%s',
4744
  section_name or 'section', local_round,
4745
- [f'SP-P{block_id}-{claim_id}' for block_id, claim_id, _codes in failed_pairs],
 
 
 
4746
  )
 
4747
  touched_blocks = set()
4748
  for block_id, claim_id, codes in failed_pairs:
4749
  source_claims = {
@@ -4760,6 +4922,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4760
  )
4761
  translations[block_id] = self._compose_translation_claim_segments(claim_order, segments)
4762
  touched_blocks.add(block_id)
 
4763
  quality_issues = self._audit_translation_blocks_with_retry(
4764
  {block_id: source_blocks[block_id] for block_id in sorted(touched_blocks)},
4765
  {block_id: translations[block_id] for block_id in sorted(touched_blocks)},
 
61
 
62
 
63
  class SSciPipelineAgent:
64
+ TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v2'
65
 
66
  def __init__(self, ds_key, s2_key=""):
67
  self.ds_key = str(ds_key or "").strip()
 
3459
  core = 'Use formal academic Simplified Chinese with explicit concepts, natural Chinese syntax, clear hierarchy, and logically connected claims. Avoid carrying English nominalization and clause order into Chinese mechanically.'
3460
  return core + ' ' + profile['term_policy'] + ' ' + profile['contamination_policy']
3461
 
3462
+ @classmethod
3463
+ def _russian_contamination_evidence(cls, source, target, glossary=None):
3464
+ """Deterministically confirm only sustained, unprotected Latin prose in Russian.
3465
+
3466
+ LLM QA may propose LANGUAGE_CONTAMINATION, but a fatal/repair signal is
3467
+ accepted only when this evidence check finds a multi-word Latin clause
3468
+ after removing immutable research objects and explicit glossary terms.
3469
+ This prevents AI/LLM/ChatGPT/SEM/model names from becoming lexical
3470
+ blacklists while preserving fail-closed behavior for real English prose.
3471
+ """
3472
+ source_value = str(source or '')
3473
+ text = cls._strip_translation_claim_markers(str(target or ''))
3474
+ # A verbatim quoted span is protected evidence only when the same quote
3475
+ # exists in the locked English source. Invented/untranslated English
3476
+ # quotes are not silently exempted. Research-object protection also
3477
+ # removes acronyms, model/tool names, citations, URLs, DOI strings,
3478
+ # statistics and numeric anchors.
3479
+ quote_pattern = re.compile(r'“[^”\n]{1,500}”|"[^"\n]{1,500}"')
3480
+ text = quote_pattern.sub(lambda match: ' ' if match.group(0) in source_value else match.group(0), text)
3481
+ text = cls._translation_protected_pattern().sub(' ', text)
3482
+ text = re.sub(r'\[\[SP-(?:P\d{6}|C\d{6}|T\d{6})\]\]', ' ', text)
3483
+
3484
+ # A glossary target may legitimately remain Latin (for example a
3485
+ # standardized construct/tool name). Remove only exact glossary targets;
3486
+ # do not whitelist arbitrary English source prose.
3487
+ glossary_terms = []
3488
+ english_function_words = {
3489
+ 'a','an','and','as','at','by','for','from','in','of','on','or','the','to','with',
3490
+ }
3491
+ for value in dict(glossary or {}).values():
3492
+ term = str(value or '').strip()
3493
+ words = re.findall(r'[A-Za-z][A-Za-z0-9+.#-]*', term)
3494
+ title_like = bool(words) and len(words) <= 5 and all(
3495
+ word.casefold() in english_function_words or word[:1].isupper() for word in words
3496
+ )
3497
+ technical_shape = bool(words) and any(
3498
+ any(ch.isdigit() for ch in word)
3499
+ or word.isupper()
3500
+ or (len(word) > 2 and any(ch.isupper() for ch in word[1:]))
3501
+ for word in words
3502
+ )
3503
+ if term and re.search(r'[A-Za-z]', term) and (title_like or technical_shape):
3504
+ glossary_terms.append(term)
3505
+ for term in sorted(set(glossary_terms), key=len, reverse=True):
3506
+ text = re.sub(re.escape(term), ' ', text, flags=re.I)
3507
+
3508
+ # Mixed-case technical names such as OpenAI/TensorFlow are not covered by
3509
+ # the all-caps acronym rule. Allow them only when they are present in the
3510
+ # locked English source; ordinary source prose is never whitelisted.
3511
+ source_tokens = set(re.findall(r'\b[A-Za-z][A-Za-z0-9+.#-]*\b', str(source or '')))
3512
+ technical = {
3513
+ token for token in source_tokens
3514
+ if any(ch.isdigit() for ch in token)
3515
+ or (len(token) > 2 and any(ch.isupper() for ch in token[1:]))
3516
+ }
3517
+ for token in sorted(technical, key=len, reverse=True):
3518
+ text = re.sub(rf'(?<![A-Za-z0-9_]){re.escape(token)}(?![A-Za-z0-9_])', ' ', text)
3519
+
3520
+ latin = list(re.finditer(r"[A-Za-z][A-Za-z'’-]*", text))
3521
+ allowed_gap = re.compile(r"^[\s,;:()\[\]{}.!?\"'“”‘’—–/\\-]*$")
3522
+ function_words = {
3523
+ 'a','an','and','are','as','at','be','been','being','by','for','from','had','has','have',
3524
+ 'in','into','is','it','of','on','or','that','the','these','this','those','to','was','were',
3525
+ 'which','with','within','without','would','could','should','than','then','their','its',
3526
+ }
3527
+ max_run = 0
3528
+ max_run_has_function = False
3529
+ run = []
3530
+ previous_end = None
3531
+ normalized_source = re.sub(r'\s+', ' ', source_value).casefold()
3532
+
3533
+ def evaluate(items):
3534
+ nonlocal max_run, max_run_has_function
3535
+ if not items:
3536
+ return False
3537
+ original_words = [item.group(0) for item in items]
3538
+ words = [word.casefold() for word in original_words]
3539
+ has_function = any(word in function_words for word in words)
3540
+ if len(words) > max_run:
3541
+ max_run = len(words)
3542
+ max_run_has_function = has_function
3543
+ # Preserve a bounded source-matched proper name/title such as
3544
+ # "University of California at Berkeley". Ordinary copied English
3545
+ # prose is not title-like and therefore remains detectable.
3546
+ title_like = len(words) <= 6 and all(
3547
+ word in function_words or original[:1].isupper()
3548
+ for word, original in zip(words, original_words)
3549
+ )
3550
+ candidate = ' '.join(original_words).casefold()
3551
+ if title_like and candidate and candidate in normalized_source:
3552
+ return False
3553
+ # Four-word clause with an English function word is strong evidence;
3554
+ # seven Latin words is strong even without one. Short titles/terms
3555
+ # and isolated Latin research objects remain legal.
3556
+ return (len(words) >= 4 and has_function) or len(words) >= 7
3557
+
3558
+ confirmed = False
3559
+ for match in latin:
3560
+ if previous_end is None:
3561
+ run = [match]
3562
+ else:
3563
+ gap = text[previous_end:match.start()]
3564
+ if allowed_gap.fullmatch(gap or ''):
3565
+ run.append(match)
3566
+ else:
3567
+ confirmed = confirmed or evaluate(run)
3568
+ run = [match]
3569
+ previous_end = match.end()
3570
+ confirmed = confirmed or evaluate(run)
3571
+ return {
3572
+ 'confirmed': bool(confirmed),
3573
+ 'max_latin_run_words': int(max_run),
3574
+ 'max_run_has_function_word': bool(max_run_has_function),
3575
+ 'glossary_latin_terms': int(len(set(glossary_terms))),
3576
+ 'source_technical_tokens': int(len(technical)),
3577
+ }
3578
+
3579
  @classmethod
3580
  def _compile_translation_ir(cls, source):
3581
  """Compile a private, language-neutral translation intermediate representation."""
 
4419
  system_prompt = f"""You are a bilingual academic translation QA classifier for {target_lang}.
4420
  Review EACH claim independently. Do not rewrite and do not provide free-form explanations. Check semantic equivalence and language quality: omissions/additions, negation, uncertainty, association-versus-causation strength, terminology, grammar, language contamination, citation attachment, and acronym attachment.
4421
  LANGUAGE ADAPTER: {profile['adapter_id']}; morphology={profile['morphology']}. {profile['term_policy']} {profile['contamination_policy']}
4422
+ For Russian, do not flag a terminology error merely because a translated term is inflected for grammatical case/number/gender. For Russian LANGUAGE_CONTAMINATION, require sustained unprotected non-Russian prose such as a multi-word English clause or sentence. Never use LANGUAGE_CONTAMINATION merely for protected acronyms, AI/LLM/SEM, model or tool names, variable names, citations, glossary terms, source-matched proper names/titles, or isolated Latin terminology. For Kazakh, do not flag a terminology error merely because productive suffixes attach to the correct term stem. For Kazakh language identity, absence of Kazakh-specific letters in one short claim is not sufficient evidence of Russian contamination.
4423
  Return valid JSON only in this exact form: {{"items":[{{"id":"SP-P000001-C000001","ok":true,"codes":[]}}]}}.
4424
  If ok=false, codes MUST contain only values from allowed_issue_codes. Never return numbered prose, examples, quotes, or corrective wording. Return every supplied id exactly once."""
4425
  response = self.call_deepseek(
 
4436
  f'Translation claim QA id mismatch: expected={expected_row_ids}, got={sorted(by_id)}.'
4437
  )
4438
  row_lookup = {row['id']: row for row in rows}
4439
+ language_code = profile.get('language_code')
4440
  for row_id in expected_row_ids:
4441
  returned_row = by_id[row_id]
4442
+ meta = row_lookup[row_id]
 
4443
  raw_codes = [str(code or '').strip().upper() for code in (returned_row.get('codes') or [])]
4444
  codes = [code for code in raw_codes if code in allowed_codes]
4445
+ model_contamination = 'LANGUAGE_CONTAMINATION' in codes
4446
+ contamination = None
4447
+ if language_code == 'ru':
4448
+ contamination = self._russian_contamination_evidence(
4449
+ meta['english'], meta['target'], relevant_glossary
4450
+ )
4451
+ if contamination.get('confirmed'):
4452
+ if 'LANGUAGE_CONTAMINATION' not in codes:
4453
+ codes.append('LANGUAGE_CONTAMINATION')
4454
+ elif model_contamination:
4455
+ codes = [code for code in codes if code != 'LANGUAGE_CONTAMINATION']
4456
+ self._record_translation_assurance(
4457
+ 'RU_LANGUAGE_CONTAMINATION_UNCONFIRMED', target_lang=target_lang, block_id=row_id,
4458
+ severity='diagnostic', decision='PASS_WITH_WARNING', detail=contamination,
4459
+ )
4460
+
4461
+ # A deterministic confirmed Russian contamination signal can
4462
+ # override an LLM false negative. Conversely, a sole Russian
4463
+ # contamination code without deterministic evidence is suppressed.
4464
+ if returned_row.get('ok') is True and not (contamination and contamination.get('confirmed')):
4465
+ continue
4466
  if not codes:
4467
+ only_suppressed_contamination = (
4468
+ language_code == 'ru' and model_contamination
4469
+ and not (contamination or {}).get('confirmed')
4470
+ and bool(raw_codes) and all(code == 'LANGUAGE_CONTAMINATION' for code in raw_codes)
4471
+ )
4472
+ if only_suppressed_contamination:
4473
+ continue
4474
  codes = ['SEMANTIC_DRIFT']
 
4475
  issues.setdefault(meta['block_id'], {})[meta['claim_id']] = list(dict.fromkeys(codes))[:5]
4476
  return issues
4477
 
 
4829
  )
4830
  return {}
4831
 
4832
+ def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2, stage_callback=None):
4833
  """Translate one frozen English unit and publish only after all QA passes."""
4834
  value = str(section_text or '').strip()
4835
  if not value:
 
4860
  )
4861
  attempts = max(1, int(max_attempts or 1))
4862
  last_error = None
4863
+
4864
+ def emit_stage(stage):
4865
+ if not callable(stage_callback):
4866
+ return
4867
+ try:
4868
+ stage_callback(str(stage or ''))
4869
+ except Exception as error:
4870
+ logging.warning(
4871
+ 'Translation stage callback ignored: section=%s stage=%s error=%s',
4872
+ section_name or 'section', stage, type(error).__name__,
4873
+ )
4874
+
4875
  for attempt in range(1, attempts + 1):
4876
  self._check_cancelled()
4877
+ emit_stage('translating')
4878
  translations = {}
4879
  try:
4880
  for batch in batches:
 
4884
  for batch in batches
4885
  for block_id, source in batch
4886
  }
4887
+ emit_stage('qa')
4888
  quality_issues = self._audit_translation_blocks_with_retry(
4889
  source_blocks, translations, target_lang, frozen_glossary,
4890
  section_name=section_name or 'frozen section', max_attempts=2,
 
4900
  logging.warning(
4901
  'Translation claim-local QA recovery: section=%s round=%s failed=%s',
4902
  section_name or 'section', local_round,
4903
+ [
4904
+ {'id': f'SP-P{block_id}-{claim_id}', 'codes': list(codes)}
4905
+ for block_id, claim_id, codes in failed_pairs
4906
+ ],
4907
  )
4908
+ emit_stage('repairing')
4909
  touched_blocks = set()
4910
  for block_id, claim_id, codes in failed_pairs:
4911
  source_claims = {
 
4922
  )
4923
  translations[block_id] = self._compose_translation_claim_segments(claim_order, segments)
4924
  touched_blocks.add(block_id)
4925
+ emit_stage('qa')
4926
  quality_issues = self._audit_translation_blocks_with_retry(
4927
  {block_id: source_blocks[block_id] for block_id in sorted(touched_blocks)},
4928
  {block_id: translations[block_id] for block_id in sorted(touched_blocks)},
smartpaper_restore_bridge/frontend/index.html CHANGED
@@ -335,10 +335,33 @@
335
  target.textContent = value;
336
  }
337
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  function updateTargetPreparation(parentDocument, payload, labels) {
339
  const target = parentDocument.getElementById("sp-live-translation-preview");
340
  const channel = renderChannels.target;
341
- if (!target || channel.authoritative || target.querySelector("[data-sp-live-stream='1']")) return;
342
  const map = labels && typeof labels === "object" ? labels : {};
343
  const stage = String(payload && payload.target_stage || "");
344
  let value = String(map.waiting || "");
@@ -346,12 +369,23 @@
346
  value = formatTemplate(map.translating || value, {name:stage.slice("translating:".length)});
347
  } else if (stage.startsWith("translation_qa:")) {
348
  value = formatTemplate(map.qa || value, {name:stage.slice("translation_qa:".length)});
 
 
349
  } else if (stage.startsWith("translation_ready:")) {
350
  value = formatTemplate(map.ready || value, {name:stage.slice("translation_ready:".length)});
351
  } else if (stage === "translation_complete") {
352
  value = String(map.complete || value);
353
  }
354
  if (!value) return;
 
 
 
 
 
 
 
 
 
355
  target.dataset.spPlaceholder = "1";
356
  target.textContent = value;
357
  }
@@ -512,7 +546,7 @@
512
  updateOnePreview(parentDocument, parentWindow, "sp-live-translation-preview", "sp-live-translation-shell", targetText, renderChannels.target);
513
  lastTargetPreviewText = targetText;
514
  }
515
- if (!targetText) updateTargetPreparation(parentDocument, payload, watch && watch.targetStatusLabels);
516
  }
517
 
518
  async function fetchPreview(watch) {
 
335
  target.textContent = value;
336
  }
337
 
338
+ function ensureTargetStatusNode(parentDocument) {
339
+ const shell = parentDocument.getElementById("sp-live-translation-shell");
340
+ const target = parentDocument.getElementById("sp-live-translation-preview");
341
+ if (!shell || !target) return null;
342
+ let status = parentDocument.getElementById("sp-live-translation-status");
343
+ if (!status) {
344
+ status = parentDocument.createElement("div");
345
+ status.id = "sp-live-translation-status";
346
+ status.style.whiteSpace = "pre-wrap";
347
+ status.style.fontSize = "0.88em";
348
+ status.style.opacity = "0.72";
349
+ status.style.marginBottom = "8px";
350
+ status.style.paddingBottom = "4px";
351
+ status.style.position = "sticky";
352
+ status.style.top = "0";
353
+ status.style.zIndex = "1";
354
+ status.style.background = "#ffffff";
355
+ status.style.display = "none";
356
+ shell.insertBefore(status, target);
357
+ }
358
+ return status;
359
+ }
360
+
361
  function updateTargetPreparation(parentDocument, payload, labels) {
362
  const target = parentDocument.getElementById("sp-live-translation-preview");
363
  const channel = renderChannels.target;
364
+ if (!target) return;
365
  const map = labels && typeof labels === "object" ? labels : {};
366
  const stage = String(payload && payload.target_stage || "");
367
  let value = String(map.waiting || "");
 
369
  value = formatTemplate(map.translating || value, {name:stage.slice("translating:".length)});
370
  } else if (stage.startsWith("translation_qa:")) {
371
  value = formatTemplate(map.qa || value, {name:stage.slice("translation_qa:".length)});
372
+ } else if (stage.startsWith("translation_repair_pending:")) {
373
+ value = formatTemplate(map.qa || value, {name:stage.slice("translation_repair_pending:".length)});
374
  } else if (stage.startsWith("translation_ready:")) {
375
  value = formatTemplate(map.ready || value, {name:stage.slice("translation_ready:".length)});
376
  } else if (stage === "translation_complete") {
377
  value = String(map.complete || value);
378
  }
379
  if (!value) return;
380
+ const hasTargetText = Boolean(String(payload && payload.target_text || "").trim()) || Boolean(channel.authoritative);
381
+ if (hasTargetText) {
382
+ const status = ensureTargetStatusNode(parentDocument);
383
+ if (!status) return;
384
+ status.textContent = value;
385
+ status.style.display = "block";
386
+ return;
387
+ }
388
+ if (target.querySelector("[data-sp-live-stream='1']")) return;
389
  target.dataset.spPlaceholder = "1";
390
  target.textContent = value;
391
  }
 
546
  updateOnePreview(parentDocument, parentWindow, "sp-live-translation-preview", "sp-live-translation-shell", targetText, renderChannels.target);
547
  lastTargetPreviewText = targetText;
548
  }
549
+ updateTargetPreparation(parentDocument, payload, watch && watch.targetStatusLabels);
550
  }
551
 
552
  async function fetchPreview(watch) {