R6A-R4-R4 four-language translatable-payload gate and async progress observability

#42
core/generation_runner.py CHANGED
@@ -183,6 +183,12 @@ class GenerationRunner:
183
  self._translation_worker_collected = 0
184
  self._translation_worker_pending = set()
185
  self._translation_worker_context = {}
 
 
 
 
 
 
186
  self._qualitative_quote_sources = {}
187
  if self.preview_token:
188
  preview_dir = Path(__file__).resolve().parents[1] / "static" / "previews"
@@ -711,6 +717,40 @@ class GenerationRunner:
711
  raise RuntimeError(f"Unresolved target-language qualitative quote tags: {unresolved[:8]}")
712
  return value, ledger
713
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  def _write_preview_payload(self, *, force: bool = False) -> None:
715
  with self._preview_lock:
716
  if self.preview_path is None:
@@ -731,6 +771,7 @@ class GenerationRunner:
731
  "target_english_commit_text": self._preview_visible_text(
732
  self._preview_target_english_commit_text
733
  ),
 
734
  "updated_at": datetime.now(timezone.utc).isoformat(),
735
  }
736
  temporary = self.preview_path.with_suffix(".json.tmp")
@@ -874,7 +915,18 @@ class GenerationRunner:
874
  'adaptive_structure': list(adaptive_structure or []),
875
  'target_lang': str(target_lang or ''),
876
  }
 
 
 
 
 
 
 
 
 
 
877
  self._translation_worker = _OrderedTranslationWorker(self._run_translation_job)
 
878
 
879
  def _run_translation_job(self, job: dict[str, Any]) -> dict[str, Any]:
880
  if self.translation_agent is None:
@@ -912,6 +964,14 @@ class GenerationRunner:
912
  source_hash = self._text_hash(source)
913
  fact_ledger_hash = self._translation_ledger_hash(agent, source)
914
  existing = dict((translation_sections or {}).get(unit_key) or {})
 
 
 
 
 
 
 
 
