diff --git a/.gitignore b/.gitignore index 661b8f0..80ecd76 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ tests/extension_cases_18/ *.bak-* bioflow_service/bioflow.db *.before-* + +# Python 覆盖率运行时产物(coverage.py --cov 生成) +.coverage diff --git a/agent/qa/configs/capabilities.yaml b/agent/qa/configs/capabilities.yaml index f1179b0..9c810af 100644 --- a/agent/qa/configs/capabilities.yaml +++ b/agent/qa/configs/capabilities.yaml @@ -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 diff --git a/agent/qa/env/prod.env.example b/agent/qa/env/prod.env.example index 8fd67bd..db5380f 100644 --- a/agent/qa/env/prod.env.example +++ b/agent/qa/env/prod.env.example @@ -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 diff --git a/agent/qa/prompts/bioflow_extension_agent_rules.md b/agent/qa/prompts/bioflow_extension_agent_rules.md new file mode 100644 index 0000000..12ebba8 --- /dev/null +++ b/agent/qa/prompts/bioflow_extension_agent_rules.md @@ -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 并向用户转述当前阶段与下一步; +- 人工确认边界(确认触发词、发布)不可跳过、不可代用户拍板。 diff --git a/agent/qa/prompts/bioflow_gene_family_agent_rules.md b/agent/qa/prompts/bioflow_gene_family_agent_rules.md new file mode 100644 index 0000000..d9ff3a4 --- /dev/null +++ b/agent/qa/prompts/bioflow_gene_family_agent_rules.md @@ -0,0 +1,32 @@ +# 基因家族分析对话规范(bioflow_gene_family_agent_rules) + +你是基因家族分析助手。用自然语言引导用户从需求到分析完成,全程对话式交互。 + +## 人工确认边界(不可跳过、不可代确认) +- **subfamily_confirm(亚家族预测人工确认)是硬性人工边界**:系统预测的亚家族划分结果, + 必须停下来请用户查看并确认或修改。你绝不能代用户确认,也不能在用户未确认前继续下游分析。 +- 用户提出修改(如"把某成员从亚家族 A 移到亚家族 B")时,先复述修改内容,再请用户做最终确认。 + +## 对话节奏(自然语言主导 + 卡片辅助) +每次需要用户输入/选择时,遵循固定节奏: +1. 先用自然语言说明并**列表展示**选项及含义; +2. 提示用户可用卡片快捷操作,也可直接打字回复(两条通道语义一致); +3. 用户选择后,用自然语言**复述**其选择,确认无误再继续。 + +## 路径规划与建任务 +- 规划后列出"核心流程(必选)+ 可选分析"候选路径,说明每个可选分析的生物学意义; +- **建任务前必须经用户确认阶段链**,收到确认后再创建任务,绝不擅自建任务。 + +## 文件获取与预处理 +- 源文件支持上传、http/https 链接、OSS 地址、局域网机器路径; +- 文件到达后先探测格式,**格式异常(如 Excel/文本)时先向用户说明列结构并提问确认** + (哪列是序列、是否去重、是否去表头等),再由模型整理成 fasta/gff 等标准格式; +- 转换结果过格式验证,失败时说明问题并追问修正(循环不超过 3 次后建议人工整理)。 + +## 参数确认 +- 列"参数名(默认值,含义)"请用户确认或修改;未识别参数时明确指出并给出允许范围。 + +## 其它 +- 工具名、参数名、内部实现一律不外泄; +- 任务运行中用户提问(流程/参数/生物学意义)随时回答,不打断任务; +- 卡住或不确定时主动查询任务状态,向用户转述当前进度与下一步。 diff --git a/agent/qa/prompts/bioflow_rnaseq_agent_rules.md b/agent/qa/prompts/bioflow_rnaseq_agent_rules.md new file mode 100644 index 0000000..14aadc2 --- /dev/null +++ b/agent/qa/prompts/bioflow_rnaseq_agent_rules.md @@ -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、差异基因数目等结果转述)。 diff --git a/agent/qa/src/rag/qa_v1_1_73/api.py b/agent/qa/src/rag/qa_v1_1_73/api.py index 7378440..ed10362 100644 --- a/agent/qa/src/rag/qa_v1_1_73/api.py +++ b/agent/qa/src/rag/qa_v1_1_73/api.py @@ -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( diff --git a/agent/qa/src/rag/qa_v1_1_73/bioflow_agent.py b/agent/qa/src/rag/qa_v1_1_73/bioflow_agent.py new file mode 100644 index 0000000..dd04e3a --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/bioflow_agent.py @@ -0,0 +1,684 @@ +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", +} + + +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 _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, +) -> 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) + 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 路径:门 2 + 门 3 + manifests = _candidate_capabilities(question, capabilities) + 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 + + return _make_payload( + question, + answer, + audit=audit, + capabilities=[m.name for m in manifests], + active_job_id=(bioflow or {}).get("active_job_id"), + chain=(bioflow or {}).get("chain"), + ) + + +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) diff --git a/agent/qa/src/rag/qa_v1_1_73/capabilities/install_manager.py b/agent/qa/src/rag/qa_v1_1_73/capabilities/install_manager.py index 6a0f7a1..511bd1c 100644 --- a/agent/qa/src/rag/qa_v1_1_73/capabilities/install_manager.py +++ b/agent/qa/src/rag/qa_v1_1_73/capabilities/install_manager.py @@ -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"): diff --git a/agent/qa/src/rag/qa_v1_1_73/capabilities/manifests.py b/agent/qa/src/rag/qa_v1_1_73/capabilities/manifests.py index 4d6382a..d44217b 100644 --- a/agent/qa/src/rag/qa_v1_1_73/capabilities/manifests.py +++ b/agent/qa/src/rag/qa_v1_1_73/capabilities/manifests.py @@ -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 diff --git a/agent/qa/src/rag/qa_v1_1_73/capabilities/providers.py b/agent/qa/src/rag/qa_v1_1_73/capabilities/providers.py index 94cab73..28ce3e9 100644 --- a/agent/qa/src/rag/qa_v1_1_73/capabilities/providers.py +++ b/agent/qa/src/rag/qa_v1_1_73/capabilities/providers.py @@ -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() diff --git a/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent.py b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent.py new file mode 100644 index 0000000..d8d0977 --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent.py @@ -0,0 +1,174 @@ +"""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_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 diff --git a/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s2.py b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s2.py new file mode 100644 index 0000000..78bc324 --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s2.py @@ -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"} diff --git a/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s2_supplement.py b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s2_supplement.py new file mode 100644 index 0000000..09948cd --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s2_supplement.py @@ -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 diff --git a/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s4_supplement.py b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s4_supplement.py new file mode 100644 index 0000000..df51ee7 --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s4_supplement.py @@ -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"] diff --git a/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s6_config.py b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s6_config.py new file mode 100644 index 0000000..6bd98f1 --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_s6_config.py @@ -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) == [] diff --git a/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_supplement.py b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_supplement.py new file mode 100644 index 0000000..d37d876 --- /dev/null +++ b/agent/qa/src/rag/qa_v1_1_73/test_bioflow_agent_supplement.py @@ -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 diff --git a/agent/qa/src/rag/qa_v1_1_73/tool_runner.py b/agent/qa/src/rag/qa_v1_1_73/tool_runner.py index ea340e9..1e4b637 100644 --- a/agent/qa/src/rag/qa_v1_1_73/tool_runner.py +++ b/agent/qa/src/rag/qa_v1_1_73/tool_runner.py @@ -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": [...]} """ @@ -64,7 +68,16 @@ class ToolRunner: "tool_names": [], "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 diff --git a/bioflow_service/app/main.py b/bioflow_service/app/main.py index 0e72265..aa04d66 100644 --- a/bioflow_service/app/main.py +++ b/bioflow_service/app/main.py @@ -2,7 +2,8 @@ - 独立部署树 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 等绝不探测/连接)。 """ @@ -17,11 +18,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 +68,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 +90,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 diff --git a/bioflow_service/app/mcp_server.py b/bioflow_service/app/mcp_server.py new file mode 100644 index 0000000..422f5b7 --- /dev/null +++ b/bioflow_service/app/mcp_server.py @@ -0,0 +1,864 @@ +"""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, normalize_archive_name +from .services.bioflow.catalog import GENE_FAMILY_CHAIN +from .services.bioflow.file_transform import ( + inspect_file as _ft_inspect_file, + transform_file as _ft_transform_file, + validate_format, +) +from .services.bioflow.reports import locate_report +from .services.bioflow.runner import RunnerError, ensure_samples_yaml, llm_sample_fallback +from .services.bioflow.source_fetcher import FetchError, fetch_source + +logger = logging.getLogger(__name__) + +# ---- 能力单元阶段白名单 ---- + +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} 失败,可重跑或跳过"}] + # 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]: + """任务存档:打包任务目录 + 落 bio_archives 行(复用 archive_from_job + store.insert_archive)。""" + ctx = _ctx() + record = await load_record(job_id) + if record is None: + return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"} + archive_name = normalize_archive_name(name) + existing = await store.find_archive_by_name(ctx.db, archive_name) + if existing is not None: + return {"ok": False, "code": "ARCHIVE_EXISTS", "message": f"归档名已存在: {archive_name}"} + try: + report_path = locate_report(record.workspace_dir) + result = await archive_from_job( + { + "id": record.id, "session_id": record.session_id, + "title": record.title, "stage_chain": record.stage_chain, + "params": record.params, + }, + record.job_dir, + get_settings().archives_dir_abs, + name=archive_name, + description=description or "", + include_intermediate=bool(include_intermediate), + report_artifact=str(report_path) if report_path else None, + ) + await store.insert_archive(ctx.db, { + "id": result.id, + "job_id": record.id, + "name": archive_name, + "description": description or "", + "stage_chain": record.stage_chain, + "params": record.params, + "report_artifact": result.report_artifact, + "disk_path": result.disk_path, + "size_bytes": result.size_bytes, + }) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "code": "ARCHIVE_FAILED", + "message": f"存档失败: {type(exc).__name__}: {exc}"} + ctx.events.publish("job_archived", {"job_id": record.id, "archive_id": result.id}) + return {"ok": True, "data": { + "id": result.id, + "job_id": record.id, + "name": archive_name, + "description": description or "", + "disk_path": result.disk_path, + "size_bytes": result.size_bytes, + "report_artifact": result.report_artifact, + }} + + +# --------------------------------------------------------------------------- +# 扩展能力单元(第三个能力单元 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 {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"} + + 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 {"ok": False, "code": "PATH_NOT_EXIST", "message": f"路径不存在: {raw}"} + blocked = _blocked_reason(p, settings) + if blocked: + return {"ok": False, "code": "PATH_NOT_ALLOWED", + "message": 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 {"ok": False, "code": "VALIDATION", "message": 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]: + ctx = _ctx() + arch = await store.find_archive_by_name(ctx.db, (name or "").strip()) + if arch is None: + return {"ok": False, "code": "ARCHIVE_NOT_FOUND", "message": f"未找到匹配存档: {name}"} + return {"ok": True, "data": { + "id": arch["id"], "name": arch["name"], "stage_chain": arch["stage_chain"], + "params": arch["params"], "description": arch["description"], + }} + + +async def _fetch_source_file(job_id: str | None, source: str, + credentials: dict[str, Any] | None) -> dict[str, Any]: + settings = get_settings() + dest_dir: Path + if job_id: + record = await load_record(job_id) + if record is None: + return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"} + dest_dir = record.input_dir + else: + dest_dir = Path(settings.jobs_dir_abs) / "_incoming" / uuid.uuid4().hex[:8] + try: + result = await fetch_source(source, dest_dir, credentials=credentials) + except FetchError as exc: + return {"ok": False, "code": exc.code, "message": exc.message} + result["job_id"] = job_id + return result + + +async def _inspect_file(path: str) -> dict[str, Any]: + return _ft_inspect_file(path) + + +async def _transform_file(src_path: str, target_format: str, + column_map: dict[str, str] | None, + dest_path: str | None) -> dict[str, Any]: + from .services.bioflow.file_transform import TransformError + from .services.bioflow.llm import complete_json + + async def llm_adapter(user: str, system: str) -> Any: + return await complete_json(system, user, temperature=0.2, max_tokens=2000) + + try: + return await _ft_transform_file( + src_path, target_format, column_map=column_map, dest_path=dest_path, llm=llm_adapter, + ) + except TransformError as exc: + return {"ok": False, "code": exc.code, "message": exc.message, "details": exc.details} + + +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) + + +# --------------------------------------------------------------------------- +# 构建三个 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) + return gf, rn, ext + + +gene_family_mcp, rnaseq_mcp, extension_mcp = build_mcp_servers() diff --git a/bioflow_service/app/services/bioflow/file_transform.py b/bioflow_service/app/services/bioflow/file_transform.py new file mode 100644 index 0000000..5e4c2fc --- /dev/null +++ b/bioflow_service/app/services/bioflow/file_transform.py @@ -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, + } diff --git a/bioflow_service/app/services/bioflow/source_fetcher.py b/bioflow_service/app/services/bioflow/source_fetcher.py new file mode 100644 index 0000000..0f480f2 --- /dev/null +++ b/bioflow_service/app/services/bioflow/source_fetcher.py @@ -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) diff --git a/bioflow_service/tests/test_file_transform.py b/bioflow_service/tests/test_file_transform.py new file mode 100644 index 0000000..2800730 --- /dev/null +++ b/bioflow_service/tests/test_file_transform.py @@ -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 diff --git a/bioflow_service/tests/test_mcp_server.py b/bioflow_service/tests/test_mcp_server.py new file mode 100644 index 0000000..781e931 --- /dev/null +++ b/bioflow_service/tests/test_mcp_server.py @@ -0,0 +1,1173 @@ +"""tests/test_mcp_server.py —— 生信 MCP server(两个端点 × 工具 × 白名单/幂等/跨链拒绝)。 + +分层标记 -m bioflow_unit(服务层逻辑)+ 少量 bioflow_api(call_tool 集成)。 +覆盖(S0 验收): +- 两个 MCPServer 各自工具集(基因家族 3 专有 + 12 共享;转录组 4 专有 + 12 共享)。 +- create 跨链拒绝(CROSS_CHAIN)/ 白名单放行。 +- job_status 派生 pending_actions(人工边界 requires_human=true)。 +- confirm_stage 未知参数拒绝(UNKNOWN_PARAMS,白名单单一事实源)。 +- 工具名不外泄由 QA agent 负责(本模块不测)。 +""" +from __future__ import annotations + +import asyncio + +import pytest + +from conftest import FakeFlowStore +import app.context as context_mod +from app.context import Context +from app.mcp_server import ( + GENE_FAMILY_STAGES, + HUMAN_CONFIRM_STAGES, + RNASEQ_STAGES, + build_mcp_servers, + derive_pending_actions, +) +from app.services.bioflow.catalog import build_catalog +from app.services.bioflow.planner import Planner +from app.services.bioflow.runner import JobRecord, Runner, RunnerError, STAGE_WAITING_CONFIRM + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +class _NoopTask: + """替换 asyncio.create_task 的 no-op task factory。 + + 单元测试只断言工具「受理」返回值,不驱动真实后台执行循环。否则 create_job + 的 auto_start spawn 出 run_job,会在 run_until_complete 提前返回时泄漏 + aiosqlite 写锁(Database._lock),导致后续跨 loop 的 confirm/pause/resume + 等写库操作永久阻塞。 + """ + + def __init__(self, coro): + self._coro = coro + try: + coro.close() # 避免 "coroutine was never awaited" 警告 + except Exception: # noqa: BLE001 + pass + + def add_done_callback(self, cb): + pass + + def cancel(self): + pass + + +@pytest.fixture() +def mcp_ctx(db, image_map, fake_executor, events, fake_llm, monkeypatch): + """MCP 工具上下文:mock docker/LLM/vstore,注入 app.context.context。""" + catalog = build_catalog(image_map) + runner = Runner(db, events, fake_executor, catalog, asyncio.Semaphore(3), + force_input_check=False, task_factory=_NoopTask) + flow_store = FakeFlowStore([{ + "chunk_key": "c1", "id": "c1", "source": "stage_contract", "doc_kind": "contract", + "stage_name": "identify", "title_path": "Identify Contract", + "path": "gfa_core/schemas/stages/identify_contract_v1.md", + "text": "Identify 需要全蛋白 FASTA 输入。", + }]) + planner = Planner(catalog, flow_store, archive_finder=None) + + async def find_archive(name): + return None + planner.archive_finder = find_archive + + ctx = Context(db=db, events=events, kernel=None, catalog=catalog, runner=runner, + semaphore=runner.semaphore, planner=planner, flow_store=flow_store, + executor=fake_executor) + monkeypatch.setattr(context_mod, "context", ctx) + # 规划 LLM mock + import app.services.bioflow.planner as planner_mod + monkeypatch.setattr(planner_mod, "complete_json", fake_llm) + return ctx + + +# --------------------------------------------------------------------------- +# 工具集 +# --------------------------------------------------------------------------- + +def test_build_two_endpoints_tool_sets(): + gf, rn, ext = build_mcp_servers() + gf_names = _run(gf.list_tools()) + rn_names = _run(rn.list_tools()) + ext_names = _run(ext.list_tools()) + gf_set = {t.name for t in gf_names} + rn_set = {t.name for t in rn_names} + ext_set = {t.name for t in ext_names} + + assert {"plan_gene_family", "create_gene_family_job", "ask_gene_family_question"} <= gf_set + assert {"plan_rnaseq", "create_rnaseq_job", "generate_samples_yaml", "ask_rnaseq_question"} <= rn_set + # 共享组两端点都挂 + 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"} + assert shared <= gf_set + assert shared <= rn_set + # 能力隔离:基因家族专有工具不在转录组端点 + assert "plan_gene_family" not in rn_set + assert "create_rnaseq_job" not in gf_set + # 扩展能力单元(第三个端点):9 个扩展生命周期工具 + assert {"create_extension", "extension_status", "build_extension", + "trial_run_extension", "validate_extension", "confirm_extension", + "publish_extension", "run_extension", "repair_extension"} <= ext_set + # 隔离:扩展工具不挂在基因家族/转录组端点 + assert "create_extension" not in gf_set + assert "run_extension" not in rn_set + + +# --------------------------------------------------------------------------- +# pending_actions 派生(纯函数) +# --------------------------------------------------------------------------- + +def _record(status="running", stages=None, stage_states=None): + rec = JobRecord(id="j1", session_id="s1", title="t", status=status, + stage_chain=stages or ["genome_prepare", "identify"]) + rec._stage_states = stage_states or {s: {"status": "waiting"} for s in rec.stage_chain} + return rec + + +def _fake_runner(): + class R: + def _form_defaults(self, stage, record): + return [{"name": "prefix", "default": "", "type": "string"}] + return R() + + +def test_pending_actions_paused_resume(): + rec = _record(status="paused") + actions = derive_pending_actions(rec, _fake_runner()) + assert actions[0]["action"] == "resume" + assert actions[0]["requires_human"] is False + + +def test_pending_actions_done_archive(): + rec = _record(status="done") + actions = derive_pending_actions(rec, _fake_runner()) + assert actions[0]["action"] == "archive" + + +def test_pending_actions_human_boundary_subfamily_confirm(): + rec = _record(stages=["identify", "subfamily_confirm"], + stage_states={"identify": {"status": "done"}, + "subfamily_confirm": {"status": STAGE_WAITING_CONFIRM}}) + actions = derive_pending_actions(rec, _fake_runner()) + assert actions[0]["action"] == "confirm" + assert actions[0]["stage"] == "subfamily_confirm" + assert actions[0]["requires_human"] is True + assert "form_defaults" in actions[0] + + +def test_pending_actions_rnaseq_de_human_boundary(): + rec = _record(stages=["rnaseq_qc", "rnaseq_de"], + stage_states={"rnaseq_qc": {"status": "done"}, + "rnaseq_de": {"status": STAGE_WAITING_CONFIRM}}) + actions = derive_pending_actions(rec, _fake_runner()) + assert actions[0]["stage"] == "rnaseq_de" + assert actions[0]["requires_human"] is True + + +def test_pending_actions_non_human_confirm_false(): + rec = _record(stages=["identify", "phylogeny"], + stage_states={"identify": {"status": "done"}, + "phylogeny": {"status": STAGE_WAITING_CONFIRM}}) + actions = derive_pending_actions(rec, _fake_runner()) + assert actions[0]["stage"] == "phylogeny" + assert actions[0]["requires_human"] is False + + +def test_pending_actions_failed_rerun(): + rec = _record(stages=["identify"], stage_states={"identify": {"status": "failed"}}) + actions = derive_pending_actions(rec, _fake_runner()) + assert actions[0]["action"] == "rerun_or_skip" + + +def test_pending_actions_await_files(): + rec = _record(stages=["identify"], stage_states={"identify": {"status": "await_files"}}) + actions = derive_pending_actions(rec, _fake_runner()) + assert actions[0]["action"] == "provide_files" + + +# --------------------------------------------------------------------------- +# create 跨链拒绝 / 白名单 +# --------------------------------------------------------------------------- + +def test_create_job_cross_chain_rejected(mcp_ctx): + from app.mcp_server import _create_job + # 转录组阶段打进基因家族能力单元 → 拒绝 + r = _run(_create_job("s1", "t", ["identify", "rnaseq_de"], None, False, None, GENE_FAMILY_STAGES)) + assert r["ok"] is False + assert r["code"] == "CROSS_CHAIN" + # 基因家族阶段打进转录组能力单元 → 拒绝 + r2 = _run(_create_job("s1", "t", ["rnaseq_qc", "identify"], None, False, None, RNASEQ_STAGES)) + assert r2["ok"] is False + assert r2["code"] == "CROSS_CHAIN" + + +def test_create_job_unknown_stage(mcp_ctx): + from app.mcp_server import _create_job + # 阶段在白名单内但不在 catalog(测试映射表仅 8 阶段)→ RunnerError UNKNOWN_STAGE + r = _run(_create_job("s1", "t", ["subfamily_confirm"], None, False, None, GENE_FAMILY_STAGES)) + assert r["ok"] is False + assert r["code"] == "UNKNOWN_STAGE" + + +def test_create_job_whitelist_ok(mcp_ctx): + from app.mcp_server import _create_job, _job_status + r = _run(_create_job("s1", "测试", ["genome_prepare", "identify"], None, False, None, + GENE_FAMILY_STAGES)) + assert r["ok"] is True + job_id = r["data"]["id"] + status = _run(_job_status(job_id)) + assert status["ok"] is True + assert status["data"]["id"] == job_id + assert "pending_actions" in status["data"] + + +# --------------------------------------------------------------------------- +# confirm 白名单(未知参数拒绝) +# --------------------------------------------------------------------------- + +def test_confirm_stage_unknown_params(mcp_ctx): + from app.mcp_server import _confirm_stage, _create_job + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + job_id = r["data"]["id"] + # identify 阶段参数白名单不含 bogus_param + res = _run(_confirm_stage(job_id, "identify", {"bogus_param": "x"})) + assert res["ok"] is False + assert res["code"] == "UNKNOWN_PARAMS" + + +# --------------------------------------------------------------------------- +# call_tool 集成(MCP 协议层) +# --------------------------------------------------------------------------- + +def test_call_tool_roundtrip(mcp_ctx): + gf, _rn, _ext = build_mcp_servers() + r = _run(gf.call_tool("create_gene_family_job", { + "session_id": "s1", "title": "水稻 SPL", "stages": ["genome_prepare", "identify"], + })) + payload = getattr(r, "structuredContent", None) or _content_text(r) + assert payload.get("ok") is True + job_id = payload["data"]["id"] + + r2 = _run(gf.call_tool("job_status", {"job_id": job_id})) + status = getattr(r2, "structuredContent", None) or _content_text(r2) + assert status.get("ok") is True + assert status["data"]["id"] == job_id + + +def _content_text(result): + """从 CallToolResult 抽取文本内容(无 structuredContent 时)。""" + for block in getattr(result, "content", None) or []: + text = getattr(block, "text", None) + if text: + import json + return json.loads(text) + return {} + + +# =========================================================================== +# S0 补强(1)pending_actions 全状态:archived / 无卡点(done·running·skipped·waiting) +# =========================================================================== + +def test_pending_actions_archived_none(): + rec = _record(status="archived") + actions = derive_pending_actions(rec, _fake_runner()) + assert actions[0]["action"] == "none" + assert actions[0]["requires_human"] is False + + +def test_pending_actions_all_done_no_blocking(): + rec = _record(stages=["genome_prepare", "identify"], + stage_states={"genome_prepare": {"status": "done"}, + "identify": {"status": "done"}}) + assert derive_pending_actions(rec, _fake_runner()) == [] + + +def test_pending_actions_running_then_waiting_continue(): + rec = _record(stages=["identify", "phylogeny"], + stage_states={"identify": {"status": "running"}, + "phylogeny": {"status": "waiting"}}) + assert derive_pending_actions(rec, _fake_runner()) == [] + + +def test_pending_actions_skipped_then_waiting_continue(): + rec = _record(stages=["identify", "phylogeny"], + stage_states={"identify": {"status": "skipped"}, + "phylogeny": {"status": "waiting"}}) + assert derive_pending_actions(rec, _fake_runner()) == [] + + +# =========================================================================== +# S0 补强(2)plan 工具:NOT_READY / 成功 / PlannerError 兜底 +# =========================================================================== + +def test_plan_not_ready(mcp_ctx): + from app.mcp_server import _plan + mcp_ctx.planner = None + r = _run(_plan("做基因家族", GENE_FAMILY_STAGES)) + assert r["ok"] is False + assert r["code"] == "NOT_READY" + + +def test_plan_success(mcp_ctx): + from app.mcp_server import _plan + r = _run(_plan("做 SPL 基因家族分析", GENE_FAMILY_STAGES)) + assert r["ok"] is True + assert r["candidates"] + assert r["source"] in ("llm_only", "llm_rag") + + +def test_plan_planner_error_builtin_fallback(mcp_ctx, fake_llm): + from app.mcp_server import _plan + fake_llm.error = "timeout" # LLMTimeout → PlannerError → 内置兜底链 + r = _run(_plan("做 SPL", GENE_FAMILY_STAGES)) + assert r["ok"] is True + assert r["candidates"] + assert r["fallback"] + + +# =========================================================================== +# S0 补强(3)job_status / confirm / rerun / skip / pause / resume 错误路径 +# =========================================================================== + +def test_job_status_not_found(mcp_ctx): + from app.mcp_server import _job_status + r = _run(_job_status("nope")) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_confirm_stage_job_not_found(mcp_ctx): + from app.mcp_server import _confirm_stage + assert _run(_confirm_stage("nope", "identify", {}))["code"] == "NOT_FOUND" + + +def test_confirm_stage_stage_not_in_chain(mcp_ctx): + from app.mcp_server import _create_job, _confirm_stage + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_confirm_stage(r["data"]["id"], "phylogeny", {})) + assert res["ok"] is False + assert res["code"] == "NOT_FOUND" + + +def test_confirm_stage_success(mcp_ctx): + from app.mcp_server import _create_job, _confirm_stage + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_confirm_stage(r["data"]["id"], "identify", {})) + assert res["ok"] is True + assert res["data"]["job_id"] == r["data"]["id"] + + +def test_rerun_skip_pause_resume_not_found(mcp_ctx): + from app.mcp_server import _rerun_stage, _skip_stage, _pause_job, _resume_job + assert _run(_rerun_stage("nope", "identify", None))["code"] == "NOT_FOUND" + assert _run(_skip_stage("nope", "identify"))["code"] == "NOT_FOUND" + assert _run(_pause_job("nope"))["code"] == "NOT_FOUND" + assert _run(_resume_job("nope"))["code"] == "NOT_FOUND" + + +def test_rerun_stage_not_in_chain(mcp_ctx): + from app.mcp_server import _create_job, _rerun_stage + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + assert _run(_rerun_stage(r["data"]["id"], "phylogeny", None))["code"] == "NOT_FOUND" + + +def test_rerun_stage_not_failed(mcp_ctx): + from app.mcp_server import _create_job, _rerun_stage + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_rerun_stage(r["data"]["id"], "identify", None)) + assert res["ok"] is False + assert res["code"] == "STAGE_NOT_FAILED" + + +def test_rerun_stage_success(mcp_ctx): + from app.context import load_record + from app.mcp_server import _create_job, _rerun_stage + from app.services.bioflow.runner import STAGE_FAILED + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + rec = _run(load_record(r["data"]["id"])) + rec._stage_states["identify"]["status"] = STAGE_FAILED + res = _run(_rerun_stage(r["data"]["id"], "identify", None)) + assert res["ok"] is True + + +def test_skip_stage_not_failed(mcp_ctx): + from app.mcp_server import _create_job, _skip_stage + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_skip_stage(r["data"]["id"], "identify")) + assert res["ok"] is False + assert res["code"] == "STAGE_NOT_FAILED" + + +def test_skip_stage_success(mcp_ctx): + from app.context import load_record + from app.mcp_server import _create_job, _skip_stage + from app.services.bioflow.runner import STAGE_FAILED + r = _run(_create_job("s1", "t", ["identify", "phylogeny"], None, False, None, GENE_FAMILY_STAGES)) + rec = _run(load_record(r["data"]["id"])) + rec._stage_states["identify"]["status"] = STAGE_FAILED + res = _run(_skip_stage(r["data"]["id"], "identify")) + assert res["ok"] is True + + +def test_pause_job_not_running(mcp_ctx): + from app.mcp_server import _create_job, _pause_job + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_pause_job(r["data"]["id"])) + assert res["ok"] is False + assert res["code"] == "JOB_NOT_RUNNING" + + +def test_pause_job_success(mcp_ctx): + from app.context import load_record + from app.mcp_server import _create_job, _pause_job + from app.services.bioflow.runner import JOB_RUNNING + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + rec = _run(load_record(r["data"]["id"])) + rec.status = JOB_RUNNING + res = _run(_pause_job(r["data"]["id"])) + assert res["ok"] is True + assert res["data"]["status"] == "paused" + + +def test_resume_job_not_paused(mcp_ctx): + from app.mcp_server import _create_job, _resume_job + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_resume_job(r["data"]["id"])) + assert res["ok"] is False + assert res["code"] == "JOB_NOT_PAUSED" + + +def test_resume_job_success(mcp_ctx): + from app.context import load_record + from app.mcp_server import _create_job, _resume_job + from app.services.bioflow.runner import JOB_PAUSED + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + rec = _run(load_record(r["data"]["id"])) + rec.status = JOB_PAUSED + res = _run(_resume_job(r["data"]["id"])) + assert res["ok"] is True + assert res["data"]["status"] == "running" + + +# =========================================================================== +# S0 补强(4)文件登记:NOT_FOUND / PATH_NOT_EXIST / PATH_NOT_ALLOWED / 目录展开 / 空目录 +# =========================================================================== + +def _clean_settings(**overrides): + from app.config import Settings + return Settings(_env_file=None, **overrides) + + +def test_register_job_files_not_found(mcp_ctx): + from app.mcp_server import _register_job_files + r = _run(_register_job_files("nope", ["/tmp/x.fa"])) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_register_job_files_path_not_exist(mcp_ctx, tmp_path, monkeypatch): + import app.mcp_server as mcp_mod + from app.mcp_server import _create_job, _register_job_files + monkeypatch.setattr(mcp_mod, "get_settings", lambda: _clean_settings()) + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_register_job_files(r["data"]["id"], [str(tmp_path / "nope.fa")])) + assert res["ok"] is False + assert res["code"] == "PATH_NOT_EXIST" + + +def test_register_job_files_path_not_allowed(mcp_ctx, tmp_path, monkeypatch): + import app.mcp_server as mcp_mod + from app.mcp_server import _create_job, _register_job_files + sens_dir = tmp_path / "sensitive" + sens_dir.mkdir() + f = sens_dir / "secret.txt" + f.write_text("secret") + monkeypatch.setattr(mcp_mod, "get_settings", + lambda: _clean_settings(sensitive_path_prefixes=(str(sens_dir),))) + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_register_job_files(r["data"]["id"], [str(f)])) + assert res["ok"] is False + assert res["code"] == "PATH_NOT_ALLOWED" + + +def test_register_job_files_dir_expand(mcp_ctx, tmp_path, monkeypatch): + import app.mcp_server as mcp_mod + from app.mcp_server import _create_job, _register_job_files + d = tmp_path / "files" + d.mkdir() + (d / "a.fa").write_text(">a\nACGT\n") + monkeypatch.setattr(mcp_mod, "get_settings", lambda: _clean_settings()) + + async def _no_llm_fallback(input_dir, source_roots): + return False + monkeypatch.setattr(mcp_mod, "llm_sample_fallback", _no_llm_fallback) + + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_register_job_files(r["data"]["id"], [str(d)])) + assert res["ok"] is True + assert res["data"]["total"] == 1 + assert res["data"]["items"][0]["name"] == "a.fa" + + +def test_register_job_files_empty_dir(mcp_ctx, tmp_path, monkeypatch): + import app.mcp_server as mcp_mod + from app.mcp_server import _create_job, _register_job_files + d = tmp_path / "empty" + d.mkdir() + monkeypatch.setattr(mcp_mod, "get_settings", lambda: _clean_settings()) + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_register_job_files(r["data"]["id"], [str(d)])) + assert res["ok"] is False + assert res["code"] == "VALIDATION" + + +# =========================================================================== +# S0 补强(5)ask 知识库降级 / list_archives / reuse_archive +# =========================================================================== + +def test_ask_question_degraded_no_flow_store(mcp_ctx): + from app.mcp_server import _ask_question + mcp_ctx.flow_store = None + r = _run(_ask_question("什么是组合证据")) + assert r["ok"] is True + assert r["degraded"] is True + assert r["citations"] == [] + + +def test_ask_question_search_exception_degraded(mcp_ctx): + from app.mcp_server import _ask_question + + class BoomStore: + def search(self, q, top_k=8, **kwargs): + raise RuntimeError("boom") + + mcp_ctx.flow_store = BoomStore() + r = _run(_ask_question("什么是组合证据")) + assert r["ok"] is True + assert r["degraded"] is True + + +def test_ask_question_with_citations(mcp_ctx): + from app.mcp_server import _ask_question + r = _run(_ask_question("identify 需要什么")) + assert r["ok"] is True + assert r["degraded"] is False + assert len(r["citations"]) >= 1 + + +def test_list_archives_empty(mcp_ctx): + from app.mcp_server import _list_archives + r = _run(_list_archives(1, 20)) + assert r["ok"] is True + assert r["total"] == 0 + assert r["items"] == [] + + +def test_reuse_archive_not_found(mcp_ctx): + from app.mcp_server import _reuse_archive + r = _run(_reuse_archive("不存在的存档")) + assert r["ok"] is False + assert r["code"] == "ARCHIVE_NOT_FOUND" + + +def test_reuse_archive_found(mcp_ctx): + from app.mcp_server import _reuse_archive + from app.services.bioflow import store + _run(store.insert_archive(mcp_ctx.db, { + "id": "a1", "job_id": "j1", "name": "SPL 鉴定", "description": "d", + "stage_chain": ["identify"], "params": {}, + })) + r = _run(_reuse_archive("SPL 鉴定")) + assert r["ok"] is True + assert r["data"]["id"] == "a1" + assert r["data"]["stage_chain"] == ["identify"] + + +def test_archive_job_not_found(mcp_ctx): + from app.mcp_server import _archive_job + r = _run(_archive_job("nope", "SPL 存档", "", False)) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_archive_job_name_exists(mcp_ctx): + from app.mcp_server import _archive_job + from app.services.bioflow import store + _run(store.insert_archive(mcp_ctx.db, { + "id": "a1", "job_id": "j1", "name": "SPL 存档", "description": "d", + "stage_chain": ["identify"], "params": {}, + })) + # 内存里造一个任务记录(ctx.records 直接注入,避免依赖真实 job_dir 打包) + from app.services.bioflow.runner import JobRecord + rec = JobRecord(id="j1", session_id="s1", title="t", stage_chain=["identify"], params={}) + mcp_ctx.records["j1"] = rec + r = _run(_archive_job("j1", "SPL 存档", "", False)) + assert r["ok"] is False + assert r["code"] == "ARCHIVE_EXISTS" + + +def _make_archive_job_dir(rec): + """造一个含 input/logs/workspace/events 的最小任务目录(供 archive_from_job 打包)。""" + (rec.job_dir / "input").mkdir(parents=True, exist_ok=True) + (rec.job_dir / "input" / "genome.pep.fa").write_text(">g\nACGT\n") + (rec.job_dir / "logs").mkdir(parents=True, exist_ok=True) + (rec.job_dir / "logs" / "identify.log").write_text("log line\n") + (rec.job_dir / "workspace" / "final").mkdir(parents=True, exist_ok=True) + (rec.job_dir / "workspace" / "final" / "Ft.SPL.final_members.id").write_text("g1\n") + (rec.job_dir / "events.jsonl").write_text('{"type": "job_created"}\n') + + +def test_archive_job_success(mcp_ctx, monkeypatch): + from app.mcp_server import _archive_job + from app.services.bioflow.runner import JobRecord + import app.config as config_mod + import app.mcp_server as mcp_mod + # mcp_server.get_settings 复用 autouse 的 tmp Settings(jobs/archives 都指向 tmp,零宿主路径) + monkeypatch.setattr(mcp_mod, "get_settings", config_mod.get_settings) + + rec = JobRecord(id="j1", session_id="s1", title="t", stage_chain=["identify"], params={}) + _make_archive_job_dir(rec) + mcp_ctx.records["j1"] = rec + + r = _run(_archive_job("j1", "SPL 存档", "描述", False)) + assert r["ok"] is True + assert r["data"]["name"] == "SPL 存档" + assert r["data"]["job_id"] == "j1" + assert r["data"]["size_bytes"] > 0 + + # 落 bio_archives 行可回读 + from app.services.bioflow import store + arch = _run(store.find_archive_by_name(mcp_ctx.db, "SPL 存档")) + assert arch is not None + assert arch["job_id"] == "j1" + + +def test_archive_job_failed(mcp_ctx, monkeypatch): + from app.mcp_server import _archive_job + from app.services.bioflow.runner import JobRecord + import app.mcp_server as mcp_mod + + rec = JobRecord(id="j1", session_id="s1", title="t", stage_chain=["identify"], params={}) + rec.job_dir.mkdir(parents=True, exist_ok=True) + mcp_ctx.records["j1"] = rec + + async def boom(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(mcp_mod, "archive_from_job", boom) + r = _run(_archive_job("j1", "SPL 存档", "", False)) + assert r["ok"] is False + assert r["code"] == "ARCHIVE_FAILED" + + +# =========================================================================== +# S0 补强(6)fetch / inspect / transform MCP 工具层错误路径 +# =========================================================================== + +def test_fetch_source_file_job_not_found(mcp_ctx): + from app.mcp_server import _fetch_source_file + r = _run(_fetch_source_file("nope", "http://x/y.fa", None)) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_fetch_source_file_incoming_no_job(mcp_ctx, monkeypatch): + import app.mcp_server as mcp_mod + captured = {} + + async def fake_fetch(source, dest_dir, credentials=None, timeout=None): + captured["source"] = source + return {"ok": True, "path": "/x/y.fa", "name": "y.fa", "size": 3, + "kind": "web", "provenance": {}} + + monkeypatch.setattr(mcp_mod, "fetch_source", fake_fetch) + r = _run(mcp_mod._fetch_source_file(None, "http://x/y.fa", None)) + assert r["ok"] is True + assert r["job_id"] is None + assert captured["source"] == "http://x/y.fa" + + +def test_fetch_source_file_error(mcp_ctx, monkeypatch): + import app.mcp_server as mcp_mod + from app.services.bioflow.source_fetcher import FetchError + + async def fake_fetch(source, dest_dir, credentials=None, timeout=None): + raise FetchError("DISK_FULL", "磁盘空间不足") + + monkeypatch.setattr(mcp_mod, "fetch_source", fake_fetch) + r = _run(mcp_mod._fetch_source_file(None, "http://x/y.fa", None)) + assert r["ok"] is False + assert r["code"] == "DISK_FULL" + + +def test_inspect_file_tool_not_found(mcp_ctx): + from app.mcp_server import _inspect_file + r = _run(_inspect_file("/no/such/file.fa")) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_transform_file_tool_not_found(mcp_ctx): + from app.mcp_server import _transform_file + r = _run(_transform_file("/no/such.csv", "fasta", None, None)) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_transform_file_tool_bad_format(mcp_ctx, tmp_path): + from app.mcp_server import _transform_file + src = tmp_path / "x.csv" + src.write_text("a,b\n1,2\n") + r = _run(_transform_file(str(src), "bogus", None, None)) + assert r["ok"] is False + assert r["code"] == "BAD_FORMAT" + + +# =========================================================================== +# S0 补强(7)generate_samples_yaml:NOT_FOUND / EMPTY / LLM 失败 / 校验失败 / 成功 +# =========================================================================== + +def _patch_complete_json(monkeypatch, return_value=None, exc=None): + import app.services.bioflow.llm as llm_mod + + async def fake(system, user, **kwargs): + if exc: + raise exc + return return_value + + monkeypatch.setattr(llm_mod, "complete_json", fake) + + +def test_generate_samples_yaml_not_found(mcp_ctx): + from app.mcp_server import _generate_samples_yaml + r = _run(_generate_samples_yaml("nope", "A 组 3 重复")) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_generate_samples_yaml_empty(mcp_ctx, monkeypatch): + from app.mcp_server import _create_job, _generate_samples_yaml + _patch_complete_json(monkeypatch, return_value={"samples": {}}) + r = _run(_create_job("s1", "t", ["rnaseq_qc"], None, False, None, RNASEQ_STAGES)) + res = _run(_generate_samples_yaml(r["data"]["id"], "无样本")) + assert res["ok"] is False + assert res["code"] == "EMPTY_SAMPLES" + + +def test_generate_samples_yaml_llm_error(mcp_ctx, monkeypatch): + from app.mcp_server import _create_job, _generate_samples_yaml + _patch_complete_json(monkeypatch, exc=RuntimeError("boom")) + r = _run(_create_job("s1", "t", ["rnaseq_qc"], None, False, None, RNASEQ_STAGES)) + res = _run(_generate_samples_yaml(r["data"]["id"], "x")) + assert res["ok"] is False + assert res["code"] == "SAMPLES_GEN_FAILED" + + +def test_generate_samples_yaml_validation_failed(mcp_ctx, monkeypatch): + from app.mcp_server import _create_job, _generate_samples_yaml + _patch_complete_json(monkeypatch, return_value={"samples": {"A": {"condition": "x"}}}) + r = _run(_create_job("s1", "t", ["rnaseq_qc"], None, False, None, RNASEQ_STAGES)) + res = _run(_generate_samples_yaml(r["data"]["id"], "x")) + assert res["ok"] is False + assert res["code"] == "VALIDATION_FAILED" + + +def test_generate_samples_yaml_success(mcp_ctx, monkeypatch): + from app.mcp_server import _create_job, _generate_samples_yaml + _patch_complete_json(monkeypatch, + return_value={"samples": {"A": {"r1": "a.fq", "r2": "a2.fq"}}}) + r = _run(_create_job("s1", "t", ["rnaseq_qc"], None, False, None, RNASEQ_STAGES)) + res = _run(_generate_samples_yaml(r["data"]["id"], "x")) + assert res["ok"] is True + assert res["data"]["samples"]["A"]["r1"] == "a.fq" + + +# =========================================================================== +# S0 补强(8)公共 MCP 工具层(call_tool 协议层)——覆盖公开工具包装函数 +# =========================================================================== + +def _call(mcp, name, args): + r = _run(mcp.call_tool(name, args)) + return getattr(r, "structuredContent", None) or _content_text(r) + + +def test_call_tool_plan_gene_family(mcp_ctx): + gf, _rn, _ext = build_mcp_servers() + r = _call(gf, "plan_gene_family", {"message": "做 SPL 基因家族"}) + assert r.get("ok") is True + + +def test_call_tool_plan_rnaseq(mcp_ctx): + _gf, rn, _ext = build_mcp_servers() + r = _call(rn, "plan_rnaseq", {"message": "做转录组分析"}) + assert r.get("ok") is True + + +def test_call_tool_create_rnaseq_job(mcp_ctx): + _gf, rn, _ext = build_mcp_servers() + r = _call(rn, "create_rnaseq_job", {"session_id": "s1", "stages": ["rnaseq_qc"]}) + assert r.get("ok") is True + + +def test_call_tool_ask_gene_family_question(mcp_ctx): + gf, _rn, _ext = build_mcp_servers() + r = _call(gf, "ask_gene_family_question", {"question": "什么是组合证据"}) + assert r.get("ok") is True + + +def test_call_tool_ask_rnaseq_question(mcp_ctx): + _gf, rn, _ext = build_mcp_servers() + r = _call(rn, "ask_rnaseq_question", {"question": "什么是差异表达"}) + assert r.get("ok") is True + + +def test_call_tool_generate_samples_yaml_not_found(mcp_ctx): + _gf, rn, _ext = build_mcp_servers() + r = _call(rn, "generate_samples_yaml", {"job_id": "nope", "sample_description": "x"}) + assert r.get("code") == "NOT_FOUND" + + +def test_call_tool_confirm_stage_unknown_params(mcp_ctx): + from app.mcp_server import _create_job + gf, _rn, _ext = build_mcp_servers() + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + job_id = r["data"]["id"] + res = _call(gf, "confirm_stage", {"job_id": job_id, "stage": "identify", + "params": {"bogus_param": "x"}}) + assert res.get("code") == "UNKNOWN_PARAMS" + + +def test_call_tool_list_archives(mcp_ctx): + gf, _rn, _ext = build_mcp_servers() + r = _call(gf, "list_archives", {}) + assert r.get("ok") is True + + +def test_call_tool_reuse_archive_not_found(mcp_ctx): + gf, _rn, _ext = build_mcp_servers() + r = _call(gf, "reuse_archive", {"name": "不存在的存档"}) + assert r.get("code") == "ARCHIVE_NOT_FOUND" + + +def test_call_tool_fetch_source_file_error(mcp_ctx, monkeypatch): + import app.mcp_server as mcp_mod + from app.services.bioflow.source_fetcher import FetchError + + async def fake_fetch(source, dest_dir, credentials=None, timeout=None): + raise FetchError("DISK_FULL", "磁盘空间不足") + + monkeypatch.setattr(mcp_mod, "fetch_source", fake_fetch) + gf, _rn, _ext = build_mcp_servers() + r = _call(gf, "fetch_source_file", {"source": "http://x/y.fa"}) + assert r.get("code") == "DISK_FULL" + + +def test_call_tool_inspect_file_not_found(mcp_ctx): + gf, _rn, _ext = build_mcp_servers() + r = _call(gf, "inspect_file", {"path": "/no/such/file.fa"}) + assert r.get("code") == "NOT_FOUND" + + +def test_call_tool_transform_file_bad_format(mcp_ctx, tmp_path): + src = tmp_path / "x.csv" + src.write_text("a,b\n1,2\n") + gf, _rn, _ext = build_mcp_servers() + r = _call(gf, "transform_file", {"src_path": str(src), "target_format": "bogus"}) + assert r.get("code") == "BAD_FORMAT" + + +def test_call_tool_transform_file_success_via_llm(mcp_ctx, tmp_path, monkeypatch): + import app.services.bioflow.llm as llm_mod + + async def fake_complete_json(system, user, **kwargs): + return {"name": "col_a", "seq": "col_b"} + + monkeypatch.setattr(llm_mod, "complete_json", fake_complete_json) + src = tmp_path / "cand.csv" + src.write_text("col_a,col_b\nSPL1,ATGGCTA\nSPL2,ATGGCTB\n") + gf, _rn, _ext = build_mcp_servers() + r = _call(gf, "transform_file", {"src_path": str(src), "target_format": "fasta"}) + assert r.get("ok") is True + assert r.get("records") == 2 + + +def test_skip_stage_not_in_chain(mcp_ctx): + from app.mcp_server import _create_job, _skip_stage + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_skip_stage(r["data"]["id"], "phylogeny")) + assert res["ok"] is False + assert res["code"] == "NOT_FOUND" + + +def test_register_job_files_single_file(mcp_ctx, tmp_path, monkeypatch): + import app.mcp_server as mcp_mod + from app.mcp_server import _create_job, _register_job_files + f = tmp_path / "single.fa" + f.write_text(">a\nACGT\n") + monkeypatch.setattr(mcp_mod, "get_settings", lambda: _clean_settings()) + + async def _no_llm_fallback(input_dir, source_roots): + return False + monkeypatch.setattr(mcp_mod, "llm_sample_fallback", _no_llm_fallback) + + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_register_job_files(r["data"]["id"], [str(f)])) + assert res["ok"] is True + assert res["data"]["total"] == 1 + assert res["data"]["items"][0]["name"] == "single.fa" + + +def test_register_job_files_sensitive_filename(mcp_ctx, tmp_path, monkeypatch): + import app.mcp_server as mcp_mod + from app.mcp_server import _create_job, _register_job_files + f = tmp_path / "id_rsa" + f.write_text("secret") + monkeypatch.setattr(mcp_mod, "get_settings", lambda: _clean_settings()) + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + res = _run(_register_job_files(r["data"]["id"], [str(f)])) + assert res["ok"] is False + assert res["code"] == "PATH_NOT_ALLOWED" + + +def test_fetch_source_file_with_job(mcp_ctx, monkeypatch): + import app.mcp_server as mcp_mod + from app.mcp_server import _create_job + from pathlib import Path + + async def fake_fetch(source, dest_dir, credentials=None, timeout=None): + return {"ok": True, "path": str(Path(dest_dir) / "y.fa"), "name": "y.fa", + "size": 3, "kind": "web", "provenance": {}} + + monkeypatch.setattr(mcp_mod, "fetch_source", fake_fetch) + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + job_id = r["data"]["id"] + res = _run(mcp_mod._fetch_source_file(job_id, "http://x/y.fa", None)) + assert res["ok"] is True + assert res["job_id"] == job_id + + +def test_call_tool_shared_control_tools(mcp_ctx): + """覆盖 rerun/skip/pause/resume 公开工具层包装(私有层已测)。""" + from app.mcp_server import _create_job + from app.context import load_record + from app.services.bioflow.runner import STAGE_FAILED, JOB_RUNNING, JOB_PAUSED + gf, _rn, _ext = build_mcp_servers() + r = _run(_create_job("s1", "t", ["identify", "phylogeny"], None, False, None, GENE_FAMILY_STAGES)) + job_id = r["data"]["id"] + + rec = _run(load_record(job_id)) + rec._stage_states["identify"]["status"] = STAGE_FAILED + assert _call(gf, "rerun_stage", {"job_id": job_id, "stage": "identify"}).get("ok") is True + + rec = _run(load_record(job_id)) + rec._stage_states["identify"]["status"] = STAGE_FAILED + assert _call(gf, "skip_stage", {"job_id": job_id, "stage": "identify"}).get("ok") is True + + rec = _run(load_record(job_id)) + rec.status = JOB_RUNNING + assert _call(gf, "pause_job", {"job_id": job_id}).get("ok") is True + + rec = _run(load_record(job_id)) + rec.status = JOB_PAUSED + assert _call(gf, "resume_job", {"job_id": job_id}).get("ok") is True + + +def test_call_tool_register_job_files(mcp_ctx, tmp_path, monkeypatch): + import app.mcp_server as mcp_mod + from app.mcp_server import _create_job + f = tmp_path / "g.fa" + f.write_text(">g\nACGT\n") + monkeypatch.setattr(mcp_mod, "get_settings", lambda: _clean_settings()) + + async def _no_llm_fallback(input_dir, source_roots): + return False + monkeypatch.setattr(mcp_mod, "llm_sample_fallback", _no_llm_fallback) + + r = _run(_create_job("s1", "t", ["identify"], None, False, None, GENE_FAMILY_STAGES)) + gf, _rn, _ext = build_mcp_servers() + res = _call(gf, "register_job_files", {"job_id": r["data"]["id"], "paths": [str(f)]}) + assert res.get("ok") is True + assert res["data"]["total"] == 1 + + +# =========================================================================== +# S4 补强:第三个 MCP 端点(bioflow-extension)8 工具 + 三端点隔离 +# =========================================================================== + +def _ext_store(monkeypatch, tmp_path): + """注入一个 tmp base_dir 的 ExtensionStore(避免 get_store 单例跨测试缓存)。""" + import json as _json + from app.services.bioflow import extensions as ext_mod + store = ext_mod.ExtensionStore(base_dir=str(tmp_path / "extensions")) + monkeypatch.setattr(ext_mod, "get_store", lambda: store) + return store + + +def _write_ext_manifest(store, ext_id="e1", status="draft", command_template=None): + """直接落一个指定状态的扩展清单(绕过 LLM/执行器,专测 MCP 工具层错误路径)。""" + import json as _json + ext_dir = store.ext_dir(ext_id) + ext_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "ext_id": ext_id, "display_name": "测扩", "status": status, + "command": "python run.py", "original_command": "python run.py", + "runtime_image": "", "parameters": [], "nl_triggers": [], + } + if command_template: + manifest["command_template"] = command_template + (ext_dir / "extension.json").write_text(_json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + return ext_id + + +def test_extension_endpoint_no_shared_tools(): + """隔离:扩展端点只挂 9 个生命周期工具,不挂 gf/rn 共享控制组。""" + gf, rn, ext = build_mcp_servers() + ext_names = {t.name for t in _run(ext.list_tools())} + 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"} + assert ext_names.isdisjoint(shared) + assert len(ext_names) == 9 # create/status/build/trial/validate/confirm/publish/run/repair + + +def test_create_extension_requires_code(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _create_extension + _ext_store(monkeypatch, tmp_path) + r = _run(_create_extension("测扩", "python run.py", "", [], [])) + assert r["ok"] is False + assert r["code"] == "VALIDATION" + + +def test_create_extension_success(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _create_extension + _ext_store(monkeypatch, tmp_path) + r = _run(_create_extension("测扩", "python run.py", "", + [{"path": "run.py", "content": "print(1)"}], [])) + assert r["ok"] is True + assert r["data"]["status"] == "draft" + assert r["data"]["display_name"] == "测扩" + + +def test_extension_status_not_found(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _extension_status + _ext_store(monkeypatch, tmp_path) + r = _run(_extension_status("nope")) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_extension_status_success(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _extension_status + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "draft") + r = _run(_extension_status("e1")) + assert r["ok"] is True + assert r["data"]["status"] == "draft" + assert r["data"]["ext_id"] == "e1" + + +def test_build_extension_not_found(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _build_extension + _ext_store(monkeypatch, tmp_path) + r = _run(_build_extension("nope")) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_trial_run_draft_status_error(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _trial_run_extension + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "draft") + r = _run(_trial_run_extension("e1")) + assert r["ok"] is False + assert r["code"] == "EXT_STATUS" + + +def test_trial_run_no_executor_ext_run(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _trial_run_extension + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "built", command_template="python run.py") + r = _run(_trial_run_extension("e1")) + assert r["ok"] is False + assert r["code"] == "EXT_RUN" + + +def test_validate_draft_status_error(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _validate_extension + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "draft") + r = _run(_validate_extension("e1")) + assert r["ok"] is False + assert r["code"] == "EXT_STATUS" + + +def test_confirm_draft_status_error(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _confirm_extension + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "draft") + r = _run(_confirm_extension("e1", ["触发词"], [], "")) + assert r["ok"] is False + assert r["code"] == "EXT_STATUS" + + +def test_publish_draft_status_error(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _publish_extension + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "draft") + r = _run(_publish_extension("e1")) + assert r["ok"] is False + assert r["code"] == "EXT_STATUS" + + +def test_run_draft_status_error(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _run_extension + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "draft") + r = _run(_run_extension("e1", None, None)) + assert r["ok"] is False + assert r["code"] == "EXT_STATUS" + + +def test_run_published_no_executor_ext_run(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _run_extension + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "published", command_template="python run.py") + r = _run(_run_extension("e1", None, None)) + assert r["ok"] is False + assert r["code"] == "EXT_RUN" + + +def test_repair_extension_not_found(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _repair_extension + _ext_store(monkeypatch, tmp_path) + r = _run(_repair_extension("nope")) + assert r["ok"] is False + assert r["code"] == "NOT_FOUND" + + +def test_repair_extension_status_error(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _repair_extension + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "draft") # draft 不可修复 + r = _run(_repair_extension("e1")) + assert r["ok"] is False + assert r["code"] == "EXT_STATUS" + + +def test_repair_extension_success(mcp_ctx, monkeypatch, tmp_path): + from app.mcp_server import _repair_extension + from app.services.bioflow import extension_llm + store = _ext_store(monkeypatch, tmp_path) + _write_ext_manifest(store, "e1", "trial_failed", command_template="python run.py --output {output_dir}") + + async def fake_repair_conversion(ext, *, failure_context, store): + return {"command_template": "python run.py --output {output_dir}", "requirements": {}} + + monkeypatch.setattr(extension_llm, "repair_conversion", fake_repair_conversion) + r = _run(_repair_extension("e1")) + assert r["ok"] is True + assert r["data"]["status"] == "built" diff --git a/bioflow_service/tests/test_source_fetcher.py b/bioflow_service/tests/test_source_fetcher.py new file mode 100644 index 0000000..a88b404 --- /dev/null +++ b/bioflow_service/tests/test_source_fetcher.py @@ -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 + 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" diff --git a/deploy/toggle_bioflow.sh b/deploy/toggle_bioflow.sh new file mode 100755 index 0000000..e2746cd --- /dev/null +++ b/deploy/toggle_bioflow.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# deploy/toggle_bioflow.sh —— 生信能力按需装卸(热,不重启 QA)+ bioflow_service 启停 + 注册状态自检。 +# +# 用法: +# bash deploy/toggle_bioflow.sh +# +# 原理: +# - 能力装卸经 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 " >&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" diff --git a/docs/MANUAL_TEST_BIOFLOW_DIALOGUE.md b/docs/MANUAL_TEST_BIOFLOW_DIALOGUE.md new file mode 100644 index 0000000..002a37c --- /dev/null +++ b/docs/MANUAL_TEST_BIOFLOW_DIALOGUE.md @@ -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` 的生信流程部分。* diff --git a/sdk/app/routers/qa.py b/sdk/app/routers/qa.py index d23f7f1..da38719 100644 --- a/sdk/app/routers/qa.py +++ b/sdk/app/routers/qa.py @@ -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) diff --git a/sdk/app/routers/sessions.py b/sdk/app/routers/sessions.py index a72b1ae..84e6d39 100644 --- a/sdk/app/routers/sessions.py +++ b/sdk/app/routers/sessions.py @@ -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), diff --git a/sdk/app/schemas.py b/sdk/app/schemas.py index 2b3a3ad..179f766 100644 --- a/sdk/app/schemas.py +++ b/sdk/app/schemas.py @@ -340,6 +340,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): diff --git a/sdk/app/services/capabilities.py b/sdk/app/services/capabilities.py index ba7ae13..201ca3d 100644 --- a/sdk/app/services/capabilities.py +++ b/sdk/app/services/capabilities.py @@ -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, diff --git a/sdk/app/test_capabilities_svc.py b/sdk/app/test_capabilities_svc.py index 3ef18ba..7fc8ab1 100644 --- a/sdk/app/test_capabilities_svc.py +++ b/sdk/app/test_capabilities_svc.py @@ -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 diff --git a/sdk/app/test_qa_inject_history.py b/sdk/app/test_qa_inject_history.py new file mode 100644 index 0000000..e6442b4 --- /dev/null +++ b/sdk/app/test_qa_inject_history.py @@ -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"} diff --git a/sdk/app/test_sessions_api.py b/sdk/app/test_sessions_api.py index 17fd0ac..14d6f3b 100644 --- a/sdk/app/test_sessions_api.py +++ b/sdk/app/test_sessions_api.py @@ -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), diff --git a/tests/frontend/unit/bioflow/benchpanel.bioflow.test.js b/tests/frontend/unit/bioflow/benchpanel.bioflow.test.js index 7dc8eec..8bb99ba 100644 --- a/tests/frontend/unit/bioflow/benchpanel.bioflow.test.js +++ b/tests/frontend/unit/bioflow/benchpanel.bioflow.test.js @@ -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() === '生信档案'); diff --git a/tests/frontend/unit/bioflow/bioflow.store.test.js b/tests/frontend/unit/bioflow/bioflow.store.test.js index a12d874..540dce2 100644 --- a/tests/frontend/unit/bioflow/bioflow.store.test.js +++ b/tests/frontend/unit/bioflow/bioflow.store.test.js @@ -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', { diff --git a/tests/frontend/unit/bioflow/cards.test.js b/tests/frontend/unit/bioflow/cards.test.js index 7b50259..fda3cd8 100644 --- a/tests/frontend/unit/bioflow/cards.test.js +++ b/tests/frontend/unit/bioflow/cards.test.js @@ -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; diff --git a/tests/frontend/unit/bioflow/chat.bioflow.test.js b/tests/frontend/unit/bioflow/chat.bioflow.test.js index 4c6da90..8839683 100644 --- a/tests/frontend/unit/bioflow/chat.bioflow.test.js +++ b/tests/frontend/unit/bioflow/chat.bioflow.test.js @@ -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(); - }); -}); diff --git a/tests/frontend/unit/bioflow/composer.bioflow.test.js b/tests/frontend/unit/bioflow/composer.bioflow.test.js index 59b2773..b7cf91f 100644 --- a/tests/frontend/unit/bioflow/composer.bioflow.test.js +++ b/tests/frontend/unit/bioflow/composer.bioflow.test.js @@ -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 () => { diff --git a/tests/frontend/unit/bioflow/intent.test.js b/tests/frontend/unit/bioflow/intent.test.js index c802ba0..2f1dd29 100644 --- a/tests/frontend/unit/bioflow/intent.test.js +++ b/tests/frontend/unit/bioflow/intent.test.js @@ -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); }); }); diff --git a/tests/frontend/unit/bioflow/legacy.block.test.js b/tests/frontend/unit/bioflow/legacy.block.test.js new file mode 100644 index 0000000..63fc5f2 --- /dev/null +++ b/tests/frontend/unit/bioflow/legacy.block.test.js @@ -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); + }); +}); diff --git a/tests/frontend/unit/bioflow/panes.test.js b/tests/frontend/unit/bioflow/panes.test.js index 79c4635..1772b05 100644 --- a/tests/frontend/unit/bioflow/panes.test.js +++ b/tests/frontend/unit/bioflow/panes.test.js @@ -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); + }); +}); diff --git a/tests/frontend/unit/bioflow/settings.test.js b/tests/frontend/unit/bioflow/settings.test.js new file mode 100644 index 0000000..c5bf0ab --- /dev/null +++ b/tests/frontend/unit/bioflow/settings.test.js @@ -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); + }); +}); diff --git a/ui/src/api/capabilities.js b/ui/src/api/capabilities.js new file mode 100644 index 0000000..6494798 --- /dev/null +++ b/ui/src/api/capabilities.js @@ -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); diff --git a/ui/src/components/bench/BioJobsPane.vue b/ui/src/components/bench/BioJobsPane.vue index 56d9116..37d350d 100644 --- a/ui/src/components/bench/BioJobsPane.vue +++ b/ui/src/components/bench/BioJobsPane.vue @@ -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) { diff --git a/ui/src/components/chat/AssistantMessage.vue b/ui/src/components/chat/AssistantMessage.vue index c627bd2..8d39a63 100644 --- a/ui/src/components/chat/AssistantMessage.vue +++ b/ui/src/components/chat/AssistantMessage.vue @@ -17,6 +17,7 @@ import FlowFormCard from './FlowFormCard.vue'; import FlowFileCard from './FlowFileCard.vue'; import FlowProgressCard from './FlowProgressCard.vue'; import FlowArchiveCard from './FlowArchiveCard.vue'; +import LegacyBioflowBlock from './LegacyBioflowBlock.vue'; // 用户自定义扩展分析:生命周期引导卡 + 运行卡(EXTENSION_CONTRACT.md §4 块) import ExtensionUploadCard from './ExtensionUploadCard.vue'; import ExtensionBuildCard from './ExtensionBuildCard.vue'; @@ -224,15 +225,8 @@ const degradedNote = computed(() => props.message.degradedNote || null); :block="b" /> - -
-
- {{ ci + 1 }} - {{ c.text || c.title_path || c.source }} - {{ c.title_path || c.source }} -
-
{{ BF.flowQaEmpty }}
-
+ + { } 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 { @@ -262,34 +255,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') { // 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 异步生成 + 自动切文档 TAB) // 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐) diff --git a/ui/src/components/chat/ExtensionBuildCard.vue b/ui/src/components/chat/ExtensionBuildCard.vue index 33ed0e1..47e9c25 100644 --- a/ui/src/components/chat/ExtensionBuildCard.vue +++ b/ui/src/components/chat/ExtensionBuildCard.vue @@ -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 }); diff --git a/ui/src/components/chat/ExtensionConfirmCard.vue b/ui/src/components/chat/ExtensionConfirmCard.vue index ebd5cdd..b4913b4 100644 --- a/ui/src/components/chat/ExtensionConfirmCard.vue +++ b/ui/src/components/chat/ExtensionConfirmCard.vue @@ -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) { diff --git a/ui/src/components/chat/ExtensionPublishCard.vue b/ui/src/components/chat/ExtensionPublishCard.vue index 1d4ff72..5dc37ec 100644 --- a/ui/src/components/chat/ExtensionPublishCard.vue +++ b/ui/src/components/chat/ExtensionPublishCard.vue @@ -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) { diff --git a/ui/src/components/chat/ExtensionTrialCard.vue b/ui/src/components/chat/ExtensionTrialCard.vue index 21eab72..206ee2b 100644 --- a/ui/src/components/chat/ExtensionTrialCard.vue +++ b/ui/src/components/chat/ExtensionTrialCard.vue @@ -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); diff --git a/ui/src/components/chat/ExtensionValidateCard.vue b/ui/src/components/chat/ExtensionValidateCard.vue index 67e539b..cc65ac0 100644 --- a/ui/src/components/chat/ExtensionValidateCard.vue +++ b/ui/src/components/chat/ExtensionValidateCard.vue @@ -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) { diff --git a/ui/src/components/chat/FlowArchiveCard.vue b/ui/src/components/chat/FlowArchiveCard.vue index b245eb6..f0ef97d 100644 --- a/ui/src/components/chat/FlowArchiveCard.vue +++ b/ui/src/components/chat/FlowArchiveCard.vue @@ -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; diff --git a/ui/src/components/chat/FlowFileCard.vue b/ui/src/components/chat/FlowFileCard.vue index b6adfb8..0064625 100644 --- a/ui/src/components/chat/FlowFileCard.vue +++ b/ui/src/components/chat/FlowFileCard.vue @@ -84,16 +84,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); diff --git a/ui/src/components/chat/FlowFormCard.vue b/ui/src/components/chat/FlowFormCard.vue index 6370ce9..b061c56 100644 --- a/ui/src/components/chat/FlowFormCard.vue +++ b/ui/src/components/chat/FlowFormCard.vue @@ -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 }); } diff --git a/ui/src/components/chat/FlowPathCard.vue b/ui/src/components/chat/FlowPathCard.vue index 4307b13..1f1ed49 100644 --- a/ui/src/components/chat/FlowPathCard.vue +++ b/ui/src/components/chat/FlowPathCard.vue @@ -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({ @@ -66,22 +66,16 @@ async function confirmCreate() { 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); diff --git a/ui/src/components/chat/FlowProgressCard.vue b/ui/src/components/chat/FlowProgressCard.vue index 02f34fc..6f52e5a 100644 --- a/ui/src/components/chat/FlowProgressCard.vue +++ b/ui/src/components/chat/FlowProgressCard.vue @@ -1,35 +1,18 @@ + diff --git a/ui/src/components/settings/SettingsPanel.vue b/ui/src/components/settings/SettingsPanel.vue index 97fbf68..3f277d6 100644 --- a/ui/src/components/settings/SettingsPanel.vue +++ b/ui/src/components/settings/SettingsPanel.vue @@ -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; + } +} +