P3-A5-R2 adaptive recovery integrity correction

#154
Files changed (1) hide show
  1. 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
- quote_record = sections.get('__quote_ledger__')
3725
- quote_map = dict((quote_record or {}).get('translations') or {}) if isinstance(quote_record, dict) else {}
3726
- required_quotes = {str(row.get('quote_id') or '') for row in manifest.get('rows') or []}
3727
- if any(not str(quote_map.get(tag) or '').strip() for tag in required_quotes):
3728
- missing = sorted(tag for tag in required_quotes if not str(quote_map.get(tag) or '').strip())
3729
- raise RuntimeError(f'qualitative_appendix_quote_ledger_incomplete:{missing[:6]}')
 
 
 
 
 
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
- result = translate_cells(
3740
- model_cells, target_lang,
3741
- glossary=translation_glossary, max_attempts=2,
3742
- claim_states=(existing.get('appendix_cell_claim_states') if isinstance(existing.get('appendix_cell_claim_states'), dict) else None),
3743
- )
3744
- model_translations = dict((result or {}).get('translations') or {})
3745
- updated_glossary = dict((result or {}).get('glossary') or translation_glossary or {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- fallback = translation_agent.adaptive_translate_fallback(
7189
- translation_source, target_lang, section_name=unit_name, glossary=translation_glossary
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: