Spaces:
Running
Running
Step 4A-6-C-R3.2.8.1 Translation Protected Recovery Fix
#19
by GuXiaopeng - opened
- core/pipeline_agent.py +103 -105
core/pipeline_agent.py
CHANGED
|
@@ -2397,19 +2397,21 @@ SECTION: {section_name}
|
|
| 2397 |
]
|
| 2398 |
|
| 2399 |
@staticmethod
|
| 2400 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 3214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3215 |
without_doi = re.sub(r'https?://\S+|doi:\s*\S+', ' ', str(value or ''), flags=re.I)
|
| 3216 |
-
return Counter(re.findall(
|
| 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 |
-
@
|
| 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.
|
| 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 |
-
|
| 3245 |
r'|(?:χ²|R²|ΔR²|η²|ω²|[αβγδΔχλμρστφηωΩ])'
|
| 3246 |
r'|\b(?:QUOTE|CASE|RESP|P)-\d+\b'
|
| 3247 |
-
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 3572 |
-
|
| 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 |
-
|
| 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 |
-
|
| 3705 |
-
|
|
|
|
| 3706 |
translated = self._restore_translation_tokens(
|
| 3707 |
parsed[block_id], protected_maps.get(block_id, {}), f'SP-P{block_id}'
|
| 3708 |
)
|
| 3709 |
-
|
| 3710 |
-
block_id, source, translated,
|
| 3711 |
)
|
| 3712 |
-
|
| 3713 |
-
|
| 3714 |
-
|
| 3715 |
-
|
| 3716 |
-
|
| 3717 |
-
|
| 3718 |
-
|
| 3719 |
-
|
| 3720 |
-
|
| 3721 |
-
|
| 3722 |
-
|
| 3723 |
-
logging.warning(
|
| 3724 |
-
|
| 3725 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|