R6A-R4-R12 canonical translation identity and evidence-grounded QA adjudication

#47
Files changed (2) hide show
  1. core/generation_runner.py +62 -7
  2. core/pipeline_agent.py +157 -2
core/generation_runner.py CHANGED
@@ -37,7 +37,7 @@ ProgressCallback = Callable[..., None]
37
  MIN_FINAL_REFERENCES = 50
38
  LITERATURE_EVIDENCE_TARGET = 75
39
  CITATION_PLAN_TARGET = 70
40
- TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v3'
41
 
42
 
43
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
@@ -866,6 +866,27 @@ class GenerationRunner:
866
  # Test doubles / legacy adapters still get deterministic invalidation.
867
  return hashlib.sha256(str(source or '').encode('utf-8')).hexdigest()
868
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
869
  @staticmethod
870
  def _assemble_partial_translation(translation_sections, adaptive_structure, *, include_front=False) -> str:
871
  mapping = translation_sections if isinstance(translation_sections, dict) else {}
@@ -939,8 +960,10 @@ class GenerationRunner:
939
  source = str(source_text or '').strip()
940
  if not source or self._translation_worker is None:
941
  return False
942
- source_hash = self._text_hash(source)
943
- fact_ledger_hash = self._translation_ledger_hash(agent, source)
 
 
944
  existing = dict((translation_sections or {}).get(unit_key) or {})
945
  if not isinstance(getattr(self, '_translation_progress_queued', None), set):
946
  self._translation_progress_queued = set()
@@ -953,13 +976,23 @@ class GenerationRunner:
953
  if (
954
  existing.get('qa_status') in {'passed', 'passed_with_warning'}
955
  and existing.get('source_hash') == source_hash
 
956
  and existing.get('target_lang') == str(self._translation_worker_context.get('target_lang') or '')
957
  and existing.get('translation_contract_version') == TRANSLATION_CONTRACT_VERSION
958
  and existing.get('fact_ledger_hash') == fact_ledger_hash
959
  and existing.get('translated_text')
960
  ):
961
  return False
962
- pending_key = (str(unit_key), source_hash, fact_ledger_hash)
 
 
 
 
 
 
 
 
 
963
  if pending_key in self._translation_worker_pending:
964
  return False
965
  self._translation_progress_inflight.add(str(unit_key))
@@ -968,6 +1001,7 @@ class GenerationRunner:
968
  'unit_name': str(unit_name),
969
  'source_text': source,
970
  'source_hash': source_hash,
 
971
  'fact_ledger_hash': fact_ledger_hash,
972
  'english_commit_text': str(english_commit_text or ''),
973
  })
@@ -1010,9 +1044,12 @@ class GenerationRunner:
1010
  source = str(source_text or '').strip()
1011
  if not source:
1012
  return translation_sections, translation_glossary
1013
- source_hash = self._text_hash(source)
 
 
 
1014
  contract_version = TRANSLATION_CONTRACT_VERSION
1015
- fact_ledger_hash = self._translation_ledger_hash(agent, source)
1016
  existing = dict((translation_sections or {}).get(unit_key) or {})
1017
  if not isinstance(getattr(self, '_translation_progress_queued', None), set):
1018
  self._translation_progress_queued = set()
@@ -1023,6 +1060,7 @@ class GenerationRunner:
1023
  if (
1024
  existing.get('qa_status') in {'passed', 'passed_with_warning'}
1025
  and existing.get('source_hash') == source_hash
 
1026
  and existing.get('target_lang') == target_lang
1027
  and existing.get('translation_contract_version') == contract_version
1028
  and existing.get('fact_ledger_hash') == fact_ledger_hash
@@ -1030,6 +1068,15 @@ class GenerationRunner:
1030
  ):
1031
  self._translation_progress_inflight.discard(str(unit_key))
1032
  return translation_sections, translation_glossary
 
 
 
 
 
 
 
 
 
1033
  self._translation_progress_inflight.add(str(unit_key))
