A2 Adaptive Completion Core + Provider-Neutral Model Gateway

#102
config.py CHANGED
@@ -1,5 +1,30 @@
1
  # -*- coding: utf-8 -*-
2
  import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  def load_config():
5
  base_config = {
@@ -117,6 +142,62 @@ def load_config():
117
  "DEEPSEEK_SHOW_REASONING",
118
  "false",
119
  ).strip().lower() in {"1", "true", "yes", "on"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  # Citation-semantic audit repairs only affected sentences. Technical
121
  # failures fall back to deterministic single-source recovery and are
122
  # recorded in the audit log instead of terminating the whole paper.
 
1
  # -*- coding: utf-8 -*-
2
  import os
3
+ import json
4
+
5
+ def _provider_fallbacks(env_name):
6
+ return [part.strip().lower() for part in str(os.environ.get(env_name, "") or "").split(",") if part.strip()]
7
+
8
+ def _optional_plan_range(prefix):
9
+ raw_min = str(os.environ.get(f"{prefix}_WORD_MIN", "") or "").strip()
10
+ raw_max = str(os.environ.get(f"{prefix}_WORD_MAX", "") or "").strip()
11
+ if not raw_min or not raw_max:
12
+ return None
13
+ try:
14
+ lo, hi = int(raw_min), int(raw_max)
15
+ except ValueError:
16
+ return None
17
+ return [min(lo, hi), max(lo, hi)]
18
+
19
+ def _plan_model_routes(prefix):
20
+ raw = str(os.environ.get(f"{prefix}_MODEL_ROUTES_JSON", "") or "").strip()
21
+ if not raw:
22
+ return {}
23
+ try:
24
+ parsed = json.loads(raw)
25
+ except Exception:
26
+ return {}
27
+ return dict(parsed) if isinstance(parsed, dict) else {}
28
 
29
  def load_config():
30
  base_config = {
 
142
  "DEEPSEEK_SHOW_REASONING",
143
  "false",
144
  ).strip().lower() in {"1", "true", "yes", "on"},
145
+ # A2: provider-neutral routing seam. Academic code requests capabilities;
146
+ # this table decides which provider adapter owns each capability. The
147
+ # current verified production route remains DeepSeek until a trusted plan
148
+ # policy explicitly selects another registered provider.
149
+ "model_gateway": {
150
+ "default_provider": os.environ.get("MODEL_PROVIDER_DEFAULT", "deepseek").strip().lower(),
151
+ "capability_routes": {
152
+ "reasoning": {"provider": os.environ.get("MODEL_PROVIDER_REASONING", "deepseek"), "provider_role": "reasoning", "fallback_providers": _provider_fallbacks("MODEL_PROVIDER_REASONING_FALLBACKS")},
153
+ "writing": {"provider": os.environ.get("MODEL_PROVIDER_WRITING", "deepseek"), "provider_role": "writing", "fallback_providers": _provider_fallbacks("MODEL_PROVIDER_WRITING_FALLBACKS")},
154
+ "translation": {"provider": os.environ.get("MODEL_PROVIDER_TRANSLATION", "deepseek"), "provider_role": "translation", "fallback_providers": _provider_fallbacks("MODEL_PROVIDER_TRANSLATION_FALLBACKS")},
155
+ "fast": {"provider": os.environ.get("MODEL_PROVIDER_FAST", "deepseek"), "provider_role": "fast", "fallback_providers": _provider_fallbacks("MODEL_PROVIDER_FAST_FALLBACKS")},
156
+ "repair": {"provider": os.environ.get("MODEL_PROVIDER_REPAIR", os.environ.get("MODEL_PROVIDER_REASONING", "deepseek")), "provider_role": "reasoning", "fallback_providers": _provider_fallbacks("MODEL_PROVIDER_REPAIR_FALLBACKS")},
157
+ "final_review": {"provider": os.environ.get("MODEL_PROVIDER_FINAL_REVIEW", os.environ.get("MODEL_PROVIDER_REASONING", "deepseek")), "provider_role": "reasoning", "fallback_providers": _provider_fallbacks("MODEL_PROVIDER_FINAL_REVIEW_FALLBACKS")},
158
+ },
159
+ },
160
+ # A2: content problems are recoverable by default. These budgets are
161
+ # model-policy inputs, not hard-coded academic business rules.
162
+ "adaptive_completion": {
163
+ "enabled": os.environ.get("ADAPTIVE_COMPLETION_ENABLED", "true").strip().lower() in {"1", "true", "yes", "on"},
164
+ "section_repair_passes": max(1, min(4, int(os.environ.get("ADAPTIVE_SECTION_REPAIR_PASSES", "2")))),
165
+ "front_matter_repair_passes": max(1, min(4, int(os.environ.get("ADAPTIVE_FRONT_MATTER_REPAIR_PASSES", "2")))),
166
+ "translation_fallback_passes": max(1, min(3, int(os.environ.get("ADAPTIVE_TRANSLATION_FALLBACK_PASSES", "1")))),
167
+ },
168
+ # A2 only freezes a policy interface. Worker/D1 payment and quota logic
169
+ # is intentionally unchanged. Concrete prices/entitlements remain
170
+ # backend-owned and can be revised without changing manuscript code.
171
+ "plan_policy_version": "plan-policy-v1",
172
+ "plan_policies": {
173
+ "legacy_current": {
174
+ "word_range": [5000, 20000],
175
+ "paper_limit": None,
176
+ "model_routes": {},
177
+ "repair_profile": "standard",
178
+ },
179
+ # A2 deliberately does not invent commercial tier limits. Concrete
180
+ # word ranges and provider routes are deployment policy, supplied by
181
+ # environment/backend configuration when those products are enabled.
182
+ "free": {
183
+ "word_range": _optional_plan_range("SMARTPAPER_PLAN_FREE"),
184
+ "paper_limit": None,
185
+ "model_routes": _plan_model_routes("SMARTPAPER_PLAN_FREE"),
186
+ "repair_profile": os.environ.get("SMARTPAPER_PLAN_FREE_REPAIR_PROFILE", "economy"),
187
+ },
188
+ "standard": {
189
+ "word_range": _optional_plan_range("SMARTPAPER_PLAN_STANDARD"),
190
+ "paper_limit": None,
191
+ "model_routes": _plan_model_routes("SMARTPAPER_PLAN_STANDARD"),
192
+ "repair_profile": os.environ.get("SMARTPAPER_PLAN_STANDARD_REPAIR_PROFILE", "standard"),
193
+ },
194
+ "flagship": {
195
+ "word_range": _optional_plan_range("SMARTPAPER_PLAN_FLAGSHIP"),
196
+ "paper_limit": None,
197
+ "model_routes": _plan_model_routes("SMARTPAPER_PLAN_FLAGSHIP"),
198
+ "repair_profile": os.environ.get("SMARTPAPER_PLAN_FLAGSHIP_REPAIR_PROFILE", "maximum"),
199
+ },
200
+ },
201
  # Citation-semantic audit repairs only affected sentences. Technical
202
  # failures fall back to deterministic single-source recovery and are
203
  # recorded in the audit log instead of terminating the whole paper.
core/generation_runner.py CHANGED
@@ -26,6 +26,7 @@ from typing import Callable, Any
26
  from config import APP_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS
27
  from core.checkpoint_store import TaskCheckpointStore
28
  from core.meta_extractor import extract_golden_metadata
 
29
  from core.pipeline_agent import SSciPipelineAgent, PipelineCancelledError
30
  from core.production_guard import build_validated_package, replace_abstract
31
  from core.qualitative_engine import get_qualitative_engine
@@ -1206,6 +1207,122 @@ class GenerationRunner:
1206
  continue
1207
  quality_notes.append(note)
1208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1209
  @staticmethod
1210
  def _citation_violation_tags(rows) -> set:
1211
  """Extract only citation tags owned by the rejected claim sentence.
@@ -2700,10 +2817,12 @@ class GenerationRunner:
2700
  if not APP_CONFIG["target_words_min"] <= target_words <= APP_CONFIG["target_words_max"]:
2701
  raise RuntimeError("target_words_out_of_range")
2702
 
 
 
 
2703
  ds_key = str(os.environ.get("DEEPSEEK_API_KEY") or "").strip()
2704
  s2_key = str(os.environ.get("S2_API_KEY") or "").strip()
2705
- if not ds_key:
2706
- raise RuntimeError("DEEPSEEK_API_KEY_missing")
2707
 
2708
  if mode == "amj":
2709
  APP_CONFIG.update(AMJ_PROMPTS)
@@ -2712,6 +2831,7 @@ class GenerationRunner:
2712
 
2713
  agent = SSciPipelineAgent(ds_key, s2_key)
2714
  self.agent = agent
 
2715
  agent.set_cancel_checker(self.cancel_checker)
2716
  # DeepSeek/search activity marks the executor as genuinely alive; the
2717
  # heartbeat thread decides separately when to renew D1's lease.
@@ -2729,6 +2849,7 @@ class GenerationRunner:
2729
  state = self.checkpoints.load_confirmed() or {}
2730
  quality_diagnostics = list(state.get("quality_diagnostics") or [])
2731
  quality_notes = list(state.get("quality_notes") or [])
 
2732
  self._evidence_coverage_events = list(state.get("evidence_coverage_events") or [])
2733
  if str(state.get("evidence_status") or "") == "limited":
2734
  self._preview_evidence_status = "limited"
@@ -3006,7 +3127,14 @@ class GenerationRunner:
3006
  working_abstract, hypothesis_ledger, section_name="Working Abstract"
3007
  ))
3008
  if remaining_front_rows:
3009
- raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining_front_rows[:3]}")
 
 
 
 
 
 
 
3010
  quality_diagnostics.append({
3011
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
3012
  "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
@@ -3311,7 +3439,14 @@ class GenerationRunner:
3311
  candidate_abstract, hypothesis_ledger, section_name="Working Abstract"
3312
  ))
3313
  if remaining:
3314
- raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
 
 
 
 
 
 
 
3315
  working_abstract = candidate_abstract
3316
  working_keywords = candidate_keywords or working_keywords
3317
  state["working_abstract"] = working_abstract
@@ -3431,6 +3566,8 @@ class GenerationRunner:
3431
  # English writer. A dedicated single translation agent/worker keeps
3432
  # glossary order deterministic and avoids sharing mutable writer state.
3433
  translation_agent = SSciPipelineAgent(ds_key, s2_key)
 
 
3434
  translation_agent.set_execution_progress_hook(lambda: self._progress_activity())
3435
  translation_agent.set_cancel_checker(lambda: self.cancel_checker())
3436
  seeded_glossary = translation_agent.seed_canonical_concept_glossary(
@@ -3546,7 +3683,14 @@ class GenerationRunner:
3546
  working_abstract, hypothesis_ledger, section_name="Working Abstract"
3547
  ))
3548
  if remaining:
3549
- raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
 
 
 
 
 
 
 
3550
  quality_diagnostics.append({
3551
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
3552
  "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
@@ -3902,10 +4046,10 @@ Output only final academic prose. Do not report the word count."""
3902
  isinstance(prior_retry_exhausted, dict)
3903
  and str(prior_retry_exhausted.get("section_name") or "") == display_name
3904
  ):
3905
- # S0-R1 deliberately does not invent an executor/network retry state.
3906
- # A content-invalid Section that exhausted its one local retry remains
3907
- # durable and fail-closed until a separately approved lifecycle change.
3908
- raise RuntimeError(f"section_retry_exhausted_requires_new_lifecycle:{display_name}")
3909
 
3910
  deterministic_appendix = mode == "asq" and section_id == "appendix"
3911
  # R4: one initial attempt plus bounded lineage-owned repair turns.
@@ -4124,31 +4268,37 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4124
  sorted({str(row.get("type") or "") for row in advisory_rows if isinstance(row, dict)}),
4125
  )
4126
  if blocking_rows:
4127
- self._restore_section_citation_state(
4128
- agent, citation_plan, display_name, frozen_citation_state
 
 
 
 
4129
  )
