Spaces:
Running
Running
A2 Adaptive Completion Core + Provider-Neutral Model Gateway
#102
by GuXiaopeng - opened
- config.py +81 -0
- core/generation_runner.py +293 -96
- core/model_gateway.py +185 -0
- core/pipeline_agent.py +260 -28
- core/production_guard.py +9 -7
- core/quantitative_engine.py +165 -40
- task_executor.py +8 -3
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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 3906 |
-
#
|
| 3907 |
-
|
| 3908 |
-
|
| 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 |
-
|
| 4128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4129 |
)
|
| 4130 |
-
|
| 4131 |
-
|
| 4132 |
-
|
| 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 |
-
|
| 4141 |
-
|
| 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 |
-
|
| 4150 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 4320 |
-
agent,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4321 |
)
|
| 4322 |
-
|
| 4323 |
-
"
|
| 4324 |
-
|
| 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 |
-
|
| 4503 |
-
|
| 4504 |
-
|
| 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 |
-
|
| 4512 |
-
"
|
| 4513 |
-
|
| 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 |
-
|
| 4524 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 4538 |
-
|
| 4539 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4540 |
repeated_quotes = agent.find_repeated_qualitative_quotes(full_draft_en, data_metadata)
|
| 4541 |
if repeated_quotes:
|
| 4542 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4681 |
-
|
| 4682 |
-
|
| 4683 |
-
|
| 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 |
-
|
| 4710 |
-
|
|
|
|
| 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 |
-
#
|
| 4871 |
-
#
|
| 4872 |
-
#
|
| 4873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4901 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 4947 |
-
translated_filename=f"
|
| 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 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 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
|
| 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
|
| 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 |
-
|
| 5763 |
-
|
| 5764 |
-
|
| 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 |
-
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
|
| 1580 |
-
|
| 1581 |
-
|
| 1582 |
-
|
| 1583 |
-
|
| 1584 |
-
|
| 1585 |
-
|
| 1586 |
-
|
| 1587 |
-
|
| 1588 |
-
|
| 1589 |
-
|
| 1590 |
-
|
| 1591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1747 |
-
selected
|
| 1748 |
-
|
| 1749 |
-
|
| 1750 |
-
|
| 1751 |
-
|
| 1752 |
-
|
| 1753 |
-
|
| 1754 |
-
|
| 1755 |
-
|
| 1756 |
-
|
| 1757 |
-
|
| 1758 |
-
|
| 1759 |
-
|
| 1760 |
-
|
| 1761 |
-
|
| 1762 |
-
|
| 1763 |
-
|
| 1764 |
-
|
| 1765 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 132 |
-
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|