1034
  partial = self._assemble_partial_translation(translation_sections, adaptive_structure, include_front=include_front)
1035
  self._publish_target_preview(
@@ -1069,6 +1116,9 @@ class GenerationRunner:
1069
  translation_source, pending_quote_tags, reused_quote_tags = self._prepare_qualitative_translation_source(
1070
  source, getattr(self, "_qualitative_quote_sources", {}), quote_ledger
1071
  )
 
 
 
1072
 
1073
  def publish_translation_stage(stage):
1074
  value = str(stage or '').strip().casefold()
@@ -1164,6 +1214,7 @@ class GenerationRunner:
1164
  error_code = str(code_method(error) if callable(code_method) else 'TRANSLATION_PENDING')
1165
  updated_sections[unit_key] = {
1166
  'source_hash': source_hash,
 
1167
  'target_lang': target_lang,
1168
  'glossary_version': self._glossary_version(translation_glossary),
1169
  'translation_contract_version': contract_version,
@@ -1186,12 +1237,16 @@ class GenerationRunner:
1186
  )
1187
  return updated_sections, translation_glossary
1188
  updated_glossary = dict(result.get('glossary') or translation_glossary or {})
 
 
 
1189
  updated_sections[unit_key] = {
1190
  'source_hash': source_hash,
 
1191
  'target_lang': target_lang,
1192
  'glossary_version': self._glossary_version(updated_glossary),
1193
  'translation_contract_version': str(result.get('translation_contract_version') or contract_version),
1194
- 'fact_ledger_hash': str(result.get('fact_ledger_hash') or fact_ledger_hash),
1195
  'translated_text': str(result.get('text') or '').strip(),
1196
  'qa_status': str(result.get('qa_status') or 'passed'),
1197
  'language_adapter': str(result.get('language_adapter') or ''),
 
37
  MIN_FINAL_REFERENCES = 50
38
  LITERATURE_EVIDENCE_TARGET = 75
39
  CITATION_PLAN_TARGET = 70
40
+ TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v4'
41
 
42
 
43
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
 
866
  # Test doubles / legacy adapters still get deterministic invalidation.
867
  return hashlib.sha256(str(source or '').encode('utf-8')).hexdigest()
868
 
869
+ def _translation_identity_bundle(self, agent, source: str) -> dict[str, str]:
870
+ """Return one canonical identity domain for translation reuse.
871
+
872
+ The frozen English source remains the manuscript authority, while the
873
+ Translation Compiler may consume a canonicalized representation (for
874
+ example qualitative quote literals become QUOTE semantic slots). Cache
875
+ identity must therefore persist both hashes and derive the private Fact
876
+ Ledger only from the exact compiled source sent to the translator.
877
+ """
878
+ raw_source = str(source or '').strip()
879
+ compiled_source, _pending, _reused = self._prepare_qualitative_translation_source(
880
+ raw_source, getattr(self, '_qualitative_quote_sources', {}), {}
881
+ )
882
+ compiled_source = str(compiled_source or '').strip()
883
+ return {
884
+ 'source_hash': self._text_hash(raw_source),
885
+ 'compiled_source': compiled_source,
886
+ 'compiled_source_hash': self._text_hash(compiled_source),
887
+ 'fact_ledger_hash': self._translation_ledger_hash(agent, compiled_source),
888
+ }
889
+
890
  @staticmethod
891
  def _assemble_partial_translation(translation_sections, adaptive_structure, *, include_front=False) -> str:
892
  mapping = translation_sections if isinstance(translation_sections, dict) else {}
 
960
  source = str(source_text or '').strip()
961
  if not source or self._translation_worker is None:
962
  return False
963
+ identity = self._translation_identity_bundle(agent, source)
964
+ source_hash = identity['source_hash']
965
+ compiled_source_hash = identity['compiled_source_hash']
966
+ fact_ledger_hash = identity['fact_ledger_hash']
967
  existing = dict((translation_sections or {}).get(unit_key) or {})
968
  if not isinstance(getattr(self, '_translation_progress_queued', None), set):
969
  self._translation_progress_queued = set()
 
976
  if (
977
  existing.get('qa_status') in {'passed', 'passed_with_warning'}
978
  and existing.get('source_hash') == source_hash
979
+ and existing.get('compiled_source_hash') == compiled_source_hash
980
  and existing.get('target_lang') == str(self._translation_worker_context.get('target_lang') or '')
981
  and existing.get('translation_contract_version') == TRANSLATION_CONTRACT_VERSION
982
  and existing.get('fact_ledger_hash') == fact_ledger_hash
983
  and existing.get('translated_text')
984
  ):
985
  return False
986
+ if existing and existing.get('translated_text'):
987
+ reasons = []
988
+ if existing.get('source_hash') != source_hash: reasons.append('english_source_hash')
989
+ if existing.get('compiled_source_hash') != compiled_source_hash: reasons.append('compiled_source_hash')
990
+ if existing.get('fact_ledger_hash') != fact_ledger_hash: reasons.append('fact_ledger_hash')
991
+ if existing.get('translation_contract_version') != TRANSLATION_CONTRACT_VERSION: reasons.append('contract_version')
992
+ if existing.get('target_lang') != str(self._translation_worker_context.get('target_lang') or ''): reasons.append('target_lang')
993
+ if existing.get('qa_status') not in {'passed', 'passed_with_warning'}: reasons.append('qa_status')
994
+ logging.info('Translation reuse miss | unit=%s phase=enqueue reasons=%s', unit_name, ','.join(reasons) or 'unknown')
995
+ pending_key = (str(unit_key), source_hash, compiled_source_hash, fact_ledger_hash)
996
  if pending_key in self._translation_worker_pending:
997
  return False
998
  self._translation_progress_inflight.add(str(unit_key))
 
1001
  'unit_name': str(unit_name),
1002
  'source_text': source,
1003
  'source_hash': source_hash,
1004
+ 'compiled_source_hash': compiled_source_hash,
1005
  'fact_ledger_hash': fact_ledger_hash,
1006
  'english_commit_text': str(english_commit_text or ''),
1007
  })
 
