Spaces:
Sleeping
Sleeping
Commit ·
712ea36
1
Parent(s): a612ae0
P3-A5-R2 adaptive recovery integrity correction (#154)
Browse files- P3-A5-R2 adaptive recovery integrity correction (49b0ef3ede32183047f4c44967bebd685ce88505)
- core/generation_runner.py +479 -16
core/generation_runner.py
CHANGED
|
@@ -3688,6 +3688,348 @@ class GenerationRunner:
|
|
| 3688 |
)
|
| 3689 |
return updated_sections, updated_glossary
|
| 3690 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3691 |
def _translate_qualitative_appendix_unit(
|
| 3692 |
self, agent, qual_engine, *, unit_key: str, source_text: str, target_lang: str,
|
| 3693 |
translation_sections: dict, translation_glossary: dict, data_metadata: dict,
|
|
@@ -3721,12 +4063,17 @@ class GenerationRunner:
|
|
| 3721 |
qual_engine.validate_bound_target_appendix(existing['translated_text'], manifest, target_lang)
|
| 3722 |
return sections, translation_glossary
|
| 3723 |
|
| 3724 |
-
|
| 3725 |
-
|
| 3726 |
-
|
| 3727 |
-
|
| 3728 |
-
|
| 3729 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3730 |
|
| 3731 |
translate_cells = getattr(agent, 'translate_qualitative_appendix_cells', None)
|
| 3732 |
if not callable(translate_cells):
|
|
@@ -3735,14 +4082,82 @@ class GenerationRunner:
|
|
| 3735 |
prebound_cells, model_cells = qual_engine.appendix_translation_plan(
|
| 3736 |
manifest, translation_glossary
|
| 3737 |
)
|
|
|
|
| 3738 |
if model_cells:
|
| 3739 |
-
|
| 3740 |
-
|
| 3741 |
-
|
| 3742 |
-
|
| 3743 |
-
|
| 3744 |
-
|
| 3745 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3746 |
else:
|
| 3747 |
result = {
|
| 3748 |
'qa_status': 'passed', 'attempts': 1, 'claim_states': {},
|
|
@@ -3802,6 +4217,8 @@ class GenerationRunner:
|
|
| 3802 |
'appendix_cell_contract_version': str((result or {}).get('appendix_cell_contract_version') or ''),
|
| 3803 |
'appendix_cell_claim_states': dict((result or {}).get('claim_states') or {}),
|
| 3804 |
'appendix_prebound_label_count': len(prebound_cells),
|
|
|
|
|
|
|
| 3805 |
'updated_at': datetime.now(timezone.utc).isoformat(),
|
| 3806 |
}
|
| 3807 |
return sections, updated_glossary
|
|
@@ -7182,12 +7599,31 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 7182 |
f'translation_{unit_key}_repair_pending', state, pct,
|
| 7183 |
display_node=f'translation_repair_pending:{index}:{len(final_units)}',
|
| 7184 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7185 |
# A2: strict claim QA is diagnostic/recovery authority, not a
|
| 7186 |
# manuscript kill switch. Escalate the same frozen source to
|
| 7187 |
# a whole-unit provider-neutral fallback and keep going.
|
| 7188 |
-
|
| 7189 |
-
|
| 7190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7191 |
current_translation.update({
|
| 7192 |
'translated_text': str(fallback.get('text') or '').strip(),
|
| 7193 |
'qa_status': str(fallback.get('qa_status') or 'passed_with_warning'),
|
|
@@ -7258,6 +7694,33 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 7258 |
quality_notes, [{'type':'target_qualitative_appendix_review','reason':str(exc)[:500]}],
|
| 7259 |
section_name='Appendix', stage='translation_final_review'
|
| 7260 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7261 |
try:
|
| 7262 |
agent.validate_translation_alignment(full_draft_en, full_draft_translated)
|
| 7263 |
except Exception as exc:
|
|
|
|
| 3688 |
)
|
| 3689 |
return updated_sections, updated_glossary
|
| 3690 |
|
| 3691 |
+
@staticmethod
|
| 3692 |
+
def _adaptive_recovery_language_code(target_lang: str) -> str:
|
| 3693 |
+
value = str(target_lang or '').strip().casefold().replace('_', '-')
|
| 3694 |
+
if value.startswith('zh') or 'chinese' in value or '中文' in value or '简体' in value:
|
| 3695 |
+
return 'zh'
|
| 3696 |
+
if value.startswith('ru') or 'russian' in value or 'рус' in value:
|
| 3697 |
+
return 'ru'
|
| 3698 |
+
if value.startswith('kk') or value.startswith('kz') or 'kazakh' in value or 'қазақ' in value:
|
| 3699 |
+
return 'kk'
|
| 3700 |
+
return 'other'
|
| 3701 |
+
|
| 3702 |
+
def _validate_adaptive_recovery_text(
|
| 3703 |
+
self, agent, *, source_text: str, translated_text: str, target_lang: str,
|
| 3704 |
+
section_name: str, glossary=None,
|
| 3705 |
+
) -> None:
|
| 3706 |
+
"""Fail closed on adaptive recovery text that bypassed strict compiler QA.
|
| 3707 |
+
|
| 3708 |
+
Adaptive fallbacks exist to preserve delivery after bounded strict-QA
|
| 3709 |
+
exhaustion, not to waive language or semantic integrity. Short quotes and
|
| 3710 |
+
Appendix cells are explicitly re-audited because the ordinary validators
|
| 3711 |
+
intentionally treat very short strings as diagnostic-only.
|
| 3712 |
+
"""
|
| 3713 |
+
source = str(source_text or '').strip()
|
| 3714 |
+
translated = str(translated_text or '').strip()
|
| 3715 |
+
if not source or not translated:
|
| 3716 |
+
raise RuntimeError('adaptive_recovery_text_missing')
|
| 3717 |
+
|
| 3718 |
+
# Deterministic script signal for genuinely translatable English lexical
|
| 3719 |
+
# material. Protected acronyms/numeric-only cells are allowed to remain
|
| 3720 |
+
# unchanged and therefore do not require a target-script character.
|
| 3721 |
+
words = re.findall(r'[A-Za-z]{2,}', source)
|
| 3722 |
+
translatable_words = [word for word in words if not (word.isupper() and len(word) <= 6)]
|
| 3723 |
+
protected_surface = bool(
|
| 3724 |
+
source == translated
|
| 3725 |
+
and re.fullmatch(r'[A-Za-z][A-Za-z0-9._+\-]{1,40}', source)
|
| 3726 |
+
and (source.isupper() or any(char.isupper() for char in source[1:]))
|
| 3727 |
+
)
|
| 3728 |
+
if translatable_words and not protected_surface:
|
| 3729 |
+
language_code = self._adaptive_recovery_language_code(target_lang)
|
| 3730 |
+
if language_code == 'zh' and not re.search(r'[\u4e00-\u9fff]', translated):
|
| 3731 |
+
raise RuntimeError('adaptive_recovery_target_language_signal_missing:zh')
|
| 3732 |
+
if language_code in {'ru', 'kk'} and not re.search(
|
| 3733 |
+
r'[А-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]', translated
|
| 3734 |
+
):
|
| 3735 |
+
raise RuntimeError(
|
| 3736 |
+
f'adaptive_recovery_target_language_signal_missing:{language_code}'
|
| 3737 |
+
)
|
| 3738 |
+
|
| 3739 |
+
language_validator = getattr(agent, '_validate_translation_language_quality', None)
|
| 3740 |
+
if callable(language_validator):
|
| 3741 |
+
language_validator(translated, target_lang)
|
| 3742 |
+
|
| 3743 |
+
# The ordinary semantic-equivalence validator intentionally short-circuits
|
| 3744 |
+
# short strings. For those exact objects, require a fresh provider-neutral
|
| 3745 |
+
# binary audit so a non-empty but unrelated translation cannot be accepted.
|
| 3746 |
+
short_object = len(source) < 120 or len(translated) < 80
|
| 3747 |
+
if short_object:
|
| 3748 |
+
call_model = getattr(agent, 'call_model', None)
|
| 3749 |
+
extractor = getattr(agent, '_extract_json_from_text', None)
|
| 3750 |
+
if not callable(call_model):
|
| 3751 |
+
raise RuntimeError('adaptive_recovery_short_semantic_audit_unavailable')
|
| 3752 |
+
prompt = (
|
| 3753 |
+
'Audit semantic equivalence for one short academic translation object. '
|
| 3754 |
+
'Return JSON only: {"ok":true|false,"issues":["short issue"]}. '
|
| 3755 |
+
'Reject if the TARGET is not actually written in the requested target language except '
|
| 3756 |
+
'for source-owned acronyms, proper names, identifiers, or numbers; also reject if it '
|
| 3757 |
+
'omits/adds substantive meaning, changes actor/object/relation, negation, uncertainty, '
|
| 3758 |
+
'causality strength, temporal scope, or quotation meaning. Do not rewrite.\n'
|
| 3759 |
+
f'SECTION: {section_name}\nTARGET_LANGUAGE: {target_lang}\n'
|
| 3760 |
+
f'<ENGLISH>{source}</ENGLISH>\n<TARGET>{translated}</TARGET>'
|
| 3761 |
+
)
|
| 3762 |
+
response = call_model(
|
| 3763 |
+
'You are a conservative bilingual academic translation equivalence auditor.',
|
| 3764 |
+
prompt, max_tokens=700, temp=0.0, model_role='reasoning', thinking=False,
|
| 3765 |
+
allow_fallback=False, logical_node='ordinary_translation_qa',
|
| 3766 |
+
)
|
| 3767 |
+
if callable(extractor):
|
| 3768 |
+
parsed = extractor(response)
|
| 3769 |
+
else:
|
| 3770 |
+
try:
|
| 3771 |
+
parsed = json.loads(str(response or ''))
|
| 3772 |
+
except Exception:
|
| 3773 |
+
parsed = None
|
| 3774 |
+
if not isinstance(parsed, dict) or parsed.get('ok') is not True:
|
| 3775 |
+
issues = parsed.get('issues') if isinstance(parsed, dict) else []
|
| 3776 |
+
detail = '; '.join(str(item) for item in (issues or [])[:4])
|
| 3777 |
+
raise RuntimeError(
|
| 3778 |
+
'adaptive_recovery_short_semantic_equivalence_failed:' + (detail[:300] or 'unconfirmed')
|
| 3779 |
+
)
|
| 3780 |
+
return
|
| 3781 |
+
|
| 3782 |
+
semantic_validator = getattr(agent, 'validate_translation_semantic_equivalence', None)
|
| 3783 |
+
if not callable(semantic_validator):
|
| 3784 |
+
raise RuntimeError('adaptive_recovery_semantic_validator_unavailable')
|
| 3785 |
+
semantic_validator(
|
| 3786 |
+
source, translated, target_lang, section_name=section_name,
|
| 3787 |
+
)
|
| 3788 |
+
|
| 3789 |
+
@staticmethod
|
| 3790 |
+
def _rebind_target_qualitative_appendix_once(
|
| 3791 |
+
manuscript: str, canonical_appendix: str, reference_heading: str,
|
| 3792 |
+
) -> str:
|
| 3793 |
+
"""Deterministically replace/append the final typed Appendix exactly once."""
|
| 3794 |
+
value = str(manuscript or '').strip()
|
| 3795 |
+
appendix = str(canonical_appendix or '').strip()
|
| 3796 |
+
if not appendix:
|
| 3797 |
+
raise RuntimeError('target_qualitative_appendix_rebind_source_missing')
|
| 3798 |
+
heading_match = re.match(r'(?m)^#\s+([^\n]+)\s*$', appendix)
|
| 3799 |
+
if not heading_match:
|
| 3800 |
+
raise RuntimeError('target_qualitative_appendix_rebind_heading_missing')
|
| 3801 |
+
appendix_heading = heading_match.group(1).strip()
|
| 3802 |
+
ref_heading = str(reference_heading or '').strip()
|
| 3803 |
+
|
| 3804 |
+
reference_tail = ''
|
| 3805 |
+
body = value
|
| 3806 |
+
if ref_heading:
|
| 3807 |
+
ref_match = re.search(rf'(?m)^#\s+{re.escape(ref_heading)}\s*$', value)
|
| 3808 |
+
if ref_match:
|
| 3809 |
+
body = value[:ref_match.start()].rstrip()
|
| 3810 |
+
reference_tail = value[ref_match.start():].strip()
|
| 3811 |
+
|
| 3812 |
+
appendix_match = re.search(rf'(?m)^#\s+{re.escape(appendix_heading)}\s*$', body)
|
| 3813 |
+
if appendix_match:
|
| 3814 |
+
appendix_tail = body[appendix_match.end():]
|
| 3815 |
+
if re.search(r'(?m)^#\s+[^#].*$', appendix_tail):
|
| 3816 |
+
raise RuntimeError('target_qualitative_appendix_rebind_nonterminal_section')
|
| 3817 |
+
body = body[:appendix_match.start()].rstrip()
|
| 3818 |
+
|
| 3819 |
+
pieces = [part for part in (body, appendix, reference_tail) if str(part or '').strip()]
|
| 3820 |
+
return '\n\n'.join(str(part).strip() for part in pieces).strip()
|
| 3821 |
+
|
| 3822 |
+
def _recover_qualitative_quote_ledger(
|
| 3823 |
+
self, agent, *, sections: dict, target_lang: str, translation_glossary: dict,
|
| 3824 |
+
required_tags, stage_callback=None,
|
| 3825 |
+
):
|
| 3826 |
+
"""Recover only missing/stale typed qualitative Quote objects.
|
| 3827 |
+
|
| 3828 |
+
Quote identity remains server-owned. Recovery is language/tier neutral:
|
| 3829 |
+
required IDs come from the frozen Appendix manifest, source text comes
|
| 3830 |
+
from the frozen evidence ledger, and only missing target objects are
|
| 3831 |
+
translated. A failed batch is isolated to individual Quote objects so
|
| 3832 |
+
one brittle item cannot discard already recoverable evidence.
|
| 3833 |
+
"""
|
| 3834 |
+
updated_sections = sections if isinstance(sections, dict) else {}
|
| 3835 |
+
glossary = dict(translation_glossary or {})
|
| 3836 |
+
quote_sources = dict(getattr(self, '_qualitative_quote_sources', {}) or {})
|
| 3837 |
+
required = {
|
| 3838 |
+
str(tag or '').strip() for tag in (required_tags or []) if str(tag or '').strip()
|
| 3839 |
+
}
|
| 3840 |
+
missing_sources = sorted(tag for tag in required if not str(quote_sources.get(tag) or '').strip())
|
| 3841 |
+
if missing_sources:
|
| 3842 |
+
raise RuntimeError(f'qualitative_quote_source_missing:{missing_sources[:8]}')
|
| 3843 |
+
|
| 3844 |
+
current_quote_hashes = {
|
| 3845 |
+
tag: hashlib.sha256(re.sub(r"\s+", " ", quote).strip().encode("utf-8")).hexdigest()
|
| 3846 |
+
for tag, quote in quote_sources.items()
|
| 3847 |
+
}
|
| 3848 |
+
current_request_hash = str(getattr(self, 'request_hash', '') or '')
|
| 3849 |
+
current_task_id = str(getattr(getattr(self, 'task_dir', None), 'name', '') or '')
|
| 3850 |
+
raw_record = updated_sections.get('__quote_ledger__')
|
| 3851 |
+
record = dict(raw_record) if isinstance(raw_record, dict) else {}
|
| 3852 |
+
identity_ok = self._qualitative_quote_ledger_identity_ok(
|
| 3853 |
+
record,
|
| 3854 |
+
task_id=current_task_id,
|
| 3855 |
+
target_lang=target_lang,
|
| 3856 |
+
request_hash=current_request_hash,
|
| 3857 |
+
translation_contract_version=TRANSLATION_CONTRACT_VERSION,
|
| 3858 |
+
source_quote_hashes=current_quote_hashes,
|
| 3859 |
+
)
|
| 3860 |
+
stored = record.get('translations')
|
| 3861 |
+
quote_map = dict(stored) if identity_ok and isinstance(stored, dict) else {}
|
| 3862 |
+
recovery_events = (
|
| 3863 |
+
list(record.get('recovery_events') or [])
|
| 3864 |
+
if identity_ok and isinstance(record.get('recovery_events'), list) else []
|
| 3865 |
+
)
|
| 3866 |
+
|
| 3867 |
+
def persist():
|
| 3868 |
+
updated_sections['__quote_ledger__'] = {
|
| 3869 |
+
'task_id': current_task_id,
|
| 3870 |
+
'request_hash': current_request_hash,
|
| 3871 |
+
'writer_execution_epoch': int(getattr(self, 'execution_epoch', 0) or 0),
|
| 3872 |
+
'target_lang': target_lang,
|
| 3873 |
+
'translation_contract_version': TRANSLATION_CONTRACT_VERSION,
|
| 3874 |
+
'source_quote_hashes': current_quote_hashes,
|
| 3875 |
+
'translations': dict(quote_map),
|
| 3876 |
+
'recovery_events': list(recovery_events)[-40:],
|
| 3877 |
+
'updated_at': datetime.now(timezone.utc).isoformat(),
|
| 3878 |
+
}
|
| 3879 |
+
|
| 3880 |
+
missing = sorted(tag for tag in required if not str(quote_map.get(tag) or '').strip())
|
| 3881 |
+
if not missing:
|
| 3882 |
+
if required and (not identity_ok or raw_record is not updated_sections.get('__quote_ledger__')):
|
| 3883 |
+
persist()
|
| 3884 |
+
return updated_sections, glossary, quote_map, recovery_events
|
| 3885 |
+
|
| 3886 |
+
translate_quotes = getattr(agent, 'translate_qualitative_quote_objects', None)
|
| 3887 |
+
if not callable(translate_quotes):
|
| 3888 |
+
raise RuntimeError('Typed qualitative quote translation entry point is unavailable.')
|
| 3889 |
+
|
| 3890 |
+
def merge_result(result, expected_tags, route):
|
| 3891 |
+
nonlocal glossary
|
| 3892 |
+
returned = (result or {}).get('translations')
|
| 3893 |
+
expected = set(expected_tags)
|
| 3894 |
+
if not isinstance(returned, dict) or set(returned) != expected:
|
| 3895 |
+
raise RuntimeError(
|
| 3896 |
+
'Typed qualitative quote translation identity mismatch: '
|
| 3897 |
+
f'expected={sorted(expected)} got={sorted(returned) if isinstance(returned, dict) else []}'
|
| 3898 |
+
)
|
| 3899 |
+
for tag in expected_tags:
|
| 3900 |
+
rendered = str(returned.get(tag) or '').strip()
|
| 3901 |
+
if not rendered:
|
| 3902 |
+
raise RuntimeError(f'Typed qualitative quote translation is empty: {tag}')
|
| 3903 |
+
quote_map[tag] = rendered
|
| 3904 |
+
returned_glossary = (result or {}).get('glossary')
|
| 3905 |
+
if isinstance(returned_glossary, dict):
|
| 3906 |
+
glossary = dict(returned_glossary)
|
| 3907 |
+
recovery_events.append({
|
| 3908 |
+
'route': str(route), 'quote_ids': sorted(expected),
|
| 3909 |
+
'at': datetime.now(timezone.utc).isoformat(),
|
| 3910 |
+
})
|
| 3911 |
+
persist()
|
| 3912 |
+
|
| 3913 |
+
# Fast path: one typed batch. If any object destabilizes the strict
|
| 3914 |
+
# compiler, isolate only the missing objects instead of losing the batch.
|
| 3915 |
+
try:
|
| 3916 |
+
batch_result = translate_quotes(
|
| 3917 |
+
{tag: quote_sources[tag] for tag in missing},
|
| 3918 |
+
target_lang, glossary=glossary, max_attempts=2,
|
| 3919 |
+
stage_callback=stage_callback,
|
| 3920 |
+
)
|
| 3921 |
+
merge_result(batch_result, missing, 'typed_batch')
|
| 3922 |
+
except PipelineCancelledError:
|
| 3923 |
+
raise
|
| 3924 |
+
except Exception as batch_error:
|
| 3925 |
+
logging.warning(
|
| 3926 |
+
'Qualitative Quote Ledger batch recovery isolated | missing=%s error=%s',
|
| 3927 |
+
len(missing), type(batch_error).__name__,
|
| 3928 |
+
)
|
| 3929 |
+
for tag in missing:
|
| 3930 |
+
if str(quote_map.get(tag) or '').strip():
|
| 3931 |
+
continue
|
| 3932 |
+
source_quote = str(quote_sources.get(tag) or '').strip()
|
| 3933 |
+
try:
|
| 3934 |
+
one_result = translate_quotes(
|
| 3935 |
+
{tag: source_quote}, target_lang,
|
| 3936 |
+
glossary=glossary, max_attempts=2,
|
| 3937 |
+
stage_callback=stage_callback,
|
| 3938 |
+
)
|
| 3939 |
+
merge_result(one_result, [tag], 'typed_single')
|
| 3940 |
+
continue
|
| 3941 |
+
except PipelineCancelledError:
|
| 3942 |
+
raise
|
| 3943 |
+
except Exception as strict_error:
|
| 3944 |
+
fallback_method = getattr(agent, 'adaptive_translate_fallback', None)
|
| 3945 |
+
if not callable(fallback_method):
|
| 3946 |
+
raise strict_error
|
| 3947 |
+
fallback = fallback_method(
|
| 3948 |
+
source_quote, target_lang,
|
| 3949 |
+
section_name=f'Qualitative Quote {tag}', glossary=glossary,
|
| 3950 |
+
)
|
| 3951 |
+
adaptive_text = str((fallback or {}).get('text') or '').strip()
|
| 3952 |
+
if not adaptive_text:
|
| 3953 |
+
raise RuntimeError(f'Adaptive qualitative quote translation is empty: {tag}') from strict_error
|
| 3954 |
+
self._validate_adaptive_recovery_text(
|
| 3955 |
+
agent, source_text=source_quote, translated_text=adaptive_text,
|
| 3956 |
+
target_lang=target_lang, section_name=f'Qualitative Quote {tag}',
|
| 3957 |
+
glossary=glossary,
|
| 3958 |
+
)
|
| 3959 |
+
render_method = getattr(agent, '_render_qualitative_quote_translation', None)
|
| 3960 |
+
rendered = adaptive_text
|
| 3961 |
+
if callable(render_method):
|
| 3962 |
+
rendered = str(render_method(adaptive_text, target_lang) or '').strip()
|
| 3963 |
+
if not rendered:
|
| 3964 |
+
raise RuntimeError(f'Adaptive qualitative quote rendering is empty: {tag}') from strict_error
|
| 3965 |
+
quote_map[tag] = rendered
|
| 3966 |
+
recovery_events.append({
|
| 3967 |
+
'route': 'adaptive_single', 'quote_ids': [tag],
|
| 3968 |
+
'prior_error': type(strict_error).__name__,
|
| 3969 |
+
'at': datetime.now(timezone.utc).isoformat(),
|
| 3970 |
+
})
|
| 3971 |
+
persist()
|
| 3972 |
+
|
| 3973 |
+
unresolved = sorted(tag for tag in required if not str(quote_map.get(tag) or '').strip())
|
| 3974 |
+
if unresolved:
|
| 3975 |
+
raise RuntimeError(f'qualitative_appendix_quote_ledger_incomplete:{unresolved[:8]}')
|
| 3976 |
+
return updated_sections, glossary, quote_map, recovery_events
|
| 3977 |
+
|
| 3978 |
+
def _adaptive_translate_qualitative_unit_with_quotes(
|
| 3979 |
+
self, agent, *, source_text: str, target_lang: str, section_name: str,
|
| 3980 |
+
translation_sections: dict, translation_glossary: dict,
|
| 3981 |
+
):
|
| 3982 |
+
"""Delivery-first fallback that preserves one shared Quote identity.
|
| 3983 |
+
|
| 3984 |
+
Generic whole-unit fallback is allowed for qualitative narrative only
|
| 3985 |
+
after verbatim evidence is replaced by stable Quote slots. Missing Quote
|
| 3986 |
+
objects are recovered from frozen evidence, then injected back into the
|
| 3987 |
+
fallback result. This keeps Findings and Appendix on the same translated
|
| 3988 |
+
quotation instead of letting two independent model calls paraphrase the
|
| 3989 |
+
same verbatim evidence differently.
|
| 3990 |
+
"""
|
| 3991 |
+
source = str(source_text or '').strip()
|
| 3992 |
+
canonical_source, pending_tags, reused_tags = self._prepare_qualitative_translation_source(
|
| 3993 |
+
source, getattr(self, '_qualitative_quote_sources', {}), {}
|
| 3994 |
+
)
|
| 3995 |
+
canonical_source = str(canonical_source or '').strip()
|
| 3996 |
+
required_tags = list(dict.fromkeys(list(pending_tags or []) + list(reused_tags or [])))
|
| 3997 |
+
sections = translation_sections if isinstance(translation_sections, dict) else {}
|
| 3998 |
+
glossary = dict(translation_glossary or {})
|
| 3999 |
+
quote_map = {}
|
| 4000 |
+
recovery_events = []
|
| 4001 |
+
if required_tags:
|
| 4002 |
+
sections, glossary, quote_map, recovery_events = self._recover_qualitative_quote_ledger(
|
| 4003 |
+
agent, sections=sections, target_lang=target_lang,
|
| 4004 |
+
translation_glossary=glossary, required_tags=required_tags,
|
| 4005 |
+
)
|
| 4006 |
+
|
| 4007 |
+
fallback_method = getattr(agent, 'adaptive_translate_fallback', None)
|
| 4008 |
+
if not callable(fallback_method):
|
| 4009 |
+
raise RuntimeError('Adaptive translation fallback entry point is unavailable.')
|
| 4010 |
+
fallback = fallback_method(
|
| 4011 |
+
canonical_source, target_lang, section_name=section_name, glossary=glossary
|
| 4012 |
+
)
|
| 4013 |
+
translated = str((fallback or {}).get('text') or '').strip()
|
| 4014 |
+
if not translated:
|
| 4015 |
+
raise RuntimeError('adaptive_qualitative_translation_output_missing')
|
| 4016 |
+
if required_tags:
|
| 4017 |
+
translated, quote_map = self._finalize_qualitative_translation_quotes(
|
| 4018 |
+
translated, required_tags, [], quote_map
|
| 4019 |
+
)
|
| 4020 |
+
alignment_validator = getattr(agent, 'validate_translation_alignment', None)
|
| 4021 |
+
if callable(alignment_validator):
|
| 4022 |
+
alignment_validator(source, translated)
|
| 4023 |
+
self._validate_adaptive_recovery_text(
|
| 4024 |
+
agent, source_text=source, translated_text=translated,
|
| 4025 |
+
target_lang=target_lang, section_name=section_name, glossary=glossary,
|
| 4026 |
+
)
|
| 4027 |
+
result = dict(fallback or {})
|
| 4028 |
+
result['text'] = translated
|
| 4029 |
+
if recovery_events:
|
| 4030 |
+
result['qualitative_quote_recovery_events'] = list(recovery_events)[-40:]
|
| 4031 |
+
return result, sections, glossary
|
| 4032 |
+
|
| 4033 |
def _translate_qualitative_appendix_unit(
|
| 4034 |
self, agent, qual_engine, *, unit_key: str, source_text: str, target_lang: str,
|
| 4035 |
translation_sections: dict, translation_glossary: dict, data_metadata: dict,
|
|
|
|
| 4063 |
qual_engine.validate_bound_target_appendix(existing['translated_text'], manifest, target_lang)
|
| 4064 |
return sections, translation_glossary
|
| 4065 |
|
| 4066 |
+
required_quotes = {
|
| 4067 |
+
str(row.get('quote_id') or '').strip()
|
| 4068 |
+
for row in manifest.get('rows') or []
|
| 4069 |
+
if str(row.get('quote_id') or '').strip()
|
| 4070 |
+
}
|
| 4071 |
+
sections, translation_glossary, quote_map, quote_recovery_events = (
|
| 4072 |
+
self._recover_qualitative_quote_ledger(
|
| 4073 |
+
agent, sections=sections, target_lang=target_lang,
|
| 4074 |
+
translation_glossary=translation_glossary, required_tags=required_quotes,
|
| 4075 |
+
)
|
| 4076 |
+
)
|
| 4077 |
|
| 4078 |
translate_cells = getattr(agent, 'translate_qualitative_appendix_cells', None)
|
| 4079 |
if not callable(translate_cells):
|
|
|
|
| 4082 |
prebound_cells, model_cells = qual_engine.appendix_translation_plan(
|
| 4083 |
manifest, translation_glossary
|
| 4084 |
)
|
| 4085 |
+
appendix_cell_recovery_events = []
|
| 4086 |
if model_cells:
|
| 4087 |
+
try:
|
| 4088 |
+
result = translate_cells(
|
| 4089 |
+
model_cells, target_lang,
|
| 4090 |
+
glossary=translation_glossary, max_attempts=2,
|
| 4091 |
+
claim_states=(existing.get('appendix_cell_claim_states') if isinstance(existing.get('appendix_cell_claim_states'), dict) else None),
|
| 4092 |
+
)
|
| 4093 |
+
model_translations = dict((result or {}).get('translations') or {})
|
| 4094 |
+
if set(model_translations) != set(model_cells):
|
| 4095 |
+
raise RuntimeError('qualitative_appendix_target_cell_translation_identity_mismatch')
|
| 4096 |
+
updated_glossary = dict((result or {}).get('glossary') or translation_glossary or {})
|
| 4097 |
+
except PipelineCancelledError:
|
| 4098 |
+
raise
|
| 4099 |
+
except Exception as batch_error:
|
| 4100 |
+
logging.warning(
|
| 4101 |
+
'Qualitative Appendix cell batch recovery isolated | cells=%s error=%s',
|
| 4102 |
+
len(model_cells), type(batch_error).__name__,
|
| 4103 |
+
)
|
| 4104 |
+
model_translations = {}
|
| 4105 |
+
updated_glossary = dict(translation_glossary or {})
|
| 4106 |
+
for cell_id, source_cell in model_cells.items():
|
| 4107 |
+
try:
|
| 4108 |
+
one_result = translate_cells(
|
| 4109 |
+
{cell_id: source_cell}, target_lang,
|
| 4110 |
+
glossary=updated_glossary, max_attempts=2,
|
| 4111 |
+
)
|
| 4112 |
+
one_map = dict((one_result or {}).get('translations') or {})
|
| 4113 |
+
if set(one_map) != {cell_id} or not str(one_map.get(cell_id) or '').strip():
|
| 4114 |
+
raise RuntimeError(
|
| 4115 |
+
f'qualitative_appendix_target_cell_translation_identity_mismatch:{cell_id}'
|
| 4116 |
+
)
|
| 4117 |
+
model_translations[cell_id] = str(one_map[cell_id]).strip()
|
| 4118 |
+
one_glossary = (one_result or {}).get('glossary')
|
| 4119 |
+
if isinstance(one_glossary, dict):
|
| 4120 |
+
updated_glossary = dict(one_glossary)
|
| 4121 |
+
appendix_cell_recovery_events.append({
|
| 4122 |
+
'route': 'typed_single', 'cell_id': str(cell_id),
|
| 4123 |
+
'at': datetime.now(timezone.utc).isoformat(),
|
| 4124 |
+
})
|
| 4125 |
+
except PipelineCancelledError:
|
| 4126 |
+
raise
|
| 4127 |
+
except Exception as strict_error:
|
| 4128 |
+
fallback_method = getattr(agent, 'adaptive_translate_fallback', None)
|
| 4129 |
+
if not callable(fallback_method):
|
| 4130 |
+
raise strict_error
|
| 4131 |
+
fallback = fallback_method(
|
| 4132 |
+
str(source_cell or ''), target_lang,
|
| 4133 |
+
section_name=f'Qualitative Appendix Cell {cell_id}',
|
| 4134 |
+
glossary=updated_glossary,
|
| 4135 |
+
)
|
| 4136 |
+
rendered_cell = re.sub(
|
| 4137 |
+
r'\s+', ' ', str((fallback or {}).get('text') or '')
|
| 4138 |
+
).strip()
|
| 4139 |
+
if not rendered_cell:
|
| 4140 |
+
raise RuntimeError(
|
| 4141 |
+
f'qualitative_appendix_target_cell_translation_empty:{cell_id}'
|
| 4142 |
+
) from strict_error
|
| 4143 |
+
self._validate_adaptive_recovery_text(
|
| 4144 |
+
agent, source_text=str(source_cell or ''),
|
| 4145 |
+
translated_text=rendered_cell, target_lang=target_lang,
|
| 4146 |
+
section_name=f'Qualitative Appendix Cell {cell_id}',
|
| 4147 |
+
glossary=updated_glossary,
|
| 4148 |
+
)
|
| 4149 |
+
model_translations[cell_id] = rendered_cell
|
| 4150 |
+
appendix_cell_recovery_events.append({
|
| 4151 |
+
'route': 'adaptive_single', 'cell_id': str(cell_id),
|
| 4152 |
+
'prior_error': type(strict_error).__name__,
|
| 4153 |
+
'at': datetime.now(timezone.utc).isoformat(),
|
| 4154 |
+
})
|
| 4155 |
+
result = {
|
| 4156 |
+
'qa_status': 'passed_with_warning',
|
| 4157 |
+
'attempts': int(existing.get('attempts') or 0) + 1,
|
| 4158 |
+
'claim_states': {},
|
| 4159 |
+
'appendix_cell_contract_version': 'typed-qualitative-appendix-cells-v2',
|
| 4160 |
+
}
|
| 4161 |
else:
|
| 4162 |
result = {
|
| 4163 |
'qa_status': 'passed', 'attempts': 1, 'claim_states': {},
|
|
|
|
| 4217 |
'appendix_cell_contract_version': str((result or {}).get('appendix_cell_contract_version') or ''),
|
| 4218 |
'appendix_cell_claim_states': dict((result or {}).get('claim_states') or {}),
|
| 4219 |
'appendix_prebound_label_count': len(prebound_cells),
|
| 4220 |
+
'quote_ledger_recovery_events': list(quote_recovery_events)[-40:],
|
| 4221 |
+
'appendix_cell_recovery_events': list(appendix_cell_recovery_events)[-80:],
|
| 4222 |
'updated_at': datetime.now(timezone.utc).isoformat(),
|
| 4223 |
}
|
| 4224 |
return sections, updated_glossary
|
|
|
|
| 7599 |
f'translation_{unit_key}_repair_pending', state, pct,
|
| 7600 |
display_node=f'translation_repair_pending:{index}:{len(final_units)}',
|
| 7601 |
)
|
| 7602 |
+
if mode == "asq" and str(unit_key).strip().casefold() == "appendix":
|
| 7603 |
+
# The Appendix is a manifest-owned typed artifact. Generic
|
| 7604 |
+
# whole-unit translation must never bypass its row/cell/Quote
|
| 7605 |
+
# identity contract. _translate_qualitative_appendix_unit
|
| 7606 |
+
# already exhausted bounded typed recovery above.
|
| 7607 |
+
raise RuntimeError(
|
| 7608 |
+
'qualitative_appendix_typed_recovery_exhausted:'
|
| 7609 |
+
+ str(state.get('translation_blocking_error_code') or 'TRANSLATION_PENDING')
|
| 7610 |
+
)
|
| 7611 |
# A2: strict claim QA is diagnostic/recovery authority, not a
|
| 7612 |
# manuscript kill switch. Escalate the same frozen source to
|
| 7613 |
# a whole-unit provider-neutral fallback and keep going.
|
| 7614 |
+
if mode == "asq":
|
| 7615 |
+
fallback, translation_sections, translation_glossary = (
|
| 7616 |
+
self._adaptive_translate_qualitative_unit_with_quotes(
|
| 7617 |
+
translation_agent, source_text=translation_source,
|
| 7618 |
+
target_lang=target_lang, section_name=unit_name,
|
| 7619 |
+
translation_sections=translation_sections,
|
| 7620 |
+
translation_glossary=translation_glossary,
|
| 7621 |
+
)
|
| 7622 |
+
)
|
| 7623 |
+
else:
|
| 7624 |
+
fallback = translation_agent.adaptive_translate_fallback(
|
| 7625 |
+
translation_source, target_lang, section_name=unit_name, glossary=translation_glossary
|
| 7626 |
+
)
|
| 7627 |
current_translation.update({
|
| 7628 |
'translated_text': str(fallback.get('text') or '').strip(),
|
| 7629 |
'qa_status': str(fallback.get('qa_status') or 'passed_with_warning'),
|
|
|
|
| 7694 |
quality_notes, [{'type':'target_qualitative_appendix_review','reason':str(exc)[:500]}],
|
| 7695 |
section_name='Appendix', stage='translation_final_review'
|
| 7696 |
)
|
| 7697 |
+
# One deterministic self-heal is allowed when the typed
|
| 7698 |
+
# Appendix record itself is valid. No model is called and no
|
| 7699 |
+
# row/cell/Quote identity can change during this rebind.
|
| 7700 |
+
appendix_record = dict(translation_sections.get('appendix') or {})
|
| 7701 |
+
canonical_appendix = _normalize_target_manuscript_typography(
|
| 7702 |
+
str(appendix_record.get('translated_text') or '').strip(), target_lang
|
| 7703 |
+
)
|
| 7704 |
+
try:
|
| 7705 |
+
qual_engine.validate_bound_target_appendix(
|
| 7706 |
+
canonical_appendix, appendix_manifest, target_lang
|
| 7707 |
+
)
|
| 7708 |
+
full_draft_translated = self._rebind_target_qualitative_appendix_once(
|
| 7709 |
+
full_draft_translated, canonical_appendix,
|
| 7710 |
+
agent._localized_reference_heading(target_lang),
|
| 7711 |
+
)
|
| 7712 |
+
qual_engine.validate_bound_target_appendix(
|
| 7713 |
+
full_draft_translated, appendix_manifest, target_lang
|
| 7714 |
+
)
|
| 7715 |
+
self._record_adaptive_completion_residual(
|
| 7716 |
+
quality_notes,
|
| 7717 |
+
[{'type':'target_qualitative_appendix_deterministic_rebind','reason':type(exc).__name__}],
|
| 7718 |
+
section_name='Appendix', stage='translation_final_recovery'
|
| 7719 |
+
)
|
| 7720 |
+
except Exception as recovery_exc:
|
| 7721 |
+
# A structurally invalid/missing typed Appendix is a real
|
| 7722 |
+
# completion failure, not a cosmetic translation warning.
|
| 7723 |
+
raise RuntimeError('target_qualitative_appendix_final_validation_failed') from recovery_exc
|
| 7724 |
try:
|
| 7725 |
agent.validate_translation_alignment(full_draft_en, full_draft_translated)
|
| 7726 |
except Exception as exc:
|