Step 4A-6-C-R3.2.8.5 Claim-Bound Translation Compiler + Refresh Diagnostics + 100ms Streaming + Four-Language Arabic Heading Numbering

#23
__pycache__/app.cpython-313.pyc DELETED
Binary file (60.6 kB)
 
__pycache__/config.cpython-313.pyc DELETED
Binary file (31.4 kB)
 
__pycache__/desktop.ini DELETED
@@ -1,2 +0,0 @@
1
- [.ShellClassInfo]
2
- IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
 
 
 
__pycache__/i18n.cpython-313.pyc DELETED
Binary file (76.6 kB)
 
__pycache__/managed_auth.cpython-313.pyc DELETED
Binary file (31 kB)
 
__pycache__/task_executor.cpython-313.pyc DELETED
Binary file (33.6 kB)
 
app.py CHANGED
@@ -709,6 +709,14 @@ def _watch_task_status_live(task: dict) -> dict | None:
709
  key=f"sp_task_watch_{_safe_key(task_id)}",
710
  )
711
  boundary_task = watch_result.get("task") if isinstance(watch_result, dict) and watch_result.get("boundary") else None
 
 
 
 
 
 
 
 
712
  if isinstance(boundary_task, dict) and str(boundary_task.get("task_id") or "") == task_id:
713
  st.session_state["sp_generation_task"] = dict(boundary_task)
714
  st.session_state["sp_last_ui_task_status"] = str(boundary_task.get("status") or "")
 
709
  key=f"sp_task_watch_{_safe_key(task_id)}",
710
  )
711
  boundary_task = watch_result.get("task") if isinstance(watch_result, dict) and watch_result.get("boundary") else None
712
+ if isinstance(watch_result, dict) and watch_result.get("boundary"):
713
+ navdiag = watch_result.get("ui_navdiag")
714
+ if isinstance(navdiag, dict):
715
+ try:
716
+ safe_diag = json.dumps(navdiag, ensure_ascii=False, separators=(",", ":"))[:4000]
717
+ logging.info("[SP_UI_NAVDIAG] task_id=%s snapshot=%s", task_id, safe_diag)
718
+ except Exception:
719
+ logging.info("[SP_UI_NAVDIAG] task_id=%s snapshot_unserializable", task_id)
720
  if isinstance(boundary_task, dict) and str(boundary_task.get("task_id") or "") == task_id:
721
  st.session_state["sp_generation_task"] = dict(boundary_task)
722
  st.session_state["sp_last_ui_task_status"] = str(boundary_task.get("status") or "")
core/__pycache__/checkpoint_store.cpython-313.pyc DELETED
Binary file (4.62 kB)
 
core/__pycache__/desktop.ini DELETED
@@ -1,2 +0,0 @@
1
- [.ShellClassInfo]
2
- IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
 
 
 
core/__pycache__/generation_runner.cpython-313.pyc DELETED
Binary file (65 kB)
 
core/__pycache__/meta_extractor.cpython-313.pyc DELETED
Binary file (3.42 kB)
 
core/__pycache__/pipeline_agent.cpython-313.pyc DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a58cec79f28771e880158bd36b2afd8c77ac1982a61db28ff832a962c41ed79c
3
- size 251613
 
 
 
 
core/__pycache__/production_guard.cpython-313.pyc DELETED
Binary file (11 kB)
 
core/__pycache__/qualitative_engine.cpython-313.pyc DELETED
Binary file (15.3 kB)
 
core/__pycache__/quantitative_engine.cpython-313.pyc DELETED
Binary file (24.4 kB)
 
core/__pycache__/rag_engine.cpython-313.pyc DELETED
Binary file (4.36 kB)
 
core/generation_runner.py CHANGED
@@ -33,7 +33,7 @@ ProgressCallback = Callable[..., None]
33
  MIN_FINAL_REFERENCES = 50
34
  LITERATURE_EVIDENCE_TARGET = 75
35
  CITATION_PLAN_TARGET = 70
36
- TRANSLATION_CONTRACT_VERSION = 'fact-ledger-v1'
37
 
38
 
39
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
 
33
  MIN_FINAL_REFERENCES = 50
34
  LITERATURE_EVIDENCE_TARGET = 75
35
  CITATION_PLAN_TARGET = 70
36
+ TRANSLATION_CONTRACT_VERSION = 'claim-bound-v1'
37
 
38
 
39
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
core/pipeline_agent.py CHANGED
@@ -56,7 +56,7 @@ class CitationAuditError(DeepSeekRequestError):
56
 
57
 
58
  class SSciPipelineAgent:
59
- TRANSLATION_CONTRACT_VERSION = 'fact-ledger-v1'
60
 
61
  def __init__(self, ds_key, s2_key=""):
62
  self.ds_key = str(ds_key or "").strip()
@@ -3310,7 +3310,7 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3310
 
3311
  @staticmethod
3312
  def _translation_prompt(target_lang):
3313
- common = """Translate the supplied blocks from the locked English manuscript faithfully. Preserve every [[SP-Pxxxxxx]] paragraph marker and every [[SP-Txxxxxx]] semantic anchor exactly once. Semantic anchors are typed immutable research objects. Keep each anchor attached to the same source sentence and semantic owner/claim; citation anchors must stay with the same claim. Anchor order may change inside the same sentence only when target-language grammar requires it. Never move an anchor into another sentence or claim. Do not translate, normalize, duplicate, or delete anchor placeholders. Preserve quotation meaning and conclusion direction. Do not add or remove claims. Use concise, accurate, rigorous academic language. Avoid metaphors, analogies, rhetorical questions, promotional language, formulaic transitions, and mechanical repetition. Output only the marked translations."""
3314
  return common + ' ' + SSciPipelineAgent._translation_language_rule(target_lang)
3315
 
3316
  @staticmethod
@@ -3583,8 +3583,73 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3583
  )
3584
 
3585
  @classmethod