915
  if (
916
  existing.get('qa_status') in {'passed', 'passed_with_warning'}
917
  and existing.get('source_hash') == source_hash
@@ -924,6 +984,7 @@ class GenerationRunner:
924
  pending_key = (str(unit_key), source_hash, fact_ledger_hash)
925
  if pending_key in self._translation_worker_pending:
926
  return False
 
927
  future = self._translation_worker.submit({
928
  'unit_key': str(unit_key),
929
  'unit_name': str(unit_name),
@@ -937,6 +998,7 @@ class GenerationRunner:
937
  'pending_key': pending_key,
938
  'future': future,
939
  })
 
940
  return True
941
 
942
  def _collect_translation_results(self, translation_sections, translation_glossary, *, block=False):
@@ -950,6 +1012,7 @@ class GenerationRunner:
950
  result = future.result()
951
  sections = dict(result.get('translation_sections') or sections)
952
  glossary = dict(result.get('translation_glossary') or glossary)
 
953
  self._translation_worker_pending.discard(item['pending_key'])
954
  self._translation_worker_collected += 1
955
  return sections, glossary
@@ -973,6 +1036,12 @@ class GenerationRunner:
973
  contract_version = TRANSLATION_CONTRACT_VERSION
974
  fact_ledger_hash = self._translation_ledger_hash(agent, source)
975
  existing = dict((translation_sections or {}).get(unit_key) or {})
 
 
 
 
 
 
976
  if (
977
  existing.get('qa_status') in {'passed', 'passed_with_warning'}
978
  and existing.get('source_hash') == source_hash
@@ -981,7 +1050,9 @@ class GenerationRunner:
981
  and existing.get('fact_ledger_hash') == fact_ledger_hash
982
  and existing.get('translated_text')
983
  ):
 
984
  return translation_sections, translation_glossary
 
985
  partial = self._assemble_partial_translation(translation_sections, adaptive_structure, include_front=include_front)
986
  self._publish_target_preview(
987
  partial, f'translating:{unit_name}', force=True, complete=False,
@@ -1089,6 +1160,8 @@ class GenerationRunner:
1089
  'Translation deferred until final gate | unit=%s code=%s error=%s',
1090
  unit_name, error_code, type(error).__name__,
1091
  )
 
 
1092
  self._publish_target_preview(
1093
  partial, f'translation_repair_pending:{unit_name}', force=True, complete=False,
1094
  english_commit_text=english_commit_text,
@@ -1109,6 +1182,8 @@ class GenerationRunner:
1109
  'completed_at': datetime.now(timezone.utc).isoformat(),
1110
  }
1111
  partial = self._assemble_partial_translation(updated_sections, adaptive_structure, include_front=include_front)
 
 
1112
  self._publish_target_preview(
1113
  partial, f'translation_ready:{unit_name}', force=True, complete=False,
1114
  english_commit_text=english_commit_text,
 
183
  self._translation_worker_collected = 0
184
  self._translation_worker_pending = set()
185
  self._translation_worker_context = {}
186
+ # Display-only observability. These counters never decide task success,
187
+ # checkpoint authority, quota, or translation reuse.
188
+ self._translation_progress_sections = {}
189
+ self._translation_progress_queued = set()
190
+ self._translation_progress_inflight = set()
191
+ self._translation_progress_total = 0
192
  self._qualitative_quote_sources = {}
193
  if self.preview_token:
194
  preview_dir = Path(__file__).resolve().parents[1] / "static" / "previews"
 
717
  raise RuntimeError(f"Unresolved target-language qualitative quote tags: {unresolved[:8]}")
718
  return value, ledger
719
 
720
+ def _translation_progress_snapshot(self) -> dict[str, int]:
721
+ """Return non-authoritative logical target-unit progress for the UI."""
722
+ mapping = dict(getattr(self, '_translation_progress_sections', {}) or {})
723
+ context = getattr(self, '_translation_worker_context', {}) or {}
724
+ tasks = context.get('adaptive_structure') or [] if isinstance(context, dict) else []
725
+ logical_keys = []
726
+ if tasks:
727
+ logical_keys = ['__front_matter__'] + [
728
+ key for key, _title in self._unique_top_level_sections(tasks)
729
+ ]
730
+ logical_keys = list(dict.fromkeys(key for key in logical_keys if key))
731
+ total = int(getattr(self, '_translation_progress_total', 0) or len(logical_keys))
732
+ if total <= 0 and logical_keys:
733
+ total = len(logical_keys)
734
+ qa_passed = 0
735
+ repair_pending = 0
736
+ inflight = set(getattr(self, '_translation_progress_inflight', set()) or set())
737
+ for key in logical_keys:
738
+ record = mapping.get(key) or {}
739
+ status = str(record.get('qa_status') or '')
740
+ if key not in inflight and status in {'passed', 'passed_with_warning'} and record.get('translated_text'):
741
+ qa_passed += 1
742
+ elif status == 'repair_pending':
743
+ repair_pending += 1
744
+ queued = len(set(getattr(self, '_translation_progress_queued', set()) or set()).intersection(logical_keys))
745
+ in_flight = len(inflight.intersection(logical_keys))
746
+ return {
747
+ 'qa_passed': min(qa_passed, total) if total else qa_passed,
748
+ 'repair_pending': min(repair_pending, total) if total else repair_pending,
749
+ 'queued': min(queued, total) if total else queued,
750
+ 'in_flight': min(in_flight, total) if total else in_flight,
751
+ 'total': max(0, total),
752
+ }
753
+
754
  def _write_preview_payload(self, *, force: bool = False) -> None:
755
  with self._preview_lock:
756
  if self.preview_path is None:
 
771
  "target_english_commit_text": self._preview_visible_text(
772
  self._preview_target_english_commit_text
773
  ),
774
+ "target_progress": self._translation_progress_snapshot(),
775
  "updated_at": datetime.now(timezone.utc).isoformat(),
776
  }
777
  temporary = self.preview_path.with_suffix(".json.tmp")
 
915
  'adaptive_structure': list(adaptive_structure or []),
916
  'target_lang': str(target_lang or ''),
917
  }
