Step 4A-6-C-R3.2.8.1 Translation Protected Recovery Fix

#19
Files changed (1) hide show
  1. core/pipeline_agent.py +103 -105
core/pipeline_agent.py CHANGED
@@ -2397,19 +2397,21 @@ SECTION: {section_name}
2397
  ]
2398
 
2399
  @staticmethod
2400
- def _numeric_tokens(value):
 
 
 
 
 
 
 
2401
  without_citation_tags = re.sub(
2402
  r"\[?CITE-\d+\]?",
2403
  "",
2404
  str(value),
2405
  flags=re.IGNORECASE,
2406
  )
2407
- return Counter(
2408
- re.findall(
2409
- r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?",
2410
- without_citation_tags,
2411
- )
2412
- )
2413
 
2414
  @staticmethod
2415
  def _quoted_passages(value):
@@ -3210,10 +3212,17 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3210
  return Counter(re.findall(r'\bH\d+[A-Za-z]?\b', str(value or ''), flags=re.I))
3211
 
3212
  @staticmethod
3213
- def _translation_acronym_tokens(value):
3214
- """Protect exact uppercase abbreviations, including plural suffixes."""
 
 
 
 
 
 
 
3215
  without_doi = re.sub(r'https?://\S+|doi:\s*\S+', ' ', str(value or ''), flags=re.I)
3216
- return Counter(re.findall(r'\b[A-Z][A-Z0-9-]{1,11}(?:s|es)?\b', without_doi))
3217
 
3218
  @staticmethod
3219
  def _translation_citation_tokens(value):
@@ -3225,11 +3234,13 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
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)>]+)'
@@ -3241,10 +3252,10 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
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
 
@@ -3288,7 +3299,7 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
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)
@@ -3310,7 +3321,7 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
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
@@ -3477,10 +3488,10 @@ Return valid JSON only: {{"items":[{{"id":"SP-P000001","ok":true,"issues":[]}}]}
3477
  term_limit = max(10, int(APP_CONFIG.get('translation_glossary_term_limit', 40)))
3478
  recurring = [term for term, count in phrase_counts.most_common() if count >= 2]
3479
  unique_headings = list(dict.fromkeys(headings))
3480
- extras = [term for term in list(dict.fromkeys(recurring + acronyms)) if term not in unique_headings]
3481
  sources = unique_headings + extras[:max(0, term_limit - len(unique_headings))]
3482
  if not sources:
3483
- return {}
3484
 
3485
  language_rule = self._translation_language_rule(target_lang)
3486
  payload = json.dumps({'terms': sources}, ensure_ascii=False)
@@ -3509,13 +3520,16 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
3509
  'Translation glossary omitted heading(s): ' + ', '.join(missing_headings[:5])
3510
  )
3511
  for acronym in acronyms:
3512
- mapping.setdefault(acronym, acronym)
3513
  return mapping
3514
 
3515
  @staticmethod
3516
  def _parse_marked_translation(value, expected_ids):
3517
  pattern = re.compile(r'(?m)^\[\[SP-P(\d{6})\]\]\s*$')
3518
- matches = list(pattern.finditer(str(value or '')))
 
 
 
3519
  found_ids = [match.group(1) for match in matches]
3520
  if found_ids != list(expected_ids):
