Step 4A-6-C-R3.2.8.3 Translation Semantic Anchor Working Final Abstract Fix

#21
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ core/__pycache__/pipeline_agent.cpython-313.pyc filter=lfs diff=lfs merge=lfs -text
__pycache__/app.cpython-313.pyc ADDED
Binary file (60.6 kB). View file
 
__pycache__/config.cpython-313.pyc ADDED
Binary file (31.4 kB). View file
 
__pycache__/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
__pycache__/i18n.cpython-313.pyc ADDED
Binary file (76.6 kB). View file
 
__pycache__/managed_auth.cpython-313.pyc ADDED
Binary file (31 kB). View file
 
__pycache__/task_executor.cpython-313.pyc ADDED
Binary file (33.6 kB). View file
 
core/__pycache__/checkpoint_store.cpython-313.pyc ADDED
Binary file (4.62 kB). View file
 
core/__pycache__/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
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 ADDED
Binary file (65 kB). View file
 
core/__pycache__/meta_extractor.cpython-313.pyc ADDED
Binary file (3.42 kB). View file
 
core/__pycache__/pipeline_agent.cpython-313.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a58cec79f28771e880158bd36b2afd8c77ac1982a61db28ff832a962c41ed79c
3
+ size 251613
core/__pycache__/production_guard.cpython-313.pyc ADDED
Binary file (11 kB). View file
 
core/__pycache__/qualitative_engine.cpython-313.pyc ADDED
Binary file (15.3 kB). View file
 
core/__pycache__/quantitative_engine.cpython-313.pyc ADDED
Binary file (24.4 kB). View file
 
core/__pycache__/rag_engine.cpython-313.pyc ADDED
Binary file (4.36 kB). View file
 
core/generation_runner.py CHANGED
@@ -224,6 +224,20 @@ class GenerationRunner:
224
  match = re.search(rf'(?m)^#\s+{re.escape(str(first_section_title))}\s*$', value)
225
  return value[:match.start()].strip() if match else ''
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  @staticmethod
228
  def _section_is_complete(tasks, completed_display_names, section_key: str) -> bool:
229
  relevant = [
@@ -463,13 +477,29 @@ class GenerationRunner:
463
  raise RuntimeError("research_logic_contract_missing")
464
  agent.global_memory["research_logic_contract"] = research_logic_contract
465
 
 
 
 
 
 
 
 
 
 
 
 
466
  self._progress(14, "outline_planning")
467
  plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
468
  en_topic = str(plan.get("en_topic", topic))
469
  structure = plan.get("structure", [])
470
- # The final abstract is derived after Results/Findings validation;
471
- # do not pre-commit the paper to an expected conclusion.
472
- full_draft_en = f"# {en_topic}\n\n## Abstract\n\n## Keywords\n{keywords}\n\n"
 
 
 
 
 
473
  front_budget = max(180, int(APP_CONFIG.get("word_budget", {}).get("front_matter_words", 220)))
474
  body_target_words = max(1000, target_words - front_budget)
475
  adaptive_structure = agent.build_generation_tasks(structure, body_target_words, safe_chunk_words)
@@ -498,6 +528,9 @@ class GenerationRunner:
498
  "adaptive_structure": adaptive_structure,
499
  "citation_plan": citation_plan,
500
  "research_logic_contract": research_logic_contract,
 
 
 
501
  "body_target_words": body_target_words,
502
  "full_draft_en": full_draft_en,
503
  "full_draft_translated": "",
@@ -526,8 +559,62 @@ class GenerationRunner:
526
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
527
  translation_sections = dict(state.get('translation_sections') or {})
528
  translation_glossary = dict(state.get('translation_glossary') or {})
 
 
 
 
529
  if self._preview_target_text == '':
530
  self._preview_target_text = str(state.get('full_draft_translated') or '')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  if not citation_plan:
532
  self._progress(15, "citation_planning")
533
  citation_plan = agent.build_claim_evidence_plan(
@@ -653,6 +740,10 @@ Target length: {int(adjusted_target * min_ratio)}-{int(adjusted_target * max_rat
653
  <PROVISIONAL_RESEARCH_LOGIC>
654
  {research_logic_contract}
655
  </PROVISIONAL_RESEARCH_LOGIC>
 
 
 
 
656
  {memory_block}
657
  Literature synthesis: {synthesis_result}
658
  <FROZEN_RESEARCH_EVIDENCE>
@@ -751,9 +842,9 @@ Output only final academic prose. Do not report the word count."""
751
  agent, unit_key=section_key, unit_name=sec_title, source_text=frozen_section,
752
  target_lang=target_lang, translation_sections=translation_sections,
753
  translation_glossary=translation_glossary, adaptive_structure=adaptive_structure,
754
- include_front=False,
755
  )
756
- partial_translation = self._assemble_partial_translation(translation_sections, adaptive_structure)
757
  state.update(
758
  {
759
  "completed_sections": completed_sections,
@@ -772,10 +863,9 @@ Output only final academic prose. Do not report the word count."""
772
  if not state.get("english_finalized"):
773
  self._progress(91, "english_validation")
774
  full_draft_en = _clean_trailing_references(full_draft_en, ["Reference", "References", "Reference List"])
775
- final_abstract = agent.generate_final_abstract(
776
- topic, keywords, full_draft_en, data_evidence=user_data_md, mode=mode
777
- )
778
- full_draft_en = replace_abstract(full_draft_en, final_abstract)
779
  agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
780
  if mode == "asq":
781
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
@@ -807,15 +897,54 @@ Output only final academic prose. Do not report the word count."""
807
  )
808
  agent.validate_manuscript_structure(body_only, adaptive_structure, translated=False)
809
  agent.enforce_resolved_claim_specific_citations(body_only)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
810
  self._validate_target_length(full_draft_en, target_words)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
811
  self._publish_preview(full_draft_en, "english_complete", force=True, complete=True)
812
  state.update(
813
  {
814
  "full_draft_en": full_draft_en,
815
- "full_draft_translated": self._assemble_partial_translation(translation_sections, adaptive_structure),
816
  "translation_sections": translation_sections,
817
  "translation_glossary": translation_glossary,
 
 
 
818
  "english_finalized": True,
 
819
  "agent_state": agent.export_state(),
820
  }
821
  )
 
224
  match = re.search(rf'(?m)^#\s+{re.escape(str(first_section_title))}\s*$', value)
225
  return value[:match.start()].strip() if match else ''
226
 
227
+ @staticmethod
228
+ def _replace_keywords(manuscript: str, keywords) -> str:
229
+ value = str(manuscript or '')
230
+ if isinstance(keywords, (list, tuple)):
231
+ rendered = ', '.join(str(item).strip() for item in keywords if str(item).strip())
232
+ else:
233
+ rendered = str(keywords or '').strip()
234
+ pattern = re.compile(r'(?ms)(^##\s+Keywords\s*$\n)(.*?)(?=^#\s+[^#].*?$|\Z)')
235
+ matches = list(pattern.finditer(value))
236
+ if len(matches) != 1:
237
+ raise RuntimeError(f'keywords_heading_count_invalid:{len(matches)}')
238
+ match = matches[0]
239
+ return value[:match.start()] + match.group(1) + rendered + '\n\n' + value[match.end():].lstrip('\n')
240
+
241
  @staticmethod
242
  def _section_is_complete(tasks, completed_display_names, section_key: str) -> bool:
243
  relevant = [
 
477
  raise RuntimeError("research_logic_contract_missing")
478
  agent.global_memory["research_logic_contract"] = research_logic_contract
479
 
480
+ self._progress(14, "working_front_matter")
481
+ working_front = agent.generate_working_front_matter(
482
+ topic, keywords, research_logic_contract, synthesis_result,
483
+ data_evidence=user_data_md, mode=mode,
484
+ )
485
+ working_abstract = str(working_front.get("abstract") or "").strip()
486
+ working_keywords = list(working_front.get("keywords") or [])
487
+ if not working_abstract or not working_keywords:
488
+ raise RuntimeError("working_front_matter_missing")
489
+ agent.global_memory["working_abstract"] = working_abstract
490
+
491
  self._progress(14, "outline_planning")
492
  plan = agent.generate_outline(topic, keywords, mode=mode, data_metadata=data_metadata)
493
  en_topic = str(plan.get("en_topic", topic))
494
  structure = plan.get("structure", [])
495
+ full_draft_en = (
496
+ f"# {en_topic}\n\n## Abstract\n{working_abstract}\n\n"
497
+ f"## Keywords\n{', '.join(working_keywords)}\n\n"
498
+ )
499
+ # Publish the Working Abstract/Keywords immediately, before JIT and
500
+ # citation planning, so the user sees the manuscript anchor before
501
+ # Introduction while those longer planning stages continue.
502
+ self._publish_preview(full_draft_en, "working_front_matter", force=True)
503
  front_budget = max(180, int(APP_CONFIG.get("word_budget", {}).get("front_matter_words", 220)))
504
  body_target_words = max(1000, target_words - front_budget)
505
  adaptive_structure = agent.build_generation_tasks(structure, body_target_words, safe_chunk_words)
 
528
  "adaptive_structure": adaptive_structure,
529
  "citation_plan": citation_plan,
530
  "research_logic_contract": research_logic_contract,
531
+ "working_abstract": working_abstract,
532
+ "working_keywords": working_keywords,
533
+ "front_matter_phase": "working",
534
  "body_target_words": body_target_words,
535
  "full_draft_en": full_draft_en,
536
  "full_draft_translated": "",
 
559
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
560
  translation_sections = dict(state.get('translation_sections') or {})
561
  translation_glossary = dict(state.get('translation_glossary') or {})
562
+ working_abstract = str(state.get("working_abstract") or "").strip()
563
+ working_keywords = list(state.get("working_keywords") or [])
564
+ if working_abstract:
565
+ agent.global_memory["working_abstract"] = working_abstract
566
  if self._preview_target_text == '':
567
  self._preview_target_text = str(state.get('full_draft_translated') or '')
568
+
569
+ # Backward-compatible resume path for a pre-R3.2.8.3 checkpoint that has
570
+ # an outline but no Working Abstract yet. Generate it once, checkpoint it,
571
+ # and reuse it thereafter; never regenerate an unchanged anchor on resume.
572
+ if not working_abstract and not state.get("english_finalized"):
573
+ working_front = agent.generate_working_front_matter(
574
+ topic, keywords, research_logic_contract, synthesis_result,
575
+ data_evidence=user_data_md, mode=mode,
576
+ )
577
+ working_abstract = str(working_front.get("abstract") or "").strip()
578
+ working_keywords = list(working_front.get("keywords") or [])
579
+ if not working_abstract or not working_keywords:
580
+ raise RuntimeError("working_front_matter_missing")
581
+ agent.global_memory["working_abstract"] = working_abstract
582
+ full_draft_en = replace_abstract(full_draft_en, working_abstract)
583
+ full_draft_en = self._replace_keywords(full_draft_en, working_keywords)
584
+ state.update({
585
+ "working_abstract": working_abstract,
586
+ "working_keywords": working_keywords,
587
+ "front_matter_phase": "working",
588
+ "full_draft_en": full_draft_en,
589
+ "agent_state": agent.export_state(),
590
+ })
591
+ self._publish_preview(full_draft_en, "working_front_matter", force=True)
592
+ self._checkpoint("working_front_matter_ready", state, 15)
593
+
594
+ # Translate the QA-frozen Working front matter before Introduction. The
595
+ # same __front_matter__ record is hash-reused on resume and invalidated
596
+ # later when Final Abstract/Keywords replace the working front matter.
597
+ if working_abstract and not state.get("english_finalized"):
598
+ section_order_for_front = self._unique_top_level_sections(adaptive_structure)
599
+ if section_order_for_front:
600
+ working_front_source = self._extract_front_matter(full_draft_en, section_order_for_front[0][1])
601
+ translation_sections, translation_glossary = self._translate_frozen_unit(
602
+ agent, unit_key='__front_matter__', unit_name='Working Front Matter',
603
+ source_text=working_front_source, target_lang=target_lang,
604
+ translation_sections=translation_sections, translation_glossary=translation_glossary,
605
+ adaptive_structure=adaptive_structure, include_front=True,
606
+ )
607
+ working_target = self._assemble_partial_translation(
608
+ translation_sections, adaptive_structure, include_front=True
609
+ )
610
+ state.update({
611
+ "full_draft_en": full_draft_en,
612
+ "full_draft_translated": working_target,
613
+ "translation_sections": translation_sections,
614
+ "translation_glossary": translation_glossary,
615
+ "agent_state": agent.export_state(),
616
+ })
617
+ self._checkpoint("working_front_matter_translated", state, 16)
618
  if not citation_plan:
619
  self._progress(15, "citation_planning")
620
  citation_plan = agent.build_claim_evidence_plan(
 
740
  <PROVISIONAL_RESEARCH_LOGIC>
741
  {research_logic_contract}
742
  </PROVISIONAL_RESEARCH_LOGIC>
743
+ <WORKING_ABSTRACT_ANCHOR>
744
+ {working_abstract}
745
+ </WORKING_ABSTRACT_ANCHOR>
746
+ ANCHOR PRECEDENCE: Frozen research evidence and the PROVISIONAL_RESEARCH_LOGIC override the Working Abstract if any tension appears. The Working Abstract is continuity guidance only and must never force a result.
747
  {memory_block}
748
  Literature synthesis: {synthesis_result}
749
  <FROZEN_RESEARCH_EVIDENCE>
 
842
  agent, unit_key=section_key, unit_name=sec_title, source_text=frozen_section,
843
  target_lang=target_lang, translation_sections=translation_sections,
844
  translation_glossary=translation_glossary, adaptive_structure=adaptive_structure,
845
+ include_front=True,
846
  )
847
+ partial_translation = self._assemble_partial_translation(translation_sections, adaptive_structure, include_front=True)
848
  state.update(
849
  {
850
  "completed_sections": completed_sections,
 
863
  if not state.get("english_finalized"):
864
  self._progress(91, "english_validation")
865
  full_draft_en = _clean_trailing_references(full_draft_en, ["Reference", "References", "Reference List"])
866
+ # Validate the completed body while the Working Abstract is still in
867
+ # place. Final front matter must not be generated until all body
868
+ # sections, citation cleanup, and any Discussion rebalance are frozen.
 
869
  agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
870
  if mode == "asq":
871
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
 
897
  )
898
  agent.validate_manuscript_structure(body_only, adaptive_structure, translated=False)
899
  agent.enforce_resolved_claim_specific_citations(body_only)
900
+
901
+ # Body is now frozen. Generate both Final Abstract and Final Keywords
902
+ # from the completed manuscript/evidence, then replace the provisional
903
+ # front matter deterministically. No body rebalance may occur afterward.
904
+ self._progress(92, "final_front_matter")
905
+ final_front = agent.generate_final_front_matter(
906
+ topic, keywords, body_only, data_evidence=user_data_md, mode=mode
907
+ )
908
+ final_abstract = str(final_front.get("abstract") or "").strip()
909
+ final_keywords = list(final_front.get("keywords") or [])
910
+ if not final_abstract or not final_keywords:
911
+ raise RuntimeError("final_front_matter_missing")
912
+ full_draft_en = replace_abstract(full_draft_en, final_abstract)
913
+ full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
914
+ body_only, _reference_tail = agent.validate_reference_tail(
915
+ full_draft_en, minimum_references=MIN_FINAL_REFERENCES
916
+ )
917
+ agent.validate_manuscript_structure(body_only, adaptive_structure, translated=False)
918
+ agent.enforce_resolved_claim_specific_citations(body_only)
919
  self._validate_target_length(full_draft_en, target_words)
920
+
921
+ # Final front matter has a different source hash from the Working
922
+ # front matter. Retranslate only __front_matter__; unchanged body
923
+ # translations are retained and reused.
924
+ section_order_for_front = self._unique_top_level_sections(adaptive_structure)
925
+ if section_order_for_front:
926
+ final_front_source = self._extract_front_matter(body_only, section_order_for_front[0][1])
927
+ translation_sections, translation_glossary = self._translate_frozen_unit(
928
+ agent, unit_key='__front_matter__', unit_name='Final Front Matter',
929
+ source_text=final_front_source, target_lang=target_lang,
930
+ translation_sections=translation_sections, translation_glossary=translation_glossary,
931
+ adaptive_structure=adaptive_structure, include_front=True,
932
+ )
933
+ partial_target = self._assemble_partial_translation(
934
+ translation_sections, adaptive_structure, include_front=True
935
+ )
936
  self._publish_preview(full_draft_en, "english_complete", force=True, complete=True)
937
  state.update(
938
  {
939
  "full_draft_en": full_draft_en,
940
+ "full_draft_translated": partial_target,
941
  "translation_sections": translation_sections,
942
  "translation_glossary": translation_glossary,
943
+ "final_abstract": final_abstract,
944
+ "final_keywords": final_keywords,
945
+ "front_matter_phase": "final",
946
  "english_finalized": True,
947
+ "translation_complete": False,
948
  "agent_state": agent.export_state(),
949
  }
950
  )
core/pipeline_agent.py CHANGED
@@ -1229,6 +1229,122 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1229
  model_role="reasoning", thinking=True, allow_fallback=False,
1230
  )
1231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1232
  # Backward-compatible name retained for old checkpoints/tests.
1233
  def stream_generate_anchors(self, topic, keywords, synthesis_result, refs_text, data_evidence="", mode="amj"):
1234
  return self.stream_generate_research_logic(
@@ -3176,18 +3292,10 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3176
  return self._truncate_context(combined, max_tokens)
3177
 
3178
  def generate_final_abstract(self, topic, keywords, manuscript, data_evidence='', mode='amj'):
3179
- evidence = self._truncate_context(str(data_evidence or ''), 5000)
3180
- body = self._abstract_manuscript_context(manuscript, 18000)
3181
- prompt = f"""Write the final English abstract for this completed {mode.upper()} manuscript. Maximum 200 words. State the research problem, design, main finding, and contribution. Use no citations. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Use concise, accurate, rigorous academic language. Do not use metaphors, analogies, rhetorical questions, promotional language, formulaic transitions, or AI-related meta-commentary. Output only the abstract.\nTOPIC: {topic}\nKEYWORDS: {keywords}\nFROZEN EVIDENCE:\n{evidence}\nCOMPLETED MANUSCRIPT:\n{body}"""
3182
- abstract = self.call_deepseek(
3183
- 'You are a senior journal editor.', prompt, max_tokens=900, temp=0.1,
3184
- model_role='writing', thinking=False, allow_fallback=False,
3185
- )
3186
- abstract = self._remove_ai_meta_traces(abstract)
3187
- abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', abstract).strip()
3188
- abstract = self.polish_academic_section(abstract, 'Abstract', abstract=True, mode=mode)
3189
- max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
3190
- return self._trim_at_sentence_boundary(abstract, max_words)
3191
 
3192
  @staticmethod
3193
  def _translation_language_rule(target_lang):
@@ -3199,7 +3307,7 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3199
 
3200
  @staticmethod
3201
  def _translation_prompt(target_lang):
3202
- common = """Translate the supplied blocks from the locked English manuscript faithfully. Preserve every [[SP-Pxxxxxx]] paragraph marker and every [[SP-Txxxxxx]] protected token exactly once and in the same order. Protected tokens represent citations, statistics, identifiers, formulas, URLs/DOIs, hypotheses, abbreviations, or other immutable research objects and must never be translated or normalized. 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."""
3203
  return common + ' ' + SSciPipelineAgent._translation_language_rule(target_lang)
3204
 
3205
  @staticmethod
@@ -3244,6 +3352,139 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3244
  )
3245
  )
3246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3247
  @classmethod
3248
  def _translation_protected_pattern(cls):
3249
  # One-pass alternation prevents a placeholder created for a citation or
@@ -3251,7 +3492,10 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3251
  # conservative: protect immutable research objects, not prose.
3252
  acronym = cls._translation_acronym_regex()
3253
  numeric = cls._translation_numeric_regex()
 
3254
  return re.compile(
 
 
3255
  r'(?i:https?://[^\s)>]+)'
3256
  r'|(?i:\bdoi:\s*[^\s)>]+)'
3257
  r'|(?i:\b10\.\d{4,9}/[-._;()/:A-Z0-9]+)'
@@ -3271,6 +3515,7 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3271
 
3272
  @classmethod
3273
  def _protect_translation_tokens(cls, value):
 
3274
  mapping = {}
3275
  counter = 0
3276
  pattern = cls._translation_protected_pattern()
@@ -3279,13 +3524,25 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3279
  nonlocal counter
3280
  counter += 1
3281
  placeholder = f'[[SP-T{counter:06d}]]'
3282
- mapping[placeholder] = match.group(0)
 
 
 
 
 
 
 
 
3283
  return placeholder
3284
 
3285
- return pattern.sub(replace, str(value or '')), mapping
 
 
 
 
3286
 
3287
- @staticmethod
3288
- def _restore_translation_tokens(value, mapping, block_id=''):
3289
  text = str(value or '')
3290
  found = Counter(re.findall(r'\[\[SP-T\d{6}\]\]', text))
3291
  expected = Counter(mapping.keys())
@@ -3295,8 +3552,22 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3295
  raise DeepSeekRequestError(
3296
  f'Translation protected placeholder mismatch in {block_id}: missing={missing}, added={added}.'
3297
  )
3298
- for placeholder, original in mapping.items():
3299
- text = text.replace(placeholder, original)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3300
  if re.search(r'\[\[SP-T\d{6}\]\]', text):
3301
  raise DeepSeekRequestError(f'Translation left unresolved protected placeholders in {block_id}.')
3302
  return text
@@ -3308,8 +3579,9 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
3308
  acronyms = sorted(self._translation_acronym_tokens(body))
3309
  phrases = re.findall(r'\b(?:[A-Z][a-z]+(?:[- ][A-Z][a-z]+){1,3})\b', str(body or ''))
3310
  recurring = [term for term, count in Counter(phrases).most_common() if count >= 2]
 
3311
  term_limit = max(10, int(APP_CONFIG.get('translation_glossary_term_limit', 40)))
3312
- sources = list(dict.fromkeys(headings + recurring))[:term_limit]
3313
  missing = [term for term in sources if not str(mapping.get(term) or '').strip()]
3314
  if missing:
3315
  language_rule = self._translation_language_rule(target_lang)
@@ -3555,7 +3827,9 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
3555
  return parsed
3556
 
3557
  def _validate_translation_block(self, source, translated, block_id, target_lang):
3558
- if self._numeric_tokens(source) != self._numeric_tokens(translated):
 
 
3559
  raise DeepSeekRequestError(f'Translation changed numerical content in {block_id}.')
3560
  if self._translation_citation_tokens(source) != self._translation_citation_tokens(translated):
3561
  raise DeepSeekRequestError(f'Translation changed citations in {block_id}.')
@@ -3570,6 +3844,11 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
3570
  f'Translation changed variable abbreviations in {block_id}: '
3571
  f'missing={missing}, added={added}.'
3572
  )
 
 
 
 
 
3573
 
3574
  source_compact = re.sub(r'\s+', '', str(source or ''))
3575
  target_compact = re.sub(r'\s+', '', str(translated or ''))
@@ -3616,11 +3895,24 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
3616
  f'[[SP-P{block_id}]]\n{protected_source}'
3617
  for block_id, _, protected_source in protected_batch
3618
  )
3619
- effective_prompt = system_prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
3620
  if _strict:
3621
  effective_prompt += (
3622
- '\nSTRICT RETRY: Copy every SP-P and SP-T marker byte-for-byte. Do not merge, drop, reorder, '
3623
- 'translate, pluralize, or normalize any protected token.'
3624
  )
3625
  result = self.call_deepseek(
3626
  effective_prompt, marked_source,
@@ -3673,7 +3965,7 @@ Translate headings and terminology naturally. Preserve standard variable abbrevi
3673
  for block_id, source in batch:
3674
  try:
3675
  translated = self._restore_translation_tokens(
3676
- parsed[block_id], protected_maps.get(block_id, {}), f'SP-P{block_id}'
3677
  )
3678
  restored[block_id] = self._validate_translation_block_protected(
3679
  block_id, source, translated, target_lang
 
1229
  model_role="reasoning", thinking=True, allow_fallback=False,
1230
  )
1231
 
1232
+ @staticmethod
1233
+ def _front_matter_keywords(value, minimum=4, maximum=6):
1234
+ if isinstance(value, list):
1235
+ raw = value
1236
+ else:
1237
+ raw = re.split(r'[,;\n]+', str(value or ''))
1238
+ cleaned = []
1239
+ for item in raw:
1240
+ term = re.sub(r'^[\s\-•]+|[\s.;]+$', '', str(item or '')).strip()
1241
+ if term and term.casefold() not in {existing.casefold() for existing in cleaned}:
1242
+ cleaned.append(term)
1243
+ if not minimum <= len(cleaned) <= maximum:
1244
+ raise DeepSeekRequestError(
1245
+ f'Front-matter keywords must contain {minimum}-{maximum} distinct terms; got {len(cleaned)}.'
1246
+ )
1247
+ return cleaned
1248
+
1249
+ @staticmethod
1250
+ def _front_matter_has_citation(value):
1251
+ text = str(value or '')
1252
+ return bool(
1253
+ re.search(r'\[?CITE-\d+\]?', text, flags=re.I)
1254
+ or re.search(r'\([^()\n]*?(?:19|20)\d{2}[a-z]?[^()\n]*?\)', text, flags=re.I)
1255
+ )
1256
+
1257
+ @classmethod
1258
+ def _validate_working_abstract(cls, value):
1259
+ text = re.sub(r'(?im)^#+\s*abstract\s*', '', str(value or '')).strip()
1260
+ words = re.findall(r"\b[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*\b", text)
1261
+ if len(words) < 80 or len(words) > 180:
1262
+ raise DeepSeekRequestError(
1263
+ f'Working Abstract must contain 80-180 English words; got {len(words)}.'
1264
+ )
1265
+ if cls._front_matter_has_citation(text):
1266
+ raise DeepSeekRequestError('Working Abstract must not contain citations.')
1267
+ forbidden = (
1268
+ r'\bwe\s+(?:find|found|show|demonstrate|observe|establish)\b',
1269
+ r'\b(?:results?|findings?)\s+(?:show|indicate|reveal|demonstrate|confirm|support)\b',
1270
+ r'\b(?:supported|rejected)\s+(?:H\d+|hypothes)',
1271
+ r'\bstatistically\s+significant\b',
1272
+ r'(?:β|R²|χ²|ΔR²)\s*(?:=|<|>|≤|≥)',
1273
+ r'\bp\s*(?:=|<|>|≤|≥)\s*(?:\d|\.)',
1274
+ )
1275
+ if any(re.search(pattern, text, flags=re.I) for pattern in forbidden):
1276
+ raise DeepSeekRequestError('Working Abstract asserted a result that is not yet frozen.')
1277
+ return text
1278
+
1279
+ def generate_working_front_matter(
1280
+ self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj'
1281
+ ):
1282
+ """Create a provisional visible abstract/keyword anchor without prewriting results."""
1283
+ evidence = self._truncate_context(str(data_evidence or ''), 3500)
1284
+ logic = self._truncate_context(str(research_logic_contract or ''), 3500)
1285
+ synthesis = self._truncate_context(str(synthesis_result or ''), 3500)
1286
+ prompt = f"""Create WORKING front matter for an empirical {str(mode).upper()}-oriented manuscript before body drafting.
1287
+ Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
1288
+ The Working Abstract must be 120-180 English words, have no citations, and function only as a continuity anchor. It may state the research problem, theoretical tension, focal concepts, question/hypothesis architecture, design or method class, and intended contribution domain. It MUST NOT state coefficients, p-values, fit statistics, sample statistics unless already indispensable to define the design, supported/unsupported hypotheses, result direction, substantive findings, or a conclusion that has not yet been frozen. Do not predict findings.
1289
+ Return 4-6 concise academic keywords. User keywords are seeds, not an authoritative final list.
1290
+ TOPIC: {topic}
1291
+ USER KEYWORD SEEDS: {keywords}
1292
+ <PROVISIONAL_RESEARCH_LOGIC>
1293
+ {logic}
1294
+ </PROVISIONAL_RESEARCH_LOGIC>
1295
+ <LITERATURE_SYNTHESIS>
1296
+ {synthesis}
1297
+ </LITERATURE_SYNTHESIS>
1298
+ <FROZEN_RESEARCH_EVIDENCE>
1299
+ {evidence}
1300
+ </FROZEN_RESEARCH_EVIDENCE>"""
1301
+ response = self.call_deepseek(
1302
+ 'You are a senior management-journal editor creating provisional front matter without anticipating results.',
1303
+ prompt, max_tokens=1100, temp=0.0, model_role='writing', thinking=False, allow_fallback=False,
1304
+ )
1305
+ parsed = self._extract_json_from_text(response)
1306
+ if not isinstance(parsed, dict):
1307
+ raise DeepSeekRequestError('Working front matter was not valid JSON.')
1308
+ abstract = self._validate_working_abstract(parsed.get('abstract'))
1309
+ working_keywords = self._front_matter_keywords(parsed.get('keywords'))
1310
+ return {'abstract': abstract, 'keywords': working_keywords}
1311
+
1312
+ def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj'):
1313
+ """Generate final Abstract + Keywords only from the frozen completed body/evidence."""
1314
+ evidence = self._truncate_context(str(data_evidence or ''), 5000)
1315
+ body = self._abstract_manuscript_context(manuscript, 18000)
1316
+ prompt = f"""Write FINAL English front matter for this completed {str(mode).upper()} manuscript.
1317
+ Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
1318
+ The Abstract must be no more than 200 words, use no citations, and accurately state the research problem, validated design, main frozen finding(s), and contribution. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Do not infer beyond the frozen evidence or completed manuscript.
1319
+ Return 4-6 concise final academic keywords derived from the completed manuscript. User keyword seeds are advisory only.
1320
+ TOPIC: {topic}
1321
+ USER KEYWORD SEEDS: {keywords}
1322
+ <FROZEN_RESEARCH_EVIDENCE>
1323
+ {evidence}
1324
+ </FROZEN_RESEARCH_EVIDENCE>
1325
+ <COMPLETED_FROZEN_MANUSCRIPT>
1326
+ {body}
1327
+ </COMPLETED_FROZEN_MANUSCRIPT>"""
1328
+ response = self.call_deepseek(
1329
+ 'You are a senior journal editor finalizing front matter from frozen manuscript evidence.',
1330
+ prompt, max_tokens=1200, temp=0.0, model_role='writing', thinking=False, allow_fallback=False,
1331
+ )
1332
+ parsed = self._extract_json_from_text(response)
1333
+ if not isinstance(parsed, dict):
1334
+ raise DeepSeekRequestError('Final front matter was not valid JSON.')
1335
+ abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', str(parsed.get('abstract') or '')).strip()
1336
+ if self._front_matter_has_citation(abstract):
1337
+ raise DeepSeekRequestError('Final Abstract must not contain citations.')
1338
+ abstract = self.polish_academic_section(abstract, 'Abstract', abstract=True, mode=mode)
1339
+ if self._front_matter_has_citation(abstract):
1340
+ raise DeepSeekRequestError('Final Abstract revision introduced a citation.')
1341
+ max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
1342
+ abstract = self._trim_at_sentence_boundary(abstract, max_words)
1343
+ if len(re.findall(r"\b[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*\b", abstract)) < 80:
1344
+ raise DeepSeekRequestError('Final Abstract is too short after validation.')
1345
+ final_keywords = self._front_matter_keywords(parsed.get('keywords'))
1346
+ return {'abstract': abstract, 'keywords': final_keywords}
1347
+
1348
  # Backward-compatible name retained for old checkpoints/tests.
1349
  def stream_generate_anchors(self, topic, keywords, synthesis_result, refs_text, data_evidence="", mode="amj"):
1350
  return self.stream_generate_research_logic(
 
3292
  return self._truncate_context(combined, max_tokens)
3293
 
3294
  def generate_final_abstract(self, topic, keywords, manuscript, data_evidence='', mode='amj'):
3295
+ """Backward-compatible abstract-only facade over final front-matter generation."""
3296
+ return self.generate_final_front_matter(
3297
+ topic, keywords, manuscript, data_evidence=data_evidence, mode=mode
3298
+ )['abstract']
 
 
 
 
 
 
 
 
3299
 
3300
  @staticmethod
3301
  def _translation_language_rule(target_lang):
 
3307
 
3308
  @staticmethod
3309
  def _translation_prompt(target_lang):
3310
+ 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."""
3311
  return common + ' ' + SSciPipelineAgent._translation_language_rule(target_lang)
3312
 
3313
  @staticmethod
 
3352
  )
3353
  )
3354
 
3355
+ @staticmethod
3356
+ def _translation_date_regex():
3357
+ months = (
3358
+ r'January|February|March|April|May|June|July|August|September|October|November|December|'
3359
+ r'Jan\.?|Feb\.?|Mar\.?|Apr\.?|Jun\.?|Jul\.?|Aug\.?|Sep\.?|Sept\.?|Oct\.?|Nov\.?|Dec\.?'
3360
+ )
3361
+ return (
3362
+ rf'(?<![A-Za-z])(?:{months})\s+\d{{1,2}}(?:st|nd|rd|th)?(?:,)?\s+(?:19|20)\d{{2}}(?!\d)'
3363
+ rf'|(?<![A-Za-z0-9])\d{{1,2}}(?:st|nd|rd|th)?\s+(?:{months})\s+(?:19|20)\d{{2}}(?!\d)'
3364
+ r'|(?<!\d)(?:19|20)\d{2}-\d{1,2}-\d{1,2}(?!\d)'
3365
+ )
3366
+
3367
+ @classmethod
3368
+ def _canonicalize_translation_date(cls, value):
3369
+ text = str(value or '').strip().replace(',', '')
3370
+ iso = re.fullmatch(r'((?:19|20)\d{2})-(\d{1,2})-(\d{1,2})', text)
3371
+ if iso:
3372
+ year, month, day = map(int, iso.groups())
3373
+ try:
3374
+ return datetime(year, month, day).strftime('%Y-%m-%d')
3375
+ except ValueError:
3376
+ return ''
3377
+ month_map = {
3378
+ 'jan':1,'january':1,'feb':2,'february':2,'mar':3,'march':3,'apr':4,'april':4,'may':5,
3379
+ 'jun':6,'june':6,'jul':7,'july':7,'aug':8,'august':8,'sep':9,'sept':9,'september':9,
3380
+ 'oct':10,'october':10,'nov':11,'november':11,'dec':12,'december':12,
3381
+ }
3382
+ m = re.fullmatch(r'([A-Za-z.]+)\s+(\d{1,2})(?:st|nd|rd|th)?\s+((?:19|20)\d{2})', text, flags=re.I)
3383
+ if m:
3384
+ month_name, day, year = m.groups()
3385
+ else:
3386
+ m = re.fullmatch(r'(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z.]+)\s+((?:19|20)\d{2})', text, flags=re.I)
3387
+ if not m:
3388
+ return ''
3389
+ day, month_name, year = m.groups()
3390
+ month = month_map.get(month_name.rstrip('.').casefold())
3391
+ if not month:
3392
+ return ''
3393
+ try:
3394
+ return datetime(int(year), int(month), int(day)).strftime('%Y-%m-%d')
3395
+ except ValueError:
3396
+ return ''
3397
+
3398
+ @staticmethod
3399
+ def _render_translation_date(canonical, target_lang):
3400
+ try:
3401
+ parsed = datetime.strptime(str(canonical), '%Y-%m-%d')
3402
+ except Exception:
3403
+ return str(canonical or '')
3404
+ year, month, day = parsed.year, parsed.month, parsed.day
3405
+ if 'Chinese' in str(target_lang):
3406
+ return f'{year}年{month}月{day}日'
3407
+ if 'Russian' in str(target_lang):
3408
+ months = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря']
3409
+ return f'{day} {months[month-1]} {year} года'
3410
+ if 'Kazakh' in str(target_lang):
3411
+ months = ['қаңтар','ақпан','наурыз','сәуір','мамыр','маусым','шілде','тамыз','қыркүйек','қазан','қараша','желтоқсан']
3412
+ return f'{year} жылғы {day} {months[month-1]}'
3413
+ return parsed.strftime('%B %-d, %Y') if os.name != 'nt' else f'{parsed.strftime("%B")} {day}, {year}'
3414
+
3415
+ @classmethod
3416
+ def _translation_date_canonicals(cls, value):
3417
+ text = str(value or '')
3418
+ found = []
3419
+ for match in re.finditer(cls._translation_date_regex(), text, flags=re.I):
3420
+ canonical = cls._canonicalize_translation_date(match.group(0))
3421
+ if canonical:
3422
+ found.append(canonical)
3423
+ for year, month, day in re.findall(r'(?<!\d)((?:19|20)\d{2})年(\d{1,2})月(\d{1,2})日', text):
3424
+ try:
3425
+ found.append(datetime(int(year), int(month), int(day)).strftime('%Y-%m-%d'))
3426
+ except ValueError:
3427
+ pass
3428
+ ru_months = {'января':1,'февраля':2,'марта':3,'апреля':4,'мая':5,'июня':6,'июля':7,'августа':8,'сентября':9,'октября':10,'ноября':11,'декабря':12}
3429
+ for day, month_name, year in re.findall(r'(?<!\d)(\d{1,2})\s+(января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)\s+((?:19|20)\d{2})(?:\s+года|\s+г\.)?', text, flags=re.I):
3430
+ try:
3431
+ found.append(datetime(int(year), ru_months[month_name.casefold()], int(day)).strftime('%Y-%m-%d'))
3432
+ except ValueError:
3433
+ pass
3434
+ kz_months = {'қаңтар':1,'ақпан':2,'наурыз':3,'сәуір':4,'мамыр':5,'маусым':6,'шілде':7,'тамыз':8,'қыркүйек':9,'қазан':10,'қараша':11,'желтоқсан':12}
3435
+ for year, day, month_name in re.findall(r'(?<!\d)((?:19|20)\d{2})\s+жылғы\s+(\d{1,2})\s+(қаңтар|ақпан|наурыз|сәуір|мамыр|маусым|шілде|тамыз|қыркүйек|қазан|қараша|желтоқсан)', text, flags=re.I):
3436
+ try:
3437
+ found.append(datetime(int(year), kz_months[month_name.casefold()], int(day)).strftime('%Y-%m-%d'))
3438
+ except ValueError:
3439
+ pass
3440
+ return Counter(found)
3441
+
3442
+ @classmethod
3443
+ def _numeric_tokens_without_dates(cls, value):
3444
+ text = str(value or '')
3445
+ text = re.sub(cls._translation_date_regex(), ' ', text, flags=re.I)
3446
+ text = re.sub(r'(?<!\d)(?:19|20)\d{2}年\d{1,2}月\d{1,2}日', ' ', text)
3447
+ text = re.sub(r'(?<!\d)\d{1,2}\s+(?:января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)\s+(?:19|20)\d{2}(?:\s+года|\s+г\.)?', ' ', text, flags=re.I)
3448
+ text = re.sub(r'(?<!\d)(?:19|20)\d{2}\s+жылғы\s+\d{1,2}\s+(?:қаңтар|ақпан|наурыз|сәуір|мамыр|маусым|шілде|тамыз|қыркүйек|қазан|қараша|желтоқсан)', ' ', text, flags=re.I)
3449
+ return cls._numeric_tokens(text)
3450
+
3451
+ @classmethod
3452
+ def _translation_anchor_type(cls, token):
3453
+ value = str(token or '')
3454
+ if re.fullmatch(cls._translation_date_regex(), value, flags=re.I): return 'DATE'
3455
+ if re.fullmatch(r'\([^()\n]*?(?:19|20)\d{2}[a-z]?[^()\n]*?\)', value, flags=re.I): return 'CITATION'
3456
+ if re.fullmatch(r'(?i:https?://[^\s)>]+|doi:\s*[^\s)>]+|10\.\d{4,9}/[-._;()/:A-Z0-9]+)', value): return 'DOI_URL'
3457
+ if re.fullmatch(r'(?i:H\d+[A-Za-z]?)', value): return 'HYPOTHESIS'
3458
+ if re.search(r'(?:p|t|f|z|r|β|χ²|R²|ΔR²)\s*(?:<=|>=|=|<|>|≤|≥)', value, flags=re.I): return 'STAT'
3459
+ if re.fullmatch(r'(?:fsQCA|SmartPLS|Mplus|OpenAlex|DeepSeek|ChatGPT|RStudio|SPSS|AMOS|Stata)', value): return 'NAME'
3460
+ if re.fullmatch(cls._translation_acronym_regex(), value): return 'ACRONYM'
3461
+ if re.fullmatch(r'(?:QUOTE|CASE|RESP|P)-\d+', value, flags=re.I): return 'QUOTE_ID'
3462
+ if re.fullmatch(cls._translation_numeric_regex(), value): return 'NUMBER'
3463
+ if (value.startswith('`') and value.endswith('`')) or (value.startswith('$') and value.endswith('$')): return 'FORMULA'
3464
+ return 'IMMUTABLE'
3465
+
3466
+ @staticmethod
3467
+ def _translation_sentence_index(text, position):
3468
+ prefix = str(text or '')[:max(0, int(position))]
3469
+ # Chinese sentence punctuation does not require whitespace. For Latin/
3470
+ # Cyrillic punctuation, count a boundary only before a plausible new
3471
+ # sentence or end-of-prefix so abbreviations such as e.g. do not create
3472
+ # false semantic-owner boundaries.
3473
+ return len(re.findall(
3474
+ r'[。!?]|[.!?](?=\s*(?:[A-ZА-ЯЁӘҒҚҢӨҰҮҺІ\["“]|$))', prefix
3475
+ ))
3476
+
3477
+ @classmethod
3478
+ def _translation_longform_acronym_pairs(cls, value):
3479
+ pairs = []
3480
+ text = str(value or '')
3481
+ pattern = re.compile(r'(?<![A-Za-z])([A-Za-z][A-Za-z-]*(?:\s+[A-Za-z][A-Za-z-]*){1,5})\s*\(([^()\s]{2,20})\)')
3482
+ for match in pattern.finditer(text):
3483
+ long_form, acronym = match.group(1).strip(), match.group(2).strip()
3484
+ if cls._translation_acronym_tokens(acronym) == Counter({acronym: 1}):
3485
+ pairs.append((long_form, acronym))
3486
+ return pairs
3487
+
3488
  @classmethod
3489
  def _translation_protected_pattern(cls):
3490
  # One-pass alternation prevents a placeholder created for a citation or
 
3492
  # conservative: protect immutable research objects, not prose.
3493
  acronym = cls._translation_acronym_regex()
3494
  numeric = cls._translation_numeric_regex()
3495
+ date_pattern = cls._translation_date_regex()
3496
  return re.compile(
3497
+ rf'(?:{date_pattern})'
3498
+ r'|'
3499
  r'(?i:https?://[^\s)>]+)'
3500
  r'|(?i:\bdoi:\s*[^\s)>]+)'
3501
  r'|(?i:\b10\.\d{4,9}/[-._;()/:A-Z0-9]+)'
 
3515
 
3516
  @classmethod
3517
  def _protect_translation_tokens(cls, value):
3518
+ source = str(value or '')
3519
  mapping = {}
3520
  counter = 0
3521
  pattern = cls._translation_protected_pattern()
 
3524
  nonlocal counter
3525
  counter += 1
3526
  placeholder = f'[[SP-T{counter:06d}]]'
3527
+ original = match.group(0)
3528
+ anchor_type = cls._translation_anchor_type(original)
3529
+ manifest = {'source': original, 'type': anchor_type}
3530
+ if anchor_type == 'DATE':
3531
+ manifest['canonical'] = cls._canonicalize_translation_date(original)
3532
+ left = source[max(0, match.start() - 80):match.start()]
3533
+ right = source[match.end():min(len(source), match.end() + 60)]
3534
+ manifest['owner_hint'] = (left + '{ANCHOR}' + right).replace('\n', ' ')
3535
+ mapping[placeholder] = manifest
3536
  return placeholder
3537
 
3538
+ protected = pattern.sub(replace, source)
3539
+ for placeholder, manifest in mapping.items():
3540
+ position = protected.find(placeholder)
3541
+ manifest['sentence_index'] = cls._translation_sentence_index(protected, position)
3542
+ return protected, mapping
3543
 
3544
+ @classmethod
3545
+ def _restore_translation_tokens(cls, value, mapping, block_id='', target_lang=''):
3546
  text = str(value or '')
3547
  found = Counter(re.findall(r'\[\[SP-T\d{6}\]\]', text))
3548
  expected = Counter(mapping.keys())
 
3552
  raise DeepSeekRequestError(
3553
  f'Translation protected placeholder mismatch in {block_id}: missing={missing}, added={added}.'
3554
  )
3555
+ for placeholder, raw_manifest in mapping.items():
3556
+ manifest = raw_manifest if isinstance(raw_manifest, dict) else {'source': str(raw_manifest), 'type': 'IMMUTABLE'}
3557
+ source_sentence = int(manifest.get('sentence_index') or 0)
3558
+ target_sentence = cls._translation_sentence_index(text, text.find(placeholder))
3559
+ if manifest.get('type') in {'DATE','CITATION','STAT','HYPOTHESIS','ACRONYM','NAME'} and target_sentence != source_sentence:
3560
+ raise DeepSeekRequestError(
3561
+ f'Translation semantic anchor moved across sentence boundary in {block_id}: {placeholder}.'
3562
+ )
3563
+ for placeholder, raw_manifest in mapping.items():
3564
+ manifest = raw_manifest if isinstance(raw_manifest, dict) else {'source': str(raw_manifest), 'type': 'IMMUTABLE'}
3565
+ original = str(manifest.get('source') or '')
3566
+ if target_lang and manifest.get('type') == 'DATE' and manifest.get('canonical'):
3567
+ replacement = cls._render_translation_date(manifest.get('canonical'), target_lang)
3568
+ else:
3569
+ replacement = original
3570
+ text = text.replace(placeholder, replacement)
3571
  if re.search(r'\[\[SP-T\d{6}\]\]', text):
3572
  raise DeepSeekRequestError(f'Translation left unresolved protected placeholders in {block_id}.')
3573
  return text
 
3579
  acronyms = sorted(self._translation_acronym_tokens(body))
3580
  phrases = re.findall(r'\b(?:[A-Z][a-z]+(?:[- ][A-Z][a-z]+){1,3})\b', str(body or ''))
3581
  recurring = [term for term, count in Counter(phrases).most_common() if count >= 2]
3582
+ paired_long_forms = [long_form for long_form, _ in self._translation_longform_acronym_pairs(body)]
3583
  term_limit = max(10, int(APP_CONFIG.get('translation_glossary_term_limit', 40)))
3584
+ sources = list(dict.fromkeys(headings + paired_long_forms + recurring))[:term_limit]
3585
  missing = [term for term in sources if not str(mapping.get(term) or '').strip()]
3586
  if missing:
3587
  language_rule = self._translation_language_rule(target_lang)
 
3827
  return parsed
3828
 
3829
  def _validate_translation_block(self, source, translated, block_id, target_lang):
3830
+ if self._translation_date_canonicals(source) != self._translation_date_canonicals(translated):
3831
+ raise DeepSeekRequestError(f'Translation changed date content in {block_id}.')
3832
+ if self._numeric_tokens_without_dates(source) != self._numeric_tokens_without_dates(translated):
3833
  raise DeepSeekRequestError(f'Translation changed numerical content in {block_id}.')
3834
  if self._translation_citation_tokens(source) != self._translation_citation_tokens(translated):
3835
  raise DeepSeekRequestError(f'Translation changed citations in {block_id}.')
 
3844
  f'Translation changed variable abbreviations in {block_id}: '
3845
  f'missing={missing}, added={added}.'
3846
  )
3847
+ for _long_form, acronym in self._translation_longform_acronym_pairs(source):
3848
+ if not re.search(rf'[((]\s*{re.escape(acronym)}\s*[))]', str(translated or '')):
3849
+ raise DeepSeekRequestError(
3850
+ f'Translation detached long-form/acronym pair in {block_id}: {acronym}.'
3851
+ )
3852
 
3853
  source_compact = re.sub(r'\s+', '', str(source or ''))
3854
  target_compact = re.sub(r'\s+', '', str(translated or ''))
 
3895
  f'[[SP-P{block_id}]]\n{protected_source}'
3896
  for block_id, _, protected_source in protected_batch
3897
  )
3898
+ semantic_manifest = {
3899
+ block_id: [
3900
+ {'id': placeholder, **manifest}
3901
+ for placeholder, manifest in protected_maps.get(block_id, {}).items()
3902
+ ]
3903
+ for block_id, _source, _protected in protected_batch
3904
+ }
3905
+ effective_prompt = system_prompt + (
3906
+ '\n<SEMANTIC_ANCHOR_MANIFEST>\n'
3907
+ + json.dumps(semantic_manifest, ensure_ascii=False)
3908
+ + '\n</SEMANTIC_ANCHOR_MANIFEST>\n'
3909
+ 'Use anchor type, canonical value, sentence_index, and owner_hint only to keep each placeholder attached to its original semantic owner. '
3910
+ 'Do not print the manifest. DATE anchors may change local word order inside their sentence; CITATION anchors must remain with their original claim.'
3911
+ )
3912
  if _strict:
3913
  effective_prompt += (
3914
+ '\nSTRICT RETRY: Copy every SP-P and SP-T marker byte-for-byte. Do not merge, drop, translate, '
3915
+ '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.'
3916
  )
3917
  result = self.call_deepseek(
3918
  effective_prompt, marked_source,
 
3965
  for block_id, source in batch:
3966
  try:
3967
  translated = self._restore_translation_tokens(
3968
+ parsed[block_id], protected_maps.get(block_id, {}), f'SP-P{block_id}', target_lang
3969
  )
3970
  restored[block_id] = self._validate_translation_block_protected(
3971
  block_id, source, translated, target_lang
smartpaper_restore_bridge/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (3.12 kB). View file
 
smartpaper_restore_bridge/__pycache__/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
utils/__pycache__/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
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 ADDED
Binary file (9.38 kB). View file
 
utils/__pycache__/file_parser.cpython-313.pyc ADDED
Binary file (8.01 kB). View file