Files

719 lines
29 KiB
Python

"""DeepSeek-backed, evidence-locked gene-family summary reports.
The LLM is used only to turn verified local results into readable Chinese. All
numbers, result boundaries and figure names come from local manifests and the
existing evidence interpreter. The report, prompt evidence and API metadata
are persisted separately so a generated claim can be audited later.
"""
from __future__ import annotations
import json
import hashlib
import os
import re
import time
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from gfa_core.mas.agents.interpretation_evidence import (
build_evidence_report,
extract_stage_evidence,
)
from gfa_core.mas.llm.base import LLMMessage
from gfa_core.mas.llm.providers.deepseek import DeepSeekProvider
PROJECT_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_FRAMEWORK = PROJECT_ROOT / "prompts" / "gene_family_summary_report_framework_zh.md"
IMAGE_SUFFIXES = {".png", ".pdf", ".svg", ".jpg", ".jpeg"}
STAGE_HINTS = [
"reference_classification",
"subfamily_confirm",
"interspecies_synteny",
"phylogeny",
"identify",
"motif",
"domain",
"structure",
"combo",
"promoter",
"tfbs",
"synteny",
"karyotype",
"ortholog",
"kaks",
"pep",
]
class DeepSeekSummaryError(RuntimeError):
"""Raised when an evidence-locked report cannot be generated."""
def _read_json(path: Path) -> Dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else {}
except Exception:
return {}
def _load_env_file(path: Path) -> Dict[str, str]:
values: Dict[str, str] = {}
if not path.is_file():
return values
for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def load_deepseek_settings(*, model_override: str = "") -> Dict[str, str]:
values: Dict[str, str] = {}
for env_path in (
PROJECT_ROOT / ".env.v15_deepseek",
PROJECT_ROOT / ".env",
PROJECT_ROOT / "config" / ".env",
):
for key, value in _load_env_file(env_path).items():
values.setdefault(key, value)
api_key = (
os.environ.get("DEEPSEEK_API_KEY", "").strip()
or os.environ.get("NEW_API_KEY", "").strip()
or values.get("DEEPSEEK_API_KEY", "").strip()
or values.get("NEW_API_KEY", "").strip()
)
if not api_key:
raise DeepSeekSummaryError("DeepSeek API key is not configured (DEEPSEEK_API_KEY or NEW_API_KEY).")
base_url = (
os.environ.get("DEEPSEEK_BASE_URL", "").strip()
or os.environ.get("NEW_API_BASE_URL", "").strip()
or values.get("DEEPSEEK_BASE_URL", "").strip()
or values.get("NEW_API_BASE_URL", "").strip()
or "https://api.deepseek.com"
)
model = (
model_override.strip()
or os.environ.get("DEEPSEEK_MODEL", "").strip()
or os.environ.get("NEW_API_MODEL", "").strip()
or values.get("DEEPSEEK_MODEL", "").strip()
or values.get("NEW_API_MODEL", "").strip()
or "deepseek-chat"
)
return {"api_key": api_key, "base_url": base_url, "model": model}
def select_primary_scope(summaries: Iterable[Dict[str, Any]], requested: str = "") -> str:
if str(requested or "").strip():
return str(requested).strip()
counts: Dict[str, int] = defaultdict(int)
for item in summaries:
scope = str(item.get("scope_id") or "").strip()
stage = str(item.get("stage") or "").strip()
if scope and stage not in {"summary", "genome_prepare", "config_autofill"}:
counts[scope] += 1
if not counts:
return ""
return max(
counts,
key=lambda scope: (
int("target_plus_reference" in scope),
int("subfamily_evidence" not in scope),
counts[scope],
scope,
),
)
def _infer_stage(name: str, role: str = "") -> str:
text = f"{role} {name}".lower()
if "tfbs" in text or "motif_source" in text or "gene_motif" in text:
return "tfbs"
if "interspecies" in text:
return "interspecies_synteny"
if "circular_tree" in text or ".tree." in text or text.endswith("tree.png"):
return "phylogeny"
if "protparam" in text or "tmhmm" in text or "pep_" in text:
return "pep"
for stage in STAGE_HINTS:
if stage in text:
return stage
return "other"
def _logical_figure_key(path: Path) -> str:
return path.stem.lower()
def collect_figure_inventory(
final_dir: Path,
summaries: Iterable[Dict[str, Any]],
*,
scope_id: str = "",
) -> List[Dict[str, Any]]:
artifact_map: Dict[str, Dict[str, str]] = {}
for item in summaries:
item_scope = str(item.get("scope_id") or "")
if scope_id and item_scope and item_scope != scope_id:
# Foundational target-only figures are not reused in a reference scope.
continue
stage = str(item.get("stage") or "")
for artifact in item.get("artifact_rows") or []:
raw = str(artifact.get("path") or "")
if not raw:
continue
path = Path(raw)
if path.suffix.lower() not in IMAGE_SUFFIXES:
continue
artifact_map[path.name] = {
"stage": stage or _infer_stage(path.name, str(artifact.get("artifact") or "")),
"role": str(artifact.get("artifact") or ""),
}
candidates: List[Path] = []
if final_dir.is_dir():
candidates.extend(
path for path in final_dir.iterdir()
if path.is_file()
and path.suffix.lower() in IMAGE_SUFFIXES
and ".bak" not in path.name.lower()
)
grouped: Dict[str, List[Path]] = defaultdict(list)
for path in candidates:
grouped[_logical_figure_key(path)].append(path)
inventory: List[Dict[str, Any]] = []
seen_content: Dict[str, str] = {}
preference = {".png": 0, ".jpg": 1, ".jpeg": 1, ".svg": 2, ".pdf": 3}
for _, paths in sorted(grouped.items()):
paths.sort(key=lambda p: (preference.get(p.suffix.lower(), 9), p.name))
preferred = paths[0]
try:
digest = hashlib.sha256(preferred.read_bytes()).hexdigest()
except OSError:
digest = ""
if digest and digest in seen_content:
continue
if digest:
seen_content[digest] = preferred.name
mapped = artifact_map.get(preferred.name, {})
role = mapped.get("role", "")
stage = mapped.get("stage", "") or _infer_stage(preferred.name, role)
inventory.append({
"figure_id": f"FIG-{len(inventory) + 1:03d}",
"file": preferred.name,
"stage": stage,
"artifact_role": role,
"available_formats": sorted({p.suffix.lower().lstrip(".") for p in paths}),
"marker": f"[[FIG:{preferred.name}]]",
})
return inventory
def _foundation_identify(final_dir: Path, prefix: str) -> Dict[str, Any]:
candidates: List[Path] = []
exact = final_dir / f"{prefix}.identify.manifest.json"
if exact.is_file():
candidates.append(exact)
candidates.extend(
path for path in final_dir.glob(f"{prefix}*.identify.manifest.json")
if path not in candidates
)
for path in candidates:
manifest = _read_json(path)
if manifest.get("stage") == "identify" and manifest.get("status") in {"success", "partial_success"}:
bundle = extract_stage_evidence("identify", manifest)
return {
"manifest": path.name,
"scope_id": manifest.get("scope_id", ""),
"status": manifest.get("status", ""),
"metrics": manifest.get("metrics", {}),
"parsed": bundle.get("parsed", {}),
"evidence": bundle.get("evidence", []),
"risks": bundle.get("risks", []),
}
return {}
def _compact_report(report: Dict[str, Any]) -> Dict[str, Any]:
evidence_cards = report.get("evidence_cards") or []
return {
"metadata": report.get("metadata", {}),
"source_summary": report.get("source_summary", {}),
"stage_flow": report.get("stage_flow", {}),
"key_findings": report.get("key_findings", []),
"structured_result_interpretations": report.get("structured_result_interpretations", {}),
"grouped_result_interpretations": report.get("grouped_result_interpretations", {}),
"scientific_discoveries": report.get("scientific_discoveries", []),
"risk_flags": report.get("risk_flags", []),
"stage_reviews": report.get("stage_reviews", []),
"publication_claims": report.get("publication_claims", []),
"major_claims": report.get("major_claims", []),
"next_steps": report.get("next_steps", []),
"evidence_cards": evidence_cards[:100],
}
def build_summary_evidence(
*,
final_dir: Path,
prefix: str,
job_id: str,
summaries: List[Dict[str, Any]],
scope_id: str,
) -> Dict[str, Any]:
report = build_evidence_report(
final_dir,
prefix,
job_id=job_id,
scope_id=scope_id,
analysis_route="target_plus_reference" if "target_plus_reference" in scope_id else "",
)
if not report:
raise DeepSeekSummaryError(f"No evidence report could be built for scope: {scope_id or 'default'}")
evidence = _compact_report(report)
included = list((evidence.get("metadata") or {}).get("included_stages") or [])
identify = _foundation_identify(final_dir, prefix)
if identify and "identify" not in included:
evidence["foundation_identify"] = identify
included.insert(0, "identify")
identify_metrics = identify.get("metrics") or {}
final_members = identify_metrics.get("final_members")
candidate_members = identify_metrics.get("candidate_members")
hmm_members = identify_metrics.get("hmm_members")
blast_members = identify_metrics.get("blast_members")
evidence.setdefault("structured_result_interpretations", {})["identify"] = {
"stage": "identify",
"stage_name": "基因家族成员鉴定",
"status": identify.get("status") or "success",
"interpretation_level": "publishable_result",
"result_interpretation": (
f"目标物种全蛋白集共有 {identify_metrics.get('total_proteins', 'N/A')} 条蛋白;"
f"HMM 搜索得到 {hmm_members} 个命中,BLAST 搜索记录 {blast_members} 个命中,"
f"联合筛选形成 {candidate_members} 个候选,最终确认 {final_members} 个家族成员。"
"HMM、BLAST、候选集和最终集的关系必须按本地记录描述,不能自行写成取交集或取并集。"
),
"evidence_basis": identify.get("evidence") or [],
"limitations": [
"鉴定证据来自目标物种 target_only 基础清单,并作为 target_plus_reference 后续分析的成员基础。",
"鉴定成功不能直接证明所有成员具有相同功能。",
],
"boundary": "不得把全基因组蛋白数写成家族成员数,也不得猜测联合筛选的集合运算方式。",
}
metadata = evidence["metadata"]
metadata["included_stages"] = included
metadata["included_stages_for_deepseek"] = included
missing = list(evidence["metadata"].get("missing_expected_stages") or [])
missing = [x for x in missing if x != "identify"]
metadata["missing_expected_stages"] = missing
metadata["missing_expected_stages_for_deepseek"] = missing
expected = list(metadata.get("expected_stages") or [])
metadata["completed_expected_stages"] = [stage for stage in expected if stage in included]
metadata["stage_count"] = len(included)
metadata["is_final"] = bool(expected) and not missing
stage_flow = evidence.setdefault("stage_flow", {})
stages = list(stage_flow.get("stages") or [])
stages.insert(0, {
"id": "identify",
"name": "基因家族成员鉴定",
"status": identify.get("status") or "success",
"key_result": f"最终确认 {final_members} 个成员",
})
stage_flow["stages"] = stages
stage_flow["visualization"] = " -> ".join(item.get("id", "") for item in stages)
source_summary = evidence.setdefault("source_summary", {})
source_summary["manifest_count"] = int(source_summary.get("manifest_count") or 0) + 1
evidence["figures"] = collect_figure_inventory(final_dir, summaries, scope_id=scope_id)
return evidence
def _system_prompt() -> str:
return """你是植物基因家族分析结果报告作者。你只能使用用户提供的本地证据,不能补造数字、成员、分组、功能或文献。
任务是把机器结果写成普通研究人员能直接看懂的中文报告。少用专业词;首次出现的专业词必须用一句日常语言解释。
每张图必须逐图解读,但不能假装看到了证据包未提供的像素细节。若只有文件名和阶段事实,应明确写“结合该阶段统计进行解释,具体成员级图形细节需打开原图复核”。
所有关联性、预测性结果都必须保留边界,不得使用“证明、决定、必然、直接调控”等过强措辞。
风险清单优先级高于一般解释。高风险阶段不得写成可靠生物学结论。
输出只允许完整 Markdown 正文,不要输出代码围栏、JSON、写作说明或思考过程。"""
def _user_prompt(*, framework: str, evidence: Dict[str, Any], job_id: str, prefix: str, scope_id: str) -> str:
figures = evidence.get("figures") or []
return f"""请为真实任务生成最终中文报告。
任务编号:{job_id}
分析前缀:{prefix}
分析范围:{scope_id or '默认范围'}
硬性要求:
1. 按“鉴定→系统发育与分类→motif→domain→基因结构→组合图→启动子/TFBS→种内与种间共线性→染色体分布→ortholog→PEP→综合结论”的顺序组织。
2. 首页给出普通读者能看懂的一页式摘要,并列出完成阶段、缺失阶段和最重要风险。
3. 对 figures 中的每个逻辑图写一个小节,并原样保留对应 marker,例如 [[FIG:xxx.png]]。共有 {len(figures)} 个逻辑图;同一图的 PDF/PNG 不重复解释。
4. 每个图小节写:图用来回答什么、从本任务事实能看出什么、通俗解释、不能说明什么。没有足够的成员级数值时必须直说,不能编造。
5. 核型阶段存在高风险时,必须明确说明不能据图得出染色体分布结论。
6. 种内共线性没有检测到家族成员基因对时,不能写成片段复制驱动家族扩张。
7. 最终成员数以 identify 事实为准;全基因组背景数量不能写成家族成员数。
8. 报告末尾给出候选成员筛选框架、主要限制、下一步验证建议和“读者最需要记住的五句话”。
9. 不要保留任何 {{{{占位符}}}}、TODO 或空表格;没有数据的内容直接写“本任务未提供”。
10. 语言以短句为主,结论先行。专业词后立即补通俗解释。
以下是报告结构参考。它决定覆盖范围和表达方式,不代表其中的示例数字是本任务事实:
--- 框架开始 ---
{framework}
--- 框架结束 ---
以下 JSON 是本任务唯一允许使用的事实来源:
--- 本地证据开始 ---
{json.dumps(evidence, ensure_ascii=False, separators=(',', ':'))}
--- 本地证据结束 ---
"""
def _strip_fence(text: str) -> str:
value = str(text or "").strip()
if value.startswith("```"):
value = re.sub(r"^```(?:markdown|md)?\s*", "", value, flags=re.I)
value = re.sub(r"\s*```$", "", value)
return value.strip()
def _validate_report(report: str, *, job_id: str, figures: List[Dict[str, Any]]) -> Dict[str, Any]:
markers = [str(item.get("marker") or "") for item in figures]
missing = [marker for marker in markers if marker and marker not in report]
heading_count = len(re.findall(r"(?m)^##+\s+", report))
placeholders = sorted(set(re.findall(r"\{\{[^{}]+\}\}", report)))
return {
"character_count": len(report),
"heading_count": heading_count,
"job_id_present": job_id in report,
"figure_count": len(markers),
"covered_figure_count": len(markers) - len(missing),
"missing_figure_markers": missing,
"placeholders": placeholders,
"passed": bool(len(report) >= 4000 and heading_count >= 8 and job_id in report and not missing and not placeholders),
}
def _supplement_prompt(missing: List[str], evidence: Dict[str, Any]) -> str:
wanted = [item for item in evidence.get("figures") or [] if item.get("marker") in set(missing)]
return f"""上一版报告漏掉了部分图。只输出一个 Markdown 章节,标题为“## 补充图表解读”。
必须逐项原样保留 marker,并为每项写:图的用途、本地证据允许的解释、不能得出的结论。
不得补造图中具体成员、颜色、分组或数值。
缺失图:
{json.dumps(wanted, ensure_ascii=False)}
可用阶段解释和风险:
{json.dumps({'structured': evidence.get('structured_result_interpretations', {}), 'risks': evidence.get('risk_flags', [])}, ensure_ascii=False)}
"""
def _items_for_stages(items: Iterable[Any], stages: set[str]) -> List[Any]:
selected: List[Any] = []
for item in items or []:
if not isinstance(item, dict):
continue
item_stages = item.get("stages") or [item.get("stage")]
if isinstance(item_stages, str):
item_stages = [item_stages]
if stages.intersection(str(stage) for stage in item_stages if stage):
selected.append(item)
return selected
def _group_prompt(
*,
title: str,
stages: List[str],
evidence: Dict[str, Any],
figures: List[Dict[str, Any]],
) -> str:
stage_set = set(stages)
structured = evidence.get("structured_result_interpretations") or {}
payload = {
"stages": stages,
"structured_result_interpretations": {
stage: structured.get(stage) for stage in stages if structured.get(stage)
},
"key_findings": _items_for_stages(evidence.get("key_findings") or [], stage_set),
"risk_flags": _items_for_stages(evidence.get("risk_flags") or [], stage_set),
"stage_reviews": _items_for_stages(evidence.get("stage_reviews") or [], stage_set),
"evidence_cards": _items_for_stages(evidence.get("evidence_cards") or [], stage_set)[:40],
"figures": figures,
}
if "identify" in stage_set and evidence.get("foundation_identify"):
payload["foundation_identify"] = evidence["foundation_identify"]
return f"""请只撰写报告章节“## {title}”,不要写整篇报告,也不要重复首页信息。
覆盖阶段:{', '.join(stages)}。
要求:
- 先用一段通俗文字说明这些阶段得到了什么,再按图逐项解释。
- 共有 {len(figures)} 个逻辑图。每张图必须建立三级标题,并原样保留 marker。
- 每个图写“图的用途—本任务可确认的结果—通俗解释—结论边界”。
- 没有成员级或像素级事实时,必须写需要打开原图复核,不能猜颜色、成员和具体分组。
- 风险提示优先。高风险结果必须明确降级。
- 本章节最后写一个“阶段小结”,只总结本地证据。
- 只输出 Markdown 章节正文。
本地证据:
{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}
"""
def _overview_prompt(*, evidence: Dict[str, Any], job_id: str, prefix: str, scope_id: str) -> str:
payload = {
"metadata": evidence.get("metadata", {}),
"source_summary": evidence.get("source_summary", {}),
"stage_flow": evidence.get("stage_flow", {}),
"foundation_identify": evidence.get("foundation_identify", {}),
"risk_flags": evidence.get("risk_flags", []),
}
return f"""请撰写基因家族结果报告的开头部分,只输出以下三个二级章节:
## 一、项目与数据范围
## 二、一页式结论摘要
## 三、流程完成情况与可信度
任务编号:{job_id}
前缀:{prefix}
范围:{scope_id}
使用短句和通俗中文。摘要写5—8条最重要结论。必须写清最终成员数、完成阶段、缺失阶段、主要风险和结论边界。
禁止补造证据;不能把全基因组背景数写成家族成员数。只输出 Markdown 章节正文。
本地证据:
{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}
"""
def _conclusion_prompt(*, evidence: Dict[str, Any]) -> str:
payload = {
"scientific_discoveries": evidence.get("scientific_discoveries", []),
"publication_claims": evidence.get("publication_claims", []),
"major_claims": evidence.get("major_claims", []),
"risk_flags": evidence.get("risk_flags", []),
"next_steps": evidence.get("next_steps", []),
"foundation_identify": evidence.get("foundation_identify", {}),
}
return f"""请撰写基因家族结果报告的结尾,只输出以下四个二级章节:
## 十、候选成员与多证据综合
## 十一、主要限制
## 十二、下一步验证建议
## 十三、读者最需要记住的五句话
候选成员没有足够成员级证据时,不要强行编排名;改为给出可执行筛选规则和待补证据。
把预测和相关结果写成线索,不得写成已证明功能。高风险问题必须进入主要限制并排在修复建议前面。
只输出 Markdown 章节正文。
本地证据:
{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}
"""
def _call_section(
provider: DeepSeekProvider,
prompt: str,
*,
max_tokens: int,
) -> tuple[str, Dict[str, Any]]:
response = provider.call(
[LLMMessage(role="system", content=_system_prompt()), LLMMessage(role="user", content=prompt)],
temperature=0.12,
max_tokens=max_tokens,
disable_thinking=True,
)
text = _strip_fence(response.content)
if len(text) < 300 or not re.search(r"(?m)^##\s+", text):
raise DeepSeekSummaryError(
f"DeepSeek returned an incomplete section (finish_reason={response.finish_reason}, chars={len(text)}): {text[:500]}"
)
return text, {
"provider": response.provider,
"model": response.model,
"usage": response.usage or {},
"finish_reason": response.finish_reason,
}
def generate_deepseek_summary(
*,
final_dir: Path,
prefix: str,
job_id: str,
summaries: List[Dict[str, Any]],
scope_id: str = "",
framework_path: Optional[Path] = None,
model: str = "",
max_tokens: int = 16000,
) -> Dict[str, Any]:
final_dir = final_dir.resolve()
effective_scope = select_primary_scope(summaries, scope_id)
framework_file = (framework_path or DEFAULT_FRAMEWORK).expanduser().resolve()
if not framework_file.is_file():
raise DeepSeekSummaryError(f"Report framework not found: {framework_file}")
evidence = build_summary_evidence(
final_dir=final_dir,
prefix=prefix,
job_id=job_id,
summaries=summaries,
scope_id=effective_scope,
)
settings = load_deepseek_settings(model_override=model)
provider = DeepSeekProvider(
api_key=settings["api_key"],
model=settings["model"],
temperature=0.15,
max_tokens=max_tokens,
api_base=settings["base_url"],
)
started = time.time()
framework = framework_file.read_text(encoding="utf-8", errors="ignore")
framework_outline = "\n".join(
line for line in framework.splitlines() if line.startswith("## ")
)
section_records: List[Dict[str, Any]] = []
overview, call_meta = _call_section(
provider,
_overview_prompt(
evidence=evidence,
job_id=job_id,
prefix=prefix,
scope_id=effective_scope,
) + "\n\n报告框架章节纲要:\n" + framework_outline,
max_tokens=min(max_tokens, 4500),
)
section_records.append({"section": "overview", **call_meta})
figures = evidence.get("figures") or []
group_specs = [
(
"四、成员鉴定、系统发育与分类",
["identify", "phylogeny", "reference_classification", "subfamily_confirm"],
),
(
"五、保守片段、功能区域、基因结构与组合证据",
["motif", "domain", "structure", "combo"],
),
(
"六、启动子与 TFBS 调控线索",
["promoter", "tfbs"],
),
(
"七、基因组分布、共线性与跨物种关系",
["synteny", "interspecies_synteny", "karyotype", "ortholog", "kaks"],
),
(
"八、候选蛋白 PEP 特征",
["pep"],
),
(
"九、其他结果图",
["other"],
),
]
sections = [overview]
covered_stages: set[str] = set()
for title, stages in group_specs:
group_figures = [item for item in figures if str(item.get("stage") or "other") in set(stages)]
structured = evidence.get("structured_result_interpretations") or {}
has_stage_evidence = any(structured.get(stage) for stage in stages) or (
"identify" in stages and evidence.get("foundation_identify")
)
if not group_figures and not has_stage_evidence:
continue
section_text, call_meta = _call_section(
provider,
_group_prompt(title=title, stages=stages, evidence=evidence, figures=group_figures),
max_tokens=min(max_tokens, 6500),
)
sections.append(section_text)
section_records.append({
"section": title,
"stages": stages,
"figure_count": len(group_figures),
**call_meta,
})
covered_stages.update(stages)
conclusion, call_meta = _call_section(
provider,
_conclusion_prompt(evidence=evidence),
max_tokens=min(max_tokens, 5000),
)
sections.append(conclusion)
section_records.append({"section": "conclusion", **call_meta})
report = (
f"# {prefix} 基因家族分析结果通俗解读报告\n\n"
f"任务编号:`{job_id}` \n"
f"分析范围:`{effective_scope or 'default'}` \n"
f"生成方式:DeepSeek API 分段生成;本地证据锁定 \n\n"
+ "\n\n".join(sections)
)
validation = _validate_report(report, job_id=job_id, figures=evidence.get("figures") or [])
supplement_usage: Dict[str, Any] = {}
if validation["missing_figure_markers"]:
supplement = provider.call(
[
LLMMessage(role="system", content=_system_prompt()),
LLMMessage(role="user", content=_supplement_prompt(validation["missing_figure_markers"], evidence)),
],
temperature=0.1,
max_tokens=min(max_tokens, 8000),
disable_thinking=True,
)
supplement_text = _strip_fence(supplement.content)
if supplement_text:
report = report.rstrip() + "\n\n" + supplement_text + "\n"
supplement_usage = supplement.usage or {}
section_records.append({
"section": "missing_figure_supplement",
"usage": supplement_usage,
"finish_reason": supplement.finish_reason,
})
validation = _validate_report(report, job_id=job_id, figures=evidence.get("figures") or [])
if not validation["passed"]:
raise DeepSeekSummaryError(
"DeepSeek report failed validation: "
+ json.dumps(validation, ensure_ascii=False)
)
scope_token = re.sub(r"[^0-9A-Za-z_.-]+", "-", effective_scope).strip(".-")
name_root = f"{prefix}.{scope_token}" if scope_token else prefix
report_path = final_dir / f"{name_root}.summary_report.md"
evidence_path = final_dir / f"{name_root}.summary.evidence.json"
metadata_path = final_dir / f"{name_root}.summary.deepseek.json"
evidence_path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2), encoding="utf-8")
report_path.write_text(report.rstrip() + "\n", encoding="utf-8")
metadata = {
"schema": "deepseek_summary_generation_v1",
"status": "success",
"generated_at": datetime.now().isoformat(),
"job_id": job_id,
"prefix": prefix,
"scope_id": effective_scope,
"provider": "deepseek",
"model": settings["model"],
"base_url": settings["base_url"],
"duration_seconds": round(time.time() - started, 3),
"section_calls": section_records,
"supplement_usage": supplement_usage,
"framework": str(framework_file),
"evidence_file": str(evidence_path),
"report_file": str(report_path),
"validation": validation,
}
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
return {
"scope_id": effective_scope,
"report_path": report_path,
"evidence_path": evidence_path,
"metadata_path": metadata_path,
"metadata": metadata,
}