1044
  source = str(source_text or '').strip()
1045
  if not source:
1046
  return translation_sections, translation_glossary
1047
+ identity = self._translation_identity_bundle(agent, source)
1048
+ source_hash = identity['source_hash']
1049
+ compiled_source_hash = identity['compiled_source_hash']
1050
+ canonical_translation_source = identity['compiled_source']
1051
  contract_version = TRANSLATION_CONTRACT_VERSION
1052
+ fact_ledger_hash = identity['fact_ledger_hash']
1053
  existing = dict((translation_sections or {}).get(unit_key) or {})
1054
  if not isinstance(getattr(self, '_translation_progress_queued', None), set):
1055
  self._translation_progress_queued = set()
 
1060
  if (
1061
  existing.get('qa_status') in {'passed', 'passed_with_warning'}
1062
  and existing.get('source_hash') == source_hash
1063
+ and existing.get('compiled_source_hash') == compiled_source_hash
1064
  and existing.get('target_lang') == target_lang
1065
  and existing.get('translation_contract_version') == contract_version
1066
  and existing.get('fact_ledger_hash') == fact_ledger_hash
 
1068
  ):
1069
  self._translation_progress_inflight.discard(str(unit_key))
1070
  return translation_sections, translation_glossary
1071
+ if existing and existing.get('translated_text'):
1072
+ reasons = []
1073
+ if existing.get('source_hash') != source_hash: reasons.append('english_source_hash')
1074
+ if existing.get('compiled_source_hash') != compiled_source_hash: reasons.append('compiled_source_hash')
1075
+ if existing.get('fact_ledger_hash') != fact_ledger_hash: reasons.append('fact_ledger_hash')
1076
+ if existing.get('translation_contract_version') != contract_version: reasons.append('contract_version')
1077
+ if existing.get('target_lang') != target_lang: reasons.append('target_lang')
1078
+ if existing.get('qa_status') not in {'passed', 'passed_with_warning'}: reasons.append('qa_status')
1079
+ logging.info('Translation reuse miss | unit=%s phase=final_or_direct reasons=%s', unit_name, ','.join(reasons) or 'unknown')
1080
  self._translation_progress_inflight.add(str(unit_key))