918
+ logical_keys = ['__front_matter__'] + [
919
+ key for key, _title in self._unique_top_level_sections(adaptive_structure)
920
+ ]
921
+ logical_keys = list(dict.fromkeys(key for key in logical_keys if key))
922
+ self._translation_progress_total = len(logical_keys)
923
+ self._translation_progress_sections = dict(translation_sections or {})
924
+ self._translation_progress_queued = {
925
+ key for key in logical_keys if key in self._translation_progress_sections
926
+ }
927
+ self._translation_progress_inflight = set()
928
  self._translation_worker = _OrderedTranslationWorker(self._run_translation_job)
929
+ self._write_preview_payload(force=True)
930
 
931
  def _run_translation_job(self, job: dict[str, Any]) -> dict[str, Any]:
932
  if self.translation_agent is None:
 
964
  source_hash = self._text_hash(source)
965
  fact_ledger_hash = self._translation_ledger_hash(agent, source)
966
  existing = dict((translation_sections or {}).get(unit_key) or {})
967
+ if not isinstance(getattr(self, '_translation_progress_queued', None), set):
968
+ self._translation_progress_queued = set()
969
+ if not isinstance(getattr(self, '_translation_progress_inflight', None), set):
970
+ self._translation_progress_inflight = set()
971
+ # Do not replace the worker-owned display snapshot with a possibly stale
972
+ # main-thread copy here; collection or the worker completion path updates
973
+ # _translation_progress_sections monotonically.
974
+ self._translation_progress_queued.add(str(unit_key))
975
  if (
976
  existing.get('qa_status') in {'passed', 'passed_with_warning'}
977
  and existing.get('source_hash') == source_hash
 
984
  pending_key = (str(unit_key), source_hash, fact_ledger_hash)
985
  if pending_key in self._translation_worker_pending:
986
  return False
987
+ self._translation_progress_inflight.add(str(unit_key))
988
  future = self._translation_worker.submit({
989
  'unit_key': str(unit_key),
990
  'unit_name': str(unit_name),
 
998
  'pending_key': pending_key,
999
  'future': future,
1000
  })
1001
+ self._write_preview_payload(force=True)
1002
  return True
1003
 
1004
  def _collect_translation_results(self, translation_sections, translation_glossary, *, block=False):
 
1012
  result = future.result()
1013
  sections = dict(result.get('translation_sections') or sections)
1014
  glossary = dict(result.get('translation_glossary') or glossary)
1015
+ self._translation_progress_sections = dict(sections)
1016
  self._translation_worker_pending.discard(item['pending_key'])
1017
  self._translation_worker_collected += 1
1018
  return sections, glossary
 
1036
  contract_version = TRANSLATION_CONTRACT_VERSION
1037
  fact_ledger_hash = self._translation_ledger_hash(agent, source)
1038
  existing = dict((translation_sections or {}).get(unit_key) or {})
1039
+ if not isinstance(getattr(self, '_translation_progress_queued', None), set):
1040
+ self._translation_progress_queued = set()
1041
+ if not isinstance(getattr(self, '_translation_progress_inflight', None), set):
1042
+ self._translation_progress_inflight = set()
1043
+ self._translation_progress_sections = dict(translation_sections or {})
1044
+ self._translation_progress_queued.add(str(unit_key))
1045
  if (
1046
  existing.get('qa_status') in {'passed', 'passed_with_warning'}
1047
  and existing.get('source_hash') == source_hash
 
1050
  and existing.get('fact_ledger_hash') == fact_ledger_hash
1051
  and existing.get('translated_text')
1052
  ):
1053
+ self._translation_progress_inflight.discard(str(unit_key))
1054
  return translation_sections, translation_glossary
1055
+ self._translation_progress_inflight.add(str(unit_key))
1056
  partial = self._assemble_partial_translation(translation_sections, adaptive_structure, include_front=include_front)
1057
  self._publish_target_preview(
1058
  partial, f'translating:{unit_name}', force=True, complete=False,
 
1160
  'Translation deferred until final gate | unit=%s code=%s error=%s',
1161
  unit_name, error_code, type(error).__name__,
1162
  )
1163
+ self._translation_progress_sections = dict(updated_sections)
1164
+ self._translation_progress_inflight.discard(str(unit_key))
1165
  self._publish_target_preview(
1166
  partial, f'translation_repair_pending:{unit_name}', force=True, complete=False,
1167
  english_commit_text=english_commit_text,
 
1182
  'completed_at': datetime.now(timezone.utc).isoformat(),
1183
  }
1184
  partial = self._assemble_partial_translation(updated_sections, adaptive_structure, include_front=include_front)
1185
+ self._translation_progress_sections = dict(updated_sections)
1186
+ self._translation_progress_inflight.discard(str(unit_key))
1187
  self._publish_target_preview(
1188
  partial, f'translation_ready:{unit_name}', force=True, complete=False,
1189
  english_commit_text=english_commit_text,
core/pipeline_agent.py CHANGED
@@ -4873,6 +4873,32 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4873
  parsed[match.group(1)] = translated
4874
  return parsed
4875
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4876
  def _validate_translation_block(self, source, translated, block_id, target_lang):
4877
  """Hierarchical multilingual block assurance.
4878
 
@@ -4883,10 +4909,22 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4883
  source_compact = re.sub(r'\s+', '', str(source or ''))
4884
  target_compact = re.sub(r'\s+', '', str(translated or ''))
4885
  profile = self._translation_language_profile(target_lang)
 
 
4886
  evidence = []
4887
 
4888
  if source_compact and source_compact.casefold() == target_compact.casefold():
4889
- evidence.append({'code': 'UNCHANGED_SOURCE', 'severity': 'repair'})
 
 
 
 
 
 
 
 
 
 
4890
 
4891
  if len(source_compact) >= 120:
4892
  minimum = float(APP_CONFIG.get(profile['surface_min_key'], profile['surface_min_default']))
@@ -4897,7 +4935,10 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4897
  elif ratio > maximum:
4898
  evidence.append({'code': 'SURFACE_RATIO_HIGH', 'severity': 'diagnostic', 'ratio': round(ratio, 4), 'threshold': maximum})
4899
 
4900
- if len(target_compact) >= 40:
 
 
 
4901
  if profile['language_code'] == 'zh':
4902
  han = len(re.findall(r'[\u4e00-\u9fff]', str(translated or '')))
4903
  if han == 0:
 
4873
  parsed[match.group(1)] = translated
4874
  return parsed
4875
 
4876
+ @classmethod
4877
+ def _translation_translatable_payload_profile(cls, source):
4878
+ """Classify source content left after Translation-IR immutable anchors.
4879
+
4880
+ Equality is only suspicious when the source still contains lexical
4881
+ payload after server-owned immutable facts/names/IDs/formulas have been
4882
+ replaced by SP-T anchors. This classifier is source-side and therefore
4883
+ shared by Chinese, Russian, and Kazakh without language-specific
4884
+ exceptions. English remains the frozen authority.
4885
+ """
4886
+ ir = cls._compile_translation_ir(source)
4887
+ protected = str(ir.get('protected_source') or '')
4888
+ residual = re.sub(r'\[\[SP-(?:C\d{6}|T\d{6})\]\]', ' ', protected)
4889
+ # Markdown structure and punctuation carry no lexical translation
4890
+ # obligation by themselves. Unicode alphabetic runs are retained so
4891
+ # mixed academic text is never treated as immutable merely because it
4892
+ # is short or contains protected objects.
4893
+ tokens = re.findall(r"[^\W\d_]+(?:['’\-][^\W\d_]+)*", residual, flags=re.UNICODE)
4894
+ lexical = [token for token in tokens if any(char.isalpha() for char in token)]
4895
+ return {
4896
+ 'has_translatable_payload': bool(lexical),
4897
+ 'payload_tokens': len(lexical),
4898
+ 'payload_chars': sum(sum(1 for char in token if char.isalpha()) for token in lexical),
4899
+ 'protected_anchors': len(ir.get('fact_ledger') or {}),
4900
+ }
4901
+
4902
  def _validate_translation_block(self, source, translated, block_id, target_lang):
4903
  """Hierarchical multilingual block assurance.
4904
 
 
4909
  source_compact = re.sub(r'\s+', '', str(source or ''))
4910
  target_compact = re.sub(r'\s+', '', str(translated or ''))
4911
  profile = self._translation_language_profile(target_lang)
4912
+ payload_profile = self._translation_translatable_payload_profile(source)
4913
+ has_translatable_payload = bool(payload_profile.get('has_translatable_payload'))
4914
  evidence = []
4915
 
4916
  if source_compact and source_compact.casefold() == target_compact.casefold():
4917
+ if has_translatable_payload:
4918
+ evidence.append({
4919
+ 'code': 'UNCHANGED_SOURCE', 'severity': 'repair',
4920
+ 'payload_tokens': int(payload_profile.get('payload_tokens') or 0),
4921
+ 'payload_chars': int(payload_profile.get('payload_chars') or 0),
4922
+ })
4923
+ else:
4924
+ evidence.append({
4925
+ 'code': 'UNCHANGED_IMMUTABLE_ONLY', 'severity': 'diagnostic',
4926
+ 'protected_anchors': int(payload_profile.get('protected_anchors') or 0),
4927
+ })
4928
 
4929
  if len(source_compact) >= 120:
4930
  minimum = float(APP_CONFIG.get(profile['surface_min_key'], profile['surface_min_default']))
 
4935
  elif ratio > maximum:
4936
  evidence.append({'code': 'SURFACE_RATIO_HIGH', 'severity': 'diagnostic', 'ratio': round(ratio, 4), 'threshold': maximum})
4937
 
4938
+ # A block made entirely of immutable Translation-IR anchors is not
4939
+ # expected to acquire Han/Cyrillic characters. Script presence is
4940
+ # meaningful only when the English source has lexical payload.
4941
+ if len(target_compact) >= 40 and has_translatable_payload:
4942
  if profile['language_code'] == 'zh':
4943
  han = len(re.findall(r'[\u4e00-\u9fff]', str(translated or '')))
4944
  if han == 0:
smartpaper_restore_bridge/frontend/index.html CHANGED
@@ -386,6 +386,14 @@
386
  } else if (stage === "translation_complete") {
387
  value = String(map.complete || value);
388
  }
 
 
 
 
 
 
 
 
389
  if (!value) return;
390
  const hasTargetText = Boolean(String(payload && payload.target_text || "").trim()) || Boolean(channel.authoritative);
391
  if (hasTargetText) {
 
386
  } else if (stage === "translation_complete") {
387
  value = String(map.complete || value);
388
  }
389
+ // R6A-R4-R4: keep the approved localized status/layout and append only a
390
+ // language-neutral logical QA-passed/total fraction. Unapproved target
391
+ // prose remains hidden until the existing QA publication boundary passes.
392
+ const targetProgress = payload && payload.target_progress && typeof payload.target_progress === "object"
393
+ ? payload.target_progress : {};
394
+ const total = Math.max(0, asCount(targetProgress.total));
395
+ const qaPassed = Math.max(0, Math.min(total || Number.MAX_SAFE_INTEGER, asCount(targetProgress.qa_passed)));
396
+ if (total > 0) value = `${value} · ${qaPassed}/${total}`;
397
  if (!value) return;
398
  const hasTargetText = Boolean(String(payload && payload.target_text || "").trim()) || Boolean(channel.authoritative);
399
  if (hasTargetText) {