3521
  raise DeepSeekRequestError(
@@ -3568,68 +3582,11 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
3568
  if not is_chinese and not re.search(r'[А-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]', translated):
3569
  raise DeepSeekRequestError(f'Translation block {block_id} contains no Cyrillic target-language text.')
3570
 
3571
- def _retry_translation_block_for_abbreviations(self, block_id, source, system_prompt, target_lang):
3572
- protected_counter = self._translation_acronym_tokens(source)
3573
- protected = list(protected_counter.elements())
3574
- strict_prompt = (
3575
- system_prompt
3576
- + '\nCRITICAL ABBREVIATION PRESERVATION: Preserve every token in this JSON list '
3577
- 'exactly, including capitalization and plural suffix, and preserve its exact count. '
3578
- 'Do not normalize plural abbreviations (for example, LLMs must not become LLM).\n'
3579
- + json.dumps(protected, ensure_ascii=False)
3580
- )
3581
- marked_source = f'[[SP-P{block_id}]]\n{source}'
3582
- result = self.call_deepseek(
3583
- strict_prompt,
3584
- marked_source,
3585
- max_tokens=max(1800, min(9000, self._count_tokens(marked_source) * 2 + 1200)),
3586
- temp=0.0,
3587
- model_role='translation',
3588
- thinking=False,
3589
- allow_fallback=False,
3590
- )
3591
- result = self._remove_ai_meta_traces(result)
3592
- try:
3593
- translated = self._parse_marked_translation(result, [block_id])[block_id]
3594
- except DeepSeekRequestError as error:
3595
- if not str(error).startswith('Translation marker mismatch:'):
3596
- raise
3597
- unexpected_markers = re.findall(
3598
- r'(?m)^\[\[SP-P(\d{6})\]\]\s*$',
3599
- str(result or ''),
3600
- )
3601
- if unexpected_markers:
3602
- raise DeepSeekRequestError(
3603
- 'Abbreviation retry returned an unexpected marker: '
3604
- f'expected={block_id}, got={unexpected_markers}.'
3605
- ) from error
3606
- translated = str(result or '').strip()
3607
- if not translated:
3608
- raise DeepSeekRequestError(
3609
- f'Abbreviation retry returned empty text for SP-P{block_id}.'
3610
- ) from error
3611
  self._validate_translation_block(source, translated, f'SP-P{block_id}', target_lang)
3612
- logging.info(
3613
- 'Translation abbreviation recovery accepted block: SP-P%s protected=%s',
3614
- block_id, protected,
3615
- )
3616
  return translated
3617
 
3618
- def _validate_translation_block_with_abbreviation_recovery(self, block_id, source, translated, system_prompt, target_lang):
3619
- try:
3620
- self._validate_translation_block(source, translated, f'SP-P{block_id}', target_lang)
3621
- return translated
3622
- except DeepSeekRequestError as error:
3623
- if not str(error).startswith('Translation changed variable abbreviations in SP-P'):
3624
- raise
3625
- logging.warning(
3626
- 'Translation abbreviation recovery: block=SP-P%s error=%s',
3627
- block_id, str(error),
3628
- )
3629
- return self._retry_translation_block_for_abbreviations(
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
 
@@ -3660,8 +3617,9 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
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
-
 
3665
  try:
3666
  parsed = self._parse_marked_translation(result, expected_ids)
3667
  except DeepSeekRequestError as error:
@@ -3701,30 +3659,59 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
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()
@@ -3765,9 +3752,9 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
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:
@@ -3789,10 +3776,10 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
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(
@@ -3829,7 +3816,18 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
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',
 
2397
  ]
2398
 
2399
  @staticmethod
2400
+ def _translation_numeric_regex():
2401
+ # Canonical immutable numeric token used by both translation protection
2402
+ # and deterministic validation. Covers leading decimals (e.g. .05, -.12),
2403
+ # grouped numbers, percentages, and scientific notation.
2404
+ return r"(?<![A-Za-z])[-+]?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?%?"
2405
+
2406
+ @classmethod
2407
+ def _numeric_tokens(cls, value):
2408
  without_citation_tags = re.sub(
2409
  r"\[?CITE-\d+\]?",
2410
  "",
2411
  str(value),
2412
  flags=re.IGNORECASE,
2413
  )
2414
+ return Counter(re.findall(cls._translation_numeric_regex(), without_citation_tags))
 
 
 
 
 
2415
 
2416
  @staticmethod
2417
  def _quoted_passages(value):
 
3212
  return Counter(re.findall(r'\bH\d+[A-Za-z]?\b', str(value or ''), flags=re.I))
3213
 
3214
  @staticmethod
3215
+ def _translation_acronym_regex():
3216
+ # Internal technical hyphens are allowed (GPT-4, PLS-SEM, COVID-19),
3217
+ # but a grammatical hyphen before lowercase prose is not part of the
3218
+ # acronym (AI-powered -> AI; LLM-assisted -> LLM).
3219
+ return r'\b[A-Z]{2,}[A-Z0-9]*(?:-[A-Z0-9]+)*(?:s|es)?\b'
3220
+
3221
+ @classmethod
3222
+ def _translation_acronym_tokens(cls, value):
3223
+ """Return exact immutable uppercase abbreviations, including plurals."""
3224
  without_doi = re.sub(r'https?://\S+|doi:\s*\S+', ' ', str(value or ''), flags=re.I)
3225
+ return Counter(re.findall(cls._translation_acronym_regex(), without_doi))
3226
 
3227
  @staticmethod
3228
  def _translation_citation_tokens(value):
 
3234
  )
3235
  )
3236
 
3237
+ @classmethod
3238
+ def _translation_protected_pattern(cls):
3239
  # One-pass alternation prevents a placeholder created for a citation or
3240
+ # URL from being matched again as an acronym/number. Keep this list
3241
  # conservative: protect immutable research objects, not prose.