4130
- state.update({
4131
- "citation_plan": citation_plan,
4132
- "agent_state": agent.export_state(),
4133
- "quality_diagnostics": quality_diagnostics,
4134
- "quality_notes": quality_notes,
4135
- })
4136
- self._checkpoint_section_retry_exhausted(
4137
- state, section_id=section_id, section_name=display_name,
4138
- violations=blocking_rows, pct=pct, attempts=max_section_attempts,
4139
  )
4140
- blocking_citation_rows = [
4141
- row for row in blocking_rows
4142
- if isinstance(row, dict) and str(row.get("type") or "").startswith("citation_")
4143
- ]
4144
- terminal_kind = (
4145
- "section_citation_invalid_after_retry"
4146
- if blocking_citation_rows and len(blocking_citation_rows) == len(blocking_rows)
4147
- else "section_content_invalid_after_retry"
4148
  )
4149
- raise RuntimeError(
4150
- f"{terminal_kind}:{display_name}:{blocking_rows[:3]}"
 
 
 
4151
  )
 
 
 
 
 
 
 
 
 
 
 
4152
 
4153
  used_after = set(agent.used_citations)
4154
  survived = used_after - used_before
@@ -4316,22 +4466,19 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4316
  sorted({str(row.get("type") or "") for row in advisory_rows if isinstance(row, dict)}),
4317
  )
4318
  if blocking_rows:
4319
- self._restore_section_citation_state(
4320
- agent, citation_plan, display_name, frozen_citation_state
 
 
 
 
 
4321
  )
4322
- state.update({
4323
- "citation_plan": citation_plan,
4324
- "agent_state": agent.export_state(),
4325
- "quality_diagnostics": quality_diagnostics,
4326
- "quality_notes": quality_notes,
4327
- })
4328
- self._checkpoint_section_retry_exhausted(
4329
- state, section_id=section_id, section_name=display_name,
4330
- violations=blocking_rows, pct=pct, attempts=max_section_attempts,
4331
- )
4332
- raise RuntimeError(
4333
- f"section_content_invalid_after_retry:{display_name}:{blocking_rows[:3]}"
4334
  )
 
4335
 
4336
  quality_diagnostics.extend(attempt_diagnostics)
4337
  assigned_tag_list = list(attempt_assigned_tag_list)
@@ -4498,48 +4645,57 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4498
  raise RuntimeError(f"final_english_section_missing:{section_title}")
4499
  seal = dict(section_integrity_seals.get(section_key) or {})
4500
  self._validate_section_integrity_seal(seal, sealed_section, section_title)
 
 
 
 
 
 
 
 
 
4501
  try:
4502
- if mode == "asq":
4503
- agent.validate_qualitative_study_fact_contract(
4504
- sealed_section, data_metadata, section_name=section_title
4505
- )
4506
- elif mode == "amj":
4507
- quant_engine.validate_quantitative_study_fact_contract(
4508
- sealed_section, data_metadata, section_name=section_title
4509
- )
4510
  except Exception as exc:
4511
- prefix = (
4512
- "final_qualitative_study_fact_contract_failed"
4513
- if mode == "asq" else "final_quantitative_study_fact_contract_failed"
4514
  )
4515
- raise RuntimeError(f"{prefix}:{section_title}") from exc
4516
- # Post-seal structural validation is pure validation. Never
4517
- # invoke a mutating enumeration repair on frozen English text.
4518
- agent.validate_enumeration_contract(
4519
- sealed_section, section_name=f"Final sealed section: {section_title}"
4520
- )
4521
  internal_rows = self._author_facing_internal_workflow_violations(sealed_section)
4522
  if internal_rows:
4523
- raise RuntimeError(
4524
- f"final_author_facing_purity_failed:{section_title}:{internal_rows[:3]}"
4525
  )
4526
  if mode == "amj" and hypothesis_ledger:
4527
  hypothesis_rows = self._hypothesis_contract_violations(
4528
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
4529
  )
4530
  if hypothesis_rows:
4531
- raise RuntimeError(f"final_hypothesis_contract_failed:{hypothesis_rows[:3]}")
 
 
4532
  # Validate the completed body while the Working Abstract is still in
4533
  # place. Final front matter must not be generated until all body
4534
  # sections, citation cleanup, and any Discussion rebalance are frozen.
4535
  agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
4536
  if mode == "asq":
4537
- self._validate_qualitative_quote_rendering(
4538
- full_draft_en, quote_invocation_plan, data_metadata
4539
- )
 
 
 
 
 
 
4540
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
4541
  if repeated_quotes:
4542
- raise RuntimeError(f"Repeated qualitative quotations: {repeated_quotes[:5]}")
 
 
 
4543
  # R4-R3: the body is already citation-resolved at this point. Never
4544
  # rescan it for raw CITE tags; that destroys a valid citation ledger.
4545
  final_citation_ledger = agent.finalize_resolved_citation_ledger(full_draft_en)
@@ -4677,22 +4833,14 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4677
  final_abstract, hypothesis_ledger, section_name="Final Abstract"
4678
  ))
4679
  if remaining_final_fact_rows:
4680
- state["final_abstract_retry_exhausted"] = {
4681
- "attempts": 2,
4682
- "violations": copy.deepcopy(remaining_final_fact_rows[:12]),
4683
- "semantic_integrity_corruption": False,
4684
- }
4685
- state.update({
4686
- "agent_state": agent.export_state(),
4687
- "quality_diagnostics": quality_diagnostics,
4688
- })
4689
- self._checkpoint(
4690
- "final_abstract_retry_exhausted", state, 92,
4691
- display_node="final_abstract_retry_exhausted",
4692
- )
4693
- raise RuntimeError(
4694
- f"final_abstract_invalid_after_retry:{remaining_final_fact_rows[:3]}"
4695
  )
 
 
4696
  quality_diagnostics.append({
4697
  "scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",
4698
  "status":"front_matter_retry_resolved", "count":len(final_front_fact_rows),
@@ -4706,8 +4854,9 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4706
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
4707
  )
4708
  if post_front_hypothesis_rows:
4709
- raise RuntimeError(
4710
- f"final_hypothesis_contract_failed_after_front_matter:{post_front_hypothesis_rows[:3]}"
 
4711
  )
4712
  body_only, _reference_tail = agent.validate_reference_tail(
4713
  full_draft_en, minimum_references=final_reference_count
@@ -4867,10 +5016,31 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4867
  f'translation_{unit_key}_repair_pending', state, pct,
4868
  display_node=f'translation_repair_pending:{index}:{len(final_units)}',
4869
  )
4870
- # Stop at the first blocking unit. The durable claim state is
4871
- # now checkpointed, so resume continues exactly here instead
4872
- # of spending tokens on later units or repeating prior repair.
4873
- raise RuntimeError('translation_final_gate_unresolved:' + str(unit_key))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4874
  state.pop('translation_blocking_unit', None)
4875
  state.pop('translation_blocking_unit_name', None)
4876
  state.pop('translation_blocking_error_code', None)
@@ -4885,7 +5055,15 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4885
  or not (translation_sections.get(key) or {}).get('translated_text')
4886
  ]
4887
  if unresolved_units:
4888
- raise RuntimeError('translation_final_gate_unresolved:' + ','.join(unresolved_units[:8]))
 
 
 
 
 
 
 
 
4889
  full_draft_translated = self._assemble_partial_translation(
4890
  translation_sections, adaptive_structure, include_front=True
4891
  ).rstrip()
@@ -4897,11 +5075,23 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4897
  )
4898
  if mode == "asq":
4899
  appendix_manifest = qual_engine.build_appendix_manifest(data_metadata)
4900
- qual_engine.validate_bound_target_appendix(
4901
- full_draft_translated, appendix_manifest, target_lang
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4902
  )
4903
- agent.validate_translation_alignment(full_draft_en, full_draft_translated)
4904
- agent.validate_reference_preservation(full_draft_en, full_draft_translated, target_lang)
4905
  self._publish_target_preview(
4906
  full_draft_translated, 'translation_complete', force=True, complete=True
4907
  )
@@ -4940,17 +5130,24 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4940
 
4941
  self._progress(97, "packaging")
4942
  bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
 
 
 
 
 
 