3586
- def _protect_translation_tokens(cls, value):
3587
- """Build a private fact ledger and replace model-inaccessible values."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3588
  source = str(value or '')
3589
  mapping = {}
3590
  counter = 0
@@ -3598,32 +3663,65 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3598
  anchor_type = cls._translation_anchor_type(original)
3599
  mapping[placeholder] = {
3600
  'fact_id': f'F{counter:06d}',
3601
- 'source': original, # private server ledger only
3602
  'type': anchor_type,
3603
  'canonical': cls._translation_fact_canonical(original, anchor_type),
3604
  'render_policy': cls._translation_fact_render_policy(anchor_type),
 
 
 
3605
  }
3606
  return placeholder
3607
 
3608
- protected = pattern.sub(replace, source)
3609
- for placeholder, manifest in mapping.items():
3610
- position = protected.find(placeholder)
3611
- manifest['sentence_index'] = cls._translation_sentence_index(protected, position)
3612
- return protected, mapping
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3613
 
3614
  @classmethod
3615
  def _model_safe_translation_manifest(cls, mapping):
3616
- """Expose only placement metadata; factual values remain server-private."""
3617
  safe = []
3618
  for placeholder, raw in (mapping or {}).items():
3619
  item = raw if isinstance(raw, dict) else {'type':'IMMUTABLE'}
3620
- anchor_type = str(item.get('type') or 'IMMUTABLE')
3621
  safe.append({
3622
  'id': placeholder,
3623
  'fact_id': str(item.get('fact_id') or placeholder),
3624
- 'type': anchor_type,
3625
- 'sentence_index': int(item.get('sentence_index') or 0),
3626
- 'mobility': 'same_sentence' if anchor_type in {'DATE','CITATION'} else 'same_owner',
3627
  })
3628
  return safe
3629
 
@@ -3635,10 +3733,10 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3635
  item = raw if isinstance(raw, dict) else {'source':str(raw),'type':'IMMUTABLE'}
3636
  records.append({
3637
  'block_id': str(block_id),
 
3638
  'fact_id': str(item.get('fact_id') or placeholder),
3639
  'type': str(item.get('type') or 'IMMUTABLE'),
3640
  'canonical': str(item.get('canonical') or item.get('source') or ''),
3641
- 'sentence_index': int(item.get('sentence_index') or 0),
3642
  'render_policy': str(item.get('render_policy') or 'exact'),
3643
  })
3644
  payload = json.dumps(records, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
@@ -3646,7 +3744,7 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3646
 
3647
  @classmethod
3648
  def translation_fact_ledger_hash(cls, section_text):
3649
- """Deterministic source ledger hash used by translation checkpoint reuse."""
3650
  units = cls._translation_body_units(section_text)
3651
  ledger = {}
3652
  body_index = 0
@@ -3654,7 +3752,7 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3654
  if unit.get('kind') != 'body':
3655
  continue
3656
  body_index += 1
3657
- _protected, mapping = cls._protect_translation_tokens(unit.get('text') or '')
3658
  ledger[f'{body_index:06d}'] = mapping
3659
  return cls._fact_ledger_hash(ledger)
3660
 
@@ -3668,11 +3766,62 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3668
  return str(item.get('canonical'))
3669
  return str(item.get('source') or '')
3670
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3671
  @classmethod
3672
  def _translation_unowned_objects(cls, protected_target):
3673
- """Find factual objects invented by the model outside SP-T anchors."""
3674
  text = re.sub(r'\[\[SP-T\d{6}\]\]', ' ', str(protected_target or ''))
3675
  text = re.sub(r'\[\[SP-P\d{6}\]\]', ' ', text)
 
3676
  unowned = {
3677
  'numeric': list(cls._numeric_tokens(text).elements()),
3678
  'citation': list(cls._translation_citation_tokens(text).elements()),
@@ -3688,8 +3837,9 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3688
  return {key: values for key, values in unowned.items() if values}
3689
 
3690
  @classmethod
3691
- def _validate_translation_skeleton(cls, value, mapping, block_id=''):
3692
- found = Counter(re.findall(r'\[\[SP-T\d{6}\]\]', str(value or '')))
 
3693
  expected = Counter((mapping or {}).keys())
3694
  if found != expected:
3695
  missing = list((expected - found).elements())[:8]
@@ -3697,47 +3847,78 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3697
  raise DeepSeekRequestError(
3698
  f'Translation protected placeholder mismatch in {block_id}: missing={missing}, added={added}.'
3699
  )
3700
- unowned = cls._translation_unowned_objects(value)
3701
- if unowned:
3702
- safe_summary = {key: len(values) for key, values in unowned.items()}
3703
- logging.warning('Translation fact integrity failed: block=%s unowned=%s', block_id, safe_summary)
3704
- raise DeepSeekRequestError(f'Translation introduced unowned factual content in {block_id}: {safe_summary}.')
3705
- target_text = str(value or '')
 
 
 
 
 
 
3706
  for placeholder, raw_manifest in (mapping or {}).items():
3707
  manifest = raw_manifest if isinstance(raw_manifest, dict) else {'type':'IMMUTABLE'}
3708
- source_sentence = int(manifest.get('sentence_index') or 0)
3709
- target_sentence = cls._translation_sentence_index(target_text, target_text.find(placeholder))
3710
- if target_sentence != source_sentence:
3711
  raise DeepSeekRequestError(
3712
- f'Translation fact moved across sentence boundary in {block_id}: {manifest.get("fact_id") or placeholder}.'
3713
  )
3714
- # Preserve the relative order of hard quantitative/identifier facts
3715
- # inside each claim. DATE/CITATION/NAME may legitimately move for target
3716
- # grammar, but two sample/statistical values must never trade owners.
3717
  ordered_types = {'NUMBER','QUANTITY','STAT','HYPOTHESIS','DOI_URL','FORMULA','QUOTE_ID'}
3718
- for sentence_index in sorted({int((m if isinstance(m, dict) else {}).get('sentence_index') or 0) for m in (mapping or {}).values()}):
 
3719
  expected_order = [
3720
- ph for ph, raw in (mapping or {}).items()
3721
- if int((raw if isinstance(raw, dict) else {}).get('sentence_index') or 0) == sentence_index
3722
  and str((raw if isinstance(raw, dict) else {}).get('type') or '') in ordered_types
3723
  ]
3724
  if len(expected_order) > 1:
3725
- actual_order = sorted(expected_order, key=lambda ph: target_text.find(ph))
3726
  if actual_order != expected_order:
3727
  raise DeepSeekRequestError(
3728
- f'Translation fact order changed within owner sentence in {block_id}: sentence={sentence_index}.'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3729
  )
3730
  return True
3731
 
3732
  @classmethod
3733
- def _restore_translation_tokens(cls, value, mapping, block_id='', target_lang=''):
3734
  text = str(value or '')
3735
- cls._validate_translation_skeleton(text, mapping, block_id)
3736
  for placeholder, manifest in (mapping or {}).items():
3737
  text = text.replace(placeholder, cls._render_translation_fact(manifest, target_lang))
3738
  if re.search(r'\[\[SP-T\d{6}\]\]', text):
3739
  raise DeepSeekRequestError(f'Translation left unresolved protected placeholders in {block_id}.')
3740
- return text
3741
 
3742
  def extend_translation_glossary(self, body, target_lang, existing=None):
3743
  """Extend a task-level glossary without remapping an existing term."""
@@ -3826,12 +4007,7 @@ SECTION: {section_name}
3826
  return True
3827
 
3828
  def audit_translation_blocks(self, sources, translations, target_lang, glossary=None, section_name=''):
3829
- """Return block-localized translation QA issues without rewriting good blocks.
3830
-
3831
- This combines semantic-equivalence and target-language quality review so
3832
- a failed paragraph can be regenerated independently rather than forcing
3833
- a whole-section translation retry.
3834
- """
3835
  source_map = {str(key): str(value or '').strip() for key, value in dict(sources or {}).items()}
3836
  target_map = {str(key): str(value or '').strip() for key, value in dict(translations or {}).items()}
3837
  expected_ids = [key for key in source_map if key in target_map]
@@ -3840,59 +4016,72 @@ SECTION: {section_name}
3840
  if not expected_ids:
3841
  return {}
3842
 
 
 
 
 
 
 
3843
  glossary_value = dict(glossary or {})
3844
- batch_size = 6
 
 
 
 
 
 
 
 
 
 
 
 
 
3845
  issues = {}
3846
- for offset in range(0, len(expected_ids), batch_size):
3847
- batch_ids = expected_ids[offset:offset + batch_size]
3848
- relevant_glossary = {}
3849
- joined_source = '\n'.join(source_map[block_id] for block_id in batch_ids)
3850
- for source_term, target_term in glossary_value.items():
3851
- if source_term and str(source_term) in joined_source:
3852
- relevant_glossary[str(source_term)] = str(target_term)
 
 
3853
  payload = {
3854
  'section': str(section_name or 'section'),
3855
  'target_language': target_lang,
3856
  'glossary': relevant_glossary,
3857
- 'items': [
3858
- {
3859
- 'id': f'SP-P{block_id}',
3860
- 'english': source_map[block_id],
3861
- 'target': target_map[block_id],
3862
- }
3863
- for block_id in batch_ids
3864
- ],
3865
  }
3866
- system_prompt = f"""You are a bilingual academic translation QA reviewer for {target_lang}.
3867
- Review EACH supplied block independently. Do not rewrite. Check: (1) no substantive claim is omitted or added; (2) negation, direction, uncertainty, association-versus-causation strength, hypothesis/result status, limitations, and quotation meaning are preserved; (3) the target is natural, grammatically correct academic {target_lang}, not language-contaminated; (4) frozen glossary terms are used consistently, allowing only grammatical inflection where the target language requires it.
3868
- For Russian, reject noticeable Kazakh-language contamination. For Kazakh, reject Russian wording/syntax masquerading as Kazakh and require natural Kazakh morphology. For Simplified Chinese, require natural academic Chinese while preserving protected research objects.
3869
- Return valid JSON only: {{"items":[{{"id":"SP-P000001","ok":true,"issues":[]}}]}}. Return every supplied id exactly once."""
3870
  response = self.call_deepseek(
3871
- system_prompt,
3872
- json.dumps(payload, ensure_ascii=False),
3873
- max_tokens=max(1200, min(3600, 700 + 420 * len(batch_ids))),
3874
  temp=0.0, model_role='reasoning', thinking=False, allow_fallback=False,
3875
  )
3876
  parsed = self._extract_json_from_text(response)
3877
- rows = parsed.get('items', []) if isinstance(parsed, dict) else []
3878
- by_id = {}
3879
- for row in rows:
3880
- if not isinstance(row, dict):
3881
- continue
3882
- raw_id = str(row.get('id') or '').strip()
3883
- match = re.fullmatch(r'SP-P(\d{6})', raw_id)
3884
- if match and match.group(1) in batch_ids and match.group(1) not in by_id:
3885
- by_id[match.group(1)] = row
3886
- if set(by_id) != set(batch_ids):
3887
  raise DeepSeekRequestError(
3888
- f'Translation block QA id mismatch: expected={batch_ids}, got={sorted(by_id)}.'
3889
  )
3890
- for block_id in batch_ids:
3891
- row = by_id[block_id]
3892
- if row.get('ok') is True:
 
3893
  continue
3894
- detail = [str(item).strip() for item in (row.get('issues') or []) if str(item).strip()]
3895
- issues[block_id] = detail[:5] or ['translation quality was not confirmed']
 
 
 
 
3896
  return issues
3897
 
3898
  @staticmethod
@@ -4032,20 +4221,16 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4032
  return translated
4033
 
4034
  def _translate_marked_batch_with_recovery(self, batch, system_prompt, target_lang, _strict=False):
4035
- """Translate one marked batch with protected tokens and bounded recovery.
4036
-
4037
- Multi-block marker/protected-token failures are binary-split first. A
4038
- single block may accept a marker-free response only when it is the sole
4039
- source, after which every protected object and existing integrity gate
4040
- must still pass.
4041
- """
4042
  expected_ids = [block_id for block_id, _ in batch]
4043
  protected_batch = []
4044
  protected_maps = {}
 
4045
  for block_id, source in batch:
4046
- protected_source, token_map = self._protect_translation_tokens(source)
4047
  protected_batch.append((block_id, source, protected_source))
4048
  protected_maps[block_id] = token_map
 
4049
  marked_source = '\n\n'.join(
4050
  f'[[SP-P{block_id}]]\n{protected_source}'
4051
  for block_id, _, protected_source in protected_batch
@@ -4058,23 +4243,20 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4058
  '\n<SEMANTIC_ANCHOR_MANIFEST>\n'
4059
  + json.dumps(semantic_manifest, ensure_ascii=False)
4060
  + '\n</SEMANTIC_ANCHOR_MANIFEST>\n'
4061
- 'This manifest intentionally contains no factual values. Use only fact type and sentence ownership to place each placeholder. '
4062
- 'Never infer, spell out, calculate, or recreate a hidden number, date, citation, acronym, name, formula, or identifier. '
4063
- 'Do not print the manifest. DATE and CITATION anchors may move only within their original sentence when grammar requires it.'
4064
  )
4065
  if _strict:
4066
  effective_prompt += (
4067
- '\nSTRICT RETRY: Copy every SP-P and SP-T marker byte-for-byte. Do not merge, drop, translate, '
4068
- 'pluralize, or normalize any protected token. Reposition an SP-T only inside the same sentence when target-language grammar requires it; never move it to another claim.'
4069
  )
4070
  result = self.call_deepseek(
4071
  effective_prompt, marked_source,
4072
  max_tokens=max(1800, min(9000, self._count_tokens(marked_source) * 2 + 1200)),
4073
  temp=0.0, model_role='translation', thinking=False, allow_fallback=False,
4074
  )
4075
- # Translation is non-destructive: never run generic manuscript cleanup
4076
- # on target-language text. Marker/placeholder parsing rejects malformed
4077
- # output instead of deleting potentially legitimate manuscript lines.
4078
  try:
4079
  parsed = self._parse_marked_translation(result, expected_ids)
4080
  except DeepSeekRequestError as error:
@@ -4117,19 +4299,18 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4117
  failed = {}
4118
  for block_id, source in batch:
4119
  try:
4120
- translated = self._restore_translation_tokens(
4121
- parsed[block_id], protected_maps.get(block_id, {}), f'SP-P{block_id}', target_lang
4122
- )
4123
- restored[block_id] = self._validate_translation_block_protected(
4124
- block_id, source, translated, target_lang
4125
  )
 
 
 
4126
  except DeepSeekRequestError as error:
4127
  failed[block_id] = error
4128
 
4129
  if failed:
4130
  if _strict:
4131
- # A strict isolated retry already used the protected gateway.
4132
- # Failing again is fail-closed; never fall back to raw source.
4133
  first_id = next(iter(failed))
4134
  raise failed[first_id]
4135
  source_by_id = {block_id: source for block_id, source in batch}
@@ -4144,6 +4325,36 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4144
  restored[block_id] = recovered[block_id]
4145
  return restored
4146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4147
  def _audit_translation_blocks_with_retry(
4148
  self, sources, translations, target_lang, glossary=None, section_name='', max_attempts=2
4149
  ):
@@ -4214,35 +4425,45 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4214
  local_round = 0
4215
  while quality_issues and local_round < 2:
4216
  local_round += 1
4217
- failed_ids = sorted(quality_issues)
 
 
 
 
4218
  logging.warning(
4219
- 'Translation block-local QA recovery: section=%s round=%s failed=%s',
4220
- section_name or 'section', local_round, failed_ids,
 
4221
  )
4222
- for block_id in failed_ids:
4223
- details = '; '.join(quality_issues.get(block_id) or [])[:700]
4224
- quality_prompt = (
4225
- system_prompt
4226
- + '\nQUALITY RETRY FOR ONE BLOCK: Correct only the translation-quality issue(s) listed below. '
4227
- 'Preserve every protected placeholder and SP-P marker exactly. Do not add claims.\n'
4228
- + details
4229
- )
4230
- retry = self._translate_marked_batch_with_recovery(
4231
- [(block_id, source_blocks[block_id])], quality_prompt, target_lang, _strict=True
 
 
 
4232
  )
4233
- translations[block_id] = retry[block_id]
 
4234
  quality_issues = self._audit_translation_blocks_with_retry(
4235
- {block_id: source_blocks[block_id] for block_id in failed_ids},
4236
- {block_id: translations[block_id] for block_id in failed_ids},
4237
  target_lang, frozen_glossary, section_name=section_name or 'frozen section', max_attempts=2,
4238
  )
4239
  if quality_issues:
4240
  detail = '; '.join(
4241
- f'SP-P{block_id}: {"; ".join(messages)}'
4242
- for block_id, messages in sorted(quality_issues.items())
 
4243
  )
4244
  raise DeepSeekRequestError(
4245
- f'Translation block-local QA failed for {section_name or "section"}: {detail[:1000]}'
4246
  )
4247
 
4248
  # Rebuild after any localized repairs, then apply deterministic
@@ -4257,7 +4478,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4257
  output_units.append('#' * int(unit['level']) + ' ' + translated_heading)
4258
  else:
4259
  body_index += 1
4260
- output_units.append(translations[f'{body_index:06d}'])
4261
  output = '\n\n'.join(output_units).strip()
4262
  self.validate_translation_alignment(value, output)
4263
  self._validate_translation_language_quality(output, target_lang)
@@ -4275,7 +4496,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4275
  last_error = error
4276
  message = str(error)
4277
  if (
4278
- message.startswith('Translation block-local QA failed')
4279
  or message.startswith('Translation alignment')
4280
  or message.startswith('Translation changed ')
4281
  or message.startswith('Translation protected ')
@@ -4342,7 +4563,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
4342
  output_units.append('#' * int(unit['level']) + ' ' + translated_heading)
4343
  else:
4344
  body_index += 1
4345
- output_units.append(translations[f'{body_index:06d}'])
4346
 
4347
  output = '\n\n'.join(output_units).strip()
4348
  if references:
 
56
 
57
 
58
  class SSciPipelineAgent:
59
+ TRANSLATION_CONTRACT_VERSION = 'claim-bound-v1'
60
 
61
  def __init__(self, ds_key, s2_key=""):
62
  self.ds_key = str(ds_key or "").strip()
 
3310
 
3311
  @staticmethod
3312
  def _translation_prompt(target_lang):
3313
+ common = """Translate the supplied blocks from the locked English manuscript faithfully. Preserve every [[SP-Pxxxxxx]] paragraph marker, [[SP-Cxxxxxx]] claim marker, and [[SP-Txxxxxx]] semantic anchor exactly once. Semantic anchors are typed immutable research objects. Keep each anchor attached to the same SP-C claim; citation anchors must stay with the same claim. Target-language sentence count and punctuation may differ naturally inside a claim. Never move an anchor into another claim. Do not translate, normalize, duplicate, or delete anchor placeholders. Preserve quotation meaning and conclusion direction. Do not add or remove claims. Use concise, accurate, rigorous academic language. Avoid metaphors, analogies, rhetorical questions, promotional language, formulaic transitions, and mechanical repetition. Output only the marked translations."""
3314
  return common + ' ' + SSciPipelineAgent._translation_language_rule(target_lang)
3315
 
3316
  @staticmethod
 
3583
  )
3584
 
3585
  @classmethod
3586
+ def _translation_claim_units(cls, value):
3587
+ """Create stable source-side claim IDs without constraining target punctuation."""
3588
+ text = str(value or '').strip()
3589
+ if not text:
3590
+ return []
3591
+ abbreviations = ('e.g.', 'i.e.', 'et al.', 'fig.', 'dr.', 'mr.', 'ms.', 'prof.', 'vs.', 'no.', 'vol.')
3592
+ boundaries = []
3593
+ for match in re.finditer(r'[.!?](?=\s+(?:["“‘\[]?[A-Z0-9])|$)', text):
3594
+ end = match.end()
3595
+ prefix = text[:end].casefold()
3596
+ is_abbrev = any(re.search(rf'(?:^|[^a-z]){re.escape(item)}$', prefix) for item in abbreviations)
3597
+ if not is_abbrev:
3598
+ boundaries.append(end)
3599
+ claims = []
3600
+ start = 0
3601
+ for end in boundaries:
3602
+ part = text[start:end].strip()
3603
+ if part:
3604
+ claims.append(part)
3605
+ start = end
3606
+ tail = text[start:].strip()
3607
+ if tail:
3608
+ claims.append(tail)
3609
+ return [
3610
+ {'claim_id': f'C{index:06d}', 'text': claim}
3611
+ for index, claim in enumerate(claims or [text], start=1)
3612
+ ]
3613
+
3614
+ @classmethod
3615
+ def _translation_lexical_quantifier_candidates(cls, value):
3616
+ """High-confidence source lexical quantities that may render as Arabic digits.
3617
+
3618
+ These candidates are private attribution evidence, not model-visible facts.
3619
+ Rhetorical ordinals are intentionally excluded unless attached to a bounded
3620
+ research/structural noun.
3621
+ """
3622
+ text = str(value or '')
3623
+ cardinal = {
3624
+ 'zero':'0','one':'1','two':'2','three':'3','four':'4','five':'5','six':'6',
3625
+ 'seven':'7','eight':'8','nine':'9','ten':'10','eleven':'11','twelve':'12',
3626
+ }
3627
+ ordinal = {
3628
+ 'first':'1','second':'2','third':'3','fourth':'4','fifth':'5','sixth':'6',
3629
+ 'seventh':'7','eighth':'8','ninth':'9','tenth':'10','eleventh':'11','twelfth':'12',
3630
+ }
3631
+ measurable_nouns = (
3632
+ r'mechanisms?|groups?|stages?|steps?|waves?|models?|hypotheses?|dimensions?|factors?|items?|'
3633
+ r'rounds?|phases?|samples?|studies?|analyses?|conditions?|paths?|constructs?|measures?|components?|'
3634
+ r'categories?|levels?|questions?|options?|variables?|sections?|claims?|explanations?|participants?|teams?'
3635
+ )
3636
+ found = []
3637
+ cardinal_words = '|'.join(cardinal)
3638
+ for match in re.finditer(rf'(?i)\b({cardinal_words})\s+({measurable_nouns})\b', text):
3639
+ found.append(cardinal[match.group(1).casefold()])
3640
+ ordinal_words = '|'.join(ordinal)
3641
+ for match in re.finditer(rf'(?i)\b({ordinal_words})\s+({measurable_nouns})\b', text):
3642
+ found.append(ordinal[match.group(1).casefold()])
3643
+ for match in re.finditer(r'(?i)\b(twice|twofold|threefold|fourfold)\b', text):
3644
+ found.append({'twice':'2','twofold':'2','threefold':'3','fourfold':'4'}[match.group(1).casefold()])
3645
+ # one-to-one is a relational form with two explicit quantitative roles.
3646
+ if re.search(r'(?i)\bone[- ]to[- ]one\b', text):
3647
+ found.extend(['1', '1'])
3648
+ return Counter(found)
3649
+
3650
+ @classmethod
3651
+ def _protect_translation_tokens(cls, value, claim_id='C000001'):
3652
+ """Protect one claim while keeping factual values in a private server ledger."""
3653
  source = str(value or '')
3654
  mapping = {}
3655
  counter = 0
 
3663
  anchor_type = cls._translation_anchor_type(original)
3664
  mapping[placeholder] = {
3665
  'fact_id': f'F{counter:06d}',
3666
+ 'source': original,
3667
  'type': anchor_type,
3668
  'canonical': cls._translation_fact_canonical(original, anchor_type),
3669
  'render_policy': cls._translation_fact_render_policy(anchor_type),
3670
+ 'claim_id': str(claim_id or 'C000001'),
3671
+ # Kept only as diagnostic compatibility; never authoritative.
3672
+ 'sentence_index': 0,
3673
  }
3674
  return placeholder
3675
 
3676
+ return pattern.sub(replace, source), mapping
3677
+
3678
+ @classmethod
3679
+ def _protect_translation_claims(cls, value):
3680
+ """Compile a paragraph into claim markers plus a private fact ledger."""
3681
+ claims = cls._translation_claim_units(value)
3682
+ pattern = cls._translation_protected_pattern()
3683
+ mapping = {}
3684
+ claim_sources = {}
3685
+ protected_parts = []
3686
+ fact_counter = 0
3687
+ for claim in claims:
3688
+ claim_id = claim['claim_id']
3689
+ claim_source = claim['text']
3690
+ claim_sources[claim_id] = claim_source
3691
+
3692
+ def replace(match, _claim_id=claim_id):
3693
+ nonlocal fact_counter
3694
+ fact_counter += 1
3695
+ placeholder = f'[[SP-T{fact_counter:06d}]]'
3696
+ original = match.group(0)
3697
+ anchor_type = cls._translation_anchor_type(original)
3698
+ mapping[placeholder] = {
3699
+ 'fact_id': f'F{fact_counter:06d}',
3700
+ 'source': original,
3701
+ 'type': anchor_type,
3702
+ 'canonical': cls._translation_fact_canonical(original, anchor_type),
3703
+ 'render_policy': cls._translation_fact_render_policy(anchor_type),
3704
+ 'claim_id': _claim_id,
3705
+ 'sentence_index': 0,
3706
+ }
3707
+ return placeholder
3708
+
3709
+ protected_claim = pattern.sub(replace, claim_source)
3710
+ protected_parts.append(f'[[SP-{claim_id}]]\n{protected_claim}')
3711
+ return '\n'.join(protected_parts), mapping, claim_sources
3712
 
3713
  @classmethod
3714
  def _model_safe_translation_manifest(cls, mapping):
3715
+ """Expose claim/type metadata only; factual values remain server-private."""
3716
  safe = []
3717
  for placeholder, raw in (mapping or {}).items():
3718
  item = raw if isinstance(raw, dict) else {'type':'IMMUTABLE'}
 
3719
  safe.append({
3720
  'id': placeholder,
3721
  'fact_id': str(item.get('fact_id') or placeholder),
3722
+ 'type': str(item.get('type') or 'IMMUTABLE'),
3723
+ 'claim_id': str(item.get('claim_id') or 'C000001'),
3724
+ 'mobility': 'same_claim',
3725
  })
3726
  return safe
3727
 
 
3733
  item = raw if isinstance(raw, dict) else {'source':str(raw),'type':'IMMUTABLE'}
3734
  records.append({
3735
  'block_id': str(block_id),
3736
+ 'claim_id': str(item.get('claim_id') or 'C000001'),
3737
  'fact_id': str(item.get('fact_id') or placeholder),
3738
  'type': str(item.get('type') or 'IMMUTABLE'),
3739
  'canonical': str(item.get('canonical') or item.get('source') or ''),
 
3740
  'render_policy': str(item.get('render_policy') or 'exact'),
3741
  })
3742
  payload = json.dumps(records, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
 
3744
 
3745
  @classmethod
3746
  def translation_fact_ledger_hash(cls, section_text):
3747
+ """Deterministic claim-bound ledger hash used by translation checkpoint reuse."""
3748
  units = cls._translation_body_units(section_text)
3749
  ledger = {}
3750
  body_index = 0
 
3752
  if unit.get('kind') != 'body':
3753
  continue
3754
  body_index += 1
3755
+ _protected, mapping, _claims = cls._protect_translation_claims(unit.get('text') or '')
3756
  ledger[f'{body_index:06d}'] = mapping
3757
  return cls._fact_ledger_hash(ledger)
3758
 
 
3766
  return str(item.get('canonical'))
3767
  return str(item.get('source') or '')
3768
 
3769
+ @staticmethod
3770
+ def _translation_claim_marker(claim_id):
3771
+ return f'[[SP-{claim_id}]]'
3772
+
3773
+ @classmethod
3774
+ def _parse_translation_claim_segments(cls, value, expected_claim_ids=None):
3775
+ text = str(value or '')
3776
+ pattern = re.compile(r'(?m)^\[\[SP-(C\d{6})\]\]\s*$')
3777
+ matches = list(pattern.finditer(text))
3778
+ expected = list(expected_claim_ids or [])
3779
+ if not matches:
3780
+ if len(expected) <= 1:
3781
+ claim_id = expected[0] if expected else 'C000001'
3782
+ return [claim_id], {claim_id: text.strip()}
3783
+ raise DeepSeekRequestError(f'Translation claim marker mismatch: expected={expected}, got=[].')
3784
+ found = [match.group(1) for match in matches]
3785
+ if expected and found != expected:
3786
+ raise DeepSeekRequestError(f'Translation claim marker mismatch: expected={expected}, got={found}.')
3787
+ if text[:matches[0].start()].strip():
3788
+ raise DeepSeekRequestError('Translation output contained unexpected text before the first SP-C marker.')
3789
+ segments = {}
3790
+ for index, match in enumerate(matches):
3791
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
3792
+ segment = text[match.end():end].strip()
3793
+ if not segment:
3794
+ raise DeepSeekRequestError(f'Translation claim {match.group(1)} is empty.')
3795
+ segments[match.group(1)] = segment
3796
+ return found, segments
3797
+
3798
+ @classmethod
3799
+ def _compose_translation_claim_segments(cls, claim_order, segments):
3800
+ return '\n'.join(
3801
+ f'{cls._translation_claim_marker(claim_id)}\n{str(segments.get(claim_id) or "").strip()}'
3802
+ for claim_id in claim_order
3803
+ ).strip()
3804
+
3805
+ @classmethod
3806
+ def _strip_translation_claim_markers(cls, value):
3807
+ text = str(value or '')
3808
+ matches = list(re.finditer(r'(?m)^\[\[SP-(C\d{6})\]\]\s*$', text))
3809
+ if not matches:
3810
+ return text.strip()
3811
+ parts = []
3812
+ for index, match in enumerate(matches):
3813
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
3814
+ part = text[match.end():end].strip()
3815
+ if part:
3816
+ parts.append(part)
3817
+ return ' '.join(parts).strip()
3818
+
3819
  @classmethod
3820
  def _translation_unowned_objects(cls, protected_target):
3821
+ """Find non-anchor factual surfaces; numeric candidates are adjudicated per claim."""
3822
  text = re.sub(r'\[\[SP-T\d{6}\]\]', ' ', str(protected_target or ''))
3823
  text = re.sub(r'\[\[SP-P\d{6}\]\]', ' ', text)
3824
+ text = re.sub(r'\[\[SP-C\d{6}\]\]', ' ', text)
3825
  unowned = {
3826
  'numeric': list(cls._numeric_tokens(text).elements()),
3827
  'citation': list(cls._translation_citation_tokens(text).elements()),
 
3837
  return {key: values for key, values in unowned.items() if values}
3838
 
3839
  @classmethod
3840
+ def _validate_translation_skeleton(cls, value, mapping, block_id='', claim_sources=None):
3841
+ text = str(value or '')
3842
+ found = Counter(re.findall(r'\[\[SP-T\d{6}\]\]', text))
3843
  expected = Counter((mapping or {}).keys())
3844
  if found != expected:
3845
  missing = list((expected - found).elements())[:8]
 
3847
  raise DeepSeekRequestError(
3848
  f'Translation protected placeholder mismatch in {block_id}: missing={missing}, added={added}.'
3849
  )
3850
+
3851
+ private_claims = dict(claim_sources or {})
3852
+ expected_claim_ids = list(private_claims)
3853
+ if not expected_claim_ids:
3854
+ expected_claim_ids = list(dict.fromkeys(
3855
+ str((raw if isinstance(raw, dict) else {}).get('claim_id') or 'C000001')
3856
+ for raw in (mapping or {}).values()
3857
+ )) or ['C000001']
3858
+ claim_order, target_claims = cls._parse_translation_claim_segments(text, expected_claim_ids)
3859
+
3860
+ # Every protected fact must remain inside its source claim; target
3861
+ # sentence count/punctuation is deliberately non-authoritative.
3862
  for placeholder, raw_manifest in (mapping or {}).items():
3863
  manifest = raw_manifest if isinstance(raw_manifest, dict) else {'type':'IMMUTABLE'}
3864
+ claim_id = str(manifest.get('claim_id') or 'C000001')
3865
+ if placeholder not in str(target_claims.get(claim_id) or ''):
 
3866
  raise DeepSeekRequestError(
3867
+ f'Translation fact moved across claim boundary in {block_id}: {manifest.get("fact_id") or placeholder}.'
3868
  )
3869
+
 
 
3870
  ordered_types = {'NUMBER','QUANTITY','STAT','HYPOTHESIS','DOI_URL','FORMULA','QUOTE_ID'}
3871
+ for claim_id in claim_order:
3872
+ target_claim = str(target_claims.get(claim_id) or '')
3873
  expected_order = [
3874
+ placeholder for placeholder, raw in (mapping or {}).items()
3875
+ if str((raw if isinstance(raw, dict) else {}).get('claim_id') or 'C000001') == claim_id
3876
  and str((raw if isinstance(raw, dict) else {}).get('type') or '') in ordered_types
3877
  ]
3878
  if len(expected_order) > 1:
3879
+ actual_order = sorted(expected_order, key=lambda placeholder: target_claim.find(placeholder))
3880
  if actual_order != expected_order:
3881
  raise DeepSeekRequestError(
3882
+ f'Translation fact order changed within owner claim in {block_id}: claim={claim_id}.'
3883
+ )
3884
+
3885
+ unowned = cls._translation_unowned_objects(target_claim)
3886
+ numeric = list(unowned.pop('numeric', []))
3887
+ if unowned:
3888
+ safe_summary = {key: len(values) for key, values in unowned.items()}
3889
+ logging.warning('Translation fact integrity failed: block=%s claim=%s unowned=%s', block_id, claim_id, safe_summary)
3890
+ raise DeepSeekRequestError(
3891
+ f'Translation introduced unowned factual content in {block_id} claim={claim_id}: {safe_summary}.'
3892
+ )
3893
+ if numeric:
3894
+ allowed = cls._translation_lexical_quantifier_candidates(private_claims.get(claim_id, ''))
3895
+ unresolved = []
3896
+ for token in numeric:
3897
+ canonical = cls._canonicalize_numeric_fact(token)
3898
+ if allowed.get(canonical, 0) > 0:
3899
+ allowed[canonical] -= 1
3900
+ else:
3901
+ unresolved.append(token)
3902
+ if unresolved:
3903
+ safe_summary = {'numeric': len(unresolved)}
3904
+ logging.warning(
3905
+ 'Translation fact integrity failed: block=%s claim=%s unowned=%s suspect_tokens=%s',
3906
+ block_id, claim_id, safe_summary, unresolved[:4],
3907
+ )
3908
+ raise DeepSeekRequestError(
3909
+ f'Translation introduced unowned factual content in {block_id} claim={claim_id}: {safe_summary}.'
3910
  )
3911
  return True
3912
 
3913
  @classmethod
3914
+ def _restore_translation_tokens(cls, value, mapping, block_id='', target_lang='', claim_sources=None, keep_claim_markers=False):
3915
  text = str(value or '')
3916
+ cls._validate_translation_skeleton(text, mapping, block_id, claim_sources=claim_sources)
3917
  for placeholder, manifest in (mapping or {}).items():
3918
  text = text.replace(placeholder, cls._render_translation_fact(manifest, target_lang))
3919
  if re.search(r'\[\[SP-T\d{6}\]\]', text):
3920
  raise DeepSeekRequestError(f'Translation left unresolved protected placeholders in {block_id}.')
3921
+ return text.strip() if keep_claim_markers else cls._strip_translation_claim_markers(text)
3922
 
3923
  def extend_translation_glossary(self, body, target_lang, existing=None):
3924
  """Extend a task-level glossary without remapping an existing term."""
 
4007
  return True
4008
 
4009
  def audit_translation_blocks(self, sources, translations, target_lang, glossary=None, section_name=''):
4010
+ """Return claim-local bounded QA issue codes without rewriting text."""
 
 
 
 
 
4011
  source_map = {str(key): str(value or '').strip() for key, value in dict(sources or {}).items()}
4012
  target_map = {str(key): str(value or '').strip() for key, value in dict(translations or {}).items()}
4013
  expected_ids = [key for key in source_map if key in target_map]
 
4016
  if not expected_ids:
4017
  return {}
4018
 
4019
+ allowed_codes = {
4020
+ 'OMITTED_CLAIM','ADDED_CLAIM','NEGATION_DRIFT','CAUSALITY_STRENGTH_DRIFT',
4021
+ 'UNCERTAINTY_DRIFT','TERMINOLOGY','GRAMMAR','LANGUAGE_CONTAMINATION',
4022
+ 'CITATION_ATTACHMENT','ACRONYM_ATTACHMENT','SEMANTIC_DRIFT',
4023
+ }
4024
+ claim_rows = []
4025
  glossary_value = dict(glossary or {})
4026
+ for block_id in expected_ids:
4027
+ source_claims = self._translation_claim_units(source_map[block_id])
4028
+ claim_order = [item['claim_id'] for item in source_claims]
4029
+ _order, target_claims = self._parse_translation_claim_segments(target_map[block_id], claim_order)
4030
+ for item in source_claims:
4031
+ claim_id = item['claim_id']
4032
+ claim_rows.append({
4033
+ 'id': f'SP-P{block_id}-{claim_id}',
4034
+ 'block_id': block_id,
4035
+ 'claim_id': claim_id,
4036
+ 'english': item['text'],
4037
+ 'target': target_claims[claim_id],
4038
+ })
4039
+
4040
  issues = {}
4041
+ batch_size = 8
4042
+ for offset in range(0, len(claim_rows), batch_size):
4043
+ rows = claim_rows[offset:offset + batch_size]
4044
+ joined_source = '\n'.join(row['english'] for row in rows)
4045
+ relevant_glossary = {
4046
+ str(source_term): str(target_term)
4047
+ for source_term, target_term in glossary_value.items()
4048
+ if source_term and str(source_term) in joined_source
4049
+ }
4050
  payload = {
4051
  'section': str(section_name or 'section'),
4052
  'target_language': target_lang,
4053
  'glossary': relevant_glossary,
4054
+ 'allowed_issue_codes': sorted(allowed_codes),
4055
+ 'items': [{'id': row['id'], 'english': row['english'], 'target': row['target']} for row in rows],
 
 
 
 
 
 
4056
  }
4057
+ system_prompt = f"""You are a bilingual academic translation QA classifier for {target_lang}.
4058
+ Review EACH claim independently. Do not rewrite and do not provide free-form explanations. Check semantic equivalence and language quality: omissions/additions, negation, uncertainty, association-versus-causation strength, terminology, grammar, language contamination, citation attachment, and acronym attachment.
4059
+ Return valid JSON only in this exact form: {{"items":[{{"id":"SP-P000001-C000001","ok":true,"codes":[]}}]}}.
4060
+ If ok=false, codes MUST contain only values from allowed_issue_codes. Never return numbered prose, examples, quotes, or corrective wording. Return every supplied id exactly once."""
4061
  response = self.call_deepseek(
4062
+ system_prompt, json.dumps(payload, ensure_ascii=False),
4063
+ max_tokens=max(1200, min(3600, 650 + 260 * len(rows))),
 
4064
  temp=0.0, model_role='reasoning', thinking=False, allow_fallback=False,
4065
  )
4066
  parsed = self._extract_json_from_text(response)
4067
+ returned = parsed.get('items', []) if isinstance(parsed, dict) else []
4068
+ by_id = {str(row.get('id') or ''): row for row in returned if isinstance(row, dict)}
4069
+ expected_row_ids = [row['id'] for row in rows]
4070
+ if set(by_id) != set(expected_row_ids):
 
 
 
 
 
 
4071
  raise DeepSeekRequestError(
4072
+ f'Translation claim QA id mismatch: expected={expected_row_ids}, got={sorted(by_id)}.'
4073
  )
4074
+ row_lookup = {row['id']: row for row in rows}
4075
+ for row_id in expected_row_ids:
4076
+ returned_row = by_id[row_id]
4077
+ if returned_row.get('ok') is True:
4078
  continue
4079
+ raw_codes = [str(code or '').strip().upper() for code in (returned_row.get('codes') or [])]
4080
+ codes = [code for code in raw_codes if code in allowed_codes]
4081
+ if not codes:
4082
+ codes = ['SEMANTIC_DRIFT']
4083
+ meta = row_lookup[row_id]
4084
+ issues.setdefault(meta['block_id'], {})[meta['claim_id']] = list(dict.fromkeys(codes))[:5]
4085
  return issues
4086
 
4087
  @staticmethod
 
4221
  return translated
4222
 
4223
  def _translate_marked_batch_with_recovery(self, batch, system_prompt, target_lang, _strict=False):
4224
+ """Translate SP-P blocks through claim-bound, value-blind protected skeletons."""
 
 
 
 
 
 
4225
  expected_ids = [block_id for block_id, _ in batch]
4226
  protected_batch = []
4227
  protected_maps = {}
4228
+ claim_sources_by_block = {}
4229
  for block_id, source in batch:
4230
+ protected_source, token_map, claim_sources = self._protect_translation_claims(source)
4231
  protected_batch.append((block_id, source, protected_source))
4232
  protected_maps[block_id] = token_map
4233
+ claim_sources_by_block[block_id] = claim_sources
4234
  marked_source = '\n\n'.join(
4235
  f'[[SP-P{block_id}]]\n{protected_source}'
4236
  for block_id, _, protected_source in protected_batch
 
4243
  '\n<SEMANTIC_ANCHOR_MANIFEST>\n'
4244
  + json.dumps(semantic_manifest, ensure_ascii=False)
4245
  + '\n</SEMANTIC_ANCHOR_MANIFEST>\n'
4246
+ 'This manifest intentionally contains no factual values. Preserve every SP-P, SP-C, and SP-T marker exactly. '
4247
+ 'Each SP-C marker identifies one source claim. A target claim may contain one or more grammatical sentences, but every protected fact must remain inside its original SP-C claim. '
4248
+ 'Never infer, spell out, calculate, or recreate a hidden number, date, citation, acronym, name, formula, or identifier. Do not print the manifest.'
4249
  )
4250
  if _strict:
4251
  effective_prompt += (
4252
+ '\nSTRICT RETRY: Copy every SP-P, SP-C, and SP-T marker byte-for-byte. Do not merge, drop, translate, '
4253
+ 'pluralize, or normalize any protected token. Do not move any SP-T outside its SP-C claim.'
4254
  )
4255
  result = self.call_deepseek(
4256
  effective_prompt, marked_source,
4257
  max_tokens=max(1800, min(9000, self._count_tokens(marked_source) * 2 + 1200)),
4258
  temp=0.0, model_role='translation', thinking=False, allow_fallback=False,
4259
  )
 
 
 
4260
  try:
4261
  parsed = self._parse_marked_translation(result, expected_ids)
4262
  except DeepSeekRequestError as error:
 
4299
  failed = {}
4300
  for block_id, source in batch:
4301
  try:
4302
+ translated_with_claims = self._restore_translation_tokens(
4303
+ parsed[block_id], protected_maps.get(block_id, {}), f'SP-P{block_id}', target_lang,
4304
+ claim_sources=claim_sources_by_block.get(block_id, {}), keep_claim_markers=True,
 
 
4305
  )
4306
+ clean_translation = self._strip_translation_claim_markers(translated_with_claims)
4307
+ self._validate_translation_block_protected(block_id, source, clean_translation, target_lang)
4308
+ restored[block_id] = translated_with_claims
4309
  except DeepSeekRequestError as error:
4310
  failed[block_id] = error
4311
 
4312
  if failed:
4313
  if _strict:
 
 
4314
  first_id = next(iter(failed))
4315
  raise failed[first_id]
4316
  source_by_id = {block_id: source for block_id, source in batch}
 
4325
  restored[block_id] = recovered[block_id]
4326
  return restored
4327
 
4328
+ def _translate_single_claim_with_recovery(self, block_id, claim_id, source_claim, system_prompt, target_lang, issue_codes):
4329
+ """Regenerate one failed claim using bounded QA codes only."""
4330
+ protected_claim, mapping = self._protect_translation_tokens(source_claim, claim_id=claim_id)
4331
+ marked_source = f'[[SP-P{block_id}]]\n[[SP-{claim_id}]]\n{protected_claim}'
4332
+ safe_manifest = self._model_safe_translation_manifest(mapping)
4333
+ codes = [str(code or '').strip().upper() for code in (issue_codes or []) if str(code or '').strip()]
4334
+ effective_prompt = system_prompt + (
4335
+ '\nCLAIM QUALITY RETRY. Repair only this one claim. The only permitted repair signals are the following bounded issue codes: '
4336
+ + json.dumps(codes[:5], ensure_ascii=False)
4337
+ + '. Do not infer any examples or numbered instructions from those codes. Preserve every SP-P, SP-C, and SP-T marker byte-for-byte. '
4338
+ 'Do not add claims or factual content. Return only the marked translated claim.'
4339
+ + '\n<SEMANTIC_ANCHOR_MANIFEST>\n'
4340
+ + json.dumps({block_id: safe_manifest}, ensure_ascii=False)
4341
+ + '\n</SEMANTIC_ANCHOR_MANIFEST>'
4342
+ )
4343
+ result = self.call_deepseek(
4344
+ effective_prompt, marked_source,
4345
+ max_tokens=max(1200, min(2600, self._count_tokens(marked_source) * 2 + 900)),
4346
+ temp=0.0, model_role='translation', thinking=False, allow_fallback=False,
4347
+ )
4348
+ parsed = self._parse_marked_translation(result, [block_id])
4349
+ restored = self._restore_translation_tokens(
4350
+ parsed[block_id], mapping, f'SP-P{block_id}', target_lang,
4351
+ claim_sources={claim_id: source_claim}, keep_claim_markers=True,
4352
+ )
4353
+ clean = self._strip_translation_claim_markers(restored)
4354
+ self._validate_translation_block_protected(block_id, source_claim, clean, target_lang)
4355
+ _order, segments = self._parse_translation_claim_segments(restored, [claim_id])
4356
+ return segments[claim_id]
4357
+
4358
  def _audit_translation_blocks_with_retry(
4359
  self, sources, translations, target_lang, glossary=None, section_name='', max_attempts=2
4360
  ):
 
4425
  local_round = 0
4426
  while quality_issues and local_round < 2:
4427
  local_round += 1
4428
+ failed_pairs = [
4429
+ (block_id, claim_id, codes)
4430
+ for block_id, claim_map in sorted(quality_issues.items())
4431
+ for claim_id, codes in sorted((claim_map or {}).items())
4432
+ ]
4433
  logging.warning(
4434
+ 'Translation claim-local QA recovery: section=%s round=%s failed=%s',
4435
+ section_name or 'section', local_round,
4436
+ [f'SP-P{block_id}-{claim_id}' for block_id, claim_id, _codes in failed_pairs],
4437
  )
4438
+ touched_blocks = set()
4439
+ for block_id, claim_id, codes in failed_pairs:
4440
+ source_claims = {
4441
+ item['claim_id']: item['text'] for item in self._translation_claim_units(source_blocks[block_id])
4442
+ }
4443
+ if claim_id not in source_claims:
4444
+ raise DeepSeekRequestError(
4445
+ f'Translation claim-local QA source mismatch: SP-P{block_id}-{claim_id}.'
4446
+ )
4447
+ claim_order = list(source_claims)
4448
+ _order, segments = self._parse_translation_claim_segments(translations[block_id], claim_order)
4449
+ segments[claim_id] = self._translate_single_claim_with_recovery(
4450
+ block_id, claim_id, source_claims[claim_id], system_prompt, target_lang, codes
4451
  )
4452
+ translations[block_id] = self._compose_translation_claim_segments(claim_order, segments)
4453
+ touched_blocks.add(block_id)
4454
  quality_issues = self._audit_translation_blocks_with_retry(
4455
+ {block_id: source_blocks[block_id] for block_id in sorted(touched_blocks)},
4456
+ {block_id: translations[block_id] for block_id in sorted(touched_blocks)},
4457
  target_lang, frozen_glossary, section_name=section_name or 'frozen section', max_attempts=2,
4458
  )
4459
  if quality_issues:
4460
  detail = '; '.join(
4461
+ f'SP-P{block_id}-{claim_id}: {",".join(codes)}'
4462
+ for block_id, claim_map in sorted(quality_issues.items())
4463
+ for claim_id, codes in sorted((claim_map or {}).items())
4464
  )
4465
  raise DeepSeekRequestError(
4466
+ f'Translation claim-local QA failed for {section_name or "section"}: {detail[:1000]}'
4467
  )
4468
 
4469
  # Rebuild after any localized repairs, then apply deterministic
 
4478
  output_units.append('#' * int(unit['level']) + ' ' + translated_heading)
4479
  else:
4480
  body_index += 1
4481
+ output_units.append(self._strip_translation_claim_markers(translations[f'{body_index:06d}']))
4482
  output = '\n\n'.join(output_units).strip()
4483
  self.validate_translation_alignment(value, output)
4484
  self._validate_translation_language_quality(output, target_lang)
 
4496
  last_error = error
4497
  message = str(error)
4498
  if (
4499
+ message.startswith('Translation claim-local QA failed')
4500
  or message.startswith('Translation alignment')
4501
  or message.startswith('Translation changed ')
4502
  or message.startswith('Translation protected ')
 
4563
  output_units.append('#' * int(unit['level']) + ' ' + translated_heading)
4564
  else:
4565
  body_index += 1
4566
+ output_units.append(self._strip_translation_claim_markers(translations[f'{body_index:06d}']))
4567
 
4568
  output = '\n\n'.join(output_units).strip()
4569
  if references:
smartpaper_restore_bridge/__pycache__/__init__.cpython-313.pyc DELETED
Binary file (3.12 kB)
 
smartpaper_restore_bridge/__pycache__/desktop.ini DELETED
@@ -1,2 +0,0 @@
1
- [.ShellClassInfo]
2
- IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
 
 
 
smartpaper_restore_bridge/frontend/index.html CHANGED
@@ -19,6 +19,8 @@
19
  const MAX_TOKEN_LENGTH = 4096;
20
  const MAX_RESULT_AGE_MS = 10 * 60 * 1000;
21
  const REQUEST_TIMEOUT_MS = 20000;
 
 
22
  const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "cancelled"]);
23
  let lastResponseSignature = "";
24
  let activeTaskWatch = null;
@@ -32,6 +34,86 @@
32
  target:{ authoritative:"", displayed:"", queue:[], timer:null }
33
  };
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  const ACTION_PATHS = Object.freeze({
36
  task_start: "/edge/task/start",
37
  task_cancel: "/edge/task/cancel",
@@ -274,15 +356,63 @@
274
  target.textContent = value;
275
  }
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  function normalizePreviewDisplay(text) {
278
- return String(text || "")
279
- .replace(/\r/g, "")
280
- .replace(/^#{1,3}\s+/gm, "");
281
  }
282
 
283
  function wordLikeUnits(value) {
284
- const matches = String(value || "").match(/[\u4e00-\u9fff]{1,2}|\s+|[^\s\u4e00-\u9fff]+/g);
285
- return matches || [];
 
 
 
 
 
286
  }
287
 
288
  function ensureStreamNode(parentDocument, target, channel) {
@@ -311,7 +441,7 @@
311
  if (!channel.queue.length) return;
312
  const stream = ensureStreamNode(parentDocument, target, channel);
313
  const backlog = channel.queue.length;
314
- const perTick = backlog > 220 ? 3 : backlog > 90 ? 2 : 1;
315
  let chunk = "";
316
  for (let i=0; i<perTick && channel.queue.length; i++) chunk += channel.queue.shift();
317
  if (chunk) {
@@ -319,7 +449,7 @@
319
  channel.displayed += chunk;
320
  if (shell && shell.dataset.spAutoFollow !== "0") shell.scrollTop = shell.scrollHeight;
321
  }
322
- channel.timer = window.setTimeout(() => pumpRenderQueue(parentDocument, target, shell, channel), 60);
323
  }
324
 
325
  function renderBufferedPreview(parentDocument, target, shell, text, channel) {
@@ -528,6 +658,7 @@
528
  const bestEffortPageExitCancel = payload.best_effort_page_exit_cancel === true;
529
  const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, draftStatusLabels, targetStatusLabels, bestEffortPageExitCancel, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
530
  activeTaskWatch = watch;
 
531
  installPageExitCancel(watch);
532
  lastPreviewText = "";
533
  lastTargetPreviewText = "";
@@ -562,12 +693,14 @@
562
  watch.stopped = true;
563
  activeTaskWatch = null;
564
  removePageExitCancel();
 
565
  sendValue(Object.assign({}, result, {
566
  action:"task_watch",
567
  command_id:commandId,
568
  terminal:TERMINAL_TASK_STATUSES.has(status),
569
  boundary:true,
570
- boundary_type:"terminal"
 
571
  }));
572
  return;
573
  }
@@ -624,6 +757,7 @@
624
  sendValue(response);
625
  }
626
 
 
627
  window.addEventListener("message", onRender);
628
  sendMessage("streamlit:componentReady", { apiVersion:1 });
629
  setFrameHeight();
 
19
  const MAX_TOKEN_LENGTH = 4096;
20
  const MAX_RESULT_AGE_MS = 10 * 60 * 1000;
21
  const REQUEST_TIMEOUT_MS = 20000;
22
+ const NAV_DIAG_KEY = "smartpaper.ui.navdiag.v1";
23
+ const NAV_DIAG_MAX_EVENTS = 24;
24
  const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "cancelled"]);
25
  let lastResponseSignature = "";
26
  let activeTaskWatch = null;
 
34
  target:{ authoritative:"", displayed:"", queue:[], timer:null }
35
  };
36
 
37
+
38
+ function readSessionJson(key, fallback=null) {
39
+ try {
40
+ const raw = window.sessionStorage.getItem(key);
41
+ return raw ? JSON.parse(raw) : fallback;
42
+ } catch (_) { return fallback; }
43
+ }
44
+
45
+ function writeSessionJson(key, value) {
46
+ try { window.sessionStorage.setItem(key, JSON.stringify(value)); return true; }
47
+ catch (_) { return false; }
48
+ }
49
+
50
+ function navigationSnapshot() {
51
+ let timeOrigin = 0;
52
+ let navigationType = "unknown";
53
+ try {
54
+ const perf = window.parent && window.parent.performance ? window.parent.performance : window.performance;
55
+ timeOrigin = Number(perf.timeOrigin || 0);
56
+ const entries = perf.getEntriesByType ? perf.getEntriesByType("navigation") : [];
57
+ if (entries && entries[0] && entries[0].type) navigationType = String(entries[0].type);
58
+ } catch (_) {
59
+ try {
60
+ timeOrigin = Number(window.performance.timeOrigin || 0);
61
+ const entries = window.performance.getEntriesByType("navigation");
62
+ if (entries && entries[0] && entries[0].type) navigationType = String(entries[0].type);
63
+ } catch (_) {}
64
+ }
65
+ return { time_origin:timeOrigin, navigation_type:navigationType };
66
+ }
67
+
68
+ function recordNavDiagnostic(kind, detail={}) {
69
+ const current = readSessionJson(NAV_DIAG_KEY, { version:1, mount_count:0, events:[] }) || { version:1, mount_count:0, events:[] };
70
+ if (!Array.isArray(current.events)) current.events = [];
71
+ const snapshot = navigationSnapshot();
72
+ current.last_time_origin = snapshot.time_origin;
73
+ current.navigation_type = snapshot.navigation_type;
74
+ current.events.push(Object.assign({
75
+ t:Date.now(), kind:String(kind || "event"), time_origin:snapshot.time_origin,
76
+ navigation_type:snapshot.navigation_type
77
+ }, detail && typeof detail === "object" ? detail : {}));
78
+ if (current.events.length > NAV_DIAG_MAX_EVENTS) current.events = current.events.slice(-NAV_DIAG_MAX_EVENTS);
79
+ writeSessionJson(NAV_DIAG_KEY, current);
80
+ }
81
+
82
+ function mountNavigationDiagnostics() {
83
+ const current = readSessionJson(NAV_DIAG_KEY, { version:1, mount_count:0, events:[] }) || { version:1, mount_count:0, events:[] };
84
+ current.mount_count = Number(current.mount_count || 0) + 1;
85
+ if (!Array.isArray(current.events)) current.events = [];
86
+ writeSessionJson(NAV_DIAG_KEY, current);
87
+ recordNavDiagnostic("bridge_mount", { mount_count:current.mount_count });
88
+ const add = (target, name, handler) => { try { target && target.addEventListener && target.addEventListener(name, handler); } catch (_) {} };
89
+ add(window, "pageshow", event => recordNavDiagnostic("pageshow", { persisted:!!event.persisted }));
90
+ add(window, "pagehide", event => recordNavDiagnostic("pagehide", { persisted:!!event.persisted }));
91
+ add(window, "online", () => recordNavDiagnostic("online"));
92
+ add(window, "offline", () => recordNavDiagnostic("offline"));
93
+ add(document, "visibilitychange", () => recordNavDiagnostic("visibilitychange", { state:String(document.visibilityState || "") }));
94
+ try {
95
+ const pw = window.parent;
96
+ add(pw, "pageshow", event => recordNavDiagnostic("parent_pageshow", { persisted:!!event.persisted }));
97
+ add(pw, "pagehide", event => recordNavDiagnostic("parent_pagehide", { persisted:!!event.persisted }));
98
+ add(pw, "online", () => recordNavDiagnostic("parent_online"));
99
+ add(pw, "offline", () => recordNavDiagnostic("parent_offline"));
100
+ if (pw.navigation && pw.navigation.addEventListener) {
101
+ add(pw.navigation, "navigate", event => recordNavDiagnostic("navigation", { navigation_type:String(event.navigationType || "") }));
102
+ }
103
+ } catch (_) {}
104
+ }
105
+
106
+ function navDiagnosticSnapshot() {
107
+ const current = readSessionJson(NAV_DIAG_KEY, { version:1, mount_count:0, events:[] }) || { version:1, mount_count:0, events:[] };
108
+ return {
109
+ version:1,
110
+ mount_count:Number(current.mount_count || 0),
111
+ last_time_origin:Number(current.last_time_origin || 0),
112
+ navigation_type:String(current.navigation_type || "unknown"),
113
+ events:Array.isArray(current.events) ? current.events.slice(-NAV_DIAG_MAX_EVENTS) : []
114
+ };
115
+ }
116
+
117
  const ACTION_PATHS = Object.freeze({
118
  task_start: "/edge/task/start",
119
  task_cancel: "/edge/task/cancel",
 
356
  target.textContent = value;
357
  }
358
 
359
+ const UNNUMBERED_HEADINGS = new Set([
360
+ "abstract","keywords","references","摘要","关键词","参考文献",
361
+ "аннотация","ключевые слова","литература","список литературы",
362
+ "аңдатпа","аннотация","түйінді сөздер","кілт сөздер","әдебиеттер","пайдаланылған әдебиеттер"
363
+ ]);
364
+
365
+ function cleanHeadingTitle(value) {
366
+ return String(value || "").trim().replace(/^\d+(?:\.\d+){0,2}\s+/, "");
367
+ }
368
+
369
+ function numberMarkdownHeadings(value) {
370
+ const text = String(value || "").replace(/\r/g, "");
371
+ const lines = text.split("\n");
372
+ const counters = [0,0,0];
373
+ const headingRows = lines.map((line, index) => {
374
+ const match = line.match(/^(#{1,3})\s+(.+?)\s*$/);
375
+ return match ? { index, level:match[1].length, title:cleanHeadingTitle(match[2]) } : null;
376
+ }).filter(Boolean);
377
+ const hasFrontMatter = headingRows.some(row => UNNUMBERED_HEADINGS.has(row.title.toLocaleLowerCase()));
378
+ const firstH1 = headingRows.find(row => row.level === 1);
379
+ for (const row of headingRows) {
380
+ const key = row.title.toLocaleLowerCase();
381
+ const isKnownUnnumbered = UNNUMBERED_HEADINGS.has(key);
382
+ const isTitle = hasFrontMatter && firstH1 && row.index === firstH1.index && !isKnownUnnumbered;
383
+ if (isKnownUnnumbered || isTitle) {
384
+ lines[row.index] = row.title;
385
+ continue;
386
+ }
387
+ if (row.level === 1) {
388
+ counters[0] += 1; counters[1] = 0; counters[2] = 0;
389
+ lines[row.index] = `${counters[0]} ${row.title}`;
390
+ } else if (row.level === 2) {
391
+ if (counters[0] === 0) counters[0] = 1;
392
+ counters[1] += 1; counters[2] = 0;
393
+ lines[row.index] = `${counters[0]}.${counters[1]} ${row.title}`;
394
+ } else {
395
+ if (counters[0] === 0) counters[0] = 1;
396
+ if (counters[1] === 0) counters[1] = 1;
397
+ counters[2] += 1;
398
+ lines[row.index] = `${counters[0]}.${counters[1]}.${counters[2]} ${row.title}`;
399
+ }
400
+ }
401
+ return lines.join("\n");
402
+ }
403
+
404
  function normalizePreviewDisplay(text) {
405
+ return numberMarkdownHeadings(text);
 
 
406
  }
407
 
408
  function wordLikeUnits(value) {
409
+ const raw = String(value || "").match(/[\u4e00-\u9fff]{1,2}|[^\s\u4e00-\u9fff]+|\s+/g) || [];
410
+ const units = [];
411
+ for (const token of raw) {
412
+ if (/^\s+$/.test(token) && units.length) units[units.length - 1] += token;
413
+ else units.push(token);
414
+ }
415
+ return units;
416
  }
417
 
418
  function ensureStreamNode(parentDocument, target, channel) {
 
441
  if (!channel.queue.length) return;
442
  const stream = ensureStreamNode(parentDocument, target, channel);
443
  const backlog = channel.queue.length;
444
+ const perTick = backlog > 500 ? 2 : 1;
445
  let chunk = "";
446
  for (let i=0; i<perTick && channel.queue.length; i++) chunk += channel.queue.shift();
447
  if (chunk) {
 
449
  channel.displayed += chunk;
450
  if (shell && shell.dataset.spAutoFollow !== "0") shell.scrollTop = shell.scrollHeight;
451
  }
452
+ channel.timer = window.setTimeout(() => pumpRenderQueue(parentDocument, target, shell, channel), 100);
453
  }
454
 
455
  function renderBufferedPreview(parentDocument, target, shell, text, channel) {
 
658
  const bestEffortPageExitCancel = payload.best_effort_page_exit_cancel === true;
659
  const watch = { commandId, taskId, apiOrigin, intervalMs, previewIntervalMs, previewUrl:resolvedPreviewUrl, expectedEpoch, draftStatusLabels, targetStatusLabels, bestEffortPageExitCancel, timer:null, previewTimer:null, controller:null, stopped:false, lastCheckpointSeq:initialCheckpointSeq };
660
  activeTaskWatch = watch;
661
+ recordNavDiagnostic("task_watch_start");
662
  installPageExitCancel(watch);
663
  lastPreviewText = "";
664
  lastTargetPreviewText = "";
 
693
  watch.stopped = true;
694
  activeTaskWatch = null;
695
  removePageExitCancel();
696
+ recordNavDiagnostic("task_watch_boundary", { status });
697
  sendValue(Object.assign({}, result, {
698
  action:"task_watch",
699
  command_id:commandId,
700
  terminal:TERMINAL_TASK_STATUSES.has(status),
701
  boundary:true,
702
+ boundary_type:"terminal",
703
+ ui_navdiag:navDiagnosticSnapshot()
704
  }));
705
  return;
706
  }
 
757
  sendValue(response);
758
  }
759
 
760
+ mountNavigationDiagnostics();
761
  window.addEventListener("message", onRender);
762
  sendMessage("streamlit:componentReady", { apiVersion:1 });
763
  setFrameHeight();
utils/__pycache__/desktop.ini DELETED
@@ -1,2 +0,0 @@
1
- [.ShellClassInfo]
2
- IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
 
 
 
utils/__pycache__/docx_builder.cpython-313.pyc DELETED
Binary file (9.38 kB)
 
utils/__pycache__/file_parser.cpython-313.pyc DELETED
Binary file (8.01 kB)