1081
  partial = self._assemble_partial_translation(translation_sections, adaptive_structure, include_front=include_front)
1082
  self._publish_target_preview(
 
1116
  translation_source, pending_quote_tags, reused_quote_tags = self._prepare_qualitative_translation_source(
1117
  source, getattr(self, "_qualitative_quote_sources", {}), quote_ledger
1118
  )
1119
+ translation_source = str(translation_source or '').strip()
1120
+ if translation_source != canonical_translation_source:
1121
+ raise RuntimeError('Canonical translation source changed with Quote Ledger state.')
1122
 
1123
  def publish_translation_stage(stage):
1124
  value = str(stage or '').strip().casefold()
 
1214
  error_code = str(code_method(error) if callable(code_method) else 'TRANSLATION_PENDING')
1215
  updated_sections[unit_key] = {
1216
  'source_hash': source_hash,
1217
+ 'compiled_source_hash': compiled_source_hash,
1218
  'target_lang': target_lang,
1219
  'glossary_version': self._glossary_version(translation_glossary),
1220
  'translation_contract_version': contract_version,
 
1237
  )
1238
  return updated_sections, translation_glossary
1239
  updated_glossary = dict(result.get('glossary') or translation_glossary or {})
1240
+ result_fact_ledger_hash = str(result.get('fact_ledger_hash') or fact_ledger_hash)
1241
+ if result_fact_ledger_hash != fact_ledger_hash:
1242
+ raise RuntimeError('Translation result Fact Ledger identity differs from canonical compiled source.')
1243
  updated_sections[unit_key] = {
1244
  'source_hash': source_hash,
1245
+ 'compiled_source_hash': compiled_source_hash,
1246
  'target_lang': target_lang,
1247
  'glossary_version': self._glossary_version(updated_glossary),
1248
  'translation_contract_version': str(result.get('translation_contract_version') or contract_version),
1249
+ 'fact_ledger_hash': result_fact_ledger_hash,
1250
  'translated_text': str(result.get('text') or '').strip(),
1251
  'qa_status': str(result.get('qa_status') or 'passed'),
1252
  'language_adapter': str(result.get('language_adapter') or ''),
core/pipeline_agent.py CHANGED
@@ -61,7 +61,7 @@ class CitationAuditError(DeepSeekRequestError):
61
 
62
 
63
  class SSciPipelineAgent:
64
- TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v3'
65
 
66
  def __init__(self, ds_key, s2_key=""):
67
  self.ds_key = str(ds_key or "").strip()
@@ -5300,6 +5300,154 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5300
  _order, segments = self._parse_translation_claim_segments(restored, [claim_id])
5301
  return segments[claim_id]
5302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5303
  def _audit_translation_blocks_with_retry(
5304
  self, sources, translations, target_lang, glossary=None, section_name='', max_attempts=2
5305
  ):
@@ -5431,6 +5579,12 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5431
  {block_id: translations[block_id] for block_id in sorted(touched_blocks)},
5432
  target_lang, frozen_glossary, section_name=section_name or 'frozen section', max_attempts=2,
5433
  )
 
 
 
 
 
 
5434
  if quality_issues:
5435
  detail = '; '.join(
5436
  f'SP-P{block_id}-{claim_id}: {",".join(codes)}'
@@ -5438,7 +5592,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5438
  for claim_id, codes in sorted((claim_map or {}).items())
5439
  )
5440
  raise DeepSeekRequestError(
5441
- f'Translation claim-local QA failed for {section_name or "section"}: {detail[:1000]}'
5442
  )
5443
 
5444
  # Rebuild after any localized repairs, then apply deterministic
@@ -5475,6 +5629,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
5475
  message = str(error)
5476
  if (
5477
  message.startswith('Translation claim-local QA failed')
 
5478
  or message.startswith('Translation introduced unowned factual content')
5479
  or message.startswith('Translation order-sensitive relation changed')
5480
  or message.startswith('Translation fact order changed')
 
61
 
62
 
63
  class SSciPipelineAgent:
64
+ TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v4'
65
 
66
  def __init__(self, ds_key, s2_key=""):
67
  self.ds_key = str(ds_key or "").strip()
 
5300
  _order, segments = self._parse_translation_claim_segments(restored, [claim_id])
5301
  return segments[claim_id]
5302
 
5303
+ def _adjudicate_persistent_translation_issues(
5304
+ self, sources, translations, issues, target_lang, glossary=None, section_name=''
5305
+ ):
5306
+ """Independently verify persistent stochastic QA findings before fatality.
5307
+
5308
+ Structural/factual invariants remain owned by deterministic validators.
5309
+ This verifier only decides whether an LLM-raised semantic/linguistic
5310
+ issue that survived bounded claim repair has blocking evidence in the
5311
+ source/target pair. It cannot rewrite text and returns bounded codes.
5312
+ """
5313
+ issue_map = {
5314
+ str(block_id): {
5315
+ str(claim_id): [str(code or '').strip().upper() for code in (codes or []) if str(code or '').strip()]
5316
+ for claim_id, codes in dict(claim_map or {}).items()
5317
+ }
5318
+ for block_id, claim_map in dict(issues or {}).items()
5319
+ }
5320
+ if not issue_map:
5321
+ return {}
5322
+
5323
+ source_map = {str(key): str(value or '').strip() for key, value in dict(sources or {}).items()}
5324
+ target_map = {str(key): str(value or '').strip() for key, value in dict(translations or {}).items()}
5325
+ profile = self._translation_language_profile(target_lang)
5326
+ rows = []
5327
+ for block_id, claim_map in sorted(issue_map.items()):
5328
+ if block_id not in source_map or block_id not in target_map:
5329
+ raise DeepSeekRequestError(f'Translation QA adjudication block identity mismatch: SP-P{block_id}.')
5330
+ source_claims = {item['claim_id']: item['text'] for item in self._translation_claim_units(source_map[block_id])}
5331
+ claim_order = list(source_claims)
5332
+ _order, target_claims = self._parse_translation_claim_segments(target_map[block_id], claim_order)
5333
+ for claim_id, codes in sorted(claim_map.items()):
5334
+ if claim_id not in source_claims or claim_id not in target_claims:
5335
+ raise DeepSeekRequestError(f'Translation QA adjudication claim identity mismatch: SP-P{block_id}-{claim_id}.')
5336
+ for code in codes:
5337
+ # Russian sustained Latin prose already has deterministic
5338
+ # evidence authority inside audit_translation_blocks.
5339
+ if code == 'LANGUAGE_CONTAMINATION' and profile.get('language_code') == 'ru':
5340
+ evidence = self._russian_contamination_evidence(
5341
+ source_claims[claim_id], target_claims[claim_id], glossary
5342
+ )
5343
+ if evidence.get('confirmed'):
5344
+ rows.append({
5345
+ 'id': f'SP-P{block_id}-{claim_id}', 'block_id': block_id,
5346
+ 'claim_id': claim_id, 'code': code, 'english': source_claims[claim_id],
5347
+ 'target': target_claims[claim_id], 'deterministic_confirmed': True,
5348
+ })
5349
+ continue
5350
+ rows.append({
5351
+ 'id': f'SP-P{block_id}-{claim_id}', 'block_id': block_id,
5352
+ 'claim_id': claim_id, 'code': code, 'english': source_claims[claim_id],
5353
+ 'target': target_claims[claim_id], 'deterministic_confirmed': False,
5354
+ })
5355
+
5356
+ confirmed = {}
5357
+ model_rows = [row for row in rows if not row.get('deterministic_confirmed')]
5358
+ for row in rows:
5359
+ if row.get('deterministic_confirmed'):
5360
+ confirmed.setdefault(row['block_id'], {}).setdefault(row['claim_id'], []).append(row['code'])
5361
+ self._record_translation_assurance(
5362
+ 'QA_ADJUDICATION_CONFIRMED', target_lang=target_lang, block_id=row['id'],
5363
+ severity='critical', decision='HOLD_FINAL',
5364
+ detail={'issue_code': row['code'], 'evidence_code': 'UNTRANSLATED_PROSE'},
5365
+ )
5366
+
5367
+ if not model_rows:
5368
+ return confirmed
5369
+
5370
+ allowed_evidence = {
5371
+ 'CLAIM_MISSING','CLAIM_ADDED','POLARITY_CHANGED','CAUSALITY_CHANGED',
5372
+ 'UNCERTAINTY_CHANGED','MEANING_CHANGED','CITATION_MOVED','ACRONYM_MISATTACHED',
5373
+ 'GRAMMATICAL_VIOLATION','GLOSSARY_CONCEPT_MISMATCH','UNTRANSLATED_PROSE',
5374
+ 'ACCEPTABLE_VARIANT','MORPHOLOGICAL_VARIANT','STYLISTIC_PREFERENCE',
5375
+ 'QA_DISAGREEMENT','NO_BLOCKING_EVIDENCE',
5376
+ }
5377
+ payload = {
5378
+ 'section': str(section_name or 'section'),
5379
+ 'target_language': target_lang,
5380
+ 'language_adapter': profile.get('adapter_id'),
5381
+ 'morphology': profile.get('morphology'),
5382
+ 'term_policy': profile.get('term_policy'),
5383
+ 'items': [
5384
+ {'id': row['id'], 'issue_code': row['code'], 'english': row['english'], 'target': row['target']}
5385
+ for row in model_rows
5386
+ ],
5387
+ }
5388
+ system_prompt = f"""You are an independent bilingual translation QA adjudicator for {target_lang}.
5389
+ This is verification-only: do not rewrite, suggest wording, or emit free-form explanations. Each supplied issue already survived two bounded claim-local repair rounds. Confirm a BLOCKING defect only when the English/target pair itself provides clear evidence. Do not treat stylistic preference or a legitimate grammatical/morphological variant as blocking.
5390
+ For Russian, allow required case/number/gender inflection. For Kazakh, allow productive case, possessive, person/plural and vowel-harmony suffixes on the correct term stem. For Chinese, allow natural reordering and sentence reshaping inside the same claim while preserving meaning.
5391
+ Return valid JSON only: {{"items":[{{"id":"SP-P000001-C000001","issue_code":"GRAMMAR","decision":"CONFIRMED_BLOCKING","evidence_code":"GRAMMATICAL_VIOLATION"}}]}}.
5392
+ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one of: {sorted(allowed_evidence)}. Return every supplied id+issue_code pair exactly once."""
5393
+ try:
5394
+ response = self.call_deepseek(
5395
+ system_prompt, json.dumps(payload, ensure_ascii=False),
5396
+ max_tokens=max(1200, min(3200, 600 + 180 * len(model_rows))),
5397
+ temp=0.0, model_role='reasoning', thinking=False, allow_fallback=False,
5398
+ )
5399
+ parsed = self._extract_json_from_text(response)
5400
+ returned = parsed.get('items', []) if isinstance(parsed, dict) else []
5401
+ by_key = {}
5402
+ for item in returned:
5403
+ if not isinstance(item, dict):
5404
+ continue
5405
+ key = (str(item.get('id') or ''), str(item.get('issue_code') or '').strip().upper())
5406
+ by_key[key] = item
5407
+ expected = {(row['id'], row['code']) for row in model_rows}
5408
+ if set(by_key) != expected:
5409
+ raise DeepSeekRequestError(
5410
+ f'Translation QA adjudication identity mismatch: expected={sorted(expected)}, got={sorted(by_key)}.'
5411
+ )
5412
+ for row in model_rows:
5413
+ item = by_key[(row['id'], row['code'])]
5414
+ decision = str(item.get('decision') or '').strip().upper()
5415
+ evidence_code = str(item.get('evidence_code') or '').strip().upper()
5416
+ if decision not in {'CONFIRMED_BLOCKING', 'NOT_CONFIRMED'} or evidence_code not in allowed_evidence:
5417
+ raise DeepSeekRequestError(f'Translation QA adjudication returned invalid bounded decision for {row["id"]}.')
5418
+ if decision == 'CONFIRMED_BLOCKING':
5419
+ confirmed.setdefault(row['block_id'], {}).setdefault(row['claim_id'], []).append(row['code'])
5420
+ self._record_translation_assurance(
5421
+ 'QA_ADJUDICATION_CONFIRMED', target_lang=target_lang, block_id=row['id'],
5422
+ severity='critical', decision='HOLD_FINAL',
5423
+ detail={'issue_code': row['code'], 'evidence_code': evidence_code},
5424
+ )
5425
+ else:
5426
+ self._record_translation_assurance(
5427
+ 'QA_ADJUDICATION_NOT_CONFIRMED', target_lang=target_lang, block_id=row['id'],
5428
+ severity='diagnostic', decision='PASS_WITH_WARNING',
5429
+ detail={'issue_code': row['code'], 'evidence_code': evidence_code},
5430
+ )
5431
+ except PipelineCancelledError:
5432
+ raise
5433
+ except Exception as error:
5434
+ # A verifier outage or malformed verifier payload is not evidence
5435
+ # that the translation is wrong. Deterministic integrity gates have
5436
+ # already run and remain authoritative.
5437
+ self._record_translation_assurance(
5438
+ 'QA_ADJUDICATION_UNCERTAIN', target_lang=target_lang, block_id=section_name or 'section',
5439
+ severity='diagnostic', decision='PASS_WITH_WARNING',
5440
+ detail={'error_type': type(error).__name__, 'items': len(model_rows)},
5441
+ )
5442
+ logging.warning(
5443
+ 'Translation QA adjudication technical fallback: section=%s error=%s',
5444
+ section_name or 'section', self._safe_source_error(error),
5445
+ )
5446
+ return {
5447
+ block_id: {claim_id: list(dict.fromkeys(codes)) for claim_id, codes in claim_map.items() if codes}
5448
+ for block_id, claim_map in confirmed.items() if claim_map
5449
+ }
5450
+
5451
  def _audit_translation_blocks_with_retry(
5452
  self, sources, translations, target_lang, glossary=None, section_name='', max_attempts=2
5453
  ):
 
5579
  {block_id: translations[block_id] for block_id in sorted(touched_blocks)},
5580
  target_lang, frozen_glossary, section_name=section_name or 'frozen section', max_attempts=2,
5581
  )
5582
+ if quality_issues:
5583
+ emit_stage('qa')
5584
+ quality_issues = self._adjudicate_persistent_translation_issues(
5585
+ source_blocks, translations, quality_issues, target_lang, frozen_glossary,
5586
+ section_name=section_name or 'frozen section',
5587
+ )
5588
  if quality_issues:
5589
  detail = '; '.join(
5590
  f'SP-P{block_id}-{claim_id}: {",".join(codes)}'
 
5592
  for claim_id, codes in sorted((claim_map or {}).items())
5593
  )
5594
  raise DeepSeekRequestError(
5595
+ f'Translation claim-local QA confirmed blocking for {section_name or "section"}: {detail[:1000]}'
5596
  )
5597
 
5598
  # Rebuild after any localized repairs, then apply deterministic
 
5629
  message = str(error)
5630
  if (
5631
  message.startswith('Translation claim-local QA failed')
5632
+ or message.startswith('Translation claim-local QA confirmed blocking')
5633
  or message.startswith('Translation introduced unowned factual content')
5634
  or message.startswith('Translation order-sensitive relation changed')
5635
  or message.startswith('Translation fact order changed')