4943
  package = build_validated_package(
4944
  english_markdown=full_draft_en,
4945
  translated_markdown=full_draft_translated,
4946
- english_filename=f"Research_EN_{bj_time}.docx",
4947
- translated_filename=f"Research_{output_suffix}_{bj_time}.docx",
4948
  data_metadata=data_metadata,
4949
  mode=mode,
4950
  citation_audit=agent.citation_audit_log,
4951
  literature_metrics=agent.get_source_metrics(),
4952
  quality_diagnostics=quality_diagnostics,
4953
  quality_notes=quality_notes,
 
4954
  evidence_coverage=state.get("evidence_coverage_summary") or {
4955
  "status": str(state.get("evidence_status") or "adequate"),
4956
  "early_risk_detected": bool(state.get("evidence_risk_detected")),
 
26
  from config import APP_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS
27
  from core.checkpoint_store import TaskCheckpointStore
28
  from core.meta_extractor import extract_golden_metadata
29
+ from core.model_gateway import resolve_plan_policy_snapshot
30
  from core.pipeline_agent import SSciPipelineAgent, PipelineCancelledError
31
  from core.production_guard import build_validated_package, replace_abstract
32
  from core.qualitative_engine import get_qualitative_engine
 
1207
  continue
1208
  quality_notes.append(note)
1209
 
1210
+ @staticmethod
1211
+ def _record_adaptive_completion_residual(quality_notes: list, rows, *, section_name: str, stage: str) -> None:
1212
+ kinds = sorted({str((row or {}).get("type") or "validation_issue") for row in list(rows or []) if isinstance(row, dict)})
1213
+ note = {
1214
+ "type": "adaptive_completion_residual",
1215
+ "severity": "ADVISORY",
1216
+ "section": str(section_name or "Manuscript"),
1217
+ "issue_type": ",".join(kinds[:8]) or "validation_issue",
1218
+ "detail": (
1219
+ "SmartPaper completed the manuscript after bounded adaptive repair. "
1220
+ "A conservative validator still reported a residual issue, so it is recorded for optional author review instead of terminating generation."
1221
+ ),
1222
+ "user_action": "Review this passage before formal submission if additional refinement is desired.",
1223
+ "stage": str(stage or "adaptive_completion"),
1224
+ }
1225
+ sig = (note["section"], note["issue_type"], note["stage"])
1226
+ if not any(
1227
+ (str(x.get("section") or ""), str(x.get("issue_type") or ""), str(x.get("stage") or "")) == sig
1228
+ for x in quality_notes if isinstance(x, dict)
1229
+ ):
1230
+ quality_notes.append(note)
1231
+
1232
+ def _adaptive_repair_section_after_exhaustion(
1233
+ self, *, agent, quant_engine, text: str, violations, mode: str, section_id: str,
1234
+ section_name: str, data_metadata: dict, study_evidence: str, evidence_contract: str,
1235
+ hypothesis_ledger: dict, task: dict, quote_invocation_plan: dict, quality_notes: list,
1236
+ ):
1237
+ """A2 last-mile semantic repair after lineage retry exhaustion.
1238
+
1239
+ Content defects are recoverable by default. The model gets the locked
1240
+ study/evidence context, then deterministic validators re-check the exact
1241
+ returned unit. If conservative diagnostics still disagree after bounded
1242
+ repair, the best complete unit is retained and the residual is recorded
1243
+ in Appendix quality notes rather than killing the whole paper.
1244
+ """
1245
+ current = str(text or '').strip()
1246
+ remaining = list(violations or [])
1247
+ passes = max(1, int((APP_CONFIG.get("adaptive_completion") or {}).get("section_repair_passes", 2)))
1248
+ scopes = ("paragraph", "section", "section")
1249
+ for idx in range(passes):
1250
+ self._check_cancelled()
1251
+ repaired = agent.adaptive_repair_academic_unit(
1252
+ current, violations=remaining, section_id=section_id, section_name=section_name,
1253
+ mode=mode, study_evidence=study_evidence, evidence_contract=evidence_contract,
1254
+ repair_scope=scopes[min(idx, len(scopes) - 1)],
1255
+ )
1256
+ if not str(repaired or '').strip():
1257
+ repaired = current
1258
+ if mode == "amj" and hypothesis_ledger:
1259
+ repaired = self._normalize_hypothesis_ordinal_aliases(repaired, hypothesis_ledger)
1260
+ if mode == "asq" and str(section_id or '').lower() == "findings":
1261
+ repaired = self._strip_model_controlled_qualitative_quotes(repaired, data_metadata)
1262
+ repaired = self._render_planned_qualitative_quotes(
1263
+ repaired, task=task, data_metadata=data_metadata, quote_invocation_plan=quote_invocation_plan,
1264
+ )
1265
+ if mode == "amj" and data_metadata.get("quantitative_table_manifest"):
1266
+ if str(section_id or '').lower() == "results":
1267
+ repaired = self._apply_quantitative_table_contract(repaired, data_metadata, section_name)
1268
+ else:
1269
+ repaired = self._strip_model_markdown_tables(repaired)
1270
+ repaired, _enum_rows = agent.repair_and_validate_enumeration_contract(
1271
+ repaired, section_name=section_name
1272
+ )
1273
+ rows = self._section_validation_bundle(
1274
+ mode, agent, quant_engine, repaired, data_metadata, section_id=section_id,
1275
+ section_name=section_name, evidence_contract=evidence_contract, include_critic=False,
1276
+ hypothesis_ledger=hypothesis_ledger,
1277
+ )
1278
+ blocking, advisory = self._partition_section_violations(
1279
+ rows, section_name=section_name, mode=mode, exhausted=True
1280
+ )
1281
+ if advisory:
1282
+ self._record_section_advisories(quality_notes, advisory, section_name=section_name)
1283
+ current = repaired
1284
+ remaining = blocking
1285
+ if not blocking:
1286
+ return current, []
1287
+ if remaining:
1288
+ self._record_adaptive_completion_residual(
1289
+ quality_notes, remaining, section_name=section_name, stage="section_repair_exhausted"
1290
+ )
1291
+ return current, remaining
1292
+
1293
+ def _adaptive_repair_front_matter(
1294
+ self, *, agent, quant_engine, abstract_text: str, violations, mode: str,
1295
+ data_metadata: dict, study_evidence: str, hypothesis_ledger: dict, quality_notes: list,
1296
+ label: str,
1297
+ ):
1298
+ current = str(abstract_text or '').strip()
1299
+ remaining = list(violations or [])
1300
+ passes = max(1, int((APP_CONFIG.get("adaptive_completion") or {}).get("front_matter_repair_passes", 2)))
1301
+ for idx in range(passes):
1302
+ repaired = agent.adaptive_repair_academic_unit(
1303
+ current, violations=remaining, section_id="abstract", section_name=label, mode=mode,
1304
+ study_evidence=study_evidence, evidence_contract="",
1305
+ repair_scope="paragraph" if idx == 0 else "section",
1306
+ )
1307
+ repaired = str(repaired or current).strip()
1308
+ if hypothesis_ledger:
1309
+ repaired = self._normalize_hypothesis_ordinal_aliases(repaired, hypothesis_ledger)
1310
+ rows = self._study_fact_violations(
1311
+ mode, agent, quant_engine, repaired, data_metadata, section_name=label
1312
+ )
1313
+ if hypothesis_ledger:
1314
+ rows.extend(self._hypothesis_contract_violations(
1315
+ repaired, hypothesis_ledger, section_name=label
1316
+ ))
1317
+ current, remaining = repaired, rows
1318
+ if not remaining:
1319
+ return current, []
1320
+ if remaining:
1321
+ self._record_adaptive_completion_residual(
1322
+ quality_notes, remaining, section_name=label, stage="front_matter_repair_exhausted"
1323
+ )
1324
+ return current, remaining
1325
+
1326
  @staticmethod
1327
  def _citation_violation_tags(rows) -> set:
1328
  """Extract only citation tags owned by the rejected claim sentence.
 
2817
  if not APP_CONFIG["target_words_min"] <= target_words <= APP_CONFIG["target_words_max"]:
2818
  raise RuntimeError("target_words_out_of_range")
2819
 
2820
+ # A2: provider credentials are adapter-owned. DeepSeek remains the
2821
+ # current default adapter, but the runner no longer hard-codes it as the
2822
+ # only legal model provider.
2823
  ds_key = str(os.environ.get("DEEPSEEK_API_KEY") or "").strip()
2824
  s2_key = str(os.environ.get("S2_API_KEY") or "").strip()
2825
+ plan_policy_snapshot = resolve_plan_policy_snapshot(APP_CONFIG, req)
 
2826
 
2827
  if mode == "amj":
2828
  APP_CONFIG.update(AMJ_PROMPTS)
 
2831
 
2832
  agent = SSciPipelineAgent(ds_key, s2_key)
2833
  self.agent = agent
2834
+ agent.set_model_policy_snapshot(plan_policy_snapshot)
2835
  agent.set_cancel_checker(self.cancel_checker)
2836
  # DeepSeek/search activity marks the executor as genuinely alive; the
2837
  # heartbeat thread decides separately when to renew D1's lease.
 
2849
  state = self.checkpoints.load_confirmed() or {}
2850
  quality_diagnostics = list(state.get("quality_diagnostics") or [])
2851
  quality_notes = list(state.get("quality_notes") or [])
2852
+ state.setdefault("plan_policy_snapshot", copy.deepcopy(plan_policy_snapshot))
2853
  self._evidence_coverage_events = list(state.get("evidence_coverage_events") or [])
2854
  if str(state.get("evidence_status") or "") == "limited":
2855
  self._preview_evidence_status = "limited"
 
3127
  working_abstract, hypothesis_ledger, section_name="Working Abstract"
3128
  ))
3129
  if remaining_front_rows:
3130
+ working_abstract, residual_front_rows = self._adaptive_repair_front_matter(
3131
+ agent=agent, quant_engine=quant_engine, abstract_text=working_abstract,
3132
+ violations=remaining_front_rows, mode=mode, data_metadata=data_metadata,
3133
+ study_evidence=planning_data_md, hypothesis_ledger=hypothesis_ledger,
3134
+ quality_notes=quality_notes, label="Working Abstract",
3135
+ )
3136
+ if residual_front_rows:
3137
+ logging.warning("Adaptive Working Abstract accepted with residual diagnostics | count=%s", len(residual_front_rows))
3138
  quality_diagnostics.append({
3139
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
3140
  "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
 
3439
  candidate_abstract, hypothesis_ledger, section_name="Working Abstract"
3440
  ))
3441
  if remaining:
3442
+ candidate_abstract, residual_front_rows = self._adaptive_repair_front_matter(
3443
+ agent=agent, quant_engine=quant_engine, abstract_text=candidate_abstract,
3444
+ violations=remaining, mode=mode, data_metadata=data_metadata,
3445
+ study_evidence=planning_data_md, hypothesis_ledger=hypothesis_ledger,
3446
+ quality_notes=quality_notes, label="Working Abstract",
3447
+ )
3448
+ if residual_front_rows:
3449
+ logging.warning("Adaptive resumed Working Abstract accepted with residual diagnostics | count=%s", len(residual_front_rows))
3450
  working_abstract = candidate_abstract
3451
  working_keywords = candidate_keywords or working_keywords
3452
  state["working_abstract"] = working_abstract
 
3566
  # English writer. A dedicated single translation agent/worker keeps
3567
  # glossary order deterministic and avoids sharing mutable writer state.
3568
  translation_agent = SSciPipelineAgent(ds_key, s2_key)
3569
+ translation_agent.set_model_policy_snapshot(plan_policy_snapshot)
3570
+ self.translation_agent = translation_agent
3571
  translation_agent.set_execution_progress_hook(lambda: self._progress_activity())
3572
  translation_agent.set_cancel_checker(lambda: self.cancel_checker())
3573
  seeded_glossary = translation_agent.seed_canonical_concept_glossary(
 
3683
  working_abstract, hypothesis_ledger, section_name="Working Abstract"
3684
  ))
3685
  if remaining:
3686
+ working_abstract, residual_front_rows = self._adaptive_repair_front_matter(
3687
+ agent=agent, quant_engine=quant_engine, abstract_text=working_abstract,
3688
+ violations=remaining, mode=mode, data_metadata=data_metadata,
3689
+ study_evidence=planning_data_md, hypothesis_ledger=hypothesis_ledger,
3690
+ quality_notes=quality_notes, label="Working Abstract",
3691
+ )
3692
+ if residual_front_rows:
3693
+ logging.warning("Adaptive migrated Working Abstract accepted with residual diagnostics | count=%s", len(residual_front_rows))
3694
  quality_diagnostics.append({
3695
  "scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
3696
  "status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
 
4046
  isinstance(prior_retry_exhausted, dict)
4047
  and str(prior_retry_exhausted.get("section_name") or "") == display_name
4048
  ):
4049
+ # A2: content exhaustion is resumable. The checkpoint records the
4050
+ # diagnostics, but it no longer creates a terminal lifecycle state.
4051
+ logging.warning("Adaptive completion resumed exhausted section | unit=%s", display_name)
4052
+ state.pop("section_retry_exhausted", None)
4053
 
4054
  deterministic_appendix = mode == "asq" and section_id == "appendix"
4055
  # R4: one initial attempt plus bounded lineage-owned repair turns.
 
4268
  sorted({str(row.get("type") or "") for row in advisory_rows if isinstance(row, dict)}),
4269
  )
4270
  if blocking_rows:
4271
+ # A2: phase-1 exhaustion escalates to a provider-neutral
4272
+ # semantic repair rather than terminating the manuscript.
4273
+ adaptive_raw = agent.adaptive_repair_academic_unit(
4274
+ attempt_section_text, violations=blocking_rows, section_id=section_id,
4275
+ section_name=display_name, mode=mode, study_evidence=writer_scoped_data_md,
4276
+ evidence_contract=evidence_packet, repair_scope="section",
4277
  )
4278
+ adaptive_resolved = agent.audit_and_resolve_citations(
4279
+ adaptive_raw, section_name=display_name, section_id=section_id,
4280
+ quarantine=is_quarantined, allowed_tags=allowed_section_citation_tags,
 
 
 
 
 
 
4281
  )
4282
+ adaptive_citation_rows = copy.deepcopy(
4283
+ list(getattr(agent, "_last_citation_semantic_violations", []) or [])
 
 
 
 
 
 
4284
  )
4285
+ adaptive_rows = self._section_validation_bundle(
4286
+ mode, agent, quant_engine, adaptive_raw, data_metadata,
4287
+ section_id=section_id, section_name=display_name,
4288
+ evidence_contract=evidence_packet, include_critic=False,
4289
+ citation_violations=adaptive_citation_rows, hypothesis_ledger=hypothesis_ledger,
4290
  )
4291
+ adaptive_blocking, adaptive_advisory = self._partition_section_violations(
4292
+ adaptive_rows, section_name=display_name, mode=mode, exhausted=True
4293
+ )
4294
+ if adaptive_advisory:
4295
+ self._record_section_advisories(quality_notes, adaptive_advisory, section_name=display_name)
4296
+ if adaptive_blocking:
4297
+ self._record_adaptive_completion_residual(
4298
+ quality_notes, adaptive_blocking, section_name=display_name, stage="phase1_semantic_repair"
4299
+ )
4300
+ en_resolved = adaptive_resolved
4301
+ state.pop("section_retry_exhausted", None)
4302
 
4303
  used_after = set(agent.used_citations)
4304
  survived = used_after - used_before
 
4466
  sorted({str(row.get("type") or "") for row in advisory_rows if isinstance(row, dict)}),
4467
  )
4468
  if blocking_rows:
4469
+ en_resolved, residual_rows = self._adaptive_repair_section_after_exhaustion(
4470
+ agent=agent, quant_engine=quant_engine, text=en_resolved,
4471
+ violations=blocking_rows, mode=mode, section_id=section_id,
4472
+ section_name=display_name, data_metadata=data_metadata,
4473
+ study_evidence=writer_scoped_data_md, evidence_contract=evidence_packet,
4474
+ hypothesis_ledger=hypothesis_ledger, task=sec,
4475
+ quote_invocation_plan=quote_invocation_plan, quality_notes=quality_notes,
4476
  )
4477
+ logging.warning(
4478
+ "Adaptive completion accepted section | unit=%s residual=%s",
4479
+ display_name, len(residual_rows),
 
 
 
 
 
 
 
 
 
4480
  )
4481
+ state.pop("section_retry_exhausted", None)
4482
 
4483
  quality_diagnostics.extend(attempt_diagnostics)
4484
  assigned_tag_list = list(attempt_assigned_tag_list)
 
4645
  raise RuntimeError(f"final_english_section_missing:{section_title}")
4646
  seal = dict(section_integrity_seals.get(section_key) or {})
4647
  self._validate_section_integrity_seal(seal, sealed_section, section_title)
4648
+ final_fact_rows = self._study_fact_violations(
4649
+ mode, agent, quant_engine, sealed_section, data_metadata, section_name=section_title
4650
+ )
4651
+ if final_fact_rows:
4652
+ self._record_adaptive_completion_residual(
4653
+ quality_notes, final_fact_rows, section_name=section_title, stage="final_sealed_fact_review"
4654
+ )
4655
+ # A2: final validators diagnose residual content; they do not
4656
+ # destroy an otherwise complete manuscript after adaptive repair.
4657
  try:
4658
+ agent.validate_enumeration_contract(
4659
+ sealed_section, section_name=f"Final sealed section: {section_title}"
4660
+ )
 
 
 
 
 
4661
  except Exception as exc:
4662
+ self._record_adaptive_completion_residual(
4663
+ quality_notes, [{"type":"enumeration_contract_invalid","reason":str(exc)[:400]}],
4664
+ section_name=section_title, stage="final_enumeration_review"
4665
  )
 
 
 
 
 
 
4666
  internal_rows = self._author_facing_internal_workflow_violations(sealed_section)
4667
  if internal_rows:
4668
+ self._record_adaptive_completion_residual(
4669
+ quality_notes, internal_rows, section_name=section_title, stage="final_reader_facing_review"
4670
  )
4671
  if mode == "amj" and hypothesis_ledger:
4672
  hypothesis_rows = self._hypothesis_contract_violations(
4673
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
4674
  )
4675
  if hypothesis_rows:
4676
+ self._record_adaptive_completion_residual(
4677
+ quality_notes, hypothesis_rows, section_name="Full Manuscript", stage="final_hypothesis_review"
4678
+ )
4679
  # Validate the completed body while the Working Abstract is still in
4680
  # place. Final front matter must not be generated until all body
4681
  # sections, citation cleanup, and any Discussion rebalance are frozen.
4682
  agent.validate_manuscript_structure(full_draft_en, adaptive_structure, translated=False)
4683
  if mode == "asq":
4684
+ try:
4685
+ self._validate_qualitative_quote_rendering(
4686
+ full_draft_en, quote_invocation_plan, data_metadata
4687
+ )
4688
+ except Exception as exc:
4689
+ self._record_adaptive_completion_residual(
4690
+ quality_notes, [{"type":"qualitative_quote_rendering","reason":str(exc)[:500]}],
4691
+ section_name="Findings", stage="final_quote_review"
4692
+ )
4693
  repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
4694
  if repeated_quotes:
4695
+ self._record_adaptive_completion_residual(
4696
+ quality_notes, [{"type":"qualitative_quote_repetition","reason":str(repeated_quotes[:5])}],
4697
+ section_name="Findings", stage="final_quote_review"
4698
+ )
4699
  # R4-R3: the body is already citation-resolved at this point. Never
4700
  # rescan it for raw CITE tags; that destroys a valid citation ledger.
4701
  final_citation_ledger = agent.finalize_resolved_citation_ledger(full_draft_en)
 
4833
  final_abstract, hypothesis_ledger, section_name="Final Abstract"
4834
  ))
4835
  if remaining_final_fact_rows:
4836
+ final_abstract, residual_final_rows = self._adaptive_repair_front_matter(
4837
+ agent=agent, quant_engine=quant_engine, abstract_text=final_abstract,
4838
+ violations=remaining_final_fact_rows, mode=mode, data_metadata=data_metadata,
4839
+ study_evidence=user_data_md, hypothesis_ledger=hypothesis_ledger,
4840
+ quality_notes=quality_notes, label="Final Abstract",
 
 
 
 
 
 
 
 
 
 
4841
  )
4842
+ if residual_final_rows:
4843
+ logging.warning("Adaptive Final Abstract accepted with residual diagnostics | count=%s", len(residual_final_rows))
4844
  quality_diagnostics.append({
4845
  "scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",
4846
  "status":"front_matter_retry_resolved", "count":len(final_front_fact_rows),
 
4854
  full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
4855
  )
4856
  if post_front_hypothesis_rows:
4857
+ self._record_adaptive_completion_residual(
4858
+ quality_notes, post_front_hypothesis_rows, section_name="Final Abstract",
4859
+ stage="post_front_matter_hypothesis_review"
4860
  )
4861
  body_only, _reference_tail = agent.validate_reference_tail(
4862
  full_draft_en, minimum_references=final_reference_count
 
5016
  f'translation_{unit_key}_repair_pending', state, pct,
5017
  display_node=f'translation_repair_pending:{index}:{len(final_units)}',
5018
  )
5019
+ # A2: strict claim QA is diagnostic/recovery authority, not a
5020
+ # manuscript kill switch. Escalate the same frozen source to
5021
+ # a whole-unit provider-neutral fallback and keep going.
5022
+ fallback = translation_agent.adaptive_translate_fallback(
5023
+ translation_source, target_lang, section_name=unit_name, glossary=translation_glossary
5024
+ )
5025
+ current_translation.update({
5026
+ 'translated_text': str(fallback.get('text') or '').strip(),
5027
+ 'qa_status': str(fallback.get('qa_status') or 'passed_with_warning'),
5028
+ 'diagnostics': list(current_translation.get('diagnostics') or []) + list(fallback.get('diagnostics') or []),
5029
+ 'error_code': '',
5030
+ 'adaptive_completion_fallback': True,
5031
+ })
5032
+ translation_sections[unit_key] = current_translation
5033
+ if mode == "amj" and str(unit_key).strip().casefold() == "results":
5034
+ table_manifest = dict(data_metadata.get("quantitative_table_manifest") or {})
5035
+ if table_manifest and current_translation.get('translated_text'):
5036
+ current_translation['translated_text'] = quant_engine.bind_target_language_quantitative_tables(
5037
+ current_translation.get('translated_text'), table_manifest, target_lang, translation_glossary
5038
+ )
5039
+ translation_sections[unit_key] = current_translation
5040
+ self._record_adaptive_completion_residual(
5041
+ quality_notes, [{'type':'translation_strict_gate_fallback','reason':str(state.get('translation_blocking_error_code') or '')}],
5042
+ section_name=unit_name, stage='translation_fallback'
5043
+ )
5044
  state.pop('translation_blocking_unit', None)
5045
  state.pop('translation_blocking_unit_name', None)
5046
  state.pop('translation_blocking_error_code', None)
 
5055
  or not (translation_sections.get(key) or {}).get('translated_text')
5056
  ]
5057
  if unresolved_units:
5058
+ # A2 should normally resolve these inside the per-unit fallback.
5059
+ # Keep the manuscript moving if a stale checkpoint status remains
5060
+ # but usable translated text exists; empty target text is still an
5061
+ # infrastructure/completion failure and cannot produce a document.
5062
+ empty_units = [key for key in unresolved_units if not (translation_sections.get(key) or {}).get('translated_text')]
5063
+ if empty_units:
5064
+ raise RuntimeError('translation_output_missing:' + ','.join(empty_units[:8]))
5065
+ for key in unresolved_units:
5066
+ translation_sections[key]['qa_status'] = 'passed_with_warning'
5067
  full_draft_translated = self._assemble_partial_translation(
5068
  translation_sections, adaptive_structure, include_front=True
5069
  ).rstrip()
 
5075
  )
5076
  if mode == "asq":
5077
  appendix_manifest = qual_engine.build_appendix_manifest(data_metadata)
5078
+ try:
5079
+ qual_engine.validate_bound_target_appendix(
5080
+ full_draft_translated, appendix_manifest, target_lang
5081
+ )
5082
+ except Exception as exc:
5083
+ self._record_adaptive_completion_residual(
5084
+ quality_notes, [{'type':'target_qualitative_appendix_review','reason':str(exc)[:500]}],
5085
+ section_name='Appendix', stage='translation_final_review'
5086
+ )
5087
+ try:
5088
+ agent.validate_translation_alignment(full_draft_en, full_draft_translated)
5089
+ agent.validate_reference_preservation(full_draft_en, full_draft_translated, target_lang)
5090
+ except Exception as exc:
5091
+ self._record_adaptive_completion_residual(
5092
+ quality_notes, [{'type':'translation_final_alignment_review','reason':str(exc)[:500]}],
5093
+ section_name='Target-language manuscript', stage='translation_final_review'
5094
  )
 
 
5095
  self._publish_target_preview(
5096
  full_draft_translated, 'translation_complete', force=True, complete=True
5097
  )
 
5130
 
5131
  self._progress(97, "packaging")
5132
  bj_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y%m%d_%H%M")
5133
+ target_file_language = (
5134
+ "Chinese" if "chinese" in target_lang.casefold() or "中文" in target_lang
5135
+ else "Russian" if "russian" in target_lang.casefold() or "рус" in target_lang.casefold()
5136
+ else "Kazakh" if "kazakh" in target_lang.casefold() or "қазақ" in target_lang.casefold()
5137
+ else re.sub(r"[^A-Za-z]+", "", output_suffix) or "Target"
5138
+ )
5139
  package = build_validated_package(
5140
  english_markdown=full_draft_en,
5141
  translated_markdown=full_draft_translated,
5142
+ english_filename="Paper_English.docx",
5143
+ translated_filename=f"Paper_{target_file_language}.docx",
5144
  data_metadata=data_metadata,
5145
  mode=mode,
5146
  citation_audit=agent.citation_audit_log,
5147
  literature_metrics=agent.get_source_metrics(),
5148
  quality_diagnostics=quality_diagnostics,
5149
  quality_notes=quality_notes,
5150
+ organize_appendix=True,
5151
  evidence_coverage=state.get("evidence_coverage_summary") or {
5152
  "status": str(state.get("evidence_status") or "adequate"),
5153
  "early_risk_detected": bool(state.get("evidence_risk_detected")),
core/model_gateway.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Provider-neutral model routing primitives for SmartPaper.
3
+
4
+ A2 keeps the currently verified DeepSeek transport intact while moving provider
5
+ selection out of academic business logic. Providers are registered adapters;
6
+ academic code asks for a capability (reasoning/writing/translation/fast/repair)
7
+ and never needs to know the concrete vendor or model name.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from copy import deepcopy
12
+ from dataclasses import dataclass
13
+ from typing import Any, Callable, Iterable
14
+
15
+
16
+ class ModelGatewayError(RuntimeError):
17
+ pass
18
+
19
+
20
+ class ModelProviderUnavailableError(ModelGatewayError):
21
+ pass
22
+
23
+
24
+ class ModelAuthenticationError(ModelGatewayError):
25
+ pass
26
+
27
+
28
+ class ModelBalanceError(ModelGatewayError):
29
+ pass
30
+
31
+
32
+ class ModelRequestError(ModelGatewayError):
33
+ pass
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ModelRoute:
38
+ provider: str
39
+ provider_role: str
40
+ fallback_providers: tuple[str, ...] = ()
41
+
42
+
43
+ class ProviderNeutralModelGateway:
44
+ """Route capability calls to registered provider adapters.
45
+
46
+ Adapter callables intentionally share the existing PipelineAgent request
47
+ signature. This makes A2 a low-risk architectural seam: DeepSeek remains
48
+ the verified adapter today, while Gemini/Qwen/OpenAI adapters can be
49
+ registered later without changing manuscript generation code.
50
+ """
51
+
52
+ def __init__(self, config: dict[str, Any] | None = None):
53
+ cfg = dict(config or {})
54
+ self.default_provider = str(cfg.get("default_provider") or "deepseek").strip().lower()
55
+ raw_routes = dict(cfg.get("capability_routes") or {})
56
+ self._routes: dict[str, ModelRoute] = {}
57
+ for capability, row in raw_routes.items():
58
+ cap = str(capability or "").strip().lower()
59
+ if not cap:
60
+ continue
61
+ if isinstance(row, str):
62
+ provider = str(row or self.default_provider).strip().lower()
63
+ provider_role = cap
64
+ else:
65
+ item = dict(row or {})
66
+ provider = str(item.get("provider") or self.default_provider).strip().lower()
67
+ provider_role = str(item.get("provider_role") or cap).strip().lower()
68
+ raw_fallbacks = item.get("fallback_providers") or ()
69
+ if isinstance(raw_fallbacks, str):
70
+ raw_fallbacks = [part.strip() for part in raw_fallbacks.split(",") if part.strip()]
71
+ fallbacks = tuple(
72
+ str(name or "").strip().lower()
73
+ for name in raw_fallbacks
74
+ if str(name or "").strip()
75
+ )
76
+ if not isinstance(row, dict):
77
+ fallbacks = ()
78
+ self._routes[cap] = ModelRoute(provider=provider, provider_role=provider_role, fallback_providers=fallbacks)
79
+ self._call_adapters: dict[str, Callable[..., str]] = {}
80
+ self._stream_adapters: dict[str, Callable[..., Iterable[str]]] = {}
81
+ self._policy_snapshot: dict[str, Any] = {}
82
+
83
+ def register_provider(
84
+ self,
85
+ provider: str,
86
+ *,
87
+ call_adapter: Callable[..., str],
88
+ stream_adapter: Callable[..., Iterable[str]],
89
+ ) -> None:
90
+ name = str(provider or "").strip().lower()
91
+ if not name:
92
+ raise ValueError("provider name is required")
93
+ self._call_adapters[name] = call_adapter
94
+ self._stream_adapters[name] = stream_adapter
95
+
96
+ def set_policy_snapshot(self, snapshot: dict[str, Any] | None) -> None:
97
+ self._policy_snapshot = deepcopy(dict(snapshot or {}))
98
+
99
+ @property
100
+ def policy_snapshot(self) -> dict[str, Any]:
101
+ return deepcopy(self._policy_snapshot)
102
+
103
+ def _route(self, capability: str) -> ModelRoute:
104
+ cap = str(capability or "reasoning").strip().lower() or "reasoning"
105
+ policy_routes = dict(self._policy_snapshot.get("model_routes") or {})
106
+ row = policy_routes.get(cap)
107
+ if isinstance(row, str):
108
+ return ModelRoute(provider=row.strip().lower(), provider_role=cap)
109
+ if isinstance(row, dict):
110
+ raw_fallbacks = row.get("fallback_providers") or ()
111
+ if isinstance(raw_fallbacks, str):
112
+ raw_fallbacks = [part.strip() for part in raw_fallbacks.split(",") if part.strip()]
113
+ return ModelRoute(
114
+ provider=str(row.get("provider") or self.default_provider).strip().lower(),
115
+ provider_role=str(row.get("provider_role") or cap).strip().lower(),
116
+ fallback_providers=tuple(
117
+ str(name or "").strip().lower() for name in raw_fallbacks
118
+ if str(name or "").strip()
119
+ ),
120
+ )
121
+ return self._routes.get(cap) or ModelRoute(self.default_provider, cap)
122
+
123
+ @staticmethod
124
+ def _provider_sequence(route: ModelRoute) -> tuple[str, ...]:
125
+ return tuple(dict.fromkeys(
126
+ name for name in (route.provider, *route.fallback_providers) if name
127
+ ))
128
+
129
+ def call(self, *, capability: str, **kwargs) -> str:
130
+ route = self._route(capability)
131
+ missing = []
132
+ for provider in self._provider_sequence(route):
133
+ adapter = self._call_adapters.get(provider)
134
+ if not callable(adapter):
135
+ missing.append(provider)
136
+ continue
137
+ payload = dict(kwargs)
138
+ payload["model_role"] = route.provider_role
139
+ return adapter(**payload)
140
+ raise ModelProviderUnavailableError(
141
+ f"model_provider_unavailable:{','.join(missing) or route.provider}:{capability}"
142
+ )
143
+
144
+ def stream(self, *, capability: str, **kwargs):
145
+ route = self._route(capability)
146
+ missing = []
147
+ for provider in self._provider_sequence(route):
148
+ adapter = self._stream_adapters.get(provider)
149
+ if not callable(adapter):
150
+ missing.append(provider)
151
+ continue
152
+ payload = dict(kwargs)
153
+ payload["model_role"] = route.provider_role
154
+ yield from adapter(**payload)
155
+ return
156
+ raise ModelProviderUnavailableError(
157
+ f"model_provider_unavailable:{','.join(missing) or route.provider}:{capability}"
158
+ )
159
+
160
+
161
+ def resolve_plan_policy_snapshot(
162
+ app_config: dict[str, Any], request: dict[str, Any] | None = None
163
+ ) -> dict[str, Any]:
164
+ """Freeze a config-driven plan policy without touching payment authority.
165
+
166
+ A2 only creates the interface. Worker/D1 remain the authority for payment,
167
+ quota and entitlement; if the request does not yet carry a trusted plan_id,
168
+ the legacy-current policy preserves existing production behavior.
169
+ """
170
+ req = dict(request or {})
171
+ policies = dict(app_config.get("plan_policies") or {})
172
+ # Never trust a browser/request plan label as an entitlement authority.
173
+ # A future Worker/D1 integration may inject a trusted entitlement snapshot;
174
+ # until then every task stays on the legacy-current production policy.
175
+ entitlement = req.get("entitlement_snapshot") if isinstance(req.get("entitlement_snapshot"), dict) else {}
176
+ requested = str(
177
+ req.get("trusted_plan_id") or entitlement.get("plan_id") or ""
178
+ ).strip().lower()
179
+ selected = requested if requested in policies else "legacy_current"
180
+ policy = deepcopy(dict(policies.get(selected) or policies.get("legacy_current") or {}))
181
+ policy["plan_id"] = selected
182
+ policy["policy_version"] = str(
183
+ policy.get("policy_version") or app_config.get("plan_policy_version") or "plan-policy-v1"
184
+ )
185
+ return policy
core/pipeline_agent.py CHANGED
@@ -28,6 +28,7 @@ except ImportError:
28
 
29
  from config import APP_CONFIG
30
  from core.production_guard import find_authorial_ai_meta_spans
 
31
 
32
 
33
  class DeepSeekAPIError(RuntimeError):
@@ -104,13 +105,12 @@ class SSciPipelineAgent:
104
  families.append(code)
105
  return tuple(dict.fromkeys(families)) or ('UNKNOWN',)
106
 
107
- def __init__(self, ds_key, s2_key=""):
108
  self.ds_key = str(ds_key or "").strip()
109
- if not self.ds_key:
110
- raise DeepSeekAuthenticationError(
111
- "DEEPSEEK_API_KEY is empty. Configure it in Hugging Face "
112
- "Space Settings > Variables and secrets."
113
- )
114
  # R3-Free: the generation engine is headless. Hugging Face Secrets are
115
  # exposed to the process as environment variables; Streamlit is not a
116
  # dependency of the executor or pipeline.
@@ -187,22 +187,6 @@ class SSciPipelineAgent:
187
  str(role).strip().lower(): str(model or "").strip()
188
  for role, model in configured_models.items()
189
  }
190
- required_roles = {
191
- "reasoning",
192
- "writing",
193
- "translation",
194
- "fast",
195
- "fallback",
196
- }
197
- missing_roles = [
198
- role for role in required_roles
199
- if not self.deepseek_models.get(role)
200
- ]
201
- if missing_roles:
202
- raise DeepSeekRequestError(
203
- "Missing DeepSeek model role configuration: "
204
- + ", ".join(sorted(missing_roles))
205
- )
206
  self.deepseek_task_settings = APP_CONFIG.get(
207
  "deepseek_task_settings",
208
  {},
@@ -224,6 +208,16 @@ class SSciPipelineAgent:
224
  20,
225
  int(APP_CONFIG.get("deepseek_stream_idle_seconds", 60)),
226
  )
 
 
 
 
 
 
 
 
 
 
227
 
228
  self.global_memory = {
229
  "hypotheses": "", "variables": "", "model_summary": "",
@@ -797,7 +791,7 @@ class SSciPipelineAgent:
797
  model = fallback_model
798
  yield attempt, model, thinking
799
 
800
- def call_deepseek(
801
  self,
802
  system_prompt,
803
  user_prompt,
@@ -810,6 +804,10 @@ class SSciPipelineAgent:
810
  allow_fallback=None,
811
  response_format=None,
812
  ):
 
 
 
 
813
  (
814
  requested_model,
815
  requested_thinking,
@@ -944,7 +942,7 @@ class SSciPipelineAgent:
944
  f"{self.deepseek_max_retries} attempts: {last_error}"
945
  )
946
 
947
- def stream_deepseek(
948
  self,
949
  system_prompt,
950
  user_prompt,
@@ -957,6 +955,10 @@ class SSciPipelineAgent:
957
  allow_fallback=None,
958
  reasoning_only_recovery=False,
959
  ):
 
 
 
 
960
  (
961
  requested_model,
962
  requested_thinking,
@@ -1181,6 +1183,79 @@ class SSciPipelineAgent:
1181
  f"{self.deepseek_max_retries} attempts: {last_error}"
1182
  )
1183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184
  def parse_user_core_literature(self, user_lit_text):
1185
  if not user_lit_text.strip():
1186
  return ""
@@ -5759,10 +5834,21 @@ SECTION ROLE: {profile}
5759
  EVIDENCE CONTRACT: {evidence_contract[:4500]}
5760
  TEXT:
5761
  {value}"""
5762
- raw = self.call_deepseek(
5763
- 'You are an independent senior management-journal reviewer. Be exact and conservative.',
5764
- prompt, max_tokens=800, temp=0.0, model_role='fast', thinking=False, allow_fallback=True,
5765
- )
 
 
 
 
 
 
 
 
 
 
 
5766
  parsed = self._extract_json_from_text(raw)
5767
  if not isinstance(parsed, dict) or parsed.get('pass') is not False:
5768
  return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
@@ -5772,6 +5858,83 @@ TEXT:
5772
  return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
5773
  return {'pass': False, 'critical_issue': issue[:800], 'repair_instruction': instruction[:1200]}
5774
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5775
  def critique_and_repair_section(self, text, *, section_id, section_name, mode, evidence_contract=''):
5776
  """Legacy compatibility wrapper. Active S0-R1 path uses critique_section().
5777
 
@@ -10286,6 +10449,75 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
10286
  raise
10287
  raise last_error or DeepSeekRequestError('Translation section QA failed.')
10288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10289
  def translate_locked_manuscript(self, manuscript, target_lang):
10290
  value = str(manuscript or '').strip()
10291
  if not value:
 
28
 
29
  from config import APP_CONFIG
30
  from core.production_guard import find_authorial_ai_meta_spans
31
+ from core.model_gateway import ProviderNeutralModelGateway
32
 
33
 
34
  class DeepSeekAPIError(RuntimeError):
 
105
  families.append(code)
106
  return tuple(dict.fromkeys(families)) or ('UNKNOWN',)
107
 
108
+ def __init__(self, ds_key="", s2_key=""):
109
  self.ds_key = str(ds_key or "").strip()
110
+ self.model_gateway_config = dict(APP_CONFIG.get("model_gateway") or {})
111
+ # Provider credentials are adapter-owned and validated only when that
112
+ # provider is actually selected. This is essential for future plan
113
+ # policies that may route every capability away from DeepSeek.
 
114
  # R3-Free: the generation engine is headless. Hugging Face Secrets are
115
  # exposed to the process as environment variables; Streamlit is not a
116
  # dependency of the executor or pipeline.
 
187
  str(role).strip().lower(): str(model or "").strip()
188
  for role, model in configured_models.items()
189
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  self.deepseek_task_settings = APP_CONFIG.get(
191
  "deepseek_task_settings",
192
  {},
 
208
  20,
209
  int(APP_CONFIG.get("deepseek_stream_idle_seconds", 60)),
210
  )
211
+ self.model_gateway = ProviderNeutralModelGateway(self.model_gateway_config)
212
+ # A2 registers the already-verified DeepSeek transport as one provider
213
+ # adapter. Future provider adapters can be added here without changing
214
+ # academic generation, translation, or repair call sites.
215
+ self.model_gateway.register_provider(
216
+ "deepseek",
217
+ call_adapter=self._call_deepseek_transport,
218
+ stream_adapter=self._stream_deepseek_transport,
219
+ )
220
+ self.model_policy_snapshot = {}
221
 
222
  self.global_memory = {
223
  "hypotheses": "", "variables": "", "model_summary": "",
 
791
  model = fallback_model
792
  yield attempt, model, thinking
793
 
794
+ def _call_deepseek_transport(
795
  self,
796
  system_prompt,
797
  user_prompt,
 
804
  allow_fallback=None,
805
  response_format=None,
806
  ):
807
+ if not str(getattr(self, "ds_key", "") or "").strip():
808
+ raise DeepSeekAuthenticationError(
809
+ "DEEPSEEK_API_KEY is empty for the selected DeepSeek provider adapter."
810
+ )
811
  (
812
  requested_model,
813
  requested_thinking,
 
942
  f"{self.deepseek_max_retries} attempts: {last_error}"
943
  )
944
 
945
+ def _stream_deepseek_transport(
946
  self,
947
  system_prompt,
948
  user_prompt,
 
955
  allow_fallback=None,
956
  reasoning_only_recovery=False,
957
  ):
958
+ if not str(getattr(self, "ds_key", "") or "").strip():
959
+ raise DeepSeekAuthenticationError(
960
+ "DEEPSEEK_API_KEY is empty for the selected DeepSeek provider adapter."
961
+ )
962
  (
963
  requested_model,
964
  requested_thinking,
 
1183
  f"{self.deepseek_max_retries} attempts: {last_error}"
1184
  )
1185
 
1186
+ def set_model_policy_snapshot(self, snapshot):
1187
+ self.model_policy_snapshot = dict(snapshot or {})
1188
+ self.model_gateway.set_policy_snapshot(self.model_policy_snapshot)
1189
+
1190
+ def call_model(
1191
+ self, system_prompt, user_prompt, max_tokens=8000, temp=0.3,
1192
+ model_name=None, model_role="reasoning", thinking=None,
1193
+ reasoning_effort=None, allow_fallback=None, response_format=None,
1194
+ ):
1195
+ gateway = getattr(self, "model_gateway", None)
1196
+ if gateway is None:
1197
+ # Unit-test / old-checkpoint compatibility for agents constructed
1198
+ # without __init__. Production instances always own a gateway.
1199
+ return self._call_deepseek_transport(
1200
+ system_prompt, user_prompt, max_tokens=max_tokens, temp=temp,
1201
+ model_name=model_name, model_role=model_role, thinking=thinking,
1202
+ reasoning_effort=reasoning_effort, allow_fallback=allow_fallback,
1203
+ response_format=response_format,
1204
+ )
1205
+ return gateway.call(
1206
+ capability=model_role, system_prompt=system_prompt, user_prompt=user_prompt,
1207
+ max_tokens=max_tokens, temp=temp, model_name=model_name,
1208
+ thinking=thinking, reasoning_effort=reasoning_effort,
1209
+ allow_fallback=allow_fallback, response_format=response_format,
1210
+ )
1211
+
1212
+ def stream_model(
1213
+ self, system_prompt, user_prompt, max_tokens=8000, model_name=None,
1214
+ model_role="reasoning", thinking=None, reasoning_effort=None, temp=0.3,
1215
+ allow_fallback=None, reasoning_only_recovery=False,
1216
+ ):
1217
+ gateway = getattr(self, "model_gateway", None)
1218
+ if gateway is None:
1219
+ yield from self._stream_deepseek_transport(
1220
+ system_prompt, user_prompt, max_tokens=max_tokens, model_name=model_name,
1221
+ model_role=model_role, thinking=thinking, reasoning_effort=reasoning_effort,
1222
+ temp=temp, allow_fallback=allow_fallback,
1223
+ reasoning_only_recovery=reasoning_only_recovery,
1224
+ )
1225
+ return
1226
+ yield from gateway.stream(
1227
+ capability=model_role, system_prompt=system_prompt, user_prompt=user_prompt,
1228
+ max_tokens=max_tokens, model_name=model_name, thinking=thinking,
1229
+ reasoning_effort=reasoning_effort, temp=temp, allow_fallback=allow_fallback,
1230
+ reasoning_only_recovery=reasoning_only_recovery,
1231
+ )
1232
+
1233
+ # Compatibility aliases retained for the existing verified test surface and
1234
+ # older engines. Runtime routing is provider-neutral through call_model.
1235
+ def call_deepseek(
1236
+ self, system_prompt, user_prompt, max_tokens=8000, temp=0.3,
1237
+ model_name=None, model_role="reasoning", thinking=None,
1238
+ reasoning_effort=None, allow_fallback=None, response_format=None,
1239
+ ):
1240
+ return self.call_model(
1241
+ system_prompt, user_prompt, max_tokens=max_tokens, temp=temp,
1242
+ model_name=model_name, model_role=model_role, thinking=thinking,
1243
+ reasoning_effort=reasoning_effort, allow_fallback=allow_fallback,
1244
+ response_format=response_format,
1245
+ )
1246
+
1247
+ def stream_deepseek(
1248
+ self, system_prompt, user_prompt, max_tokens=8000, model_name=None,
1249
+ model_role="reasoning", thinking=None, reasoning_effort=None, temp=0.3,
1250
+ allow_fallback=None, reasoning_only_recovery=False,
1251
+ ):
1252
+ yield from self.stream_model(
1253
+ system_prompt, user_prompt, max_tokens=max_tokens, model_name=model_name,
1254
+ model_role=model_role, thinking=thinking, reasoning_effort=reasoning_effort,
1255
+ temp=temp, allow_fallback=allow_fallback,
1256
+ reasoning_only_recovery=reasoning_only_recovery,
1257
+ )
1258
+
1259
  def parse_user_core_literature(self, user_lit_text):
1260
  if not user_lit_text.strip():
1261
  return ""
 
5834
  EVIDENCE CONTRACT: {evidence_contract[:4500]}
5835
  TEXT:
5836
  {value}"""
5837
+ try:
5838
+ # Compatibility entrypoint; production call_deepseek is now only
5839
+ # an alias into the provider-neutral model gateway.
5840
+ raw = self.call_deepseek(
5841
+ 'You are an independent senior management-journal reviewer. Be exact and conservative.',
5842
+ prompt, max_tokens=800, temp=0.0, model_role='fast', thinking=False, allow_fallback=True,
5843
+ )
5844
+ except PipelineCancelledError:
5845
+ raise
5846
+ except Exception as exc:
5847
+ logging.warning(
5848
+ 'Optional top-journal critic unavailable; continuing without critic | section=%s error=%s',
5849
+ section_name or section_id, type(exc).__name__,
5850
+ )
5851
+ return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
5852
  parsed = self._extract_json_from_text(raw)
5853
  if not isinstance(parsed, dict) or parsed.get('pass') is not False:
5854
  return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
 
5858
  return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
5859
  return {'pass': False, 'critical_issue': issue[:800], 'repair_instruction': instruction[:1200]}
5860
 
5861
+ def adaptive_repair_academic_unit(
5862
+ self, text, *, violations, section_id='', section_name='', mode='amj',
5863
+ study_evidence='', evidence_contract='', repair_scope='section',
5864
+ ):
5865
+ """A2 provider-neutral semantic repair for exhausted academic content.
5866
+
5867
+ The model may rewrite prose, but it may not invent study facts, numeric
5868
+ results, citations, methods, hypotheses, or quotations. Deterministic
5869
+ validators remain the source of truth and re-check the returned text.
5870
+ """
5871
+ value = str(text or '').strip()
5872
+ if not value:
5873
+ return value
5874
+ feedback_rows = []
5875
+ for row in list(violations or [])[:12]:
5876
+ if isinstance(row, dict):
5877
+ feedback_rows.append({
5878
+ 'type': str(row.get('type') or 'validation_issue'),
5879
+ 'reason': str(row.get('repair_instruction') or row.get('reason') or row.get('preview') or '')[:500],
5880
+ 'observed': row.get('observed'),
5881
+ 'expected': row.get('expected'),
5882
+ })
5883
+ else:
5884
+ feedback_rows.append({'type': 'validation_issue', 'reason': str(row)[:500]})
5885
+ prompt = f"""Repair the academic manuscript unit below. Return ONLY the revised reader-facing unit.
5886
+
5887
+ REPAIR SCOPE: {str(repair_scope or 'section').upper()}
5888
+ RESEARCH MODE: {'QUALITATIVE' if str(mode).lower() == 'asq' else 'QUANTITATIVE'}
5889
+ SECTION ID: {section_id}
5890
+ SECTION NAME: {section_name}
5891
+
5892
+ NON-NEGOTIABLE RULES:
5893
+ 1. Preserve the study's actual design, actors, sample, measures, methods, statistics, hypotheses, themes, quotations, and evidence boundaries.
5894
+ 2. Do not invent or import any numeric value, citation, bibliographic identity, quotation, method, participant, data source, or result.
5895
+ 3. Existing citations may be removed, narrowed, or repositioned only to correct support; never create a new citation identity.
5896
+ 4. Preserve direct qualitative quotations verbatim.
5897
+ 5. For observational/cross-sectional quantitative work, use association/covariation language unless causal identification is explicitly supported by the locked study evidence.
5898
+ 6. Write concise, accurate, rigorous academic prose. Do not mention validation, repair, prompts, AI, ledgers, orchestration, or internal workflow.
5899
+ 7. Resolve the listed defects while keeping the section role and logical continuity.
5900
+
5901
+ VALIDATION DEFECTS:
5902
+ {json.dumps(feedback_rows, ensure_ascii=False)}
5903
+
5904
+ LOCKED STUDY EVIDENCE:
5905
+ {str(study_evidence or '')[:12000]}
5906
+
5907
+ AUTHORIZED EVIDENCE CONTEXT:
5908
+ {str(evidence_contract or '')[:8000]}
5909
+
5910
+ CURRENT UNIT:
5911
+ {value}
5912
+ """
5913
+ try:
5914
+ repaired = self.call_model(
5915
+ 'You are SmartPaper Adaptive Academic Repair. Preserve evidence truth and repair prose with Conciseness, Accuracy, and Rigor.',
5916
+ prompt, max_tokens=7000, temp=0.15, model_role='repair', thinking=True, allow_fallback=True,
5917
+ )
5918
+ except PipelineCancelledError:
5919
+ raise
5920
+ except Exception as exc:
5921
+ # A repair-provider failure must not destroy an otherwise complete
5922
+ # academic unit. Revalidation will preserve the residual as an
5923
+ # Appendix advisory; core writer/provider outages remain handled at
5924
+ # their actual generation boundary.
5925
+ self._last_adaptive_repair_diagnostic = {
5926
+ 'status': 'repair_provider_unavailable',
5927
+ 'error_type': type(exc).__name__,
5928
+ 'section': str(section_name or section_id or ''),
5929
+ }
5930
+ logging.warning(
5931
+ 'Adaptive academic repair unavailable; retaining complete source unit | section=%s error=%s',
5932
+ section_name or section_id, type(exc).__name__,
5933
+ )
5934
+ return value
5935
+ repaired = re.sub(r'<think>.*?</think>', '', str(repaired or ''), flags=re.S).strip()
5936
+ return repaired or value
5937
+
5938
  def critique_and_repair_section(self, text, *, section_id, section_name, mode, evidence_contract=''):
5939
  """Legacy compatibility wrapper. Active S0-R1 path uses critique_section().
5940
 
 
10449
  raise
10450
  raise last_error or DeepSeekRequestError('Translation section QA failed.')
10451
 
10452
+ def adaptive_translate_fallback(self, section_text, target_lang, *, section_name='', glossary=None):
10453
+ """A2 final content fallback after strict claim QA cannot converge.
10454
+
10455
+ This is intentionally less brittle than the claim-level strict gate but
10456
+ still requires a complete target-language unit with the same heading
10457
+ tree. References are never sent here by the runner.
10458
+ """
10459
+ value = str(section_text or '').strip()
10460
+ if not value:
10461
+ raise DeepSeekRequestError('Adaptive translation fallback received empty source.')
10462
+ attempts = max(1, int((APP_CONFIG.get('adaptive_completion') or {}).get('translation_fallback_passes', 1)))
10463
+ last_error = None
10464
+ for _attempt in range(attempts):
10465
+ try:
10466
+ translated = self.translate_locked_manuscript(value, target_lang)
10467
+ self.validate_translation_alignment(value, translated)
10468
+ self._validate_translation_language_quality(translated, target_lang)
10469
+ return {
10470
+ 'text': translated,
10471
+ 'qa_status': 'passed_with_warning',
10472
+ 'diagnostics': [{
10473
+ 'code': 'ADAPTIVE_TRANSLATION_FALLBACK',
10474
+ 'section': str(section_name or ''),
10475
+ 'decision': 'ACCEPT_AFTER_FALLBACK',
10476
+ }],
10477
+ }
10478
+ except PipelineCancelledError:
10479
+ raise
10480
+ except (DeepSeekAuthenticationError, DeepSeekBalanceError):
10481
+ raise
10482
+ except Exception as exc:
10483
+ last_error = exc
10484
+ logging.warning('Adaptive translation fallback retry | section=%s error=%s', section_name or 'section', type(exc).__name__)
10485
+
10486
+ # Final semantic escape hatch: ask the configured translation capability
10487
+ # for a whole-unit faithful rewrite. This path is used only after the
10488
+ # stricter claim compiler/QA cannot converge; it still preserves the
10489
+ # frozen source and requires non-empty target prose.
10490
+ prompt = f"""Translate the complete frozen English manuscript unit into {target_lang}.
10491
+ Return ONLY the translated Markdown unit.
10492
+ Preserve every heading level and order, every number/statistic/date, every citation already present, every hypothesis identity, and every direct quotation exactly in meaning. Do not add facts, citations, methods, results, or commentary. Use natural academic language and keep the argument logically equivalent to the English source.
10493
+
10494
+ SOURCE UNIT:
10495
+ {value}
10496
+ """
10497
+ translated = self.call_model(
10498
+ 'You are SmartPaper Adaptive Translation. Produce faithful academic translation with no added or omitted claims.',
10499
+ prompt, max_tokens=8000, temp=0.1, model_role='translation', thinking=False, allow_fallback=True,
10500
+ )
10501
+ translated = re.sub(r'<think>.*?</think>', '', str(translated or ''), flags=re.S).strip()
10502
+ if not translated:
10503
+ raise last_error or DeepSeekRequestError('Adaptive translation fallback failed.')
10504
+ diagnostics = [{
10505
+ 'code': 'ADAPTIVE_TRANSLATION_WHOLE_UNIT_FALLBACK',
10506
+ 'section': str(section_name or ''),
10507
+ 'decision': 'ACCEPT_AFTER_FALLBACK',
10508
+ 'prior_error': type(last_error).__name__ if last_error else '',
10509
+ }]
10510
+ try:
10511
+ self.validate_translation_alignment(value, translated)
10512
+ self._validate_translation_language_quality(translated, target_lang)
10513
+ except Exception as exc:
10514
+ diagnostics.append({
10515
+ 'code': 'ADAPTIVE_TRANSLATION_RESIDUAL_DIAGNOSTIC',
10516
+ 'decision': 'PASS_WITH_WARNING',
10517
+ 'detail': self._safe_source_error(exc),
10518
+ })
10519
+ return {'text': translated, 'qa_status': 'passed_with_warning', 'diagnostics': diagnostics}
10520
+
10521
  def translate_locked_manuscript(self, manuscript, target_lang):
10522
  value = str(manuscript or '').strip()
10523
  if not value:
core/production_guard.py CHANGED
@@ -685,6 +685,7 @@ def build_validated_package(
685
  quality_diagnostics: list[dict[str, Any]] | None = None,
686
  quality_notes: list[dict[str, Any]] | None = None,
687
  evidence_coverage: dict[str, Any] | None = None,
 
688
  ) -> PackageResult:
689
  validate_markdown_manuscript(english_markdown, language_name="English")
690
  validate_markdown_manuscript(translated_markdown, language_name="Target-language")
@@ -713,18 +714,19 @@ def build_validated_package(
713
  validate_qualitative_appendix_docx_layout(english_docx, label="English DOCX")
714
  validate_qualitative_appendix_docx_layout(translated_docx, label="Target-language DOCX")
715
 
 
716
  entries: dict[str, bytes] = {
717
  english_filename: english_docx,
718
  translated_filename: translated_docx,
719
- "GENERATION_NOTES.txt": build_generation_notes(data_metadata, mode).encode("utf-8"),
720
- "CITATION_AUDIT.json": json.dumps(citation_audit or [], ensure_ascii=False, indent=2).encode("utf-8"),
721
- "LITERATURE_METRICS.json": json.dumps(literature_metrics or {}, ensure_ascii=False, indent=2).encode("utf-8"),
722
- "WORD_COUNT_QUALITY_REPORT.txt": build_word_count_quality_report(quality_diagnostics).encode("utf-8"),
723
- "SmartPaper_Quality_Notes.txt": build_quality_notes(quality_notes).encode("utf-8"),
724
- "EVIDENCE_COVERAGE_REPORT.txt": build_evidence_coverage_report(evidence_coverage).encode("utf-8"),
725
  }
726
  manifest = _manifest(entries)
727
- entries["FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8")
728
 
729
  buffer = io.BytesIO()
730
  with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
 
685
  quality_diagnostics: list[dict[str, Any]] | None = None,
686
  quality_notes: list[dict[str, Any]] | None = None,
687
  evidence_coverage: dict[str, Any] | None = None,
688
+ organize_appendix: bool = False,
689
  ) -> PackageResult:
690
  validate_markdown_manuscript(english_markdown, language_name="English")
691
  validate_markdown_manuscript(translated_markdown, language_name="Target-language")
 
714
  validate_qualitative_appendix_docx_layout(english_docx, label="English DOCX")
715
  validate_qualitative_appendix_docx_layout(translated_docx, label="Target-language DOCX")
716
 
717
+ appendix_prefix = "Appendix/" if organize_appendix else ""
718
  entries: dict[str, bytes] = {
719
  english_filename: english_docx,
720
  translated_filename: translated_docx,
721
+ appendix_prefix + "GENERATION_NOTES.txt": build_generation_notes(data_metadata, mode).encode("utf-8"),
722
+ appendix_prefix + "CITATION_AUDIT.json": json.dumps(citation_audit or [], ensure_ascii=False, indent=2).encode("utf-8"),
723
+ appendix_prefix + "LITERATURE_METRICS.json": json.dumps(literature_metrics or {}, ensure_ascii=False, indent=2).encode("utf-8"),
724
+ appendix_prefix + "WORD_COUNT_QUALITY_REPORT.txt": build_word_count_quality_report(quality_diagnostics).encode("utf-8"),
725
+ appendix_prefix + "SmartPaper_Quality_Notes.txt": build_quality_notes(quality_notes).encode("utf-8"),
726
+ appendix_prefix + "EVIDENCE_COVERAGE_REPORT.txt": build_evidence_coverage_report(evidence_coverage).encode("utf-8"),
727
  }
728
  manifest = _manifest(entries)
729
+ entries[appendix_prefix + "FILE_MANIFEST_SHA256.txt"] = manifest.encode("utf-8")
730
 
731
  buffer = io.BytesIO()
732
  with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
core/quantitative_engine.py CHANGED
@@ -1453,6 +1453,122 @@ Keywords: {keywords}"""
1453
  return False
1454
  return True
1455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1456
  @classmethod
1457
  def inspect_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
1458
  """Pure AMJ quantitative Evidence Ledger inspection; never rewrites prose."""
@@ -1569,26 +1685,31 @@ Keywords: {keywords}"""
1569
  construct = str(constructs.get(role) or "")
1570
  if construct and results.get(key) is not None:
1571
  add_metric_violations(sentence, rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", results.get(key), label=key)
1572
- lower = sentence.casefold()
1573
- selected = None
1574
- specs = [
1575
- ("indirect", results.get("indirect"), [constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")]),
1576
- ("direct", results.get("direct"), [constructs.get("predictor"), constructs.get("outcome")]),
1577
- ("a", results.get("a"), [constructs.get("predictor"), constructs.get("mediator")]),
1578
- ("b", results.get("b"), [constructs.get("mediator"), constructs.get("outcome")]),
1579
- ]
1580
- if "indirect" in lower and all(str(x or "").casefold() in lower for x in specs[0][2] if x):
1581
- selected = specs[0]
1582
- elif f"controlling {str(constructs.get('mediator') or '').casefold()}" in lower and str(constructs.get("predictor") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower:
1583
- selected = specs[1]
1584
- elif f"controlling {str(constructs.get('predictor') or '').casefold()}" in lower and str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower:
1585
- selected = specs[3]
1586
- elif str(constructs.get("predictor") or "").casefold() in lower and str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() not in lower:
1587
- selected = specs[2]
1588
- elif str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower and str(constructs.get("predictor") or "").casefold() not in lower:
1589
- selected = specs[3]
1590
- if selected and selected[1] is not None:
1591
- add_metric_violations(sentence, r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", selected[1], label=selected[0])
 
 
 
 
 
1592
  elif not strict:
1593
  for label, expected in dict(uploaded.get("r2_by_label") or {}).items():
1594
  if label and label in sentence.casefold():
@@ -1743,26 +1864,30 @@ Keywords: {keywords}"""
1743
  metric_pattern = rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)"
1744
  replace_local_metric(metric_pattern, results.get(key), label=key)
1745
 
1746
- lower = revised.casefold()
1747
- selected = None
1748
- specs = [
1749
- ("indirect", results.get("indirect"), [constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")]),
1750
- ("direct", results.get("direct"), [constructs.get("predictor"), constructs.get("outcome")]),
1751
- ("a", results.get("a"), [constructs.get("predictor"), constructs.get("mediator")]),
1752
- ("b", results.get("b"), [constructs.get("mediator"), constructs.get("outcome")]),
1753
- ]
1754
- if "indirect" in lower and all(str(x or "").casefold() in lower for x in specs[0][2] if x):
1755
- selected = specs[0]
1756
- elif f"controlling {str(constructs.get('mediator') or '').casefold()}" in lower and str(constructs.get("predictor") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower:
1757
- selected = specs[1]
1758
- elif f"controlling {str(constructs.get('predictor') or '').casefold()}" in lower and str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower:
1759
- selected = specs[3]
1760
- elif str(constructs.get("predictor") or "").casefold() in lower and str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() not in lower:
1761
- selected = specs[2]
1762
- elif str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower and str(constructs.get("predictor") or "").casefold() not in lower:
1763
- selected = specs[3]
1764
- if selected and selected[1] is not None:
1765
- replace_local_metric(r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", selected[1], label=selected[0])
 
 
 
 
1766
  elif not strict:
1767
  r2_map = dict(uploaded.get("r2_by_label") or {})
1768
  for label, expected in r2_map.items():
 
1453
  return False
1454
  return True
1455
 
1456
+ @staticmethod
1457
+ def _normalize_quantitative_construct_surface(value):
1458
+ """Normalize a construct surface for conservative alias matching."""
1459
+ text = str(value or "").casefold()
1460
+ text = re.sub(r"[^a-z0-9]+", " ", text)
1461
+ return re.sub(r"\s+", " ", text).strip()
1462
+
1463
+ @classmethod
1464
+ def _quantitative_construct_alias_map(cls, constructs):
1465
+ """Return only unique, conservative aliases for frozen construct names.
1466
+
1467
+ R6-R14 permits ordinary abstract compression such as
1468
+ ``medical tourism conversion intention`` -> ``conversion intention``.
1469
+ Aliases are suffixes of at least two lexical tokens and are admitted only
1470
+ when they identify exactly one frozen construct role. No semantic model
1471
+ or fuzzy embedding is used, so ambiguous aliases remain unresolved.
1472
+ """
1473
+ role_values = {
1474
+ str(role): cls._normalize_quantitative_construct_surface(value)
1475
+ for role, value in dict(constructs or {}).items()
1476
+ if str(value or "").strip()
1477
+ }
1478
+ candidates = {}
1479
+ for role, canonical in role_values.items():
1480
+ tokens = canonical.split()
1481
+ surfaces = {canonical}
1482
+ # Conservative reader-facing shortening: terminal 2-4 token suffixes.
1483
+ # One-word aliases (e.g. "intention") are intentionally forbidden.
1484
+ for width in range(2, min(4, len(tokens)) + 1):
1485
+ if width < len(tokens):
1486
+ alias = " ".join(tokens[-width:])
1487
+ if len(alias) >= 8:
1488
+ surfaces.add(alias)
1489
+ for surface in surfaces:
1490
+ candidates.setdefault(surface, set()).add(role)
1491
+ unique = {surface: next(iter(roles)) for surface, roles in candidates.items() if len(roles) == 1}
1492
+ by_role = {role: [] for role in role_values}
1493
+ for surface, role in unique.items():
1494
+ by_role.setdefault(role, []).append(surface)
1495
+ for role in by_role:
1496
+ by_role[role] = sorted(set(by_role[role]), key=lambda x: (-len(x), x))
1497
+ return by_role
1498
+
1499
+ @classmethod
1500
+ def _quantitative_construct_roles_in_text(cls, text, constructs):
1501
+ folded = cls._normalize_quantitative_construct_surface(text)
1502
+ roles = set()
1503
+ aliases = cls._quantitative_construct_alias_map(constructs)
1504
+ for role, surfaces in aliases.items():
1505
+ for surface in surfaces:
1506
+ if re.search(rf"(?<![a-z0-9]){re.escape(surface)}(?![a-z0-9])", folded):
1507
+ roles.add(role)
1508
+ break
1509
+ return roles
1510
+
1511
+ @staticmethod
1512
+ def _quantitative_metric_local_clause(sentence, metric_start, metric_end):
1513
+ """Bound one metric occurrence to its nearest relation clause.
1514
+
1515
+ Strong punctuation/conjunction boundaries are preferred. If two metric
1516
+ occurrences remain in one span, an ``and`` between those metric surfaces
1517
+ becomes a boundary. This avoids treating two path estimates in one
1518
+ sentence as if they shared a single owner while not splitting construct
1519
+ names such as ``triage and itinerary planning``.
1520
+ """
1521
+ text = str(sentence or "")
1522
+ metric_re = re.compile(r"(?i)(?:β|standardized\s+estimate)\s*(?:=|:)?\s*[-+]?\d+(?:\.\d+)?")
1523
+ metrics = list(metric_re.finditer(text))
1524
+ boundaries = [0, len(text)]
1525
+ for match in re.finditer(r"(?i);|,\s+(?:and|but|while|whereas)\s+|\b(?:however|nevertheless)\b", text):
1526
+ boundaries.extend([match.start(), match.end()])
1527
+ # Conjunctions between adjacent metric occurrences are relation boundaries.
1528
+ for left, right in zip(metrics, metrics[1:]):
1529
+ between = text[left.end():right.start()]
1530
+ joins = list(re.finditer(r"(?i)\band\b", between))
1531
+ if joins:
1532
+ join = joins[-1]
1533
+ boundaries.extend([left.end() + join.start(), left.end() + join.end()])
1534
+ left = max((b for b in boundaries if b <= metric_start), default=0)
1535
+ right = min((b for b in boundaries if b >= metric_end), default=len(text))
1536
+ clause = text[left:right].strip(" ,;\t\n")
1537
+ return clause or text.strip()
1538
+
1539
+ @classmethod
1540
+ def _quantitative_metric_owner_for_occurrence(cls, sentence, match, constructs):
1541
+ """Resolve a single standardized estimate to a frozen path owner."""
1542
+ clause = cls._quantitative_metric_local_clause(sentence, match.start(), match.end())
1543
+ roles = cls._quantitative_construct_roles_in_text(clause, constructs)
1544
+ folded = cls._normalize_quantitative_construct_surface(clause)
1545
+ has_indirect = bool(re.search(r"\bindirect\b", folded))
1546
+ has_direct = bool(re.search(r"\b(?:direct|residual direct)\b", folded))
1547
+
1548
+ # Specific semantic labels win only when the construct frame is compatible.
1549
+ if has_indirect and {"predictor", "outcome"}.issubset(roles):
1550
+ return "indirect", clause
1551
+ if has_direct and {"predictor", "outcome"}.issubset(roles):
1552
+ return "direct", clause
1553
+ if roles == {"predictor", "mediator"}:
1554
+ return "a", clause
1555
+ if roles == {"mediator", "outcome"}:
1556
+ return "b", clause
1557
+ if roles == {"predictor", "outcome"} and has_direct:
1558
+ return "direct", clause
1559
+ # A three-construct clause without an explicit direct/indirect relation is
1560
+ # genuinely ambiguous and must not be guessed into a/b ownership.
1561
+ return None, clause
1562
+
1563
+ @classmethod
1564
+ def _strict_standardized_metric_occurrences(cls, sentence, constructs, results):
1565
+ """Yield clause-bound owners for each beta/standardized-estimate occurrence."""
1566
+ regex = re.compile(r"(?i)((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)")
1567
+ expected = {key: results.get(key) for key in ("a", "b", "direct", "indirect")}
1568
+ for match in regex.finditer(str(sentence or "")):
1569
+ owner, clause = cls._quantitative_metric_owner_for_occurrence(sentence, match, constructs)
1570
+ yield match, owner, expected.get(owner) if owner else None, clause
1571
+
1572
  @classmethod
1573
  def inspect_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
1574
  """Pure AMJ quantitative Evidence Ledger inspection; never rewrites prose."""
 
1685
  construct = str(constructs.get(role) or "")
1686
  if construct and results.get(key) is not None:
1687
  add_metric_violations(sentence, rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", results.get(key), label=key)
1688
+ # R6-R14 clause-bound metric ownership: validate each occurrence
1689
+ # against its own local construct relation, never one owner per sentence.
1690
+ for metric_match, metric_owner, metric_expected, metric_clause in cls._strict_standardized_metric_occurrences(
1691
+ sentence, constructs, results
1692
+ ):
1693
+ observed = metric_match.group(2)
1694
+ if metric_owner is None or metric_expected is None:
1695
+ violations.append({
1696
+ "type":"quantitative_metric_owner_unresolved",
1697
+ "observed":observed,
1698
+ "preview":str(metric_clause or sentence).strip()[:180],
1699
+ "reason":"The standardized estimate cannot be uniquely bound to a frozen a/b/direct/indirect construct relation.",
1700
+ })
1701
+ continue
1702
+ try:
1703
+ same = abs(float(observed) - float(metric_expected)) < 0.0006
1704
+ except Exception:
1705
+ same = False
1706
+ if not same:
1707
+ expected_text = f"{float(metric_expected):.3f}".rstrip("0").rstrip(".")
1708
+ violations.append({
1709
+ "type":"quantitative_metric_mismatch", "metric":metric_owner,
1710
+ "observed":observed, "expected":expected_text,
1711
+ "preview":str(metric_clause or sentence).strip()[:180],
1712
+ })
1713
  elif not strict:
1714
  for label, expected in dict(uploaded.get("r2_by_label") or {}).items():
1715
  if label and label in sentence.casefold():
 
1864
  metric_pattern = rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)"
1865
  replace_local_metric(metric_pattern, results.get(key), label=key)
1866
 
1867
+ # R6-R14 deterministic repair is also occurrence-local. Never
1868
+ # rewrite every metric in a sentence to one selected path value.
1869
+ metric_replacements = []
1870
+ for metric_match, metric_owner, metric_expected, metric_clause in cls._strict_standardized_metric_occurrences(
1871
+ revised, constructs, results
1872
+ ):
1873
+ if metric_owner is None or metric_expected is None:
1874
+ continue
1875
+ observed = metric_match.group(2)
1876
+ try:
1877
+ same = abs(float(observed) - float(metric_expected)) < 0.0006
1878
+ except Exception:
1879
+ same = False
1880
+ if same:
1881
+ continue
1882
+ expected_text = f"{float(metric_expected):.3f}".rstrip("0").rstrip(".")
1883
+ diagnostics.append({
1884
+ "type":"quantitative_metric_repaired", "metric":metric_owner,
1885
+ "observed":observed, "expected":expected_text,
1886
+ "preview":str(metric_clause or revised).strip()[:180],
1887
+ })
1888
+ metric_replacements.append((metric_match.start(2), metric_match.end(2), expected_text))
1889
+ for metric_start, metric_end, replacement in sorted(metric_replacements, reverse=True):
1890
+ revised = revised[:metric_start] + replacement + revised[metric_end:]
1891
  elif not strict:
1892
  r2_map = dict(uploaded.get("r2_by_label") or {})
1893
  for label, expected in r2_map.items():
task_executor.py CHANGED
@@ -128,13 +128,18 @@ def _is_recoverable_generation_failure(exc: Exception) -> bool:
128
  """Classify only post-claim execution failures.
129
 
130
  This deliberately avoids importing the generation pipeline at module import
131
- time. DeepSeek exception class names are stable public names inside this
132
- project and are inspected only after claim/bootstrap has succeeded.
 
133
  """
134
  if isinstance(exc, ExecutorApiError):
135
  return exc.status_code == 429 or exc.status_code >= 500
136
  class_name = exc.__class__.__name__
137
- if class_name in {"DeepSeekAuthenticationError", "DeepSeekBalanceError", "DeepSeekRequestError"}:
 
 
 
 
138
  return False
139
  if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)):
140
  return True
 
128
  """Classify only post-claim execution failures.
129
 
130
  This deliberately avoids importing the generation pipeline at module import
131
+ time. Provider/model exception class names are inspected only after
132
+ claim/bootstrap has succeeded, so future provider adapters do not require
133
+ executor imports or vendor-specific branching.
134
  """
135
  if isinstance(exc, ExecutorApiError):
136
  return exc.status_code == 429 or exc.status_code >= 500
137
  class_name = exc.__class__.__name__
138
+ if class_name in {
139
+ "DeepSeekAuthenticationError", "DeepSeekBalanceError", "DeepSeekRequestError",
140
+ "ModelAuthenticationError", "ModelBalanceError", "ModelRequestError",
141
+ "ModelProviderUnavailableError", "ModelGatewayError",
142
+ }:
143
  return False
144
  if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)):
145
  return True