3242
+ acronym = cls._translation_acronym_regex()
3243
+ numeric = cls._translation_numeric_regex()
3244
  return re.compile(
3245
  r'(?i:https?://[^\s)>]+)'
3246
  r'|(?i:\bdoi:\s*[^\s)>]+)'
 
3252
  r'|\$[^$\n]+\$'
3253
  r'|(?i:\bH\d+[A-Za-z]?\b)'
3254
  r'|\b(?:fsQCA|SmartPLS|Mplus|OpenAlex|DeepSeek|ChatGPT|RStudio|SPSS|AMOS|Stata)\b'
3255
+ rf'|(?:{acronym})'
3256
  r'|(?:χ²|R²|ΔR²|η²|ω²|[αβγδΔχλμρστφηωΩ])'
3257
  r'|\b(?:QUOTE|CASE|RESP|P)-\d+\b'
3258
+ rf'|(?:{numeric})'
3259
  r'|\|'
3260
  )
3261
 
 
3299
  phrases = re.findall(r'\b(?:[A-Z][a-z]+(?:[- ][A-Z][a-z]+){1,3})\b', str(body or ''))
3300
  recurring = [term for term, count in Counter(phrases).most_common() if count >= 2]
3301
  term_limit = max(10, int(APP_CONFIG.get('translation_glossary_term_limit', 40)))
3302
+ sources = list(dict.fromkeys(headings + recurring))[:term_limit]
3303
  missing = [term for term in sources if not str(mapping.get(term) or '').strip()]
3304
  if missing:
3305
  language_rule = self._translation_language_rule(target_lang)
 
3321
  for term in missing:
3322
  mapping.setdefault(term, new_map[term])
3323
  for acronym in acronyms:
3324
+ mapping[acronym] = acronym
3325
  return mapping
3326
 
3327
  @staticmethod
 
3488
  term_limit = max(10, int(APP_CONFIG.get('translation_glossary_term_limit', 40)))
3489
  recurring = [term for term, count in phrase_counts.most_common() if count >= 2]
3490
  unique_headings = list(dict.fromkeys(headings))
3491
+ extras = [term for term in list(dict.fromkeys(recurring)) if term not in unique_headings]
3492
  sources = unique_headings + extras[:max(0, term_limit - len(unique_headings))]
3493
  if not sources:
3494
+ return {acronym: acronym for acronym in acronyms}
3495
 
3496
  language_rule = self._translation_language_rule(target_lang)
3497
  payload = json.dumps({'terms': sources}, ensure_ascii=False)
 
3520
  'Translation glossary omitted heading(s): ' + ', '.join(missing_headings[:5])
3521
  )
3522
  for acronym in acronyms:
3523
+ mapping[acronym] = acronym
3524
  return mapping
3525
 
3526
  @staticmethod
3527
  def _parse_marked_translation(value, expected_ids):
3528
  pattern = re.compile(r'(?m)^\[\[SP-P(\d{6})\]\]\s*$')
3529
+ raw_value = str(value or '')
3530
+ matches = list(pattern.finditer(raw_value))
3531
+ if matches and raw_value[:matches[0].start()].strip():
3532
+ raise DeepSeekRequestError('Translation output contained unexpected text before the first SP-P marker.')
3533
  found_ids = [match.group(1) for match in matches]
3534
  if found_ids != list(expected_ids):
3535
  raise DeepSeekRequestError(
 
3582
  if not is_chinese and not re.search(r'[А-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]', translated):
3583
  raise DeepSeekRequestError(f'Translation block {block_id} contains no Cyrillic target-language text.')
3584
 
3585
+ def _validate_translation_block_protected(self, block_id, source, translated, target_lang):
3586
+ """Validate one restored block; recovery is owned by the protected gateway."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3587
  self._validate_translation_block(source, translated, f'SP-P{block_id}', target_lang)
 
 
 
 
3588
  return translated
3589
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3590
  def _translate_marked_batch_with_recovery(self, batch, system_prompt, target_lang, _strict=False):
3591
  """Translate one marked batch with protected tokens and bounded recovery.
3592
 
 
3617
  max_tokens=max(1800, min(9000, self._count_tokens(marked_source) * 2 + 1200)),
3618
  temp=0.0, model_role='translation', thinking=False, allow_fallback=False,
3619
  )
3620
+ # Translation is non-destructive: never run generic manuscript cleanup
3621
+ # on target-language text. Marker/placeholder parsing rejects malformed
3622
+ # output instead of deleting potentially legitimate manuscript lines.
3623
  try:
3624
  parsed = self._parse_marked_translation(result, expected_ids)
