Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bd86e535 |
@@ -36,3 +36,6 @@ tests/extension_cases_18/
|
||||
*.bak-*
|
||||
bioflow_service/bioflow.db
|
||||
*.before-*
|
||||
|
||||
# Python 覆盖率运行时产物(coverage.py --cov 生成)
|
||||
.coverage
|
||||
|
||||
@@ -16,3 +16,70 @@ capabilities:
|
||||
endpoint: https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi
|
||||
method: GET
|
||||
timeout: 8
|
||||
|
||||
# ---- 生信能力单元(S1):两个 MCP 端点,各自独立装卸 ----
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析(路径规划/建任务/参数确认/文件获取与预处理/状态查询),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers:
|
||||
- 基因家族
|
||||
- 家族成员
|
||||
- 亚家族
|
||||
- 系统发育
|
||||
- 建树
|
||||
- 进化树
|
||||
- 保守基序
|
||||
- motif
|
||||
- 结构域
|
||||
- 基因结构
|
||||
- 共线性
|
||||
- Ka/Ks
|
||||
- 选择压力
|
||||
- 组合证据
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析(实验设计确认/样本配置/路径规划/建任务/差异表达/富集),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers:
|
||||
- 转录组
|
||||
- RNA-seq
|
||||
- RNAseq
|
||||
- rnaseq
|
||||
- 差异表达
|
||||
- 表达定量
|
||||
- 富集
|
||||
- WGCNA
|
||||
- GSEA
|
||||
- 实验设计
|
||||
- fastq
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
|
||||
# ---- 生信能力单元(S4):第三个 MCP 端点,用户自定义扩展分析 ----
|
||||
- name: bioflow-extension
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 用户自定义扩展分析(创建/转换/试运行/校验/发布/运行),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers:
|
||||
- 扩展
|
||||
- 拓展分析
|
||||
- 扩展分析
|
||||
- 自定义分析
|
||||
- 分析模板
|
||||
- 分析脚本
|
||||
- 分析工具
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/extension/
|
||||
prompt_pack:
|
||||
file: bioflow_extension_agent_rules.md
|
||||
|
||||
Vendored
+3
@@ -31,3 +31,6 @@ QA_V1_WEB_SEARCH_MAX_RESULTS=4
|
||||
QA_V1_WEB_SEARCH_TIMEOUT=8
|
||||
QA_V1_WEB_SEARCH_MAX_CHARS=1200
|
||||
QA_V1_ENABLE_SUPERPOWERS=1
|
||||
|
||||
# 生信对话式能力熔断开关(S1):默认关(=0)。置 1 才启用 QA 前门 bioflow_agent。
|
||||
QA_V1_ENABLE_BIOFLOW_AGENT=0
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 用户自定义扩展分析对话规范(bioflow_extension_agent_rules)
|
||||
|
||||
你是用户自定义扩展分析助手。引导用户把一段可运行的分析脚本/工具,通过「创建 → AI 转换 → 试运行 → 校验 → 确认 → 发布 → 运行」流程,做成 PlantAI 可自然语言触发的自定义扩展。
|
||||
|
||||
## 对话节奏(自然语言主导 + 卡片辅助)
|
||||
每次需要用户输入/选择时,遵循固定节奏:
|
||||
1. 先用自然语言说明并列表展示需要什么;
|
||||
2. 提示用户可用卡片快捷操作,也可直接打字(两条通道语义一致);
|
||||
3. 用户回答后,用自然语言复述,确认无误再继续。
|
||||
|
||||
## 创建(create_extension)
|
||||
- 需要用户提供:显示名、运行命令、代码文件(可上传/粘贴文本内容)、运行时镜像、可选测试数据/金标。
|
||||
- 代码文件以「路径 + 文本内容」提交;引导用户说明脚本是做什么的、依赖什么。
|
||||
|
||||
## AI 转换与校验(build → trial_run → validate)
|
||||
- 创建后调用 build 做 AI 转换(原始命令 → command_template + 参数 + 依赖),失败时规则版兜底。
|
||||
- 转换结果先向用户说明(识别到的参数/依赖/输入角色),请用户确认参数名与默认值。
|
||||
- 试运行/校验的结果要转述给用户,失败时说明原因并引导修复(repair 循环)。
|
||||
|
||||
## 确认与发布(confirm_extension → publish_extension)
|
||||
- 确认前请用户确认触发词(nl_triggers)与参数——触发词决定用户以后说什么话能唤起这个扩展。
|
||||
- 发布后扩展才可被自然语言触发运行;发布是不可逆的对外可用状态,须经用户确认。
|
||||
|
||||
## 运行(run_extension)
|
||||
- 仅已发布扩展可运行;运行参数须在白名单内(参数 schema),未知参数明确追问。
|
||||
- 运行结果(run_result)转述给用户。
|
||||
|
||||
## 其它
|
||||
- 工具名/参数名/内部实现一律不外泄;
|
||||
- 状态不确定时主动查 extension_status 并向用户转述当前阶段与下一步;
|
||||
- 人工确认边界(确认触发词、发布)不可跳过、不可代用户拍板。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 基因家族分析对话规范(bioflow_gene_family_agent_rules)
|
||||
|
||||
你是基因家族分析助手。用自然语言引导用户从需求到分析完成,全程对话式交互。
|
||||
|
||||
## 人工确认边界(不可跳过、不可代确认)
|
||||
- **subfamily_confirm(亚家族预测人工确认)是硬性人工边界**:系统预测的亚家族划分结果,
|
||||
必须停下来请用户查看并确认或修改。你绝不能代用户确认,也不能在用户未确认前继续下游分析。
|
||||
- 用户提出修改(如"把某成员从亚家族 A 移到亚家族 B")时,先复述修改内容,再请用户做最终确认。
|
||||
|
||||
## 对话节奏(自然语言主导 + 卡片辅助)
|
||||
每次需要用户输入/选择时,遵循固定节奏:
|
||||
1. 先用自然语言说明并**列表展示**选项及含义;
|
||||
2. 提示用户可用卡片快捷操作,也可直接打字回复(两条通道语义一致);
|
||||
3. 用户选择后,用自然语言**复述**其选择,确认无误再继续。
|
||||
|
||||
## 路径规划与建任务
|
||||
- 规划后列出"核心流程(必选)+ 可选分析"候选路径,说明每个可选分析的生物学意义;
|
||||
- **建任务前必须经用户确认阶段链**,收到确认后再创建任务,绝不擅自建任务。
|
||||
|
||||
## 文件获取与预处理
|
||||
- 源文件支持上传、http/https 链接、OSS 地址、局域网机器路径;
|
||||
- 文件到达后先探测格式,**格式异常(如 Excel/文本)时先向用户说明列结构并提问确认**
|
||||
(哪列是序列、是否去重、是否去表头等),再由模型整理成 fasta/gff 等标准格式;
|
||||
- 转换结果过格式验证,失败时说明问题并追问修正(循环不超过 3 次后建议人工整理)。
|
||||
|
||||
## 参数确认
|
||||
- 列"参数名(默认值,含义)"请用户确认或修改;未识别参数时明确指出并给出允许范围。
|
||||
|
||||
## 其它
|
||||
- 工具名、参数名、内部实现一律不外泄;
|
||||
- 任务运行中用户提问(流程/参数/生物学意义)随时回答,不打断任务;
|
||||
- 卡住或不确定时主动查询任务状态,向用户转述当前进度与下一步。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 转录组分析对话规范(bioflow_rnaseq_agent_rules)
|
||||
|
||||
你是转录组分析助手。用自然语言引导用户从实验设计确认到差异表达/富集分析完成。
|
||||
|
||||
## 人工确认边界(不可跳过、不可代确认)
|
||||
- **实验设计确认(rnaseq_de 之前的必确认信息)是硬性边界**:开始分析前必须与用户确认——
|
||||
① 物种与参考基因组版本;② 每个样本的 FASTQ(上传/链接/局域网路径)及单端双端;
|
||||
③ 对照组/处理组各几个重复、有无时间点/批次;④ 建库是否链特异性。
|
||||
- 统计模型正确性依赖上述信息,未确认前不得创建差异表达任务,也不能代用户拍板基线/比较方向。
|
||||
|
||||
## 对话节奏(自然语言主导 + 卡片辅助)
|
||||
每次需要用户输入/选择时,遵循固定节奏:
|
||||
1. 先用自然语言说明并**列表展示**必确认信息与可选分析;
|
||||
2. 提示用户可用卡片快捷操作,也可直接打字回复(两条通道语义一致);
|
||||
3. 用户回答后,用自然语言**复述**其样本设计与选择(如"干旱 3 vs 对照 3,双端,非链特异,对照为基线"),
|
||||
确认无误再继续。
|
||||
|
||||
## 样本配置与文件预处理
|
||||
- 用户用自然语言描述样本时,可解析生成 samples.yaml(样本名/对照处理组/重复/单双端);
|
||||
- FASTQ 支持上传、http/https 链接、OSS 地址、局域网路径;核对成对完整;
|
||||
- 非 FASTQ 的输入文件(表格/文本等)先探测格式,**异常时先说明列结构并提问确认**,再整理为可用格式。
|
||||
|
||||
## 参数确认
|
||||
- 列"参数名(默认值,含义)"请用户确认或修改;未识别参数时明确指出并给出允许范围。
|
||||
|
||||
## 其它
|
||||
- 工具名、参数名、内部实现一律不外泄;
|
||||
- 任务运行中用户提问(流程/参数/实验设计意义)随时回答,不打断任务;
|
||||
- 卡住或不确定时主动查询任务状态,向用户转述当前进度与下一步(含样本质控 PCA、差异基因数目等结果转述)。
|
||||
@@ -13,6 +13,7 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import __version__
|
||||
from .bioflow_agent import try_bioflow_agent
|
||||
from .config import QAConfig
|
||||
from .pipeline import QAAnswerError, QAV1Pipeline
|
||||
|
||||
@@ -47,6 +48,9 @@ class QARequest(BaseModel):
|
||||
# ---- Capability Gateway(S1):纯数据字段,无 validator(Cython 编译安全)----
|
||||
history: List[Dict[str, str]] = Field(default_factory=list)
|
||||
capabilities: Dict[str, Any] = Field(default_factory=dict)
|
||||
# ---- Bioflow 对话式编排(S2):卡片回填协议 + session↔job 绑定 ----
|
||||
card_result: Optional[Dict[str, Any]] = None
|
||||
bioflow: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -150,6 +154,18 @@ async def chat(request: QARequest) -> Dict[str, Any]:
|
||||
detail={"error_code": "llm_unavailable", "answer": "", "message": "Final-answer LLM is not configured"},
|
||||
)
|
||||
try:
|
||||
# ---- Bioflow Agent 前门(S1):命中则短路返回,否则走原 pipeline ----
|
||||
bioflow_result = await asyncio.to_thread(
|
||||
try_bioflow_agent,
|
||||
request.question,
|
||||
llm_client=pipeline.llm,
|
||||
history=request.history,
|
||||
capabilities=request.capabilities,
|
||||
card_result=request.card_result,
|
||||
bioflow=request.bioflow,
|
||||
)
|
||||
if bioflow_result is not None:
|
||||
return bioflow_result
|
||||
ablation = dict(request.ablation or {})
|
||||
ablation["use_final_llm"] = True
|
||||
return await asyncio.to_thread(
|
||||
@@ -216,6 +232,19 @@ async def chat_stream(request: QARequest) -> StreamingResponse:
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
# ---- Bioflow Agent 前门(S1):命中则短路,否则走原 pipeline ----
|
||||
bioflow_result = try_bioflow_agent(
|
||||
request.question,
|
||||
llm_client=pipeline.llm,
|
||||
history=request.history,
|
||||
capabilities=request.capabilities,
|
||||
card_result=request.card_result,
|
||||
bioflow=request.bioflow,
|
||||
progress_callback=emit,
|
||||
)
|
||||
if bioflow_result is not None:
|
||||
events.put(("complete", bioflow_result))
|
||||
return
|
||||
ablation = dict(request.ablation or {})
|
||||
ablation["use_final_llm"] = True
|
||||
result = pipeline.answer(
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -128,6 +128,8 @@ class InstallManager:
|
||||
"allowed_llm_phases": manifest.allowed_llm_phases,
|
||||
"session_default": manifest.session_default,
|
||||
}
|
||||
if manifest.nl_triggers:
|
||||
raw["nl_triggers"] = manifest.nl_triggers
|
||||
if manifest.schema:
|
||||
raw["schema"] = manifest.schema
|
||||
for section in ("http", "mcp", "python", "prompt_pack"):
|
||||
|
||||
@@ -37,6 +37,7 @@ class CapabilityManifest:
|
||||
session_default: bool = False
|
||||
schema: Dict[str, Any] = field(default_factory=dict)
|
||||
config: Dict[str, Any] = field(default_factory=dict) # provider 专属段(http:/mcp:/python:/prompt_pack: 归并)
|
||||
nl_triggers: List[str] = field(default_factory=list) # 意图预筛触发词(S1,生信能力路由用)
|
||||
|
||||
def to_tool_spec(self) -> Dict[str, Any]:
|
||||
"""转 OpenAI function schema(tools 参数形态)。"""
|
||||
@@ -110,6 +111,19 @@ def _parse_entry(raw: Any, index: int, load_warnings: List[str]) -> CapabilityMa
|
||||
if isinstance(raw.get(section), dict):
|
||||
config[section] = raw[section]
|
||||
config["session_default"] = bool(raw.get("session_default", False))
|
||||
# nl_triggers(S1):意图预筛触发词,只收字符串、去空、去重、限长
|
||||
nl_triggers_raw = raw.get("nl_triggers") or []
|
||||
if isinstance(nl_triggers_raw, str):
|
||||
nl_triggers_raw = [nl_triggers_raw]
|
||||
nl_triggers: List[str] = []
|
||||
if isinstance(nl_triggers_raw, list):
|
||||
for trigger in nl_triggers_raw:
|
||||
if not isinstance(trigger, str):
|
||||
continue # 触发词只收字符串,非字符串(数字等)丢弃
|
||||
cleaned = _clean_text(trigger, 64)
|
||||
if cleaned and cleaned not in nl_triggers:
|
||||
nl_triggers.append(cleaned)
|
||||
nl_triggers = nl_triggers[:64]
|
||||
manifest = CapabilityManifest(
|
||||
name=name,
|
||||
provider=provider,
|
||||
@@ -119,6 +133,7 @@ def _parse_entry(raw: Any, index: int, load_warnings: List[str]) -> CapabilityMa
|
||||
session_default=bool(raw.get("session_default", False)),
|
||||
schema=schema,
|
||||
config=config,
|
||||
nl_triggers=nl_triggers,
|
||||
)
|
||||
# 大小上限:单条目序列化后 ≤8KB
|
||||
import json
|
||||
|
||||
@@ -130,6 +130,7 @@ class _McpConnection:
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._session: Any = None
|
||||
self._server_tools: List[str] = []
|
||||
self._server_tool_schemas: List[Dict[str, Any]] = []
|
||||
self._started_at = 0.0
|
||||
self._last_used = 0.0
|
||||
self._restarts = 0
|
||||
@@ -200,6 +201,18 @@ class _McpConnection:
|
||||
raise
|
||||
self._ctx = ctx
|
||||
self._server_tools = [t.name for t in tools_result.tools]
|
||||
self._server_tool_schemas = [
|
||||
{
|
||||
"name": t.name,
|
||||
"description": (getattr(t, "description", None) or ""),
|
||||
"input_schema": (
|
||||
getattr(t, "inputSchema", None)
|
||||
or getattr(t, "input_schema", None)
|
||||
or {}
|
||||
),
|
||||
}
|
||||
for t in tools_result.tools
|
||||
]
|
||||
self._session = session
|
||||
self._started_at = time.time()
|
||||
|
||||
@@ -225,6 +238,7 @@ class _McpConnection:
|
||||
ctx = getattr(self, "_ctx", None)
|
||||
self._ctx = None
|
||||
self._server_tools = []
|
||||
self._server_tool_schemas = []
|
||||
if session is not None:
|
||||
try:
|
||||
await session.__aexit__(None, None, None)
|
||||
@@ -274,6 +288,14 @@ class _McpConnection:
|
||||
return []
|
||||
return list(self._server_tools)
|
||||
|
||||
def list_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""返回 MCP server 全量工具 schema(name/description/input_schema)。连接失败返回空。"""
|
||||
try:
|
||||
self._ensure_session()
|
||||
except ToolExecutionError:
|
||||
return []
|
||||
return list(self._server_tool_schemas)
|
||||
|
||||
def call(self, tool_name: str, arguments: Dict[str, Any]) -> str:
|
||||
self._ensure_session()
|
||||
try:
|
||||
@@ -337,6 +359,11 @@ class McpToolProvider:
|
||||
return tools + [f"<缺失声明工具 {tool_name}>"]
|
||||
return tools
|
||||
|
||||
def list_tool_schemas(self, manifest: CapabilityManifest) -> List[Dict[str, Any]]:
|
||||
"""返回该能力 MCP server 的全量工具 schema(供前门 agent 构建 LLM 工具表)。"""
|
||||
conn = self._connection(manifest)
|
||||
return conn.list_tool_schemas()
|
||||
|
||||
def reap_idle(self) -> int:
|
||||
reaped = 0
|
||||
with self._lock:
|
||||
@@ -448,6 +475,10 @@ class ToolDispatcher:
|
||||
return PythonToolProvider.execute(manifest, arguments)
|
||||
raise ToolExecutionError(f"工具 {manifest.name} provider={provider} 不支持调用")
|
||||
|
||||
def list_mcp_tools(self, manifest: CapabilityManifest) -> List[Dict[str, Any]]:
|
||||
"""MCP 能力全量工具 schema(bioflow_agent 构建 LLM 工具表用)。"""
|
||||
return self._mcp.list_tool_schemas(manifest)
|
||||
|
||||
def reap_idle(self) -> int:
|
||||
return self._mcp.reap_idle()
|
||||
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,255 @@
|
||||
"""test_bioflow_agent_s2.py —— S2 对话式编排闭环单测。
|
||||
|
||||
覆盖:card_result 回填协议(四类卡片)、参数抽取(白名单)、多轮记忆注入、
|
||||
session↔job 绑定上下文(active_job_id/chain)、job_status 主动查询。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import CapabilityManifest # noqa: E402
|
||||
from rag.qa_v1_1_73.tool_runner import ToolRunner # noqa: E402
|
||||
|
||||
|
||||
def _manifest(name="bioflow-gene-family", url="http://x/mcp/gene-family/"):
|
||||
return CapabilityManifest(name=name, provider="mcp", config={"mcp": {"url": url, "tool": name}})
|
||||
|
||||
|
||||
def _tool_manifests():
|
||||
"""覆盖四类卡片用到的工具 + job_status。"""
|
||||
names = ("create_gene_family_job", "create_rnaseq_job", "confirm_stage",
|
||||
"register_job_files", "fetch_source_file", "job_status")
|
||||
out = {}
|
||||
for t in names:
|
||||
out[t] = CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/mcp/gene-family/", "tool": t}},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self, results=None):
|
||||
self.results = results or {}
|
||||
self.calls = []
|
||||
|
||||
def execute(self, manifest, arguments):
|
||||
tool = manifest.config.get("mcp", {}).get("tool")
|
||||
self.calls.append((tool, arguments))
|
||||
return self.results.get(tool, '{"ok": true, "data": {"id": "job-1"}}')
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, complete_text="已收到,已为您处理。", json_payload=None):
|
||||
self.complete_text = complete_text
|
||||
self.json_payload = json_payload or {}
|
||||
|
||||
def complete(self, system, user, **kw):
|
||||
return self.complete_text
|
||||
|
||||
def complete_json(self, system, user, **kw):
|
||||
return self.json_payload
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "answer", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 参数抽取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractParams:
|
||||
def test_extract_known_and_unknown(self):
|
||||
llm = FakeLLM(json_payload={"params": {"threads": 8, "padj": 0.01}, "unknown": ["bogus"]})
|
||||
form_defaults = {"threads": {"default": 4}, "padj": {"default": 0.05}}
|
||||
known, unknown = ba._extract_params(llm, "用 8 核,padj 用 0.01", form_defaults)
|
||||
assert known == {"threads": 8, "padj": 0.01}
|
||||
assert unknown == ["bogus"]
|
||||
|
||||
def test_non_whitelist_param_filtered(self):
|
||||
llm = FakeLLM(json_payload={"params": {"evil": 1, "threads": 4}, "unknown": []})
|
||||
form_defaults = {"threads": {"default": 4}}
|
||||
known, unknown = ba._extract_params(llm, "x", form_defaults)
|
||||
assert known == {"threads": 4}
|
||||
|
||||
def test_bad_json_returns_empty(self):
|
||||
llm = FakeLLM(json_payload="not-a-dict")
|
||||
form_defaults = {"threads": {}}
|
||||
known, unknown = ba._extract_params(llm, "x", form_defaults)
|
||||
assert known == {} and unknown == []
|
||||
|
||||
def test_empty_whitelist(self):
|
||||
llm = FakeLLM()
|
||||
known, unknown = ba._extract_params(llm, "x", {})
|
||||
assert known == {} and unknown == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# card_result 回填协议
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCardResult:
|
||||
def test_path_selection_creates_job_and_extracts_job_id(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
llm = FakeLLM()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "stages": ["identify", "tree"], "title": "水稻SPL"}},
|
||||
llm, {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["active_job_id"] == "job-1"
|
||||
assert out["bioflow"]["chain"] == "gene_family"
|
||||
assert dispatcher.calls[0][0] == "create_gene_family_job"
|
||||
assert dispatcher.calls[0][1]["stages"] == ["identify", "tree"]
|
||||
assert dispatcher.calls[0][1]["session_id"] == "s1"
|
||||
|
||||
def test_path_selection_missing_stages_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection", "payload": {"chain": "gene_family"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_form_confirm_calls_confirm_stage(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm",
|
||||
"payload": {"job_id": "job-9", "stage": "identify", "params": {"evalue": 1e-5}}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "confirm_stage"
|
||||
assert dispatcher.calls[0][1] == {"job_id": "job-9", "stage": "identify", "params": {"evalue": 1e-5}}
|
||||
|
||||
def test_form_confirm_uses_active_job_id_fallback(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result(
|
||||
{"card": "form_confirm", "payload": {"stage": "identify", "params": {}}},
|
||||
FakeLLM(), {"active_job_id": "job-7"}, _tool_manifests(),
|
||||
)
|
||||
assert dispatcher.calls[0][1]["job_id"] == "job-7"
|
||||
|
||||
def test_file_upload_splits_remote_and_local(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload",
|
||||
"payload": {"job_id": "j1", "paths": ["https://x/a.fa", "/data/b.fa"]}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
tools = [c[0] for c in dispatcher.calls]
|
||||
assert "fetch_source_file" in tools and "register_job_files" in tools
|
||||
|
||||
def test_archive_choice_paraphrases(self):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice", "payload": {"job_id": "j1", "archive": True}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert out["answer"] == "已收到,已为您处理。"
|
||||
|
||||
def test_unknown_card_returns_none(self):
|
||||
out = ba._handle_card_result({"card": "bogus", "payload": {}}, FakeLLM(), {}, _tool_manifests())
|
||||
assert out is None
|
||||
|
||||
def test_path_selection_reuse_failure_no_stages_returns_none(self):
|
||||
"""bug ②:复用失败且无显式 stages → 返回 None(不空链建任务)。"""
|
||||
dispatcher = FakeDispatcher({"reuse_archive": '{"ok": false, "code": "ARCHIVE_NOT_FOUND"}'})
|
||||
tm = _tool_manifests()
|
||||
tm["reuse_archive"] = CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x", "tool": "reuse_archive"}},
|
||||
)
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "reuse_archive": "不存在的存档"}},
|
||||
FakeLLM(), {}, tm,
|
||||
)
|
||||
assert out is None
|
||||
# 只执行了 reuse_archive,未调用 create_gene_family_job
|
||||
assert [c[0] for c in dispatcher.calls] == ["reuse_archive"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前门路由(card_result / bioflow 上下文)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRouteCardResult:
|
||||
def test_try_bioflow_agent_card_result_short_circuits(self):
|
||||
registry_manifest = _manifest()
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[registry_manifest]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"type": "function", "function": {"name": "confirm_stage", "description": "", "parameters": {"type": "object", "properties": {}}}}], {"confirm_stage": registry_manifest})):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba.try_bioflow_agent(
|
||||
"确认", llm_client=FakeLLM(),
|
||||
card_result={"card": "form_confirm", "payload": {"job_id": "j1", "stage": "s", "params": {}}},
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["handled"] is True
|
||||
|
||||
def test_try_bioflow_agent_fuse_off_ignores_card_result(self):
|
||||
with mock.patch.dict("os.environ", {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
out = ba.try_bioflow_agent(
|
||||
"确认", llm_client=FakeLLM(),
|
||||
card_result={"card": "form_confirm", "payload": {}},
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 多轮记忆注入(ToolRunner.run history)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiTurnHistory:
|
||||
def test_history_injected_into_messages(self):
|
||||
class RecordingLLM:
|
||||
def __init__(self):
|
||||
self.captured_messages = None
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
self.captured_messages = list(messages)
|
||||
return {"content": "ok", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
llm = RecordingLLM()
|
||||
runner = ToolRunner(make_llm_complete(llm), max_rounds=1)
|
||||
history = [{"role": "user", "content": "上一轮问题"}, {"role": "assistant", "content": "上一轮答案"}]
|
||||
runner.run("sys", "当前问题", [], {}, history=history)
|
||||
msgs = llm.captured_messages
|
||||
assert msgs[0] == {"role": "user", "content": "上一轮问题"}
|
||||
assert msgs[1] == {"role": "assistant", "content": "上一轮答案"}
|
||||
assert msgs[-1] == {"role": "user", "content": "当前问题"}
|
||||
|
||||
def test_history_filters_bad_roles(self):
|
||||
class RecordingLLM:
|
||||
def __init__(self):
|
||||
self.captured_messages = None
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
self.captured_messages = list(messages)
|
||||
return {"content": "ok", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
llm = RecordingLLM()
|
||||
runner = ToolRunner(make_llm_complete(llm), max_rounds=1)
|
||||
history = [{"role": "system", "content": "evil"}, {"role": "user", "content": ""}, {"role": "user", "content": "ok"}]
|
||||
runner.run("sys", "q", [], {}, history=history)
|
||||
roles = [m["role"] for m in llm.captured_messages]
|
||||
assert "system" not in roles
|
||||
assert llm.captured_messages[-1] == {"role": "user", "content": "q"}
|
||||
@@ -0,0 +1,511 @@
|
||||
"""test_bioflow_agent_s2_supplement.py —— S2 覆盖清单补强测试。
|
||||
|
||||
补强 dev 的 test_bioflow_agent_s2.py 未覆盖的分支:
|
||||
- _chain_to_capability / _extract_job_id / _execute_tool / _paraphrase / _query_job_status
|
||||
- card 四类 handler 缺必需字段回退 None、files 键、archive=False、非 dict payload
|
||||
- _route_card_result:chain 过滤 / 未注册能力→None / tools 空→None
|
||||
- NL 路径注入 bioflow 上下文(active_job_id/chain)
|
||||
- system prompt 硬规则在位(追问 ≤3 / 不可代确认 / 工具名不外泄)
|
||||
- ToolRunner.run(history) 边界(non-dict 条目 / history=None / 空内容剔除)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import CapabilityManifest # noqa: E402
|
||||
from rag.qa_v1_1_73.tool_runner import ToolRunner, make_llm_complete # noqa: E402
|
||||
|
||||
|
||||
def _manifest(name="bioflow-gene-family", url="http://x/mcp/gene-family/"):
|
||||
return CapabilityManifest(name=name, provider="mcp", config={"mcp": {"url": url, "tool": name}})
|
||||
|
||||
|
||||
def _tool_manifests():
|
||||
names = ("create_gene_family_job", "create_rnaseq_job", "confirm_stage",
|
||||
"register_job_files", "fetch_source_file", "job_status",
|
||||
"rerun_stage", "reuse_archive", "archive_job")
|
||||
out = {}
|
||||
for t in names:
|
||||
out[t] = CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/mcp/gene-family/", "tool": t}},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, complete_text="已收到。", json_payload=None, complete_error=None):
|
||||
self.complete_text = complete_text
|
||||
self.json_payload = json_payload or {}
|
||||
self.complete_error = complete_error
|
||||
|
||||
def complete(self, system, user, **kw):
|
||||
if self.complete_error:
|
||||
raise self.complete_error
|
||||
return self.complete_text
|
||||
|
||||
def complete_json(self, system, user, **kw):
|
||||
return self.json_payload
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "answer", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self, results=None):
|
||||
self.results = results or {}
|
||||
self.calls = []
|
||||
|
||||
def execute(self, manifest, arguments):
|
||||
tool = manifest.config.get("mcp", {}).get("tool")
|
||||
self.calls.append((tool, arguments))
|
||||
return self.results.get(tool, '{"ok": true, "data": {"id": "job-1"}}')
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 辅助函数
|
||||
# ===========================================================================
|
||||
|
||||
class TestChainToCapability:
|
||||
def test_known(self):
|
||||
assert ba._chain_to_capability("gene_family") == "bioflow-gene-family"
|
||||
assert ba._chain_to_capability("rnaseq") == "bioflow-rnaseq"
|
||||
|
||||
def test_unknown_and_empty(self):
|
||||
assert ba._chain_to_capability("wgs") is None
|
||||
assert ba._chain_to_capability("") is None
|
||||
assert ba._chain_to_capability(None) is None
|
||||
|
||||
def test_case_and_whitespace(self):
|
||||
assert ba._chain_to_capability(" Gene_Family ") == "bioflow-gene-family"
|
||||
|
||||
|
||||
class TestExtractJobId:
|
||||
def test_data_id(self):
|
||||
assert ba._extract_job_id('{"ok": true, "data": {"id": "job-9"}}') == "job-9"
|
||||
|
||||
def test_data_job_id(self):
|
||||
assert ba._extract_job_id('{"ok": true, "data": {"job_id": "job-8"}}') == "job-8"
|
||||
|
||||
def test_flat_id(self):
|
||||
assert ba._extract_job_id('{"ok": true, "id": "job-7"}') == "job-7"
|
||||
|
||||
def test_non_json_and_no_id(self):
|
||||
assert ba._extract_job_id("not json") is None
|
||||
assert ba._extract_job_id('{"ok": false}') is None
|
||||
assert ba._extract_job_id("") is None
|
||||
|
||||
|
||||
class TestExecuteTool:
|
||||
def test_unknown_tool(self):
|
||||
out = ba._execute_tool(_tool_manifests(), "ghost", {})
|
||||
assert "未注册" in out
|
||||
|
||||
def test_tool_exception_becomes_text(self):
|
||||
class Boom:
|
||||
def execute(self, manifest, arguments):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=Boom()):
|
||||
out = ba._execute_tool(_tool_manifests(), "job_status", {"job_id": "j"})
|
||||
assert "工具执行失败" in out
|
||||
|
||||
|
||||
class TestParaphrase:
|
||||
def test_llm_success(self):
|
||||
out = ba._paraphrase(FakeLLM(complete_text="已转述。"), "原文")
|
||||
assert out == "已转述。"
|
||||
|
||||
def test_llm_exception_falls_back_to_raw(self):
|
||||
out = ba._paraphrase(FakeLLM(complete_error=RuntimeError("boom")), "原文")
|
||||
assert out == "原文"
|
||||
|
||||
def test_llm_empty_falls_back_to_raw(self):
|
||||
out = ba._paraphrase(FakeLLM(complete_text=" "), "原文")
|
||||
assert out == "原文"
|
||||
|
||||
|
||||
class TestQueryJobStatus:
|
||||
def test_calls_job_status_and_paraphrases(self):
|
||||
with mock.patch.object(ba, "_execute_tool", return_value='{"ok": true, "data": {"status": "running"}}'):
|
||||
out = ba._query_job_status("j1", _tool_manifests(), FakeLLM(complete_text="任务进行中。"))
|
||||
assert out == "任务进行中。"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# card 四类 handler 边界(缺必需字段 / files 键 / archive=False / 非 dict payload)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCardHandlersEdge:
|
||||
def test_form_confirm_missing_job_and_stage_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm", "payload": {"params": {}}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_file_upload_missing_job_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload", "payload": {"paths": ["/data/a.fa"]}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_file_upload_missing_files_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload", "payload": {"job_id": "j1"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_file_upload_uses_files_key(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload",
|
||||
"payload": {"job_id": "j1", "files": ["https://x/a.fa"]}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "fetch_source_file"
|
||||
|
||||
def test_file_upload_string_files_coerced(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload",
|
||||
"payload": {"job_id": "j1", "files": "https://x/a.fa"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "fetch_source_file"
|
||||
|
||||
def test_archive_choice_missing_job_returns_none(self):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice", "payload": {"archive": True}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_archive_choice_skip(self):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice", "payload": {"job_id": "j1", "archive": False}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["audit"]["tool_calls"] == 0 # 存档不调工具(走 REST)
|
||||
|
||||
def test_path_selection_rnaseq_chain(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "rnaseq", "stages": ["rnaseq_qc"]}},
|
||||
FakeLLM(), {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "create_rnaseq_job"
|
||||
|
||||
def test_card_non_dict_payload_treated_empty(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm", "payload": "not-a-dict"},
|
||||
FakeLLM(), {"active_job_id": "j1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is None # payload 非 dict → 无 stage/job_id → None
|
||||
|
||||
def test_card_result_with_non_dict_is_ignored(self):
|
||||
# try_bioflow_agent 对非 dict 的 card_result 不做 card 路由,走 NL 意图
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_candidate_capabilities", return_value=[]):
|
||||
out = ba.try_bioflow_agent("确认", llm_client=FakeLLM(), card_result="bogus")
|
||||
assert out is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _route_card_result 边界
|
||||
# ===========================================================================
|
||||
|
||||
class TestRouteCardResultEdge:
|
||||
def test_chain_filters_to_capability(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
rn = _manifest("bioflow-rnaseq", "http://x/mcp/rnaseq/")
|
||||
captured = {}
|
||||
|
||||
def fake_build(manifests):
|
||||
captured["manifests"] = [m.name for m in manifests]
|
||||
return ([], {})
|
||||
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf, rn]):
|
||||
with mock.patch.object(ba, "_build_tools", side_effect=fake_build):
|
||||
ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {"chain": "rnaseq"}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
assert captured["manifests"] == ["bioflow-rnaseq"] # chain 过滤到 rnaseq 能力
|
||||
|
||||
def test_chain_to_unregistered_capability_returns_none(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"x": 1}], {})):
|
||||
out = ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {"chain": "rnaseq"}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
assert out is None # rnaseq 未注册 → manifests 空 → None
|
||||
|
||||
def test_tools_empty_returns_none(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([], {})):
|
||||
out = ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_progress_callback_invoked(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
calls = []
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"x": 1}], {})):
|
||||
with mock.patch.object(ba, "_handle_card_result", return_value=None):
|
||||
ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {}},
|
||||
FakeLLM(), None, None, lambda p: calls.append(p),
|
||||
)
|
||||
assert calls and calls[0]["stage"] == "bioflow_card"
|
||||
assert calls[0]["card"] == "form_confirm"
|
||||
|
||||
def test_progress_callback_exception_swallowed(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
|
||||
def boom(p):
|
||||
raise RuntimeError("callback boom")
|
||||
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"x": 1}], {})):
|
||||
with mock.patch.object(ba, "_handle_card_result", return_value={"ok": True}):
|
||||
out = ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {}},
|
||||
FakeLLM(), None, None, boom,
|
||||
)
|
||||
assert out == {"ok": True} # 回调异常被吞,不影响主流程
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# NL 路径:bioflow 上下文注入 / 卡片与 NL 语义等价
|
||||
# ===========================================================================
|
||||
|
||||
class TestNLPathBioflowContext:
|
||||
def test_nl_path_injects_bioflow_context(self, tmp_path):
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry
|
||||
from rag.qa_v1_1_73.capabilities.manifests import parse_capabilities_text
|
||||
yaml = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [基因家族]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
"""
|
||||
(tmp_path / "c.yaml").write_text(yaml, encoding="utf-8")
|
||||
registry = CapabilityRegistry(tmp_path / "c.yaml")
|
||||
registry.load()
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("已答复", {"rounds": 1})
|
||||
out = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLM(),
|
||||
bioflow={"session_id": "s1", "active_job_id": "job-9", "chain": "gene_family"},
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["active_job_id"] == "job-9"
|
||||
assert out["bioflow"]["chain"] == "gene_family"
|
||||
|
||||
def test_card_and_nl_share_tool_chain(self):
|
||||
"""卡片操作与纯 NL 服务端语义等价:卡片 handler 只调 MCP 工具,不直连控制面。"""
|
||||
# 卡片 handler 用到的工具名 = MCP 工具(job_status/confirm_stage/create_* 等)
|
||||
tm = _tool_manifests()
|
||||
# 四类卡片触发的工具都在 tool_manifests(即 MCP 工具表)里
|
||||
for tool in ("create_gene_family_job", "create_rnaseq_job", "confirm_stage",
|
||||
"register_job_files", "fetch_source_file", "job_status"):
|
||||
assert tool in tm
|
||||
|
||||
|
||||
class TestExtractParamsEdge:
|
||||
def test_llm_exception_returns_empty(self):
|
||||
class RaisingLLM(FakeLLM):
|
||||
def complete_json(self, system, user, **kw):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
known, unknown = ba._extract_params(RaisingLLM(), "x", {"threads": {}})
|
||||
assert known == {} and unknown == []
|
||||
|
||||
def test_unknown_as_string_coerced_to_list(self):
|
||||
llm = FakeLLM(json_payload={"params": {"threads": 8}, "unknown": "bogus_param"})
|
||||
known, unknown = ba._extract_params(llm, "x", {"threads": {}})
|
||||
assert known == {"threads": 8}
|
||||
assert unknown == ["bogus_param"]
|
||||
|
||||
def test_path_selection_with_params(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "stages": ["identify"],
|
||||
"params": {"prefix": "Ft.SPL"}}},
|
||||
FakeLLM(), {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert dispatcher.calls[0][1]["params"] == {"prefix": "Ft.SPL"}
|
||||
|
||||
def test_path_selection_create_tool_missing_returns_none(self):
|
||||
tm = _tool_manifests()
|
||||
tm.pop("create_gene_family_job")
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "stages": ["identify"]}},
|
||||
FakeLLM(), {"session_id": "s1"}, tm,
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# system prompt 硬规则在位
|
||||
# ===========================================================================
|
||||
|
||||
class TestSystemPromptRules:
|
||||
def test_param_retry_rule(self):
|
||||
assert "最多 3 次" in ba._SYSTEM_BASE
|
||||
|
||||
def test_human_boundary_rule(self):
|
||||
assert "不可跳过" in ba._SYSTEM_BASE
|
||||
assert "不可代用户确认" in ba._SYSTEM_BASE
|
||||
|
||||
def test_tool_name_no_leak_rule(self):
|
||||
assert "绝不向用户泄露工具名" in ba._SYSTEM_BASE
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ToolRunner.run(history) 边界
|
||||
# ===========================================================================
|
||||
|
||||
class TestHistoryEdge:
|
||||
def _run_with_history(self, history):
|
||||
captured = {}
|
||||
|
||||
class RecordingLLM:
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
captured["messages"] = list(messages)
|
||||
return {"content": "ok", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
runner = ToolRunner(make_llm_complete(RecordingLLM()), max_rounds=1)
|
||||
runner.run("sys", "当前", [], {}, history=history)
|
||||
return captured["messages"]
|
||||
|
||||
def test_none_history(self):
|
||||
msgs = self._run_with_history(None)
|
||||
assert msgs == [{"role": "user", "content": "当前"}]
|
||||
|
||||
def test_non_dict_items_skipped(self):
|
||||
msgs = self._run_with_history(["not-a-dict", {"role": "user", "content": "ok"}])
|
||||
assert msgs == [{"role": "user", "content": "ok"}, {"role": "user", "content": "当前"}]
|
||||
|
||||
def test_empty_content_filtered(self):
|
||||
msgs = self._run_with_history([{"role": "user", "content": " "}])
|
||||
assert msgs == [{"role": "user", "content": "当前"}]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S3:card_result rerun / reuse_archive / archive_job 语义
|
||||
# ===========================================================================
|
||||
|
||||
class TestS3CardSemantics:
|
||||
def test_form_confirm_rerun_true_uses_rerun_stage(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm",
|
||||
"payload": {"job_id": "j1", "stage": "identify",
|
||||
"params": {"evalue": 1e-5}, "rerun": True}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "rerun_stage"
|
||||
assert out["bioflow"]["audit"]["tool_names"] == ["rerun_stage"]
|
||||
|
||||
def test_form_confirm_rerun_false_uses_confirm_stage(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result(
|
||||
{"card": "form_confirm",
|
||||
"payload": {"job_id": "j1", "stage": "identify", "params": {}}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert dispatcher.calls[0][0] == "confirm_stage"
|
||||
|
||||
def test_path_selection_reuse_archive(self):
|
||||
dispatcher = FakeDispatcher({
|
||||
"reuse_archive": '{"ok": true, "data": {"id": "arch-1", '
|
||||
'"stage_chain": ["identify", "phylogeny"], '
|
||||
'"params": {"identify": {"prefix": "Ft"}}}}',
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "reuse_archive": "SPL 鉴定"}},
|
||||
FakeLLM(), {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
tools = [c[0] for c in dispatcher.calls]
|
||||
assert tools[0] == "reuse_archive"
|
||||
assert tools[1] == "create_gene_family_job"
|
||||
create_args = dispatcher.calls[1][1]
|
||||
assert create_args["archive_id"] == "arch-1"
|
||||
assert create_args["stages"] == ["identify", "phylogeny"]
|
||||
assert create_args["params"] == {"identify": {"prefix": "Ft"}}
|
||||
assert out["bioflow"]["audit"]["tool_names"] == ["reuse_archive", "create_gene_family_job"]
|
||||
|
||||
def test_archive_choice_archive_true_calls_archive_job(self):
|
||||
dispatcher = FakeDispatcher({"archive_job": '{"ok": true, "data": {"id": "a1"}}'})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice",
|
||||
"payload": {"job_id": "j1", "archive": True,
|
||||
"name": "SPL 存档", "description": "d"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "archive_job"
|
||||
assert dispatcher.calls[0][1] == {"job_id": "j1", "name": "SPL 存档", "description": "d"}
|
||||
assert out["bioflow"]["audit"]["tool_names"] == ["archive_job"]
|
||||
|
||||
def test_archive_choice_archive_false_no_tool_call(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice",
|
||||
"payload": {"job_id": "j1", "archive": False}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls == [] # 跳过存档不发工具请求
|
||||
assert out["bioflow"]["audit"]["tool_calls"] == 0
|
||||
@@ -0,0 +1,206 @@
|
||||
"""test_bioflow_agent_s4_supplement.py —— S4 覆盖清单补强测试。
|
||||
|
||||
覆盖:
|
||||
- _card_extension_action 六种 action 分发到对应扩展工具
|
||||
- 未知 action / 缺 ext_id/action 回退 None
|
||||
- confirm 携带 nl_triggers/parameters/runtime_image;EXT_STATUS 降级话术(确定性)
|
||||
- run 携带 params/files
|
||||
- 真实 configs/capabilities.yaml 三条目(含 bioflow-extension)可解析
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import CapabilityManifest, parse_capabilities_file # noqa: E402
|
||||
|
||||
EXT_TOOLS = (
|
||||
"create_extension", "extension_status", "build_extension", "trial_run_extension",
|
||||
"validate_extension", "confirm_extension", "publish_extension", "run_extension",
|
||||
"repair_extension",
|
||||
)
|
||||
|
||||
|
||||
def _ext_tool_manifests():
|
||||
out = {}
|
||||
for t in EXT_TOOLS:
|
||||
out[t] = CapabilityManifest(
|
||||
name="bioflow-extension", provider="mcp",
|
||||
config={"mcp": {"url": "http://127.0.0.1:8098/mcp/extension/", "tool": t}},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def complete(self, system, user, **kw):
|
||||
return "已收到。"
|
||||
|
||||
def complete_json(self, system, user, **kw):
|
||||
return {}
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "answer", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self, results=None):
|
||||
self.results = results or {}
|
||||
self.calls = []
|
||||
|
||||
def execute(self, manifest, arguments):
|
||||
tool = manifest.config.get("mcp", {}).get("tool")
|
||||
self.calls.append((tool, arguments))
|
||||
return self.results.get(tool, '{"ok": true, "data": {"ext_id": "e1"}}')
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _card_extension_action 分发
|
||||
# ===========================================================================
|
||||
|
||||
class TestExtensionAction:
|
||||
@pytest.mark.parametrize("action,expected_tool", [
|
||||
("build", "build_extension"),
|
||||
("trial_run", "trial_run_extension"),
|
||||
("validate", "validate_extension"),
|
||||
("confirm", "confirm_extension"),
|
||||
("publish", "publish_extension"),
|
||||
("run", "run_extension"),
|
||||
("repair", "repair_extension"),
|
||||
])
|
||||
def test_action_dispatches_to_tool(self, action, expected_tool):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": action}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == expected_tool
|
||||
assert dispatcher.calls[0][1] == {"ext_id": "e1"}
|
||||
assert out["bioflow"]["audit"]["tool_names"] == [expected_tool]
|
||||
|
||||
def test_unknown_action_returns_none(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "bogus"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_missing_ext_id_or_action_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
assert ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"action": "build"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
) is None
|
||||
assert ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
) is None
|
||||
|
||||
def test_confirm_passes_triggers_params_image(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
payload = {"ext_id": "e1", "action": "confirm",
|
||||
"nl_triggers": ["扩展", "触发词"], "parameters": [{"name": "x"}],
|
||||
"runtime_image": "img"}
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result({"card": "extension_action", "payload": payload},
|
||||
FakeLLM(), {}, _ext_tool_manifests())
|
||||
args = dispatcher.calls[0][1]
|
||||
assert args == {"ext_id": "e1", "nl_triggers": ["扩展", "触发词"],
|
||||
"parameters": [{"name": "x"}], "runtime_image": "img"}
|
||||
|
||||
def test_confirm_status_insufficient_uses_deterministic_script(self):
|
||||
# confirm 返回 EXT_STATUS(状态不足)→ 确定性降级话术(不报 500、不经 LLM)
|
||||
dispatcher = FakeDispatcher({
|
||||
"confirm_extension": '{"ok": false, "code": "EXT_STATUS", "message": "状态不可确认"}',
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "confirm"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert "还不能确认" in out["answer"]
|
||||
assert "先完成" in out["answer"] # 确定性话术(非 LLM 转述)
|
||||
|
||||
def test_run_passes_params_and_files(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
payload = {"ext_id": "e1", "action": "run",
|
||||
"params": {"cpu": 8}, "files": ["/data/a.fa"]}
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result({"card": "extension_action", "payload": payload},
|
||||
FakeLLM(), {}, _ext_tool_manifests())
|
||||
assert dispatcher.calls[0][1] == {"ext_id": "e1", "params": {"cpu": 8}, "files": ["/data/a.fa"]}
|
||||
|
||||
def test_extension_tool_not_in_manifests_returns_none(self):
|
||||
# tool_manifests 里没有扩展工具 → 回退 None(能力未安装/未发现工具)
|
||||
empty = CapabilityManifest(name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x", "tool": "job_status"}})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "build"}},
|
||||
FakeLLM(), {}, {"job_status": empty},
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 真实 capabilities.yaml 三条目可解析
|
||||
# ===========================================================================
|
||||
|
||||
class TestCapabilitiesThreeEntries:
|
||||
def _real_yaml(self) -> Path:
|
||||
return Path(__file__).resolve().parents[5] / "agent" / "qa" / "configs" / "capabilities.yaml"
|
||||
|
||||
def test_three_bioflow_entries(self):
|
||||
manifests, warnings = parse_capabilities_file(self._real_yaml())
|
||||
by_name = {m.name: m for m in manifests}
|
||||
assert {"bioflow-gene-family", "bioflow-rnaseq", "bioflow-extension"} <= set(by_name)
|
||||
|
||||
ext = by_name["bioflow-extension"]
|
||||
assert ext.provider == "mcp"
|
||||
assert ext.permission == "read_only"
|
||||
assert ext.nl_triggers
|
||||
assert ext.config["mcp"]["url"].endswith("/mcp/extension/")
|
||||
assert ext.config["prompt_pack"]["file"] == "bioflow_extension_agent_rules.md"
|
||||
|
||||
def test_bioflow_extension_in_capability_names(self):
|
||||
assert "bioflow-extension" in ba.BIOFLOW_CAPABILITY_NAMES
|
||||
assert ba.CARD_EXTENSION_ACTION == "extension_action"
|
||||
assert ba.CARD_EXTENSION_ACTION in ba.CARD_TYPES
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S5:_route_card_result 对 extension_action 只发现 extension 能力(一次 list_tools)
|
||||
# ===========================================================================
|
||||
|
||||
class TestRouteExtensionOptimization:
|
||||
def test_extension_action_discovers_only_extension_capability(self):
|
||||
gf = CapabilityManifest(name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/gene-family/"}})
|
||||
rn = CapabilityManifest(name="bioflow-rnaseq", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/rnaseq/"}})
|
||||
ext = CapabilityManifest(name="bioflow-extension", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/extension/"}})
|
||||
captured = {}
|
||||
|
||||
def fake_build(manifests):
|
||||
captured["names"] = [m.name for m in manifests]
|
||||
return ([], {})
|
||||
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf, rn, ext]):
|
||||
with mock.patch.object(ba, "_build_tools", side_effect=fake_build):
|
||||
ba._route_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "build"}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
# 只把 bioflow-extension manifest 交给 _build_tools(避免对三能力各 list_tools 一次)
|
||||
assert captured["names"] == ["bioflow-extension"]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""test_bioflow_agent_s6_config.py —— S6 多配置回归(QA 单元层)。
|
||||
|
||||
模拟四种部署配置,断言候选能力集合 + 意图预筛结果正确(真实 E2E/金标 20 题依赖部署,
|
||||
此处以 monkeypatch/env 在单元层覆盖漏判安全语义)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry # noqa: E402
|
||||
|
||||
THREE_YAML = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 亚家族]
|
||||
mcp: {url: "http://x/gene-family/"}
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [转录组, 差异表达]
|
||||
mcp: {url: "http://x/rnaseq/"}
|
||||
- name: bioflow-extension
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [自定义扩展]
|
||||
mcp: {url: "http://x/extension/"}
|
||||
"""
|
||||
|
||||
|
||||
def _registry(tmp_path, yaml_text=THREE_YAML):
|
||||
path = tmp_path / "caps.yaml"
|
||||
path.write_text(yaml_text, encoding="utf-8")
|
||||
reg = CapabilityRegistry(path)
|
||||
reg.load()
|
||||
return reg
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def complete_raw_messages(self, *a, **kw):
|
||||
return {"content": "x", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 1:全关(QA_V1_ENABLE_BIOFLOW_AGENT=0 + 三能力全卸载)
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigAllOff:
|
||||
def test_fuse_off_returns_none(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
out = ba.try_bioflow_agent("帮我做基因家族分析", llm_client=FakeLLM())
|
||||
assert out is None # 走原 pipeline
|
||||
|
||||
def test_all_uninstalled_no_candidate(self, tmp_path):
|
||||
reg = _registry(tmp_path, "capabilities: []\n") # 三能力全卸载
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
assert ba._candidate_capabilities("帮我做基因家族分析", None) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 2:只开基因家族
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigGeneFamilyOnly:
|
||||
def test_gene_family_hit_rnaseq_miss(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
caps = {"bioflow-gene-family": True, "bioflow-rnaseq": False, "bioflow-extension": False}
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
names = [m.name for m in ba._candidate_capabilities("帮我做水稻 SPL 基因家族分析", caps)]
|
||||
assert names == ["bioflow-gene-family"]
|
||||
assert ba._candidate_capabilities("帮我做转录组差异表达分析", caps) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 3:只开转录组
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigRnaseqOnly:
|
||||
def test_rnaseq_hit_gene_family_miss(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
caps = {"bioflow-gene-family": False, "bioflow-rnaseq": True, "bioflow-extension": False}
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
names = [m.name for m in ba._candidate_capabilities("转录组差异表达分析", caps)]
|
||||
assert names == ["bioflow-rnaseq"]
|
||||
assert ba._candidate_capabilities("帮我做水稻 SPL 基因家族分析", caps) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 4:全开但非生信提问
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigAllOnNonBioflow:
|
||||
def test_non_bioflow_question_no_candidate(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
caps = {"bioflow-gene-family": True, "bioflow-rnaseq": True, "bioflow-extension": True}
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
assert ba._candidate_capabilities("光合作用的电子传递链是什么", caps) == []
|
||||
assert ba._candidate_capabilities("什么是转录因子", caps) == []
|
||||
@@ -0,0 +1,496 @@
|
||||
"""test_bioflow_agent_supplement.py —— S1 覆盖清单补强测试。
|
||||
|
||||
补强 dev 的 test_bioflow_agent.py 未覆盖的分支(对照 S1 覆盖清单):
|
||||
- 熔断开关值解析(1/true/yes/on 与 0/false/off/空/缺失)
|
||||
- nl_triggers 同时命中 → 两个候选(交 LLM 依 description 选)
|
||||
- session_default 回退(True/False)/ registry 访问异常 / registry.get 异常
|
||||
- _build_tools:MCP 工具发现(空 url 跳过 / 发现异常 / 共享连接按能力名 / 无名跳过 / bad schema 兜底)
|
||||
- _build_system:prompt_pack 注入 / 去重 / 加载异常兜底
|
||||
- try_bioflow_agent:tools 空→None、answer 空→None、progress_callback 触发与吞异常
|
||||
- ToolRunner max_rounds=5 / bioflow_agent.MAX_ROUNDS 常量
|
||||
- make_llm_complete 包装(正常 / 异常)
|
||||
- 真实 configs/capabilities.yaml 两条目可解析
|
||||
- bioflow_agent 不 import pipeline/answer_composer(红线静态守卫)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import ( # noqa: E402
|
||||
CapabilityManifest,
|
||||
parse_capabilities_file,
|
||||
)
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry # noqa: E402
|
||||
|
||||
BIOFLOW_YAML = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 家族成员, 亚家族, 建树]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析
|
||||
session_default: true
|
||||
nl_triggers: [转录组, RNA-seq, 差异表达, 富集]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
"""
|
||||
|
||||
|
||||
def _make_registry(tmp_path: Path, yaml_text: str = BIOFLOW_YAML) -> CapabilityRegistry:
|
||||
path = tmp_path / "capabilities.yaml"
|
||||
path.write_text(yaml_text, encoding="utf-8")
|
||||
registry = CapabilityRegistry(path)
|
||||
registry.load()
|
||||
return registry
|
||||
|
||||
|
||||
def _gf_manifest(**overrides) -> CapabilityManifest:
|
||||
cfg = {
|
||||
"mcp": {"url": "http://127.0.0.1:8098/mcp/gene-family/"},
|
||||
"prompt_pack": {"file": "bioflow_gene_family_agent_rules.md"},
|
||||
}
|
||||
cfg.update(overrides.pop("config", {}))
|
||||
return CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp", permission="read_only",
|
||||
description="基因家族分析", config=cfg, nl_triggers=["基因家族"], **overrides,
|
||||
)
|
||||
|
||||
|
||||
class FakeLLMClient:
|
||||
def __init__(self, content: str = "已为您规划分析路径。") -> None:
|
||||
self.content = content
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kwargs):
|
||||
return {"content": self.content, "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 门 1:熔断开关值解析
|
||||
# ===========================================================================
|
||||
|
||||
class TestEnvFuse:
|
||||
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", " On ", "YES"])
|
||||
def test_truthy(self, value):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: value}):
|
||||
assert ba._env_enabled() is True
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "off", "", "no", " "])
|
||||
def test_falsy(self, value):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: value}):
|
||||
assert ba._env_enabled() is False
|
||||
|
||||
def test_missing_defaults_off(self):
|
||||
with mock.patch.dict(ba.os.environ, {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
assert ba._env_enabled() is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 门 3:nl_triggers 意图预筛
|
||||
# ===========================================================================
|
||||
|
||||
class TestIntentMatchEdge:
|
||||
def test_empty_triggers_never_match(self):
|
||||
assert ba._intent_match("做基因家族分析", []) is False
|
||||
assert ba._intent_match("做基因家族分析", [""]) is False
|
||||
|
||||
def test_case_insensitive_substring(self):
|
||||
assert ba._intent_match("RNA-SEQ 分析", ["rna-seq"]) is True
|
||||
assert ba._intent_match("ka/ks 选择压力", ["Ka/Ks"]) is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 门 2 + 门 3:候选能力(registry 异常 / session_default 回退 / 同时命中)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCandidateCapabilities:
|
||||
def test_registry_access_exception_returns_empty(self):
|
||||
with mock.patch.object(ba, "get_registry", side_effect=RuntimeError("boom")):
|
||||
assert ba._candidate_capabilities("做基因家族", None) == []
|
||||
|
||||
def test_registry_get_exception_skips(self):
|
||||
class BadRegistry:
|
||||
def get(self, name):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with mock.patch.object(ba, "get_registry", return_value=BadRegistry()):
|
||||
assert ba._candidate_capabilities("做基因家族", None) == []
|
||||
|
||||
def test_both_triggers_match_returns_both(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
caps = ba._candidate_capabilities("做基因家族和转录组差异表达分析", None)
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family", "bioflow-rnaseq"]
|
||||
|
||||
def test_session_default_false_skips(self, tmp_path):
|
||||
yaml = BIOFLOW_YAML.replace("session_default: true", "session_default: false")
|
||||
registry = _make_registry(tmp_path, yaml)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
# 无会话开关时回退 session_default=false → 不候选
|
||||
assert ba._candidate_capabilities("做基因家族分析", None) == []
|
||||
# 显式会话开关 true → 候选
|
||||
caps = ba._candidate_capabilities("做基因家族分析", {"bioflow-gene-family": True})
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family"]
|
||||
|
||||
def test_session_switch_true_overrides_default_false(self, tmp_path):
|
||||
yaml = BIOFLOW_YAML.replace("session_default: true", "session_default: false", 1)
|
||||
registry = _make_registry(tmp_path, yaml)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
caps = ba._candidate_capabilities("做基因家族分析", {"bioflow-gene-family": True})
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _build_tools:MCP 工具发现
|
||||
# ===========================================================================
|
||||
|
||||
class _FakeDispatcher:
|
||||
def __init__(self, tools_by_name=None, exc=None):
|
||||
self.tools_by_name = tools_by_name or {}
|
||||
self.exc = exc
|
||||
|
||||
def list_mcp_tools(self, manifest):
|
||||
if self.exc:
|
||||
raise self.exc
|
||||
return self.tools_by_name.get(manifest.name, [])
|
||||
|
||||
|
||||
class TestBuildTools:
|
||||
def test_empty_url_skipped(self):
|
||||
manifest = CapabilityManifest(
|
||||
name="x", provider="mcp", config={"mcp": {"url": ""}}
|
||||
)
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=_FakeDispatcher()):
|
||||
tools, tool_manifests = ba._build_tools([manifest])
|
||||
assert tools == []
|
||||
assert tool_manifests == {}
|
||||
|
||||
def test_discovery_exception_skipped(self):
|
||||
manifest = _gf_manifest()
|
||||
with mock.patch.object(
|
||||
ba, "get_dispatcher",
|
||||
return_value=_FakeDispatcher(exc=RuntimeError("mcp down")),
|
||||
):
|
||||
tools, tool_manifests = ba._build_tools([manifest])
|
||||
assert tools == []
|
||||
assert tool_manifests == {}
|
||||
|
||||
def test_builds_schema_and_shared_connection(self):
|
||||
dispatcher = _FakeDispatcher({
|
||||
"bioflow-gene-family": [
|
||||
{"name": "plan_gene_family", "description": "规划路径",
|
||||
"input_schema": {"type": "object", "properties": {"message": {"type": "string"}}}},
|
||||
]
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
tools, tool_manifests = ba._build_tools([_gf_manifest()])
|
||||
assert len(tools) == 1
|
||||
assert tools[0]["function"]["name"] == "plan_gene_family"
|
||||
assert tools[0]["function"]["description"] == "规划路径"
|
||||
# 共享连接:manifest.name 用能力名,mcp.tool 指向真实 server 工具名
|
||||
tm = tool_manifests["plan_gene_family"]
|
||||
assert tm.name == "bioflow-gene-family"
|
||||
assert tm.config["mcp"]["tool"] == "plan_gene_family"
|
||||
assert tm.config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
|
||||
def test_skips_nameless_and_bad_schema(self):
|
||||
dispatcher = _FakeDispatcher({
|
||||
"bioflow-gene-family": [
|
||||
{"name": "", "description": "无名", "input_schema": {}},
|
||||
{"name": "job_status", "description": "查状态", "input_schema": "not-a-dict"},
|
||||
]
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
tools, tool_manifests = ba._build_tools([_gf_manifest()])
|
||||
assert [t["function"]["name"] for t in tools] == ["job_status"]
|
||||
assert tools[0]["function"]["parameters"] == {"type": "object", "properties": {}}
|
||||
|
||||
def test_shared_tool_deduped_across_capabilities(self):
|
||||
"""两能力同时命中:共享工具去重(只保留首个能力),专有工具各自保留。
|
||||
|
||||
覆盖 bug ① 修复:job_status 等 12 个共享工具两端点语义一致,按名去重后
|
||||
LLM tools 列表无重名,且 job_status 路由到首个能力(gene-family)。
|
||||
"""
|
||||
dispatcher = _FakeDispatcher({
|
||||
"bioflow-gene-family": [
|
||||
{"name": "plan_gene_family", "description": "规划基因家族", "input_schema": {}},
|
||||
{"name": "job_status", "description": "查状态", "input_schema": {}},
|
||||
{"name": "confirm_stage", "description": "确认参数", "input_schema": {}},
|
||||
],
|
||||
"bioflow-rnaseq": [
|
||||
{"name": "plan_rnaseq", "description": "规划转录组", "input_schema": {}},
|
||||
{"name": "job_status", "description": "查状态", "input_schema": {}},
|
||||
{"name": "confirm_stage", "description": "确认参数", "input_schema": {}},
|
||||
],
|
||||
})
|
||||
rn = CapabilityManifest(
|
||||
name="bioflow-rnaseq", provider="mcp", permission="read_only",
|
||||
description="转录组分析", nl_triggers=["转录组"],
|
||||
config={"mcp": {"url": "http://127.0.0.1:8098/mcp/rnaseq/"}},
|
||||
)
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
tools, tool_manifests = ba._build_tools([_gf_manifest(), rn])
|
||||
|
||||
names = [t["function"]["name"] for t in tools]
|
||||
# 专有工具各保留 + 共享工具只保留一份,且无重名
|
||||
assert names == ["plan_gene_family", "job_status", "confirm_stage", "plan_rnaseq"]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
# 共享工具路由到首个能力(gene-family)
|
||||
assert tool_manifests["job_status"].name == "bioflow-gene-family"
|
||||
assert tool_manifests["job_status"].config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
assert tool_manifests["confirm_stage"].name == "bioflow-gene-family"
|
||||
# 专有工具各自路由到自身能力
|
||||
assert tool_manifests["plan_rnaseq"].name == "bioflow-rnaseq"
|
||||
assert tool_manifests["plan_rnaseq"].config["mcp"]["url"].endswith("/mcp/rnaseq/")
|
||||
assert tool_manifests["plan_gene_family"].name == "bioflow-gene-family"
|
||||
|
||||
# ===========================================================================
|
||||
# _build_system:prompt_pack 注入
|
||||
# ===========================================================================
|
||||
|
||||
class TestBuildSystem:
|
||||
def test_appends_prompt_pack(self):
|
||||
manifest = _gf_manifest()
|
||||
with mock.patch.object(ba.PromptPackProvider, "load", return_value="基因家族规则正文"):
|
||||
system = ba._build_system([manifest])
|
||||
assert ba._SYSTEM_BASE in system
|
||||
assert "基因家族规则正文" in system
|
||||
|
||||
def test_dedup_same_file_once(self):
|
||||
m1 = _gf_manifest()
|
||||
m2 = _gf_manifest() # 同 file
|
||||
calls = []
|
||||
with mock.patch.object(ba.PromptPackProvider, "load", side_effect=lambda m, root: calls.append(m) or "RULES"):
|
||||
system = ba._build_system([m1, m2])
|
||||
assert len(calls) == 1 # 去重后只加载一次
|
||||
assert system.count("RULES") == 1
|
||||
|
||||
def test_empty_file_skipped(self):
|
||||
manifest = CapabilityManifest(name="x", provider="mcp", config={"prompt_pack": {"file": ""}})
|
||||
system = ba._build_system([manifest])
|
||||
assert system == ba._SYSTEM_BASE
|
||||
|
||||
def test_load_exception_falls_back(self):
|
||||
manifest = _gf_manifest()
|
||||
with mock.patch.object(ba.PromptPackProvider, "load", side_effect=RuntimeError("boom")):
|
||||
system = ba._build_system([manifest])
|
||||
assert ba._SYSTEM_BASE in system # 不炸,只用基础提示
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# try_bioflow_agent:tools 空 / answer 空 / progress_callback
|
||||
# ===========================================================================
|
||||
|
||||
class TestFullFlowEdge:
|
||||
def test_tools_empty_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([], {})):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_answer_empty_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("", {"rounds": 1})
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_progress_callback_invoked(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
calls = []
|
||||
|
||||
def cb(payload):
|
||||
calls.append(payload)
|
||||
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("已答复", {"rounds": 1})
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(),
|
||||
capabilities=None, progress_callback=cb,
|
||||
)
|
||||
assert result is not None
|
||||
assert calls and calls[0]["stage"] == "bioflow_agent_started"
|
||||
assert calls[0]["capabilities"] == ["bioflow-gene-family"]
|
||||
assert calls[0]["tool_count"] == 1
|
||||
|
||||
def test_progress_callback_exception_swallowed(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
|
||||
def cb(payload):
|
||||
raise RuntimeError("callback boom")
|
||||
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("已答复", {"rounds": 1})
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(),
|
||||
capabilities=None, progress_callback=cb,
|
||||
)
|
||||
assert result is not None # 回调异常被吞,不影响主流程
|
||||
assert result["answer"] == "已答复"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ToolRunner max_rounds=5 / MAX_ROUNDS 常量
|
||||
# ===========================================================================
|
||||
|
||||
class TestMaxRounds:
|
||||
def test_max_rounds_constant_is_5(self):
|
||||
assert ba.MAX_ROUNDS == 5
|
||||
|
||||
def test_tool_runner_respects_max_rounds_5(self):
|
||||
from rag.qa_v1_1_73.tool_runner import ToolRunner
|
||||
|
||||
class LLM:
|
||||
def __call__(self, system, messages, tools):
|
||||
return "", [{"id": "c", "name": "echo", "arguments": "{}"}], None
|
||||
|
||||
class Dispatcher:
|
||||
def execute(self, manifest, arguments):
|
||||
return "ok"
|
||||
|
||||
manifest = CapabilityManifest(name="echo", provider="python")
|
||||
tools = [{"type": "function", "function": {"name": "echo", "description": "e",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
runner = ToolRunner(LLM(), dispatcher=Dispatcher(), max_rounds=5)
|
||||
content, audit = runner.run("sys", "user", tools, {"echo": manifest})
|
||||
assert audit["rounds"] == 5
|
||||
assert audit["tool_calls"] == 5
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# make_llm_complete 包装
|
||||
# ===========================================================================
|
||||
|
||||
class TestMakeLLMComplete:
|
||||
def test_normal(self):
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
class Client:
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "hi", "tool_calls": [{"name": "x"}], "finish_reason": "stop"}
|
||||
|
||||
complete = make_llm_complete(Client())
|
||||
content, tool_calls, error = complete("sys", [{"role": "user", "content": "q"}], [])
|
||||
assert content == "hi"
|
||||
assert tool_calls == [{"name": "x"}]
|
||||
assert error is None
|
||||
|
||||
def test_exception_becomes_error(self):
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
class Client:
|
||||
def complete_raw_messages(self, *a, **kw):
|
||||
raise RuntimeError("llm down")
|
||||
|
||||
complete = make_llm_complete(Client())
|
||||
content, tool_calls, error = complete("sys", [], [])
|
||||
assert content == ""
|
||||
assert tool_calls == []
|
||||
assert "RuntimeError" in error
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 真实 configs/capabilities.yaml 两条目可解析
|
||||
# ===========================================================================
|
||||
|
||||
class TestRealCapabilitiesYaml:
|
||||
def _real_yaml(self) -> Path:
|
||||
repo_root = Path(__file__).resolve().parents[5]
|
||||
return repo_root / "agent" / "qa" / "configs" / "capabilities.yaml"
|
||||
|
||||
def test_both_bioflow_entries_parse(self):
|
||||
manifests, warnings = parse_capabilities_file(self._real_yaml())
|
||||
by_name = {m.name: m for m in manifests}
|
||||
assert "bioflow-gene-family" in by_name
|
||||
assert "bioflow-rnaseq" in by_name
|
||||
|
||||
gf = by_name["bioflow-gene-family"]
|
||||
assert gf.provider == "mcp"
|
||||
assert gf.permission == "read_only"
|
||||
assert gf.nl_triggers
|
||||
assert gf.config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
assert gf.config["prompt_pack"]["file"] == "bioflow_gene_family_agent_rules.md"
|
||||
|
||||
rn = by_name["bioflow-rnaseq"]
|
||||
assert rn.permission == "read_only"
|
||||
assert rn.nl_triggers
|
||||
assert rn.config["mcp"]["url"].endswith("/mcp/rnaseq/")
|
||||
assert rn.config["prompt_pack"]["file"] == "bioflow_rnaseq_agent_rules.md"
|
||||
|
||||
def test_nl_triggers_cover_expected_words(self):
|
||||
manifests, _ = parse_capabilities_file(self._real_yaml())
|
||||
gf = next(m for m in manifests if m.name == "bioflow-gene-family")
|
||||
rn = next(m for m in manifests if m.name == "bioflow-rnaseq")
|
||||
# 关键触发词在位(意图预筛依赖)
|
||||
assert "基因家族" in gf.nl_triggers
|
||||
assert "转录组" in rn.nl_triggers
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 红线静态守卫:bioflow_agent 不依赖 pipeline / answer_composer
|
||||
# ===========================================================================
|
||||
|
||||
class TestRedLineNoPipelineDep:
|
||||
def test_no_pipeline_or_answer_composer_import(self):
|
||||
import ast
|
||||
|
||||
src = Path(ba.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(src)
|
||||
imported = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Import):
|
||||
imported += [a.name for a in node.names]
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
imported.append(node.module or "")
|
||||
assert not any("pipeline" in m or "answer_composer" in m for m in imported)
|
||||
|
||||
def test_never_raises(self, tmp_path):
|
||||
# 任何异常都返回 None/正常 payload,绝不外抛
|
||||
with mock.patch.object(ba, "get_registry", side_effect=RuntimeError("boom")):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent("做基因家族", llm_client=FakeLLMClient())
|
||||
assert result is None
|
||||
@@ -51,9 +51,13 @@ class ToolRunner:
|
||||
user: str,
|
||||
tools: List[Dict[str, Any]],
|
||||
manifests: Dict[str, CapabilityManifest],
|
||||
history: Optional[List[Dict[str, str]]] = None,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""执行工具调用循环。返回 (content, audit)。
|
||||
|
||||
history:多轮记忆(S2),List[{role: user|assistant, content}],旧→新,
|
||||
不含当前这轮提问;注入为 messages 前置轮次(作为上下文,不参与工具结果回填)。
|
||||
|
||||
audit: {"rounds": n, "tool_calls": k, "used_json_fallback": bool,
|
||||
"tool_names": [...], "errors": [...]}
|
||||
"""
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,438 @@
|
||||
"""app/services/bioflow/file_transform.py —— 非标准文件预处理(探测 + 转换 + 校验)。
|
||||
|
||||
FR-BIO-014 文件医生闭环:
|
||||
- inspect_file:内容嗅探(扩展名仅参考),返回结构化摘要(预览行/列结构/大小/
|
||||
格式猜测/乱码检测/与目标阶段期望格式的差异)。
|
||||
- transform_file:LLM 抽取列映射/清洗规则 + 规则脚本执行,把 xlsx/csv/tsv/txt 转换
|
||||
为 fasta/gff3/tsv/samples.yaml;转换结果过格式验证器硬校验,失败返回原因供追问
|
||||
循环(≤3 次由上层 QA 控制)。
|
||||
|
||||
服务层纪律:纯 dataclass/dict/stdlib + yaml/openpyxl,无 pydantic。
|
||||
LLM 可注入(默认走 .llm.complete_json),测试注入 fake。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PREVIEW_LINES = 10
|
||||
PREVIEW_BYTES = 4000
|
||||
|
||||
# 支持的目标格式
|
||||
TARGET_FORMATS = ("fasta", "gff3", "tsv", "samples.yaml")
|
||||
|
||||
# 乱码检测:非 ASCII 可打印且非 CJK 的异常字节比例阈值
|
||||
GARBLED_RATIO = 0.30
|
||||
|
||||
|
||||
class TransformError(Exception):
|
||||
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 探测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _guess_format(name: str, head: bytes) -> str:
|
||||
lower = name.lower()
|
||||
if lower.endswith((".fa", ".fasta", ".faa", ".fna", ".pep", ".cds")):
|
||||
return "fasta"
|
||||
if lower.endswith((".gff", ".gff3")):
|
||||
return "gff3"
|
||||
if lower.endswith((".gtf",)):
|
||||
return "gtf"
|
||||
if lower.endswith((".vcf", ".vcf.gz")):
|
||||
return "vcf"
|
||||
if lower.endswith((".tsv",)):
|
||||
return "tsv"
|
||||
if lower.endswith((".csv",)):
|
||||
return "csv"
|
||||
if lower.endswith((".xlsx", ".xls")):
|
||||
return "xlsx"
|
||||
if lower.endswith((".yaml", ".yml")):
|
||||
return "yaml"
|
||||
# 内容嗅探兜底
|
||||
text = head.decode("utf-8", errors="replace")
|
||||
if text.lstrip().startswith(">"):
|
||||
return "fasta"
|
||||
if "\t" in text and len(text.splitlines()[0].split("\t")) >= 3:
|
||||
return "tsv"
|
||||
return "txt"
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
raw = path.read_bytes()
|
||||
for enc in ("utf-8", "gb18030", "latin-1"):
|
||||
try:
|
||||
return raw.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _garbled_ratio(text: str) -> float:
|
||||
"""异常字符比例:控制字符 + 替换符 + 私有区(不含常规空白)。"""
|
||||
if not text:
|
||||
return 0.0
|
||||
bad = 0
|
||||
for ch in text:
|
||||
o = ord(ch)
|
||||
if o == 0xFFFD or (o < 32 and ch not in "\t\n\r") or (0xE000 <= o <= 0xF8FF):
|
||||
bad += 1
|
||||
return bad / len(text)
|
||||
|
||||
|
||||
def _preview(text: str) -> list[str]:
|
||||
return text.splitlines()[:PREVIEW_LINES]
|
||||
|
||||
|
||||
def _columns(path: Path, fmt: str) -> list[str]:
|
||||
"""表格列名提取(tsv/csv/xlsx)。"""
|
||||
try:
|
||||
if fmt == "xlsx":
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
ws = wb.active
|
||||
row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
||||
return [str(c or "").strip() for c in (row or [])]
|
||||
finally:
|
||||
wb.close()
|
||||
if fmt in ("tsv", "csv"):
|
||||
text = _read_text(path)
|
||||
reader = csv.reader(io.StringIO(text), delimiter="\t" if fmt == "tsv" else ",")
|
||||
first = next(reader, None)
|
||||
return [str(c or "").strip() for c in (first or [])]
|
||||
except Exception as exc: # noqa: BLE001 - 列提取失败降级空
|
||||
logger.warning("file_transform: 列提取失败 %s: %s", path, exc)
|
||||
return []
|
||||
|
||||
|
||||
def inspect_file(path: str | Path, *, preview_lines: int = PREVIEW_LINES) -> dict[str, Any]:
|
||||
"""内容嗅探摘要(扩展名仅参考;失败返回 ok=False + 原因,不抛)。"""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": f"文件不存在: {path}"}
|
||||
try:
|
||||
size = p.stat().st_size
|
||||
head = p.read_bytes()[:PREVIEW_BYTES]
|
||||
fmt = _guess_format(p.name, head)
|
||||
text = _read_text(p) if size <= 4 * 1024 * 1024 else _read_text_preview(p)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(p),
|
||||
"name": p.name,
|
||||
"ext": p.suffix.lower() or "",
|
||||
"size": size,
|
||||
"format": fmt,
|
||||
"preview": _preview(text)[:preview_lines],
|
||||
"columns": _columns(p, fmt),
|
||||
"garbled": _garbled_ratio(text),
|
||||
"line_count": text.count("\n") + 1 if text else 0,
|
||||
}
|
||||
except OSError as exc:
|
||||
return {"ok": False, "code": "READ_ERROR", "message": f"文件读取失败: {exc}"}
|
||||
|
||||
|
||||
def _read_text_preview(path: Path) -> str:
|
||||
"""大文件只读头部做预览/乱码检测,避免整读内存。"""
|
||||
with path.open("rb") as fh:
|
||||
head = fh.read(PREVIEW_BYTES)
|
||||
for enc in ("utf-8", "gb18030", "latin-1"):
|
||||
try:
|
||||
return head.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return head.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 校验器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def validate_fasta(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
text = _read_text(p)
|
||||
records = re.findall(r"^>.*$", text, flags=re.M)
|
||||
if not records:
|
||||
return {"ok": False, "reason": "无 FASTA 记录(缺 '>' 头行)"}
|
||||
# 序列非空:头行之间至少存在非空白序列行
|
||||
body = re.sub(r"^>.*$", "", text, flags=re.M)
|
||||
if not body.strip():
|
||||
return {"ok": False, "reason": "FASTA 记录缺少序列"}
|
||||
return {"ok": True, "records": len(records)}
|
||||
|
||||
|
||||
def validate_gff3(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
bad = 0
|
||||
total = 0
|
||||
for line in _read_text(p).splitlines():
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
total += 1
|
||||
cols = line.split("\t")
|
||||
if len(cols) != 9:
|
||||
bad += 1
|
||||
continue
|
||||
try:
|
||||
int(cols[3])
|
||||
int(cols[4])
|
||||
except ValueError:
|
||||
bad += 1
|
||||
continue
|
||||
if cols[6] not in ("+", "-", "."):
|
||||
bad += 1
|
||||
if total == 0:
|
||||
return {"ok": False, "reason": "无 GFF3 数据行"}
|
||||
if bad:
|
||||
return {"ok": False, "reason": f"{bad}/{total} 行列数或字段非法"}
|
||||
return {"ok": True, "records": total}
|
||||
|
||||
|
||||
def validate_tsv(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
widths: set[int] = set()
|
||||
for line in _read_text(p).splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
widths.add(len(line.split("\t")))
|
||||
if len(widths) > 1:
|
||||
return {"ok": False, "reason": f"列数不一致: {sorted(widths)}"}
|
||||
if not widths:
|
||||
return {"ok": False, "reason": "空文件"}
|
||||
return {"ok": True, "columns": widths.pop()}
|
||||
|
||||
|
||||
def validate_samples_yaml(path: str | Path) -> dict[str, Any]:
|
||||
import yaml
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
try:
|
||||
raw = yaml.safe_load(_read_text(p))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "reason": f"YAML 解析失败: {exc}"}
|
||||
samples = (raw or {}).get("samples") if isinstance(raw, dict) else None
|
||||
if not isinstance(samples, dict) or not samples:
|
||||
return {"ok": False, "reason": "缺 samples 映射或为空"}
|
||||
for name, entry in samples.items():
|
||||
if not isinstance(entry, dict) or not (entry.get("r1") or entry.get("r2")):
|
||||
return {"ok": False, "reason": f"样本 {name} 缺 r1/r2"}
|
||||
return {"ok": True, "samples": len(samples)}
|
||||
|
||||
|
||||
_VALIDATORS = {
|
||||
"fasta": validate_fasta,
|
||||
"gff3": validate_gff3,
|
||||
"tsv": validate_tsv,
|
||||
"samples.yaml": validate_samples_yaml,
|
||||
}
|
||||
|
||||
|
||||
def validate_format(path: str | Path, target_format: str) -> dict[str, Any]:
|
||||
fn = _VALIDATORS.get(target_format)
|
||||
if fn is None:
|
||||
return {"ok": False, "reason": f"不支持的目标格式: {target_format}"}
|
||||
return fn(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 转换
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LLMFn = Callable[[str, str], Awaitable[Any]]
|
||||
|
||||
|
||||
def _read_table(path: Path) -> tuple[list[str], list[dict[str, str]]]:
|
||||
"""读表格(tsv/csv/xlsx)→ (表头, 行 dict 列表)。"""
|
||||
fmt = _guess_format(path.name, path.read_bytes()[:64])
|
||||
if fmt == "xlsx":
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
ws = wb.active
|
||||
rows = [list(r) for r in ws.iter_rows(values_only=True)]
|
||||
finally:
|
||||
wb.close()
|
||||
if not rows:
|
||||
return [], []
|
||||
headers = [str(c or "").strip() for c in rows[0]]
|
||||
return headers, [dict(zip(headers, ["" if v is None else str(v).strip() for v in r])) for r in rows[1:]]
|
||||
text = _read_text(path)
|
||||
delimiter = "\t" if fmt == "tsv" else ","
|
||||
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
return [], []
|
||||
headers = [str(c or "").strip() for c in rows[0]]
|
||||
return headers, [dict(zip(headers, ["" if v is None else str(v).strip() for v in r])) for r in rows[1:]]
|
||||
|
||||
|
||||
def _default_column_map(headers: list[str], target: str) -> dict[str, str]:
|
||||
"""按表头名启发式推断列映射(名称/序列/基因名等)。"""
|
||||
m: dict[str, str] = {}
|
||||
for h in headers:
|
||||
lh = h.lower()
|
||||
if target == "fasta":
|
||||
if not m.get("seq") and any(k in lh for k in ("seq", "sequence", "序列", "蛋白", "pep", "cds")):
|
||||
m["seq"] = h
|
||||
elif not m.get("name") and any(k in lh for k in ("name", "id", "gene", "名称", "基因", "基因名", "accession")):
|
||||
m["name"] = h
|
||||
elif target == "samples.yaml":
|
||||
if "r1" in lh or "read1" in lh or "forward" in lh:
|
||||
m.setdefault("r1", h)
|
||||
elif "r2" in lh or "read2" in lh or "reverse" in lh:
|
||||
m.setdefault("r2", h)
|
||||
elif "run" in lh or "sample" in lh or "样本" in lh or "name" in lh:
|
||||
m.setdefault("name", h)
|
||||
return m
|
||||
|
||||
|
||||
async def _llm_column_map(headers: list[str], target: str, llm: LLMFn, sample_rows: list[dict[str, str]]) -> dict[str, str]:
|
||||
"""LLM 抽取列映射(启发式不充分时)。返回 {目标字段: 列名}。"""
|
||||
import json as _json
|
||||
prompt = (
|
||||
f"你是生信数据整理助手。下表头 {_json.dumps(headers, ensure_ascii=False)} "
|
||||
f"需转换为 {target} 格式。样本行:{_json.dumps(sample_rows[:3], ensure_ascii=False)[:2000]}。"
|
||||
"请输出列映射 JSON,键为目标字段名,值为表头列名。"
|
||||
)
|
||||
try:
|
||||
result = await llm(prompt, "只输出 JSON 对象,如 {\"name\":\"基因名\",\"seq\":\"序列\"}。")
|
||||
if isinstance(result, dict):
|
||||
return {str(k): str(v) for k, v in result.items() if str(v) in headers}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("file_transform: LLM 列映射失败 %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_fasta(rows: list[dict[str, str]], name_col: str, seq_col: str, dest: Path) -> int:
|
||||
n = 0
|
||||
with dest.open("w", encoding="utf-8") as fh:
|
||||
for r in rows:
|
||||
name = r.get(name_col) or f"seq{n + 1}"
|
||||
seq = (r.get(seq_col) or "").replace("\n", "").replace("\r", "")
|
||||
if not seq:
|
||||
continue
|
||||
fh.write(f">{name}\n{seq}\n")
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _write_tsv(rows: list[dict[str, str]], cols: list[str], dest: Path) -> int:
|
||||
n = 0
|
||||
with dest.open("w", encoding="utf-8", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t")
|
||||
w.writerow(cols)
|
||||
for r in rows:
|
||||
w.writerow([r.get(c, "") for c in cols])
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _write_samples_yaml(rows: list[dict[str, str]], name_col: str, r1_col: str, r2_col: str, dest: Path) -> int:
|
||||
import yaml
|
||||
samples: dict[str, dict[str, str]] = {}
|
||||
for r in rows:
|
||||
name = r.get(name_col) or ""
|
||||
r1 = r.get(r1_col) or ""
|
||||
r2 = r.get(r2_col) or ""
|
||||
if not name and not r1 and not r2:
|
||||
continue
|
||||
entry: dict[str, str] = {}
|
||||
if r1:
|
||||
entry["r1"] = r1
|
||||
if r2:
|
||||
entry["r2"] = r2
|
||||
# 额外列作为 meta(run/condition/group 等)
|
||||
for k, v in r.items():
|
||||
if v and k not in (name_col, r1_col, r2_col):
|
||||
entry[k] = v
|
||||
samples[name or Path(r1).stem] = entry
|
||||
dest.write_text(yaml.safe_dump({"samples": samples}, allow_unicode=True), encoding="utf-8")
|
||||
return len(samples)
|
||||
|
||||
|
||||
async def transform_file(
|
||||
src_path: str | Path,
|
||||
target_format: str,
|
||||
*,
|
||||
column_map: dict[str, str] | None = None,
|
||||
dest_path: str | Path | None = None,
|
||||
llm: LLMFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""非标准文件转换(LLM 列映射兜底 + 规则执行 + 格式校验)。
|
||||
|
||||
column_map:用户/QA 确认的 {目标字段: 源列名}(可部分),缺列经启发式/LLM 补齐。
|
||||
返回 ok=True + path/size/validated/records,或 ok=False + code/message(供追问)。
|
||||
"""
|
||||
src = Path(src_path)
|
||||
if not src.is_file():
|
||||
raise TransformError("NOT_FOUND", f"源文件不存在: {src_path}")
|
||||
if target_format not in TARGET_FORMATS:
|
||||
raise TransformError("BAD_FORMAT", f"不支持的目标格式: {target_format}")
|
||||
|
||||
dest = Path(dest_path) if dest_path else src.with_suffix("").with_name(
|
||||
f"{src.stem}.{target_format.replace('.', '_')}"
|
||||
)
|
||||
|
||||
headers, rows = _read_table(src)
|
||||
if not headers:
|
||||
raise TransformError("EMPTY_TABLE", "表格为空或无表头,无法转换")
|
||||
cm = dict(column_map or {})
|
||||
cm = {**cm, **_default_column_map(headers, target_format)}
|
||||
|
||||
if target_format == "fasta":
|
||||
if "seq" not in cm or "name" not in cm:
|
||||
if llm is not None:
|
||||
inferred = await _llm_column_map(headers, target_format, llm, rows)
|
||||
cm = {**cm, **inferred}
|
||||
if "seq" not in cm or "name" not in cm:
|
||||
raise TransformError("MISSING_COLUMNS", "无法确定序列列/名称列,请确认列映射")
|
||||
n = _write_fasta(rows, cm["name"], cm["seq"], dest)
|
||||
elif target_format == "tsv":
|
||||
cols = [cm.get("name")] if cm.get("name") else []
|
||||
cols = [c for c in (cols + [h for h in headers if h not in cols]) if c]
|
||||
n = _write_tsv(rows, cols, dest)
|
||||
elif target_format == "samples.yaml":
|
||||
if "r1" not in cm and "r2" not in cm:
|
||||
if llm is not None:
|
||||
inferred = await _llm_column_map(headers, target_format, llm, rows)
|
||||
cm = {**cm, **inferred}
|
||||
if "r1" not in cm and "r2" not in cm:
|
||||
raise TransformError("MISSING_COLUMNS", "无法确定 fastq 路径列(r1/r2),请确认列映射")
|
||||
n = _write_samples_yaml(rows, cm.get("name", ""), cm.get("r1", ""), cm.get("r2", ""), dest)
|
||||
else: # gff3:表格式 GFF 需严格 9 列映射,LLM 定规则后由上层脚本执行
|
||||
raise TransformError("UNSUPPORTED", f"{target_format} 需 9 列映射,请经 LLM 定规则后转换")
|
||||
|
||||
validated = validate_format(dest, target_format)
|
||||
if not validated.get("ok"):
|
||||
# 校验失败:删除产物,返回原因供追问
|
||||
dest.unlink(missing_ok=True)
|
||||
raise TransformError("VALIDATION_FAILED", f"转换结果校验失败: {validated.get('reason')}", validated)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(dest),
|
||||
"name": dest.name,
|
||||
"size": dest.stat().st_size,
|
||||
"format": target_format,
|
||||
"records": n,
|
||||
"validated": validated,
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"""app/services/bioflow/source_fetcher.py —— 源文件拉取(web/OSS/局域网)+ provenance。
|
||||
|
||||
三类来源(FR-BIO-013 网络源扩展):
|
||||
- web:http/https。httpx 流式下载,不设大小上限,超时 300s,重定向跟随。
|
||||
- OSS:`oss://bucket/key` 或预签名 URL。无 OSS SDK 依赖:`oss://` 经可配 endpoint
|
||||
(BIOFLOW_OSS_ENDPOINT)转 http 下载;预签名 URL 直接走 http 通道。
|
||||
- 局域网:本机已挂载/可达路径直读(不复制,登记原路径);或 `[user@]host:/abs/path`
|
||||
经 scp 拉取(密钥认证优先;密码需本机有 sshpass)。凭据仅单次内存使用,不落盘。
|
||||
|
||||
- 下载前检查磁盘剩余空间(Content-Length 已知时硬校验;否则保底阈值 512MB)。
|
||||
- 每次拉取记录 provenance(来源/哈希/时间/kind)到返回值并落 sidecar .provenance.json,
|
||||
不记明文密码。
|
||||
服务层纪律:纯 dataclass/dict/stdlib + httpx,无 pydantic。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_SCHEMES = ("http", "https")
|
||||
DEFAULT_TIMEOUT = 300.0
|
||||
MIN_FREE_FALLBACK = 512 * 1024 * 1024 # 未知大小时保底磁盘余量检查(512MB)
|
||||
CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
class FetchError(Exception):
|
||||
"""源文件拉取错误(带错误码;MCP 工具层转自然语言提示)。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for block in iter(lambda: fh.read(CHUNK), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""URL 解码后取文件名(去路径分隔符),空则占位。"""
|
||||
name = urllib.parse.unquote(name or "")
|
||||
base = Path(name).name or "download"
|
||||
return base
|
||||
|
||||
|
||||
def _name_from_url(url: str) -> str:
|
||||
return _safe_name(urllib.parse.urlparse(url).path.rsplit("/", 1)[-1])
|
||||
|
||||
|
||||
def check_disk_space(dest_dir: str | Path, needed: int) -> tuple[bool, int]:
|
||||
"""检查目标目录所在盘剩余空间是否 ≥ needed 字节。返回 (够, 剩余字节)。"""
|
||||
dest_dir = Path(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
free = shutil.disk_usage(dest_dir).free
|
||||
return (free >= needed, free)
|
||||
|
||||
|
||||
def _write_provenance(dest: Path, source: str, kind: str, sha256: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""落 provenance sidecar({dest}.provenance.json),返回 provenance dict。"""
|
||||
prov: dict[str, Any] = {
|
||||
"source": source,
|
||||
"kind": kind,
|
||||
"sha256": sha256,
|
||||
"fetched_at": _now_iso(),
|
||||
}
|
||||
if extra:
|
||||
prov.update(extra)
|
||||
sidecar = dest.parent / f"{dest.name}.provenance.json"
|
||||
try:
|
||||
sidecar.write_text(json.dumps(prov, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except OSError: # noqa: BLE001 - provenance 落盘失败不阻断拉取
|
||||
logger.warning("source_fetcher: provenance 落盘失败 %s", sidecar)
|
||||
return prov
|
||||
|
||||
|
||||
# ---- web ----
|
||||
|
||||
async def fetch_web(url: str, dest_dir: str | Path, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||
"""http/https 流式下载(不设大小上限;Content-Length 已知时下载前查磁盘余量)。"""
|
||||
scheme = urllib.parse.urlparse(url).scheme.lower()
|
||||
if scheme not in ALLOWED_SCHEMES:
|
||||
raise FetchError("BAD_SCHEME", f"仅支持 http/https 协议: {url}")
|
||||
dest_dir = Path(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = _name_from_url(url)
|
||||
dest = dest_dir / name
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
try:
|
||||
async with client.stream("GET", url, timeout=httpx.Timeout(connect=30.0, read=timeout, write=60.0, pool=10.0)) as resp:
|
||||
if resp.status_code >= 400:
|
||||
raise FetchError("HTTP_ERROR", f"下载失败 HTTP {resp.status_code}: {url}")
|
||||
total = int(resp.headers.get("content-length") or 0)
|
||||
if total:
|
||||
ok, free = check_disk_space(dest_dir, total)
|
||||
if not ok:
|
||||
raise FetchError("DISK_FULL", f"磁盘空间不足:需 {total} 字节,剩余 {free} 字节")
|
||||
written = 0
|
||||
with dest.open("wb") as fh:
|
||||
async for chunk in resp.aiter_bytes(CHUNK):
|
||||
fh.write(chunk)
|
||||
written += len(chunk)
|
||||
if total and written != total:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise FetchError("TRUNCATED", f"下载不完整:期望 {total} 字节,实收 {written} 字节")
|
||||
except FetchError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise FetchError("NETWORK_ERROR", f"网络错误: {exc}") from exc
|
||||
|
||||
size = dest.stat().st_size
|
||||
sha = _sha256(dest)
|
||||
prov = _write_provenance(dest, url, "web", sha)
|
||||
return {"ok": True, "path": str(dest), "name": name, "size": size, "kind": "web", "provenance": prov}
|
||||
|
||||
|
||||
# ---- OSS ----
|
||||
|
||||
def _resolve_oss_url(spec: str) -> str:
|
||||
"""oss://bucket/key → http 下载 URL(public-read 或预签名;无 SDK,需 endpoint 配置)。
|
||||
|
||||
环境键:BIOFLOW_OSS_ENDPOINT(如 oss-cn-hangzhou.aliyuncs.com,或内网 endpoint)。
|
||||
预签名 URL(含 aliyuncs.com 的 http/https)原样返回,走 web 通道。
|
||||
"""
|
||||
if not spec.startswith("oss://"):
|
||||
return spec # 已是 http/https 预签名 URL
|
||||
rest = spec[len("oss://"):]
|
||||
bucket, _, key = rest.partition("/")
|
||||
endpoint = (os.environ.get("BIOFLOW_OSS_ENDPOINT") or "").strip().rstrip("/")
|
||||
if not bucket or not key:
|
||||
raise FetchError("BAD_OSS", f"OSS 地址格式非法(应为 oss://bucket/key): {spec}")
|
||||
if not endpoint:
|
||||
raise FetchError("OSS_UNCONFIGURED", "未配置 BIOFLOW_OSS_ENDPOINT,无法解析 oss:// 地址;请提供预签名 URL")
|
||||
# 内网/公网 endpoint 统一拼 http(https 由 endpoint 决定;公网 OSS 建议 https)
|
||||
scheme = "https" if "aliyuncs.com" in endpoint else "http"
|
||||
return f"{scheme}://{bucket}.{endpoint}/{key}"
|
||||
|
||||
|
||||
async def fetch_oss(spec: str, dest_dir: str | Path, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||
url = _resolve_oss_url(spec)
|
||||
result = await fetch_web(url, dest_dir, timeout=timeout)
|
||||
result["kind"] = "oss"
|
||||
# 重写 provenance kind(sidecar 已由 fetch_web 写为 web;此处修正)
|
||||
if result.get("provenance"):
|
||||
result["provenance"]["kind"] = "oss"
|
||||
result["provenance"]["oss_spec"] = spec
|
||||
try:
|
||||
sidecar = Path(result["path"]).parent / f"{Path(result['path']).name}.provenance.json"
|
||||
sidecar.write_text(json.dumps(result["provenance"], ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# ---- 局域网 ----
|
||||
|
||||
def _parse_lan_remote(spec: str) -> tuple[str, str] | None:
|
||||
"""解析 `[user@]host:/abs/path` 远端形态;非远端返回 None。"""
|
||||
if ":" not in spec:
|
||||
return None
|
||||
# 排除协议(http:// / oss:// 含 ://,已在上层分流)
|
||||
if "://" in spec:
|
||||
return None
|
||||
host_part, _, path_part = spec.partition(":")
|
||||
if not path_part.startswith("/"):
|
||||
return None
|
||||
return host_part, path_part
|
||||
|
||||
|
||||
async def _scp_fetch(host_part: str, remote_path: str, dest_dir: Path, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""scp 拉取远端文件(密钥认证;密码需本机 sshpass)。"""
|
||||
creds = credentials or {}
|
||||
user = str(creds.get("user") or "").strip()
|
||||
password = str(creds.get("password") or "").strip()
|
||||
port = str(creds.get("port") or "22").strip()
|
||||
target = f"{user}@{host_part}" if user else host_part
|
||||
name = _safe_name(remote_path.rsplit("/", 1)[-1])
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd: list[str] = []
|
||||
if password:
|
||||
if shutil.which("sshpass") is None:
|
||||
raise FetchError("SSHPASS_MISSING", "本机未安装 sshpass,无法使用密码拉取;请改用密钥认证或提供已挂载路径")
|
||||
cmd += ["sshpass", "-p", password]
|
||||
cmd += ["scp", "-P", port, "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=30",
|
||||
f"{target}:{remote_path}", str(dest_dir / name)]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
_out, err = await asyncio.wait_for(proc.communicate(), timeout=DEFAULT_TIMEOUT)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise FetchError("LAN_TIMEOUT", f"局域网拉取超时: {target}:{remote_path}") from exc
|
||||
except OSError as exc:
|
||||
raise FetchError("LAN_ERROR", f"局域网拉取失败: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise FetchError("LAN_ERROR", f"scp 失败: {(err or b'').decode(errors='replace')[:500]}")
|
||||
dest = dest_dir / name
|
||||
if not dest.is_file():
|
||||
raise FetchError("LAN_ERROR", f"scp 未产出文件: {dest}")
|
||||
size = dest.stat().st_size
|
||||
sha = _sha256(dest)
|
||||
prov = _write_provenance(dest, f"{target}:{remote_path}", "lan", sha)
|
||||
return {"ok": True, "path": str(dest), "name": name, "size": size, "kind": "lan", "provenance": prov}
|
||||
|
||||
|
||||
async def fetch_lan(spec: str, dest_dir: str | Path, *, credentials: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""局域网路径:本机已挂载/可达直读(返回原路径,不复制);远端走 scp。"""
|
||||
dest_dir = Path(dest_dir)
|
||||
remote = _parse_lan_remote(spec)
|
||||
if remote is not None:
|
||||
return await _scp_fetch(remote[0], remote[1], dest_dir, credentials)
|
||||
# 本机路径:直读(存在即成功;不复制,登记语义由 register_job_files 处理)
|
||||
p = Path(spec).expanduser()
|
||||
if not p.exists():
|
||||
raise FetchError("PATH_NOT_EXIST", f"局域网路径不存在或不可达: {spec}")
|
||||
if p.is_dir():
|
||||
raise FetchError("IS_DIR", f"路径是目录(仅支持单文件拉取): {spec}")
|
||||
sha = _sha256(p)
|
||||
prov = _write_provenance(p, str(p), "lan", sha)
|
||||
return {"ok": True, "path": str(p), "name": p.name, "size": p.stat().st_size, "kind": "lan", "provenance": prov}
|
||||
|
||||
|
||||
# ---- 总入口 ----
|
||||
|
||||
async def fetch_source(
|
||||
source: str,
|
||||
dest_dir: str | Path,
|
||||
*,
|
||||
credentials: dict[str, Any] | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict[str, Any]:
|
||||
"""按来源形态分流拉取(web/OSS/局域网/本机路径)。
|
||||
|
||||
credentials(可选,仅局域网 scp 用):{"user": ..., "password": ..., "port": ...}。
|
||||
凭据不落盘、不进入 provenance。
|
||||
"""
|
||||
source = (source or "").strip()
|
||||
if not source:
|
||||
raise FetchError("EMPTY_SOURCE", "源地址为空")
|
||||
lower = source.lower()
|
||||
# 协议白名单:含 "://" 的 URL 只允许 http/https/oss(其余如 ftp/file/sftp 拒绝)
|
||||
if "://" in source and not lower.startswith(("http://", "https://", "oss://")):
|
||||
raise FetchError("BAD_SCHEME", f"不支持的协议(仅 http/https/oss): {source}")
|
||||
if lower.startswith(("http://", "https://")):
|
||||
if lower.startswith("oss://") or "aliyuncs.com" in lower:
|
||||
return await fetch_oss(source, dest_dir, timeout=timeout)
|
||||
return await fetch_web(source, dest_dir, timeout=timeout)
|
||||
if lower.startswith("oss://"):
|
||||
return await fetch_oss(source, dest_dir, timeout=timeout)
|
||||
return await fetch_lan(source, dest_dir, credentials=credentials)
|
||||
@@ -0,0 +1,403 @@
|
||||
"""tests/test_file_transform.py —— 非标准文件预处理(探测 + 转换 + 校验)。
|
||||
|
||||
分层标记 -m bioflow_unit。覆盖(S0 验收):
|
||||
- inspect_file:fasta/tsv 探测、乱码检测、列结构提取。
|
||||
- transform_file:xlsx→fasta(显式列映射)、csv→fasta(启发式列映射)、
|
||||
samples.yaml 转换 + 校验。
|
||||
- 校验失败 → TransformError VALIDATION_FAILED(追问循环依据)。
|
||||
- 缺列映射且无 LLM → MISSING_COLUMNS。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.file_transform import (
|
||||
TransformError,
|
||||
_garbled_ratio,
|
||||
inspect_file,
|
||||
transform_file,
|
||||
validate_fasta,
|
||||
validate_format,
|
||||
validate_gff3,
|
||||
validate_samples_yaml,
|
||||
validate_tsv,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inspect_file 探测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_inspect_file_fasta(tmp_path):
|
||||
f = tmp_path / "proteins.fa"
|
||||
f.write_text(">SPL1\nMKTA\n>SPL2\nMKTB\n")
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "fasta"
|
||||
assert r["size"] > 0
|
||||
|
||||
|
||||
def test_inspect_file_tsv_columns(tmp_path):
|
||||
f = tmp_path / "samples.tsv"
|
||||
f.write_text("run\tfastq_1\tfastq_2\nSRR1\ta_1.fq\ta_2.fq\n")
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "tsv"
|
||||
assert r["columns"] == ["run", "fastq_1", "fastq_2"]
|
||||
|
||||
|
||||
def test_inspect_file_garbled(tmp_path):
|
||||
f = tmp_path / "messy.txt"
|
||||
f.write_bytes(b"\x00\x01\x02\x03\xff\xfe garbage \x00\x00")
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["garbled"] > 0.3
|
||||
|
||||
|
||||
def test_garbled_ratio_clean():
|
||||
assert _garbled_ratio("ATCGATCG\n>seq\n") < 0.01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transform_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_transform_xlsx_to_fasta(tmp_path):
|
||||
import openpyxl
|
||||
src = tmp_path / "spl_list.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["基因名", "序列", "备注"])
|
||||
ws.append(["SPL1", "ATGGCTA", "水稻"])
|
||||
ws.append(["SPL2", "ATGGCTB", "水稻"])
|
||||
wb.save(src)
|
||||
|
||||
r = _run(transform_file(src, "fasta", column_map={"name": "基因名", "seq": "序列"}))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
assert r["validated"]["ok"] is True
|
||||
text = Path(r["path"]).read_text()
|
||||
assert ">SPL1" in text and "ATGGCTA" in text
|
||||
|
||||
|
||||
def test_transform_csv_to_fasta_heuristic(tmp_path):
|
||||
src = tmp_path / "genes.csv"
|
||||
src.write_text("gene_id,protein_sequence\nG1,MKTA\nG2,MKTB\n")
|
||||
r = _run(transform_file(src, "fasta"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
|
||||
|
||||
def test_transform_csv_to_samples_yaml(tmp_path):
|
||||
src = tmp_path / "design.csv"
|
||||
src.write_text("sample,fastq_1,fastq_2,condition\nA,a_1.fq,a_2.fq,ctrl\nB,b_1.fq,b_2.fq,treated\n")
|
||||
r = _run(transform_file(src, "samples.yaml", column_map={"name": "sample", "r1": "fastq_1", "r2": "fastq_2"}))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
assert r["validated"]["ok"] is True
|
||||
import yaml
|
||||
samples = yaml.safe_load(Path(r["path"]).read_text())["samples"]
|
||||
assert "A" in samples and samples["A"]["r1"] == "a_1.fq"
|
||||
|
||||
|
||||
def test_transform_validation_failed(tmp_path):
|
||||
# seq 列全空 → 产物无序列 → 校验失败
|
||||
src = tmp_path / "bad.csv"
|
||||
src.write_text("name,seq\nG1,\nG2,\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta", column_map={"name": "name", "seq": "seq"}))
|
||||
assert ei.value.code == "VALIDATION_FAILED"
|
||||
|
||||
|
||||
def test_transform_missing_columns(tmp_path):
|
||||
# 无列映射、无 LLM、表头无启发式命中 → MISSING_COLUMNS
|
||||
src = tmp_path / "unknown.csv"
|
||||
src.write_text("col_a,col_b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta"))
|
||||
assert ei.value.code == "MISSING_COLUMNS"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 校验器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_validate_fasta(tmp_path):
|
||||
f = tmp_path / "ok.fa"
|
||||
f.write_text(">a\nACGT\n")
|
||||
assert validate_fasta(f)["ok"] is True
|
||||
|
||||
|
||||
def test_validate_fasta_empty(tmp_path):
|
||||
f = tmp_path / "bad.fa"
|
||||
f.write_text("no records here\n")
|
||||
assert validate_fasta(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_tsv_inconsistent(tmp_path):
|
||||
f = tmp_path / "bad.tsv"
|
||||
f.write_text("a\tb\tc\n1\t2\n")
|
||||
assert validate_tsv(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_samples_yaml(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples:\n A:\n r1: a.fq\n r2: a2.fq\n")
|
||||
assert validate_samples_yaml(f)["ok"] is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(1)inspect_file:NOT_FOUND / xlsx 列 / csv 格式 / 大文件 / 内容嗅探
|
||||
# ===========================================================================
|
||||
|
||||
def test_inspect_file_not_found():
|
||||
r = inspect_file("/no/such/file.fa")
|
||||
assert r["ok"] is False
|
||||
assert r["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_inspect_file_xlsx_columns(tmp_path):
|
||||
import openpyxl
|
||||
f = tmp_path / "spl.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["基因名", "序列"])
|
||||
ws.append(["SPL1", "MKTA"])
|
||||
wb.save(f)
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "xlsx"
|
||||
assert r["columns"] == ["基因名", "序列"]
|
||||
|
||||
|
||||
def test_inspect_file_csv_format(tmp_path):
|
||||
f = tmp_path / "genes.csv"
|
||||
f.write_text("id,seq\nG1,MKTA\n")
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "csv"
|
||||
assert r["columns"] == ["id", "seq"]
|
||||
|
||||
|
||||
def test_inspect_file_large_uses_preview(tmp_path):
|
||||
f = tmp_path / "big.fa"
|
||||
f.write_bytes(b">seq\nACGT\n" + b"A" * (4 * 1024 * 1024))
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "fasta"
|
||||
|
||||
|
||||
def test_guess_format_content_sniffing_fasta(tmp_path):
|
||||
from app.services.bioflow.file_transform import _guess_format
|
||||
assert _guess_format("unknown.dat", b">seq1\nACGT\n") == "fasta"
|
||||
|
||||
|
||||
def test_guess_format_content_sniffing_tsv(tmp_path):
|
||||
from app.services.bioflow.file_transform import _guess_format
|
||||
assert _guess_format("unknown.dat", b"a\tb\tc\n1\t2\t3\n") == "tsv"
|
||||
|
||||
|
||||
def test_garbled_ratio_empty():
|
||||
assert _garbled_ratio("") == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(2)transform_file:NOT_FOUND / BAD_FORMAT / EMPTY_TABLE / tsv / gff3 /
|
||||
# samples.yaml 启发式 / LLM 兜底 / LLM 异常兜底
|
||||
# ===========================================================================
|
||||
|
||||
def test_transform_not_found(tmp_path):
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(tmp_path / "nope.csv", "fasta"))
|
||||
assert ei.value.code == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_transform_bad_format(tmp_path):
|
||||
src = tmp_path / "x.csv"
|
||||
src.write_text("a,b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "bogus"))
|
||||
assert ei.value.code == "BAD_FORMAT"
|
||||
|
||||
|
||||
def test_transform_empty_table(tmp_path):
|
||||
src = tmp_path / "empty.csv"
|
||||
src.write_text("")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta"))
|
||||
assert ei.value.code == "EMPTY_TABLE"
|
||||
|
||||
|
||||
def test_transform_to_tsv(tmp_path):
|
||||
src = tmp_path / "genes.csv"
|
||||
src.write_text("gene_id,protein_sequence\nG1,MKTA\nG2,MKTB\n")
|
||||
r = _run(transform_file(src, "tsv"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
assert r["validated"]["ok"] is True
|
||||
|
||||
|
||||
def test_transform_to_gff3_unsupported(tmp_path):
|
||||
src = tmp_path / "x.csv"
|
||||
src.write_text("a,b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "gff3"))
|
||||
assert ei.value.code == "UNSUPPORTED"
|
||||
|
||||
|
||||
def test_transform_samples_yaml_heuristic(tmp_path):
|
||||
src = tmp_path / "design.csv"
|
||||
src.write_text("name,r1,r2,condition\nA,a_1.fq,a_2.fq,ctrl\nB,b_1.fq,b_2.fq,trt\n")
|
||||
r = _run(transform_file(src, "samples.yaml"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
|
||||
|
||||
def test_transform_samples_yaml_missing_columns(tmp_path):
|
||||
src = tmp_path / "unknown.csv"
|
||||
src.write_text("col_a,col_b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "samples.yaml"))
|
||||
assert ei.value.code == "MISSING_COLUMNS"
|
||||
|
||||
|
||||
def test_transform_fasta_llm_column_map(tmp_path):
|
||||
src = tmp_path / "candidates.csv"
|
||||
src.write_text("col_a,col_b\nSPL1,ATGGCTA\nSPL2,ATGGCTB\n")
|
||||
|
||||
async def fake_llm(user, system):
|
||||
return {"name": "col_a", "seq": "col_b"}
|
||||
|
||||
r = _run(transform_file(src, "fasta", llm=fake_llm))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
|
||||
|
||||
def test_transform_fasta_llm_exception_falls_back_missing(tmp_path):
|
||||
src = tmp_path / "candidates.csv"
|
||||
src.write_text("col_a,col_b\nSPL1,ATGGCTA\n")
|
||||
|
||||
async def fake_llm(user, system):
|
||||
raise RuntimeError("llm down")
|
||||
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta", llm=fake_llm))
|
||||
assert ei.value.code == "MISSING_COLUMNS"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(3)校验器:gff3 各失败态 / samples.yaml 缺 r1r2 / YAML 解析失败 /
|
||||
# tsv 空文件 / validate_format 未知格式
|
||||
# ===========================================================================
|
||||
|
||||
def test_validate_gff3_ok(tmp_path):
|
||||
f = tmp_path / "ok.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\t1\t100\t.\t+\t.\tID=x\n")
|
||||
assert validate_gff3(f)["ok"] is True
|
||||
|
||||
|
||||
def test_validate_gff3_no_data(tmp_path):
|
||||
f = tmp_path / "empty.gff3"
|
||||
f.write_text("# just a comment\n")
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_gff3_bad_columns(tmp_path):
|
||||
f = tmp_path / "bad.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\t1\t100\n") # 5 列 ≠ 9
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_gff3_bad_strand(tmp_path):
|
||||
f = tmp_path / "bad.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\t1\t100\t.\tx\t.\tID=x\n") # 第 7 列非法
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_gff3_bad_int(tmp_path):
|
||||
f = tmp_path / "bad.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\tabc\t100\t.\t+\t.\tID=x\n") # 起止非整数
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_fasta_no_sequence(tmp_path):
|
||||
f = tmp_path / "bad.fa"
|
||||
f.write_text(">a\n")
|
||||
assert validate_fasta(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_samples_yaml_missing_r1r2(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples:\n A:\n condition: ctrl\n")
|
||||
assert validate_samples_yaml(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_samples_yaml_parse_fail(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples: [unclosed\n")
|
||||
assert validate_samples_yaml(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_tsv_empty(tmp_path):
|
||||
f = tmp_path / "empty.tsv"
|
||||
f.write_text("")
|
||||
assert validate_tsv(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_format_unknown(tmp_path):
|
||||
f = tmp_path / "x.txt"
|
||||
f.write_text("x")
|
||||
r = validate_format(f, "bogus")
|
||||
assert r["ok"] is False
|
||||
assert "不支持" in r["reason"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(4)校验器缺失文件 / 格式猜测扩展名 / samples 非 dict / 空行跳过
|
||||
# ===========================================================================
|
||||
|
||||
def test_validators_missing_file(tmp_path):
|
||||
missing = tmp_path / "nope"
|
||||
assert validate_fasta(missing)["ok"] is False
|
||||
assert validate_gff3(missing)["ok"] is False
|
||||
assert validate_tsv(missing)["ok"] is False
|
||||
assert validate_samples_yaml(missing)["ok"] is False
|
||||
|
||||
|
||||
def test_guess_format_all_extensions():
|
||||
from app.services.bioflow.file_transform import _guess_format
|
||||
assert _guess_format("a.gff3", b"") == "gff3"
|
||||
assert _guess_format("a.gff", b"") == "gff3"
|
||||
assert _guess_format("a.gtf", b"") == "gtf"
|
||||
assert _guess_format("a.vcf", b"") == "vcf"
|
||||
assert _guess_format("a.vcf.gz", b"") == "vcf"
|
||||
assert _guess_format("a.yaml", b"") == "yaml"
|
||||
assert _guess_format("a.yml", b"") == "yaml"
|
||||
assert _guess_format("a.xls", b"") == "xlsx"
|
||||
assert _guess_format("a.faa", b"") == "fasta"
|
||||
assert _guess_format("a.fna", b"") == "fasta"
|
||||
assert _guess_format("a.pep", b"") == "fasta"
|
||||
assert _guess_format("a.cds", b"") == "fasta"
|
||||
assert _guess_format("a.dat", b"no marker") == "txt"
|
||||
|
||||
|
||||
def test_validate_samples_yaml_not_dict(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples: just_a_string\n")
|
||||
assert validate_samples_yaml(f)["ok"] is False
|
||||
|
||||
|
||||
def test_transform_samples_yaml_skips_empty_rows(tmp_path):
|
||||
src = tmp_path / "d.csv"
|
||||
src.write_text("name,r1,r2\n,,\nA,a.fq,a2.fq\n")
|
||||
r = _run(transform_file(src, "samples.yaml"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 1
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
"""tests/test_source_fetcher.py —— 源文件拉取(web/OSS/局域网)+ 磁盘余量 + provenance。
|
||||
|
||||
分层标记 -m bioflow_unit。覆盖(S0 验收):
|
||||
- web http 流式下载(本地 http.server 实测 httpx 通道)+ provenance 落盘。
|
||||
- 协议白名单(ftp:// 拒绝 BAD_SCHEME)。
|
||||
- oss:// 无 endpoint 拒绝 / 预签名 URL 直通 web / endpoint 解析。
|
||||
- 局域网本机路径直读;远端 host:/path 解析。
|
||||
- 磁盘余量不足 DISK_FULL(check_disk_space 纯函数)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import http.server
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.source_fetcher import (
|
||||
FetchError,
|
||||
_parse_lan_remote,
|
||||
_resolve_oss_url,
|
||||
check_disk_space,
|
||||
fetch_lan,
|
||||
fetch_source,
|
||||
fetch_web,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地 http 服务器(测 httpx 通道,不依赖外网)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def http_url(tmp_path):
|
||||
content = b">seq1\nACGTACGT\n>seq2\nTGCATGCA\n"
|
||||
(tmp_path / "test.fa").write_bytes(content)
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(tmp_path))
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}/test.fa"
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_fetch_web_download_and_provenance(http_url, tmp_path):
|
||||
out = tmp_path / "out"
|
||||
r = _run(fetch_web(http_url, out))
|
||||
assert r["ok"] is True
|
||||
assert r["name"] == "test.fa"
|
||||
assert r["size"] > 0
|
||||
assert r["kind"] == "web"
|
||||
assert r["provenance"]["source"] == http_url
|
||||
assert len(r["provenance"]["sha256"]) == 64
|
||||
assert (out / "test.fa").read_text().startswith(">seq1")
|
||||
# sidecar provenance
|
||||
assert (out / "test.fa.provenance.json").is_file()
|
||||
|
||||
|
||||
def test_fetch_web_http_error(http_url, tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web(http_url + "/missing", tmp_path / "out"))
|
||||
assert ei.value.code == "HTTP_ERROR"
|
||||
|
||||
|
||||
def test_fetch_source_bad_scheme(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_source("ftp://host/x.fa", tmp_path))
|
||||
assert ei.value.code == "BAD_SCHEME"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OSS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_oss_url_no_endpoint_rejected():
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_resolve_oss_url("oss://bucket/key.fa")
|
||||
assert ei.value.code == "OSS_UNCONFIGURED"
|
||||
|
||||
|
||||
def test_oss_url_endpoint_resolve(monkeypatch):
|
||||
monkeypatch.setenv("BIOFLOW_OSS_ENDPOINT", "oss-cn-hangzhou.aliyuncs.com")
|
||||
url = _resolve_oss_url("oss://seq-bucket/data/x.fa")
|
||||
assert url == "https://seq-bucket.oss-cn-hangzhou.aliyuncs.com/data/x.fa"
|
||||
|
||||
|
||||
def test_oss_presigned_passthrough():
|
||||
url = _resolve_oss_url("https://bucket.oss-cn-hangzhou.aliyuncs.com/x.fa?sig=abc")
|
||||
assert url.startswith("https://")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 局域网 / 本机路径
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_lan_remote():
|
||||
assert _parse_lan_remote("192.168.1.5:/data/x.fa") == ("192.168.1.5", "/data/x.fa")
|
||||
assert _parse_lan_remote("xzq@192.168.1.5:/data/x.fa") == ("xzq@192.168.1.5", "/data/x.fa")
|
||||
assert _parse_lan_remote("/local/path/x.fa") is None
|
||||
assert _parse_lan_remote("http://x/y") is None
|
||||
|
||||
|
||||
def test_fetch_lan_local_path(tmp_path):
|
||||
f = tmp_path / "genome.fa"
|
||||
f.write_text(">g1\nACGT\n")
|
||||
r = _run(fetch_lan(str(f), tmp_path / "out"))
|
||||
assert r["ok"] is True
|
||||
assert r["kind"] == "lan"
|
||||
assert r["path"] == str(f)
|
||||
assert r["provenance"]["sha256"]
|
||||
|
||||
|
||||
def test_fetch_lan_missing_path(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("/no/such/path.fa", tmp_path))
|
||||
assert ei.value.code == "PATH_NOT_EXIST"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 磁盘余量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_check_disk_space_insufficient(tmp_path):
|
||||
ok, free = check_disk_space(tmp_path, 10 ** 18) # 1EB,必不足
|
||||
assert ok is False
|
||||
assert free < 10 ** 18
|
||||
|
||||
|
||||
def test_check_disk_space_sufficient(tmp_path):
|
||||
ok, _free = check_disk_space(tmp_path, 1) # 1 字节
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(1)web:BAD_SCHEME / 磁盘满 DISK_FULL / 断点 TRUNCATED / NETWORK_ERROR
|
||||
# ===========================================================================
|
||||
|
||||
def test_fetch_web_bad_scheme(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web("ftp://host/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "BAD_SCHEME"
|
||||
|
||||
|
||||
def test_fetch_web_disk_full(http_url, tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
monkeypatch.setattr(sf, "check_disk_space", lambda dest_dir, needed: (False, 0))
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web(http_url, tmp_path / "out"))
|
||||
assert ei.value.code == "DISK_FULL"
|
||||
|
||||
|
||||
def test_fetch_web_truncated(tmp_path, monkeypatch):
|
||||
"""断点/不完整下载:aiter_bytes 实收字节 < Content-Length → TRUNCATED。
|
||||
|
||||
用 fake httpx 确定性触发(真实服务器下 httpx 会因短读抛 HTTPError)。
|
||||
"""
|
||||
import types as _types
|
||||
|
||||
import httpx as real_httpx
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
headers = {"content-length": "100"}
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def aiter_bytes(self, chunk_size):
|
||||
yield b"short"
|
||||
|
||||
class FakeStream:
|
||||
async def __aenter__(self):
|
||||
return FakeResp()
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, follow_redirects=True):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, timeout=None):
|
||||
return FakeStream()
|
||||
|
||||
monkeypatch.setattr(sf, "httpx", _types.SimpleNamespace(
|
||||
AsyncClient=FakeClient, Timeout=real_httpx.Timeout, HTTPError=real_httpx.HTTPError,
|
||||
))
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web("http://x/y.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "TRUNCATED"
|
||||
|
||||
|
||||
def test_fetch_web_network_error(tmp_path, monkeypatch):
|
||||
"""网络错误:httpx.HTTPError → NETWORK_ERROR(fake 确定性触发)。"""
|
||||
import types as _types
|
||||
|
||||
import httpx as real_httpx
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
class FailingStream:
|
||||
async def __aenter__(self):
|
||||
raise real_httpx.HTTPError("connection reset")
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
class FailingClient:
|
||||
def __init__(self, follow_redirects=True):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, timeout=None):
|
||||
return FailingStream()
|
||||
|
||||
monkeypatch.setattr(sf, "httpx", _types.SimpleNamespace(
|
||||
AsyncClient=FailingClient, Timeout=real_httpx.Timeout, HTTPError=real_httpx.HTTPError,
|
||||
))
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web("http://x/y.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "NETWORK_ERROR"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(2)OSS:BAD_OSS / endpoint 解析(内网 http)/ fetch_oss 走 web + kind 修正
|
||||
# ===========================================================================
|
||||
|
||||
def test_oss_url_bad_format():
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_resolve_oss_url("oss://onlybucket")
|
||||
assert ei.value.code == "BAD_OSS"
|
||||
|
||||
|
||||
def test_oss_url_intranet_endpoint_http(monkeypatch):
|
||||
monkeypatch.setenv("BIOFLOW_OSS_ENDPOINT", "oss-internal.example.com")
|
||||
url = _resolve_oss_url("oss://seq-bucket/data/x.fa")
|
||||
assert url == "http://seq-bucket.oss-internal.example.com/data/x.fa"
|
||||
|
||||
|
||||
def test_fetch_oss_marks_kind_and_provenance(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
from pathlib import Path as _Path
|
||||
|
||||
async def fake_web(url, dest_dir, timeout=None):
|
||||
dest = _Path(dest_dir) / "x.fa"
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(">x\nACGT\n")
|
||||
return {"ok": True, "path": str(dest), "name": "x.fa", "size": 4,
|
||||
"kind": "web", "provenance": {"source": url, "kind": "web",
|
||||
"sha256": "0" * 64, "fetched_at": "t"}}
|
||||
|
||||
monkeypatch.setattr(sf, "fetch_web", fake_web)
|
||||
monkeypatch.setenv("BIOFLOW_OSS_ENDPOINT", "oss-cn-hangzhou.aliyuncs.com")
|
||||
r = _run(sf.fetch_oss("oss://b/k.fa", tmp_path / "out"))
|
||||
assert r["ok"] is True
|
||||
assert r["kind"] == "oss"
|
||||
assert r["provenance"]["kind"] == "oss"
|
||||
assert r["provenance"]["oss_spec"] == "oss://b/k.fa"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(3)fetch_source:EMPTY_SOURCE / oss 路由 / aliyuncs 预签名路由
|
||||
# ===========================================================================
|
||||
|
||||
def test_fetch_source_empty(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_source("", tmp_path))
|
||||
assert ei.value.code == "EMPTY_SOURCE"
|
||||
|
||||
|
||||
def test_fetch_source_oss_routing(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
calls = []
|
||||
|
||||
async def fake_oss(spec, dest_dir, timeout=None):
|
||||
calls.append(spec)
|
||||
return {"ok": True, "kind": "oss"}
|
||||
|
||||
monkeypatch.setattr(sf, "fetch_oss", fake_oss)
|
||||
r = _run(sf.fetch_source("oss://bucket/k.fa", tmp_path))
|
||||
assert r["kind"] == "oss"
|
||||
assert calls == ["oss://bucket/k.fa"]
|
||||
|
||||
|
||||
def test_fetch_source_aliyuncs_presigned_routes_to_oss(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
calls = []
|
||||
|
||||
async def fake_oss(spec, dest_dir, timeout=None):
|
||||
calls.append(spec)
|
||||
return {"ok": True, "kind": "oss"}
|
||||
|
||||
monkeypatch.setattr(sf, "fetch_oss", fake_oss)
|
||||
presigned = "https://bucket.aliyuncs.com/x.fa?sig=abc"
|
||||
r = _run(sf.fetch_source(presigned, tmp_path))
|
||||
assert r["kind"] == "oss"
|
||||
assert calls == [presigned]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(4)局域网:目录 IS_DIR / scp 拉取 / sshpass 缺失 / scp 失败 / 解析边界
|
||||
# ===========================================================================
|
||||
|
||||
def test_fetch_lan_dir_rejected(tmp_path):
|
||||
d = tmp_path / "adir"
|
||||
d.mkdir()
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan(str(d), tmp_path / "out"))
|
||||
assert ei.value.code == "IS_DIR"
|
||||
|
||||
|
||||
def test_parse_lan_remote_relative_rejected():
|
||||
assert _parse_lan_remote("host:relative/path") is None
|
||||
|
||||
|
||||
def test_fetch_lan_scp_success(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
from pathlib import Path as _Path
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
dest = _Path(args[-1])
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(">g\nACGT\n")
|
||||
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
r = _run(fetch_lan("xzq@192.168.1.5:/data/genome.fa", tmp_path / "out",
|
||||
credentials={"user": "xzq"}))
|
||||
assert r["ok"] is True
|
||||
assert r["kind"] == "lan"
|
||||
assert r["name"] == "genome.fa"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_sshpass_missing(tmp_path, monkeypatch):
|
||||
import shutil
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out", credentials={"password": "p"}))
|
||||
assert ei.value.code == "SSHPASS_MISSING"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_failed(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
class P:
|
||||
returncode = 1
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"scp: no such file")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "LAN_ERROR"
|
||||
|
||||
|
||||
def test_scp_fetch_remote_uses_port_and_user(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
from pathlib import Path as _Path
|
||||
captured = {}
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
captured["args"] = list(args)
|
||||
dest = _Path(args[-1])
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(">g\nACGT\n")
|
||||
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out",
|
||||
credentials={"user": "u", "port": "2222"}))
|
||||
# scp -P 2222 u@host:/data/x.fa <dest>
|
||||
assert captured["args"][1] == "-P"
|
||||
assert captured["args"][2] == "2222"
|
||||
assert captured["args"][-2] == "u@host:/data/x.fa"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_timeout(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
async def fake_wait_for(coro, timeout=None):
|
||||
coro.close() # 未 await 的 communicate 协程关闭,避免 never-awaited 警告
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
monkeypatch.setattr(asyncio, "wait_for", fake_wait_for)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "LAN_TIMEOUT"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_no_file(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
# returncode 0 但未产出目标文件
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "LAN_ERROR"
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/toggle_bioflow.sh —— 生信能力按需装卸(热,不重启 QA)+ bioflow_service 启停 + 注册状态自检。
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy/toggle_bioflow.sh <gene-family|rnaseq|extension|all> <on|off>
|
||||
#
|
||||
# 原理:
|
||||
# - 能力装卸经 BFF POST /api/capabilities/install(透传 QA install_manager,热加载生效不重启 QA)
|
||||
# - on = register(manifest 校验 + 原子写 capabilities.yaml + registry 差分刷新)
|
||||
# - off = unregister(按 name 移除能力段,其余能力不受影响)
|
||||
# - 自检 = GET /api/capabilities 的 installed 数组是否含对应能力
|
||||
# - bioflow_service 启停:全部能力关闭时停 8098;任一开启时启动 8098
|
||||
# - 卸载活跃任务:三表已持久化,服务恢复后 runner.recover 续跑;QA 侧按「生信服务未启动」降级话术
|
||||
set -uo pipefail
|
||||
|
||||
BFF="http://127.0.0.1:8090"
|
||||
BIOFLOW_SERVICE_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/bioflow_service"
|
||||
PY="/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python"
|
||||
BIOFLOW_PORT="${BIOFLOW_PORT:-8098}"
|
||||
|
||||
CAP="${1:-}"
|
||||
ACTION="${2:-}"
|
||||
|
||||
CAPS=(gene-family rnaseq extension)
|
||||
|
||||
usage() {
|
||||
echo "用法: $0 <gene-family|rnaseq|extension|all> <on|off>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# capability key -> registry 能力名
|
||||
cap_name() {
|
||||
case "$1" in
|
||||
gene-family) echo "bioflow-gene-family" ;;
|
||||
rnaseq) echo "bioflow-rnaseq" ;;
|
||||
extension) echo "bioflow-extension" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# capability key -> register manifest YAML(与 agent/qa/configs/capabilities.yaml 一致)
|
||||
manifest() {
|
||||
case "$1" in
|
||||
gene-family) cat <<'YAML'
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析(路径规划/建任务/参数确认/文件获取与预处理/状态查询),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 家族成员, 亚家族, 系统发育, 建树, 进化树, 保守基序, motif, 结构域, 基因结构, 共线性, Ka/Ks, 选择压力, 组合证据]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
YAML
|
||||
;;
|
||||
rnaseq) cat <<'YAML'
|
||||
capabilities:
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析(实验设计确认/样本配置/路径规划/建任务/差异表达/富集),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers: [转录组, RNA-seq, RNAseq, rnaseq, 差异表达, 表达定量, 富集, WGCNA, GSEA, 实验设计, fastq]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
YAML
|
||||
;;
|
||||
extension) cat <<'YAML'
|
||||
capabilities:
|
||||
- name: bioflow-extension
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 用户自定义扩展分析(创建/转换/试运行/校验/发布/运行),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers: [扩展, 拓展分析, 扩展分析, 自定义分析, 分析模板, 分析脚本, 分析工具]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/extension/
|
||||
prompt_pack:
|
||||
file: bioflow_extension_agent_rules.md
|
||||
YAML
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
|
||||
[ -n "$CAP" ] && [ -n "$ACTION" ] || usage
|
||||
[ "$ACTION" = "on" ] || [ "$ACTION" = "off" ] || usage
|
||||
if [ "$CAP" != "all" ]; then
|
||||
cap_name "$CAP" >/dev/null 2>&1 || usage
|
||||
fi
|
||||
|
||||
# 1. 逐能力 register/unregister(热加载不重启 QA)
|
||||
targets=("$CAP")
|
||||
[ "$CAP" = "all" ] && targets=("${CAPS[@]}")
|
||||
for key in "${targets[@]}"; do
|
||||
name="$(cap_name "$key")"
|
||||
if [ "$ACTION" = "on" ]; then
|
||||
body="$(manifest "$key")"
|
||||
resp=$(curl -s -m 30 -X POST "$BFF/api/capabilities/install" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(python3 -c 'import json,sys; print(json.dumps({"action":"register","manifest":sys.stdin.read()}))' <<<"$body")")
|
||||
else
|
||||
resp=$(curl -s -m 30 -X POST "$BFF/api/capabilities/install" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"action\":\"unregister\",\"name\":\"$name\"}")
|
||||
fi
|
||||
echo "[$ACTION] $name -> $(echo "$resp" | head -c 300)"
|
||||
done
|
||||
|
||||
# 2. 自检:GET /api/capabilities 的 installed 数组
|
||||
installed=$(curl -s -m 10 "$BFF/api/capabilities")
|
||||
echo "注册状态: $(echo "$installed" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i.get("name") for i in d.get("installed") or []])' 2>/dev/null || echo 'parse-fail')"
|
||||
|
||||
# 3. bioflow_service 按需启停:任一能力开启则启动,全部关闭则停止
|
||||
any_on=false
|
||||
for key in "${CAPS[@]}"; do
|
||||
name="$(cap_name "$key")"
|
||||
if echo "$installed" | grep -q "\"$name\""; then
|
||||
any_on=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$any_on" = true ]; then
|
||||
if ! lsof -ti tcp:$BIOFLOW_PORT -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "启动 bioflow_service :$BIOFLOW_PORT"
|
||||
( cd "$BIOFLOW_SERVICE_DIR" && setsid "$PY" -m uvicorn app.main:app --host 127.0.0.1 --port "$BIOFLOW_PORT" \
|
||||
> /tmp/plantai-bioflow.log 2>&1 < /dev/null & ) && disown -a
|
||||
sleep 2
|
||||
fi
|
||||
curl -s -m 8 "http://127.0.0.1:$BIOFLOW_PORT/api/bioflow/health" | head -c 150
|
||||
echo
|
||||
else
|
||||
echo "全部能力已卸载:停止 bioflow_service :$BIOFLOW_PORT"
|
||||
kill $(lsof -ti tcp:$BIOFLOW_PORT -sTCP:LISTEN) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "DONE"
|
||||
@@ -0,0 +1,177 @@
|
||||
# 生信流程 · 对话式手工测试手册(S6 更新版)
|
||||
|
||||
> 供用户逐项实测。生信模块已从「卡片式交互」改造为「自然语言对话式交互 + MCP 外挂」,
|
||||
> 本手册以**对话剧本**形式给出每个场景的:对话示例 → 系统应如何回应 → 通过判据。
|
||||
> 环境:内网 http://192.168.0.175:8090(BFF);生信服务 8098、QA 8091 已部署;
|
||||
> 三能力(基因家族/转录组/扩展)按 §0 装卸。
|
||||
|
||||
---
|
||||
|
||||
## 0. 前置条件
|
||||
|
||||
1. 浏览器打开 http://192.168.0.175:8090。
|
||||
2. **能力安装检查**:设置面板(SettingsPanel)应能看到三个生信开关(基因家族/转录组/扩展)。
|
||||
打开任意一个后,工作区出现「生信任务」「生信档案」TAB;全关则 TAB 消失(**TAB 显隐 = 能力注册状态**,非服务健康)。
|
||||
3. 服务器 docker 已有 8 个 bioflow 镜像;金标数据在 `bioflow_kernel/assets/examples/test_input/`(Ft 四件套 pep/cds/genome/gff3)。
|
||||
4. 熔断开关:`QA_V1_ENABLE_BIOFLOW_AGENT=1`(默认 0,关时生信句走普通 QA)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 多配置开关回归(S6)
|
||||
|
||||
| 配置 | 操作 | 期望 | 判据 |
|
||||
|---|---|---|---|
|
||||
| 全关(熔断=0 + 三能力卸载) | 发「帮我做基因家族分析」 | 普通 QA 礼貌回答(说明未安装生信能力),**无**路径卡 | 无生信分流 |
|
||||
| 只开基因家族 | 发「帮我做基因家族分析」 | 进入生信对话(列路径+挂路径卡) | 命中 |
|
||||
| 只开基因家族 | 发「帮我做转录组差异表达」 | 走普通 QA(转录组能力未装) | 不响应 |
|
||||
| 只开转录组 | 发「帮我做转录组」 | 进入生信对话 | 命中 |
|
||||
| 只开转录组 | 发「帮我做基因家族分析」 | 走普通 QA | 不响应 |
|
||||
| 全开但非生信提问 | 发「光合作用的电子传递链是什么」 | 走普通 QA(非生信意图不调工具) | 不调工具 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 对话剧本 A:基因家族分析(卡片辅助通道)
|
||||
|
||||
**第 1 轮 —— 需求与路径**
|
||||
> 用户:帮我做水稻 SPL 基因家族分析
|
||||
> 系统(自然语言):列「核心流程(必选)+ 可选分析」,说明每个可选项生物学意义;
|
||||
> **同帧挂出路径选择卡**。
|
||||
> 判据:回复含路径列表 + 路径卡出现(卡片不替代自然语言)。
|
||||
|
||||
**第 2 轮 —— 卡片操作 → 系统复述**
|
||||
> 用户(路径卡勾选「核心流程 + Ka/Ks + 启动子」,点确认;打字补充文件位置)
|
||||
> 系统:先自然语言**复述**用户选择("您选择了核心流程 + Ka/Ks + 启动子分析…"),再请确认。
|
||||
> 判据:系统复述内容与卡片勾选一致;建任务前必有一轮复述确认(不直接建任务)。
|
||||
|
||||
**第 3 轮 —— 网络文件 + 非标准文件预处理**
|
||||
> 用户:文件在 `https://…/rice.fa` 与 `oss://seq-bucket/rice.gff3`,另传 `spl_list.xlsx`
|
||||
> 系统:拉取 web/OSS 文件(**下载前检查磁盘余量、记录 provenance**);对 `spl_list.xlsx`
|
||||
> 做内容嗅探后**自然语言说明列结构并提问**("推测第二列是蛋白序列,备注列忽略?")。
|
||||
> 判据:文件获取成功;Excel 触发预处理提问(列表方式,可卡片点选也可打字回答)。
|
||||
|
||||
**第 4 轮 —— 建任务执行**
|
||||
> 用户:都对,备注列不要;确认,开始吧
|
||||
> 系统:复述整理结果("已整理为 FASTA 3 条序列")→ 创建任务 → 消息内出现**只读精简进度块**,
|
||||
> 右侧「生信任务」TAB 完整滚动日志实时跟随。
|
||||
> 判据:进度块只读(无按钮);TAB 日志自动滚底。
|
||||
|
||||
**执行中 —— 进度转述 + 随时提问**
|
||||
> 用户:什么是组合证据?
|
||||
> 系统:经流程知识库自然语言回答(带引用),**不打断任务**。
|
||||
> 判据:提问得到引用回答,任务继续推进。
|
||||
|
||||
**人工确认边界(不可跳过)**
|
||||
> 系统:第 8 步亚家族预测完成(6 个亚家族)。⚠ 需您人工确认,我不能代您确认。
|
||||
> 用户:把 SPL3 从亚家族 A 移到 B → 系统复述修改 → 用户「确认」。
|
||||
> 判据:subfamily_confirm 必停、必复述、绝不代确认。
|
||||
|
||||
**收尾 —— 存档**
|
||||
> 系统:全部完成(19 成员 6 亚家族),同帧挂出存档卡。
|
||||
> 用户(存档卡填名称点「存档」或打字「存档,叫 SPL 水稻鉴定」)
|
||||
> 系统:实际执行 `archive_job` 存档(复用 REST 语义),复述存档三要素(名称/位置/大小)。
|
||||
> 判据:存档成功(重名提示 ARCHIVE_EXISTS)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 对话剧本 B:转录组分析(纯自然语言通道,全程不点卡片)
|
||||
|
||||
**第 1 轮 —— 必确认信息**
|
||||
> 用户:帮我做转录组,比较干旱处理和对照
|
||||
> 系统:列主流程 + 可选;**必须确认**四项(物种/参考基因组版本、FASTQ 单双端、
|
||||
> 对照处理组重复数、链特异性),列表展示。
|
||||
> 判据:实验设计确认边界被明确列出,未确认前不得建差异表达任务。
|
||||
|
||||
**第 2 轮 —— OSS 文件 + 样本信息(纯打字,不点卡)**
|
||||
> 用户:数据在 oss://seq-bucket/drought/,水稻日本晴,干旱 3 重复对照 3 重复,双端非链特异
|
||||
> 系统:从 OSS 拉取 12 个 FASTQ 并核对成对;复述样本设计("干旱 3 vs 对照 3,双端,非链特异"),
|
||||
> 请确认基线(对照为基线、比较方向「干旱 vs 对照」)。
|
||||
> 判据:纯自然语言路径走通(**卡片与打字两条通道服务端语义一致**)。
|
||||
|
||||
**第 3 轮 —— 执行 + 结果转述**
|
||||
> 用户:对 → 系统执行 QC→比对→定量→样本质控→差异表达→富集,逐段转述(接头率/Q30/唯一比对率/差异基因数/PCA 分离)。
|
||||
> 判据:每步完成后自然语言转述关键指标;样本质控 PCA 结论有转述。
|
||||
|
||||
---
|
||||
|
||||
## 4. 三种网络文件来源用例
|
||||
|
||||
| 来源 | 示例 | 期望 |
|
||||
|---|---|---|
|
||||
| web | 「文件在 https://…/x.fa」 | httpx 流式下载,不设大小上限;下载前查磁盘余量;记录 provenance |
|
||||
| OSS | 「数据在 oss://bucket/key 或预签名 URL」 | 经 BIOFLOW_OSS_ENDPOINT 解析或预签名直通;无 endpoint 时提示改用预签名 |
|
||||
| 局域网 | 「文件在 192.168.x.x:/data/x.fa,账号 xx 密码 xx」 | ssh/scp 拉取(凭据单次会话内存用,不落盘、审计不记明文) |
|
||||
|
||||
判据:文件落 `bioflow_data/jobs/{job}/input/`,先内容嗅探后登记,provenance 记录来源/哈希/时间。
|
||||
|
||||
---
|
||||
|
||||
## 5. 非标准文件预处理(Excel / 乱码文本)闭环
|
||||
|
||||
1. 上传 `xxx.xlsx` / 乱码 txt → 系统 `inspect_file` 探测(列结构/格式猜测/乱码检测)。
|
||||
2. 系统自然语言说明 + 列表提问(哪列是序列、是否去重、是否去表头)。
|
||||
3. 用户回答 → `transform_file` 转换(LLM 定规则 + 脚本执行,大文件抽样定规则)。
|
||||
4. 结果过格式验证器(fasta 可解析/gff3 9 列/tsv 列一致/samples.yaml schema);
|
||||
失败说明问题并追问,循环 ≤3 次后建议人工整理。
|
||||
5. 转换后 `register_job_files` 登记,provenance 记「原始→规则→产物」链。
|
||||
|
||||
判据:全程自然语言引导;转换产物格式校验通过;原文件永不覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 6. 卡片辅助 vs 纯自然语言双通道等价
|
||||
|
||||
| 场景 | 卡片通道 | 纯 NL 通道 | 等价判据 |
|
||||
|---|---|---|---|
|
||||
| 路径选择 | 路径卡勾选 | 打字「核心流程 + Ka/Ks」 | 两者复述的确认内容一致,建任务入参一致 |
|
||||
| 参数确认 | 参数卡填值 | 打字「用 8 核、padj 用 0.01」 | confirm_stage 入参一致 |
|
||||
| 文件提供 | 文件卡拖拽/填路径 | 打字「文件在 …」 | register_job_files / fetch_source_file 入参一致 |
|
||||
| 存档 | 存档卡点存档 | 打字「存档,叫 X」 | archive_job 入参一致 |
|
||||
|
||||
判据:卡片操作产生的是 `card_result` 结构化消息进 QA 流(**不直连 bioflow 控制面**),
|
||||
与打字在服务端语义完全一致(QA 对话状态机是唯一决策点)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 旧会话兼容(Legacy 块只读)
|
||||
|
||||
1. 打开一个改造前创建、含旧生信块(bioflow_qa / 旧 bioflow_* 块)的会话。
|
||||
2. 期望:旧块以只读形式降级渲染(标题 + 只读引用/提示),**不报错、无可交互控件**。
|
||||
|
||||
---
|
||||
|
||||
## 8. 扩展子系统对话化(第三能力单元)
|
||||
|
||||
1. 打开「扩展」能力开关。
|
||||
2. 对话创建扩展:描述分析脚本(代码文件 + 运行命令)→ `create_extension`(draft)。
|
||||
3. `build` → `trial_run` → `validate` → `confirm` → `publish` → `run` 逐步走(卡片或打字等价)。
|
||||
4. 试运行失败 → 「AI 修复」触发 `repair_extension`(修复上限 20 轮);校验失败/状态不足时系统给出确定性提示。
|
||||
|
||||
判据:扩展生命周期全程对话化;非法状态返回清晰提示(EXT_STATUS),不报 500。
|
||||
|
||||
---
|
||||
|
||||
## 9. 后端金标验证(服务器命令行,S6 回归)
|
||||
|
||||
```bash
|
||||
cd /home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new
|
||||
BIOFLOW_DATA=/home/xzq/5090/teamWorkspace/zh/bioflow-data \
|
||||
bash bioflow_kernel/tests/smoke_golden.sh bioflow-gf:dev
|
||||
```
|
||||
|
||||
期望:4 步全绿——python 3.11.14 / ldd 0 缺库 / 无 conda / 金标 identify 24 ID diff 空(`GOLDEN_DIFF_OK 24 IDs`)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 端到端主线(一次性走全流程,双通道各一遍)
|
||||
|
||||
1. 卡片通道:§2 全程(路径卡→文件卡→参数卡→进度块→存档卡)。
|
||||
2. 纯 NL 通道:§3 全程(不点任何卡片)。
|
||||
3. 含一次 Excel 预处理、一次局域网/OSS 文件拉取。
|
||||
4. 任务完成后存档 → 生信档案 TAB 回看 → 复用发起。
|
||||
|
||||
期望:两条通道均走通,7 项引导(路径/文件/参数/报告/存档/复用/过程展示)逐项达成,
|
||||
进度块实时流转、右侧日志滚底。
|
||||
|
||||
---
|
||||
|
||||
*本手册为 S6 对话式改造更新版,替代旧卡片式 `MANUAL_TEST_BIOFLOW.md` 的生信流程部分。*
|
||||
+11
-1
@@ -286,11 +286,21 @@ async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]:
|
||||
history = await assemble_history(bff_db, session_id)
|
||||
if history:
|
||||
body["history"] = history
|
||||
# 会话开关注入上游(web_search/superpowers 经 QA 门控/注入点生效)
|
||||
# 会话开关注入上游(web_search/superpowers/bioflow 经 QA 门控/注入点生效)
|
||||
body["capabilities"] = {
|
||||
"web_search": bool(caps.get("web_search", False)),
|
||||
"superpowers": bool(caps.get("superpowers", False)),
|
||||
"bioflow": bool(caps.get("bioflow", False)),
|
||||
}
|
||||
# session↔job 绑定(S2):meta.bioflow = {active_job_id, chain} 注入上游,
|
||||
# 供 BioflowAgent 复用当前任务(job_status 是任务真相唯一权威)。
|
||||
bioflow_meta = meta.get("bioflow")
|
||||
if isinstance(bioflow_meta, dict):
|
||||
body["bioflow"] = {
|
||||
"session_id": session_id,
|
||||
"active_job_id": str(bioflow_meta.get("active_job_id") or ""),
|
||||
"chain": str(bioflow_meta.get("chain") or ""),
|
||||
}
|
||||
humanize = bool(caps.get("humanize", False))
|
||||
except Exception as exc:
|
||||
logger.warning("多轮记忆注入失败(降级为无历史): %s", exc)
|
||||
|
||||
@@ -30,7 +30,7 @@ from ..schemas import (
|
||||
SessionOut,
|
||||
SessionPatch,
|
||||
)
|
||||
from ..services.capabilities import sanitize_capabilities
|
||||
from ..services.capabilities import sanitize_bioflow_meta, sanitize_capabilities
|
||||
from ..upstream import envelope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -159,11 +159,17 @@ async def patch_session(
|
||||
)
|
||||
if payload.meta is not None:
|
||||
caps = sanitize_capabilities(payload.meta.get("capabilities"))
|
||||
if caps:
|
||||
has_bioflow = "bioflow" in payload.meta
|
||||
bioflow_patch = sanitize_bioflow_meta(payload.meta.get("bioflow"))
|
||||
if caps or has_bioflow:
|
||||
meta = session.meta.model_dump()
|
||||
merged_caps = dict(meta.get("capabilities") or {})
|
||||
merged_caps.update(caps)
|
||||
meta["capabilities"] = merged_caps
|
||||
if caps:
|
||||
merged_caps = dict(meta.get("capabilities") or {})
|
||||
merged_caps.update(caps)
|
||||
meta["capabilities"] = merged_caps
|
||||
if has_bioflow:
|
||||
# None 表示「清空绑定」(前端传 bioflow: {} 或 null)
|
||||
meta["bioflow"] = bioflow_patch
|
||||
await db.execute(
|
||||
"UPDATE sessions SET meta = ?, updated_at = ? WHERE id = ?",
|
||||
(dumps_json(meta), _db_now(), session_id),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.services.capabilities import ( # noqa: E402
|
||||
DEFAULTS,
|
||||
MANIFEST,
|
||||
merged_capabilities,
|
||||
sanitize_bioflow_meta,
|
||||
sanitize_capabilities,
|
||||
)
|
||||
|
||||
@@ -50,3 +51,39 @@ class TestMerged:
|
||||
)
|
||||
assert body["installed"] == [{"name": "web_search", "description": "x"}]
|
||||
assert body["load_warnings"] == ["bad entry skipped"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S2 sanitize_bioflow_meta(session↔job 绑定白名单)
|
||||
# ===========================================================================
|
||||
|
||||
class TestSanitizeBioflowMeta:
|
||||
def test_valid(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "job-1", "chain": "gene_family"}) == {
|
||||
"active_job_id": "job-1",
|
||||
"chain": "gene_family",
|
||||
}
|
||||
|
||||
def test_evil_key_dropped(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "j", "chain": "c", "evil": "x"}) == {
|
||||
"active_job_id": "j",
|
||||
"chain": "c",
|
||||
}
|
||||
|
||||
def test_non_dict_returns_none(self):
|
||||
assert sanitize_bioflow_meta(None) is None
|
||||
assert sanitize_bioflow_meta("x") is None
|
||||
assert sanitize_bioflow_meta(["a"]) is None
|
||||
|
||||
def test_empty_or_null_returns_none(self):
|
||||
assert sanitize_bioflow_meta({}) is None
|
||||
assert sanitize_bioflow_meta({"active_job_id": "", "chain": ""}) is None
|
||||
assert sanitize_bioflow_meta({"active_job_id": None, "chain": None}) is None
|
||||
|
||||
def test_partial(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "j"}) == {"active_job_id": "j"}
|
||||
assert sanitize_bioflow_meta({"chain": "c"}) == {"chain": "c"}
|
||||
|
||||
def test_truncation(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "x" * 200})["active_job_id"] == "x" * 128
|
||||
assert sanitize_bioflow_meta({"chain": "y" * 100})["chain"] == "y" * 64
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""test_qa_inject_history.py —— _inject_history 的 bioflow 注入单测(S2)。
|
||||
|
||||
覆盖:session↔job 绑定(meta.bioflow)注入 body["bioflow"]、capabilities.bioflow 开关注入、
|
||||
memory 关跳过、显式 history 优先、会话不存在原样返回。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.routers import qa as qa_mod # noqa: E402
|
||||
|
||||
|
||||
class FakeRow(dict):
|
||||
"""aiosqlite.Row 最小替身。"""
|
||||
|
||||
def __getattr__(self, key):
|
||||
return self[key]
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self, row=None):
|
||||
self.row = row
|
||||
|
||||
async def fetchone(self, sql, params):
|
||||
return self.row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_bioflow_binding(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
row = FakeRow(meta='{"capabilities": {"bioflow": true, "memory": true}, '
|
||||
'"bioflow": {"active_job_id": "job-1", "chain": "gene_family"}}')
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(row))
|
||||
|
||||
async def fake_assemble(db, session_id):
|
||||
return [{"role": "user", "content": "上轮"}, {"role": "assistant", "content": "上答"}]
|
||||
|
||||
monkeypatch.setattr(qa_mod, "assemble_history", fake_assemble)
|
||||
body, humanize = await qa_mod._inject_history({"session_id": "s1", "question": "继续"})
|
||||
assert body["bioflow"] == {"session_id": "s1", "active_job_id": "job-1", "chain": "gene_family"}
|
||||
assert body["capabilities"] == {"web_search": False, "superpowers": False, "bioflow": True}
|
||||
assert body["history"] == [{"role": "user", "content": "上轮"}, {"role": "assistant", "content": "上答"}]
|
||||
assert humanize is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_bioflow_meta_injects_nothing(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
row = FakeRow(meta='{"capabilities": {"memory": true}}') # 无 bioflow 绑定
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(row))
|
||||
|
||||
async def fake_assemble(db, session_id):
|
||||
return [{"role": "user", "content": "上轮"}]
|
||||
|
||||
monkeypatch.setattr(qa_mod, "assemble_history", fake_assemble)
|
||||
body, _ = await qa_mod._inject_history({"session_id": "s1"})
|
||||
assert "bioflow" not in body
|
||||
assert body["capabilities"]["bioflow"] is False # 未开 bioflow 开关
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_off_skips_injection(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
row = FakeRow(meta='{"capabilities": {"memory": false, "bioflow": true}, '
|
||||
'"bioflow": {"active_job_id": "j"}}')
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(row))
|
||||
body, _ = await qa_mod._inject_history({"session_id": "s1"})
|
||||
assert "bioflow" not in body
|
||||
assert "capabilities" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_not_found_returns_body_unchanged(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(None)) # fetchone → None
|
||||
body, _ = await qa_mod._inject_history({"session_id": "s1", "question": "q"})
|
||||
assert body == {"question": "q"} # session_id 被 pop,其余原样
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_history_takes_priority(monkeypatch):
|
||||
body, _ = await qa_mod._inject_history(
|
||||
{"session_id": "s1", "history": [{"role": "user", "content": "x"}]}
|
||||
)
|
||||
assert body["history"] == [{"role": "user", "content": "x"}]
|
||||
assert "bioflow" not in body # 显式 history 优先 → 不读 meta
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_session_id_returns_body_unchanged(monkeypatch):
|
||||
body, _ = await qa_mod._inject_history({"question": "q"})
|
||||
assert body == {"question": "q"}
|
||||
@@ -73,7 +73,7 @@ def test_session_crud_full_flow(client):
|
||||
assert sess["title"] == "新会话"
|
||||
assert sess["status"] == "idle"
|
||||
assert sess["message_count"] == 0
|
||||
assert sess["meta"] == {"title_manual": False, "capabilities": {}}
|
||||
assert sess["meta"] == {"title_manual": False, "capabilities": {}, "bioflow": None}
|
||||
|
||||
r2 = client.post("/api/sessions", json={"title": " 水稻SPL "})
|
||||
sess2 = r2.json()
|
||||
@@ -132,7 +132,7 @@ def test_session_crud_full_flow(client):
|
||||
# PATCH 会话标题 → title_manual=true
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"title": "手工标题"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"] == {"title_manual": True, "capabilities": {}}
|
||||
assert r.json()["meta"] == {"title_manual": True, "capabilities": {}, "bioflow": None}
|
||||
|
||||
# PATCH 会话状态
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"status": "done"})
|
||||
@@ -149,6 +149,29 @@ def test_session_crud_full_flow(client):
|
||||
assert body["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_session_bioflow_binding_patch(client):
|
||||
"""S2 session↔job 绑定:PATCH meta.bioflow 白名单落库 + 串行往返。"""
|
||||
r = client.post("/api/sessions", json={})
|
||||
sess = r.json()
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
# 只收 active_job_id / chain 两个键,非法键丢弃
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={
|
||||
"meta": {"bioflow": {"active_job_id": "job-42", "chain": "gene_family", "evil": "x"}}
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["meta"]["bioflow"] == {"active_job_id": "job-42", "chain": "gene_family"}
|
||||
|
||||
# 回读持久化
|
||||
r = client.get(f"/api/sessions/{sess['id']}")
|
||||
assert r.json()["meta"]["bioflow"] == {"active_job_id": "job-42", "chain": "gene_family"}
|
||||
|
||||
# 空 dict → 清空绑定
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"meta": {"bioflow": {}}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"]["bioflow"] is None
|
||||
|
||||
|
||||
def test_not_found_envelopes(client):
|
||||
cases = [
|
||||
("get", "/api/sessions/nope", None),
|
||||
|
||||
@@ -8,6 +8,10 @@ import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useBenchStore } from '../../../../ui/src/stores/bench.js';
|
||||
|
||||
vi.mock('../../../../ui/src/api/capabilities.js', () => ({
|
||||
getCapabilities: vi.fn(async () => ({ installed: [{ name: 'bioflow-gene-family' }] })),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
|
||||
bioflowHealth: vi.fn(async () => ({ ok: true, status: 'ok', db: { connected: true } })),
|
||||
listBioflowJobs: vi.fn(async () => ({ ok: true, data: { items: [], total: 0 } })),
|
||||
@@ -68,6 +72,7 @@ vi.mock('../../../../ui/src/api/qa.js', () => ({
|
||||
import BenchPanel from '../../../../ui/src/components/bench/BenchPanel.vue';
|
||||
import { useBioflowStore } from '../../../../ui/src/stores/bioflow.js';
|
||||
import * as bioflowApi from '../../../../ui/src/api/bioflow.js';
|
||||
import * as capabilitiesApi from '../../../../ui/src/api/capabilities.js';
|
||||
|
||||
describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
let pinia;
|
||||
@@ -78,14 +83,15 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
});
|
||||
|
||||
it('探活成功:五个既有 TAB + 生信任务/生信档案两 TAB(TC-BIO-JOB-001/PANE-001)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
expect(labels).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档', '生信任务', '生信档案']);
|
||||
});
|
||||
|
||||
it('探活失败(health 不可达):生信 TAB 不渲染(TC-BIO-JOB-012/PANE-013)', async () => {
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
it('能力未注册:生信 TAB 不渲染(S3 主信号 = 能力注册状态)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
@@ -94,13 +100,23 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
expect(labels).not.toContain('生信档案');
|
||||
});
|
||||
|
||||
it('探活恢复:强制重探后 TAB 出现(TC-BIO-JOB-013/PANE-014)', async () => {
|
||||
it('已注册但 health 不可达:TAB 仍显示(次信号不影响显隐)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
expect(labels).toContain('生信任务');
|
||||
expect(labels).toContain('生信档案');
|
||||
});
|
||||
|
||||
it('探活恢复:能力注册后强制重探 TAB 出现(TC-BIO-JOB-013/PANE-014)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
expect(w.findAll('.bench-tab').map((t) => t.text())).not.toContain('生信任务');
|
||||
// 服务恢复:强制重探
|
||||
bioflowApi.bioflowHealth.mockResolvedValueOnce({ ok: true });
|
||||
// 能力安装:强制重探
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const bioflow = useBioflowStore();
|
||||
await bioflow.probe({ force: true });
|
||||
await flushPromises();
|
||||
@@ -109,6 +125,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
});
|
||||
|
||||
it('切到生信任务 TAB:渲染 BioJobsPane(TC-BIO-JOB-001)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const biojobsTab = w.findAll('.bench-tab').find((t) => t.text() === '生信任务');
|
||||
@@ -119,6 +136,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
});
|
||||
|
||||
it('切到生信档案 TAB:渲染 BioArchivePane(TC-BIO-PANE-001)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const bioarchiveTab = w.findAll('.bench-tab').find((t) => t.text() === '生信档案');
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
STAGE_STATUS_BY_EVENT,
|
||||
} from '../../../../ui/src/stores/bioflow.js';
|
||||
|
||||
// mock api/capabilities.js(S3 双信号主信号源)
|
||||
vi.mock('../../../../ui/src/api/capabilities.js', () => ({
|
||||
getCapabilities: vi.fn(async () => ({ installed: [{ name: 'bioflow-gene-family' }] })),
|
||||
}));
|
||||
|
||||
// mock api/bioflow.js(SSE 用 mock,不依赖生产服务;vi.mock 工厂内不得引用顶层变量)
|
||||
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
|
||||
bioflowHealth: vi.fn(async () => ({ ok: true, status: 'ok', db: { connected: true } })),
|
||||
@@ -176,20 +181,36 @@ describe('bioflow store —— 探活 / 动作 / 面板 / 复用', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('探活 200 → online;失败 → offline(TC-BIO-STORE-016)', async () => {
|
||||
it('探活 能力注册 → online;未注册 → offline(TC-BIO-STORE-016 / S3 双信号)', async () => {
|
||||
const capsApi = await import('../../../../ui/src/api/capabilities.js');
|
||||
const store = useBioflowStore();
|
||||
capsApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
bioflowApi.bioflowHealth.mockResolvedValueOnce({ ok: true });
|
||||
await store.probe({ force: true });
|
||||
expect(store.capabilityInstalled).toBe(true);
|
||||
expect(store.bioflowAvailable).toBe(true);
|
||||
|
||||
const s2 = useBioflowStore();
|
||||
s2.availability = 'unknown';
|
||||
capsApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
await s2.probe({ force: true });
|
||||
expect(s2.capabilityInstalled).toBe(false);
|
||||
expect(s2.availability).toBe('offline');
|
||||
expect(s2.bioflowAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('探活 已注册但服务不可达 → TAB 仍显示(次信号不影响显隐)', async () => {
|
||||
const capsApi = await import('../../../../ui/src/api/capabilities.js');
|
||||
const store = useBioflowStore();
|
||||
capsApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-rnaseq' }] });
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
await store.probe({ force: true });
|
||||
expect(store.capabilityInstalled).toBe(true);
|
||||
expect(store.healthOnline).toBe(false);
|
||||
expect(store.bioflowAvailable).toBe(true); // 已注册 → TAB 仍显示
|
||||
});
|
||||
|
||||
it('applyChatEvent plan_ready 装载候选路径(TC-BIO-STORE-003)', () => {
|
||||
const store = useBioflowStore();
|
||||
store.applyChatEvent('plan_ready', {
|
||||
|
||||
@@ -82,9 +82,18 @@ function setup() {
|
||||
bioflow.currentJob.jobId = 'job-1';
|
||||
const chat = useChatStore();
|
||||
chat.sessionId = 'sess-1';
|
||||
// S3:卡片操作改走 card_result 进 QA 流——单测把 sendCardResult 打桩为「挂起中」
|
||||
//(模拟真实 QA 流在 complete 前不 resolve),断言其被以正确 card/payload 调用;
|
||||
// 挂起态也保证 confirming 防双击守卫在测试里不被 finally 提前复位。
|
||||
vi.spyOn(chat, 'sendCardResult').mockImplementation(() => new Promise(() => {}));
|
||||
return { bioflow, chat };
|
||||
}
|
||||
|
||||
/** 当前活跃 chat store 的 sendCardResult spy(setup 已打桩)。 */
|
||||
function sendCardSpy() {
|
||||
return useChatStore().sendCardResult;
|
||||
}
|
||||
|
||||
const PATH_BLOCK = () => ({
|
||||
type: 'bioflow_path',
|
||||
status: 'done',
|
||||
@@ -169,9 +178,11 @@ describe('FlowPathCard —— TC-BIO-PATH-001~022(L4)', () => {
|
||||
await w.find('.fpc-confirm').trigger('click'); // 进确认视图
|
||||
await w.findAll('.fpc-confirm')[0].trigger('click'); // 确认创建
|
||||
await flushPromises();
|
||||
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
|
||||
const body = bioflowApi.createBioflowJob.mock.calls[0][0];
|
||||
expect(body.stages).toEqual(['identify', 'align', 'phylogeny']);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
const [card, payload] = sendCardSpy().mock.calls[0];
|
||||
expect(card).toBe('path_selection');
|
||||
expect(payload.chain).toBe('gene_family');
|
||||
expect(payload.stages).toEqual(['identify', 'align', 'phylogeny']);
|
||||
});
|
||||
|
||||
it('确认按钮双击只触发一次创建(TC-BIO-ACT-008 防重复)', async () => {
|
||||
@@ -182,7 +193,7 @@ describe('FlowPathCard —— TC-BIO-PATH-001~022(L4)', () => {
|
||||
await confirmBtn.trigger('click');
|
||||
await confirmBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('键盘 Enter 触发确认(TC-BIO-ACT-010)', async () => {
|
||||
@@ -272,11 +283,12 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
await w.findAll('.ffc-input')[0].setValue('8');
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
|
||||
const [jobId, stage, params] = bioflowApi.confirmBioflowStage.mock.calls[0];
|
||||
expect(jobId).toBe('job-1');
|
||||
expect(stage).toBe('identify');
|
||||
expect(params.cpu).toBe(8);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
const [card, payload] = sendCardSpy().mock.calls[0];
|
||||
expect(card).toBe('form_confirm');
|
||||
expect(payload.job_id).toBe('job-1');
|
||||
expect(payload.stage).toBe('identify');
|
||||
expect(payload.params.cpu).toBe(8);
|
||||
});
|
||||
|
||||
it('继续按钮双击仅一次请求(TC-BIO-ACT-021 提交中禁用)', async () => {
|
||||
@@ -285,7 +297,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
await btn.trigger('click');
|
||||
await btn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('数字类型校验:输入 abc 拦截(TC-BIO-FORM-014)', async () => {
|
||||
@@ -293,7 +305,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
await w.findAll('.ffc-input')[0].setValue('abc');
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
|
||||
expect(sendCardSpy()).not.toHaveBeenCalled();
|
||||
expect(w.find('.ffc-err').text()).toContain('类型');
|
||||
});
|
||||
|
||||
@@ -303,7 +315,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
const w = mount(FlowFormCard, { props: { block: blk } });
|
||||
await w.findAll('.ffc-input')[0].setValue('999');
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
|
||||
expect(sendCardSpy()).not.toHaveBeenCalled();
|
||||
expect(w.find('.ffc-err').text()).toContain('1 ~ 64');
|
||||
});
|
||||
|
||||
@@ -312,15 +324,15 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
// species 默认空字符串
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
await flushPromises();
|
||||
const [, , params] = bioflowApi.confirmBioflowStage.mock.calls[0];
|
||||
expect(params.species).toBeUndefined();
|
||||
const [, payload] = sendCardSpy().mock.calls[0];
|
||||
expect(payload.params.species).toBeUndefined();
|
||||
});
|
||||
|
||||
it('键盘 Enter 提交(TC-BIO-FORM-023/ACT-016)', async () => {
|
||||
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
|
||||
await w.findAll('.ffc-input')[0].trigger('keydown', { key: 'Enter' });
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('键盘 Esc 恢复默认值(TC-BIO-FORM-024/ACT-017)', async () => {
|
||||
@@ -432,7 +444,7 @@ describe('FlowFileCard —— TC-BIO-FILE-001~022(L4)', () => {
|
||||
await w.find('.ffile-path-input').setValue('/data/examples/SPL.fa');
|
||||
await w.find('.ffile-path-row .pane-btn').trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalledWith('job-1', { server_path: '/data/examples/SPL.fa' });
|
||||
expect(sendCardSpy()).toHaveBeenCalledWith('file_upload', expect.objectContaining({ job_id: 'job-1', paths: ['/data/examples/SPL.fa'] }));
|
||||
});
|
||||
|
||||
it('登记路径按钮空 input 禁用(TC-BIO-ACT-048)', () => {
|
||||
@@ -445,7 +457,7 @@ describe('FlowFileCard —— TC-BIO-FILE-001~022(L4)', () => {
|
||||
await w.find('.ffile-path-input').setValue('/data/SPL.fa');
|
||||
await w.find('.ffile-path-input').trigger('keydown', { key: 'Enter' });
|
||||
await flushPromises();
|
||||
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalled();
|
||||
expect(sendCardSpy()).toHaveBeenCalledWith('file_upload', expect.objectContaining({ job_id: 'job-1', paths: ['/data/SPL.fa'] }));
|
||||
});
|
||||
|
||||
it('路径输入 Esc 清空(TC-BIO-ACT-044)', async () => {
|
||||
@@ -524,46 +536,15 @@ describe('FlowProgressCard —— TC-BIO-PROG-001~024(L4)', () => {
|
||||
expect(w.find('.pc-log-body').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('失败标红 ✕ + 状态文字 error 色 + 三选一按钮出现(TC-BIO-PROG-006/007)', () => {
|
||||
it('失败标红 ✕ + 状态文字 error 色(TC-BIO-PROG-006/007;S3 无三选一按钮)', () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: 'error', error: 'manifest_failed' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
const failedStep = w.findAll('.p-step')[1];
|
||||
expect(failedStep.classes()).toContain('failed');
|
||||
expect(failedStep.find('.p-dot').text()).toBe('✕');
|
||||
const actions = failedStep ? w.findAll('.pc-actions')[0] : null;
|
||||
expect(actions.text()).toContain('重跑');
|
||||
expect(actions.text()).toContain('跳过');
|
||||
expect(actions.text()).toContain('改参数重跑');
|
||||
});
|
||||
|
||||
it('重跑:发出 rerun 请求(TC-BIO-PROG-008/ACT-050)', async () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
const btn = w.findAll('.pc-actions .pane-btn')[0];
|
||||
await btn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.rerunBioflowStage).toHaveBeenCalledWith('job-1', 'align', null);
|
||||
});
|
||||
|
||||
it('跳过:发出 skip 请求(TC-BIO-PROG-009/ACT-051)', async () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
await w.findAll('.pc-actions .pane-btn')[1].trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.skipBioflowStage).toHaveBeenCalledWith('job-1', 'align');
|
||||
});
|
||||
|
||||
it('改参数重跑:弹出参数表单(TC-BIO-PROG-010/ACT-052)', async () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
await w.findAll('.pc-actions .pane-btn')[2].trigger('click');
|
||||
await flushPromises();
|
||||
expect(w.find('.pc-rerun-form').exists()).toBe(true);
|
||||
expect(bioflowApi.bioflowCatalog).toHaveBeenCalledWith({ stage: 'align' });
|
||||
// S3 精简:失败态不再渲染直连控制按钮(重跑/跳过/改参数重跑)
|
||||
expect(w.findAll('.pc-actions').length).toBe(0);
|
||||
});
|
||||
|
||||
it('skipped 态视觉(TC-BIO-PROG-011)', () => {
|
||||
@@ -604,24 +585,6 @@ describe('FlowProgressCard —— TC-BIO-PROG-001~024(L4)', () => {
|
||||
expect(w.findAll('.p-step').length).toBe(10);
|
||||
});
|
||||
|
||||
it('暂停/恢复按钮随任务状态显隐;点击发请求(TC-BIO-PROG-017/018)', async () => {
|
||||
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
|
||||
// running → 暂停按钮
|
||||
let pauseBtn = w.findAll('.pc-head-right .pane-btn').find((b) => b.text().includes('暂停'));
|
||||
expect(pauseBtn).toBeTruthy();
|
||||
await pauseBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.pauseBioflowJob).toHaveBeenCalledWith('job-1');
|
||||
// paused → 恢复按钮
|
||||
const blk = { ...PROG_BLOCK(), jobStatus: 'paused' };
|
||||
const w2 = mount(FlowProgressCard, { props: { block: blk } });
|
||||
const resumeBtn = w2.findAll('.pc-head-right .pane-btn').find((b) => b.text().includes('继续'));
|
||||
expect(resumeBtn).toBeTruthy();
|
||||
await resumeBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.resumeBioflowJob).toHaveBeenCalledWith('job-1');
|
||||
});
|
||||
|
||||
it('快照整体替换:块 stages 引用变化后自动整体刷新(TC-BIO-PROG-015)', async () => {
|
||||
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
|
||||
const before = w.findAll('.p-step').length;
|
||||
|
||||
@@ -190,141 +190,3 @@ describe('消息更新器 —— 五类块 patch', () => {
|
||||
expect(blk.citations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBioflow —— 全流程(SSE 用 mock,不依赖生产服务)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
chatFrames = [];
|
||||
});
|
||||
|
||||
it('plan_ready + job_created + stage_await_confirm + job_done 事件流 patch 消息 blocks(TC-BIO-STORE-003/004/005)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: '标准流程', stages: ['identify', 'align'] }], source: 'llm_rag', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
frame('job_created', { job_id: 'j1', session_id: 'sess-1', title: 'SPL', stages: [{ stage: 'identify', status: 'waiting' }, { stage: 'align', status: 'waiting' }], auto_continue: false }),
|
||||
frame('stage_started', { job_id: 'j1', stage: 'identify', attempt: 1 }),
|
||||
frame('stage_await_confirm', { job_id: 'j1', stage: 'align', form_defaults: [{ name: 'cpu', default: 4, source: 'contract' }] }),
|
||||
frame('job_done', { job_id: 'j1', progress: 100 }),
|
||||
];
|
||||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||||
expect(chat.messages).toHaveLength(2);
|
||||
expect(chat.messages[0].role).toBe('user');
|
||||
expect(chat.messages[0].kind).toBe('bioflow');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.kind).toBe('bioflow');
|
||||
expect(aMsg.status).toBe('done');
|
||||
const types = aMsg.blocks.map((b) => b.type);
|
||||
expect(types).toContain('bioflow_path');
|
||||
expect(types).toContain('bioflow_progress');
|
||||
expect(types).toContain('bioflow_form');
|
||||
expect(types).toContain('bioflow_archive');
|
||||
const path = aMsg.blocks.find((b) => b.type === 'bioflow_path');
|
||||
expect(path.candidates).toHaveLength(1);
|
||||
const prog = aMsg.blocks.find((b) => b.type === 'bioflow_progress');
|
||||
expect(prog.jobId).toBe('j1');
|
||||
expect(prog.stages.map((s) => s.status)).toEqual(['running', 'waiting_confirm']);
|
||||
// 用户消息落库(骨架 + 终态 patch)
|
||||
expect(sessionCalls.messages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(sessionCalls.messages[0].body.role).toBe('user');
|
||||
});
|
||||
|
||||
it('plan_ready 后心跳流断连(无任务事件):path 卡保留,消息收口 done', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
];
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('done');
|
||||
expect(aMsg.blocks.find((b) => b.type === 'bioflow_path').candidates).toHaveLength(1);
|
||||
expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false);
|
||||
});
|
||||
|
||||
it('message_done flow_qa 分支:引用块渲染(FR-BIO-026 不新建任务)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('message_done', { kind: 'flow_qa', citations: [{ source: 'flow_docs', title_path: 'identify.md', text: '输入 fasta 与 hmm' }], degraded: false }),
|
||||
];
|
||||
await chat.sendBioflow('identify 需要什么输入');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('done');
|
||||
const qa = aMsg.blocks.find((b) => b.type === 'bioflow_qa');
|
||||
expect(qa.citations).toHaveLength(1);
|
||||
expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false);
|
||||
});
|
||||
|
||||
it('错误帧(统一信封):标注 error + 可重试', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('error', { code: 'UPSTREAM_DOWN', message: '生信服务当前不可用' }),
|
||||
];
|
||||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('error');
|
||||
expect(aMsg.errText).toBe('生信服务当前不可用');
|
||||
expect(chat.lastResult.ok).toBe(false);
|
||||
expect(chat.lastResult.error.code).toBe('UPSTREAM_DOWN');
|
||||
});
|
||||
|
||||
it('网络中断:标注 disconnected;任务状态保留(快照兜底不丢进度)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
frame('job_created', { job_id: 'j1', stages: [{ stage: 'identify', status: 'waiting' }], auto_continue: false }),
|
||||
frame('stage_started', { job_id: 'j1', stage: 'identify', attempt: 1 }),
|
||||
];
|
||||
// 让 fetch 在流消费中途失败:mock 抛网络错误
|
||||
bioflowApi.bioflowChatStream.mockRejectedValueOnce(new Error('network'));
|
||||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('error');
|
||||
expect(aMsg.streamErr).toBe('disconnected');
|
||||
expect(chat.lastResult.ok).toBe(false);
|
||||
expect(chat.lastResult.error.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('retry:bioflow 失败消息同位置替换走生信路径(TC-BIO-STORE-022 语义)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [frame('error', { code: 'UPSTREAM_DOWN', message: 'x' })];
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
expect(chat.messages).toHaveLength(2);
|
||||
// 重试成功路径
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
];
|
||||
await chat.retry();
|
||||
expect(chat.messages).toHaveLength(2); // 同位置替换不新增
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('done');
|
||||
expect(aMsg.kind).toBe('bioflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('分流纪律(漏判安全)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sendBioflow 不经过 qa 流(bioflowChatStream 独立端点)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [frame('plan_ready', { candidates: [], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null })];
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
expect(bioflowApi.bioflowChatStream).toHaveBeenCalledTimes(1);
|
||||
const body = bioflowApi.bioflowChatStream.mock.calls[0][0];
|
||||
expect(body.session_id).toBe('sess-1');
|
||||
expect(body.message).toBe('跑 SPL 家族');
|
||||
expect(body).toHaveProperty('has_bioflow_context');
|
||||
});
|
||||
|
||||
it('busy 时不重复发起(并发防护)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
vi.clearAllMocks();
|
||||
chat.streamCtx['sess-1'] = { busy: true };
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,18 +100,12 @@ describe('Composer 生信分流(漏判安全)', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('bioflow 意图:走 chat.sendBioflow(用户消息 kind=bioflow + path 块)', async () => {
|
||||
chatFrames = [{
|
||||
event: 'plan_ready',
|
||||
data: { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null },
|
||||
}];
|
||||
it('生信意图:统一走 QA 流(emit send,不产生 bioflow 直连请求;S3)', async () => {
|
||||
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
|
||||
await submit(w, '帮我跑水稻 SPL 家族鉴定');
|
||||
expect(bioflowApi.bioflowChatStream).toHaveBeenCalledTimes(1);
|
||||
const chat = useChatStore();
|
||||
expect(chat.messages).toHaveLength(2);
|
||||
expect(chat.messages[0].kind).toBe('bioflow');
|
||||
expect(chat.messages[1].blocks[0].type).toBe('bioflow_path');
|
||||
expect(w.emitted('send')).toBeTruthy();
|
||||
expect(w.emitted('send')[0][0]).toContain('帮我跑水稻 SPL 家族鉴定');
|
||||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('未命中词典:走原 send 路径(不产生 bioflow 请求;TC-BIO-STORE-002)', async () => {
|
||||
|
||||
@@ -1,118 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/frontend/unit/bioflow/intent.test.js —— 生信意图分流(L3)
|
||||
// 与 docs/team/cases_bioflow_intent.md 冻结词典 40 用例表同表双跑
|
||||
// (TC-BIO-INTENT-001~047;后端 intent.py 同词典同规则,L2 侧由 T 执行同表校验)。
|
||||
// tests/frontend/unit/bioflow/intent.test.js —— 意图分流(S3:生信词典删除后等价语义)
|
||||
// S3 起生信分析意图统一走 QA 流(bioflow_agent 服务端 nl_triggers 判定),
|
||||
// 前端 route() 不再产生 bioflow/bioflow_qa——本测试锁定该等价语义:
|
||||
// 生信句 → qa;文档意图(docgen/export/edit)不变;扩展创建意图保留。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { route, extractReuseName, hasAnyWord } from '../../../../ui/src/utils/intent.js';
|
||||
import { route, hasAnyWord, isExtensionCreateIntent } from '../../../../ui/src/utils/intent.js';
|
||||
|
||||
/** 词典用例表(cases_bioflow_intent.md §4):[text, attachments, activeArtifact, bioflowContext, expected] */
|
||||
const DICT_CASES = [
|
||||
// # text 附件 活动文档 生信上下文 期望
|
||||
['帮我跑水稻 SPL 家族鉴定', false, false, false, 'bioflow'],
|
||||
['跑一下拟南芥 MADS-box 家族分析', false, false, false, 'bioflow'],
|
||||
['请帮我鉴定水稻 SPL 家族', false, false, false, 'bioflow'],
|
||||
['识别玉米 bZIP 家族成员', false, false, false, 'bioflow'],
|
||||
['帮我研究一下水稻 SPL 基因家族', false, false, false, 'bioflow'],
|
||||
['分析小麦 WRKY 转录因子家族', false, false, false, 'bioflow'],
|
||||
['做个大豆 NAC 家族分析', false, false, false, 'bioflow'],
|
||||
['跑 SPL 家族', false, false, false, 'bioflow'],
|
||||
['水稻 SPL 家族,帮我跑', false, false, false, 'bioflow'],
|
||||
['基因家族鉴定:水稻 SPL', false, false, false, 'bioflow'],
|
||||
['帮我跑水稻叶片 RNA-seq 差异表达分析', false, false, false, 'bioflow'],
|
||||
['帮我做水稻全基因组变异检测', false, false, false, 'bioflow'],
|
||||
['帮我跑水稻产量性状 GWAS 关联分析', false, false, false, 'bioflow'],
|
||||
['什么是基因家族?', false, false, false, 'qa'],
|
||||
['帮我跑一下', false, false, false, 'qa'],
|
||||
['SPL 家族', false, false, false, 'qa'],
|
||||
['今天天气不错', false, false, false, 'qa'],
|
||||
['跑 SPL 家族鉴定需要准备什么输入?', false, false, false, 'qa'], // 无生信上下文漏判安全
|
||||
['帮我跑一个数据分析', false, false, false, 'qa'],
|
||||
['你好呀', false, false, false, 'qa'],
|
||||
['', false, false, false, 'qa'], // 空字符串
|
||||
['帮我跑香蕉 XYK 家族鉴定', false, false, false, 'qa'], // XYK 不在家族词典
|
||||
['跑水稻 spl 家族鉴定', false, false, false, 'bioflow'], // 家族名大小写不敏感
|
||||
['Please run rice SPL family identification', false, false, false, 'bioflow'],
|
||||
['帮我跑(水稻 SPL 家族)鉴定!', false, false, false, 'bioflow'],
|
||||
['用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定', false, false, false, 'bioflow'], // 复用意图
|
||||
['根据 XXX 存档任务的方法帮我跑一下玉米 bZIP', false, false, false, 'bioflow'],
|
||||
['参考 XXX 存档的方法再跑一个水稻 SPL', false, false, false, 'bioflow'],
|
||||
['用《XXX》存档任务的方法帮我跑小麦 WRKY 家族', false, false, false, 'bioflow'],
|
||||
['用 XXX 存档任务的方法', false, false, false, 'qa'], // 无目标任务词
|
||||
['用 XXX 存档任务的方法帮我跑 yyy', false, false, false, 'qa'], // yyy 无家族/对象匹配
|
||||
['帮我生成一份水稻 SPL 家族分析报告', false, false, false, 'docgen'], // docgen 优先于 bioflow
|
||||
['帮我写一篇水稻基因家族研究论文', false, false, false, 'docgen'],
|
||||
['导出成 PDF', false, true, false, 'export'],
|
||||
['把这句话改成红色', false, true, false, 'edit'], // 无图表词
|
||||
['xxx 图表颜色换成红色', false, true, false, 'bioflow'], // 图表词走 FR-BIO-019
|
||||
['identify 需要什么输入', false, false, true, 'bioflow_qa'], // 不新建任务
|
||||
['帮我总结一下这个问题的要点', false, false, false, 'qa'],
|
||||
['写一段水稻基因的介绍', false, false, false, 'qa'], // 介绍在 docgen 排除表
|
||||
['跑水稻 SPL 家族鉴定', false, true, false, 'bioflow'], // 活动文档不改判定
|
||||
];
|
||||
|
||||
describe('intent route —— 词典 40 用例同表双跑', () => {
|
||||
DICT_CASES.forEach(([text, att, artifact, ctx, expected], i) => {
|
||||
const label = text ? text.slice(0, 24) : '(空字符串)';
|
||||
it(`#${i + 1} 「${label}」 → ${expected}`, () => {
|
||||
const { intent } = route(text, att, artifact, ctx);
|
||||
expect(intent).toBe(expected);
|
||||
});
|
||||
describe('route —— S3 生信意图一律 qa(漏判安全)', () => {
|
||||
it('生信分析句 → qa(不再产生 bioflow 分流)', () => {
|
||||
for (const t of [
|
||||
'帮我跑水稻 SPL 家族鉴定',
|
||||
'跑一下拟南芥 MADS-box 家族分析',
|
||||
'帮我跑水稻叶片 RNA-seq 差异表达分析',
|
||||
'帮我做水稻全基因组变异检测',
|
||||
'识别玉米 bZIP 家族成员',
|
||||
'用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定',
|
||||
'xxx 图表颜色换成红色',
|
||||
]) {
|
||||
expect(route(t, false, false, false).intent).toBe('qa');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('intent route —— 边界与实现注意(词典 §5)', () => {
|
||||
it('文本长度 ≤1 或纯空白 → qa', () => {
|
||||
it('生信流程咨询句 → qa(bioflow_qa 已删除)', () => {
|
||||
expect(route('identify 需要什么输入', false, false, true).intent).toBe('qa');
|
||||
});
|
||||
|
||||
it('文档意图 docgen 不变', () => {
|
||||
expect(route('帮我写一份水稻分析报告').intent).toBe('docgen');
|
||||
expect(route('帮我整理一下', true, false, false).intent).toBe('docgen');
|
||||
});
|
||||
|
||||
it('导出/编辑需活动文档才生效(docgen 语义不变)', () => {
|
||||
expect(route('导出成 PDF', false, true, false).intent).toBe('export');
|
||||
expect(route('导出成 PDF', false, false, false).intent).toBe('qa');
|
||||
expect(route('把这句话改成红色', false, true, false).intent).toBe('edit');
|
||||
});
|
||||
|
||||
it('边界:长度 ≤1/空/纯空白 → qa', () => {
|
||||
expect(route('a').intent).toBe('qa');
|
||||
expect(route(' ').intent).toBe('qa');
|
||||
expect(route(null).intent).toBe('qa');
|
||||
expect(route(undefined).intent).toBe('qa');
|
||||
});
|
||||
it('>2000 字符按截断前 2000 判定(长前缀不淹没意图)', () => {
|
||||
const long = '啊'.repeat(3000) + ' 帮我跑水稻 SPL 家族鉴定';
|
||||
expect(route(long).intent).toBe('qa'); // 意图词在 2000 之后被截掉
|
||||
const short = '帮我跑水稻 SPL 家族鉴定' + '啊'.repeat(3000);
|
||||
expect(route(short).intent).toBe('bioflow');
|
||||
});
|
||||
it('bioflow_qa 仅在 has_bioflow_context=true 生效', () => {
|
||||
expect(route('identify 需要什么输入', false, false, true).intent).toBe('bioflow_qa');
|
||||
expect(route('identify 需要什么输入', false, false, false).intent).toBe('qa');
|
||||
});
|
||||
it('家族名后须跟语境词或位于句尾(SPL 会议不命中)', () => {
|
||||
expect(route('跑 SPL 会议').intent).toBe('qa');
|
||||
expect(route('帮我跑玉米 bZIP').intent).toBe('bioflow'); // 句尾命中
|
||||
});
|
||||
it('「做」动词需对象组合(分析对象词命中)', () => {
|
||||
expect(route('帮我做水稻全基因组变异检测').intent).toBe('bioflow');
|
||||
expect(route('帮我做个分析').intent).toBe('qa'); // 无对象词
|
||||
});
|
||||
it('附件 + 写类动词 → docgen(兜底信号,词典规则 4)', () => {
|
||||
expect(route('帮我整理一下', true, false, false).intent).toBe('docgen');
|
||||
});
|
||||
it('导出需 has_active_artifact 才生效', () => {
|
||||
expect(route('导出成 PDF', false, false, false).intent).toBe('qa');
|
||||
});
|
||||
it('命中句不打乱后续 QA 判定(无状态残留)', () => {
|
||||
expect(route('什么是 SPL 家族').intent).toBe('qa');
|
||||
expect(route('帮我跑水稻 SPL 家族鉴定').intent).toBe('bioflow');
|
||||
expect(route('什么是 SPL 家族').intent).toBe('qa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractReuseName —— 复用方法名提取(词典实现注意 2)', () => {
|
||||
it('标准句式:方法名 = REUSE_PHRASE 之前最近的 3-20 字符片段', () => {
|
||||
expect(extractReuseName('用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定')).toBe('XXX');
|
||||
expect(extractReuseName('根据 XXX 存档任务的方法帮我跑一下玉米 bZIP')).toBe('XXX');
|
||||
expect(extractReuseName('参考 XXX 存档的方法再跑一个水稻 SPL')).toBe('XXX');
|
||||
expect(extractReuseName('用《XXX》存档任务的方法帮我跑小麦 WRKY 家族')).toBe('XXX');
|
||||
describe('isExtensionCreateIntent —— 扩展创建意图(S4 保留)', () => {
|
||||
it('创建/上传 扩展句式命中', () => {
|
||||
expect(isExtensionCreateIntent('帮我创建一个分析扩展')).toBe(true);
|
||||
expect(isExtensionCreateIntent('上传一个自定义分析工具')).toBe(true);
|
||||
});
|
||||
it('非复用句式返回 null', () => {
|
||||
expect(extractReuseName('帮我跑水稻 SPL 家族鉴定')).toBeNull();
|
||||
expect(extractReuseName('')).toBeNull();
|
||||
});
|
||||
it('片段长度不足 3 或超 20 返回 null', () => {
|
||||
expect(extractReuseName('用 AB 存档任务的方法帮我跑水稻 SPL 家族鉴定')).toBeNull();
|
||||
// 21 字符片段超上限
|
||||
expect(extractReuseName('用 一二三四五六七八九十一二三四五六七八九十一 存档任务的方法帮我跑水稻 SPL')).toBeNull();
|
||||
it('非扩展句不命中', () => {
|
||||
expect(isExtensionCreateIntent('帮我跑水稻 SPL 家族鉴定')).toBe(false);
|
||||
expect(isExtensionCreateIntent('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/frontend/unit/bioflow/legacy.block.test.js —— S3 旧生信块只读降级(LegacyBioflowBlock)
|
||||
// 覆盖:四类旧块标题映射 / bioflow_qa 引用列表(含空态)/ 任务类块 jobId 前缀 / 只读(无可交互控件)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import LegacyBioflowBlock from '../../../../ui/src/components/chat/LegacyBioflowBlock.vue';
|
||||
|
||||
describe('LegacyBioflowBlock —— 旧生信块只读降级(S3)', () => {
|
||||
it('bioflow_qa 渲染只读引用列表(title + citations)', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: {
|
||||
block: {
|
||||
type: 'bioflow_qa',
|
||||
citations: [{ text: '引用正文', source: 's1', title_path: 't1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(w.find('.legacy-bioflow-block').exists()).toBe(true);
|
||||
expect(w.findAll('.lbf-cite')).toHaveLength(1);
|
||||
expect(w.text()).toContain('引用正文');
|
||||
});
|
||||
|
||||
it('bioflow_qa 空引用显示空态文案', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: { block: { type: 'bioflow_qa', citations: [] } },
|
||||
});
|
||||
expect(w.find('.lbf-empty').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('任务类块(bioflow_progress)显示 jobId 前缀 + 只读提示', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: { block: { type: 'bioflow_progress', jobId: 'job-9' } },
|
||||
});
|
||||
expect(w.find('.lbf-job').exists()).toBe(true);
|
||||
expect(w.text()).toContain('job-9');
|
||||
expect(w.find('.lbf-empty').exists()).toBe(true); // 只读提示
|
||||
});
|
||||
|
||||
it('各类旧块标题映射非空(path/form/file/archive/progress)', () => {
|
||||
for (const type of ['bioflow_path', 'bioflow_form', 'bioflow_file', 'bioflow_archive', 'bioflow_progress']) {
|
||||
const w = mount(LegacyBioflowBlock, { props: { block: { type } } });
|
||||
expect(w.find('.lbf-title').text()).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('未知类型回退 legacyBlockTitle 不抛错', () => {
|
||||
const w = mount(LegacyBioflowBlock, { props: { block: { type: 'bioflow_unknown' } } });
|
||||
expect(w.find('.lbf-title').text()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('只读:不渲染 button/input/a 等可交互控件(旧块 hydrate 不报错)', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: { block: { type: 'bioflow_progress', jobId: 'j1', citations: [] } },
|
||||
});
|
||||
expect(w.find('button').exists()).toBe(false);
|
||||
expect(w.find('input').exists()).toBe(false);
|
||||
expect(w.find('a').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -331,3 +331,27 @@ describe('BioArchivePane —— TC-BIO-PANE-001~016(L4)', () => {
|
||||
expect(sizes[0].text()).toContain('KB');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// S3:BioJobsPane 日志自动滚底(实时跟随最新一行)
|
||||
// ===========================================================================
|
||||
describe('BioJobsPane 日志自动滚底(S3)', () => {
|
||||
it('展开日志后追加行 → scrollTop 置为 scrollHeight(保持在最新一行)', async () => {
|
||||
const w = mount(BioJobsPane);
|
||||
await flushPromises();
|
||||
await w.findAll('.bj-item')[0].trigger('click');
|
||||
await flushPromises();
|
||||
await w.findAll('.bj-dag-row')[0].trigger('click');
|
||||
await flushPromises();
|
||||
const pre = w.find('.bj-log-body').element;
|
||||
// jsdom 不实现 scrollHeight(恒 0),此处 mock 为确定值以验证滚底赋值
|
||||
Object.defineProperty(pre, 'scrollHeight', { configurable: true, get: () => 800 });
|
||||
// 追加一行日志(轮询增量)→ watch 触发 → nextTick 滚底
|
||||
const bioflow = useBioflowStore();
|
||||
bioflow.stageLogs['job-1:identify'].lines.push('appended-log-line');
|
||||
await flushPromises();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // 放行 nextTick 链
|
||||
expect(pre.scrollTop).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/frontend/unit/bioflow/settings.test.js —— S5 生信能力装卸(settings store)
|
||||
// 覆盖:isInstalled 判定 / toggleInstalled register↔unregister / 未知能力抛错 / 装卸后重拉清单。
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
vi.mock('../../../../ui/src/api/capabilities.js', () => ({
|
||||
installCapability: vi.fn(async () => ({ ok: true })),
|
||||
BIOFLOW_MANIFESTS: {
|
||||
'bioflow-gene-family': 'capabilities:\n - name: bioflow-gene-family',
|
||||
'bioflow-rnaseq': 'capabilities:\n - name: bioflow-rnaseq',
|
||||
'bioflow-extension': 'capabilities:\n - name: bioflow-extension',
|
||||
},
|
||||
getCapabilities: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../ui/src/api/http.js', () => ({
|
||||
get: vi.fn(async () => ({ capabilities: [], defaults: {}, installed: [], load_warnings: [] })),
|
||||
post: vi.fn(async () => ({})),
|
||||
patch: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
import { useSettingsStore } from '../../../../ui/src/stores/settings.js';
|
||||
import * as capabilitiesApi from '../../../../ui/src/api/capabilities.js';
|
||||
|
||||
function setup() {
|
||||
setActivePinia(createPinia());
|
||||
return useSettingsStore();
|
||||
}
|
||||
|
||||
describe('SettingsStore —— 生信能力装卸(S5)', () => {
|
||||
beforeEach(() => {
|
||||
capabilitiesApi.installCapability.mockClear();
|
||||
});
|
||||
|
||||
it('isInstalled:installed 数组含该能力名 → true,否则 false', () => {
|
||||
const s = setup();
|
||||
s.installed = [{ name: 'bioflow-gene-family' }];
|
||||
expect(s.isInstalled('bioflow-gene-family')).toBe(true);
|
||||
expect(s.isInstalled('bioflow-rnaseq')).toBe(false);
|
||||
expect(s.isInstalled('bioflow-extension')).toBe(false);
|
||||
});
|
||||
|
||||
it('toggleInstalled:未安装 → register;已安装 → unregister', async () => {
|
||||
const s = setup();
|
||||
s.installed = [];
|
||||
await s.toggleInstalled('bioflow-gene-family');
|
||||
expect(capabilitiesApi.installCapability).toHaveBeenLastCalledWith(
|
||||
'register', expect.any(String), 'bioflow-gene-family',
|
||||
);
|
||||
|
||||
s.installed = [{ name: 'bioflow-gene-family' }];
|
||||
await s.toggleInstalled('bioflow-gene-family');
|
||||
expect(capabilitiesApi.installCapability).toHaveBeenLastCalledWith(
|
||||
'unregister', '', 'bioflow-gene-family',
|
||||
);
|
||||
});
|
||||
|
||||
it('toggleInstalled:未知生信能力抛错', async () => {
|
||||
const s = setup();
|
||||
await expect(s.toggleInstalled('bogus')).rejects.toThrow('未知生信能力');
|
||||
});
|
||||
|
||||
it('toggleInstalled:装卸后重拉清单刷新 installed(load 被调用)', async () => {
|
||||
const s = setup();
|
||||
s.installed = [];
|
||||
const loadSpy = vi.spyOn(s, 'load').mockResolvedValue(undefined);
|
||||
await s.toggleInstalled('bioflow-rnaseq');
|
||||
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// api/capabilities.js —— 能力清单端点(S4 BFF;GET /api/capabilities)+ 装卸接口(S5)。
|
||||
// 生信 TAB 显隐的主信号源:installed 数组含 bioflow-* 能力即视为「生信能力已安装」。
|
||||
import { get, post } from './http.js';
|
||||
|
||||
/** GET /api/capabilities —— 能力清单 + 默认值 + 当前会话开关 + QA 已装插件 */
|
||||
export function getCapabilities(params = {}, opts = {}) {
|
||||
return get('/api/capabilities', params, { timeout: 5000, ...opts });
|
||||
}
|
||||
|
||||
/** POST /api/capabilities/install —— 按能力 register/unregister(透传 QA,热加载不重启) */
|
||||
export function installCapability(action, manifest, name) {
|
||||
return post('/api/capabilities/install', { action, manifest, name });
|
||||
}
|
||||
|
||||
// 三个生信能力的 register manifest(与 agent/qa/configs/capabilities.yaml 一致,热装卸用)。
|
||||
export const BIOFLOW_MANIFESTS = {
|
||||
'bioflow-gene-family': [
|
||||
'capabilities:',
|
||||
' - name: bioflow-gene-family',
|
||||
' provider: mcp',
|
||||
' permission: read_only',
|
||||
' description: 基因家族分析(路径规划/建任务/参数确认/文件获取与预处理/状态查询),自然语言触发',
|
||||
' session_default: true',
|
||||
' nl_triggers: [基因家族, 家族成员, 亚家族, 系统发育, 建树, 进化树, 保守基序, motif, 结构域, 基因结构, 共线性, Ka/Ks, 选择压力, 组合证据]',
|
||||
' mcp:',
|
||||
' url: http://127.0.0.1:8098/mcp/gene-family/',
|
||||
' prompt_pack:',
|
||||
' file: bioflow_gene_family_agent_rules.md',
|
||||
].join('\n'),
|
||||
'bioflow-rnaseq': [
|
||||
'capabilities:',
|
||||
' - name: bioflow-rnaseq',
|
||||
' provider: mcp',
|
||||
' permission: read_only',
|
||||
' description: 转录组分析(实验设计确认/样本配置/路径规划/建任务/差异表达/富集),自然语言触发',
|
||||
' session_default: true',
|
||||
' nl_triggers: [转录组, RNA-seq, RNAseq, rnaseq, 差异表达, 表达定量, 富集, WGCNA, GSEA, 实验设计, fastq]',
|
||||
' mcp:',
|
||||
' url: http://127.0.0.1:8098/mcp/rnaseq/',
|
||||
' prompt_pack:',
|
||||
' file: bioflow_rnaseq_agent_rules.md',
|
||||
].join('\n'),
|
||||
'bioflow-extension': [
|
||||
'capabilities:',
|
||||
' - name: bioflow-extension',
|
||||
' provider: mcp',
|
||||
' permission: read_only',
|
||||
' description: 用户自定义扩展分析(创建/转换/试运行/校验/发布/运行),自然语言触发',
|
||||
' session_default: true',
|
||||
' nl_triggers: [扩展, 拓展分析, 扩展分析, 自定义分析, 分析模板, 分析脚本, 分析工具]',
|
||||
' mcp:',
|
||||
' url: http://127.0.0.1:8098/mcp/extension/',
|
||||
' prompt_pack:',
|
||||
' file: bioflow_extension_agent_rules.md',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
// 生信能力名(与 BIOFLOW_MANIFESTS 键一致;SettingsPanel 三开关与 TAB 显隐共用)
|
||||
export const BIOFLOW_CAPABILITY_NAMES = Object.keys(BIOFLOW_MANIFESTS);
|
||||
@@ -164,6 +164,16 @@ function closeStageLog() {
|
||||
logStage.value = null;
|
||||
}
|
||||
|
||||
// 实时跟随:日志行追加(轮询增量)时自动滚底,保持在最新一行。
|
||||
watch(
|
||||
() => (logRec.value ? logRec.value.lines.length : 0),
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 面板定位消费(聊天进度条点击 → scrollIntoView + hl-fade + 展开 log) ----
|
||||
const hlStage = ref(null);
|
||||
function consumeLocate(payload) {
|
||||
|
||||
@@ -17,6 +17,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"
|
||||
/>
|
||||
|
||||
<!-- P2 生信:流程知识库引用块(FR-BIO-026;引用携带 cite 供跳转) -->
|
||||
<div v-if="b.type === 'bioflow_qa'" class="bioflow-qa-block">
|
||||
<div v-for="(c, ci) in b.citations" :key="ci" class="bf-qa-cite">
|
||||
<span class="bf-qa-idx">{{ ci + 1 }}</span>
|
||||
<span class="bf-qa-text">{{ c.text || c.title_path || c.source }}</span>
|
||||
<span class="bf-qa-src">{{ c.title_path || c.source }}</span>
|
||||
</div>
|
||||
<div v-if="!b.citations || !b.citations.length" class="bf-qa-empty">{{ BF.flowQaEmpty }}</div>
|
||||
</div>
|
||||
<!-- P2 生信:流程知识库引用块(FR-BIO-026)→ S3 降级为只读 LegacyBioflowBlock -->
|
||||
<LegacyBioflowBlock v-if="b.type === 'bioflow_qa'" :block="b" />
|
||||
|
||||
<!-- 用户自定义扩展分析:上传引导卡(extension_upload) -->
|
||||
<ExtensionUploadCard
|
||||
|
||||
@@ -17,7 +17,7 @@ import { MODES as MODE_LABELS, T } from '../../i18n/labels.js';
|
||||
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { route } from '../../intent.js';
|
||||
import { route as routeBioflow, isExtensionCreateIntent } from '../../utils/intent.js';
|
||||
import { isExtensionCreateIntent } from '../../utils/intent.js';
|
||||
import { uploadLibrary, listLibrary } from '../../api/docgen.js';
|
||||
import { uploadBioflowFile } from '../../api/bioflow.js';
|
||||
import { useDocgenStore } from '../../stores/docgen.js';
|
||||
@@ -235,20 +235,13 @@ const submit = () => {
|
||||
} catch {
|
||||
intent = 'qa'; // 路由异常按漏判安全处理
|
||||
}
|
||||
// 生信意图(含图表调整走生信 FR-BIO-019):词典同表双跑(utils/intent.js)
|
||||
let bioflowRoute = { intent: 'qa', reuseName: null };
|
||||
if (intent !== 'docgen' && intent !== 'edit' && intent !== 'export' && bioflowEnabled.value) {
|
||||
try {
|
||||
bioflowRoute = routeBioflow(text, hasAttachments.value, hasActiveArtifact.value, hasBioflowContext.value);
|
||||
} catch {
|
||||
bioflowRoute = { intent: 'qa', reuseName: null };
|
||||
}
|
||||
}
|
||||
// 用户自定义扩展分析:A 层创建意图(EXTENSION_CONTRACT.md §5 词典同表双跑;
|
||||
// 「创建/上传 一个扩展」→ sendExtension 上传引导卡。歧义消解与运行触发由
|
||||
// 服务端 intent.py 在 chat/stream 内判定——前端只做确定性创建识别,漏判安全。)
|
||||
// 生信意图(基因家族/转录组)S3 起不再走前端词典分流:一律进 QA 流,
|
||||
// 由 bioflow_agent 在服务端经 nl_triggers 判定(统一由 QA 流承载)。
|
||||
let extIntent = false;
|
||||
if (bioflowRoute.intent !== 'bioflow_qa' && bioflowRoute.intent !== 'bioflow' && bioflowEnabled.value) {
|
||||
if (bioflowEnabled.value) {
|
||||
try {
|
||||
extIntent = isExtensionCreateIntent(text);
|
||||
} catch {
|
||||
@@ -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 } 传入(空串/未选 = 自动推荐)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 复用 FlowFormCard 视觉令牌(字段行 label 96px / input 同款 / 主按钮 gradient)。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { buildExtension } from '../../api/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -13,6 +13,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['submitted', 'error']);
|
||||
|
||||
const chat = useChatStore();
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitError = ref('');
|
||||
|
||||
@@ -43,27 +45,25 @@ const hasDeps = computed(() =>
|
||||
(Array.isArray(reqs.value.pip) && reqs.value.pip.length > 0)
|
||||
|| (Array.isArray(reqs.value.apt) && reqs.value.apt.length > 0));
|
||||
|
||||
/** 提交修正:POST build 带修正值(后端按 ext_id 重入转换) */
|
||||
/** 提交(S4 改走 card_result 进 QA 流):
|
||||
* - skip=true(跳过/采纳 AI 结果)→ 重触发 AI 转换(build)
|
||||
* - skip=false(编辑修正)→ confirm 语义(复用 confirm_extension 编辑值入参) */
|
||||
async function submit({ skip = false } = {}) {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
submitError.value = '';
|
||||
try {
|
||||
const body = {};
|
||||
if (!skip) {
|
||||
if (skip) {
|
||||
await chat.sendCardResult('extension_action', { action: 'build', ext_id: props.block.extId });
|
||||
} else {
|
||||
if (!templateDraft.value.trim()) { submitError.value = BF.extBuildFieldRequired; submitting.value = false; return; }
|
||||
body.command_template = templateDraft.value.trim();
|
||||
body.runtime_image = imageDraft.value.trim() || undefined;
|
||||
body.nl_triggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
body.analysis_type = typeDraft.value.trim() || undefined;
|
||||
}
|
||||
await buildExtension(props.block.extId, body);
|
||||
// 提交成功以本地修正为准(后端重入结果由 extension_build 帧再次刷新)
|
||||
if (!skip) {
|
||||
props.block.commandTemplate = templateDraft.value.trim();
|
||||
props.block.runtimeImage = imageDraft.value.trim() || props.block.runtimeImage;
|
||||
props.block.nlTriggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
props.block.analysisType = typeDraft.value.trim() || props.block.analysisType;
|
||||
await chat.sendCardResult('extension_action', {
|
||||
action: 'confirm',
|
||||
ext_id: props.block.extId,
|
||||
nl_triggers: triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean),
|
||||
parameters: props.block.parameters || [],
|
||||
runtime_image: imageDraft.value.trim(),
|
||||
});
|
||||
}
|
||||
props.block.submitted = true;
|
||||
emit('submitted', { extId: props.block.extId, skip });
|
||||
|
||||
@@ -47,10 +47,10 @@ async function run() {
|
||||
else if (f.type === 'float' || f.type === 'number') params[f.name] = parseFloat(v);
|
||||
else params[f.name] = String(v);
|
||||
}
|
||||
const result = await chat.confirmExtensionRun(props.block, {
|
||||
const result = await chat.sendCardResult('extension_action', {
|
||||
action: 'run',
|
||||
ext_id: props.block.extId,
|
||||
params,
|
||||
files: [],
|
||||
aMsg: props.message || null,
|
||||
});
|
||||
emit('submitted', { extId: props.block.extId, result });
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 发布成功后 extension_published 帧挂发布确认块(本卡随试运行卡同帧刷新)。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { publishExtension } from '../../api/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['published', 'error']);
|
||||
|
||||
const chat = useChatStore();
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitError = ref('');
|
||||
const triggersDraft = ref('');
|
||||
@@ -29,11 +31,12 @@ async function publish() {
|
||||
submitError.value = '';
|
||||
try {
|
||||
const nlTriggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
const data = await publishExtension(props.block.extId, { nl_triggers: nlTriggers });
|
||||
const result = (data && data.data) || data || {};
|
||||
if (Array.isArray(result.nl_triggers)) props.block.nlTriggers = result.nl_triggers;
|
||||
else props.block.nlTriggers = nlTriggers;
|
||||
if (result.display_name) props.block.displayName = result.display_name;
|
||||
await chat.sendCardResult('extension_action', {
|
||||
action: 'publish',
|
||||
ext_id: props.block.extId,
|
||||
nl_triggers: nlTriggers,
|
||||
});
|
||||
props.block.nlTriggers = nlTriggers;
|
||||
props.block.published = true;
|
||||
emit('published', { extId: props.block.extId, nlTriggers: props.block.nlTriggers });
|
||||
} catch (err) {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
import { repairExtension } from '../../api/bioflow.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -36,17 +35,17 @@ function onGoldenChange(e) {
|
||||
if (e.target) e.target.value = '';
|
||||
}
|
||||
|
||||
/** 上传测试输入 → 触发试运行(服务端跑容器,结果经 extension_trial 帧回流) */
|
||||
/** 试运行(S4 改走 card_result 进 QA 流;测试输入文件上传为后置上传,另行登记) */
|
||||
async function runTrial() {
|
||||
if (running.value || repairing.value) return;
|
||||
if (!props.block.extId) { submitError.value = BF.extTrialNoExtId; return; }
|
||||
if (!testInput.value) { submitError.value = BF.extTrialInputEmpty; return; }
|
||||
running.value = true;
|
||||
submitError.value = '';
|
||||
try {
|
||||
const files = [testInput.value];
|
||||
if (goldenInput.value) files.push(goldenInput.value);
|
||||
const result = await chat.trialRunExtension(props.block.extId, files);
|
||||
const result = await chat.sendCardResult('extension_action', {
|
||||
action: 'trial_run',
|
||||
ext_id: props.block.extId,
|
||||
});
|
||||
emit('trialled', { extId: props.block.extId, result });
|
||||
} catch (err) {
|
||||
submitError.value = err?.message || BF.extUploadError;
|
||||
@@ -63,7 +62,7 @@ async function doRepair() {
|
||||
repairing.value = true;
|
||||
submitError.value = '';
|
||||
try {
|
||||
await repairExtension(props.block.extId, {});
|
||||
await chat.sendCardResult('extension_action', { action: 'repair', ext_id: props.block.extId });
|
||||
} catch (err) {
|
||||
submitError.value = err?.message || BF.extTrialRepairFailed;
|
||||
emit('error', err);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 复用 FlowFormCard 字段行令牌。确认成功后由服务端 extension_published 帧收尾。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { buildExtension } from '../../api/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['submitted', 'error']);
|
||||
|
||||
const chat = useChatStore();
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitError = ref('');
|
||||
const values = ref({});
|
||||
@@ -37,7 +39,7 @@ const goldenOk = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
/** 确认:重新 build(门禁纪律:确认前不推进生命周期;修正值随 build 重入)。 */
|
||||
/** 确认(S4 改走 card_result):编辑修正(form_defaults 参数)→ confirm 语义。 */
|
||||
async function confirm() {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
@@ -49,7 +51,13 @@ async function confirm() {
|
||||
if (v === '' || v === null || v === undefined) continue;
|
||||
params[f.name] = v;
|
||||
}
|
||||
await buildExtension(props.block.extId, { params });
|
||||
// 编辑修正走 confirm 环节(复用 confirm_extension 编辑值入参)
|
||||
await chat.sendCardResult('extension_action', {
|
||||
action: 'confirm',
|
||||
ext_id: props.block.extId,
|
||||
parameters: props.block.parameters || [],
|
||||
runtime_image: '',
|
||||
});
|
||||
props.block.confirmed = true;
|
||||
emit('submitted', { extId: props.block.extId, params });
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -15,6 +16,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['dismissed', 'archived', 'error']);
|
||||
|
||||
const bioflow = useBioflowStore();
|
||||
const chat = useChatStore();
|
||||
|
||||
const nameDraft = ref(props.block.name || '');
|
||||
const descDraft = ref(props.block.description || '');
|
||||
@@ -50,6 +52,12 @@ async function doArchive() {
|
||||
// 存档状态回复(FR-BIO-021):名称/位置/大小三要素写回块状态
|
||||
props.block.status = 'done';
|
||||
props.block.result = result;
|
||||
// S3:同步发 card_result 进 QA 流,让 BioflowAgent 自然语言复述存档决定(语义一致)
|
||||
chat.sendCardResult('archive_choice', {
|
||||
job_id: props.block.jobId,
|
||||
archive: true,
|
||||
name: props.block.name,
|
||||
}).catch(() => { /* 复述失败不影响已完成的存档 */ });
|
||||
emit('archived', result);
|
||||
} catch (err) {
|
||||
errorText.value = err?.message || BF.archiveError;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -18,6 +19,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['submitted', 'error']);
|
||||
|
||||
const bioflow = useBioflowStore();
|
||||
const chat = useChatStore();
|
||||
|
||||
const fields = computed(() => props.block.fields || []);
|
||||
const stage = computed(() => props.block.stage || '');
|
||||
@@ -153,7 +155,8 @@ async function submit() {
|
||||
} else {
|
||||
const jobId = bioflow.currentJob.jobId || props.block.jobId;
|
||||
if (!jobId) throw new Error('no job');
|
||||
await bioflow.confirmStage(stage.value, params);
|
||||
// S3:卡片不直连控制面——参数确认改为 card_result 消息进 QA 流(BioflowAgent 经 confirm_stage 执行)
|
||||
await chat.sendCardResult('form_confirm', { job_id: jobId, stage: stage.value, params });
|
||||
props.block.submitted = true;
|
||||
emit('submitted', { stage: stage.value, params });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// 复用句式(FR-BIO-023):候选来自存档装载,卡片头部明示「复用存档 XXX 的方法」。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useBioflowStore, inferBioflowChain } from '../../stores/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -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);
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
<script setup>
|
||||
// chat/FlowProgressCard.vue —— 生信阶段进度卡(FR-BIO-011/012/013/014,UI_SPEC §8.4)
|
||||
// 结构/状态点/连接线/相位色复用 §4.4 pipeline-card 全部令牌(PipelineCard 零改动,
|
||||
// 本卡按 §8.4 同款视觉独立实现):
|
||||
// done=--pa-t-done 底白 ✓;running=相位色底 ● + pulse;waiting=surface-3 底 ◌
|
||||
// 新增两态与附加区(§8.4):
|
||||
// failed 态:--pa-error 底 ✕,状态文字 error 色 12px/600
|
||||
// skipped 态:--pa-ink-subtle 底 ◌,状态文字 ink-subtle
|
||||
// 日志尾区:仅 running/failed 展开:--pa-code-bg 底,mono 11px,padding 8px 10px,
|
||||
// max-height 96px 滚动,随 SSE 追加
|
||||
// 三选一按钮:仅 failed:重跑/跳过/改参数重跑,pane-btn 同款 12px,gap 8px
|
||||
// 折叠:阶段 >10 自动分组折叠(组头 12px/600 + 展开箭头),默认展开运行中所在组
|
||||
// 断线回放(FR-BIO-014):block.stages 被快照整体替换(引用变化)时本卡自动整体刷新。
|
||||
// chat/FlowProgressCard.vue —— 生信阶段进度卡(S3 精简为只读)
|
||||
// 只读展示:阶段列表(状态点三态)+ 进度条 + 最近一行日志尾 + 折叠分组。
|
||||
// S3 起去掉暂停/恢复、失败重跑/跳过、改参数重跑等直连控制按钮与 locate 点击——
|
||||
// 任务控制一律走自然语言(QA 流),本卡不直连生信控制面。
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { BF, stageStatusText } from '../../i18n/bioflow.js';
|
||||
import { PHASE_COLOR_VAR } from '../../i18n/labels.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { bioflowCatalog } from '../../api/bioflow.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['rerun', 'skip', 'rerun-with-params', 'pause', 'resume', 'action-error', 'locate']);
|
||||
|
||||
const bioflow = useBioflowStore();
|
||||
|
||||
const stages = computed(() => props.block.stages || []);
|
||||
const jobStatus = computed(() => props.block.jobStatus || bioflow.currentJob.jobStatus);
|
||||
const acting = ref(''); // 'pause' | 'resume' | `rerun:${stage}` | `skip:${stage}`
|
||||
const actionError = ref('');
|
||||
const rerunFormStage = ref(null); // 改参数重跑弹出该阶段参数表单(FR-BIO-012)
|
||||
const jobStatus = computed(() => props.block.jobStatus);
|
||||
|
||||
// 相位色映射(与 PipelineCard PHASE_COLOR_VAR 一致;阶段序号对应五相位循环)
|
||||
function phaseVar(i) {
|
||||
@@ -37,7 +20,6 @@ function phaseVar(i) {
|
||||
return PHASE_COLOR_VAR[keys[i % keys.length]] || PHASE_COLOR_VAR.planning;
|
||||
}
|
||||
|
||||
/** 状态点符号与颜色(三重编码:颜色 + 图标 + 文字) */
|
||||
function dotClass(st) {
|
||||
return {
|
||||
done: 'done',
|
||||
@@ -54,20 +36,13 @@ function dotSymbol(st) {
|
||||
}
|
||||
|
||||
const showLog = (st) => st.status === 'running' || st.status === 'failed';
|
||||
const showActions = (st) => st.status === 'failed';
|
||||
|
||||
// ---- 进度条(进度帧 stage_progress 驱动;点击 → 定位到生信任务 TAB) ----
|
||||
/** 阶段是否有确定性进度(白名单 rnaseq 阶段才有 percent) */
|
||||
function hasPct(st) {
|
||||
return typeof st.progress === 'number' && st.progress >= 0 && st.progress <= 100;
|
||||
}
|
||||
/** 阶段是否处于真运行态(收到过 stage_started;queued 无事件不显示条) */
|
||||
function isLiveRunning(st) {
|
||||
return st.status === 'running';
|
||||
}
|
||||
function locateStage(st) {
|
||||
emit('locate', { jobId: props.block.jobId, stage: st.stage });
|
||||
}
|
||||
|
||||
// ---- 日志尾区自动滚底(SSE 追加,2s 节流) ----
|
||||
const logRefs = ref({});
|
||||
@@ -91,13 +66,12 @@ watch(
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// ---- 折叠(FR-BIO-013:阶段 >10 自动分组折叠;默认展开运行中所在组) ----
|
||||
// ---- 折叠(阶段 >10 自动分组折叠;默认展开运行中所在组) ----
|
||||
const FOLD_THRESHOLD = 10;
|
||||
const COLLAPSED_GROUP_SIZE = 8;
|
||||
const needsFold = computed(() => stages.value.length > FOLD_THRESHOLD);
|
||||
const openGroups = ref(new Set());
|
||||
|
||||
/** 折叠分组:每组 8 阶段,全部可折叠;默认仅展开运行中所在组(其余折叠,组头可展开) */
|
||||
const groups = computed(() => {
|
||||
const all = stages.value;
|
||||
if (!needsFold.value) return [{ title: null, stages: all, collapsible: false }];
|
||||
@@ -118,7 +92,6 @@ const groups = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
/** 组展开状态:快照整体替换(引用变化)时按运行中所在组重算默认展开 */
|
||||
watch(
|
||||
() => props.block.stages,
|
||||
() => {
|
||||
@@ -144,120 +117,6 @@ function toggleGroup(i) {
|
||||
function isGroupOpen(i) {
|
||||
return openGroups.value.has(i);
|
||||
}
|
||||
|
||||
// ---- 失败三选一(FR-BIO-012) ----
|
||||
async function doRerun(st) {
|
||||
if (acting.value) return;
|
||||
acting.value = `rerun:${st.stage}`;
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.rerunStage(st.stage, null);
|
||||
emit('rerun', st.stage);
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
async function doSkip(st) {
|
||||
if (acting.value) return;
|
||||
acting.value = `skip:${st.stage}`;
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.skipStage(st.stage);
|
||||
emit('skip', st.stage);
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
function doRerunWithParams(st) {
|
||||
rerunFormStage.value = st.stage;
|
||||
// 拉取该阶段目录参数 schema(来源标注=阶段契约文档),表单值空提交即按默认重跑
|
||||
loadRerunSchema(st.stage);
|
||||
}
|
||||
function closeRerunForm() {
|
||||
rerunFormStage.value = null;
|
||||
}
|
||||
|
||||
// ---- 改参数重跑表单(目录 schema 驱动;提交 rerun 带参数) ----
|
||||
const rerunSchema = ref([]);
|
||||
const rerunValues = ref({});
|
||||
const rerunLoading = ref(false);
|
||||
const rerunSubmitting = ref(false);
|
||||
|
||||
async function loadRerunSchema(stage) {
|
||||
rerunSchema.value = [];
|
||||
rerunValues.value = {};
|
||||
rerunLoading.value = true;
|
||||
try {
|
||||
const data = await bioflowCatalog({ stage });
|
||||
const entry = (data && data.data && data.data.stage) || {};
|
||||
rerunSchema.value = Array.isArray(entry.params) ? entry.params : [];
|
||||
for (const p of rerunSchema.value) rerunValues.value[p.name] = p.default ?? '';
|
||||
} catch {
|
||||
rerunSchema.value = []; // 目录不可达:空表单直接重跑(默认参数)
|
||||
} finally {
|
||||
rerunLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRerunForm() {
|
||||
if (rerunSubmitting.value) return;
|
||||
rerunSubmitting.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const params = {};
|
||||
for (const p of rerunSchema.value) {
|
||||
const v = rerunValues.value[p.name];
|
||||
if (v === '' || v === null || v === undefined) continue;
|
||||
params[p.name] = (p.type === 'int' || p.type === 'float' || p.type === 'number') ? Number(v) : String(v);
|
||||
}
|
||||
await bioflow.rerunStage(rerunFormStage.value, params);
|
||||
emit('rerun-with-params', rerunFormStage.value);
|
||||
rerunFormStage.value = null;
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
rerunSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 暂停/恢复(FR-BIO-010) ----
|
||||
const showPause = computed(() => jobStatus.value === 'running');
|
||||
const showResume = computed(() => jobStatus.value === 'paused');
|
||||
async function doPause() {
|
||||
if (acting.value) return;
|
||||
acting.value = 'pause';
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.pauseJob();
|
||||
emit('pause');
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
async function doResume() {
|
||||
if (acting.value) return;
|
||||
acting.value = 'resume';
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.resumeJob();
|
||||
emit('resume');
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -265,12 +124,6 @@ async function doResume() {
|
||||
<div class="pc-head">
|
||||
<span class="pc-title">{{ BF.progTitle }}</span>
|
||||
<div class="pc-head-right">
|
||||
<button v-if="showPause" class="pane-btn" type="button" :disabled="!!acting" @click="doPause">
|
||||
{{ acting === 'pause' ? BF.progPausing : BF.progPause }}
|
||||
</button>
|
||||
<button v-if="showResume" class="pane-btn" type="button" :disabled="!!acting" @click="doResume">
|
||||
{{ acting === 'resume' ? BF.progResuming : BF.progResume }}
|
||||
</button>
|
||||
<span class="badge" :class="jobStatus === 'running' ? 'running' : jobStatus === 'paused' ? 'idle' : jobStatus === 'done' ? 'done' : jobStatus === 'failed' ? 'error' : 'idle'">
|
||||
<span class="dot"></span>
|
||||
{{ jobStatus === 'running' ? BF.progBadgeRunning : jobStatus === 'paused' ? BF.progBadgePaused : jobStatus === 'done' ? BF.progBadgeDone : jobStatus === 'failed' ? BF.progBadgeFailed : BF.progBadgeCreated }}
|
||||
@@ -278,7 +131,7 @@ async function doResume() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 折叠分组(FR-BIO-013) -->
|
||||
<!-- 折叠分组 -->
|
||||
<template v-for="(g, gi) in groups" :key="gi">
|
||||
<button
|
||||
v-if="g.collapsible"
|
||||
@@ -301,17 +154,14 @@ async function doResume() {
|
||||
>{{ dotSymbol(st) }}</span>
|
||||
<span class="p-name">{{ st.stage }}</span>
|
||||
<span class="p-desc" :title="st.error || (st.progressMessage || '')">{{ st.progressMessage || st.error || '' }}</span>
|
||||
<!-- 进度条:有 percent → 确定性;running 无 percent → 不确定扫光;其余 → 不显示 -->
|
||||
<button
|
||||
<!-- 进度条(S3 只读,不可点击定位) -->
|
||||
<div
|
||||
v-if="hasPct(st) || (isLiveRunning(st) && !hasPct(st))"
|
||||
class="prog-track clickable"
|
||||
type="button"
|
||||
:title="BF.progLocateHint"
|
||||
@click.stop="locateStage(st)"
|
||||
class="prog-track"
|
||||
>
|
||||
<span v-if="hasPct(st)" class="prog-fill" :class="{ full: st.progress >= 100 }" :style="{ width: `${Math.min(st.progress, 100)}%` }"></span>
|
||||
<span v-else class="prog-indeterminate"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="hasPct(st)" class="prog-pct">{{ Math.min(st.progress, 100) }}%</span>
|
||||
<span class="p-state" :style="st.status === 'running' ? { color: `var(${phaseVar(gi * COLLAPSED_GROUP_SIZE + si)})` } : null">
|
||||
<span v-if="st.status === 'running'" class="spin"></span>
|
||||
@@ -321,57 +171,17 @@ async function doResume() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 日志尾区(§8.4:仅 running/failed 展开;--pa-code-bg mono 11px padding 8px 10px max-height 96px 滚动) -->
|
||||
<!-- 日志尾区(仅 running/failed 展开;最近一行,随 SSE 追加自动滚底) -->
|
||||
<template v-for="st in stages" :key="'log-' + st.stage">
|
||||
<div v-if="showLog(st)" class="pc-log">
|
||||
<div class="pc-log-head">{{ st.stage }} · {{ BF.jobsLogTitle }}</div>
|
||||
<pre class="pc-log-body" :ref="(el) => setLogRef(st.stage, el)">{{ st.log_tail || BF.progLogPlaceholder }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 失败三选一(§8.4:仅 failed;pane-btn 同款 12px,gap 8px) -->
|
||||
<div v-if="showActions(st)" class="pc-actions">
|
||||
<button
|
||||
class="pane-btn"
|
||||
type="button"
|
||||
:disabled="!!acting"
|
||||
@click="doRerun(st)"
|
||||
>{{ acting === 'rerun:' + st.stage ? BF.formSubmitting : BF.progRerun }}</button>
|
||||
<button
|
||||
class="pane-btn"
|
||||
type="button"
|
||||
:disabled="!!acting"
|
||||
@click="doSkip(st)"
|
||||
>{{ acting === 'skip:' + st.stage ? BF.formSubmitting : BF.progSkip }}</button>
|
||||
<button class="pane-btn" type="button" @click="doRerunWithParams(st)">{{ BF.progRerunWithParams }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 改参数重跑:该阶段参数表单(FR-BIO-012;由阶段目录 schema 构造) -->
|
||||
<div v-if="rerunFormStage" class="pc-rerun-form">
|
||||
<div class="pc-rerun-head">
|
||||
<span>{{ BF.rerunFormTitle.replace('{stage}', rerunFormStage) }}</span>
|
||||
<button class="pane-btn" type="button" @click="closeRerunForm">✕</button>
|
||||
</div>
|
||||
<div v-if="rerunLoading" class="pc-rerun-hint">{{ BF.jobsManifestLoading }}</div>
|
||||
<div v-else-if="rerunSchema.length" class="pc-rerun-fields">
|
||||
<div v-for="p in rerunSchema" :key="p.name" class="pc-rerun-field">
|
||||
<span class="pc-rerun-label">{{ p.name }}</span>
|
||||
<input v-model="rerunValues[p.name]" class="pc-rerun-input" type="text" />
|
||||
<span class="pc-rerun-source">{{ BF.formSourceContract }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-rerun-actions">
|
||||
<button class="pane-btn" type="button" @click="closeRerunForm">{{ BF.jobsConfirmNo }}</button>
|
||||
<button class="pane-btn run" type="button" :disabled="rerunSubmitting" @click="submitRerunForm">
|
||||
{{ rerunSubmitting ? BF.formSubmitting : BF.rerunFormSubmit }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="actionError" class="pc-error">{{ actionError }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
/* UI_SPEC §8.4:结构/状态点/连接线/相位色复用 §4.4 pipeline-card 全部令牌 */
|
||||
.bioflow-progress-card {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
// chat/LegacyBioflowBlock.vue —— 旧生信块只读降级渲染(S3)
|
||||
// 用途:旧会话历史里遗留的生信块(bioflow_qa 引用块 / 旧版 bioflow_* 块)在新架构下
|
||||
// 以只读形式降级渲染,保证 hydrate 打开旧会话不报错、不渲染可交互控件。
|
||||
// 仅展示,不发任何控制面请求。
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
});
|
||||
|
||||
function titleFor(type) {
|
||||
switch (type) {
|
||||
case 'bioflow_qa':
|
||||
return BF.legacyQaTitle;
|
||||
case 'bioflow_path':
|
||||
return BF.legacyPathTitle;
|
||||
case 'bioflow_form':
|
||||
return BF.legacyFormTitle;
|
||||
case 'bioflow_file':
|
||||
return BF.legacyFileTitle;
|
||||
case 'bioflow_progress':
|
||||
return BF.legacyProgressTitle;
|
||||
case 'bioflow_archive':
|
||||
return BF.legacyArchiveTitle;
|
||||
default:
|
||||
return BF.legacyBlockTitle;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="legacy-bioflow-block">
|
||||
<div class="lbf-title">{{ titleFor(block.type) }}</div>
|
||||
<!-- 旧 bioflow_qa 引用块:只读引用列表 -->
|
||||
<template v-if="block.type === 'bioflow_qa'">
|
||||
<div v-for="(c, ci) in (block.citations || [])" :key="ci" class="lbf-cite">
|
||||
<span class="lbf-idx">{{ ci + 1 }}</span>
|
||||
<span class="lbf-text">{{ c.text || c.title_path || c.source }}</span>
|
||||
<span class="lbf-src">{{ c.title_path || c.source }}</span>
|
||||
</div>
|
||||
<div v-if="!block.citations || !block.citations.length" class="lbf-empty">{{ BF.flowQaEmpty }}</div>
|
||||
</template>
|
||||
<!-- 旧任务/进度类块:只读摘要(若携带 task/job 信息则显示,否则显示占位) -->
|
||||
<template v-else>
|
||||
<div v-if="block.jobId" class="lbf-job">{{ BF.legacyJobPrefix }} {{ block.jobId }}</div>
|
||||
<div class="lbf-empty">{{ BF.legacyReadonlyHint }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-bioflow-block {
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
opacity: 0.72;
|
||||
background: var(--surface-1, #fff);
|
||||
}
|
||||
.lbf-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #6b7280);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.lbf-cite {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 3px 0;
|
||||
}
|
||||
.lbf-idx {
|
||||
color: var(--accent, #2563eb);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lbf-text {
|
||||
color: var(--text, #111827);
|
||||
}
|
||||
.lbf-src {
|
||||
color: var(--text-dim, #6b7280);
|
||||
font-size: 12px;
|
||||
}
|
||||
.lbf-job {
|
||||
font-size: 13px;
|
||||
color: var(--text, #111827);
|
||||
}
|
||||
.lbf-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim, #9ca3af);
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ import { onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useSettingsStore } from '../../stores/settings.js';
|
||||
import { useSessionsStore } from '../../stores/sessions.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import { BIOFLOW_CAPABILITY_NAMES } from '../../api/capabilities.js';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
@@ -15,10 +16,24 @@ const emit = defineEmits(['close']);
|
||||
const settings = useSettingsStore();
|
||||
const sessions = useSessionsStore();
|
||||
const toggling = ref(null);
|
||||
const installing = ref(null);
|
||||
const toggleError = ref('');
|
||||
const modalRef = ref(null);
|
||||
let previouslyFocused = null;
|
||||
|
||||
// 生信能力三开关(S5:装卸 MCP 能力,热加载不重启 QA)
|
||||
const BIOFLOW_LABELS = {
|
||||
'bioflow-gene-family': '基因家族分析',
|
||||
'bioflow-rnaseq': '转录组分析',
|
||||
'bioflow-extension': '自定义扩展分析',
|
||||
};
|
||||
const bioflowCaps = () =>
|
||||
BIOFLOW_CAPABILITY_NAMES.map((name) => ({
|
||||
name,
|
||||
label: BIOFLOW_LABELS[name] || name,
|
||||
enabled: settings.isInstalled(name),
|
||||
}));
|
||||
|
||||
// 抽屉打开时才拉取(组件随 AppShell 常驻挂载,onMounted 时抽屉未开)
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -108,6 +123,20 @@ async function onToggle(cap) {
|
||||
}
|
||||
}
|
||||
|
||||
// S5:生信能力装卸(register/unregister,热加载)
|
||||
async function onToggleInstalled(name) {
|
||||
if (installing.value) return;
|
||||
installing.value = name;
|
||||
toggleError.value = '';
|
||||
try {
|
||||
await settings.toggleInstalled(name);
|
||||
} catch (err) {
|
||||
toggleError.value = err.message || T.settingsToggleFailed;
|
||||
} finally {
|
||||
installing.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -160,6 +189,32 @@ async function onToggle(cap) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">{{ T.settingsBioflow }}</div>
|
||||
<div
|
||||
v-for="cap in bioflowCaps()"
|
||||
:key="cap.name"
|
||||
class="settings-row"
|
||||
>
|
||||
<div class="settings-row-text">
|
||||
<div class="settings-row-name">{{ cap.label }}</div>
|
||||
<div class="settings-row-desc">{{ cap.name }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="toggle"
|
||||
:class="{ on: cap.enabled }"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="String(cap.enabled)"
|
||||
:disabled="installing === cap.name"
|
||||
:aria-label="cap.label"
|
||||
@click="onToggleInstalled(cap.name)"
|
||||
>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">{{ T.settingsInstalled }}</div>
|
||||
<div v-if="!settings.installed.length" class="settings-empty">{{ T.settingsNoPlugins }}</div>
|
||||
|
||||
@@ -169,6 +169,16 @@ export const BF = {
|
||||
bioflowUnavailableNote: '生信服务不可用(探活失败)',
|
||||
// ---- 流程知识库引用块(FR-BIO-026) ----
|
||||
flowQaEmpty: '暂无引用',
|
||||
// ---- 旧生信块只读降级(S3 LegacyBioflowBlock) ----
|
||||
legacyBlockTitle: '生信分析块(已归档,只读)',
|
||||
legacyQaTitle: '流程知识引用(只读)',
|
||||
legacyPathTitle: '分析路径(只读)',
|
||||
legacyFormTitle: '参数确认(只读)',
|
||||
legacyFileTitle: '文件引导(只读)',
|
||||
legacyProgressTitle: '任务进度(只读)',
|
||||
legacyArchiveTitle: '存档提示(只读)',
|
||||
legacyJobPrefix: '任务:',
|
||||
legacyReadonlyHint: '该块来自旧版本会话,已转为只读展示。',
|
||||
// ---- 阶段状态文字(进度卡/面板共用) ----
|
||||
stageStatusWaiting: '等待',
|
||||
stageStatusAwaitFiles: '等待文件',
|
||||
|
||||
@@ -181,6 +181,7 @@ export const T = {
|
||||
benchCollapse: '折叠工作台',
|
||||
close: '关闭',
|
||||
settingsTitle: '设置',
|
||||
settingsBioflow: '生信分析能力',
|
||||
settingsInstalled: '已装插件',
|
||||
settingsNoPlugins: '暂无已安装的外部插件(可在对话中安装)',
|
||||
settingsGatedBy: '需服务端环境变量 {env} 开启后生效',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// 与本仓库 docgen store 同约定);文案全部 i18n/bioflow.js;SSE 用 mock 可测。
|
||||
import { defineStore } from 'pinia';
|
||||
import { SseParser } from '../sse/parser.js';
|
||||
import { getCapabilities } from '../api/capabilities.js';
|
||||
import {
|
||||
bioflowHealth,
|
||||
createBioflowJob,
|
||||
@@ -38,6 +39,16 @@ const REGISTERED_JOBS_KEY = 'pa-bioflow-registered-jobs';
|
||||
const JOB_TERMINAL = new Set(['done', 'failed', 'archived']);
|
||||
const STAGE_TERMINAL = new Set(['done', 'failed', 'skipped']);
|
||||
|
||||
// 生信能力名(S3 双信号主信号:任一注册即视为「生信能力已安装」)
|
||||
const BIOFLOW_CAPABILITY_NAMES = ['bioflow-gene-family', 'bioflow-rnaseq', 'bioflow-extension'];
|
||||
|
||||
/** 从 GET /api/capabilities 响应判定是否含生信能力(installed 数组按 name 匹配)。 */
|
||||
function _hasBioflowCapability(payload) {
|
||||
const installed = (payload && payload.installed) || [];
|
||||
if (!Array.isArray(installed)) return false;
|
||||
return installed.some((c) => c && BIOFLOW_CAPABILITY_NAMES.includes(c.name));
|
||||
}
|
||||
|
||||
// 事件 → 阶段状态映射(snapshot 应用与逐帧推进共用同一张表)
|
||||
export const STAGE_STATUS_BY_EVENT = {
|
||||
stage_started: 'running',
|
||||
@@ -187,10 +198,18 @@ export function capLogTail(text) {
|
||||
return text.slice(-4000);
|
||||
}
|
||||
|
||||
/** 从阶段链推断生信能力链(S3 card_result 用):rnaseq 核心阶段带 rnaseq_ 前缀,其余归 gene_family。 */
|
||||
export function inferBioflowChain(stages) {
|
||||
const list = Array.isArray(stages) ? stages.map((s) => String(s || '')) : [];
|
||||
return list.some((s) => s.startsWith('rnaseq_')) ? 'rnaseq' : 'gene_family';
|
||||
}
|
||||
|
||||
export const useBioflowStore = defineStore('bioflow', {
|
||||
state: () => ({
|
||||
// ---- 探活(FR-BIO-025) ----
|
||||
availability: 'unknown', // 'unknown' | 'online' | 'offline'
|
||||
// ---- 探活(FR-BIO-025 / S3 双信号) ----
|
||||
availability: 'unknown', // 'unknown' | 'online' | 'offline'(主信号:能力注册状态)
|
||||
capabilityInstalled: false, // 主信号:GET /api/capabilities 是否含 bioflow-* 能力
|
||||
healthOnline: false, // 次信号:GET /api/bioflow/health 服务健康(仅执行期降级参考)
|
||||
// ---- 当前会话任务(SSE 事件逐帧推进 + 快照整体刷新) ----
|
||||
currentJob: {
|
||||
jobId: null,
|
||||
@@ -234,7 +253,7 @@ export const useBioflowStore = defineStore('bioflow', {
|
||||
|
||||
getters: {
|
||||
bioflowAvailable() {
|
||||
return this.availability === 'online';
|
||||
return this.capabilityInstalled;
|
||||
},
|
||||
hasJob() {
|
||||
return !!this.currentJob.jobId;
|
||||
@@ -258,16 +277,27 @@ export const useBioflowStore = defineStore('bioflow', {
|
||||
|
||||
actions: {
|
||||
// ---------------------------------------------------------------
|
||||
// 探活(FR-BIO-025)
|
||||
// 探活(FR-BIO-025 / S3 双信号)
|
||||
// 主信号 = 能力注册状态(GET /api/capabilities 的 installed 是否含 bioflow-*)
|
||||
// 次信号 = 服务健康(GET /api/bioflow/health,仅执行期降级参考,不影响 TAB 显隐)
|
||||
// ---------------------------------------------------------------
|
||||
async probe({ force = false } = {}) {
|
||||
if (!force && this.availability !== 'unknown') return this.availability;
|
||||
// 主信号:能力注册状态
|
||||
try {
|
||||
const caps = await getCapabilities();
|
||||
this.capabilityInstalled = _hasBioflowCapability(caps);
|
||||
} catch {
|
||||
this.capabilityInstalled = false;
|
||||
}
|
||||
// 次信号:服务健康(保留 probe,已注册但服务不可达时 TAB 仍显示)
|
||||
try {
|
||||
const data = await bioflowHealth();
|
||||
this.availability = data && data.ok ? 'online' : 'offline';
|
||||
this.healthOnline = !!(data && data.ok);
|
||||
} catch {
|
||||
this.availability = 'offline';
|
||||
this.healthOnline = false;
|
||||
}
|
||||
this.availability = this.capabilityInstalled ? 'online' : 'offline';
|
||||
return this.availability;
|
||||
},
|
||||
|
||||
|
||||
+171
-326
@@ -28,7 +28,6 @@ import { useDocgenStore } from './docgen.js';
|
||||
|
||||
// ---- P2 生信流程编排:独立分支(send/_runStream/sendDocgen 零改动) ----
|
||||
import { BF } from '../i18n/bioflow.js';
|
||||
import { route as routeBioflow } from '../utils/intent.js';
|
||||
import { bioflowChatStream } from '../api/bioflow.js';
|
||||
import { useBioflowStore } from './bioflow.js';
|
||||
import { consumeSseStream } from '../sse/parser.js';
|
||||
@@ -1566,13 +1565,6 @@ export const useChatStore = defineStore('chat', {
|
||||
entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// sendBioflow(P2):生信对话式编排独立分支——用户消息落库(既有机制)→
|
||||
// 生信骨架(path 块)→ POST /api/bioflow/chat/stream(SSE 经 sse/parser.js
|
||||
// 消费,与既有 parser 完全复用)→ 事件按更新器 patch 消息 blocks →
|
||||
// 阶段态订阅(FR-BIO-014:断线重连首帧快照整体刷新由本动作处理)。
|
||||
// send()/_runStream/sendDocgen 零改动;不命中词典一律不进此分支(漏判安全)。
|
||||
// ---------------------------------------------------------------
|
||||
/**
|
||||
* 进度条点击定位:请求 bioflow 面板定位 + 切右侧生信 TAB。
|
||||
* BioJobsPane 消费 panelLocate(nonce 变化)后 scrollIntoView + 展开 log。
|
||||
@@ -1586,314 +1578,6 @@ export const useChatStore = defineStore('chat', {
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
async sendBioflow(text, { userMsg, mode = 'ask', reuseName = null } = {}) {
|
||||
const q = String(text == null ? '' : text).trim();
|
||||
if (!q || this.busy) return;
|
||||
const sessions = useSessionsStore();
|
||||
if (!this.sessionId) {
|
||||
try {
|
||||
const sess = await sessions.create();
|
||||
this.sessionId = sess.id;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
|
||||
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
|
||||
}
|
||||
const sessionId = this.sessionId;
|
||||
const msgList = this.messages;
|
||||
this.sessionMsgs[sessionId] = msgList;
|
||||
const ctx = newStreamCtx(sessionId, msgList);
|
||||
this.streamCtx[sessionId] = ctx;
|
||||
this.lastResult = null;
|
||||
const startAt = Date.now();
|
||||
|
||||
// 1. 用户消息入列 + 落库(retry 复用既有用户消息时跳过——同位置替换)
|
||||
let userId = null;
|
||||
let userMsgRef = null;
|
||||
if (userMsg) {
|
||||
userMsgRef = userMsg;
|
||||
userId = userMsg.id;
|
||||
} else {
|
||||
const userMsgRaw = { id: null, role: 'user', kind: 'bioflow', status: 'done', text: q, mode, meta: { bioflow: true }, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
msgList.push(userMsgRaw);
|
||||
userMsgRef = msgList[msgList.length - 1]; // 持代理(响应式断链陷阱)
|
||||
try {
|
||||
const saved = await createMessage(sessionId, { role: 'user', text: q });
|
||||
userId = saved.id;
|
||||
userMsgRef.id = userId;
|
||||
} catch (err) {
|
||||
userMsgRef.persistError = err;
|
||||
this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 会话 running
|
||||
sessions.patchLocal(sessionId, { status: 'running' });
|
||||
try {
|
||||
await updateSessionQuiet(sessionId, { status: 'running' });
|
||||
} catch { /* 状态 PATCH 失败不阻断 */ }
|
||||
|
||||
// 3. 生信骨架消息(path 块等待候选)+ 落库
|
||||
const aMsgRaw = newBioflowSkeletonMessage();
|
||||
msgList.push(aMsgRaw);
|
||||
const aMsg = msgList[msgList.length - 1]; // 持代理
|
||||
ctx.aMsg = aMsg;
|
||||
this.runningMsgId = null;
|
||||
try {
|
||||
const skeleton = await createMessage(sessionId, {
|
||||
role: 'assistant', kind: 'bioflow', status: 'streaming',
|
||||
blocks: [{ type: 'bioflow_path', status: 'running', candidates: [] }],
|
||||
});
|
||||
aMsg.id = skeleton.id;
|
||||
this.runningMsgId = skeleton.id;
|
||||
} catch {
|
||||
aMsg.id = null; // 骨架落库失败:终态 replace 语义重建(防御)
|
||||
}
|
||||
|
||||
// 4. 事件流(chat/stream 规划入口;任务事件经 /jobs/{id}/events 订阅)
|
||||
const bioflow = useBioflowStore();
|
||||
bioflow.resetSession();
|
||||
// 事件 patch 前保证 path 块引用稳定(computed 消费时缺块即建)
|
||||
let jobEventsOpened = false;
|
||||
let lastJobSeq = 0;
|
||||
const openJobEvents = (jobId) => {
|
||||
if (jobEventsOpened || !jobId) return;
|
||||
jobEventsOpened = true;
|
||||
// 订阅任务事件流:首帧快照整体刷新进度块(FR-BIO-014 断线重连回放)
|
||||
try {
|
||||
bioflow.connectJobEvents(jobId, sessionId, {
|
||||
onOpen: () => { /* EventSource 内建重连;重连后快照自动整体刷新 */ },
|
||||
});
|
||||
} catch { /* EventSource 不可用(降级/测试):事件仍可由快照兜底 */ }
|
||||
// 快照整体刷新 → 消息进度块同步(订阅后立即拉一次快照应用)
|
||||
};
|
||||
// 从 bioflow store 当前任务快照刷新消息进度块(断线重连回放兜底路径)
|
||||
const syncProgressFromStore = () => {
|
||||
const st = bioflow.currentJob;
|
||||
if (!st || !st.jobId) return;
|
||||
patchBioflowSnapshot(aMsg, {
|
||||
job_id: st.jobId,
|
||||
job: { status: st.jobStatus },
|
||||
stages: st.stages,
|
||||
last_seq: st.lastSeq,
|
||||
});
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
ctx.controller = controller;
|
||||
let ended = false;
|
||||
const endTask = () => {
|
||||
ended = true;
|
||||
if (ctx.controller === controller) ctx.controller = null;
|
||||
this._stopCheckpoint(ctx);
|
||||
};
|
||||
|
||||
const applyFrame = (ev) => {
|
||||
if (ended) return;
|
||||
const type = ev.event || 'message';
|
||||
const data = ev.data || {};
|
||||
if (type === 'plan_ready') {
|
||||
patchBioflowPlan(aMsg, data);
|
||||
bioflow.applyChatEvent('plan_ready', data);
|
||||
this.scrollSeq++;
|
||||
} else if (type === 'planning') {
|
||||
patchBioflowPlanning(aMsg, data);
|
||||
this.scrollSeq++;
|
||||
} else if (type === 'job_created') {
|
||||
patchBioflowEvent(aMsg, { type, data });
|
||||
bioflow.applyJobEvent({ type, data });
|
||||
openJobEvents(data.job_id);
|
||||
this.scrollSeq++;
|
||||
} else if (type === 'stage_await_confirm') {
|
||||
patchBioflowEvent(aMsg, { type, data });
|
||||
bioflow.applyJobEvent({ type, data });
|
||||
patchBioflowForm(aMsg, data);
|
||||
this.scrollSeq++;
|
||||
} else if (type === 'stage_await_files') {
|
||||
patchBioflowEvent(aMsg, { type, data });
|
||||
bioflow.applyJobEvent({ type, data });
|
||||
patchBioflowFile(aMsg, data);
|
||||
this.scrollSeq++;
|
||||
} else if (type === 'job_done') {
|
||||
patchBioflowEvent(aMsg, { type, data });
|
||||
bioflow.applyJobEvent({ type, data });
|
||||
const blk = patchBioflowArchiveSuggest(aMsg, data.job_id || aMsg.jobId);
|
||||
if (blk) blk.jobId = data.job_id || blk.jobId;
|
||||
// P3 报告登记(FR-BIO-016):落 doc_artifacts 供 DocPane 预览;
|
||||
// 登记失败不阻断对话(报告仍可直连下载 HTML)
|
||||
bioflow.registerReport({
|
||||
jobId: data.job_id || bioflow.currentJob.jobId,
|
||||
sessionId,
|
||||
title: bioflow.currentJob.title || '生信分析报告',
|
||||
reportPath: (data.report_path || ''),
|
||||
figures: Array.isArray(data.figures) ? data.figures : [],
|
||||
}).catch(() => {});
|
||||
this.scrollSeq++;
|
||||
} else if (type === 'job_paused' || type === 'job_archived') {
|
||||
patchBioflowEvent(aMsg, { type, data });
|
||||
bioflow.applyJobEvent({ type, data });
|
||||
this.scrollSeq++;
|
||||
} else if (type === 'message_done') {
|
||||
// 生信流收尾:非生信/流程咨询分支(bioflow_qa 引用块 / qa 转交)
|
||||
endTask();
|
||||
if (data && data.kind === 'flow_qa') {
|
||||
patchBioflowQa(aMsg, { question: q, citations: data.citations, degraded: data.degraded });
|
||||
}
|
||||
updateLocalDone();
|
||||
return;
|
||||
} else if (type === 'error') {
|
||||
// 生信服务错误帧(统一信封形态):标注可重试
|
||||
endTask();
|
||||
// 派生点收口:当前运行相位置 error(防点永久 running)
|
||||
const dotBlk = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline' && b.derived);
|
||||
if (dotBlk) {
|
||||
for (const s of dotBlk.steps || []) {
|
||||
if (s.state === 'running') s.state = 'error';
|
||||
}
|
||||
}
|
||||
const code = (data && data.code) || 'STREAM_ERROR';
|
||||
const message = (data && data.message) || BF.bioflowRouteError;
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = 'message';
|
||||
aMsg.errText = message;
|
||||
});
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finishCtx(ctx, {
|
||||
ok: false,
|
||||
error: { code, message, retryable: true },
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
});
|
||||
return;
|
||||
} else if (['stage_started', 'stage_log', 'stage_progress', 'stage_done', 'stage_failed', 'stage_skipped', 'job_recovered'].includes(type)) {
|
||||
// 会话级串流场景的事件也按任务事件处理(P1 契约:规划入口心跳流;
|
||||
// 任务事件主要在 jobs/{id}/events 上,这里双保险不丢事件)
|
||||
patchBioflowEvent(aMsg, { type, data });
|
||||
bioflow.applyJobEvent({ type, data });
|
||||
this.scrollSeq++;
|
||||
} else if (applyExtensionFrame(aMsg, type, data)) {
|
||||
// 用户自定义扩展分析(EXTENSION_CONTRACT.md §4):extension_* 帧 → 引导卡块
|
||||
this.scrollSeq++;
|
||||
}
|
||||
// keepalive / 未知事件:忽略
|
||||
};
|
||||
|
||||
const updateLocal = (fn) => {
|
||||
if (ctx.msgList.includes(aMsg)) fn();
|
||||
};
|
||||
const updateLocalDone = () => {
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'done';
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await bioflowChatStream(
|
||||
{
|
||||
session_id: sessionId,
|
||||
message: q,
|
||||
has_attachments: false,
|
||||
has_active_artifact: this._hasActiveArtifactSafe(),
|
||||
has_bioflow_context: this._sessionHasBioflowContext(sessionId),
|
||||
last_seq: bioflow.currentJob.lastSeq || null,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await consumeSseStream(res, applyFrame);
|
||||
// 流自然关闭:job_done 之后服务端关闭 / 心跳流断连(任务仍存活于服务端)
|
||||
if (!ended) {
|
||||
endTask();
|
||||
// 有任务但无终态:订阅仍在(jobEventsSource),进度块随快照刷新;
|
||||
// 消息状态按已收事件收口。
|
||||
updateLocal(() => {
|
||||
const blk = (aMsg.blocks || []).find((b) => b.type === 'bioflow_progress');
|
||||
if (blk) {
|
||||
aMsg.status = 'done';
|
||||
} else if (!(aMsg.blocks || []).some((b) => b.type === 'bioflow_qa')) {
|
||||
// 无任务(纯规划心跳断连):path 卡保留,收口状态
|
||||
aMsg.status = 'done';
|
||||
}
|
||||
});
|
||||
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
|
||||
try {
|
||||
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
|
||||
} catch (persistErr) {
|
||||
console.warn('persistFinal failed after bioflow stream:', persistErr);
|
||||
}
|
||||
this._finishCtx(ctx, {
|
||||
ok: true,
|
||||
payload: bioflow.currentJob,
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
dur,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (ended) return; // 已被某个帧处理(message_done/error)
|
||||
endTask();
|
||||
const isAbort = err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED');
|
||||
if (isAbort) {
|
||||
// 用户停止(FR-CHAT-011 语义):保留已收块
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = 'stopped';
|
||||
});
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finishCtx(ctx, {
|
||||
ok: false,
|
||||
error: { code: 'ABORTED', message: T.stopped, retryable: false },
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isAbort && err && typeof err.code === 'string' && err.code !== 'NETWORK') {
|
||||
// 统一错误信封(UPSTREAM_DOWN/INTERNAL 等)透传后端 message
|
||||
const text = err.message || BF.bioflowRouteError;
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = 'message';
|
||||
aMsg.errText = text;
|
||||
});
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finishCtx(ctx, {
|
||||
ok: false,
|
||||
error: { code: err.code, message: text, retryable: true },
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 网络中断:任务仍存活于服务端;重连回放由消息进度块 + 订阅快照兜底
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = 'disconnected';
|
||||
});
|
||||
syncProgressFromStore();
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finishCtx(ctx, {
|
||||
ok: false,
|
||||
error: { code: 'NETWORK', message: T.disconnected, retryable: true },
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/** 会话是否有生信上下文(当前会话消息含 bioflow 块;用于 chat/stream has_bioflow_context) */
|
||||
_sessionHasBioflowContext(sessionId) {
|
||||
const list = this.sessionMsgs[sessionId] || this.messages || [];
|
||||
@@ -2477,6 +2161,169 @@ export const useChatStore = defineStore('chat', {
|
||||
} catch { /* bench 未就绪(测试/降级):静默 */ }
|
||||
},
|
||||
|
||||
/** S2 卡片回填协议:把卡片操作作为结构化用户消息发 QA 流(card_result)。
|
||||
* 经 BioflowAgent 工具执行 + 自然语言复述;卡片与自然语言两条通道语义一致。
|
||||
* card ∈ path_selection|form_confirm|file_upload|archive_choice|extension_action。 */
|
||||
async sendCardResult(card, payload, { text = '', userMsg = null } = {}) {
|
||||
if (this.busy) return;
|
||||
const sessions = useSessionsStore();
|
||||
if (!this.sessionId) {
|
||||
try {
|
||||
const sess = await sessions.create();
|
||||
this.sessionId = sess.id;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const sessionId = this.sessionId;
|
||||
const msgList = this.messages;
|
||||
this.sessionMsgs[sessionId] = msgList;
|
||||
const ctx = newStreamCtx(sessionId, msgList);
|
||||
this.streamCtx[sessionId] = ctx;
|
||||
this.lastResult = null;
|
||||
const startAt = Date.now();
|
||||
const cardLabel = String(text || '').trim() || '(卡片操作)';
|
||||
const updateLocal = (fn) => {
|
||||
if (ctx.msgList.includes(aMsg)) fn();
|
||||
};
|
||||
|
||||
// 1. 用户消息(卡片操作)入列 + 落库
|
||||
let userId = null;
|
||||
let userMsgRef = null;
|
||||
if (userMsg) {
|
||||
userMsgRef = userMsg;
|
||||
userId = userMsg.id;
|
||||
} else {
|
||||
const userMsgRaw = { id: null, role: 'user', kind: 'bioflow', status: 'done', text: cardLabel, mode: 'ask', meta: { card_result: { card, payload } }, blocks: [{ type: 'text', html: cardLabel }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
msgList.push(userMsgRaw);
|
||||
userMsgRef = msgList[msgList.length - 1]; // 持代理(响应式断链陷阱)
|
||||
try {
|
||||
const saved = await createMessage(sessionId, { role: 'user', text: cardLabel });
|
||||
userId = saved.id;
|
||||
userMsgRef.id = userId;
|
||||
} catch (err) {
|
||||
userMsgRef.persistError = err;
|
||||
this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 会话 running
|
||||
sessions.patchLocal(sessionId, { status: 'running' });
|
||||
try { await updateSessionQuiet(sessionId, { status: 'running' }); } catch { /* 状态 PATCH 失败不阻断 */ }
|
||||
|
||||
// 3. 助手骨架(bioflow)+ 落库
|
||||
const aMsgRaw = newBioflowSkeletonMessage();
|
||||
msgList.push(aMsgRaw);
|
||||
const aMsg = msgList[msgList.length - 1]; // 持代理
|
||||
ctx.aMsg = aMsg;
|
||||
this.runningMsgId = null;
|
||||
try {
|
||||
const skeleton = await createMessage(sessionId, {
|
||||
role: 'assistant', kind: 'bioflow', status: 'streaming',
|
||||
blocks: [{ type: 'bioflow_path', status: 'running', candidates: [] }],
|
||||
});
|
||||
aMsg.id = skeleton.id;
|
||||
this.runningMsgId = skeleton.id;
|
||||
} catch {
|
||||
aMsg.id = null; // 骨架落库失败:终态 replace 语义重建(防御)
|
||||
}
|
||||
|
||||
// 4. QA 流(携带 card_result)
|
||||
const controller = new AbortController();
|
||||
ctx.controller = controller;
|
||||
let ended = false;
|
||||
const endTask = () => {
|
||||
ended = true;
|
||||
if (ctx.controller === controller) ctx.controller = null;
|
||||
this._stopCheckpoint(ctx);
|
||||
};
|
||||
|
||||
try {
|
||||
await chatStream(
|
||||
{ question: cardLabel, session_id: sessionId, card_result: { card, payload } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
onProgress: (ev) => {
|
||||
if (ended) return;
|
||||
const p = ev.payload || {};
|
||||
// bioflow 帧仅记录(生信消息无 pipeline 骨架)
|
||||
const evProxy = this.streamCtx[sessionId] || ctx;
|
||||
evProxy.events.push({ ...p, at: p.at || new Date().toISOString() });
|
||||
},
|
||||
onComplete: async (ev) => {
|
||||
if (ended) return;
|
||||
endTask();
|
||||
const p = ev.payload || {};
|
||||
const answer = p.answer || '';
|
||||
updateLocal(() => {
|
||||
aMsg.blocks = [
|
||||
{ type: 'bioflow_path', status: 'done', candidates: [] },
|
||||
{ type: 'text', html: answer },
|
||||
];
|
||||
aMsg.raw = p.raw ?? p;
|
||||
aMsg.status = 'done';
|
||||
});
|
||||
// session↔job 绑定持久化:complete 的 bioflow.active_job_id → meta.bioflow
|
||||
const bf = p.bioflow || {};
|
||||
if (bf.active_job_id) {
|
||||
updateSessionQuiet(sessionId, {
|
||||
meta: { bioflow: { active_job_id: bf.active_job_id, chain: bf.chain || '' } },
|
||||
}).catch(() => {});
|
||||
}
|
||||
try {
|
||||
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
|
||||
} catch (persistErr) {
|
||||
console.warn('persistFinal failed after card complete:', persistErr);
|
||||
}
|
||||
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
|
||||
this._finishCtx(ctx, {
|
||||
ok: true,
|
||||
payload: p,
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
dur,
|
||||
blocks: 2,
|
||||
});
|
||||
},
|
||||
onError: (ev) => {
|
||||
if (ended) return;
|
||||
const p = ev.payload || {};
|
||||
endTask();
|
||||
const err = { code: p.code || 'STREAM_ERROR', message: p.message || '', retryable: !!p.retryable };
|
||||
const map = this._errorMarker(err.code);
|
||||
if (map.text) err.message = err.message || map.text;
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = map.ann;
|
||||
aMsg.errText = err.message;
|
||||
});
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finishCtx(ctx, { ok: false, error: err, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id });
|
||||
},
|
||||
onRaw: () => { /* raw 帧仅记录 */ },
|
||||
onUnknown: () => { /* 无 event 名的帧忽略 */ },
|
||||
},
|
||||
);
|
||||
if (!ended) {
|
||||
endTask();
|
||||
const err = { code: 'CONNECTION_CLOSED', message: T.disconnected };
|
||||
updateLocal(() => { aMsg.status = 'error'; aMsg.streamErr = 'disconnected'; });
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finishCtx(ctx, { ok: false, error: err, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id });
|
||||
}
|
||||
} catch (err) {
|
||||
if (ended) return;
|
||||
endTask();
|
||||
const isAbort = err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED');
|
||||
updateLocal(() => { aMsg.status = 'error'; aMsg.streamErr = isAbort ? 'stopped' : 'disconnected'; });
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finishCtx(ctx, { ok: false, error: { code: 'CONNECTION_CLOSED', message: T.disconnected, retryable: true }, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id });
|
||||
}
|
||||
},
|
||||
|
||||
async _runStream(q, modeCfg, aMsg, sessionId, userId, ctx) {
|
||||
const controller = new AbortController();
|
||||
ctx.controller = controller;
|
||||
@@ -2503,6 +2350,10 @@ export const useChatStore = defineStore('chat', {
|
||||
const phase = this._normalizePhase(p.phase);
|
||||
const label = p.label || p.stage || '';
|
||||
updateLocal(() => {
|
||||
// S2:bioflow 帧(生信对话编排)不推 pipeline 相位——生信消息走
|
||||
// bioflow 块,由 sendCardResult / bioflow 分支消费,此处仅记录。
|
||||
const isBioflow = typeof p.stage === 'string' && p.stage.startsWith('bioflow_');
|
||||
if (isBioflow) return;
|
||||
if (!ctx.completedOnce && phase !== 'done') this._advancePipeline(aMsg, phase, label);
|
||||
if (phase === 'done') {
|
||||
// 上游 completed 事件:当前相位定格 done(此时 complete 尚未到达)
|
||||
@@ -2911,18 +2762,12 @@ export const useChatStore = defineStore('chat', {
|
||||
await this.sendDocgen(q, (user.meta && user.meta.library_ids) || [], { userMsg: user });
|
||||
return;
|
||||
}
|
||||
// P2:生信失败消息的重试走生信路径(同位置替换;reuseName 从 user.meta 恢复)
|
||||
if (aMsg.kind === 'bioflow' || user.kind === 'bioflow') {
|
||||
// 扩展分析消息(user.meta.extension 标记):重试走扩展引导流(同位置替换)
|
||||
if (user.meta && user.meta.extension) {
|
||||
msgList.splice(idx, 1);
|
||||
this.lastResult = null;
|
||||
await this.sendExtension(q, { userMsg: user });
|
||||
return;
|
||||
}
|
||||
// 扩展分析失败消息的重试走扩展引导流(S4 未改);生信消息 S3 起统一走
|
||||
// QA 流(sendBioflow 已删除),非扩展的生信消息重试落回下方 QA 重试路径。
|
||||
if ((aMsg.kind === 'bioflow' || user.kind === 'bioflow') && user.meta && user.meta.extension) {
|
||||
msgList.splice(idx, 1);
|
||||
this.lastResult = null;
|
||||
await this.sendBioflow(q, { userMsg: user, reuseName: (user.meta && user.meta.reuseName) || null });
|
||||
await this.sendExtension(q, { userMsg: user });
|
||||
return;
|
||||
}
|
||||
msgList.splice(idx, 1);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// stores/settings.js —— 能力设置面板状态(S4)
|
||||
// 能力清单(GET /api/capabilities)+ 当前会话开关 + PATCH 失败回滚。
|
||||
// stores/settings.js —— 能力设置面板状态(S4/S5)
|
||||
// 能力清单(GET /api/capabilities)+ 当前会话开关 + PATCH 失败回滚 + 生信能力装卸(S5)。
|
||||
import { defineStore } from 'pinia';
|
||||
import { get, patch } from '../api/http.js';
|
||||
import { installCapability, BIOFLOW_MANIFESTS } from '../api/capabilities.js';
|
||||
|
||||
export const useSettingsStore = defineStore('settings', {
|
||||
state: () => ({
|
||||
@@ -21,6 +22,10 @@ export const useSettingsStore = defineStore('settings', {
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
// 判定某能力是否已安装(方法而非 getter:getter 首参是 state、不接受自定义参数)
|
||||
isInstalled(name) {
|
||||
return (this.installed || []).some((c) => c.name === name);
|
||||
},
|
||||
async load(sessionId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
@@ -54,5 +59,17 @@ export const useSettingsStore = defineStore('settings', {
|
||||
throw new Error(`能力 ${name} 设置保存失败`);
|
||||
}
|
||||
},
|
||||
// S5:生信能力装卸(register/unregister,热加载不重启 QA)
|
||||
async toggleInstalled(name) {
|
||||
const manifest = BIOFLOW_MANIFESTS[name];
|
||||
if (!manifest) throw new Error(`未知生信能力 ${name}`);
|
||||
if (this.isInstalled(name)) {
|
||||
await installCapability('unregister', '', name);
|
||||
} else {
|
||||
await installCapability('register', manifest, name);
|
||||
}
|
||||
// 装卸后重拉清单,刷新 installed 与 TAB 显隐主信号
|
||||
await this.load();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+12
-249
@@ -1,16 +1,8 @@
|
||||
// utils/intent.js —— 生信意图分流(P2/dev3 A3 归属;纯函数,无 Vue 依赖)
|
||||
// 唯一事实源:docs/team/cases_bioflow_intent.md v1.0(与后端 bioflow_service/
|
||||
// app/services/bioflow/intent.py 同词典同规则同用例表 40 条双跑)。
|
||||
// 判定顺序(优先级从高到低,命中即返回;漏判安全:不命中一律 qa):
|
||||
// 1. export:hasActiveArtifact 且含 EXPORT_WORD 且含 FORMAT_WORD
|
||||
// 2. edit: hasActiveArtifact 且含 EDIT_WORD 且不含 CHART_OBJECT_WORD
|
||||
// 2b. 图表调整(FR-BIO-019):hasActiveArtifact 且含 CHART_OBJECT_WORD → bioflow
|
||||
// 3. docgen:含 WRITE_VERB 且含 DOC_TYPE(排除词优先)
|
||||
// 4. docgen:hasAttachments 且含 WRITE_VERB
|
||||
// 5. bioflow_qa:hasBioflowContext 且含 FLOW_QA_WORD
|
||||
// 5b. 流程咨询句式(无上下文):含 FLOW_QA_WORD → qa(漏判安全)
|
||||
// 6. bioflow:含 ANALYZE_VERB 且(家族名命中 或 分析对象命中);复用句式同判
|
||||
// 7. 其余一律 qa
|
||||
// utils/intent.js —— 用户自定义扩展创建意图分流(P2/dev3 A3 归属;纯函数,无 Vue 依赖)
|
||||
// S3 起:生信分析意图(基因家族/转录组)已统一走 QA 流(bioflow_agent 服务端经
|
||||
// nl_triggers 判定),本文件删除生信词典(FAMILY_NAME/ANALYSIS_OBJECT/TASK_VERB/
|
||||
// CHAIN_VERB/familyHit/prefixHit/chainObjectHit 等),route() 仅保留文档意图
|
||||
// (export/edit/docgen)与扩展创建意图,其余一律 qa(漏判安全)。
|
||||
|
||||
// ---- 导出意图词典(沿用 docgen 词典) ----
|
||||
const EXPORT_WORD = ['导出', '转成', '转为', '转换成', '下载成', '输出为', '存为', '转pdf', '转word'];
|
||||
@@ -19,12 +11,6 @@ const FORMAT_WORD = ['pdf', 'docx', 'doc', 'word'];
|
||||
// ---- 编辑意图词典(仅当存在活动文档时生效) ----
|
||||
const EDIT_WORD = ['改', '修改', '调整', '删', '删除', '加一段', '补充', '润色', '缩写', '扩写', '重写', '改写', '优化', '加个', '增加一段'];
|
||||
|
||||
// ---- 图表调整词(供 edit 规则排除;FR-BIO-019 走生信) ----
|
||||
const CHART_WORD = ['图表', '图颜色', '颜色换成', '图改成', '柱状图', '热图', '火山图', '曼哈顿图', '韦恩图', '气泡图', '散点图', '折线图', '改成红色', '换成蓝色', '颜色'];
|
||||
// 图表对象词(活动文档 + 图表对象词 → 生信图表调整;「颜色」单独在 edit 排除表但不在对象词表,
|
||||
// 与后端 intent.py CHART_OBJECT_WORD 一致——「把这句话改成红色」仍走 edit)
|
||||
const CHART_OBJECT_WORD = ['图表', '图颜色', '颜色换成', '图改成', '柱状图', '热图', '火山图', '曼哈顿图', '韦恩图', '气泡图', '散点图', '折线图'];
|
||||
|
||||
// ---- 写文档意图词典(沿用 docgen 词典全表) ----
|
||||
const WRITE_VERB = ['写', '撰写', '拟', '起草', '编制', '生成', '编写', '拟定', '草拟', '制定', '整理'];
|
||||
const DOC_TYPE = [
|
||||
@@ -40,71 +26,6 @@ const DOC_TYPE_EXCLUDE = ['介绍', '内容', '代码', '诗', '作业', '问题
|
||||
// 生信词典补充「文档类型」表达(与后端 _BIO_DOC_TYPE_EXTRA 一致)
|
||||
const BIO_DOC_TYPE_EXTRA = ['分析报告', '研究论文'];
|
||||
|
||||
// ---- 生信流程咨询词(FR-BIO-026) ----
|
||||
const FLOW_QA_WORD = [
|
||||
'需要什么输入', '有什么输入', '输入是什么', '需要什么参数', '参数是什么',
|
||||
'什么参数', '怎么设置', '如何设置', '什么软件', '用什么软件', '前置条件',
|
||||
'介绍', '是什么', '怎么跑', '怎么做', '需要准备什么输入', '需要准备什么参数',
|
||||
];
|
||||
|
||||
// ---- 生信任务动词(v1.1)----
|
||||
// 可单独成立的生信动作动词:动词 + 家族/对象 即命中(「跑 SPL 家族」)。
|
||||
// 裸「做」「研究」已从 v1.0 动词表移入 CHAIN_VERB(须与对象/前缀组合)——
|
||||
// 避免「做一下」被裸「做」子串误判(漏判安全收窄)。
|
||||
const TASK_VERB = ['跑', '跑一下', '分析', '鉴定', '识别', '做个', '做一个', '做一下', '研究一下', '执行', '运行', 'run', 'identify'];
|
||||
|
||||
// ---- 分析前缀词(v1.1)----
|
||||
// 把宾语动作指回用户本人的引导前缀:前缀自带链动词(「帮我分析」)时仅需
|
||||
// 宾语对象命中;纯前缀(「帮我/请你」)时须紧随链动词 + 宾语对象命中。
|
||||
const VERB_PREFIX = ['帮我', '请帮我', '请你', '帮我分析', '请帮我分析'];
|
||||
|
||||
// ---- 链动词(v1.1)----
|
||||
// 与分析链对象(ANALYSIS_OBJECT)同现时参与前缀分析句式判定,
|
||||
// 且须全词边界匹配(「关联分析」不被「分析」误拆,「做不了」不被「做」误拆)。
|
||||
// 与后端 intent.py 的 CHAIN_VERB 同表(cases_bioflow_intent.md v1.1 §3)。
|
||||
const CHAIN_VERB = ['分析', '研究', '做', '跑'];
|
||||
|
||||
// ---- 疑问引导词(v1.1 拦截表)----
|
||||
// 「什么/怎么/如何/哪些/哪个」等问句一律 qa(复用句式豁免——复用判定在前)。
|
||||
// 断言式词「有哪些」不在此表:不命中下方任何通道,本来就落 qa。
|
||||
const QUESTION_WORD = ['什么', '怎么', '如何', '哪些', '哪个', '什么软件'];
|
||||
|
||||
// ---- 问答主题词(v1.1 拦截表)----
|
||||
// 「研究进展/综述」等问答主题句一律 qa(含「帮我分析水稻 SPL 家族的最新研究进展」)。
|
||||
// 不含「研究」单字——「帮我研究一下水稻 SPL 家族」是分析指令(用例表 #5 保持 bioflow)。
|
||||
const QA_TOPIC = [
|
||||
'研究进展', '研究现状', '研究热点', '最新研究', '最新进展', '最新成果',
|
||||
'研究成果', '研究趋势', '综述', '进展',
|
||||
];
|
||||
|
||||
// 生信分析动词 = 任务动词 + 链动词(与后端 intent.py 的 ANALYZE_VERB 并集语义对齐)
|
||||
const ANALYZE_VERB = [...TASK_VERB, ...CHAIN_VERB];
|
||||
|
||||
// ---- 分析对象词 ----
|
||||
const ANALYSIS_OBJECT = [
|
||||
'rna-seq', 'rnaseq', '转录组', '差异表达', 'wgs', '全基因组', '变异检测',
|
||||
'全基因组测序', 'gwas', '关联分析', '全基因组关联', '群体', '群体遗传', '遗传多样性',
|
||||
'进化', '系统发育', '选择性清除', '群体结构', '拷贝数变异', '基因预测', '基因注释',
|
||||
'结构注释', '功能注释', '宏基因组',
|
||||
];
|
||||
|
||||
// ---- 家族名词典 ----
|
||||
const FAMILY_NAME = [
|
||||
'spl', 'sbp', 'mads', 'mads-box', 'madsbox', 'wrky', 'nac', 'myb', 'bzip', 'bhlh',
|
||||
'ap2', 'erf', 'gras', 'tcp', 'hd-zip', 'hdzip', 'arf', 'aux', 'iaa', 'dof', 'gata',
|
||||
'hsf', 'lbd', 'lob', 'trihelix', 'e2f', 'cpp', 'nf-y', 'nfy', 'c2h2', 'zf-hd',
|
||||
'zfhd', 'far1', 'frs', 'hb', 'wox', 'pin', 'mate', 'abc', 'pp2c', 'snrk', 'cdpk',
|
||||
'mapk', 'rlck', 'nbs', 'lrr', 'nlr', 'tps', 'cyp', 'gst', 'ugt', '转录因子家族',
|
||||
'基因家族',
|
||||
];
|
||||
|
||||
// 家族名语境词(中文:家族名后须紧跟语境词;英文:family/gene 等句内任意位置)
|
||||
const FAMILY_CONTEXT = ['家族', '因子', '基因家族', '转录因子', 'family', '成员', '基因'];
|
||||
const FAMILY_EN_CONTEXT = ['family', 'gene', 'factor', 'identification', 'analysis', 'member'];
|
||||
|
||||
// ---- 存档复用句式 ----
|
||||
const REUSE_PHRASE = ['存档任务的方法', '存档的方法'];
|
||||
|
||||
/** 文本是否含词典中任一词(子串判定;英文不区分大小写——中文不受影响,与后端一致) */
|
||||
export function hasAnyWord(text, words) {
|
||||
const s = String(text == null ? '' : text).toLowerCase();
|
||||
@@ -114,143 +35,20 @@ export function hasAnyWord(text, words) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 家族名命中:词典词 + 语境词(中文)或英文语境;家族名后须跟语境词或位于句尾 */
|
||||
export function familyHit(textLower) {
|
||||
for (const name of [...FAMILY_NAME].sort((a, b) => b.length - a.length)) {
|
||||
const idx = textLower.indexOf(name);
|
||||
if (idx < 0) continue;
|
||||
const tail = textLower.slice(idx + name.length);
|
||||
const tailStripped = tail.replace(/^\s+/, '');
|
||||
// 中文语境:家族名后紧跟语境词
|
||||
if (FAMILY_CONTEXT.some((w) => tailStripped.startsWith(w))) return true;
|
||||
// 英文语境(family 等在句内任意位置)
|
||||
if (textLower.includes('family') && hasAnyWord(textLower, FAMILY_EN_CONTEXT)) return true;
|
||||
// 句尾(家族名后仅空白/标点)
|
||||
if (!tailStripped.replace(/[,。!?;:,.!?;:()()\s]/g, '')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 是否含文档类型词(DOC_TYPE_EXCLUDE 排除优先,与后端 _has_doc_type 一致) */
|
||||
function hasDocType(textLower) {
|
||||
if (hasAnyWord(textLower, DOC_TYPE_EXCLUDE)) return false;
|
||||
return hasAnyWord(textLower, DOC_TYPE) || hasAnyWord(textLower, BIO_DOC_TYPE_EXTRA);
|
||||
}
|
||||
|
||||
/** 全词边界查找(v1.1):命中位置的前后字符均非字母数字(「关联分析」中的「分析」不匹配) */
|
||||
function boundaryIndex(textLower, word) {
|
||||
for (let i = 0; i <= textLower.length - word.length; i++) {
|
||||
const idx = textLower.indexOf(word, i);
|
||||
if (idx < 0) return -1;
|
||||
const beforeOk = idx === 0 || !/[A-Za-z0-9一-鿿]/.test(textLower[idx - 1]);
|
||||
const afterIdx = idx + word.length;
|
||||
const afterOk = afterIdx >= textLower.length || !/[A-Za-z0-9一-鿿]/.test(textLower[afterIdx]);
|
||||
if (beforeOk && afterOk) return idx;
|
||||
i = idx + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 分析链对象命中(v1.1):链动词 + 链对象(全词边界)。
|
||||
* 「帮我分析水稻叶片 RNA-seq」命中;「做一个全基因组分析」不命中(缺链对象词)。 */
|
||||
function chainObjectHit(textLower) {
|
||||
for (const verb of [...CHAIN_VERB].sort((a, b) => b.length - a.length)) {
|
||||
const idx = boundaryIndex(textLower, verb);
|
||||
if (idx < 0) continue;
|
||||
if (hasAnyWord(textLower.slice(idx + verb.length), ANALYSIS_OBJECT)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 前缀分析句式命中(v1.1):VERB_PREFIX 引导 + 宾语对象命中。
|
||||
* 「帮我分析水稻 SPL 家族」→ True;「帮我看看水稻 SPL 家族」→ False(「看」非链动词);
|
||||
* 「帮我研究一下水稻 SPL 家族」→ True(补语「一下」+ 链动词);「帮我做水稻全基因组变异检测」
|
||||
* → True(链动词直接邻接宾语,v1.0 金标 #5/#12 形态)。 */
|
||||
function prefixHit(textLower) {
|
||||
for (const p of ['请帮我分析', '帮我分析']) {
|
||||
const idx = textLower.indexOf(p);
|
||||
if (idx < 0) continue;
|
||||
let rest = textLower.slice(idx + p.length).replace(/^\s+/, '');
|
||||
// 前缀动词后容许任务动词(「帮我分析鉴定水稻 SPL 家族」)与补语「一下」
|
||||
for (const v of ['鉴定', '识别', '跑一下', '跑']) {
|
||||
if (rest.startsWith(v) && !(rest.length > v.length && /[A-Za-z0-9一-鿿]/.test(rest[v.length]))) {
|
||||
rest = rest.slice(v.length).replace(/^\s+/, '');
|
||||
}
|
||||
}
|
||||
if (rest.startsWith('一下')) rest = rest.slice(2).replace(/^\s+/, '');
|
||||
if (familyHit(rest) || hasAnyWord(rest, ANALYSIS_OBJECT)) return true;
|
||||
}
|
||||
for (const p of ['请帮我', '帮我', '请你']) {
|
||||
const idx = textLower.indexOf(p);
|
||||
if (idx < 0) continue;
|
||||
let rest = textLower.slice(idx + p.length).replace(/^\s+/, '');
|
||||
let verb = '';
|
||||
// 先试「X一下」形态,再试裸链动词(「做/研究」直接邻接宾语是金标形态)
|
||||
for (const cv of ['研究一下', '做一下', '跑一下']) {
|
||||
if (rest.startsWith(cv)) {
|
||||
verb = cv;
|
||||
rest = rest.slice(cv.length).replace(/^\s+/, '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!verb) {
|
||||
for (const cv of [...CHAIN_VERB].sort((a, b) => b.length - a.length)) {
|
||||
if (rest.startsWith(cv)) {
|
||||
verb = cv;
|
||||
rest = rest.slice(cv.length).replace(/^\s+/, '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!verb) continue;
|
||||
// 补语「一下」容许
|
||||
if (rest.startsWith('一下')) rest = rest.slice(2).replace(/^\s+/, '');
|
||||
// 前缀动词后容许任务动词(「帮我做全基因组变异检测」)
|
||||
for (const v of ['鉴定', '识别', '跑一下', '跑']) {
|
||||
if (rest.startsWith(v) && !(rest.length > v.length && /[A-Za-z0-9一-鿿]/.test(rest[v.length]))) {
|
||||
rest = rest.slice(v.length).replace(/^\s+/, '');
|
||||
}
|
||||
}
|
||||
if (familyHit(rest) || hasAnyWord(rest, ANALYSIS_OBJECT)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取存档复用句式的「方法名」(REUSE_PHRASE 之前最近的 3-20 字符片段)。
|
||||
* FR-BIO-023:「用 XXX 存档任务的方法帮我跑 xxxx」→ 方法名 = XXX。
|
||||
* 与后端 extract_reuse_name 同规则。返回 null 表示未命中复用句式。
|
||||
*/
|
||||
export function extractReuseName(text) {
|
||||
const textLower = String(text == null ? '' : text).toLowerCase();
|
||||
let pos = -1;
|
||||
for (const p of REUSE_PHRASE) {
|
||||
const idx = textLower.lastIndexOf(p);
|
||||
if (idx > pos) pos = idx;
|
||||
}
|
||||
if (pos < 0) return null;
|
||||
let before = text.slice(0, pos).trim();
|
||||
// 剔除引导词与书名号
|
||||
for (const lead of ['根据', '参考', '按照', '用', '按', '《', '》', '的']) {
|
||||
before = before.split(lead).join(' ');
|
||||
}
|
||||
before = before.replace(/[《》]/g, ' ');
|
||||
const parts = before.split(/\s+/).filter((s) => s.trim());
|
||||
if (!parts.length) return null;
|
||||
const name = parts[parts.length - 1];
|
||||
if (name.length >= 3 && name.length <= 20) return name;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 意图路由(唯一事实源 docs/team/cases_bioflow_intent.md 的 route 签名)
|
||||
* 意图路由(S3:仅文档意图 + 扩展创建意图,生信意图一律 qa)
|
||||
* @param {string} text 用户输入文本
|
||||
* @param {boolean} hasAttachments 本轮是否带附件
|
||||
* @param {boolean} hasActiveArtifact 是否存在活动文档(文档 TAB 当前打开的 artifact)
|
||||
* @param {boolean} hasBioflowContext 当前会话是否已有生信任务/生信对话上下文
|
||||
* @returns {{intent: 'qa'|'docgen'|'edit'|'export'|'bioflow'|'bioflow_qa'|'extension', reuseName: string|null}}
|
||||
* @returns {{intent: 'qa'|'docgen'|'edit'|'export', reuseName: null}}
|
||||
*/
|
||||
export function route(text, hasAttachments = false, hasActiveArtifact = false, hasBioflowContext = false) {
|
||||
export function route(text, hasAttachments = false, hasActiveArtifact = false) {
|
||||
const t = String(text == null ? '' : text).trim();
|
||||
// 边界约定:长度 ≤1 或纯空白 → qa;>2000 字符按截断前 2000 判定
|
||||
if (!t || t.length <= 1) return { intent: 'qa', reuseName: null };
|
||||
@@ -260,14 +58,10 @@ export function route(text, hasAttachments = false, hasActiveArtifact = false, h
|
||||
if (hasActiveArtifact && hasAnyWord(tl, EXPORT_WORD) && hasAnyWord(tl, FORMAT_WORD)) {
|
||||
return { intent: 'export', reuseName: null };
|
||||
}
|
||||
// 2. edit(需活动文档;含图表对象词跳过 → 生信 FR-BIO-019)
|
||||
if (hasActiveArtifact && hasAnyWord(tl, EDIT_WORD) && !hasAnyWord(tl, CHART_OBJECT_WORD)) {
|
||||
// 2. edit(需活动文档)
|
||||
if (hasActiveArtifact && hasAnyWord(tl, EDIT_WORD)) {
|
||||
return { intent: 'edit', reuseName: null };
|
||||
}
|
||||
// 2b. 图表调整走生信(FR-BIO-019)
|
||||
if (hasActiveArtifact && hasAnyWord(tl, CHART_OBJECT_WORD)) {
|
||||
return { intent: 'bioflow', reuseName: null };
|
||||
}
|
||||
// 3. docgen(写文档意图;排除词优先)
|
||||
if (hasAnyWord(tl, WRITE_VERB) && hasDocType(tl)) {
|
||||
return { intent: 'docgen', reuseName: null };
|
||||
@@ -276,38 +70,7 @@ export function route(text, hasAttachments = false, hasActiveArtifact = false, h
|
||||
if (hasAttachments && hasAnyWord(tl, WRITE_VERB)) {
|
||||
return { intent: 'docgen', reuseName: null };
|
||||
}
|
||||
// 5. bioflow_qa(生信流内流程咨询;FLOW_QA 命中即拦,不新建任务)
|
||||
if (hasBioflowContext && hasAnyWord(tl, FLOW_QA_WORD)) {
|
||||
return { intent: 'bioflow_qa', reuseName: null };
|
||||
}
|
||||
// 5b. 流程咨询句式(无上下文)→ qa(漏判安全,词典用例表 #18)
|
||||
if (hasAnyWord(tl, FLOW_QA_WORD)) {
|
||||
return { intent: 'qa', reuseName: null };
|
||||
}
|
||||
|
||||
// 5c. 疑问引导词拦截(v1.1):「水稻SPL家族的最新研究进展有哪些?」——
|
||||
// 「有哪些」命中断言词但按漏判安全不拦;「什么/怎么/如何/哪些/哪个」等
|
||||
// 问句优先 qa(复用句式豁免——复用判定在前,词典 v1.1 判定顺序)。
|
||||
if (hasAnyWord(tl, QUESTION_WORD) && !hasAnyWord(tl, REUSE_PHRASE)) {
|
||||
return { intent: 'qa', reuseName: null };
|
||||
}
|
||||
// 5d. 问答主题词拦截(v1.1):「研究进展/综述」等主题句一律 qa,
|
||||
// 含「帮我分析水稻 SPL 家族的最新研究进展」——主题句优先于分析指令。
|
||||
if (hasAnyWord(tl, QA_TOPIC)) {
|
||||
return { intent: 'qa', reuseName: null };
|
||||
}
|
||||
|
||||
const hasTask = hasAnyWord(tl, TASK_VERB);
|
||||
const hasObject = familyHit(tl) || hasAnyWord(tl, ANALYSIS_OBJECT);
|
||||
|
||||
// 6. bioflow(v1.1 三通道):
|
||||
// A. 任务动词 + 对象(「跑 SPL 家族」)
|
||||
// B. 分析链句式(「帮我分析水稻叶片 RNA-seq」)
|
||||
// C. 复用句式(「用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定」)
|
||||
if ((hasTask && hasObject) || chainObjectHit(tl) || prefixHit(tl)) {
|
||||
return { intent: 'bioflow', reuseName: extractReuseName(text) };
|
||||
}
|
||||
// 7. 漏判安全:不命中绝不进生信流
|
||||
// 5. 漏判安全:其余(含生信分析意图)一律 qa
|
||||
return { intent: 'qa', reuseName: null };
|
||||
}
|
||||
|
||||
@@ -316,7 +79,7 @@ export function route(text, hasAttachments = false, hasActiveArtifact = false, h
|
||||
// 形态 = 「动作词 + 目标词 + 描述」,动作词 ∈ {创建,建,新建,上传,自定义,写,弄,做一个},
|
||||
// 目标词 ∈ {扩展,拓展分析,分析模板,分析脚本,分析工具},同句命中即进创建流程
|
||||
// (上传引导卡)。歧义消解(无动作词 + 已发布扩展命中)由服务端 intent.py 判定,
|
||||
// 前端只做确定性创建意图识别——漏判安全:不命中一律回落 bioflow/qa。
|
||||
// 前端只做确定性创建意图识别——漏判安全:不命中一律回落 qa。
|
||||
// ---------------------------------------------------------------
|
||||
const EXT_ACTION_WORD = ['创建', '建', '新建', '上传', '自定义', '写', '弄', '做一个'];
|
||||
const EXT_TARGET_WORD = ['拓展分析', '扩展分析', '扩展', '分析模板', '分析脚本', '分析工具'];
|
||||
|
||||
Reference in New Issue
Block a user