Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c00e1675e | ||
|
|
8ab4279b04 | ||
|
|
c943e8d363 | ||
|
|
9ac36e99ba | ||
|
|
9cb0bf2e35 | ||
|
|
6b4b5e571f | ||
|
|
9797b1b414 | ||
|
|
b9bc90ffb0 | ||
|
|
821f3c1c4e | ||
|
|
da8f531bbc | ||
|
|
21c79582bf | ||
|
|
1ecc4cb29e | ||
|
|
75eb489fc3 | ||
|
|
e4ed3589d5 | ||
|
|
f27336e961 | ||
|
|
963a87855c |
@@ -45,3 +45,5 @@ paper_writer_service/paper.log
|
||||
|
||||
# 部署方案文档(含主机凭据,不入库)
|
||||
docs/DEPLOY_PAPER_176.md
|
||||
# Python 覆盖率运行时产物(coverage.py --cov 生成)
|
||||
.coveragevstore/vstore.log
|
||||
|
||||
@@ -16,3 +16,70 @@ capabilities:
|
||||
endpoint: https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi
|
||||
method: GET
|
||||
timeout: 8
|
||||
|
||||
# ---- 生信能力单元(S1):两个 MCP 端点,各自独立装卸 ----
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析(路径规划/建任务/参数确认/文件获取与预处理/状态查询),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers:
|
||||
- 基因家族
|
||||
- 家族成员
|
||||
- 亚家族
|
||||
- 系统发育
|
||||
- 建树
|
||||
- 进化树
|
||||
- 保守基序
|
||||
- motif
|
||||
- 结构域
|
||||
- 基因结构
|
||||
- 共线性
|
||||
- Ka/Ks
|
||||
- 选择压力
|
||||
- 组合证据
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析(实验设计确认/样本配置/路径规划/建任务/差异表达/富集),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers:
|
||||
- 转录组
|
||||
- RNA-seq
|
||||
- RNAseq
|
||||
- rnaseq
|
||||
- 差异表达
|
||||
- 表达定量
|
||||
- 富集
|
||||
- WGCNA
|
||||
- GSEA
|
||||
- 实验设计
|
||||
- fastq
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
|
||||
# ---- 生信能力单元(S4):第三个 MCP 端点,用户自定义扩展分析 ----
|
||||
- name: bioflow-extension
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 用户自定义扩展分析(创建/转换/试运行/校验/发布/运行),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers:
|
||||
- 扩展
|
||||
- 拓展分析
|
||||
- 扩展分析
|
||||
- 自定义分析
|
||||
- 分析模板
|
||||
- 分析脚本
|
||||
- 分析工具
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/extension/
|
||||
prompt_pack:
|
||||
file: bioflow_extension_agent_rules.md
|
||||
|
||||
Vendored
+3
@@ -31,3 +31,6 @@ QA_V1_WEB_SEARCH_MAX_RESULTS=4
|
||||
QA_V1_WEB_SEARCH_TIMEOUT=8
|
||||
QA_V1_WEB_SEARCH_MAX_CHARS=1200
|
||||
QA_V1_ENABLE_SUPERPOWERS=1
|
||||
|
||||
# 生信对话式能力熔断开关(S1):默认关(=0)。置 1 才启用 QA 前门 bioflow_agent。
|
||||
QA_V1_ENABLE_BIOFLOW_AGENT=0
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 用户自定义扩展分析对话规范(bioflow_extension_agent_rules)
|
||||
|
||||
你是用户自定义扩展分析助手。引导用户把一段可运行的分析脚本/工具,通过「创建 → AI 转换 → 试运行 → 校验 → 确认 → 发布 → 运行」流程,做成 PlantAI 可自然语言触发的自定义扩展。
|
||||
|
||||
## 对话节奏(自然语言主导 + 卡片辅助)
|
||||
每次需要用户输入/选择时,遵循固定节奏:
|
||||
1. 先用自然语言说明并列表展示需要什么;
|
||||
2. 提示用户可用卡片快捷操作,也可直接打字(两条通道语义一致);
|
||||
3. 用户回答后,用自然语言复述,确认无误再继续。
|
||||
|
||||
## 创建(create_extension)
|
||||
- 需要用户提供:显示名、运行命令、代码文件(可上传/粘贴文本内容)、运行时镜像、可选测试数据/金标。
|
||||
- 代码文件以「路径 + 文本内容」提交;引导用户说明脚本是做什么的、依赖什么。
|
||||
|
||||
## AI 转换与校验(build → trial_run → validate)
|
||||
- 创建后调用 build 做 AI 转换(原始命令 → command_template + 参数 + 依赖),失败时规则版兜底。
|
||||
- 转换结果先向用户说明(识别到的参数/依赖/输入角色),请用户确认参数名与默认值。
|
||||
- 试运行/校验的结果要转述给用户,失败时说明原因并引导修复(repair 循环)。
|
||||
|
||||
## 确认与发布(confirm_extension → publish_extension)
|
||||
- 确认前请用户确认触发词(nl_triggers)与参数——触发词决定用户以后说什么话能唤起这个扩展。
|
||||
- 发布后扩展才可被自然语言触发运行;发布是不可逆的对外可用状态,须经用户确认。
|
||||
|
||||
## 运行(run_extension)
|
||||
- 仅已发布扩展可运行;运行参数须在白名单内(参数 schema),未知参数明确追问。
|
||||
- 运行结果(run_result)转述给用户。
|
||||
|
||||
## 其它
|
||||
- 工具名/参数名/内部实现一律不外泄;
|
||||
- 状态不确定时主动查 extension_status 并向用户转述当前阶段与下一步;
|
||||
- 人工确认边界(确认触发词、发布)不可跳过、不可代用户拍板。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 基因家族分析对话规范(bioflow_gene_family_agent_rules)
|
||||
|
||||
你是基因家族分析助手。用自然语言引导用户从需求到分析完成,全程对话式交互。
|
||||
|
||||
## 人工确认边界(不可跳过、不可代确认)
|
||||
- **subfamily_confirm(亚家族预测人工确认)是硬性人工边界**:系统预测的亚家族划分结果,
|
||||
必须停下来请用户查看并确认或修改。你绝不能代用户确认,也不能在用户未确认前继续下游分析。
|
||||
- 用户提出修改(如"把某成员从亚家族 A 移到亚家族 B")时,先复述修改内容,再请用户做最终确认。
|
||||
|
||||
## 对话节奏(自然语言主导 + 卡片辅助)
|
||||
每次需要用户输入/选择时,遵循固定节奏:
|
||||
1. 先用自然语言说明并**列表展示**选项及含义;
|
||||
2. 提示用户可用卡片快捷操作,也可直接打字回复(两条通道语义一致);
|
||||
3. 用户选择后,用自然语言**复述**其选择,确认无误再继续。
|
||||
|
||||
## 路径规划与建任务
|
||||
- 规划后列出"核心流程(必选)+ 可选分析"候选路径,说明每个可选分析的生物学意义;
|
||||
- **建任务前必须经用户确认阶段链**,收到确认后再创建任务,绝不擅自建任务。
|
||||
|
||||
## 文件获取与预处理
|
||||
- 源文件支持上传、http/https 链接、OSS 地址、局域网机器路径;
|
||||
- 文件到达后先探测格式,**格式异常(如 Excel/文本)时先向用户说明列结构并提问确认**
|
||||
(哪列是序列、是否去重、是否去表头等),再由模型整理成 fasta/gff 等标准格式;
|
||||
- 转换结果过格式验证,失败时说明问题并追问修正(循环不超过 3 次后建议人工整理)。
|
||||
|
||||
## 参数确认
|
||||
- 列"参数名(默认值,含义)"请用户确认或修改;未识别参数时明确指出并给出允许范围。
|
||||
|
||||
## 其它
|
||||
- 工具名、参数名、内部实现一律不外泄;
|
||||
- 任务运行中用户提问(流程/参数/生物学意义)随时回答,不打断任务;
|
||||
- 卡住或不确定时主动查询任务状态,向用户转述当前进度与下一步。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 转录组分析对话规范(bioflow_rnaseq_agent_rules)
|
||||
|
||||
你是转录组分析助手。用自然语言引导用户从实验设计确认到差异表达/富集分析完成。
|
||||
|
||||
## 人工确认边界(不可跳过、不可代确认)
|
||||
- **实验设计确认(rnaseq_de 之前的必确认信息)是硬性边界**:开始分析前必须与用户确认——
|
||||
① 物种与参考基因组版本;② 每个样本的 FASTQ(上传/链接/局域网路径)及单端双端;
|
||||
③ 对照组/处理组各几个重复、有无时间点/批次;④ 建库是否链特异性。
|
||||
- 统计模型正确性依赖上述信息,未确认前不得创建差异表达任务,也不能代用户拍板基线/比较方向。
|
||||
|
||||
## 对话节奏(自然语言主导 + 卡片辅助)
|
||||
每次需要用户输入/选择时,遵循固定节奏:
|
||||
1. 先用自然语言说明并**列表展示**必确认信息与可选分析;
|
||||
2. 提示用户可用卡片快捷操作,也可直接打字回复(两条通道语义一致);
|
||||
3. 用户回答后,用自然语言**复述**其样本设计与选择(如"干旱 3 vs 对照 3,双端,非链特异,对照为基线"),
|
||||
确认无误再继续。
|
||||
|
||||
## 样本配置与文件预处理
|
||||
- 用户用自然语言描述样本时,可解析生成 samples.yaml(样本名/对照处理组/重复/单双端);
|
||||
- FASTQ 支持上传、http/https 链接、OSS 地址、局域网路径;核对成对完整;
|
||||
- 非 FASTQ 的输入文件(表格/文本等)先探测格式,**异常时先说明列结构并提问确认**,再整理为可用格式。
|
||||
|
||||
## 参数确认
|
||||
- 列"参数名(默认值,含义)"请用户确认或修改;未识别参数时明确指出并给出允许范围。
|
||||
|
||||
## 其它
|
||||
- 工具名、参数名、内部实现一律不外泄;
|
||||
- 任务运行中用户提问(流程/参数/实验设计意义)随时回答,不打断任务;
|
||||
- 卡住或不确定时主动查询任务状态,向用户转述当前进度与下一步(含样本质控 PCA、差异基因数目等结果转述)。
|
||||
@@ -13,6 +13,7 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import __version__
|
||||
from .bioflow_agent import try_bioflow_agent
|
||||
from .config import QAConfig
|
||||
from .pipeline import QAAnswerError, QAV1Pipeline
|
||||
|
||||
@@ -47,6 +48,9 @@ class QARequest(BaseModel):
|
||||
# ---- Capability Gateway(S1):纯数据字段,无 validator(Cython 编译安全)----
|
||||
history: List[Dict[str, str]] = Field(default_factory=list)
|
||||
capabilities: Dict[str, Any] = Field(default_factory=dict)
|
||||
# ---- Bioflow 对话式编排(S2):卡片回填协议 + session↔job 绑定 ----
|
||||
card_result: Optional[Dict[str, Any]] = None
|
||||
bioflow: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -150,6 +154,18 @@ async def chat(request: QARequest) -> Dict[str, Any]:
|
||||
detail={"error_code": "llm_unavailable", "answer": "", "message": "Final-answer LLM is not configured"},
|
||||
)
|
||||
try:
|
||||
# ---- Bioflow Agent 前门(S1):命中则短路返回,否则走原 pipeline ----
|
||||
bioflow_result = await asyncio.to_thread(
|
||||
try_bioflow_agent,
|
||||
request.question,
|
||||
llm_client=pipeline.llm,
|
||||
history=request.history,
|
||||
capabilities=request.capabilities,
|
||||
card_result=request.card_result,
|
||||
bioflow=request.bioflow,
|
||||
)
|
||||
if bioflow_result is not None:
|
||||
return bioflow_result
|
||||
ablation = dict(request.ablation or {})
|
||||
ablation["use_final_llm"] = True
|
||||
return await asyncio.to_thread(
|
||||
@@ -216,6 +232,19 @@ async def chat_stream(request: QARequest) -> StreamingResponse:
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
# ---- Bioflow Agent 前门(S1):命中则短路,否则走原 pipeline ----
|
||||
bioflow_result = try_bioflow_agent(
|
||||
request.question,
|
||||
llm_client=pipeline.llm,
|
||||
history=request.history,
|
||||
capabilities=request.capabilities,
|
||||
card_result=request.card_result,
|
||||
bioflow=request.bioflow,
|
||||
progress_callback=emit,
|
||||
)
|
||||
if bioflow_result is not None:
|
||||
events.put(("complete", bioflow_result))
|
||||
return
|
||||
ablation = dict(request.ablation or {})
|
||||
ablation["use_final_llm"] = True
|
||||
result = pipeline.answer(
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""bioflow_agent.py —— 生信 MCP 能力前门路由(S1 + S2)。
|
||||
|
||||
S1:三重门(会话开关/注册表/意图预筛)→ ToolRunner 工具循环 → 自然语言转述。
|
||||
S2:对话式编排闭环——card_result 卡片回填协议 + session↔job 绑定 + 多轮记忆
|
||||
+ 参数抽取闭环 + job_status 主动查询转述。
|
||||
|
||||
card_result 协议(前端卡片操作 → 结构化用户消息 → QA 流):
|
||||
{card: path_selection|form_confirm|file_upload|archive_choice, payload: {...}}
|
||||
BioflowAgent 读取后经工具执行,再自然语言复述;卡片与自然语言两条通道在
|
||||
服务端语义一致(QA 对话状态机是唯一决策点)。
|
||||
|
||||
session↔job 绑定:BFF sessions.meta.bioflow = {active_job_id, chain},经请求
|
||||
body["bioflow"] 注入(含 session_id);job_status 工具是任务真相唯一权威。
|
||||
|
||||
金标路径零改动:任何不命中/异常都返回 None 回退原 pipeline。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .capabilities.manifests import CapabilityManifest
|
||||
from .capabilities.providers import PromptPackProvider, get_dispatcher
|
||||
from .capabilities.registry import get_registry
|
||||
from .tool_runner import ToolRunner, make_llm_complete
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 三个生信能力单元(与 bioflow_service 三个 MCP 端点一一对应)
|
||||
BIOFLOW_CAPABILITY_NAMES = ("bioflow-gene-family", "bioflow-rnaseq", "bioflow-extension")
|
||||
ENV_FUSE = "QA_V1_ENABLE_BIOFLOW_AGENT"
|
||||
MAX_ROUNDS = 5
|
||||
|
||||
# card_result 卡片类型(S2 前端卡片回填协议)
|
||||
CARD_PATH_SELECTION = "path_selection"
|
||||
CARD_FORM_CONFIRM = "form_confirm"
|
||||
CARD_FILE_UPLOAD = "file_upload"
|
||||
CARD_ARCHIVE_CHOICE = "archive_choice"
|
||||
CARD_EXTENSION_ACTION = "extension_action" # S4:扩展能力单元操作(build/trial/validate/confirm/publish/run)
|
||||
CARD_TYPES = (CARD_PATH_SELECTION, CARD_FORM_CONFIRM, CARD_FILE_UPLOAD, CARD_ARCHIVE_CHOICE, CARD_EXTENSION_ACTION)
|
||||
|
||||
# chain 名 → 能力名(建任务时选择哪条链的 create 工具)
|
||||
_CHAIN_TO_CAPABILITY = {
|
||||
"gene_family": "bioflow-gene-family",
|
||||
"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。"""
|
||||
return _CHAIN_TO_CAPABILITY.get(str(chain or "").strip().lower())
|
||||
|
||||
_SYSTEM_BASE = (
|
||||
"你是 PlantAI 生信分析助手,用自然语言引导用户完成基因家族或转录组分析。\n"
|
||||
"你可以调用生信工具完成规划、建任务、确认参数、登记文件、查状态等操作。\n"
|
||||
"硬性规则:\n"
|
||||
"1. 回复只使用自然语言,绝不向用户泄露工具名、参数名或内部实现细节。\n"
|
||||
"2. 需要用户提供文件/参数/做选择时,先自然语言说明并列表展示选项与含义,再请用户回答,"
|
||||
"绝不擅自替用户做不可逆决策。\n"
|
||||
"3. 人工确认边界(基因家族 subfamily_confirm、转录组实验设计确认)不可跳过、不可代用户确认。\n"
|
||||
"4. 任务运行中可随时回答流程/参数/生物学意义类问题;卡住或不确定时主动查询任务状态,"
|
||||
"再向用户转述当前进度与下一步(等待文件、等待参数确认、失败可重跑等)。\n"
|
||||
"5. 文件格式异常时先向用户说明并提问确认,再由用户决定如何处理。\n"
|
||||
"6. 用户用自然语言给出参数(如「用 8 核、padj 用 0.01」)时,抽取为结构化参数后经确认步骤提交;"
|
||||
"只提交白名单内的参数名,未知参数明确指出并追问(最多 3 次)。\n"
|
||||
"7. 若用户消息是对上一轮卡片/提问的确认或选择,先复述用户的选择,确认无误再执行对应动作。\n"
|
||||
)
|
||||
|
||||
|
||||
def _env_enabled() -> bool:
|
||||
"""门 1:QA_V1_ENABLE_BIOFLOW_AGENT 熔断开关。默认关(未设置 = 关)。"""
|
||||
raw = os.environ.get(ENV_FUSE)
|
||||
return raw is not None and str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _intent_match(question: str, triggers: List[str]) -> bool:
|
||||
"""门 3:意图预筛——问句是否命中任一触发词(大小写不敏感子串匹配)。"""
|
||||
if not triggers:
|
||||
return False
|
||||
q = (question or "").lower()
|
||||
return any(t and t.lower() in q for t in triggers)
|
||||
|
||||
|
||||
def _registered_capabilities(capabilities: Optional[Dict[str, Any]]) -> List[CapabilityManifest]:
|
||||
"""注册表 + 会话开关:返回所有已注册且会话开启的生信能力 manifest(不判意图)。"""
|
||||
try:
|
||||
registry = get_registry()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: registry 访问失败,回退原 pipeline: %s", exc)
|
||||
return []
|
||||
session_caps = capabilities or {}
|
||||
out: List[CapabilityManifest] = []
|
||||
for name in BIOFLOW_CAPABILITY_NAMES:
|
||||
try:
|
||||
spec = registry.get(name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: registry.get(%s) 失败: %s", name, exc)
|
||||
continue
|
||||
if spec is None:
|
||||
continue # 未注册
|
||||
manifest = spec.manifest
|
||||
if not bool(session_caps.get(name, manifest.session_default)):
|
||||
continue # 会话开关关
|
||||
out.append(manifest)
|
||||
return out
|
||||
|
||||
|
||||
def _candidate_capabilities(
|
||||
question: str,
|
||||
capabilities: Optional[Dict[str, Any]],
|
||||
) -> List[CapabilityManifest]:
|
||||
"""门 2 + 门 3:注册表 + 会话开关 + 意图预筛,返回命中的候选能力清单。"""
|
||||
return [
|
||||
m for m in _registered_capabilities(capabilities)
|
||||
if _intent_match(question, m.nl_triggers)
|
||||
]
|
||||
|
||||
|
||||
def _build_tools(
|
||||
manifests: List[CapabilityManifest],
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[str, CapabilityManifest]]:
|
||||
"""经 MCP list_tools 发现各能力工具,构造 LLM 工具表 + ToolRunner manifests。
|
||||
|
||||
返回 (tools, tool_manifests):
|
||||
- tools: OpenAI function schema 列表(真实 server 工具名/描述/入参 schema)
|
||||
- tool_manifests: {真实工具名: manifest},manifest.name=能力名(共享连接),
|
||||
mcp.tool=真实工具名
|
||||
|
||||
多能力同时命中时:专有工具名本就不同,各自保留;共享工具(两端点语义一致)
|
||||
按名去重,只保留首个能力的一份,避免 LLM tools 重名 + 同名覆盖。
|
||||
"""
|
||||
dispatcher = get_dispatcher()
|
||||
tools: List[Dict[str, Any]] = []
|
||||
tool_manifests: Dict[str, CapabilityManifest] = {}
|
||||
for cap_manifest in manifests:
|
||||
url = str((cap_manifest.config.get("mcp") or {}).get("url") or "").strip()
|
||||
if not url:
|
||||
LOGGER.warning("bioflow agent: 能力 %s 缺 mcp.url,跳过", cap_manifest.name)
|
||||
continue
|
||||
try:
|
||||
discovered = dispatcher.list_mcp_tools(cap_manifest)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: 能力 %s MCP 工具发现失败: %s", cap_manifest.name, exc)
|
||||
continue
|
||||
for item in discovered:
|
||||
tool_name = str(item.get("name") or "").strip()
|
||||
if not tool_name:
|
||||
continue
|
||||
if tool_name in tool_manifests:
|
||||
# 共享工具去重(见 S1 修复说明)
|
||||
continue
|
||||
input_schema = item.get("input_schema") or {}
|
||||
if not isinstance(input_schema, dict):
|
||||
input_schema = {"type": "object", "properties": {}}
|
||||
tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"description": str(item.get("description") or "")[:1000],
|
||||
"parameters": input_schema,
|
||||
},
|
||||
}
|
||||
)
|
||||
tool_manifests[tool_name] = CapabilityManifest(
|
||||
name=cap_manifest.name,
|
||||
provider="mcp",
|
||||
permission="read_only",
|
||||
description=str(item.get("description") or ""),
|
||||
config={"mcp": {"url": url, "tool": tool_name}},
|
||||
)
|
||||
return tools, tool_manifests
|
||||
|
||||
|
||||
def _build_system(manifests: List[CapabilityManifest]) -> str:
|
||||
"""基础系统提示 + 各能力对话规范(prompt_pack 注入)。"""
|
||||
qa_root = Path(os.environ.get("GENE_FAMILY_ROOT", "/data/gene-family-mas-v2"))
|
||||
parts = [_SYSTEM_BASE]
|
||||
seen: set[str] = set()
|
||||
for manifest in manifests:
|
||||
file_name = str((manifest.config.get("prompt_pack") or {}).get("file") or "").strip()
|
||||
if not file_name or file_name in seen:
|
||||
continue
|
||||
seen.add(file_name)
|
||||
try:
|
||||
text = PromptPackProvider.load(manifest, qa_root)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: prompt_pack 加载失败 %s: %s", file_name, exc)
|
||||
text = ""
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S2 工具执行 / 转述 / 参数抽取 辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _execute_tool(
|
||||
tool_manifests: Dict[str, CapabilityManifest], tool_name: str, arguments: Dict[str, Any]
|
||||
) -> str:
|
||||
"""执行单个工具,返回结果文本(异常转文本,不炸主流程)。"""
|
||||
manifest = tool_manifests.get(tool_name)
|
||||
if manifest is None:
|
||||
return f"工具 {tool_name} 未注册"
|
||||
try:
|
||||
return get_dispatcher().execute(manifest, arguments)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"工具执行失败: {type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
def _paraphrase(llm_client: Any, user_text: str, system: Optional[str] = None) -> str:
|
||||
"""把工具执行结果转述为自然语言(LLM 优先,失败回退原文,绝不抛异常)。"""
|
||||
system = system or (
|
||||
"你是 PlantAI 生信分析助手。把下面的工具执行结果转述为对用户的自然语言回复,"
|
||||
"绝不提及工具名/参数名/内部实现;需要用户下一步操作时明确说明。"
|
||||
)
|
||||
try:
|
||||
text = llm_client.complete(system, user_text, temperature=0.2, max_tokens=600)
|
||||
if text and str(text).strip():
|
||||
return str(text).strip()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: paraphrase LLM 失败,回退原文: %s", exc)
|
||||
return str(user_text or "").strip()
|
||||
|
||||
|
||||
def _extract_params(
|
||||
llm_client: Any, user_text: str, form_defaults: Dict[str, Any]
|
||||
) -> Tuple[Dict[str, Any], List[str]]:
|
||||
"""LLM 抽取用户自然语言中的阶段确认参数 → (已识别参数, 未识别参数列表)。
|
||||
|
||||
白名单 = form_defaults 的键;未识别参数(不在白名单)单独返回供追问(≤3 次)。
|
||||
参照 extension_llm 的「LLM 出 JSON → 白名单净化」模式,LLM 失败/坏 JSON 返回空。
|
||||
"""
|
||||
whitelist = {str(k) for k in (form_defaults or {}).keys()}
|
||||
if not whitelist:
|
||||
return {}, []
|
||||
system = (
|
||||
"你是参数抽取助手。从用户的话中抽取阶段确认参数,只输出 JSON。\n"
|
||||
'输出形态:{"params": {"<参数名>": <值>}, "unknown": ["<未识别参数名>"]}。\n'
|
||||
f"允许的参数名(白名单):{', '.join(sorted(whitelist))}。\n"
|
||||
"只抽取用户明确提到的参数;不在白名单里的参数名放进 unknown。"
|
||||
)
|
||||
try:
|
||||
raw = llm_client.complete_json(system, user_text, max_tokens=500)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: 参数抽取 LLM 失败: %s", exc)
|
||||
return {}, []
|
||||
if not isinstance(raw, dict):
|
||||
return {}, []
|
||||
params = raw.get("params") if isinstance(raw.get("params"), dict) else {}
|
||||
known = {str(k): v for k, v in params.items() if str(k) in whitelist}
|
||||
unknown_raw = raw.get("unknown") or []
|
||||
if isinstance(unknown_raw, str):
|
||||
unknown_raw = [unknown_raw]
|
||||
unknown = [str(x).strip() for x in unknown_raw if isinstance(x, str) and str(x).strip()]
|
||||
return known, unknown
|
||||
|
||||
|
||||
def _try_parse_json(text: str) -> Optional[Any]:
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_job_id(result_text: str) -> Optional[str]:
|
||||
"""从 create_job 工具结果文本里提取 job_id(结果形如 {"ok":true,"data":{"id":...}})。"""
|
||||
data = _try_parse_json(result_text)
|
||||
if isinstance(data, dict):
|
||||
inner = data.get("data") if isinstance(data.get("data"), dict) else data
|
||||
jid = inner.get("id") or inner.get("job_id")
|
||||
if jid:
|
||||
return str(jid)
|
||||
return None
|
||||
|
||||
|
||||
def _query_job_status(job_id: str, tool_manifests: Dict[str, CapabilityManifest], llm_client: Any) -> str:
|
||||
"""主动查 job_status 并转述(任务真相唯一权威)。"""
|
||||
result = _execute_tool(tool_manifests, "job_status", {"job_id": job_id})
|
||||
return _paraphrase(llm_client, f"查询任务状态的结果:\n{result}")
|
||||
|
||||
|
||||
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,
|
||||
*,
|
||||
audit: Optional[Dict[str, Any]] = None,
|
||||
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,
|
||||
"version": "bioflow-agent-s2",
|
||||
"question": question,
|
||||
"answer": answer,
|
||||
"sources": [],
|
||||
"source_count": 0,
|
||||
"citation_validation": {"valid": True, "cited_pmids": [], "unknown_pmids": []},
|
||||
"bioflow": {
|
||||
"handled": True,
|
||||
"audit": audit or _empty_audit(),
|
||||
"capabilities": capabilities or [],
|
||||
},
|
||||
}
|
||||
if active_job_id:
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# card_result 回填处理(S2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _card_path_selection(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""路径卡确认 → create_*_job → 复述;带 reuse_archive 时先复用存档的阶段链/参数再建任务。"""
|
||||
chain = str(payload.get("chain") or (bioflow or {}).get("chain") or "").strip()
|
||||
stages = payload.get("stages")
|
||||
reuse_name = str(payload.get("reuse_archive") or payload.get("reuse_name") or "").strip()
|
||||
has_stages = isinstance(stages, list) and len(stages) > 0
|
||||
if not has_stages and not reuse_name:
|
||||
# 既无显式阶段链也无存档复用来源 → 无法建任务,回退原 pipeline
|
||||
return None
|
||||
cap_name = _chain_to_capability(chain)
|
||||
if cap_name is None:
|
||||
# 无法确定能力(chain 空/未知):不能默认走某条链,回退原 pipeline
|
||||
return None
|
||||
tool = "create_gene_family_job" if cap_name == "bioflow-gene-family" else "create_rnaseq_job"
|
||||
if tool not in tool_manifests:
|
||||
return None
|
||||
args: Dict[str, Any] = {
|
||||
"session_id": str((bioflow or {}).get("session_id") or ""),
|
||||
"stages": [str(s) for s in stages] if isinstance(stages, list) and stages else [],
|
||||
"title": str(payload.get("title") or ""),
|
||||
"auto_continue": bool(payload.get("auto_continue", False)),
|
||||
}
|
||||
tool_names: List[str] = []
|
||||
# 存档复用(FR-BIO-023):先 reuse_archive 取阶段链/参数/archive_id,再以其发起
|
||||
if reuse_name and "reuse_archive" in tool_manifests:
|
||||
tool_names.append("reuse_archive") # 实际先执行 reuse_archive(audit 顺序与实际一致)
|
||||
reuse_text = _execute_tool(tool_manifests, "reuse_archive", {"name": reuse_name})
|
||||
arch = _try_parse_json(reuse_text)
|
||||
if isinstance(arch, dict) and arch.get("ok"):
|
||||
data = arch.get("data") if isinstance(arch.get("data"), dict) else {}
|
||||
if data.get("id"):
|
||||
args["archive_id"] = str(data["id"])
|
||||
if not args["stages"] and isinstance(data.get("stage_chain"), list):
|
||||
args["stages"] = [str(s) for s in data["stage_chain"]]
|
||||
if isinstance(data.get("params"), dict):
|
||||
merged = dict(data["params"])
|
||||
if isinstance(payload.get("params"), dict):
|
||||
merged.update(payload["params"])
|
||||
args["params"] = merged
|
||||
elif not args["stages"]:
|
||||
# 复用失败(非 ok)且无显式阶段链:不能空链建任务,回退原 pipeline
|
||||
return None
|
||||
tool_names.append(tool) # create_*_job 最后执行
|
||||
if "params" not in args and isinstance(payload.get("params"), dict):
|
||||
args["params"] = payload["params"]
|
||||
result = _execute_tool(tool_manifests, tool, args)
|
||||
job_id = _extract_job_id(result)
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户确认了分析路径并创建任务({'基因家族' if cap_name == 'bioflow-gene-family' else '转录组'}链"
|
||||
f"{',复用存档 ' + reuse_name if reuse_name else ''})。工具结果:\n{result}",
|
||||
)
|
||||
return _make_payload(
|
||||
"确认路径并创建任务", answer,
|
||||
audit={"rounds": 1, "tool_calls": len(tool_names), "used_json_fallback": False, "tool_names": tool_names, "errors": []},
|
||||
capabilities=[cap_name] if cap_name else [],
|
||||
active_job_id=job_id,
|
||||
chain=chain or None,
|
||||
)
|
||||
|
||||
|
||||
def _card_form_confirm(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""参数卡确认 → confirm_stage(白名单校验)→ 复述;rerun:true 走 rerun_stage(改参数重跑)。"""
|
||||
job_id = str(payload.get("job_id") or (bioflow or {}).get("active_job_id") or "").strip()
|
||||
stage = str(payload.get("stage") or "").strip()
|
||||
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
||||
rerun = bool(payload.get("rerun", False))
|
||||
if not job_id or not stage:
|
||||
return None
|
||||
tool = "rerun_stage" if rerun else "confirm_stage"
|
||||
result = _execute_tool(tool_manifests, tool, {"job_id": job_id, "stage": stage, "params": params})
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户{'重跑并确认' if rerun else '确认'}了阶段参数(任务 {job_id} 的 {stage})。工具结果:\n{result}",
|
||||
)
|
||||
return _make_payload(
|
||||
f"{'重跑' if rerun else '确认'}阶段参数:{stage}", answer,
|
||||
audit={"rounds": 1, "tool_calls": 1, "used_json_fallback": False, "tool_names": [tool], "errors": []},
|
||||
active_job_id=job_id,
|
||||
)
|
||||
|
||||
|
||||
def _card_file_upload(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""文件卡 → 网络源 fetch_source_file / 服务器·局域网路径 register_job_files → 复述。"""
|
||||
job_id = str(payload.get("job_id") or (bioflow or {}).get("active_job_id") or "").strip()
|
||||
raw_files = payload.get("paths") or payload.get("files") or []
|
||||
if isinstance(raw_files, str):
|
||||
raw_files = [raw_files]
|
||||
files = [str(f) for f in raw_files if str(f).strip()] if isinstance(raw_files, list) else []
|
||||
if not job_id or not files:
|
||||
return None
|
||||
remote = [f for f in files if f.startswith(("http://", "https://", "oss://"))]
|
||||
local = [f for f in files if f not in remote]
|
||||
results: List[str] = []
|
||||
tool_names: List[str] = []
|
||||
for src in remote:
|
||||
r = _execute_tool(tool_manifests, "fetch_source_file", {"source": src, "job_id": job_id})
|
||||
results.append(r)
|
||||
tool_names.append("fetch_source_file")
|
||||
if local:
|
||||
r = _execute_tool(tool_manifests, "register_job_files", {"job_id": job_id, "paths": local})
|
||||
results.append(r)
|
||||
tool_names.append("register_job_files")
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户提供了文件({'远程 ' + str(len(remote)) + ' 个' if remote else ''}"
|
||||
f"{'本地 ' + str(len(local)) + ' 个' if local else ''})。工具结果:\n" + "\n".join(results),
|
||||
)
|
||||
return _make_payload(
|
||||
"登记任务文件", answer,
|
||||
audit={"rounds": 1, "tool_calls": len(tool_names), "used_json_fallback": False, "tool_names": tool_names, "errors": []},
|
||||
active_job_id=job_id,
|
||||
)
|
||||
|
||||
|
||||
def _card_archive_choice(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""存档卡 → archive_job 工具执行实际存档(或跳过)→ 复述。"""
|
||||
job_id = str(payload.get("job_id") or (bioflow or {}).get("active_job_id") or "").strip()
|
||||
do_archive = bool(payload.get("archive", False))
|
||||
if not job_id:
|
||||
return None
|
||||
if do_archive:
|
||||
name = str(payload.get("name") or "")
|
||||
description = str(payload.get("description") or "")
|
||||
result = _execute_tool(
|
||||
tool_manifests, "archive_job",
|
||||
{"job_id": job_id, "name": name, "description": description},
|
||||
)
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户选择把任务 {job_id} 存档(名称:{name or '未命名'})。工具结果:\n{result}",
|
||||
)
|
||||
return _make_payload(
|
||||
"存档选择", answer,
|
||||
audit={"rounds": 1, "tool_calls": 1, "used_json_fallback": False, "tool_names": ["archive_job"], "errors": []},
|
||||
active_job_id=job_id,
|
||||
)
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户选择不存档任务 {job_id}。请用自然语言告知用户:已跳过存档,"
|
||||
"本次分析结果仍可在报告/结果中查看。",
|
||||
)
|
||||
return _make_payload(
|
||||
"存档选择", answer,
|
||||
audit=_empty_audit(),
|
||||
active_job_id=job_id,
|
||||
)
|
||||
|
||||
|
||||
def _card_extension_action(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""扩展操作卡(S4):action ∈ build|trial_run|validate|confirm|publish|run → 对应扩展工具。"""
|
||||
ext_id = str(payload.get("ext_id") or "").strip()
|
||||
action = str(payload.get("action") or "").strip()
|
||||
if not ext_id or not action:
|
||||
return None
|
||||
tool = {
|
||||
"build": "build_extension",
|
||||
"trial_run": "trial_run_extension",
|
||||
"validate": "validate_extension",
|
||||
"confirm": "confirm_extension",
|
||||
"publish": "publish_extension",
|
||||
"run": "run_extension",
|
||||
"repair": "repair_extension",
|
||||
}.get(action)
|
||||
if tool is None or tool not in tool_manifests:
|
||||
return None
|
||||
args: Dict[str, Any] = {"ext_id": ext_id}
|
||||
if action == "confirm":
|
||||
if isinstance(payload.get("nl_triggers"), list):
|
||||
args["nl_triggers"] = payload["nl_triggers"]
|
||||
if isinstance(payload.get("parameters"), list):
|
||||
args["parameters"] = payload["parameters"]
|
||||
if payload.get("runtime_image"):
|
||||
args["runtime_image"] = str(payload["runtime_image"])
|
||||
if action == "run":
|
||||
if isinstance(payload.get("params"), dict):
|
||||
args["params"] = payload["params"]
|
||||
if isinstance(payload.get("files"), list):
|
||||
args["files"] = payload["files"]
|
||||
result = _execute_tool(tool_manifests, tool, args)
|
||||
if action == "confirm" and "EXT_STATUS" in result:
|
||||
# 状态降级话术:confirm 要求 validated/trial_passed 前置状态,
|
||||
# 编辑修正若发生在校验前,明确提示先试运行/校验,不报 500。
|
||||
answer = (
|
||||
"该扩展当前状态还不能确认。请先完成 AI 转换与试运行、通过校验后,"
|
||||
"再确认参数与触发词。"
|
||||
)
|
||||
else:
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户对扩展 {ext_id} 执行了操作 {action}。工具结果:\n{result}",
|
||||
)
|
||||
return _make_payload(
|
||||
f"扩展操作:{action}", answer,
|
||||
audit={"rounds": 1, "tool_calls": 1, "used_json_fallback": False, "tool_names": [tool], "errors": []},
|
||||
)
|
||||
|
||||
|
||||
def _handle_card_result(
|
||||
card_result: Dict[str, Any],
|
||||
llm_client: Any,
|
||||
bioflow: Optional[Dict[str, Any]],
|
||||
tool_manifests: Dict[str, CapabilityManifest],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
card = str(card_result.get("card") or "").strip()
|
||||
payload = card_result.get("payload") if isinstance(card_result.get("payload"), dict) else {}
|
||||
ctx = bioflow or {}
|
||||
if card == CARD_PATH_SELECTION:
|
||||
return _card_path_selection(payload, llm_client, ctx, tool_manifests)
|
||||
if card == CARD_FORM_CONFIRM:
|
||||
return _card_form_confirm(payload, llm_client, ctx, tool_manifests)
|
||||
if card == CARD_FILE_UPLOAD:
|
||||
return _card_file_upload(payload, llm_client, ctx, tool_manifests)
|
||||
if card == CARD_ARCHIVE_CHOICE:
|
||||
return _card_archive_choice(payload, llm_client, ctx, tool_manifests)
|
||||
if card == CARD_EXTENSION_ACTION:
|
||||
return _card_extension_action(payload, llm_client, ctx, tool_manifests)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前门路由
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def try_bioflow_agent(
|
||||
question: str,
|
||||
*,
|
||||
llm_client: Any,
|
||||
history: Optional[List[Dict[str, str]]] = None,
|
||||
capabilities: Optional[Dict[str, Any]] = None,
|
||||
card_result: Optional[Dict[str, Any]] = None,
|
||||
bioflow: Optional[Dict[str, Any]] = None,
|
||||
progress_callback: Optional[Any] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""前门路由入口。命中生信能力则返回答案 payload,否则返回 None(走原 pipeline)。
|
||||
|
||||
- card_result:非空时走卡片回填协议(直接路由到对应工具,不做意图预筛)。
|
||||
- 任何异常都不上抛——记日志后返回 None,保证金标路径永不因本模块失败。
|
||||
"""
|
||||
try:
|
||||
return _try_bioflow_agent_impl(
|
||||
question,
|
||||
llm_client=llm_client,
|
||||
history=history,
|
||||
capabilities=capabilities,
|
||||
card_result=card_result,
|
||||
bioflow=bioflow,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: 执行失败,回退原 pipeline: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _try_bioflow_agent_impl(
|
||||
question: str,
|
||||
*,
|
||||
llm_client: Any,
|
||||
history: Optional[List[Dict[str, str]]],
|
||||
capabilities: Optional[Dict[str, Any]],
|
||||
card_result: Optional[Dict[str, Any]],
|
||||
bioflow: Optional[Dict[str, Any]],
|
||||
progress_callback: Optional[Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
# 门 1:会话开关(熔断)
|
||||
if not _env_enabled():
|
||||
return None
|
||||
|
||||
# card_result 卡片操作优先:用户已在卡片上明确选择,不做意图预筛,直接路由
|
||||
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 路径:活跃任务/生信链续接优先,其次意图预筛
|
||||
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
|
||||
|
||||
tools, tool_manifests = _build_tools(manifests)
|
||||
if not tools:
|
||||
LOGGER.warning("bioflow agent: 意图命中但 MCP 工具发现为空,回退原 pipeline")
|
||||
return None
|
||||
|
||||
if progress_callback is not None:
|
||||
try:
|
||||
progress_callback(
|
||||
{
|
||||
"stage": "bioflow_agent_started",
|
||||
"capabilities": [m.name for m in manifests],
|
||||
"tool_count": len(tools),
|
||||
}
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
system = _build_system(manifests)
|
||||
runner = ToolRunner(make_llm_complete(llm_client), max_rounds=MAX_ROUNDS)
|
||||
content, audit = runner.run(system, question, tools, tool_manifests, history=history)
|
||||
|
||||
answer = (content or "").strip()
|
||||
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") or new_job_id,
|
||||
chain=effective_chain or None,
|
||||
readiness=readiness,
|
||||
versioning=versioning,
|
||||
plan=plan,
|
||||
)
|
||||
|
||||
|
||||
def _route_card_result(
|
||||
card_result: Dict[str, Any],
|
||||
llm_client: Any,
|
||||
capabilities: Optional[Dict[str, Any]],
|
||||
bioflow: Optional[Dict[str, Any]],
|
||||
progress_callback: Optional[Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""卡片操作路由:确定相关能力 → 构建工具 → 分发到对应 card handler。"""
|
||||
card = str(card_result.get("card") or "").strip()
|
||||
payload = card_result.get("payload") if isinstance(card_result.get("payload"), dict) else {}
|
||||
chain = str(payload.get("chain") or (bioflow or {}).get("chain") or "").strip()
|
||||
if card == CARD_EXTENSION_ACTION:
|
||||
# 扩展操作只依赖 bioflow-extension 能力,避免对三个能力各做一次 list_tools
|
||||
cap_name = "bioflow-extension"
|
||||
else:
|
||||
cap_name = _chain_to_capability(chain)
|
||||
registered = _registered_capabilities(capabilities)
|
||||
if cap_name:
|
||||
manifests = [m for m in registered if m.name == cap_name]
|
||||
else:
|
||||
manifests = registered
|
||||
if not manifests:
|
||||
return None
|
||||
tools, tool_manifests = _build_tools(manifests)
|
||||
if not tools:
|
||||
LOGGER.warning("bioflow agent: card_result 但 MCP 工具发现为空,回退原 pipeline")
|
||||
return None
|
||||
if progress_callback is not None:
|
||||
try:
|
||||
progress_callback({"stage": "bioflow_card", "card": card_result.get("card")})
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return _handle_card_result(card_result, llm_client, bioflow, tool_manifests)
|
||||
@@ -128,6 +128,8 @@ class InstallManager:
|
||||
"allowed_llm_phases": manifest.allowed_llm_phases,
|
||||
"session_default": manifest.session_default,
|
||||
}
|
||||
if manifest.nl_triggers:
|
||||
raw["nl_triggers"] = manifest.nl_triggers
|
||||
if manifest.schema:
|
||||
raw["schema"] = manifest.schema
|
||||
for section in ("http", "mcp", "python", "prompt_pack"):
|
||||
|
||||
@@ -37,6 +37,7 @@ class CapabilityManifest:
|
||||
session_default: bool = False
|
||||
schema: Dict[str, Any] = field(default_factory=dict)
|
||||
config: Dict[str, Any] = field(default_factory=dict) # provider 专属段(http:/mcp:/python:/prompt_pack: 归并)
|
||||
nl_triggers: List[str] = field(default_factory=list) # 意图预筛触发词(S1,生信能力路由用)
|
||||
|
||||
def to_tool_spec(self) -> Dict[str, Any]:
|
||||
"""转 OpenAI function schema(tools 参数形态)。"""
|
||||
@@ -110,6 +111,19 @@ def _parse_entry(raw: Any, index: int, load_warnings: List[str]) -> CapabilityMa
|
||||
if isinstance(raw.get(section), dict):
|
||||
config[section] = raw[section]
|
||||
config["session_default"] = bool(raw.get("session_default", False))
|
||||
# nl_triggers(S1):意图预筛触发词,只收字符串、去空、去重、限长
|
||||
nl_triggers_raw = raw.get("nl_triggers") or []
|
||||
if isinstance(nl_triggers_raw, str):
|
||||
nl_triggers_raw = [nl_triggers_raw]
|
||||
nl_triggers: List[str] = []
|
||||
if isinstance(nl_triggers_raw, list):
|
||||
for trigger in nl_triggers_raw:
|
||||
if not isinstance(trigger, str):
|
||||
continue # 触发词只收字符串,非字符串(数字等)丢弃
|
||||
cleaned = _clean_text(trigger, 64)
|
||||
if cleaned and cleaned not in nl_triggers:
|
||||
nl_triggers.append(cleaned)
|
||||
nl_triggers = nl_triggers[:64]
|
||||
manifest = CapabilityManifest(
|
||||
name=name,
|
||||
provider=provider,
|
||||
@@ -119,6 +133,7 @@ def _parse_entry(raw: Any, index: int, load_warnings: List[str]) -> CapabilityMa
|
||||
session_default=bool(raw.get("session_default", False)),
|
||||
schema=schema,
|
||||
config=config,
|
||||
nl_triggers=nl_triggers,
|
||||
)
|
||||
# 大小上限:单条目序列化后 ≤8KB
|
||||
import json
|
||||
|
||||
@@ -130,6 +130,7 @@ class _McpConnection:
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._session: Any = None
|
||||
self._server_tools: List[str] = []
|
||||
self._server_tool_schemas: List[Dict[str, Any]] = []
|
||||
self._started_at = 0.0
|
||||
self._last_used = 0.0
|
||||
self._restarts = 0
|
||||
@@ -200,6 +201,18 @@ class _McpConnection:
|
||||
raise
|
||||
self._ctx = ctx
|
||||
self._server_tools = [t.name for t in tools_result.tools]
|
||||
self._server_tool_schemas = [
|
||||
{
|
||||
"name": t.name,
|
||||
"description": (getattr(t, "description", None) or ""),
|
||||
"input_schema": (
|
||||
getattr(t, "inputSchema", None)
|
||||
or getattr(t, "input_schema", None)
|
||||
or {}
|
||||
),
|
||||
}
|
||||
for t in tools_result.tools
|
||||
]
|
||||
self._session = session
|
||||
self._started_at = time.time()
|
||||
|
||||
@@ -225,6 +238,7 @@ class _McpConnection:
|
||||
ctx = getattr(self, "_ctx", None)
|
||||
self._ctx = None
|
||||
self._server_tools = []
|
||||
self._server_tool_schemas = []
|
||||
if session is not None:
|
||||
try:
|
||||
await session.__aexit__(None, None, None)
|
||||
@@ -274,6 +288,14 @@ class _McpConnection:
|
||||
return []
|
||||
return list(self._server_tools)
|
||||
|
||||
def list_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""返回 MCP server 全量工具 schema(name/description/input_schema)。连接失败返回空。"""
|
||||
try:
|
||||
self._ensure_session()
|
||||
except ToolExecutionError:
|
||||
return []
|
||||
return list(self._server_tool_schemas)
|
||||
|
||||
def call(self, tool_name: str, arguments: Dict[str, Any]) -> str:
|
||||
self._ensure_session()
|
||||
try:
|
||||
@@ -337,6 +359,11 @@ class McpToolProvider:
|
||||
return tools + [f"<缺失声明工具 {tool_name}>"]
|
||||
return tools
|
||||
|
||||
def list_tool_schemas(self, manifest: CapabilityManifest) -> List[Dict[str, Any]]:
|
||||
"""返回该能力 MCP server 的全量工具 schema(供前门 agent 构建 LLM 工具表)。"""
|
||||
conn = self._connection(manifest)
|
||||
return conn.list_tool_schemas()
|
||||
|
||||
def reap_idle(self) -> int:
|
||||
reaped = 0
|
||||
with self._lock:
|
||||
@@ -448,6 +475,10 @@ class ToolDispatcher:
|
||||
return PythonToolProvider.execute(manifest, arguments)
|
||||
raise ToolExecutionError(f"工具 {manifest.name} provider={provider} 不支持调用")
|
||||
|
||||
def list_mcp_tools(self, manifest: CapabilityManifest) -> List[Dict[str, Any]]:
|
||||
"""MCP 能力全量工具 schema(bioflow_agent 构建 LLM 工具表用)。"""
|
||||
return self._mcp.list_tool_schemas(manifest)
|
||||
|
||||
def reap_idle(self) -> int:
|
||||
return self._mcp.reap_idle()
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""test_bioflow_agent.py —— bioflow_agent 前门三重门 + nl_triggers 解析单测。
|
||||
|
||||
覆盖 S1 验收要点:
|
||||
- 熔断开关(QA_V1_ENABLE_BIOFLOW_AGENT)默认关 → 返回 None(走原 pipeline)
|
||||
- 意图预筛(nl_triggers)各自评估,未命中返回 None
|
||||
- 注册表未注册 / 会话开关关 → 返回 None
|
||||
- 命中后短路返回自然语言 payload(bioflow.handled=True)
|
||||
- 只装一个能力时,另一个能力的触发语不响应(隔离)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import parse_capabilities_text # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry # noqa: E402
|
||||
|
||||
BIOFLOW_YAML = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 家族成员, 亚家族, 建树]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析
|
||||
session_default: true
|
||||
nl_triggers: [转录组, RNA-seq, 差异表达, 富集]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
"""
|
||||
|
||||
|
||||
def _make_registry(tmp_path: Path) -> CapabilityRegistry:
|
||||
path = tmp_path / "capabilities.yaml"
|
||||
path.write_text(BIOFLOW_YAML, encoding="utf-8")
|
||||
registry = CapabilityRegistry(path)
|
||||
registry.load()
|
||||
return registry
|
||||
|
||||
|
||||
class FakeLLMClient:
|
||||
def __init__(self, content: str = "已为您规划分析路径。") -> None:
|
||||
self.content = content
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kwargs):
|
||||
return {"content": self.content, "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
class TestNlTriggers:
|
||||
def test_parse_nl_triggers(self):
|
||||
manifests, warnings = parse_capabilities_text(BIOFLOW_YAML)
|
||||
assert warnings == []
|
||||
gf = next(m for m in manifests if m.name == "bioflow-gene-family")
|
||||
assert gf.nl_triggers == ["基因家族", "家族成员", "亚家族", "建树"]
|
||||
assert gf.config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
assert gf.config["prompt_pack"]["file"] == "bioflow_gene_family_agent_rules.md"
|
||||
|
||||
def test_nl_triggers_dedupe_and_clean(self):
|
||||
text = """
|
||||
capabilities:
|
||||
- name: t
|
||||
provider: mcp
|
||||
nl_triggers: [基因家族, 基因家族, "", " 亚家族 ", 123]
|
||||
"""
|
||||
manifests, _ = parse_capabilities_text(text)
|
||||
assert manifests[0].nl_triggers == ["基因家族", "亚家族"]
|
||||
|
||||
|
||||
class TestIntentMatch:
|
||||
def test_match(self):
|
||||
assert ba._intent_match("帮我做水稻SPL基因家族分析", ["基因家族"]) is True
|
||||
assert ba._intent_match("转录组差异表达分析", ["转录组", "RNA-seq"]) is True
|
||||
assert ba._intent_match("什么是光合作用", ["基因家族", "转录组"]) is False
|
||||
|
||||
|
||||
class TestGateLogic:
|
||||
def test_fuse_off_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_no_intent_match_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"光合作用的电子传递链是什么", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
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()
|
||||
with mock.patch.object(ba, "get_registry", return_value=empty):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_session_switch_off_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析",
|
||||
llm_client=FakeLLMClient(),
|
||||
capabilities={"bioflow-gene-family": False},
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_isolation_only_installed_responds(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
caps = ba._candidate_capabilities("帮我做基因家族分析", None)
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family"]
|
||||
caps = ba._candidate_capabilities("转录组差异表达", None)
|
||||
assert [m.name for m in caps] == ["bioflow-rnaseq"]
|
||||
|
||||
def test_full_flow_short_circuits(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "plan_gene_family",
|
||||
"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, {"plan_gene_family": mock.ANY})):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is not None
|
||||
assert result["success"] is True
|
||||
assert result["answer"] == "已为您规划分析路径。"
|
||||
assert result["bioflow"]["handled"] is True
|
||||
assert result["bioflow"]["capabilities"] == ["bioflow-gene-family"]
|
||||
|
||||
def test_exception_falls_back_to_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.object(ba, "_candidate_capabilities", side_effect=RuntimeError("boom")):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
@@ -0,0 +1,255 @@
|
||||
"""test_bioflow_agent_s2.py —— S2 对话式编排闭环单测。
|
||||
|
||||
覆盖:card_result 回填协议(四类卡片)、参数抽取(白名单)、多轮记忆注入、
|
||||
session↔job 绑定上下文(active_job_id/chain)、job_status 主动查询。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import CapabilityManifest # noqa: E402
|
||||
from rag.qa_v1_1_73.tool_runner import ToolRunner # noqa: E402
|
||||
|
||||
|
||||
def _manifest(name="bioflow-gene-family", url="http://x/mcp/gene-family/"):
|
||||
return CapabilityManifest(name=name, provider="mcp", config={"mcp": {"url": url, "tool": name}})
|
||||
|
||||
|
||||
def _tool_manifests():
|
||||
"""覆盖四类卡片用到的工具 + job_status。"""
|
||||
names = ("create_gene_family_job", "create_rnaseq_job", "confirm_stage",
|
||||
"register_job_files", "fetch_source_file", "job_status")
|
||||
out = {}
|
||||
for t in names:
|
||||
out[t] = CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/mcp/gene-family/", "tool": t}},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self, results=None):
|
||||
self.results = results or {}
|
||||
self.calls = []
|
||||
|
||||
def execute(self, manifest, arguments):
|
||||
tool = manifest.config.get("mcp", {}).get("tool")
|
||||
self.calls.append((tool, arguments))
|
||||
return self.results.get(tool, '{"ok": true, "data": {"id": "job-1"}}')
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, complete_text="已收到,已为您处理。", json_payload=None):
|
||||
self.complete_text = complete_text
|
||||
self.json_payload = json_payload or {}
|
||||
|
||||
def complete(self, system, user, **kw):
|
||||
return self.complete_text
|
||||
|
||||
def complete_json(self, system, user, **kw):
|
||||
return self.json_payload
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "answer", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 参数抽取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractParams:
|
||||
def test_extract_known_and_unknown(self):
|
||||
llm = FakeLLM(json_payload={"params": {"threads": 8, "padj": 0.01}, "unknown": ["bogus"]})
|
||||
form_defaults = {"threads": {"default": 4}, "padj": {"default": 0.05}}
|
||||
known, unknown = ba._extract_params(llm, "用 8 核,padj 用 0.01", form_defaults)
|
||||
assert known == {"threads": 8, "padj": 0.01}
|
||||
assert unknown == ["bogus"]
|
||||
|
||||
def test_non_whitelist_param_filtered(self):
|
||||
llm = FakeLLM(json_payload={"params": {"evil": 1, "threads": 4}, "unknown": []})
|
||||
form_defaults = {"threads": {"default": 4}}
|
||||
known, unknown = ba._extract_params(llm, "x", form_defaults)
|
||||
assert known == {"threads": 4}
|
||||
|
||||
def test_bad_json_returns_empty(self):
|
||||
llm = FakeLLM(json_payload="not-a-dict")
|
||||
form_defaults = {"threads": {}}
|
||||
known, unknown = ba._extract_params(llm, "x", form_defaults)
|
||||
assert known == {} and unknown == []
|
||||
|
||||
def test_empty_whitelist(self):
|
||||
llm = FakeLLM()
|
||||
known, unknown = ba._extract_params(llm, "x", {})
|
||||
assert known == {} and unknown == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# card_result 回填协议
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCardResult:
|
||||
def test_path_selection_creates_job_and_extracts_job_id(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
llm = FakeLLM()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "stages": ["identify", "tree"], "title": "水稻SPL"}},
|
||||
llm, {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["active_job_id"] == "job-1"
|
||||
assert out["bioflow"]["chain"] == "gene_family"
|
||||
assert dispatcher.calls[0][0] == "create_gene_family_job"
|
||||
assert dispatcher.calls[0][1]["stages"] == ["identify", "tree"]
|
||||
assert dispatcher.calls[0][1]["session_id"] == "s1"
|
||||
|
||||
def test_path_selection_missing_stages_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection", "payload": {"chain": "gene_family"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_form_confirm_calls_confirm_stage(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm",
|
||||
"payload": {"job_id": "job-9", "stage": "identify", "params": {"evalue": 1e-5}}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "confirm_stage"
|
||||
assert dispatcher.calls[0][1] == {"job_id": "job-9", "stage": "identify", "params": {"evalue": 1e-5}}
|
||||
|
||||
def test_form_confirm_uses_active_job_id_fallback(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result(
|
||||
{"card": "form_confirm", "payload": {"stage": "identify", "params": {}}},
|
||||
FakeLLM(), {"active_job_id": "job-7"}, _tool_manifests(),
|
||||
)
|
||||
assert dispatcher.calls[0][1]["job_id"] == "job-7"
|
||||
|
||||
def test_file_upload_splits_remote_and_local(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload",
|
||||
"payload": {"job_id": "j1", "paths": ["https://x/a.fa", "/data/b.fa"]}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
tools = [c[0] for c in dispatcher.calls]
|
||||
assert "fetch_source_file" in tools and "register_job_files" in tools
|
||||
|
||||
def test_archive_choice_paraphrases(self):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice", "payload": {"job_id": "j1", "archive": True}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert out["answer"] == "已收到,已为您处理。"
|
||||
|
||||
def test_unknown_card_returns_none(self):
|
||||
out = ba._handle_card_result({"card": "bogus", "payload": {}}, FakeLLM(), {}, _tool_manifests())
|
||||
assert out is None
|
||||
|
||||
def test_path_selection_reuse_failure_no_stages_returns_none(self):
|
||||
"""bug ②:复用失败且无显式 stages → 返回 None(不空链建任务)。"""
|
||||
dispatcher = FakeDispatcher({"reuse_archive": '{"ok": false, "code": "ARCHIVE_NOT_FOUND"}'})
|
||||
tm = _tool_manifests()
|
||||
tm["reuse_archive"] = CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x", "tool": "reuse_archive"}},
|
||||
)
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "reuse_archive": "不存在的存档"}},
|
||||
FakeLLM(), {}, tm,
|
||||
)
|
||||
assert out is None
|
||||
# 只执行了 reuse_archive,未调用 create_gene_family_job
|
||||
assert [c[0] for c in dispatcher.calls] == ["reuse_archive"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前门路由(card_result / bioflow 上下文)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRouteCardResult:
|
||||
def test_try_bioflow_agent_card_result_short_circuits(self):
|
||||
registry_manifest = _manifest()
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[registry_manifest]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"type": "function", "function": {"name": "confirm_stage", "description": "", "parameters": {"type": "object", "properties": {}}}}], {"confirm_stage": registry_manifest})):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba.try_bioflow_agent(
|
||||
"确认", llm_client=FakeLLM(),
|
||||
card_result={"card": "form_confirm", "payload": {"job_id": "j1", "stage": "s", "params": {}}},
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["handled"] is True
|
||||
|
||||
def test_try_bioflow_agent_fuse_off_ignores_card_result(self):
|
||||
with mock.patch.dict("os.environ", {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
out = ba.try_bioflow_agent(
|
||||
"确认", llm_client=FakeLLM(),
|
||||
card_result={"card": "form_confirm", "payload": {}},
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 多轮记忆注入(ToolRunner.run history)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiTurnHistory:
|
||||
def test_history_injected_into_messages(self):
|
||||
class RecordingLLM:
|
||||
def __init__(self):
|
||||
self.captured_messages = None
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
self.captured_messages = list(messages)
|
||||
return {"content": "ok", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
llm = RecordingLLM()
|
||||
runner = ToolRunner(make_llm_complete(llm), max_rounds=1)
|
||||
history = [{"role": "user", "content": "上一轮问题"}, {"role": "assistant", "content": "上一轮答案"}]
|
||||
runner.run("sys", "当前问题", [], {}, history=history)
|
||||
msgs = llm.captured_messages
|
||||
assert msgs[0] == {"role": "user", "content": "上一轮问题"}
|
||||
assert msgs[1] == {"role": "assistant", "content": "上一轮答案"}
|
||||
assert msgs[-1] == {"role": "user", "content": "当前问题"}
|
||||
|
||||
def test_history_filters_bad_roles(self):
|
||||
class RecordingLLM:
|
||||
def __init__(self):
|
||||
self.captured_messages = None
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
self.captured_messages = list(messages)
|
||||
return {"content": "ok", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
llm = RecordingLLM()
|
||||
runner = ToolRunner(make_llm_complete(llm), max_rounds=1)
|
||||
history = [{"role": "system", "content": "evil"}, {"role": "user", "content": ""}, {"role": "user", "content": "ok"}]
|
||||
runner.run("sys", "q", [], {}, history=history)
|
||||
roles = [m["role"] for m in llm.captured_messages]
|
||||
assert "system" not in roles
|
||||
assert llm.captured_messages[-1] == {"role": "user", "content": "q"}
|
||||
@@ -0,0 +1,511 @@
|
||||
"""test_bioflow_agent_s2_supplement.py —— S2 覆盖清单补强测试。
|
||||
|
||||
补强 dev 的 test_bioflow_agent_s2.py 未覆盖的分支:
|
||||
- _chain_to_capability / _extract_job_id / _execute_tool / _paraphrase / _query_job_status
|
||||
- card 四类 handler 缺必需字段回退 None、files 键、archive=False、非 dict payload
|
||||
- _route_card_result:chain 过滤 / 未注册能力→None / tools 空→None
|
||||
- NL 路径注入 bioflow 上下文(active_job_id/chain)
|
||||
- system prompt 硬规则在位(追问 ≤3 / 不可代确认 / 工具名不外泄)
|
||||
- ToolRunner.run(history) 边界(non-dict 条目 / history=None / 空内容剔除)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import CapabilityManifest # noqa: E402
|
||||
from rag.qa_v1_1_73.tool_runner import ToolRunner, make_llm_complete # noqa: E402
|
||||
|
||||
|
||||
def _manifest(name="bioflow-gene-family", url="http://x/mcp/gene-family/"):
|
||||
return CapabilityManifest(name=name, provider="mcp", config={"mcp": {"url": url, "tool": name}})
|
||||
|
||||
|
||||
def _tool_manifests():
|
||||
names = ("create_gene_family_job", "create_rnaseq_job", "confirm_stage",
|
||||
"register_job_files", "fetch_source_file", "job_status",
|
||||
"rerun_stage", "reuse_archive", "archive_job")
|
||||
out = {}
|
||||
for t in names:
|
||||
out[t] = CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/mcp/gene-family/", "tool": t}},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, complete_text="已收到。", json_payload=None, complete_error=None):
|
||||
self.complete_text = complete_text
|
||||
self.json_payload = json_payload or {}
|
||||
self.complete_error = complete_error
|
||||
|
||||
def complete(self, system, user, **kw):
|
||||
if self.complete_error:
|
||||
raise self.complete_error
|
||||
return self.complete_text
|
||||
|
||||
def complete_json(self, system, user, **kw):
|
||||
return self.json_payload
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "answer", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self, results=None):
|
||||
self.results = results or {}
|
||||
self.calls = []
|
||||
|
||||
def execute(self, manifest, arguments):
|
||||
tool = manifest.config.get("mcp", {}).get("tool")
|
||||
self.calls.append((tool, arguments))
|
||||
return self.results.get(tool, '{"ok": true, "data": {"id": "job-1"}}')
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 辅助函数
|
||||
# ===========================================================================
|
||||
|
||||
class TestChainToCapability:
|
||||
def test_known(self):
|
||||
assert ba._chain_to_capability("gene_family") == "bioflow-gene-family"
|
||||
assert ba._chain_to_capability("rnaseq") == "bioflow-rnaseq"
|
||||
|
||||
def test_unknown_and_empty(self):
|
||||
assert ba._chain_to_capability("wgs") is None
|
||||
assert ba._chain_to_capability("") is None
|
||||
assert ba._chain_to_capability(None) is None
|
||||
|
||||
def test_case_and_whitespace(self):
|
||||
assert ba._chain_to_capability(" Gene_Family ") == "bioflow-gene-family"
|
||||
|
||||
|
||||
class TestExtractJobId:
|
||||
def test_data_id(self):
|
||||
assert ba._extract_job_id('{"ok": true, "data": {"id": "job-9"}}') == "job-9"
|
||||
|
||||
def test_data_job_id(self):
|
||||
assert ba._extract_job_id('{"ok": true, "data": {"job_id": "job-8"}}') == "job-8"
|
||||
|
||||
def test_flat_id(self):
|
||||
assert ba._extract_job_id('{"ok": true, "id": "job-7"}') == "job-7"
|
||||
|
||||
def test_non_json_and_no_id(self):
|
||||
assert ba._extract_job_id("not json") is None
|
||||
assert ba._extract_job_id('{"ok": false}') is None
|
||||
assert ba._extract_job_id("") is None
|
||||
|
||||
|
||||
class TestExecuteTool:
|
||||
def test_unknown_tool(self):
|
||||
out = ba._execute_tool(_tool_manifests(), "ghost", {})
|
||||
assert "未注册" in out
|
||||
|
||||
def test_tool_exception_becomes_text(self):
|
||||
class Boom:
|
||||
def execute(self, manifest, arguments):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=Boom()):
|
||||
out = ba._execute_tool(_tool_manifests(), "job_status", {"job_id": "j"})
|
||||
assert "工具执行失败" in out
|
||||
|
||||
|
||||
class TestParaphrase:
|
||||
def test_llm_success(self):
|
||||
out = ba._paraphrase(FakeLLM(complete_text="已转述。"), "原文")
|
||||
assert out == "已转述。"
|
||||
|
||||
def test_llm_exception_falls_back_to_raw(self):
|
||||
out = ba._paraphrase(FakeLLM(complete_error=RuntimeError("boom")), "原文")
|
||||
assert out == "原文"
|
||||
|
||||
def test_llm_empty_falls_back_to_raw(self):
|
||||
out = ba._paraphrase(FakeLLM(complete_text=" "), "原文")
|
||||
assert out == "原文"
|
||||
|
||||
|
||||
class TestQueryJobStatus:
|
||||
def test_calls_job_status_and_paraphrases(self):
|
||||
with mock.patch.object(ba, "_execute_tool", return_value='{"ok": true, "data": {"status": "running"}}'):
|
||||
out = ba._query_job_status("j1", _tool_manifests(), FakeLLM(complete_text="任务进行中。"))
|
||||
assert out == "任务进行中。"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# card 四类 handler 边界(缺必需字段 / files 键 / archive=False / 非 dict payload)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCardHandlersEdge:
|
||||
def test_form_confirm_missing_job_and_stage_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm", "payload": {"params": {}}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_file_upload_missing_job_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload", "payload": {"paths": ["/data/a.fa"]}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_file_upload_missing_files_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload", "payload": {"job_id": "j1"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_file_upload_uses_files_key(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload",
|
||||
"payload": {"job_id": "j1", "files": ["https://x/a.fa"]}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "fetch_source_file"
|
||||
|
||||
def test_file_upload_string_files_coerced(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload",
|
||||
"payload": {"job_id": "j1", "files": "https://x/a.fa"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "fetch_source_file"
|
||||
|
||||
def test_archive_choice_missing_job_returns_none(self):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice", "payload": {"archive": True}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_archive_choice_skip(self):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice", "payload": {"job_id": "j1", "archive": False}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["audit"]["tool_calls"] == 0 # 存档不调工具(走 REST)
|
||||
|
||||
def test_path_selection_rnaseq_chain(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "rnaseq", "stages": ["rnaseq_qc"]}},
|
||||
FakeLLM(), {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "create_rnaseq_job"
|
||||
|
||||
def test_card_non_dict_payload_treated_empty(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm", "payload": "not-a-dict"},
|
||||
FakeLLM(), {"active_job_id": "j1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is None # payload 非 dict → 无 stage/job_id → None
|
||||
|
||||
def test_card_result_with_non_dict_is_ignored(self):
|
||||
# try_bioflow_agent 对非 dict 的 card_result 不做 card 路由,走 NL 意图
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_candidate_capabilities", return_value=[]):
|
||||
out = ba.try_bioflow_agent("确认", llm_client=FakeLLM(), card_result="bogus")
|
||||
assert out is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _route_card_result 边界
|
||||
# ===========================================================================
|
||||
|
||||
class TestRouteCardResultEdge:
|
||||
def test_chain_filters_to_capability(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
rn = _manifest("bioflow-rnaseq", "http://x/mcp/rnaseq/")
|
||||
captured = {}
|
||||
|
||||
def fake_build(manifests):
|
||||
captured["manifests"] = [m.name for m in manifests]
|
||||
return ([], {})
|
||||
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf, rn]):
|
||||
with mock.patch.object(ba, "_build_tools", side_effect=fake_build):
|
||||
ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {"chain": "rnaseq"}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
assert captured["manifests"] == ["bioflow-rnaseq"] # chain 过滤到 rnaseq 能力
|
||||
|
||||
def test_chain_to_unregistered_capability_returns_none(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"x": 1}], {})):
|
||||
out = ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {"chain": "rnaseq"}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
assert out is None # rnaseq 未注册 → manifests 空 → None
|
||||
|
||||
def test_tools_empty_returns_none(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([], {})):
|
||||
out = ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_progress_callback_invoked(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
calls = []
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"x": 1}], {})):
|
||||
with mock.patch.object(ba, "_handle_card_result", return_value=None):
|
||||
ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {}},
|
||||
FakeLLM(), None, None, lambda p: calls.append(p),
|
||||
)
|
||||
assert calls and calls[0]["stage"] == "bioflow_card"
|
||||
assert calls[0]["card"] == "form_confirm"
|
||||
|
||||
def test_progress_callback_exception_swallowed(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
|
||||
def boom(p):
|
||||
raise RuntimeError("callback boom")
|
||||
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"x": 1}], {})):
|
||||
with mock.patch.object(ba, "_handle_card_result", return_value={"ok": True}):
|
||||
out = ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {}},
|
||||
FakeLLM(), None, None, boom,
|
||||
)
|
||||
assert out == {"ok": True} # 回调异常被吞,不影响主流程
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# NL 路径:bioflow 上下文注入 / 卡片与 NL 语义等价
|
||||
# ===========================================================================
|
||||
|
||||
class TestNLPathBioflowContext:
|
||||
def test_nl_path_injects_bioflow_context(self, tmp_path):
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry
|
||||
from rag.qa_v1_1_73.capabilities.manifests import parse_capabilities_text
|
||||
yaml = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [基因家族]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
"""
|
||||
(tmp_path / "c.yaml").write_text(yaml, encoding="utf-8")
|
||||
registry = CapabilityRegistry(tmp_path / "c.yaml")
|
||||
registry.load()
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("已答复", {"rounds": 1})
|
||||
out = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLM(),
|
||||
bioflow={"session_id": "s1", "active_job_id": "job-9", "chain": "gene_family"},
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["active_job_id"] == "job-9"
|
||||
assert out["bioflow"]["chain"] == "gene_family"
|
||||
|
||||
def test_card_and_nl_share_tool_chain(self):
|
||||
"""卡片操作与纯 NL 服务端语义等价:卡片 handler 只调 MCP 工具,不直连控制面。"""
|
||||
# 卡片 handler 用到的工具名 = MCP 工具(job_status/confirm_stage/create_* 等)
|
||||
tm = _tool_manifests()
|
||||
# 四类卡片触发的工具都在 tool_manifests(即 MCP 工具表)里
|
||||
for tool in ("create_gene_family_job", "create_rnaseq_job", "confirm_stage",
|
||||
"register_job_files", "fetch_source_file", "job_status"):
|
||||
assert tool in tm
|
||||
|
||||
|
||||
class TestExtractParamsEdge:
|
||||
def test_llm_exception_returns_empty(self):
|
||||
class RaisingLLM(FakeLLM):
|
||||
def complete_json(self, system, user, **kw):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
known, unknown = ba._extract_params(RaisingLLM(), "x", {"threads": {}})
|
||||
assert known == {} and unknown == []
|
||||
|
||||
def test_unknown_as_string_coerced_to_list(self):
|
||||
llm = FakeLLM(json_payload={"params": {"threads": 8}, "unknown": "bogus_param"})
|
||||
known, unknown = ba._extract_params(llm, "x", {"threads": {}})
|
||||
assert known == {"threads": 8}
|
||||
assert unknown == ["bogus_param"]
|
||||
|
||||
def test_path_selection_with_params(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "stages": ["identify"],
|
||||
"params": {"prefix": "Ft.SPL"}}},
|
||||
FakeLLM(), {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert dispatcher.calls[0][1]["params"] == {"prefix": "Ft.SPL"}
|
||||
|
||||
def test_path_selection_create_tool_missing_returns_none(self):
|
||||
tm = _tool_manifests()
|
||||
tm.pop("create_gene_family_job")
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "stages": ["identify"]}},
|
||||
FakeLLM(), {"session_id": "s1"}, tm,
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# system prompt 硬规则在位
|
||||
# ===========================================================================
|
||||
|
||||
class TestSystemPromptRules:
|
||||
def test_param_retry_rule(self):
|
||||
assert "最多 3 次" in ba._SYSTEM_BASE
|
||||
|
||||
def test_human_boundary_rule(self):
|
||||
assert "不可跳过" in ba._SYSTEM_BASE
|
||||
assert "不可代用户确认" in ba._SYSTEM_BASE
|
||||
|
||||
def test_tool_name_no_leak_rule(self):
|
||||
assert "绝不向用户泄露工具名" in ba._SYSTEM_BASE
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ToolRunner.run(history) 边界
|
||||
# ===========================================================================
|
||||
|
||||
class TestHistoryEdge:
|
||||
def _run_with_history(self, history):
|
||||
captured = {}
|
||||
|
||||
class RecordingLLM:
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
captured["messages"] = list(messages)
|
||||
return {"content": "ok", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
runner = ToolRunner(make_llm_complete(RecordingLLM()), max_rounds=1)
|
||||
runner.run("sys", "当前", [], {}, history=history)
|
||||
return captured["messages"]
|
||||
|
||||
def test_none_history(self):
|
||||
msgs = self._run_with_history(None)
|
||||
assert msgs == [{"role": "user", "content": "当前"}]
|
||||
|
||||
def test_non_dict_items_skipped(self):
|
||||
msgs = self._run_with_history(["not-a-dict", {"role": "user", "content": "ok"}])
|
||||
assert msgs == [{"role": "user", "content": "ok"}, {"role": "user", "content": "当前"}]
|
||||
|
||||
def test_empty_content_filtered(self):
|
||||
msgs = self._run_with_history([{"role": "user", "content": " "}])
|
||||
assert msgs == [{"role": "user", "content": "当前"}]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S3:card_result rerun / reuse_archive / archive_job 语义
|
||||
# ===========================================================================
|
||||
|
||||
class TestS3CardSemantics:
|
||||
def test_form_confirm_rerun_true_uses_rerun_stage(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm",
|
||||
"payload": {"job_id": "j1", "stage": "identify",
|
||||
"params": {"evalue": 1e-5}, "rerun": True}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "rerun_stage"
|
||||
assert out["bioflow"]["audit"]["tool_names"] == ["rerun_stage"]
|
||||
|
||||
def test_form_confirm_rerun_false_uses_confirm_stage(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result(
|
||||
{"card": "form_confirm",
|
||||
"payload": {"job_id": "j1", "stage": "identify", "params": {}}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert dispatcher.calls[0][0] == "confirm_stage"
|
||||
|
||||
def test_path_selection_reuse_archive(self):
|
||||
dispatcher = FakeDispatcher({
|
||||
"reuse_archive": '{"ok": true, "data": {"id": "arch-1", '
|
||||
'"stage_chain": ["identify", "phylogeny"], '
|
||||
'"params": {"identify": {"prefix": "Ft"}}}}',
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "reuse_archive": "SPL 鉴定"}},
|
||||
FakeLLM(), {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
tools = [c[0] for c in dispatcher.calls]
|
||||
assert tools[0] == "reuse_archive"
|
||||
assert tools[1] == "create_gene_family_job"
|
||||
create_args = dispatcher.calls[1][1]
|
||||
assert create_args["archive_id"] == "arch-1"
|
||||
assert create_args["stages"] == ["identify", "phylogeny"]
|
||||
assert create_args["params"] == {"identify": {"prefix": "Ft"}}
|
||||
assert out["bioflow"]["audit"]["tool_names"] == ["reuse_archive", "create_gene_family_job"]
|
||||
|
||||
def test_archive_choice_archive_true_calls_archive_job(self):
|
||||
dispatcher = FakeDispatcher({"archive_job": '{"ok": true, "data": {"id": "a1"}}'})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice",
|
||||
"payload": {"job_id": "j1", "archive": True,
|
||||
"name": "SPL 存档", "description": "d"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "archive_job"
|
||||
assert dispatcher.calls[0][1] == {"job_id": "j1", "name": "SPL 存档", "description": "d"}
|
||||
assert out["bioflow"]["audit"]["tool_names"] == ["archive_job"]
|
||||
|
||||
def test_archive_choice_archive_false_no_tool_call(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice",
|
||||
"payload": {"job_id": "j1", "archive": False}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls == [] # 跳过存档不发工具请求
|
||||
assert out["bioflow"]["audit"]["tool_calls"] == 0
|
||||
@@ -0,0 +1,206 @@
|
||||
"""test_bioflow_agent_s4_supplement.py —— S4 覆盖清单补强测试。
|
||||
|
||||
覆盖:
|
||||
- _card_extension_action 六种 action 分发到对应扩展工具
|
||||
- 未知 action / 缺 ext_id/action 回退 None
|
||||
- confirm 携带 nl_triggers/parameters/runtime_image;EXT_STATUS 降级话术(确定性)
|
||||
- run 携带 params/files
|
||||
- 真实 configs/capabilities.yaml 三条目(含 bioflow-extension)可解析
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import CapabilityManifest, parse_capabilities_file # noqa: E402
|
||||
|
||||
EXT_TOOLS = (
|
||||
"create_extension", "extension_status", "build_extension", "trial_run_extension",
|
||||
"validate_extension", "confirm_extension", "publish_extension", "run_extension",
|
||||
"repair_extension",
|
||||
)
|
||||
|
||||
|
||||
def _ext_tool_manifests():
|
||||
out = {}
|
||||
for t in EXT_TOOLS:
|
||||
out[t] = CapabilityManifest(
|
||||
name="bioflow-extension", provider="mcp",
|
||||
config={"mcp": {"url": "http://127.0.0.1:8098/mcp/extension/", "tool": t}},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def complete(self, system, user, **kw):
|
||||
return "已收到。"
|
||||
|
||||
def complete_json(self, system, user, **kw):
|
||||
return {}
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "answer", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self, results=None):
|
||||
self.results = results or {}
|
||||
self.calls = []
|
||||
|
||||
def execute(self, manifest, arguments):
|
||||
tool = manifest.config.get("mcp", {}).get("tool")
|
||||
self.calls.append((tool, arguments))
|
||||
return self.results.get(tool, '{"ok": true, "data": {"ext_id": "e1"}}')
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _card_extension_action 分发
|
||||
# ===========================================================================
|
||||
|
||||
class TestExtensionAction:
|
||||
@pytest.mark.parametrize("action,expected_tool", [
|
||||
("build", "build_extension"),
|
||||
("trial_run", "trial_run_extension"),
|
||||
("validate", "validate_extension"),
|
||||
("confirm", "confirm_extension"),
|
||||
("publish", "publish_extension"),
|
||||
("run", "run_extension"),
|
||||
("repair", "repair_extension"),
|
||||
])
|
||||
def test_action_dispatches_to_tool(self, action, expected_tool):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": action}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == expected_tool
|
||||
assert dispatcher.calls[0][1] == {"ext_id": "e1"}
|
||||
assert out["bioflow"]["audit"]["tool_names"] == [expected_tool]
|
||||
|
||||
def test_unknown_action_returns_none(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "bogus"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_missing_ext_id_or_action_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
assert ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"action": "build"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
) is None
|
||||
assert ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
) is None
|
||||
|
||||
def test_confirm_passes_triggers_params_image(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
payload = {"ext_id": "e1", "action": "confirm",
|
||||
"nl_triggers": ["扩展", "触发词"], "parameters": [{"name": "x"}],
|
||||
"runtime_image": "img"}
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result({"card": "extension_action", "payload": payload},
|
||||
FakeLLM(), {}, _ext_tool_manifests())
|
||||
args = dispatcher.calls[0][1]
|
||||
assert args == {"ext_id": "e1", "nl_triggers": ["扩展", "触发词"],
|
||||
"parameters": [{"name": "x"}], "runtime_image": "img"}
|
||||
|
||||
def test_confirm_status_insufficient_uses_deterministic_script(self):
|
||||
# confirm 返回 EXT_STATUS(状态不足)→ 确定性降级话术(不报 500、不经 LLM)
|
||||
dispatcher = FakeDispatcher({
|
||||
"confirm_extension": '{"ok": false, "code": "EXT_STATUS", "message": "状态不可确认"}',
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "confirm"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert "还不能确认" in out["answer"]
|
||||
assert "先完成" in out["answer"] # 确定性话术(非 LLM 转述)
|
||||
|
||||
def test_run_passes_params_and_files(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
payload = {"ext_id": "e1", "action": "run",
|
||||
"params": {"cpu": 8}, "files": ["/data/a.fa"]}
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result({"card": "extension_action", "payload": payload},
|
||||
FakeLLM(), {}, _ext_tool_manifests())
|
||||
assert dispatcher.calls[0][1] == {"ext_id": "e1", "params": {"cpu": 8}, "files": ["/data/a.fa"]}
|
||||
|
||||
def test_extension_tool_not_in_manifests_returns_none(self):
|
||||
# tool_manifests 里没有扩展工具 → 回退 None(能力未安装/未发现工具)
|
||||
empty = CapabilityManifest(name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x", "tool": "job_status"}})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "build"}},
|
||||
FakeLLM(), {}, {"job_status": empty},
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 真实 capabilities.yaml 三条目可解析
|
||||
# ===========================================================================
|
||||
|
||||
class TestCapabilitiesThreeEntries:
|
||||
def _real_yaml(self) -> Path:
|
||||
return Path(__file__).resolve().parents[5] / "agent" / "qa" / "configs" / "capabilities.yaml"
|
||||
|
||||
def test_three_bioflow_entries(self):
|
||||
manifests, warnings = parse_capabilities_file(self._real_yaml())
|
||||
by_name = {m.name: m for m in manifests}
|
||||
assert {"bioflow-gene-family", "bioflow-rnaseq", "bioflow-extension"} <= set(by_name)
|
||||
|
||||
ext = by_name["bioflow-extension"]
|
||||
assert ext.provider == "mcp"
|
||||
assert ext.permission == "read_only"
|
||||
assert ext.nl_triggers
|
||||
assert ext.config["mcp"]["url"].endswith("/mcp/extension/")
|
||||
assert ext.config["prompt_pack"]["file"] == "bioflow_extension_agent_rules.md"
|
||||
|
||||
def test_bioflow_extension_in_capability_names(self):
|
||||
assert "bioflow-extension" in ba.BIOFLOW_CAPABILITY_NAMES
|
||||
assert ba.CARD_EXTENSION_ACTION == "extension_action"
|
||||
assert ba.CARD_EXTENSION_ACTION in ba.CARD_TYPES
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S5:_route_card_result 对 extension_action 只发现 extension 能力(一次 list_tools)
|
||||
# ===========================================================================
|
||||
|
||||
class TestRouteExtensionOptimization:
|
||||
def test_extension_action_discovers_only_extension_capability(self):
|
||||
gf = CapabilityManifest(name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/gene-family/"}})
|
||||
rn = CapabilityManifest(name="bioflow-rnaseq", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/rnaseq/"}})
|
||||
ext = CapabilityManifest(name="bioflow-extension", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/extension/"}})
|
||||
captured = {}
|
||||
|
||||
def fake_build(manifests):
|
||||
captured["names"] = [m.name for m in manifests]
|
||||
return ([], {})
|
||||
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf, rn, ext]):
|
||||
with mock.patch.object(ba, "_build_tools", side_effect=fake_build):
|
||||
ba._route_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "build"}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
# 只把 bioflow-extension manifest 交给 _build_tools(避免对三能力各 list_tools 一次)
|
||||
assert captured["names"] == ["bioflow-extension"]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""test_bioflow_agent_s6_config.py —— S6 多配置回归(QA 单元层)。
|
||||
|
||||
模拟四种部署配置,断言候选能力集合 + 意图预筛结果正确(真实 E2E/金标 20 题依赖部署,
|
||||
此处以 monkeypatch/env 在单元层覆盖漏判安全语义)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry # noqa: E402
|
||||
|
||||
THREE_YAML = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 亚家族]
|
||||
mcp: {url: "http://x/gene-family/"}
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [转录组, 差异表达]
|
||||
mcp: {url: "http://x/rnaseq/"}
|
||||
- name: bioflow-extension
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [自定义扩展]
|
||||
mcp: {url: "http://x/extension/"}
|
||||
"""
|
||||
|
||||
|
||||
def _registry(tmp_path, yaml_text=THREE_YAML):
|
||||
path = tmp_path / "caps.yaml"
|
||||
path.write_text(yaml_text, encoding="utf-8")
|
||||
reg = CapabilityRegistry(path)
|
||||
reg.load()
|
||||
return reg
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def complete_raw_messages(self, *a, **kw):
|
||||
return {"content": "x", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 1:全关(QA_V1_ENABLE_BIOFLOW_AGENT=0 + 三能力全卸载)
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigAllOff:
|
||||
def test_fuse_off_returns_none(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
out = ba.try_bioflow_agent("帮我做基因家族分析", llm_client=FakeLLM())
|
||||
assert out is None # 走原 pipeline
|
||||
|
||||
def test_all_uninstalled_no_candidate(self, tmp_path):
|
||||
reg = _registry(tmp_path, "capabilities: []\n") # 三能力全卸载
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
assert ba._candidate_capabilities("帮我做基因家族分析", None) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 2:只开基因家族
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigGeneFamilyOnly:
|
||||
def test_gene_family_hit_rnaseq_miss(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
caps = {"bioflow-gene-family": True, "bioflow-rnaseq": False, "bioflow-extension": False}
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
names = [m.name for m in ba._candidate_capabilities("帮我做水稻 SPL 基因家族分析", caps)]
|
||||
assert names == ["bioflow-gene-family"]
|
||||
assert ba._candidate_capabilities("帮我做转录组差异表达分析", caps) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 3:只开转录组
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigRnaseqOnly:
|
||||
def test_rnaseq_hit_gene_family_miss(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
caps = {"bioflow-gene-family": False, "bioflow-rnaseq": True, "bioflow-extension": False}
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
names = [m.name for m in ba._candidate_capabilities("转录组差异表达分析", caps)]
|
||||
assert names == ["bioflow-rnaseq"]
|
||||
assert ba._candidate_capabilities("帮我做水稻 SPL 基因家族分析", caps) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 4:全开但非生信提问
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigAllOnNonBioflow:
|
||||
def test_non_bioflow_question_no_candidate(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
caps = {"bioflow-gene-family": True, "bioflow-rnaseq": True, "bioflow-extension": True}
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
assert ba._candidate_capabilities("光合作用的电子传递链是什么", caps) == []
|
||||
assert ba._candidate_capabilities("什么是转录因子", caps) == []
|
||||
@@ -0,0 +1,496 @@
|
||||
"""test_bioflow_agent_supplement.py —— S1 覆盖清单补强测试。
|
||||
|
||||
补强 dev 的 test_bioflow_agent.py 未覆盖的分支(对照 S1 覆盖清单):
|
||||
- 熔断开关值解析(1/true/yes/on 与 0/false/off/空/缺失)
|
||||
- nl_triggers 同时命中 → 两个候选(交 LLM 依 description 选)
|
||||
- session_default 回退(True/False)/ registry 访问异常 / registry.get 异常
|
||||
- _build_tools:MCP 工具发现(空 url 跳过 / 发现异常 / 共享连接按能力名 / 无名跳过 / bad schema 兜底)
|
||||
- _build_system:prompt_pack 注入 / 去重 / 加载异常兜底
|
||||
- try_bioflow_agent:tools 空→None、answer 空→None、progress_callback 触发与吞异常
|
||||
- ToolRunner max_rounds=5 / bioflow_agent.MAX_ROUNDS 常量
|
||||
- make_llm_complete 包装(正常 / 异常)
|
||||
- 真实 configs/capabilities.yaml 两条目可解析
|
||||
- bioflow_agent 不 import pipeline/answer_composer(红线静态守卫)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import ( # noqa: E402
|
||||
CapabilityManifest,
|
||||
parse_capabilities_file,
|
||||
)
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry # noqa: E402
|
||||
|
||||
BIOFLOW_YAML = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 家族成员, 亚家族, 建树]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析
|
||||
session_default: true
|
||||
nl_triggers: [转录组, RNA-seq, 差异表达, 富集]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
"""
|
||||
|
||||
|
||||
def _make_registry(tmp_path: Path, yaml_text: str = BIOFLOW_YAML) -> CapabilityRegistry:
|
||||
path = tmp_path / "capabilities.yaml"
|
||||
path.write_text(yaml_text, encoding="utf-8")
|
||||
registry = CapabilityRegistry(path)
|
||||
registry.load()
|
||||
return registry
|
||||
|
||||
|
||||
def _gf_manifest(**overrides) -> CapabilityManifest:
|
||||
cfg = {
|
||||
"mcp": {"url": "http://127.0.0.1:8098/mcp/gene-family/"},
|
||||
"prompt_pack": {"file": "bioflow_gene_family_agent_rules.md"},
|
||||
}
|
||||
cfg.update(overrides.pop("config", {}))
|
||||
return CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp", permission="read_only",
|
||||
description="基因家族分析", config=cfg, nl_triggers=["基因家族"], **overrides,
|
||||
)
|
||||
|
||||
|
||||
class FakeLLMClient:
|
||||
def __init__(self, content: str = "已为您规划分析路径。") -> None:
|
||||
self.content = content
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kwargs):
|
||||
return {"content": self.content, "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 门 1:熔断开关值解析
|
||||
# ===========================================================================
|
||||
|
||||
class TestEnvFuse:
|
||||
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", " On ", "YES"])
|
||||
def test_truthy(self, value):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: value}):
|
||||
assert ba._env_enabled() is True
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "off", "", "no", " "])
|
||||
def test_falsy(self, value):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: value}):
|
||||
assert ba._env_enabled() is False
|
||||
|
||||
def test_missing_defaults_off(self):
|
||||
with mock.patch.dict(ba.os.environ, {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
assert ba._env_enabled() is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 门 3:nl_triggers 意图预筛
|
||||
# ===========================================================================
|
||||
|
||||
class TestIntentMatchEdge:
|
||||
def test_empty_triggers_never_match(self):
|
||||
assert ba._intent_match("做基因家族分析", []) is False
|
||||
assert ba._intent_match("做基因家族分析", [""]) is False
|
||||
|
||||
def test_case_insensitive_substring(self):
|
||||
assert ba._intent_match("RNA-SEQ 分析", ["rna-seq"]) is True
|
||||
assert ba._intent_match("ka/ks 选择压力", ["Ka/Ks"]) is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 门 2 + 门 3:候选能力(registry 异常 / session_default 回退 / 同时命中)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCandidateCapabilities:
|
||||
def test_registry_access_exception_returns_empty(self):
|
||||
with mock.patch.object(ba, "get_registry", side_effect=RuntimeError("boom")):
|
||||
assert ba._candidate_capabilities("做基因家族", None) == []
|
||||
|
||||
def test_registry_get_exception_skips(self):
|
||||
class BadRegistry:
|
||||
def get(self, name):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with mock.patch.object(ba, "get_registry", return_value=BadRegistry()):
|
||||
assert ba._candidate_capabilities("做基因家族", None) == []
|
||||
|
||||
def test_both_triggers_match_returns_both(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
caps = ba._candidate_capabilities("做基因家族和转录组差异表达分析", None)
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family", "bioflow-rnaseq"]
|
||||
|
||||
def test_session_default_false_skips(self, tmp_path):
|
||||
yaml = BIOFLOW_YAML.replace("session_default: true", "session_default: false")
|
||||
registry = _make_registry(tmp_path, yaml)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
# 无会话开关时回退 session_default=false → 不候选
|
||||
assert ba._candidate_capabilities("做基因家族分析", None) == []
|
||||
# 显式会话开关 true → 候选
|
||||
caps = ba._candidate_capabilities("做基因家族分析", {"bioflow-gene-family": True})
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family"]
|
||||
|
||||
def test_session_switch_true_overrides_default_false(self, tmp_path):
|
||||
yaml = BIOFLOW_YAML.replace("session_default: true", "session_default: false", 1)
|
||||
registry = _make_registry(tmp_path, yaml)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
caps = ba._candidate_capabilities("做基因家族分析", {"bioflow-gene-family": True})
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _build_tools:MCP 工具发现
|
||||
# ===========================================================================
|
||||
|
||||
class _FakeDispatcher:
|
||||
def __init__(self, tools_by_name=None, exc=None):
|
||||
self.tools_by_name = tools_by_name or {}
|
||||
self.exc = exc
|
||||
|
||||
def list_mcp_tools(self, manifest):
|
||||
if self.exc:
|
||||
raise self.exc
|
||||
return self.tools_by_name.get(manifest.name, [])
|
||||
|
||||
|
||||
class TestBuildTools:
|
||||
def test_empty_url_skipped(self):
|
||||
manifest = CapabilityManifest(
|
||||
name="x", provider="mcp", config={"mcp": {"url": ""}}
|
||||
)
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=_FakeDispatcher()):
|
||||
tools, tool_manifests = ba._build_tools([manifest])
|
||||
assert tools == []
|
||||
assert tool_manifests == {}
|
||||
|
||||
def test_discovery_exception_skipped(self):
|
||||
manifest = _gf_manifest()
|
||||
with mock.patch.object(
|
||||
ba, "get_dispatcher",
|
||||
return_value=_FakeDispatcher(exc=RuntimeError("mcp down")),
|
||||
):
|
||||
tools, tool_manifests = ba._build_tools([manifest])
|
||||
assert tools == []
|
||||
assert tool_manifests == {}
|
||||
|
||||
def test_builds_schema_and_shared_connection(self):
|
||||
dispatcher = _FakeDispatcher({
|
||||
"bioflow-gene-family": [
|
||||
{"name": "plan_gene_family", "description": "规划路径",
|
||||
"input_schema": {"type": "object", "properties": {"message": {"type": "string"}}}},
|
||||
]
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
tools, tool_manifests = ba._build_tools([_gf_manifest()])
|
||||
assert len(tools) == 1
|
||||
assert tools[0]["function"]["name"] == "plan_gene_family"
|
||||
assert tools[0]["function"]["description"] == "规划路径"
|
||||
# 共享连接:manifest.name 用能力名,mcp.tool 指向真实 server 工具名
|
||||
tm = tool_manifests["plan_gene_family"]
|
||||
assert tm.name == "bioflow-gene-family"
|
||||
assert tm.config["mcp"]["tool"] == "plan_gene_family"
|
||||
assert tm.config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
|
||||
def test_skips_nameless_and_bad_schema(self):
|
||||
dispatcher = _FakeDispatcher({
|
||||
"bioflow-gene-family": [
|
||||
{"name": "", "description": "无名", "input_schema": {}},
|
||||
{"name": "job_status", "description": "查状态", "input_schema": "not-a-dict"},
|
||||
]
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
tools, tool_manifests = ba._build_tools([_gf_manifest()])
|
||||
assert [t["function"]["name"] for t in tools] == ["job_status"]
|
||||
assert tools[0]["function"]["parameters"] == {"type": "object", "properties": {}}
|
||||
|
||||
def test_shared_tool_deduped_across_capabilities(self):
|
||||
"""两能力同时命中:共享工具去重(只保留首个能力),专有工具各自保留。
|
||||
|
||||
覆盖 bug ① 修复:job_status 等 12 个共享工具两端点语义一致,按名去重后
|
||||
LLM tools 列表无重名,且 job_status 路由到首个能力(gene-family)。
|
||||
"""
|
||||
dispatcher = _FakeDispatcher({
|
||||
"bioflow-gene-family": [
|
||||
{"name": "plan_gene_family", "description": "规划基因家族", "input_schema": {}},
|
||||
{"name": "job_status", "description": "查状态", "input_schema": {}},
|
||||
{"name": "confirm_stage", "description": "确认参数", "input_schema": {}},
|
||||
],
|
||||
"bioflow-rnaseq": [
|
||||
{"name": "plan_rnaseq", "description": "规划转录组", "input_schema": {}},
|
||||
{"name": "job_status", "description": "查状态", "input_schema": {}},
|
||||
{"name": "confirm_stage", "description": "确认参数", "input_schema": {}},
|
||||
],
|
||||
})
|
||||
rn = CapabilityManifest(
|
||||
name="bioflow-rnaseq", provider="mcp", permission="read_only",
|
||||
description="转录组分析", nl_triggers=["转录组"],
|
||||
config={"mcp": {"url": "http://127.0.0.1:8098/mcp/rnaseq/"}},
|
||||
)
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
tools, tool_manifests = ba._build_tools([_gf_manifest(), rn])
|
||||
|
||||
names = [t["function"]["name"] for t in tools]
|
||||
# 专有工具各保留 + 共享工具只保留一份,且无重名
|
||||
assert names == ["plan_gene_family", "job_status", "confirm_stage", "plan_rnaseq"]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
# 共享工具路由到首个能力(gene-family)
|
||||
assert tool_manifests["job_status"].name == "bioflow-gene-family"
|
||||
assert tool_manifests["job_status"].config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
assert tool_manifests["confirm_stage"].name == "bioflow-gene-family"
|
||||
# 专有工具各自路由到自身能力
|
||||
assert tool_manifests["plan_rnaseq"].name == "bioflow-rnaseq"
|
||||
assert tool_manifests["plan_rnaseq"].config["mcp"]["url"].endswith("/mcp/rnaseq/")
|
||||
assert tool_manifests["plan_gene_family"].name == "bioflow-gene-family"
|
||||
|
||||
# ===========================================================================
|
||||
# _build_system:prompt_pack 注入
|
||||
# ===========================================================================
|
||||
|
||||
class TestBuildSystem:
|
||||
def test_appends_prompt_pack(self):
|
||||
manifest = _gf_manifest()
|
||||
with mock.patch.object(ba.PromptPackProvider, "load", return_value="基因家族规则正文"):
|
||||
system = ba._build_system([manifest])
|
||||
assert ba._SYSTEM_BASE in system
|
||||
assert "基因家族规则正文" in system
|
||||
|
||||
def test_dedup_same_file_once(self):
|
||||
m1 = _gf_manifest()
|
||||
m2 = _gf_manifest() # 同 file
|
||||
calls = []
|
||||
with mock.patch.object(ba.PromptPackProvider, "load", side_effect=lambda m, root: calls.append(m) or "RULES"):
|
||||
system = ba._build_system([m1, m2])
|
||||
assert len(calls) == 1 # 去重后只加载一次
|
||||
assert system.count("RULES") == 1
|
||||
|
||||
def test_empty_file_skipped(self):
|
||||
manifest = CapabilityManifest(name="x", provider="mcp", config={"prompt_pack": {"file": ""}})
|
||||
system = ba._build_system([manifest])
|
||||
assert system == ba._SYSTEM_BASE
|
||||
|
||||
def test_load_exception_falls_back(self):
|
||||
manifest = _gf_manifest()
|
||||
with mock.patch.object(ba.PromptPackProvider, "load", side_effect=RuntimeError("boom")):
|
||||
system = ba._build_system([manifest])
|
||||
assert ba._SYSTEM_BASE in system # 不炸,只用基础提示
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# try_bioflow_agent:tools 空 / answer 空 / progress_callback
|
||||
# ===========================================================================
|
||||
|
||||
class TestFullFlowEdge:
|
||||
def test_tools_empty_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([], {})):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_answer_empty_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("", {"rounds": 1})
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_progress_callback_invoked(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
calls = []
|
||||
|
||||
def cb(payload):
|
||||
calls.append(payload)
|
||||
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("已答复", {"rounds": 1})
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(),
|
||||
capabilities=None, progress_callback=cb,
|
||||
)
|
||||
assert result is not None
|
||||
assert calls and calls[0]["stage"] == "bioflow_agent_started"
|
||||
assert calls[0]["capabilities"] == ["bioflow-gene-family"]
|
||||
assert calls[0]["tool_count"] == 1
|
||||
|
||||
def test_progress_callback_exception_swallowed(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
|
||||
def cb(payload):
|
||||
raise RuntimeError("callback boom")
|
||||
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("已答复", {"rounds": 1})
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(),
|
||||
capabilities=None, progress_callback=cb,
|
||||
)
|
||||
assert result is not None # 回调异常被吞,不影响主流程
|
||||
assert result["answer"] == "已答复"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ToolRunner max_rounds=5 / MAX_ROUNDS 常量
|
||||
# ===========================================================================
|
||||
|
||||
class TestMaxRounds:
|
||||
def test_max_rounds_constant_is_5(self):
|
||||
assert ba.MAX_ROUNDS == 5
|
||||
|
||||
def test_tool_runner_respects_max_rounds_5(self):
|
||||
from rag.qa_v1_1_73.tool_runner import ToolRunner
|
||||
|
||||
class LLM:
|
||||
def __call__(self, system, messages, tools):
|
||||
return "", [{"id": "c", "name": "echo", "arguments": "{}"}], None
|
||||
|
||||
class Dispatcher:
|
||||
def execute(self, manifest, arguments):
|
||||
return "ok"
|
||||
|
||||
manifest = CapabilityManifest(name="echo", provider="python")
|
||||
tools = [{"type": "function", "function": {"name": "echo", "description": "e",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
runner = ToolRunner(LLM(), dispatcher=Dispatcher(), max_rounds=5)
|
||||
content, audit = runner.run("sys", "user", tools, {"echo": manifest})
|
||||
assert audit["rounds"] == 5
|
||||
assert audit["tool_calls"] == 5
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# make_llm_complete 包装
|
||||
# ===========================================================================
|
||||
|
||||
class TestMakeLLMComplete:
|
||||
def test_normal(self):
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
class Client:
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "hi", "tool_calls": [{"name": "x"}], "finish_reason": "stop"}
|
||||
|
||||
complete = make_llm_complete(Client())
|
||||
content, tool_calls, error = complete("sys", [{"role": "user", "content": "q"}], [])
|
||||
assert content == "hi"
|
||||
assert tool_calls == [{"name": "x"}]
|
||||
assert error is None
|
||||
|
||||
def test_exception_becomes_error(self):
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
class Client:
|
||||
def complete_raw_messages(self, *a, **kw):
|
||||
raise RuntimeError("llm down")
|
||||
|
||||
complete = make_llm_complete(Client())
|
||||
content, tool_calls, error = complete("sys", [], [])
|
||||
assert content == ""
|
||||
assert tool_calls == []
|
||||
assert "RuntimeError" in error
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 真实 configs/capabilities.yaml 两条目可解析
|
||||
# ===========================================================================
|
||||
|
||||
class TestRealCapabilitiesYaml:
|
||||
def _real_yaml(self) -> Path:
|
||||
repo_root = Path(__file__).resolve().parents[5]
|
||||
return repo_root / "agent" / "qa" / "configs" / "capabilities.yaml"
|
||||
|
||||
def test_both_bioflow_entries_parse(self):
|
||||
manifests, warnings = parse_capabilities_file(self._real_yaml())
|
||||
by_name = {m.name: m for m in manifests}
|
||||
assert "bioflow-gene-family" in by_name
|
||||
assert "bioflow-rnaseq" in by_name
|
||||
|
||||
gf = by_name["bioflow-gene-family"]
|
||||
assert gf.provider == "mcp"
|
||||
assert gf.permission == "read_only"
|
||||
assert gf.nl_triggers
|
||||
assert gf.config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
assert gf.config["prompt_pack"]["file"] == "bioflow_gene_family_agent_rules.md"
|
||||
|
||||
rn = by_name["bioflow-rnaseq"]
|
||||
assert rn.permission == "read_only"
|
||||
assert rn.nl_triggers
|
||||
assert rn.config["mcp"]["url"].endswith("/mcp/rnaseq/")
|
||||
assert rn.config["prompt_pack"]["file"] == "bioflow_rnaseq_agent_rules.md"
|
||||
|
||||
def test_nl_triggers_cover_expected_words(self):
|
||||
manifests, _ = parse_capabilities_file(self._real_yaml())
|
||||
gf = next(m for m in manifests if m.name == "bioflow-gene-family")
|
||||
rn = next(m for m in manifests if m.name == "bioflow-rnaseq")
|
||||
# 关键触发词在位(意图预筛依赖)
|
||||
assert "基因家族" in gf.nl_triggers
|
||||
assert "转录组" in rn.nl_triggers
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 红线静态守卫:bioflow_agent 不依赖 pipeline / answer_composer
|
||||
# ===========================================================================
|
||||
|
||||
class TestRedLineNoPipelineDep:
|
||||
def test_no_pipeline_or_answer_composer_import(self):
|
||||
import ast
|
||||
|
||||
src = Path(ba.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(src)
|
||||
imported = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Import):
|
||||
imported += [a.name for a in node.names]
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
imported.append(node.module or "")
|
||||
assert not any("pipeline" in m or "answer_composer" in m for m in imported)
|
||||
|
||||
def test_never_raises(self, tmp_path):
|
||||
# 任何异常都返回 None/正常 payload,绝不外抛
|
||||
with mock.patch.object(ba, "get_registry", side_effect=RuntimeError("boom")):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent("做基因家族", llm_client=FakeLLMClient())
|
||||
assert result is None
|
||||
@@ -51,9 +51,13 @@ class ToolRunner:
|
||||
user: str,
|
||||
tools: List[Dict[str, Any]],
|
||||
manifests: Dict[str, CapabilityManifest],
|
||||
history: Optional[List[Dict[str, str]]] = None,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""执行工具调用循环。返回 (content, audit)。
|
||||
|
||||
history:多轮记忆(S2),List[{role: user|assistant, content}],旧→新,
|
||||
不含当前这轮提问;注入为 messages 前置轮次(作为上下文,不参与工具结果回填)。
|
||||
|
||||
audit: {"rounds": n, "tool_calls": k, "used_json_fallback": bool,
|
||||
"tool_names": [...], "errors": [...]}
|
||||
"""
|
||||
@@ -62,9 +66,19 @@ class ToolRunner:
|
||||
"tool_calls": 0,
|
||||
"used_json_fallback": False,
|
||||
"tool_names": [],
|
||||
"tool_results": {},
|
||||
"errors": [],
|
||||
}
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": user}]
|
||||
messages: List[Dict[str, Any]] = []
|
||||
for h in history or []:
|
||||
if not isinstance(h, dict):
|
||||
continue
|
||||
role = str(h.get("role") or "").strip()
|
||||
content = str(h.get("content") or "").strip()
|
||||
if role not in {"user", "assistant"} or not content:
|
||||
continue
|
||||
messages.append({"role": role, "content": content})
|
||||
messages.append({"role": "user", "content": user})
|
||||
system_text = system
|
||||
for round_index in range(1, self.max_rounds + 1):
|
||||
audit["rounds"] = round_index
|
||||
@@ -82,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 {}
|
||||
@@ -112,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
|
||||
|
||||
@@ -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)
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
- 独立部署树 bioflow_service/(FR-BIO-028:独立进程 8098 + 独立 SQLite)。
|
||||
- lifespan:init_db(幂等迁移)→ init_context(kernel/catalog/planner/runner)
|
||||
→ recover(服务重启恢复 in-flight 续跑,FR-BIO-031)→ shutdown 清理。
|
||||
→ recover(服务重启恢复 in-flight 续跑,FR-BIO-031)→ MCP session manager
|
||||
(anyio task group,streamable-http 处理请求必需)→ shutdown 清理。
|
||||
- 端点:health(/api/bioflow/health,前端探活 FR-BIO-025)。
|
||||
- 不依赖 BFF/生产服务(8090/8091 等绝不探测/连接)。
|
||||
"""
|
||||
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
|
||||
@@ -17,11 +25,18 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from .config import get_settings
|
||||
from .context import context, init_context, shutdown_context
|
||||
from .db import close_db, init_db
|
||||
from .mcp_server import extension_mcp, gene_family_mcp, rnaseq_mcp
|
||||
from .routers import bioarchives, bioflow, biojobs, extension
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 三个 MCP streamable-http app(调用即创建 session_manager,供 lifespan 进入 run())。
|
||||
# 端点地址:/mcp/gene-family、/mcp/rnaseq、/mcp/extension(streamable_http_path="/" + 挂载前缀)。
|
||||
_gene_family_mcp_app = gene_family_mcp.streamable_http_app(streamable_http_path="/")
|
||||
_rnaseq_mcp_app = rnaseq_mcp.streamable_http_app(streamable_http_path="/")
|
||||
_extension_mcp_app = extension_mcp.streamable_http_app(streamable_http_path="/")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
@@ -60,14 +75,21 @@ def create_app() -> FastAPI:
|
||||
logger.info("main: 重启恢复 %d 个 in-flight 任务续跑", len(recovered))
|
||||
except Exception:
|
||||
logger.exception("main: 重启恢复扫描失败(任务不重跑,等待后续操作)")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await shutdown_context()
|
||||
try:
|
||||
await close_db()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# MCP session manager 的 task group 必须在服务运行期内保持活跃
|
||||
# (streamable-http 处理请求时要求已初始化)。Starlette 不会自动运行
|
||||
# 子 Mount 的 lifespan,故在此显式进入三个 MCP 的 run()。
|
||||
async with gene_family_mcp.session_manager.run():
|
||||
async with rnaseq_mcp.session_manager.run():
|
||||
async with extension_mcp.session_manager.run():
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await shutdown_context()
|
||||
try:
|
||||
await close_db()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app.router.lifespan_context = _lifespan
|
||||
|
||||
@@ -75,6 +97,12 @@ def create_app() -> FastAPI:
|
||||
app.include_router(biojobs.router)
|
||||
app.include_router(bioarchives.router)
|
||||
app.include_router(extension.router)
|
||||
|
||||
# 生信 MCP 能力外挂(mcp 2.x:MCPServer.streamable_http_app)。
|
||||
# 三个独立 streamable-http 端点,各自装卸(REST 面不受影响)。
|
||||
app.mount("/mcp/gene-family", _gene_family_mcp_app)
|
||||
app.mount("/mcp/rnaseq", _rnaseq_mcp_app)
|
||||
app.mount("/mcp/extension", _extension_mcp_app)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
"""app/mcp_server.py —— 生信 MCP server(两个 streamable-http 端点)。
|
||||
|
||||
- `/mcp/gene-family`:基因家族能力单元(plan/create/ask + 共享控制组 + 文件组)
|
||||
- `/mcp/rnaseq`:转录组能力单元(plan/create/generate_samples_yaml/ask + 共享控制组 + 文件组)
|
||||
|
||||
设计约束(实施方案 S0):
|
||||
- 工具只承诺「已受理」,执行进度/日志由 SSE 事件总线直推,不走 MCP。
|
||||
- 每个能力单元只接受本链阶段(create 跨链拒绝 CROSS_CHAIN)。
|
||||
- 人工确认边界(subfamily_confirm / rnaseq_de 实验设计确认)在 job_status 的
|
||||
pending_actions 里标 requires_human=true,工具不提供绕过。
|
||||
- 白名单沿用 runner._allowed_param_names(单一事实源)。
|
||||
- 工具名不外泄由 QA agent(S1)负责,本模块只负责工具语义。
|
||||
|
||||
mcp 2.x 注意:MCPServer(非 FastMCP,来自 mcp.server.mcpserver);挂载用
|
||||
`mcp.streamable_http_app()`。本文件位于 app/ 层(非 services/),可用 pydantic(mcp
|
||||
自身也依赖 pydantic 构造 schema)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
from .services.bioflow.catalog import GENE_FAMILY_CHAIN
|
||||
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 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)
|
||||
|
||||
RNASEQ_STAGES = {
|
||||
"rnaseq_qc", "rnaseq_align", "rnaseq_quant", "rnaseq_sample_qc", "rnaseq_de",
|
||||
"rnaseq_wgcna", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_enrich",
|
||||
"rnaseq_fgsea", "rnaseq_candidate_family", "rnaseq_report",
|
||||
"annotation", "expression",
|
||||
}
|
||||
|
||||
# 人工确认边界:这些阶段 waiting_confirm 时 requires_human=true,工具/QA 不可代确认
|
||||
HUMAN_CONFIRM_STAGES = {"subfamily_confirm", "rnaseq_de"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# context 访问(动态读模块全局,测试 monkeypatch 兼容)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ctx():
|
||||
from . import context as context_mod
|
||||
|
||||
return context_mod.context
|
||||
|
||||
|
||||
def _job_detail(record: Any) -> dict[str, Any]:
|
||||
stages = []
|
||||
for stage in record.stage_chain:
|
||||
st = record._stage_states.get(stage, {})
|
||||
stages.append({
|
||||
"stage": stage,
|
||||
"status": st.get("status", "waiting"),
|
||||
"params": st.get("params") or {},
|
||||
"attempt": int(st.get("attempt") or 0),
|
||||
"manifest_path": st.get("manifest_path"),
|
||||
"log_tail": st.get("log_tail") or "",
|
||||
"error": st.get("error"),
|
||||
})
|
||||
return {
|
||||
"id": record.id,
|
||||
"session_id": record.session_id,
|
||||
"title": record.title,
|
||||
"status": record.status,
|
||||
"stages": stages,
|
||||
"params": record.params,
|
||||
"auto_continue": record.auto_continue,
|
||||
"archive_id": record.archive_id,
|
||||
"progress": record.progress(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pending_actions 派生(job_status 工具返回给 QA agent 的下一步动作)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def derive_pending_actions(record: Any, runner: Any) -> list[dict[str, Any]]:
|
||||
"""由任务/阶段状态派生待办动作(QA agent 据此引导用户,人工边界不可代确认)。
|
||||
|
||||
按阶段链顺序找第一个「卡住」的环节,返回动作清单(通常 0-1 条)。
|
||||
"""
|
||||
if record.status == "paused":
|
||||
return [{"action": "resume", "stage": None, "requires_human": False,
|
||||
"message": "任务已暂停,可恢复"}]
|
||||
if record.status == "done":
|
||||
return [{"action": "archive", "stage": None, "requires_human": False,
|
||||
"message": "任务已完成,可存档复用"}]
|
||||
if record.status == "archived":
|
||||
return [{"action": "none", "stage": None, "requires_human": False,
|
||||
"message": "任务已存档"}]
|
||||
|
||||
for stage in record.stage_chain:
|
||||
status = record._stage_states.get(stage, {}).get("status", "waiting")
|
||||
if status == "await_files":
|
||||
return [{"action": "provide_files", "stage": stage, "requires_human": False,
|
||||
"message": f"阶段 {stage} 等待输入文件"}]
|
||||
if status == "waiting_confirm":
|
||||
human = stage in HUMAN_CONFIRM_STAGES
|
||||
return [{"action": "confirm", "stage": stage, "requires_human": human,
|
||||
"form_defaults": runner._form_defaults(stage, record),
|
||||
"message": (f"阶段 {stage} 需人工确认(不可代确认)" if human
|
||||
else f"阶段 {stage} 等待参数确认")}]
|
||||
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 []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具实现(命名 async 函数,带类型提示,供两个 MCPServer 注册)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _plan(message: str, allowed_stages: set[str]) -> dict[str, Any]:
|
||||
from .services.bioflow.planner import PlanCandidate, PlannerError, PlanResult
|
||||
|
||||
ctx = _ctx()
|
||||
if ctx.planner is None:
|
||||
return {"ok": False, "code": "NOT_READY", "message": "规划器未就绪"}
|
||||
try:
|
||||
plan = await ctx.planner.plan(message)
|
||||
except PlannerError as exc:
|
||||
fallback = [s for s in ("identify", "rnaseq_qc") if s in allowed_stages and ctx.catalog.has(s)]
|
||||
plan = PlanResult(candidates=[PlanCandidate(purpose="内置默认流程", stages=fallback)],
|
||||
source="llm_only", message=exc.message)
|
||||
candidates = []
|
||||
for c in plan.candidates:
|
||||
stages = [s for s in c.stages if s in allowed_stages]
|
||||
if stages:
|
||||
candidates.append({"purpose": c.purpose, "stages": stages})
|
||||
return {
|
||||
"ok": True,
|
||||
"candidates": candidates,
|
||||
"source": plan.source,
|
||||
"reuse_archive": plan.reuse_archive_id,
|
||||
"reuse_name": plan.reuse_name,
|
||||
"fallback": plan.message or None,
|
||||
}
|
||||
|
||||
|
||||
async def _create_job(session_id: str, title: str, stages: list[str],
|
||||
params: dict[str, Any] | None, auto_continue: bool,
|
||||
archive_id: str | None, allowed_stages: set[str]) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
unknown = [s for s in stages if s not in allowed_stages]
|
||||
if unknown:
|
||||
return {"ok": False, "code": "CROSS_CHAIN",
|
||||
"message": f"阶段不属于该能力单元: {', '.join(unknown)}"}
|
||||
try:
|
||||
record = await ctx.runner.create_job(
|
||||
session_id, title or "生信任务", stages, params or {},
|
||||
auto_continue=auto_continue, archive_id=archive_id,
|
||||
)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
ctx.records[record.id] = record
|
||||
return {"ok": True, "data": _job_detail(record)}
|
||||
|
||||
|
||||
async def _job_status(job_id: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
detail = _job_detail(record)
|
||||
detail["pending_actions"] = derive_pending_actions(record, ctx.runner)
|
||||
return {"ok": True, "data": detail}
|
||||
|
||||
|
||||
async def _confirm_stage(job_id: str, stage: str, params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
if stage not in record.stage_chain:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": f"阶段不存在: {stage}"}
|
||||
try:
|
||||
await ctx.runner.confirm_stage(record, stage, params or {})
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "stage": stage,
|
||||
"status": ctx.runner.stage_status(record, stage)}}
|
||||
|
||||
|
||||
async def _rerun_stage(job_id: str, stage: str, params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
if stage not in record.stage_chain:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": f"阶段不存在: {stage}"}
|
||||
try:
|
||||
await ctx.runner.rerun_stage(record, stage, params)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "stage": stage,
|
||||
"status": ctx.runner.stage_status(record, stage)}}
|
||||
|
||||
|
||||
async def _skip_stage(job_id: str, stage: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
if stage not in record.stage_chain:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": f"阶段不存在: {stage}"}
|
||||
try:
|
||||
await ctx.runner.skip_stage(record, stage)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "stage": stage,
|
||||
"status": ctx.runner.stage_status(record, stage)}}
|
||||
|
||||
|
||||
async def _pause_job(job_id: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
try:
|
||||
await ctx.runner.pause_job(record)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "status": record.status}}
|
||||
|
||||
|
||||
async def _resume_job(job_id: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
try:
|
||||
await ctx.runner.resume_job(record)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "status": record.status}}
|
||||
|
||||
|
||||
async def _archive_job(job_id: str, name: str, description: str,
|
||||
include_intermediate: bool) -> dict[str, Any]:
|
||||
"""任务存档(薄适配):委托 cmd_archive(唯一事实源),仅保留内存 record 校验 + 事件发布。"""
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 扩展能力单元(第三个能力单元 bioflow-extension):复用 extensions.py 状态机
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EXT_VIEW_KEYS = (
|
||||
"ext_id", "display_name", "status", "command_template", "original_command",
|
||||
"runtime_image", "requirements", "parameters", "input_roles", "nl_triggers",
|
||||
"analysis_type", "golden", "repair_count", "trial", "validation", "last_build",
|
||||
"created_at", "updated_at",
|
||||
)
|
||||
|
||||
|
||||
def _ext_view(ext: dict[str, Any]) -> dict[str, Any]:
|
||||
"""扩展精简视图(剔除 source_files 等超长字段,供 MCP 工具结果返回)。"""
|
||||
return {k: ext.get(k) for k in _EXT_VIEW_KEYS if k in ext}
|
||||
|
||||
|
||||
def _file_items(files: Any) -> list[tuple[str, bytes]]:
|
||||
"""把 MCP 入参 [{path, content|data}] 转为 [(rel_path, bytes)]。"""
|
||||
out: list[tuple[str, bytes]] = []
|
||||
if not isinstance(files, list):
|
||||
return out
|
||||
for f in files:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
path = str(f.get("path") or f.get("name") or "").strip()
|
||||
if not path:
|
||||
continue
|
||||
content = f.get("content")
|
||||
if content is None:
|
||||
content = f.get("data") or ""
|
||||
if isinstance(content, (bytes, bytearray)):
|
||||
blob = bytes(content)
|
||||
else:
|
||||
blob = str(content).encode("utf-8")
|
||||
out.append((path, blob))
|
||||
return out
|
||||
|
||||
|
||||
def _ext_err(exc: Exception) -> str:
|
||||
if isinstance(exc, ext_mod.ExtensionNotFoundError):
|
||||
return "NOT_FOUND"
|
||||
if isinstance(exc, ext_mod.ExtensionStatusError):
|
||||
return "EXT_STATUS"
|
||||
if isinstance(exc, ext_mod.ExtensionBuildError):
|
||||
return "EXT_BUILD"
|
||||
if isinstance(exc, ext_mod.ExtensionRunError):
|
||||
return "EXT_RUN"
|
||||
return "EXT_ERROR"
|
||||
|
||||
|
||||
async def _create_extension(display_name: str, command: str, runtime_image: str,
|
||||
code_files: Any, data_files: Any) -> dict[str, Any]:
|
||||
code = _file_items(code_files)
|
||||
if not code:
|
||||
return {"ok": False, "code": "VALIDATION", "message": "扩展代码包不能为空"}
|
||||
try:
|
||||
ext = ext_mod.get_store().create_extension(
|
||||
display_name=display_name, command=command, runtime_image=runtime_image or "",
|
||||
code_files=code, data_files=_file_items(data_files),
|
||||
)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _extension_status(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = ext_mod.get_store().load_extension(ext_id)
|
||||
except ext_mod.ExtensionNotFoundError:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "扩展不存在"}
|
||||
return {"ok": True, "data": _ext_view(ext) if ext else None}
|
||||
|
||||
|
||||
async def _build_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = await ext_mod.get_store().build_extension(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _trial_run_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = await ext_mod.get_store().trial_run(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _validate_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = ext_mod.get_store().validate(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _confirm_extension(ext_id: str, nl_triggers: Any, parameters: Any,
|
||||
runtime_image: str) -> dict[str, Any]:
|
||||
triggers = [str(x).strip() for x in nl_triggers if str(x).strip()] if isinstance(nl_triggers, list) else None
|
||||
params = parameters if isinstance(parameters, list) else None
|
||||
try:
|
||||
ext = ext_mod.get_store().confirm(
|
||||
ext_id, nl_triggers=triggers, parameters=params,
|
||||
runtime_image=runtime_image or None,
|
||||
)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _publish_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = ext_mod.get_store().publish(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _run_extension(ext_id: str, params: Any, files: Any) -> dict[str, Any]:
|
||||
try:
|
||||
result = await ext_mod.get_store().run(
|
||||
ext_id,
|
||||
params=params if isinstance(params, dict) else None,
|
||||
files=[str(f) for f in files] if isinstance(files, list) else None,
|
||||
)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": result}
|
||||
|
||||
|
||||
async def _repair_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = await ext_mod.get_store().repair(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
# ---- 文件登记(复刻 biojobs._register_path 核心,无 FastAPI 依赖)----
|
||||
|
||||
def _blocked_reason(path: Path, settings: Any) -> str | None:
|
||||
s = str(path)
|
||||
for prefix in settings.sensitive_path_prefixes:
|
||||
if s == prefix or s.startswith(prefix + "/"):
|
||||
return f"敏感路径前缀 {prefix}"
|
||||
for pat in settings.sensitive_filename_patterns:
|
||||
if re.match(pat, path.name):
|
||||
return f"敏感文件名 {path.name}"
|
||||
return None
|
||||
|
||||
|
||||
async def _register_job_files(job_id: str, paths: list[str]) -> dict[str, Any]:
|
||||
"""登记服务器/局域网路径(单文件或目录展开)到任务 input。"""
|
||||
ctx = _ctx()
|
||||
settings = get_settings()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return _mcp_err("NOT_FOUND", "任务不存在")
|
||||
|
||||
input_dir = record.input_dir
|
||||
input_dir.mkdir(parents=True, exist_ok=True)
|
||||
items: list[dict[str, Any]] = []
|
||||
registered: list[str] = []
|
||||
|
||||
for raw in paths:
|
||||
p = Path(raw).expanduser().resolve()
|
||||
if not p.exists():
|
||||
return _mcp_err("PATH_NOT_EXIST", f"路径不存在: {raw}")
|
||||
blocked = _blocked_reason(p, settings)
|
||||
if blocked:
|
||||
return _mcp_err("PATH_NOT_ALLOWED", f"路径不允许: {raw}({blocked})")
|
||||
files: list[Path] = []
|
||||
if p.is_file():
|
||||
files = [p]
|
||||
else:
|
||||
ext_set = settings.upload_ext_set
|
||||
for f in sorted(p.rglob("*")):
|
||||
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 _mcp_err("VALIDATION", f"目录内无可登记文件: {raw}")
|
||||
for f in files:
|
||||
link = input_dir / f.name
|
||||
try:
|
||||
if not link.exists():
|
||||
link.symlink_to(f)
|
||||
except OSError as exc: # noqa: BLE001
|
||||
logger.warning("mcp register symlink 失败: %s -> %s (%s)", f, link, exc)
|
||||
items.append({"file_id": f"{record.id}:{f.name}", "name": f.name,
|
||||
"size": f.stat().st_size, "path": str(f), "source": "server_path"})
|
||||
registered.append(str(f))
|
||||
|
||||
# rnaseq 系:samples.yaml 生成(描述文件优先 + 实况分析;规则不可归纳走 LLM 兜底)
|
||||
try:
|
||||
source_roots = [str(Path(x).parent) for x in paths if Path(x).exists()]
|
||||
extra_mounts = ensure_samples_yaml(input_dir, source_roots=[Path(r) for r in source_roots])
|
||||
if not extra_mounts:
|
||||
try:
|
||||
await llm_sample_fallback(input_dir, [Path(r) for r in source_roots])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("samples.yaml LLM 兜底失败: %s", exc)
|
||||
if extra_mounts:
|
||||
record.registered_server_paths = list(
|
||||
dict.fromkeys((getattr(record, "registered_server_paths", None) or [])
|
||||
+ [str(Path(m)) for m in extra_mounts]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("samples.yaml 分析失败: %s", exc)
|
||||
|
||||
if registered:
|
||||
import json
|
||||
record.registered_server_paths = list(
|
||||
dict.fromkeys((getattr(record, "registered_server_paths", None) or []) + registered)
|
||||
)
|
||||
(record.job_dir / "server_registered.json").write_text(
|
||||
json.dumps({"registered_server_paths": record.registered_server_paths},
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# 自动续跑(await_inputs 任务重推;非等待态 no-op)
|
||||
await ctx.runner.retry_after_files(record)
|
||||
return {"ok": True, "data": {"items": items, "total": len(items)}}
|
||||
|
||||
|
||||
async def _ask_question(question: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
hits: list[dict[str, Any]] = []
|
||||
if ctx.flow_store is not None:
|
||||
try:
|
||||
hits = ctx.flow_store.search(question, top_k=8).get("items") or []
|
||||
except Exception: # noqa: BLE001
|
||||
hits = []
|
||||
citations = []
|
||||
for h in hits:
|
||||
citations.append({
|
||||
"source": h.get("source") or h.get("doc_kind") or "",
|
||||
"title_path": h.get("title_path") or "",
|
||||
"path": h.get("path") or "",
|
||||
"text": str(h.get("text") or "")[:1200],
|
||||
})
|
||||
return {"ok": True, "citations": citations, "degraded": not citations}
|
||||
|
||||
|
||||
async def _list_archives(page: int, page_size: int) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
items, total = await store.list_archives(ctx.db, page=page, page_size=page_size)
|
||||
return {"ok": True, "items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
async def _reuse_archive(name: str) -> dict[str, Any]:
|
||||
"""复用存档(薄适配):委托 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]:
|
||||
"""拉取源文件(薄适配):委托 cmd_source_fetch,仅保留内存 record 校验。"""
|
||||
if job_id:
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
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]:
|
||||
"""探测文件(薄适配):委托 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]:
|
||||
"""转换文件(薄适配):委托 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)
|
||||
|
||||
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]:
|
||||
import yaml
|
||||
|
||||
from .services.bioflow.llm import complete_json
|
||||
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
system = (
|
||||
"你是 RNA-seq 样本配置助手。根据用户的自然语言描述,输出 samples.yaml 结构 JSON:"
|
||||
'{"samples": {"样本名": {"r1": "fastq路径", "r2": "fastq路径", "condition": "...", ...}}}。'
|
||||
"只输出 JSON。"
|
||||
)
|
||||
try:
|
||||
raw = await complete_json(system, sample_description, temperature=0.2, max_tokens=2000)
|
||||
samples = raw.get("samples") if isinstance(raw, dict) else None
|
||||
if not isinstance(samples, dict) or not samples:
|
||||
return {"ok": False, "code": "EMPTY_SAMPLES", "message": "未能从描述解析出样本"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "code": "SAMPLES_GEN_FAILED", "message": f"样本配置生成失败: {exc}"}
|
||||
record.input_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = record.input_dir / "samples.yaml"
|
||||
out.write_text(yaml.safe_dump({"samples": samples}, allow_unicode=True), encoding="utf-8")
|
||||
v = validate_format(out, "samples.yaml")
|
||||
if not v.get("ok"):
|
||||
out.unlink(missing_ok=True)
|
||||
return {"ok": False, "code": "VALIDATION_FAILED", "message": f"样本配置校验失败: {v.get('reason')}"}
|
||||
return {"ok": True, "data": {"path": str(out), "samples": samples, "validated": v}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 能力单元工具(命名 async,带类型提示 + 中文描述)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def plan_gene_family(message: str) -> dict[str, Any]:
|
||||
"""根据用户自然语言需求规划基因家族分析候选路径(仅基因家族链阶段)。"""
|
||||
return await _plan(message, GENE_FAMILY_STAGES)
|
||||
|
||||
|
||||
async def create_gene_family_job(session_id: str, stages: list[str], title: str = "",
|
||||
params: dict[str, Any] | None = None,
|
||||
auto_continue: bool = False,
|
||||
archive_id: str | None = None) -> dict[str, Any]:
|
||||
"""按确认的阶段链创建基因家族任务(stages 只能是基因家族链阶段,跨链拒绝)。"""
|
||||
return await _create_job(session_id, title, stages, params, auto_continue, archive_id, GENE_FAMILY_STAGES)
|
||||
|
||||
|
||||
async def ask_gene_family_question(question: str) -> dict[str, Any]:
|
||||
"""回答基因家族流程/参数/生物学意义类问题(流程知识库检索,带引用)。"""
|
||||
return await _ask_question(question)
|
||||
|
||||
|
||||
async def plan_rnaseq(message: str) -> dict[str, Any]:
|
||||
"""根据用户自然语言需求规划转录组分析候选路径(仅转录组链阶段)。"""
|
||||
return await _plan(message, RNASEQ_STAGES)
|
||||
|
||||
|
||||
async def create_rnaseq_job(session_id: str, stages: list[str], title: str = "",
|
||||
params: dict[str, Any] | None = None,
|
||||
auto_continue: bool = False,
|
||||
archive_id: str | None = None) -> dict[str, Any]:
|
||||
"""按确认的阶段链创建转录组任务(stages 只能是转录组链阶段,跨链拒绝)。"""
|
||||
return await _create_job(session_id, title, stages, params, auto_continue, archive_id, RNASEQ_STAGES)
|
||||
|
||||
|
||||
async def generate_samples_yaml(job_id: str, sample_description: str) -> dict[str, Any]:
|
||||
"""把用户的自然语言样本描述(样本名/对照处理组/重复/单双端等)解析为 samples.yaml 写入任务 input。"""
|
||||
return await _generate_samples_yaml(job_id, sample_description)
|
||||
|
||||
|
||||
async def ask_rnaseq_question(question: str) -> dict[str, Any]:
|
||||
"""回答转录组流程/参数/实验设计类问题(流程知识库检索,带引用)。"""
|
||||
return await _ask_question(question)
|
||||
|
||||
|
||||
async def job_status(job_id: str) -> dict[str, Any]:
|
||||
"""查询生信任务状态:任务/各阶段状态 + 下一步待办动作(pending_actions)。卡住或需确认时用本工具查真相。"""
|
||||
return await _job_status(job_id)
|
||||
|
||||
|
||||
async def confirm_stage(job_id: str, stage: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""确认某阶段参数并执行。仅 waiting_confirm 状态可确认;参数名须在白名单内(未知参数被拒)。人工确认边界阶段(subfamily_confirm/rnaseq_de)不可代用户确认。"""
|
||||
return await _confirm_stage(job_id, stage, params)
|
||||
|
||||
|
||||
async def register_job_files(job_id: str, paths: list[str]) -> dict[str, Any]:
|
||||
"""把服务器/局域网路径(单文件或目录)登记为任务输入文件。目录按扩展名白名单展开。"""
|
||||
return await _register_job_files(job_id, paths)
|
||||
|
||||
|
||||
async def rerun_stage(job_id: str, stage: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""重跑失败阶段(带参数则先等确认)。仅 failed 状态可重跑。"""
|
||||
return await _rerun_stage(job_id, stage, params)
|
||||
|
||||
|
||||
async def skip_stage(job_id: str, stage: str) -> dict[str, Any]:
|
||||
"""跳过失败阶段并推进后续。仅 failed 状态可跳过。"""
|
||||
return await _skip_stage(job_id, stage)
|
||||
|
||||
|
||||
async def pause_job(job_id: str) -> dict[str, Any]:
|
||||
"""暂停运行中的任务(running 阶段会立即 kill 容器)。"""
|
||||
return await _pause_job(job_id)
|
||||
|
||||
|
||||
async def resume_job(job_id: str) -> dict[str, Any]:
|
||||
"""恢复已暂停的任务(从下一阶段继续,确认点不跳过)。"""
|
||||
return await _resume_job(job_id)
|
||||
|
||||
|
||||
async def list_archives(page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
"""列出历史存档任务(供复用参考)。"""
|
||||
return await _list_archives(page, page_size)
|
||||
|
||||
|
||||
async def reuse_archive(name: str) -> dict[str, Any]:
|
||||
"""按名称匹配存档,返回其阶段链+参数(供复用发起)。"""
|
||||
return await _reuse_archive(name)
|
||||
|
||||
|
||||
async def archive_job(job_id: str, name: str, description: str = "",
|
||||
include_intermediate: bool = False) -> dict[str, Any]:
|
||||
"""把已完成任务存档(打包结果/日志/事件 + 落档登记);重名拒绝(ARCHIVE_EXISTS)。"""
|
||||
return await _archive_job(job_id, name, description, include_intermediate)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 扩展能力单元工具(第三个能力单元 bioflow-extension)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def create_extension(display_name: str, command: str, runtime_image: str = "",
|
||||
code_files: list[dict[str, Any]] | None = None,
|
||||
data_files: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
"""创建扩展草稿(draft):上传代码文件清单(每项 {path, content 文本}) + 运行命令 + 显示名。"""
|
||||
return await _create_extension(display_name, command, runtime_image, code_files, data_files)
|
||||
|
||||
|
||||
async def extension_status(ext_id: str) -> dict[str, Any]:
|
||||
"""查询扩展详情与状态(draft/build/trial/validated/confirming/published)。"""
|
||||
return await _extension_status(ext_id)
|
||||
|
||||
|
||||
async def build_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""AI 转换扩展(原始命令→command_template+参数+dependencies);LLM 失败自动回退规则版。"""
|
||||
return await _build_extension(ext_id)
|
||||
|
||||
|
||||
async def trial_run_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""试运行扩展(docker 执行;未接入执行器时返回 EXT_RUN 503 语义)。"""
|
||||
return await _trial_run_extension(ext_id)
|
||||
|
||||
|
||||
async def validate_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""校验扩展(金标/试运行校验)。仅 built/trial_passed 可校验。"""
|
||||
return await _validate_extension(ext_id)
|
||||
|
||||
|
||||
async def confirm_extension(ext_id: str, nl_triggers: list[str] | None = None,
|
||||
parameters: list[dict[str, Any]] | None = None,
|
||||
runtime_image: str = "") -> dict[str, Any]:
|
||||
"""确认扩展参数/触发词(用户可编辑 nl_triggers);仅 validated/trial_passed 可确认。"""
|
||||
return await _confirm_extension(ext_id, nl_triggers, parameters, runtime_image)
|
||||
|
||||
|
||||
async def publish_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""发布扩展(仅 published 状态可被自然语言触发运行)。"""
|
||||
return await _publish_extension(ext_id)
|
||||
|
||||
|
||||
async def run_extension(ext_id: str, params: dict[str, Any] | None = None,
|
||||
files: list[str] | None = None) -> dict[str, Any]:
|
||||
"""正式运行已发布扩展(同步返回 run_result)。"""
|
||||
return await _run_extension(ext_id, params, files)
|
||||
|
||||
|
||||
async def repair_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""AI 修复扩展(试运行失败上下文→LLM 修复 command_template/依赖;LLM 失败回退规则版)。"""
|
||||
return await _repair_extension(ext_id)
|
||||
|
||||
|
||||
async def fetch_source_file(source: str, job_id: str | None = None,
|
||||
credentials: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""从 web(http/https 流式)、OSS(oss:// 或预签名 URL)或局域网路径拉取源文件到任务 input;下载前检查磁盘余量并记录 provenance。"""
|
||||
return await _fetch_source_file(job_id, source, credentials)
|
||||
|
||||
|
||||
async def inspect_file(path: str) -> dict[str, Any]:
|
||||
"""探测文件格式(扩展名仅参考):返回预览/列结构/大小/格式猜测/乱码检测,供判断是否需要预处理。"""
|
||||
return await _inspect_file(path)
|
||||
|
||||
|
||||
async def transform_file(src_path: str, target_format: str,
|
||||
column_map: dict[str, str] | None = None,
|
||||
dest_path: str | None = None) -> dict[str, Any]:
|
||||
"""把非标准文件(xlsx/csv/tsv/txt)转换为 fasta/gff3/tsv/samples.yaml;LLM 定列映射规则+脚本执行,结果过格式验证器。"""
|
||||
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(基因家族 / 转录组 / 扩展)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_mcp_servers() -> tuple[Any, Any, Any]:
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
gf = MCPServer(
|
||||
"bioflow-gene-family",
|
||||
title="PlantAI 基因家族分析",
|
||||
description="基因家族分析能力单元:路径规划/建任务/确认参数/查状态/文件获取与预处理。",
|
||||
)
|
||||
rn = MCPServer(
|
||||
"bioflow-rnaseq",
|
||||
title="PlantAI 转录组分析",
|
||||
description="转录组分析能力单元:路径规划/样本配置生成/建任务/确认参数/查状态/文件获取与预处理。",
|
||||
)
|
||||
ext = MCPServer(
|
||||
"bioflow-extension",
|
||||
title="PlantAI 用户自定义扩展分析",
|
||||
description="用户自定义扩展分析能力单元:创建/转换/试运行/校验/确认/发布/运行。",
|
||||
)
|
||||
|
||||
gf.tool(name="plan_gene_family", description="根据用户自然语言需求规划基因家族分析候选路径(仅基因家族链阶段)。返回 2-3 条候选路径供用户确认。")(plan_gene_family)
|
||||
gf.tool(name="create_gene_family_job", description="按确认的阶段链创建基因家族任务。stages 只能是基因家族链阶段(跨链拒绝)。")(create_gene_family_job)
|
||||
gf.tool(name="ask_gene_family_question", description="回答基因家族流程/参数/生物学意义类问题(流程知识库检索,带引用)。")(ask_gene_family_question)
|
||||
|
||||
rn.tool(name="plan_rnaseq", description="根据用户自然语言需求规划转录组分析候选路径(仅转录组链阶段)。返回候选路径供用户确认。")(plan_rnaseq)
|
||||
rn.tool(name="create_rnaseq_job", description="按确认的阶段链创建转录组任务。stages 只能是转录组链阶段(跨链拒绝)。")(create_rnaseq_job)
|
||||
rn.tool(name="generate_samples_yaml", description="把用户的自然语言样本描述解析为 samples.yaml 写入任务 input。")(generate_samples_yaml)
|
||||
rn.tool(name="ask_rnaseq_question", description="回答转录组流程/参数/实验设计类问题(流程知识库检索,带引用)。")(ask_rnaseq_question)
|
||||
|
||||
ext.tool(name="create_extension", description="创建扩展草稿(draft):上传代码文件清单(每项 {path, content 文本}) + 运行命令 + 显示名。")(create_extension)
|
||||
ext.tool(name="extension_status", description="查询扩展详情与状态(draft/build/trial/validated/confirming/published)。")(extension_status)
|
||||
ext.tool(name="build_extension", description="AI 转换扩展(原始命令→command_template+参数+依赖);LLM 失败自动回退规则版。")(build_extension)
|
||||
ext.tool(name="trial_run_extension", description="试运行扩展(docker 执行;未接入执行器时返回 EXT_RUN)。")(trial_run_extension)
|
||||
ext.tool(name="validate_extension", description="校验扩展(金标/试运行校验)。仅 built/trial_passed 可校验。")(validate_extension)
|
||||
ext.tool(name="confirm_extension", description="确认扩展参数/触发词(用户可编辑 nl_triggers);仅 validated/trial_passed 可确认。")(confirm_extension)
|
||||
ext.tool(name="publish_extension", description="发布扩展(仅 published 状态可被自然语言触发运行)。")(publish_extension)
|
||||
ext.tool(name="run_extension", description="正式运行已发布扩展(同步返回 run_result)。")(run_extension)
|
||||
ext.tool(name="repair_extension", description="AI 修复扩展(试运行失败上下文→LLM 修复;LLM 失败回退规则版)。")(repair_extension)
|
||||
|
||||
shared = [
|
||||
job_status, confirm_stage, register_job_files, rerun_stage, skip_stage,
|
||||
pause_job, resume_job, list_archives, reuse_archive, archive_job,
|
||||
fetch_source_file, inspect_file, transform_file,
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
gene_family_mcp, rnaseq_mcp, extension_mcp = build_mcp_servers()
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,438 @@
|
||||
"""app/services/bioflow/file_transform.py —— 非标准文件预处理(探测 + 转换 + 校验)。
|
||||
|
||||
FR-BIO-014 文件医生闭环:
|
||||
- inspect_file:内容嗅探(扩展名仅参考),返回结构化摘要(预览行/列结构/大小/
|
||||
格式猜测/乱码检测/与目标阶段期望格式的差异)。
|
||||
- transform_file:LLM 抽取列映射/清洗规则 + 规则脚本执行,把 xlsx/csv/tsv/txt 转换
|
||||
为 fasta/gff3/tsv/samples.yaml;转换结果过格式验证器硬校验,失败返回原因供追问
|
||||
循环(≤3 次由上层 QA 控制)。
|
||||
|
||||
服务层纪律:纯 dataclass/dict/stdlib + yaml/openpyxl,无 pydantic。
|
||||
LLM 可注入(默认走 .llm.complete_json),测试注入 fake。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PREVIEW_LINES = 10
|
||||
PREVIEW_BYTES = 4000
|
||||
|
||||
# 支持的目标格式
|
||||
TARGET_FORMATS = ("fasta", "gff3", "tsv", "samples.yaml")
|
||||
|
||||
# 乱码检测:非 ASCII 可打印且非 CJK 的异常字节比例阈值
|
||||
GARBLED_RATIO = 0.30
|
||||
|
||||
|
||||
class TransformError(Exception):
|
||||
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 探测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _guess_format(name: str, head: bytes) -> str:
|
||||
lower = name.lower()
|
||||
if lower.endswith((".fa", ".fasta", ".faa", ".fna", ".pep", ".cds")):
|
||||
return "fasta"
|
||||
if lower.endswith((".gff", ".gff3")):
|
||||
return "gff3"
|
||||
if lower.endswith((".gtf",)):
|
||||
return "gtf"
|
||||
if lower.endswith((".vcf", ".vcf.gz")):
|
||||
return "vcf"
|
||||
if lower.endswith((".tsv",)):
|
||||
return "tsv"
|
||||
if lower.endswith((".csv",)):
|
||||
return "csv"
|
||||
if lower.endswith((".xlsx", ".xls")):
|
||||
return "xlsx"
|
||||
if lower.endswith((".yaml", ".yml")):
|
||||
return "yaml"
|
||||
# 内容嗅探兜底
|
||||
text = head.decode("utf-8", errors="replace")
|
||||
if text.lstrip().startswith(">"):
|
||||
return "fasta"
|
||||
if "\t" in text and len(text.splitlines()[0].split("\t")) >= 3:
|
||||
return "tsv"
|
||||
return "txt"
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
raw = path.read_bytes()
|
||||
for enc in ("utf-8", "gb18030", "latin-1"):
|
||||
try:
|
||||
return raw.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _garbled_ratio(text: str) -> float:
|
||||
"""异常字符比例:控制字符 + 替换符 + 私有区(不含常规空白)。"""
|
||||
if not text:
|
||||
return 0.0
|
||||
bad = 0
|
||||
for ch in text:
|
||||
o = ord(ch)
|
||||
if o == 0xFFFD or (o < 32 and ch not in "\t\n\r") or (0xE000 <= o <= 0xF8FF):
|
||||
bad += 1
|
||||
return bad / len(text)
|
||||
|
||||
|
||||
def _preview(text: str) -> list[str]:
|
||||
return text.splitlines()[:PREVIEW_LINES]
|
||||
|
||||
|
||||
def _columns(path: Path, fmt: str) -> list[str]:
|
||||
"""表格列名提取(tsv/csv/xlsx)。"""
|
||||
try:
|
||||
if fmt == "xlsx":
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
ws = wb.active
|
||||
row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
||||
return [str(c or "").strip() for c in (row or [])]
|
||||
finally:
|
||||
wb.close()
|
||||
if fmt in ("tsv", "csv"):
|
||||
text = _read_text(path)
|
||||
reader = csv.reader(io.StringIO(text), delimiter="\t" if fmt == "tsv" else ",")
|
||||
first = next(reader, None)
|
||||
return [str(c or "").strip() for c in (first or [])]
|
||||
except Exception as exc: # noqa: BLE001 - 列提取失败降级空
|
||||
logger.warning("file_transform: 列提取失败 %s: %s", path, exc)
|
||||
return []
|
||||
|
||||
|
||||
def inspect_file(path: str | Path, *, preview_lines: int = PREVIEW_LINES) -> dict[str, Any]:
|
||||
"""内容嗅探摘要(扩展名仅参考;失败返回 ok=False + 原因,不抛)。"""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": f"文件不存在: {path}"}
|
||||
try:
|
||||
size = p.stat().st_size
|
||||
head = p.read_bytes()[:PREVIEW_BYTES]
|
||||
fmt = _guess_format(p.name, head)
|
||||
text = _read_text(p) if size <= 4 * 1024 * 1024 else _read_text_preview(p)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(p),
|
||||
"name": p.name,
|
||||
"ext": p.suffix.lower() or "",
|
||||
"size": size,
|
||||
"format": fmt,
|
||||
"preview": _preview(text)[:preview_lines],
|
||||
"columns": _columns(p, fmt),
|
||||
"garbled": _garbled_ratio(text),
|
||||
"line_count": text.count("\n") + 1 if text else 0,
|
||||
}
|
||||
except OSError as exc:
|
||||
return {"ok": False, "code": "READ_ERROR", "message": f"文件读取失败: {exc}"}
|
||||
|
||||
|
||||
def _read_text_preview(path: Path) -> str:
|
||||
"""大文件只读头部做预览/乱码检测,避免整读内存。"""
|
||||
with path.open("rb") as fh:
|
||||
head = fh.read(PREVIEW_BYTES)
|
||||
for enc in ("utf-8", "gb18030", "latin-1"):
|
||||
try:
|
||||
return head.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return head.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 校验器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def validate_fasta(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
text = _read_text(p)
|
||||
records = re.findall(r"^>.*$", text, flags=re.M)
|
||||
if not records:
|
||||
return {"ok": False, "reason": "无 FASTA 记录(缺 '>' 头行)"}
|
||||
# 序列非空:头行之间至少存在非空白序列行
|
||||
body = re.sub(r"^>.*$", "", text, flags=re.M)
|
||||
if not body.strip():
|
||||
return {"ok": False, "reason": "FASTA 记录缺少序列"}
|
||||
return {"ok": True, "records": len(records)}
|
||||
|
||||
|
||||
def validate_gff3(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
bad = 0
|
||||
total = 0
|
||||
for line in _read_text(p).splitlines():
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
total += 1
|
||||
cols = line.split("\t")
|
||||
if len(cols) != 9:
|
||||
bad += 1
|
||||
continue
|
||||
try:
|
||||
int(cols[3])
|
||||
int(cols[4])
|
||||
except ValueError:
|
||||
bad += 1
|
||||
continue
|
||||
if cols[6] not in ("+", "-", "."):
|
||||
bad += 1
|
||||
if total == 0:
|
||||
return {"ok": False, "reason": "无 GFF3 数据行"}
|
||||
if bad:
|
||||
return {"ok": False, "reason": f"{bad}/{total} 行列数或字段非法"}
|
||||
return {"ok": True, "records": total}
|
||||
|
||||
|
||||
def validate_tsv(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
widths: set[int] = set()
|
||||
for line in _read_text(p).splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
widths.add(len(line.split("\t")))
|
||||
if len(widths) > 1:
|
||||
return {"ok": False, "reason": f"列数不一致: {sorted(widths)}"}
|
||||
if not widths:
|
||||
return {"ok": False, "reason": "空文件"}
|
||||
return {"ok": True, "columns": widths.pop()}
|
||||
|
||||
|
||||
def validate_samples_yaml(path: str | Path) -> dict[str, Any]:
|
||||
import yaml
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
try:
|
||||
raw = yaml.safe_load(_read_text(p))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "reason": f"YAML 解析失败: {exc}"}
|
||||
samples = (raw or {}).get("samples") if isinstance(raw, dict) else None
|
||||
if not isinstance(samples, dict) or not samples:
|
||||
return {"ok": False, "reason": "缺 samples 映射或为空"}
|
||||
for name, entry in samples.items():
|
||||
if not isinstance(entry, dict) or not (entry.get("r1") or entry.get("r2")):
|
||||
return {"ok": False, "reason": f"样本 {name} 缺 r1/r2"}
|
||||
return {"ok": True, "samples": len(samples)}
|
||||
|
||||
|
||||
_VALIDATORS = {
|
||||
"fasta": validate_fasta,
|
||||
"gff3": validate_gff3,
|
||||
"tsv": validate_tsv,
|
||||
"samples.yaml": validate_samples_yaml,
|
||||
}
|
||||
|
||||
|
||||
def validate_format(path: str | Path, target_format: str) -> dict[str, Any]:
|
||||
fn = _VALIDATORS.get(target_format)
|
||||
if fn is None:
|
||||
return {"ok": False, "reason": f"不支持的目标格式: {target_format}"}
|
||||
return fn(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 转换
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LLMFn = Callable[[str, str], Awaitable[Any]]
|
||||
|
||||
|
||||
def _read_table(path: Path) -> tuple[list[str], list[dict[str, str]]]:
|
||||
"""读表格(tsv/csv/xlsx)→ (表头, 行 dict 列表)。"""
|
||||
fmt = _guess_format(path.name, path.read_bytes()[:64])
|
||||
if fmt == "xlsx":
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
ws = wb.active
|
||||
rows = [list(r) for r in ws.iter_rows(values_only=True)]
|
||||
finally:
|
||||
wb.close()
|
||||
if not rows:
|
||||
return [], []
|
||||
headers = [str(c or "").strip() for c in rows[0]]
|
||||
return headers, [dict(zip(headers, ["" if v is None else str(v).strip() for v in r])) for r in rows[1:]]
|
||||
text = _read_text(path)
|
||||
delimiter = "\t" if fmt == "tsv" else ","
|
||||
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
return [], []
|
||||
headers = [str(c or "").strip() for c in rows[0]]
|
||||
return headers, [dict(zip(headers, ["" if v is None else str(v).strip() for v in r])) for r in rows[1:]]
|
||||
|
||||
|
||||
def _default_column_map(headers: list[str], target: str) -> dict[str, str]:
|
||||
"""按表头名启发式推断列映射(名称/序列/基因名等)。"""
|
||||
m: dict[str, str] = {}
|
||||
for h in headers:
|
||||
lh = h.lower()
|
||||
if target == "fasta":
|
||||
if not m.get("seq") and any(k in lh for k in ("seq", "sequence", "序列", "蛋白", "pep", "cds")):
|
||||
m["seq"] = h
|
||||
elif not m.get("name") and any(k in lh for k in ("name", "id", "gene", "名称", "基因", "基因名", "accession")):
|
||||
m["name"] = h
|
||||
elif target == "samples.yaml":
|
||||
if "r1" in lh or "read1" in lh or "forward" in lh:
|
||||
m.setdefault("r1", h)
|
||||
elif "r2" in lh or "read2" in lh or "reverse" in lh:
|
||||
m.setdefault("r2", h)
|
||||
elif "run" in lh or "sample" in lh or "样本" in lh or "name" in lh:
|
||||
m.setdefault("name", h)
|
||||
return m
|
||||
|
||||
|
||||
async def _llm_column_map(headers: list[str], target: str, llm: LLMFn, sample_rows: list[dict[str, str]]) -> dict[str, str]:
|
||||
"""LLM 抽取列映射(启发式不充分时)。返回 {目标字段: 列名}。"""
|
||||
import json as _json
|
||||
prompt = (
|
||||
f"你是生信数据整理助手。下表头 {_json.dumps(headers, ensure_ascii=False)} "
|
||||
f"需转换为 {target} 格式。样本行:{_json.dumps(sample_rows[:3], ensure_ascii=False)[:2000]}。"
|
||||
"请输出列映射 JSON,键为目标字段名,值为表头列名。"
|
||||
)
|
||||
try:
|
||||
result = await llm(prompt, "只输出 JSON 对象,如 {\"name\":\"基因名\",\"seq\":\"序列\"}。")
|
||||
if isinstance(result, dict):
|
||||
return {str(k): str(v) for k, v in result.items() if str(v) in headers}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("file_transform: LLM 列映射失败 %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_fasta(rows: list[dict[str, str]], name_col: str, seq_col: str, dest: Path) -> int:
|
||||
n = 0
|
||||
with dest.open("w", encoding="utf-8") as fh:
|
||||
for r in rows:
|
||||
name = r.get(name_col) or f"seq{n + 1}"
|
||||
seq = (r.get(seq_col) or "").replace("\n", "").replace("\r", "")
|
||||
if not seq:
|
||||
continue
|
||||
fh.write(f">{name}\n{seq}\n")
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _write_tsv(rows: list[dict[str, str]], cols: list[str], dest: Path) -> int:
|
||||
n = 0
|
||||
with dest.open("w", encoding="utf-8", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t")
|
||||
w.writerow(cols)
|
||||
for r in rows:
|
||||
w.writerow([r.get(c, "") for c in cols])
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _write_samples_yaml(rows: list[dict[str, str]], name_col: str, r1_col: str, r2_col: str, dest: Path) -> int:
|
||||
import yaml
|
||||
samples: dict[str, dict[str, str]] = {}
|
||||
for r in rows:
|
||||
name = r.get(name_col) or ""
|
||||
r1 = r.get(r1_col) or ""
|
||||
r2 = r.get(r2_col) or ""
|
||||
if not name and not r1 and not r2:
|
||||
continue
|
||||
entry: dict[str, str] = {}
|
||||
if r1:
|
||||
entry["r1"] = r1
|
||||
if r2:
|
||||
entry["r2"] = r2
|
||||
# 额外列作为 meta(run/condition/group 等)
|
||||
for k, v in r.items():
|
||||
if v and k not in (name_col, r1_col, r2_col):
|
||||
entry[k] = v
|
||||
samples[name or Path(r1).stem] = entry
|
||||
dest.write_text(yaml.safe_dump({"samples": samples}, allow_unicode=True), encoding="utf-8")
|
||||
return len(samples)
|
||||
|
||||
|
||||
async def transform_file(
|
||||
src_path: str | Path,
|
||||
target_format: str,
|
||||
*,
|
||||
column_map: dict[str, str] | None = None,
|
||||
dest_path: str | Path | None = None,
|
||||
llm: LLMFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""非标准文件转换(LLM 列映射兜底 + 规则执行 + 格式校验)。
|
||||
|
||||
column_map:用户/QA 确认的 {目标字段: 源列名}(可部分),缺列经启发式/LLM 补齐。
|
||||
返回 ok=True + path/size/validated/records,或 ok=False + code/message(供追问)。
|
||||
"""
|
||||
src = Path(src_path)
|
||||
if not src.is_file():
|
||||
raise TransformError("NOT_FOUND", f"源文件不存在: {src_path}")
|
||||
if target_format not in TARGET_FORMATS:
|
||||
raise TransformError("BAD_FORMAT", f"不支持的目标格式: {target_format}")
|
||||
|
||||
dest = Path(dest_path) if dest_path else src.with_suffix("").with_name(
|
||||
f"{src.stem}.{target_format.replace('.', '_')}"
|
||||
)
|
||||
|
||||
headers, rows = _read_table(src)
|
||||
if not headers:
|
||||
raise TransformError("EMPTY_TABLE", "表格为空或无表头,无法转换")
|
||||
cm = dict(column_map or {})
|
||||
cm = {**cm, **_default_column_map(headers, target_format)}
|
||||
|
||||
if target_format == "fasta":
|
||||
if "seq" not in cm or "name" not in cm:
|
||||
if llm is not None:
|
||||
inferred = await _llm_column_map(headers, target_format, llm, rows)
|
||||
cm = {**cm, **inferred}
|
||||
if "seq" not in cm or "name" not in cm:
|
||||
raise TransformError("MISSING_COLUMNS", "无法确定序列列/名称列,请确认列映射")
|
||||
n = _write_fasta(rows, cm["name"], cm["seq"], dest)
|
||||
elif target_format == "tsv":
|
||||
cols = [cm.get("name")] if cm.get("name") else []
|
||||
cols = [c for c in (cols + [h for h in headers if h not in cols]) if c]
|
||||
n = _write_tsv(rows, cols, dest)
|
||||
elif target_format == "samples.yaml":
|
||||
if "r1" not in cm and "r2" not in cm:
|
||||
if llm is not None:
|
||||
inferred = await _llm_column_map(headers, target_format, llm, rows)
|
||||
cm = {**cm, **inferred}
|
||||
if "r1" not in cm and "r2" not in cm:
|
||||
raise TransformError("MISSING_COLUMNS", "无法确定 fastq 路径列(r1/r2),请确认列映射")
|
||||
n = _write_samples_yaml(rows, cm.get("name", ""), cm.get("r1", ""), cm.get("r2", ""), dest)
|
||||
else: # gff3:表格式 GFF 需严格 9 列映射,LLM 定规则后由上层脚本执行
|
||||
raise TransformError("UNSUPPORTED", f"{target_format} 需 9 列映射,请经 LLM 定规则后转换")
|
||||
|
||||
validated = validate_format(dest, target_format)
|
||||
if not validated.get("ok"):
|
||||
# 校验失败:删除产物,返回原因供追问
|
||||
dest.unlink(missing_ok=True)
|
||||
raise TransformError("VALIDATION_FAILED", f"转换结果校验失败: {validated.get('reason')}", validated)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(dest),
|
||||
"name": dest.name,
|
||||
"size": dest.stat().st_size,
|
||||
"format": target_format,
|
||||
"records": n,
|
||||
"validated": validated,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,271 @@
|
||||
"""app/services/bioflow/source_fetcher.py —— 源文件拉取(web/OSS/局域网)+ provenance。
|
||||
|
||||
三类来源(FR-BIO-013 网络源扩展):
|
||||
- web:http/https。httpx 流式下载,不设大小上限,超时 300s,重定向跟随。
|
||||
- OSS:`oss://bucket/key` 或预签名 URL。无 OSS SDK 依赖:`oss://` 经可配 endpoint
|
||||
(BIOFLOW_OSS_ENDPOINT)转 http 下载;预签名 URL 直接走 http 通道。
|
||||
- 局域网:本机已挂载/可达路径直读(不复制,登记原路径);或 `[user@]host:/abs/path`
|
||||
经 scp 拉取(密钥认证优先;密码需本机有 sshpass)。凭据仅单次内存使用,不落盘。
|
||||
|
||||
- 下载前检查磁盘剩余空间(Content-Length 已知时硬校验;否则保底阈值 512MB)。
|
||||
- 每次拉取记录 provenance(来源/哈希/时间/kind)到返回值并落 sidecar .provenance.json,
|
||||
不记明文密码。
|
||||
服务层纪律:纯 dataclass/dict/stdlib + httpx,无 pydantic。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_SCHEMES = ("http", "https")
|
||||
DEFAULT_TIMEOUT = 300.0
|
||||
MIN_FREE_FALLBACK = 512 * 1024 * 1024 # 未知大小时保底磁盘余量检查(512MB)
|
||||
CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
class FetchError(Exception):
|
||||
"""源文件拉取错误(带错误码;MCP 工具层转自然语言提示)。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for block in iter(lambda: fh.read(CHUNK), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""URL 解码后取文件名(去路径分隔符),空则占位。"""
|
||||
name = urllib.parse.unquote(name or "")
|
||||
base = Path(name).name or "download"
|
||||
return base
|
||||
|
||||
|
||||
def _name_from_url(url: str) -> str:
|
||||
return _safe_name(urllib.parse.urlparse(url).path.rsplit("/", 1)[-1])
|
||||
|
||||
|
||||
def check_disk_space(dest_dir: str | Path, needed: int) -> tuple[bool, int]:
|
||||
"""检查目标目录所在盘剩余空间是否 ≥ needed 字节。返回 (够, 剩余字节)。"""
|
||||
dest_dir = Path(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
free = shutil.disk_usage(dest_dir).free
|
||||
return (free >= needed, free)
|
||||
|
||||
|
||||
def _write_provenance(dest: Path, source: str, kind: str, sha256: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""落 provenance sidecar({dest}.provenance.json),返回 provenance dict。"""
|
||||
prov: dict[str, Any] = {
|
||||
"source": source,
|
||||
"kind": kind,
|
||||
"sha256": sha256,
|
||||
"fetched_at": _now_iso(),
|
||||
}
|
||||
if extra:
|
||||
prov.update(extra)
|
||||
sidecar = dest.parent / f"{dest.name}.provenance.json"
|
||||
try:
|
||||
sidecar.write_text(json.dumps(prov, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except OSError: # noqa: BLE001 - provenance 落盘失败不阻断拉取
|
||||
logger.warning("source_fetcher: provenance 落盘失败 %s", sidecar)
|
||||
return prov
|
||||
|
||||
|
||||
# ---- web ----
|
||||
|
||||
async def fetch_web(url: str, dest_dir: str | Path, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||
"""http/https 流式下载(不设大小上限;Content-Length 已知时下载前查磁盘余量)。"""
|
||||
scheme = urllib.parse.urlparse(url).scheme.lower()
|
||||
if scheme not in ALLOWED_SCHEMES:
|
||||
raise FetchError("BAD_SCHEME", f"仅支持 http/https 协议: {url}")
|
||||
dest_dir = Path(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = _name_from_url(url)
|
||||
dest = dest_dir / name
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
try:
|
||||
async with client.stream("GET", url, timeout=httpx.Timeout(connect=30.0, read=timeout, write=60.0, pool=10.0)) as resp:
|
||||
if resp.status_code >= 400:
|
||||
raise FetchError("HTTP_ERROR", f"下载失败 HTTP {resp.status_code}: {url}")
|
||||
total = int(resp.headers.get("content-length") or 0)
|
||||
if total:
|
||||
ok, free = check_disk_space(dest_dir, total)
|
||||
if not ok:
|
||||
raise FetchError("DISK_FULL", f"磁盘空间不足:需 {total} 字节,剩余 {free} 字节")
|
||||
written = 0
|
||||
with dest.open("wb") as fh:
|
||||
async for chunk in resp.aiter_bytes(CHUNK):
|
||||
fh.write(chunk)
|
||||
written += len(chunk)
|
||||
if total and written != total:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise FetchError("TRUNCATED", f"下载不完整:期望 {total} 字节,实收 {written} 字节")
|
||||
except FetchError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise FetchError("NETWORK_ERROR", f"网络错误: {exc}") from exc
|
||||
|
||||
size = dest.stat().st_size
|
||||
sha = _sha256(dest)
|
||||
prov = _write_provenance(dest, url, "web", sha)
|
||||
return {"ok": True, "path": str(dest), "name": name, "size": size, "kind": "web", "provenance": prov}
|
||||
|
||||
|
||||
# ---- OSS ----
|
||||
|
||||
def _resolve_oss_url(spec: str) -> str:
|
||||
"""oss://bucket/key → http 下载 URL(public-read 或预签名;无 SDK,需 endpoint 配置)。
|
||||
|
||||
环境键:BIOFLOW_OSS_ENDPOINT(如 oss-cn-hangzhou.aliyuncs.com,或内网 endpoint)。
|
||||
预签名 URL(含 aliyuncs.com 的 http/https)原样返回,走 web 通道。
|
||||
"""
|
||||
if not spec.startswith("oss://"):
|
||||
return spec # 已是 http/https 预签名 URL
|
||||
rest = spec[len("oss://"):]
|
||||
bucket, _, key = rest.partition("/")
|
||||
endpoint = (os.environ.get("BIOFLOW_OSS_ENDPOINT") or "").strip().rstrip("/")
|
||||
if not bucket or not key:
|
||||
raise FetchError("BAD_OSS", f"OSS 地址格式非法(应为 oss://bucket/key): {spec}")
|
||||
if not endpoint:
|
||||
raise FetchError("OSS_UNCONFIGURED", "未配置 BIOFLOW_OSS_ENDPOINT,无法解析 oss:// 地址;请提供预签名 URL")
|
||||
# 内网/公网 endpoint 统一拼 http(https 由 endpoint 决定;公网 OSS 建议 https)
|
||||
scheme = "https" if "aliyuncs.com" in endpoint else "http"
|
||||
return f"{scheme}://{bucket}.{endpoint}/{key}"
|
||||
|
||||
|
||||
async def fetch_oss(spec: str, dest_dir: str | Path, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||
url = _resolve_oss_url(spec)
|
||||
result = await fetch_web(url, dest_dir, timeout=timeout)
|
||||
result["kind"] = "oss"
|
||||
# 重写 provenance kind(sidecar 已由 fetch_web 写为 web;此处修正)
|
||||
if result.get("provenance"):
|
||||
result["provenance"]["kind"] = "oss"
|
||||
result["provenance"]["oss_spec"] = spec
|
||||
try:
|
||||
sidecar = Path(result["path"]).parent / f"{Path(result['path']).name}.provenance.json"
|
||||
sidecar.write_text(json.dumps(result["provenance"], ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# ---- 局域网 ----
|
||||
|
||||
def _parse_lan_remote(spec: str) -> tuple[str, str] | None:
|
||||
"""解析 `[user@]host:/abs/path` 远端形态;非远端返回 None。"""
|
||||
if ":" not in spec:
|
||||
return None
|
||||
# 排除协议(http:// / oss:// 含 ://,已在上层分流)
|
||||
if "://" in spec:
|
||||
return None
|
||||
host_part, _, path_part = spec.partition(":")
|
||||
if not path_part.startswith("/"):
|
||||
return None
|
||||
return host_part, path_part
|
||||
|
||||
|
||||
async def _scp_fetch(host_part: str, remote_path: str, dest_dir: Path, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""scp 拉取远端文件(密钥认证;密码需本机 sshpass)。"""
|
||||
creds = credentials or {}
|
||||
user = str(creds.get("user") or "").strip()
|
||||
password = str(creds.get("password") or "").strip()
|
||||
port = str(creds.get("port") or "22").strip()
|
||||
target = f"{user}@{host_part}" if user else host_part
|
||||
name = _safe_name(remote_path.rsplit("/", 1)[-1])
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd: list[str] = []
|
||||
if password:
|
||||
if shutil.which("sshpass") is None:
|
||||
raise FetchError("SSHPASS_MISSING", "本机未安装 sshpass,无法使用密码拉取;请改用密钥认证或提供已挂载路径")
|
||||
cmd += ["sshpass", "-p", password]
|
||||
cmd += ["scp", "-P", port, "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=30",
|
||||
f"{target}:{remote_path}", str(dest_dir / name)]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
_out, err = await asyncio.wait_for(proc.communicate(), timeout=DEFAULT_TIMEOUT)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise FetchError("LAN_TIMEOUT", f"局域网拉取超时: {target}:{remote_path}") from exc
|
||||
except OSError as exc:
|
||||
raise FetchError("LAN_ERROR", f"局域网拉取失败: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise FetchError("LAN_ERROR", f"scp 失败: {(err or b'').decode(errors='replace')[:500]}")
|
||||
dest = dest_dir / name
|
||||
if not dest.is_file():
|
||||
raise FetchError("LAN_ERROR", f"scp 未产出文件: {dest}")
|
||||
size = dest.stat().st_size
|
||||
sha = _sha256(dest)
|
||||
prov = _write_provenance(dest, f"{target}:{remote_path}", "lan", sha)
|
||||
return {"ok": True, "path": str(dest), "name": name, "size": size, "kind": "lan", "provenance": prov}
|
||||
|
||||
|
||||
async def fetch_lan(spec: str, dest_dir: str | Path, *, credentials: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""局域网路径:本机已挂载/可达直读(返回原路径,不复制);远端走 scp。"""
|
||||
dest_dir = Path(dest_dir)
|
||||
remote = _parse_lan_remote(spec)
|
||||
if remote is not None:
|
||||
return await _scp_fetch(remote[0], remote[1], dest_dir, credentials)
|
||||
# 本机路径:直读(存在即成功;不复制,登记语义由 register_job_files 处理)
|
||||
p = Path(spec).expanduser()
|
||||
if not p.exists():
|
||||
raise FetchError("PATH_NOT_EXIST", f"局域网路径不存在或不可达: {spec}")
|
||||
if p.is_dir():
|
||||
raise FetchError("IS_DIR", f"路径是目录(仅支持单文件拉取): {spec}")
|
||||
sha = _sha256(p)
|
||||
prov = _write_provenance(p, str(p), "lan", sha)
|
||||
return {"ok": True, "path": str(p), "name": p.name, "size": p.stat().st_size, "kind": "lan", "provenance": prov}
|
||||
|
||||
|
||||
# ---- 总入口 ----
|
||||
|
||||
async def fetch_source(
|
||||
source: str,
|
||||
dest_dir: str | Path,
|
||||
*,
|
||||
credentials: dict[str, Any] | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict[str, Any]:
|
||||
"""按来源形态分流拉取(web/OSS/局域网/本机路径)。
|
||||
|
||||
credentials(可选,仅局域网 scp 用):{"user": ..., "password": ..., "port": ...}。
|
||||
凭据不落盘、不进入 provenance。
|
||||
"""
|
||||
source = (source or "").strip()
|
||||
if not source:
|
||||
raise FetchError("EMPTY_SOURCE", "源地址为空")
|
||||
lower = source.lower()
|
||||
# 协议白名单:含 "://" 的 URL 只允许 http/https/oss(其余如 ftp/file/sftp 拒绝)
|
||||
if "://" in source and not lower.startswith(("http://", "https://", "oss://")):
|
||||
raise FetchError("BAD_SCHEME", f"不支持的协议(仅 http/https/oss): {source}")
|
||||
if lower.startswith(("http://", "https://")):
|
||||
if lower.startswith("oss://") or "aliyuncs.com" in lower:
|
||||
return await fetch_oss(source, dest_dir, timeout=timeout)
|
||||
return await fetch_web(source, dest_dir, timeout=timeout)
|
||||
if lower.startswith("oss://"):
|
||||
return await fetch_oss(source, dest_dir, timeout=timeout)
|
||||
return await fetch_lan(source, dest_dir, credentials=credentials)
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
"""bioflowctl —— 生信业务逻辑 CLI(唯一事实源)。
|
||||
|
||||
无状态、幂等、直连 SQLite + 文件系统。后续 MCP 薄适配与 Skill 接入建立在本 CLI 之上。
|
||||
|
||||
用法:`python -m bioflowctl <子命令> ...`(cwd = bioflow_service/)。
|
||||
"""
|
||||
@@ -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())
|
||||
@@ -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}}
|
||||
Executable
+20
@@ -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
|
||||
@@ -0,0 +1,403 @@
|
||||
"""tests/test_file_transform.py —— 非标准文件预处理(探测 + 转换 + 校验)。
|
||||
|
||||
分层标记 -m bioflow_unit。覆盖(S0 验收):
|
||||
- inspect_file:fasta/tsv 探测、乱码检测、列结构提取。
|
||||
- transform_file:xlsx→fasta(显式列映射)、csv→fasta(启发式列映射)、
|
||||
samples.yaml 转换 + 校验。
|
||||
- 校验失败 → TransformError VALIDATION_FAILED(追问循环依据)。
|
||||
- 缺列映射且无 LLM → MISSING_COLUMNS。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.file_transform import (
|
||||
TransformError,
|
||||
_garbled_ratio,
|
||||
inspect_file,
|
||||
transform_file,
|
||||
validate_fasta,
|
||||
validate_format,
|
||||
validate_gff3,
|
||||
validate_samples_yaml,
|
||||
validate_tsv,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inspect_file 探测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_inspect_file_fasta(tmp_path):
|
||||
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_inspect_file_tsv_columns(tmp_path):
|
||||
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_inspect_file_garbled(tmp_path):
|
||||
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
|
||||
|
||||
|
||||
def test_garbled_ratio_clean():
|
||||
assert _garbled_ratio("ATCGATCG\n>seq\n") < 0.01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transform_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_transform_xlsx_to_fasta(tmp_path):
|
||||
import openpyxl
|
||||
src = tmp_path / "spl_list.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["基因名", "序列", "备注"])
|
||||
ws.append(["SPL1", "ATGGCTA", "水稻"])
|
||||
ws.append(["SPL2", "ATGGCTB", "水稻"])
|
||||
wb.save(src)
|
||||
|
||||
r = _run(transform_file(src, "fasta", column_map={"name": "基因名", "seq": "序列"}))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
assert r["validated"]["ok"] is True
|
||||
text = Path(r["path"]).read_text()
|
||||
assert ">SPL1" in text and "ATGGCTA" in text
|
||||
|
||||
|
||||
def test_transform_csv_to_fasta_heuristic(tmp_path):
|
||||
src = tmp_path / "genes.csv"
|
||||
src.write_text("gene_id,protein_sequence\nG1,MKTA\nG2,MKTB\n")
|
||||
r = _run(transform_file(src, "fasta"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
|
||||
|
||||
def test_transform_csv_to_samples_yaml(tmp_path):
|
||||
src = tmp_path / "design.csv"
|
||||
src.write_text("sample,fastq_1,fastq_2,condition\nA,a_1.fq,a_2.fq,ctrl\nB,b_1.fq,b_2.fq,treated\n")
|
||||
r = _run(transform_file(src, "samples.yaml", column_map={"name": "sample", "r1": "fastq_1", "r2": "fastq_2"}))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
assert r["validated"]["ok"] is True
|
||||
import yaml
|
||||
samples = yaml.safe_load(Path(r["path"]).read_text())["samples"]
|
||||
assert "A" in samples and samples["A"]["r1"] == "a_1.fq"
|
||||
|
||||
|
||||
def test_transform_validation_failed(tmp_path):
|
||||
# seq 列全空 → 产物无序列 → 校验失败
|
||||
src = tmp_path / "bad.csv"
|
||||
src.write_text("name,seq\nG1,\nG2,\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta", column_map={"name": "name", "seq": "seq"}))
|
||||
assert ei.value.code == "VALIDATION_FAILED"
|
||||
|
||||
|
||||
def test_transform_missing_columns(tmp_path):
|
||||
# 无列映射、无 LLM、表头无启发式命中 → MISSING_COLUMNS
|
||||
src = tmp_path / "unknown.csv"
|
||||
src.write_text("col_a,col_b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta"))
|
||||
assert ei.value.code == "MISSING_COLUMNS"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 校验器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_validate_fasta(tmp_path):
|
||||
f = tmp_path / "ok.fa"
|
||||
f.write_text(">a\nACGT\n")
|
||||
assert validate_fasta(f)["ok"] is True
|
||||
|
||||
|
||||
def test_validate_fasta_empty(tmp_path):
|
||||
f = tmp_path / "bad.fa"
|
||||
f.write_text("no records here\n")
|
||||
assert validate_fasta(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_tsv_inconsistent(tmp_path):
|
||||
f = tmp_path / "bad.tsv"
|
||||
f.write_text("a\tb\tc\n1\t2\n")
|
||||
assert validate_tsv(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_samples_yaml(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples:\n A:\n r1: a.fq\n r2: a2.fq\n")
|
||||
assert validate_samples_yaml(f)["ok"] is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(1)inspect_file:NOT_FOUND / xlsx 列 / csv 格式 / 大文件 / 内容嗅探
|
||||
# ===========================================================================
|
||||
|
||||
def test_inspect_file_not_found():
|
||||
r = inspect_file("/no/such/file.fa")
|
||||
assert r["ok"] is False
|
||||
assert r["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_inspect_file_xlsx_columns(tmp_path):
|
||||
import openpyxl
|
||||
f = tmp_path / "spl.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["基因名", "序列"])
|
||||
ws.append(["SPL1", "MKTA"])
|
||||
wb.save(f)
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "xlsx"
|
||||
assert r["columns"] == ["基因名", "序列"]
|
||||
|
||||
|
||||
def test_inspect_file_csv_format(tmp_path):
|
||||
f = tmp_path / "genes.csv"
|
||||
f.write_text("id,seq\nG1,MKTA\n")
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "csv"
|
||||
assert r["columns"] == ["id", "seq"]
|
||||
|
||||
|
||||
def test_inspect_file_large_uses_preview(tmp_path):
|
||||
f = tmp_path / "big.fa"
|
||||
f.write_bytes(b">seq\nACGT\n" + b"A" * (4 * 1024 * 1024))
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "fasta"
|
||||
|
||||
|
||||
def test_guess_format_content_sniffing_fasta(tmp_path):
|
||||
from app.services.bioflow.file_transform import _guess_format
|
||||
assert _guess_format("unknown.dat", b">seq1\nACGT\n") == "fasta"
|
||||
|
||||
|
||||
def test_guess_format_content_sniffing_tsv(tmp_path):
|
||||
from app.services.bioflow.file_transform import _guess_format
|
||||
assert _guess_format("unknown.dat", b"a\tb\tc\n1\t2\t3\n") == "tsv"
|
||||
|
||||
|
||||
def test_garbled_ratio_empty():
|
||||
assert _garbled_ratio("") == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(2)transform_file:NOT_FOUND / BAD_FORMAT / EMPTY_TABLE / tsv / gff3 /
|
||||
# samples.yaml 启发式 / LLM 兜底 / LLM 异常兜底
|
||||
# ===========================================================================
|
||||
|
||||
def test_transform_not_found(tmp_path):
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(tmp_path / "nope.csv", "fasta"))
|
||||
assert ei.value.code == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_transform_bad_format(tmp_path):
|
||||
src = tmp_path / "x.csv"
|
||||
src.write_text("a,b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "bogus"))
|
||||
assert ei.value.code == "BAD_FORMAT"
|
||||
|
||||
|
||||
def test_transform_empty_table(tmp_path):
|
||||
src = tmp_path / "empty.csv"
|
||||
src.write_text("")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta"))
|
||||
assert ei.value.code == "EMPTY_TABLE"
|
||||
|
||||
|
||||
def test_transform_to_tsv(tmp_path):
|
||||
src = tmp_path / "genes.csv"
|
||||
src.write_text("gene_id,protein_sequence\nG1,MKTA\nG2,MKTB\n")
|
||||
r = _run(transform_file(src, "tsv"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
assert r["validated"]["ok"] is True
|
||||
|
||||
|
||||
def test_transform_to_gff3_unsupported(tmp_path):
|
||||
src = tmp_path / "x.csv"
|
||||
src.write_text("a,b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "gff3"))
|
||||
assert ei.value.code == "UNSUPPORTED"
|
||||
|
||||
|
||||
def test_transform_samples_yaml_heuristic(tmp_path):
|
||||
src = tmp_path / "design.csv"
|
||||
src.write_text("name,r1,r2,condition\nA,a_1.fq,a_2.fq,ctrl\nB,b_1.fq,b_2.fq,trt\n")
|
||||
r = _run(transform_file(src, "samples.yaml"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
|
||||
|
||||
def test_transform_samples_yaml_missing_columns(tmp_path):
|
||||
src = tmp_path / "unknown.csv"
|
||||
src.write_text("col_a,col_b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "samples.yaml"))
|
||||
assert ei.value.code == "MISSING_COLUMNS"
|
||||
|
||||
|
||||
def test_transform_fasta_llm_column_map(tmp_path):
|
||||
src = tmp_path / "candidates.csv"
|
||||
src.write_text("col_a,col_b\nSPL1,ATGGCTA\nSPL2,ATGGCTB\n")
|
||||
|
||||
async def fake_llm(user, system):
|
||||
return {"name": "col_a", "seq": "col_b"}
|
||||
|
||||
r = _run(transform_file(src, "fasta", llm=fake_llm))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
|
||||
|
||||
def test_transform_fasta_llm_exception_falls_back_missing(tmp_path):
|
||||
src = tmp_path / "candidates.csv"
|
||||
src.write_text("col_a,col_b\nSPL1,ATGGCTA\n")
|
||||
|
||||
async def fake_llm(user, system):
|
||||
raise RuntimeError("llm down")
|
||||
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta", llm=fake_llm))
|
||||
assert ei.value.code == "MISSING_COLUMNS"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(3)校验器:gff3 各失败态 / samples.yaml 缺 r1r2 / YAML 解析失败 /
|
||||
# tsv 空文件 / validate_format 未知格式
|
||||
# ===========================================================================
|
||||
|
||||
def test_validate_gff3_ok(tmp_path):
|
||||
f = tmp_path / "ok.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\t1\t100\t.\t+\t.\tID=x\n")
|
||||
assert validate_gff3(f)["ok"] is True
|
||||
|
||||
|
||||
def test_validate_gff3_no_data(tmp_path):
|
||||
f = tmp_path / "empty.gff3"
|
||||
f.write_text("# just a comment\n")
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_gff3_bad_columns(tmp_path):
|
||||
f = tmp_path / "bad.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\t1\t100\n") # 5 列 ≠ 9
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_gff3_bad_strand(tmp_path):
|
||||
f = tmp_path / "bad.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\t1\t100\t.\tx\t.\tID=x\n") # 第 7 列非法
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_gff3_bad_int(tmp_path):
|
||||
f = tmp_path / "bad.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\tabc\t100\t.\t+\t.\tID=x\n") # 起止非整数
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_fasta_no_sequence(tmp_path):
|
||||
f = tmp_path / "bad.fa"
|
||||
f.write_text(">a\n")
|
||||
assert validate_fasta(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_samples_yaml_missing_r1r2(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples:\n A:\n condition: ctrl\n")
|
||||
assert validate_samples_yaml(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_samples_yaml_parse_fail(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples: [unclosed\n")
|
||||
assert validate_samples_yaml(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_tsv_empty(tmp_path):
|
||||
f = tmp_path / "empty.tsv"
|
||||
f.write_text("")
|
||||
assert validate_tsv(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_format_unknown(tmp_path):
|
||||
f = tmp_path / "x.txt"
|
||||
f.write_text("x")
|
||||
r = validate_format(f, "bogus")
|
||||
assert r["ok"] is False
|
||||
assert "不支持" in r["reason"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(4)校验器缺失文件 / 格式猜测扩展名 / samples 非 dict / 空行跳过
|
||||
# ===========================================================================
|
||||
|
||||
def test_validators_missing_file(tmp_path):
|
||||
missing = tmp_path / "nope"
|
||||
assert validate_fasta(missing)["ok"] is False
|
||||
assert validate_gff3(missing)["ok"] is False
|
||||
assert validate_tsv(missing)["ok"] is False
|
||||
assert validate_samples_yaml(missing)["ok"] is False
|
||||
|
||||
|
||||
def test_guess_format_all_extensions():
|
||||
from app.services.bioflow.file_transform import _guess_format
|
||||
assert _guess_format("a.gff3", b"") == "gff3"
|
||||
assert _guess_format("a.gff", b"") == "gff3"
|
||||
assert _guess_format("a.gtf", b"") == "gtf"
|
||||
assert _guess_format("a.vcf", b"") == "vcf"
|
||||
assert _guess_format("a.vcf.gz", b"") == "vcf"
|
||||
assert _guess_format("a.yaml", b"") == "yaml"
|
||||
assert _guess_format("a.yml", b"") == "yaml"
|
||||
assert _guess_format("a.xls", b"") == "xlsx"
|
||||
assert _guess_format("a.faa", b"") == "fasta"
|
||||
assert _guess_format("a.fna", b"") == "fasta"
|
||||
assert _guess_format("a.pep", b"") == "fasta"
|
||||
assert _guess_format("a.cds", b"") == "fasta"
|
||||
assert _guess_format("a.dat", b"no marker") == "txt"
|
||||
|
||||
|
||||
def test_validate_samples_yaml_not_dict(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples: just_a_string\n")
|
||||
assert validate_samples_yaml(f)["ok"] is False
|
||||
|
||||
|
||||
def test_transform_samples_yaml_skips_empty_rows(tmp_path):
|
||||
src = tmp_path / "d.csv"
|
||||
src.write_text("name,r1,r2\n,,\nA,a.fq,a2.fq\n")
|
||||
r = _run(transform_file(src, "samples.yaml"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 1
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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 # 释放后可被重新接管
|
||||
@@ -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
|
||||
@@ -0,0 +1,455 @@
|
||||
"""tests/test_source_fetcher.py —— 源文件拉取(web/OSS/局域网)+ 磁盘余量 + provenance。
|
||||
|
||||
分层标记 -m bioflow_unit。覆盖(S0 验收):
|
||||
- web http 流式下载(本地 http.server 实测 httpx 通道)+ provenance 落盘。
|
||||
- 协议白名单(ftp:// 拒绝 BAD_SCHEME)。
|
||||
- oss:// 无 endpoint 拒绝 / 预签名 URL 直通 web / endpoint 解析。
|
||||
- 局域网本机路径直读;远端 host:/path 解析。
|
||||
- 磁盘余量不足 DISK_FULL(check_disk_space 纯函数)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import http.server
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.source_fetcher import (
|
||||
FetchError,
|
||||
_parse_lan_remote,
|
||||
_resolve_oss_url,
|
||||
check_disk_space,
|
||||
fetch_lan,
|
||||
fetch_source,
|
||||
fetch_web,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地 http 服务器(测 httpx 通道,不依赖外网)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def http_url(tmp_path):
|
||||
content = b">seq1\nACGTACGT\n>seq2\nTGCATGCA\n"
|
||||
(tmp_path / "test.fa").write_bytes(content)
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(tmp_path))
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}/test.fa"
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_fetch_web_download_and_provenance(http_url, tmp_path):
|
||||
out = tmp_path / "out"
|
||||
r = _run(fetch_web(http_url, out))
|
||||
assert r["ok"] is True
|
||||
assert r["name"] == "test.fa"
|
||||
assert r["size"] > 0
|
||||
assert r["kind"] == "web"
|
||||
assert r["provenance"]["source"] == http_url
|
||||
assert len(r["provenance"]["sha256"]) == 64
|
||||
assert (out / "test.fa").read_text().startswith(">seq1")
|
||||
# sidecar provenance
|
||||
assert (out / "test.fa.provenance.json").is_file()
|
||||
|
||||
|
||||
def test_fetch_web_http_error(http_url, tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web(http_url + "/missing", tmp_path / "out"))
|
||||
assert ei.value.code == "HTTP_ERROR"
|
||||
|
||||
|
||||
def test_fetch_source_bad_scheme(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_source("ftp://host/x.fa", tmp_path))
|
||||
assert ei.value.code == "BAD_SCHEME"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OSS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_oss_url_no_endpoint_rejected():
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_resolve_oss_url("oss://bucket/key.fa")
|
||||
assert ei.value.code == "OSS_UNCONFIGURED"
|
||||
|
||||
|
||||
def test_oss_url_endpoint_resolve(monkeypatch):
|
||||
monkeypatch.setenv("BIOFLOW_OSS_ENDPOINT", "oss-cn-hangzhou.aliyuncs.com")
|
||||
url = _resolve_oss_url("oss://seq-bucket/data/x.fa")
|
||||
assert url == "https://seq-bucket.oss-cn-hangzhou.aliyuncs.com/data/x.fa"
|
||||
|
||||
|
||||
def test_oss_presigned_passthrough():
|
||||
url = _resolve_oss_url("https://bucket.oss-cn-hangzhou.aliyuncs.com/x.fa?sig=abc")
|
||||
assert url.startswith("https://")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 局域网 / 本机路径
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_lan_remote():
|
||||
assert _parse_lan_remote("192.168.1.5:/data/x.fa") == ("192.168.1.5", "/data/x.fa")
|
||||
assert _parse_lan_remote("xzq@192.168.1.5:/data/x.fa") == ("xzq@192.168.1.5", "/data/x.fa")
|
||||
assert _parse_lan_remote("/local/path/x.fa") is None
|
||||
assert _parse_lan_remote("http://x/y") is None
|
||||
|
||||
|
||||
def test_fetch_lan_local_path(tmp_path):
|
||||
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_fetch_lan_missing_path(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("/no/such/path.fa", tmp_path))
|
||||
assert ei.value.code == "PATH_NOT_EXIST"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 磁盘余量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_check_disk_space_insufficient(tmp_path):
|
||||
ok, free = check_disk_space(tmp_path, 10 ** 18) # 1EB,必不足
|
||||
assert ok is False
|
||||
assert free < 10 ** 18
|
||||
|
||||
|
||||
def test_check_disk_space_sufficient(tmp_path):
|
||||
ok, _free = check_disk_space(tmp_path, 1) # 1 字节
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(1)web:BAD_SCHEME / 磁盘满 DISK_FULL / 断点 TRUNCATED / NETWORK_ERROR
|
||||
# ===========================================================================
|
||||
|
||||
def test_fetch_web_bad_scheme(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web("ftp://host/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "BAD_SCHEME"
|
||||
|
||||
|
||||
def test_fetch_web_disk_full(http_url, tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
monkeypatch.setattr(sf, "check_disk_space", lambda dest_dir, needed: (False, 0))
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web(http_url, tmp_path / "out"))
|
||||
assert ei.value.code == "DISK_FULL"
|
||||
|
||||
|
||||
def test_fetch_web_truncated(tmp_path, monkeypatch):
|
||||
"""断点/不完整下载:aiter_bytes 实收字节 < Content-Length → TRUNCATED。
|
||||
|
||||
用 fake httpx 确定性触发(真实服务器下 httpx 会因短读抛 HTTPError)。
|
||||
"""
|
||||
import types as _types
|
||||
|
||||
import httpx as real_httpx
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
headers = {"content-length": "100"}
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def aiter_bytes(self, chunk_size):
|
||||
yield b"short"
|
||||
|
||||
class FakeStream:
|
||||
async def __aenter__(self):
|
||||
return FakeResp()
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
class FakeClient:
|
||||
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 FakeStream()
|
||||
|
||||
monkeypatch.setattr(sf, "httpx", _types.SimpleNamespace(
|
||||
AsyncClient=FakeClient, Timeout=real_httpx.Timeout, HTTPError=real_httpx.HTTPError,
|
||||
))
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web("http://x/y.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "TRUNCATED"
|
||||
|
||||
|
||||
def test_fetch_web_network_error(tmp_path, monkeypatch):
|
||||
"""网络错误:httpx.HTTPError → NETWORK_ERROR(fake 确定性触发)。"""
|
||||
import types as _types
|
||||
|
||||
import httpx as real_httpx
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
class FailingStream:
|
||||
async def __aenter__(self):
|
||||
raise real_httpx.HTTPError("connection reset")
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
class FailingClient:
|
||||
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 FailingStream()
|
||||
|
||||
monkeypatch.setattr(sf, "httpx", _types.SimpleNamespace(
|
||||
AsyncClient=FailingClient, Timeout=real_httpx.Timeout, HTTPError=real_httpx.HTTPError,
|
||||
))
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web("http://x/y.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "NETWORK_ERROR"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(2)OSS:BAD_OSS / endpoint 解析(内网 http)/ fetch_oss 走 web + kind 修正
|
||||
# ===========================================================================
|
||||
|
||||
def test_oss_url_bad_format():
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_resolve_oss_url("oss://onlybucket")
|
||||
assert ei.value.code == "BAD_OSS"
|
||||
|
||||
|
||||
def test_oss_url_intranet_endpoint_http(monkeypatch):
|
||||
monkeypatch.setenv("BIOFLOW_OSS_ENDPOINT", "oss-internal.example.com")
|
||||
url = _resolve_oss_url("oss://seq-bucket/data/x.fa")
|
||||
assert url == "http://seq-bucket.oss-internal.example.com/data/x.fa"
|
||||
|
||||
|
||||
def test_fetch_oss_marks_kind_and_provenance(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
from pathlib import Path as _Path
|
||||
|
||||
async def fake_web(url, dest_dir, timeout=None):
|
||||
dest = _Path(dest_dir) / "x.fa"
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(">x\nACGT\n")
|
||||
return {"ok": True, "path": str(dest), "name": "x.fa", "size": 4,
|
||||
"kind": "web", "provenance": {"source": url, "kind": "web",
|
||||
"sha256": "0" * 64, "fetched_at": "t"}}
|
||||
|
||||
monkeypatch.setattr(sf, "fetch_web", fake_web)
|
||||
monkeypatch.setenv("BIOFLOW_OSS_ENDPOINT", "oss-cn-hangzhou.aliyuncs.com")
|
||||
r = _run(sf.fetch_oss("oss://b/k.fa", tmp_path / "out"))
|
||||
assert r["ok"] is True
|
||||
assert r["kind"] == "oss"
|
||||
assert r["provenance"]["kind"] == "oss"
|
||||
assert r["provenance"]["oss_spec"] == "oss://b/k.fa"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(3)fetch_source:EMPTY_SOURCE / oss 路由 / aliyuncs 预签名路由
|
||||
# ===========================================================================
|
||||
|
||||
def test_fetch_source_empty(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_source("", tmp_path))
|
||||
assert ei.value.code == "EMPTY_SOURCE"
|
||||
|
||||
|
||||
def test_fetch_source_oss_routing(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
calls = []
|
||||
|
||||
async def fake_oss(spec, dest_dir, timeout=None):
|
||||
calls.append(spec)
|
||||
return {"ok": True, "kind": "oss"}
|
||||
|
||||
monkeypatch.setattr(sf, "fetch_oss", fake_oss)
|
||||
r = _run(sf.fetch_source("oss://bucket/k.fa", tmp_path))
|
||||
assert r["kind"] == "oss"
|
||||
assert calls == ["oss://bucket/k.fa"]
|
||||
|
||||
|
||||
def test_fetch_source_aliyuncs_presigned_routes_to_oss(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
calls = []
|
||||
|
||||
async def fake_oss(spec, dest_dir, timeout=None):
|
||||
calls.append(spec)
|
||||
return {"ok": True, "kind": "oss"}
|
||||
|
||||
monkeypatch.setattr(sf, "fetch_oss", fake_oss)
|
||||
presigned = "https://bucket.aliyuncs.com/x.fa?sig=abc"
|
||||
r = _run(sf.fetch_source(presigned, tmp_path))
|
||||
assert r["kind"] == "oss"
|
||||
assert calls == [presigned]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(4)局域网:目录 IS_DIR / scp 拉取 / sshpass 缺失 / scp 失败 / 解析边界
|
||||
# ===========================================================================
|
||||
|
||||
def test_fetch_lan_dir_rejected(tmp_path):
|
||||
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_parse_lan_remote_relative_rejected():
|
||||
assert _parse_lan_remote("host:relative/path") is None
|
||||
|
||||
|
||||
def test_fetch_lan_scp_success(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
from pathlib import Path as _Path
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
dest = _Path(args[-1])
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(">g\nACGT\n")
|
||||
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
r = _run(fetch_lan("xzq@192.168.1.5:/data/genome.fa", tmp_path / "out",
|
||||
credentials={"user": "xzq"}))
|
||||
assert r["ok"] is True
|
||||
assert r["kind"] == "lan"
|
||||
assert r["name"] == "genome.fa"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_sshpass_missing(tmp_path, monkeypatch):
|
||||
import shutil
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out", credentials={"password": "p"}))
|
||||
assert ei.value.code == "SSHPASS_MISSING"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_failed(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
class P:
|
||||
returncode = 1
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"scp: no such file")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "LAN_ERROR"
|
||||
|
||||
|
||||
def test_scp_fetch_remote_uses_port_and_user(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
from pathlib import Path as _Path
|
||||
captured = {}
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
captured["args"] = list(args)
|
||||
dest = _Path(args[-1])
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(">g\nACGT\n")
|
||||
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out",
|
||||
credentials={"user": "u", "port": "2222"}))
|
||||
# scp -P 2222 u@host:/data/x.fa <dest>
|
||||
assert captured["args"][1] == "-P"
|
||||
assert captured["args"][2] == "2222"
|
||||
assert captured["args"][-2] == "u@host:/data/x.fa"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_timeout(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
async def fake_wait_for(coro, timeout=None):
|
||||
coro.close() # 未 await 的 communicate 协程关闭,避免 never-awaited 警告
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
monkeypatch.setattr(asyncio, "wait_for", fake_wait_for)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "LAN_TIMEOUT"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_no_file(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
# returncode 0 但未产出目标文件
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "LAN_ERROR"
|
||||
Executable
+19
@@ -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"
|
||||
Executable
+91
@@ -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"
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/toggle_bioflow.sh —— 生信能力按需装卸(热,不重启 QA)+ bioflow_service 启停 + 注册状态自检。
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy/toggle_bioflow.sh <gene-family|rnaseq|extension|all> <on|off>
|
||||
#
|
||||
# 原理:
|
||||
# - 能力装卸经 BFF POST /api/capabilities/install(透传 QA install_manager,热加载生效不重启 QA)
|
||||
# - on = register(manifest 校验 + 原子写 capabilities.yaml + registry 差分刷新)
|
||||
# - off = unregister(按 name 移除能力段,其余能力不受影响)
|
||||
# - 自检 = GET /api/capabilities 的 installed 数组是否含对应能力
|
||||
# - bioflow_service 启停:全部能力关闭时停 8098;任一开启时启动 8098
|
||||
# - 卸载活跃任务:三表已持久化,服务恢复后 runner.recover 续跑;QA 侧按「生信服务未启动」降级话术
|
||||
set -uo pipefail
|
||||
|
||||
BFF="http://127.0.0.1:8090"
|
||||
BIOFLOW_SERVICE_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/bioflow_service"
|
||||
PY="/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python"
|
||||
BIOFLOW_PORT="${BIOFLOW_PORT:-8098}"
|
||||
|
||||
CAP="${1:-}"
|
||||
ACTION="${2:-}"
|
||||
|
||||
CAPS=(gene-family rnaseq extension)
|
||||
|
||||
usage() {
|
||||
echo "用法: $0 <gene-family|rnaseq|extension|all> <on|off>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# capability key -> registry 能力名
|
||||
cap_name() {
|
||||
case "$1" in
|
||||
gene-family) echo "bioflow-gene-family" ;;
|
||||
rnaseq) echo "bioflow-rnaseq" ;;
|
||||
extension) echo "bioflow-extension" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# capability key -> register manifest YAML(与 agent/qa/configs/capabilities.yaml 一致)
|
||||
manifest() {
|
||||
case "$1" in
|
||||
gene-family) cat <<'YAML'
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析(路径规划/建任务/参数确认/文件获取与预处理/状态查询),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 家族成员, 亚家族, 系统发育, 建树, 进化树, 保守基序, motif, 结构域, 基因结构, 共线性, Ka/Ks, 选择压力, 组合证据]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
YAML
|
||||
;;
|
||||
rnaseq) cat <<'YAML'
|
||||
capabilities:
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析(实验设计确认/样本配置/路径规划/建任务/差异表达/富集),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers: [转录组, RNA-seq, RNAseq, rnaseq, 差异表达, 表达定量, 富集, WGCNA, GSEA, 实验设计, fastq]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
YAML
|
||||
;;
|
||||
extension) cat <<'YAML'
|
||||
capabilities:
|
||||
- name: bioflow-extension
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 用户自定义扩展分析(创建/转换/试运行/校验/发布/运行),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers: [扩展, 拓展分析, 扩展分析, 自定义分析, 分析模板, 分析脚本, 分析工具]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/extension/
|
||||
prompt_pack:
|
||||
file: bioflow_extension_agent_rules.md
|
||||
YAML
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
|
||||
[ -n "$CAP" ] && [ -n "$ACTION" ] || usage
|
||||
[ "$ACTION" = "on" ] || [ "$ACTION" = "off" ] || usage
|
||||
if [ "$CAP" != "all" ]; then
|
||||
cap_name "$CAP" >/dev/null 2>&1 || usage
|
||||
fi
|
||||
|
||||
# 1. 逐能力 register/unregister(热加载不重启 QA)
|
||||
targets=("$CAP")
|
||||
[ "$CAP" = "all" ] && targets=("${CAPS[@]}")
|
||||
for key in "${targets[@]}"; do
|
||||
name="$(cap_name "$key")"
|
||||
if [ "$ACTION" = "on" ]; then
|
||||
body="$(manifest "$key")"
|
||||
resp=$(curl -s -m 30 -X POST "$BFF/api/capabilities/install" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(python3 -c 'import json,sys; print(json.dumps({"action":"register","manifest":sys.stdin.read()}))' <<<"$body")")
|
||||
else
|
||||
resp=$(curl -s -m 30 -X POST "$BFF/api/capabilities/install" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"action\":\"unregister\",\"name\":\"$name\"}")
|
||||
fi
|
||||
echo "[$ACTION] $name -> $(echo "$resp" | head -c 300)"
|
||||
done
|
||||
|
||||
# 2. 自检:GET /api/capabilities 的 installed 数组
|
||||
installed=$(curl -s -m 10 "$BFF/api/capabilities")
|
||||
echo "注册状态: $(echo "$installed" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i.get("name") for i in d.get("installed") or []])' 2>/dev/null || echo 'parse-fail')"
|
||||
|
||||
# 3. bioflow_service 按需启停:任一能力开启则启动,全部关闭则停止
|
||||
any_on=false
|
||||
for key in "${CAPS[@]}"; do
|
||||
name="$(cap_name "$key")"
|
||||
if echo "$installed" | grep -q "\"$name\""; then
|
||||
any_on=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$any_on" = true ]; then
|
||||
if ! lsof -ti tcp:$BIOFLOW_PORT -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "启动 bioflow_service :$BIOFLOW_PORT"
|
||||
( cd "$BIOFLOW_SERVICE_DIR" && setsid "$PY" -m uvicorn app.main:app --host 127.0.0.1 --port "$BIOFLOW_PORT" \
|
||||
> /tmp/plantai-bioflow.log 2>&1 < /dev/null & ) && disown -a
|
||||
sleep 2
|
||||
fi
|
||||
curl -s -m 8 "http://127.0.0.1:$BIOFLOW_PORT/api/bioflow/health" | head -c 150
|
||||
echo
|
||||
else
|
||||
echo "全部能力已卸载:停止 bioflow_service :$BIOFLOW_PORT"
|
||||
kill $(lsof -ti tcp:$BIOFLOW_PORT -sTCP:LISTEN) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "DONE"
|
||||
@@ -0,0 +1,177 @@
|
||||
# 生信流程 · 对话式手工测试手册(S6 更新版)
|
||||
|
||||
> 供用户逐项实测。生信模块已从「卡片式交互」改造为「自然语言对话式交互 + MCP 外挂」,
|
||||
> 本手册以**对话剧本**形式给出每个场景的:对话示例 → 系统应如何回应 → 通过判据。
|
||||
> 环境:内网 http://192.168.0.175:8090(BFF);生信服务 8098、QA 8091 已部署;
|
||||
> 三能力(基因家族/转录组/扩展)按 §0 装卸。
|
||||
|
||||
---
|
||||
|
||||
## 0. 前置条件
|
||||
|
||||
1. 浏览器打开 http://192.168.0.175:8090。
|
||||
2. **能力安装检查**:设置面板(SettingsPanel)应能看到三个生信开关(基因家族/转录组/扩展)。
|
||||
打开任意一个后,工作区出现「生信任务」「生信档案」TAB;全关则 TAB 消失(**TAB 显隐 = 能力注册状态**,非服务健康)。
|
||||
3. 服务器 docker 已有 8 个 bioflow 镜像;金标数据在 `bioflow_kernel/assets/examples/test_input/`(Ft 四件套 pep/cds/genome/gff3)。
|
||||
4. 熔断开关:`QA_V1_ENABLE_BIOFLOW_AGENT=1`(默认 0,关时生信句走普通 QA)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 多配置开关回归(S6)
|
||||
|
||||
| 配置 | 操作 | 期望 | 判据 |
|
||||
|---|---|---|---|
|
||||
| 全关(熔断=0 + 三能力卸载) | 发「帮我做基因家族分析」 | 普通 QA 礼貌回答(说明未安装生信能力),**无**路径卡 | 无生信分流 |
|
||||
| 只开基因家族 | 发「帮我做基因家族分析」 | 进入生信对话(列路径+挂路径卡) | 命中 |
|
||||
| 只开基因家族 | 发「帮我做转录组差异表达」 | 走普通 QA(转录组能力未装) | 不响应 |
|
||||
| 只开转录组 | 发「帮我做转录组」 | 进入生信对话 | 命中 |
|
||||
| 只开转录组 | 发「帮我做基因家族分析」 | 走普通 QA | 不响应 |
|
||||
| 全开但非生信提问 | 发「光合作用的电子传递链是什么」 | 走普通 QA(非生信意图不调工具) | 不调工具 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 对话剧本 A:基因家族分析(卡片辅助通道)
|
||||
|
||||
**第 1 轮 —— 需求与路径**
|
||||
> 用户:帮我做水稻 SPL 基因家族分析
|
||||
> 系统(自然语言):列「核心流程(必选)+ 可选分析」,说明每个可选项生物学意义;
|
||||
> **同帧挂出路径选择卡**。
|
||||
> 判据:回复含路径列表 + 路径卡出现(卡片不替代自然语言)。
|
||||
|
||||
**第 2 轮 —— 卡片操作 → 系统复述**
|
||||
> 用户(路径卡勾选「核心流程 + Ka/Ks + 启动子」,点确认;打字补充文件位置)
|
||||
> 系统:先自然语言**复述**用户选择("您选择了核心流程 + Ka/Ks + 启动子分析…"),再请确认。
|
||||
> 判据:系统复述内容与卡片勾选一致;建任务前必有一轮复述确认(不直接建任务)。
|
||||
|
||||
**第 3 轮 —— 网络文件 + 非标准文件预处理**
|
||||
> 用户:文件在 `https://…/rice.fa` 与 `oss://seq-bucket/rice.gff3`,另传 `spl_list.xlsx`
|
||||
> 系统:拉取 web/OSS 文件(**下载前检查磁盘余量、记录 provenance**);对 `spl_list.xlsx`
|
||||
> 做内容嗅探后**自然语言说明列结构并提问**("推测第二列是蛋白序列,备注列忽略?")。
|
||||
> 判据:文件获取成功;Excel 触发预处理提问(列表方式,可卡片点选也可打字回答)。
|
||||
|
||||
**第 4 轮 —— 建任务执行**
|
||||
> 用户:都对,备注列不要;确认,开始吧
|
||||
> 系统:复述整理结果("已整理为 FASTA 3 条序列")→ 创建任务 → 消息内出现**只读精简进度块**,
|
||||
> 右侧「生信任务」TAB 完整滚动日志实时跟随。
|
||||
> 判据:进度块只读(无按钮);TAB 日志自动滚底。
|
||||
|
||||
**执行中 —— 进度转述 + 随时提问**
|
||||
> 用户:什么是组合证据?
|
||||
> 系统:经流程知识库自然语言回答(带引用),**不打断任务**。
|
||||
> 判据:提问得到引用回答,任务继续推进。
|
||||
|
||||
**人工确认边界(不可跳过)**
|
||||
> 系统:第 8 步亚家族预测完成(6 个亚家族)。⚠ 需您人工确认,我不能代您确认。
|
||||
> 用户:把 SPL3 从亚家族 A 移到 B → 系统复述修改 → 用户「确认」。
|
||||
> 判据:subfamily_confirm 必停、必复述、绝不代确认。
|
||||
|
||||
**收尾 —— 存档**
|
||||
> 系统:全部完成(19 成员 6 亚家族),同帧挂出存档卡。
|
||||
> 用户(存档卡填名称点「存档」或打字「存档,叫 SPL 水稻鉴定」)
|
||||
> 系统:实际执行 `archive_job` 存档(复用 REST 语义),复述存档三要素(名称/位置/大小)。
|
||||
> 判据:存档成功(重名提示 ARCHIVE_EXISTS)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 对话剧本 B:转录组分析(纯自然语言通道,全程不点卡片)
|
||||
|
||||
**第 1 轮 —— 必确认信息**
|
||||
> 用户:帮我做转录组,比较干旱处理和对照
|
||||
> 系统:列主流程 + 可选;**必须确认**四项(物种/参考基因组版本、FASTQ 单双端、
|
||||
> 对照处理组重复数、链特异性),列表展示。
|
||||
> 判据:实验设计确认边界被明确列出,未确认前不得建差异表达任务。
|
||||
|
||||
**第 2 轮 —— OSS 文件 + 样本信息(纯打字,不点卡)**
|
||||
> 用户:数据在 oss://seq-bucket/drought/,水稻日本晴,干旱 3 重复对照 3 重复,双端非链特异
|
||||
> 系统:从 OSS 拉取 12 个 FASTQ 并核对成对;复述样本设计("干旱 3 vs 对照 3,双端,非链特异"),
|
||||
> 请确认基线(对照为基线、比较方向「干旱 vs 对照」)。
|
||||
> 判据:纯自然语言路径走通(**卡片与打字两条通道服务端语义一致**)。
|
||||
|
||||
**第 3 轮 —— 执行 + 结果转述**
|
||||
> 用户:对 → 系统执行 QC→比对→定量→样本质控→差异表达→富集,逐段转述(接头率/Q30/唯一比对率/差异基因数/PCA 分离)。
|
||||
> 判据:每步完成后自然语言转述关键指标;样本质控 PCA 结论有转述。
|
||||
|
||||
---
|
||||
|
||||
## 4. 三种网络文件来源用例
|
||||
|
||||
| 来源 | 示例 | 期望 |
|
||||
|---|---|---|
|
||||
| web | 「文件在 https://…/x.fa」 | httpx 流式下载,不设大小上限;下载前查磁盘余量;记录 provenance |
|
||||
| OSS | 「数据在 oss://bucket/key 或预签名 URL」 | 经 BIOFLOW_OSS_ENDPOINT 解析或预签名直通;无 endpoint 时提示改用预签名 |
|
||||
| 局域网 | 「文件在 192.168.x.x:/data/x.fa,账号 xx 密码 xx」 | ssh/scp 拉取(凭据单次会话内存用,不落盘、审计不记明文) |
|
||||
|
||||
判据:文件落 `bioflow_data/jobs/{job}/input/`,先内容嗅探后登记,provenance 记录来源/哈希/时间。
|
||||
|
||||
---
|
||||
|
||||
## 5. 非标准文件预处理(Excel / 乱码文本)闭环
|
||||
|
||||
1. 上传 `xxx.xlsx` / 乱码 txt → 系统 `inspect_file` 探测(列结构/格式猜测/乱码检测)。
|
||||
2. 系统自然语言说明 + 列表提问(哪列是序列、是否去重、是否去表头)。
|
||||
3. 用户回答 → `transform_file` 转换(LLM 定规则 + 脚本执行,大文件抽样定规则)。
|
||||
4. 结果过格式验证器(fasta 可解析/gff3 9 列/tsv 列一致/samples.yaml schema);
|
||||
失败说明问题并追问,循环 ≤3 次后建议人工整理。
|
||||
5. 转换后 `register_job_files` 登记,provenance 记「原始→规则→产物」链。
|
||||
|
||||
判据:全程自然语言引导;转换产物格式校验通过;原文件永不覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 6. 卡片辅助 vs 纯自然语言双通道等价
|
||||
|
||||
| 场景 | 卡片通道 | 纯 NL 通道 | 等价判据 |
|
||||
|---|---|---|---|
|
||||
| 路径选择 | 路径卡勾选 | 打字「核心流程 + Ka/Ks」 | 两者复述的确认内容一致,建任务入参一致 |
|
||||
| 参数确认 | 参数卡填值 | 打字「用 8 核、padj 用 0.01」 | confirm_stage 入参一致 |
|
||||
| 文件提供 | 文件卡拖拽/填路径 | 打字「文件在 …」 | register_job_files / fetch_source_file 入参一致 |
|
||||
| 存档 | 存档卡点存档 | 打字「存档,叫 X」 | archive_job 入参一致 |
|
||||
|
||||
判据:卡片操作产生的是 `card_result` 结构化消息进 QA 流(**不直连 bioflow 控制面**),
|
||||
与打字在服务端语义完全一致(QA 对话状态机是唯一决策点)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 旧会话兼容(Legacy 块只读)
|
||||
|
||||
1. 打开一个改造前创建、含旧生信块(bioflow_qa / 旧 bioflow_* 块)的会话。
|
||||
2. 期望:旧块以只读形式降级渲染(标题 + 只读引用/提示),**不报错、无可交互控件**。
|
||||
|
||||
---
|
||||
|
||||
## 8. 扩展子系统对话化(第三能力单元)
|
||||
|
||||
1. 打开「扩展」能力开关。
|
||||
2. 对话创建扩展:描述分析脚本(代码文件 + 运行命令)→ `create_extension`(draft)。
|
||||
3. `build` → `trial_run` → `validate` → `confirm` → `publish` → `run` 逐步走(卡片或打字等价)。
|
||||
4. 试运行失败 → 「AI 修复」触发 `repair_extension`(修复上限 20 轮);校验失败/状态不足时系统给出确定性提示。
|
||||
|
||||
判据:扩展生命周期全程对话化;非法状态返回清晰提示(EXT_STATUS),不报 500。
|
||||
|
||||
---
|
||||
|
||||
## 9. 后端金标验证(服务器命令行,S6 回归)
|
||||
|
||||
```bash
|
||||
cd /home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new
|
||||
BIOFLOW_DATA=/home/xzq/5090/teamWorkspace/zh/bioflow-data \
|
||||
bash bioflow_kernel/tests/smoke_golden.sh bioflow-gf:dev
|
||||
```
|
||||
|
||||
期望:4 步全绿——python 3.11.14 / ldd 0 缺库 / 无 conda / 金标 identify 24 ID diff 空(`GOLDEN_DIFF_OK 24 IDs`)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 端到端主线(一次性走全流程,双通道各一遍)
|
||||
|
||||
1. 卡片通道:§2 全程(路径卡→文件卡→参数卡→进度块→存档卡)。
|
||||
2. 纯 NL 通道:§3 全程(不点任何卡片)。
|
||||
3. 含一次 Excel 预处理、一次局域网/OSS 文件拉取。
|
||||
4. 任务完成后存档 → 生信档案 TAB 回看 → 复用发起。
|
||||
|
||||
期望:两条通道均走通,7 项引导(路径/文件/参数/报告/存档/复用/过程展示)逐项达成,
|
||||
进度块实时流转、右侧日志滚底。
|
||||
|
||||
---
|
||||
|
||||
*本手册为 S6 对话式改造更新版,替代旧卡片式 `MANUAL_TEST_BIOFLOW.md` 的生信流程部分。*
|
||||
+11
-1
@@ -286,11 +286,21 @@ async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]:
|
||||
history = await assemble_history(bff_db, session_id)
|
||||
if history:
|
||||
body["history"] = history
|
||||
# 会话开关注入上游(web_search/superpowers 经 QA 门控/注入点生效)
|
||||
# 会话开关注入上游(web_search/superpowers/bioflow 经 QA 门控/注入点生效)
|
||||
body["capabilities"] = {
|
||||
"web_search": bool(caps.get("web_search", False)),
|
||||
"superpowers": bool(caps.get("superpowers", False)),
|
||||
"bioflow": bool(caps.get("bioflow", False)),
|
||||
}
|
||||
# session↔job 绑定(S2):meta.bioflow = {active_job_id, chain} 注入上游,
|
||||
# 供 BioflowAgent 复用当前任务(job_status 是任务真相唯一权威)。
|
||||
bioflow_meta = meta.get("bioflow")
|
||||
if isinstance(bioflow_meta, dict):
|
||||
body["bioflow"] = {
|
||||
"session_id": session_id,
|
||||
"active_job_id": str(bioflow_meta.get("active_job_id") or ""),
|
||||
"chain": str(bioflow_meta.get("chain") or ""),
|
||||
}
|
||||
humanize = bool(caps.get("humanize", False))
|
||||
except Exception as exc:
|
||||
logger.warning("多轮记忆注入失败(降级为无历史): %s", exc)
|
||||
|
||||
@@ -30,7 +30,7 @@ from ..schemas import (
|
||||
SessionOut,
|
||||
SessionPatch,
|
||||
)
|
||||
from ..services.capabilities import sanitize_capabilities
|
||||
from ..services.capabilities import sanitize_bioflow_meta, sanitize_capabilities
|
||||
from ..upstream import envelope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -159,11 +159,17 @@ async def patch_session(
|
||||
)
|
||||
if payload.meta is not None:
|
||||
caps = sanitize_capabilities(payload.meta.get("capabilities"))
|
||||
if caps:
|
||||
has_bioflow = "bioflow" in payload.meta
|
||||
bioflow_patch = sanitize_bioflow_meta(payload.meta.get("bioflow"))
|
||||
if caps or has_bioflow:
|
||||
meta = session.meta.model_dump()
|
||||
merged_caps = dict(meta.get("capabilities") or {})
|
||||
merged_caps.update(caps)
|
||||
meta["capabilities"] = merged_caps
|
||||
if caps:
|
||||
merged_caps = dict(meta.get("capabilities") or {})
|
||||
merged_caps.update(caps)
|
||||
meta["capabilities"] = merged_caps
|
||||
if has_bioflow:
|
||||
# None 表示「清空绑定」(前端传 bioflow: {} 或 null)
|
||||
meta["bioflow"] = bioflow_patch
|
||||
await db.execute(
|
||||
"UPDATE sessions SET meta = ?, updated_at = ? WHERE id = ?",
|
||||
(dumps_json(meta), _db_now(), session_id),
|
||||
|
||||
@@ -393,6 +393,8 @@ class GraphData(BaseModel):
|
||||
class SessionMeta(BaseModel):
|
||||
title_manual: bool = False
|
||||
capabilities: dict[str, bool] = Field(default_factory=dict)
|
||||
# S2 session↔job 绑定:{active_job_id, chain},由 QA 流 complete 回填、前门注入
|
||||
bioflow: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
|
||||
@@ -39,6 +39,13 @@ MANIFEST: Dict[str, Dict[str, Any]] = {
|
||||
"provider": "qa_gateway",
|
||||
"session_default": False,
|
||||
},
|
||||
"bioflow": {
|
||||
"name": "bioflow",
|
||||
"label": "生信对话式分析",
|
||||
"description": "自然语言对话式生信分析(基因家族/转录组,MCP 外挂,热装卸)",
|
||||
"provider": "qa_gateway",
|
||||
"session_default": False,
|
||||
},
|
||||
"mcp_plugins": {
|
||||
"name": "mcp_plugins",
|
||||
"label": "MCP 插件",
|
||||
@@ -55,7 +62,7 @@ DEFAULTS: Dict[str, bool] = {
|
||||
}
|
||||
|
||||
# 会话 meta.capabilities 白名单(允许 PATCH 写入的键)
|
||||
SESSION_KEYS = ("memory", "web_search", "humanize")
|
||||
SESSION_KEYS = ("memory", "web_search", "humanize", "bioflow")
|
||||
|
||||
|
||||
def sanitize_capabilities(raw: Any) -> Dict[str, bool]:
|
||||
@@ -75,6 +82,21 @@ def sanitize_capabilities(raw: Any) -> Dict[str, bool]:
|
||||
return out
|
||||
|
||||
|
||||
def sanitize_bioflow_meta(raw: Any) -> Dict[str, str] | None:
|
||||
"""S2 session↔job 绑定白名单:只收 {active_job_id, chain} 两个字符串键。
|
||||
|
||||
返回 None 表示「无有效绑定(清空/不改)」;调用方据此决定是否写 meta.bioflow。
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
out: Dict[str, str] = {}
|
||||
if raw.get("active_job_id") not in (None, ""):
|
||||
out["active_job_id"] = str(raw.get("active_job_id"))[:128]
|
||||
if raw.get("chain") not in (None, ""):
|
||||
out["chain"] = str(raw.get("chain"))[:64]
|
||||
return out or None
|
||||
|
||||
|
||||
def merged_capabilities(
|
||||
session_caps: Dict[str, bool] | None,
|
||||
qa_registry: List[Dict[str, Any]] | None = None,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.services.capabilities import ( # noqa: E402
|
||||
DEFAULTS,
|
||||
MANIFEST,
|
||||
merged_capabilities,
|
||||
sanitize_bioflow_meta,
|
||||
sanitize_capabilities,
|
||||
)
|
||||
|
||||
@@ -50,3 +51,39 @@ class TestMerged:
|
||||
)
|
||||
assert body["installed"] == [{"name": "web_search", "description": "x"}]
|
||||
assert body["load_warnings"] == ["bad entry skipped"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S2 sanitize_bioflow_meta(session↔job 绑定白名单)
|
||||
# ===========================================================================
|
||||
|
||||
class TestSanitizeBioflowMeta:
|
||||
def test_valid(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "job-1", "chain": "gene_family"}) == {
|
||||
"active_job_id": "job-1",
|
||||
"chain": "gene_family",
|
||||
}
|
||||
|
||||
def test_evil_key_dropped(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "j", "chain": "c", "evil": "x"}) == {
|
||||
"active_job_id": "j",
|
||||
"chain": "c",
|
||||
}
|
||||
|
||||
def test_non_dict_returns_none(self):
|
||||
assert sanitize_bioflow_meta(None) is None
|
||||
assert sanitize_bioflow_meta("x") is None
|
||||
assert sanitize_bioflow_meta(["a"]) is None
|
||||
|
||||
def test_empty_or_null_returns_none(self):
|
||||
assert sanitize_bioflow_meta({}) is None
|
||||
assert sanitize_bioflow_meta({"active_job_id": "", "chain": ""}) is None
|
||||
assert sanitize_bioflow_meta({"active_job_id": None, "chain": None}) is None
|
||||
|
||||
def test_partial(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "j"}) == {"active_job_id": "j"}
|
||||
assert sanitize_bioflow_meta({"chain": "c"}) == {"chain": "c"}
|
||||
|
||||
def test_truncation(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "x" * 200})["active_job_id"] == "x" * 128
|
||||
assert sanitize_bioflow_meta({"chain": "y" * 100})["chain"] == "y" * 64
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""test_qa_inject_history.py —— _inject_history 的 bioflow 注入单测(S2)。
|
||||
|
||||
覆盖:session↔job 绑定(meta.bioflow)注入 body["bioflow"]、capabilities.bioflow 开关注入、
|
||||
memory 关跳过、显式 history 优先、会话不存在原样返回。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.routers import qa as qa_mod # noqa: E402
|
||||
|
||||
|
||||
class FakeRow(dict):
|
||||
"""aiosqlite.Row 最小替身。"""
|
||||
|
||||
def __getattr__(self, key):
|
||||
return self[key]
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self, row=None):
|
||||
self.row = row
|
||||
|
||||
async def fetchone(self, sql, params):
|
||||
return self.row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_bioflow_binding(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
row = FakeRow(meta='{"capabilities": {"bioflow": true, "memory": true}, '
|
||||
'"bioflow": {"active_job_id": "job-1", "chain": "gene_family"}}')
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(row))
|
||||
|
||||
async def fake_assemble(db, session_id):
|
||||
return [{"role": "user", "content": "上轮"}, {"role": "assistant", "content": "上答"}]
|
||||
|
||||
monkeypatch.setattr(qa_mod, "assemble_history", fake_assemble)
|
||||
body, humanize = await qa_mod._inject_history({"session_id": "s1", "question": "继续"})
|
||||
assert body["bioflow"] == {"session_id": "s1", "active_job_id": "job-1", "chain": "gene_family"}
|
||||
assert body["capabilities"] == {"web_search": False, "superpowers": False, "bioflow": True}
|
||||
assert body["history"] == [{"role": "user", "content": "上轮"}, {"role": "assistant", "content": "上答"}]
|
||||
assert humanize is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_bioflow_meta_injects_nothing(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
row = FakeRow(meta='{"capabilities": {"memory": true}}') # 无 bioflow 绑定
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(row))
|
||||
|
||||
async def fake_assemble(db, session_id):
|
||||
return [{"role": "user", "content": "上轮"}]
|
||||
|
||||
monkeypatch.setattr(qa_mod, "assemble_history", fake_assemble)
|
||||
body, _ = await qa_mod._inject_history({"session_id": "s1"})
|
||||
assert "bioflow" not in body
|
||||
assert body["capabilities"]["bioflow"] is False # 未开 bioflow 开关
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_off_skips_injection(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
row = FakeRow(meta='{"capabilities": {"memory": false, "bioflow": true}, '
|
||||
'"bioflow": {"active_job_id": "j"}}')
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(row))
|
||||
body, _ = await qa_mod._inject_history({"session_id": "s1"})
|
||||
assert "bioflow" not in body
|
||||
assert "capabilities" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_not_found_returns_body_unchanged(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(None)) # fetchone → None
|
||||
body, _ = await qa_mod._inject_history({"session_id": "s1", "question": "q"})
|
||||
assert body == {"question": "q"} # session_id 被 pop,其余原样
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_history_takes_priority(monkeypatch):
|
||||
body, _ = await qa_mod._inject_history(
|
||||
{"session_id": "s1", "history": [{"role": "user", "content": "x"}]}
|
||||
)
|
||||
assert body["history"] == [{"role": "user", "content": "x"}]
|
||||
assert "bioflow" not in body # 显式 history 优先 → 不读 meta
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_session_id_returns_body_unchanged(monkeypatch):
|
||||
body, _ = await qa_mod._inject_history({"question": "q"})
|
||||
assert body == {"question": "q"}
|
||||
@@ -73,7 +73,7 @@ def test_session_crud_full_flow(client):
|
||||
assert sess["title"] == "新会话"
|
||||
assert sess["status"] == "idle"
|
||||
assert sess["message_count"] == 0
|
||||
assert sess["meta"] == {"title_manual": False, "capabilities": {}}
|
||||
assert sess["meta"] == {"title_manual": False, "capabilities": {}, "bioflow": None}
|
||||
|
||||
r2 = client.post("/api/sessions", json={"title": " 水稻SPL "})
|
||||
sess2 = r2.json()
|
||||
@@ -132,7 +132,7 @@ def test_session_crud_full_flow(client):
|
||||
# PATCH 会话标题 → title_manual=true
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"title": "手工标题"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"] == {"title_manual": True, "capabilities": {}}
|
||||
assert r.json()["meta"] == {"title_manual": True, "capabilities": {}, "bioflow": None}
|
||||
|
||||
# PATCH 会话状态
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"status": "done"})
|
||||
@@ -149,6 +149,29 @@ def test_session_crud_full_flow(client):
|
||||
assert body["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_session_bioflow_binding_patch(client):
|
||||
"""S2 session↔job 绑定:PATCH meta.bioflow 白名单落库 + 串行往返。"""
|
||||
r = client.post("/api/sessions", json={})
|
||||
sess = r.json()
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
# 只收 active_job_id / chain 两个键,非法键丢弃
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={
|
||||
"meta": {"bioflow": {"active_job_id": "job-42", "chain": "gene_family", "evil": "x"}}
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["meta"]["bioflow"] == {"active_job_id": "job-42", "chain": "gene_family"}
|
||||
|
||||
# 回读持久化
|
||||
r = client.get(f"/api/sessions/{sess['id']}")
|
||||
assert r.json()["meta"]["bioflow"] == {"active_job_id": "job-42", "chain": "gene_family"}
|
||||
|
||||
# 空 dict → 清空绑定
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"meta": {"bioflow": {}}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"]["bioflow"] is None
|
||||
|
||||
|
||||
def test_not_found_envelopes(client):
|
||||
cases = [
|
||||
("get", "/api/sessions/nope", None),
|
||||
|
||||
@@ -8,6 +8,10 @@ import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useBenchStore } from '../../../../ui/src/stores/bench.js';
|
||||
|
||||
vi.mock('../../../../ui/src/api/capabilities.js', () => ({
|
||||
getCapabilities: vi.fn(async () => ({ installed: [{ name: 'bioflow-gene-family' }] })),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
|
||||
bioflowHealth: vi.fn(async () => ({ ok: true, status: 'ok', db: { connected: true } })),
|
||||
listBioflowJobs: vi.fn(async () => ({ ok: true, data: { items: [], total: 0 } })),
|
||||
@@ -68,6 +72,7 @@ vi.mock('../../../../ui/src/api/qa.js', () => ({
|
||||
import BenchPanel from '../../../../ui/src/components/bench/BenchPanel.vue';
|
||||
import { useBioflowStore } from '../../../../ui/src/stores/bioflow.js';
|
||||
import * as bioflowApi from '../../../../ui/src/api/bioflow.js';
|
||||
import * as capabilitiesApi from '../../../../ui/src/api/capabilities.js';
|
||||
|
||||
describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
let pinia;
|
||||
@@ -78,14 +83,15 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
});
|
||||
|
||||
it('探活成功:五个既有 TAB + 生信任务/生信档案两 TAB(TC-BIO-JOB-001/PANE-001)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
expect(labels).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档', '生信任务', '生信档案']);
|
||||
});
|
||||
|
||||
it('探活失败(health 不可达):生信 TAB 不渲染(TC-BIO-JOB-012/PANE-013)', async () => {
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
it('能力未注册:生信 TAB 不渲染(S3 主信号 = 能力注册状态)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
@@ -94,13 +100,23 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
expect(labels).not.toContain('生信档案');
|
||||
});
|
||||
|
||||
it('探活恢复:强制重探后 TAB 出现(TC-BIO-JOB-013/PANE-014)', async () => {
|
||||
it('已注册但 health 不可达:TAB 仍显示(次信号不影响显隐)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
expect(labels).toContain('生信任务');
|
||||
expect(labels).toContain('生信档案');
|
||||
});
|
||||
|
||||
it('探活恢复:能力注册后强制重探 TAB 出现(TC-BIO-JOB-013/PANE-014)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
expect(w.findAll('.bench-tab').map((t) => t.text())).not.toContain('生信任务');
|
||||
// 服务恢复:强制重探
|
||||
bioflowApi.bioflowHealth.mockResolvedValueOnce({ ok: true });
|
||||
// 能力安装:强制重探
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const bioflow = useBioflowStore();
|
||||
await bioflow.probe({ force: true });
|
||||
await flushPromises();
|
||||
@@ -109,6 +125,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
});
|
||||
|
||||
it('切到生信任务 TAB:渲染 BioJobsPane(TC-BIO-JOB-001)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const biojobsTab = w.findAll('.bench-tab').find((t) => t.text() === '生信任务');
|
||||
@@ -119,6 +136,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
});
|
||||
|
||||
it('切到生信档案 TAB:渲染 BioArchivePane(TC-BIO-PANE-001)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const bioarchiveTab = w.findAll('.bench-tab').find((t) => t.text() === '生信档案');
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
STAGE_STATUS_BY_EVENT,
|
||||
} from '../../../../ui/src/stores/bioflow.js';
|
||||
|
||||
// mock api/capabilities.js(S3 双信号主信号源)
|
||||
vi.mock('../../../../ui/src/api/capabilities.js', () => ({
|
||||
getCapabilities: vi.fn(async () => ({ installed: [{ name: 'bioflow-gene-family' }] })),
|
||||
}));
|
||||
|
||||
// mock api/bioflow.js(SSE 用 mock,不依赖生产服务;vi.mock 工厂内不得引用顶层变量)
|
||||
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
|
||||
bioflowHealth: vi.fn(async () => ({ ok: true, status: 'ok', db: { connected: true } })),
|
||||
@@ -176,20 +181,36 @@ describe('bioflow store —— 探活 / 动作 / 面板 / 复用', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('探活 200 → online;失败 → offline(TC-BIO-STORE-016)', async () => {
|
||||
it('探活 能力注册 → online;未注册 → offline(TC-BIO-STORE-016 / S3 双信号)', async () => {
|
||||
const capsApi = await import('../../../../ui/src/api/capabilities.js');
|
||||
const store = useBioflowStore();
|
||||
capsApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
bioflowApi.bioflowHealth.mockResolvedValueOnce({ ok: true });
|
||||
await store.probe({ force: true });
|
||||
expect(store.capabilityInstalled).toBe(true);
|
||||
expect(store.bioflowAvailable).toBe(true);
|
||||
|
||||
const s2 = useBioflowStore();
|
||||
s2.availability = 'unknown';
|
||||
capsApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
await s2.probe({ force: true });
|
||||
expect(s2.capabilityInstalled).toBe(false);
|
||||
expect(s2.availability).toBe('offline');
|
||||
expect(s2.bioflowAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('探活 已注册但服务不可达 → TAB 仍显示(次信号不影响显隐)', async () => {
|
||||
const capsApi = await import('../../../../ui/src/api/capabilities.js');
|
||||
const store = useBioflowStore();
|
||||
capsApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-rnaseq' }] });
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
await store.probe({ force: true });
|
||||
expect(store.capabilityInstalled).toBe(true);
|
||||
expect(store.healthOnline).toBe(false);
|
||||
expect(store.bioflowAvailable).toBe(true); // 已注册 → TAB 仍显示
|
||||
});
|
||||
|
||||
it('applyChatEvent plan_ready 装载候选路径(TC-BIO-STORE-003)', () => {
|
||||
const store = useBioflowStore();
|
||||
store.applyChatEvent('plan_ready', {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -82,9 +82,18 @@ function setup() {
|
||||
bioflow.currentJob.jobId = 'job-1';
|
||||
const chat = useChatStore();
|
||||
chat.sessionId = 'sess-1';
|
||||
// S3:卡片操作改走 card_result 进 QA 流——单测把 sendCardResult 打桩为「挂起中」
|
||||
//(模拟真实 QA 流在 complete 前不 resolve),断言其被以正确 card/payload 调用;
|
||||
// 挂起态也保证 confirming 防双击守卫在测试里不被 finally 提前复位。
|
||||
vi.spyOn(chat, 'sendCardResult').mockImplementation(() => new Promise(() => {}));
|
||||
return { bioflow, chat };
|
||||
}
|
||||
|
||||
/** 当前活跃 chat store 的 sendCardResult spy(setup 已打桩)。 */
|
||||
function sendCardSpy() {
|
||||
return useChatStore().sendCardResult;
|
||||
}
|
||||
|
||||
const PATH_BLOCK = () => ({
|
||||
type: 'bioflow_path',
|
||||
status: 'done',
|
||||
@@ -169,9 +178,11 @@ describe('FlowPathCard —— TC-BIO-PATH-001~022(L4)', () => {
|
||||
await w.find('.fpc-confirm').trigger('click'); // 进确认视图
|
||||
await w.findAll('.fpc-confirm')[0].trigger('click'); // 确认创建
|
||||
await flushPromises();
|
||||
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
|
||||
const body = bioflowApi.createBioflowJob.mock.calls[0][0];
|
||||
expect(body.stages).toEqual(['identify', 'align', 'phylogeny']);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
const [card, payload] = sendCardSpy().mock.calls[0];
|
||||
expect(card).toBe('path_selection');
|
||||
expect(payload.chain).toBe('gene_family');
|
||||
expect(payload.stages).toEqual(['identify', 'align', 'phylogeny']);
|
||||
});
|
||||
|
||||
it('确认按钮双击只触发一次创建(TC-BIO-ACT-008 防重复)', async () => {
|
||||
@@ -182,7 +193,7 @@ describe('FlowPathCard —— TC-BIO-PATH-001~022(L4)', () => {
|
||||
await confirmBtn.trigger('click');
|
||||
await confirmBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('键盘 Enter 触发确认(TC-BIO-ACT-010)', async () => {
|
||||
@@ -272,11 +283,12 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
await w.findAll('.ffc-input')[0].setValue('8');
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
|
||||
const [jobId, stage, params] = bioflowApi.confirmBioflowStage.mock.calls[0];
|
||||
expect(jobId).toBe('job-1');
|
||||
expect(stage).toBe('identify');
|
||||
expect(params.cpu).toBe(8);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
const [card, payload] = sendCardSpy().mock.calls[0];
|
||||
expect(card).toBe('form_confirm');
|
||||
expect(payload.job_id).toBe('job-1');
|
||||
expect(payload.stage).toBe('identify');
|
||||
expect(payload.params.cpu).toBe(8);
|
||||
});
|
||||
|
||||
it('继续按钮双击仅一次请求(TC-BIO-ACT-021 提交中禁用)', async () => {
|
||||
@@ -285,7 +297,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
await btn.trigger('click');
|
||||
await btn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('数字类型校验:输入 abc 拦截(TC-BIO-FORM-014)', async () => {
|
||||
@@ -293,7 +305,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
await w.findAll('.ffc-input')[0].setValue('abc');
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
|
||||
expect(sendCardSpy()).not.toHaveBeenCalled();
|
||||
expect(w.find('.ffc-err').text()).toContain('类型');
|
||||
});
|
||||
|
||||
@@ -303,7 +315,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
const w = mount(FlowFormCard, { props: { block: blk } });
|
||||
await w.findAll('.ffc-input')[0].setValue('999');
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
|
||||
expect(sendCardSpy()).not.toHaveBeenCalled();
|
||||
expect(w.find('.ffc-err').text()).toContain('1 ~ 64');
|
||||
});
|
||||
|
||||
@@ -312,15 +324,15 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
// species 默认空字符串
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
await flushPromises();
|
||||
const [, , params] = bioflowApi.confirmBioflowStage.mock.calls[0];
|
||||
expect(params.species).toBeUndefined();
|
||||
const [, payload] = sendCardSpy().mock.calls[0];
|
||||
expect(payload.params.species).toBeUndefined();
|
||||
});
|
||||
|
||||
it('键盘 Enter 提交(TC-BIO-FORM-023/ACT-016)', async () => {
|
||||
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
|
||||
await w.findAll('.ffc-input')[0].trigger('keydown', { key: 'Enter' });
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('键盘 Esc 恢复默认值(TC-BIO-FORM-024/ACT-017)', async () => {
|
||||
@@ -432,7 +444,7 @@ describe('FlowFileCard —— TC-BIO-FILE-001~022(L4)', () => {
|
||||
await w.find('.ffile-path-input').setValue('/data/examples/SPL.fa');
|
||||
await w.find('.ffile-path-row .pane-btn').trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalledWith('job-1', { server_path: '/data/examples/SPL.fa' });
|
||||
expect(sendCardSpy()).toHaveBeenCalledWith('file_upload', expect.objectContaining({ job_id: 'job-1', paths: ['/data/examples/SPL.fa'] }));
|
||||
});
|
||||
|
||||
it('登记路径按钮空 input 禁用(TC-BIO-ACT-048)', () => {
|
||||
@@ -445,7 +457,7 @@ describe('FlowFileCard —— TC-BIO-FILE-001~022(L4)', () => {
|
||||
await w.find('.ffile-path-input').setValue('/data/SPL.fa');
|
||||
await w.find('.ffile-path-input').trigger('keydown', { key: 'Enter' });
|
||||
await flushPromises();
|
||||
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalled();
|
||||
expect(sendCardSpy()).toHaveBeenCalledWith('file_upload', expect.objectContaining({ job_id: 'job-1', paths: ['/data/SPL.fa'] }));
|
||||
});
|
||||
|
||||
it('路径输入 Esc 清空(TC-BIO-ACT-044)', async () => {
|
||||
@@ -524,46 +536,15 @@ describe('FlowProgressCard —— TC-BIO-PROG-001~024(L4)', () => {
|
||||
expect(w.find('.pc-log-body').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('失败标红 ✕ + 状态文字 error 色 + 三选一按钮出现(TC-BIO-PROG-006/007)', () => {
|
||||
it('失败标红 ✕ + 状态文字 error 色(TC-BIO-PROG-006/007;S3 无三选一按钮)', () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: 'error', error: 'manifest_failed' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
const failedStep = w.findAll('.p-step')[1];
|
||||
expect(failedStep.classes()).toContain('failed');
|
||||
expect(failedStep.find('.p-dot').text()).toBe('✕');
|
||||
const actions = failedStep ? w.findAll('.pc-actions')[0] : null;
|
||||
expect(actions.text()).toContain('重跑');
|
||||
expect(actions.text()).toContain('跳过');
|
||||
expect(actions.text()).toContain('改参数重跑');
|
||||
});
|
||||
|
||||
it('重跑:发出 rerun 请求(TC-BIO-PROG-008/ACT-050)', async () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
const btn = w.findAll('.pc-actions .pane-btn')[0];
|
||||
await btn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.rerunBioflowStage).toHaveBeenCalledWith('job-1', 'align', null);
|
||||
});
|
||||
|
||||
it('跳过:发出 skip 请求(TC-BIO-PROG-009/ACT-051)', async () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
await w.findAll('.pc-actions .pane-btn')[1].trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.skipBioflowStage).toHaveBeenCalledWith('job-1', 'align');
|
||||
});
|
||||
|
||||
it('改参数重跑:弹出参数表单(TC-BIO-PROG-010/ACT-052)', async () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
await w.findAll('.pc-actions .pane-btn')[2].trigger('click');
|
||||
await flushPromises();
|
||||
expect(w.find('.pc-rerun-form').exists()).toBe(true);
|
||||
expect(bioflowApi.bioflowCatalog).toHaveBeenCalledWith({ stage: 'align' });
|
||||
// S3 精简:失败态不再渲染直连控制按钮(重跑/跳过/改参数重跑)
|
||||
expect(w.findAll('.pc-actions').length).toBe(0);
|
||||
});
|
||||
|
||||
it('skipped 态视觉(TC-BIO-PROG-011)', () => {
|
||||
@@ -604,24 +585,6 @@ describe('FlowProgressCard —— TC-BIO-PROG-001~024(L4)', () => {
|
||||
expect(w.findAll('.p-step').length).toBe(10);
|
||||
});
|
||||
|
||||
it('暂停/恢复按钮随任务状态显隐;点击发请求(TC-BIO-PROG-017/018)', async () => {
|
||||
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
|
||||
// running → 暂停按钮
|
||||
let pauseBtn = w.findAll('.pc-head-right .pane-btn').find((b) => b.text().includes('暂停'));
|
||||
expect(pauseBtn).toBeTruthy();
|
||||
await pauseBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.pauseBioflowJob).toHaveBeenCalledWith('job-1');
|
||||
// paused → 恢复按钮
|
||||
const blk = { ...PROG_BLOCK(), jobStatus: 'paused' };
|
||||
const w2 = mount(FlowProgressCard, { props: { block: blk } });
|
||||
const resumeBtn = w2.findAll('.pc-head-right .pane-btn').find((b) => b.text().includes('继续'));
|
||||
expect(resumeBtn).toBeTruthy();
|
||||
await resumeBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.resumeBioflowJob).toHaveBeenCalledWith('job-1');
|
||||
});
|
||||
|
||||
it('快照整体替换:块 stages 引用变化后自动整体刷新(TC-BIO-PROG-015)', async () => {
|
||||
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
|
||||
const before = w.findAll('.p-step').length;
|
||||
|
||||
@@ -190,141 +190,3 @@ describe('消息更新器 —— 五类块 patch', () => {
|
||||
expect(blk.citations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBioflow —— 全流程(SSE 用 mock,不依赖生产服务)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
chatFrames = [];
|
||||
});
|
||||
|
||||
it('plan_ready + job_created + stage_await_confirm + job_done 事件流 patch 消息 blocks(TC-BIO-STORE-003/004/005)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: '标准流程', stages: ['identify', 'align'] }], source: 'llm_rag', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
frame('job_created', { job_id: 'j1', session_id: 'sess-1', title: 'SPL', stages: [{ stage: 'identify', status: 'waiting' }, { stage: 'align', status: 'waiting' }], auto_continue: false }),
|
||||
frame('stage_started', { job_id: 'j1', stage: 'identify', attempt: 1 }),
|
||||
frame('stage_await_confirm', { job_id: 'j1', stage: 'align', form_defaults: [{ name: 'cpu', default: 4, source: 'contract' }] }),
|
||||
frame('job_done', { job_id: 'j1', progress: 100 }),
|
||||
];
|
||||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||||
expect(chat.messages).toHaveLength(2);
|
||||
expect(chat.messages[0].role).toBe('user');
|
||||
expect(chat.messages[0].kind).toBe('bioflow');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.kind).toBe('bioflow');
|
||||
expect(aMsg.status).toBe('done');
|
||||
const types = aMsg.blocks.map((b) => b.type);
|
||||
expect(types).toContain('bioflow_path');
|
||||
expect(types).toContain('bioflow_progress');
|
||||
expect(types).toContain('bioflow_form');
|
||||
expect(types).toContain('bioflow_archive');
|
||||
const path = aMsg.blocks.find((b) => b.type === 'bioflow_path');
|
||||
expect(path.candidates).toHaveLength(1);
|
||||
const prog = aMsg.blocks.find((b) => b.type === 'bioflow_progress');
|
||||
expect(prog.jobId).toBe('j1');
|
||||
expect(prog.stages.map((s) => s.status)).toEqual(['running', 'waiting_confirm']);
|
||||
// 用户消息落库(骨架 + 终态 patch)
|
||||
expect(sessionCalls.messages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(sessionCalls.messages[0].body.role).toBe('user');
|
||||
});
|
||||
|
||||
it('plan_ready 后心跳流断连(无任务事件):path 卡保留,消息收口 done', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
];
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('done');
|
||||
expect(aMsg.blocks.find((b) => b.type === 'bioflow_path').candidates).toHaveLength(1);
|
||||
expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false);
|
||||
});
|
||||
|
||||
it('message_done flow_qa 分支:引用块渲染(FR-BIO-026 不新建任务)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('message_done', { kind: 'flow_qa', citations: [{ source: 'flow_docs', title_path: 'identify.md', text: '输入 fasta 与 hmm' }], degraded: false }),
|
||||
];
|
||||
await chat.sendBioflow('identify 需要什么输入');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('done');
|
||||
const qa = aMsg.blocks.find((b) => b.type === 'bioflow_qa');
|
||||
expect(qa.citations).toHaveLength(1);
|
||||
expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false);
|
||||
});
|
||||
|
||||
it('错误帧(统一信封):标注 error + 可重试', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('error', { code: 'UPSTREAM_DOWN', message: '生信服务当前不可用' }),
|
||||
];
|
||||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('error');
|
||||
expect(aMsg.errText).toBe('生信服务当前不可用');
|
||||
expect(chat.lastResult.ok).toBe(false);
|
||||
expect(chat.lastResult.error.code).toBe('UPSTREAM_DOWN');
|
||||
});
|
||||
|
||||
it('网络中断:标注 disconnected;任务状态保留(快照兜底不丢进度)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
frame('job_created', { job_id: 'j1', stages: [{ stage: 'identify', status: 'waiting' }], auto_continue: false }),
|
||||
frame('stage_started', { job_id: 'j1', stage: 'identify', attempt: 1 }),
|
||||
];
|
||||
// 让 fetch 在流消费中途失败:mock 抛网络错误
|
||||
bioflowApi.bioflowChatStream.mockRejectedValueOnce(new Error('network'));
|
||||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('error');
|
||||
expect(aMsg.streamErr).toBe('disconnected');
|
||||
expect(chat.lastResult.ok).toBe(false);
|
||||
expect(chat.lastResult.error.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('retry:bioflow 失败消息同位置替换走生信路径(TC-BIO-STORE-022 语义)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [frame('error', { code: 'UPSTREAM_DOWN', message: 'x' })];
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
expect(chat.messages).toHaveLength(2);
|
||||
// 重试成功路径
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
];
|
||||
await chat.retry();
|
||||
expect(chat.messages).toHaveLength(2); // 同位置替换不新增
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('done');
|
||||
expect(aMsg.kind).toBe('bioflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('分流纪律(漏判安全)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sendBioflow 不经过 qa 流(bioflowChatStream 独立端点)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [frame('plan_ready', { candidates: [], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null })];
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
expect(bioflowApi.bioflowChatStream).toHaveBeenCalledTimes(1);
|
||||
const body = bioflowApi.bioflowChatStream.mock.calls[0][0];
|
||||
expect(body.session_id).toBe('sess-1');
|
||||
expect(body.message).toBe('跑 SPL 家族');
|
||||
expect(body).toHaveProperty('has_bioflow_context');
|
||||
});
|
||||
|
||||
it('busy 时不重复发起(并发防护)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
vi.clearAllMocks();
|
||||
chat.streamCtx['sess-1'] = { busy: true };
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,18 +100,12 @@ describe('Composer 生信分流(漏判安全)', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('bioflow 意图:走 chat.sendBioflow(用户消息 kind=bioflow + path 块)', async () => {
|
||||
chatFrames = [{
|
||||
event: 'plan_ready',
|
||||
data: { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null },
|
||||
}];
|
||||
it('生信意图:统一走 QA 流(emit send,不产生 bioflow 直连请求;S3)', async () => {
|
||||
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
|
||||
await submit(w, '帮我跑水稻 SPL 家族鉴定');
|
||||
expect(bioflowApi.bioflowChatStream).toHaveBeenCalledTimes(1);
|
||||
const chat = useChatStore();
|
||||
expect(chat.messages).toHaveLength(2);
|
||||
expect(chat.messages[0].kind).toBe('bioflow');
|
||||
expect(chat.messages[1].blocks[0].type).toBe('bioflow_path');
|
||||
expect(w.emitted('send')).toBeTruthy();
|
||||
expect(w.emitted('send')[0][0]).toContain('帮我跑水稻 SPL 家族鉴定');
|
||||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('未命中词典:走原 send 路径(不产生 bioflow 请求;TC-BIO-STORE-002)', async () => {
|
||||
|
||||
@@ -1,118 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/frontend/unit/bioflow/intent.test.js —— 生信意图分流(L3)
|
||||
// 与 docs/team/cases_bioflow_intent.md 冻结词典 40 用例表同表双跑
|
||||
// (TC-BIO-INTENT-001~047;后端 intent.py 同词典同规则,L2 侧由 T 执行同表校验)。
|
||||
// tests/frontend/unit/bioflow/intent.test.js —— 意图分流(S3:生信词典删除后等价语义)
|
||||
// S3 起生信分析意图统一走 QA 流(bioflow_agent 服务端 nl_triggers 判定),
|
||||
// 前端 route() 不再产生 bioflow/bioflow_qa——本测试锁定该等价语义:
|
||||
// 生信句 → qa;文档意图(docgen/export/edit)不变;扩展创建意图保留。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { route, extractReuseName, hasAnyWord } from '../../../../ui/src/utils/intent.js';
|
||||
import { route, hasAnyWord, isExtensionCreateIntent } from '../../../../ui/src/utils/intent.js';
|
||||
|
||||
/** 词典用例表(cases_bioflow_intent.md §4):[text, attachments, activeArtifact, bioflowContext, expected] */
|
||||
const DICT_CASES = [
|
||||
// # text 附件 活动文档 生信上下文 期望
|
||||
['帮我跑水稻 SPL 家族鉴定', false, false, false, 'bioflow'],
|
||||
['跑一下拟南芥 MADS-box 家族分析', false, false, false, 'bioflow'],
|
||||
['请帮我鉴定水稻 SPL 家族', false, false, false, 'bioflow'],
|
||||
['识别玉米 bZIP 家族成员', false, false, false, 'bioflow'],
|
||||
['帮我研究一下水稻 SPL 基因家族', false, false, false, 'bioflow'],
|
||||
['分析小麦 WRKY 转录因子家族', false, false, false, 'bioflow'],
|
||||
['做个大豆 NAC 家族分析', false, false, false, 'bioflow'],
|
||||
['跑 SPL 家族', false, false, false, 'bioflow'],
|
||||
['水稻 SPL 家族,帮我跑', false, false, false, 'bioflow'],
|
||||
['基因家族鉴定:水稻 SPL', false, false, false, 'bioflow'],
|
||||
['帮我跑水稻叶片 RNA-seq 差异表达分析', false, false, false, 'bioflow'],
|
||||
['帮我做水稻全基因组变异检测', false, false, false, 'bioflow'],
|
||||
['帮我跑水稻产量性状 GWAS 关联分析', false, false, false, 'bioflow'],
|
||||
['什么是基因家族?', false, false, false, 'qa'],
|
||||
['帮我跑一下', false, false, false, 'qa'],
|
||||
['SPL 家族', false, false, false, 'qa'],
|
||||
['今天天气不错', false, false, false, 'qa'],
|
||||
['跑 SPL 家族鉴定需要准备什么输入?', false, false, false, 'qa'], // 无生信上下文漏判安全
|
||||
['帮我跑一个数据分析', false, false, false, 'qa'],
|
||||
['你好呀', false, false, false, 'qa'],
|
||||
['', false, false, false, 'qa'], // 空字符串
|
||||
['帮我跑香蕉 XYK 家族鉴定', false, false, false, 'qa'], // XYK 不在家族词典
|
||||
['跑水稻 spl 家族鉴定', false, false, false, 'bioflow'], // 家族名大小写不敏感
|
||||
['Please run rice SPL family identification', false, false, false, 'bioflow'],
|
||||
['帮我跑(水稻 SPL 家族)鉴定!', false, false, false, 'bioflow'],
|
||||
['用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定', false, false, false, 'bioflow'], // 复用意图
|
||||
['根据 XXX 存档任务的方法帮我跑一下玉米 bZIP', false, false, false, 'bioflow'],
|
||||
['参考 XXX 存档的方法再跑一个水稻 SPL', false, false, false, 'bioflow'],
|
||||
['用《XXX》存档任务的方法帮我跑小麦 WRKY 家族', false, false, false, 'bioflow'],
|
||||
['用 XXX 存档任务的方法', false, false, false, 'qa'], // 无目标任务词
|
||||
['用 XXX 存档任务的方法帮我跑 yyy', false, false, false, 'qa'], // yyy 无家族/对象匹配
|
||||
['帮我生成一份水稻 SPL 家族分析报告', false, false, false, 'docgen'], // docgen 优先于 bioflow
|
||||
['帮我写一篇水稻基因家族研究论文', false, false, false, 'docgen'],
|
||||
['导出成 PDF', false, true, false, 'export'],
|
||||
['把这句话改成红色', false, true, false, 'edit'], // 无图表词
|
||||
['xxx 图表颜色换成红色', false, true, false, 'bioflow'], // 图表词走 FR-BIO-019
|
||||
['identify 需要什么输入', false, false, true, 'bioflow_qa'], // 不新建任务
|
||||
['帮我总结一下这个问题的要点', false, false, false, 'qa'],
|
||||
['写一段水稻基因的介绍', false, false, false, 'qa'], // 介绍在 docgen 排除表
|
||||
['跑水稻 SPL 家族鉴定', false, true, false, 'bioflow'], // 活动文档不改判定
|
||||
];
|
||||
|
||||
describe('intent route —— 词典 40 用例同表双跑', () => {
|
||||
DICT_CASES.forEach(([text, att, artifact, ctx, expected], i) => {
|
||||
const label = text ? text.slice(0, 24) : '(空字符串)';
|
||||
it(`#${i + 1} 「${label}」 → ${expected}`, () => {
|
||||
const { intent } = route(text, att, artifact, ctx);
|
||||
expect(intent).toBe(expected);
|
||||
});
|
||||
describe('route —— S3 生信意图一律 qa(漏判安全)', () => {
|
||||
it('生信分析句 → qa(不再产生 bioflow 分流)', () => {
|
||||
for (const t of [
|
||||
'帮我跑水稻 SPL 家族鉴定',
|
||||
'跑一下拟南芥 MADS-box 家族分析',
|
||||
'帮我跑水稻叶片 RNA-seq 差异表达分析',
|
||||
'帮我做水稻全基因组变异检测',
|
||||
'识别玉米 bZIP 家族成员',
|
||||
'用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定',
|
||||
'xxx 图表颜色换成红色',
|
||||
]) {
|
||||
expect(route(t, false, false, false).intent).toBe('qa');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('intent route —— 边界与实现注意(词典 §5)', () => {
|
||||
it('文本长度 ≤1 或纯空白 → qa', () => {
|
||||
it('生信流程咨询句 → qa(bioflow_qa 已删除)', () => {
|
||||
expect(route('identify 需要什么输入', false, false, true).intent).toBe('qa');
|
||||
});
|
||||
|
||||
it('文档意图 docgen 不变', () => {
|
||||
expect(route('帮我写一份水稻分析报告').intent).toBe('docgen');
|
||||
expect(route('帮我整理一下', true, false, false).intent).toBe('docgen');
|
||||
});
|
||||
|
||||
it('导出/编辑需活动文档才生效(docgen 语义不变)', () => {
|
||||
expect(route('导出成 PDF', false, true, false).intent).toBe('export');
|
||||
expect(route('导出成 PDF', false, false, false).intent).toBe('qa');
|
||||
expect(route('把这句话改成红色', false, true, false).intent).toBe('edit');
|
||||
});
|
||||
|
||||
it('边界:长度 ≤1/空/纯空白 → qa', () => {
|
||||
expect(route('a').intent).toBe('qa');
|
||||
expect(route(' ').intent).toBe('qa');
|
||||
expect(route(null).intent).toBe('qa');
|
||||
expect(route(undefined).intent).toBe('qa');
|
||||
});
|
||||
it('>2000 字符按截断前 2000 判定(长前缀不淹没意图)', () => {
|
||||
const long = '啊'.repeat(3000) + ' 帮我跑水稻 SPL 家族鉴定';
|
||||
expect(route(long).intent).toBe('qa'); // 意图词在 2000 之后被截掉
|
||||
const short = '帮我跑水稻 SPL 家族鉴定' + '啊'.repeat(3000);
|
||||
expect(route(short).intent).toBe('bioflow');
|
||||
});
|
||||
it('bioflow_qa 仅在 has_bioflow_context=true 生效', () => {
|
||||
expect(route('identify 需要什么输入', false, false, true).intent).toBe('bioflow_qa');
|
||||
expect(route('identify 需要什么输入', false, false, false).intent).toBe('qa');
|
||||
});
|
||||
it('家族名后须跟语境词或位于句尾(SPL 会议不命中)', () => {
|
||||
expect(route('跑 SPL 会议').intent).toBe('qa');
|
||||
expect(route('帮我跑玉米 bZIP').intent).toBe('bioflow'); // 句尾命中
|
||||
});
|
||||
it('「做」动词需对象组合(分析对象词命中)', () => {
|
||||
expect(route('帮我做水稻全基因组变异检测').intent).toBe('bioflow');
|
||||
expect(route('帮我做个分析').intent).toBe('qa'); // 无对象词
|
||||
});
|
||||
it('附件 + 写类动词 → docgen(兜底信号,词典规则 4)', () => {
|
||||
expect(route('帮我整理一下', true, false, false).intent).toBe('docgen');
|
||||
});
|
||||
it('导出需 has_active_artifact 才生效', () => {
|
||||
expect(route('导出成 PDF', false, false, false).intent).toBe('qa');
|
||||
});
|
||||
it('命中句不打乱后续 QA 判定(无状态残留)', () => {
|
||||
expect(route('什么是 SPL 家族').intent).toBe('qa');
|
||||
expect(route('帮我跑水稻 SPL 家族鉴定').intent).toBe('bioflow');
|
||||
expect(route('什么是 SPL 家族').intent).toBe('qa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractReuseName —— 复用方法名提取(词典实现注意 2)', () => {
|
||||
it('标准句式:方法名 = REUSE_PHRASE 之前最近的 3-20 字符片段', () => {
|
||||
expect(extractReuseName('用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定')).toBe('XXX');
|
||||
expect(extractReuseName('根据 XXX 存档任务的方法帮我跑一下玉米 bZIP')).toBe('XXX');
|
||||
expect(extractReuseName('参考 XXX 存档的方法再跑一个水稻 SPL')).toBe('XXX');
|
||||
expect(extractReuseName('用《XXX》存档任务的方法帮我跑小麦 WRKY 家族')).toBe('XXX');
|
||||
describe('isExtensionCreateIntent —— 扩展创建意图(S4 保留)', () => {
|
||||
it('创建/上传 扩展句式命中', () => {
|
||||
expect(isExtensionCreateIntent('帮我创建一个分析扩展')).toBe(true);
|
||||
expect(isExtensionCreateIntent('上传一个自定义分析工具')).toBe(true);
|
||||
});
|
||||
it('非复用句式返回 null', () => {
|
||||
expect(extractReuseName('帮我跑水稻 SPL 家族鉴定')).toBeNull();
|
||||
expect(extractReuseName('')).toBeNull();
|
||||
});
|
||||
it('片段长度不足 3 或超 20 返回 null', () => {
|
||||
expect(extractReuseName('用 AB 存档任务的方法帮我跑水稻 SPL 家族鉴定')).toBeNull();
|
||||
// 21 字符片段超上限
|
||||
expect(extractReuseName('用 一二三四五六七八九十一二三四五六七八九十一 存档任务的方法帮我跑水稻 SPL')).toBeNull();
|
||||
it('非扩展句不命中', () => {
|
||||
expect(isExtensionCreateIntent('帮我跑水稻 SPL 家族鉴定')).toBe(false);
|
||||
expect(isExtensionCreateIntent('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/frontend/unit/bioflow/legacy.block.test.js —— S3 旧生信块只读降级(LegacyBioflowBlock)
|
||||
// 覆盖:四类旧块标题映射 / bioflow_qa 引用列表(含空态)/ 任务类块 jobId 前缀 / 只读(无可交互控件)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import LegacyBioflowBlock from '../../../../ui/src/components/chat/LegacyBioflowBlock.vue';
|
||||
|
||||
describe('LegacyBioflowBlock —— 旧生信块只读降级(S3)', () => {
|
||||
it('bioflow_qa 渲染只读引用列表(title + citations)', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: {
|
||||
block: {
|
||||
type: 'bioflow_qa',
|
||||
citations: [{ text: '引用正文', source: 's1', title_path: 't1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(w.find('.legacy-bioflow-block').exists()).toBe(true);
|
||||
expect(w.findAll('.lbf-cite')).toHaveLength(1);
|
||||
expect(w.text()).toContain('引用正文');
|
||||
});
|
||||
|
||||
it('bioflow_qa 空引用显示空态文案', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: { block: { type: 'bioflow_qa', citations: [] } },
|
||||
});
|
||||
expect(w.find('.lbf-empty').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('任务类块(bioflow_progress)显示 jobId 前缀 + 只读提示', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: { block: { type: 'bioflow_progress', jobId: 'job-9' } },
|
||||
});
|
||||
expect(w.find('.lbf-job').exists()).toBe(true);
|
||||
expect(w.text()).toContain('job-9');
|
||||
expect(w.find('.lbf-empty').exists()).toBe(true); // 只读提示
|
||||
});
|
||||
|
||||
it('各类旧块标题映射非空(path/form/file/archive/progress)', () => {
|
||||
for (const type of ['bioflow_path', 'bioflow_form', 'bioflow_file', 'bioflow_archive', 'bioflow_progress']) {
|
||||
const w = mount(LegacyBioflowBlock, { props: { block: { type } } });
|
||||
expect(w.find('.lbf-title').text()).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('未知类型回退 legacyBlockTitle 不抛错', () => {
|
||||
const w = mount(LegacyBioflowBlock, { props: { block: { type: 'bioflow_unknown' } } });
|
||||
expect(w.find('.lbf-title').text()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('只读:不渲染 button/input/a 等可交互控件(旧块 hydrate 不报错)', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: { block: { type: 'bioflow_progress', jobId: 'j1', citations: [] } },
|
||||
});
|
||||
expect(w.find('button').exists()).toBe(false);
|
||||
expect(w.find('input').exists()).toBe(false);
|
||||
expect(w.find('a').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -331,3 +331,27 @@ describe('BioArchivePane —— TC-BIO-PANE-001~016(L4)', () => {
|
||||
expect(sizes[0].text()).toContain('KB');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// S3:BioJobsPane 日志自动滚底(实时跟随最新一行)
|
||||
// ===========================================================================
|
||||
describe('BioJobsPane 日志自动滚底(S3)', () => {
|
||||
it('展开日志后追加行 → scrollTop 置为 scrollHeight(保持在最新一行)', async () => {
|
||||
const w = mount(BioJobsPane);
|
||||
await flushPromises();
|
||||
await w.findAll('.bj-item')[0].trigger('click');
|
||||
await flushPromises();
|
||||
await w.findAll('.bj-dag-row')[0].trigger('click');
|
||||
await flushPromises();
|
||||
const pre = w.find('.bj-log-body').element;
|
||||
// jsdom 不实现 scrollHeight(恒 0),此处 mock 为确定值以验证滚底赋值
|
||||
Object.defineProperty(pre, 'scrollHeight', { configurable: true, get: () => 800 });
|
||||
// 追加一行日志(轮询增量)→ watch 触发 → nextTick 滚底
|
||||
const bioflow = useBioflowStore();
|
||||
bioflow.stageLogs['job-1:identify'].lines.push('appended-log-line');
|
||||
await flushPromises();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // 放行 nextTick 链
|
||||
expect(pre.scrollTop).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/frontend/unit/bioflow/settings.test.js —— S5 生信能力装卸(settings store)
|
||||
// 覆盖:isInstalled 判定 / toggleInstalled register↔unregister / 未知能力抛错 / 装卸后重拉清单。
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
vi.mock('../../../../ui/src/api/capabilities.js', () => ({
|
||||
installCapability: vi.fn(async () => ({ ok: true })),
|
||||
BIOFLOW_MANIFESTS: {
|
||||
'bioflow-gene-family': 'capabilities:\n - name: bioflow-gene-family',
|
||||
'bioflow-rnaseq': 'capabilities:\n - name: bioflow-rnaseq',
|
||||
'bioflow-extension': 'capabilities:\n - name: bioflow-extension',
|
||||
},
|
||||
getCapabilities: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../ui/src/api/http.js', () => ({
|
||||
get: vi.fn(async () => ({ capabilities: [], defaults: {}, installed: [], load_warnings: [] })),
|
||||
post: vi.fn(async () => ({})),
|
||||
patch: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
import { useSettingsStore } from '../../../../ui/src/stores/settings.js';
|
||||
import * as capabilitiesApi from '../../../../ui/src/api/capabilities.js';
|
||||
|
||||
function setup() {
|
||||
setActivePinia(createPinia());
|
||||
return useSettingsStore();
|
||||
}
|
||||
|
||||
describe('SettingsStore —— 生信能力装卸(S5)', () => {
|
||||
beforeEach(() => {
|
||||
capabilitiesApi.installCapability.mockClear();
|
||||
});
|
||||
|
||||
it('isInstalled:installed 数组含该能力名 → true,否则 false', () => {
|
||||
const s = setup();
|
||||
s.installed = [{ name: 'bioflow-gene-family' }];
|
||||
expect(s.isInstalled('bioflow-gene-family')).toBe(true);
|
||||
expect(s.isInstalled('bioflow-rnaseq')).toBe(false);
|
||||
expect(s.isInstalled('bioflow-extension')).toBe(false);
|
||||
});
|
||||
|
||||
it('toggleInstalled:未安装 → register;已安装 → unregister', async () => {
|
||||
const s = setup();
|
||||
s.installed = [];
|
||||
await s.toggleInstalled('bioflow-gene-family');
|
||||
expect(capabilitiesApi.installCapability).toHaveBeenLastCalledWith(
|
||||
'register', expect.any(String), 'bioflow-gene-family',
|
||||
);
|
||||
|
||||
s.installed = [{ name: 'bioflow-gene-family' }];
|
||||
await s.toggleInstalled('bioflow-gene-family');
|
||||
expect(capabilitiesApi.installCapability).toHaveBeenLastCalledWith(
|
||||
'unregister', '', 'bioflow-gene-family',
|
||||
);
|
||||
});
|
||||
|
||||
it('toggleInstalled:未知生信能力抛错', async () => {
|
||||
const s = setup();
|
||||
await expect(s.toggleInstalled('bogus')).rejects.toThrow('未知生信能力');
|
||||
});
|
||||
|
||||
it('toggleInstalled:装卸后重拉清单刷新 installed(load 被调用)', async () => {
|
||||
const s = setup();
|
||||
s.installed = [];
|
||||
const loadSpy = vi.spyOn(s, 'load').mockResolvedValue(undefined);
|
||||
await s.toggleInstalled('bioflow-rnaseq');
|
||||
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// api/capabilities.js —— 能力清单端点(S4 BFF;GET /api/capabilities)+ 装卸接口(S5)。
|
||||
// 生信 TAB 显隐的主信号源:installed 数组含 bioflow-* 能力即视为「生信能力已安装」。
|
||||
import { get, post } from './http.js';
|
||||
|
||||
/** GET /api/capabilities —— 能力清单 + 默认值 + 当前会话开关 + QA 已装插件 */
|
||||
export function getCapabilities(params = {}, opts = {}) {
|
||||
return get('/api/capabilities', params, { timeout: 5000, ...opts });
|
||||
}
|
||||
|
||||
/** POST /api/capabilities/install —— 按能力 register/unregister(透传 QA,热加载不重启) */
|
||||
export function installCapability(action, manifest, name) {
|
||||
return post('/api/capabilities/install', { action, manifest, name });
|
||||
}
|
||||
|
||||
// 三个生信能力的 register manifest(与 agent/qa/configs/capabilities.yaml 一致,热装卸用)。
|
||||
export const BIOFLOW_MANIFESTS = {
|
||||
'bioflow-gene-family': [
|
||||
'capabilities:',
|
||||
' - name: bioflow-gene-family',
|
||||
' provider: mcp',
|
||||
' permission: read_only',
|
||||
' description: 基因家族分析(路径规划/建任务/参数确认/文件获取与预处理/状态查询),自然语言触发',
|
||||
' session_default: true',
|
||||
' nl_triggers: [基因家族, 家族成员, 亚家族, 系统发育, 建树, 进化树, 保守基序, motif, 结构域, 基因结构, 共线性, Ka/Ks, 选择压力, 组合证据]',
|
||||
' mcp:',
|
||||
' url: http://127.0.0.1:8098/mcp/gene-family/',
|
||||
' prompt_pack:',
|
||||
' file: bioflow_gene_family_agent_rules.md',
|
||||
].join('\n'),
|
||||
'bioflow-rnaseq': [
|
||||
'capabilities:',
|
||||
' - name: bioflow-rnaseq',
|
||||
' provider: mcp',
|
||||
' permission: read_only',
|
||||
' description: 转录组分析(实验设计确认/样本配置/路径规划/建任务/差异表达/富集),自然语言触发',
|
||||
' session_default: true',
|
||||
' nl_triggers: [转录组, RNA-seq, RNAseq, rnaseq, 差异表达, 表达定量, 富集, WGCNA, GSEA, 实验设计, fastq]',
|
||||
' mcp:',
|
||||
' url: http://127.0.0.1:8098/mcp/rnaseq/',
|
||||
' prompt_pack:',
|
||||
' file: bioflow_rnaseq_agent_rules.md',
|
||||
].join('\n'),
|
||||
'bioflow-extension': [
|
||||
'capabilities:',
|
||||
' - name: bioflow-extension',
|
||||
' provider: mcp',
|
||||
' permission: read_only',
|
||||
' description: 用户自定义扩展分析(创建/转换/试运行/校验/发布/运行),自然语言触发',
|
||||
' session_default: true',
|
||||
' nl_triggers: [扩展, 拓展分析, 扩展分析, 自定义分析, 分析模板, 分析脚本, 分析工具]',
|
||||
' mcp:',
|
||||
' url: http://127.0.0.1:8098/mcp/extension/',
|
||||
' prompt_pack:',
|
||||
' file: bioflow_extension_agent_rules.md',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
// 生信能力名(与 BIOFLOW_MANIFESTS 键一致;SettingsPanel 三开关与 TAB 显隐共用)
|
||||
export const BIOFLOW_CAPABILITY_NAMES = Object.keys(BIOFLOW_MANIFESTS);
|
||||
@@ -164,6 +164,16 @@ function closeStageLog() {
|
||||
logStage.value = null;
|
||||
}
|
||||
|
||||
// 实时跟随:日志行追加(轮询增量)时自动滚底,保持在最新一行。
|
||||
watch(
|
||||
() => (logRec.value ? logRec.value.lines.length : 0),
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 面板定位消费(聊天进度条点击 → scrollIntoView + hl-fade + 展开 log) ----
|
||||
const hlStage = ref(null);
|
||||
function consumeLocate(payload) {
|
||||
|
||||
@@ -17,6 +17,11 @@ 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';
|
||||
import ExtensionBuildCard from './ExtensionBuildCard.vue';
|
||||
@@ -229,15 +234,26 @@ const degradedNote = computed(() => props.message.degradedNote || null);
|
||||
:block="b"
|
||||
/>
|
||||
|
||||
<!-- P2 生信:流程知识库引用块(FR-BIO-026;引用携带 cite 供跳转) -->
|
||||
<div v-if="b.type === 'bioflow_qa'" class="bioflow-qa-block">
|
||||
<div v-for="(c, ci) in b.citations" :key="ci" class="bf-qa-cite">
|
||||
<span class="bf-qa-idx">{{ ci + 1 }}</span>
|
||||
<span class="bf-qa-text">{{ c.text || c.title_path || c.source }}</span>
|
||||
<span class="bf-qa-src">{{ c.title_path || c.source }}</span>
|
||||
</div>
|
||||
<div v-if="!b.citations || !b.citations.length" class="bf-qa-empty">{{ BF.flowQaEmpty }}</div>
|
||||
</div>
|
||||
<!-- 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" />
|
||||
|
||||
<!-- 用户自定义扩展分析:上传引导卡(extension_upload) -->
|
||||
<ExtensionUploadCard
|
||||
|
||||
@@ -17,8 +17,7 @@ import { T } from '../../i18n/labels.js';
|
||||
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { route, paperIntent } from '../../intent.js';
|
||||
import { route as routeBioflow, isExtensionCreateIntent } from '../../utils/intent.js';
|
||||
import { uploadLibrary, listLibrary } from '../../api/docgen.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';
|
||||
@@ -279,20 +278,13 @@ const submit = () => {
|
||||
} catch {
|
||||
intent = 'qa'; // 路由异常按漏判安全处理
|
||||
}
|
||||
// 生信意图(含图表调整走生信 FR-BIO-019):词典同表双跑(utils/intent.js)
|
||||
let bioflowRoute = { intent: 'qa', reuseName: null };
|
||||
if (intent !== 'docgen' && intent !== 'edit' && intent !== 'export' && bioflowEnabled.value) {
|
||||
try {
|
||||
bioflowRoute = routeBioflow(text, hasAttachments.value, hasActiveArtifact.value, hasBioflowContext.value);
|
||||
} catch {
|
||||
bioflowRoute = { intent: 'qa', reuseName: null };
|
||||
}
|
||||
}
|
||||
// 用户自定义扩展分析:A 层创建意图(EXTENSION_CONTRACT.md §5 词典同表双跑;
|
||||
// 「创建/上传 一个扩展」→ sendExtension 上传引导卡。歧义消解与运行触发由
|
||||
// 服务端 intent.py 在 chat/stream 内判定——前端只做确定性创建识别,漏判安全。)
|
||||
// 生信意图(基因家族/转录组)S3 起不再走前端词典分流:一律进 QA 流,
|
||||
// 由 bioflow_agent 在服务端经 nl_triggers 判定(统一由 QA 流承载)。
|
||||
let extIntent = false;
|
||||
if (bioflowRoute.intent !== 'bioflow_qa' && bioflowRoute.intent !== 'bioflow' && bioflowEnabled.value) {
|
||||
if (bioflowEnabled.value) {
|
||||
try {
|
||||
extIntent = isExtensionCreateIntent(text);
|
||||
} catch {
|
||||
@@ -306,34 +298,6 @@ const submit = () => {
|
||||
} catch {
|
||||
emit('send', text); // chat store 未就绪(测试/降级):漏判安全退化为 QA 路径
|
||||
}
|
||||
} else if (bioflowRoute.intent === 'bioflow_qa') {
|
||||
// 生信流内流程咨询(FR-BIO-026):也走 sendBioflow(后端流内返引用块,不新建任务)
|
||||
try {
|
||||
useChatStore().sendBioflow(text, { mode: 'ask' }).catch(() => { /* 内部已标注错误 */ });
|
||||
} catch {
|
||||
emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径
|
||||
}
|
||||
} else if (bioflowRoute.intent === 'bioflow') {
|
||||
// 生信分析意图:chat.sendBioflow(plan_ready → 路径卡 → 任务事件)
|
||||
try {
|
||||
const jobId = null; // 任务未创建:文件上传待任务生成后由 FlowFileCard 引导
|
||||
const chat = useChatStore();
|
||||
chat.sendBioflow(text, { mode: 'ask', reuseName: bioflowRoute.reuseName }).catch(() => { /* 内部已标注错误 */ });
|
||||
// 附件按生信文件上传(XHR 进度;FR-BIO-006):任务 id 由 sendBioflow 创建任务后
|
||||
// 回填——任务创建前的拖入文件由 FlowFileCard 阶段引导卡处理,此处兼容
|
||||
// 已存在任务场景(当前会话任务 id 已知时直接上传)。
|
||||
if (readyAttachments.value.length) {
|
||||
const bioflowStore = useBioflowStore();
|
||||
const jobIdNow = jobId || (bioflowStore.currentJob && bioflowStore.currentJob.jobId);
|
||||
if (jobIdNow) {
|
||||
for (const a of readyAttachments.value) {
|
||||
chat.uploadBioflowFileToJob(jobIdNow, a.file).catch(() => { /* 失败可见:chips 显示错误 */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径
|
||||
}
|
||||
} else if (intent === 'docgen') {
|
||||
// 文档生成分支:论文撰写 sub-route(paperIntent && paperEnabled → sendPaper),
|
||||
// 否则 sendDocgen(降级路径零改动——paper 未启用/未命中时触发词落 docgen 单发)。
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -5,7 +5,7 @@
|
||||
// 复用 FlowFormCard 视觉令牌(字段行 label 96px / input 同款 / 主按钮 gradient)。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { buildExtension } from '../../api/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -13,6 +13,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['submitted', 'error']);
|
||||
|
||||
const chat = useChatStore();
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitError = ref('');
|
||||
|
||||
@@ -43,27 +45,25 @@ const hasDeps = computed(() =>
|
||||
(Array.isArray(reqs.value.pip) && reqs.value.pip.length > 0)
|
||||
|| (Array.isArray(reqs.value.apt) && reqs.value.apt.length > 0));
|
||||
|
||||
/** 提交修正:POST build 带修正值(后端按 ext_id 重入转换) */
|
||||
/** 提交(S4 改走 card_result 进 QA 流):
|
||||
* - skip=true(跳过/采纳 AI 结果)→ 重触发 AI 转换(build)
|
||||
* - skip=false(编辑修正)→ confirm 语义(复用 confirm_extension 编辑值入参) */
|
||||
async function submit({ skip = false } = {}) {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
submitError.value = '';
|
||||
try {
|
||||
const body = {};
|
||||
if (!skip) {
|
||||
if (skip) {
|
||||
await chat.sendCardResult('extension_action', { action: 'build', ext_id: props.block.extId });
|
||||
} else {
|
||||
if (!templateDraft.value.trim()) { submitError.value = BF.extBuildFieldRequired; submitting.value = false; return; }
|
||||
body.command_template = templateDraft.value.trim();
|
||||
body.runtime_image = imageDraft.value.trim() || undefined;
|
||||
body.nl_triggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
body.analysis_type = typeDraft.value.trim() || undefined;
|
||||
}
|
||||
await buildExtension(props.block.extId, body);
|
||||
// 提交成功以本地修正为准(后端重入结果由 extension_build 帧再次刷新)
|
||||
if (!skip) {
|
||||
props.block.commandTemplate = templateDraft.value.trim();
|
||||
props.block.runtimeImage = imageDraft.value.trim() || props.block.runtimeImage;
|
||||
props.block.nlTriggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
props.block.analysisType = typeDraft.value.trim() || props.block.analysisType;
|
||||
await chat.sendCardResult('extension_action', {
|
||||
action: 'confirm',
|
||||
ext_id: props.block.extId,
|
||||
nl_triggers: triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean),
|
||||
parameters: props.block.parameters || [],
|
||||
runtime_image: imageDraft.value.trim(),
|
||||
});
|
||||
}
|
||||
props.block.submitted = true;
|
||||
emit('submitted', { extId: props.block.extId, skip });
|
||||
|
||||
@@ -47,10 +47,10 @@ async function run() {
|
||||
else if (f.type === 'float' || f.type === 'number') params[f.name] = parseFloat(v);
|
||||
else params[f.name] = String(v);
|
||||
}
|
||||
const result = await chat.confirmExtensionRun(props.block, {
|
||||
const result = await chat.sendCardResult('extension_action', {
|
||||
action: 'run',
|
||||
ext_id: props.block.extId,
|
||||
params,
|
||||
files: [],
|
||||
aMsg: props.message || null,
|
||||
});
|
||||
emit('submitted', { extId: props.block.extId, result });
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 发布成功后 extension_published 帧挂发布确认块(本卡随试运行卡同帧刷新)。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { publishExtension } from '../../api/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['published', 'error']);
|
||||
|
||||
const chat = useChatStore();
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitError = ref('');
|
||||
const triggersDraft = ref('');
|
||||
@@ -29,11 +31,12 @@ async function publish() {
|
||||
submitError.value = '';
|
||||
try {
|
||||
const nlTriggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
const data = await publishExtension(props.block.extId, { nl_triggers: nlTriggers });
|
||||
const result = (data && data.data) || data || {};
|
||||
if (Array.isArray(result.nl_triggers)) props.block.nlTriggers = result.nl_triggers;
|
||||
else props.block.nlTriggers = nlTriggers;
|
||||
if (result.display_name) props.block.displayName = result.display_name;
|
||||
await chat.sendCardResult('extension_action', {
|
||||
action: 'publish',
|
||||
ext_id: props.block.extId,
|
||||
nl_triggers: nlTriggers,
|
||||
});
|
||||
props.block.nlTriggers = nlTriggers;
|
||||
props.block.published = true;
|
||||
emit('published', { extId: props.block.extId, nlTriggers: props.block.nlTriggers });
|
||||
} catch (err) {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
import { repairExtension } from '../../api/bioflow.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -36,17 +35,17 @@ function onGoldenChange(e) {
|
||||
if (e.target) e.target.value = '';
|
||||
}
|
||||
|
||||
/** 上传测试输入 → 触发试运行(服务端跑容器,结果经 extension_trial 帧回流) */
|
||||
/** 试运行(S4 改走 card_result 进 QA 流;测试输入文件上传为后置上传,另行登记) */
|
||||
async function runTrial() {
|
||||
if (running.value || repairing.value) return;
|
||||
if (!props.block.extId) { submitError.value = BF.extTrialNoExtId; return; }
|
||||
if (!testInput.value) { submitError.value = BF.extTrialInputEmpty; return; }
|
||||
running.value = true;
|
||||
submitError.value = '';
|
||||
try {
|
||||
const files = [testInput.value];
|
||||
if (goldenInput.value) files.push(goldenInput.value);
|
||||
const result = await chat.trialRunExtension(props.block.extId, files);
|
||||
const result = await chat.sendCardResult('extension_action', {
|
||||
action: 'trial_run',
|
||||
ext_id: props.block.extId,
|
||||
});
|
||||
emit('trialled', { extId: props.block.extId, result });
|
||||
} catch (err) {
|
||||
submitError.value = err?.message || BF.extUploadError;
|
||||
@@ -63,7 +62,7 @@ async function doRepair() {
|
||||
repairing.value = true;
|
||||
submitError.value = '';
|
||||
try {
|
||||
await repairExtension(props.block.extId, {});
|
||||
await chat.sendCardResult('extension_action', { action: 'repair', ext_id: props.block.extId });
|
||||
} catch (err) {
|
||||
submitError.value = err?.message || BF.extTrialRepairFailed;
|
||||
emit('error', err);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 复用 FlowFormCard 字段行令牌。确认成功后由服务端 extension_published 帧收尾。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { buildExtension } from '../../api/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['submitted', 'error']);
|
||||
|
||||
const chat = useChatStore();
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitError = ref('');
|
||||
const values = ref({});
|
||||
@@ -37,7 +39,7 @@ const goldenOk = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
/** 确认:重新 build(门禁纪律:确认前不推进生命周期;修正值随 build 重入)。 */
|
||||
/** 确认(S4 改走 card_result):编辑修正(form_defaults 参数)→ confirm 语义。 */
|
||||
async function confirm() {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
@@ -49,7 +51,13 @@ async function confirm() {
|
||||
if (v === '' || v === null || v === undefined) continue;
|
||||
params[f.name] = v;
|
||||
}
|
||||
await buildExtension(props.block.extId, { params });
|
||||
// 编辑修正走 confirm 环节(复用 confirm_extension 编辑值入参)
|
||||
await chat.sendCardResult('extension_action', {
|
||||
action: 'confirm',
|
||||
ext_id: props.block.extId,
|
||||
parameters: props.block.parameters || [],
|
||||
runtime_image: '',
|
||||
});
|
||||
props.block.confirmed = true;
|
||||
emit('submitted', { extId: props.block.extId, params });
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -15,6 +16,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['dismissed', 'archived', 'error']);
|
||||
|
||||
const bioflow = useBioflowStore();
|
||||
const chat = useChatStore();
|
||||
|
||||
const nameDraft = ref(props.block.name || '');
|
||||
const descDraft = ref(props.block.description || '');
|
||||
@@ -50,6 +52,12 @@ async function doArchive() {
|
||||
// 存档状态回复(FR-BIO-021):名称/位置/大小三要素写回块状态
|
||||
props.block.status = 'done';
|
||||
props.block.result = result;
|
||||
// S3:同步发 card_result 进 QA 流,让 BioflowAgent 自然语言复述存档决定(语义一致)
|
||||
chat.sendCardResult('archive_choice', {
|
||||
job_id: props.block.jobId,
|
||||
archive: true,
|
||||
name: props.block.name,
|
||||
}).catch(() => { /* 复述失败不影响已完成的存档 */ });
|
||||
emit('archived', result);
|
||||
} catch (err) {
|
||||
errorText.value = err?.message || BF.archiveError;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
})
|
||||
@@ -84,16 +85,16 @@ function onInputChange(e) {
|
||||
if (e.target) e.target.value = ''; // 允许重复选择同一文件
|
||||
}
|
||||
|
||||
// ---- 服务器路径登记(FR-BIO-007) ----
|
||||
// ---- 服务器路径登记(FR-BIO-007;S3 改走 card_result 进 QA 流) ----
|
||||
async function registerPath() {
|
||||
const p = pathDraft.value.trim();
|
||||
if (!p || registering.value || !jobId.value) return;
|
||||
registering.value = true;
|
||||
registerError.value = '';
|
||||
try {
|
||||
const res = await chat.registerBioflowPath(jobId.value, p);
|
||||
await chat.sendCardResult('file_upload', { job_id: jobId.value, paths: [p] });
|
||||
pathDraft.value = '';
|
||||
emit('registered', { path: p, res });
|
||||
emit('registered', { path: p });
|
||||
} catch (err) {
|
||||
registerError.value = err?.message || BF.filePathError;
|
||||
emit('error', err);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -18,6 +19,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['submitted', 'error']);
|
||||
|
||||
const bioflow = useBioflowStore();
|
||||
const chat = useChatStore();
|
||||
|
||||
const fields = computed(() => props.block.fields || []);
|
||||
const stage = computed(() => props.block.stage || '');
|
||||
@@ -153,7 +155,8 @@ async function submit() {
|
||||
} else {
|
||||
const jobId = bioflow.currentJob.jobId || props.block.jobId;
|
||||
if (!jobId) throw new Error('no job');
|
||||
await bioflow.confirmStage(stage.value, params);
|
||||
// S3:卡片不直连控制面——参数确认改为 card_result 消息进 QA 流(BioflowAgent 经 confirm_stage 执行)
|
||||
await chat.sendCardResult('form_confirm', { job_id: jobId, stage: stage.value, params });
|
||||
props.block.submitted = true;
|
||||
emit('submitted', { stage: stage.value, params });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// 复用句式(FR-BIO-023):候选来自存档装载,卡片头部明示「复用存档 XXX 的方法」。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useBioflowStore, inferBioflowChain } from '../../stores/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -61,27 +61,22 @@ 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;
|
||||
error.value = '';
|
||||
try {
|
||||
// r4 修复:不再 emit('updated')(该事件全链无人监听,任务创建后 UI 静默)——
|
||||
// 改走 chat store 一站式 confirmBioflowPath:创建 → 挂进度块 → 订阅任务事件
|
||||
// 桥接推进 → 落库。aMsg 从 chat 消息流定位(当前消息 = 含本 path 块的 assistant 消息)。
|
||||
const aMsg = (chat.messages || []).find(
|
||||
(m) => m && m.role === 'assistant' && Array.isArray(m.blocks) && m.blocks.includes(props.block),
|
||||
);
|
||||
const detail = await chat.confirmBioflowPath({
|
||||
title: (chat.messages || []).find((m) => m.role === 'user')?.text?.slice(0, 50) || '生信任务',
|
||||
// S3:卡片不直连控制面——确认路径改为生成 card_result 消息进 QA 流,
|
||||
// 由 BioflowAgent 经 create_*_job 工具执行并自然语言复述。
|
||||
props.block.jobId = null; // 由 QA 流返回的 active_job_id 回填
|
||||
await chat.sendCardResult('path_selection', {
|
||||
chain: inferBioflowChain(cand.stages),
|
||||
stages: cand.stages,
|
||||
title: (chat.messages || []).find((m) => m.role === 'user')?.text?.slice(0, 50) || '生信任务',
|
||||
auto_continue: false,
|
||||
params: {},
|
||||
autoContinue: false,
|
||||
archiveId: reuseArchive.value || null,
|
||||
aMsg: aMsg || null,
|
||||
});
|
||||
// 进度块由 confirmBioflowPath 挂载;此处标记 path 块已确认
|
||||
props.block.jobId = (detail && detail.id) || null;
|
||||
} catch (err) {
|
||||
error.value = err?.message || BF.pathStageCreating;
|
||||
emit('flow-error', err);
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
<script setup>
|
||||
// chat/FlowProgressCard.vue —— 生信阶段进度卡(FR-BIO-011/012/013/014,UI_SPEC §8.4)
|
||||
// 结构/状态点/连接线/相位色复用 §4.4 pipeline-card 全部令牌(PipelineCard 零改动,
|
||||
// 本卡按 §8.4 同款视觉独立实现):
|
||||
// done=--pa-t-done 底白 ✓;running=相位色底 ● + pulse;waiting=surface-3 底 ◌
|
||||
// 新增两态与附加区(§8.4):
|
||||
// failed 态:--pa-error 底 ✕,状态文字 error 色 12px/600
|
||||
// skipped 态:--pa-ink-subtle 底 ◌,状态文字 ink-subtle
|
||||
// 日志尾区:仅 running/failed 展开:--pa-code-bg 底,mono 11px,padding 8px 10px,
|
||||
// max-height 96px 滚动,随 SSE 追加
|
||||
// 三选一按钮:仅 failed:重跑/跳过/改参数重跑,pane-btn 同款 12px,gap 8px
|
||||
// 折叠:阶段 >10 自动分组折叠(组头 12px/600 + 展开箭头),默认展开运行中所在组
|
||||
// 断线回放(FR-BIO-014):block.stages 被快照整体替换(引用变化)时本卡自动整体刷新。
|
||||
// chat/FlowProgressCard.vue —— 生信阶段进度卡(S3 精简为只读)
|
||||
// 只读展示:阶段列表(状态点三态)+ 进度条 + 最近一行日志尾 + 折叠分组。
|
||||
// S3 起去掉暂停/恢复、失败重跑/跳过、改参数重跑等直连控制按钮与 locate 点击——
|
||||
// 任务控制一律走自然语言(QA 流),本卡不直连生信控制面。
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { BF, stageStatusText } from '../../i18n/bioflow.js';
|
||||
import { PHASE_COLOR_VAR } from '../../i18n/labels.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { bioflowCatalog } from '../../api/bioflow.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['rerun', 'skip', 'rerun-with-params', 'pause', 'resume', 'action-error', 'locate']);
|
||||
|
||||
const bioflow = useBioflowStore();
|
||||
|
||||
const stages = computed(() => props.block.stages || []);
|
||||
const jobStatus = computed(() => props.block.jobStatus || bioflow.currentJob.jobStatus);
|
||||
const acting = ref(''); // 'pause' | 'resume' | `rerun:${stage}` | `skip:${stage}`
|
||||
const actionError = ref('');
|
||||
const rerunFormStage = ref(null); // 改参数重跑弹出该阶段参数表单(FR-BIO-012)
|
||||
const jobStatus = computed(() => props.block.jobStatus);
|
||||
|
||||
// 相位色映射(与 PipelineCard PHASE_COLOR_VAR 一致;阶段序号对应五相位循环)
|
||||
function phaseVar(i) {
|
||||
@@ -37,7 +20,6 @@ function phaseVar(i) {
|
||||
return PHASE_COLOR_VAR[keys[i % keys.length]] || PHASE_COLOR_VAR.planning;
|
||||
}
|
||||
|
||||
/** 状态点符号与颜色(三重编码:颜色 + 图标 + 文字) */
|
||||
function dotClass(st) {
|
||||
return {
|
||||
done: 'done',
|
||||
@@ -54,20 +36,13 @@ function dotSymbol(st) {
|
||||
}
|
||||
|
||||
const showLog = (st) => st.status === 'running' || st.status === 'failed';
|
||||
const showActions = (st) => st.status === 'failed';
|
||||
|
||||
// ---- 进度条(进度帧 stage_progress 驱动;点击 → 定位到生信任务 TAB) ----
|
||||
/** 阶段是否有确定性进度(白名单 rnaseq 阶段才有 percent) */
|
||||
function hasPct(st) {
|
||||
return typeof st.progress === 'number' && st.progress >= 0 && st.progress <= 100;
|
||||
}
|
||||
/** 阶段是否处于真运行态(收到过 stage_started;queued 无事件不显示条) */
|
||||
function isLiveRunning(st) {
|
||||
return st.status === 'running';
|
||||
}
|
||||
function locateStage(st) {
|
||||
emit('locate', { jobId: props.block.jobId, stage: st.stage });
|
||||
}
|
||||
|
||||
// ---- 日志尾区自动滚底(SSE 追加,2s 节流) ----
|
||||
const logRefs = ref({});
|
||||
@@ -91,13 +66,12 @@ watch(
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// ---- 折叠(FR-BIO-013:阶段 >10 自动分组折叠;默认展开运行中所在组) ----
|
||||
// ---- 折叠(阶段 >10 自动分组折叠;默认展开运行中所在组) ----
|
||||
const FOLD_THRESHOLD = 10;
|
||||
const COLLAPSED_GROUP_SIZE = 8;
|
||||
const needsFold = computed(() => stages.value.length > FOLD_THRESHOLD);
|
||||
const openGroups = ref(new Set());
|
||||
|
||||
/** 折叠分组:每组 8 阶段,全部可折叠;默认仅展开运行中所在组(其余折叠,组头可展开) */
|
||||
const groups = computed(() => {
|
||||
const all = stages.value;
|
||||
if (!needsFold.value) return [{ title: null, stages: all, collapsible: false }];
|
||||
@@ -118,7 +92,6 @@ const groups = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
/** 组展开状态:快照整体替换(引用变化)时按运行中所在组重算默认展开 */
|
||||
watch(
|
||||
() => props.block.stages,
|
||||
() => {
|
||||
@@ -144,120 +117,6 @@ function toggleGroup(i) {
|
||||
function isGroupOpen(i) {
|
||||
return openGroups.value.has(i);
|
||||
}
|
||||
|
||||
// ---- 失败三选一(FR-BIO-012) ----
|
||||
async function doRerun(st) {
|
||||
if (acting.value) return;
|
||||
acting.value = `rerun:${st.stage}`;
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.rerunStage(st.stage, null);
|
||||
emit('rerun', st.stage);
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
async function doSkip(st) {
|
||||
if (acting.value) return;
|
||||
acting.value = `skip:${st.stage}`;
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.skipStage(st.stage);
|
||||
emit('skip', st.stage);
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
function doRerunWithParams(st) {
|
||||
rerunFormStage.value = st.stage;
|
||||
// 拉取该阶段目录参数 schema(来源标注=阶段契约文档),表单值空提交即按默认重跑
|
||||
loadRerunSchema(st.stage);
|
||||
}
|
||||
function closeRerunForm() {
|
||||
rerunFormStage.value = null;
|
||||
}
|
||||
|
||||
// ---- 改参数重跑表单(目录 schema 驱动;提交 rerun 带参数) ----
|
||||
const rerunSchema = ref([]);
|
||||
const rerunValues = ref({});
|
||||
const rerunLoading = ref(false);
|
||||
const rerunSubmitting = ref(false);
|
||||
|
||||
async function loadRerunSchema(stage) {
|
||||
rerunSchema.value = [];
|
||||
rerunValues.value = {};
|
||||
rerunLoading.value = true;
|
||||
try {
|
||||
const data = await bioflowCatalog({ stage });
|
||||
const entry = (data && data.data && data.data.stage) || {};
|
||||
rerunSchema.value = Array.isArray(entry.params) ? entry.params : [];
|
||||
for (const p of rerunSchema.value) rerunValues.value[p.name] = p.default ?? '';
|
||||
} catch {
|
||||
rerunSchema.value = []; // 目录不可达:空表单直接重跑(默认参数)
|
||||
} finally {
|
||||
rerunLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRerunForm() {
|
||||
if (rerunSubmitting.value) return;
|
||||
rerunSubmitting.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const params = {};
|
||||
for (const p of rerunSchema.value) {
|
||||
const v = rerunValues.value[p.name];
|
||||
if (v === '' || v === null || v === undefined) continue;
|
||||
params[p.name] = (p.type === 'int' || p.type === 'float' || p.type === 'number') ? Number(v) : String(v);
|
||||
}
|
||||
await bioflow.rerunStage(rerunFormStage.value, params);
|
||||
emit('rerun-with-params', rerunFormStage.value);
|
||||
rerunFormStage.value = null;
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
rerunSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 暂停/恢复(FR-BIO-010) ----
|
||||
const showPause = computed(() => jobStatus.value === 'running');
|
||||
const showResume = computed(() => jobStatus.value === 'paused');
|
||||
async function doPause() {
|
||||
if (acting.value) return;
|
||||
acting.value = 'pause';
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.pauseJob();
|
||||
emit('pause');
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
async function doResume() {
|
||||
if (acting.value) return;
|
||||
acting.value = 'resume';
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.resumeJob();
|
||||
emit('resume');
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -265,12 +124,6 @@ async function doResume() {
|
||||
<div class="pc-head">
|
||||
<span class="pc-title">{{ BF.progTitle }}</span>
|
||||
<div class="pc-head-right">
|
||||
<button v-if="showPause" class="pane-btn" type="button" :disabled="!!acting" @click="doPause">
|
||||
{{ acting === 'pause' ? BF.progPausing : BF.progPause }}
|
||||
</button>
|
||||
<button v-if="showResume" class="pane-btn" type="button" :disabled="!!acting" @click="doResume">
|
||||
{{ acting === 'resume' ? BF.progResuming : BF.progResume }}
|
||||
</button>
|
||||
<span class="badge" :class="jobStatus === 'running' ? 'running' : jobStatus === 'paused' ? 'idle' : jobStatus === 'done' ? 'done' : jobStatus === 'failed' ? 'error' : 'idle'">
|
||||
<span class="dot"></span>
|
||||
{{ jobStatus === 'running' ? BF.progBadgeRunning : jobStatus === 'paused' ? BF.progBadgePaused : jobStatus === 'done' ? BF.progBadgeDone : jobStatus === 'failed' ? BF.progBadgeFailed : BF.progBadgeCreated }}
|
||||
@@ -278,7 +131,7 @@ async function doResume() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 折叠分组(FR-BIO-013) -->
|
||||
<!-- 折叠分组 -->
|
||||
<template v-for="(g, gi) in groups" :key="gi">
|
||||
<button
|
||||
v-if="g.collapsible"
|
||||
@@ -301,17 +154,14 @@ async function doResume() {
|
||||
>{{ dotSymbol(st) }}</span>
|
||||
<span class="p-name">{{ st.stage }}</span>
|
||||
<span class="p-desc" :title="st.error || (st.progressMessage || '')">{{ st.progressMessage || st.error || '' }}</span>
|
||||
<!-- 进度条:有 percent → 确定性;running 无 percent → 不确定扫光;其余 → 不显示 -->
|
||||
<button
|
||||
<!-- 进度条(S3 只读,不可点击定位) -->
|
||||
<div
|
||||
v-if="hasPct(st) || (isLiveRunning(st) && !hasPct(st))"
|
||||
class="prog-track clickable"
|
||||
type="button"
|
||||
:title="BF.progLocateHint"
|
||||
@click.stop="locateStage(st)"
|
||||
class="prog-track"
|
||||
>
|
||||
<span v-if="hasPct(st)" class="prog-fill" :class="{ full: st.progress >= 100 }" :style="{ width: `${Math.min(st.progress, 100)}%` }"></span>
|
||||
<span v-else class="prog-indeterminate"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="hasPct(st)" class="prog-pct">{{ Math.min(st.progress, 100) }}%</span>
|
||||
<span class="p-state" :style="st.status === 'running' ? { color: `var(${phaseVar(gi * COLLAPSED_GROUP_SIZE + si)})` } : null">
|
||||
<span v-if="st.status === 'running'" class="spin"></span>
|
||||
@@ -321,57 +171,17 @@ async function doResume() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 日志尾区(§8.4:仅 running/failed 展开;--pa-code-bg mono 11px padding 8px 10px max-height 96px 滚动) -->
|
||||
<!-- 日志尾区(仅 running/failed 展开;最近一行,随 SSE 追加自动滚底) -->
|
||||
<template v-for="st in stages" :key="'log-' + st.stage">
|
||||
<div v-if="showLog(st)" class="pc-log">
|
||||
<div class="pc-log-head">{{ st.stage }} · {{ BF.jobsLogTitle }}</div>
|
||||
<pre class="pc-log-body" :ref="(el) => setLogRef(st.stage, el)">{{ st.log_tail || BF.progLogPlaceholder }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 失败三选一(§8.4:仅 failed;pane-btn 同款 12px,gap 8px) -->
|
||||
<div v-if="showActions(st)" class="pc-actions">
|
||||
<button
|
||||
class="pane-btn"
|
||||
type="button"
|
||||
:disabled="!!acting"
|
||||
@click="doRerun(st)"
|
||||
>{{ acting === 'rerun:' + st.stage ? BF.formSubmitting : BF.progRerun }}</button>
|
||||
<button
|
||||
class="pane-btn"
|
||||
type="button"
|
||||
:disabled="!!acting"
|
||||
@click="doSkip(st)"
|
||||
>{{ acting === 'skip:' + st.stage ? BF.formSubmitting : BF.progSkip }}</button>
|
||||
<button class="pane-btn" type="button" @click="doRerunWithParams(st)">{{ BF.progRerunWithParams }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 改参数重跑:该阶段参数表单(FR-BIO-012;由阶段目录 schema 构造) -->
|
||||
<div v-if="rerunFormStage" class="pc-rerun-form">
|
||||
<div class="pc-rerun-head">
|
||||
<span>{{ BF.rerunFormTitle.replace('{stage}', rerunFormStage) }}</span>
|
||||
<button class="pane-btn" type="button" @click="closeRerunForm">✕</button>
|
||||
</div>
|
||||
<div v-if="rerunLoading" class="pc-rerun-hint">{{ BF.jobsManifestLoading }}</div>
|
||||
<div v-else-if="rerunSchema.length" class="pc-rerun-fields">
|
||||
<div v-for="p in rerunSchema" :key="p.name" class="pc-rerun-field">
|
||||
<span class="pc-rerun-label">{{ p.name }}</span>
|
||||
<input v-model="rerunValues[p.name]" class="pc-rerun-input" type="text" />
|
||||
<span class="pc-rerun-source">{{ BF.formSourceContract }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-rerun-actions">
|
||||
<button class="pane-btn" type="button" @click="closeRerunForm">{{ BF.jobsConfirmNo }}</button>
|
||||
<button class="pane-btn run" type="button" :disabled="rerunSubmitting" @click="submitRerunForm">
|
||||
{{ rerunSubmitting ? BF.formSubmitting : BF.rerunFormSubmit }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="actionError" class="pc-error">{{ actionError }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
/* UI_SPEC §8.4:结构/状态点/连接线/相位色复用 §4.4 pipeline-card 全部令牌 */
|
||||
.bioflow-progress-card {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
// chat/LegacyBioflowBlock.vue —— 旧生信块只读降级渲染(S3)
|
||||
// 用途:旧会话历史里遗留的生信块(bioflow_qa 引用块 / 旧版 bioflow_* 块)在新架构下
|
||||
// 以只读形式降级渲染,保证 hydrate 打开旧会话不报错、不渲染可交互控件。
|
||||
// 仅展示,不发任何控制面请求。
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
});
|
||||
|
||||
function titleFor(type) {
|
||||
switch (type) {
|
||||
case 'bioflow_qa':
|
||||
return BF.legacyQaTitle;
|
||||
case 'bioflow_path':
|
||||
return BF.legacyPathTitle;
|
||||
case 'bioflow_form':
|
||||
return BF.legacyFormTitle;
|
||||
case 'bioflow_file':
|
||||
return BF.legacyFileTitle;
|
||||
case 'bioflow_progress':
|
||||
return BF.legacyProgressTitle;
|
||||
case 'bioflow_archive':
|
||||
return BF.legacyArchiveTitle;
|
||||
default:
|
||||
return BF.legacyBlockTitle;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="legacy-bioflow-block">
|
||||
<div class="lbf-title">{{ titleFor(block.type) }}</div>
|
||||
<!-- 旧 bioflow_qa 引用块:只读引用列表 -->
|
||||
<template v-if="block.type === 'bioflow_qa'">
|
||||
<div v-for="(c, ci) in (block.citations || [])" :key="ci" class="lbf-cite">
|
||||
<span class="lbf-idx">{{ ci + 1 }}</span>
|
||||
<span class="lbf-text">{{ c.text || c.title_path || c.source }}</span>
|
||||
<span class="lbf-src">{{ c.title_path || c.source }}</span>
|
||||
</div>
|
||||
<div v-if="!block.citations || !block.citations.length" class="lbf-empty">{{ BF.flowQaEmpty }}</div>
|
||||
</template>
|
||||
<!-- 旧任务/进度类块:只读摘要(若携带 task/job 信息则显示,否则显示占位) -->
|
||||
<template v-else>
|
||||
<div v-if="block.jobId" class="lbf-job">{{ BF.legacyJobPrefix }} {{ block.jobId }}</div>
|
||||
<div class="lbf-empty">{{ BF.legacyReadonlyHint }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-bioflow-block {
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
opacity: 0.72;
|
||||
background: var(--surface-1, #fff);
|
||||
}
|
||||
.lbf-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #6b7280);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.lbf-cite {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 3px 0;
|
||||
}
|
||||
.lbf-idx {
|
||||
color: var(--accent, #2563eb);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lbf-text {
|
||||
color: var(--text, #111827);
|
||||
}
|
||||
.lbf-src {
|
||||
color: var(--text-dim, #6b7280);
|
||||
font-size: 12px;
|
||||
}
|
||||
.lbf-job {
|
||||
font-size: 13px;
|
||||
color: var(--text, #111827);
|
||||
}
|
||||
.lbf-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim, #9ca3af);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -6,6 +6,7 @@ import { onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useSettingsStore } from '../../stores/settings.js';
|
||||
import { useSessionsStore } from '../../stores/sessions.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import { BIOFLOW_CAPABILITY_NAMES } from '../../api/capabilities.js';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
@@ -15,10 +16,24 @@ const emit = defineEmits(['close']);
|
||||
const settings = useSettingsStore();
|
||||
const sessions = useSessionsStore();
|
||||
const toggling = ref(null);
|
||||
const installing = ref(null);
|
||||
const toggleError = ref('');
|
||||
const modalRef = ref(null);
|
||||
let previouslyFocused = null;
|
||||
|
||||
// 生信能力三开关(S5:装卸 MCP 能力,热加载不重启 QA)
|
||||
const BIOFLOW_LABELS = {
|
||||
'bioflow-gene-family': '基因家族分析',
|
||||
'bioflow-rnaseq': '转录组分析',
|
||||
'bioflow-extension': '自定义扩展分析',
|
||||
};
|
||||
const bioflowCaps = () =>
|
||||
BIOFLOW_CAPABILITY_NAMES.map((name) => ({
|
||||
name,
|
||||
label: BIOFLOW_LABELS[name] || name,
|
||||
enabled: settings.isInstalled(name),
|
||||
}));
|
||||
|
||||
// 抽屉打开时才拉取(组件随 AppShell 常驻挂载,onMounted 时抽屉未开)
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -108,6 +123,20 @@ async function onToggle(cap) {
|
||||
}
|
||||
}
|
||||
|
||||
// S5:生信能力装卸(register/unregister,热加载)
|
||||
async function onToggleInstalled(name) {
|
||||
if (installing.value) return;
|
||||
installing.value = name;
|
||||
toggleError.value = '';
|
||||
try {
|
||||
await settings.toggleInstalled(name);
|
||||
} catch (err) {
|
||||
toggleError.value = err.message || T.settingsToggleFailed;
|
||||
} finally {
|
||||
installing.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -160,6 +189,32 @@ async function onToggle(cap) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">{{ T.settingsBioflow }}</div>
|
||||
<div
|
||||
v-for="cap in bioflowCaps()"
|
||||
:key="cap.name"
|
||||
class="settings-row"
|
||||
>
|
||||
<div class="settings-row-text">
|
||||
<div class="settings-row-name">{{ cap.label }}</div>
|
||||
<div class="settings-row-desc">{{ cap.name }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="toggle"
|
||||
:class="{ on: cap.enabled }"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="String(cap.enabled)"
|
||||
:disabled="installing === cap.name"
|
||||
:aria-label="cap.label"
|
||||
@click="onToggleInstalled(cap.name)"
|
||||
>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">{{ T.settingsInstalled }}</div>
|
||||
<div v-if="!settings.installed.length" class="settings-empty">{{ T.settingsNoPlugins }}</div>
|
||||
|
||||
+37
-1
@@ -16,7 +16,7 @@ export const BF = {
|
||||
pathCandidatePurposeLabel: '用途',
|
||||
pathAutoFilled: '已自动补全',
|
||||
pathConfirm: '确认此路径',
|
||||
pathEmpty: '暂无候选路径',
|
||||
pathEmpty: '正在检索可用分析路径....',
|
||||
pathPlanning: '正在分析需求并规划分析路径…',
|
||||
formConfirmed: '参数已确认',
|
||||
pathCreated: '任务已创建',
|
||||
@@ -169,6 +169,16 @@ export const BF = {
|
||||
bioflowUnavailableNote: '生信服务不可用(探活失败)',
|
||||
// ---- 流程知识库引用块(FR-BIO-026) ----
|
||||
flowQaEmpty: '暂无引用',
|
||||
// ---- 旧生信块只读降级(S3 LegacyBioflowBlock) ----
|
||||
legacyBlockTitle: '生信分析块(已归档,只读)',
|
||||
legacyQaTitle: '流程知识引用(只读)',
|
||||
legacyPathTitle: '分析路径(只读)',
|
||||
legacyFormTitle: '参数确认(只读)',
|
||||
legacyFileTitle: '文件引导(只读)',
|
||||
legacyProgressTitle: '任务进度(只读)',
|
||||
legacyArchiveTitle: '存档提示(只读)',
|
||||
legacyJobPrefix: '任务:',
|
||||
legacyReadonlyHint: '该块来自旧版本会话,已转为只读展示。',
|
||||
// ---- 阶段状态文字(进度卡/面板共用) ----
|
||||
stageStatusWaiting: '等待',
|
||||
stageStatusAwaitFiles: '等待文件',
|
||||
@@ -304,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 工具链最全。
|
||||
|
||||
@@ -181,6 +181,7 @@ export const T = {
|
||||
benchCollapse: '折叠工作台',
|
||||
close: '关闭',
|
||||
settingsTitle: '设置',
|
||||
settingsBioflow: '生信分析能力',
|
||||
settingsInstalled: '已装插件',
|
||||
settingsNoPlugins: '暂无已安装的外部插件(可在对话中安装)',
|
||||
settingsGatedBy: '需服务端环境变量 {env} 开启后生效',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// 与本仓库 docgen store 同约定);文案全部 i18n/bioflow.js;SSE 用 mock 可测。
|
||||
import { defineStore } from 'pinia';
|
||||
import { SseParser } from '../sse/parser.js';
|
||||
import { getCapabilities } from '../api/capabilities.js';
|
||||
import {
|
||||
bioflowHealth,
|
||||
createBioflowJob,
|
||||
@@ -38,6 +39,16 @@ const REGISTERED_JOBS_KEY = 'pa-bioflow-registered-jobs';
|
||||
const JOB_TERMINAL = new Set(['done', 'failed', 'archived']);
|
||||
const STAGE_TERMINAL = new Set(['done', 'failed', 'skipped']);
|
||||
|
||||
// 生信能力名(S3 双信号主信号:任一注册即视为「生信能力已安装」)
|
||||
const BIOFLOW_CAPABILITY_NAMES = ['bioflow-gene-family', 'bioflow-rnaseq', 'bioflow-extension'];
|
||||
|
||||
/** 从 GET /api/capabilities 响应判定是否含生信能力(installed 数组按 name 匹配)。 */
|
||||
function _hasBioflowCapability(payload) {
|
||||
const installed = (payload && payload.installed) || [];
|
||||
if (!Array.isArray(installed)) return false;
|
||||
return installed.some((c) => c && BIOFLOW_CAPABILITY_NAMES.includes(c.name));
|
||||
}
|
||||
|
||||
// 事件 → 阶段状态映射(snapshot 应用与逐帧推进共用同一张表)
|
||||
export const STAGE_STATUS_BY_EVENT = {
|
||||
stage_started: 'running',
|
||||
@@ -187,10 +198,18 @@ export function capLogTail(text) {
|
||||
return text.slice(-4000);
|
||||
}
|
||||
|
||||
/** 从阶段链推断生信能力链(S3 card_result 用):rnaseq 核心阶段带 rnaseq_ 前缀,其余归 gene_family。 */
|
||||
export function inferBioflowChain(stages) {
|
||||
const list = Array.isArray(stages) ? stages.map((s) => String(s || '')) : [];
|
||||
return list.some((s) => s.startsWith('rnaseq_')) ? 'rnaseq' : 'gene_family';
|
||||
}
|
||||
|
||||
export const useBioflowStore = defineStore('bioflow', {
|
||||
state: () => ({
|
||||
// ---- 探活(FR-BIO-025) ----
|
||||
availability: 'unknown', // 'unknown' | 'online' | 'offline'
|
||||
// ---- 探活(FR-BIO-025 / S3 双信号) ----
|
||||
availability: 'unknown', // 'unknown' | 'online' | 'offline'(主信号:能力注册状态)
|
||||
capabilityInstalled: false, // 主信号:GET /api/capabilities 是否含 bioflow-* 能力
|
||||
healthOnline: false, // 次信号:GET /api/bioflow/health 服务健康(仅执行期降级参考)
|
||||
// ---- 当前会话任务(SSE 事件逐帧推进 + 快照整体刷新) ----
|
||||
currentJob: {
|
||||
jobId: null,
|
||||
@@ -234,7 +253,7 @@ export const useBioflowStore = defineStore('bioflow', {
|
||||
|
||||
getters: {
|
||||
bioflowAvailable() {
|
||||
return this.availability === 'online';
|
||||
return this.capabilityInstalled;
|
||||
},
|
||||
hasJob() {
|
||||
return !!this.currentJob.jobId;
|
||||
@@ -258,16 +277,27 @@ export const useBioflowStore = defineStore('bioflow', {
|
||||
|
||||
actions: {
|
||||
// ---------------------------------------------------------------
|
||||
// 探活(FR-BIO-025)
|
||||
// 探活(FR-BIO-025 / S3 双信号)
|
||||
// 主信号 = 能力注册状态(GET /api/capabilities 的 installed 是否含 bioflow-*)
|
||||
// 次信号 = 服务健康(GET /api/bioflow/health,仅执行期降级参考,不影响 TAB 显隐)
|
||||
// ---------------------------------------------------------------
|
||||
async probe({ force = false } = {}) {
|
||||
if (!force && this.availability !== 'unknown') return this.availability;
|
||||
// 主信号:能力注册状态
|
||||
try {
|
||||
const caps = await getCapabilities();
|
||||
this.capabilityInstalled = _hasBioflowCapability(caps);
|
||||
} catch {
|
||||
this.capabilityInstalled = false;
|
||||
}
|
||||
// 次信号:服务健康(保留 probe,已注册但服务不可达时 TAB 仍显示)
|
||||
try {
|
||||
const data = await bioflowHealth();
|
||||
this.availability = data && data.ok ? 'online' : 'offline';
|
||||
this.healthOnline = !!(data && data.ok);
|
||||
} catch {
|
||||
this.availability = 'offline';
|
||||
this.healthOnline = false;
|
||||
}
|
||||
this.availability = this.capabilityInstalled ? 'online' : 'offline';
|
||||
return this.availability;
|
||||
},
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user