3625
  except DeepSeekRequestError as error:
 
3659
  logging.info('Translation marker recovery accepted marker-free single block: SP-P%s', block_id)
3660
 
3661
  restored = {}
3662
+ failed = {}
3663
+ for block_id, source in batch:
3664
+ try:
3665
  translated = self._restore_translation_tokens(
3666
  parsed[block_id], protected_maps.get(block_id, {}), f'SP-P{block_id}'
3667
  )
3668
+ restored[block_id] = self._validate_translation_block_protected(
3669
+ block_id, source, translated, target_lang
3670
  )
3671
+ except DeepSeekRequestError as error:
3672
+ failed[block_id] = error
3673
+
3674
+ if failed:
3675
+ if _strict:
3676
+ # A strict isolated retry already used the protected gateway.
3677
+ # Failing again is fail-closed; never fall back to raw source.
3678
+ first_id = next(iter(failed))
3679
+ raise failed[first_id]
3680
+ source_by_id = {block_id: source for block_id, source in batch}
3681
+ for block_id, error in failed.items():
3682
+ logging.warning(
3683
+ 'Translation protected block recovery: block=SP-P%s error=%s; strict isolated retry.',
3684
+ block_id, self._safe_source_error(error),
3685
+ )
3686
+ recovered = self._translate_marked_batch_with_recovery(
3687
+ [(block_id, source_by_id[block_id])], system_prompt, target_lang, _strict=True
3688
+ )
3689
+ restored[block_id] = recovered[block_id]
3690
  return restored
3691
 
3692
+ def _audit_translation_blocks_with_retry(
3693
+ self, sources, translations, target_lang, glossary=None, section_name='', max_attempts=2
3694
+ ):
3695
+ """Retry QA transport/JSON failures without retranslating any block."""
3696
+ attempts = max(1, int(max_attempts or 1))
3697
+ last_error = None
3698
+ for attempt in range(1, attempts + 1):
3699
+ try:
3700
+ return self.audit_translation_blocks(
3701
+ sources, translations, target_lang, glossary, section_name=section_name
3702
+ )
3703
+ except PipelineCancelledError:
3704
+ raise
3705
+ except Exception as error:
3706
+ last_error = error
3707
+ if attempt >= attempts:
3708
+ raise
3709
+ logging.warning(
3710
+ 'Translation QA-only retry: section=%s attempt=%s/%s error=%s',
3711
+ section_name or 'section', attempt, attempts, self._safe_source_error(error),
3712
+ )
3713
+ raise last_error or DeepSeekRequestError('Translation QA transport failed.')
3714
+
3715
  def translate_frozen_section(self, section_text, target_lang, glossary=None, section_name='', max_attempts=2):
3716
  """Translate one frozen English unit and publish only after all QA passes."""
3717
  value = str(section_text or '').strip()
 
3752
  for batch in batches
3753
  for block_id, source in batch
3754
  }
3755
+ quality_issues = self._audit_translation_blocks_with_retry(
3756
  source_blocks, translations, target_lang, frozen_glossary,
3757
+ section_name=section_name or 'frozen section', max_attempts=2,
3758
  )
3759
  local_round = 0
3760
  while quality_issues and local_round < 2:
 
3776
  [(block_id, source_blocks[block_id])], quality_prompt, target_lang, _strict=True
3777
  )
3778
  translations[block_id] = retry[block_id]
3779
+ quality_issues = self._audit_translation_blocks_with_retry(
3780
  {block_id: source_blocks[block_id] for block_id in failed_ids},
3781
  {block_id: translations[block_id] for block_id in failed_ids},
3782
+ target_lang, frozen_glossary, section_name=section_name or 'frozen section', max_attempts=2,
3783
  )
3784
  if quality_issues:
3785
  detail = '; '.join(
 
3816
  raise
3817
  except Exception as error:
3818
  last_error = error
3819
+ message = str(error)
3820
+ if (
3821
+ message.startswith('Translation block-local QA failed')
3822
+ or message.startswith('Translation alignment')
3823
+ or message.startswith('Translation changed ')
3824
+ or message.startswith('Translation protected ')
3825
+ or message.startswith('Translation coverage ratio')
3826
+ or message.startswith('Translation block ')
3827
+ or message.startswith('Chinese translation quality validation failed')
3828
+ or message.startswith('Kazakh translation quality validation failed')
3829
+ or message.startswith('Russian translation quality validation failed')
3830
+ ):
3831
  raise
3832
  logging.warning(
3833
  'Translation section technical retry: section=%s attempt=%s/%s error=%s',