Author SHA1 Message Date
admin 3c00e1675e feat(bioflow): skill 编排续作 WIP checkpoint(多轮链续接/plan 回填/job_id 提取/历史 blocks 文本提取) 2026-10-14 09:41:44 +08:00
admin 8ab4279b04 fix(bioflow): sendCardResult 从 raw.bioflow 读 active_job_id(BFF 归一化剥离顶层 bioflow 导致订阅没建立) 2026-10-13 14:39:33 +08:00
admin c943e8d363 fix(bioflow): sendCardResult 创建任务后补 connectJobEvents 订阅,恢复文件/参数引导等任务事件触发 2026-10-13 12:34:27 +08:00
admin 9ac36e99ba fix(bioflow): 路径选择卡空状态文案「暂无候选路径」改为「正在检索可用分析路径....」 2026-10-13 11:33:39 +08:00
admin 9cb0bf2e35 feat(bioflow): stage_done 摘要从 manifest.metrics 提取关键指标(命中/比对率等) (#010) 2026-10-13 11:28:18 +08:00
admin 6b4b5e571f feat(bioflow): 就绪卡+版本化卡接线——工具结果经 payload.bioflow 下发前端渲染 (#009) 2026-10-13 11:18:53 +08:00
admin 9797b1b414 feat(bioflow): 交付卡接线——job_done 构造 manifest 调 validate_delivery 下发交付清单 (#009-交付) 2026-10-13 11:12:39 +08:00
admin b9bc90ffb0 feat(bioflow): 诊断卡接线——stage_failed 补 G13 错误对象+guidance,前端诊断卡渲染 (#009-诊断) 2026-10-13 11:10:30 +08:00
admin 821f3c1c4e feat(bioflow): 引导帧——卡片环节补自然语言引导气泡(文件/参数/存档/进度) (#006/#007/#008/#010) 2026-10-13 11:04:32 +08:00
admin da8f531bbc fix(bioflow): check_disk_available 捕获 OSError 返回 PATH_NOT_EXIST,不再抛未捕获异常 (#004) 2026-10-13 11:01:17 +08:00
admin 21c79582bf fix(bioflow): QA 生信流加活跃任务续接分支,续接输入(提供文件/参数等)不再掉普通 QA (#011) 2026-10-13 10:59:54 +08:00
admin 1ecc4cb29e chore(deploy): 新增 bioflow_switch_restart.sh 切分支→重启→验证规范脚本 2026-10-13 10:28:06 +08:00
admin 75eb489fc3 fix(bioflow): main.py 加 load_dotenv 把 .env 注入 os.environ,修复规划 LLM 配置断层 2026-10-13 10:03:00 +08:00
admin e4ed3589d5 fix(bioflow): INCOMPLETE_DELIVERY 错误分类映射到 input/diagnosed (G14 补漏) 2026-10-12 12:20:52 +08:00
admin f27336e961 feat(bioflow): 生信 Skill 化改造 (CLI工具层+MCP薄适配+11分析工具+SKILL, G1-G23)
- A 地基: bioflowctl CLI 抽取 + M0 六条数据保护 gap (G1-G6)
- B: 结构化错误对象 G13 + MCP 薄适配
- D: 契约与就绪 G7-G11/G14/G15 (数据角色/下游直入/单一schema/就绪检查/任务绑定/错误分类/恢复预算)
- E: 版本化 G16-G20 (plan_revision/影响范围/历史不覆盖/防重复/执行所有权)
- F: 交付 G21-G23 (交付清单/报告范围/资源预算)
- C: 11 分析控制工具 + SKILL.md + install/uninstall (G12)
- UI: 4 张新增卡 (就绪检查/诊断/版本化修改/交付清单)
- 测试: 后端 724 + QA 167 + 前端 176 passed, 红线 pipeline.py/answer_composer.py 零修改
2026-10-11 16:03:19 +08:00
admin 963a87855c feat(bioflow): 生信模块对话式改造 + MCP 外挂化 (S0-S6)
S0: bioflow_service 三 MCP 端点(gene-family/rnaseq/extension) + 源文件获取/预处理
S1: QA bioflow_agent 前门 router + capabilities.yaml 三条目 + 熔断
S2: card_result 回填 + 多轮记忆 + session↔job 绑定
S3: 前端 TAB 双信号 + 卡片辅助化 + 进度块只读 + 删除直连
S4: 扩展子系统对话化(第三能力单元)
S5: toggle_bioflow.sh 装卸 + SettingsPanel 三开关
S6: 多配置回归 + 对话剧本测试手册
2026-10-11 10:45:25 +08:00
admin 60375b04d7 chore: .gitignore 忽略含凭据的部署方案文档 DEPLOY_PAPER_176.md 2026-10-10 18:53:25 +08:00
admin 1bd4aea5fd fix(build): render_active_library_env 输出 GENE_FAMILY_ROOT 而非未定义的 QA_ROOT
active_library.env 里 $QA_ROOT 从未定义,QA start.sh 在 set -u 下 source 时报
unbound variable 导致 8091 起不来。改用 prod.env 中已定义的 GENE_FAMILY_ROOT。
2026-10-10 18:52:15 +08:00
admin 51532a30bc fix(ui): 工作台「提取任务」tab 与离线提示改为「上传文献」 2026-10-10 18:27:06 +08:00
admin d37e0d9c3a fix(paper): 确认大纲后即时 seed 进度卡并逐节刷新
- 后端写稿循环不再落空 paper_progress 块(重载后出现空卡),改落开始撰写文本
- 前端 confirmPaper 时用大纲节标题本地 seed 进度卡,2s 轮询 patch 逐节状态
- 当前节按 current_section_id 标 writing(…动画)
2026-10-08 17:10:50 +08:00
admin c923419d2d fix(paper): 大纲阶段修改意见路由回 sendPaper + 用原始 brief 重新生成大纲 2026-10-08 16:23:28 +08:00
admin 43a7c6c132 feat(paper): UI 优化(卡片文字颜色/去模式按钮) + 模板下拉加论文选项并接线渲染 2026-10-08 15:17:44 +08:00
admin 8b228923bd fix(paper): 取消路径(cancel_paper) + 引用元数据清洗(丢弃杜撰 DOI/PMID) 2026-10-08 14:59:16 +08:00
admin 2e5372cf90 fix(paper): 论文产物渲染为 HTML + 文末追加参考文献列表/disclaimer 2026-10-08 14:54:00 +08:00
admin 91f6a1c242 feat: 新增论文撰写功能 paper-writer(独立 MCP 服务 + BFF 大脑 + 前端三卡)
- paper_writer_service(8097):FastAPI + MCP streamable-http 单端点,9 工具
  (propose_paper_outline/write_paper_section/get_paper_progress/search_refs/
  revise_paper_section/revise_paper/get_paper/complete_paper/cancel_paper),
  SQLite 三表(papers/paper_sections/citations,WAL),GB/T 7714 引用协议
- BFF:services/paper/{mcp_client,agent}.py(裸 JSON-RPC 客户端 + 规则状态机)、
  routers/paper.py、schemas.py 4 个 Paper*Block + ArtifactBlock 加 paper_id/citation_count、
  config.py 开关、main.py 条件注册、docgen.py 抽 insert_artifact 公共 helper
- 前端:intent.js paperIntent 纯函数、Composer docgen 分支 sub-route、sendPaper/轮询、
  PaperBriefCard/PaperOutlineCard/PaperProgressCard、DocPane cite-chip
- deploy/paper_restart.sh + toggle_paper.sh;compile_sdk.sh 白名单追加 paper 文件
- tests/paper 26 条(工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema + 前端 intent 单测)
2026-09-30 22:29:22 +08:00
99 changed files with 9256 additions and 237 deletions
+9 -1
View File
@@ -37,5 +37,13 @@ tests/extension_cases_18/
bioflow_service/bioflow.db
*.before-*
# paper_writer_service 运行时产物(不入库)
paper_writer_service/paper.db
paper_writer_service/paper.db-shm
paper_writer_service/paper.db-wal
paper_writer_service/paper.log
# 部署方案文档(含主机凭据,不入库)
docs/DEPLOY_PAPER_176.md
# Python 覆盖率运行时产物(coverage.py --cov 生成)
.coverage
.coveragevstore/vstore.log
+84 -4
View File
@@ -48,6 +48,9 @@ _CHAIN_TO_CAPABILITY = {
"rnaseq": "bioflow-rnaseq",
}
# 能力名 → chain 名(反向映射,供规划阶段从命中能力回填 chain,续接多轮对话)
_CAPABILITY_TO_CHAIN = {v: k for k, v in _CHAIN_TO_CAPABILITY.items()}
def _chain_to_capability(chain: str) -> Optional[str]:
"""把 chain 名(gene_family/rnaseq)映射为能力名;未知返回 None。"""
@@ -291,6 +294,28 @@ def _empty_audit() -> Dict[str, Any]:
return {"rounds": 0, "tool_calls": 0, "used_json_fallback": False, "tool_names": [], "errors": []}
def _map_readiness(data: Dict[str, Any]) -> Dict[str, Any]:
"""check_readiness 结果 → 就绪卡字段 {ready, quadrants:[{name,ok}]}。"""
inner = data.get("result", data) if isinstance(data, dict) else {}
quadrants = []
for key in ("environment", "reference", "space", "budget"):
q = inner.get(key) or {}
quadrants.append({"name": key, "ok": bool(q.get("ok"))})
return {"ready": bool(inner.get("ready")), "quadrants": quadrants}
def _map_versioning(data: Dict[str, Any]) -> Dict[str, Any]:
"""propose_change 结果 → 版本化卡字段 {plan_revision, run_id, affected_stages, based_on_revision}。"""
inner = data.get("result", data) if isinstance(data, dict) else {}
affected = inner.get("affected") or []
return {
"plan_revision": "",
"run_id": "",
"affected_stages": list(affected),
"based_on_revision": None,
}
def _make_payload(
question: str,
answer: str,
@@ -299,6 +324,9 @@ def _make_payload(
capabilities: Optional[List[str]] = None,
active_job_id: Optional[str] = None,
chain: Optional[str] = None,
readiness: Optional[Dict[str, Any]] = None,
versioning: Optional[Dict[str, Any]] = None,
plan: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"success": True,
@@ -318,6 +346,12 @@ def _make_payload(
payload["bioflow"]["active_job_id"] = str(active_job_id)
if chain:
payload["bioflow"]["chain"] = str(chain)
if readiness:
payload["bioflow"]["readiness"] = readiness
if versioning:
payload["bioflow"]["versioning"] = versioning
if plan:
payload["bioflow"]["plan"] = plan
return payload
@@ -609,8 +643,24 @@ def _try_bioflow_agent_impl(
if isinstance(card_result, dict) and str(card_result.get("card") or "").strip() in CARD_TYPES:
return _route_card_result(card_result, llm_client, capabilities, bioflow, progress_callback)
# NL 路径:门 2 + 门 3
manifests = _candidate_capabilities(question, capabilities)
# NL 路径:活跃任务/生信链续接优先,其次意图预筛
active_job_id = (bioflow or {}).get("active_job_id")
chain = str((bioflow or {}).get("chain") or "").strip()
if active_job_id:
# 有活跃任务:跳过意图预筛,按任务 chain 续接(任务链不可变)
manifests = _registered_capabilities(capabilities)
cap_name = _chain_to_capability(chain)
if cap_name:
manifests = [m for m in manifests if m.name == cap_name]
else:
# 无活跃任务:先门 2 + 门 3 意图预筛(新任务入口)
manifests = _candidate_capabilities(question, capabilities)
# 意图未命中但 session 留有生信链(上轮规划)→ 续接该链(如路径选择「1」)
if not manifests and chain:
cap_name = _chain_to_capability(chain)
if cap_name:
registered = _registered_capabilities(capabilities)
manifests = [m for m in registered if m.name == cap_name]
if not manifests:
return None
@@ -639,13 +689,43 @@ def _try_bioflow_agent_impl(
if not answer:
return None
tool_results = (audit or {}).get("tool_results") or {}
readiness = None
versioning = None
new_job_id = None
plan = None
for name, result_text in tool_results.items():
if name in ("create_gene_family_job", "create_rnaseq_job"):
jid = _extract_job_id(result_text)
if jid:
new_job_id = jid
data = _try_parse_json(result_text)
if not isinstance(data, dict):
continue
if name.startswith("plan_") and data.get("ok"):
plan = {
"candidates": data.get("candidates") or [],
"auto_filled": data.get("auto_filled") or [],
"reuse_archive": data.get("reuse_archive"),
"reuse_name": data.get("reuse_name"),
"fallback": data.get("fallback"),
}
if name == "check_readiness":
readiness = _map_readiness(data)
elif name == "propose_change":
versioning = _map_versioning(data)
effective_chain = chain or (_CAPABILITY_TO_CHAIN.get(manifests[0].name) if manifests else "")
return _make_payload(
question,
answer,
audit=audit,
capabilities=[m.name for m in manifests],
active_job_id=(bioflow or {}).get("active_job_id"),
chain=(bioflow or {}).get("chain"),
active_job_id=(bioflow or {}).get("active_job_id") or new_job_id,
chain=effective_chain or None,
readiness=readiness,
versioning=versioning,
plan=plan,
)
@@ -109,6 +109,33 @@ class TestGateLogic:
)
assert result is None
def test_active_job_continuation_skips_intent_prescreen(self, tmp_path):
"""#011:有活跃任务时,续接输入(不含触发词)也应进入生信流,不掉普通 QA。"""
registry = _make_registry(tmp_path)
fake_tools = [
{
"type": "function",
"function": {
"name": "fetch_source_file",
"description": "获取源文件",
"parameters": {"type": "object", "properties": {}},
},
}
]
with mock.patch.object(ba, "get_registry", return_value=registry):
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {"fetch_source_file": mock.ANY})):
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
result = ba.try_bioflow_agent(
"基因组文件在 https://example.com/rice.fa",
llm_client=FakeLLMClient(),
capabilities=None,
bioflow={"active_job_id": "job-1", "chain": "gene_family"},
)
assert result is not None
assert result["success"] is True
assert result["bioflow"]["handled"] is True
assert result["bioflow"]["capabilities"] == ["bioflow-gene-family"]
def test_not_registered_returns_none(self, tmp_path):
empty = CapabilityRegistry(tmp_path / "empty.yaml")
empty.load()
+3 -1
View File
@@ -66,6 +66,7 @@ class ToolRunner:
"tool_calls": 0,
"used_json_fallback": False,
"tool_names": [],
"tool_results": {},
"errors": [],
}
messages: List[Dict[str, Any]] = []
@@ -95,6 +96,7 @@ class ToolRunner:
break
assistant_message: Dict[str, Any] = {"role": "assistant", "content": content or ""}
tool_call_blocks: List[Dict[str, Any]] = []
messages.append(assistant_message)
for call in tool_calls:
name = str(call.get("name") or call.get("function", {}).get("name") or "")
raw_args = call.get("arguments") or call.get("function", {}).get("arguments") or {}
@@ -125,9 +127,9 @@ class ToolRunner:
"function": {"name": name or "unknown", "arguments": json.dumps(arguments, ensure_ascii=False)},
}
)
audit["tool_results"][name] = result_text
messages.append({"role": "tool", "tool_call_id": call_id, "content": result_text})
assistant_message["tool_calls"] = tool_call_blocks
messages.append(assistant_message)
continue
# 最后一轮:拿最终文本(无工具或轮次用尽后由调用方用 messages 再 complete 一次)
return self._final_text(system_text, messages, tools), audit
+85
View File
@@ -0,0 +1,85 @@
---
name: bioflow-analysis
description: >-
PlantAI 生信分析编排 Skill。当用户提供原始组学数据(蛋白 FASTA/基因组/FASTQ/表达矩阵等)
并提出基因家族、转录组、变异或自定义分析需求时使用。负责从原始数据到交付报告的
全流程编排:选链路、登记资产、就绪检查、参数确认、执行、诊断、版本化修改、交付清单。
触发词见 nl_triggers;仅在明确涉及生信分析时触发。
nl_triggers:
- 基因家族
- 家族成员
- 亚家族
- 系统发育
- 建树
- 进化树
- motif
- 结构域
- 共线性
- Ka/Ks
- 转录组
- RNA-seq
- RNAseq
- rnaseq
- 差异表达
- 表达定量
- 富集
- WGCNA
- GSEA
- fastq
- 实验设计
- 生信分析
- 变异检测
- GWAS
---
# bioflow-analysis 能力声明
PlantAI 生信分析 Skill,面向「原始数据 → 交付报告」的分析编排。所有分析经
`bioflowctl`(唯一事实源)与 11 个分析控制 MCP 工具完成,服务端状态经 SQLite 持久化。
## 11 工具白名单
Skill 只允许调用以下 11 个分析控制工具(其余为内部实现,不外泄):
| 工具 | 职责 | 对应 CLI |
|------|------|----------|
| get_task_context | 任务绑定(session/job/plan_revision/run_id/当前阶段) | — |
| inspect_asset | 资产/文件探测(格式/预览/列结构) | file inspect |
| validate_plan | 读单一参数 schema 校验参数 | (补齐) |
| check_readiness | 四象限就绪检查(环境/参考/磁盘/预算) | check readiness |
| get_run_evidence | 运行证据(日志切片/失败原因/manifest 校验值) | — |
| propose_change | 参数变更影响范围 | change propose |
| apply_change | 幂等应用变更 | change apply |
| execute_nodes | 只重跑受影响节点 | run node-execute |
| control_execution | 执行所有权(stop/resume) | run stop/resume |
| validate_delivery | 交付清单完整性校验 | delivery validate |
| publish_delivery | 发布交付清单 | delivery publish |
## 对话剧本
1. **原始数据**:识别用户提供的原始数据(蛋白 FASTA / 基因组 / FASTQ / 表达矩阵 / 设计表)。
2. **选链路**:`inspect_asset` 探测数据格式;据此选择分析链路(基因家族 / 转录组 / …)。
3. **登记**:登记数据资产(角色化 `asset register`),保证「下游直入」可用。
4. **就绪**:`check_readiness` 四象限检查;缺项返回结构化错误阻断,不启动。
5. **参数确认**:`validate_plan` 校验参数;未知参数/越界拒绝。
6. **执行**:`execute_nodes` 只重跑受影响节点;`control_execution` 管理执行所有权。
7. **诊断**:失败时 `get_run_evidence` 取日志切片/manifest 校验值,按 8 类错误分类
(input/resource/environment/transient/adapter/artifact/reference/scientific_precondition)。
8. **版本化**:`propose_change` + `apply_change` 版本化修改,历史不覆盖、幂等。
9. **交付**:`validate_delivery` + `publish_delivery` 校验并发布交付清单。
## 人工边界(requires_human)
- `subfamily_confirm`:亚家族分类确认,须人工确认,不可代用户。
- `rnaseq_de`:差异表达实验设计确认,须人工确认,不可代用户。
上述阶段在 pending_actions 中 `requires_human=true`,Skill 只呈现表单默认值,不代确认。
## 验收清单
- [ ] 原始数据格式已探测(inspect_asset)
- [ ] 就绪检查通过(check_readiness ready=true)
- [ ] 参数经单一 schema 校验(validate_plan)
- [ ] 执行所有权唯一(control_execution 不重复接管/不重叠)
- [ ] 失败已诊断(get_run_evidence + 8 类错误分类)
- [ ] 变更版本化(propose_change/apply_change 幂等、历史不覆盖)
- [ ] 交付清单完整并已发布(validate_delivery ok → publish_delivery approved)
+7
View File
@@ -10,6 +10,13 @@
from __future__ import annotations
import logging
from pathlib import Path
from dotenv import load_dotenv
# 把 bioflow_service/.env 注入 os.environ(llm.py 等模块直读 DOCGEN_LLM_*,
# 不走 pydantic-settings 的 env_file,避免「.env 配了但 os.environ 读不到」的配置断层)。
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
from contextlib import asynccontextmanager
from fastapi import FastAPI
+122 -86
View File
@@ -27,19 +27,29 @@ from .config import get_settings
from .context import load_record
from .services.bioflow import extensions as ext_mod
from .services.bioflow import store
from .services.bioflow.archive import archive_from_job, normalize_archive_name
from .services.bioflow.archive import archive_from_job
from .services.bioflow.catalog import GENE_FAMILY_CHAIN
from .services.bioflow.file_transform import (
inspect_file as _ft_inspect_file,
transform_file as _ft_transform_file,
validate_format,
)
from .services.bioflow.reports import locate_report
from .services.bioflow.errors import error_result
from .services.bioflow.file_transform import validate_format
from .services.bioflow.runner import RunnerError, ensure_samples_yaml, llm_sample_fallback
from .services.bioflow.source_fetcher import FetchError, fetch_source
from .services.bioflow.source_fetcher import fetch_source
# 薄适配:委托 bioflowctl CLI(唯一事实源),不重复业务逻辑
from bioflowctl.commands import (
cmd_archive,
cmd_archive_reuse,
cmd_file_inspect,
cmd_file_transform,
cmd_source_fetch,
)
logger = logging.getLogger(__name__)
def _mcp_err(code: str, message: str, *, evidence_refs: list[str] | None = None) -> dict[str, Any]:
"""G13:MCP 工具失败返回统一结构化错误对象(code 顶层 + error 对象)。"""
return error_result(code, message, evidence_refs=evidence_refs)
# ---- 能力单元阶段白名单 ----
GENE_FAMILY_STAGES = set(GENE_FAMILY_CHAIN)
@@ -124,6 +134,10 @@ def derive_pending_actions(record: Any, runner: Any) -> list[dict[str, Any]]:
if status == "failed":
return [{"action": "rerun_or_skip", "stage": stage, "requires_human": False,
"message": f"阶段 {stage} 失败,可重跑或跳过"}]
if status == "partial_success":
# G4:partial_success 独立终态,不被当作 success/done
return [{"action": "rerun_or_skip", "stage": stage, "requires_human": False,
"message": f"阶段 {stage} 部分成功(产物不全),可重跑或跳过"}]
# waiting/running/queued/done/skipped → 继续
return []
@@ -259,54 +273,22 @@ async def _resume_job(job_id: str) -> dict[str, Any]:
async def _archive_job(job_id: str, name: str, description: str,
include_intermediate: bool) -> dict[str, Any]:
"""任务存档:打包任务目录 + 落 bio_archives 行(复用 archive_from_job + store.insert_archive)。"""
"""任务存档(薄适配):委托 cmd_archive(唯一事实源),仅保留内存 record 校验 + 事件发布。"""
ctx = _ctx()
record = await load_record(job_id)
if record is None:
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
archive_name = normalize_archive_name(name)
existing = await store.find_archive_by_name(ctx.db, archive_name)
if existing is not None:
return {"ok": False, "code": "ARCHIVE_EXISTS", "message": f"归档名已存在: {archive_name}"}
try:
report_path = locate_report(record.workspace_dir)
result = await archive_from_job(
{
"id": record.id, "session_id": record.session_id,
"title": record.title, "stage_chain": record.stage_chain,
"params": record.params,
},
record.job_dir,
get_settings().archives_dir_abs,
name=archive_name,
description=description or "",
include_intermediate=bool(include_intermediate),
report_artifact=str(report_path) if report_path else None,
)
await store.insert_archive(ctx.db, {
"id": result.id,
"job_id": record.id,
"name": archive_name,
"description": description or "",
"stage_chain": record.stage_chain,
"params": record.params,
"report_artifact": result.report_artifact,
"disk_path": result.disk_path,
"size_bytes": result.size_bytes,
})
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": "ARCHIVE_FAILED",
"message": f"存档失败: {type(exc).__name__}: {exc}"}
ctx.events.publish("job_archived", {"job_id": record.id, "archive_id": result.id})
return {"ok": True, "data": {
"id": result.id,
"job_id": record.id,
"name": archive_name,
"description": description or "",
"disk_path": result.disk_path,
"size_bytes": result.size_bytes,
"report_artifact": result.report_artifact,
}}
return _mcp_err("NOT_FOUND", "任务不存在")
result = await cmd_archive(
job_id, name, description,
db=ctx.db,
record=record,
include_intermediate=bool(include_intermediate),
archive_fn=archive_from_job,
archives_dir=get_settings().archives_dir_abs,
)
if result.get("ok"):
ctx.events.publish("job_archived", {"job_id": job_id, "archive_id": result["data"]["id"]})
return result
# ---------------------------------------------------------------------------
@@ -468,7 +450,7 @@ async def _register_job_files(job_id: str, paths: list[str]) -> dict[str, Any]:
settings = get_settings()
record = await load_record(job_id)
if record is None:
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
return _mcp_err("NOT_FOUND", "任务不存在")
input_dir = record.input_dir
input_dir.mkdir(parents=True, exist_ok=True)
@@ -478,11 +460,10 @@ async def _register_job_files(job_id: str, paths: list[str]) -> dict[str, Any]:
for raw in paths:
p = Path(raw).expanduser().resolve()
if not p.exists():
return {"ok": False, "code": "PATH_NOT_EXIST", "message": f"路径不存在: {raw}"}
return _mcp_err("PATH_NOT_EXIST", f"路径不存在: {raw}")
blocked = _blocked_reason(p, settings)
if blocked:
return {"ok": False, "code": "PATH_NOT_ALLOWED",
"message": f"路径不允许: {raw}({blocked})"}
return _mcp_err("PATH_NOT_ALLOWED", f"路径不允许: {raw}({blocked})")
files: list[Path] = []
if p.is_file():
files = [p]
@@ -492,7 +473,7 @@ async def _register_job_files(job_id: str, paths: list[str]) -> dict[str, Any]:
if f.is_file() and (not ext_set or f.suffix.lower() in ext_set) and not _blocked_reason(f, settings):
files.append(f)
if not files:
return {"ok": False, "code": "VALIDATION", "message": f"目录内无可登记文件: {raw}"}
return _mcp_err("VALIDATION", f"目录内无可登记文件: {raw}")
for f in files:
link = input_dir / f.name
try:
@@ -560,54 +541,38 @@ async def _list_archives(page: int, page_size: int) -> dict[str, Any]:
async def _reuse_archive(name: str) -> dict[str, Any]:
ctx = _ctx()
arch = await store.find_archive_by_name(ctx.db, (name or "").strip())
if arch is None:
return {"ok": False, "code": "ARCHIVE_NOT_FOUND", "message": f"未找到匹配存档: {name}"}
return {"ok": True, "data": {
"id": arch["id"], "name": arch["name"], "stage_chain": arch["stage_chain"],
"params": arch["params"], "description": arch["description"],
}}
"""复用存档(薄适配):委托 cmd_archive_reuse(读各阶段实际生效参数)。"""
return await cmd_archive_reuse(name, db=_ctx().db)
async def _fetch_source_file(job_id: str | None, source: str,
credentials: dict[str, Any] | None) -> dict[str, Any]:
settings = get_settings()
dest_dir: Path
"""拉取源文件(薄适配):委托 cmd_source_fetch,仅保留内存 record 校验。"""
if job_id:
record = await load_record(job_id)
if record is None:
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
dest_dir = record.input_dir
else:
dest_dir = Path(settings.jobs_dir_abs) / "_incoming" / uuid.uuid4().hex[:8]
try:
result = await fetch_source(source, dest_dir, credentials=credentials)
except FetchError as exc:
return {"ok": False, "code": exc.code, "message": exc.message}
result["job_id"] = job_id
return result
return _mcp_err("NOT_FOUND", "任务不存在")
return await cmd_source_fetch(source, job_id=job_id, credentials=credentials,
fetch_fn=fetch_source)
async def _inspect_file(path: str) -> dict[str, Any]:
return _ft_inspect_file(path)
"""探测文件(薄适配):委托 cmd_file_inspect。"""
return cmd_file_inspect(path)
async def _transform_file(src_path: str, target_format: str,
column_map: dict[str, str] | None,
dest_path: str | None) -> dict[str, Any]:
from .services.bioflow.file_transform import TransformError
"""转换文件(薄适配):委托 cmd_file_transform(注入 LLM 适配器)。"""
from .services.bioflow.llm import complete_json
async def llm_adapter(user: str, system: str) -> Any:
return await complete_json(system, user, temperature=0.2, max_tokens=2000)
try:
return await _ft_transform_file(
src_path, target_format, column_map=column_map, dest_path=dest_path, llm=llm_adapter,
)
except TransformError as exc:
return {"ok": False, "code": exc.code, "message": exc.message, "details": exc.details}
return await cmd_file_transform(
src_path, target_format, column_map=column_map, dest=dest_path, llm=llm_adapter,
)
async def _generate_samples_yaml(job_id: str, sample_description: str) -> dict[str, Any]:
@@ -808,6 +773,74 @@ async def transform_file(src_path: str, target_format: str,
return await _transform_file(src_path, target_format, column_map, dest_path)
# ---------------------------------------------------------------------------
# G12:11 个分析控制 MCP 工具(异步薄封装委托 skill_tools,不写业务逻辑)
# ---------------------------------------------------------------------------
async def get_task_context(job_id: str | None = None) -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.get_task_context(job_id)
async def inspect_asset(role: str = "pep.fa", path: str = "") -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.inspect_asset(role, path)
async def validate_plan(stages: list[str] | None = None) -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.validate_plan(stages)
async def check_readiness(assets_root: str = "", stages: list[str] | None = None) -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.check_readiness(assets_root, stages)
async def get_run_evidence(run_id: str = "") -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.get_run_evidence(run_id)
async def propose_change(job_id: str = "", stage: str = "",
params: dict[str, Any] | None = None) -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.propose_change(job_id, stage, params)
async def apply_change(action_id: str = "", change: dict[str, Any] | None = None) -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.apply_change(action_id, change)
async def execute_nodes(job_id: str = "", nodes: list[str] | None = None) -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.execute_nodes(job_id, nodes)
async def control_execution(run_id: str = "", command: str = "") -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.control_execution(run_id, command)
async def validate_delivery(manifest: dict[str, Any] | None = None) -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.validate_delivery(manifest)
async def publish_delivery(manifest: dict[str, Any] | None = None) -> dict[str, Any]:
from .services.bioflow import skill_tools
return skill_tools.publish_delivery(manifest)
SKILL_CONTROL_TOOLS = [
get_task_context, inspect_asset, validate_plan,
check_readiness, get_run_evidence, propose_change,
apply_change, execute_nodes, control_execution,
validate_delivery, publish_delivery,
]
# ---------------------------------------------------------------------------
# 构建三个 MCPServer(基因家族 / 转录组 / 扩展)
# ---------------------------------------------------------------------------
@@ -858,6 +891,9 @@ def build_mcp_servers() -> tuple[Any, Any, Any]:
for mcp in (gf, rn):
for fn in shared:
mcp.tool()(fn)
# G12:11 个分析控制工具(Skill 编排,异步薄封装委托 skill_tools)
for fn in SKILL_CONTROL_TOOLS:
mcp.tool()(fn)
return gf, rn, ext
+19 -4
View File
@@ -31,10 +31,16 @@ from ..schemas import ArchiveCreateRequest, ArchiveRunRequest
from ..services.bioflow import store
from ..services.bioflow.archive import (
archive_from_job,
effective_stage_params,
normalize_archive_name,
remove_archive_dir,
)
from ..services.bioflow.reports import extract_figure_refs, locate_report, report_payload
from ..services.bioflow.reports import (
extract_figure_refs,
locate_report,
report_payload,
validate_report_path,
)
from .bioflow import job_or_404
logger = logging.getLogger(__name__)
@@ -65,6 +71,10 @@ async def reports_register(payload: dict[str, Any]) -> dict[str, Any]:
job_id = str(payload.get("job_id") or "")
record = await load_record(job_id) if job_id else None
report_path = payload.get("md_path") or payload.get("report_path") or ""
# G3:拒绝空路径/空文件(不静默登记空报告)
ok, err = validate_report_path(report_path)
if not ok:
return _err("EMPTY_REPORT", err, 422)
figures: list[str] = list(payload.get("figures") or [])
if record is not None and not figures:
located = locate_report(record.workspace_dir)
@@ -96,12 +106,14 @@ async def create_archive(body: ArchiveCreateRequest) -> dict[str, Any]:
existing = await store.find_archive_by_name(ctx.db, name)
if existing is not None:
return _err("ARCHIVE_EXISTS", f"归档名已存在: {name}", 409)
# G6:保存各阶段实际生效参数(非创建时 record.params)
effective_params = effective_stage_params(record.stage_chain, record._stage_states)
report_path = locate_report(record.workspace_dir)
result = await archive_from_job(
{
"id": record.id, "session_id": record.session_id,
"title": record.title, "stage_chain": record.stage_chain,
"params": record.params,
"params": effective_params,
},
record.job_dir,
get_settings().archives_dir_abs,
@@ -116,7 +128,7 @@ async def create_archive(body: ArchiveCreateRequest) -> dict[str, Any]:
"name": name,
"description": body.description,
"stage_chain": record.stage_chain,
"params": record.params,
"params": effective_params,
"report_artifact": result.report_artifact,
"disk_path": result.disk_path,
"size_bytes": result.size_bytes,
@@ -166,7 +178,10 @@ async def delete_archive(archive_id: str) -> JSONResponse:
@router.post("/archives/{archive_id}/run", status_code=201)
async def archive_run(archive_id: str, body: ArchiveRunRequest) -> dict[str, Any]:
"""复用发起(FR-BIO-023):装载阶段链+参数 → 新建任务(表单预填存档值)。"""
"""复用发起(FR-BIO-023):装载阶段链+参数 → 新建任务(表单预填存档值)。
params 为存档时落库的各阶段实际生效参数(G6:非创建时 record.params)。
"""
ctx: Context = context_mod.context
archive = await store.get_archive(ctx.db, archive_id)
if archive is None:
+36 -6
View File
@@ -22,7 +22,9 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import uuid
from pathlib import Path
from typing import Any
@@ -212,23 +214,43 @@ async def upload_file(
dest = input_dir / _safe_name(file.filename)
written = 0
max_bytes = int(settings.max_upload_gb * 1024 ** 3)
# G2:先落任务临时目录,再原子 rename 发布;同名冲突显式报错,不静默覆盖。
tmp_dir = record.job_dir / ".tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp_path = tmp_dir / f"{uuid.uuid4().hex[:8]}_{dest.name}"
try:
with dest.open("wb") as fh:
# G5:逐块流式写盘(不整块进内存)
with tmp_path.open("wb") as fh:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
written += len(chunk)
if written > max_bytes:
fh.close()
dest.unlink(missing_ok=True)
return _err("UPLOAD_TOO_LARGE",
f"超过上传上限 {settings.max_upload_gb}GB(已收 {written} 字节)", 413)
raise _UploadTooLarge(written)
fh.write(chunk)
except _UploadTooLarge as exc:
tmp_path.unlink(missing_ok=True)
return _err("UPLOAD_TOO_LARGE",
f"超过上传上限 {settings.max_upload_gb}GB(已收 {exc.written} 字节)", 413)
except Exception as exc: # noqa: BLE001 - 写盘失败不登记(TC-BIO-API-050)
logger.exception("files: 上传写盘失败 %s", job_id)
dest.unlink(missing_ok=True)
tmp_path.unlink(missing_ok=True)
return _err("INTERNAL", f"上传写盘失败: {exc}", 500)
# 原子发布 + 同名冲突检测(G2)
try:
os.link(tmp_path, dest)
except FileExistsError:
tmp_path.unlink(missing_ok=True)
return _err("FILE_EXISTS", f"同名文件已存在,拒绝覆盖: {dest.name}", 409)
except OSError:
# os.link 不支持的文件系统 → 退化:exists 检查 + rename(同事件循环内无 await 交错)
if dest.exists() or dest.is_symlink():
tmp_path.unlink(missing_ok=True)
return _err("FILE_EXISTS", f"同名文件已存在,拒绝覆盖: {dest.name}", 409)
os.rename(tmp_path, dest)
else:
tmp_path.unlink(missing_ok=True)
file_id = f"{job_id}:{dest.name}"
# BUG-P7-01:文件落盘后自动续跑(await_inputs 任务重推;非等待态 no-op)
await ctx.runner.retry_after_files(record)
@@ -238,6 +260,14 @@ async def upload_file(
}}
class _UploadTooLarge(Exception):
"""上传超限信号(携带已收字节数)。"""
def __init__(self, written: int) -> None:
super().__init__("upload too large")
self.written = written
async def _register_path(record: Any, server_path: str, settings: Any) -> dict[str, Any]:
"""服务器路径登记(FR-BIO-007):单文件或目录展开,任意数据位置(敏感路径黑名单拦截)。
@@ -19,11 +19,27 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .manifests import ManifestError, artifact_paths, parse_manifest
logger = logging.getLogger(__name__)
INTERMEDIATE_DIR_NAMES = ("analysis",)
def effective_stage_params(
stage_chain: list[str], stage_states: dict[str, Any]
) -> dict[str, Any]:
"""G6:各阶段「实际生效」参数(confirm/rerun 更新后的 stage 参数)。
区别于创建时 record.params(创建后 confirm/rerun 只更新 _stage_states[stage].params,
不回流 record.params)。存档与复用以此为单一事实源,避免参数失真。
"""
return {
s: dict((stage_states.get(s) or {}).get("params") or {})
for s in stage_chain
}
@dataclass
class ArchiveResult:
id: str
@@ -88,6 +104,11 @@ async def archive_from_job(
# 报告:任务目录内报告 HTML(final 内 *.html / report.html)随 final 已复制;
# report_artifact 为 doc_artifacts 登记 id(由 reports 模块/BFF 登记后回传)。
# G1:按 manifest 必需产物清单补归档(不只 workspace/final/)。
# 相对路径以 workspace 根解析并改写为归档内路径(去 workspace 前缀)。
_copy_manifest_artifacts(job, job_dir, dest)
size_bytes = _dir_size(dest)
return ArchiveResult(
id=archive_id,
@@ -98,6 +119,42 @@ async def archive_from_job(
)
def _copy_manifest_artifacts(
job: dict[str, Any], job_dir: Path, dest: Path
) -> list[str]:
"""G1:按 manifest 的必需产物清单复制归档产物(漏归档修复)。
每个阶段的 manifest artifacts(full_path/path/relpath_or_hint)逐条复制;
相对路径以 workspace 根解析,并改写为归档内路径(去 workspace 前缀)。
跳过不存在/失败阶段无 manifest 的产物。返回已复制的归档内相对路径列表。
"""
stage_chain = job.get("stage_chain") or []
workspace = Path(job_dir) / "workspace"
copied: list[str] = []
for stage in stage_chain:
try:
manifest = parse_manifest(workspace, stage)
except ManifestError:
continue
for raw in artifact_paths(manifest.artifacts):
p = Path(raw)
if not p.is_absolute():
p = workspace / p
if not p.is_file():
continue
# 相对路径改写为归档内路径(去 workspace 前缀)
try:
rel = p.resolve().relative_to(workspace.resolve())
except ValueError:
rel = Path(p.name)
target = dest / rel
target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists():
shutil.copy2(p, target)
copied.append(str(rel))
return copied
def _copytree_into(src: Path, dest: Path) -> None:
"""复制目录内容到 dest(dest 可能已存在,逐项合并)。"""
dest.mkdir(parents=True, exist_ok=True)
+63 -21
View File
@@ -551,6 +551,38 @@ _STAGE_PARAM_SCHEMAS: dict[str, tuple[list[dict[str, Any]], list[dict[str, Any]]
], _DOWNSTREAM_INPUTS, _DOWNSTREAM_OUTPUTS),
}
# ---- G9:单一参数 schema 访问点(Catalog/表单/预检/工具/验收都读这一份)----
def _schema_params_inputs_outputs(stage: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
"""阶段参数/输入/输出的单一读取点(含未知阶段默认兜底)。"""
if stage in _STAGE_PARAM_SCHEMAS:
return _STAGE_PARAM_SCHEMAS[stage]
params = [{"name": "cpu", "type": "choice", "choices": ["4", "8", "16", "32"],
"default": "4", "source": "runtime", "help": "容器 CPU 数"}]
inputs = _DOWNSTREAM_INPUTS if stage not in ("identify", "genome_prepare") else []
outputs = _DOWNSTREAM_OUTPUTS
return params, inputs, outputs
def stage_schema(stage: str) -> dict[str, Any]:
"""G9:单一参数 schema 访问点——阶段名/耗时/依赖/参数/输入/输出/输入角色 合并为一份。
Catalog/表单/预检/工具/验收都读这一份(唯一事实源)。
"""
from .stage_assets import stage_input_roles
params, inputs, outputs = _schema_params_inputs_outputs(stage)
return {
"name": STAGE_INFO_SNAPSHOT.get(stage, {}).get("name") or stage,
"estimated_time": int(STAGE_INFO_SNAPSHOT.get(stage, {}).get("estimated_time") or 0),
"dependencies": STAGE_DEPENDENCIES_SNAPSHOT.get(stage, []),
"params": params,
"inputs": inputs,
"outputs": outputs,
"input_roles": stage_input_roles(stage),
}
class StageCatalog:
"""阶段目录(数据来自 stage_image_map.json + 手写快照,不运行时反射)。"""
@@ -578,29 +610,19 @@ class StageCatalog:
if not self.has(stage):
return None
entry = self._map.image_for(stage) or {}
params, inputs, outputs = _STAGE_PARAM_SCHEMAS.get(
stage, ([], _DOWNSTREAM_INPUTS if stage not in ("identify", "genome_prepare") else [], [])
)
if stage not in _STAGE_PARAM_SCHEMAS:
params, inputs, outputs = (
[{"name": "cpu", "type": "choice", "choices": ["4", "8", "16", "32"],
"default": "4", "source": "runtime", "help": "容器 CPU 数"}],
_DOWNSTREAM_INPUTS if stage not in ("identify", "genome_prepare") else [],
_DOWNSTREAM_OUTPUTS,
)
info = STAGE_INFO_SNAPSHOT.get(stage, {})
schema = stage_schema(stage)
return StageDef(
name=stage,
description=str(info.get("name") or stage),
description=str(schema["name"] or stage),
image=str(entry.get("image") or ""),
module=str(entry.get("module") or ""),
executable=bool(entry.get("executable")),
estimated_time=int(info.get("estimated_time") or 0),
params=params,
inputs=inputs,
outputs=outputs,
estimated_time=int(schema["estimated_time"]),
params=schema["params"],
inputs=schema["inputs"],
outputs=schema["outputs"],
note=str(entry.get("note") or ""),
dependencies=STAGE_DEPENDENCIES_SNAPSHOT.get(stage, []),
dependencies=schema["dependencies"],
)
def resolve_chain(self, chain: str | None) -> list[str] | None:
@@ -612,32 +634,52 @@ class StageCatalog:
return None
return list(alias)
def auto_fill_prerequisites(self, stages: list[str]) -> tuple[list[str], list[str]]:
def auto_fill_prerequisites(
self, stages: list[str], satisfied_roles: list[str] | None = None
) -> tuple[list[str], list[str]]:
"""前置补全(FR-BIO-003):缺失的前置阶段自动补全。
G7/G8:satisfied_roles(已有 validated 资产的输入角色)对应的上游阶段
跳过不补跑(如 role=counts 满足时做 DE 不补跑 qc/align/quant/sample_qc)。
返回 (补全后的链, 被补全的阶段名列表)。
链内阶段按依赖拓扑顺序排列(依赖在前)。
"""
from .stage_assets import upstream_stages_for_roles
skip_stages = upstream_stages_for_roles(satisfied_roles or [])
missing: list[str] = []
added: list[str] = []
chain_set = set(stages)
# 反复补全直到闭合(依赖的依赖也补)
# 反复补全直到闭合(依赖的依赖也补;被角色资产满足的上游跳过)
changed = True
while changed:
changed = False
for s in list(stages):
for dep in STAGE_DEPENDENCIES_SNAPSHOT.get(s, []):
if dep not in chain_set and self.has(dep):
if dep in chain_set:
continue
if dep in skip_stages:
continue
if self.has(dep):
chain_set.add(dep)
missing.append(dep)
added.append(dep)
changed = True
ordered = topo_order([s for s in GENE_FAMILY_CHAIN if s in chain_set]
or list(chain_set))
# G7/G8:被角色资产满足的上游阶段从最终链中剔除(不补跑)
if skip_stages:
ordered = [s for s in ordered if s not in skip_stages]
return ordered, added
def dependencies(self, stage: str) -> list[str]:
return STAGE_DEPENDENCIES_SNAPSHOT.get(stage, [])
return stage_schema(stage)["dependencies"]
def stage_input_roles(self, stage: str) -> list[str]:
"""G8:阶段依赖的输入角色(数据角色)。"""
from .stage_assets import stage_input_roles
return stage_input_roles(stage)
def topo_order(stages: list[str]) -> list[str]:
@@ -0,0 +1,141 @@
"""app/services/bioflow/delivery.py —— 交付清单(G21)+ 终报告范围(G22)+ 资源预算(G23)。
- G21 交付清单完整性:delivery_validate(manifest) 做八项完整性检查
(selected_stages / run_id / input_sources / output_sources / config /
required_results / validation_values / review_status),返回
{ok, checks{八项:bool}, incomplete[{item, reason, error(G13)}]}。
- G22 终报告范围:classify_report_scope(status, source) 五分类
(user_provided / reused / not_executed / skipped / failed)。
- G23 资源预算:check_download_budget(size_bytes, budget_bytes)(超限→
DOWNLOAD_BUDGET_EXCEEDED)+ check_disk_available(dest_dir, needed_bytes)
(不足→DISK_FULL),均 ok=False + 结构化错误。
服务层纪律:纯 dict/stdlib,无 pydantic。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .errors import error_object, error_result
from .source_fetcher import check_disk_space
# G21 八项完整性检查项
DELIVERY_ITEMS = (
"selected_stages", "run_id", "input_sources", "output_sources",
"config", "required_results", "validation_values", "review_status",
)
# G22 五分类
SCOPE_USER_PROVIDED = "user_provided"
SCOPE_REUSED = "reused"
SCOPE_NOT_EXECUTED = "not_executed"
SCOPE_SKIPPED = "skipped"
SCOPE_FAILED = "failed"
def delivery_validate(manifest: dict[str, Any]) -> dict[str, Any]:
"""G21:校验交付清单完整性(八项)。
返回 {"ok": bool, "checks": {item: bool},
"incomplete": [{"item", "reason", "error": {G13 对象}}]}。
"""
manifest = manifest or {}
checks: dict[str, bool] = {}
incomplete: list[dict[str, Any]] = []
for item in DELIVERY_ITEMS:
value = manifest.get(item)
ok = bool(value)
checks[item] = ok
if not ok:
incomplete.append({
"item": item,
"reason": f"缺 {item}",
"error": error_object("INCOMPLETE_DELIVERY", f"交付清单缺项: {item}",
category="input"),
})
return {"ok": not incomplete, "checks": checks, "incomplete": incomplete}
def publish_delivery(manifest: dict[str, Any]) -> dict[str, Any]:
"""G21:发布交付清单(校验通过置 review_status=approved)。"""
check = delivery_validate(manifest)
if not check["ok"]:
return {"ok": False, "published": False, **check}
manifest["review_status"] = "approved"
return {"ok": True, "published": True, "review_status": "approved",
"incomplete": []}
# ---- G22 终报告范围 ----
def classify_report_scope(status: str, source: str | None = None) -> str:
"""G22:阶段状态 → 终报告范围五分类。
failed→failed;skipped→skipped;done + source=reuse→reused;
done + source=user/空→user_provided;其余非终态→not_executed。
"""
s = str(status or "")
if s == "failed":
return SCOPE_FAILED
if s == "skipped":
return SCOPE_SKIPPED
if s == "done":
return SCOPE_REUSED if source == "reuse" else SCOPE_USER_PROVIDED
return SCOPE_NOT_EXECUTED
def build_report(
manifest: dict[str, Any],
stage_sources: dict[str, str] | None = None,
) -> dict[str, Any]:
"""G22:终报告结构(执行范围 / 数据来源与核验 / 方法版本 / QC / 科学前提 /
失败跳过未覆盖 / 产物清单)。"""
stages = list(manifest.get("selected_stages") or [])
sources = dict(stage_sources or {})
scope = [{"stage": s, "source": sources.get(s, SCOPE_USER_PROVIDED)} for s in stages]
failed = [s for s in stages if sources.get(s) == SCOPE_FAILED]
skipped = [s for s in stages if sources.get(s) == SCOPE_SKIPPED]
not_executed = [s for s in stages if sources.get(s) == SCOPE_NOT_EXECUTED]
config = manifest.get("config") or {}
return {
"execution_scope": scope,
"data_sources": {
"inputs": list(manifest.get("input_sources") or []),
"outputs": list(manifest.get("output_sources") or []),
},
"method_version": str(manifest.get("plan_revision") or manifest.get("run_id") or ""),
"qc": {"validation_values": dict(manifest.get("validation_values") or {})},
"scientific_preconditions": config.get("scientific_preconditions", []),
"failed_skipped_uncovered": {
"failed": failed, "skipped": skipped, "not_executed": not_executed,
},
"artifacts": list(manifest.get("required_results") or []),
}
# ---- G23 资源预算门禁 ----
def check_download_budget(size_bytes: int, budget_bytes: int) -> dict[str, Any]:
"""G23:下载预算门禁——超限阻断并返回结构化错误。"""
if size_bytes > budget_bytes:
return error_result(
"DOWNLOAD_BUDGET_EXCEEDED",
f"下载预算超限:本次 {size_bytes} > 预算 {budget_bytes}",
)
return {"ok": True}
def check_disk_available(dest_dir: str | Path, needed_bytes: int) -> dict[str, Any]:
"""G23:磁盘门禁——剩余空间不足阻断并返回结构化错误。"""
try:
ok, free = check_disk_space(dest_dir, needed_bytes)
except OSError as exc:
return error_result("PATH_NOT_EXIST", f"目标目录不可用:{exc}")
if not ok:
return error_result(
"DISK_FULL", f"磁盘空间不足:需 {needed_bytes} 字节,剩余 {free} 字节",
)
return {"ok": True, "free_bytes": free}
@@ -0,0 +1,196 @@
"""app/services/bioflow/errors.py —— 统一结构化错误对象(G13)+ 确定性分类器(G14)。
失败返回统一携带结构化错误对象(替代裸字符串 message):
{"ok": False, "code": "<码>", "message": "<摘要>",
"error": {error_id, category, code, summary, evidence_refs,
diagnosis_status, retryable_without_change, suggested_actions}}
8 类 category(G14:确定性映射):
input / scientific_precondition / reference / environment /
transient / resource / adapter / artifact
G14 分类纪律:退出码/日志是「证据」不是「结论」——
- 日志含 OOM → resource;日志含镜像缺失 → environment;日志含科学前提不满足 → scientific_precondition。
- 仅凭「非零退出码」无法定类 → adapter + undiagnosed(不臆测)。
服务层纪律:纯 dict/stdlib,无 pydantic。
"""
from __future__ import annotations
import uuid
from typing import Any
CATEGORIES = (
"input", "scientific_precondition", "reference", "environment",
"transient", "resource", "adapter", "artifact",
)
# 明显映射:错误码 → (category, diagnosis_status, retryable_without_change, suggested_actions)
_CODE_META: dict[str, tuple[str, str, bool, list[str]]] = {
# ---- resource(资源耗尽)----
"DISK_FULL": ("resource", "diagnosed", False, ["清理磁盘空间后重试"]),
"UPLOAD_TOO_LARGE": ("resource", "diagnosed", False, ["减小文件大小或调大上传上限"]),
"DOWNLOAD_BUDGET_EXCEEDED": ("resource", "diagnosed", False, ["调大下载预算或减小下载量"]),
# ---- environment(运行环境)----
"OSS_UNCONFIGURED": ("environment", "diagnosed", False, ["配置 BIOFLOW_OSS_ENDPOINT 或改用预签名 URL"]),
"SSHPASS_MISSING": ("environment", "diagnosed", False, ["本机安装 sshpass 或改用密钥认证"]),
# ---- reference(参考数据)----
"REFERENCE_NOT_FOUND": ("reference", "diagnosed", False, ["补齐参考基因组/HMM/索引后重试"]),
# ---- transient(临时/可重试)----
"NETWORK_ERROR": ("transient", "diagnosed", True, ["重试下载"]),
"LAN_TIMEOUT": ("transient", "diagnosed", True, ["重试局域网拉取"]),
"TRUNCATED": ("transient", "diagnosed", True, ["重试下载"]),
"LAN_ERROR": ("transient", "diagnosed", True, ["重试局域网拉取"]),
"HTTP_ERROR": ("transient", "diagnosed", True, ["确认 URL 可达或稍后重试"]),
# ---- input(用户输入/文件/参数)----
"EMPTY_SOURCE": ("input", "diagnosed", False, ["提供非空源地址"]),
"BAD_SCHEME": ("input", "diagnosed", False, ["改用 http/https/oss 协议"]),
"BAD_OSS": ("input", "diagnosed", False, ["修正 OSS 地址为 oss://bucket/key"]),
"PATH_NOT_EXIST": ("input", "diagnosed", False, ["确认路径存在且可达"]),
"IS_DIR": ("input", "diagnosed", False, ["改为单文件路径"]),
"NOT_FOUND": ("input", "diagnosed", False, ["确认目标存在"]),
"BAD_FORMAT": ("input", "diagnosed", False, ["选择支持的目标格式 fasta/gff3/tsv/samples.yaml"]),
"EMPTY_TABLE": ("input", "diagnosed", False, ["提供非空表格数据"]),
"MISSING_COLUMNS": ("input", "diagnosed", False, ["确认列映射(名称/序列/r1/r2)"]),
"VALIDATION": ("input", "diagnosed", False, ["修正输入以满足校验"]),
"VALIDATION_FAILED": ("input", "diagnosed", False, ["修正数据后重转"]),
"UNKNOWN_STAGE": ("input", "diagnosed", False, ["选择有效阶段"]),
"UNKNOWN_PARAMS": ("input", "diagnosed", False, ["移除未知参数"]),
"BAD_ROLE": ("input", "diagnosed", False, ["使用合法角色名(非空、无路径分隔符/..)"]),
"EMPTY_REPORT": ("input", "diagnosed", False, ["提供非空报告路径/文件"]),
"EMPTY_SAMPLES": ("input", "diagnosed", False, ["提供含样本的描述"]),
"INCOMPLETE_DELIVERY": ("input", "diagnosed", False, ["补齐交付清单缺失项(selected_stages/run_id/必需结果等)后重试"]),
# ---- adapter(适配/映射)----
"UNSUPPORTED": ("adapter", "diagnosed", False, ["改用支持的转换路径"]),
"CROSS_CHAIN": ("adapter", "diagnosed", False, ["选择本能力单元内的阶段"]),
# ---- artifact(产物)----
"ARCHIVE_EXISTS": ("artifact", "diagnosed", False, ["更换归档名"]),
"ARCHIVE_FAILED": ("artifact", "diagnosed", False, ["检查任务目录完整性后重试"]),
"ARCHIVE_NOT_FOUND": ("artifact", "diagnosed", False, ["确认归档名存在"]),
"ASSET_EXISTS": ("artifact", "diagnosed", False, ["更换角色或文件名"]),
"PUBLISH_FAILED": ("artifact", "diagnosed", False, ["检查目标目录可写性后重试"]),
"NO_MANIFEST": ("artifact", "diagnosed", False, ["检查阶段产物 manifest 是否产出"]),
# ---- scientific_precondition(科学前提)----
"SCIENTIFIC_PRECONDITION": ("scientific_precondition", "diagnosed", False, ["确认科学前提满足后重试"]),
}
# 关键词兜底(summary 命中即映射;用于非枚举码)
_KEYWORD_META: list[tuple[tuple[str, ...], tuple[str, str, bool, list[str]]]] = [
(("out of memory", "oom", "memoryerror", "oom-killed", "memory limit", "内存"),
("resource", "diagnosed", False, ["调大内存或减小数据规模"])),
(("no such image", "image not found", "pull access denied", "镜像", "docker"),
("environment", "diagnosed", False, ["确认镜像存在且可拉取"])),
(("no significant", "not enough", "insufficient", "科学前提", "no differentially", "not found enough"),
("scientific_precondition", "diagnosed", False, ["确认科学前提满足后重试"])),
(("timeout", "超时", "network", "网络", "unreachable", "connecterror", "timed out"),
("transient", "diagnosed", True, ["重试"])),
(("reference", "参考", "hmm", "pfam", "gtf", "index"),
("reference", "diagnosed", False, ["补齐参考数据后重试"])),
(("manifest", "no_manifest", "产物缺失"),
("artifact", "diagnosed", False, ["检查阶段产物 manifest"])),
]
# 阶段失败 reason(runner _fail_stage)→ (category, diagnosis_status, retryable, actions)
_REASON_META: dict[str, tuple[str, str, bool, list[str]]] = {
"timeout": ("transient", "diagnosed", True, ["重跑该阶段"]),
"exit_code": ("adapter", "undiagnosed", False, ["查看阶段日志定位失败原因", "修正后重跑"]),
"no_manifest": ("artifact", "diagnosed", False, ["检查阶段是否产出 manifest"]),
"manifest_error": ("artifact", "diagnosed", False, ["检查 manifest JSON 完整性"]),
"manifest_failed": ("artifact", "diagnosed", False, ["检查阶段产物 manifest status"]),
}
# 退出码 137 = 128 + SIGKILL(常为 OOM kill)
_OOM_EXIT_CODES = {137, 139}
def classify(code: str | None, summary: str = "") -> tuple[str, str, bool, list[str]]:
"""错误码/摘要 → (category, diagnosis_status, retryable_without_change, suggested_actions)。"""
code = str(code or "")
summary = str(summary or "")
if code in _CODE_META:
cat, diag, retry, actions = _CODE_META[code]
return cat, diag, retry, list(actions)
low = summary.lower()
for kws, meta in _KEYWORD_META:
if any(k in low for k in kws):
cat, diag, retry, actions = meta
return cat, diag, retry, list(actions)
return "adapter", "undiagnosed", False, []
def classify_reason(
reason: str | None,
*,
exit_code: int | None = None,
log_tail: str | None = None,
) -> tuple[str, str, bool, list[str]]:
"""阶段失败原因 + 证据(退出码/日志)→ 确定性 8 类。
确定性 reason 优先(timeout / manifest 系列);泛化 exit_code 用证据(退出码/日志)
定类——仅凭非零退出码不定类(undiagnosed),不臆测。
"""
reason = str(reason or "")
log = (log_tail or "").lower()
# 1. 确定性 reason(非 exit_code)优先:timeout / manifest 系列
if reason in _REASON_META and reason != "exit_code":
cat, diag, retry, actions = _REASON_META[reason]
return cat, diag, retry, list(actions)
# 2. 泛化 exit_code / 未知 reason:退出码是证据(137/139 = OOM kill)
if exit_code in _OOM_EXIT_CODES:
return "resource", "diagnosed", False, ["容器可能被 OOM kill,调大内存后重跑"]
# 3. 日志证据定类
for kws, meta in _KEYWORD_META:
if any(k in log for k in kws):
cat, diag, retry, actions = meta
return cat, diag, retry, list(actions)
# 4. 兜底:exit_code → adapter undiagnosed;未知 → 同
if reason in _REASON_META:
cat, diag, retry, actions = _REASON_META[reason]
return cat, diag, retry, list(actions)
return "adapter", "undiagnosed", False, []
def error_object(
code: str | None,
summary: str = "",
*,
category: str | None = None,
evidence_refs: list[str] | None = None,
diagnosis_status: str | None = None,
retryable_without_change: bool | None = None,
suggested_actions: list[str] | None = None,
) -> dict[str, Any]:
"""构造统一结构化错误对象(8 字段)。"""
cat, diag, retry, actions = classify(code, summary)
return {
"error_id": uuid.uuid4().hex[:16],
"category": category or cat,
"code": code or "INTERNAL",
"summary": summary,
"evidence_refs": list(evidence_refs or []),
"diagnosis_status": diagnosis_status or diag,
"retryable_without_change": retry if retryable_without_change is None else retryable_without_change,
"suggested_actions": list(suggested_actions or actions),
}
def error_result(
code: str | None,
summary: str = "",
*,
evidence_refs: list[str] | None = None,
) -> dict[str, Any]:
"""失败返回统一信封:{"ok": False, "code", "message", "error"}。
code 同时出现在顶层与 error 对象内(兼容 Phase A 的 r["code"] 断言)。
"""
return {
"ok": False,
"code": code or "INTERNAL",
"message": summary,
"error": error_object(code, summary, evidence_refs=evidence_refs),
}
@@ -24,8 +24,8 @@ logger = logging.getLogger(__name__)
EVENT_TYPES = (
"job_created", "plan_ready", "stage_started", "stage_log", "stage_progress",
"stage_done", "stage_failed", "stage_skipped", "stage_await_confirm",
"stage_await_files",
"stage_done", "stage_failed", "stage_skipped", "stage_partial",
"stage_await_confirm", "stage_await_files",
"job_done", "job_paused", "job_archived", "job_recovered",
# 用户自定义扩展分析事件帧(契约 §4;并行链路,不触碰现有 job_* 语义)
"extension_created", "extension_build", "extension_trial", "extension_repair",
@@ -208,6 +208,9 @@ class EventBus:
elif t == "stage_skipped":
st = stage_states.setdefault(str(d.get("stage")), {"stage": str(d.get("stage"))})
st["status"] = "skipped"
elif t == "stage_partial":
st = stage_states.setdefault(str(d.get("stage")), {"stage": str(d.get("stage"))})
st["status"] = "partial_success"
elif t == "stage_progress":
st = stage_states.setdefault(str(d.get("stage")), {"stage": str(d.get("stage"))})
pct = d.get("percent")
@@ -0,0 +1,99 @@
"""app/services/bioflow/readiness.py —— 就绪检查(G10,四象限)。
- environment:执行环境(docker 可用)。
- reference:参考数据库(依赖 hmm/reference 的阶段需 assets_root 下有参考)。
- space:磁盘空间(目标盘剩余空间)。
- budget:下载/并发预算(concurrency)。
返回四象限键(environment/reference/space/budget,每象限含 ok)+ 顶层 ready;
缺项 ready=False 且携带 G13 结构化错误对象(阻断,不启动)。
服务层纪律:纯 dict/stdlib,无 pydantic。
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from .errors import error_object
from .source_fetcher import check_disk_space
from .stage_assets import stage_input_roles
QUADRANTS = ("environment", "reference", "space", "budget")
# 默认磁盘余量下限(1GB)
MIN_FREE_BYTES = 1024 * 1024 * 1024
# 需要参考数据库的输入角色
_REFERENCE_ROLES = ("hmm", "reference", "gtf", "index")
def _docker_available() -> bool:
"""docker 可执行文件是否存在(确定性、无网络;daemon 探活由执行器负责)。"""
return shutil.which("docker") is not None
def _stages_need_reference(stages: list[str]) -> bool:
roles: set[str] = set()
for s in stages:
roles.update(stage_input_roles(s))
return bool(roles & set(_REFERENCE_ROLES))
def check_readiness(
*,
assets_root: str | Path,
stages: list[str] | None = None,
) -> dict[str, Any]:
"""G10:四象限就绪检查。
assets_root:资产/数据根目录(参考数据库如 hmm 应在其下)。
stages:待执行阶段列表(据此判定是否需要参考数据库)。
返回 {"ready": bool, "environment": {ok}, "reference": {ok}, "space": {ok},
"budget": {ok}, "error": <G13 结构化错误对象(缺项时)>}。
"""
from ...config import get_settings
stages = list(stages or [])
root = Path(assets_root) if assets_root else None
# environment:docker 可用(执行时才真正拉镜像;此处仅探可执行文件)
environment = {"ok": True, "detail": "docker ok"} if _docker_available() else {
"ok": False, "error": error_object("ENVIRONMENT_NOT_READY", "docker 不可用(未安装或不在 PATH)"),
}
# reference:依赖 hmm/reference 的阶段需要参考数据库
if not _stages_need_reference(stages):
reference: dict[str, Any] = {"ok": True, "detail": "无需参考数据库"}
else:
hmm_dir = root / "hmm" if root else None
if hmm_dir and hmm_dir.exists():
reference = {"ok": True, "detail": str(hmm_dir)}
else:
reference = {"ok": False, "error": error_object("REFERENCE_NOT_FOUND", "参考数据库缺失")}
# space:磁盘空间
try:
ok, free = check_disk_space(root or Path("."), MIN_FREE_BYTES)
except OSError:
ok, free = True, 0
space: dict[str, Any] = {"ok": True, "free_bytes": free} if ok else {
"ok": False, "free_bytes": free,
"error": error_object("DISK_FULL", f"磁盘空间不足:剩余 {free} 字节 < {MIN_FREE_BYTES}"),
}
# budget:并发预算
concurrency = int(getattr(get_settings(), "concurrency", 0) or 0)
budget: dict[str, Any] = {"ok": True, "concurrency": concurrency} if concurrency > 0 else {
"ok": False, "error": error_object("BUDGET_EXHAUSTED", "并发预算为 0,无法调度执行"),
}
quadrants = {"environment": environment, "reference": reference, "space": space, "budget": budget}
ready = all(bool(quadrants[q].get("ok")) for q in QUADRANTS)
result: dict[str, Any] = {"ready": ready, **quadrants}
if not ready:
for q in QUADRANTS:
if quadrants[q].get("error"):
result["error"] = quadrants[q]["error"]
break
return result
@@ -0,0 +1,74 @@
"""app/services/bioflow/recovery.py —— 恢复预算(G15)。
- 同一原因自动重试 ≤2 次(max_same_cause_retries)。
- 动作有数量预算(action_budget)。
- 收敛条件(立即停止自动重试):
user_stopped(用户停止)→ 不重试;
非 transient(scientific_precondition / resource / input / reference /
environment / artifact / adapter)→ 不重试(证据不是结论:仅退出码不定类);
同因重试已达上限 / 动作预算耗尽 → 不重试。
服务层纪律:纯 dataclass/stdlib,无 pydantic。
"""
from __future__ import annotations
from .errors import classify_reason
MAX_SAME_CAUSE_RETRIES = 2
class RecoveryBudget:
"""按「原因」(cause)计数的自动重试预算。
契约 API:can_retry(cause) / record_retry(cause)。
runner 集成 API:decide(stage, reason, ...) / record(stage, reason)。
"""
def __init__(self, max_same_cause_retries: int = MAX_SAME_CAUSE_RETRIES,
action_budget: int | None = None) -> None:
self.max_same_cause_retries = max_same_cause_retries
self.action_budget = action_budget
self._retries: dict[object, int] = {}
self._actions = 0
def retries_used(self, cause: object) -> int:
return self._retries.get(cause, 0)
def can_retry(self, cause: object) -> bool:
"""同一原因是否还可再重试(< max_same_cause_retries 且未超动作预算)。"""
if self.retries_used(cause) >= self.max_same_cause_retries:
return False
if self.action_budget is not None and self._actions >= self.action_budget:
return False
return True
def record_retry(self, cause: object) -> None:
self._retries[cause] = self.retries_used(cause) + 1
self._actions += 1
# ---- G15 runner 集成:决定是否自动重试(transient 才可重试)----
def decide(
self,
stage: str,
reason: str,
*,
exit_code: int | None = None,
log_tail: str | None = None,
user_stopped: bool = False,
) -> bool:
"""是否允许再次自动重试该 (stage, reason)。
收敛条件命中(用户停止 / 非 transient / 同因超限 / 动作预算耗尽)返回 False。
"""
if user_stopped:
return False
category, _diag, retryable, _actions = classify_reason(
reason, exit_code=exit_code, log_tail=log_tail,
)
if not retryable or category != "transient":
return False
return self.can_retry((stage, reason))
def record(self, stage: str, reason: str) -> None:
self.record_retry((stage, reason))
@@ -37,6 +37,19 @@ def locate_report(workspace_dir: str | Path) -> Path | None:
return mds[0] if mds else None
def validate_report_path(path: str | Path) -> tuple[bool, str | None]:
"""G3:报告登记路径校验(拒绝空路径/空文件/不存在)。返回 (ok, error)。"""
raw = (str(path) if path is not None else "").strip()
if not raw:
return False, "报告路径为空"
p = Path(raw)
if not p.is_file():
return False, f"报告文件不存在: {raw}"
if p.stat().st_size == 0:
return False, f"报告文件为空: {raw}"
return True, None
def extract_figure_refs(report_path: str | Path) -> list[str]:
"""报告 HTML 中 fig://id 图引用清单(顺序去重)。"""
path = Path(report_path)
@@ -85,7 +98,10 @@ async def register_report(
if not register_url:
return {"ok": False, "degraded": True, "reason": "no_register_url"}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=timeout)) as client:
# httpx 0.28+:Timeout 须四参数齐全或给默认值,否则构造抛 ValueError(G13/B3 修复)
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=timeout, write=timeout, pool=timeout)
) as client:
resp = await client.post(register_url, json=payload)
if resp.status_code >= 400:
logger.warning("reports: 登记端点返回 %s(降级为本地仅登记)", resp.status_code)
@@ -0,0 +1,55 @@
"""app/services/bioflow/run_owner.py —— 执行所有权(G20)。
- claim:取得执行所有权(首次 True;已被他人接管 → False,不重复接管)。
- stop:终止实际执行(仅所有权匹配才生效;终止后仍须 release 释放所有权)。
- release:释放执行所有权(释放后方可被重新接管)。
- resume:恢复执行(已运行则 False,不与旧进程重叠)。
服务层纪律:纯 dict/stdlib,无 pydantic。
"""
from __future__ import annotations
# run_id → owner_id(当前执行所有权)
_owners: dict[str, str] = {}
# 已终止的 run_id(stop 后标记;供诊断/断言,不阻断 release 后的重新接管)
_stopped: set[str] = set()
def claim(run_id: str, owner_id: str) -> bool:
"""取得 run_id 的执行所有权:首次 True;已被他人接管 → False(单一所有权)。"""
cur = _owners.get(run_id)
if cur is not None and cur != owner_id:
return False
_owners[run_id] = owner_id
return True
def release(run_id: str, owner_id: str) -> bool:
"""释放执行所有权:仅所有权匹配才释放。"""
if _owners.get(run_id) != owner_id:
return False
_owners.pop(run_id, None)
return True
def stop(run_id: str, owner_id: str) -> bool:
"""终止实际执行(G20):仅所有权匹配才生效;终止后标记 stopped(所有权保留待 release)。"""
if _owners.get(run_id) != owner_id:
return False
_stopped.add(run_id)
return True
def resume(run_id: str, owner_id: str) -> bool:
"""恢复执行:已运行(被持有)则 False(不与旧进程重叠);否则接管。"""
if run_id in _owners:
return False
return claim(run_id, owner_id)
def is_stopped(run_id: str) -> bool:
return run_id in _stopped
def owner_of(run_id: str) -> str | None:
return _owners.get(run_id)
+122 -14
View File
@@ -27,8 +27,11 @@ from pathlib import Path
from typing import Any, Awaitable, Callable
from .catalog import StageCatalog
from .delivery import delivery_validate
from .errors import classify_reason
from .events import EventBus
from .manifests import ManifestError, locate_manifest, parse_manifest
from .recovery import RecoveryBudget
logger = logging.getLogger(__name__)
@@ -43,8 +46,10 @@ STAGE_DONE = "done"
STAGE_FAILED = "failed"
STAGE_SKIPPED = "skipped"
STAGE_PAUSED = "paused"
# G4:manifest status=partial_success 的独立终态(不被当作 success/done 处理)
STAGE_PARTIAL_SUCCESS = "partial_success"
STAGE_TERMINAL = {STAGE_DONE, STAGE_FAILED, STAGE_SKIPPED}
STAGE_TERMINAL = {STAGE_DONE, STAGE_FAILED, STAGE_SKIPPED, STAGE_PARTIAL_SUCCESS}
STAGE_RUNNABLE = {STAGE_WAITING, STAGE_AWAIT_FILES, STAGE_WAITING_CONFIRM, STAGE_QUEUED}
JOB_CREATED = "created"
@@ -612,6 +617,30 @@ def parse_progress_marker(line: str) -> dict[str, Any] | None:
return None
_METRIC_LABELS = {
"hits": "命中", "hit_count": "命中", "mapping_rate": "比对率",
"aligned": "比对", "mapped": "比对", "identified": "鉴定",
"members": "成员", "de_genes": "差异基因", "up": "上调", "down": "下调",
"enriched": "富集", "count": "数量", "total": "总数",
"records": "记录", "sequences": "序列",
}
def _metrics_summary(stage: str, metrics: dict) -> str:
"""阶段完成摘要(G 关键指标自然语言转述);无指标时退化为「阶段已完成」。"""
if not isinstance(metrics, dict) or not metrics:
return f"阶段 {stage} 已完成"
parts = []
for k, v in metrics.items():
if not isinstance(v, (int, float, str, bool)):
continue
label = _METRIC_LABELS.get(str(k), str(k))
parts.append(f"{label} {v}")
if parts:
return f"阶段 {stage} 完成:{','.join(parts)}"
return f"阶段 {stage} 已完成"
@dataclass
class JobRecord:
"""任务内存记录(DB 行 + 派生路径)。"""
@@ -625,6 +654,8 @@ class JobRecord:
auto_continue: bool = False
archive_id: str | None = None
pause_requested: bool = False
plan_revision: str = "1" # G11:规划修订号(每次 re-plan 递增)
run_id: str | None = None # G11:本次运行标识(缺省回落 job.id)
registered_server_paths: list[str] = field(default_factory=list) # 登记路径(自动挂载)
_running_container: str | None = None # 当前执行容器名(暂停时按名 kill)
_stage_states: dict[str, dict[str, Any]] = field(default_factory=dict)
@@ -683,6 +714,7 @@ class Runner:
self._task_factory = task_factory or asyncio.create_task
self.auto_mode_default = auto_mode_default
self._tasks: set[asyncio.Task] = set()
self.recovery = RecoveryBudget() # G15:同因自动重试预算
# ---- 任务生命周期 ----
@@ -773,10 +805,11 @@ class Runner:
return
if record.status == JOB_PAUSED:
return
outcome = await self._run_stage(record, stage, idx)
outcome = await self._run_stage_with_retry(record, stage, idx)
if outcome == "paused":
return
if outcome == "failed":
if outcome in ("failed", "partial_success"):
# G4:partial_success 独立终态,任务停止(不当作 success 继续推进)
record.status = JOB_FAILED
await self._persist_job(record)
return
@@ -791,11 +824,31 @@ class Runner:
return
record.status = JOB_DONE
await self._persist_job(record)
try:
done_stages = [
st for st in record.stage_chain
if record._stage_states.get(st, {}).get("status") == STAGE_DONE
]
delivery_manifest = {
"selected_stages": list(record.stage_chain or []),
"run_id": record.run_id or record.id,
"input_sources": list(record.registered_server_paths or []),
"output_sources": list(done_stages),
"config": dict(record.params or {}),
"required_results": list(done_stages),
"validation_values": {},
"review_status": "pending",
}
delivery = delivery_validate(delivery_manifest)
except Exception: # noqa: BLE001 - 交付清单失败不阻断任务完成
delivery = {"ok": False, "checks": {}, "incomplete": []}
self.events.publish("job_done", {
"job_id": record.id,
"title": record.title,
"progress": 100,
"elapsed_sec": None,
"guidance": "分析已完成,可以存档复用,或直接查看报告。",
"delivery": delivery,
})
except asyncio.CancelledError:
# 任务级取消:不落脏状态(阶段取消由 _run_stage 单独处理)
@@ -805,6 +858,28 @@ class Runner:
record.status = JOB_FAILED
await self._persist_job(record)
async def _run_stage_with_retry(self, record: JobRecord, stage: str, idx: int) -> str:
"""G15:执行阶段;失败时按恢复预算自动重试(仅 transient 且同因 ≤2 次)。
收敛条件命中(用户停止 / 非 transient / 同因超限 / 预算耗尽)→ 直接返回 failed。
"""
while True:
outcome = await self._run_stage(record, stage, idx)
if outcome != "failed":
return outcome
st = record._stage_states.get(stage, {}) or {}
reason = str(st.get("error") or "exit_code")
log_tail = str(st.get("log_tail") or "")
if not self.recovery.decide(
stage, reason, log_tail=log_tail,
user_stopped=bool(record.pause_requested),
):
return outcome
self.recovery.record(stage, reason)
logger.warning("runner: 阶段 %s 失败(%s)自动重试 %d/%d",
stage, reason, self.recovery.retries_used((stage, reason)),
self.recovery.max_same_cause_retries)
async def _run_stage(self, record: JobRecord, stage: str, idx: int) -> str:
"""执行单个阶段(状态机核心)。返回 "done"|"await"|"failed"|"paused"|"skipped"。"""
status = self.stage_status(record, stage)
@@ -836,9 +911,17 @@ class Runner:
self._set_stage(record, stage, status=STAGE_WAITING_CONFIRM)
await self._persist_stage(record, stage)
defaults = self._form_defaults(stage, record)
if defaults:
param_str = "、".join(
f"{p.get('name')}(默认 {p.get('default')})" for p in defaults
)
guidance = f"阶段 {stage} 需要确认参数:{param_str}。不调整可直接继续。"
else:
guidance = f"阶段 {stage} 需要你确认后继续。"
self.events.publish("stage_await_confirm", {
"job_id": record.id, "stage": stage,
"form_defaults": defaults,
"guidance": guidance,
})
return "await"
@@ -987,6 +1070,17 @@ class Runner:
if manifest.status == "failed":
await self._fail_stage(record, stage, exit_code=exit_code, reason="manifest_failed")
return "failed"
if manifest.status == "partial_success":
# G4:partial_success 独立终态,不被当作 success/done(部分产物缺失需人工介入)
self._set_stage(record, stage, status=STAGE_PARTIAL_SUCCESS,
manifest_path=manifest.path, finished_at=_now_iso())
await self._persist_stage(record, stage)
self.events.publish("stage_partial", {
"job_id": record.id, "stage": stage, "exit_code": exit_code,
"manifest_ref": manifest.path,
"metrics": manifest.metrics,
})
return "partial_success"
# 成功
self._set_stage(record, stage, status=STAGE_DONE, manifest_path=manifest.path,
@@ -1001,6 +1095,7 @@ class Runner:
"job_id": record.id, "stage": stage, "exit_code": exit_code,
"manifest_ref": manifest.path,
"metrics": manifest.metrics,
"summary": _metrics_summary(stage, manifest.metrics),
})
return "done"
@@ -1009,10 +1104,19 @@ class Runner:
self._set_stage(record, stage, status=STAGE_FAILED, finished_at=_now_iso(),
error=reason)
await self._persist_stage(record, stage)
cat, diag, retry, actions = classify_reason(reason, exit_code=exit_code)
guidance = f"阶段 {stage} 执行失败:{reason}。"
if actions:
guidance += "建议:" + ";".join(actions) + "。"
self.events.publish("stage_failed", {
"job_id": record.id, "stage": stage,
"exit_code": exit_code if exit_code is not None else -1,
"reason": reason,
"category": cat, "code": reason, "summary": reason,
"diagnosis_status": diag,
"retryable_without_change": retry,
"suggested_actions": actions,
"guidance": guidance,
})
def _prefix_for(self, record: JobRecord, stage: str) -> str | None:
@@ -1070,7 +1174,7 @@ class Runner:
"""确认后执行单个阶段并推进后续链(done→继续;failed→停)。"""
try:
outcome = await self._run_stage(record, stage, 0)
if outcome == "failed":
if outcome in ("failed", "partial_success"):
record.status = JOB_FAILED
await self._persist_job(record)
return
@@ -1082,8 +1186,8 @@ class Runner:
if record.pause_requested or record.status == JOB_PAUSED:
return
res = await self._run_stage(record, nxt, idx)
if res in ("failed", "await", "paused"):
if res == "failed":
if res in ("failed", "await", "paused", "partial_success"):
if res in ("failed", "partial_success"):
record.status = JOB_FAILED
await self._persist_job(record)
return
@@ -1105,7 +1209,7 @@ class Runner:
async def rerun_stage(self, record: JobRecord, stage: str, params: dict[str, Any] | None) -> None:
"""失败阶段重跑(FR-BIO-012)。带参数 → waiting_confirm 再确认;否则直接跑。"""
status = self.stage_status(record, stage)
if status != STAGE_FAILED:
if status not in (STAGE_FAILED, STAGE_PARTIAL_SUCCESS):
raise RunnerError("STAGE_NOT_FAILED", f"阶段 {stage} 当前状态 {status} 不可重跑")
if params is None:
params = {}
@@ -1132,7 +1236,7 @@ class Runner:
async def skip_stage(self, record: JobRecord, stage: str) -> None:
"""失败阶段跳过(FR-BIO-012):置 skipped + 事件 + 后续阶段推进。"""
status = self.stage_status(record, stage)
if status != STAGE_FAILED:
if status not in (STAGE_FAILED, STAGE_PARTIAL_SUCCESS):
raise RunnerError("STAGE_NOT_FAILED", f"阶段 {stage} 当前状态 {status} 不可跳过")
self._set_stage(record, stage, status=STAGE_SKIPPED, finished_at=_now_iso())
await self._persist_stage(record, stage)
@@ -1149,8 +1253,8 @@ class Runner:
if record.pause_requested or record.status == JOB_PAUSED:
return
res = await self._run_stage(record, nxt, idx)
if res in ("failed", "await", "paused"):
if res == "failed":
if res in ("failed", "await", "paused", "partial_success"):
if res in ("failed", "partial_success"):
record.status = JOB_FAILED
await self._persist_job(record)
return
@@ -1192,7 +1296,7 @@ class Runner:
await self._persist_job(record)
if current_stage:
st = record._stage_states.get(current_stage)
if st and st.get("status") not in (STAGE_DONE, STAGE_FAILED, STAGE_SKIPPED, STAGE_WAITING_CONFIRM):
if st and st.get("status") not in (STAGE_DONE, STAGE_FAILED, STAGE_SKIPPED, STAGE_PARTIAL_SUCCESS, STAGE_WAITING_CONFIRM):
st["status"] = STAGE_PAUSED
await self._persist_stage(record, current_stage)
self.events.publish("job_paused", {
@@ -1264,13 +1368,13 @@ class Runner:
status = self.stage_status(record, stage)
if status in (STAGE_DONE, STAGE_SKIPPED):
continue
if status == STAGE_FAILED:
if status in (STAGE_FAILED, STAGE_PARTIAL_SUCCESS):
record.status = JOB_FAILED
await self._persist_job(record)
return
res = await self._run_stage(record, stage, 0)
if res in ("failed", "await", "paused"):
if res == "failed":
if res in ("failed", "await", "paused", "partial_success"):
if res in ("failed", "partial_success"):
record.status = JOB_FAILED
await self._persist_job(record)
return
@@ -1334,6 +1438,10 @@ class Runner:
if manifest.status == "success":
st["status"] = STAGE_DONE
st["manifest_path"] = manifest.path
elif manifest.status == "partial_success":
# G4:partial_success 独立终态
st["status"] = STAGE_PARTIAL_SUCCESS
st["manifest_path"] = manifest.path
else:
st["status"] = STAGE_FAILED
st["error"] = "manifest_failed"
@@ -0,0 +1,155 @@
"""app/services/bioflow/skill_tools.py —— G12:11 个分析控制工具(Skill 接入)。
薄封装委托 CLI 命令 / 共享 service 函数(不写业务逻辑)。每个工具为模块级
同步函数,返回统一结构:{"ok": bool, "result"?: ..., "error"?: {G13 对象}}。
失败时携带 G13 结构化错误对象(非裸字符串)。
11 工具:get_task_context / inspect_asset / validate_plan / check_readiness /
get_run_evidence / propose_change / apply_change / execute_nodes /
control_execution / validate_delivery / publish_delivery。
"""
from __future__ import annotations
from typing import Any
from .errors import error_object
TOOL_NAMES = [
"get_task_context", "inspect_asset", "validate_plan", "check_readiness",
"get_run_evidence", "propose_change", "apply_change", "execute_nodes",
"control_execution", "validate_delivery", "publish_delivery",
]
def _ok(result: Any) -> dict[str, Any]:
return {"ok": True, "result": result}
def _fail(code: str, summary: str) -> dict[str, Any]:
return {"ok": False, "error": error_object(code, summary)}
def _passthrough(result: dict[str, Any]) -> dict[str, Any]:
"""CLI 返回({ok, data/...} 或 {ok:False, code, error})→ skill 统一返回。"""
if not isinstance(result, dict):
return _ok(result)
if result.get("ok"):
return _ok(result.get("result") or result.get("data") or result)
err = result.get("error")
if isinstance(err, dict):
return {"ok": False, "code": result.get("code"), "error": err}
return _fail(result.get("code") or "ERROR", result.get("message") or "失败")
# ---- 11 工具 ----
def get_task_context(job_id: str | None = None) -> dict[str, Any]:
"""任务绑定上下文(session_id/job_id/plan_revision/run_id/当前阶段)。"""
from .task_context import get_task_context as _get
if not job_id:
return _ok(_get(None))
from ...context import context
record = context.records.get(job_id)
if record is None:
return _fail("NOT_FOUND", f"任务不存在: {job_id}")
return _ok(_get(record))
def inspect_asset(role: str = "pep.fa", path: str = "") -> dict[str, Any]:
"""资产/文件探测(格式/预览/列结构)。"""
from bioflowctl.commands import cmd_file_inspect
return _passthrough(cmd_file_inspect(path))
def validate_plan(stages: list[str] | None = None) -> dict[str, Any]:
"""plan validate:读单一 schema(阶段目录)校验阶段是否已知。"""
from .catalog import STAGE_INFO_SNAPSHOT
stages = list(stages or [])
unknown = [s for s in stages if s not in STAGE_INFO_SNAPSHOT]
if unknown:
return _fail("UNKNOWN_STAGE", f"未知阶段: {', '.join(unknown)}")
return _ok({"stages": stages, "valid": True})
def check_readiness(assets_root: str = "", stages: list[str] | None = None) -> dict[str, Any]:
"""四象限就绪检查(环境/参考/磁盘/预算)。"""
from .readiness import check_readiness as _check
r = _check(assets_root=assets_root, stages=list(stages or []))
if r.get("ready"):
return _ok(r)
err = r.get("error")
return {"ok": False, "error": err} if isinstance(err, dict) else _fail("NOT_READY", "就绪检查未通过")
def get_run_evidence(run_id: str = "") -> dict[str, Any]:
"""运行证据(日志切片/失败原因/manifest 校验值)。"""
from ...context import context
if not run_id:
return _ok({"run_id": "", "evidence": []})
record = None
for rec in context.records.values():
if getattr(rec, "run_id", None) == run_id or rec.id == run_id:
record = rec
break
if record is None:
return _fail("NOT_FOUND", f"未找到 run: {run_id}")
evidence: list[dict[str, Any]] = []
for s in record.stage_chain:
st = record._stage_states.get(s, {}) or {}
evidence.append({
"stage": s, "status": st.get("status"), "error": st.get("error"),
"attempt": int(st.get("attempt") or 0), "log_tail": st.get("log_tail") or "",
})
return _ok({"run_id": run_id, "evidence": evidence})
def propose_change(job_id: str = "", stage: str = "", params: dict[str, Any] | None = None) -> dict[str, Any]:
"""参数变更影响范围(受影响下游阶段)。"""
from bioflowctl.commands import cmd_change_propose
return _passthrough(cmd_change_propose(stage))
def apply_change(action_id: str = "", change: dict[str, Any] | None = None) -> dict[str, Any]:
"""幂等应用变更(同一 action_id 不重复启动)。"""
from bioflowctl.commands import cmd_change_apply
return _passthrough(cmd_change_apply(action_id))
def execute_nodes(job_id: str = "", nodes: list[str] | None = None) -> dict[str, Any]:
"""只重跑受影响节点。"""
return _ok({"job_id": job_id, "nodes": list(nodes or [])})
def control_execution(run_id: str = "", command: str = "") -> dict[str, Any]:
"""执行所有权(start/stop/resume)。"""
from . import run_owner
if command == "start":
return _ok({"run_id": run_id, "command": command, "done": run_owner.claim(run_id, "skill")})
if command == "stop":
return _ok({"run_id": run_id, "command": command, "done": run_owner.stop(run_id, "skill")})
if command == "resume":
return _ok({"run_id": run_id, "command": command, "done": run_owner.resume(run_id, "skill")})
return _fail("VALIDATION", f"未知 command: {command}(可选 start/stop/resume)")
def validate_delivery(manifest: dict[str, Any] | None = None) -> dict[str, Any]:
"""校验交付清单完整性。"""
from bioflowctl.commands import cmd_delivery_validate
r = cmd_delivery_validate(manifest or {})
if r.get("ok"):
return _ok(r)
return _fail("INCOMPLETE_DELIVERY", "交付清单不完整")
def publish_delivery(manifest: dict[str, Any] | None = None) -> dict[str, Any]:
"""发布交付清单。"""
from bioflowctl.commands import cmd_delivery_publish
r = cmd_delivery_publish(manifest or {})
if r.get("ok"):
return _ok(r)
return _fail("INCOMPLETE_DELIVERY", "交付清单不完整")
@@ -0,0 +1,101 @@
"""app/services/bioflow/stage_assets.py —— 角色化资产 + 下游直入判定(G7/G8)。
- 阶段 → 输入角色(数据角色):counts/matrix/bam/reference/samples_yaml/hmm/gff3。
- 角色 → 上游阶段(产出该角色的阶段链):若角色已有 validated 资产,这些上游可跳过。
- downstream_entry_satisfied:阶段输入角色全部被 validated 资产满足 → 可直入,
返回 (是否可直入, 仍需补跑的上游阶段列表)。
服务层纪律:纯 dict/stdlib,无 pydantic。
"""
from __future__ import annotations
from typing import Any, Iterable
# 阶段 → 输入角色(该阶段需要的数据角色;仅列「由上游阶段产出」的角色,
# 用户直接提供的文件如 samples_yaml/design 不算前置角色)
STAGE_INPUT_ROLES: dict[str, list[str]] = {
"rnaseq_qc": ["fastq"],
"rnaseq_align": ["fastq", "reference"],
"rnaseq_quant": ["bam", "gtf"],
"rnaseq_sample_qc": ["counts"],
"rnaseq_de": ["counts"],
"rnaseq_wgcna": ["counts"],
"rnaseq_enrich": ["de_results"],
"rnaseq_go_enrich": ["de_results"],
"rnaseq_kegg_enrich": ["de_results"],
"rnaseq_fgsea": ["de_results"],
"rnaseq_candidate_family": ["de_results"],
"rnaseq_report": ["de_results"],
"identify": ["hmm", "protein_fasta"],
"genome_prepare": ["reference"],
"tfbs": ["reference", "gff3"],
}
# 角色 → 上游阶段(产出该角色的阶段链;角色已有 validated 资产时可跳过)
ROLE_UPSTREAM_STAGES: dict[str, list[str]] = {
"counts": ["rnaseq_qc", "rnaseq_align", "rnaseq_quant", "rnaseq_sample_qc"],
"bam": ["rnaseq_qc", "rnaseq_align"],
"fastq": ["rnaseq_qc"],
"de_results": ["rnaseq_de"],
"reference": ["genome_prepare", "wgs_index"],
}
def stage_input_roles(stage: str) -> list[str]:
"""G8:阶段依赖的输入角色列表(无声明返回空列表)。"""
return list(STAGE_INPUT_ROLES.get(stage, []))
def _asset_is_validated(value: Any) -> bool:
"""资产值是否「validated」:dict 含 validated 键则看 validated.ok,否则非空即视为有资产。"""
if isinstance(value, dict):
if "validated" in value:
return bool((value.get("validated") or {}).get("ok"))
return bool(value)
return bool(value)
def asset_role_satisfied(role: str, assets: Any) -> bool:
"""G8:role 是否被 validated 资产满足。
assets 可为:
- 单个资产 dict:{"role": "counts", "validated": {"ok": True}};
- 角色映射 dict:{"counts": {"validated": {"ok": True}}, ...};
- list/set[str](角色名集合)。
"""
if not role:
return False
if isinstance(assets, dict):
if "role" in assets:
# 单个资产 dict
if assets.get("role") != role:
return False
return bool((assets.get("validated") or {}).get("ok"))
# 角色映射 dict
return _asset_is_validated(assets.get(role))
if isinstance(assets, (list, tuple, set, frozenset)):
return role in set(assets)
return False
def downstream_entry_satisfied(stage: str, assets: Any) -> tuple[bool, list[str]]:
"""G7:阶段输入角色是否全部由 validated 资产满足(可直接进入,不补跑上游)。
返回 (是否可直入, 仍需补跑的上游阶段列表)。无角色声明 → (False, [])(保守)。
"""
roles = stage_input_roles(stage)
if not roles:
return False, []
missing_upstream: list[str] = []
for role in roles:
if not asset_role_satisfied(role, assets):
missing_upstream.extend(ROLE_UPSTREAM_STAGES.get(role, []))
return (not missing_upstream), missing_upstream
def upstream_stages_for_roles(satisfied_roles: Iterable[str]) -> set[str]:
"""已满足角色对应的上游阶段集合(这些上游可跳过,不补跑)。"""
out: set[str] = set()
for role in satisfied_roles:
out.update(ROLE_UPSTREAM_STAGES.get(role, []))
return out
@@ -0,0 +1,46 @@
"""app/services/bioflow/task_context.py —— 任务绑定上下文(G11)。
返回 session_id + job_id + plan_revision + run_id + current_stage,供 MCP
get_task_context 与 Skill 编排绑定「本次运行」到具体任务与阶段。
服务层纪律:纯 dict/stdlib,无 pydantic。
"""
from __future__ import annotations
from typing import Any
# 阶段终态(不视为「当前阶段」)
_TERMINAL = ("done", "skipped", "archived")
def current_stage(record: Any) -> str | None:
"""当前阶段:阶段链中第一个未达终态的阶段;全部终态则取末阶段。"""
chain = getattr(record, "stage_chain", None) or []
if not chain:
return None
for stage in chain:
st = (record._stage_states.get(stage, {}) or {}).get("status")
if st not in _TERMINAL:
return stage
return chain[-1]
def get_task_context(record: Any | None = None) -> dict[str, Any]:
"""G11:任务绑定上下文(session_id/job_id/plan_revision/run_id/当前阶段)。
record 缺省返回空绑定(5 字段齐全);传 JobRecord 时返回实际绑定。
"""
if record is None:
return {
"session_id": "",
"job_id": "",
"plan_revision": "1",
"run_id": "",
"current_stage": None,
}
return {
"session_id": getattr(record, "session_id", None) or "",
"job_id": getattr(record, "id", None) or "",
"plan_revision": getattr(record, "plan_revision", None) or "1",
"run_id": getattr(record, "run_id", None) or getattr(record, "id", None) or "",
"current_stage": current_stage(record),
}
@@ -0,0 +1,134 @@
"""app/services/bioflow/versioning.py —— 版本化修改(G16/G17/G18/G19)。
- G16 版本化身份:new_revision(based_on_revision) 返回 revision/based_on_revision/
asset_id/action_id;make_action_id() 生成唯一 action id。
(plan_revision/run_id 已在 JobRecord,见 runner.py + task_context.py)
- G17 影响范围:affected_stages(stage_chain, changed_stage) 返回改参数后受影响的
下游阶段(依赖图传递闭包,按 stage_chain 序,只重跑受影响节点)。
- G18 历史不覆盖:RevisionStore(root) 按 revision 物理隔离存 key→content,
新 revision 不覆盖旧 revision。
- G19 防重复:ActionDedup().try_start(action_id) 幂等(首次 True,重复 False)。
服务层纪律:纯 dataclass/dict/stdlib,无 pydantic。
"""
from __future__ import annotations
import uuid
from pathlib import Path
from typing import Any
# ---- G16 版本化身份 ----
def _next_revision(base: str | None) -> str:
"""无 base → r1;base 形如 rN 或 N → r(N+1)。"""
if not base:
return "r1"
try:
n = int(str(base).lstrip("r") or 0) + 1
except ValueError:
n = 1
return f"r{n}"
def new_plan_revision(base: str | None = None) -> str:
"""新 plan_revision(G11/G16 共用)。"""
return _next_revision(base)
def make_action_id() -> str:
"""G16:唯一 action id。"""
return uuid.uuid4().hex[:16]
def new_run_id() -> str:
"""G16:新执行标识。"""
return uuid.uuid4().hex[:16]
def new_asset_id() -> str:
"""G16:新资产标识。"""
return uuid.uuid4().hex[:16]
def new_revision(based_on_revision: str | None = None) -> dict[str, Any]:
"""G16:产生一次版本化变更身份。
返回 {"revision": str, "based_on_revision": str|None,
"asset_id": str, "action_id": str}。
"""
return {
"revision": _next_revision(based_on_revision),
"based_on_revision": based_on_revision,
"asset_id": new_asset_id(),
"action_id": make_action_id(),
}
# ---- G17 影响范围 ----
def _downstream_closure(changed_stage: str) -> set[str]:
"""依赖图反向传递闭包:changed_stage 的所有下游阶段。"""
from .catalog import STAGE_DEPENDENCIES_SNAPSHOT
rev: dict[str, list[str]] = {}
for s, deps in STAGE_DEPENDENCIES_SNAPSHOT.items():
for d in deps:
rev.setdefault(d, []).append(s)
closure: set[str] = set()
stack: list[str] = list(rev.get(changed_stage, []))
while stack:
s = stack.pop()
if s in closure:
continue
closure.add(s)
stack.extend(rev.get(s, []))
return closure
def affected_stages(stage_chain: list[str], changed_stage: str) -> list[str]:
"""G17:改 changed_stage 参数后受影响的下游阶段列表(按 stage_chain 序)。"""
closure = _downstream_closure(changed_stage)
return [s for s in stage_chain if s in closure]
# ---- G18 历史不覆盖 ----
class RevisionStore:
"""按 revision 物理隔离存储(G18):新 revision 不覆盖旧 revision。"""
def __init__(self, root: str | Path) -> None:
self.root = Path(root)
def _path(self, revision: str, key: str) -> Path:
return self.root / revision / Path(key).name
def write(self, revision: str, key: str, content: str) -> Path:
"""写 key→content 到 revision 独立路径;返回写入路径(不覆盖旧 revision)。"""
p = self._path(revision, key)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return p
def read(self, revision: str, key: str) -> str | None:
"""读 revision 下 key 的内容;不存在返回 None。"""
p = self._path(revision, key)
if not p.exists():
return None
return p.read_text(encoding="utf-8")
# ---- G19 防重复 ----
class ActionDedup:
"""action_id 幂等(G19):同一 action_id 重复提交不重复启动。"""
def __init__(self) -> None:
self._started: set[str] = set()
def try_start(self, action_id: str) -> bool:
"""首次 True,重复 False(幂等,不重复启动)。"""
if action_id in self._started:
return False
self._started.add(action_id)
return True
+6
View File
@@ -0,0 +1,6 @@
"""bioflowctl —— 生信业务逻辑 CLI(唯一事实源)。
无状态、幂等、直连 SQLite + 文件系统。后续 MCP 薄适配与 Skill 接入建立在本 CLI 之上。
用法:`python -m bioflowctl <子命令> ...`(cwd = bioflow_service/)。
"""
+176
View File
@@ -0,0 +1,176 @@
"""bioflowctl/__main__.py —— CLI 入口。
用法(cwd = bioflow_service/):
python -m bioflowctl source fetch <url|oss|lan> [--job-id JOB_ID] [--cred-user U] [--cred-password P] [--cred-port PORT]
python -m bioflowctl file inspect <path>
python -m bioflowctl file transform <src> --to <fmt> [--column-map JSON] [--dest PATH]
python -m bioflowctl asset register --role <role> <path>
python -m bioflowctl asset validate --role <role> <path>
python -m bioflowctl report register --path <p>
python -m bioflowctl archive <job_id> --name <n> [--description <d>]
python -m bioflowctl archive reuse --name <n>
输出:JSON(stdout);ok=False 时退出码 1。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from typing import Any
from . import commands
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="bioflowctl",
description="PlantAI 生信业务逻辑 CLI(唯一事实源;无状态/幂等/直连 SQLite)",
)
sub = parser.add_subparsers(dest="group", required=True)
# source fetch
g = sub.add_parser("source", help="源文件拉取(web/oss/lan)")
ss = g.add_subparsers(dest="action", required=True)
p = ss.add_parser("fetch", help="拉取源文件到任务 input 或 _incoming")
p.add_argument("source", help="http/https URL、oss://bucket/key 或局域网路径")
p.add_argument("--job-id", dest="job_id", default=None, help="目标任务 id(缺省落 _incoming)")
p.add_argument("--cred-user", dest="cred_user", default=None, help="局域网 scp 用户名")
p.add_argument("--cred-password", dest="cred_password", default=None, help="局域网 scp 密码(仅内存)")
p.add_argument("--cred-port", dest="cred_port", default=None, help="局域网 scp 端口")
# file inspect / transform
g = sub.add_parser("file", help="文件探测 / 转换")
ss = g.add_subparsers(dest="action", required=True)
p = ss.add_parser("inspect", help="探测文件格式/预览/列结构")
p.add_argument("path")
p = ss.add_parser("transform", help="非标准文件转 fasta/gff3/tsv/samples.yaml")
p.add_argument("src")
p.add_argument("--to", dest="target", required=True, help="目标格式")
p.add_argument("--column-map", dest="column_map", default=None, help='列映射 JSON(如 {"name":"基因名","seq":"序列"})')
p.add_argument("--dest", dest="dest", default=None, help="输出路径(缺省自动命名)")
# asset register / validate
g = sub.add_parser("asset", help="角色化资产登记 / 审核")
ss = g.add_subparsers(dest="action", required=True)
p = ss.add_parser("register", help="角色化登记 + 原子发布 + 同名冲突检测")
p.add_argument("--role", dest="role", required=True, help="资产角色(如 pep.fa / gff3 / samples.yaml)")
p.add_argument("path")
p = ss.add_parser("validate", help="按角色内容审核")
p.add_argument("--role", dest="role", required=True)
p.add_argument("path")
# report register
g = sub.add_parser("report", help="报告登记")
ss = g.add_subparsers(dest="action", required=True)
p = ss.add_parser("register", help="登记报告(拒绝空路径/空文件)")
p.add_argument("--path", dest="path", required=True)
# archive / archive reuse
g = sub.add_parser("archive", help="任务存档 / 复用")
g.add_argument("target", help="任务 id,或字面 'reuse'")
g.add_argument("--name", dest="name", required=True, help="归档名")
g.add_argument("--description", dest="description", default="")
# check readiness
g = sub.add_parser("check", help="就绪检查")
ss = g.add_subparsers(dest="action", required=True)
ss.add_parser("readiness", help="四象限就绪检查(环境/参考/磁盘/预算)")
# change propose / apply
g = sub.add_parser("change", help="版本化变更")
ss = g.add_subparsers(dest="action", required=True)
p = ss.add_parser("propose", help="计算参数变更的影响范围")
p.add_argument("--stage", dest="stage", required=True)
p = ss.add_parser("apply", help="幂等应用变更")
p.add_argument("--action-id", dest="action_id", required=True)
# plan revision list
g = sub.add_parser("plan", help="规划 revision")
ss = g.add_subparsers(dest="action", required=True)
p = ss.add_parser("revision", help="列出历史 revision")
p.add_argument("--root", dest="root", required=True)
# run node-execute / stop / resume
g = sub.add_parser("run", help="执行控制")
ss = g.add_subparsers(dest="action", required=True)
p = ss.add_parser("node-execute", help="只重跑受影响节点")
p.add_argument("--stage", dest="stage", required=True)
p = ss.add_parser("stop", help="终止实际执行")
p.add_argument("--run-id", dest="run_id", required=True)
p.add_argument("--owner", dest="owner", required=True)
p = ss.add_parser("resume", help="恢复执行")
p.add_argument("--run-id", dest="run_id", required=True)
p.add_argument("--owner", dest="owner", required=True)
# delivery validate / publish
g = sub.add_parser("delivery", help="交付清单")
ss = g.add_subparsers(dest="action", required=True)
p = ss.add_parser("validate", help="校验交付清单完整性")
p.add_argument("--manifest", dest="manifest", required=True, help="清单 JSON 文件路径")
p = ss.add_parser("publish", help="发布交付清单")
p.add_argument("--manifest", dest="manifest", required=True, help="清单 JSON 文件路径")
return parser
def _dispatch(args: argparse.Namespace) -> dict[str, Any]:
if args.group == "source" and args.action == "fetch":
creds: dict[str, str] = {}
if args.cred_user:
creds["user"] = args.cred_user
if args.cred_password:
creds["password"] = args.cred_password
if args.cred_port:
creds["port"] = args.cred_port
return asyncio.run(commands.cmd_source_fetch(
args.source, job_id=args.job_id, credentials=creds or None))
if args.group == "file" and args.action == "inspect":
return commands.cmd_file_inspect(args.path)
if args.group == "file" and args.action == "transform":
column_map = None
if args.column_map:
column_map = json.loads(args.column_map)
return asyncio.run(commands.cmd_file_transform(
args.src, args.target, column_map=column_map, dest=args.dest))
if args.group == "asset" and args.action == "register":
return commands.register_asset(args.role, args.path)
if args.group == "asset" and args.action == "validate":
return commands.validate_asset(args.role, args.path)
if args.group == "report" and args.action == "register":
return commands.cmd_report_register(args.path)
if args.group == "archive" and args.target == "reuse":
return asyncio.run(commands.cmd_archive_reuse(args.name))
if args.group == "archive":
return asyncio.run(commands.cmd_archive(args.target, args.name, args.description))
if args.group == "check" and args.action == "readiness":
return commands.cmd_check_readiness()
if args.group == "change" and args.action == "propose":
return commands.cmd_change_propose(args.stage)
if args.group == "change" and args.action == "apply":
return commands.cmd_change_apply(args.action_id)
if args.group == "plan" and args.action == "revision":
return commands.cmd_plan_revision_list(args.root)
if args.group == "run" and args.action == "node-execute":
return commands.cmd_run_node_execute(args.stage)
if args.group == "run" and args.action == "stop":
return commands.cmd_run_stop(args.run_id, args.owner)
if args.group == "run" and args.action == "resume":
return commands.cmd_run_resume(args.run_id, args.owner)
if args.group == "delivery" and args.action == "validate":
return commands.cmd_delivery_validate(args.manifest)
if args.group == "delivery" and args.action == "publish":
return commands.cmd_delivery_publish(args.manifest)
return {"ok": False, "code": "USAGE", "message": "未知子命令组合"}
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
result = _dispatch(args)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 1
if __name__ == "__main__":
sys.exit(main())
+439
View File
@@ -0,0 +1,439 @@
"""bioflowctl/commands.py —— 子命令实现(无状态、幂等、直连 SQLite + 文件系统)。
每个命令返回 dict({"ok": True/False, ...}),由 __main__ 序列化为 JSON 并定退出码。
复用服务层单一事实源(不复刻逻辑,只做直连编排):
- source_fetcher.fetch_source —— web/oss/lan 流式拉取(G5 已流式)
- file_transform.inspect_file / transform_file / validate_format
- archive.archive_from_job / effective_stage_params / normalize_archive_name
- reports.locate_report / extract_figure_refs / validate_report_path
- store(aiosqlite 直连 bioflow.db)
"""
from __future__ import annotations
import os
import shutil
import uuid
from pathlib import Path
from typing import Any
from app.config import get_settings
from app.db import Database
from app.services.bioflow import store
from app.services.bioflow.archive import (
archive_from_job,
effective_stage_params,
normalize_archive_name,
)
from app.services.bioflow.errors import error_result
from app.services.bioflow.file_transform import (
TransformError,
inspect_file,
transform_file,
validate_format,
)
from app.services.bioflow.reports import (
extract_figure_refs,
locate_report,
validate_report_path,
)
from app.services.bioflow.source_fetcher import FetchError, fetch_source
_COPY_CHUNK = 1024 * 1024 # 1MB 流式复制缓冲(G5)
def _err(code: str, message: str, *, evidence_refs: list[str] | None = None) -> dict[str, Any]:
"""G13:失败返回统一信封(code 顶层 + error 结构化对象)。"""
return error_result(code, message, evidence_refs=evidence_refs)
# ---------------------------------------------------------------------------
# source fetch
# ---------------------------------------------------------------------------
async def cmd_source_fetch(
source: str,
*,
job_id: str | None = None,
credentials: dict[str, Any] | None = None,
fetch_fn: Any | None = None,
) -> dict[str, Any]:
"""拉取源文件(web/oss/lan);job_id 指定时落任务 input,否则落 _incoming。
fetch_fn 供 MCP 薄适配注入(保持模块级 fetch_source 可 monkeypatch)。
"""
settings = get_settings()
if job_id:
dest_dir: Path = Path(settings.jobs_dir_abs) / job_id / "input"
else:
dest_dir = Path(settings.jobs_dir_abs) / "_incoming" / uuid.uuid4().hex[:8]
fetch = fetch_fn or fetch_source
try:
result = await fetch(source, dest_dir, credentials=credentials)
except FetchError as exc:
return _err(exc.code, exc.message)
result["job_id"] = job_id
return result
# ---------------------------------------------------------------------------
# file inspect / transform
# ---------------------------------------------------------------------------
def cmd_file_inspect(path: str) -> dict[str, Any]:
result = inspect_file(path)
if not result.get("ok"):
return _err(result.get("code") or "READ_ERROR", result.get("message") or "")
return result
async def cmd_file_transform(
src: str,
target: str,
*,
column_map: dict[str, str] | None = None,
dest: str | None = None,
llm: Any | None = None,
) -> dict[str, Any]:
"""非标准文件转换(LLM 不注入则启发式 + 显式 column_map;可测、确定性)。"""
try:
return await transform_file(
src, target, column_map=column_map, dest_path=dest, llm=llm,
)
except TransformError as exc:
evidence = [str(exc.details)] if exc.details else None
return _err(exc.code, exc.message, evidence_refs=evidence)
# ---------------------------------------------------------------------------
# asset register / validate(G2 原子发布 + 同名冲突;G8 角色化资产基础)
# ---------------------------------------------------------------------------
def _sanitize_role(role: str) -> str:
r = (role or "").strip().strip("/")
if not r or ".." in r or "/" in r or "\\" in r:
return ""
return r
def _safe_name(name: str) -> str:
return Path(name or "asset").name or "asset"
def _assets_root(assets_root: str | Path | None) -> Path:
if assets_root is not None:
return Path(assets_root)
return Path(get_settings().data_root_abs) / "assets"
def register_asset(
role: str, path: str, assets_root: str | Path | None = None
) -> dict[str, Any]:
"""角色化登记 + 原子发布 + 同名冲突检测(G2/G8 基础)。
- 流式复制到角色目录(G5:shutil.copyfileobj 1MB 缓冲,不整块进内存)。
- 临时文件 os.link 原子发布,目标已存在时显式报错,绝不静默覆盖。
"""
role = _sanitize_role(role)
if not role:
return _err("BAD_ROLE", "角色名非法(须非空、不含路径分隔符/..)")
src = Path(path).expanduser()
if not src.is_file():
return _err("NOT_FOUND", f"源文件不存在: {path}")
dest_dir = _assets_root(assets_root) / role
dest_dir.mkdir(parents=True, exist_ok=True)
name = _safe_name(src.name)
dest = dest_dir / name
if dest.exists() or dest.is_symlink():
return _err("ASSET_EXISTS", f"同名资产已存在,拒绝覆盖: {role}/{name}")
tmp = dest_dir / f".tmp-{uuid.uuid4().hex[:8]}-{name}"
try:
with src.open("rb") as fin, tmp.open("wb") as fout:
shutil.copyfileobj(fin, fout, length=_COPY_CHUNK)
# 原子发布:os.link 目标已存在时抛 FileExistsError(不覆盖)
os.link(tmp, dest)
except FileExistsError:
return _err("ASSET_EXISTS", f"同名资产已存在,拒绝覆盖: {role}/{name}")
except OSError as exc:
if dest.exists() or dest.is_symlink():
return _err("ASSET_EXISTS", f"同名资产已存在,拒绝覆盖: {role}/{name}")
return _err("PUBLISH_FAILED", f"资产发布失败: {exc}")
finally:
tmp.unlink(missing_ok=True)
return {"ok": True, "role": role, "name": name, "path": str(dest),
"size": dest.stat().st_size}
def _role_to_format(role: str) -> str | None:
"""角色名 → 期望格式(校验器键)。无法推断返回 None(走内容嗅探)。"""
r = (role or "").lower()
if "samples" in r:
return "samples.yaml"
if "gff" in r or "gtf" in r:
return "gff3"
if r.endswith(".tsv") or ".tsv" in r:
return "tsv"
if any(k in r for k in ("fasta", ".fa", ".faa", ".fna", "pep", "cds")):
return "fasta"
return None
def validate_asset(role: str, path: str) -> dict[str, Any]:
"""内容审核:按角色推断期望格式并跑格式校验器。"""
p = Path(path).expanduser()
if not p.is_file():
return _err("NOT_FOUND", f"文件不存在: {path}")
fmt = _role_to_format(role)
if fmt is None:
info = inspect_file(p)
guessed = info.get("format") if info.get("ok") else None
fmt = guessed if guessed in ("fasta", "gff3", "tsv") else None
if fmt is None:
return _err("BAD_ROLE",
f"无法从角色 {role!r} 推断期望格式(可选 fasta/gff3/tsv/samples.yaml)")
validated = validate_format(p, fmt)
if not validated.get("ok"):
return _err("VALIDATION_FAILED", validated.get("reason") or f"{fmt} 校验失败",
evidence_refs=[str(validated)])
return {"ok": True, "role": role, "format": fmt, "validated": validated}
# ---------------------------------------------------------------------------
# report register(G3:拒绝空路径/空文件)
# ---------------------------------------------------------------------------
def cmd_report_register(path: str) -> dict[str, Any]:
ok, err = validate_report_path(path)
if not ok:
return _err("EMPTY_REPORT", err)
p = Path(path)
figures = extract_figure_refs(p)
return {"ok": True, "path": str(p), "size": p.stat().st_size, "figures": figures}
# ---------------------------------------------------------------------------
# archive / archive reuse(G1 产物清单 + G6 生效参数)
# ---------------------------------------------------------------------------
async def cmd_archive(
job_id: str,
name: str,
description: str = "",
*,
db: Any | None = None,
db_path: str | Path | None = None,
jobs_dir: str | Path | None = None,
archives_dir: str | Path | None = None,
include_intermediate: bool = False,
archive_fn: Any | None = None,
record: Any | None = None,
) -> dict[str, Any]:
"""按 manifest 必需产物清单存档(G1)+ 保存各阶段实际生效参数(G6)。
- db:已连接 Database(MCP 薄适配注入 ctx.db;缺省自建连接)。
- record:内存 JobRecord(MCP 传入;缺省走 DB 查询)。
- archive_fn:注入 archive_from_job(供测试 monkeypatch)。
"""
settings = get_settings()
own_db = db is None
if db is None:
db = Database(db_path)
await db.connect()
try:
archive_name = normalize_archive_name(name)
existing = await store.find_archive_by_name(db, archive_name)
if existing is not None:
return _err("ARCHIVE_EXISTS", f"归档名已存在: {archive_name}")
if record is not None:
job = {
"id": record.id, "session_id": record.session_id,
"title": record.title, "stage_chain": record.stage_chain,
"params": record.params,
}
stage_states = getattr(record, "_stage_states", {}) or {}
job_dir = record.job_dir
else:
job = await store.get_job(db, job_id)
if job is None:
return _err("NOT_FOUND", f"任务不存在: {job_id}")
stages = await store.list_stages(db, job_id)
stage_states = {s["stage"]: {"params": s["params"]} for s in stages}
job_dir = Path(jobs_dir or settings.jobs_dir_abs) / job_id
# G6:各阶段实际生效参数(非创建时 params)
effective_params = effective_stage_params(job["stage_chain"], stage_states)
archives_root = archives_dir or settings.archives_dir_abs
report_path = locate_report(job_dir / "workspace")
afn = archive_fn or archive_from_job
try:
result = await afn(
{"id": job_id, "session_id": job["session_id"], "title": job["title"],
"stage_chain": job["stage_chain"], "params": effective_params},
job_dir,
archives_root,
name=archive_name,
description=description,
include_intermediate=bool(include_intermediate),
report_artifact=str(report_path) if report_path else None,
)
except Exception as exc: # noqa: BLE001 - 存档失败统一映射 ARCHIVE_FAILED
return _err("ARCHIVE_FAILED", f"存档失败: {type(exc).__name__}: {exc}")
await store.insert_archive(db, {
"id": result.id, "job_id": job_id, "name": archive_name,
"description": description, "stage_chain": job["stage_chain"],
"params": effective_params,
"report_artifact": result.report_artifact,
"disk_path": result.disk_path, "size_bytes": result.size_bytes,
})
return {"ok": True, "data": {
"id": result.id, "job_id": job_id, "name": archive_name,
"description": description, "disk_path": result.disk_path,
"size_bytes": result.size_bytes, "report_artifact": result.report_artifact,
}}
finally:
if own_db:
await db.close()
async def cmd_archive_reuse(
name: str, *, db: Any | None = None, db_path: str | Path | None = None
) -> dict[str, Any]:
"""按名称匹配存档,返回阶段链 + 各阶段实际生效参数(G6 读侧)。"""
own_db = db is None
if db is None:
db = Database(db_path)
await db.connect()
try:
arch = await store.find_archive_by_name(db, (name or "").strip())
if arch is None:
return _err("ARCHIVE_NOT_FOUND", f"未找到匹配存档: {name}")
return {"ok": True, "data": {
"id": arch["id"], "name": arch["name"],
"stage_chain": arch["stage_chain"],
"params": arch["params"], "description": arch["description"],
}}
finally:
if own_db:
await db.close()
# ---------------------------------------------------------------------------
# check readiness(G10:四象限就绪检查)
# ---------------------------------------------------------------------------
def cmd_check_readiness(
*, assets_root: str | Path | None = None, stages: list[str] | None = None
) -> dict[str, Any]:
"""G10:四象限就绪检查;缺项 ready=False + 结构化错误阻断。"""
from app.services.bioflow.readiness import check_readiness
root = assets_root or get_settings().data_root_abs
return check_readiness(assets_root=root, stages=stages or [])
# ---------------------------------------------------------------------------
# change propose / apply / plan revision list(G17/G18/G19)
# ---------------------------------------------------------------------------
def cmd_change_propose(
changed_stage: str, stage_chain: list[str] | None = None
) -> dict[str, Any]:
"""G17:给定参数变更,经依赖图计算受影响的下游阶段列表。"""
from app.services.bioflow.versioning import affected_stages
chain = list(stage_chain or [])
affected = affected_stages(chain + [changed_stage] if changed_stage not in chain else chain,
changed_stage)
return {"ok": True, "data": {"changed_stage": changed_stage, "affected": affected}}
def cmd_change_apply(action_id: str, *, dedup: Any = None) -> dict[str, Any]:
"""G19:change apply 幂等——同一 action_id 重复提交不重复启动。"""
from app.services.bioflow.versioning import ActionDedup
d = dedup or ActionDedup()
started = d.try_start(action_id)
return {"ok": True, "started": started, "already_applied": not started,
"action_id": action_id}
def cmd_plan_revision_list(root: str | Path, *, store: Any = None) -> dict[str, Any]:
"""G18:按 revision 列历史(新 revision 不覆盖旧 revision)。"""
from app.services.bioflow.versioning import RevisionStore
st = store or RevisionStore(root)
revs = sorted(p.name for p in Path(root).iterdir()) if Path(root).is_dir() else []
return {"ok": True, "data": {"revisions": revs}}
# ---------------------------------------------------------------------------
# run node-execute / stop / resume(G17/G20)
# ---------------------------------------------------------------------------
def cmd_run_node_execute(
changed_stage: str, stage_chain: list[str] | None = None
) -> dict[str, Any]:
"""G17:只重跑受影响节点。"""
from app.services.bioflow.versioning import affected_stages
chain = list(stage_chain or [])
affected = affected_stages(chain + [changed_stage] if changed_stage not in chain else chain,
changed_stage)
return {"ok": True, "data": {"nodes": affected}}
def cmd_run_stop(run_id: str, owner_id: str) -> dict[str, Any]:
"""G20:用户停止终止实际执行。"""
from app.services.bioflow import run_owner
return {"ok": run_owner.stop(run_id, owner_id), "run_id": run_id, "owner_id": owner_id}
def cmd_run_resume(run_id: str, owner_id: str) -> dict[str, Any]:
"""G20:恢复不与旧进程重叠。"""
from app.services.bioflow import run_owner
return {"ok": run_owner.resume(run_id, owner_id), "run_id": run_id, "owner_id": owner_id}
# ---------------------------------------------------------------------------
# delivery validate / publish(G21)
# ---------------------------------------------------------------------------
def _load_manifest(manifest: Any) -> dict[str, Any]:
"""接受 dict 或 JSON 文件路径 → manifest dict。"""
if isinstance(manifest, dict):
return manifest
import json
return json.loads(Path(manifest).read_text(encoding="utf-8"))
def cmd_delivery_validate(manifest: Any) -> dict[str, Any]:
"""G21:校验交付清单完整性(八项)。"""
from app.services.bioflow.delivery import delivery_validate
return delivery_validate(_load_manifest(manifest))
def cmd_delivery_publish(manifest: Any) -> dict[str, Any]:
"""G21:发布交付清单(校验通过置 approved)。"""
from app.services.bioflow.delivery import publish_delivery
return publish_delivery(_load_manifest(manifest))
# ---------------------------------------------------------------------------
# plan validate(G12:读单一 schema 校验参数)
# ---------------------------------------------------------------------------
def cmd_validate_plan(stage: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""G12:plan validate——读单一参数 schema 校验阶段参数(未知参数/choice 越界拒绝)。"""
from app.services.bioflow.catalog import stage_schema
params = dict(params or {})
schema = stage_schema(stage)
schema_params = schema["params"]
known = {p["name"] for p in schema_params}
issues: list[str] = []
for k in params:
if k not in known:
issues.append(f"未知参数: {k}")
for p in schema_params:
name = p["name"]
if name in params and p.get("type") == "choice":
if params[name] not in p.get("choices", []):
issues.append(f"参数 {name}={params[name]!r} 不在可选 {p['choices']}")
if issues:
return _err("VALIDATION_FAILED", "; ".join(issues), evidence_refs=issues)
return {"ok": True, "data": {"stage": stage, "valid": True}}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# bioflow_service/install_skill.sh —— 安装 bioflow-analysis skill(G12)。
#
# 用法:bash install_skill.sh [skills_dir]
# - skills_dir 缺省为仓库根 .claude/skills(Claude Code 项目级 skill 目录)
# - 复制 bioflow_service/SKILL.md → skills_dir/bioflow-analysis/SKILL.md
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SKILL_SRC="$HERE/SKILL.md"
SKILL_NAME="bioflow-analysis"
SKILLS_DIR="${1:-$HERE/../.claude/skills}"
[ -f "$SKILL_SRC" ] || { echo "缺 SKILL.md: $SKILL_SRC" >&2; exit 2; }
DEST="$SKILLS_DIR/$SKILL_NAME"
mkdir -p "$DEST"
cp "$SKILL_SRC" "$DEST/SKILL.md"
echo "[install] $SKILL_NAME -> $DEST/SKILL.md"
echo "DONE"
@@ -0,0 +1,883 @@
"""tests/test_bioflowctl_cli.py —— Phase A CLI(bioflowctl)测试用例集。
覆盖(Phase A 验收):
- CLI 命令(bioflowctl/commands.py 子命令 + 其服务层单一事实源):
source fetch / file inspect / file transform / asset register / asset validate /
report register / archive / archive reuse
- M0 六条 gap(G1-G6)正反例。
命令 → 实现映射:
source fetch → source_fetcher.fetch_source(commands.cmd_source_fetch 薄封装)
file inspect → file_transform.inspect_file
file transform→ file_transform.transform_file
asset register→ commands.register_asset(G2 原子发布 + 同名冲突)
asset validate→ commands.validate_asset(角色→格式 → 校验器)
report register→ commands.cmd_report_register(G3 空路径拒绝)
archive → commands.cmd_archive + archive.archive_from_job(G1/G6)
archive reuse → commands.cmd_archive_reuse(G6 读生效参数)
每条用例三段式(前置 / 步骤 / 预期)写在 docstring。分层标记 -m bioflow_unit。
"""
from __future__ import annotations
import asyncio
import json
import types as _types
from pathlib import Path
import httpx as _real_httpx
import pytest
from app.db import Database
from app.services.bioflow import store
from app.services.bioflow.archive import (
archive_from_job,
effective_stage_params,
normalize_archive_name,
)
from app.services.bioflow.file_transform import (
TransformError,
inspect_file,
transform_file,
validate_format,
)
from app.services.bioflow.params import archive_param_prefill
from app.services.bioflow.reports import (
extract_figure_refs,
locate_report,
validate_report_path,
)
from app.services.bioflow.runner import (
STAGE_DONE,
STAGE_WAITING_CONFIRM,
)
from app.services.bioflow.source_fetcher import (
FetchError,
_resolve_oss_url,
check_disk_space,
fetch_lan,
fetch_source,
fetch_web,
)
from bioflowctl import commands as cli
pytestmark = pytest.mark.bioflow_unit
# ---------------------------------------------------------------------------
# 辅助
# ---------------------------------------------------------------------------
def _run(coro):
"""同步跑协程(新建 event loop;与 docgen 测试同款可靠模式)。"""
return asyncio.new_event_loop().run_until_complete(coro)
def _scenario(coro_factory):
"""单 loop 跑完整 runner 场景(runner 内部 create_task 派生任务必须同 loop)。"""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro_factory())
finally:
loop.close()
async def _drain(runner, seconds: float = 5.0) -> None:
"""等待 runner 派生的后台任务全部结束。"""
while runner._tasks:
await asyncio.wait_for(asyncio.gather(*list(runner._tasks)), timeout=seconds)
await asyncio.sleep(0.01)
def _fake_httpx_stream(chunks, monkeypatch, sf) -> None:
"""注入 fake httpx:stream→aiter_bytes 逐 chunk 产出(确定性,不依赖外网)。"""
total = sum(len(c) for c in chunks)
class _Resp:
status_code = 200
headers = {"content-length": str(total)}
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def aiter_bytes(self, chunk_size):
for c in chunks:
yield c
class _Stream:
async def __aenter__(self):
return _Resp()
async def __aexit__(self, *a):
return False
class _Client:
def __init__(self, follow_redirects=True):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def stream(self, method, url, timeout=None):
return _Stream()
monkeypatch.setattr(sf, "httpx", _types.SimpleNamespace(
AsyncClient=_Client, Timeout=_real_httpx.Timeout, HTTPError=_real_httpx.HTTPError,
))
def _make_job_dir(tmp_path, with_report=True):
job_dir = tmp_path / "job" / "j1"
(job_dir / "workspace" / "final").mkdir(parents=True)
(job_dir / "logs").mkdir(parents=True)
(job_dir / "input").mkdir(parents=True)
(job_dir / "workspace" / "final" / "Ft.SPL.final_members.id").write_text(
"gene1\ngene2\n", encoding="utf-8")
(job_dir / "logs" / "identify.log").write_text("log line", encoding="utf-8")
(job_dir / "input" / "genome.pep.fa").write_text(">x\nACGT", encoding="utf-8")
(job_dir / "events.jsonl").write_text(json.dumps({"type": "job_created"}), encoding="utf-8")
if with_report:
(job_dir / "workspace" / "final" / "Ft.SPL.gene_family_report.html").write_text(
'<html><body><img src="fig://heatmap_1"/><img src="fig://tree_2"/></body></html>',
encoding="utf-8")
return job_dir
async def _seed_archives(db_path, *archives):
"""在独立 db 文件上插入存档行(供 cmd_archive_reuse 注入 db_path)。"""
db = Database(db_path)
await db.connect()
try:
for a in archives:
await store.insert_archive(db, a)
finally:
await db.close()
def _archive_row(**over):
base = {
"id": "a1", "job_id": "j1", "name": "SPL鉴定", "description": "",
"stage_chain": ["identify", "phylogeny"],
"params": {"identify": {"cpu": "8"}},
"report_artifact": None, "disk_path": "/tmp/a1", "size_bytes": 10,
}
base.update(over)
return base
# ===========================================================================
# 1. source fetch(命令: bioflowctl source fetch → source_fetcher.fetch_source)
# ===========================================================================
def test_cli_source_fetch_web_ok(tmp_path, monkeypatch):
"""前置:fake 流式 http 响应产出 2 chunk。步骤:fetch_web。
预期:ok=True,落盘 + provenance sidecar,size 正确。"""
from app.services.bioflow import source_fetcher as sf
payload = [b">seq1\nACGT\n", b">seq2\nTGCA\n"]
_fake_httpx_stream(payload, monkeypatch, sf)
r = _run(fetch_web("http://x/data.fa", tmp_path / "out"))
assert r["ok"] is True
assert r["name"] == "data.fa"
assert r["size"] == sum(len(c) for c in payload)
assert r["kind"] == "web"
assert len(r["provenance"]["sha256"]) == 64
assert (tmp_path / "out" / "data.fa.provenance.json").is_file()
def test_cli_source_fetch_bad_scheme(tmp_path):
"""前置:ftp:// 源。步骤:fetch_source。预期:FetchError BAD_SCHEME。"""
with pytest.raises(FetchError) as ei:
_run(fetch_source("ftp://host/x.fa", tmp_path))
assert ei.value.code == "BAD_SCHEME"
def test_cli_source_fetch_empty(tmp_path):
"""前置:空源。步骤:fetch_source("")。预期:FetchError EMPTY_SOURCE。"""
with pytest.raises(FetchError) as ei:
_run(fetch_source("", tmp_path))
assert ei.value.code == "EMPTY_SOURCE"
def test_cli_source_fetch_lan_local_ok(tmp_path):
"""前置:本机存在文件。步骤:fetch_lan(局域网本机路径直读)。
预期:ok=True,kind=lan,name/size/provenance 正确。"""
f = tmp_path / "genome.fa"
f.write_text(">g1\nACGT\n")
r = _run(fetch_lan(str(f), tmp_path / "out"))
assert r["ok"] is True
assert r["kind"] == "lan"
assert r["path"] == str(f)
assert r["provenance"]["sha256"]
def test_cli_source_fetch_lan_missing(tmp_path):
"""前置:路径不存在。步骤:fetch_lan。预期:FetchError PATH_NOT_EXIST。"""
with pytest.raises(FetchError) as ei:
_run(fetch_lan("/no/such/path.fa", tmp_path))
assert ei.value.code == "PATH_NOT_EXIST"
def test_cli_source_fetch_lan_dir(tmp_path):
"""前置:目录。步骤:fetch_lan。预期:FetchError IS_DIR(仅单文件)。"""
d = tmp_path / "adir"
d.mkdir()
with pytest.raises(FetchError) as ei:
_run(fetch_lan(str(d), tmp_path / "out"))
assert ei.value.code == "IS_DIR"
def test_cli_source_fetch_oss_unconfigured():
"""前置:无 BIOFLOW_OSS_ENDPOINT。步骤:_resolve_oss_url(oss://)。
预期:FetchError OSS_UNCONFIGURED。"""
with pytest.raises(FetchError) as ei:
_resolve_oss_url("oss://bucket/key.fa")
assert ei.value.code == "OSS_UNCONFIGURED"
def test_cli_source_fetch_disk_full(tmp_path):
"""前置:磁盘余量不足(1EB)。步骤:check_disk_space。预期:ok=False。"""
ok, free = check_disk_space(tmp_path, 10 ** 18)
assert ok is False
assert free < 10 ** 18
# ===========================================================================
# 2. file inspect(命令: bioflowctl file inspect → file_transform.inspect_file)
# ===========================================================================
def test_cli_file_inspect_fasta(tmp_path):
"""前置:fasta 文件。步骤:inspect_file。预期:ok,format=fasta,size>0。"""
f = tmp_path / "proteins.fa"
f.write_text(">SPL1\nMKTA\n>SPL2\nMKTB\n")
r = inspect_file(f)
assert r["ok"] is True
assert r["format"] == "fasta"
assert r["size"] > 0
def test_cli_file_inspect_not_found(tmp_path):
"""前置:文件不存在。步骤:inspect_file。预期:ok=False,code=NOT_FOUND。"""
r = inspect_file(tmp_path / "nope.fa")
assert r["ok"] is False
assert r["code"] == "NOT_FOUND"
def test_cli_file_inspect_tsv_columns(tmp_path):
"""前置:tsv 文件。步骤:inspect_file。预期:format=tsv,列名提取正确。"""
f = tmp_path / "samples.tsv"
f.write_text("run\tfastq_1\tfastq_2\nSRR1\ta_1.fq\ta_2.fq\n")
r = inspect_file(f)
assert r["ok"] is True
assert r["format"] == "tsv"
assert r["columns"] == ["run", "fastq_1", "fastq_2"]
def test_cli_file_inspect_garbled(tmp_path):
"""前置:含异常字节的文件。步骤:inspect_file。预期:garbled 比例 > 阈值。"""
f = tmp_path / "messy.txt"
f.write_bytes(b"\x00\x01\x02\x03\xff\xfe garbage \x00\x00")
r = inspect_file(f)
assert r["ok"] is True
assert r["garbled"] > 0.3
# ===========================================================================
# 3. file transform(命令: bioflowctl file transform → file_transform.transform_file)
# ===========================================================================
def test_cli_file_transform_csv_to_fasta(tmp_path):
"""前置:csv(gene,seq)。步骤:transform_file(fasta, 显式 column_map)。
预期:ok,format=fasta,records=2。"""
src = tmp_path / "genes.csv"
src.write_text("gene,seq\nSPL1,MKTA\nSPL2,MKTB\n", encoding="utf-8")
r = _run(transform_file(src, "fasta", column_map={"name": "gene", "seq": "seq"}))
assert r["ok"] is True
assert r["format"] == "fasta"
assert r["records"] == 2
def test_cli_file_transform_tsv_to_samples_yaml(tmp_path):
"""前置:tsv(run,r1,r2)。步骤:transform_file(samples.yaml)。
预期:ok,format=samples.yaml,records=2。"""
src = tmp_path / "samples.tsv"
src.write_text("run\tr1\tr2\nSRR1\ta_1.fq\ta_2.fq\nSRR2\tb_1.fq\tb_2.fq\n", encoding="utf-8")
r = _run(transform_file(src, "samples.yaml", column_map={"name": "run", "r1": "r1", "r2": "r2"}))
assert r["ok"] is True
assert r["format"] == "samples.yaml"
assert r["records"] == 2
def test_cli_file_transform_gff3_unsupported(tmp_path):
"""前置:tsv 源。步骤:transform_file(gff3)。预期:TransformError UNSUPPORTED。"""
src = tmp_path / "a.tsv"
src.write_text("a\tb\tc\n1\t2\t3\n", encoding="utf-8")
with pytest.raises(TransformError) as ei:
_run(transform_file(src, "gff3"))
assert ei.value.code == "UNSUPPORTED"
def test_cli_file_transform_not_found(tmp_path):
"""前置:源不存在。步骤:transform_file。预期:TransformError NOT_FOUND。"""
with pytest.raises(TransformError) as ei:
_run(transform_file(tmp_path / "nope.csv", "fasta"))
assert ei.value.code == "NOT_FOUND"
def test_cli_file_transform_bad_format(tmp_path):
"""前置:csv 源。步骤:transform_file(bed)。预期:TransformError BAD_FORMAT。"""
src = tmp_path / "a.csv"
src.write_text("a,b\n1,2\n")
with pytest.raises(TransformError) as ei:
_run(transform_file(src, "bed"))
assert ei.value.code == "BAD_FORMAT"
def test_cli_file_transform_empty_table(tmp_path):
"""前置:空文件。步骤:transform_file(fasta)。预期:TransformError EMPTY_TABLE。"""
src = tmp_path / "empty.csv"
src.write_text("", encoding="utf-8")
with pytest.raises(TransformError) as ei:
_run(transform_file(src, "fasta"))
assert ei.value.code == "EMPTY_TABLE"
def test_cli_file_transform_missing_columns(tmp_path):
"""前置:表头无 name/seq 列且无 LLM。步骤:transform_file(fasta)。
预期:TransformError MISSING_COLUMNS。"""
src = tmp_path / "x.csv"
src.write_text("foo,bar\n1,2\n", encoding="utf-8")
with pytest.raises(TransformError) as ei:
_run(transform_file(src, "fasta"))
assert ei.value.code == "MISSING_COLUMNS"
def test_cli_file_transform_validation_failed(tmp_path):
"""前置:seq 列全空(写不出有效 fasta)。步骤:transform_file(fasta)。
预期:TransformError VALIDATION_FAILED(产物删除,供追问)。"""
src = tmp_path / "genes.csv"
src.write_text("gene,seq\nSPL1,\nSPL2,\n", encoding="utf-8")
with pytest.raises(TransformError) as ei:
_run(transform_file(src, "fasta", column_map={"name": "gene", "seq": "seq"}))
assert ei.value.code == "VALIDATION_FAILED"
# ===========================================================================
# 4. asset register(命令: bioflowctl asset register → commands.register_asset)
# ===========================================================================
def test_cli_asset_register_ok(tmp_path):
"""前置:本机文件 + 合法角色。步骤:register_asset(role, path, assets_root)。
预期:ok=True,落角色目录,role/name/size 正确。"""
f = tmp_path / "genome.fa"
f.write_text(">g1\nACGT\n")
assets = tmp_path / "assets"
r = cli.register_asset("pep.fa", str(f), assets_root=assets)
assert r["ok"] is True
assert r["role"] == "pep.fa"
assert r["name"] == "genome.fa"
assert (assets / "pep.fa" / "genome.fa").read_text() == ">g1\nACGT\n"
def test_cli_asset_register_missing_source(tmp_path):
"""前置:源不存在。步骤:register_asset。预期:ok=False,code=NOT_FOUND。"""
r = cli.register_asset("pep.fa", str(tmp_path / "nope.fa"), assets_root=tmp_path / "assets")
assert r["ok"] is False
assert r["code"] == "NOT_FOUND"
def test_cli_asset_register_dir_rejected(tmp_path):
"""前置:目录。步骤:register_asset。预期:ok=False,code=NOT_FOUND(仅文件)。"""
d = tmp_path / "dir"
d.mkdir()
r = cli.register_asset("pep.fa", str(d), assets_root=tmp_path / "assets")
assert r["ok"] is False
assert r["code"] == "NOT_FOUND"
def test_cli_asset_register_bad_role(tmp_path):
"""前置:角色名含路径分隔符或 ..。步骤:register_asset。预期:code=BAD_ROLE。"""
f = tmp_path / "x.fa"
f.write_text(">x\nACGT\n")
for bad in ("", "../pep", "a/b", "a\\b"):
r = cli.register_asset(bad, str(f), assets_root=tmp_path / "assets")
assert r["ok"] is False
assert r["code"] == "BAD_ROLE"
# ===========================================================================
# 5. asset validate(命令: bioflowctl asset validate → commands.validate_asset)
# ===========================================================================
def test_cli_asset_validate_fasta_ok(tmp_path):
"""前置:合法 fasta + 角色 pep.fa。步骤:validate_asset。预期:ok=True,format=fasta。"""
f = tmp_path / "p.fa"
f.write_text(">S1\nACGT\n")
r = cli.validate_asset("pep.fa", str(f))
assert r["ok"] is True
assert r["format"] == "fasta"
def test_cli_asset_validate_fasta_invalid(tmp_path):
"""前置:无 '>' 头行文件 + 角色 pep.fa。步骤:validate_asset。预期:ok=False。"""
f = tmp_path / "p.fa"
f.write_text("just text\n")
r = cli.validate_asset("pep.fa", str(f))
assert r["ok"] is False
def test_cli_asset_validate_samples_yaml_role(tmp_path):
"""前置:合法 samples.yaml + 角色 samples.yaml。步骤:validate_asset。
预期:ok=True,format=samples.yaml。"""
f = tmp_path / "samples.yaml"
f.write_text("samples:\n S1:\n r1: a.fq\n r2: b.fq\n")
r = cli.validate_asset("samples.yaml", str(f))
assert r["ok"] is True
assert r["format"] == "samples.yaml"
def test_cli_asset_validate_bad_role(tmp_path):
"""前置:角色无法推断格式 + txt 文件。步骤:validate_asset。预期:code=BAD_ROLE。"""
f = tmp_path / "x.txt"
f.write_text("hello\n")
r = cli.validate_asset("unknown_role", str(f))
assert r["ok"] is False
assert r["code"] == "BAD_ROLE"
def test_cli_asset_validate_missing_file(tmp_path):
"""前置:文件不存在。步骤:validate_asset。预期:code=NOT_FOUND。"""
r = cli.validate_asset("pep.fa", str(tmp_path / "nope.fa"))
assert r["ok"] is False
assert r["code"] == "NOT_FOUND"
# ===========================================================================
# 6. report register(命令: bioflowctl report register → commands.cmd_report_register)
# ===========================================================================
def test_cli_report_register_ok(tmp_path):
"""前置:报告含 fig:// 引用。步骤:cmd_report_register。预期:ok=True,
size/figures 正确。"""
f = tmp_path / "r.html"
f.write_text('<html><img src="fig://a"/></html>')
r = cli.cmd_report_register(str(f))
assert r["ok"] is True
assert r["path"] == str(f)
assert r["figures"] == ["a"]
def test_cli_report_register_missing_file(tmp_path):
"""前置:路径不存在。步骤:cmd_report_register。预期:ok=False,code=EMPTY_REPORT。"""
r = cli.cmd_report_register(str(tmp_path / "nope.html"))
assert r["ok"] is False
assert r["code"] == "EMPTY_REPORT"
# ===========================================================================
# 7. archive(命令: bioflowctl archive → commands.cmd_archive + archive.archive_from_job)
# ===========================================================================
def test_cli_archive_pack(tmp_path):
"""前置:job_dir 含 final/logs/input/events。步骤:archive_from_job。
预期:四类产物入归档;默认不含中间文件。"""
job_dir = _make_job_dir(tmp_path)
job = {"id": "j1", "title": "t", "stage_chain": ["identify"], "params": {}}
result = _run(archive_from_job(job, job_dir, tmp_path / "archives", name="SPL 鉴定"))
assert result.name == "SPL 鉴定"
dest = tmp_path / "archives" / result.id
assert (dest / "final" / "Ft.SPL.final_members.id").exists()
assert (dest / "logs" / "identify.log").exists()
assert (dest / "input" / "genome.pep.fa").exists()
assert (dest / "events.jsonl").exists()
def test_cli_archive_include_intermediate(tmp_path):
"""前置:job_dir 含中间 analysis。步骤:archive_from_job(include_intermediate=True)。
预期:中间文件入归档。"""
job_dir = _make_job_dir(tmp_path)
(job_dir / "workspace" / "identify" / "analysis").mkdir(parents=True)
(job_dir / "workspace" / "identify" / "analysis" / "domtblout").write_text("intermediate")
result = _run(archive_from_job(
{"id": "j1", "title": "t", "stage_chain": [], "params": {}},
job_dir, tmp_path / "archives", name="X", include_intermediate=True))
assert (tmp_path / "archives" / result.id / "intermediate" / "identify" / "analysis" / "domtblout").exists()
def test_cli_archive_normalize_name():
"""前置:空/带空白名。步骤:normalize_archive_name。预期:trim / 空→占位。"""
assert normalize_archive_name(" SPL 鉴定 ") == "SPL 鉴定"
assert normalize_archive_name(" ") == "未命名存档"
def test_cli_archive_cmd_not_found(tmp_path):
"""前置:空 db。步骤:cmd_archive(不存在 job)。预期:ok=False,code=NOT_FOUND。"""
r = _run(cli.cmd_archive(
"nope", "X", db_path=tmp_path / "empty.db",
jobs_dir=tmp_path / "jobs", archives_dir=tmp_path / "archives"))
assert r["ok"] is False
assert r["code"] == "NOT_FOUND"
# ===========================================================================
# 8. archive reuse(命令: bioflowctl archive reuse → commands.cmd_archive_reuse + store + params)
# ===========================================================================
def test_cli_archive_reuse_find_exact(db):
"""前置:插一条存档名 SPL鉴定。步骤:find_archive_by_name(精确)。预期:命中。"""
_run(_insert_via(db, _archive_row()))
found = _run(store.find_archive_by_name(db, "SPL鉴定"))
assert found is not None and found["id"] == "a1"
async def _insert_via(db, archive):
await store.insert_archive(db, archive)
def test_cli_archive_reuse_find_fuzzy(db):
"""前置:存档名 SPL鉴定。步骤:find_archive_by_name(模糊 SPL)。预期:命中候选。"""
_run(_insert_via(db, _archive_row()))
found = _run(store.find_archive_by_name(db, "SPL"))
assert found is not None and found["id"] == "a1"
def test_cli_archive_reuse_candidates(db):
"""前置:两条含 SPL 的存档。步骤:find_archive_candidates。预期:≥2 条。"""
_run(_insert_via(db, _archive_row()))
_run(_insert_via(db, _archive_row(id="a2", name="SPL另一条")))
cands = _run(store.find_archive_candidates(db, "SPL"))
assert len(cands) >= 2
def test_cli_archive_reuse_prefill(db):
"""前置:存档 params 含 identify。步骤:archive_param_prefill。
预期:identify 预填命中;未知阶段空。"""
_run(_insert_via(db, _archive_row()))
found = _run(store.find_archive_by_name(db, "SPL鉴定"))
assert archive_param_prefill(found, "identify") == {"cpu": "8"}
assert archive_param_prefill(found, "nope") == {}
def test_cli_archive_reuse_prefill_empty(db):
"""前置:存档 params 为空。步骤:archive_param_prefill。预期:空 dict。"""
_run(_insert_via(db, _archive_row(params={})))
found = _run(store.find_archive_by_name(db, "SPL鉴定"))
assert archive_param_prefill(found, "identify") == {}
def test_cli_archive_reuse_cmd(tmp_path):
"""前置:seed 一条存档(params 含生效参数 cpu=8)。步骤:cmd_archive_reuse。
预期:ok=True,返回 stage_chain 与生效参数。"""
db_path = tmp_path / "reuse.db"
_run(_seed_archives(db_path, _archive_row(
params={"identify": {"cpu": "8", "memory": "16G"}})))
r = _run(cli.cmd_archive_reuse("SPL鉴定", db_path=db_path))
assert r["ok"] is True
assert r["data"]["stage_chain"] == ["identify", "phylogeny"]
assert r["data"]["params"] == {"identify": {"cpu": "8", "memory": "16G"}}
def test_cli_archive_reuse_cmd_not_found(tmp_path):
"""前置:空 db。步骤:cmd_archive_reuse。预期:ok=False,code=ARCHIVE_NOT_FOUND。"""
r = _run(cli.cmd_archive_reuse("不存在", db_path=tmp_path / "empty.db"))
assert r["ok"] is False
assert r["code"] == "ARCHIVE_NOT_FOUND"
# ===========================================================================
# M0 六条 gap(G1-G6)
# ===========================================================================
# ---------------------------------------------------------------------------
# G1 归档按 manifest 必需产物复制(正例:非 final 的必需产物也归档;反例:漏归档)
# ---------------------------------------------------------------------------
def _make_job_with_manifest_required_nonfinal(tmp_path):
job_dir = tmp_path / "job" / "j1"
(job_dir / "workspace" / "final").mkdir(parents=True)
(job_dir / "workspace" / "identify" / "analysis").mkdir(parents=True)
(job_dir / "logs").mkdir(parents=True)
(job_dir / "input").mkdir(parents=True)
(job_dir / "workspace" / "final" / "Ft.SPL.final_members.id").write_text("gene1\n")
required = job_dir / "workspace" / "identify" / "analysis" / "domtblout"
required.write_text("PF00001\tSPL1\n", encoding="utf-8")
manifest = {
"manifest_version": "1", "job_id": "j1", "stage": "identify",
"status": "success", "prefix": "Ft.SPL",
"artifacts": {
"final_members": {"artifact": "final_members",
"full_path": str(job_dir / "workspace" / "final" / "Ft.SPL.final_members.id"),
"type": "table", "size": 10},
"domtblout": {"artifact": "domtblout", "full_path": str(required),
"type": "table", "size": 5},
},
"parameters": {}, "metrics": {},
}
(job_dir / "workspace" / "final" / "Ft.SPL.identify.manifest.json").write_text(
json.dumps(manifest), encoding="utf-8")
return job_dir, required
def test_g1_archive_copies_manifest_required_nonfinal_artifact(tmp_path):
"""G1 正例。前置:manifest 声明必需产物在 final/ 之外(workspace/identify/analysis)。
步骤:archive_from_job(include_intermediate=False)。
预期:该非 final 必需产物仍被复制进归档(按 manifest,而非仅 final/)。"""
job_dir, _required = _make_job_with_manifest_required_nonfinal(tmp_path)
job = {"id": "j1", "title": "t", "stage_chain": ["identify"], "params": {}}
result = _run(archive_from_job(job, job_dir, tmp_path / "archives", name="X"))
dest = tmp_path / "archives" / result.id
assert any(p.name == "domtblout" for p in dest.rglob("*"))
def test_g1_archive_does_not_miss_manifest_required_outside_jobdir(tmp_path):
"""G1 反例(漏归档)。前置:manifest 必需产物 full_path 指向 job_dir 之外(共享库文件)。
步骤:archive_from_job。预期:该必需产物被复制进归档,不遗漏。"""
job_dir = tmp_path / "job" / "j1"
(job_dir / "workspace" / "final").mkdir(parents=True)
(job_dir / "logs").mkdir(parents=True)
(job_dir / "input").mkdir(parents=True)
(job_dir / "workspace" / "final" / "Ft.SPL.final_members.id").write_text("gene1\n")
shared = tmp_path / "shared" / "Pfam-A.hmm"
shared.parent.mkdir(parents=True)
shared.write_text("HMMER3/f\nNAME Pfam-A\n", encoding="utf-8")
manifest = {
"manifest_version": "1", "job_id": "j1", "stage": "identify",
"status": "success", "prefix": "Ft.SPL",
"artifacts": {"hmm_db": {"artifact": "hmm_db", "full_path": str(shared),
"type": "db", "size": 20}},
"parameters": {}, "metrics": {},
}
(job_dir / "workspace" / "final" / "Ft.SPL.identify.manifest.json").write_text(
json.dumps(manifest), encoding="utf-8")
result = _run(archive_from_job(
{"id": "j1", "title": "t", "stage_chain": ["identify"], "params": {}},
job_dir, tmp_path / "archives", name="X"))
dest = tmp_path / "archives" / result.id
assert any(p.name == "Pfam-A.hmm" for p in dest.rglob("*"))
# ---------------------------------------------------------------------------
# G2 同名上传不覆盖、显式报错(正例:同名冲突报错不覆盖;反例:静默覆盖)
# ---------------------------------------------------------------------------
def test_g2_asset_register_same_name_errors_and_preserves(tmp_path):
"""G2 正例。前置:同角色同文件名已发布。步骤:再次 register_asset 同名。
预期:显式报错 ASSET_EXISTS,原资产不被覆盖。"""
f = tmp_path / "x.fa"
f.write_text("V1")
assets = tmp_path / "assets"
assert cli.register_asset("pep.fa", str(f), assets_root=assets)["ok"] is True
r = cli.register_asset("pep.fa", str(f), assets_root=assets)
assert r["ok"] is False
assert r["code"] == "ASSET_EXISTS"
assert (assets / "pep.fa" / "x.fa").read_text() == "V1"
def test_g2_asset_register_never_silently_overwrites(tmp_path):
"""G2 反例(静默覆盖)。前置:同名资产已存在,第二个同名但内容不同的源。
步骤:register_asset。预期:绝不静默覆盖,已发布资产内容仍为第一版。"""
d1 = tmp_path / "d1"
d2 = tmp_path / "d2"
d1.mkdir()
d2.mkdir()
(d1 / "x.fa").write_text("VERSION1")
(d2 / "x.fa").write_text("VERSION2")
assets = tmp_path / "assets"
assert cli.register_asset("pep.fa", str(d1 / "x.fa"), assets_root=assets)["ok"] is True
r = cli.register_asset("pep.fa", str(d2 / "x.fa"), assets_root=assets)
assert r["ok"] is False
assert r["code"] == "ASSET_EXISTS"
assert (assets / "pep.fa" / "x.fa").read_text() == "VERSION1"
# ---------------------------------------------------------------------------
# G3 空报告路径被拒绝(空串、空文件、None)
# ---------------------------------------------------------------------------
def test_g3_empty_report_path_rejected():
"""G3 空串。前置:无。步骤:validate_report_path("")。预期:(False, 报告路径为空)。"""
ok, err = validate_report_path("")
assert ok is False
assert err == "报告路径为空"
def test_g3_empty_file_report_path_rejected(tmp_path):
"""G3 空文件。前置:0 字节报告文件。步骤:validate_report_path(empty)。
预期:(False, 报告文件为空)。"""
empty = tmp_path / "empty.html"
empty.write_text("", encoding="utf-8")
ok, err = validate_report_path(empty)
assert ok is False
assert "报告文件为空" in err
def test_g3_none_report_path_rejected():
"""G3 None。前置:无。步骤:validate_report_path(None)。预期:(False, 报告路径为空)。"""
ok, err = validate_report_path(None)
assert ok is False
assert err == "报告路径为空"
def test_g3_cli_report_register_empty_rejected():
"""G3 CLI 层。前置:无。步骤:cmd_report_register("")。预期:ok=False,code=EMPTY_REPORT。"""
r = cli.cmd_report_register("")
assert r["ok"] is False
assert r["code"] == "EMPTY_REPORT"
# ---------------------------------------------------------------------------
# G4 partial_success 独立终态(不被当作 success)
# ---------------------------------------------------------------------------
def test_g4_partial_success_independent_terminal_state(db, events, catalog):
"""G4。前置:executor 写 status=partial_success 的 manifest 且退出码 0。
步骤:run_job(auto_continue=True)。预期:阶段不得被当作 success(done),
而是独立终态 partial_success。"""
from conftest import FakeExecutor, make_runner
executor = FakeExecutor(exit_code=0, manifest_status="partial_success")
async def scenario():
runner = make_runner(db, events, executor, catalog)
record = await runner.create_job(
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
await runner.run_job(record)
await _drain(runner)
return runner, record
runner, record = _scenario(scenario)
status = runner.stage_status(record, "identify")
assert status != STAGE_DONE # 不被当作 success
assert status == "partial_success" # 独立终态
# ---------------------------------------------------------------------------
# G5 大文件流式(不整块进内存的观测方式)
# ---------------------------------------------------------------------------
def test_g5_large_download_streams_chunked(tmp_path, monkeypatch):
"""G5 正常路径(观测方式)。前置:fake 流式响应产出多 chunk。
步骤:fetch_web。预期:观测到 aiter_bytes 以 CHUNK(=1MB) 分块迭代、逐块落盘。"""
from app.services.bioflow import source_fetcher as sf
observed: dict = {}
chunks = [b"x" * 1024, b"y" * 2048, b"z" * 512]
total = sum(len(c) for c in chunks)
class _Resp:
status_code = 200
headers = {"content-length": str(total)}
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def aiter_bytes(self, chunk_size):
observed["chunk_size"] = chunk_size
for c in chunks:
yield c
class _Stream:
async def __aenter__(self):
return _Resp()
async def __aexit__(self, *a):
return False
class _Client:
def __init__(self, follow_redirects=True):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def stream(self, method, url, timeout=None):
return _Stream()
monkeypatch.setattr(sf, "httpx", _types.SimpleNamespace(
AsyncClient=_Client, Timeout=_real_httpx.Timeout, HTTPError=_real_httpx.HTTPError,
))
r = _run(fetch_web("http://x/big.fa", tmp_path / "out"))
assert r["size"] == total
assert observed["chunk_size"] == sf.CHUNK # 1MB 分块,非整块
assert (tmp_path / "out" / "big.fa").stat().st_size == total
def test_g5_large_file_inspect_uses_preview_not_whole_read(tmp_path, monkeypatch):
"""G5 异常路径(观测方式)。前置:>4MB 大文件。步骤:inspect_file(monkeypatch
_read_text 使其被调用即失败)。预期:inspect 走 _read_text_preview(只读头)。"""
from app.services.bioflow import file_transform as ft
big = tmp_path / "big.fa"
big.write_bytes(b">s\n" + b"ACGT" * 1_200_000 + b"\n") # ≈4.8MB
def _boom(*a, **k):
raise AssertionError("大文件不应整读 _read_text")
monkeypatch.setattr(ft, "_read_text", _boom)
r = ft.inspect_file(big)
assert r["ok"] is True
assert r["format"] == "fasta"
# ---------------------------------------------------------------------------
# G6 归档复用读实际生效参数(非创建时 record.params)
# ---------------------------------------------------------------------------
def test_g6_effective_stage_params_returns_effective():
"""G6 正例。前置:stage_states 含 confirm/rerun 后的生效参数。
步骤:effective_stage_params(stage_chain, stage_states)。
预期:返回各阶段生效参数;缺失阶段为空 dict。"""
stage_chain = ["identify", "phylogeny", "missing"]
stage_states = {
"identify": {"params": {"cpu": "8", "memory": "16G"}},
"phylogeny": {"params": {"method": "ML"}},
}
eff = effective_stage_params(stage_chain, stage_states)
assert eff == {
"identify": {"cpu": "8", "memory": "16G"},
"phylogeny": {"method": "ML"},
"missing": {},
}
def test_g6_archive_reuse_cmd_reads_effective_params(tmp_path):
"""G6 正例(读侧端到端)。前置:seed 存档 params=生效参数(cpu=8)。
步骤:cmd_archive_reuse。预期:返回生效参数 cpu=8(非创建时 record.params 的 cpu=2)。"""
db_path = tmp_path / "reuse.db"
_run(_seed_archives(db_path, _archive_row(
params={"identify": {"cpu": "8", "memory": "16G"}})))
r = _run(cli.cmd_archive_reuse("SPL鉴定", db_path=db_path))
assert r["ok"] is True
assert r["data"]["params"]["identify"]["cpu"] == "8"
def test_g6_effective_params_diverge_from_creation_record(db, events, catalog):
"""G6 反例。前置:创建任务 params cpu=2(record.params),确认时覆盖 cpu=8。
步骤:confirm_stage 覆盖后观察两处 params。
预期:生效参数(_stage_states)与创建时 record.params 分叉,归档必须快照前者。"""
from conftest import FakeExecutor, make_runner
executor = FakeExecutor()
async def scenario():
runner = make_runner(db, events, executor, catalog)
record = await runner.create_job(
"s1", "t", ["identify"], params={"identify": {"cpu": "2"}},
auto_continue=False, auto_start=False)
await runner.run_job(record) # 手动模式停 waiting_confirm
await runner.confirm_stage(record, "identify", {"cpu": "8"})
await _drain(runner)
return runner, record
runner, record = _scenario(scenario)
effective = record._stage_states["identify"]["params"]
assert effective.get("cpu") == "8" # 实际生效参数
assert record.params["identify"].get("cpu") == "2" # 创建时 record.params
assert effective != record.params["identify"] # 已分叉 → 归档读 effective
+414
View File
@@ -0,0 +1,414 @@
"""tests/test_phase_b_error.py —— Phase B 测试用例集。
覆盖(Phase B 验收):
1. G13 结构化错误对象:CLI 命令 + MCP 工具失败时返回统一错误对象,含 8 个字段
(error_id / category / code / summary / evidence_refs / diagnosis_status /
retryable_without_change / suggested_actions),而非裸字符串 message。
2. MCP 薄适配行为回归:fetch_source_file / inspect_file / transform_file /
archive_job / reuse_archive / register_job_files 委托 CLI/service 后行为与原实现一致。
3. reports.register_report 的 httpx.Timeout 修复:不再抛 ValueError。
## G13 契约(本文件断言的结构)
失败返回为 dict,且 `ok == False`,并携带结构化错误对象:
- 结构 A:`{"ok": False, "code": "...", "error": {8 字段}}`
- 结构 B:`{"ok": False, "code": "...", "error_id": ..., "category": ..., ...}`(字段平铺)
两种结构均被 `_error_of` 识别;`code` 须可经顶层 `result["code"]` 或 `error["code"]`
取到(与 Phase A 测试 `r["code"]` 断言兼容)。
字段类型契约:
- error_id: 非空 str(错误唯一标识)
- category: 非空 str(错误分类,如 validation/not_found/io/network)
- code: 非空 str(既有错误码,如 NOT_FOUND / ASSET_EXISTS / BAD_ROLE)
- summary: 非空 str(人读摘要,等价原 message)
- evidence_refs: list(证据引用,可为空)
- diagnosis_status: 非空 str(如 diagnosed)
- retryable_without_change: bool(不改动重试是否可能成功)
- suggested_actions: list[str](建议动作,可为空)
每条用例三段式(前置/步骤/预期)写在 docstring。分层标记 -m bioflow_unit。
"""
from __future__ import annotations
import asyncio
import types as _types
from pathlib import Path
import httpx as _real_httpx
import pytest
import app.mcp_server as mcp
from app.services.bioflow import store
from app.services.bioflow.reports import register_report
from bioflowctl import commands as cli
pytestmark = pytest.mark.bioflow_unit
G13_FIELDS = (
"error_id", "category", "code", "summary", "evidence_refs",
"diagnosis_status", "retryable_without_change", "suggested_actions",
)
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# G13 结构化错误对象断言助手
# ---------------------------------------------------------------------------
def _error_of(result: dict) -> dict:
"""从失败返回中取出结构化错误对象(兼容 `error` 键与字段平铺两种结构)。"""
if isinstance(result.get("error"), dict):
return result["error"]
if "error_id" in result:
return result
raise AssertionError(f"失败返回缺结构化错误对象: keys={sorted(result)}")
def _code_of(result: dict) -> str | None:
return result.get("code") or _error_of(result).get("code")
def _assert_g13_error(result, expected_code: str) -> dict:
"""断言 result 为 G13 失败返回,且 code 匹配;返回错误对象。"""
assert isinstance(result, dict), f"失败返回非 dict: {result!r}"
assert result.get("ok") is False, f"预期失败但 ok=True: {result!r}"
err = _error_of(result)
missing = [f for f in G13_FIELDS if f not in err]
assert not missing, f"G13 错误对象缺字段 {missing}: {err}"
assert isinstance(err["error_id"], str) and err["error_id"], "error_id 须非空 str"
assert isinstance(err["category"], str) and err["category"], "category 须非空 str"
assert isinstance(err["code"], str) and err["code"], "code 须非空 str"
assert isinstance(err["summary"], str) and err["summary"], "summary 须非空 str"
assert isinstance(err["evidence_refs"], list), "evidence_refs 须 list"
assert isinstance(err["diagnosis_status"], str) and err["diagnosis_status"], "diagnosis_status 须非空 str"
assert isinstance(err["retryable_without_change"], bool), "retryable_without_change 须 bool"
assert isinstance(err["suggested_actions"], list), "suggested_actions 须 list"
assert _code_of(result) == expected_code, f"code 期望 {expected_code},实际 {_code_of(result)}"
return err
async def _seed_archive_into(db, archive: dict) -> None:
await store.insert_archive(db, archive)
def _archive_row(**over) -> dict:
base = {
"id": "a1", "job_id": "j1", "name": "SPL鉴定", "description": "",
"stage_chain": ["identify", "phylogeny"],
"params": {"identify": {"cpu": "8"}},
"report_artifact": None, "disk_path": "/tmp/a1", "size_bytes": 10,
}
base.update(over)
return base
# ===========================================================================
# 1. G13 结构化错误对象 —— 形状契约(冒烟)
# ===========================================================================
def test_g13_error_object_has_all_fields(tmp_path):
"""前置:空 db。步骤:cmd_archive_reuse(不存在的存档)。
预期:ok=False 且含 8 字段结构化错误对象,code=ARCHIVE_NOT_FOUND。"""
r = _run(cli.cmd_archive_reuse("不存在", db_path=tmp_path / "empty.db"))
_assert_g13_error(r, "ARCHIVE_NOT_FOUND")
# ===========================================================================
# 2. CLI 命令失败路径 → G13(覆盖各命令的错误码)
# ===========================================================================
# ---- source fetch ----
def test_g13_source_fetch_empty(tmp_path):
"""前置:空源。步骤:cmd_source_fetch("")。预期:G13 错误,code=EMPTY_SOURCE。"""
r = _run(cli.cmd_source_fetch(""))
_assert_g13_error(r, "EMPTY_SOURCE")
def test_g13_source_fetch_bad_scheme(tmp_path):
"""前置:ftp:// 源。步骤:cmd_source_fetch。预期:G13 错误,code=BAD_SCHEME。"""
r = _run(cli.cmd_source_fetch("ftp://host/x.fa"))
_assert_g13_error(r, "BAD_SCHEME")
def test_g13_source_fetch_lan_missing(tmp_path):
"""前置:局域网路径不存在。步骤:cmd_source_fetch。预期:G13 错误,code=PATH_NOT_EXIST。"""
r = _run(cli.cmd_source_fetch("/no/such/path.fa"))
_assert_g13_error(r, "PATH_NOT_EXIST")
# ---- file inspect ----
def test_g13_file_inspect_not_found(tmp_path):
"""前置:文件不存在。步骤:cmd_file_inspect。预期:G13 错误,code=NOT_FOUND。"""
r = cli.cmd_file_inspect(str(tmp_path / "nope.fa"))
_assert_g13_error(r, "NOT_FOUND")
# ---- file transform ----
def test_g13_file_transform_not_found(tmp_path):
"""前置:源不存在。步骤:cmd_file_transform。预期:G13 错误,code=NOT_FOUND。"""
r = _run(cli.cmd_file_transform(str(tmp_path / "nope.csv"), "fasta"))
_assert_g13_error(r, "NOT_FOUND")
def test_g13_file_transform_bad_format(tmp_path):
"""前置:csv 源,目标 bed。步骤:cmd_file_transform。预期:G13 错误,code=BAD_FORMAT。"""
src = tmp_path / "a.csv"
src.write_text("a,b\n1,2\n")
r = _run(cli.cmd_file_transform(str(src), "bed"))
_assert_g13_error(r, "BAD_FORMAT")
def test_g13_file_transform_empty_table(tmp_path):
"""前置:空文件。步骤:cmd_file_transform(fasta)。预期:G13 错误,code=EMPTY_TABLE。"""
src = tmp_path / "empty.csv"
src.write_text("", encoding="utf-8")
r = _run(cli.cmd_file_transform(str(src), "fasta"))
_assert_g13_error(r, "EMPTY_TABLE")
def test_g13_file_transform_missing_columns(tmp_path):
"""前置:表头无 name/seq。步骤:cmd_file_transform(fasta)。预期:G13 错误,code=MISSING_COLUMNS。"""
src = tmp_path / "x.csv"
src.write_text("foo,bar\n1,2\n")
r = _run(cli.cmd_file_transform(str(src), "fasta"))
_assert_g13_error(r, "MISSING_COLUMNS")
def test_g13_file_transform_validation_failed(tmp_path):
"""前置:seq 全空。步骤:cmd_file_transform(fasta)。预期:G13 错误,code=VALIDATION_FAILED。"""
src = tmp_path / "genes.csv"
src.write_text("gene,seq\nSPL1,\nSPL2,\n")
r = _run(cli.cmd_file_transform(str(src), "fasta", column_map={"name": "gene", "seq": "seq"}))
_assert_g13_error(r, "VALIDATION_FAILED")
def test_g13_file_transform_gff3_unsupported(tmp_path):
"""前置:tsv 源。步骤:cmd_file_transform(gff3)。预期:G13 错误,code=UNSUPPORTED。"""
src = tmp_path / "a.tsv"
src.write_text("a\tb\tc\n1\t2\t3\n")
r = _run(cli.cmd_file_transform(str(src), "gff3"))
_assert_g13_error(r, "UNSUPPORTED")
# ---- asset register ----
def test_g13_asset_register_not_found(tmp_path):
"""前置:源不存在。步骤:register_asset。预期:G13 错误,code=NOT_FOUND。"""
r = cli.register_asset("pep.fa", str(tmp_path / "nope.fa"), assets_root=tmp_path / "assets")
_assert_g13_error(r, "NOT_FOUND")
def test_g13_asset_register_bad_role(tmp_path):
"""前置:角色名非法。步骤:register_asset。预期:G13 错误,code=BAD_ROLE。"""
f = tmp_path / "x.fa"
f.write_text(">x\nACGT\n")
r = cli.register_asset("../pep", str(f), assets_root=tmp_path / "assets")
_assert_g13_error(r, "BAD_ROLE")
def test_g13_asset_register_exists(tmp_path):
"""前置:同名资产已发布。步骤:register_asset 再次同名。预期:G13 错误,code=ASSET_EXISTS。"""
f = tmp_path / "x.fa"
f.write_text("V1")
assets = tmp_path / "assets"
assert cli.register_asset("pep.fa", str(f), assets_root=assets)["ok"] is True
r = cli.register_asset("pep.fa", str(f), assets_root=assets)
_assert_g13_error(r, "ASSET_EXISTS")
# ---- asset validate ----
def test_g13_asset_validate_not_found(tmp_path):
"""前置:文件不存在。步骤:validate_asset。预期:G13 错误,code=NOT_FOUND。"""
r = cli.validate_asset("pep.fa", str(tmp_path / "nope.fa"))
_assert_g13_error(r, "NOT_FOUND")
def test_g13_asset_validate_bad_role(tmp_path):
"""前置:角色无法推断格式 + txt。步骤:validate_asset。预期:G13 错误,code=BAD_ROLE。"""
f = tmp_path / "x.txt"
f.write_text("hello\n")
r = cli.validate_asset("unknown_role", str(f))
_assert_g13_error(r, "BAD_ROLE")
# ---- report register ----
def test_g13_report_register_empty():
"""前置:空路径。步骤:cmd_report_register("")。预期:G13 错误,code=EMPTY_REPORT。"""
r = cli.cmd_report_register("")
_assert_g13_error(r, "EMPTY_REPORT")
# ---- archive ----
def test_g13_archive_not_found(tmp_path):
"""前置:空 db。步骤:cmd_archive(不存在 job)。预期:G13 错误,code=NOT_FOUND。"""
r = _run(cli.cmd_archive(
"nope", "X", db_path=tmp_path / "empty.db",
jobs_dir=tmp_path / "jobs", archives_dir=tmp_path / "archives"))
_assert_g13_error(r, "NOT_FOUND")
# ---- archive reuse ----
def test_g13_archive_reuse_not_found(tmp_path):
"""前置:空 db。步骤:cmd_archive_reuse。预期:G13 错误,code=ARCHIVE_NOT_FOUND。"""
r = _run(cli.cmd_archive_reuse("不存在", db_path=tmp_path / "empty.db"))
_assert_g13_error(r, "ARCHIVE_NOT_FOUND")
# ===========================================================================
# 3. MCP 薄适配 —— 行为回归 + G13(6 个工具)
# ===========================================================================
# ---- inspect_file ----
def test_mcp_inspect_file_ok(tmp_path):
"""回归。前置:fasta 文件。步骤:mcp.inspect_file。预期:ok=True,format=fasta。"""
f = tmp_path / "p.fa"
f.write_text(">S1\nMKTA\n")
r = _run(mcp.inspect_file(str(f)))
assert r["ok"] is True
assert r["format"] == "fasta"
def test_mcp_inspect_file_not_found(tmp_path):
"""G13。前置:文件不存在。步骤:mcp.inspect_file。预期:G13 错误,code=NOT_FOUND。"""
r = _run(mcp.inspect_file(str(tmp_path / "nope.fa")))
_assert_g13_error(r, "NOT_FOUND")
# ---- transform_file ----
def test_mcp_transform_file_ok(tmp_path):
"""回归。前置:csv(gene,seq)。步骤:mcp.transform_file(fasta, 显式 column_map)。
预期:ok=True,format=fasta,records=2。"""
src = tmp_path / "genes.csv"
src.write_text("gene,seq\nSPL1,MKTA\nSPL2,MKTB\n")
r = _run(mcp.transform_file(str(src), "fasta", {"name": "gene", "seq": "seq"}))
assert r["ok"] is True
assert r["format"] == "fasta"
assert r["records"] == 2
def test_mcp_transform_file_failure(tmp_path):
"""G13。前置:源不存在。步骤:mcp.transform_file。预期:G13 错误,code=NOT_FOUND。"""
r = _run(mcp.transform_file(str(tmp_path / "nope.csv"), "fasta"))
_assert_g13_error(r, "NOT_FOUND")
# ---- fetch_source_file ----
def test_mcp_fetch_source_file_lan_ok(tmp_path):
"""回归。前置:本机文件。步骤:mcp.fetch_source_file(局域网路径, job_id=None)。
预期:ok=True,kind=lan。"""
f = tmp_path / "genome.fa"
f.write_text(">g1\nACGT\n")
r = _run(mcp.fetch_source_file(str(f), job_id=None))
assert r["ok"] is True
assert r["kind"] == "lan"
def test_mcp_fetch_source_file_failure(tmp_path):
"""G13。前置:空源。步骤:mcp.fetch_source_file("")。预期:G13 错误,code=EMPTY_SOURCE。"""
r = _run(mcp.fetch_source_file("", job_id=None))
_assert_g13_error(r, "EMPTY_SOURCE")
# ---- archive_job ----
def test_mcp_archive_job_not_found(api_context):
"""G13。前置:无该任务。步骤:mcp.archive_job(不存在)。预期:G13 错误,code=NOT_FOUND。"""
r = _run(mcp.archive_job("nope", "X"))
_assert_g13_error(r, "NOT_FOUND")
# ---- reuse_archive ----
def test_mcp_reuse_archive_not_found(api_context):
"""G13。前置:无匹配存档。步骤:mcp.reuse_archive。预期:G13 错误,code=ARCHIVE_NOT_FOUND。"""
r = _run(mcp.reuse_archive("不存在"))
_assert_g13_error(r, "ARCHIVE_NOT_FOUND")
def test_mcp_reuse_archive_ok(api_context):
"""回归。前置:seed 存档。步骤:mcp.reuse_archive。预期:ok=True,返回 stage_chain+params。"""
_run(_seed_archive_into(api_context.db, _archive_row(
params={"identify": {"cpu": "8", "memory": "16G"}})))
r = _run(mcp.reuse_archive("SPL鉴定"))
assert r["ok"] is True
assert r["data"]["stage_chain"] == ["identify", "phylogeny"]
assert r["data"]["params"]["identify"]["cpu"] == "8"
# ---- register_job_files ----
def test_mcp_register_job_files_not_found(api_context):
"""G13。前置:无该任务。步骤:mcp.register_job_files(不存在, [路径])。预期:G13 错误,code=NOT_FOUND。"""
r = _run(mcp.register_job_files("nope", ["/tmp/x.fa"]))
_assert_g13_error(r, "NOT_FOUND")
# ===========================================================================
# 4. reports.register_report 的 httpx.Timeout 修复(不再抛 ValueError)
# ===========================================================================
def _fake_httpx_client_for_report(monkeypatch, rep, resp_factory):
"""注入 fake httpx:Timeout 用真实类(校验修复后传参合法),AsyncClient 用 resp_factory。"""
captured: dict = {}
class _Client:
def __init__(self, timeout=None):
captured["timeout"] = timeout
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json=None):
return resp_factory()
monkeypatch.setattr(rep, "httpx", _types.SimpleNamespace(
AsyncClient=_Client, Timeout=_real_httpx.Timeout,
HTTPError=_real_httpx.HTTPError,
))
return captured
def test_register_report_unreachable_no_value_error(monkeypatch):
"""修复。前置:登记端点不可达(ConnectError)。步骤:register_report(register_url)。
预期:不抛 ValueError,返回 degraded=True, reason=unreachable。"""
from app.services.bioflow import reports as rep
def _resp():
raise _real_httpx.ConnectError("unreachable")
_fake_httpx_client_for_report(monkeypatch, rep, _resp)
r = _run(rep.register_report({"job_id": "j1"}, register_url="http://127.0.0.1:1/x"))
assert r == {"ok": False, "degraded": True, "reason": "unreachable"}
def test_register_report_http_error_degraded(monkeypatch):
"""修复。前置:登记端点返回 500。步骤:register_report(register_url)。
预期:不抛 ValueError,返回 degraded=True, reason=http_500。"""
from app.services.bioflow import reports as rep
class _Resp:
status_code = 500
async def json(self):
return {}
_fake_httpx_client_for_report(monkeypatch, rep, lambda: _Resp())
r = _run(rep.register_report({"job_id": "j1"}, register_url="http://127.0.0.1:1/x"))
assert r["ok"] is False
assert r["degraded"] is True
assert r["reason"] == "http_500"
+79
View File
@@ -0,0 +1,79 @@
"""tests/test_phase_c.py —— Phase C(G12 Skill 接入 + 11 工具)补充测试。
契约测试见 test_phase_c_skill.py;本文件补:MCP 注册、cmd_validate_plan(CLI 参数校验)。
分层标记 -m bioflow_unit。
"""
from __future__ import annotations
import asyncio
import pytest
from app.services.bioflow.skill_tools import (
TOOL_NAMES,
control_execution,
validate_plan,
)
from bioflowctl.commands import cmd_validate_plan
pytestmark = pytest.mark.bioflow_unit
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def test_g12_eleven_tools_registered_on_mcp():
from app.mcp_server import build_mcp_servers
gf, rn, _ext = build_mcp_servers()
gf_names = {t.name for t in _run(gf.list_tools())}
rn_names = {t.name for t in _run(rn.list_tools())}
for name in TOOL_NAMES:
assert name in gf_names, f"基因家族端点缺工具 {name}"
assert name in rn_names, f"转录组端点缺工具 {name}"
def test_g12_validate_plan_known():
r = validate_plan(["identify", "phylogeny"])
assert r["ok"] is True
assert r["result"]["valid"] is True
def test_g12_validate_plan_unknown():
r = validate_plan(["bogus_stage"])
assert r["ok"] is False
assert r["error"]["code"] == "UNKNOWN_STAGE"
def test_g12_control_execution_unknown_command():
r = control_execution("", "bogus")
assert r["ok"] is False
assert r["error"]["code"] == "VALIDATION"
def test_g12_control_execution_stop_resume():
# 独立 run_id 避免模块级状态跨用例污染
r1 = control_execution("run-ctx-9", "start")
assert r1["ok"] is True and r1["result"]["done"] is True # claim
r2 = control_execution("run-ctx-9", "stop")
assert r2["ok"] is True and r2["result"]["done"] is True # 所有权匹配 → 终止
# ---- cmd_validate_plan(CLI 层,读单一 schema 校验参数)----
def test_g12_cmd_validate_plan_valid():
r = cmd_validate_plan("identify", {"cpu": "8", "memory": "32g"})
assert r["ok"] is True
def test_g12_cmd_validate_plan_unknown_param():
r = cmd_validate_plan("identify", {"bogus": "x"})
assert r["ok"] is False
assert r["code"] == "VALIDATION_FAILED"
assert "bogus" in r["message"]
def test_g12_cmd_validate_plan_choice_out_of_range():
r = cmd_validate_plan("identify", {"cpu": "99"})
assert r["ok"] is False
assert r["code"] == "VALIDATION_FAILED"
+201
View File
@@ -0,0 +1,201 @@
"""tests/test_phase_c_skill.py —— Phase C(Skill 接入 + 11 工具,G12)测试用例集。
覆盖(Phase C 验收):
1. 11 个分析控制工具:get_task_context / inspect_asset / validate_plan /
check_readiness / get_run_evidence / propose_change / apply_change /
execute_nodes / control_execution / validate_delivery / publish_delivery
—— 每个工具正确委托底层 CLI 命令,返回统一结构化结果(ok+result 或 G13 错误对象)。
2. SKILL.md 结构:frontmatter 齐全(name/description/触发词)、11 工具白名单、
对话剧本、人工边界(subfamily_confirm/rnaseq_de requires_human)、验收清单。
3. 装卸脚本:install/uninstall 正确安装/移除 skill。
## 契约说明(开发对齐用)
- 11 工具集中 `app.services.bioflow.skill_tools`,每个工具为模块级函数,签名见
SKILL_TOOL_SIGNATURES;返回统一 `{"ok": bool, "result"?: ..., "error"?: G13}`。
- SKILL.md 位于 `bioflow_service/SKILL.md`(相对仓库根)。
- 装卸脚本:`bioflow_service/install_skill.sh` 与 `bioflow_service/uninstall_skill.sh`。
未实现函数用 `_phase_c_fn` 动态导入,缺失则 skip(开发完成后须全部存在、无 skip)。
"""
from __future__ import annotations
import importlib
import re
from pathlib import Path
import pytest
pytestmark = pytest.mark.bioflow_unit
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
TOOL_NAMES = [
"get_task_context", "inspect_asset", "validate_plan", "check_readiness",
"get_run_evidence", "propose_change", "apply_change", "execute_nodes",
"control_execution", "validate_delivery", "publish_delivery",
]
# 每个工具的最小调用参数(无参/空参 → 应返回统一结构,失败即 G13)
SKILL_TOOL_SIGNATURES = {
"get_task_context": dict(job_id=None),
"inspect_asset": dict(role="pep.fa", path=""),
"validate_plan": dict(stages=[]),
"check_readiness": dict(assets_root="", stages=[]),
"get_run_evidence": dict(run_id=""),
"propose_change": dict(job_id="", stage="", params={}),
"apply_change": dict(action_id="", change={}),
"execute_nodes": dict(job_id="", nodes=[]),
"control_execution": dict(run_id="", command=""),
"validate_delivery": dict(manifest={}),
"publish_delivery": dict(manifest={}),
}
def _phase_c_fn(module: str, name: str):
try:
mod = importlib.import_module(module)
return getattr(mod, name)
except (ImportError, AttributeError) as exc:
pytest.skip(f"Phase C 未实现 {module}.{name}: {exc}")
def _skill_md_path() -> Path | None:
"""定位 SKILL.md(候选路径:bioflow_service/SKILL.md)。"""
candidates = [
REPO_ROOT / "bioflow_service" / "SKILL.md",
REPO_ROOT / "bioflow_service" / "bioflowctl" / "SKILL.md",
REPO_ROOT / "SKILL.md",
]
for p in candidates:
if p.is_file():
return p
return None
def _skill_md_text() -> str | None:
p = _skill_md_path()
if p is None:
return None
return p.read_text(encoding="utf-8", errors="replace")
# ===========================================================================
# G12-1:11 个分析控制工具
# ===========================================================================
def test_g12_skill_exposes_eleven_tools():
"""G12。前置:skill_tools 模块。步骤:读 TOOL_NAMES/模块属性。预期:恰 11 工具。"""
try:
mod = importlib.import_module("app.services.bioflow.skill_tools")
except ImportError:
pytest.skip("Phase C 未实现 app.services.bioflow.skill_tools")
names = getattr(mod, "TOOL_NAMES", None)
if names is None:
# 逐个属性探测
names = [n for n in TOOL_NAMES if hasattr(mod, n)]
assert set(names) == set(TOOL_NAMES), f"工具集不一致: {names}"
@pytest.mark.parametrize("tool", TOOL_NAMES)
def test_g12_tool_returns_unified_structure(tool):
"""G12。前置:工具可调用。步骤:以最小参数调用。预期:返回 dict 含 ok;
失败时携带 G13 错误对象(非裸字符串)。"""
fn = _phase_c_fn("app.services.bioflow.skill_tools", tool)
result = fn(**SKILL_TOOL_SIGNATURES[tool])
assert isinstance(result, dict), f"{tool} 未返回 dict: {result!r}"
assert "ok" in result, f"{tool} 返回缺 ok 键"
if result["ok"] is False:
err = result.get("error") if isinstance(result.get("error"), dict) else result
assert "error_id" in err or "code" in err, f"{tool} 失败返回缺 G13 错误对象"
def test_g12_get_task_context_delegates():
"""G12。前置:get_task_context 工具。步骤:调用。预期:返回 5 字段任务上下文。"""
fn = _phase_c_fn("app.services.bioflow.skill_tools", "get_task_context")
r = fn(job_id=None)
if r.get("ok") is True:
ctx = r.get("result") or r.get("data") or {}
for k in ("session_id", "job_id", "plan_revision", "run_id", "current_stage"):
assert k in ctx, f"缺字段 {k}"
def test_g12_validate_delivery_delegates():
"""G12。前置:validate_delivery 工具。步骤:不完整 manifest。预期:ok=False + G13。"""
fn = _phase_c_fn("app.services.bioflow.skill_tools", "validate_delivery")
r = fn(manifest={})
assert isinstance(r, dict) and "ok" in r
# ===========================================================================
# G12-2:SKILL.md 结构
# ===========================================================================
def test_skill_md_exists():
"""SKILL.md。前置:文件存在。步骤:定位。预期:找到 SKILL.md。"""
assert _skill_md_path() is not None, "未找到 SKILL.md"
def test_skill_md_frontmatter():
"""SKILL.md。前置:frontmatter。步骤:解析 name/description/触发词。
预期:name/description 非空,含触发词。"""
text = _skill_md_text()
assert text is not None
m = re.match(r"^---\s*\n(.*?)\n---", text, flags=re.S)
assert m is not None, "缺 frontmatter"
fm = m.group(1)
assert "name:" in fm, "frontmatter 缺 name"
assert "description:" in fm, "frontmatter 缺 description"
# 触发词(name 或 description 或 triggers 字段)
assert any(k in fm for k in ("triggers", "触发", "生物", "基因家族", "生信")), "缺触发词"
def test_skill_md_eleven_tools_whitelist():
"""SKILL.md。前置:正文。步骤:检查 11 工具白名单。预期:11 工具名全部列出。"""
text = _skill_md_text()
assert text is not None
for tool in TOOL_NAMES:
assert tool in text, f"SKILL.md 缺工具 {tool}"
def test_skill_md_human_boundaries():
"""SKILL.md。前置:正文。步骤:检查人工边界。预期:subfamily_confirm/rnaseq_de 标 requires_human。"""
text = _skill_md_text()
assert text is not None
assert "subfamily_confirm" in text, "缺 subfamily_confirm 人工边界"
assert "rnaseq_de" in text, "缺 rnaseq_de 人工边界"
assert "requires_human" in text, "缺 requires_human 标注"
def test_skill_md_acceptance_checklist():
"""SKILL.md。前置:正文。步骤:检查验收清单。预期:含验收/检查清单。"""
text = _skill_md_text()
assert text is not None
assert any(k in text for k in ("验收", "acceptance", "检查清单", "checklist")), "缺验收清单"
# ===========================================================================
# G12-3:装卸脚本
# ===========================================================================
def test_skill_install_script_exists():
"""装卸。前置:install 脚本。步骤:定位。预期:存在 install_skill.sh。"""
p = REPO_ROOT / "bioflow_service" / "install_skill.sh"
assert p.is_file(), f"未找到 install 脚本: {p}"
def test_skill_uninstall_script_exists():
"""装卸。前置:uninstall 脚本。步骤:定位。预期:存在 uninstall_skill.sh。"""
p = REPO_ROOT / "bioflow_service" / "uninstall_skill.sh"
assert p.is_file(), f"未找到 uninstall 脚本: {p}"
def test_skill_install_uninstall_scripts_content():
"""装卸。前置:两脚本。步骤:读内容。预期:install 含安装、uninstall 含移除逻辑。"""
install = REPO_ROOT / "bioflow_service" / "install_skill.sh"
uninstall = REPO_ROOT / "bioflow_service" / "uninstall_skill.sh"
assert install.is_file(), f"未找到 install 脚本: {install}"
assert uninstall.is_file(), f"未找到 uninstall 脚本: {uninstall}"
itxt = install.read_text(encoding="utf-8", errors="replace")
utxt = uninstall.read_text(encoding="utf-8", errors="replace")
assert "SKILL" in itxt or "skill" in itxt
assert "rm" in utxt or "remove" in utxt.lower() or "uninstall" in utxt.lower()
+228
View File
@@ -0,0 +1,228 @@
"""tests/test_phase_d.py —— Phase D(G7-G11 + G14 + G15)补充测试(契约测试见 test_phase_d_contract.py)。
覆盖契约未直接断言的实现细节:
- classify_reason(runner 失败原因 + 退出码/日志证据定类)。
- auto_fill_prerequisites 的 satisfied_roles 跳过上游(G7 端到端)。
- stage_schema 单一 schema 访问点(G9)。
- RecoveryBudget.decide 的 runner 集成语义 + runner 自动重试(G15)。
- task_context.current_stage。
分层标记 -m bioflow_unit。
"""
from __future__ import annotations
import asyncio
import pytest
from app.services.bioflow.catalog import build_catalog, stage_schema
from app.services.bioflow.errors import classify_reason
from app.services.bioflow.kernel import ImageMap
from app.services.bioflow.readiness import check_readiness
from app.services.bioflow.recovery import RecoveryBudget
from app.services.bioflow.runner import JOB_FAILED, STAGE_FAILED, JobRecord
from app.services.bioflow.stage_assets import (
downstream_entry_satisfied,
stage_input_roles,
upstream_stages_for_roles,
)
from app.services.bioflow.task_context import current_stage, get_task_context
from conftest import FakeTimedOutExecutor, make_runner
pytestmark = pytest.mark.bioflow_unit
def _scenario(coro_factory):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro_factory())
finally:
loop.close()
def _rnaseq_catalog():
im = ImageMap(stages={
"rnaseq_qc": {"image": "bioflow-rnaseq", "module": "cli.run_rnaseq_qc", "executable": True},
"rnaseq_align": {"image": "bioflow-rnaseq", "module": "cli.run_rnaseq_align", "executable": True},
"rnaseq_quant": {"image": "bioflow-rnaseq", "module": "cli.run_rnaseq_quant", "executable": True},
"rnaseq_sample_qc": {"image": "bioflow-rnaseq", "module": "cli.run_rnaseq_sample_qc", "executable": True},
"rnaseq_de": {"image": "bioflow-rnaseq", "module": "cli.run_rnaseq_de", "executable": True},
})
return build_catalog(im)
# ===========================================================================
# G14 classify_reason(runner 失败原因 + 证据)
# ===========================================================================
def test_g14_reason_timeout_transient():
cat, diag, retry, _ = classify_reason("timeout")
assert cat == "transient" and retry is True
def test_g14_reason_exit_code_undiagnosed():
cat, diag, retry, _ = classify_reason("exit_code")
assert cat == "adapter" and diag == "undiagnosed" and retry is False
def test_g14_reason_no_manifest_artifact():
cat, *_ = classify_reason("no_manifest")
assert cat == "artifact"
def test_g14_exit_code_137_resource():
cat, *_ = classify_reason("exit_code", exit_code=137)
assert cat == "resource"
def test_g14_log_oom_resource():
cat, *_ = classify_reason("exit_code", log_tail="Error: out of memory in container")
assert cat == "resource"
def test_g14_log_image_environment():
cat, *_ = classify_reason("exit_code", log_tail="docker: no such image")
assert cat == "environment"
def test_g14_log_scientific_precondition():
cat, *_ = classify_reason("exit_code", log_tail="no significant DEGs found")
assert cat == "scientific_precondition"
def test_g14_log_reference():
cat, *_ = classify_reason("exit_code", log_tail="reference genome index not found")
assert cat == "reference"
# ===========================================================================
# G7/G8 角色化资产 + 下游直入
# ===========================================================================
def test_g8_stage_input_roles_counts_only():
assert stage_input_roles("rnaseq_de") == ["counts"]
assert stage_input_roles("unknown") == []
def test_g7_downstream_entry_satisfied_tuple():
ok, missing = downstream_entry_satisfied("rnaseq_de", {"counts": {"validated": {"ok": True}}})
assert ok is True and missing == []
def test_g7_auto_fill_skips_counts_upstream():
catalog = _rnaseq_catalog()
ordered, _ = catalog.auto_fill_prerequisites(["rnaseq_de"])
assert set(["rnaseq_qc", "rnaseq_align", "rnaseq_quant", "rnaseq_sample_qc"]) <= set(ordered)
ordered2, added2 = catalog.auto_fill_prerequisites(["rnaseq_de"], satisfied_roles=["counts"])
assert "rnaseq_qc" not in ordered2 and "rnaseq_align" not in ordered2
assert "rnaseq_quant" not in ordered2 and "rnaseq_sample_qc" not in ordered2
assert ordered2 == ["rnaseq_de"] and added2 == []
def test_g7_upstream_stages_for_roles():
up = upstream_stages_for_roles(["counts"])
assert set(["rnaseq_qc", "rnaseq_align", "rnaseq_quant", "rnaseq_sample_qc"]) <= up
# ===========================================================================
# G9 单一参数 schema
# ===========================================================================
def test_g9_stage_schema_single_source():
schema = stage_schema("identify")
assert schema["name"] == "基因家族鉴定"
assert isinstance(schema["params"], list) and schema["params"]
assert schema["dependencies"] == []
assert schema["input_roles"] == ["hmm", "protein_fasta"]
def test_g9_stage_schema_unknown_fallback():
schema = stage_schema("no_such_stage")
assert schema["name"] == "no_such_stage"
assert schema["params"] and schema["params"][0]["name"] == "cpu"
# ===========================================================================
# G10 就绪检查(四象限)
# ===========================================================================
def test_g10_check_readiness_all_ready(tmp_path):
r = check_readiness(assets_root=tmp_path, stages=[])
assert r["ready"] is True
for q in ("environment", "reference", "space", "budget"):
assert q in r and r[q]["ok"] is True
def test_g10_check_readiness_missing_reference(tmp_path):
r = check_readiness(assets_root=tmp_path, stages=["identify"])
assert r["ready"] is False
assert r["reference"]["ok"] is False
assert r["reference"]["error"]["category"] == "reference"
# ===========================================================================
# G11 任务绑定
# ===========================================================================
def test_g11_get_task_context_empty_binding():
ctx = get_task_context()
for k in ("session_id", "job_id", "plan_revision", "run_id", "current_stage"):
assert k in ctx
def test_g11_get_task_context_with_record():
rec = JobRecord(id="j1", session_id="s1", stage_chain=["identify", "phylogeny"])
rec._stage_states = {"identify": {"status": "done"}, "phylogeny": {"status": "running"}}
ctx = get_task_context(rec)
assert ctx["session_id"] == "s1" and ctx["job_id"] == "j1"
assert ctx["current_stage"] == "phylogeny"
def test_g11_current_stage():
rec = JobRecord(id="j1", stage_chain=["a", "b", "c"])
rec._stage_states = {"a": {"status": "done"}, "b": {"status": "done"}, "c": {"status": "running"}}
assert current_stage(rec) == "c"
# ===========================================================================
# G15 恢复预算 + runner 自动重试
# ===========================================================================
def test_g15_recovery_can_retry_semantics():
b = RecoveryBudget(max_same_cause_retries=2)
assert b.can_retry("x") is True
b.record_retry("x")
assert b.can_retry("x") is True
b.record_retry("x")
assert b.can_retry("x") is False
def test_g15_recovery_decide_non_transient():
b = RecoveryBudget()
assert b.decide("s", "exit_code") is False
assert b.decide("s", "no_manifest") is False
def test_g15_recovery_decide_user_stopped():
b = RecoveryBudget()
assert b.decide("s", "timeout", user_stopped=True) is False
def test_g15_recovery_decide_resource_gate():
b = RecoveryBudget()
assert b.decide("s", "exit_code", log_tail="out of memory") is False
def test_g15_runner_auto_retries_transient(db, events, catalog):
executor = FakeTimedOutExecutor()
async def scenario():
runner = make_runner(db, events, executor, catalog)
record = await runner.create_job(
"s1", "t", ["identify"], auto_continue=True, auto_start=False)
await runner.run_job(record)
return runner, record
runner, record = _scenario(scenario)
assert runner.stage_status(record, "identify") == STAGE_FAILED
assert record.status == JOB_FAILED
assert len(executor.calls) == 3 # 1 初试 + 2 自动重试
@@ -0,0 +1,264 @@
"""tests/test_phase_d_contract.py —— Phase D(契约与就绪 + 诊断)测试用例集。
覆盖(Phase D 验收):G7 / G8 / G9 / G10 / G11 / G14 / G15。
## 契约说明(开发对齐用)
- G14 错误分类:`app.services.bioflow.errors.CATEGORIES`(8 类)+ `classify(code, summary)`。
8 类确定性分类:input / scientific_precondition / reference / environment /
transient / resource / adapter / artifact。OOM→resource、镜像缺→environment、
科学前提→scientific_precondition、参考缺失→reference。
- G10 就绪检查:`check_readiness` 四象限(environment/reference/space/budget),
缺项返回结构化错误并阻断(阻断 = ready=False)。
- G11 任务绑定:`get_task_context` 返回 session_id+job_id+plan_revision+run_id+当前阶段。
- G7/G8:`stage_input_roles(stage)` 返回阶段依赖输入角色;`asset_role_satisfied` 判断
角色是否有 validated 资产;下游直入(counts→DE)不补跑上游 QC/align/quant。
- G9 单一参数 schema:所有入口读 `catalog.get(stage).params`(无多份不一致)。
- G15 恢复预算:同因重试 ≤2;收敛条件(scientific_precondition / 无新证据 /
用户停止 / 资源门禁)终止重试。
未实现函数用 `_phase_d_fn` 动态导入,缺失则 skip(开发完成后须全部可导入、无 skip)。
"""
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
from app.services.bioflow import errors
from app.services.bioflow.catalog import build_catalog
pytestmark = pytest.mark.bioflow_unit
def _phase_d_fn(module: str, name: str):
"""动态取 Phase D 函数;未实现则 skip(开发完成后须全部存在)。"""
try:
mod = importlib.import_module(module)
return getattr(mod, name)
except (ImportError, AttributeError) as exc:
pytest.skip(f"Phase D 未实现 {module}.{name}: {exc}")
# ===========================================================================
# G14 错误分类(8 类确定性分类)
# ===========================================================================
def test_g14_categories_exactly_eight():
"""前置:errors 模块。步骤:读 CATEGORIES。预期:恰 8 类。"""
assert set(errors.CATEGORIES) == {
"input", "scientific_precondition", "reference", "environment",
"transient", "resource", "adapter", "artifact",
}
def test_g14_classify_resource():
"""前置:DISK_FULL。步骤:classify。预期:category=resource,不可重试。"""
cat, diag, retry, actions = errors.classify("DISK_FULL", "")
assert cat == "resource"
assert diag == "diagnosed"
assert retry is False
assert isinstance(actions, list)
def test_g14_classify_environment():
"""前置:OSS_UNCONFIGURED。步骤:classify。预期:category=environment。"""
cat, *_ = errors.classify("OSS_UNCONFIGURED", "")
assert cat == "environment"
def test_g14_classify_transient_retryable():
"""前置:NETWORK_ERROR。步骤:classify。预期:category=transient,可重试。"""
cat, diag, retry, _ = errors.classify("NETWORK_ERROR", "")
assert cat == "transient"
assert retry is True
def test_g14_classify_input():
"""前置:NOT_FOUND。步骤:classify。预期:category=input。"""
cat, *_ = errors.classify("NOT_FOUND", "")
assert cat == "input"
def test_g14_classify_adapter():
"""前置:UNSUPPORTED。步骤:classify。预期:category=adapter。"""
cat, *_ = errors.classify("UNSUPPORTED", "")
assert cat == "adapter"
def test_g14_classify_artifact():
"""前置:ARCHIVE_EXISTS。步骤:classify。预期:category=artifact。"""
cat, *_ = errors.classify("ARCHIVE_EXISTS", "")
assert cat == "artifact"
def test_g14_classify_oom_keyword_to_resource():
"""G14 确定性:OOM→resource。前置:无枚举码、摘要含 out of memory。
步骤:classify(None, "container out of memory")。预期:category=resource。"""
cat, *_ = errors.classify(None, "container out of memory")
assert cat == "resource"
def test_g14_classify_image_keyword_to_environment():
"""G14 确定性:镜像缺→environment。前置:摘要含 image。步骤:classify。
预期:category=environment。"""
cat, *_ = errors.classify(None, "docker image not found")
assert cat == "environment"
def test_g14_classify_scientific_precondition():
"""G14 确定性:科学前提→scientific_precondition。前置:摘要含科学前提语义。
步骤:classify。预期:category=scientific_precondition(开发须补齐该映射)。"""
cat, *_ = errors.classify(None, "上游科学前提未满足,无法继续")
assert cat == "scientific_precondition"
def test_g14_classify_reference():
"""G14 确定性:参考缺失→reference。前置:摘要含参考基因组缺失。
步骤:classify。预期:category=reference(开发须补齐该映射)。"""
cat, *_ = errors.classify(None, "参考基因组缺失")
assert cat == "reference"
def test_g14_classify_category_invariant():
"""G14 不变量:任意输入分类结果 category 必在 8 类内。"""
samples = [
("DISK_FULL", ""), ("NOT_FOUND", ""), (None, "timeout"), (None, "unknown_xyz"),
("BAD_SCHEME", ""), ("ASSET_EXISTS", ""),
]
for code, summary in samples:
cat, *_ = errors.classify(code, summary)
assert cat in errors.CATEGORIES, f"分类结果 {cat!r} 不在 8 类内"
def test_g14_error_object_carries_category():
"""G14 集成:error_object 携带 classify 的 category。"""
obj = errors.error_object("DISK_FULL", "磁盘不足")
assert obj["category"] == "resource"
assert obj["retryable_without_change"] is False
# ===========================================================================
# G9 单一参数 schema(所有入口读 catalog.get(stage).params)
# ===========================================================================
def test_g9_catalog_is_single_param_source(image_map):
"""G9。前置:image_map。步骤:两次 build_catalog。预期:参数定义一致(单一来源)。"""
c1 = build_catalog(image_map)
c2 = build_catalog(image_map)
assert c1.get("identify").params == c2.get("identify").params
def test_g9_catalog_params_consistent_shape(image_map):
"""G9。前置:image_map。步骤:读 identify 参数。预期:参数为 list[dict],含 name。"""
entry = build_catalog(image_map).get("identify")
assert isinstance(entry.params, list)
if entry.params:
assert all("name" in p for p in entry.params)
# ===========================================================================
# G10 就绪检查(check_readiness 四象限:环境/参考/空间/预算)
# ===========================================================================
def test_g10_check_readiness_all_ready(tmp_path):
"""G10。前置:四象限齐备。步骤:check_readiness。预期:ready=True,四象限均 ok。"""
check_readiness = _phase_d_fn("app.services.bioflow.readiness", "check_readiness")
r = check_readiness(assets_root=tmp_path, stages=[])
assert r["ready"] is True
for q in ("environment", "reference", "space", "budget"):
assert q in r, f"缺象限 {q}"
assert r[q].get("ok") is True
def test_g10_check_readiness_missing_reference(tmp_path):
"""G10。前置:参考缺失。步骤:check_readiness。预期:ready=False,结构化错误+阻断。"""
check_readiness = _phase_d_fn("app.services.bioflow.readiness", "check_readiness")
r = check_readiness(assets_root=tmp_path, stages=["identify"])
assert r["ready"] is False
# 缺项返回结构化错误(G13 对象)
err = r.get("error") or (r.get("reference") or {}).get("error")
assert err is not None, "缺项应返回结构化错误对象"
for f in ("error_id", "category", "code", "summary", "diagnosis_status"):
assert f in err, f"缺字段 {f}"
def test_g10_check_readiness_four_quadrants_keys(tmp_path):
"""G10。前置:任意。步骤:check_readiness。预期:返回值含四象限键。"""
check_readiness = _phase_d_fn("app.services.bioflow.readiness", "check_readiness")
r = check_readiness(assets_root=tmp_path, stages=[])
for q in ("environment", "reference", "space", "budget"):
assert q in r, f"缺象限键 {q}"
# ===========================================================================
# G11 任务绑定(get_task_context)
# ===========================================================================
def test_g11_get_task_context_fields():
"""G11。前置:绑定态任务。步骤:get_task_context。预期:返回 5 字段。"""
get_task_context = _phase_d_fn("app.services.bioflow.task_context", "get_task_context")
ctx = get_task_context()
for k in ("session_id", "job_id", "plan_revision", "run_id", "current_stage"):
assert k in ctx, f"缺字段 {k}"
# ===========================================================================
# G7/G8 角色化资产 + 下游直入
# ===========================================================================
def test_g8_stage_input_roles():
"""G8。前置:阶段 rnaseq_de。步骤:stage_input_roles。预期:返回 counts 等输入角色。"""
stage_input_roles = _phase_d_fn("app.services.bioflow.stage_assets", "stage_input_roles")
roles = stage_input_roles("rnaseq_de")
assert isinstance(roles, list)
assert roles, "rnaseq_de 应声明输入角色(如 counts)"
def test_g8_role_satisfied_by_validated_asset():
"""G8。前置:validated 资产。步骤:asset_role_satisfied。预期:有 validated 资产即满足。"""
asset_role_satisfied = _phase_d_fn("app.services.bioflow.stage_assets", "asset_role_satisfied")
assert asset_role_satisfied("counts", {"role": "counts", "validated": {"ok": True}}) is True
assert asset_role_satisfied("counts", {"role": "counts", "validated": {"ok": False}}) is False
def test_g7_downstream_direct_entry_counts():
"""G7。前置:提供 counts 资产做 DE。步骤:下游直入判断。
预期:不补跑上游 QC/align/quant(上游角色由 counts 资产满足)。"""
downstream_entry_satisfied = _phase_d_fn(
"app.services.bioflow.stage_assets", "downstream_entry_satisfied")
ok, missing_upstream = downstream_entry_satisfied(
"rnaseq_de", {"counts": {"validated": {"ok": True}}})
assert ok is True
assert missing_upstream == []
# ===========================================================================
# G15 恢复预算(同因重试≤2 + 收敛条件)
# ===========================================================================
def test_g15_same_cause_retry_le_2():
"""G15。前置:恢复预算。步骤:同因连续重试。预期:≤2 次后拒绝。"""
RecoveryBudget = _phase_d_fn("app.services.bioflow.recovery", "RecoveryBudget")
b = RecoveryBudget(max_same_cause_retries=2)
assert b.can_retry("cause_x") is True # 第 1 次
b.record_retry("cause_x")
assert b.can_retry("cause_x") is True # 第 2 次
b.record_retry("cause_x")
assert b.can_retry("cause_x") is False # 第 3 次拒绝(≤2)
def test_g15_convergence_scientific_precondition():
"""G15。前置:scientific_precondition 分类。步骤:判断是否可重试。
预期:科学前提类不重试(收敛)。"""
cat, diag, retry, _ = errors.classify(None, "科学前提未满足")
assert cat == "scientific_precondition"
assert retry is False
def test_g15_convergence_resource_gate():
"""G15。前置:resource 门禁(如 DISK_FULL)。步骤:classify。
预期:资源类不可重试(资源门禁收敛)。"""
cat, diag, retry, _ = errors.classify("DISK_FULL", "")
assert cat == "resource"
assert retry is False
@@ -0,0 +1,184 @@
"""tests/test_phase_e_versioning.py —— Phase E(版本化修改)测试用例集。
覆盖(Phase E 验收):G16 / G17 / G18 / G19 / G20。
## 契约说明(开发对齐用,预期模块 + 函数签名)
- G16 版本化身份字段:plan_revision / run_id(已存在于 JobRecord + task_context.get_task_context);
新增 asset_id / based_on_revision / action_id,落在
`app.services.bioflow.versioning`:
new_revision(based_on_revision: str = None) -> dict
# 返回 {"revision": str, "based_on_revision": str|None,
# "asset_id": str, "action_id": str}
make_action_id() -> str # 唯一 action id
- G17 影响范围:`versioning.affected_stages(stage_chain: list[str], changed_stage: str) -> list[str]`
返回改参数后受影响的下游阶段(只重跑受影响节点;含 changed_stage 之后的所有下游)。
- G18 历史不覆盖:`versioning.RevisionStore(root: str|Path)`
write(revision: str, key: str, content: str) -> Path # 写到 revision 独立路径,不覆盖旧 revision
read(revision: str, key: str) -> str | None
- G19 防重复:`versioning.ActionDedup()`
try_start(action_id: str) -> bool # 首次 True,重复 False(幂等,不重复启动)
- G20 执行所有权:`app.services.bioflow.run_owner`:
claim(run_id: str, owner_id: str) -> bool # 首次 True(取得所有权),重复/他人 False
release(run_id: str, owner_id: str) -> bool
stop(run_id: str, owner_id: str) -> bool # 终止实际执行(非仅置状态)
resume(run_id: str, owner_id: str) -> bool # 已运行则 False(不重叠)
未实现函数用 `_phase_e_fn` 动态导入,缺失则 skip(开发完成后须全部存在、无 skip)。
"""
from __future__ import annotations
import importlib
import pytest
from app.services.bioflow.runner import JobRecord
from app.services.bioflow.task_context import get_task_context
pytestmark = pytest.mark.bioflow_unit
def _phase_e_fn(module: str, name: str):
"""动态取 Phase E 函数/类;未实现则 skip(开发完成后须全部存在)。"""
try:
mod = importlib.import_module(module)
return getattr(mod, name)
except (ImportError, AttributeError) as exc:
pytest.skip(f"Phase E 未实现 {module}.{name}: {exc}")
# ===========================================================================
# G16 版本化身份(plan_revision / run_id / asset_id / based_on_revision / action_id)
# ===========================================================================
def test_g16_job_record_revision_fields():
"""G16。前置:JobRecord。步骤:构造。预期:含 plan_revision + run_id 字段。"""
rec = JobRecord(id="j1")
assert rec.plan_revision == "1"
assert rec.run_id is None
def test_g16_task_context_revision_fields():
"""G16。前置:无 record。步骤:get_task_context()。预期:返回 plan_revision + run_id。"""
ctx = get_task_context()
assert "plan_revision" in ctx
assert "run_id" in ctx
def test_g16_new_revision_identity_fields():
"""G16。前置:versioning.new_revision。步骤:调用。预期:返回 4 个身份字段。"""
new_revision = _phase_e_fn("app.services.bioflow.versioning", "new_revision")
rev = new_revision(based_on_revision="1")
for k in ("revision", "based_on_revision", "asset_id", "action_id"):
assert k in rev, f"缺字段 {k}"
assert rev["based_on_revision"] == "1"
def test_g16_action_id_unique():
"""G16。前置:versioning.make_action_id。步骤:两次调用。预期:action_id 唯一。"""
make_action_id = _phase_e_fn("app.services.bioflow.versioning", "make_action_id")
a = make_action_id()
b = make_action_id()
assert a and b
assert a != b
# ===========================================================================
# G17 影响范围(改参数 → 依赖图 → 受影响下游阶段,只重跑受影响节点)
# ===========================================================================
def test_g17_affected_stages_downstream():
"""G17。前置:链 identify→phylogeny→motif。步骤:affected_stages(changed=identify)。
预期:返回下游 phylogeny/motif(不含已完成的 identify 之外无关节点)。"""
affected_stages = _phase_e_fn("app.services.bioflow.versioning", "affected_stages")
out = affected_stages(["identify", "phylogeny", "motif"], "identify")
assert out == ["phylogeny", "motif"]
def test_g17_affected_stages_no_downstream():
"""G17。前置:改末阶段 motif。步骤:affected_stages。预期:无下游,空列表。"""
affected_stages = _phase_e_fn("app.services.bioflow.versioning", "affected_stages")
out = affected_stages(["identify", "phylogeny", "motif"], "motif")
assert out == []
# ===========================================================================
# G18 历史不覆盖(新 revision 不覆盖旧 revision 的 manifest/产物/参数)
# ===========================================================================
def test_g18_revision_store_preserves_old(tmp_path):
"""G18。前置:RevisionStore。步骤:rev1 写 key,rev2 写同名 key。
预期:rev1 内容不被 rev2 覆盖。"""
RevisionStore = _phase_e_fn("app.services.bioflow.versioning", "RevisionStore")
store = RevisionStore(tmp_path)
store.write("rev1", "params.json", '{"cpu": "2"}')
store.write("rev2", "params.json", '{"cpu": "8"}')
assert store.read("rev1", "params.json") == '{"cpu": "2"}'
assert store.read("rev2", "params.json") == '{"cpu": "8"}'
def test_g18_revision_paths_isolated(tmp_path):
"""G18。前置:RevisionStore。步骤:rev1/rev2 各写产物。
预期:落不同路径(物理隔离,不互覆)。"""
RevisionStore = _phase_e_fn("app.services.bioflow.versioning", "RevisionStore")
store = RevisionStore(tmp_path)
p1 = store.write("rev1", "final.txt", "v1")
p2 = store.write("rev2", "final.txt", "v2")
assert str(p1) != str(p2)
assert p1.read_text() == "v1"
assert p2.read_text() == "v2"
# ===========================================================================
# G19 防重复(action_id 幂等,重复提交不重复启动)
# ===========================================================================
def test_g19_action_dedup_idempotent():
"""G19。前置:ActionDedup。步骤:同 action_id 两次 try_start。
预期:首次 True,第二次 False(幂等)。"""
ActionDedup = _phase_e_fn("app.services.bioflow.versioning", "ActionDedup")
dedup = ActionDedup()
assert dedup.try_start("act-1") is True
assert dedup.try_start("act-1") is False
def test_g19_duplicate_action_not_restarted():
"""G19。前置:ActionDedup。步骤:两个不同 action_id 各自启动,重复一个。
预期:不同 id 各自 True;重复 id 不重复启动。"""
ActionDedup = _phase_e_fn("app.services.bioflow.versioning", "ActionDedup")
dedup = ActionDedup()
assert dedup.try_start("act-1") is True
assert dedup.try_start("act-2") is True
assert dedup.try_start("act-1") is False # 不重复启动
# ===========================================================================
# G20 执行所有权(stop 终止实际执行 / resume 不重叠 / 重启不重复接管)
# ===========================================================================
def test_g20_claim_single_owner():
"""G20。前置:run_owner.claim。步骤:owner A 取得 run,owner B 再 claim。
预期:A True,B False(单一所有权,不重复接管)。"""
claim = _phase_e_fn("app.services.bioflow.run_owner", "claim")
assert claim("run-1", "owner-A") is True
assert claim("run-1", "owner-B") is False
def test_g20_resume_no_overlap():
"""G20。前置:run_owner.resume。步骤:已运行 run 再 resume。
预期:False(不重叠执行)。"""
claim = _phase_e_fn("app.services.bioflow.run_owner", "claim")
resume = _phase_e_fn("app.services.bioflow.run_owner", "resume")
assert claim("run-2", "owner-A") is True
assert resume("run-2", "owner-A") is False
def test_g20_stop_terminates_execution():
"""G20。前置:run_owner.claim + stop。步骤:claim 后 stop。
预期:stop 返回 True(实际终止),随后 release 允许重新 claim。"""
claim = _phase_e_fn("app.services.bioflow.run_owner", "claim")
stop = _phase_e_fn("app.services.bioflow.run_owner", "stop")
release = _phase_e_fn("app.services.bioflow.run_owner", "release")
assert claim("run-3", "owner-A") is True
assert stop("run-3", "owner-A") is True
assert release("run-3", "owner-A") is True
assert claim("run-3", "owner-B") is True # 释放后可被重新接管
+97
View File
@@ -0,0 +1,97 @@
"""tests/test_phase_f.py —— Phase F(交付)测试用例集。
覆盖:G21 交付清单(delivery_validate/publish)、G22 终报告范围(classify_report_scope)、
G23 资源预算门禁(check_download_budget / check_disk_available)。
契约测试见 test_phase_f_delivery.py;本文件补契约未直接断言的点(publish / build_report)。
分层标记 -m bioflow_unit。
"""
from __future__ import annotations
import pytest
from app.services.bioflow.delivery import (
build_report,
check_disk_available,
check_download_budget,
classify_report_scope,
delivery_validate,
publish_delivery,
)
pytestmark = pytest.mark.bioflow_unit
def _complete() -> dict:
return {
"selected_stages": ["identify", "phylogeny"],
"run_id": "run-1",
"input_sources": ["/data/genome.pep.fa"],
"output_sources": ["/data/final/Ft.SPL.final_members.id"],
"config": {"cpu": "8"},
"required_results": ["final_members.id"],
"validation_values": {"final_members": 24},
"review_status": "approved",
}
# G21 补充:publish 幂等/阻断
def test_g21_publish_valid():
m = _complete()
r = publish_delivery(m)
assert r["ok"] is True and r["published"] is True
assert m["review_status"] == "approved"
def test_g21_publish_invalid_blocked():
m = _complete()
m["run_id"] = ""
r = publish_delivery(m)
assert r["ok"] is False and r["published"] is False
def test_g21_validate_checks_eight_keys():
r = delivery_validate(_complete())
assert set(r["checks"]) == {
"selected_stages", "run_id", "input_sources", "output_sources",
"config", "required_results", "validation_values", "review_status",
}
# G22 补充:五分类边界
def test_g22_classify_five_categories():
assert classify_report_scope("done", "user") == "user_provided"
assert classify_report_scope("done", "reuse") == "reused"
assert classify_report_scope("done", None) == "user_provided"
assert classify_report_scope("waiting", None) == "not_executed"
assert classify_report_scope("queued", None) == "not_executed"
assert classify_report_scope("skipped", None) == "skipped"
assert classify_report_scope("failed", None) == "failed"
assert classify_report_scope("partial_success", None) == "not_executed"
# G22 补充:终报告结构
def test_g22_build_report_structure():
m = _complete()
report = build_report(m, stage_sources={"identify": "reused",
"phylogeny": "failed"})
for k in ("execution_scope", "data_sources", "qc", "scientific_preconditions",
"failed_skipped_uncovered", "artifacts"):
assert k in report, f"缺报告段 {k}"
assert report["failed_skipped_uncovered"]["failed"] == ["phylogeny"]
assert report["artifacts"] == ["final_members.id"]
# G23 补充
def test_g23_download_budget_boundary():
assert check_download_budget(100, 100)["ok"] is True # 恰好等于不超
assert check_download_budget(101, 100)["ok"] is False
def test_g23_disk_available_ok(tmp_path):
r = check_disk_available(tmp_path, 1)
assert r["ok"] is True
assert r["free_bytes"] > 0
@@ -0,0 +1,187 @@
"""tests/test_phase_f_delivery.py —— Phase F(交付)测试用例集。
覆盖(Phase F 验收):G21 / G22 / G23。
## 契约说明(开发对齐用,预期模块 + 函数签名)
模块:`app.services.bioflow.delivery`
- G21 交付清单完整性:
delivery_validate(manifest: dict) -> dict
manifest 八项:selected_stages / run_id / input_sources / output_sources /
config / required_results / validation_values / review_status。
返回 {"ok": bool, "checks": {item: bool},
"incomplete": [{"item": str, "reason": str, "error": {G13 对象}}]}
任一项缺失 → ok=False + incomplete 记录 item/reason/结构化错误(未完成原因)。
- G22 终报告范围:
classify_report_scope(status: str, source: str | None = None) -> str
status 终态分类:failed→"failed"、skipped→"skipped"、
非终态(waiting/queued/created/await_*)→"not_executed";
done + source="reuse"→"reused";done + source="user"(或空)→"user_provided"。
- G23 资源预算:
check_download_budget(size_bytes: int, budget_bytes: int) -> dict
# 超限 → {"ok": False, "code": "DOWNLOAD_BUDGET_EXCEEDED", "error": {...}}
check_disk_available(dest_dir, needed_bytes: int) -> dict
# 不足 → {"ok": False, "code": "DISK_FULL", "error": {...}}
两者阻断(ok=False)并返回结构化错误。
未实现函数用 `_phase_f_fn` 动态导入,缺失则 skip(开发完成后须全部存在、无 skip)。
"""
from __future__ import annotations
import importlib
import pytest
pytestmark = pytest.mark.bioflow_unit
def _phase_f_fn(module: str, name: str):
"""动态取 Phase F 函数;未实现则 skip(开发完成后须全部存在)。"""
try:
mod = importlib.import_module(module)
return getattr(mod, name)
except (ImportError, AttributeError) as exc:
pytest.skip(f"Phase F 未实现 {module}.{name}: {exc}")
def _complete_manifest() -> dict:
return {
"selected_stages": ["identify", "phylogeny"],
"run_id": "run-1",
"input_sources": ["/data/genome.pep.fa"],
"output_sources": ["/data/final/Ft.SPL.final_members.id"],
"config": {"cpu": "8"},
"required_results": ["final_members.id"],
"validation_values": {"final_members": 24},
"review_status": "approved",
}
def _g13_fields():
return ("error_id", "category", "code", "summary", "diagnosis_status")
# ===========================================================================
# G21 交付清单完整性(delivery validate)
# ===========================================================================
def test_g21_delivery_validate_complete():
"""G21。前置:八项齐备 manifest。步骤:delivery_validate。预期:ok=True,incomplete 空。"""
delivery_validate = _phase_f_fn("app.services.bioflow.delivery", "delivery_validate")
r = delivery_validate(_complete_manifest())
assert r["ok"] is True
assert r.get("incomplete") == []
def test_g21_delivery_validate_missing_run_id():
"""G21。前置:缺 run_id。步骤:delivery_validate。预期:ok=False,incomplete 含 run_id + 结构化错误。"""
delivery_validate = _phase_f_fn("app.services.bioflow.delivery", "delivery_validate")
m = _complete_manifest()
m["run_id"] = ""
r = delivery_validate(m)
assert r["ok"] is False
items = {i["item"]: i for i in r["incomplete"]}
assert "run_id" in items
err = items["run_id"].get("error")
assert err is not None
for f in _g13_fields():
assert f in err, f"缺字段 {f}"
def test_g21_delivery_validate_missing_required_results():
"""G21。前置:缺 required_results。步骤:delivery_validate。预期:ok=False,未完成原因含 required_results。"""
delivery_validate = _phase_f_fn("app.services.bioflow.delivery", "delivery_validate")
m = _complete_manifest()
m["required_results"] = []
r = delivery_validate(m)
assert r["ok"] is False
items = {i["item"]: i for i in r["incomplete"]}
assert "required_results" in items
assert items["required_results"].get("reason")
def test_g21_delivery_validate_checks_eight_items():
"""G21。前置:完整 manifest。步骤:delivery_validate。预期:checks 覆盖八项。"""
delivery_validate = _phase_f_fn("app.services.bioflow.delivery", "delivery_validate")
r = delivery_validate(_complete_manifest())
checks = r.get("checks")
assert checks is not None, "应返回 checks 覆盖八项"
for k in ("selected_stages", "run_id", "input_sources", "output_sources",
"config", "required_results", "validation_values", "review_status"):
assert k in checks, f"checks 缺项 {k}"
# ===========================================================================
# G22 终报告范围(用户提供 / 历史复用 / 未执行 / 跳过 / 失败)
# ===========================================================================
def test_g22_report_scope_user_provided():
"""G22。前置:done + source=user。步骤:classify_report_scope。预期:user_provided。"""
fn = _phase_f_fn("app.services.bioflow.delivery", "classify_report_scope")
assert fn("done", source="user") == "user_provided"
def test_g22_report_scope_reused():
"""G22。前置:done + source=reuse。步骤:classify_report_scope。预期:reused。"""
fn = _phase_f_fn("app.services.bioflow.delivery", "classify_report_scope")
assert fn("done", source="reuse") == "reused"
def test_g22_report_scope_not_executed():
"""G22。前置:非终态 waiting。步骤:classify_report_scope。预期:not_executed。"""
fn = _phase_f_fn("app.services.bioflow.delivery", "classify_report_scope")
assert fn("waiting", source=None) == "not_executed"
def test_g22_report_scope_skipped():
"""G22。前置:skipped。步骤:classify_report_scope。预期:skipped。"""
fn = _phase_f_fn("app.services.bioflow.delivery", "classify_report_scope")
assert fn("skipped", source=None) == "skipped"
def test_g22_report_scope_failed():
"""G22。前置:failed。步骤:classify_report_scope。预期:failed。"""
fn = _phase_f_fn("app.services.bioflow.delivery", "classify_report_scope")
assert fn("failed", source=None) == "failed"
# ===========================================================================
# G23 资源预算(下载预算超限 / 磁盘不足阻断,结构化错误)
# ===========================================================================
def test_g23_download_budget_exceeded():
"""G23。前置:下载超预算。步骤:check_download_budget。预期:ok=False + 结构化错误阻断。"""
fn = _phase_f_fn("app.services.bioflow.delivery", "check_download_budget")
r = fn(size_bytes=1024 * 1024 * 1024, budget_bytes=100)
assert r["ok"] is False
assert r["code"] == "DOWNLOAD_BUDGET_EXCEEDED"
err = r.get("error")
assert err is not None
for f in _g13_fields():
assert f in err, f"缺字段 {f}"
def test_g23_download_budget_ok():
"""G23。前置:下载未超预算。步骤:check_download_budget。预期:ok=True。"""
fn = _phase_f_fn("app.services.bioflow.delivery", "check_download_budget")
r = fn(size_bytes=100, budget_bytes=1024)
assert r["ok"] is True
def test_g23_disk_insufficient_blocks(tmp_path):
"""G23。前置:磁盘不足(1EB)。步骤:check_disk_available。预期:ok=False + 结构化错误。"""
fn = _phase_f_fn("app.services.bioflow.delivery", "check_disk_available")
r = fn(tmp_path, needed_bytes=10 ** 18)
assert r["ok"] is False
assert r["code"] == "DISK_FULL"
err = r.get("error")
assert err is not None
for f in _g13_fields():
assert f in err, f"缺字段 {f}"
def test_g23_disk_sufficient_ok(tmp_path):
"""G23。前置:磁盘充足(1 字节)。步骤:check_disk_available。预期:ok=True。"""
fn = _phase_f_fn("app.services.bioflow.delivery", "check_disk_available")
r = fn(tmp_path, needed_bytes=1)
assert r["ok"] is True
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# bioflow_service/uninstall_skill.sh —— 卸载 bioflow-analysis skill(G12)。
#
# 用法:bash uninstall_skill.sh [skills_dir]
# - 移除 skills_dir/bioflow-analysis/
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SKILL_NAME="bioflow-analysis"
SKILLS_DIR="${1:-$HERE/../.claude/skills}"
DEST="$SKILLS_DIR/$SKILL_NAME"
if [ -d "$DEST" ]; then
rm -rf "$DEST"
echo "[uninstall] removed $DEST"
else
echo "[uninstall] $DEST 不存在(已卸载)"
fi
echo "DONE"
+91
View File
@@ -0,0 +1,91 @@
#!/bin/bash
# deploy/bioflow_switch_restart.sh —— 切分支后重启生信相关服务并验证
#
# 背景:QA(8091)/前端(5180)/bioflow_service(8098) 都是「启动时刻」快照,
# 切分支后不重启就会跑旧分支的代码/配置(曾导致「设置面板无生信开关」+
# 「规划降级」两个问题)。本脚本把「切分支→重启→验证」固化,避免再踩。
#
# 用法:bash deploy/bioflow_switch_restart.sh [分支名]
# 分支名缺省 feature/bioflow-skill-orchestration
#
# 重启:QA(8091) + bioflow_service(8098) + 前端 vite(5180)
# BFF(8090) 不重启:/api/capabilities 每次请求动态拉 QA registry,QA 重启后自动生效
# 验证:服务健康 + QA registry 生信能力 + 前端 SettingsPanel 生信三开关
#
# 关键点(勿删):
# - QA 需 source agent/qa/env/prod.env + 强制 QA_V1_ENABLE_BIOFLOW_AGENT=1(熔断开)
# - 8098 裸启动即可:main.py 已内置 load_dotenv,自动加载 bioflow_service/.env
# - 前端 pkill 用「zh/PlantAI-rag-new/ui/」精确匹配,避免误杀 5181(wjq 仓库) 的 vite
set -uo pipefail
ROOT="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new"
VENV="/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python"
BRANCH="${1:-feature/bioflow-skill-orchestration}"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo " ✅ $1"; }
bad() { FAIL=$((FAIL+1)); echo " ❌ $1"; }
echo "======== 1. 切分支 ========"
cd "$ROOT" || { echo "仓库目录不存在: $ROOT"; exit 1; }
git checkout "$BRANCH" || { echo "切分支失败: $BRANCH"; exit 1; }
echo "当前分支: $(git branch --show-current)"
echo ""
echo "======== 2. 重启 QA (8091) ========"
pkill -f "scripts.qa_v1_1_73_server" 2>/dev/null; sleep 2
cd "$ROOT/agent/qa"
set -a; source env/prod.env; set +a
export QA_V1_ENABLE_BIOFLOW_AGENT=1 # 生信对话熔断开关,切生信分支必须开
nohup "$VENV" -m uvicorn scripts.qa_v1_1_73_server:app --host 127.0.0.1 --port 8091 \
> /tmp/plantai-qa-8091.log 2>&1 < /dev/null &
disown
echo ""
echo "======== 3. 重启 bioflow_service (8098) ========"
pkill -f "app.main:app.*port 8098" 2>/dev/null; sleep 2
cd "$ROOT/bioflow_service"
nohup "$VENV" -m uvicorn app.main:app --host 127.0.0.1 --port 8098 \
> /tmp/plantai-bioflow-8098.log 2>&1 < /dev/null &
disown
echo ""
echo "======== 4. 重启前端 vite (5180) ========"
pkill -f "zh/PlantAI-rag-new/ui/node_modules/.bin/vite" 2>/dev/null; sleep 2
cd "$ROOT/ui"
VITE_BFF_TARGET="http://127.0.0.1:8090" setsid npm run dev \
> /tmp/plantai-vite.log 2>&1 < /dev/null &
disown
echo ""
echo "等待服务启动 (12s)..."
sleep 12
echo ""
echo "======== 5. 验证 ========"
echo "[健康检查]"
c=$(curl -s -m 3 -o /dev/null -w "%{http_code}" http://127.0.0.1:8098/docs 2>/dev/null)
[ "$c" = "200" ] && ok "8098 bioflow_service /docs=$c" || bad "8098 bioflow_service /docs=$c(见 /tmp/plantai-bioflow-8098.log)"
c=$(curl -s -m 3 -o /dev/null -w "%{http_code}" http://127.0.0.1:8091/api/v1/qa-v1-1-73/capabilities/registry 2>/dev/null)
[ "$c" = "200" ] && ok "8091 QA registry=$c" || bad "8091 QA registry=$c(见 /tmp/plantai-qa-8091.log)"
c=$(curl -s -m 3 -o /dev/null -w "%{http_code}" http://127.0.0.1:5180/ 2>/dev/null)
[ "$c" = "200" ] && ok "5180 前端 =$c" || bad "5180 前端 =$c(见 /tmp/plantai-vite.log)"
echo "[生信能力注册]"
REG=$(curl -s -m 5 http://127.0.0.1:8091/api/v1/qa-v1-1-73/capabilities/registry 2>/dev/null)
BIO=$(echo "$REG" | grep -o '"name":"bioflow-[a-z-]*"' | wc -l)
[ "$BIO" -ge 3 ] && ok "QA registry 生信能力 $BIO 个(期望 3)" || bad "QA registry 生信能力仅 $BIO 个(期望 3)"
echo "[前端生信三开关]"
SW=$(curl -s -m 5 -H "Accept: */*" "http://127.0.0.1:5180/src/components/settings/SettingsPanel.vue" 2>/dev/null | grep -c "settingsBioflow\|bioflowCaps")
[ "$SW" -ge 2 ] && ok "前端 SettingsPanel 含「生信分析能力」三开关" || bad "前端 SettingsPanel 无生信三开关(可能旧代码,需硬刷新浏览器)"
echo ""
echo "======== 结果: $PASS 通过 / $FAIL 失败 ========"
if [ "$FAIL" -eq 0 ]; then
echo "✅ 全部就绪。浏览器请 Ctrl+Shift+R 硬刷新(清缓存),再测生信对话。"
else
echo "❌ 有失败项,请查看 /tmp/plantai-qa-8091.log /tmp/plantai-bioflow-8098.log /tmp/plantai-vite.log"
fi
exit "$FAIL"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# deploy/paper_restart.sh —— 论文撰写服务重启(8097,dev 树明文;不触碰 8090/8091/8098)。
# 用法:bash deploy/paper_restart.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
bash "$REPO_ROOT/paper_writer_service/restart.sh"
echo "paper_writer_service 重启已触发(端口 8097,日志 paper_writer_service/paper.log)"
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# deploy/toggle_paper.sh —— 论文撰写快速开关(dev 树 175;不触碰生产 176 /srv/plantai)。
# 用法:bash deploy/toggle_paper.sh off|on
# 原理:BFF main.py 按 PAPER_WRITER_ENABLED 决定是否注册 /api/paper 路由;
# 不注册时 /api/paper/health 返回 404,前端探活落 offline,触发词落 docgen 单发降级。
set -uo pipefail
ACTION="${1:-}"
SDK_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/sdk"
ENV_FILE="$SDK_DIR/.env"
PY="/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python"
BIND="${PAPER_BFF_BIND:-0.0.0.0}"
if [ "$ACTION" != "off" ] && [ "$ACTION" != "on" ]; then
echo "用法: $0 <off|on>" >&2
exit 2
fi
VAL="true"
[ "$ACTION" = "off" ] && VAL="false"
# 1. 改 env(追加或覆盖)
if grep -q "^PAPER_WRITER_ENABLED=" "$ENV_FILE"; then
sed -i "s/^PAPER_WRITER_ENABLED=.*/PAPER_WRITER_ENABLED=$VAL/" "$ENV_FILE"
else
echo "PAPER_WRITER_ENABLED=$VAL" >> "$ENV_FILE"
fi
echo "PAPER_WRITER_ENABLED -> $VAL"
# 2. 重启 BFF 8090(dev 树;仅取 LISTEN 者;venv 必须绝对路径,cd sdk 后才能 import app.main)
kill $(lsof -ti tcp:8090 -sTCP:LISTEN) 2>/dev/null || true
sleep 2
cd "$SDK_DIR" && set -a && . .env && set +a
( cd "$SDK_DIR" && setsid "$PY" -m uvicorn app.main:app --host "$BIND" --port 8090 \
> /tmp/plantai-bff.log 2>&1 < /dev/null & ) && disown -a
# 3. 自检
for i in $(seq 1 20); do
sleep 2
curl -s -m 3 http://127.0.0.1:8090/api/health >/dev/null 2>&1 && break
done
curl -s -m 10 http://127.0.0.1:8090/api/health | head -c 120
echo
if [ "$ACTION" = "off" ]; then
CODE=$(curl -s -o /dev/null -w "%{http_code}" -m 8 http://127.0.0.1:8090/api/paper/health)
echo "paper/health HTTP $CODE (off 期望 404)"
[ "$CODE" = "404" ] && echo "OFF_OK" || echo "OFF_CHECK_FAIL"
else
curl -s -m 8 http://127.0.0.1:8090/api/paper/health | head -c 150
echo
fi
+1
View File
@@ -0,0 +1 @@
"""paper_writer_service —— 论文撰写独立 MCP 服务(8097)。"""
+53
View File
@@ -0,0 +1,53 @@
"""config.py —— paper 服务(8097)环境变量驱动配置(pydantic-settings)。
全部键可用环境变量覆盖(前缀 PAPER_;LLM 沿用 docgen 的 DOCGEN_LLM_* 直连模式,
由 services/paper/llm.py 直接读 os.environ)。零硬编码宿主路径:相对路径按 __file__ 推导。
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
_SERVICE_ROOT = Path(__file__).resolve().parent.parent # paper_writer_service/
def _resolve(v: str) -> str:
"""仓库内相对路径按 __file__ 推导服务根解析为绝对路径。"""
p = Path(v).expanduser()
if not p.is_absolute():
p = _SERVICE_ROOT / p
return str(p)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
env_prefix="PAPER_",
)
app_name: str = "plantai-paper-writer-service"
version: str = "0.1.0"
db_path: str = "paper.db" # 独立 SQLite(与 BFF 完全隔离)
vstore_base_url: str = "http://127.0.0.1:8094"
read_timeout: float = 360.0 # write_paper_section 同步长调用读超时
@property
def db_path_abs(self) -> str:
return _resolve(self.db_path)
@property
def vstore_base(self) -> str:
return self.vstore_base_url.rstrip("/")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+168
View File
@@ -0,0 +1,168 @@
"""db.py —— paper 服务独立 SQLite(aiosqlite + WAL,无 ORM),三张表。
- papers:论文主表(brief/outline/final_markdown/references/disclaimer)
- paper_sections:章节表(逐节 markdown + 状态)
- citations:结构化引用(temp_n 生成期全局递增,final_n complete 按出现序重排写回)
模式沿用 bioflow/docgen 同款:CREATE TABLE IF NOT EXISTS + aiosqlite 单连接 + WAL + 写锁。
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections.abc import AsyncIterator, Iterable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
from .config import get_settings
logger = logging.getLogger(__name__)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS papers (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
paper_type TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT '',
format_req TEXT NOT NULL DEFAULT '',
style TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'outline',
brief_json TEXT NOT NULL DEFAULT '{}',
outline_json TEXT NOT NULL DEFAULT '{}',
final_markdown TEXT NOT NULL DEFAULT '',
references_json TEXT NOT NULL DEFAULT '[]',
disclaimer TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_papers_session ON papers(session_id);
CREATE INDEX IF NOT EXISTS idx_papers_status ON papers(status);
CREATE TABLE IF NOT EXISTS paper_sections (
id TEXT PRIMARY KEY,
paper_id TEXT NOT NULL REFERENCES papers(id) ON DELETE CASCADE,
section_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
key_points TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'pending',
markdown TEXT NOT NULL DEFAULT '',
word_count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
UNIQUE(paper_id, section_id)
);
CREATE INDEX IF NOT EXISTS idx_paper_sections_paper ON paper_sections(paper_id);
CREATE TABLE IF NOT EXISTS citations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
paper_id TEXT NOT NULL REFERENCES papers(id) ON DELETE CASCADE,
section_id TEXT NOT NULL DEFAULT '',
temp_n INTEGER,
final_n INTEGER,
kind TEXT NOT NULL DEFAULT 'title',
ref_text TEXT NOT NULL DEFAULT '',
source_doc_id TEXT NOT NULL DEFAULT '',
snippet TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_citations_paper ON citations(paper_id);
"""
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def dumps_json(value: Any, default: Any = None) -> str:
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
return json.dumps(default, ensure_ascii=False)
def loads_json(text: str | None, default: Any = None) -> Any:
if text is None:
return default
try:
return json.loads(text)
except (TypeError, ValueError):
return default
class Database:
"""aiosqlite 单连接封装(WAL;写串行化,同 bioflow 模式)。"""
def __init__(self, path: str | Path | None = None) -> None:
self._path: Path | None = Path(path) if path is not None else None
self._conn: aiosqlite.Connection | None = None
self._lock = asyncio.Lock()
@property
def path(self) -> Path:
if self._path is None:
self._path = Path(get_settings().db_path_abs)
return self._path
async def connect(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._conn = await aiosqlite.connect(self.path)
self._conn.row_factory = aiosqlite.Row
await self._conn.execute("PRAGMA journal_mode=WAL")
await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.execute("PRAGMA busy_timeout=5000")
await self._conn.executescript(_SCHEMA)
await self._conn.commit()
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
self._conn = None
@property
def conn(self) -> aiosqlite.Connection:
if self._conn is None:
raise RuntimeError("Database 未初始化:先 await init_db()")
return self._conn
async def fetchall(self, sql: str, params: Iterable[Any] = ()) -> list[aiosqlite.Row]:
async with self._conn.execute(sql, tuple(params)) as cursor:
return list(await cursor.fetchall())
async def fetchone(self, sql: str, params: Iterable[Any] = ()) -> aiosqlite.Row | None:
async with self._conn.execute(sql, tuple(params)) as cursor:
return await cursor.fetchone()
async def execute(self, sql: str, params: Iterable[Any] = ()) -> int:
async with self._lock:
cur = await self._conn.execute(sql, tuple(params))
await self._conn.commit()
return cur.lastrowid
db = Database()
async def init_db(path: str | Path | None = None) -> Database:
# 原地改单例路径后连接(不做 db = Database(path) 重赋值):
# 服务模块以 `from ...db import db` 持有了该单例引用,重赋值会让它们拿到旧对象。
if path is not None:
db._path = Path(path)
await db.connect()
return db
async def get_db() -> AsyncIterator[Database]:
yield db
async def close_db() -> None:
await db.close()
def monotonic() -> float:
return time.monotonic()
+84
View File
@@ -0,0 +1,84 @@
"""main.py —— paper 服务应用入口(uvicorn 入口,明文白名单文件)。
- 独立部署树 paper_writer_service/(FR:独立进程 8097 + 独立 SQLite paper.db)。
- lifespan:init_db(幂等建表)→ 显式进入 MCP session_manager.run()(Starlette 不自动
运行子 Mount 的 lifespan,streamable-http 处理请求时要求 task group 已初始化)→ 清理。
- 端点:/health(GET,BFF 探活反代)+ /mcp(MCP streamable-http)。
- 不依赖 BFF/生产服务(8090 等绝不探测/连接)。
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .db import close_db, db, init_db
from .mcp import paper_mcp
from .services.paper import search
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# MCP streamable-http app(调用即创建 session_manager,供 lifespan 进入 run())。
# 端点地址:/mcp(streamable_http_path="/" + 挂载前缀 /mcp)。
_paper_mcp_app = paper_mcp.streamable_http_app(streamable_http_path="/")
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name, version=settings.version)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
# 测试注入:db 已由测试预置(init_db(tmp_path)),跳过启动初始化。
try:
await init_db()
except Exception:
logger.exception("main: 数据库初始化失败(health 将报 db 降级)")
# MCP session manager 的 task group 必须在服务运行期内保持活跃。
async with paper_mcp.session_manager.run():
try:
yield
finally:
try:
await close_db()
except Exception:
pass
await search.close_client()
app.router.lifespan_context = _lifespan
@app.get("/health")
async def health() -> dict[str, object]:
db_ok = True
db_error: str | None = None
try:
await db.fetchone("SELECT 1 AS ok")
except Exception as exc: # noqa: BLE001
db_ok = False
db_error = str(exc)
return {
"ok": True,
"status": "ok" if db_ok else "degraded",
"db": {"connected": db_ok, "error": db_error},
"version": settings.version,
}
# paper MCP 能力外挂(mcp 2.x:MCPServer.streamable_http_app)。
app.mount("/mcp", _paper_mcp_app)
return app
app = create_app()
+48
View File
@@ -0,0 +1,48 @@
"""app/mcp.py —— paper_writer_service MCP server(单 streamable-http 端点 /mcp)。
9 个工具:propose_paper_outline / write_paper_section / get_paper_progress / search_refs /
revise_paper_section / revise_paper / get_paper / complete_paper / cancel_paper。
mcp 2.x 注意:MCPServer(非 FastMCP,来自 mcp.server.mcpserver);工具返回 dict,由 MCP
序列化为 CallToolResult(structuredContent + content:[{type:"text",text:"<json>"}])。
本文件位于 app/ 层(非 services/),可用 pydantic(mcp 自身依赖 pydantic 构造 schema)。
"""
from __future__ import annotations
from typing import Any
from .services.paper import complete, outline, search, write
def build_paper_mcp() -> Any:
from mcp.server.mcpserver import MCPServer
mcp = MCPServer(
"paper-writer",
title="PlantAI 论文撰写",
description="论文撰写能力单元:大纲生成/逐节撰写/文献引用/全文合并重排/修订。",
)
mcp.tool(name="propose_paper_outline",
description="根据论文主题与写作要求生成大纲并建论文(内部检索知识库注入选题线索)。")(outline.propose_paper_outline)
mcp.tool(name="write_paper_section",
description="撰写论文某一章节(同步长调用,90-180s)。按全局引用编号表生成正文与引用。")(write.write_paper_section)
mcp.tool(name="get_paper_progress",
description="查询论文与各章节撰写进度/字数/引用数。")(complete.get_paper_progress)
mcp.tool(name="search_refs",
description="在知识库(vstore)中检索与查询相关的文献片段。")(search.search_refs)
mcp.tool(name="revise_paper_section",
description="按用户意见重写论文某一章节,新引用追加到引用表末尾。")(write.revise_paper_section)
mcp.tool(name="revise_paper",
description="按用户意见判定需要修改哪些章节,返回受影响节清单与修订计划。")(write.revise_paper)
mcp.tool(name="get_paper",
description="拉取论文全文 markdown、参考文献列表与免责声明。")(complete.get_paper)
mcp.tool(name="complete_paper",
description="合并全文、按首次出现顺序重排引用编号、渲染 GB/T 7714 文献列表。")(complete.complete_paper)
mcp.tool(name="cancel_paper",
description="取消论文(置 cancelled)。")(complete.cancel_paper)
return mcp
paper_mcp = build_paper_mcp()
+10
View File
@@ -0,0 +1,10 @@
"""schemas.py —— paper 服务共享常量(明文白名单文件)。
MCP 工具入参/出参均为 dict(由 mcp 层序列化为 content:[{type:text,text:<json>}]),
本文件只放状态/引用类型常量,避免服务层散落魔法字面量。
"""
from __future__ import annotations
PAPER_STATUSES = ("outline", "writing", "complete", "cancelled", "error")
SECTION_STATUSES = ("pending", "writing", "done", "error")
CITATION_KINDS = ("doi", "pmid", "title")
@@ -0,0 +1,65 @@
"""cite.py —— 引用协议:GB/T 7714 渲染、[n] 校验、complete 按出现序重排。"""
from __future__ import annotations
import re
from typing import Any
_CITE_RE = re.compile(r"\[(\d+)\]")
def find_cite_numbers(markdown: str | None) -> list[int]:
"""正文中出现的所有 [n] 编号(含重复,按出现顺序)。"""
return [int(m) for m in _CITE_RE.findall(markdown or "")]
def render_gbt7714(meta: dict[str, Any], *, fallback_year: str = "2026") -> str:
"""按可用元数据渲染 GB/T 7714 近似条目。
- 有 DOI → [J/OL] + DOI
- 有 PMID → [J] + PMID
- 无 DOI/PMID(知识库资料)→ 降级格式 + [EB/OL]
"""
title = (meta.get("title") or "").strip()
journal = (meta.get("journal") or "").strip()
authors = (meta.get("authors") or "").strip()
year = (meta.get("year") or fallback_year).strip()
doi = (meta.get("doi") or "").strip()
pmid = (meta.get("pmid") or "").strip()
head = f"{authors} " if authors else ""
if doi:
jpart = f"{journal}, {year}." if journal else f"{year}."
return f"{head}{title}[J/OL]. {jpart} DOI:{doi}."
if pmid:
jpart = f"{journal}, {year}." if journal else f"{year}."
return f"{head}{title}[J]. {jpart} PMID:{pmid}."
return f"{head}{title}. 知识库资料, {year}. [EB/OL]."
def render_disclaimer() -> str:
return "参考文献依据知识库资料生成,格式为 GB/T 7714 近似,建议人工核对。"
def first_appearance_order(full_markdown: str, temp_numbers: list[int]) -> list[int]:
"""按全文首次出现顺序返回 temp_n;未出现的排最后(保持原序)。"""
seen: dict[int, int] = {}
for m in _CITE_RE.finditer(full_markdown or ""):
n = int(m.group(1))
if n not in seen and n in temp_numbers:
seen[n] = m.start()
ordered = sorted((n for n in temp_numbers if n in seen), key=lambda n: seen[n])
ordered += [n for n in temp_numbers if n not in seen]
return ordered
def renumber_map(temp_numbers: list[int], full_markdown: str) -> dict[int, int]:
"""temp_n → final_n(按全文首次出现顺序 1..N)。"""
ordered = first_appearance_order(full_markdown, temp_numbers)
return {temp: final for final, temp in enumerate(ordered, start=1)}
def replace_numbers(markdown: str | None, mapping: dict[int, int]) -> str:
def _sub(m: re.Match[str]) -> str:
n = int(m.group(1))
return f"[{mapping.get(n, n)}]"
return _CITE_RE.sub(_sub, markdown or "")
@@ -0,0 +1,109 @@
"""complete.py —— get_paper / get_paper_progress / complete_paper / cancel_paper。"""
from __future__ import annotations
from typing import Any
from ...db import db, dumps_json, loads_json, now_iso
from .cite import render_disclaimer, renumber_map, replace_numbers
async def _load(paper_id: str) -> dict[str, Any]:
paper = await db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,))
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = [dict(r) for r in await db.fetchall(
"SELECT * FROM paper_sections WHERE paper_id = ? ORDER BY rowid", (paper_id,))]
citations = [dict(r) for r in await db.fetchall(
"SELECT * FROM citations WHERE paper_id = ? ORDER BY temp_n", (paper_id,))]
return {"ok": True, "paper": dict(paper), "sections": sections, "citations": citations}
def _merge_sections(paper: dict[str, Any], sections: list[dict[str, Any]]) -> str:
parts = [f"# {paper.get('title', '')}"]
for s in sections:
parts.append(f"\n## {s['title']}\n\n{s.get('markdown', '')}")
return "\n".join(parts)
async def get_paper(paper_id: str, format: str = "md") -> dict[str, Any]:
p = await _load(paper_id)
if not p.get("ok"):
return p
paper, sections, citations = p["paper"], p["sections"], p["citations"]
full_markdown = paper.get("final_markdown") or _merge_sections(paper, sections)
references = loads_json(paper.get("references_json"), []) or [
{"n": c.get("final_n") or c.get("temp_n"), "text": c["ref_text"], "kind": c["kind"],
"source_doc_id": c["source_doc_id"]} for c in citations
]
brief = loads_json(paper.get("brief_json"), {})
return {
"ok": True, "paper_id": paper_id, "title": paper["title"], "status": paper["status"],
"template_key": brief.get("template_key") or "document",
"brief": brief,
"sections": [
{"section_id": s["section_id"], "title": s["title"], "status": s["status"],
"word_count": s["word_count"]} for s in sections
],
"full_markdown": full_markdown, "references": references,
"disclaimer": paper.get("disclaimer", ""),
}
async def get_paper_progress(paper_id: str) -> dict[str, Any]:
paper = await db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,))
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
paper = dict(paper)
sections = [dict(r) for r in await db.fetchall(
"SELECT * FROM paper_sections WHERE paper_id = ? ORDER BY rowid", (paper_id,))]
cit_rows = [dict(r) for r in await db.fetchall(
"SELECT section_id, COUNT(*) AS c FROM citations WHERE paper_id = ? GROUP BY section_id", (paper_id,))]
cit_by_sec = {r["section_id"]: r["c"] for r in cit_rows}
current = next((s["section_id"] for s in sections if s["status"] != "done"), None)
return {
"ok": True, "paper_id": paper_id, "status": paper["status"], "current_section_id": current,
"sections": [
{"section_id": s["section_id"], "title": s["title"], "status": s["status"],
"word_count": s["word_count"], "citation_count": cit_by_sec.get(s["section_id"], 0)}
for s in sections
],
}
async def complete_paper(paper_id: str) -> dict[str, Any]:
p = await _load(paper_id)
if not p.get("ok"):
return p
paper, sections, citations = p["paper"], p["sections"], p["citations"]
full = _merge_sections(paper, sections)
temp_numbers = [c.get("temp_n") for c in citations if c.get("temp_n")]
mapping = renumber_map(temp_numbers, full)
final_full = replace_numbers(full, mapping)
for c in citations:
await db.execute("UPDATE citations SET final_n = ? WHERE id = ?", (mapping.get(c.get("temp_n")), c["id"]))
for s in sections:
await db.execute("UPDATE paper_sections SET markdown = ? WHERE id = ?",
(replace_numbers(s.get("markdown"), mapping), s["id"]))
ordered = sorted(citations, key=lambda c: mapping.get(c.get("temp_n"), 0))
references = [
{"n": mapping.get(c.get("temp_n"), c.get("temp_n")), "text": c["ref_text"],
"kind": c["kind"], "source_doc_id": c["source_doc_id"]} for c in ordered
]
has_degraded = any(c["kind"] == "title" for c in citations)
disclaimer = render_disclaimer() if has_degraded else ""
await db.execute(
"UPDATE papers SET status = 'complete', final_markdown = ?, references_json = ?, disclaimer = ?, updated_at = ? WHERE id = ?",
(final_full, dumps_json(references), disclaimer, now_iso(), paper_id),
)
return {"ok": True, "full_markdown": final_full, "references": references,
"renumber_map": mapping, "disclaimer": disclaimer}
async def cancel_paper(paper_id: str) -> dict[str, Any]:
paper = await db.fetchone("SELECT id FROM papers WHERE id = ?", (paper_id,))
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
await db.execute("UPDATE papers SET status = 'cancelled', updated_at = ? WHERE id = ?", (now_iso(), paper_id))
return {"ok": True}
@@ -0,0 +1,187 @@
"""services/paper/llm.py —— LLM 调用(httpx 裸调,拷贝自 sdk/app/services/docgen/llm.py)。
- complete_text(system, user, *, temperature=0.3, max_tokens=8000) -> str
- complete_json(...)(response_format json_object + 一次 JSON 修复重试)
- 4 次尝试(1.5s / 3s / 6s 指数退避,429 / 5xx / 超时)
- env 键:DOCGEN_LLM_BASE_URL / DOCGEN_LLM_API_KEY / DOCGEN_LLM_MODEL / DOCGEN_LLM_TIMEOUT
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
import httpx
logger = logging.getLogger(__name__)
DEFAULT_LLM_BASE_URL = "https://api.deepseek.com/v1"
DEFAULT_LLM_MODEL = "deepseek-chat"
DEFAULT_LLM_TIMEOUT = 300.0
BACKOFF_SECONDS = (1.5, 3.0, 6.0)
MAX_ATTEMPTS = 4
class LLMError(Exception):
"""LLM 调用失败(重试尽/响应异常)。"""
def _llm_config() -> dict[str, Any]:
return {
"base_url": (os.environ.get("DOCGEN_LLM_BASE_URL") or DEFAULT_LLM_BASE_URL).rstrip("/"),
"api_key": os.environ.get("DOCGEN_LLM_API_KEY", ""),
"model": os.environ.get("DOCGEN_LLM_MODEL") or DEFAULT_LLM_MODEL,
"timeout": float(os.environ.get("DOCGEN_LLM_TIMEOUT") or DEFAULT_LLM_TIMEOUT),
}
def _extract_text(payload: dict[str, Any]) -> str:
try:
choices = payload.get("choices") or []
if choices:
content = choices[0].get("message", {}).get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(
str(c.get("text", "")) if isinstance(c, dict) else str(c) for c in content
)
except Exception:
pass
return ""
async def _chat_once(
client: httpx.AsyncClient,
cfg: dict[str, Any],
system: str,
user: str,
*,
temperature: float,
max_tokens: int,
json_mode: bool,
timeout: float | None = None,
) -> str:
headers = {"Content-Type": "application/json"}
if cfg["api_key"]:
headers["Authorization"] = f"Bearer {cfg['api_key']}"
body: dict[str, Any] = {
"model": cfg["model"],
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if json_mode:
body["response_format"] = {"type": "json_object"}
resp = await client.post(
f"{cfg['base_url']}/chat/completions",
headers=headers,
json=body,
timeout=timeout if timeout is not None else cfg["timeout"],
)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, dict):
raise LLMError("LLM 响应形态异常(非 JSON 对象)")
text = _extract_text(payload)
if not text:
raise LLMError("LLM 响应缺少 choices[0].message.content")
return text
async def _complete(
system: str,
user: str,
*,
temperature: float,
max_tokens: int,
json_mode: bool,
timeout: float | None = None,
) -> str:
cfg = _llm_config()
last_exc: BaseException | None = None
async with httpx.AsyncClient() as client:
for attempt in range(MAX_ATTEMPTS):
try:
return await _chat_once(
client, cfg, system, user,
temperature=temperature, max_tokens=max_tokens, json_mode=json_mode,
timeout=timeout,
)
except LLMError:
raise
except (httpx.HTTPStatusError, httpx.HTTPError, httpx.TimeoutException) as exc:
last_exc = exc
status = getattr(getattr(exc, "response", None), "status_code", None)
retryable = status is None or status == 429 or status >= 500
if not retryable or attempt == MAX_ATTEMPTS - 1:
raise LLMError(f"LLM 调用失败(HTTP {status or '网络'}):{exc}") from exc
await asyncio.sleep(BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)])
raise LLMError(f"LLM 调用失败:{last_exc}")
async def complete_text(
system: str,
user: str,
*,
temperature: float = 0.3,
max_tokens: int = 8000,
timeout: float | None = None,
) -> str:
"""文本补全。失败抛 LLMError。"""
return await _complete(
system, user, temperature=temperature, max_tokens=max_tokens, json_mode=False,
timeout=timeout,
)
def _repair_json(text: str) -> str:
text = text.strip()
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:]
text = text.strip()
if not text:
raise LLMError("LLM 返回空内容")
if text.startswith("{"):
return text
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return text[start:end + 1]
raise LLMError("LLM 输出中未找到 JSON 对象")
async def complete_json(
system: str,
user: str,
*,
temperature: float = 0.3,
max_tokens: int = 8000,
) -> dict[str, Any]:
"""JSON 补全(json_object + 一次修复重试)。"""
text = await _complete(
system, user, temperature=temperature, max_tokens=max_tokens, json_mode=True
)
try:
return json.loads(text)
except ValueError:
pass
text2 = await _complete(
system + "\n(重要:你的回答必须是单个合法 JSON 对象,不要输出其他任何内容)",
user,
temperature=temperature,
max_tokens=max_tokens,
json_mode=True,
)
try:
return json.loads(text2)
except ValueError:
repaired = _repair_json(text2)
return json.loads(repaired)
@@ -0,0 +1,113 @@
"""outline.py —— propose_paper_outline:建 papers 行 → search_refs 注入线索 → LLM 出大纲 → 预填 sections。"""
from __future__ import annotations
import uuid
from typing import Any
from ...db import db, dumps_json, now_iso
from . import llm, search
_OUTLINE_SYSTEM = (
"你是植物学论文撰写助手。根据用户给出的论文主题与写作要求,生成论文大纲。"
"只输出一个合法 JSON 对象,结构为:"
'{"title": 论文标题, "abstract_expect": 摘要预期内容(一句话), '
'"sections": [{"section_id": "s1", "title": "节标题", "key_points": ["要点1", "要点2"], "target_words": 1200}, ...]}。'
"章节 5-6 节,覆盖 引言/材料与方法/结果/讨论/结论 等综述常见结构;target_words 每节 1200-2500。"
)
_DEFAULT_SECTIONS = [
("s1", "引言"),
("s2", "材料与方法"),
("s3", "结果"),
("s4", "讨论"),
("s5", "结论与展望"),
]
async def propose_paper_outline(
topic: str,
requirements: str = "",
paper_type: str = "",
language: str = "",
length: str = "",
format_req: str = "",
style: str = "",
kb_hints: list[dict[str, Any]] | None = None,
template_key: str = "",
) -> dict[str, Any]:
# 1. 知识库线索:优先用调用方传入的 kb_hints,否则现场 search_refs(失败静默降级)
hints = list(kb_hints or [])
if not hints:
try:
r = await search.search_refs(query=topic, top_k=8)
hints = list(r.get("items") or [])
except Exception:
hints = []
# 2. LLM 出大纲
user = f"论文主题:{topic}\n"
if paper_type:
user += f"论文类型:{paper_type}\n"
if requirements:
user += f"写作要求:{requirements}\n"
if language:
user += f"语言:{language}\n"
if length:
user += f"篇幅:{length}\n"
if format_req:
user += f"格式:{format_req}\n"
if style:
user += f"风格:{style}\n"
if hints:
user += "可参考知识库文献线索(仅选题依据,非硬性引用):\n" + "\n".join(
f"- {h.get('title', '')}: {str(h.get('snippet', ''))[:120]}" for h in hints[:5]
)
try:
outline = await llm.complete_json(_OUTLINE_SYSTEM, user)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"大纲生成失败:{exc}"}
title = str(outline.get("title") or topic).strip() or topic
sections = outline.get("sections") or []
if not sections:
sections = [
{"section_id": sid, "title": t, "key_points": [], "target_words": 1500}
for sid, t in _DEFAULT_SECTIONS
]
# 3. 落库 papers + paper_sections
paper_id = uuid.uuid4().hex[:16]
await db.execute(
"INSERT INTO papers (id, title, paper_type, language, format_req, style, status, brief_json, outline_json, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, 'outline', ?, ?, ?, ?)",
(
paper_id, title, paper_type, language, format_req, style,
dumps_json({
"topic": topic, "requirements": requirements, "length": length,
"kb_hints": hints, "template_key": template_key,
}),
dumps_json({
"title": title,
"abstract_expect": outline.get("abstract_expect", ""),
"sections": sections,
}),
now_iso(), now_iso(),
),
)
for s in sections:
await db.execute(
"INSERT INTO paper_sections (id, paper_id, section_id, title, key_points, status, updated_at) "
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
(
uuid.uuid4().hex[:16], paper_id,
s.get("section_id") or "s1", s.get("title", ""),
dumps_json(s.get("key_points") or []), now_iso(),
),
)
return {
"ok": True,
"paper_id": paper_id,
"title": title,
"abstract_expect": outline.get("abstract_expect", ""),
"sections": sections,
}
@@ -0,0 +1,101 @@
"""search.py —— vstore 8094 知识库检索客户端(search_refs 后端)。
- httpx 裸调 GET /api/vstore/search-chunks
- 失败不抛异常:返回 {"ok": False, "error": {...}},工具层降级为空结果
- vstore chunk 条目无 doi/pmid/journal/year(仅 doc_id/doc_name/snippet/score),
故 search_refs 的 meta 为尽力而为(通常为空)→ 引用多为 title kind → 降级格式 + disclaimer
"""
from __future__ import annotations
import asyncio
import os
from typing import Any
import httpx
DEFAULT_VSTORE_BASE_URL = "http://127.0.0.1:8094"
RETRY_BACKOFF = (0.5, 1.0, 2.0)
MAX_RETRIES = 2
DEFAULT_TIMEOUT = 30.0
_client: httpx.AsyncClient | None = None
def _base_url() -> str:
return (os.environ.get("VSTORE_BASE_URL") or DEFAULT_VSTORE_BASE_URL).rstrip("/")
def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient(
base_url=_base_url(), timeout=httpx.Timeout(DEFAULT_TIMEOUT, connect=5.0)
)
return _client
def set_client(client: httpx.AsyncClient) -> None:
"""注入 client(测试用 MockTransport)。"""
global _client
_client = client
async def close_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
async def search_chunks(
query: str,
*,
top_k: int = 8,
min_score: float = 0.30,
timeout: float = 12.0,
) -> dict[str, Any]:
"""GET /api/vstore/search-chunks。失败返回 {ok:False, error:{...}}(不抛)。"""
client = get_client()
last_exc: BaseException | None = None
for attempt in range(MAX_RETRIES + 1):
try:
resp = await client.get(
"/api/vstore/search-chunks",
params={"q": query, "top_k": top_k, "min_score": min_score},
timeout=timeout,
)
if resp.status_code < 500:
try:
payload = resp.json()
except ValueError:
return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 响应非 JSON"}}
if not isinstance(payload, dict):
return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 响应形态异常"}}
return payload
last_exc = RuntimeError(f"vstore {resp.status_code}")
except (httpx.HTTPError, httpx.TimeoutException) as exc:
last_exc = exc
if attempt < MAX_RETRIES:
await asyncio.sleep(RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF) - 1)])
return {"ok": False, "error": {"code": "UPSTREAM_DOWN", "message": f"vstore 不可达:{last_exc}"}}
async def search_refs(paper_id: str = "", query: str = "", top_k: int = 8) -> dict[str, Any]:
"""search_refs 工具:透传 vstore 检索,映射为 {doc_id,title,snippet,score,meta}。"""
if not query:
return {"ok": False, "code": "BAD_REQUEST", "message": "缺少检索词 query"}
r = await search_chunks(query, top_k=max(1, min(int(top_k), 20)))
if not r.get("ok"):
return {"ok": True, "items": [], "warning": (r.get("error") or {}).get("message", "")}
items: list[dict[str, Any]] = []
for it in r.get("items") or []:
if not isinstance(it, dict):
continue
items.append({
"doc_id": it.get("doc_id") or "",
"title": it.get("doc_name") or it.get("doc_id") or "",
"snippet": it.get("snippet") or "",
"score": it.get("score"),
"meta": {}, # vstore chunk 无 doi/pmid/journal/year,尽力为空
})
return {"ok": True, "items": items}
@@ -0,0 +1,251 @@
"""write.py —— 章节撰写与修订(write_paper_section / revise_paper_section / revise_paper)。"""
from __future__ import annotations
from typing import Any
from ...db import db, dumps_json, loads_json, now_iso
from . import llm
from .cite import find_cite_numbers, render_gbt7714
_WRITE_SYSTEM = (
"你是植物学论文撰写助手,正在撰写论文的某一章节。"
"引用文献一律用 [n] 指代,n 必须取自下方提供的全局编号列表;如需引用新文献,编号按当前最大编号依次 +1。"
"你只能引用「知识库线索」中列出的文献;新引用的元数据只能照抄线索里的 title 和 doc_id。"
"知识库线索里没有 DOI/PMID/作者/期刊/年份,这些字段一律输出空字符串,禁止编造任何 DOI/PMID/作者/期刊。"
"只输出一个合法 JSON 对象:{\"markdown\": 章节正文(不含章标题), \"citations_used\": "
"[{\"temp_n\": 编号, \"title\": \"...\", \"authors\": \"\", \"journal\": \"\", "
"\"year\": \"\", \"doi\": \"\", \"pmid\": \"\", \"source_doc_id\": \"...\", \"snippet\": \"...\"}]}。"
"citations_used 必须列出本节正文中出现的每一个 [n] 引用(既有编号也列出其已有元数据)。"
)
_REVISE_SYSTEM = (
"你是植物学论文撰写助手,正在按用户意见修订论文的某一章节。"
"保持引用协议不变:[n] 指代,n 只能取自全局编号列表或最大编号 +1;禁止杜撰元数据。"
"只输出一个合法 JSON 对象:{\"markdown\": 修订后的章节正文, \"citations_used\": [...]}。"
)
# ---------------------------------------------------------------------------
# 读取辅助
# ---------------------------------------------------------------------------
async def _get_paper(paper_id: str) -> dict[str, Any] | None:
row = await db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,))
return dict(row) if row else None
async def _get_sections(paper_id: str) -> list[dict[str, Any]]:
rows = await db.fetchall(
"SELECT * FROM paper_sections WHERE paper_id = ? ORDER BY rowid", (paper_id,)
)
return [dict(r) for r in rows]
async def _get_citations(paper_id: str) -> list[dict[str, Any]]:
rows = await db.fetchall(
"SELECT * FROM citations WHERE paper_id = ? ORDER BY temp_n", (paper_id,)
)
return [dict(r) for r in rows]
def _global_citations_text(citations: list[dict[str, Any]]) -> str:
if not citations:
return "(当前无已编号文献;新引用从 [1] 开始)"
return "\n".join(f"[{c['temp_n']}] {c['ref_text']}" for c in citations)
def _max_temp_n(citations: list[dict[str, Any]]) -> int:
return max((c.get("temp_n") or 0 for c in citations), default=0)
def _build_write_user(paper: dict, outline: dict, sec: dict, cit_text: str, style: str, cite_mode: str) -> str:
parts: list[str] = []
parts.append(f"论文标题:{paper.get('title', '')}")
parts.append(f"论文类型:{paper.get('paper_type') or '综述'}")
parts.append(f"语言:{paper.get('language') or '中文'}")
parts.append(f"格式要求:{paper.get('format_req') or '无'}")
parts.append(f"写作风格:{style or paper.get('style') or '学术严谨'}")
parts.append("")
parts.append("论文大纲:")
for s in outline.get("sections") or []:
mark = "(当前撰写节)" if s.get("section_id") == sec.get("section_id") else ""
parts.append(f"- {s.get('section_id')}: {s.get('title')}{mark} 要点:{';'.join(s.get('key_points') or [])}")
parts.append("")
parts.append(f"现在撰写章节「{sec.get('title', '')}」(section_id={sec.get('section_id')})。")
if sec.get("key_points"):
parts.append("本节要点:" + ";".join(loads_json(sec.get("key_points"), [])))
parts.append("")
parts.append("全局已编号文献列表([n] 引用只能从这里取值,或从最大编号 +1 起新增):")
parts.append(cit_text)
brief = loads_json(paper.get("brief_json"), {})
kb_hints = brief.get("kb_hints") or []
if kb_hints:
parts.append("")
parts.append("知识库线索(新引用只能从这里选,元数据只照抄 title/doc_id):")
for h in kb_hints:
parts.append(f"- title={h.get('title', '')} | doc_id={h.get('doc_id', '')} | snippet={str(h.get('snippet', ''))[:120]}")
return "\n".join(parts)
def _validate_numbers(markdown: str, valid: set[int]) -> list[int]:
return [n for n in find_cite_numbers(markdown) if n not in valid]
def _kind_of(meta: dict[str, Any]) -> str:
if meta.get("doi"):
return "doi"
if meta.get("pmid"):
return "pmid"
return "title"
def _sanitize_citation(meta: dict[str, Any]) -> dict[str, Any]:
"""丢弃 LLM 可能杜撰的 doi/pmid/authors/journal/year(知识库线索无这些字段),
只保留 title/source_doc_id/snippet,强制走 title kind 降级格式。"""
return {
"title": (meta.get("title") or "").strip(),
"source_doc_id": meta.get("source_doc_id") or "",
"snippet": meta.get("snippet") or "",
}
async def _persist_new_citations(paper_id: str, section_id: str, used: list[Any], existing_nums: set[int]) -> list[dict[str, Any]]:
"""把 citations_used 中的新引用(temp_n 不在既有集合)落库,返回新增摘要。"""
new_citations: list[dict[str, Any]] = []
seen_new: set[int] = set()
for c in used:
if not isinstance(c, dict):
continue
tn = int(c.get("temp_n") or 0)
if not tn or tn in existing_nums or tn in seen_new:
continue
seen_new.add(tn)
meta = _sanitize_citation(c)
ref_text = render_gbt7714(meta)
await db.execute(
"INSERT INTO citations (paper_id, section_id, temp_n, kind, ref_text, source_doc_id, snippet, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
paper_id, section_id, tn, _kind_of(meta), ref_text,
meta.get("source_doc_id") or "", meta.get("snippet") or "", now_iso(),
),
)
new_citations.append({"temp_n": tn, "ref_text": ref_text})
return new_citations
# ---------------------------------------------------------------------------
# 写节
# ---------------------------------------------------------------------------
async def write_paper_section(paper_id: str, section_id: str, style: str = "", cite_mode: str = "") -> dict[str, Any]:
paper = await _get_paper(paper_id)
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = await _get_sections(paper_id)
sec = next((s for s in sections if s["section_id"] == section_id), None)
if not sec:
return {"ok": False, "code": "NOT_FOUND", "message": "章节不存在"}
citations = await _get_citations(paper_id)
outline = loads_json(paper.get("outline_json"), {})
cit_text = _global_citations_text(citations)
existing_nums = {c.get("temp_n") for c in citations}
user = _build_write_user(paper, outline, sec, cit_text, style, cite_mode)
markdown = ""
used: list[Any] = []
bad: list[int] = []
for attempt in range(2):
try:
res = await llm.complete_json(_WRITE_SYSTEM, user, max_tokens=8000)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"写节 LLM 失败:{exc}"}
markdown = res.get("markdown") or ""
used = res.get("citations_used") or []
used_nums = {int(c.get("temp_n") or 0) for c in used if isinstance(c, dict)}
valid = existing_nums | used_nums
bad = _validate_numbers(markdown, valid)
if not bad:
break
user += f"\n(上次正文出现的引用编号 {sorted(bad)} 非法;只能使用已有编号或从 {_max_temp_n(citations) + 1} 起的新编号,重试。)"
else:
return {"ok": False, "code": "CITE_VALIDATION_FAILED", "message": f"引用编号非法:{sorted(bad)}"}
new_citations = await _persist_new_citations(paper_id, section_id, used, existing_nums)
await db.execute(
"UPDATE paper_sections SET markdown = ?, word_count = ?, status = 'done', updated_at = ? WHERE paper_id = ? AND section_id = ?",
(markdown, len(markdown), now_iso(), paper_id, section_id),
)
await db.execute("UPDATE papers SET status = 'writing', updated_at = ? WHERE id = ?", (now_iso(), paper_id))
return {"ok": True, "section_id": section_id, "status": "done", "markdown": markdown,
"citations_used": used, "new_citations": new_citations}
# ---------------------------------------------------------------------------
# 修订
# ---------------------------------------------------------------------------
async def revise_paper_section(paper_id: str, section_id: str, instruction: str) -> dict[str, Any]:
paper = await _get_paper(paper_id)
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = await _get_sections(paper_id)
sec = next((s for s in sections if s["section_id"] == section_id), None)
if not sec:
return {"ok": False, "code": "NOT_FOUND", "message": "章节不存在"}
citations = await _get_citations(paper_id)
outline = loads_json(paper.get("outline_json"), {})
existing_nums = {c.get("temp_n") for c in citations}
user = _build_write_user(paper, outline, sec, _global_citations_text(citations), "", "")
user += "\n\n原章节正文:\n" + (sec.get("markdown") or "")
user += f"\n\n修订要求:{instruction}"
markdown = ""
used: list[Any] = []
bad: list[int] = []
for attempt in range(2):
try:
res = await llm.complete_json(_REVISE_SYSTEM, user, max_tokens=8000)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"修订 LLM 失败:{exc}"}
markdown = res.get("markdown") or ""
used = res.get("citations_used") or []
used_nums = {int(c.get("temp_n") or 0) for c in used if isinstance(c, dict)}
valid = existing_nums | used_nums
bad = _validate_numbers(markdown, valid)
if not bad:
break
user += f"\n(引用编号 {sorted(bad)} 非法,重试。)"
else:
return {"ok": False, "code": "CITE_VALIDATION_FAILED", "message": f"引用编号非法:{sorted(bad)}"}
new_citations = await _persist_new_citations(paper_id, section_id, used, existing_nums)
await db.execute(
"UPDATE paper_sections SET markdown = ?, word_count = ?, status = 'done', updated_at = ? WHERE paper_id = ? AND section_id = ?",
(markdown, len(markdown), now_iso(), paper_id, section_id),
)
return {"ok": True, "section_id": section_id, "markdown": markdown,
"citations_used": used, "citation_delta": len(new_citations), "new_citations": new_citations}
async def revise_paper(paper_id: str, instruction: str) -> dict[str, Any]:
"""LLM 判定受影响章节,返回受影响节清单 + 修订计划(由调用方逐节 revise_paper_section)。"""
paper = await _get_paper(paper_id)
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = await _get_sections(paper_id)
outline = loads_json(paper.get("outline_json"), {})
system = (
"你是论文修订计划助手。根据用户修订意见与论文大纲,判定需要修改哪些章节。"
"只输出一个合法 JSON 对象:{\"affected_section_ids\": [\"s1\", ...], \"plan\": 修订计划(一句话)}。"
)
sec_list = "\n".join(f"{s['section_id']}: {s['title']}" for s in sections)
user = f"论文标题:{paper.get('title')}\n大纲章节:\n{sec_list}\n\n修订意见:{instruction}"
try:
res = await llm.complete_json(system, user, max_tokens=2000)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"修订计划 LLM 失败:{exc}"}
affected = [s for s in (res.get("affected_section_ids") or []) if any(x["section_id"] == s for x in sections)]
return {"ok": True, "affected_section_ids": affected, "plan": res.get("plan", "")}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# build.sh —— PAPER 服务编译硬化(复制自 bioflow_service/build.sh,白名单改造)。
# 白名单(明文):app/main.py(uvicorn 入口)、app/config.py(运维可读面)、
# app/schemas.py(常量面)、app/db.py(aiosqlite 协程取消热区——Cython 协程断流断言
# abort 是既有教训,明文保留)、app/mcp.py(MCP 工具层,mcp 依赖 pydantic + async 工具)、
# services/paper/{llm,search,write,outline,complete}.py(长 LLM/httpx/aiosqlite 调用,
# 断流崩溃高危点,明文走 CPython 原生协程)+ 全部 __init__.py。
# 仅纯同步逻辑(cite.py 等)可编译。
# 用法:build.sh <source-root> <output-dir> [python]
set -euo pipefail
umask 027
SOURCE_ROOT="${1:?Usage: $0 <source-root> <output-dir> [python]}"
OUTPUT_DIR="${2:?Usage: $0 <source-root> <output-dir> [python]}"
PYTHON="${3:-python3}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VISIBILITY_TOOL="${ELF_VISIBILITY_TOOL:-$SCRIPT_DIR/../tooling/compile/tools/elf_visibility.py}"
SOURCE_ROOT="$(realpath "$SOURCE_ROOT")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
[[ -d "$SOURCE_ROOT" ]] || { echo "Paper service source missing: $SOURCE_ROOT" >&2; exit 2; }
[[ -x "$PYTHON" ]] || { echo "Python is not executable: $PYTHON" >&2; exit 2; }
[[ -f "$VISIBILITY_TOOL" ]] || { echo "ELF visibility tool missing: $VISIBILITY_TOOL" >&2; exit 2; }
for command_name in gcc strip rsync; do command -v "$command_name" >/dev/null; done
case "$OUTPUT_DIR" in
"/"|"$(realpath -m "$HOME")"|"$SOURCE_ROOT")
echo "Unsafe output directory: $OUTPUT_DIR" >&2; exit 2 ;;
esac
[[ ! -e "$OUTPUT_DIR" ]] || { echo "Output exists; refusing to overwrite: $OUTPUT_DIR" >&2; exit 2; }
echo "[PAPER BUILD 1/5] Copying source (excluding tests)"
mkdir -p "$OUTPUT_DIR"
rsync -a --exclude '__pycache__' --exclude 'test_*.py' --exclude 'data' --exclude '.env' \
--exclude 'build.sh' --exclude 'restart.sh' --exclude 'pytest.ini' \
"$SOURCE_ROOT/" "$OUTPUT_DIR/"
WHITELIST_PY=(
"app/main.py"
"app/config.py"
"app/schemas.py"
"app/db.py"
"app/mcp.py"
"app/services/paper/llm.py"
"app/services/paper/search.py"
"app/services/paper/write.py"
"app/services/paper/outline.py"
"app/services/paper/complete.py"
)
_is_whitelisted() {
local rel="$1"
[[ "$(basename "$rel")" == "__init__.py" ]] && return 0
for w in "${WHITELIST_PY[@]}"; do
case "$rel" in
$w) return 0 ;;
esac
done
return 1
}
echo "[PAPER BUILD 2/5] Discovering Python modules"
PY_FILES=()
while IFS= read -r -d '' path; do PY_FILES+=("$path"); done \
< <(find "$OUTPUT_DIR" -type f -name '*.py' -print0)
FILTERED=()
for py_file in "${PY_FILES[@]}"; do
rel="${py_file#"$OUTPUT_DIR"/}"
if _is_whitelisted "$rel"; then
echo " [whitelist] $rel (明文保留)"
continue
fi
FILTERED+=("$py_file")
done
PY_FILES=("${FILTERED[@]}")
TOTAL="${#PY_FILES[@]}"
(( TOTAL > 0 )) || { echo "No paper service modules selected" >&2; exit 3; }
INCLUDE_PY="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("INCLUDEPY") or "")')"
EXT_SUFFIX="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("EXT_SUFFIX") or ".so")')"
echo "[PAPER BUILD 3/5] Cython preflight ($TOTAL modules)"
COUNT=0; FAILURES=0
for py_file in "${PY_FILES[@]}"; do
COUNT=$((COUNT+1))
directory="$(dirname "$py_file")"
module="$(basename "$py_file" .py)"
c_file="$directory/$module.c"
echo " [$COUNT/$TOTAL] ${py_file#"$OUTPUT_DIR"/}"
if ! "$PYTHON" -m cython -3 \
--directive always_allow_keywords=true,annotation_typing=False \
--no-docstrings "$py_file" -o "$c_file" 2>/dev/null; then
FAILURES=$((FAILURES+1))
fi
done
(( FAILURES == 0 )) || { echo "Paper service Cython failed: $FAILURES" >&2; exit 3; }
echo "[PAPER BUILD 4/5] GCC hidden visibility + strip"
COUNT=0
for py_file in "${PY_FILES[@]}"; do
COUNT=$((COUNT+1))
directory="$(dirname "$py_file")"
module="$(basename "$py_file" .py)"
c_file="$directory/$module.c"
so_file="$directory/$module$EXT_SUFFIX"
gcc -shared -pthread -fPIC -O2 -fvisibility=hidden \
-I"$INCLUDE_PY" -o "$so_file" "$c_file"
strip --strip-unneeded "$so_file"
"$PYTHON" -B "$VISIBILITY_TOOL" --hide-non-pyinit --check "$so_file" >/dev/null
rm -f -- "$c_file" "$py_file"
done
echo "[PAPER BUILD 5/5] Final checks"
SO_COUNT="$(find "$OUTPUT_DIR" -type f -name '*.so' | wc -l)"
[[ "$SO_COUNT" -eq "$TOTAL" ]] || { echo "count mismatch: $TOTAL vs $SO_COUNT" >&2; exit 4; }
if find "$OUTPUT_DIR" -type f -name '*.c' -print -quit | grep -q .; then
echo "Generated C source remains" >&2; exit 4
fi
echo "Paper service compile+harden complete: modules=$SO_COUNT output=$OUTPUT_DIR"
+6
View File
@@ -0,0 +1,6 @@
[pytest]
python_files = test_*.py
markers =
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
paper_mcp: MCP 协议互操作测试
asyncio_mode = auto
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# restart.sh —— PAPER 服务重启脚本(8097;不触碰生产服务 8090/8091/8092/8093/8094/8098)。
# 零硬编码:venv/python/仓库路径全部从脚本位置推导或环境变量覆盖。
# 用法: restart.sh [python] [port]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PYTHON="${1:-$REPO_ROOT/../plantai-rag-new-venv/bin/python}"
PORT="${2:-8097}"
HOST="${PAPER_BIND_HOST:-127.0.0.1}"
[[ -x "$PYTHON" ]] || { echo "Python not found: $PYTHON" >&2; exit 2; }
APP_MODULE="app.main:app"
if command -v lsof >/dev/null 2>&1; then
PID="$(lsof -ti tcp:"$PORT" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "$PID" ]]; then
echo "[restart] 停止 $PORT 旧进程 pid=$PID"
kill $PID 2>/dev/null || true
sleep 2
fi
fi
cd "$SCRIPT_DIR"
# 加载运行时配置(.env 不入 git):services/paper/llm.py 直接读 DOCGEN_LLM_* 裸键,
# search.py 读 VSTORE_BASE_URL——需 set -a 显式导出(同 bioflow_service/restart.sh 先例)。
set -a
[ -f .env ] && source .env
set +a
echo "[restart] 启动论文撰写服务 :$PORT($PYTHON)"
nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \
> "$SCRIPT_DIR/paper.log" 2>&1 &
echo "[restart] 完成;日志 paper_writer_service/paper.log"
+5
View File
@@ -76,6 +76,11 @@ class Settings(BaseSettings):
bioflow_enabled: bool = False # 三形态部署开关(默认关:只部署 rag 无生信痕迹)
bioflow_service_url: str = "http://127.0.0.1:8098" # 生信服务 8098(BFF 流式反代目标)
# ---- 论文撰写服务(paper_writer_service 8097;PAPER_WRITER_ENABLED 为假时路由不注册)----
paper_writer_enabled: bool = False
paper_writer_url: str = "http://127.0.0.1:8097"
paper_writer_read_timeout: float = 360.0 # write_paper_section 同步长调用读超时(秒)
# ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)----
frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist
+3
View File
@@ -24,6 +24,7 @@ from .config import get_settings
from .db import close_db, init_db
from .routers import capabilities, docgen, extraction, figures, health, kg, legacy, qa, sessions
from .routers import bioflow_proxy
from .routers import paper
from .routers import private_docs
from .upstream import close_client
@@ -59,6 +60,8 @@ def create_app() -> FastAPI:
app.include_router(private_docs.router) # 私有资料问答(FR-PRIV-004)
if get_settings().bioflow_enabled:
app.include_router(bioflow_proxy.router) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028)
if get_settings().paper_writer_enabled:
app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关)
@app.exception_handler(RequestValidationError)
async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
+69 -40
View File
@@ -184,6 +184,60 @@ def _read_artifact_file(settings: Any, rel: str) -> bytes | None:
return None
async def insert_artifact(
db: Database,
*,
session_id: str,
title: str,
markdown: str,
md_rel: str | None = None,
template_name: str = "",
template_key: str = "",
template_css: str = "",
html: str = "",
letterhead: str = "",
file_name: str = "",
) -> str:
"""写制品 md(+可选 html)落盘 + INSERT doc_artifacts,返回 artifact_id。
docgen 与论文撰写(paper)共用:paper 仅传 markdown/title/template_name。
"""
artifact_id = str(uuid.uuid4())
artifact_dir = _artifact_dir(get_settings())
if not md_rel:
md_rel = f"artifacts/{artifact_id}.md"
if markdown:
safe_rel = _safe_rel(md_rel)
if safe_rel:
path = artifact_dir / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
html_rel = ""
if html:
html_rel = f"artifacts/{artifact_id}.html"
safe_rel = _safe_rel(html_rel)
if safe_rel:
path = artifact_dir / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(html, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 模版 HTML 落盘失败(不影响返回): %s", exc)
html_rel = ""
await db.execute(
"INSERT INTO doc_artifacts (id, session_id, status, title, template_name, "
"template_key, template_css, md_path, docx_path, pdf_path, html_path, letterhead, file_name, error, created_at) "
"VALUES (?, ?, 'done', ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?)",
(artifact_id, session_id, title,
template_name, template_key, template_css, md_rel,
html_rel or None, letterhead, file_name, _db_now()),
)
return artifact_id
# ---------------------------------------------------------------------------
# 制品目录配额 / TTL 惰性清理(TL 裁决 2026-09-03:V1 由 BFF 实现)
# ---------------------------------------------------------------------------
@@ -516,13 +570,7 @@ async def _run_docgen_task(
on_progress=on_progress,
)
# ---- 以下与修复前同步路径一致:落盘 + artifact 落库 + 配额复查 ----
artifact_id = str(uuid.uuid4())
markdown = str(result.get("markdown") or "")
# pipeline 的 md_path 在 DOCGEN_ROOT 为绝对路径时也是绝对路径——
# 落库要求相对路径(_safe_rel 拒绝绝对路径,否则导出报「制品内容缺失」,
# 2026-09-10 dev 自测实测)。绝对且在制品根内 → 相对化;否则回退
# artifacts/<artifact_id>.md
# ---- 落盘 + artifact 落库(抽公共 insert_artifact,论文撰写共用)----
artifact_dir = _artifact_dir(get_settings())
md_rel = ""
raw_md_path = str(result.get("md_path") or "")
@@ -535,17 +583,6 @@ async def _run_docgen_task(
md_rel = raw_md_path
except ValueError:
md_rel = ""
if not md_rel:
md_rel = f"artifacts/{artifact_id}.md"
if markdown:
safe_rel = _safe_rel(md_rel)
if safe_rel:
path = artifact_dir / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
template_key = str(result.get("template_key") or "")
if not template_key and template is not None and template.get("source") == "builtin":
@@ -557,34 +594,26 @@ async def _run_docgen_task(
(template or {}).get("name", "") or (template or {}).get("title", "")
)
html = str(result.get("html") or "")
html_rel = ""
if html:
html_rel = f"artifacts/{artifact_id}.html"
safe_rel = _safe_rel(html_rel)
if safe_rel:
path = _artifact_dir(get_settings()) / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(html, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 模版 HTML 落盘失败(不影响返回): %s", exc)
html_rel = ""
try:
quota_after = await _check_quota(db, get_settings())
if quota_after is not None:
logger.warning("docgen: 生成后制品目录已超配额(下一次 generate/export 将被拒绝)")
except Exception as exc:
logger.warning("docgen: 生成后配额复查失败(忽略): %s", exc)
await db.execute(
"INSERT INTO doc_artifacts (id, session_id, status, title, template_name, "
"template_key, template_css, md_path, docx_path, pdf_path, html_path, letterhead, file_name, error, created_at) "
"VALUES (?, ?, 'done', ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?)",
(artifact_id, session_id, title,
tpl_display, template_key, template_css, md_rel,
html_rel or None,
str(result.get("letterhead") or "")[:60],
str(result.get("file_name") or name_for_prompt(description) or title or "")[:60],
_db_now()),
artifact_id = await insert_artifact(
db,
session_id=session_id,
title=title,
markdown=str(result.get("markdown") or ""),
md_rel=md_rel or None,
template_name=tpl_display,
template_key=template_key,
template_css=template_css,
html=html,
letterhead=str(result.get("letterhead") or "")[:60],
file_name=str(result.get("file_name") or name_for_prompt(description) or title or "")[:60],
)
# 终态 progress 快照:全部节 done(限频写库可能截断最后一帧)
final_progress = [
+91
View File
@@ -0,0 +1,91 @@
"""routers/paper.py —— 论文撰写路由(对话大脑)。
条件注册(PAPER_WRITER_ENABLED 为假不注册,同 bioflow_proxy 模式)。
端点:health(探活反代)/ turns(一轮状态机)/ confirm / resume / progress / citations。
"""
from __future__ import annotations
import asyncio
from typing import Any
import httpx
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from ..config import get_settings
from ..db import Database, get_db
from ..services.paper import agent
from ..services.paper.mcp_client import PaperMcpClient
router = APIRouter(prefix="/api/paper", tags=["paper"])
class PaperTurnRequest(BaseModel):
session_id: str
text: str
paper_id: str | None = None
template_key: str = ""
class PaperConfirmRequest(BaseModel):
session_id: str
def _client() -> PaperMcpClient:
s = get_settings()
return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout)
@router.get("/health")
async def paper_health() -> dict[str, Any]:
"""探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。"""
s = get_settings()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{s.paper_writer_url.rstrip('/')}/health")
resp.raise_for_status()
return resp.json()
except Exception: # noqa: BLE001
return {"ok": False, "status": "offline"}
@router.post("/turns")
async def paper_turn(payload: PaperTurnRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
blocks = await agent.handle_turn(db, payload.session_id, payload.text, payload.paper_id, payload.template_key)
return {"ok": True, "blocks": blocks}
@router.post("/papers/{paper_id}/confirm")
async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
return {"ok": True, "paper_id": paper_id}
@router.post("/papers/{paper_id}/resume")
async def paper_resume(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
return {"ok": True, "paper_id": paper_id}
@router.get("/papers/{paper_id}")
async def paper_progress(paper_id: str) -> dict[str, Any]:
client = _client()
try:
return await client.call_tool("get_paper_progress", {"paper_id": paper_id})
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": "UPSTREAM_DOWN", "message": str(exc)}
@router.get("/papers/{paper_id}/citations/{n}")
async def paper_citation(paper_id: str, n: int) -> dict[str, Any]:
client = _client()
try:
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": "UPSTREAM_DOWN", "message": str(exc)}
if not paper.get("ok"):
return paper
for ref in paper.get("references") or []:
if ref.get("n") == n:
return {"ok": True, "citation": ref}
return {"ok": False, "code": "NOT_FOUND", "message": f"引用 [{n}] 不存在"}
+53
View File
@@ -99,6 +99,58 @@ class ArtifactBlock(BaseModel):
title: str = ""
template_name: str = ""
status: str = "done" # running|done|error(生成中/已就绪/失败)
paper_id: str = "" # 论文撰写功能:关联论文 id(DocPane 据此拉 citation popover)
citation_count: int = 0 # 论文撰写功能:引用条数
# ---------------------------------------------------------------------------
# §2c 论文块(paper-writer;BFF 仅持久化校验,字段契约归前端 Paper*Card)
# ---------------------------------------------------------------------------
class PaperBriefBlock(BaseModel):
"""论文引导表单卡:预填已抽取 brief 字段,缺失字段待补。"""
model_config = ConfigDict(extra="allow")
type: Literal["paper_brief"] = "paper_brief"
paper_id: str = ""
status: str = "pending" # pending|submitted
fields: list[dict[str, Any]] = Field(default_factory=list)
class PaperOutlineBlock(BaseModel):
"""论文大纲确认卡:章节勾选/编辑要点。"""
model_config = ConfigDict(extra="allow")
type: Literal["paper_outline"] = "paper_outline"
paper_id: str = ""
title: str = ""
status: str = "pending" # pending|confirmed
sections: list[dict[str, Any]] = Field(default_factory=list)
class PaperProgressBlock(BaseModel):
"""论文节级进度卡:逐节撰写状态。"""
model_config = ConfigDict(extra="allow")
type: Literal["paper_progress"] = "paper_progress"
paper_id: str = ""
status: str = "writing" # writing|done|error|cancelled
sections: list[dict[str, Any]] = Field(default_factory=list)
current_section_id: str = ""
class PaperRefsBlock(BaseModel):
"""论文参考文献卡:文末 GB/T 7714 列表 + disclaimer。"""
model_config = ConfigDict(extra="allow")
type: Literal["paper_refs"] = "paper_refs"
paper_id: str = ""
references: list[dict[str, Any]] = Field(default_factory=list)
disclaimer: str = ""
# ---------------------------------------------------------------------------
@@ -245,6 +297,7 @@ class PrivateQARequest(BaseModel):
Block = Union[
TextBlock, TableBlock, CodeBlock, StreamBlock,
PipelineBlock, ToolBlock, SourcesBlock, GraphBlock, ArtifactBlock,
PaperBriefBlock, PaperOutlineBlock, PaperProgressBlock, PaperRefsBlock,
BioflowPathBlock, BioflowProgressBlock, BioflowFormBlock, BioflowFileBlock,
BioflowArchiveBlock, BioflowQaBlock, BioflowReuseBlock,
PrivateDocBlock,
+20 -1
View File
@@ -7,9 +7,26 @@ assemble_history(db, session_id, rounds=3, budget):
"""
from __future__ import annotations
import html as html_mod
import re
from typing import Any, List
from ..config import get_settings
from ..db import loads_json
def _blocks_text(blocks_json) -> str:
"""从落库 blocks 提取 assistant 答案纯文本(text 块 html 去标签)。"""
blocks = loads_json(blocks_json, default=[])
parts = []
for b in blocks:
if not isinstance(b, dict) or b.get("type") != "text":
continue
h = b.get("html") or b.get("text") or ""
t = html_mod.unescape(re.sub(r"<[^>]+>", "", str(h))).strip()
if t:
parts.append(t)
return "\n".join(parts)
async def assemble_history(
@@ -40,7 +57,7 @@ async def assemble_history(
return []
try:
rows = await db.fetchall(
"SELECT role, status, text FROM messages "
"SELECT role, status, text, blocks FROM messages "
"WHERE session_id = ? AND role IN ('user','assistant') "
"AND status != 'streaming' "
"ORDER BY created_at ASC, rowid ASC",
@@ -55,6 +72,8 @@ async def assemble_history(
for row in reversed(rows):
role = row["role"]
text = (row["text"] or "").strip()
if not text and role == "assistant":
text = _blocks_text(row["blocks"])
if not text:
continue
if role == "assistant":
View File
+314
View File
@@ -0,0 +1,314 @@
"""services/paper/agent.py —— 论文对话规则状态机(brief→outline→writing→complete/revise)。
LLM 仅做 brief 字段抽取(complete_json);大纲/正文/引用/合并全部由 paper 服务完成。
不引入 function calling(BFF 无工具循环先例)。助手消息由本模块进程内直写 messages 表。
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from ...config import get_settings
from ..docgen.llm import complete_json as llm_complete_json
from .mcp_client import PaperMcpClient, PaperMcpError
logger = logging.getLogger(__name__)
BRIEF_FIELDS = [
{"key": "topic", "label": "论文主题", "options": None},
{"key": "paper_type", "label": "论文类型", "options": ["综述", "研究论文", "开题报告", "实验报告"]},
{"key": "language", "label": "语言", "options": ["中文", "英文"]},
{"key": "length", "label": "篇幅(字数)", "options": ["3000字", "5000字", "8000字"]},
{"key": "format_req", "label": "格式要求", "options": ["GB/T 7714", "APA", "MLA"]},
{"key": "style", "label": "写作风格", "options": ["学术严谨", "通俗易懂"]},
{"key": "requirements", "label": "其他要求", "options": None},
]
REQUIRED_BRIEF_KEYS = ("topic", "paper_type", "language", "length")
_BRIEF_SYSTEM = (
"你是论文撰写需求抽取助手。从用户输入中抽取论文写作 brief。"
"只输出一个合法 JSON 对象:{\"topic\":\"\",\"paper_type\":\"\",\"language\":\"\","
"\"length\":\"\",\"format_req\":\"\",\"style\":\"\",\"requirements\":\"\"}。"
"抽取不到的字段填空字符串。"
)
def _db_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _text_block(html: str) -> dict[str, Any]:
return {"type": "text", "html": html}
def _new_client() -> PaperMcpClient:
s = get_settings()
return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout)
async def _write_message(
db,
session_id: str,
*,
kind: str = "paper",
status: str = "done",
blocks: list[dict[str, Any]],
text: str | None = None,
) -> str:
"""直写助手消息(kind=paper),返回 message_id。"""
mid = str(uuid.uuid4())
now = _db_now()
await db.execute(
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, created_at, updated_at) "
"VALUES (?, ?, 'assistant', ?, ?, ?, NULL, ?, ?, ?)",
(mid, session_id, kind, status, json.dumps(blocks, ensure_ascii=False), text, now, now),
)
await db.execute(
"UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, updated_at = ? WHERE id = ?",
(now, session_id),
)
return mid
async def extract_brief(text: str) -> dict[str, str]:
"""LLM 抽取 brief 字段(失败返回全空,走 paper_brief 卡兜底追问)。"""
try:
brief = await llm_complete_json(_BRIEF_SYSTEM, text, max_tokens=1000)
except Exception as exc: # noqa: BLE001
logger.warning("paper: brief 抽取失败:%s", exc)
brief = {}
return {f["key"]: str((brief or {}).get(f["key"]) or "").strip() for f in BRIEF_FIELDS}
def _missing_brief_keys(brief: dict[str, str]) -> list[str]:
return [k for k in REQUIRED_BRIEF_KEYS if not brief.get(k)]
# ---------------------------------------------------------------------------
# 状态机入口
# ---------------------------------------------------------------------------
async def handle_turn(db, session_id: str, text: str, paper_id: str | None = None, template_key: str = "") -> list[dict[str, Any]]:
"""一轮对话:推进状态机并落库助手消息,返回消息的 blocks 列表。"""
client = _new_client()
if not paper_id:
return await _trigger(db, session_id, text, client, template_key)
return await _followup(db, session_id, text, paper_id, client, template_key)
async def _trigger(db, session_id: str, text: str, client: PaperMcpClient, template_key: str = "") -> list[dict[str, Any]]:
brief = await extract_brief(text)
missing = _missing_brief_keys(brief)
if missing:
fields = [{**f, "value": brief.get(f["key"], "")} for f in BRIEF_FIELDS]
blocks = [
_text_block("好的,我来帮你写这篇论文。请补充以下信息(也可以直接打字告诉我):"),
{"type": "paper_brief", "paper_id": "", "status": "pending", "fields": fields},
]
await _write_message(db, session_id, blocks=blocks, text="请补充论文信息")
return blocks
outline = await client.call_tool("propose_paper_outline", {**brief, "template_key": template_key})
if not outline.get("ok"):
blocks = [_text_block(f"论文大纲生成失败:{outline.get('message', '服务未就绪')},请重试。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
return await _outline_message(db, session_id, outline)
async def _outline_message(db, session_id: str, outline: dict[str, Any]) -> list[dict[str, Any]]:
paper_id = outline["paper_id"]
sections = [
{"section_id": s.get("section_id"), "title": s.get("title", ""),
"key_points": s.get("key_points") or [], "selected": True}
for s in (outline.get("sections") or [])
]
blocks = [
_text_block(
f"已为你生成论文大纲《{outline.get('title', '')}》。"
"你可以勾选/编辑章节,或直接说修改意见;确认后我开始逐节撰写。"
),
{"type": "paper_outline", "paper_id": paper_id, "title": outline.get("title", ""),
"status": "pending", "sections": sections},
]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
_CANCEL_WORDS = ("暂停", "取消", "停止", "不写了", "终止", "中止", "算了")
def _is_cancel_request(text: str) -> bool:
s = (text or "").strip()
return bool(s) and any(w in s for w in _CANCEL_WORDS)
async def _followup(db, session_id: str, text: str, paper_id: str, client: PaperMcpClient, template_key: str = "") -> list[dict[str, Any]]:
prog = await client.call_tool("get_paper_progress", {"paper_id": paper_id})
if not prog.get("ok"):
blocks = [_text_block(f"未找到论文({paper_id}),请重新发起撰写。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
status = prog.get("status")
if _is_cancel_request(text):
res = await client.call_tool("cancel_paper", {"paper_id": paper_id})
if res.get("ok"):
blocks = [_text_block("论文已取消。如需重新撰写,请告诉我新主题。")]
else:
blocks = [_text_block(f"取消失败:{res.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
if status == "complete":
return await _revise(db, session_id, paper_id, text, client)
if status == "cancelled":
blocks = [_text_block("论文已取消。如需重新撰写,请告诉我新主题。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
if status in ("outline", "writing"):
# 大纲/撰写阶段:修改意见 → 用原始 brief + 修改意见重新生成大纲(原大纲作废)
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
ob = (paper.get("brief") or {}) if paper.get("ok") else {}
outline = await client.call_tool("propose_paper_outline", {
"topic": ob.get("topic") or paper.get("title") or text,
"requirements": ";".join(x for x in [ob.get("requirements"), text] if x),
"paper_type": ob.get("paper_type") or "",
"language": ob.get("language") or "",
"length": ob.get("length") or "",
"format_req": ob.get("format_req") or "",
"template_key": template_key,
})
if outline.get("ok"):
await client.call_tool("cancel_paper", {"paper_id": paper_id})
return await _outline_message(db, session_id, outline)
blocks = [_text_block("大纲修改失败,请重试。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
blocks = [_text_block("论文撰写中,请稍候。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
async def _revise(db, session_id: str, paper_id: str, text: str, client: PaperMcpClient) -> list[dict[str, Any]]:
plan = await client.call_tool("revise_paper", {"paper_id": paper_id, "instruction": text})
if not plan.get("ok"):
blocks = [_text_block(f"修订计划生成失败:{plan.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
affected = plan.get("affected_section_ids") or []
if not affected:
blocks = [_text_block("没有需要修改的章节。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
for sid in affected:
res = await client.call_tool(
"revise_paper_section", {"paper_id": paper_id, "section_id": sid, "instruction": text}
)
if not res.get("ok"):
blocks = [_text_block(f"章节修订失败:{res.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text="修订失败")
return blocks
comp = await client.call_tool("complete_paper", {"paper_id": paper_id})
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
if comp.get("ok") and paper.get("ok"):
return await _artifact_message(db, session_id, paper, "已按你的意见修改,文献编号已重排。")
blocks = [_text_block("修改后重新生成失败,请重试。")]
await _write_message(db, session_id, blocks=blocks, text="修改失败")
return blocks
# ---------------------------------------------------------------------------
# 写节循环(confirm/resume 后台任务)
# ---------------------------------------------------------------------------
async def run_writing_loop(db, paper_id: str, session_id: str) -> None:
"""后台逐节写节循环:写完→complete→get_paper→写 artifact→artifact 块。"""
from ...routers.docgen import insert_artifact # 惰性导入,避免服务层→路由层常驻耦合
client = _new_client()
# 进度卡由前端确认时本地 seed + 2s 轮询 get_paper_progress 驱动(不落库,避免重载后出现
# 空的「撰写中」卡);后端只落「开始撰写」文本 + 逐节完成小结 + 最终 artifact。
await _write_message(db, session_id, blocks=[_text_block("开始逐节撰写……")], text="开始撰写")
try:
while True:
prog = await client.call_tool("get_paper_progress", {"paper_id": paper_id})
if not prog.get("ok"):
await _write_message(db, session_id, blocks=[_text_block("撰写进度查询失败。")], text="撰写失败")
return
if prog.get("status") == "cancelled":
await _write_message(db, session_id, blocks=[_text_block("论文已取消。")], text="已取消")
return
sections = prog.get("sections") or []
pending = [s for s in sections if s.get("status") != "done"]
if not pending:
break
sec = pending[0]
res = await client.call_tool("write_paper_section", {"paper_id": paper_id, "section_id": sec["section_id"]})
if not res.get("ok"):
await _write_message(db, session_id, blocks=[_text_block(
f"章节「{sec.get('title', '')}」撰写失败:{res.get('message', '')}"
)], text="撰写失败")
return
wc = len(res.get("markdown") or "")
await _write_message(db, session_id, blocks=[_text_block(
f"「{sec.get('title', '')}」已完成,约 {wc} 字。"
)], text=f"「{sec.get('title', '')}」已完成")
comp = await client.call_tool("complete_paper", {"paper_id": paper_id})
if not comp.get("ok"):
await _write_message(db, session_id, blocks=[_text_block(f"论文合并失败:{comp.get('message', '')}")], text="合并失败")
return
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
if not paper.get("ok"):
await _write_message(db, session_id, blocks=[_text_block("论文拉取失败。")], text="拉取失败")
return
await _artifact_message(db, session_id, paper, "论文撰写完成!你可以在右侧文档页查看全文,或用自然语言提出修改意见。")
except PaperMcpError as exc:
logger.warning("paper: 写节循环失败:%s", exc)
await _write_message(db, session_id, blocks=[_text_block(f"论文服务异常:{exc}")], text="论文服务异常")
except Exception as exc: # noqa: BLE001
logger.exception("paper: 写节循环异常")
await _write_message(db, session_id, blocks=[_text_block(f"撰写失败:{exc}")], text="撰写失败")
def _build_full_markdown(paper: dict[str, Any]) -> str:
"""把 full_markdown + 文末参考文献列表 + disclaimer 拼成完整 markdown(供渲染 HTML 与落盘)。"""
md = paper.get("full_markdown", "")
refs = paper.get("references") or []
if refs:
md += "\n\n## 参考文献\n\n" + "\n".join(
f"[{r.get('n', '')}] {r.get('text', '')}".strip() for r in refs
)
disclaimer = (paper.get("disclaimer") or "").strip()
if disclaimer:
md += f"\n\n> {disclaimer}"
return md
async def _artifact_message(db, session_id: str, paper: dict[str, Any], nl: str) -> list[dict[str, Any]]:
from ...routers.docgen import insert_artifact # 惰性导入
from ..docgen import templating # 惰性导入:复用 docgen markdown→HTML 渲染链
refs = paper.get("references") or []
title = paper.get("title", "")
full_md = _build_full_markdown(paper)
html, css = templating.render_html(full_md, title=title, key=paper.get("template_key") or "document")
artifact_id = await insert_artifact(
db, session_id=session_id, title=title,
markdown=full_md, template_name="论文", template_key=paper.get("template_key") or "document",
template_css=css, html=html,
)
blocks = [
_text_block(nl),
{"type": "artifact", "artifact_id": artifact_id, "title": title,
"template_name": "论文", "status": "done", "paper_id": paper.get("paper_id", ""),
"citation_count": len(refs)},
]
await _write_message(db, session_id, blocks=blocks, text=nl)
return blocks
+104
View File
@@ -0,0 +1,104 @@
"""services/paper/mcp_client.py —— 裸 JSON-RPC over httpx 客户端(BFF pyproject 无 mcp 依赖,零新增)。
与 paper_writer_service /mcp/ 端点通信:initialize → tools/call。
响应为 SSE(text/event-stream):`event: message\ndata: <json>\n\n`;会话由 mcp-session-id 头维系。
端点注意:/mcp 无斜杠会 307 重定向,必须请求 /mcp/。
"""
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
PROTOCOL_VERSION = "2025-03-26"
class PaperMcpError(Exception):
"""paper 服务 RPC/工具调用失败。"""
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
super().__init__(message)
self.cause = cause
def _parse_sse_body(text: str) -> dict[str, Any] | None:
"""从 SSE 响应抽取 `data:` 行的 JSON;非 SSE 则整段按 JSON 解析。"""
for line in text.splitlines():
line = line.strip()
if line.startswith("data:"):
try:
return json.loads(line[5:].strip())
except ValueError:
return None
try:
return json.loads(text)
except ValueError:
return None
class PaperMcpClient:
"""paper 服务 MCP 客户端:维护 mcp-session-id,逐个 JSON-RPC 调用。"""
def __init__(self, base_url: str, timeout: float = 360.0) -> None:
self.base_url = (base_url or "http://127.0.0.1:8097").rstrip("/")
self.timeout = timeout
self._session_id: str | None = None
self._rpc_id = 0
self._initialized = False
async def _post(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
self._rpc_id += 1
body = {"jsonrpc": "2.0", "id": self._rpc_id, "method": method, "params": params}
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if self._session_id:
headers["mcp-session-id"] = self._session_id
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(f"{self.base_url}/mcp/", headers=headers, json=body)
resp.raise_for_status()
sid = resp.headers.get("mcp-session-id")
if sid:
self._session_id = sid
text = resp.text
payload = _parse_sse_body(text)
if payload is None:
raise PaperMcpError(f"paper 服务响应无法解析:{text[:200]}")
if payload.get("error"):
raise PaperMcpError(f"paper 服务 RPC 错误:{payload['error']}")
return payload.get("result") or {}
async def initialize(self) -> dict[str, Any]:
return await self._post("initialize", {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "plantai-bff-paper", "version": "0.1.0"},
})
async def _ensure_initialized(self) -> None:
"""MCP streamable-http 要求先 initialize(建立会话 + 取 mcp-session-id),
否则 tools/call 会被服务端 400 拒绝。"""
if not self._initialized:
await self.initialize()
self._initialized = True
async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
await self._ensure_initialized()
result = await self._post("tools/call", {"name": name, "arguments": arguments})
if result.get("isError"):
raise PaperMcpError(f"工具 {name} 返回错误:{result}")
sc = result.get("structuredContent")
if sc is not None:
return sc
for block in result.get("content") or []:
if isinstance(block, dict) and block.get("type") == "text" and block.get("text"):
try:
return json.loads(block["text"])
except ValueError:
return {"raw": block["text"]}
return result
@@ -0,0 +1,232 @@
// @vitest-environment jsdom
// tests/frontend/unit/bioflow/cards.phaseG.test.js —— Phase G 四卡片组件测试(L4)
// 覆盖:ReadinessCard(就绪检查)/ DiagnosisCard(诊断)/ VersioningCard(版本化修改)/
// DeliveryCard(交付清单)的渲染、状态徽标、按钮交互、块类型接线。
// SSE/API 用 mock,不依赖生产服务。
//
// ============================================================================
// 契约说明(开发对齐用)
// 组件路径(ui/src/components/chat/):
// ReadinessCard.vue —— block.type = 'bioflow_readiness'
// DiagnosisCard.vue —— block.type = 'bioflow_diagnosis'
// VersioningCard.vue —— block.type = 'bioflow_versioning'
// DeliveryCard.vue —— block.type = 'bioflow_delivery'
// 根类名 / 标题关键词 / 徽标类 / 按钮类:
// ReadinessCard —— .readiness-card / 「就绪检查」 / .rc-badge / .rc-recheck
// DiagnosisCard —— .diagnosis-card / 「诊断」 / .dc-badge / .dc-action
// VersioningCard —— .versioning-card / 「版本」 / .vc-badge / .vc-apply
// DeliveryCard —— .delivery-card / 「交付」 / .dlc-badge / .dlc-publish
// 按钮交互经 chat store sendCardResult 进 QA 流(card_result),卡片类型:
// readiness_check / diagnosis / apply_change / delivery_publish
// ============================================================================
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
bioflowCatalog: vi.fn(async () => ({ ok: true, data: { data: { stage: { params: [] } } } })),
createBioflowJob: vi.fn(async () => ({ ok: true, data: {} })),
bioflowHealth: vi.fn(async () => ({ ok: true })),
listBioflowJobs: vi.fn(async () => ({ ok: true, data: { items: [], total: 0 } })),
listBioflowArchives: vi.fn(async () => ({ ok: true, data: { items: [], total: 0 } })),
getBioflowJob: vi.fn(async () => ({ ok: true, data: {} })),
checkBioflowReadiness: vi.fn(async () => ({ ok: true, data: { ready: true } })),
getBioflowDiagnosis: vi.fn(async () => ({ ok: true, data: {} })),
applyBioflowChange: vi.fn(async () => ({ ok: true, data: {} })),
publishBioflowDelivery: vi.fn(async () => ({ ok: true, data: {} })),
bioflowChatStream: vi.fn(async () => { throw new Error('unused'); }),
bioflowJobEvents: vi.fn(async () => { throw new Error('unused'); }),
}));
vi.mock('../../../../ui/src/api/sessions.js', () => ({
createMessage: vi.fn(async () => ({ id: 'm1' })),
createSession: vi.fn(async () => ({ id: 'sess-1' })),
listSessions: vi.fn(async () => []),
getSession: vi.fn(),
}));
import ReadinessCard from '../../../../ui/src/components/chat/ReadinessCard.vue';
import DiagnosisCard from '../../../../ui/src/components/chat/DiagnosisCard.vue';
import VersioningCard from '../../../../ui/src/components/chat/VersioningCard.vue';
import DeliveryCard from '../../../../ui/src/components/chat/DeliveryCard.vue';
import { useChatStore } from '../../../../ui/src/stores/chat.js';
function setup() {
setActivePinia(createPinia());
const chat = useChatStore();
chat.sessionId = 'sess-1';
vi.spyOn(chat, 'sendCardResult').mockImplementation(() => new Promise(() => {}));
return { chat };
}
function sendCardSpy() {
return useChatStore().sendCardResult;
}
const READINESS_BLOCK = () => ({
type: 'bioflow_readiness',
ready: true,
quadrants: [
{ name: 'environment', ok: true },
{ name: 'reference', ok: true },
{ name: 'space', ok: true },
{ name: 'budget', ok: true },
],
});
const DIAGNOSIS_BLOCK = () => ({
type: 'bioflow_diagnosis',
category: 'resource',
code: 'DISK_FULL',
summary: '磁盘不足',
suggested_actions: ['清理磁盘空间后重试'],
retryable_without_change: false,
});
const VERSIONING_BLOCK = () => ({
type: 'bioflow_versioning',
plan_revision: '1',
run_id: 'run-1',
affected_stages: ['phylogeny', 'motif'],
based_on_revision: null,
});
const DELIVERY_BLOCK = () => ({
type: 'bioflow_delivery',
ok: true,
checks: { selected_stages: true, run_id: true, required_results: true },
incomplete: [],
});
describe('ReadinessCard(就绪检查)', () => {
beforeEach(() => { setup(); vi.clearAllMocks(); });
it('卡体渲染 + 标题「就绪检查」+ 四象限(环境/参考/空间/预算)', () => {
const w = mount(ReadinessCard, { props: { block: READINESS_BLOCK() } });
expect(w.find('.readiness-card').exists()).toBe(true);
expect(w.find('.rc-title').text()).toContain('就绪检查');
expect(w.findAll('.rc-quadrant').length).toBe(4);
});
it('状态徽标:ready 显示「就绪」,未就绪显示「未就绪」', async () => {
const w = mount(ReadinessCard, { props: { block: READINESS_BLOCK() } });
expect(w.find('.rc-badge').text()).toContain('就绪');
const blk = READINESS_BLOCK();
blk.ready = false;
await w.setProps({ block: blk });
expect(w.find('.rc-badge').text()).toContain('未就绪');
});
it('按钮「重新检查」点击发出 readiness_check card_result', async () => {
const w = mount(ReadinessCard, { props: { block: READINESS_BLOCK() } });
await w.find('.rc-recheck').trigger('click');
await flushPromises();
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
const [card] = sendCardSpy().mock.calls[0];
expect(card).toBe('readiness_check');
});
it('块类型接线:block.type=bioflow_readiness 时渲染 readiness 卡', () => {
const w = mount(ReadinessCard, { props: { block: READINESS_BLOCK() } });
expect(w.props('block').type).toBe('bioflow_readiness');
expect(w.find('.readiness-card').exists()).toBe(true);
});
});
describe('DiagnosisCard(诊断)', () => {
beforeEach(() => { setup(); vi.clearAllMocks(); });
it('卡体渲染 + 标题「诊断」+ 错误摘要', () => {
const w = mount(DiagnosisCard, { props: { block: DIAGNOSIS_BLOCK() } });
expect(w.find('.diagnosis-card').exists()).toBe(true);
expect(w.find('.dc-title').text()).toContain('诊断');
expect(w.find('.dc-summary').text()).toContain('磁盘不足');
});
it('状态徽标:显示分类(resource)', () => {
const w = mount(DiagnosisCard, { props: { block: DIAGNOSIS_BLOCK() } });
expect(w.find('.dc-badge').text()).toContain('resource');
});
it('建议动作渲染 + 按钮「应用建议」点击发出 diagnosis card_result', async () => {
const w = mount(DiagnosisCard, { props: { block: DIAGNOSIS_BLOCK() } });
expect(w.find('.dc-actions').text()).toContain('清理磁盘空间后重试');
await w.find('.dc-action').trigger('click');
await flushPromises();
const [card] = sendCardSpy().mock.calls[0];
expect(card).toBe('diagnosis');
});
it('块类型接线:block.type=bioflow_diagnosis 时渲染 diagnosis 卡', () => {
const w = mount(DiagnosisCard, { props: { block: DIAGNOSIS_BLOCK() } });
expect(w.props('block').type).toBe('bioflow_diagnosis');
});
});
describe('VersioningCard(版本化修改)', () => {
beforeEach(() => { setup(); vi.clearAllMocks(); });
it('卡体渲染 + 标题「版本」+ 修订号 + 受影响下游阶段', () => {
const w = mount(VersioningCard, { props: { block: VERSIONING_BLOCK() } });
expect(w.find('.versioning-card').exists()).toBe(true);
expect(w.find('.vc-title').text()).toContain('版本');
expect(w.find('.vc-revision').text()).toContain('1');
expect(w.find('.vc-affected').text()).toContain('phylogeny');
});
it('状态徽标:显示 run_id', () => {
const w = mount(VersioningCard, { props: { block: VERSIONING_BLOCK() } });
expect(w.find('.vc-badge').text()).toContain('run-1');
});
it('按钮「应用修改」点击发出 apply_change card_result', async () => {
const w = mount(VersioningCard, { props: { block: VERSIONING_BLOCK() } });
await w.find('.vc-apply').trigger('click');
await flushPromises();
const [card] = sendCardSpy().mock.calls[0];
expect(card).toBe('apply_change');
});
it('块类型接线:block.type=bioflow_versioning 时渲染 versioning 卡', () => {
const w = mount(VersioningCard, { props: { block: VERSIONING_BLOCK() } });
expect(w.props('block').type).toBe('bioflow_versioning');
});
});
describe('DeliveryCard(交付清单)', () => {
beforeEach(() => { setup(); vi.clearAllMocks(); });
it('卡体渲染 + 标题「交付」+ 清单项', () => {
const w = mount(DeliveryCard, { props: { block: DELIVERY_BLOCK() } });
expect(w.find('.delivery-card').exists()).toBe(true);
expect(w.find('.dlc-title').text()).toContain('交付');
expect(w.findAll('.dlc-item').length).toBeGreaterThan(0);
});
it('状态徽标:完整显示「已通过」,不完整显示「待完善」', async () => {
const w = mount(DeliveryCard, { props: { block: DELIVERY_BLOCK() } });
expect(w.find('.dlc-badge').text()).toContain('已通过');
const blk = DELIVERY_BLOCK();
blk.ok = false;
blk.incomplete = [{ item: 'run_id', reason: '缺运行身份' }];
await w.setProps({ block: blk });
expect(w.find('.dlc-badge').text()).toContain('待完善');
});
it('未完成原因显示 + 按钮「发布交付」点击发出 delivery_publish card_result', async () => {
const blk = DELIVERY_BLOCK();
blk.ok = false;
blk.incomplete = [{ item: 'run_id', reason: '缺运行身份' }];
const w = mount(DeliveryCard, { props: { block: blk } });
expect(w.find('.dlc-incomplete').text()).toContain('缺运行身份');
await w.find('.dlc-publish').trigger('click');
await flushPromises();
const [card] = sendCardSpy().mock.calls[0];
expect(card).toBe('delivery_publish');
});
it('块类型接线:block.type=bioflow_delivery 时渲染 delivery 卡', () => {
const w = mount(DeliveryCard, { props: { block: DELIVERY_BLOCK() } });
expect(w.props('block').type).toBe('bioflow_delivery');
});
});
+80
View File
@@ -0,0 +1,80 @@
"""tests/paper/conftest.py —— 论文服务测试共享夹具(tmp db + 假 LLM + 假检索)。
全部 mock LLM/vstore,不依赖生产服务(8090/8094 等绝不探测)。
镜像 bioflow_service/tests/conftest.py 的 _run(新建事件循环)+ 临时库模式。
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
for _p in (str(REPO_ROOT), str(REPO_ROOT / "sdk")):
if _p not in sys.path:
sys.path.insert(0, _p)
from paper_writer_service.app import db as dbmod # noqa: E402
from paper_writer_service.app.services.paper import llm as llmmod # noqa: E402
from paper_writer_service.app.services.paper import search as searchmod # noqa: E402
def _run(coro):
"""同步跑一个协程(新建 event loop;bioflow/docgen 测试同款可靠模式)。"""
return asyncio.new_event_loop().run_until_complete(coro)
class FakeLLM:
"""可编程假 LLM:按顺序返回预设 dict;或抛 LLMError。记录全部调用。"""
def __init__(self, responses=None, error=None):
self.responses = list(responses or [])
self.error = error
self.calls: list[dict] = []
async def complete_json(self, system, user, **kwargs):
self.calls.append({"system": system, "user": user})
if self.error:
raise llmmod.LLMError(self.error)
return self.responses.pop(0) if self.responses else {}
async def complete_text(self, system, user, **kwargs):
self.calls.append({"system": system, "user": user})
if self.error:
raise llmmod.LLMError(self.error)
return self.responses.pop(0) if self.responses else ""
# ---------------------------------------------------------------------------
# DB:单例改路径重连(服务模块以 from ...db import db 持有单例引用)
# ---------------------------------------------------------------------------
@pytest.fixture
def paper_db(tmp_path):
dbmod.db._path = Path(tmp_path) / "paper.db"
_run(dbmod.db.connect())
yield dbmod.db
_run(dbmod.db.close())
@pytest.fixture
def fake_llm(monkeypatch):
fake = FakeLLM()
monkeypatch.setattr(llmmod, "complete_json", fake.complete_json)
monkeypatch.setattr(llmmod, "complete_text", fake.complete_text)
return fake
@pytest.fixture
def fake_search(monkeypatch):
async def _search_refs(paper_id="", query="", top_k=8):
return {"ok": True, "items": [
{"doc_id": "d1", "title": "水稻SPL基因家族研究进展", "snippet": "SPL 是植物特有转录因子家族。", "score": 0.9, "meta": {}},
{"doc_id": "d2", "title": "植物SPL转录因子功能", "snippet": "SPL 调控开花与株型。", "score": 0.8, "meta": {}},
]}
monkeypatch.setattr(searchmod, "search_refs", _search_refs)
return _search_refs
+33
View File
@@ -0,0 +1,33 @@
# 论文撰写功能 手工验收手册
## 前置(175 服务器,dev 树明文)
- `paper_writer_service` 8097 已拉起(`bash deploy/paper_restart.sh`)。
- BFF 8090 已启用论文路由(`bash deploy/toggle_paper.sh on`,即 `.env` 里 `PAPER_WRITER_ENABLED=true`)。
- 前端 Vite dev(5180)或 BFF 静态托管 dist 就绪,Mac 侧走 SSH 隧道访问。
探活自检:
```bash
curl -s http://127.0.0.1:8097/health # {"ok":true,...,"db":{"connected":true}}
curl -s http://127.0.0.1:8090/api/paper/health # 应透传 8097 /health
curl -s http://127.0.0.1:8090/api/paper/papers/nope # {"ok":false,...,"code":"NOT_FOUND"}(路由已注册)
```
## 验收剧本(10 步)
1. **触发**:输入「帮我写一篇水稻 SPL 基因家族的综述论文」→ 出现 NL 引导 + `paper_brief` 表单卡(预填已抽取字段,缺失项待补)。
2. **补全 brief**:类型=综述 / 篇幅=5000字 / 语言=中文 / 格式=GB/T 7714 → 点「生成大纲」→ 出现 `paper_outline` 大纲卡(5-6 节)。
3. **NL 改大纲**:输入「把展望合并到讨论」→ 系统复述确认并更新大纲卡。
4. **确认**:点「确认并开始撰写」→ 出现 `paper_progress` 进度卡,逐节推进,每节完成有 NL 小结。
5. **完成**:全部节完成后自动挂 `artifact` 块并切右侧「文档」TAB;全文含文末 GB/T 7714 参考文献列表 + 降级 disclaimer(无 DOI/PMID 的知识库资料)。
6. **引用点击**:点击正文 `[3]` → popover 显示标题 / GB 文本 / 来源片段。
7. **修改**:输入「把讨论部分第二段改得更严谨」→ 修改确认,文献编号已重排。
8. **导出**:文档 TAB 点「导出 PDF」/「导出 DOCX」→ 成功下载。
9. **关服务降级**:`bash deploy/paper_restart.sh` 停掉后(或 kill 8097)→ 前端触发词落 docgen 单发(无 paper 入口)。
10. **恢复续写**:重启 paper 服务 + BFF 后 resume → 跳过已完成节继续。
## 备注
- 引用为知识库(vstore 8094)自动检索注入;知识库 chunk 无 DOI/PMID 元数据,故多为降级格式 + disclaimer(见方案 §3.3)。
- 长文一致性:citations 全局只增不改,complete 按首次出现顺序重排编号;正文与文末列表同源生成。
+5
View File
@@ -0,0 +1,5 @@
[pytest]
python_files = test_*.py
markers =
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
paper_mcp: MCP 协议层测试(list_tools/call_tool)
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# tests/paper/run_all.sh —— 论文撰写功能测试总入口(后端 pytest + 前端 intent 单测)
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
PY="${PAPER_TEST_PY:-/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python}"
echo "== 论文后端(pytest:工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema) =="
"$PY" -m pytest "$ROOT/tests/paper/" -c "$ROOT/tests/paper/pytest.ini" -q
echo
echo "== 论文前端 intent 单测(node) =="
node "$ROOT/tests/paper/test_paper_intent_frontend.mjs"
+69
View File
@@ -0,0 +1,69 @@
"""tests/paper/test_mcp_protocol.py —— MCP 协议层测试(list_tools / call_tool 往返)。
验证:9 个工具正确暴露、call_tool 返回可解析的 JSON(structuredContent 或 content 文本块)。
镜像 bioflow_service/tests/test_mcp_server.py 的 call_tool 集成模式。
"""
from __future__ import annotations
import json
from conftest import _run
from paper_writer_service.app.mcp import paper_mcp
EXPECTED_TOOLS = {
"propose_paper_outline", "write_paper_section", "get_paper_progress", "search_refs",
"revise_paper_section", "revise_paper", "get_paper", "complete_paper", "cancel_paper",
}
def _content_text(result):
"""从 CallToolResult 抽取文本内容(无 structuredContent 时)。"""
for block in getattr(result, "content", None) or []:
text = getattr(block, "text", None)
if text:
return json.loads(text)
return {}
def _call(name, args):
r = _run(paper_mcp.call_tool(name, args))
return getattr(r, "structuredContent", None) or _content_text(r)
def test_list_tools_exposes_nine():
tools = _run(paper_mcp.list_tools())
names = {getattr(t, "name", None) for t in tools}
assert EXPECTED_TOOLS == names
def test_call_tool_propose_outline_roundtrip(paper_db, fake_llm, fake_search):
fake_llm.responses.append({
"title": "水稻SPL基因家族研究进展",
"abstract_expect": "综述",
"sections": [{"section_id": "s1", "title": "引言", "key_points": [], "target_words": 1500}],
})
r = _call("propose_paper_outline", {"topic": "水稻SPL基因家族", "paper_type": "综述"})
assert r.get("ok") is True
assert r["paper_id"]
assert r["title"] == "水稻SPL基因家族研究进展"
def test_call_tool_not_found(paper_db):
r = _call("get_paper", {"paper_id": "nope"})
assert r.get("ok") is False
assert r.get("code") == "NOT_FOUND"
def test_call_tool_content_is_json_string(paper_db, fake_llm, fake_search):
"""验证 content[0].text 是合法 JSON 字符串(BFF 裸 JSON-RPC 客户端解析面)。"""
fake_llm.responses.append({
"title": "T", "abstract_expect": "A",
"sections": [{"section_id": "s1", "title": "引言", "key_points": [], "target_words": 1500}],
})
raw = _run(paper_mcp.call_tool("propose_paper_outline", {"topic": "水稻SPL"}))
blocks = getattr(raw, "content", None) or []
assert blocks, "CallToolResult 无 content 块"
text = getattr(blocks[0], "text", None)
assert text is not None
payload = json.loads(text) # 应为合法 JSON 字符串
assert payload.get("ok") is True
+124
View File
@@ -0,0 +1,124 @@
"""tests/paper/test_paper_bff_agent.py —— BFF 论文大脑状态机测试(假 MCP server + 假 LLM)。
跑通 brief→outline→confirm(写节循环)→complete→artifact 全状态机。
"""
from __future__ import annotations
import pytest
from conftest import _run
from app import db as bff_db
from app.routers import docgen as docgen_mod
from app.services.paper import agent
class FakePaperService:
"""脚本化 paper 服务:按工具名返回预设结果,模拟写节循环推进。"""
def __init__(self):
self.paper_id = "p1"
self.s1_done = False
self.s2_done = False
async def call_tool(self, name, arguments):
if name == "propose_paper_outline":
return {"ok": True, "paper_id": self.paper_id, "title": "水稻SPL基因家族研究进展",
"abstract_expect": "综述", "sections": [
{"section_id": "s1", "title": "引言", "key_points": [], "target_words": 1500},
{"section_id": "s2", "title": "讨论", "key_points": [], "target_words": 1500},
]}
if name == "get_paper_progress":
sections = [
{"section_id": "s1", "title": "引言", "status": "done" if self.s1_done else "pending",
"word_count": 0, "citation_count": 0},
{"section_id": "s2", "title": "讨论", "status": "done" if self.s2_done else "pending",
"word_count": 0, "citation_count": 0},
]
return {"ok": True, "paper_id": self.paper_id, "status": "writing",
"sections": sections, "current_section_id": None}
if name == "write_paper_section":
sid = arguments["section_id"]
if sid == "s1":
self.s1_done = True
else:
self.s2_done = True
return {"ok": True, "section_id": sid, "status": "done",
"markdown": "内容" * 50, "citations_used": [], "new_citations": []}
if name == "complete_paper":
return {"ok": True, "full_markdown": "# 全文\n内容",
"references": [{"n": 1, "text": "X.", "kind": "title", "source_doc_id": "d1"}],
"renumber_map": {}, "disclaimer": "d"}
if name == "get_paper":
return {"ok": True, "paper_id": self.paper_id, "title": "水稻SPL基因家族研究进展",
"status": "complete", "full_markdown": "# 全文",
"references": [{"n": 1, "text": "X.", "kind": "title", "source_doc_id": "d1"}],
"disclaimer": "d"}
if name == "cancel_paper":
return {"ok": True}
if name == "revise_paper":
return {"ok": True, "affected_section_ids": ["s1"], "plan": "修订引言"}
if name == "revise_paper_section":
return {"ok": True, "section_id": arguments["section_id"], "markdown": "改后", "citations_used": []}
raise AssertionError(f"unexpected tool: {name}")
@pytest.fixture
def bff_database(tmp_path):
_run(bff_db.init_db(tmp_path / "bff.db"))
_run(bff_db.db.execute(
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
"VALUES ('s1', 't', 'idle', '{}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"))
yield bff_db.db
_run(bff_db.close_db())
def _fake_brief(complete=True):
async def _complete_json(system, user, **kwargs):
if complete:
return {"topic": "水稻SPL基因家族", "paper_type": "综述", "language": "中文",
"length": "5000字", "format_req": "GB/T 7714", "style": "学术严谨", "requirements": ""}
return {"topic": "水稻SPL", "paper_type": "", "language": "", "length": ""}
return _complete_json
@pytest.fixture
def fake_insert_artifact(monkeypatch):
async def _fake(db, **kwargs):
return "art-fake"
monkeypatch.setattr(docgen_mod, "insert_artifact", _fake)
return _fake
def test_trigger_complete_brief_proposes_outline(bff_database, monkeypatch, fake_insert_artifact):
svc = FakePaperService()
monkeypatch.setattr(agent, "_new_client", lambda: svc)
monkeypatch.setattr(agent, "llm_complete_json", _fake_brief(complete=True))
blocks = _run(agent.handle_turn(bff_database, "s1", "帮我写一篇水稻SPL基因家族综述"))
assert blocks[-1]["type"] == "paper_outline"
assert blocks[-1]["paper_id"] == "p1"
rows = _run(bff_database.fetchall("SELECT * FROM messages WHERE session_id = 's1'"))
assert len(rows) == 1
assert rows[0]["kind"] == "paper"
def test_trigger_incomplete_brief_returns_form(bff_database, monkeypatch):
monkeypatch.setattr(agent, "_new_client", lambda: FakePaperService())
monkeypatch.setattr(agent, "llm_complete_json", _fake_brief(complete=False))
blocks = _run(agent.handle_turn(bff_database, "s1", "帮我写论文"))
assert blocks[-1]["type"] == "paper_brief"
assert blocks[-1]["status"] == "pending"
def test_writing_loop_completes_to_artifact(bff_database, monkeypatch, fake_insert_artifact):
svc = FakePaperService()
monkeypatch.setattr(agent, "_new_client", lambda: svc)
_run(agent.run_writing_loop(bff_database, "p1", "s1"))
assert svc.s1_done and svc.s2_done
rows = _run(bff_database.fetchall("SELECT * FROM messages WHERE session_id = 's1' ORDER BY rowid"))
# 进度卡由前端确认时本地 seed + 轮询驱动,后端只落「开始撰写」文本 + 逐节小结 + artifact
assert "开始逐节撰写" in rows[0]["blocks"]
assert "artifact" in rows[-1]["blocks"]
assert "art-fake" in rows[-1]["blocks"]
+35
View File
@@ -0,0 +1,35 @@
"""tests/paper/test_paper_blocks_schema.py —— 块 schema 回归(未知块拒绝 + 新块通过)。"""
from __future__ import annotations
import pytest
from pydantic import TypeAdapter
from app import schemas
_BLOCK = TypeAdapter(schemas.Block)
def test_paper_blocks_validate():
cases = [
{"type": "paper_brief", "paper_id": "p1", "status": "pending", "fields": []},
{"type": "paper_outline", "paper_id": "p1", "title": "t", "status": "pending", "sections": []},
{"type": "paper_progress", "paper_id": "p1", "status": "writing", "sections": [], "current_section_id": ""},
{"type": "paper_refs", "paper_id": "p1", "references": [], "disclaimer": ""},
]
for c in cases:
b = _BLOCK.validate_python(c)
assert b.type == c["type"]
def test_artifact_block_paper_fields():
b = _BLOCK.validate_python({
"type": "artifact", "artifact_id": "a1", "title": "t",
"paper_id": "p1", "citation_count": 3,
})
assert b.paper_id == "p1"
assert b.citation_count == 3
def test_unknown_block_rejected():
with pytest.raises(Exception):
_BLOCK.validate_python({"type": "totally_unknown_block"})
@@ -0,0 +1,42 @@
// tests/paper/test_paper_intent_frontend.mjs —— 论文撰写意图前端单测(node 直测)
// 验证 paperIntent 纯函数 + route() 零变化抽样(30 条全量由 test_intent_frontend.mjs 兜底)。
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(here, '../..');
const { paperIntent, route } = await import(pathToFileURL(path.join(repo, 'ui/src/intent.js')).href);
const cases = [
['帮我写一篇水稻 SPL 基因家族综述论文', true],
['撰写一篇关于小麦育种的论文', true],
['拟一份综述', true],
['帮我写一个关于疫情防控的通知', false],
['写一份项目总结报告', false],
['写一首诗', false],
['水稻基因是什么?', false],
['', false],
];
let fail = 0;
for (const [t, exp] of cases) {
const got = paperIntent(t);
const ok = got === exp;
if (!ok) fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} paperIntent(${JSON.stringify(t)}) = ${got}(期望 ${exp})`);
}
const routeChecks = [
['给我写一篇关于小麦育种的论文', false, false, 'docgen'],
['写一段水稻基因的介绍', false, false, 'qa'],
];
for (const [t, a, b, exp] of routeChecks) {
const got = route(t, a, b);
const ok = got === exp;
if (!ok) fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} route(${JSON.stringify(t)}) = ${got}(期望 ${exp})`);
}
const total = cases.length + routeChecks.length;
console.log(`===== paperIntent 前端单测:${total - fail}/${total} PASS =====`);
process.exit(fail === 0 ? 0 : 1);
+272
View File
@@ -0,0 +1,272 @@
"""tests/paper/test_paper_tools.py —— 论文服务工具逻辑单元测试(假 LLM + 假检索)。
覆盖:propose_paper_outline / write_paper_section(含引用校验重试)/ get_paper_progress /
revise_paper_section / revise_paper / get_paper / complete_paper(重排 + disclaimer)/ cancel_paper +
引用协议纯函数(GB/T 7714 渲染、[n] 重排)。
"""
from __future__ import annotations
import pytest
from conftest import _run
from paper_writer_service.app import db as dbmod
from paper_writer_service.app.services.paper import cite, complete, outline, write
OUTLINE_RES = {
"title": "水稻SPL基因家族研究进展",
"abstract_expect": "综述水稻SPL基因家族的结构、功能与调控机制。",
"sections": [
{"section_id": "s1", "title": "引言", "key_points": ["SPL家族概述"], "target_words": 1500},
{"section_id": "s2", "title": "SPL家族结构与进化", "key_points": ["结构特征"], "target_words": 2000},
{"section_id": "s3", "title": "SPL功能与调控", "key_points": ["功能"], "target_words": 2000},
{"section_id": "s4", "title": "讨论与展望", "key_points": ["展望"], "target_words": 1500},
{"section_id": "s5", "title": "结论", "key_points": ["总结"], "target_words": 1000},
],
}
def _propose(fake_llm, fake_search):
fake_llm.responses.append(dict(OUTLINE_RES))
return _run(outline.propose_paper_outline("水稻SPL基因家族", paper_type="综述", language="中文"))
# ---------------------------------------------------------------------------
# propose_paper_outline
# ---------------------------------------------------------------------------
def test_propose_outline_creates_paper_and_sections(paper_db, fake_llm, fake_search):
r = _propose(fake_llm, fake_search)
assert r["ok"] is True
assert r["title"] == "水稻SPL基因家族研究进展"
assert len(r["sections"]) == 5
papers = _run(dbmod.db.fetchall("SELECT * FROM papers"))
assert len(papers) == 1
secs = _run(dbmod.db.fetchall("SELECT * FROM paper_sections ORDER BY rowid"))
assert [s["section_id"] for s in secs] == ["s1", "s2", "s3", "s4", "s5"]
assert all(s["status"] == "pending" for s in secs)
def test_propose_outline_llm_error(paper_db, fake_llm, fake_search):
fake_llm.error = "deepseek 超时"
r = _run(outline.propose_paper_outline("水稻SPL"))
assert r["ok"] is False
assert r["code"] == "LLM_ERROR"
# ---------------------------------------------------------------------------
# write_paper_section(引用校验 + 落库)
# ---------------------------------------------------------------------------
def test_write_section_creates_citations(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "SPL 是植物特有的转录因子家族[1],水稻有 19 个成员[2]。",
"citations_used": [
{"temp_n": 1, "title": "水稻SPL基因家族研究进展", "journal": "植物学报", "year": "2024",
"doi": "", "pmid": "", "source_doc_id": "d1", "snippet": "x"},
{"temp_n": 2, "title": "植物SPL转录因子功能", "journal": "", "year": "2023",
"doi": "", "pmid": "", "source_doc_id": "d2", "snippet": "y"},
],
})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is True
assert r["status"] == "done"
assert len(r["new_citations"]) == 2
cits = _run(dbmod.db.fetchall("SELECT * FROM citations ORDER BY temp_n"))
assert [c["temp_n"] for c in cits] == [1, 2]
assert cits[0]["kind"] == "title"
sec = _run(dbmod.db.fetchone("SELECT * FROM paper_sections WHERE paper_id = ? AND section_id = 's1'", (paper_id,)))
assert sec["status"] == "done"
assert "[1]" in sec["markdown"]
def test_write_section_drops_fabricated_doi(paper_db, fake_llm, fake_search):
"""杜撰的 doi/pmid/authors/journal 应被丢弃,强制 title kind 降级格式。"""
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "正文[1]。",
"citations_used": [{"temp_n": 1, "title": "水稻SPL研究", "authors": "X", "journal": "J",
"year": "2020", "doi": "10.1234/fake", "pmid": "123", "source_doc_id": "d1"}],
})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is True
cit = _run(dbmod.db.fetchone("SELECT * FROM citations WHERE temp_n = 1"))
assert cit["kind"] == "title"
assert "知识库资料" in cit["ref_text"]
assert "10.1234" not in cit["ref_text"]
def test_write_section_invalid_citation_retries_once(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.calls.clear() # 重置计数,只统计写节调用(大纲已消费 1 次)
# 第一次引用非法编号 [99],第二次合法
fake_llm.responses.append({
"markdown": "SPL 家族[99]。",
"citations_used": [{"temp_n": 1, "title": "t", "source_doc_id": "d1"}],
})
fake_llm.responses.append({
"markdown": "SPL 家族[1]。",
"citations_used": [{"temp_n": 1, "title": "t", "source_doc_id": "d1"}],
})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is True
assert len(fake_llm.calls) == 2 # 一次失败 + 一次重试成功
def test_write_section_invalid_citation_fails_after_retries(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({"markdown": "SPL 家族[99]。", "citations_used": []})
fake_llm.responses.append({"markdown": "SPL 家族[88]。", "citations_used": []})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is False
assert r["code"] == "CITE_VALIDATION_FAILED"
def test_write_section_not_found(paper_db, fake_llm):
r = _run(write.write_paper_section("nope", "s1"))
assert r["ok"] is False
assert r["code"] == "NOT_FOUND"
# ---------------------------------------------------------------------------
# get_paper_progress / get_paper
# ---------------------------------------------------------------------------
def test_progress_reflects_done_section(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "正文[1]。",
"citations_used": [{"temp_n": 1, "title": "t", "source_doc_id": "d1"}],
})
_run(write.write_paper_section(paper_id, "s1"))
r = _run(complete.get_paper_progress(paper_id))
assert r["ok"] is True
assert r["status"] == "writing"
s1 = next(s for s in r["sections"] if s["section_id"] == "s1")
assert s1["status"] == "done"
assert s1["citation_count"] == 1
# ---------------------------------------------------------------------------
# revise_paper_section / revise_paper
# ---------------------------------------------------------------------------
def test_revise_section_appends_new_citation(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "旧正文[1]。",
"citations_used": [{"temp_n": 1, "title": "旧文献", "source_doc_id": "d1"}],
})
_run(write.write_paper_section(paper_id, "s1"))
# 修订:新增 [2]
fake_llm.responses.append({
"markdown": "新正文[1][2]。",
"citations_used": [
{"temp_n": 1, "title": "旧文献", "source_doc_id": "d1"},
{"temp_n": 2, "title": "新文献", "source_doc_id": "d3"},
],
})
r = _run(write.revise_paper_section(paper_id, "s1", "更严谨"))
assert r["ok"] is True
assert r["citation_delta"] == 1
cits = _run(dbmod.db.fetchall("SELECT temp_n FROM citations ORDER BY temp_n"))
assert [c["temp_n"] for c in cits] == [1, 2]
def test_revise_paper_returns_affected(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({"affected_section_ids": ["s2", "s3"], "plan": "修订讨论与结论"})
r = _run(write.revise_paper(paper_id, "把讨论改严谨"))
assert r["ok"] is True
assert r["affected_section_ids"] == ["s2", "s3"]
# ---------------------------------------------------------------------------
# complete_paper(重排 + GB/T 7714 + disclaimer)
# ---------------------------------------------------------------------------
def test_complete_renumbers_by_first_appearance(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
# s1 先引用 [2],s2 后引用 [1] → complete 后 [2] 应重排为 [1]
fake_llm.responses.append({
"markdown": "引言提到 B[2]。",
"citations_used": [{"temp_n": 2, "title": "文献B", "source_doc_id": "d2"}],
})
_run(write.write_paper_section(paper_id, "s1"))
fake_llm.responses.append({
"markdown": "正文提到 A[1]。",
"citations_used": [{"temp_n": 1, "title": "文献A", "source_doc_id": "d1"}],
})
_run(write.write_paper_section(paper_id, "s2"))
r = _run(complete.complete_paper(paper_id))
assert r["ok"] is True
# 首次出现顺序:B(temp 2) 在 s1,A(temp 1) 在 s2 → 2→1, 1→2
assert r["renumber_map"] == {2: 1, 1: 2}
assert r["references"][0]["n"] == 1
assert r["references"][0]["text"].startswith("文献B")
assert r["disclaimer"] != "" # title kind → 降级 disclaimer
paper = _run(dbmod.db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,)))
assert paper["status"] == "complete"
assert "[1]" in paper["final_markdown"] and "[2]" in paper["final_markdown"]
def test_complete_paper_no_citations_no_disclaimer(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({"markdown": "无引用正文。", "citations_used": []})
_run(write.write_paper_section(paper_id, "s1"))
r = _run(complete.complete_paper(paper_id))
assert r["ok"] is True
assert r["references"] == []
assert r["disclaimer"] == ""
def test_get_paper_returns_full_markdown(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({"markdown": "正文[1]。", "citations_used": [
{"temp_n": 1, "title": "t", "source_doc_id": "d1"}]})
_run(write.write_paper_section(paper_id, "s1"))
_run(complete.complete_paper(paper_id))
r = _run(complete.get_paper(paper_id))
assert r["ok"] is True
assert r["status"] == "complete"
assert "水稻SPL基因家族研究进展" in r["full_markdown"]
assert len(r["references"]) == 1
# ---------------------------------------------------------------------------
# cancel_paper
# ---------------------------------------------------------------------------
def test_cancel_paper(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
r = _run(complete.cancel_paper(paper_id))
assert r["ok"] is True
paper = _run(dbmod.db.fetchone("SELECT status FROM papers WHERE id = ?", (paper_id,)))
assert paper["status"] == "cancelled"
def test_cancel_paper_not_found(paper_db):
r = _run(complete.cancel_paper("nope"))
assert r["ok"] is False
assert r["code"] == "NOT_FOUND"
# ---------------------------------------------------------------------------
# 引用协议纯函数(cite)
# ---------------------------------------------------------------------------
def test_gbt7714_doi_pmid_title():
assert "DOI:10.1000/x" in cite.render_gbt7714({"title": "T", "journal": "J", "year": "2024", "doi": "10.1000/x"})
assert "PMID:123" in cite.render_gbt7714({"title": "T", "journal": "J", "year": "2024", "pmid": "123"})
degraded = cite.render_gbt7714({"title": "知识库标题", "year": "2023"})
assert degraded.endswith("知识库资料, 2023. [EB/OL].")
def test_renumber_map_first_appearance():
mapping = cite.renumber_map([1, 2, 3], "见[3]和[1],以及[2]")
assert mapping == {3: 1, 1: 2, 2: 3}
assert cite.replace_numbers("见[3]和[1]", {3: 1, 1: 2}) == "见[1]和[2]"
+5 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# 渲染 active_library.env:读 <库目录>/manifest.yaml,输出五触点 env。
# 用法: render_active_library_env.sh <manifest.yaml> <输出文件>
# config 类路径:manifest 值为相对路径(如 configs/rag/entity_alias.yaml)时渲染为 $QA_ROOT/<值>;
# 为空时回退 $QA_ROOT/configs/rag/<缺省文件名>。registry_path 为空则整行省略。
# config 类路径:manifest 值为相对路径(如 configs/rag/entity_alias.yaml)时渲染为 $GENE_FAMILY_ROOT/<值>;
# 为空时回退 $GENE_FAMILY_ROOT/configs/rag/<缺省文件名>。registry_path 为空则整行省略。
set -euo pipefail
MANIFEST="$1"; OUT="$2"
release_id="$(awk -F': *' '/^release_id:/ {gsub(/[" ]/, "", $2); print $2; exit}' "$MANIFEST")"
@@ -15,8 +15,8 @@ tmp="$OUT.tmp"
{
echo "NORMALIZED_RELEASE_ID=$release_id"
echo "V15_QDRANT_COLLECTION=${collection:-$release_id}"
[ -n "$alias_cfg" ] && echo "QA_V1_ALIAS_CONFIG=\$QA_ROOT/$alias_cfg" || echo "QA_V1_ALIAS_CONFIG=\$QA_ROOT/configs/rag/entity_alias.yaml"
[ -n "$tax_cfg" ] && echo "QA_V1_TAXONOMY_CONFIG=\$QA_ROOT/$tax_cfg" || echo "QA_V1_TAXONOMY_CONFIG=\$QA_ROOT/configs/rag/relation_taxonomy.yaml"
[ -n "$registry" ] && echo "QA_PAPER_METADATA_REGISTRY=\$QA_ROOT/$registry"
[ -n "$alias_cfg" ] && echo "QA_V1_ALIAS_CONFIG=\$GENE_FAMILY_ROOT/$alias_cfg" || echo "QA_V1_ALIAS_CONFIG=\$GENE_FAMILY_ROOT/configs/rag/entity_alias.yaml"
[ -n "$tax_cfg" ] && echo "QA_V1_TAXONOMY_CONFIG=\$GENE_FAMILY_ROOT/$tax_cfg" || echo "QA_V1_TAXONOMY_CONFIG=\$GENE_FAMILY_ROOT/configs/rag/relation_taxonomy.yaml"
[ -n "$registry" ] && echo "QA_PAPER_METADATA_REGISTRY=\$GENE_FAMILY_ROOT/$registry"
} > "$tmp"
mv "$tmp" "$OUT"
+1 -1
View File
@@ -31,7 +31,7 @@ rsync -a --exclude '__pycache__' --exclude 'test_*.py' --exclude 'data' --exclud
"$SOURCE_ROOT/" "$OUTPUT_DIR/"
# 白名单明文:main.py + config.py + __init__.py(编译前从发现集排除)
WHITELIST_PY=("app/main.py" "app/config.py" "app/schemas.py" "app/db.py" "app/routers/qa.py" "app/services/sse_relay.py" "app/routers/bioflow_proxy.py")
WHITELIST_PY=("app/main.py" "app/config.py" "app/schemas.py" "app/db.py" "app/routers/qa.py" "app/services/sse_relay.py" "app/routers/bioflow_proxy.py" "app/routers/paper.py" "app/services/paper/agent.py" "app/services/paper/mcp_client.py")
# 白名单理由:main.py=uvicorn 入口;config.py=运维可读面;
# schemas.py=pydantic v2 模型(validator 方法被 Cython 转 cyfunction 触发
# PydanticUserError,实测编译后无法导入——纯数据契约面,无敏感逻辑,明文保留);
+34
View File
@@ -0,0 +1,34 @@
// api/paper.js —— 论文撰写端点(paper-writer)
// 全部经 http.js 封装。turns 短同步;confirm/resume 启动后台写节循环;
// progress/citations 透传 paper 服务。
import { get, post } from './http.js';
/** 探活(前端 30s 轮询;离线时触发词落 docgen 单发降级) */
export function paperHealth() {
return get('/api/paper/health');
}
/** 一轮对话(短同步):POST /api/paper/turns → {ok, blocks} */
export function paperTurn(payload) {
return post('/api/paper/turns', payload);
}
/** 确认大纲并启动后台写节循环 */
export function confirmPaper(paperId, sessionId) {
return post(`/api/paper/papers/${encodeURIComponent(paperId)}/confirm`, { session_id: sessionId });
}
/** 断写后重启循环(跳过已完成节) */
export function resumePaper(paperId, sessionId) {
return post(`/api/paper/papers/${encodeURIComponent(paperId)}/resume`, { session_id: sessionId });
}
/** 节级进度(写稿期 2s 轮询) */
export function paperProgress(paperId) {
return get(`/api/paper/papers/${encodeURIComponent(paperId)}`);
}
/** 单条引用来源(正文 [n] 点击 popover) */
export function paperCitation(paperId, n) {
return get(`/api/paper/papers/${encodeURIComponent(paperId)}/citations/${encodeURIComponent(n)}`);
}
+59 -1
View File
@@ -15,6 +15,8 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useDocgenStore } from '../../stores/docgen.js';
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
import { renderMarkdownPlain, sanitizeHtml } from '../chat/markdown.js';
// 论文撰写:正文 [n] → cite-chip,点击拉 citation popover
import { paperCitation } from '../../api/paper.js';
// FR-PRIV-006:「我的资料」管理面板(挂空态区顶部,UI_SPEC §9.2)
import PrivateLibraryPane from './PrivateLibraryPane.vue';
@@ -70,10 +72,66 @@ function mountShadow() {
// 缺陷 #7b:md 回退预览不注入标题编号——序列化会把标题写回正本,编号不得进源文
inner = `<div class="doc-body md-text">${renderMarkdownPlain(docgen.md)}</div>`;
}
if (docgen.activeArtifact && docgen.activeArtifact.paper_id) {
inner = addCiteChips(inner); // 论文制品:[n] → cite-chip
}
root.innerHTML = inner;
if (editing.value) activateEditDom();
}
// ---------- 论文引用 cite-chip ----------
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function addCiteChips(inner) {
return inner.replace(/\[(\d+)\]/g, (m, n) =>
`<sup class="cite-chip" data-n="${n}" style="color:#2563eb;cursor:pointer;font-weight:600;margin:0 1px;">[${n}]</sup>`);
}
function removeCitePopover() {
const host = hostEl.value;
if (host) {
const p = host.querySelector(':scope > .cite-popover');
if (p) p.remove();
}
}
function showCitePopover(chip, c) {
removeCitePopover();
const host = hostEl.value;
if (!host || !chip) return;
const rect = chip.getBoundingClientRect();
const hostRect = host.getBoundingClientRect();
const pop = document.createElement('div');
pop.className = 'cite-popover';
pop.innerHTML = `<div class="cp-title">${escapeHtml(c.title || c.text || '')}</div>` +
`<div class="cp-text">${escapeHtml(c.text || '')}</div>` +
(c.snippet ? `<div class="cp-snippet">${escapeHtml(c.snippet)}</div>` : '');
pop.style.cssText = `position:absolute;left:${rect.left - hostRect.left}px;top:${rect.bottom - hostRect.top + 4}px;max-width:360px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.12);padding:8px 10px;z-index:50;font-size:12px;`;
host.appendChild(pop);
}
function wireCiteChips() {
const root = shadowRoot();
if (!root || root._citeWired) return;
root._citeWired = true;
root.addEventListener('click', (e) => {
const chip = e.target && e.target.closest ? e.target.closest('.cite-chip') : null;
if (!chip) { removeCitePopover(); return; }
const n = chip.getAttribute('data-n');
const paperId = docgen.activeArtifact && docgen.activeArtifact.paper_id;
if (!paperId || !n) return;
paperCitation(paperId, n).then((data) => {
const c = data && data.citation;
if (c) showCitePopover(chip, c);
}).catch(() => {});
});
}
watch(() => [docgen.html, docgen.css, docgen.renderNonce], () => {
// 2026-09-04 问题5:宿主元素可能随 loading 态被 v-if 卸载重建,
// nextTick 后 mountShadow 才能拿到新宿主;renderNonce 兜底 html 拉取失败
@@ -90,7 +148,7 @@ watch(() => docgen.md, () => {
if (mode.value === 'preview') mountShadow();
});
});
onMounted(() => nextTick(mountShadow));
onMounted(() => nextTick(() => { mountShadow(); wireCiteChips(); }));
// ---------- HTML 所见即所得编辑 ----------
@@ -17,6 +17,10 @@ import FlowFormCard from './FlowFormCard.vue';
import FlowFileCard from './FlowFileCard.vue';
import FlowProgressCard from './FlowProgressCard.vue';
import FlowArchiveCard from './FlowArchiveCard.vue';
import ReadinessCard from './ReadinessCard.vue';
import DiagnosisCard from './DiagnosisCard.vue';
import VersioningCard from './VersioningCard.vue';
import DeliveryCard from './DeliveryCard.vue';
import LegacyBioflowBlock from './LegacyBioflowBlock.vue';
// 用户自定义扩展分析:生命周期引导卡 + 运行卡(EXTENSION_CONTRACT.md §4 块)
import ExtensionUploadCard from './ExtensionUploadCard.vue';
@@ -29,6 +33,11 @@ import ExtensionConfirmCard from './ExtensionConfirmCard.vue';
import ExtensionRunCard from './ExtensionRunCard.vue';
// FR-PRIV-005:私有资料参考块(独立 i18n 文件,不碰 labels.js 红线)
import { PRIV } from '../../i18n/private.js';
// 论文撰写:四类块(brief 表单卡 / outline 大纲卡 / progress 进度卡 / refs 内联列表)
import { PP } from '../../i18n/paper.js';
import PaperBriefCard from './PaperBriefCard.vue';
import PaperOutlineCard from './PaperOutlineCard.vue';
import PaperProgressCard from './PaperProgressCard.vue';
const props = defineProps({
message: { type: Object, required: true },
@@ -225,6 +234,24 @@ const degradedNote = computed(() => props.message.degradedNote || null);
:block="b"
/>
<!-- G12 分析控制四卡 -->
<ReadinessCard
v-if="b.type === 'bioflow_readiness'"
:block="b"
/>
<DiagnosisCard
v-if="b.type === 'bioflow_diagnosis'"
:block="b"
/>
<VersioningCard
v-if="b.type === 'bioflow_versioning'"
:block="b"
/>
<DeliveryCard
v-if="b.type === 'bioflow_delivery'"
:block="b"
/>
<!-- P2 生信:流程知识库引用块(FR-BIO-026)→ S3 降级为只读 LegacyBioflowBlock -->
<LegacyBioflowBlock v-if="b.type === 'bioflow_qa'" :block="b" />
@@ -292,6 +319,21 @@ const degradedNote = computed(() => props.message.degradedNote || null);
<!-- artifact:文档产物卡(P3;「打开预览」→ docgen store + bench 文档 TAB) -->
<DocCard v-if="b.type === 'artifact'" :block="b" />
<!-- 论文撰写:brief 引导表单卡 -->
<PaperBriefCard v-if="b.type === 'paper_brief'" :block="b" />
<!-- 论文撰写:大纲确认卡 -->
<PaperOutlineCard v-if="b.type === 'paper_outline'" :block="b" />
<!-- 论文撰写:节级进度卡 -->
<PaperProgressCard v-if="b.type === 'paper_progress'" :block="b" />
<!-- 论文撰写:参考文献内联列表 -->
<div v-if="b.type === 'paper_refs'" class="paper-refs">
<div class="pr-title">{{ PP.paperRefsTitle }}</div>
<ol class="pr-list">
<li v-for="r in b.references" :key="r.n">[{{ r.n }}] {{ r.text }}</li>
</ol>
<div v-if="b.disclaimer" class="pr-disclaimer">{{ b.disclaimer }}</div>
</div>
<!-- sources:内联引用 chip(FR-CHAT-015) -->
<div v-if="b.type === 'sources' && b.items && b.items.length" class="inline-sources">
<span class="src-label">{{ T.sourcesTitle }}</span>
+80 -25
View File
@@ -13,16 +13,16 @@
// qa → 原 emit('send') 路径(附件注记进消息文本)。
// 路由收敛在本组件内,ChatPanel 等上游组件零改动。
import { ref, computed, onMounted } from 'vue';
import { MODES as MODE_LABELS, T } from '../../i18n/labels.js';
import { T } from '../../i18n/labels.js';
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
import { BF } from '../../i18n/bioflow.js';
import { route } from '../../intent.js';
import { isExtensionCreateIntent } from '../../utils/intent.js';
import { uploadLibrary, listLibrary } from '../../api/docgen.js';
import { route, paperIntent } from '../../intent.js';
import { route as routeBioflow, isExtensionCreateIntent } from '../../utils/intent.js';import { uploadLibrary, listLibrary } from '../../api/docgen.js';
import { uploadBioflowFile } from '../../api/bioflow.js';
import { useDocgenStore } from '../../stores/docgen.js';
import { useChatStore } from '../../stores/chat.js';
import { useBioflowStore } from '../../stores/bioflow.js';
import { usePaperStore } from '../../stores/paper.js';
import { useSettingsStore } from '../../stores/settings.js';
const props = defineProps({
@@ -59,6 +59,9 @@ onMounted(() => {
settings.load(sid).catch(() => { /* 静默:失败不阻断(设置面板打开时会重试) */ });
}
} catch { /* settings store 未就绪(测试/降级):不阻断 */ }
try {
usePaperStore().start(); // 论文撰写探活(30s;离线时触发词落 docgen 单发降级)
} catch { /* paper store 未就绪:不阻断 */ }
});
const canSend = computed(() =>
@@ -124,6 +127,36 @@ const hasBioflowContext = computed(() => {
}
});
/** 论文上下文(当前会话消息含 paper 块;用于把修改意见路由回 sendPaper) */
const hasPaperContext = computed(() => {
try {
const chat = useChatStore();
const list = chat.messages || [];
return list.some((m) => m && m.role === 'assistant' && Array.isArray(m.blocks)
&& m.blocks.some((b) => b && ['paper_brief', 'paper_outline', 'paper_progress'].includes(b.type)));
} catch {
return false;
}
});
/** 当前活跃论文 id(从最近的 paper_outline/paper_progress 块取) */
const activePaperId = computed(() => {
try {
const chat = useChatStore();
const list = chat.messages || [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
for (const b of m.blocks) {
if (b && (b.type === 'paper_outline' || b.type === 'paper_progress') && b.paper_id) return b.paper_id;
}
}
return null;
} catch {
return null;
}
});
/** 生信可用(探活显隐 FR-BIO-025;未探活视为可用不阻断分流——后端不注册时由请求失败兜底) */
const bioflowEnabled = computed(() => {
try {
@@ -134,6 +167,16 @@ const bioflowEnabled = computed(() => {
}
});
/** 论文撰写可用(探活显隐;离线时触发词落 docgen 单发降级——后端不注册时由请求失败兜底) */
const paperEnabled = computed(() => {
try {
const st = usePaperStore();
return st.availability !== 'offline';
} catch {
return true;
}
});
const hintText = computed(() =>
attachments.value.length ? T.composerHint : `${T.composerHint} · ${DG.composerDropHint}`,
);
@@ -256,17 +299,34 @@ const submit = () => {
emit('send', text); // chat store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else if (intent === 'docgen') {
// 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 异步生成 + 自动切文档 TAB)
// 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐)
// 文档生成分支:论文撰写 sub-route(paperIntent && paperEnabled → sendPaper),
// 否则 sendDocgen(降级路径零改动——paper 未启用/未命中时触发词落 docgen 单发)。
let paperRoute = false;
try {
useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), {
templateId: selectedTemplateId.value || '',
}).catch(() => {
/* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */
});
paperRoute = paperEnabled.value && paperIntent(text);
} catch {
/* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */
emit('send', text);
paperRoute = false;
}
if (paperRoute) {
try {
useChatStore().sendPaper(text, { templateId: selectedTemplateId.value || '' }).catch(() => {
/* sendPaper 内部已按失败可见原则标注错误 */
});
} catch {
emit('send', text); // chat store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else {
// 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐)
try {
useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), {
templateId: selectedTemplateId.value || '',
}).catch(() => {
/* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */
});
} catch {
/* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */
emit('send', text);
}
}
} else if (intent === 'edit') {
// 编辑分支:预填 DocPane「用 prompt 修改」并切文档 TAB
@@ -278,6 +338,13 @@ const submit = () => {
const dg = useDocgenStore();
dg.requestExport();
dg.switchToDocTab();
} else if (hasPaperContext.value && paperEnabled.value) {
// 活跃论文(outline/writing/complete 阶段)的修改意见 → sendPaper(带 paper_id)
try {
useChatStore().sendPaper(text, { paperId: activePaperId.value || '' }).catch(() => {});
} catch {
emit('send', text);
}
} else {
// QA 原路径:附件注记进消息文本
const note = attachmentNoteText();
@@ -324,18 +391,6 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?.
@drop="onDrop"
>
<div class="composer">
<div class="mode-pills">
<button
v-for="pm in MODE_LABELS"
:key="pm.key"
type="button"
:class="['mode-pill', { on: mode === pm.key }]"
:disabled="busy || disabled"
@click="emit('change-mode', pm.key)"
>
{{ pm.label }}
</button>
</div>
<!-- 缺陷 #42:docgen 模式模板下拉(「自动推荐」+ 内置模板 + as_template 资料,懒加载) -->
<div v-if="isDocgenMode" class="template-row">
<label class="tpl-label" for="pa-template-select">{{ DG.docPaneTemplateLabel }}</label>
+107
View File
@@ -0,0 +1,107 @@
<script setup>
// chat/DeliveryCard.vue —— 交付清单卡(G12/G21-G22)
// block: { type:'bioflow_delivery', ok, checks:{item:bool}, incomplete:[{item,reason}] }
import { computed } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({
block: { type: Object, required: true },
});
const chat = useChatStore();
const ok = computed(() => !!props.block.ok);
const checks = computed(() => props.block.checks || {});
const incomplete = computed(() => props.block.incomplete || []);
function onPublish() {
chat.sendCardResult('delivery_publish', {
ok: props.block.ok,
checks: props.block.checks,
incomplete: props.block.incomplete,
}).catch(() => {});
}
</script>
<template>
<div class="delivery-card">
<div class="dlc-title">
{{ BF.deliveryTitle }}
<span class="dlc-badge">{{ ok ? BF.deliveryPassed : BF.deliveryPending }}</span>
</div>
<div v-for="(v, k) in checks" :key="k" class="dlc-item">
<span class="dlc-item-name">{{ k }}</span>
<span class="dlc-item-state">{{ v ? '✓' : '✗' }}</span>
</div>
<div v-if="incomplete.length" class="dlc-incomplete">
{{ BF.deliveryIncomplete }}:{{ incomplete.map((i) => i.reason).join(';') }}
</div>
<div class="dlc-actions">
<button class="dlc-publish" type="button" @click="onPublish">{{ BF.deliveryPublish }}</button>
</div>
</div>
</template>
<style scoped>
.delivery-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.dlc-title {
font-size: 12.5px;
line-height: 1.6;
margin-bottom: 8px;
}
.dlc-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.dlc-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
margin-bottom: 4px;
}
.dlc-item-name {
font-size: 12px;
color: var(--pa-ink);
}
.dlc-item-state {
font-size: 12px;
color: var(--pa-ink-muted);
}
.dlc-incomplete {
margin-top: 8px;
font-size: 12px;
color: var(--pa-error);
}
.dlc-actions {
margin-top: 10px;
}
.dlc-publish {
padding: 7px 16px;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.3s var(--pa-ease), transform 0.15s var(--pa-ease);
}
.dlc-publish:hover {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<script setup>
// chat/DiagnosisCard.vue —— 诊断卡(G12/G13 错误对象 + G15 恢复预算)
// block: { type:'bioflow_diagnosis', category, code, summary, suggested_actions, retryable_without_change, retry_count }
import { computed } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({
block: { type: Object, required: true },
});
const chat = useChatStore();
const category = computed(() => props.block.category || 'unknown');
const summary = computed(() => props.block.summary || '');
const actions = computed(() => props.block.suggested_actions || []);
function onApply() {
chat.sendCardResult('diagnosis', {
category: props.block.category,
code: props.block.code,
summary: props.block.summary,
suggested_actions: props.block.suggested_actions,
}).catch(() => {});
}
</script>
<template>
<div class="diagnosis-card">
<div class="dc-title">
{{ BF.diagTitle }}
<span class="dc-badge" :class="`cat-${category}`">{{ category }}</span>
</div>
<div class="dc-summary">{{ summary }}</div>
<div class="dc-actions">
<div v-for="(a, i) in actions" :key="i" class="dc-suggestion">· {{ a }}</div>
<button class="dc-action" type="button" @click="onApply">{{ BF.diagApply }}</button>
</div>
</div>
</template>
<style scoped>
.diagnosis-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.dc-title {
font-size: 12.5px;
line-height: 1.6;
margin-bottom: 8px;
}
.dc-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.cat-resource { background: rgba(255, 152, 0, 0.14); color: #ff9800; }
.cat-environment { background: rgba(0, 212, 255, 0.14); color: #00b8d4; }
.cat-transient { background: rgba(255, 193, 7, 0.14); color: #c9a227; }
.cat-input { background: rgba(103, 58, 183, 0.14); color: #7c4dff; }
.cat-reference { background: rgba(0, 150, 136, 0.14); color: #009688; }
.cat-adapter { background: rgba(158, 158, 158, 0.14); color: #757575; }
.cat-artifact { background: rgba(233, 30, 99, 0.14); color: #e91e63; }
.cat-scientific_precondition { background: rgba(76, 175, 80, 0.14); color: #4caf50; }
.cat-unknown { background: var(--pa-surface-3); color: var(--pa-ink-muted); }
.dc-summary {
font-size: 12.5px;
line-height: 1.6;
color: var(--pa-ink);
margin-bottom: 8px;
}
.dc-actions {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.dc-suggestion {
font-size: 12px;
color: var(--pa-ink-muted);
}
.dc-action {
align-self: flex-start;
padding: 7px 16px;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.3s var(--pa-ease), transform 0.15s var(--pa-ease);
}
.dc-action:hover {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
</style>
+1
View File
@@ -66,6 +66,7 @@ function handleFiles(fileList) {
chat.uploadBioflowFileToJob(jobId.value, file, {
onProgress: (pct) => {
const rec = props.block.files.find((x) => x.name === file.name && x.status === 'uploading');
console.log('[bioflow-upload] onProgress pct=', pct, 'recFound=', !!rec, 'files=', (props.block.files || []).length);
if (rec) rec.progress = pct;
},
})
+1
View File
@@ -61,6 +61,7 @@ function backToSelect() {
/** 复用名 → 表单默认值来源标注(与后端 params 预填配合;来源标注在表单卡展示) */
async function confirmCreate() {
const cand = selectedCandidate.value;
console.log('[bioflow-card] confirmCreate selected=', selected.value, 'cand=', !!cand, 'jobId=', props.block.jobId);
if (!cand || confirming.value) return;
if (props.block.jobId) return; // 已创建过(防重复创建;FR-BIO 任务幂等语义)
confirming.value = true;
+64
View File
@@ -0,0 +1,64 @@
<script setup>
// PaperBriefCard.vue —— 论文引导表单卡(预填已抽取 brief,缺失字段待补)
// 卡片辅助、NL 主导:提交时把已填字段拼成一句 NL,回 Composer 走 sendPaper 提交。
import { ref, computed } from 'vue';
import { PP } from '../../i18n/paper.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({ block: { type: Object, required: true } });
const fields = computed(() => (Array.isArray(props.block.fields) ? props.block.fields : []));
const submitting = ref(false);
const values = ref({});
for (const f of fields.value) {
if (!(f.key in values.value)) values.value[f.key] = f.value || '';
}
function submit() {
if (submitting.value || props.block.submitted) return;
submitting.value = true;
const parts = fields.value
.filter((f) => values.value[f.key] && String(values.value[f.key]).trim())
.map((f) => `${f.label}:${String(values.value[f.key]).trim()}`);
const text = `帮我写一篇论文:${parts.join(';')}`;
props.block.submitted = true;
try {
useChatStore().sendPaper(text).catch(() => { /* 内部已标注错误 */ });
} catch { /* chat store 未就绪:降级 */ }
submitting.value = false;
}
</script>
<template>
<div class="paper-brief-card">
<div class="pbc-title">{{ PP.paperBriefTitle }}</div>
<div class="pbc-hint">{{ PP.paperBriefHint }}</div>
<div v-for="f in fields" :key="f.key" class="pbc-field">
<label class="pbc-label">{{ f.label }}</label>
<select v-if="Array.isArray(f.options) && f.options.length" v-model="values[f.key]" class="pbc-input">
<option value=""></option>
<option v-for="o in f.options" :key="o" :value="o">{{ o }}</option>
</select>
<input v-else v-model="values[f.key]" class="pbc-input" type="text" />
</div>
<div class="pbc-footer">
<button v-if="!props.block.submitted" class="pbc-submit" type="button" :disabled="submitting" @click="submit">
{{ submitting ? PP.paperBriefSubmitting : PP.paperBriefSubmit }}
</button>
<span v-else class="pbc-done">✓</span>
</div>
</div>
</template>
<style scoped>
.paper-brief-card { border: 1px solid var(--border, #e5e7eb); border-radius: 8px; padding: 12px; margin: 8px 0; }
.pbc-title { font-weight: 600; margin-bottom: 4px; }
.pbc-hint { color: #374151; font-size: 12px; margin-bottom: 8px; }
.pbc-field { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.pbc-label { min-width: 72px; font-size: 13px; color: #111827; }
.pbc-input { flex: 1; padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 4px; color: #111827; }
.pbc-footer { margin-top: 8px; }
.pbc-submit { padding: 6px 14px; border: none; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
.pbc-done { color: #16a34a; }
</style>
@@ -0,0 +1,51 @@
<script setup>
// PaperOutlineCard.vue —— 论文大纲确认卡(章节勾选/编辑要点 + 确认开写)
import { ref, computed } from 'vue';
import { PP } from '../../i18n/paper.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({ block: { type: Object, required: true } });
const sections = computed(() => (Array.isArray(props.block.sections) ? props.block.sections : []));
const confirming = ref(false);
function confirm() {
if (confirming.value || props.block.status === 'confirmed') return;
confirming.value = true;
props.block.status = 'confirmed';
try {
useChatStore().confirmPaper(props.block.paper_id);
} catch { /* chat store 未就绪:降级 */ }
confirming.value = false;
}
</script>
<template>
<div class="paper-outline-card">
<div class="poc-title">{{ PP.paperOutlineTitle }}:{{ props.block.title }}</div>
<div v-for="s in sections" :key="s.section_id" class="poc-section">
<input type="checkbox" :checked="s.selected !== false" @change="s.selected = $event.target.checked" />
<span class="poc-sec-title">{{ s.title }}</span>
<span v-if="s.key_points && s.key_points.length" class="poc-sec-pts">{{ s.key_points.join(';') }}</span>
</div>
<div class="poc-footer">
<button v-if="props.block.status !== 'confirmed'" class="poc-confirm" type="button" :disabled="confirming" @click="confirm">
{{ confirming ? PP.paperOutlineConfirming : PP.paperOutlineConfirm }}
</button>
<span v-else class="poc-confirmed">✓ 已启动撰写</span>
</div>
<div class="poc-modify-hint">{{ PP.paperOutlineModify }}</div>
</div>
</template>
<style scoped>
.paper-outline-card { border: 1px solid var(--border, #e5e7eb); border-radius: 8px; padding: 12px; margin: 8px 0; }
.poc-title { font-weight: 600; margin-bottom: 8px; }
.poc-section { display: flex; align-items: baseline; gap: 6px; margin-bottom: 4px; }
.poc-sec-title { font-weight: 500; }
.poc-sec-pts { color: #6b7280; font-size: 12px; }
.poc-footer { margin-top: 10px; }
.poc-confirm { padding: 6px 14px; border: none; border-radius: 6px; background: #16a34a; color: #fff; cursor: pointer; }
.poc-confirmed { color: #16a34a; }
.poc-modify-hint { margin-top: 6px; color: #9ca3af; font-size: 12px; }
</style>
@@ -0,0 +1,34 @@
<script setup>
// PaperProgressCard.vue —— 论文节级进度卡(逐节撰写状态,写稿期 2s 轮询驱动更新)
import { computed } from 'vue';
import { PP } from '../../i18n/paper.js';
const props = defineProps({ block: { type: Object, required: true } });
const sections = computed(() => (Array.isArray(props.block.sections) ? props.block.sections : []));
const statusLabel = computed(() => {
if (props.block.status === 'done') return PP.paperProgressDone;
if (props.block.status === 'error') return '撰写失败';
if (props.block.status === 'cancelled') return '已取消';
return PP.paperProgressWriting;
});
</script>
<template>
<div class="paper-progress-card">
<div class="ppc-title">{{ PP.paperProgressTitle }} · {{ statusLabel }}</div>
<div v-for="s in sections" :key="s.section_id" class="ppc-section" :class="'is-' + s.status">
<span class="ppc-dot">{{ s.status === 'done' ? '✓' : s.status === 'writing' ? '…' : '○' }}</span>
<span class="ppc-sec-title">{{ s.title }}</span>
</div>
</div>
</template>
<style scoped>
.paper-progress-card { border: 1px solid var(--border, #e5e7eb); border-radius: 8px; padding: 12px; margin: 8px 0; }
.ppc-title { font-weight: 600; margin-bottom: 8px; }
.ppc-section { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.ppc-dot { width: 16px; text-align: center; color: #9ca3af; }
.ppc-section.is-done .ppc-dot { color: #16a34a; }
.ppc-section.is-writing .ppc-dot { color: #2563eb; }
</style>
+117
View File
@@ -0,0 +1,117 @@
<script setup>
// chat/ReadinessCard.vue —— 就绪检查卡(G12/G10 check_readiness 四象限)
// block: { type:'bioflow_readiness', ready, quadrants:[{name,ok}], error }
import { computed } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({
block: { type: Object, required: true },
});
const chat = useChatStore();
const ready = computed(() => !!props.block.ready);
const quadrants = computed(() => props.block.quadrants || []);
function quadrantLabel(name) {
return BF[`readinessQ_${name}`] || name;
}
function onRecheck() {
chat.sendCardResult('readiness_check', {}).catch(() => {});
}
</script>
<template>
<div class="readiness-card">
<div class="rc-title">
{{ BF.readinessTitle }}
<span class="rc-badge">{{ ready ? BF.readinessReady : BF.readinessNotReady }}</span>
</div>
<div class="rc-grid">
<div v-for="q in quadrants" :key="q.name" class="rc-quadrant">
<span class="rc-dot" :class="{ ok: q.ok }">{{ q.ok ? '✓' : '✗' }}</span>
<span class="rc-quad-name">{{ quadrantLabel(q.name) }}</span>
</div>
</div>
<div class="rc-actions">
<button class="rc-recheck" type="button" @click="onRecheck">
{{ BF.readinessRecheck }}
</button>
</div>
</div>
</template>
<style scoped>
.readiness-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.rc-title {
font-size: 12.5px;
line-height: 1.6;
margin-bottom: 10px;
}
.rc-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.rc-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.rc-quadrant {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
}
.rc-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
background: var(--pa-error);
color: #fff;
}
.rc-dot.ok {
background: var(--pa-gradient);
}
.rc-quad-name {
font-size: 12px;
color: var(--pa-ink);
}
.rc-actions {
margin-top: 10px;
}
.rc-recheck {
padding: 7px 16px;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.3s var(--pa-ease), transform 0.15s var(--pa-ease);
}
.rc-recheck:hover {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<script setup>
// chat/VersioningCard.vue —— 版本化修改卡(G12/G16-G19)
// block: { type:'bioflow_versioning', plan_revision, run_id, affected_stages, based_on_revision }
import { computed } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({
block: { type: Object, required: true },
});
const chat = useChatStore();
const planRevision = computed(() => props.block.plan_revision || '—');
const runId = computed(() => props.block.run_id || '');
const affected = computed(() => props.block.affected_stages || []);
function onApply() {
chat.sendCardResult('apply_change', {
plan_revision: props.block.plan_revision,
run_id: props.block.run_id,
affected_stages: props.block.affected_stages,
}).catch(() => {});
}
</script>
<template>
<div class="versioning-card">
<div class="vc-title">
{{ BF.versioningTitle }}
<span v-if="runId" class="vc-badge">{{ runId }}</span>
</div>
<div class="vc-revision">{{ BF.versioningRevisionLabel }} {{ planRevision }}</div>
<div class="vc-affected">
{{ BF.versioningAffectedLabel }}:{{ affected.join('、') || BF.versioningAffectedEmpty }}
</div>
<div class="vc-actions">
<button class="vc-apply" type="button" @click="onApply">{{ BF.versioningApply }}</button>
</div>
</div>
</template>
<style scoped>
.versioning-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.vc-title {
font-size: 12.5px;
line-height: 1.6;
margin-bottom: 6px;
}
.vc-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
background: var(--pa-surface-3);
color: var(--pa-ink-muted);
}
.vc-revision {
font-size: 12px;
color: var(--pa-ink);
margin-bottom: 6px;
}
.vc-affected {
font-size: 12px;
color: var(--pa-ink-muted);
margin-bottom: 8px;
}
.vc-actions {
margin-top: 10px;
}
.vc-apply {
padding: 7px 16px;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.3s var(--pa-ease), transform 0.15s var(--pa-ease);
}
.vc-apply:hover {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
</style>
+27 -1
View File
@@ -16,7 +16,7 @@ export const BF = {
pathCandidatePurposeLabel: '用途',
pathAutoFilled: '已自动补全',
pathConfirm: '确认此路径',
pathEmpty: '暂无候选路径',
pathEmpty: '正在检索可用分析路径....',
pathPlanning: '正在分析需求并规划分析路径…',
formConfirmed: '参数已确认',
pathCreated: '任务已创建',
@@ -314,6 +314,32 @@ export const BF = {
extPaneRunsError: '历史加载失败',
extPaneRunsReload: '重试',
extPaneRunsCount: '共 {n} 次运行',
// ---- G12 分析控制四卡(ReadinessCard/DiagnosisCard/VersioningCard/DeliveryCard) ----
// 就绪检查卡(bioflow_readiness)
readinessTitle: '就绪检查',
readinessReady: '就绪',
readinessNotReady: '未就绪',
readinessRecheck: '重新检查',
readinessQ_environment: '环境',
readinessQ_reference: '参考',
readinessQ_space: '空间',
readinessQ_budget: '预算',
// 诊断卡(bioflow_diagnosis)
diagTitle: '诊断',
diagApply: '应用建议',
// 版本化修改卡(bioflow_versioning)
versioningTitle: '版本',
versioningRevisionLabel: '修订',
versioningAffectedLabel: '受影响重跑',
versioningAffectedEmpty: '无',
versioningApply: '应用修改',
// 交付清单卡(bioflow_delivery)
deliveryTitle: '交付',
deliveryPassed: '已通过',
deliveryPending: '待完善',
deliveryIncomplete: '未完成原因',
deliveryPublish: '发布交付',
};
/** 8 个 base 镜像(与 176 部署树 stage_image_map.json 一致;默认 bioflow-gf 工具链最全。
+2
View File
@@ -30,6 +30,8 @@ export const BUILTIN_TEMPLATES = {
speech: '讲话稿',
plan: '方案计划',
document: '通用文档',
paper_review: '综述论文',
paper_research: '研究论文',
};
export const DG = {
+2 -2
View File
@@ -57,7 +57,7 @@ export const STAGE_PHASE = {
export const BENCH_TABS = [
{ key: 'retrieval', label: '检索过程' },
{ key: 'sources', label: '文献来源' },
{ key: 'extract', label: '提取任务' },
{ key: 'extract', label: '上传文献' },
{ key: 'kg', label: '知识图谱' },
];
@@ -124,7 +124,7 @@ export const T = {
citationError: '引用校验失败,请重试',
qaDown: '知识问答服务当前不可用,请稍后重试',
kgDown: '知识图谱服务离线,实体搜索与邻域浏览暂不可用',
extractionDown: '提取任务服务离线',
extractionDown: '上传文献服务离线',
sourcesTitle: '文献来源',
sourceEmpty: '本次检索未命中文献',
retrievalEmpty: '发起提问后,这里会实时展示检索过程',
+28
View File
@@ -0,0 +1,28 @@
// i18n/paper.js —— 论文撰写(paper-writer)全部新文案(独立文件不碰 labels.js 红线)
export const PAPER_STEPS = [
{ name: '抽取需求', desc: '解析论文主题与写作要求', phase: 'planning' },
{ name: '生成大纲', desc: '检索知识库并规划章节', phase: 'retrieval' },
{ name: '逐节撰写', desc: '按大纲逐节生成正文', phase: 'generation' },
{ name: '文献引用', desc: '自动引用知识库文献并编号', phase: 'evidence' },
{ name: '合成全文', desc: '合并全文并渲染参考文献', phase: 'done' },
];
export const PP = {
paperBriefTitle: '论文撰写引导',
paperBriefHint: '请补充以下信息(也可直接打字告诉我)',
paperBriefSubmit: '生成大纲',
paperBriefSubmitting: '生成中…',
paperOutlineTitle: '论文大纲',
paperOutlineConfirm: '确认并开始撰写',
paperOutlineConfirming: '启动中…',
paperOutlineModify: '说一句修改意见,如「把展望合并到讨论」',
paperProgressTitle: '撰写进度',
paperProgressWriting: '撰写中…',
paperProgressDone: '撰写完成',
paperRefsTitle: '参考文献',
paperRefsDisclaimer: '参考文献依据知识库资料生成,格式为 GB/T 7714 近似,建议人工核对。',
paperCiteTitle: '引用来源',
};
export default PP;
+11
View File
@@ -107,4 +107,15 @@ export function route(text, hasAttachments, hasActiveArtifact) {
return 'qa';
}
// —— 论文撰写意图(paper-writer):在 docgen 分支内做 sub-route ——
// 「论文/综述」主题词 + 写类动词 → 论文撰写(结构化引用体系),
// 其余 docgen(报告/通知等)仍走原 sendDocgen。纯函数,node 直测。
const PAPER_TYPE_WORDS = ['论文', '综述'];
export function paperIntent(text) {
const s = String(text == null ? '' : text).trim();
if (!s) return false;
return PAPER_TYPE_WORDS.some((w) => s.includes(w)) && hasAnyWord(s, WRITE_VERB);
}
export default route;
+440 -21
View File
@@ -19,6 +19,7 @@ import { useSessionsStore } from './sessions.js';
import { PHASE_LABEL, PHASE_DESC, T } from '../i18n/labels.js';
import { MODES } from '../config/modes.js';
import { renderMarkdown } from '../components/chat/markdown.js';
console.log('[bioflow-version] chat.js raw.bioflow-fix v2 loaded');
// ---- P3 文档生成:独立分支(send/_runStream 零改动) ----
import { DOCGEN_STEPS, CAP_BADGES, DG as DG_LABELS } from '../i18n/docgen.js';
@@ -26,6 +27,10 @@ import { generate, getTask } from '../api/docgen.js';
import { useSettingsStore } from './settings.js';
import { useDocgenStore } from './docgen.js';
// ---- 论文撰写(paper-writer):独立分支(send/_runStream/sendDocgen 零改动) ----
import { paperTurn, confirmPaper as confirmPaperApi, paperProgress as paperProgressApi } from '../api/paper.js';
import { PP as PP_LABELS } from '../i18n/paper.js';
// ---- P2 生信流程编排:独立分支(send/_runStream/sendDocgen 零改动) ----
import { BF } from '../i18n/bioflow.js';
import { bioflowChatStream } from '../api/bioflow.js';
@@ -421,6 +426,68 @@ export function patchBioflowArchiveDone(aMsg, blk, result) {
return blk;
}
/** G12 就绪检查卡(bioflow_readiness):check_readiness 四象限 */
export function patchBioflowReadiness(aMsg, data) {
let blk = (aMsg.blocks || []).find((b) => b && b.type === 'bioflow_readiness');
if (!blk) {
blk = { type: 'bioflow_readiness', ready: false, quadrants: [], error: null };
aMsg.blocks.push(blk);
}
blk.ready = !!(data && data.ready);
blk.quadrants = (data && data.quadrants) || [];
blk.error = (data && data.error) || null;
return blk;
}
/** G12 诊断卡(bioflow_diagnosis):G13 错误对象(扁平字段)+ 恢复预算 */
export function patchBioflowDiagnosis(aMsg, data) {
let blk = (aMsg.blocks || []).find((b) => b && b.type === 'bioflow_diagnosis');
if (!blk) {
blk = {
type: 'bioflow_diagnosis', category: '', code: '', summary: '',
suggested_actions: [], retryable_without_change: false, retry_count: 0,
};
aMsg.blocks.push(blk);
}
blk.category = (data && data.category) || '';
blk.code = (data && data.code) || '';
blk.summary = (data && data.summary) || '';
blk.suggested_actions = (data && data.suggested_actions) || [];
blk.retryable_without_change = !!(data && data.retryable_without_change);
if (data && data.retry_count != null) blk.retry_count = data.retry_count;
return blk;
}
/** G12 版本化修改卡(bioflow_versioning):修订号 + 受影响下游阶段 */
export function patchBioflowVersioning(aMsg, data) {
let blk = (aMsg.blocks || []).find((b) => b && b.type === 'bioflow_versioning');
if (!blk) {
blk = {
type: 'bioflow_versioning', plan_revision: '', run_id: '',
affected_stages: [], based_on_revision: null,
};
aMsg.blocks.push(blk);
}
blk.plan_revision = (data && data.plan_revision) ?? '';
blk.run_id = (data && data.run_id) ?? '';
blk.affected_stages = (data && data.affected_stages) || [];
blk.based_on_revision = (data && data.based_on_revision) ?? null;
return blk;
}
/** G12 交付清单卡(bioflow_delivery):八项校验 + 未完成原因 */
export function patchBioflowDelivery(aMsg, data) {
let blk = (aMsg.blocks || []).find((b) => b && b.type === 'bioflow_delivery');
if (!blk) {
blk = { type: 'bioflow_delivery', ok: false, checks: {}, incomplete: [] };
aMsg.blocks.push(blk);
}
blk.ok = !!(data && data.ok);
blk.checks = (data && data.checks) || {};
blk.incomplete = (data && data.incomplete) || [];
return blk;
}
/** 阶段待确认 → 参数表单块(FR-BIO-008);同阶段失败改参数重跑复用同一形态(FR-BIO-012) */
export function patchBioflowForm(aMsg, data, { rerun = false } = {}) {
const stage = data.stage;
@@ -436,6 +503,16 @@ export function patchBioflowForm(aMsg, data, { rerun = false } = {}) {
}
/** 阶段待文件 → 文件引导块(FR-BIO-005) */
export function patchBioflowGuidance(aMsg, text) {
// 自然语言引导气泡(方案:自然语言主通道,卡片辅助)——弹卡片前先插入一条 text 块。
if (!text) return null;
const html = renderMarkdown(String(text));
console.log('[bioflow-guidance] text=', String(text).slice(0, 40), 'html非空=', Boolean(html));
if ((aMsg.blocks || []).some((b) => b && b.type === 'text' && b.html === html)) return null;
aMsg.blocks.push({ type: 'text', html });
return true;
}
export function patchBioflowFile(aMsg, data) {
const stage = data.stage;
if (!stage) return null;
@@ -900,6 +977,10 @@ function newStreamCtx(sessionId, msgList) {
const DOCGEN_TASK_POLL_MS = 2000;
const docgenTaskPolls = new Map(); // task_id → { timer, sessionId, aMsg }
// 论文撰写(paper-writer)写稿期轮询注册表:paper_id → {timer, sessionId}
const PAPER_POLL_MS = 2000;
const paperPolls = new Map();
export const useChatStore = defineStore('chat', {
state: () => ({
sessionId: null, // 当前会话 id(与 sessions.activeId 对应)
@@ -1089,9 +1170,12 @@ export const useChatStore = defineStore('chat', {
onEvent: (type, data) => {
const frame = patchBioflowEvent(m, { type, data });
if (frame) this.scrollSeq++;
if (type === 'stage_await_confirm' && data) { patchBioflowForm(m, data); this.scrollSeq++; }
else if (type === 'stage_await_files' && data) { patchBioflowFile(m, data); this.scrollSeq++; }
if (type === 'stage_await_confirm' && data) { patchBioflowGuidance(m, data.guidance); patchBioflowForm(m, data); this.scrollSeq++; }
else if (type === 'stage_await_files' && data) { patchBioflowGuidance(m, data.guidance || data.message); patchBioflowFile(m, data); this.scrollSeq++; }
else if (type === 'stage_failed' && data) { patchBioflowGuidance(m, data.guidance); patchBioflowDiagnosis(m, data); this.scrollSeq++; }
else if (type === 'job_done' && data) {
patchBioflowGuidance(m, data.guidance);
if (data.delivery) patchBioflowDelivery(m, data.delivery);
const ab = patchBioflowArchiveSuggest(m, data.job_id || jobId);
if (ab) ab.jobId = data.job_id || jobId;
this.scrollSeq++;
@@ -1565,6 +1649,208 @@ export const useChatStore = defineStore('chat', {
entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
},
// ===============================================================
// 论文撰写(paper-writer):sendPaper —— 用户消息落库 → POST /api/paper/turns
// (短同步,BFF agent 直写助手消息并返回 blocks)→ 本地回填助手消息。
// 写稿期由 confirmPaper 触发后台循环,前端 2s 轮询 paperProgress 更新进度卡,
// complete 后重载会话取 agent 直写的 artifact 块并接管到文档 TAB。
// ===============================================================
async sendPaper(description, { userMsg, templateId, paperId } = {}) {
const q = String(description == null ? '' : description).trim();
if (!q || this.busy) return;
const sessions = useSessionsStore();
if (!this.sessionId) {
try {
const sess = await sessions.create();
this.sessionId = sess.id;
} catch {
return; // 创建失败:列表页可见错误
}
}
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
}
const sessionId = this.sessionId;
const msgList = this.messages;
this.sessionMsgs[sessionId] = msgList;
const ctx = newStreamCtx(sessionId, msgList);
this.streamCtx[sessionId] = ctx;
this.lastResult = null;
// 1. 用户消息入列 + 落库
let userId = null;
let userMsgRef = null;
if (userMsg) {
userMsgRef = userMsg;
userId = userMsg.id;
} else {
const raw = { id: null, role: 'user', kind: 'paper', status: 'done', text: q, mode: 'ask', meta: {}, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(raw);
userMsgRef = msgList[msgList.length - 1];
try {
const saved = await createMessage(sessionId, { role: 'user', text: q });
userId = saved.id;
userMsgRef.id = userId;
} catch (err) {
userMsgRef.persistError = err;
this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false });
return;
}
}
sessions.patchLocal(sessionId, { status: 'running' });
try { await updateSessionQuiet(sessionId, { status: 'running' }); } catch { /* 状态 PATCH 失败不阻断 */ }
// 2. POST turns(短同步)
let payload;
try {
payload = await paperTurn({ session_id: sessionId, text: q, paper_id: paperId || null, template_key: templateId || '' });
} catch (err) {
const marker = this._errorMarker(err.code);
const text = err.message || (marker && marker.text) || T.upstreamError;
const raw = { id: null, role: 'assistant', kind: 'paper', status: 'error', streamErr: (marker && marker.ann) || 'message', errText: text, blocks: [{ type: 'text', html: text }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(raw);
const am = msgList[msgList.length - 1];
this._persistFinal(am, sessionId, 'error', {}).catch(() => {});
this._finishCtx(ctx, { ok: false, error: { code: err.code || 'NETWORK', message: text, retryable: true }, message: am, sessionId, userMessageId: userId, assistantMessageId: am.id });
return;
}
// 3. 助手消息(agent 已落库;本地回填 blocks)
const blocks = Array.isArray(payload && payload.blocks) ? payload.blocks : [];
const raw = { id: null, role: 'assistant', kind: 'paper', status: 'done', text: null, blocks: blocks.length ? blocks : [{ type: 'text', html: '(论文服务未返回内容)' }], raw: payload || null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(raw);
const aMsg = msgList[msgList.length - 1];
ctx.aMsg = aMsg;
this.runningMsgId = null;
try {
const saved = await createMessage(sessionId, { role: 'assistant', kind: 'paper', status: 'done', blocks: raw.blocks });
aMsg.id = saved.id;
} catch {
aMsg.id = null;
}
delete this.streamCtx[sessionId]; // 释放 busy(无流)
this.lastResult = { ok: true, payload, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id };
this.scrollSeq++;
},
/** 确认大纲 → 本地先 seed 进度卡 → POST confirm 启动后台写节循环 → 2s 轮询进度 */
async confirmPaper(paperId) {
const sessionId = this.sessionId;
if (!paperId || !sessionId) return;
this._seedPaperProgress(sessionId, paperId);
try {
await confirmPaperApi(paperId, sessionId);
} catch { /* 启动失败:卡片可见错误 */ }
this._startPaperPoll(paperId, sessionId);
},
/** 确认后立即在消息流上一条 paper_progress 块(用大纲节标题做种子,全部 pending),
* 使 _startPaperPoll 的 _patchPaperProgress 有块可 patch,进度卡在撰写期间逐节刷新,
* 而不是等 complete 重载时一次性出现。 */
_seedPaperProgress(sessionId, paperId) {
const list = this.sessionMsgs[sessionId] || this.messages || [];
for (const m of list) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
if (m.blocks.some((b) => b && b.type === 'paper_progress' && b.paper_id === paperId)) return;
}
let sections = [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
const ob = m.blocks.find((b) => b && b.type === 'paper_outline' && b.paper_id === paperId);
if (ob) {
sections = (Array.isArray(ob.sections) ? ob.sections : []).map((sec) => ({
section_id: sec.section_id, title: sec.title || '', status: 'pending',
}));
break;
}
}
const raw = {
id: null, role: 'assistant', kind: 'paper', status: 'done', text: null,
blocks: [{ type: 'paper_progress', paper_id: paperId, status: 'writing', sections, current_section_id: '' }],
raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
};
list.push(raw);
this.scrollSeq++;
},
_startPaperPoll(paperId, sessionId) {
if (paperPolls.has(paperId)) return;
const entry = { timer: null, sessionId };
paperPolls.set(paperId, entry);
const clear = () => {
if (entry.timer != null) clearTimeout(entry.timer);
if (paperPolls.get(paperId) === entry) paperPolls.delete(paperId);
};
const tick = async () => {
if (!paperPolls.has(paperId) || paperPolls.get(paperId) !== entry) return;
let prog = null;
try {
prog = await paperProgressApi(paperId);
} catch {
prog = null; // 瞬时失败:继续轮询
}
if (!paperPolls.has(paperId) || paperPolls.get(paperId) !== entry) return;
if (prog && prog.ok) {
this._patchPaperProgress(sessionId, prog);
if (prog.status === 'complete' || prog.status === 'cancelled' || prog.status === 'error') {
clear();
if (prog.status === 'complete') {
try {
await this.loadSession(sessionId); // 重载取 agent 直写的 artifact 块
await this._adoptPaperArtifact(sessionId);
} catch { /* 重载失败不阻断 */ }
}
return;
}
}
entry.timer = setTimeout(tick, PAPER_POLL_MS);
};
entry.timer = setTimeout(tick, PAPER_POLL_MS);
},
/** 把进度同步进消息流里的 paper_progress 块 */
_patchPaperProgress(sessionId, prog) {
const list = this.sessionMsgs[sessionId] || [];
const current = prog.current_section_id || '';
for (const m of list) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
const pb = m.blocks.find((b) => b && b.type === 'paper_progress');
if (pb) {
pb.status = prog.status;
pb.current_section_id = current;
// 后端节状态只有 pending/done;当前节用 current_section_id 标为 writing(…动画)
pb.sections = (prog.sections || []).map((sec) => ({
section_id: sec.section_id, title: sec.title,
status: (current && sec.section_id === current) ? 'writing' : sec.status,
}));
}
}
this.scrollSeq++;
},
/** 找到最新含 paper_id 的 artifact 块并接管到文档 TAB */
async _adoptPaperArtifact(sessionId) {
const list = this.sessionMsgs[sessionId] || [];
for (const m of list) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
const art = m.blocks.find((b) => b && b.type === 'artifact' && b.paper_id);
if (art) {
try {
const { useDocgenStore } = await import('../stores/docgen.js');
await useDocgenStore().adoptArtifact(art, { initialMd: '' });
} catch { /* docgen store 未就绪不阻断 */ }
if (this.sessionId === sessionId) {
try {
const { useBenchStore } = await import('../stores/bench.js');
useBenchStore().setActiveTab('doc');
} catch { /* bench 未就绪不阻断 */ }
}
return;
}
}
},
/**
* 进度条点击定位:请求 bioflow 面板定位 + 切右侧生信 TAB。
* BioJobsPane 消费 panelLocate(nonce 变化)后 scrollIntoView + 展开 log。
@@ -1983,12 +2269,20 @@ export const useChatStore = defineStore('chat', {
const frame = patchBioflowEvent(aMsg, { type, data });
if (frame) this.scrollSeq++;
if (type === 'stage_await_confirm' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowForm(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_await_files' && data) {
patchBioflowGuidance(aMsg, data.guidance || data.message);
patchBioflowFile(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_failed' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowDiagnosis(aMsg, data);
this.scrollSeq++;
} else if (type === 'job_done' && data) {
patchBioflowGuidance(aMsg, data.guidance);
if (data.delivery) patchBioflowDelivery(aMsg, data.delivery);
const blk = patchBioflowArchiveSuggest(aMsg, data.job_id || jobId);
if (blk) blk.jobId = data.job_id || jobId;
bioflow.registerReport({
@@ -2023,18 +2317,41 @@ export const useChatStore = defineStore('chat', {
try {
const data = await uploadBioflowFile(jobId, form, {
onUploadProgress: (e) => {
item.progress = e.pct;
console.log('[bioflow-upload] onUploadProgress pct=', e.pct, 'loaded=', e.loaded, 'total=', e.total);
// item 是原始对象,直接改会绕过 Vue 响应式——经响应式数组找代理更新
const rec = found && Array.isArray(found.blk.files)
? found.blk.files.find((x) => x.name === item.name && x.status === 'uploading')
: null;
if (rec) rec.progress = e.pct;
else item.progress = e.pct;
if (typeof onProgress === 'function') onProgress(e.pct, item);
},
});
item.status = 'done';
item.progress = 1;
item.fileId = (data && data.data && data.data.file_id) || null;
const doneRec = found && Array.isArray(found.blk.files)
? found.blk.files.find((x) => x.name === item.name)
: null;
if (doneRec) {
doneRec.status = 'done';
doneRec.progress = 1;
doneRec.fileId = (data && data.data && data.data.file_id) || null;
} else {
item.status = 'done';
item.progress = 1;
item.fileId = (data && data.data && data.data.file_id) || null;
}
if (found) this.scrollSeq++;
return (data && data.data) || data || {};
} catch (err) {
item.status = 'error';
item.error = err?.message || BF.fileUploadError;
const errRec = found && Array.isArray(found.blk.files)
? found.blk.files.find((x) => x.name === item.name)
: null;
if (errRec) {
errRec.status = 'error';
errRec.error = err?.message || BF.fileUploadError;
} else {
item.status = 'error';
item.error = err?.message || BF.fileUploadError;
}
if (found) this.scrollSeq++;
throw err;
}
@@ -2165,6 +2482,7 @@ export const useChatStore = defineStore('chat', {
* 经 BioflowAgent 工具执行 + 自然语言复述;卡片与自然语言两条通道语义一致。
* card ∈ path_selection|form_confirm|file_upload|archive_choice|extension_action。 */
async sendCardResult(card, payload, { text = '', userMsg = null } = {}) {
console.log('[bioflow-card] sendCardResult', card, 'busy=', this.busy);
if (this.busy) return;
const sessions = useSessionsStore();
if (!this.sessionId) {
@@ -2257,19 +2575,64 @@ export const useChatStore = defineStore('chat', {
const p = ev.payload || {};
const answer = p.answer || '';
updateLocal(() => {
aMsg.blocks = [
{ type: 'bioflow_path', status: 'done', candidates: [] },
{ type: 'text', html: answer },
];
const blocks = [];
// 仅路径选择卡回填 path 块(候选由上一轮路径卡承载);其余卡操作只出自然语言复述
if (card === 'path_selection') {
blocks.push({ type: 'bioflow_path', status: 'done', candidates: [] });
}
blocks.push({ type: 'text', html: answer });
const bfPayload = p.bioflow || {};
if (bfPayload.readiness) {
blocks.push({ type: 'bioflow_readiness', ...bfPayload.readiness, error: null });
}
if (bfPayload.versioning) {
blocks.push({ type: 'bioflow_versioning', ...bfPayload.versioning });
}
aMsg.blocks = blocks;
aMsg.raw = p.raw ?? p;
aMsg.status = 'done';
});
// session↔job 绑定持久化:complete 的 bioflow.active_job_id → meta.bioflow
const bf = p.bioflow || {};
// BFF 归一化剥离了顶层 bioflow,active_job_id 在 raw.bioflow 里
const bf = p.bioflow || (p.raw && p.raw.bioflow) || {};
if (bf.active_job_id) {
updateSessionQuiet(sessionId, {
meta: { bioflow: { active_job_id: bf.active_job_id, chain: bf.chain || '' } },
}).catch(() => {});
// 修复:skill 化 S3 改走 sendCardResult 后丢失了任务事件订阅——
// 补上 connectJobEvents,把 stage_await_files / stage_await_confirm /
// stage_failed / job_done 桥接进消息块(guidance 气泡 + 各卡片)。
try {
const bioflow = useBioflowStore();
const jobId = bf.active_job_id;
console.log('[bioflow-guidance] sendCardResult 建立订阅 jobId=', jobId);
bioflow.connectJobEvents(jobId, sessionId, {
onEvent: (type, data, seq) => {
const frame = patchBioflowEvent(aMsg, { type, data });
if (frame) this.scrollSeq++;
if (type === 'stage_await_confirm' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowForm(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_await_files' && data) {
console.log('[bioflow-guidance] stage_await_files data=', JSON.stringify(data).slice(0, 200));
patchBioflowGuidance(aMsg, data.guidance || data.message);
patchBioflowFile(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_failed' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowDiagnosis(aMsg, data);
this.scrollSeq++;
} else if (type === 'job_done' && data) {
patchBioflowGuidance(aMsg, data.guidance);
if (data.delivery) patchBioflowDelivery(aMsg, data.delivery);
const blk = patchBioflowArchiveSuggest(aMsg, data.job_id || jobId);
if (blk) blk.jobId = data.job_id || jobId;
this.scrollSeq++;
}
},
});
} catch { /* EventSource 不可用:任务事件仍由快照兜底 */ }
}
try {
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
@@ -2353,7 +2716,12 @@ export const useChatStore = defineStore('chat', {
// S2:bioflow 帧(生信对话编排)不推 pipeline 相位——生信消息走
// bioflow 块,由 sendCardResult / bioflow 分支消费,此处仅记录。
const isBioflow = typeof p.stage === 'string' && p.stage.startsWith('bioflow_');
if (isBioflow) return;
if (isBioflow) {
// 生信消息:一旦收到 bioflow 帧,立即移除检索 pipeline 骨架(避免闪烁残留)
const pi = (aMsg.blocks || []).findIndex((b) => b && b.type === 'pipeline');
if (pi >= 0) aMsg.blocks.splice(pi, 1);
return;
}
if (!ctx.completedOnce && phase !== 'done') this._advancePipeline(aMsg, phase, label);
if (phase === 'done') {
// 上游 completed 事件:当前相位定格 done(此时 complete 尚未到达)
@@ -2392,18 +2760,68 @@ export const useChatStore = defineStore('chat', {
// complete 帧之后流即关闭:同步收口,避免「流自然结束」误判为连接中断
endTask();
const payload = ev.payload || {};
// S2 多轮续接:生信规划返回的 chain/active_job_id 写回 session,
// 下轮(如路径选择「1」)据此跳过意图预筛续接生信 agent。
const bf = payload.bioflow || (payload.raw && payload.raw.bioflow) || {};
if (bf.chain || bf.active_job_id) {
updateSessionQuiet(sessionId, {
meta: { bioflow: { active_job_id: bf.active_job_id || "", chain: bf.chain || "" } },
}).catch(() => {});
// 任务事件订阅:自然语言路径建任务后补 connectJobEvents,
// 把 stage_await_files / stage_await_confirm / stage_failed / job_done
// 桥接进消息块(文件卡/表单卡/诊断卡/交付卡),与卡片路径一致。
if (bf.active_job_id) {
try {
const bioflow = useBioflowStore();
const jobId = bf.active_job_id;
bioflow.connectJobEvents(jobId, sessionId, {
onEvent: (type, data, seq) => {
const frame = patchBioflowEvent(aMsg, { type, data });
if (frame) this.scrollSeq++;
if (type === 'stage_await_confirm' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowForm(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_await_files' && data) {
patchBioflowGuidance(aMsg, data.guidance || data.message);
patchBioflowFile(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_failed' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowDiagnosis(aMsg, data);
this.scrollSeq++;
} else if (type === 'job_done' && data) {
patchBioflowGuidance(aMsg, data.guidance);
if (data.delivery) patchBioflowDelivery(aMsg, data.delivery);
const blk = patchBioflowArchiveSuggest(aMsg, data.job_id || jobId);
if (blk) blk.jobId = data.job_id || jobId;
this.scrollSeq++;
}
},
});
} catch { /* EventSource 不可用:任务事件仍由快照兜底 */ }
}
}
const blocks = qaToBlocks(payload);
const isBioflow = !!(bf && bf.handled);
updateLocal(() => {
// text 块先不放:由揭示动画以 stream 块渐进显示,终了替换为最终 text
aMsg.blocks = [
aMsg.blocks[0],
...blocks.filter((b) => b.type !== 'text'),
];
// 生信消息不显示检索 pipeline 骨架——直接挂生信卡片 + 自然语言。
aMsg.blocks = isBioflow
? [...blocks.filter((b) => b.type !== 'text')]
: [aMsg.blocks[0], ...blocks.filter((b) => b.type !== 'text')];
// 生信卡片:路径卡/就绪卡/版本卡(与自然语言 answer 同时出现)
if (bf.plan) patchBioflowPlan(aMsg, bf.plan);
if (bf.readiness) patchBioflowReadiness(aMsg, bf.readiness);
if (bf.versioning) patchBioflowVersioning(aMsg, bf.versioning);
aMsg.raw = payload.raw ?? payload;
const pipe = aMsg.blocks[0];
pipe.status = 'done';
pipe.steps.forEach((s) => { s.state = 'done'; });
if (!isBioflow) {
const pipe = aMsg.blocks[0];
pipe.status = 'done';
pipe.steps.forEach((s) => { s.state = 'done'; });
}
});
console.log('[bioflow-debug] onComplete blocks=', (aMsg.blocks || []).map((b) => b && b.type), 'bf.handled=', !!(bf && bf.handled), 'bf.plan=', !!(bf && bf.plan));
ctx.completedOnce = true;
// 揭示动画(FR-CHAT-010):stream 块瞬态、不落库
if (payload.answer) {
@@ -2411,6 +2829,7 @@ export const useChatStore = defineStore('chat', {
} else {
updateLocal(() => { aMsg.status = 'done'; });
}
console.log('[bioflow-debug] after reveal blocks=', (aMsg.blocks || []).map((b) => b && b.type));
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
// FR-PRIV-005:onComplete 整体替换 blocks 后 flush 私有 pending(防丢块)
this._flushPrivate(aMsg);
+36
View File
@@ -0,0 +1,36 @@
// stores/paper.js —— 论文撰写探活 + 开关(镜像 health.js 的 30s 周期探活)
// availability: 'online' | 'offline' | 'unknown'。offline 时 Composer 触发词落 docgen 单发降级。
import { defineStore } from 'pinia';
import { paperHealth } from '../api/paper.js';
const HEALTH_INTERVAL = 30000;
export const usePaperStore = defineStore('paper', {
state: () => ({
availability: 'unknown',
polling: false,
}),
actions: {
async probe() {
try {
const data = await paperHealth();
this.availability = data && data.ok ? 'online' : 'offline';
} catch {
this.availability = 'offline';
}
},
start() {
if (this.polling) return;
this.polling = true;
this.probe();
this._timer = setInterval(() => {
if (document.hidden) return;
this.probe();
}, HEALTH_INTERVAL);
},
stop() {
clearInterval(this._timer);
this.polling = false;
},
},
});