Compare commits

..
Author SHA1 Message Date
admin 60375b04d7 chore: .gitignore 忽略含凭据的部署方案文档 DEPLOY_PAPER_176.md 2026-10-10 18:53:25 +08:00
admin 1bd4aea5fd fix(build): render_active_library_env 输出 GENE_FAMILY_ROOT 而非未定义的 QA_ROOT
active_library.env 里 $QA_ROOT 从未定义,QA start.sh 在 set -u 下 source 时报
unbound variable 导致 8091 起不来。改用 prod.env 中已定义的 GENE_FAMILY_ROOT。
2026-10-10 18:52:15 +08:00
admin 51532a30bc fix(ui): 工作台「提取任务」tab 与离线提示改为「上传文献」 2026-10-10 18:27:06 +08:00
admin d37e0d9c3a fix(paper): 确认大纲后即时 seed 进度卡并逐节刷新
- 后端写稿循环不再落空 paper_progress 块(重载后出现空卡),改落开始撰写文本
- 前端 confirmPaper 时用大纲节标题本地 seed 进度卡,2s 轮询 patch 逐节状态
- 当前节按 current_section_id 标 writing(…动画)
2026-10-08 17:10:50 +08:00
admin c923419d2d fix(paper): 大纲阶段修改意见路由回 sendPaper + 用原始 brief 重新生成大纲 2026-10-08 16:23:28 +08:00
admin 43a7c6c132 feat(paper): UI 优化(卡片文字颜色/去模式按钮) + 模板下拉加论文选项并接线渲染 2026-10-08 15:17:44 +08:00
admin 8b228923bd fix(paper): 取消路径(cancel_paper) + 引用元数据清洗(丢弃杜撰 DOI/PMID) 2026-10-08 14:59:16 +08:00
admin 2e5372cf90 fix(paper): 论文产物渲染为 HTML + 文末追加参考文献列表/disclaimer 2026-10-08 14:54:00 +08:00
admin 91f6a1c242 feat: 新增论文撰写功能 paper-writer(独立 MCP 服务 + BFF 大脑 + 前端三卡)
- paper_writer_service(8097):FastAPI + MCP streamable-http 单端点,9 工具
  (propose_paper_outline/write_paper_section/get_paper_progress/search_refs/
  revise_paper_section/revise_paper/get_paper/complete_paper/cancel_paper),
  SQLite 三表(papers/paper_sections/citations,WAL),GB/T 7714 引用协议
- BFF:services/paper/{mcp_client,agent}.py(裸 JSON-RPC 客户端 + 规则状态机)、
  routers/paper.py、schemas.py 4 个 Paper*Block + ArtifactBlock 加 paper_id/citation_count、
  config.py 开关、main.py 条件注册、docgen.py 抽 insert_artifact 公共 helper
- 前端:intent.js paperIntent 纯函数、Composer docgen 分支 sub-route、sendPaper/轮询、
  PaperBriefCard/PaperOutlineCard/PaperProgressCard、DocPane cite-chip
- deploy/paper_restart.sh + toggle_paper.sh;compile_sdk.sh 白名单追加 paper 文件
- tests/paper 26 条(工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema + 前端 intent 单测)
2026-09-30 22:29:22 +08:00
112 changed files with 4597 additions and 7763 deletions
+8 -2
View File
@@ -37,5 +37,11 @@ tests/extension_cases_18/
bioflow_service/bioflow.db
*.before-*
# Python 覆盖率运行时产物(coverage.py --cov 生成)
.coverage
# paper_writer_service 运行时产物(不入库)
paper_writer_service/paper.db
paper_writer_service/paper.db-shm
paper_writer_service/paper.db-wal
paper_writer_service/paper.log
# 部署方案文档(含主机凭据,不入库)
docs/DEPLOY_PAPER_176.md
-67
View File
@@ -16,70 +16,3 @@ 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
-3
View File
@@ -31,6 +31,3 @@ 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
@@ -1,31 +0,0 @@
# 用户自定义扩展分析对话规范(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 并向用户转述当前阶段与下一步;
- 人工确认边界(确认触发词、发布)不可跳过、不可代用户拍板。
@@ -1,32 +0,0 @@
# 基因家族分析对话规范(bioflow_gene_family_agent_rules)
你是基因家族分析助手。用自然语言引导用户从需求到分析完成,全程对话式交互。
## 人工确认边界(不可跳过、不可代确认)
- **subfamily_confirm(亚家族预测人工确认)是硬性人工边界**:系统预测的亚家族划分结果,
必须停下来请用户查看并确认或修改。你绝不能代用户确认,也不能在用户未确认前继续下游分析。
- 用户提出修改(如"把某成员从亚家族 A 移到亚家族 B")时,先复述修改内容,再请用户做最终确认。
## 对话节奏(自然语言主导 + 卡片辅助)
每次需要用户输入/选择时,遵循固定节奏:
1. 先用自然语言说明并**列表展示**选项及含义;
2. 提示用户可用卡片快捷操作,也可直接打字回复(两条通道语义一致);
3. 用户选择后,用自然语言**复述**其选择,确认无误再继续。
## 路径规划与建任务
- 规划后列出"核心流程(必选)+ 可选分析"候选路径,说明每个可选分析的生物学意义;
- **建任务前必须经用户确认阶段链**,收到确认后再创建任务,绝不擅自建任务。
## 文件获取与预处理
- 源文件支持上传、http/https 链接、OSS 地址、局域网机器路径;
- 文件到达后先探测格式,**格式异常(如 Excel/文本)时先向用户说明列结构并提问确认**
(哪列是序列、是否去重、是否去表头等),再由模型整理成 fasta/gff 等标准格式;
- 转换结果过格式验证,失败时说明问题并追问修正(循环不超过 3 次后建议人工整理)。
## 参数确认
- 列"参数名(默认值,含义)"请用户确认或修改;未识别参数时明确指出并给出允许范围。
## 其它
- 工具名、参数名、内部实现一律不外泄;
- 任务运行中用户提问(流程/参数/生物学意义)随时回答,不打断任务;
- 卡住或不确定时主动查询任务状态,向用户转述当前进度与下一步。
@@ -1,29 +0,0 @@
# 转录组分析对话规范(bioflow_rnaseq_agent_rules)
你是转录组分析助手。用自然语言引导用户从实验设计确认到差异表达/富集分析完成。
## 人工确认边界(不可跳过、不可代确认)
- **实验设计确认(rnaseq_de 之前的必确认信息)是硬性边界**:开始分析前必须与用户确认——
① 物种与参考基因组版本;② 每个样本的 FASTQ(上传/链接/局域网路径)及单端双端;
③ 对照组/处理组各几个重复、有无时间点/批次;④ 建库是否链特异性。
- 统计模型正确性依赖上述信息,未确认前不得创建差异表达任务,也不能代用户拍板基线/比较方向。
## 对话节奏(自然语言主导 + 卡片辅助)
每次需要用户输入/选择时,遵循固定节奏:
1. 先用自然语言说明并**列表展示**必确认信息与可选分析;
2. 提示用户可用卡片快捷操作,也可直接打字回复(两条通道语义一致);
3. 用户回答后,用自然语言**复述**其样本设计与选择(如"干旱 3 vs 对照 3,双端,非链特异,对照为基线"),
确认无误再继续。
## 样本配置与文件预处理
- 用户用自然语言描述样本时,可解析生成 samples.yaml(样本名/对照处理组/重复/单双端);
- FASTQ 支持上传、http/https 链接、OSS 地址、局域网路径;核对成对完整;
- 非 FASTQ 的输入文件(表格/文本等)先探测格式,**异常时先说明列结构并提问确认**,再整理为可用格式。
## 参数确认
- 列"参数名(默认值,含义)"请用户确认或修改;未识别参数时明确指出并给出允许范围。
## 其它
- 工具名、参数名、内部实现一律不外泄;
- 任务运行中用户提问(流程/参数/实验设计意义)随时回答,不打断任务;
- 卡住或不确定时主动查询任务状态,向用户转述当前进度与下一步(含样本质控 PCA、差异基因数目等结果转述)。
-29
View File
@@ -13,7 +13,6 @@ 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
@@ -48,9 +47,6 @@ 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)
@@ -154,18 +150,6 @@ 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(
@@ -232,19 +216,6 @@ 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(
@@ -1,684 +0,0 @@
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,8 +128,6 @@ 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,7 +37,6 @@ 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 参数形态)。"""
@@ -111,19 +110,6 @@ 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,
@@ -133,7 +119,6 @@ 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,7 +130,6 @@ 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
@@ -201,18 +200,6 @@ 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()
@@ -238,7 +225,6 @@ 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)
@@ -288,14 +274,6 @@ 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:
@@ -359,11 +337,6 @@ 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:
@@ -475,10 +448,6 @@ 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()
@@ -1,174 +0,0 @@
"""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
@@ -1,255 +0,0 @@
"""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"}
@@ -1,511 +0,0 @@
"""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
@@ -1,206 +0,0 @@
"""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"]
@@ -1,116 +0,0 @@
"""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) == []
@@ -1,496 +0,0 @@
"""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
+1 -14
View File
@@ -51,13 +51,9 @@ 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": [...]}
"""
@@ -68,16 +64,7 @@ class ToolRunner:
"tool_names": [],
"errors": [],
}
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})
messages: List[Dict[str, Any]] = [{"role": "user", "content": user}]
system_text = system
for round_index in range(1, self.max_rounds + 1):
audit["rounds"] = round_index
+9 -30
View File
@@ -2,8 +2,7 @@
- 独立部署树 bioflow_service/(FR-BIO-028:独立进程 8098 + 独立 SQLite)。
- lifespan:init_db(幂等迁移)→ init_context(kernel/catalog/planner/runner)
→ recover(服务重启恢复 in-flight 续跑,FR-BIO-031)→ MCP session manager
(anyio task group,streamable-http 处理请求必需)→ shutdown 清理。
→ recover(服务重启恢复 in-flight 续跑,FR-BIO-031)→ shutdown 清理。
- 端点:health(/api/bioflow/health,前端探活 FR-BIO-025)。
- 不依赖 BFF/生产服务(8090/8091 等绝不探测/连接)。
"""
@@ -18,18 +17,11 @@ 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()
@@ -68,21 +60,14 @@ def create_app() -> FastAPI:
logger.info("main: 重启恢复 %d 个 in-flight 任务续跑", len(recovered))
except Exception:
logger.exception("main: 重启恢复扫描失败(任务不重跑,等待后续操作)")
# 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
try:
yield
finally:
await shutdown_context()
try:
await close_db()
except Exception:
pass
app.router.lifespan_context = _lifespan
@@ -90,12 +75,6 @@ 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
-864
View File
@@ -1,864 +0,0 @@
"""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()
@@ -1,438 +0,0 @@
"""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,
}
@@ -1,271 +0,0 @@
"""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)
@@ -1,403 +0,0 @@
"""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
@@ -1,455 +0,0 @@
"""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"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# deploy/paper_restart.sh —— 论文撰写服务重启(8097,dev 树明文;不触碰 8090/8091/8098)。
# 用法:bash deploy/paper_restart.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
bash "$REPO_ROOT/paper_writer_service/restart.sh"
echo "paper_writer_service 重启已触发(端口 8097,日志 paper_writer_service/paper.log)"
-141
View File
@@ -1,141 +0,0 @@
#!/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"
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# deploy/toggle_paper.sh —— 论文撰写快速开关(dev 树 175;不触碰生产 176 /srv/plantai)。
# 用法:bash deploy/toggle_paper.sh off|on
# 原理:BFF main.py 按 PAPER_WRITER_ENABLED 决定是否注册 /api/paper 路由;
# 不注册时 /api/paper/health 返回 404,前端探活落 offline,触发词落 docgen 单发降级。
set -uo pipefail
ACTION="${1:-}"
SDK_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/sdk"
ENV_FILE="$SDK_DIR/.env"
PY="/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python"
BIND="${PAPER_BFF_BIND:-0.0.0.0}"
if [ "$ACTION" != "off" ] && [ "$ACTION" != "on" ]; then
echo "用法: $0 <off|on>" >&2
exit 2
fi
VAL="true"
[ "$ACTION" = "off" ] && VAL="false"
# 1. 改 env(追加或覆盖)
if grep -q "^PAPER_WRITER_ENABLED=" "$ENV_FILE"; then
sed -i "s/^PAPER_WRITER_ENABLED=.*/PAPER_WRITER_ENABLED=$VAL/" "$ENV_FILE"
else
echo "PAPER_WRITER_ENABLED=$VAL" >> "$ENV_FILE"
fi
echo "PAPER_WRITER_ENABLED -> $VAL"
# 2. 重启 BFF 8090(dev 树;仅取 LISTEN 者;venv 必须绝对路径,cd sdk 后才能 import app.main)
kill $(lsof -ti tcp:8090 -sTCP:LISTEN) 2>/dev/null || true
sleep 2
cd "$SDK_DIR" && set -a && . .env && set +a
( cd "$SDK_DIR" && setsid "$PY" -m uvicorn app.main:app --host "$BIND" --port 8090 \
> /tmp/plantai-bff.log 2>&1 < /dev/null & ) && disown -a
# 3. 自检
for i in $(seq 1 20); do
sleep 2
curl -s -m 3 http://127.0.0.1:8090/api/health >/dev/null 2>&1 && break
done
curl -s -m 10 http://127.0.0.1:8090/api/health | head -c 120
echo
if [ "$ACTION" = "off" ]; then
CODE=$(curl -s -o /dev/null -w "%{http_code}" -m 8 http://127.0.0.1:8090/api/paper/health)
echo "paper/health HTTP $CODE (off 期望 404)"
[ "$CODE" = "404" ] && echo "OFF_OK" || echo "OFF_CHECK_FAIL"
else
curl -s -m 8 http://127.0.0.1:8090/api/paper/health | head -c 150
echo
fi
-177
View File
@@ -1,177 +0,0 @@
# 生信流程 · 对话式手工测试手册(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` 的生信流程部分。*
+1
View File
@@ -0,0 +1 @@
"""paper_writer_service —— 论文撰写独立 MCP 服务(8097)。"""
+53
View File
@@ -0,0 +1,53 @@
"""config.py —— paper 服务(8097)环境变量驱动配置(pydantic-settings)。
全部键可用环境变量覆盖(前缀 PAPER_;LLM 沿用 docgen 的 DOCGEN_LLM_* 直连模式,
由 services/paper/llm.py 直接读 os.environ)。零硬编码宿主路径:相对路径按 __file__ 推导。
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
_SERVICE_ROOT = Path(__file__).resolve().parent.parent # paper_writer_service/
def _resolve(v: str) -> str:
"""仓库内相对路径按 __file__ 推导服务根解析为绝对路径。"""
p = Path(v).expanduser()
if not p.is_absolute():
p = _SERVICE_ROOT / p
return str(p)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
env_prefix="PAPER_",
)
app_name: str = "plantai-paper-writer-service"
version: str = "0.1.0"
db_path: str = "paper.db" # 独立 SQLite(与 BFF 完全隔离)
vstore_base_url: str = "http://127.0.0.1:8094"
read_timeout: float = 360.0 # write_paper_section 同步长调用读超时
@property
def db_path_abs(self) -> str:
return _resolve(self.db_path)
@property
def vstore_base(self) -> str:
return self.vstore_base_url.rstrip("/")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+168
View File
@@ -0,0 +1,168 @@
"""db.py —— paper 服务独立 SQLite(aiosqlite + WAL,无 ORM),三张表。
- papers:论文主表(brief/outline/final_markdown/references/disclaimer)
- paper_sections:章节表(逐节 markdown + 状态)
- citations:结构化引用(temp_n 生成期全局递增,final_n complete 按出现序重排写回)
模式沿用 bioflow/docgen 同款:CREATE TABLE IF NOT EXISTS + aiosqlite 单连接 + WAL + 写锁。
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections.abc import AsyncIterator, Iterable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
from .config import get_settings
logger = logging.getLogger(__name__)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS papers (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
paper_type TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT '',
format_req TEXT NOT NULL DEFAULT '',
style TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'outline',
brief_json TEXT NOT NULL DEFAULT '{}',
outline_json TEXT NOT NULL DEFAULT '{}',
final_markdown TEXT NOT NULL DEFAULT '',
references_json TEXT NOT NULL DEFAULT '[]',
disclaimer TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_papers_session ON papers(session_id);
CREATE INDEX IF NOT EXISTS idx_papers_status ON papers(status);
CREATE TABLE IF NOT EXISTS paper_sections (
id TEXT PRIMARY KEY,
paper_id TEXT NOT NULL REFERENCES papers(id) ON DELETE CASCADE,
section_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
key_points TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'pending',
markdown TEXT NOT NULL DEFAULT '',
word_count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
UNIQUE(paper_id, section_id)
);
CREATE INDEX IF NOT EXISTS idx_paper_sections_paper ON paper_sections(paper_id);
CREATE TABLE IF NOT EXISTS citations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
paper_id TEXT NOT NULL REFERENCES papers(id) ON DELETE CASCADE,
section_id TEXT NOT NULL DEFAULT '',
temp_n INTEGER,
final_n INTEGER,
kind TEXT NOT NULL DEFAULT 'title',
ref_text TEXT NOT NULL DEFAULT '',
source_doc_id TEXT NOT NULL DEFAULT '',
snippet TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_citations_paper ON citations(paper_id);
"""
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def dumps_json(value: Any, default: Any = None) -> str:
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
return json.dumps(default, ensure_ascii=False)
def loads_json(text: str | None, default: Any = None) -> Any:
if text is None:
return default
try:
return json.loads(text)
except (TypeError, ValueError):
return default
class Database:
"""aiosqlite 单连接封装(WAL;写串行化,同 bioflow 模式)。"""
def __init__(self, path: str | Path | None = None) -> None:
self._path: Path | None = Path(path) if path is not None else None
self._conn: aiosqlite.Connection | None = None
self._lock = asyncio.Lock()
@property
def path(self) -> Path:
if self._path is None:
self._path = Path(get_settings().db_path_abs)
return self._path
async def connect(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._conn = await aiosqlite.connect(self.path)
self._conn.row_factory = aiosqlite.Row
await self._conn.execute("PRAGMA journal_mode=WAL")
await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.execute("PRAGMA busy_timeout=5000")
await self._conn.executescript(_SCHEMA)
await self._conn.commit()
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
self._conn = None
@property
def conn(self) -> aiosqlite.Connection:
if self._conn is None:
raise RuntimeError("Database 未初始化:先 await init_db()")
return self._conn
async def fetchall(self, sql: str, params: Iterable[Any] = ()) -> list[aiosqlite.Row]:
async with self._conn.execute(sql, tuple(params)) as cursor:
return list(await cursor.fetchall())
async def fetchone(self, sql: str, params: Iterable[Any] = ()) -> aiosqlite.Row | None:
async with self._conn.execute(sql, tuple(params)) as cursor:
return await cursor.fetchone()
async def execute(self, sql: str, params: Iterable[Any] = ()) -> int:
async with self._lock:
cur = await self._conn.execute(sql, tuple(params))
await self._conn.commit()
return cur.lastrowid
db = Database()
async def init_db(path: str | Path | None = None) -> Database:
# 原地改单例路径后连接(不做 db = Database(path) 重赋值):
# 服务模块以 `from ...db import db` 持有了该单例引用,重赋值会让它们拿到旧对象。
if path is not None:
db._path = Path(path)
await db.connect()
return db
async def get_db() -> AsyncIterator[Database]:
yield db
async def close_db() -> None:
await db.close()
def monotonic() -> float:
return time.monotonic()
+84
View File
@@ -0,0 +1,84 @@
"""main.py —— paper 服务应用入口(uvicorn 入口,明文白名单文件)。
- 独立部署树 paper_writer_service/(FR:独立进程 8097 + 独立 SQLite paper.db)。
- lifespan:init_db(幂等建表)→ 显式进入 MCP session_manager.run()(Starlette 不自动
运行子 Mount 的 lifespan,streamable-http 处理请求时要求 task group 已初始化)→ 清理。
- 端点:/health(GET,BFF 探活反代)+ /mcp(MCP streamable-http)。
- 不依赖 BFF/生产服务(8090 等绝不探测/连接)。
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .db import close_db, db, init_db
from .mcp import paper_mcp
from .services.paper import search
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# MCP streamable-http app(调用即创建 session_manager,供 lifespan 进入 run())。
# 端点地址:/mcp(streamable_http_path="/" + 挂载前缀 /mcp)。
_paper_mcp_app = paper_mcp.streamable_http_app(streamable_http_path="/")
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name, version=settings.version)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
# 测试注入:db 已由测试预置(init_db(tmp_path)),跳过启动初始化。
try:
await init_db()
except Exception:
logger.exception("main: 数据库初始化失败(health 将报 db 降级)")
# MCP session manager 的 task group 必须在服务运行期内保持活跃。
async with paper_mcp.session_manager.run():
try:
yield
finally:
try:
await close_db()
except Exception:
pass
await search.close_client()
app.router.lifespan_context = _lifespan
@app.get("/health")
async def health() -> dict[str, object]:
db_ok = True
db_error: str | None = None
try:
await db.fetchone("SELECT 1 AS ok")
except Exception as exc: # noqa: BLE001
db_ok = False
db_error = str(exc)
return {
"ok": True,
"status": "ok" if db_ok else "degraded",
"db": {"connected": db_ok, "error": db_error},
"version": settings.version,
}
# paper MCP 能力外挂(mcp 2.x:MCPServer.streamable_http_app)。
app.mount("/mcp", _paper_mcp_app)
return app
app = create_app()
+48
View File
@@ -0,0 +1,48 @@
"""app/mcp.py —— paper_writer_service MCP server(单 streamable-http 端点 /mcp)。
9 个工具:propose_paper_outline / write_paper_section / get_paper_progress / search_refs /
revise_paper_section / revise_paper / get_paper / complete_paper / cancel_paper。
mcp 2.x 注意:MCPServer(非 FastMCP,来自 mcp.server.mcpserver);工具返回 dict,由 MCP
序列化为 CallToolResult(structuredContent + content:[{type:"text",text:"<json>"}])。
本文件位于 app/ 层(非 services/),可用 pydantic(mcp 自身依赖 pydantic 构造 schema)。
"""
from __future__ import annotations
from typing import Any
from .services.paper import complete, outline, search, write
def build_paper_mcp() -> Any:
from mcp.server.mcpserver import MCPServer
mcp = MCPServer(
"paper-writer",
title="PlantAI 论文撰写",
description="论文撰写能力单元:大纲生成/逐节撰写/文献引用/全文合并重排/修订。",
)
mcp.tool(name="propose_paper_outline",
description="根据论文主题与写作要求生成大纲并建论文(内部检索知识库注入选题线索)。")(outline.propose_paper_outline)
mcp.tool(name="write_paper_section",
description="撰写论文某一章节(同步长调用,90-180s)。按全局引用编号表生成正文与引用。")(write.write_paper_section)
mcp.tool(name="get_paper_progress",
description="查询论文与各章节撰写进度/字数/引用数。")(complete.get_paper_progress)
mcp.tool(name="search_refs",
description="在知识库(vstore)中检索与查询相关的文献片段。")(search.search_refs)
mcp.tool(name="revise_paper_section",
description="按用户意见重写论文某一章节,新引用追加到引用表末尾。")(write.revise_paper_section)
mcp.tool(name="revise_paper",
description="按用户意见判定需要修改哪些章节,返回受影响节清单与修订计划。")(write.revise_paper)
mcp.tool(name="get_paper",
description="拉取论文全文 markdown、参考文献列表与免责声明。")(complete.get_paper)
mcp.tool(name="complete_paper",
description="合并全文、按首次出现顺序重排引用编号、渲染 GB/T 7714 文献列表。")(complete.complete_paper)
mcp.tool(name="cancel_paper",
description="取消论文(置 cancelled)。")(complete.cancel_paper)
return mcp
paper_mcp = build_paper_mcp()
+10
View File
@@ -0,0 +1,10 @@
"""schemas.py —— paper 服务共享常量(明文白名单文件)。
MCP 工具入参/出参均为 dict(由 mcp 层序列化为 content:[{type:text,text:<json>}]),
本文件只放状态/引用类型常量,避免服务层散落魔法字面量。
"""
from __future__ import annotations
PAPER_STATUSES = ("outline", "writing", "complete", "cancelled", "error")
SECTION_STATUSES = ("pending", "writing", "done", "error")
CITATION_KINDS = ("doi", "pmid", "title")
@@ -0,0 +1,65 @@
"""cite.py —— 引用协议:GB/T 7714 渲染、[n] 校验、complete 按出现序重排。"""
from __future__ import annotations
import re
from typing import Any
_CITE_RE = re.compile(r"\[(\d+)\]")
def find_cite_numbers(markdown: str | None) -> list[int]:
"""正文中出现的所有 [n] 编号(含重复,按出现顺序)。"""
return [int(m) for m in _CITE_RE.findall(markdown or "")]
def render_gbt7714(meta: dict[str, Any], *, fallback_year: str = "2026") -> str:
"""按可用元数据渲染 GB/T 7714 近似条目。
- 有 DOI → [J/OL] + DOI
- 有 PMID → [J] + PMID
- 无 DOI/PMID(知识库资料)→ 降级格式 + [EB/OL]
"""
title = (meta.get("title") or "").strip()
journal = (meta.get("journal") or "").strip()
authors = (meta.get("authors") or "").strip()
year = (meta.get("year") or fallback_year).strip()
doi = (meta.get("doi") or "").strip()
pmid = (meta.get("pmid") or "").strip()
head = f"{authors} " if authors else ""
if doi:
jpart = f"{journal}, {year}." if journal else f"{year}."
return f"{head}{title}[J/OL]. {jpart} DOI:{doi}."
if pmid:
jpart = f"{journal}, {year}." if journal else f"{year}."
return f"{head}{title}[J]. {jpart} PMID:{pmid}."
return f"{head}{title}. 知识库资料, {year}. [EB/OL]."
def render_disclaimer() -> str:
return "参考文献依据知识库资料生成,格式为 GB/T 7714 近似,建议人工核对。"
def first_appearance_order(full_markdown: str, temp_numbers: list[int]) -> list[int]:
"""按全文首次出现顺序返回 temp_n;未出现的排最后(保持原序)。"""
seen: dict[int, int] = {}
for m in _CITE_RE.finditer(full_markdown or ""):
n = int(m.group(1))
if n not in seen and n in temp_numbers:
seen[n] = m.start()
ordered = sorted((n for n in temp_numbers if n in seen), key=lambda n: seen[n])
ordered += [n for n in temp_numbers if n not in seen]
return ordered
def renumber_map(temp_numbers: list[int], full_markdown: str) -> dict[int, int]:
"""temp_n → final_n(按全文首次出现顺序 1..N)。"""
ordered = first_appearance_order(full_markdown, temp_numbers)
return {temp: final for final, temp in enumerate(ordered, start=1)}
def replace_numbers(markdown: str | None, mapping: dict[int, int]) -> str:
def _sub(m: re.Match[str]) -> str:
n = int(m.group(1))
return f"[{mapping.get(n, n)}]"
return _CITE_RE.sub(_sub, markdown or "")
@@ -0,0 +1,109 @@
"""complete.py —— get_paper / get_paper_progress / complete_paper / cancel_paper。"""
from __future__ import annotations
from typing import Any
from ...db import db, dumps_json, loads_json, now_iso
from .cite import render_disclaimer, renumber_map, replace_numbers
async def _load(paper_id: str) -> dict[str, Any]:
paper = await db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,))
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = [dict(r) for r in await db.fetchall(
"SELECT * FROM paper_sections WHERE paper_id = ? ORDER BY rowid", (paper_id,))]
citations = [dict(r) for r in await db.fetchall(
"SELECT * FROM citations WHERE paper_id = ? ORDER BY temp_n", (paper_id,))]
return {"ok": True, "paper": dict(paper), "sections": sections, "citations": citations}
def _merge_sections(paper: dict[str, Any], sections: list[dict[str, Any]]) -> str:
parts = [f"# {paper.get('title', '')}"]
for s in sections:
parts.append(f"\n## {s['title']}\n\n{s.get('markdown', '')}")
return "\n".join(parts)
async def get_paper(paper_id: str, format: str = "md") -> dict[str, Any]:
p = await _load(paper_id)
if not p.get("ok"):
return p
paper, sections, citations = p["paper"], p["sections"], p["citations"]
full_markdown = paper.get("final_markdown") or _merge_sections(paper, sections)
references = loads_json(paper.get("references_json"), []) or [
{"n": c.get("final_n") or c.get("temp_n"), "text": c["ref_text"], "kind": c["kind"],
"source_doc_id": c["source_doc_id"]} for c in citations
]
brief = loads_json(paper.get("brief_json"), {})
return {
"ok": True, "paper_id": paper_id, "title": paper["title"], "status": paper["status"],
"template_key": brief.get("template_key") or "document",
"brief": brief,
"sections": [
{"section_id": s["section_id"], "title": s["title"], "status": s["status"],
"word_count": s["word_count"]} for s in sections
],
"full_markdown": full_markdown, "references": references,
"disclaimer": paper.get("disclaimer", ""),
}
async def get_paper_progress(paper_id: str) -> dict[str, Any]:
paper = await db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,))
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
paper = dict(paper)
sections = [dict(r) for r in await db.fetchall(
"SELECT * FROM paper_sections WHERE paper_id = ? ORDER BY rowid", (paper_id,))]
cit_rows = [dict(r) for r in await db.fetchall(
"SELECT section_id, COUNT(*) AS c FROM citations WHERE paper_id = ? GROUP BY section_id", (paper_id,))]
cit_by_sec = {r["section_id"]: r["c"] for r in cit_rows}
current = next((s["section_id"] for s in sections if s["status"] != "done"), None)
return {
"ok": True, "paper_id": paper_id, "status": paper["status"], "current_section_id": current,
"sections": [
{"section_id": s["section_id"], "title": s["title"], "status": s["status"],
"word_count": s["word_count"], "citation_count": cit_by_sec.get(s["section_id"], 0)}
for s in sections
],
}
async def complete_paper(paper_id: str) -> dict[str, Any]:
p = await _load(paper_id)
if not p.get("ok"):
return p
paper, sections, citations = p["paper"], p["sections"], p["citations"]
full = _merge_sections(paper, sections)
temp_numbers = [c.get("temp_n") for c in citations if c.get("temp_n")]
mapping = renumber_map(temp_numbers, full)
final_full = replace_numbers(full, mapping)
for c in citations:
await db.execute("UPDATE citations SET final_n = ? WHERE id = ?", (mapping.get(c.get("temp_n")), c["id"]))
for s in sections:
await db.execute("UPDATE paper_sections SET markdown = ? WHERE id = ?",
(replace_numbers(s.get("markdown"), mapping), s["id"]))
ordered = sorted(citations, key=lambda c: mapping.get(c.get("temp_n"), 0))
references = [
{"n": mapping.get(c.get("temp_n"), c.get("temp_n")), "text": c["ref_text"],
"kind": c["kind"], "source_doc_id": c["source_doc_id"]} for c in ordered
]
has_degraded = any(c["kind"] == "title" for c in citations)
disclaimer = render_disclaimer() if has_degraded else ""
await db.execute(
"UPDATE papers SET status = 'complete', final_markdown = ?, references_json = ?, disclaimer = ?, updated_at = ? WHERE id = ?",
(final_full, dumps_json(references), disclaimer, now_iso(), paper_id),
)
return {"ok": True, "full_markdown": final_full, "references": references,
"renumber_map": mapping, "disclaimer": disclaimer}
async def cancel_paper(paper_id: str) -> dict[str, Any]:
paper = await db.fetchone("SELECT id FROM papers WHERE id = ?", (paper_id,))
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
await db.execute("UPDATE papers SET status = 'cancelled', updated_at = ? WHERE id = ?", (now_iso(), paper_id))
return {"ok": True}
@@ -0,0 +1,187 @@
"""services/paper/llm.py —— LLM 调用(httpx 裸调,拷贝自 sdk/app/services/docgen/llm.py)。
- complete_text(system, user, *, temperature=0.3, max_tokens=8000) -> str
- complete_json(...)(response_format json_object + 一次 JSON 修复重试)
- 4 次尝试(1.5s / 3s / 6s 指数退避,429 / 5xx / 超时)
- env 键:DOCGEN_LLM_BASE_URL / DOCGEN_LLM_API_KEY / DOCGEN_LLM_MODEL / DOCGEN_LLM_TIMEOUT
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
import httpx
logger = logging.getLogger(__name__)
DEFAULT_LLM_BASE_URL = "https://api.deepseek.com/v1"
DEFAULT_LLM_MODEL = "deepseek-chat"
DEFAULT_LLM_TIMEOUT = 300.0
BACKOFF_SECONDS = (1.5, 3.0, 6.0)
MAX_ATTEMPTS = 4
class LLMError(Exception):
"""LLM 调用失败(重试尽/响应异常)。"""
def _llm_config() -> dict[str, Any]:
return {
"base_url": (os.environ.get("DOCGEN_LLM_BASE_URL") or DEFAULT_LLM_BASE_URL).rstrip("/"),
"api_key": os.environ.get("DOCGEN_LLM_API_KEY", ""),
"model": os.environ.get("DOCGEN_LLM_MODEL") or DEFAULT_LLM_MODEL,
"timeout": float(os.environ.get("DOCGEN_LLM_TIMEOUT") or DEFAULT_LLM_TIMEOUT),
}
def _extract_text(payload: dict[str, Any]) -> str:
try:
choices = payload.get("choices") or []
if choices:
content = choices[0].get("message", {}).get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(
str(c.get("text", "")) if isinstance(c, dict) else str(c) for c in content
)
except Exception:
pass
return ""
async def _chat_once(
client: httpx.AsyncClient,
cfg: dict[str, Any],
system: str,
user: str,
*,
temperature: float,
max_tokens: int,
json_mode: bool,
timeout: float | None = None,
) -> str:
headers = {"Content-Type": "application/json"}
if cfg["api_key"]:
headers["Authorization"] = f"Bearer {cfg['api_key']}"
body: dict[str, Any] = {
"model": cfg["model"],
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if json_mode:
body["response_format"] = {"type": "json_object"}
resp = await client.post(
f"{cfg['base_url']}/chat/completions",
headers=headers,
json=body,
timeout=timeout if timeout is not None else cfg["timeout"],
)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, dict):
raise LLMError("LLM 响应形态异常(非 JSON 对象)")
text = _extract_text(payload)
if not text:
raise LLMError("LLM 响应缺少 choices[0].message.content")
return text
async def _complete(
system: str,
user: str,
*,
temperature: float,
max_tokens: int,
json_mode: bool,
timeout: float | None = None,
) -> str:
cfg = _llm_config()
last_exc: BaseException | None = None
async with httpx.AsyncClient() as client:
for attempt in range(MAX_ATTEMPTS):
try:
return await _chat_once(
client, cfg, system, user,
temperature=temperature, max_tokens=max_tokens, json_mode=json_mode,
timeout=timeout,
)
except LLMError:
raise
except (httpx.HTTPStatusError, httpx.HTTPError, httpx.TimeoutException) as exc:
last_exc = exc
status = getattr(getattr(exc, "response", None), "status_code", None)
retryable = status is None or status == 429 or status >= 500
if not retryable or attempt == MAX_ATTEMPTS - 1:
raise LLMError(f"LLM 调用失败(HTTP {status or '网络'}):{exc}") from exc
await asyncio.sleep(BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)])
raise LLMError(f"LLM 调用失败:{last_exc}")
async def complete_text(
system: str,
user: str,
*,
temperature: float = 0.3,
max_tokens: int = 8000,
timeout: float | None = None,
) -> str:
"""文本补全。失败抛 LLMError。"""
return await _complete(
system, user, temperature=temperature, max_tokens=max_tokens, json_mode=False,
timeout=timeout,
)
def _repair_json(text: str) -> str:
text = text.strip()
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:]
text = text.strip()
if not text:
raise LLMError("LLM 返回空内容")
if text.startswith("{"):
return text
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return text[start:end + 1]
raise LLMError("LLM 输出中未找到 JSON 对象")
async def complete_json(
system: str,
user: str,
*,
temperature: float = 0.3,
max_tokens: int = 8000,
) -> dict[str, Any]:
"""JSON 补全(json_object + 一次修复重试)。"""
text = await _complete(
system, user, temperature=temperature, max_tokens=max_tokens, json_mode=True
)
try:
return json.loads(text)
except ValueError:
pass
text2 = await _complete(
system + "\n(重要:你的回答必须是单个合法 JSON 对象,不要输出其他任何内容)",
user,
temperature=temperature,
max_tokens=max_tokens,
json_mode=True,
)
try:
return json.loads(text2)
except ValueError:
repaired = _repair_json(text2)
return json.loads(repaired)
@@ -0,0 +1,113 @@
"""outline.py —— propose_paper_outline:建 papers 行 → search_refs 注入线索 → LLM 出大纲 → 预填 sections。"""
from __future__ import annotations
import uuid
from typing import Any
from ...db import db, dumps_json, now_iso
from . import llm, search
_OUTLINE_SYSTEM = (
"你是植物学论文撰写助手。根据用户给出的论文主题与写作要求,生成论文大纲。"
"只输出一个合法 JSON 对象,结构为:"
'{"title": 论文标题, "abstract_expect": 摘要预期内容(一句话), '
'"sections": [{"section_id": "s1", "title": "节标题", "key_points": ["要点1", "要点2"], "target_words": 1200}, ...]}。'
"章节 5-6 节,覆盖 引言/材料与方法/结果/讨论/结论 等综述常见结构;target_words 每节 1200-2500。"
)
_DEFAULT_SECTIONS = [
("s1", "引言"),
("s2", "材料与方法"),
("s3", "结果"),
("s4", "讨论"),
("s5", "结论与展望"),
]
async def propose_paper_outline(
topic: str,
requirements: str = "",
paper_type: str = "",
language: str = "",
length: str = "",
format_req: str = "",
style: str = "",
kb_hints: list[dict[str, Any]] | None = None,
template_key: str = "",
) -> dict[str, Any]:
# 1. 知识库线索:优先用调用方传入的 kb_hints,否则现场 search_refs(失败静默降级)
hints = list(kb_hints or [])
if not hints:
try:
r = await search.search_refs(query=topic, top_k=8)
hints = list(r.get("items") or [])
except Exception:
hints = []
# 2. LLM 出大纲
user = f"论文主题:{topic}\n"
if paper_type:
user += f"论文类型:{paper_type}\n"
if requirements:
user += f"写作要求:{requirements}\n"
if language:
user += f"语言:{language}\n"
if length:
user += f"篇幅:{length}\n"
if format_req:
user += f"格式:{format_req}\n"
if style:
user += f"风格:{style}\n"
if hints:
user += "可参考知识库文献线索(仅选题依据,非硬性引用):\n" + "\n".join(
f"- {h.get('title', '')}: {str(h.get('snippet', ''))[:120]}" for h in hints[:5]
)
try:
outline = await llm.complete_json(_OUTLINE_SYSTEM, user)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"大纲生成失败:{exc}"}
title = str(outline.get("title") or topic).strip() or topic
sections = outline.get("sections") or []
if not sections:
sections = [
{"section_id": sid, "title": t, "key_points": [], "target_words": 1500}
for sid, t in _DEFAULT_SECTIONS
]
# 3. 落库 papers + paper_sections
paper_id = uuid.uuid4().hex[:16]
await db.execute(
"INSERT INTO papers (id, title, paper_type, language, format_req, style, status, brief_json, outline_json, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, 'outline', ?, ?, ?, ?)",
(
paper_id, title, paper_type, language, format_req, style,
dumps_json({
"topic": topic, "requirements": requirements, "length": length,
"kb_hints": hints, "template_key": template_key,
}),
dumps_json({
"title": title,
"abstract_expect": outline.get("abstract_expect", ""),
"sections": sections,
}),
now_iso(), now_iso(),
),
)
for s in sections:
await db.execute(
"INSERT INTO paper_sections (id, paper_id, section_id, title, key_points, status, updated_at) "
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
(
uuid.uuid4().hex[:16], paper_id,
s.get("section_id") or "s1", s.get("title", ""),
dumps_json(s.get("key_points") or []), now_iso(),
),
)
return {
"ok": True,
"paper_id": paper_id,
"title": title,
"abstract_expect": outline.get("abstract_expect", ""),
"sections": sections,
}
@@ -0,0 +1,101 @@
"""search.py —— vstore 8094 知识库检索客户端(search_refs 后端)。
- httpx 裸调 GET /api/vstore/search-chunks
- 失败不抛异常:返回 {"ok": False, "error": {...}},工具层降级为空结果
- vstore chunk 条目无 doi/pmid/journal/year(仅 doc_id/doc_name/snippet/score),
故 search_refs 的 meta 为尽力而为(通常为空)→ 引用多为 title kind → 降级格式 + disclaimer
"""
from __future__ import annotations
import asyncio
import os
from typing import Any
import httpx
DEFAULT_VSTORE_BASE_URL = "http://127.0.0.1:8094"
RETRY_BACKOFF = (0.5, 1.0, 2.0)
MAX_RETRIES = 2
DEFAULT_TIMEOUT = 30.0
_client: httpx.AsyncClient | None = None
def _base_url() -> str:
return (os.environ.get("VSTORE_BASE_URL") or DEFAULT_VSTORE_BASE_URL).rstrip("/")
def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient(
base_url=_base_url(), timeout=httpx.Timeout(DEFAULT_TIMEOUT, connect=5.0)
)
return _client
def set_client(client: httpx.AsyncClient) -> None:
"""注入 client(测试用 MockTransport)。"""
global _client
_client = client
async def close_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
async def search_chunks(
query: str,
*,
top_k: int = 8,
min_score: float = 0.30,
timeout: float = 12.0,
) -> dict[str, Any]:
"""GET /api/vstore/search-chunks。失败返回 {ok:False, error:{...}}(不抛)。"""
client = get_client()
last_exc: BaseException | None = None
for attempt in range(MAX_RETRIES + 1):
try:
resp = await client.get(
"/api/vstore/search-chunks",
params={"q": query, "top_k": top_k, "min_score": min_score},
timeout=timeout,
)
if resp.status_code < 500:
try:
payload = resp.json()
except ValueError:
return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 响应非 JSON"}}
if not isinstance(payload, dict):
return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 响应形态异常"}}
return payload
last_exc = RuntimeError(f"vstore {resp.status_code}")
except (httpx.HTTPError, httpx.TimeoutException) as exc:
last_exc = exc
if attempt < MAX_RETRIES:
await asyncio.sleep(RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF) - 1)])
return {"ok": False, "error": {"code": "UPSTREAM_DOWN", "message": f"vstore 不可达:{last_exc}"}}
async def search_refs(paper_id: str = "", query: str = "", top_k: int = 8) -> dict[str, Any]:
"""search_refs 工具:透传 vstore 检索,映射为 {doc_id,title,snippet,score,meta}。"""
if not query:
return {"ok": False, "code": "BAD_REQUEST", "message": "缺少检索词 query"}
r = await search_chunks(query, top_k=max(1, min(int(top_k), 20)))
if not r.get("ok"):
return {"ok": True, "items": [], "warning": (r.get("error") or {}).get("message", "")}
items: list[dict[str, Any]] = []
for it in r.get("items") or []:
if not isinstance(it, dict):
continue
items.append({
"doc_id": it.get("doc_id") or "",
"title": it.get("doc_name") or it.get("doc_id") or "",
"snippet": it.get("snippet") or "",
"score": it.get("score"),
"meta": {}, # vstore chunk 无 doi/pmid/journal/year,尽力为空
})
return {"ok": True, "items": items}
@@ -0,0 +1,251 @@
"""write.py —— 章节撰写与修订(write_paper_section / revise_paper_section / revise_paper)。"""
from __future__ import annotations
from typing import Any
from ...db import db, dumps_json, loads_json, now_iso
from . import llm
from .cite import find_cite_numbers, render_gbt7714
_WRITE_SYSTEM = (
"你是植物学论文撰写助手,正在撰写论文的某一章节。"
"引用文献一律用 [n] 指代,n 必须取自下方提供的全局编号列表;如需引用新文献,编号按当前最大编号依次 +1。"
"你只能引用「知识库线索」中列出的文献;新引用的元数据只能照抄线索里的 title 和 doc_id。"
"知识库线索里没有 DOI/PMID/作者/期刊/年份,这些字段一律输出空字符串,禁止编造任何 DOI/PMID/作者/期刊。"
"只输出一个合法 JSON 对象:{\"markdown\": 章节正文(不含章标题), \"citations_used\": "
"[{\"temp_n\": 编号, \"title\": \"...\", \"authors\": \"\", \"journal\": \"\", "
"\"year\": \"\", \"doi\": \"\", \"pmid\": \"\", \"source_doc_id\": \"...\", \"snippet\": \"...\"}]}。"
"citations_used 必须列出本节正文中出现的每一个 [n] 引用(既有编号也列出其已有元数据)。"
)
_REVISE_SYSTEM = (
"你是植物学论文撰写助手,正在按用户意见修订论文的某一章节。"
"保持引用协议不变:[n] 指代,n 只能取自全局编号列表或最大编号 +1;禁止杜撰元数据。"
"只输出一个合法 JSON 对象:{\"markdown\": 修订后的章节正文, \"citations_used\": [...]}。"
)
# ---------------------------------------------------------------------------
# 读取辅助
# ---------------------------------------------------------------------------
async def _get_paper(paper_id: str) -> dict[str, Any] | None:
row = await db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,))
return dict(row) if row else None
async def _get_sections(paper_id: str) -> list[dict[str, Any]]:
rows = await db.fetchall(
"SELECT * FROM paper_sections WHERE paper_id = ? ORDER BY rowid", (paper_id,)
)
return [dict(r) for r in rows]
async def _get_citations(paper_id: str) -> list[dict[str, Any]]:
rows = await db.fetchall(
"SELECT * FROM citations WHERE paper_id = ? ORDER BY temp_n", (paper_id,)
)
return [dict(r) for r in rows]
def _global_citations_text(citations: list[dict[str, Any]]) -> str:
if not citations:
return "(当前无已编号文献;新引用从 [1] 开始)"
return "\n".join(f"[{c['temp_n']}] {c['ref_text']}" for c in citations)
def _max_temp_n(citations: list[dict[str, Any]]) -> int:
return max((c.get("temp_n") or 0 for c in citations), default=0)
def _build_write_user(paper: dict, outline: dict, sec: dict, cit_text: str, style: str, cite_mode: str) -> str:
parts: list[str] = []
parts.append(f"论文标题:{paper.get('title', '')}")
parts.append(f"论文类型:{paper.get('paper_type') or '综述'}")
parts.append(f"语言:{paper.get('language') or '中文'}")
parts.append(f"格式要求:{paper.get('format_req') or '无'}")
parts.append(f"写作风格:{style or paper.get('style') or '学术严谨'}")
parts.append("")
parts.append("论文大纲:")
for s in outline.get("sections") or []:
mark = "(当前撰写节)" if s.get("section_id") == sec.get("section_id") else ""
parts.append(f"- {s.get('section_id')}: {s.get('title')}{mark} 要点:{';'.join(s.get('key_points') or [])}")
parts.append("")
parts.append(f"现在撰写章节「{sec.get('title', '')}」(section_id={sec.get('section_id')})。")
if sec.get("key_points"):
parts.append("本节要点:" + ";".join(loads_json(sec.get("key_points"), [])))
parts.append("")
parts.append("全局已编号文献列表([n] 引用只能从这里取值,或从最大编号 +1 起新增):")
parts.append(cit_text)
brief = loads_json(paper.get("brief_json"), {})
kb_hints = brief.get("kb_hints") or []
if kb_hints:
parts.append("")
parts.append("知识库线索(新引用只能从这里选,元数据只照抄 title/doc_id):")
for h in kb_hints:
parts.append(f"- title={h.get('title', '')} | doc_id={h.get('doc_id', '')} | snippet={str(h.get('snippet', ''))[:120]}")
return "\n".join(parts)
def _validate_numbers(markdown: str, valid: set[int]) -> list[int]:
return [n for n in find_cite_numbers(markdown) if n not in valid]
def _kind_of(meta: dict[str, Any]) -> str:
if meta.get("doi"):
return "doi"
if meta.get("pmid"):
return "pmid"
return "title"
def _sanitize_citation(meta: dict[str, Any]) -> dict[str, Any]:
"""丢弃 LLM 可能杜撰的 doi/pmid/authors/journal/year(知识库线索无这些字段),
只保留 title/source_doc_id/snippet,强制走 title kind 降级格式。"""
return {
"title": (meta.get("title") or "").strip(),
"source_doc_id": meta.get("source_doc_id") or "",
"snippet": meta.get("snippet") or "",
}
async def _persist_new_citations(paper_id: str, section_id: str, used: list[Any], existing_nums: set[int]) -> list[dict[str, Any]]:
"""把 citations_used 中的新引用(temp_n 不在既有集合)落库,返回新增摘要。"""
new_citations: list[dict[str, Any]] = []
seen_new: set[int] = set()
for c in used:
if not isinstance(c, dict):
continue
tn = int(c.get("temp_n") or 0)
if not tn or tn in existing_nums or tn in seen_new:
continue
seen_new.add(tn)
meta = _sanitize_citation(c)
ref_text = render_gbt7714(meta)
await db.execute(
"INSERT INTO citations (paper_id, section_id, temp_n, kind, ref_text, source_doc_id, snippet, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
paper_id, section_id, tn, _kind_of(meta), ref_text,
meta.get("source_doc_id") or "", meta.get("snippet") or "", now_iso(),
),
)
new_citations.append({"temp_n": tn, "ref_text": ref_text})
return new_citations
# ---------------------------------------------------------------------------
# 写节
# ---------------------------------------------------------------------------
async def write_paper_section(paper_id: str, section_id: str, style: str = "", cite_mode: str = "") -> dict[str, Any]:
paper = await _get_paper(paper_id)
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = await _get_sections(paper_id)
sec = next((s for s in sections if s["section_id"] == section_id), None)
if not sec:
return {"ok": False, "code": "NOT_FOUND", "message": "章节不存在"}
citations = await _get_citations(paper_id)
outline = loads_json(paper.get("outline_json"), {})
cit_text = _global_citations_text(citations)
existing_nums = {c.get("temp_n") for c in citations}
user = _build_write_user(paper, outline, sec, cit_text, style, cite_mode)
markdown = ""
used: list[Any] = []
bad: list[int] = []
for attempt in range(2):
try:
res = await llm.complete_json(_WRITE_SYSTEM, user, max_tokens=8000)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"写节 LLM 失败:{exc}"}
markdown = res.get("markdown") or ""
used = res.get("citations_used") or []
used_nums = {int(c.get("temp_n") or 0) for c in used if isinstance(c, dict)}
valid = existing_nums | used_nums
bad = _validate_numbers(markdown, valid)
if not bad:
break
user += f"\n(上次正文出现的引用编号 {sorted(bad)} 非法;只能使用已有编号或从 {_max_temp_n(citations) + 1} 起的新编号,重试。)"
else:
return {"ok": False, "code": "CITE_VALIDATION_FAILED", "message": f"引用编号非法:{sorted(bad)}"}
new_citations = await _persist_new_citations(paper_id, section_id, used, existing_nums)
await db.execute(
"UPDATE paper_sections SET markdown = ?, word_count = ?, status = 'done', updated_at = ? WHERE paper_id = ? AND section_id = ?",
(markdown, len(markdown), now_iso(), paper_id, section_id),
)
await db.execute("UPDATE papers SET status = 'writing', updated_at = ? WHERE id = ?", (now_iso(), paper_id))
return {"ok": True, "section_id": section_id, "status": "done", "markdown": markdown,
"citations_used": used, "new_citations": new_citations}
# ---------------------------------------------------------------------------
# 修订
# ---------------------------------------------------------------------------
async def revise_paper_section(paper_id: str, section_id: str, instruction: str) -> dict[str, Any]:
paper = await _get_paper(paper_id)
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = await _get_sections(paper_id)
sec = next((s for s in sections if s["section_id"] == section_id), None)
if not sec:
return {"ok": False, "code": "NOT_FOUND", "message": "章节不存在"}
citations = await _get_citations(paper_id)
outline = loads_json(paper.get("outline_json"), {})
existing_nums = {c.get("temp_n") for c in citations}
user = _build_write_user(paper, outline, sec, _global_citations_text(citations), "", "")
user += "\n\n原章节正文:\n" + (sec.get("markdown") or "")
user += f"\n\n修订要求:{instruction}"
markdown = ""
used: list[Any] = []
bad: list[int] = []
for attempt in range(2):
try:
res = await llm.complete_json(_REVISE_SYSTEM, user, max_tokens=8000)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"修订 LLM 失败:{exc}"}
markdown = res.get("markdown") or ""
used = res.get("citations_used") or []
used_nums = {int(c.get("temp_n") or 0) for c in used if isinstance(c, dict)}
valid = existing_nums | used_nums
bad = _validate_numbers(markdown, valid)
if not bad:
break
user += f"\n(引用编号 {sorted(bad)} 非法,重试。)"
else:
return {"ok": False, "code": "CITE_VALIDATION_FAILED", "message": f"引用编号非法:{sorted(bad)}"}
new_citations = await _persist_new_citations(paper_id, section_id, used, existing_nums)
await db.execute(
"UPDATE paper_sections SET markdown = ?, word_count = ?, status = 'done', updated_at = ? WHERE paper_id = ? AND section_id = ?",
(markdown, len(markdown), now_iso(), paper_id, section_id),
)
return {"ok": True, "section_id": section_id, "markdown": markdown,
"citations_used": used, "citation_delta": len(new_citations), "new_citations": new_citations}
async def revise_paper(paper_id: str, instruction: str) -> dict[str, Any]:
"""LLM 判定受影响章节,返回受影响节清单 + 修订计划(由调用方逐节 revise_paper_section)。"""
paper = await _get_paper(paper_id)
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = await _get_sections(paper_id)
outline = loads_json(paper.get("outline_json"), {})
system = (
"你是论文修订计划助手。根据用户修订意见与论文大纲,判定需要修改哪些章节。"
"只输出一个合法 JSON 对象:{\"affected_section_ids\": [\"s1\", ...], \"plan\": 修订计划(一句话)}。"
)
sec_list = "\n".join(f"{s['section_id']}: {s['title']}" for s in sections)
user = f"论文标题:{paper.get('title')}\n大纲章节:\n{sec_list}\n\n修订意见:{instruction}"
try:
res = await llm.complete_json(system, user, max_tokens=2000)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"修订计划 LLM 失败:{exc}"}
affected = [s for s in (res.get("affected_section_ids") or []) if any(x["section_id"] == s for x in sections)]
return {"ok": True, "affected_section_ids": affected, "plan": res.get("plan", "")}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# build.sh —— PAPER 服务编译硬化(复制自 bioflow_service/build.sh,白名单改造)。
# 白名单(明文):app/main.py(uvicorn 入口)、app/config.py(运维可读面)、
# app/schemas.py(常量面)、app/db.py(aiosqlite 协程取消热区——Cython 协程断流断言
# abort 是既有教训,明文保留)、app/mcp.py(MCP 工具层,mcp 依赖 pydantic + async 工具)、
# services/paper/{llm,search,write,outline,complete}.py(长 LLM/httpx/aiosqlite 调用,
# 断流崩溃高危点,明文走 CPython 原生协程)+ 全部 __init__.py。
# 仅纯同步逻辑(cite.py 等)可编译。
# 用法:build.sh <source-root> <output-dir> [python]
set -euo pipefail
umask 027
SOURCE_ROOT="${1:?Usage: $0 <source-root> <output-dir> [python]}"
OUTPUT_DIR="${2:?Usage: $0 <source-root> <output-dir> [python]}"
PYTHON="${3:-python3}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VISIBILITY_TOOL="${ELF_VISIBILITY_TOOL:-$SCRIPT_DIR/../tooling/compile/tools/elf_visibility.py}"
SOURCE_ROOT="$(realpath "$SOURCE_ROOT")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
[[ -d "$SOURCE_ROOT" ]] || { echo "Paper service source missing: $SOURCE_ROOT" >&2; exit 2; }
[[ -x "$PYTHON" ]] || { echo "Python is not executable: $PYTHON" >&2; exit 2; }
[[ -f "$VISIBILITY_TOOL" ]] || { echo "ELF visibility tool missing: $VISIBILITY_TOOL" >&2; exit 2; }
for command_name in gcc strip rsync; do command -v "$command_name" >/dev/null; done
case "$OUTPUT_DIR" in
"/"|"$(realpath -m "$HOME")"|"$SOURCE_ROOT")
echo "Unsafe output directory: $OUTPUT_DIR" >&2; exit 2 ;;
esac
[[ ! -e "$OUTPUT_DIR" ]] || { echo "Output exists; refusing to overwrite: $OUTPUT_DIR" >&2; exit 2; }
echo "[PAPER BUILD 1/5] Copying source (excluding tests)"
mkdir -p "$OUTPUT_DIR"
rsync -a --exclude '__pycache__' --exclude 'test_*.py' --exclude 'data' --exclude '.env' \
--exclude 'build.sh' --exclude 'restart.sh' --exclude 'pytest.ini' \
"$SOURCE_ROOT/" "$OUTPUT_DIR/"
WHITELIST_PY=(
"app/main.py"
"app/config.py"
"app/schemas.py"
"app/db.py"
"app/mcp.py"
"app/services/paper/llm.py"
"app/services/paper/search.py"
"app/services/paper/write.py"
"app/services/paper/outline.py"
"app/services/paper/complete.py"
)
_is_whitelisted() {
local rel="$1"
[[ "$(basename "$rel")" == "__init__.py" ]] && return 0
for w in "${WHITELIST_PY[@]}"; do
case "$rel" in
$w) return 0 ;;
esac
done
return 1
}
echo "[PAPER BUILD 2/5] Discovering Python modules"
PY_FILES=()
while IFS= read -r -d '' path; do PY_FILES+=("$path"); done \
< <(find "$OUTPUT_DIR" -type f -name '*.py' -print0)
FILTERED=()
for py_file in "${PY_FILES[@]}"; do
rel="${py_file#"$OUTPUT_DIR"/}"
if _is_whitelisted "$rel"; then
echo " [whitelist] $rel (明文保留)"
continue
fi
FILTERED+=("$py_file")
done
PY_FILES=("${FILTERED[@]}")
TOTAL="${#PY_FILES[@]}"
(( TOTAL > 0 )) || { echo "No paper service modules selected" >&2; exit 3; }
INCLUDE_PY="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("INCLUDEPY") or "")')"
EXT_SUFFIX="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("EXT_SUFFIX") or ".so")')"
echo "[PAPER BUILD 3/5] Cython preflight ($TOTAL modules)"
COUNT=0; FAILURES=0
for py_file in "${PY_FILES[@]}"; do
COUNT=$((COUNT+1))
directory="$(dirname "$py_file")"
module="$(basename "$py_file" .py)"
c_file="$directory/$module.c"
echo " [$COUNT/$TOTAL] ${py_file#"$OUTPUT_DIR"/}"
if ! "$PYTHON" -m cython -3 \
--directive always_allow_keywords=true,annotation_typing=False \
--no-docstrings "$py_file" -o "$c_file" 2>/dev/null; then
FAILURES=$((FAILURES+1))
fi
done
(( FAILURES == 0 )) || { echo "Paper service Cython failed: $FAILURES" >&2; exit 3; }
echo "[PAPER BUILD 4/5] GCC hidden visibility + strip"
COUNT=0
for py_file in "${PY_FILES[@]}"; do
COUNT=$((COUNT+1))
directory="$(dirname "$py_file")"
module="$(basename "$py_file" .py)"
c_file="$directory/$module.c"
so_file="$directory/$module$EXT_SUFFIX"
gcc -shared -pthread -fPIC -O2 -fvisibility=hidden \
-I"$INCLUDE_PY" -o "$so_file" "$c_file"
strip --strip-unneeded "$so_file"
"$PYTHON" -B "$VISIBILITY_TOOL" --hide-non-pyinit --check "$so_file" >/dev/null
rm -f -- "$c_file" "$py_file"
done
echo "[PAPER BUILD 5/5] Final checks"
SO_COUNT="$(find "$OUTPUT_DIR" -type f -name '*.so' | wc -l)"
[[ "$SO_COUNT" -eq "$TOTAL" ]] || { echo "count mismatch: $TOTAL vs $SO_COUNT" >&2; exit 4; }
if find "$OUTPUT_DIR" -type f -name '*.c' -print -quit | grep -q .; then
echo "Generated C source remains" >&2; exit 4
fi
echo "Paper service compile+harden complete: modules=$SO_COUNT output=$OUTPUT_DIR"
+6
View File
@@ -0,0 +1,6 @@
[pytest]
python_files = test_*.py
markers =
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
paper_mcp: MCP 协议互操作测试
asyncio_mode = auto
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# restart.sh —— PAPER 服务重启脚本(8097;不触碰生产服务 8090/8091/8092/8093/8094/8098)。
# 零硬编码:venv/python/仓库路径全部从脚本位置推导或环境变量覆盖。
# 用法: restart.sh [python] [port]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PYTHON="${1:-$REPO_ROOT/../plantai-rag-new-venv/bin/python}"
PORT="${2:-8097}"
HOST="${PAPER_BIND_HOST:-127.0.0.1}"
[[ -x "$PYTHON" ]] || { echo "Python not found: $PYTHON" >&2; exit 2; }
APP_MODULE="app.main:app"
if command -v lsof >/dev/null 2>&1; then
PID="$(lsof -ti tcp:"$PORT" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "$PID" ]]; then
echo "[restart] 停止 $PORT 旧进程 pid=$PID"
kill $PID 2>/dev/null || true
sleep 2
fi
fi
cd "$SCRIPT_DIR"
# 加载运行时配置(.env 不入 git):services/paper/llm.py 直接读 DOCGEN_LLM_* 裸键,
# search.py 读 VSTORE_BASE_URL——需 set -a 显式导出(同 bioflow_service/restart.sh 先例)。
set -a
[ -f .env ] && source .env
set +a
echo "[restart] 启动论文撰写服务 :$PORT($PYTHON)"
nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \
> "$SCRIPT_DIR/paper.log" 2>&1 &
echo "[restart] 完成;日志 paper_writer_service/paper.log"
+5
View File
@@ -76,6 +76,11 @@ class Settings(BaseSettings):
bioflow_enabled: bool = False # 三形态部署开关(默认关:只部署 rag 无生信痕迹)
bioflow_service_url: str = "http://127.0.0.1:8098" # 生信服务 8098(BFF 流式反代目标)
# ---- 论文撰写服务(paper_writer_service 8097;PAPER_WRITER_ENABLED 为假时路由不注册)----
paper_writer_enabled: bool = False
paper_writer_url: str = "http://127.0.0.1:8097"
paper_writer_read_timeout: float = 360.0 # write_paper_section 同步长调用读超时(秒)
# ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)----
frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist
+3
View File
@@ -24,6 +24,7 @@ from .config import get_settings
from .db import close_db, init_db
from .routers import capabilities, docgen, extraction, figures, health, kg, legacy, qa, sessions
from .routers import bioflow_proxy
from .routers import paper
from .routers import private_docs
from .upstream import close_client
@@ -59,6 +60,8 @@ def create_app() -> FastAPI:
app.include_router(private_docs.router) # 私有资料问答(FR-PRIV-004)
if get_settings().bioflow_enabled:
app.include_router(bioflow_proxy.router) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028)
if get_settings().paper_writer_enabled:
app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关)
@app.exception_handler(RequestValidationError)
async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
+69 -40
View File
@@ -184,6 +184,60 @@ def _read_artifact_file(settings: Any, rel: str) -> bytes | None:
return None
async def insert_artifact(
db: Database,
*,
session_id: str,
title: str,
markdown: str,
md_rel: str | None = None,
template_name: str = "",
template_key: str = "",
template_css: str = "",
html: str = "",
letterhead: str = "",
file_name: str = "",
) -> str:
"""写制品 md(+可选 html)落盘 + INSERT doc_artifacts,返回 artifact_id。
docgen 与论文撰写(paper)共用:paper 仅传 markdown/title/template_name。
"""
artifact_id = str(uuid.uuid4())
artifact_dir = _artifact_dir(get_settings())
if not md_rel:
md_rel = f"artifacts/{artifact_id}.md"
if markdown:
safe_rel = _safe_rel(md_rel)
if safe_rel:
path = artifact_dir / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
html_rel = ""
if html:
html_rel = f"artifacts/{artifact_id}.html"
safe_rel = _safe_rel(html_rel)
if safe_rel:
path = artifact_dir / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(html, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 模版 HTML 落盘失败(不影响返回): %s", exc)
html_rel = ""
await db.execute(
"INSERT INTO doc_artifacts (id, session_id, status, title, template_name, "
"template_key, template_css, md_path, docx_path, pdf_path, html_path, letterhead, file_name, error, created_at) "
"VALUES (?, ?, 'done', ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?)",
(artifact_id, session_id, title,
template_name, template_key, template_css, md_rel,
html_rel or None, letterhead, file_name, _db_now()),
)
return artifact_id
# ---------------------------------------------------------------------------
# 制品目录配额 / TTL 惰性清理(TL 裁决 2026-09-03:V1 由 BFF 实现)
# ---------------------------------------------------------------------------
@@ -516,13 +570,7 @@ async def _run_docgen_task(
on_progress=on_progress,
)
# ---- 以下与修复前同步路径一致:落盘 + artifact 落库 + 配额复查 ----
artifact_id = str(uuid.uuid4())
markdown = str(result.get("markdown") or "")
# pipeline 的 md_path 在 DOCGEN_ROOT 为绝对路径时也是绝对路径——
# 落库要求相对路径(_safe_rel 拒绝绝对路径,否则导出报「制品内容缺失」,
# 2026-09-10 dev 自测实测)。绝对且在制品根内 → 相对化;否则回退
# artifacts/<artifact_id>.md
# ---- 落盘 + artifact 落库(抽公共 insert_artifact,论文撰写共用)----
artifact_dir = _artifact_dir(get_settings())
md_rel = ""
raw_md_path = str(result.get("md_path") or "")
@@ -535,17 +583,6 @@ async def _run_docgen_task(
md_rel = raw_md_path
except ValueError:
md_rel = ""
if not md_rel:
md_rel = f"artifacts/{artifact_id}.md"
if markdown:
safe_rel = _safe_rel(md_rel)
if safe_rel:
path = artifact_dir / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
template_key = str(result.get("template_key") or "")
if not template_key and template is not None and template.get("source") == "builtin":
@@ -557,34 +594,26 @@ async def _run_docgen_task(
(template or {}).get("name", "") or (template or {}).get("title", "")
)
html = str(result.get("html") or "")
html_rel = ""
if html:
html_rel = f"artifacts/{artifact_id}.html"
safe_rel = _safe_rel(html_rel)
if safe_rel:
path = _artifact_dir(get_settings()) / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(html, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 模版 HTML 落盘失败(不影响返回): %s", exc)
html_rel = ""
try:
quota_after = await _check_quota(db, get_settings())
if quota_after is not None:
logger.warning("docgen: 生成后制品目录已超配额(下一次 generate/export 将被拒绝)")
except Exception as exc:
logger.warning("docgen: 生成后配额复查失败(忽略): %s", exc)
await db.execute(
"INSERT INTO doc_artifacts (id, session_id, status, title, template_name, "
"template_key, template_css, md_path, docx_path, pdf_path, html_path, letterhead, file_name, error, created_at) "
"VALUES (?, ?, 'done', ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?)",
(artifact_id, session_id, title,
tpl_display, template_key, template_css, md_rel,
html_rel or None,
str(result.get("letterhead") or "")[:60],
str(result.get("file_name") or name_for_prompt(description) or title or "")[:60],
_db_now()),
artifact_id = await insert_artifact(
db,
session_id=session_id,
title=title,
markdown=str(result.get("markdown") or ""),
md_rel=md_rel or None,
template_name=tpl_display,
template_key=template_key,
template_css=template_css,
html=html,
letterhead=str(result.get("letterhead") or "")[:60],
file_name=str(result.get("file_name") or name_for_prompt(description) or title or "")[:60],
)
# 终态 progress 快照:全部节 done(限频写库可能截断最后一帧)
final_progress = [
+91
View File
@@ -0,0 +1,91 @@
"""routers/paper.py —— 论文撰写路由(对话大脑)。
条件注册(PAPER_WRITER_ENABLED 为假不注册,同 bioflow_proxy 模式)。
端点:health(探活反代)/ turns(一轮状态机)/ confirm / resume / progress / citations。
"""
from __future__ import annotations
import asyncio
from typing import Any
import httpx
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from ..config import get_settings
from ..db import Database, get_db
from ..services.paper import agent
from ..services.paper.mcp_client import PaperMcpClient
router = APIRouter(prefix="/api/paper", tags=["paper"])
class PaperTurnRequest(BaseModel):
session_id: str
text: str
paper_id: str | None = None
template_key: str = ""
class PaperConfirmRequest(BaseModel):
session_id: str
def _client() -> PaperMcpClient:
s = get_settings()
return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout)
@router.get("/health")
async def paper_health() -> dict[str, Any]:
"""探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。"""
s = get_settings()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{s.paper_writer_url.rstrip('/')}/health")
resp.raise_for_status()
return resp.json()
except Exception: # noqa: BLE001
return {"ok": False, "status": "offline"}
@router.post("/turns")
async def paper_turn(payload: PaperTurnRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
blocks = await agent.handle_turn(db, payload.session_id, payload.text, payload.paper_id, payload.template_key)
return {"ok": True, "blocks": blocks}
@router.post("/papers/{paper_id}/confirm")
async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
return {"ok": True, "paper_id": paper_id}
@router.post("/papers/{paper_id}/resume")
async def paper_resume(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
return {"ok": True, "paper_id": paper_id}
@router.get("/papers/{paper_id}")
async def paper_progress(paper_id: str) -> dict[str, Any]:
client = _client()
try:
return await client.call_tool("get_paper_progress", {"paper_id": paper_id})
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": "UPSTREAM_DOWN", "message": str(exc)}
@router.get("/papers/{paper_id}/citations/{n}")
async def paper_citation(paper_id: str, n: int) -> dict[str, Any]:
client = _client()
try:
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": "UPSTREAM_DOWN", "message": str(exc)}
if not paper.get("ok"):
return paper
for ref in paper.get("references") or []:
if ref.get("n") == n:
return {"ok": True, "citation": ref}
return {"ok": False, "code": "NOT_FOUND", "message": f"引用 [{n}] 不存在"}
+1 -11
View File
@@ -286,21 +286,11 @@ 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/bioflow 经 QA 门控/注入点生效)
# 会话开关注入上游(web_search/superpowers 经 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)
+5 -11
View File
@@ -30,7 +30,7 @@ from ..schemas import (
SessionOut,
SessionPatch,
)
from ..services.capabilities import sanitize_bioflow_meta, sanitize_capabilities
from ..services.capabilities import sanitize_capabilities
from ..upstream import envelope
logger = logging.getLogger(__name__)
@@ -159,17 +159,11 @@ async def patch_session(
)
if payload.meta is not None:
caps = sanitize_capabilities(payload.meta.get("capabilities"))
has_bioflow = "bioflow" in payload.meta
bioflow_patch = sanitize_bioflow_meta(payload.meta.get("bioflow"))
if caps or has_bioflow:
if caps:
meta = session.meta.model_dump()
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
merged_caps = dict(meta.get("capabilities") or {})
merged_caps.update(caps)
meta["capabilities"] = merged_caps
await db.execute(
"UPDATE sessions SET meta = ?, updated_at = ? WHERE id = ?",
(dumps_json(meta), _db_now(), session_id),
+53 -2
View File
@@ -99,6 +99,58 @@ class ArtifactBlock(BaseModel):
title: str = ""
template_name: str = ""
status: str = "done" # running|done|error(生成中/已就绪/失败)
paper_id: str = "" # 论文撰写功能:关联论文 id(DocPane 据此拉 citation popover)
citation_count: int = 0 # 论文撰写功能:引用条数
# ---------------------------------------------------------------------------
# §2c 论文块(paper-writer;BFF 仅持久化校验,字段契约归前端 Paper*Card)
# ---------------------------------------------------------------------------
class PaperBriefBlock(BaseModel):
"""论文引导表单卡:预填已抽取 brief 字段,缺失字段待补。"""
model_config = ConfigDict(extra="allow")
type: Literal["paper_brief"] = "paper_brief"
paper_id: str = ""
status: str = "pending" # pending|submitted
fields: list[dict[str, Any]] = Field(default_factory=list)
class PaperOutlineBlock(BaseModel):
"""论文大纲确认卡:章节勾选/编辑要点。"""
model_config = ConfigDict(extra="allow")
type: Literal["paper_outline"] = "paper_outline"
paper_id: str = ""
title: str = ""
status: str = "pending" # pending|confirmed
sections: list[dict[str, Any]] = Field(default_factory=list)
class PaperProgressBlock(BaseModel):
"""论文节级进度卡:逐节撰写状态。"""
model_config = ConfigDict(extra="allow")
type: Literal["paper_progress"] = "paper_progress"
paper_id: str = ""
status: str = "writing" # writing|done|error|cancelled
sections: list[dict[str, Any]] = Field(default_factory=list)
current_section_id: str = ""
class PaperRefsBlock(BaseModel):
"""论文参考文献卡:文末 GB/T 7714 列表 + disclaimer。"""
model_config = ConfigDict(extra="allow")
type: Literal["paper_refs"] = "paper_refs"
paper_id: str = ""
references: list[dict[str, Any]] = Field(default_factory=list)
disclaimer: str = ""
# ---------------------------------------------------------------------------
@@ -245,6 +297,7 @@ class PrivateQARequest(BaseModel):
Block = Union[
TextBlock, TableBlock, CodeBlock, StreamBlock,
PipelineBlock, ToolBlock, SourcesBlock, GraphBlock, ArtifactBlock,
PaperBriefBlock, PaperOutlineBlock, PaperProgressBlock, PaperRefsBlock,
BioflowPathBlock, BioflowProgressBlock, BioflowFormBlock, BioflowFileBlock,
BioflowArchiveBlock, BioflowQaBlock, BioflowReuseBlock,
PrivateDocBlock,
@@ -340,8 +393,6 @@ 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):
+1 -23
View File
@@ -39,13 +39,6 @@ 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 插件",
@@ -62,7 +55,7 @@ DEFAULTS: Dict[str, bool] = {
}
# 会话 meta.capabilities 白名单(允许 PATCH 写入的键)
SESSION_KEYS = ("memory", "web_search", "humanize", "bioflow")
SESSION_KEYS = ("memory", "web_search", "humanize")
def sanitize_capabilities(raw: Any) -> Dict[str, bool]:
@@ -82,21 +75,6 @@ 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,
View File
+314
View File
@@ -0,0 +1,314 @@
"""services/paper/agent.py —— 论文对话规则状态机(brief→outline→writing→complete/revise)。
LLM 仅做 brief 字段抽取(complete_json);大纲/正文/引用/合并全部由 paper 服务完成。
不引入 function calling(BFF 无工具循环先例)。助手消息由本模块进程内直写 messages 表。
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from ...config import get_settings
from ..docgen.llm import complete_json as llm_complete_json
from .mcp_client import PaperMcpClient, PaperMcpError
logger = logging.getLogger(__name__)
BRIEF_FIELDS = [
{"key": "topic", "label": "论文主题", "options": None},
{"key": "paper_type", "label": "论文类型", "options": ["综述", "研究论文", "开题报告", "实验报告"]},
{"key": "language", "label": "语言", "options": ["中文", "英文"]},
{"key": "length", "label": "篇幅(字数)", "options": ["3000字", "5000字", "8000字"]},
{"key": "format_req", "label": "格式要求", "options": ["GB/T 7714", "APA", "MLA"]},
{"key": "style", "label": "写作风格", "options": ["学术严谨", "通俗易懂"]},
{"key": "requirements", "label": "其他要求", "options": None},
]
REQUIRED_BRIEF_KEYS = ("topic", "paper_type", "language", "length")
_BRIEF_SYSTEM = (
"你是论文撰写需求抽取助手。从用户输入中抽取论文写作 brief。"
"只输出一个合法 JSON 对象:{\"topic\":\"\",\"paper_type\":\"\",\"language\":\"\","
"\"length\":\"\",\"format_req\":\"\",\"style\":\"\",\"requirements\":\"\"}。"
"抽取不到的字段填空字符串。"
)
def _db_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _text_block(html: str) -> dict[str, Any]:
return {"type": "text", "html": html}
def _new_client() -> PaperMcpClient:
s = get_settings()
return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout)
async def _write_message(
db,
session_id: str,
*,
kind: str = "paper",
status: str = "done",
blocks: list[dict[str, Any]],
text: str | None = None,
) -> str:
"""直写助手消息(kind=paper),返回 message_id。"""
mid = str(uuid.uuid4())
now = _db_now()
await db.execute(
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, created_at, updated_at) "
"VALUES (?, ?, 'assistant', ?, ?, ?, NULL, ?, ?, ?)",
(mid, session_id, kind, status, json.dumps(blocks, ensure_ascii=False), text, now, now),
)
await db.execute(
"UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, updated_at = ? WHERE id = ?",
(now, session_id),
)
return mid
async def extract_brief(text: str) -> dict[str, str]:
"""LLM 抽取 brief 字段(失败返回全空,走 paper_brief 卡兜底追问)。"""
try:
brief = await llm_complete_json(_BRIEF_SYSTEM, text, max_tokens=1000)
except Exception as exc: # noqa: BLE001
logger.warning("paper: brief 抽取失败:%s", exc)
brief = {}
return {f["key"]: str((brief or {}).get(f["key"]) or "").strip() for f in BRIEF_FIELDS}
def _missing_brief_keys(brief: dict[str, str]) -> list[str]:
return [k for k in REQUIRED_BRIEF_KEYS if not brief.get(k)]
# ---------------------------------------------------------------------------
# 状态机入口
# ---------------------------------------------------------------------------
async def handle_turn(db, session_id: str, text: str, paper_id: str | None = None, template_key: str = "") -> list[dict[str, Any]]:
"""一轮对话:推进状态机并落库助手消息,返回消息的 blocks 列表。"""
client = _new_client()
if not paper_id:
return await _trigger(db, session_id, text, client, template_key)
return await _followup(db, session_id, text, paper_id, client, template_key)
async def _trigger(db, session_id: str, text: str, client: PaperMcpClient, template_key: str = "") -> list[dict[str, Any]]:
brief = await extract_brief(text)
missing = _missing_brief_keys(brief)
if missing:
fields = [{**f, "value": brief.get(f["key"], "")} for f in BRIEF_FIELDS]
blocks = [
_text_block("好的,我来帮你写这篇论文。请补充以下信息(也可以直接打字告诉我):"),
{"type": "paper_brief", "paper_id": "", "status": "pending", "fields": fields},
]
await _write_message(db, session_id, blocks=blocks, text="请补充论文信息")
return blocks
outline = await client.call_tool("propose_paper_outline", {**brief, "template_key": template_key})
if not outline.get("ok"):
blocks = [_text_block(f"论文大纲生成失败:{outline.get('message', '服务未就绪')},请重试。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
return await _outline_message(db, session_id, outline)
async def _outline_message(db, session_id: str, outline: dict[str, Any]) -> list[dict[str, Any]]:
paper_id = outline["paper_id"]
sections = [
{"section_id": s.get("section_id"), "title": s.get("title", ""),
"key_points": s.get("key_points") or [], "selected": True}
for s in (outline.get("sections") or [])
]
blocks = [
_text_block(
f"已为你生成论文大纲《{outline.get('title', '')}》。"
"你可以勾选/编辑章节,或直接说修改意见;确认后我开始逐节撰写。"
),
{"type": "paper_outline", "paper_id": paper_id, "title": outline.get("title", ""),
"status": "pending", "sections": sections},
]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
_CANCEL_WORDS = ("暂停", "取消", "停止", "不写了", "终止", "中止", "算了")
def _is_cancel_request(text: str) -> bool:
s = (text or "").strip()
return bool(s) and any(w in s for w in _CANCEL_WORDS)
async def _followup(db, session_id: str, text: str, paper_id: str, client: PaperMcpClient, template_key: str = "") -> list[dict[str, Any]]:
prog = await client.call_tool("get_paper_progress", {"paper_id": paper_id})
if not prog.get("ok"):
blocks = [_text_block(f"未找到论文({paper_id}),请重新发起撰写。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
status = prog.get("status")
if _is_cancel_request(text):
res = await client.call_tool("cancel_paper", {"paper_id": paper_id})
if res.get("ok"):
blocks = [_text_block("论文已取消。如需重新撰写,请告诉我新主题。")]
else:
blocks = [_text_block(f"取消失败:{res.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
if status == "complete":
return await _revise(db, session_id, paper_id, text, client)
if status == "cancelled":
blocks = [_text_block("论文已取消。如需重新撰写,请告诉我新主题。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
if status in ("outline", "writing"):
# 大纲/撰写阶段:修改意见 → 用原始 brief + 修改意见重新生成大纲(原大纲作废)
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
ob = (paper.get("brief") or {}) if paper.get("ok") else {}
outline = await client.call_tool("propose_paper_outline", {
"topic": ob.get("topic") or paper.get("title") or text,
"requirements": ";".join(x for x in [ob.get("requirements"), text] if x),
"paper_type": ob.get("paper_type") or "",
"language": ob.get("language") or "",
"length": ob.get("length") or "",
"format_req": ob.get("format_req") or "",
"template_key": template_key,
})
if outline.get("ok"):
await client.call_tool("cancel_paper", {"paper_id": paper_id})
return await _outline_message(db, session_id, outline)
blocks = [_text_block("大纲修改失败,请重试。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
blocks = [_text_block("论文撰写中,请稍候。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
async def _revise(db, session_id: str, paper_id: str, text: str, client: PaperMcpClient) -> list[dict[str, Any]]:
plan = await client.call_tool("revise_paper", {"paper_id": paper_id, "instruction": text})
if not plan.get("ok"):
blocks = [_text_block(f"修订计划生成失败:{plan.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
affected = plan.get("affected_section_ids") or []
if not affected:
blocks = [_text_block("没有需要修改的章节。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
for sid in affected:
res = await client.call_tool(
"revise_paper_section", {"paper_id": paper_id, "section_id": sid, "instruction": text}
)
if not res.get("ok"):
blocks = [_text_block(f"章节修订失败:{res.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text="修订失败")
return blocks
comp = await client.call_tool("complete_paper", {"paper_id": paper_id})
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
if comp.get("ok") and paper.get("ok"):
return await _artifact_message(db, session_id, paper, "已按你的意见修改,文献编号已重排。")
blocks = [_text_block("修改后重新生成失败,请重试。")]
await _write_message(db, session_id, blocks=blocks, text="修改失败")
return blocks
# ---------------------------------------------------------------------------
# 写节循环(confirm/resume 后台任务)
# ---------------------------------------------------------------------------
async def run_writing_loop(db, paper_id: str, session_id: str) -> None:
"""后台逐节写节循环:写完→complete→get_paper→写 artifact→artifact 块。"""
from ...routers.docgen import insert_artifact # 惰性导入,避免服务层→路由层常驻耦合
client = _new_client()
# 进度卡由前端确认时本地 seed + 2s 轮询 get_paper_progress 驱动(不落库,避免重载后出现
# 空的「撰写中」卡);后端只落「开始撰写」文本 + 逐节完成小结 + 最终 artifact。
await _write_message(db, session_id, blocks=[_text_block("开始逐节撰写……")], text="开始撰写")
try:
while True:
prog = await client.call_tool("get_paper_progress", {"paper_id": paper_id})
if not prog.get("ok"):
await _write_message(db, session_id, blocks=[_text_block("撰写进度查询失败。")], text="撰写失败")
return
if prog.get("status") == "cancelled":
await _write_message(db, session_id, blocks=[_text_block("论文已取消。")], text="已取消")
return
sections = prog.get("sections") or []
pending = [s for s in sections if s.get("status") != "done"]
if not pending:
break
sec = pending[0]
res = await client.call_tool("write_paper_section", {"paper_id": paper_id, "section_id": sec["section_id"]})
if not res.get("ok"):
await _write_message(db, session_id, blocks=[_text_block(
f"章节「{sec.get('title', '')}」撰写失败:{res.get('message', '')}"
)], text="撰写失败")
return
wc = len(res.get("markdown") or "")
await _write_message(db, session_id, blocks=[_text_block(
f"「{sec.get('title', '')}」已完成,约 {wc} 字。"
)], text=f"「{sec.get('title', '')}」已完成")
comp = await client.call_tool("complete_paper", {"paper_id": paper_id})
if not comp.get("ok"):
await _write_message(db, session_id, blocks=[_text_block(f"论文合并失败:{comp.get('message', '')}")], text="合并失败")
return
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
if not paper.get("ok"):
await _write_message(db, session_id, blocks=[_text_block("论文拉取失败。")], text="拉取失败")
return
await _artifact_message(db, session_id, paper, "论文撰写完成!你可以在右侧文档页查看全文,或用自然语言提出修改意见。")
except PaperMcpError as exc:
logger.warning("paper: 写节循环失败:%s", exc)
await _write_message(db, session_id, blocks=[_text_block(f"论文服务异常:{exc}")], text="论文服务异常")
except Exception as exc: # noqa: BLE001
logger.exception("paper: 写节循环异常")
await _write_message(db, session_id, blocks=[_text_block(f"撰写失败:{exc}")], text="撰写失败")
def _build_full_markdown(paper: dict[str, Any]) -> str:
"""把 full_markdown + 文末参考文献列表 + disclaimer 拼成完整 markdown(供渲染 HTML 与落盘)。"""
md = paper.get("full_markdown", "")
refs = paper.get("references") or []
if refs:
md += "\n\n## 参考文献\n\n" + "\n".join(
f"[{r.get('n', '')}] {r.get('text', '')}".strip() for r in refs
)
disclaimer = (paper.get("disclaimer") or "").strip()
if disclaimer:
md += f"\n\n> {disclaimer}"
return md
async def _artifact_message(db, session_id: str, paper: dict[str, Any], nl: str) -> list[dict[str, Any]]:
from ...routers.docgen import insert_artifact # 惰性导入
from ..docgen import templating # 惰性导入:复用 docgen markdown→HTML 渲染链
refs = paper.get("references") or []
title = paper.get("title", "")
full_md = _build_full_markdown(paper)
html, css = templating.render_html(full_md, title=title, key=paper.get("template_key") or "document")
artifact_id = await insert_artifact(
db, session_id=session_id, title=title,
markdown=full_md, template_name="论文", template_key=paper.get("template_key") or "document",
template_css=css, html=html,
)
blocks = [
_text_block(nl),
{"type": "artifact", "artifact_id": artifact_id, "title": title,
"template_name": "论文", "status": "done", "paper_id": paper.get("paper_id", ""),
"citation_count": len(refs)},
]
await _write_message(db, session_id, blocks=blocks, text=nl)
return blocks
+104
View File
@@ -0,0 +1,104 @@
"""services/paper/mcp_client.py —— 裸 JSON-RPC over httpx 客户端(BFF pyproject 无 mcp 依赖,零新增)。
与 paper_writer_service /mcp/ 端点通信:initialize → tools/call。
响应为 SSE(text/event-stream):`event: message\ndata: <json>\n\n`;会话由 mcp-session-id 头维系。
端点注意:/mcp 无斜杠会 307 重定向,必须请求 /mcp/。
"""
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
PROTOCOL_VERSION = "2025-03-26"
class PaperMcpError(Exception):
"""paper 服务 RPC/工具调用失败。"""
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
super().__init__(message)
self.cause = cause
def _parse_sse_body(text: str) -> dict[str, Any] | None:
"""从 SSE 响应抽取 `data:` 行的 JSON;非 SSE 则整段按 JSON 解析。"""
for line in text.splitlines():
line = line.strip()
if line.startswith("data:"):
try:
return json.loads(line[5:].strip())
except ValueError:
return None
try:
return json.loads(text)
except ValueError:
return None
class PaperMcpClient:
"""paper 服务 MCP 客户端:维护 mcp-session-id,逐个 JSON-RPC 调用。"""
def __init__(self, base_url: str, timeout: float = 360.0) -> None:
self.base_url = (base_url or "http://127.0.0.1:8097").rstrip("/")
self.timeout = timeout
self._session_id: str | None = None
self._rpc_id = 0
self._initialized = False
async def _post(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
self._rpc_id += 1
body = {"jsonrpc": "2.0", "id": self._rpc_id, "method": method, "params": params}
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if self._session_id:
headers["mcp-session-id"] = self._session_id
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(f"{self.base_url}/mcp/", headers=headers, json=body)
resp.raise_for_status()
sid = resp.headers.get("mcp-session-id")
if sid:
self._session_id = sid
text = resp.text
payload = _parse_sse_body(text)
if payload is None:
raise PaperMcpError(f"paper 服务响应无法解析:{text[:200]}")
if payload.get("error"):
raise PaperMcpError(f"paper 服务 RPC 错误:{payload['error']}")
return payload.get("result") or {}
async def initialize(self) -> dict[str, Any]:
return await self._post("initialize", {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "plantai-bff-paper", "version": "0.1.0"},
})
async def _ensure_initialized(self) -> None:
"""MCP streamable-http 要求先 initialize(建立会话 + 取 mcp-session-id),
否则 tools/call 会被服务端 400 拒绝。"""
if not self._initialized:
await self.initialize()
self._initialized = True
async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
await self._ensure_initialized()
result = await self._post("tools/call", {"name": name, "arguments": arguments})
if result.get("isError"):
raise PaperMcpError(f"工具 {name} 返回错误:{result}")
sc = result.get("structuredContent")
if sc is not None:
return sc
for block in result.get("content") or []:
if isinstance(block, dict) and block.get("type") == "text" and block.get("text"):
try:
return json.loads(block["text"])
except ValueError:
return {"raw": block["text"]}
return result
-37
View File
@@ -10,7 +10,6 @@ from app.services.capabilities import ( # noqa: E402
DEFAULTS,
MANIFEST,
merged_capabilities,
sanitize_bioflow_meta,
sanitize_capabilities,
)
@@ -51,39 +50,3 @@ 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
-101
View File
@@ -1,101 +0,0 @@
"""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"}
+2 -25
View File
@@ -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": {}, "bioflow": None}
assert sess["meta"] == {"title_manual": False, "capabilities": {}}
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": {}, "bioflow": None}
assert r.json()["meta"] == {"title_manual": True, "capabilities": {}}
# PATCH 会话状态
r = client.patch(f"/api/sessions/{sess['id']}", json={"status": "done"})
@@ -149,29 +149,6 @@ 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,10 +8,6 @@ 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 } })),
@@ -72,7 +68,6 @@ 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;
@@ -83,15 +78,14 @@ 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('能力未注册:生信 TAB 不渲染(S3 主信号 = 能力注册状态)', async () => {
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
it('探活失败(health 不可达):生信 TAB 不渲染(TC-BIO-JOB-012/PANE-013)', async () => {
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());
@@ -100,23 +94,13 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
expect(labels).not.toContain('生信档案');
});
it('已注册但 health 不可达:TAB 仍显示(次信号不影响显隐)', async () => {
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
it('探活恢复:强制重探后 TAB 出现(TC-BIO-JOB-013/PANE-014)', async () => {
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('生信任务');
// 能力安装:强制重探
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
// 服务恢复:强制重探
bioflowApi.bioflowHealth.mockResolvedValueOnce({ ok: true });
const bioflow = useBioflowStore();
await bioflow.probe({ force: true });
await flushPromises();
@@ -125,7 +109,6 @@ 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() === '生信任务');
@@ -136,7 +119,6 @@ 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,11 +13,6 @@ 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 } })),
@@ -181,36 +176,20 @@ describe('bioflow store —— 探活 / 动作 / 面板 / 复用', () => {
vi.restoreAllMocks();
});
it('探活 能力注册 → online;未注册 → offline(TC-BIO-STORE-016 / S3 双信号)', async () => {
const capsApi = await import('../../../../ui/src/api/capabilities.js');
it('探活 200 → online;失败 → offline(TC-BIO-STORE-016)', async () => {
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', {
+69 -32
View File
@@ -82,18 +82,9 @@ 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',
@@ -178,11 +169,9 @@ 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(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']);
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
const body = bioflowApi.createBioflowJob.mock.calls[0][0];
expect(body.stages).toEqual(['identify', 'align', 'phylogeny']);
});
it('确认按钮双击只触发一次创建(TC-BIO-ACT-008 防重复)', async () => {
@@ -193,7 +182,7 @@ describe('FlowPathCard —— TC-BIO-PATH-001~022(L4)', () => {
await confirmBtn.trigger('click');
await confirmBtn.trigger('click');
await flushPromises();
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
});
it('键盘 Enter 触发确认(TC-BIO-ACT-010)', async () => {
@@ -283,12 +272,11 @@ 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(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);
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);
});
it('继续按钮双击仅一次请求(TC-BIO-ACT-021 提交中禁用)', async () => {
@@ -297,7 +285,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
await btn.trigger('click');
await btn.trigger('click');
await flushPromises();
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
});
it('数字类型校验:输入 abc 拦截(TC-BIO-FORM-014)', async () => {
@@ -305,7 +293,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(sendCardSpy()).not.toHaveBeenCalled();
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
expect(w.find('.ffc-err').text()).toContain('类型');
});
@@ -315,7 +303,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(sendCardSpy()).not.toHaveBeenCalled();
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
expect(w.find('.ffc-err').text()).toContain('1 ~ 64');
});
@@ -324,15 +312,15 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
// species 默认空字符串
await w.find('.ffc-continue').trigger('click');
await flushPromises();
const [, payload] = sendCardSpy().mock.calls[0];
expect(payload.params.species).toBeUndefined();
const [, , params] = bioflowApi.confirmBioflowStage.mock.calls[0];
expect(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(sendCardSpy()).toHaveBeenCalledTimes(1);
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
});
it('键盘 Esc 恢复默认值(TC-BIO-FORM-024/ACT-017)', async () => {
@@ -444,7 +432,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(sendCardSpy()).toHaveBeenCalledWith('file_upload', expect.objectContaining({ job_id: 'job-1', paths: ['/data/examples/SPL.fa'] }));
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalledWith('job-1', { server_path: '/data/examples/SPL.fa' });
});
it('登记路径按钮空 input 禁用(TC-BIO-ACT-048)', () => {
@@ -457,7 +445,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(sendCardSpy()).toHaveBeenCalledWith('file_upload', expect.objectContaining({ job_id: 'job-1', paths: ['/data/SPL.fa'] }));
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalled();
});
it('路径输入 Esc 清空(TC-BIO-ACT-044)', async () => {
@@ -536,15 +524,46 @@ describe('FlowProgressCard —— TC-BIO-PROG-001~024(L4)', () => {
expect(w.find('.pc-log-body').exists()).toBe(true);
});
it('失败标红 ✕ + 状态文字 error 色(TC-BIO-PROG-006/007;S3 无三选一按钮)', () => {
it('失败标红 ✕ + 状态文字 error 色 + 三选一按钮出现(TC-BIO-PROG-006/007)', () => {
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('✕');
// S3 精简:失败态不再渲染直连控制按钮(重跑/跳过/改参数重跑)
expect(w.findAll('.pc-actions').length).toBe(0);
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' });
});
it('skipped 态视觉(TC-BIO-PROG-011)', () => {
@@ -585,6 +604,24 @@ 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,3 +190,141 @@ 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,12 +100,18 @@ describe('Composer 生信分流(漏判安全)', () => {
vi.clearAllMocks();
});
it('生信意图:统一走 QA 流(emit send,不产生 bioflow 直连请求;S3)', async () => {
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 },
}];
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
await submit(w, '帮我跑水稻 SPL 家族鉴定');
expect(w.emitted('send')).toBeTruthy();
expect(w.emitted('send')[0][0]).toContain('帮我跑水稻 SPL 家族鉴定');
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
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');
});
it('未命中词典:走原 send 路径(不产生 bioflow 请求;TC-BIO-STORE-002)', async () => {
+102 -41
View File
@@ -1,57 +1,118 @@
// @vitest-environment jsdom
// tests/frontend/unit/bioflow/intent.test.js —— 意图分流(S3:生信词典删除后等价语义)
// S3 起生信分析意图统一走 QA 流(bioflow_agent 服务端 nl_triggers 判定),
// 前端 route() 不再产生 bioflow/bioflow_qa——本测试锁定该等价语义:
// 生信句 → qa;文档意图(docgen/export/edit)不变;扩展创建意图保留。
// tests/frontend/unit/bioflow/intent.test.js —— 生信意图分流(L3)
// 与 docs/team/cases_bioflow_intent.md 冻结词典 40 用例表同表双跑
// (TC-BIO-INTENT-001~047;后端 intent.py 同词典同规则,L2 侧由 T 执行同表校验)。
import { describe, expect, it } from 'vitest';
import { route, hasAnyWord, isExtensionCreateIntent } from '../../../../ui/src/utils/intent.js';
import { route, extractReuseName, hasAnyWord } from '../../../../ui/src/utils/intent.js';
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');
}
/** 词典用例表(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);
});
});
});
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', () => {
describe('intent route —— 边界与实现注意(词典 §5)', () => {
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('isExtensionCreateIntent —— 扩展创建意图(S4 保留)', () => {
it('创建/上传 扩展句式命中', () => {
expect(isExtensionCreateIntent('帮我创建一个分析扩展')).toBe(true);
expect(isExtensionCreateIntent('上传一个自定义分析工具')).toBe(true);
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');
});
it('非扩展句不命中', () => {
expect(isExtensionCreateIntent('帮我跑水稻 SPL 家族鉴定')).toBe(false);
expect(isExtensionCreateIntent('')).toBe(false);
it('非复用句式返回 null', () => {
expect(extractReuseName('帮我跑水稻 SPL 家族鉴定')).toBeNull();
expect(extractReuseName('')).toBeNull();
});
it('片段长度不足 3 或超 20 返回 null', () => {
expect(extractReuseName('用 AB 存档任务的方法帮我跑水稻 SPL 家族鉴定')).toBeNull();
// 21 字符片段超上限
expect(extractReuseName('用 一二三四五六七八九十一二三四五六七八九十一 存档任务的方法帮我跑水稻 SPL')).toBeNull();
});
});
@@ -1,59 +0,0 @@
// @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);
});
});
-24
View File
@@ -331,27 +331,3 @@ 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);
});
});
@@ -1,71 +0,0 @@
// @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);
});
});
+80
View File
@@ -0,0 +1,80 @@
"""tests/paper/conftest.py —— 论文服务测试共享夹具(tmp db + 假 LLM + 假检索)。
全部 mock LLM/vstore,不依赖生产服务(8090/8094 等绝不探测)。
镜像 bioflow_service/tests/conftest.py 的 _run(新建事件循环)+ 临时库模式。
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
for _p in (str(REPO_ROOT), str(REPO_ROOT / "sdk")):
if _p not in sys.path:
sys.path.insert(0, _p)
from paper_writer_service.app import db as dbmod # noqa: E402
from paper_writer_service.app.services.paper import llm as llmmod # noqa: E402
from paper_writer_service.app.services.paper import search as searchmod # noqa: E402
def _run(coro):
"""同步跑一个协程(新建 event loop;bioflow/docgen 测试同款可靠模式)。"""
return asyncio.new_event_loop().run_until_complete(coro)
class FakeLLM:
"""可编程假 LLM:按顺序返回预设 dict;或抛 LLMError。记录全部调用。"""
def __init__(self, responses=None, error=None):
self.responses = list(responses or [])
self.error = error
self.calls: list[dict] = []
async def complete_json(self, system, user, **kwargs):
self.calls.append({"system": system, "user": user})
if self.error:
raise llmmod.LLMError(self.error)
return self.responses.pop(0) if self.responses else {}
async def complete_text(self, system, user, **kwargs):
self.calls.append({"system": system, "user": user})
if self.error:
raise llmmod.LLMError(self.error)
return self.responses.pop(0) if self.responses else ""
# ---------------------------------------------------------------------------
# DB:单例改路径重连(服务模块以 from ...db import db 持有单例引用)
# ---------------------------------------------------------------------------
@pytest.fixture
def paper_db(tmp_path):
dbmod.db._path = Path(tmp_path) / "paper.db"
_run(dbmod.db.connect())
yield dbmod.db
_run(dbmod.db.close())
@pytest.fixture
def fake_llm(monkeypatch):
fake = FakeLLM()
monkeypatch.setattr(llmmod, "complete_json", fake.complete_json)
monkeypatch.setattr(llmmod, "complete_text", fake.complete_text)
return fake
@pytest.fixture
def fake_search(monkeypatch):
async def _search_refs(paper_id="", query="", top_k=8):
return {"ok": True, "items": [
{"doc_id": "d1", "title": "水稻SPL基因家族研究进展", "snippet": "SPL 是植物特有转录因子家族。", "score": 0.9, "meta": {}},
{"doc_id": "d2", "title": "植物SPL转录因子功能", "snippet": "SPL 调控开花与株型。", "score": 0.8, "meta": {}},
]}
monkeypatch.setattr(searchmod, "search_refs", _search_refs)
return _search_refs
+33
View File
@@ -0,0 +1,33 @@
# 论文撰写功能 手工验收手册
## 前置(175 服务器,dev 树明文)
- `paper_writer_service` 8097 已拉起(`bash deploy/paper_restart.sh`)。
- BFF 8090 已启用论文路由(`bash deploy/toggle_paper.sh on`,即 `.env` 里 `PAPER_WRITER_ENABLED=true`)。
- 前端 Vite dev(5180)或 BFF 静态托管 dist 就绪,Mac 侧走 SSH 隧道访问。
探活自检:
```bash
curl -s http://127.0.0.1:8097/health # {"ok":true,...,"db":{"connected":true}}
curl -s http://127.0.0.1:8090/api/paper/health # 应透传 8097 /health
curl -s http://127.0.0.1:8090/api/paper/papers/nope # {"ok":false,...,"code":"NOT_FOUND"}(路由已注册)
```
## 验收剧本(10 步)
1. **触发**:输入「帮我写一篇水稻 SPL 基因家族的综述论文」→ 出现 NL 引导 + `paper_brief` 表单卡(预填已抽取字段,缺失项待补)。
2. **补全 brief**:类型=综述 / 篇幅=5000字 / 语言=中文 / 格式=GB/T 7714 → 点「生成大纲」→ 出现 `paper_outline` 大纲卡(5-6 节)。
3. **NL 改大纲**:输入「把展望合并到讨论」→ 系统复述确认并更新大纲卡。
4. **确认**:点「确认并开始撰写」→ 出现 `paper_progress` 进度卡,逐节推进,每节完成有 NL 小结。
5. **完成**:全部节完成后自动挂 `artifact` 块并切右侧「文档」TAB;全文含文末 GB/T 7714 参考文献列表 + 降级 disclaimer(无 DOI/PMID 的知识库资料)。
6. **引用点击**:点击正文 `[3]` → popover 显示标题 / GB 文本 / 来源片段。
7. **修改**:输入「把讨论部分第二段改得更严谨」→ 修改确认,文献编号已重排。
8. **导出**:文档 TAB 点「导出 PDF」/「导出 DOCX」→ 成功下载。
9. **关服务降级**:`bash deploy/paper_restart.sh` 停掉后(或 kill 8097)→ 前端触发词落 docgen 单发(无 paper 入口)。
10. **恢复续写**:重启 paper 服务 + BFF 后 resume → 跳过已完成节继续。
## 备注
- 引用为知识库(vstore 8094)自动检索注入;知识库 chunk 无 DOI/PMID 元数据,故多为降级格式 + disclaimer(见方案 §3.3)。
- 长文一致性:citations 全局只增不改,complete 按首次出现顺序重排编号;正文与文末列表同源生成。
+5
View File
@@ -0,0 +1,5 @@
[pytest]
python_files = test_*.py
markers =
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
paper_mcp: MCP 协议层测试(list_tools/call_tool)
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# tests/paper/run_all.sh —— 论文撰写功能测试总入口(后端 pytest + 前端 intent 单测)
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
PY="${PAPER_TEST_PY:-/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python}"
echo "== 论文后端(pytest:工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema) =="
"$PY" -m pytest "$ROOT/tests/paper/" -c "$ROOT/tests/paper/pytest.ini" -q
echo
echo "== 论文前端 intent 单测(node) =="
node "$ROOT/tests/paper/test_paper_intent_frontend.mjs"
+69
View File
@@ -0,0 +1,69 @@
"""tests/paper/test_mcp_protocol.py —— MCP 协议层测试(list_tools / call_tool 往返)。
验证:9 个工具正确暴露、call_tool 返回可解析的 JSON(structuredContent 或 content 文本块)。
镜像 bioflow_service/tests/test_mcp_server.py 的 call_tool 集成模式。
"""
from __future__ import annotations
import json
from conftest import _run
from paper_writer_service.app.mcp import paper_mcp
EXPECTED_TOOLS = {
"propose_paper_outline", "write_paper_section", "get_paper_progress", "search_refs",
"revise_paper_section", "revise_paper", "get_paper", "complete_paper", "cancel_paper",
}
def _content_text(result):
"""从 CallToolResult 抽取文本内容(无 structuredContent 时)。"""
for block in getattr(result, "content", None) or []:
text = getattr(block, "text", None)
if text:
return json.loads(text)
return {}
def _call(name, args):
r = _run(paper_mcp.call_tool(name, args))
return getattr(r, "structuredContent", None) or _content_text(r)
def test_list_tools_exposes_nine():
tools = _run(paper_mcp.list_tools())
names = {getattr(t, "name", None) for t in tools}
assert EXPECTED_TOOLS == names
def test_call_tool_propose_outline_roundtrip(paper_db, fake_llm, fake_search):
fake_llm.responses.append({
"title": "水稻SPL基因家族研究进展",
"abstract_expect": "综述",
"sections": [{"section_id": "s1", "title": "引言", "key_points": [], "target_words": 1500}],
})
r = _call("propose_paper_outline", {"topic": "水稻SPL基因家族", "paper_type": "综述"})
assert r.get("ok") is True
assert r["paper_id"]
assert r["title"] == "水稻SPL基因家族研究进展"
def test_call_tool_not_found(paper_db):
r = _call("get_paper", {"paper_id": "nope"})
assert r.get("ok") is False
assert r.get("code") == "NOT_FOUND"
def test_call_tool_content_is_json_string(paper_db, fake_llm, fake_search):
"""验证 content[0].text 是合法 JSON 字符串(BFF 裸 JSON-RPC 客户端解析面)。"""
fake_llm.responses.append({
"title": "T", "abstract_expect": "A",
"sections": [{"section_id": "s1", "title": "引言", "key_points": [], "target_words": 1500}],
})
raw = _run(paper_mcp.call_tool("propose_paper_outline", {"topic": "水稻SPL"}))
blocks = getattr(raw, "content", None) or []
assert blocks, "CallToolResult 无 content 块"
text = getattr(blocks[0], "text", None)
assert text is not None
payload = json.loads(text) # 应为合法 JSON 字符串
assert payload.get("ok") is True
+124
View File
@@ -0,0 +1,124 @@
"""tests/paper/test_paper_bff_agent.py —— BFF 论文大脑状态机测试(假 MCP server + 假 LLM)。
跑通 brief→outline→confirm(写节循环)→complete→artifact 全状态机。
"""
from __future__ import annotations
import pytest
from conftest import _run
from app import db as bff_db
from app.routers import docgen as docgen_mod
from app.services.paper import agent
class FakePaperService:
"""脚本化 paper 服务:按工具名返回预设结果,模拟写节循环推进。"""
def __init__(self):
self.paper_id = "p1"
self.s1_done = False
self.s2_done = False
async def call_tool(self, name, arguments):
if name == "propose_paper_outline":
return {"ok": True, "paper_id": self.paper_id, "title": "水稻SPL基因家族研究进展",
"abstract_expect": "综述", "sections": [
{"section_id": "s1", "title": "引言", "key_points": [], "target_words": 1500},
{"section_id": "s2", "title": "讨论", "key_points": [], "target_words": 1500},
]}
if name == "get_paper_progress":
sections = [
{"section_id": "s1", "title": "引言", "status": "done" if self.s1_done else "pending",
"word_count": 0, "citation_count": 0},
{"section_id": "s2", "title": "讨论", "status": "done" if self.s2_done else "pending",
"word_count": 0, "citation_count": 0},
]
return {"ok": True, "paper_id": self.paper_id, "status": "writing",
"sections": sections, "current_section_id": None}
if name == "write_paper_section":
sid = arguments["section_id"]
if sid == "s1":
self.s1_done = True
else:
self.s2_done = True
return {"ok": True, "section_id": sid, "status": "done",
"markdown": "内容" * 50, "citations_used": [], "new_citations": []}
if name == "complete_paper":
return {"ok": True, "full_markdown": "# 全文\n内容",
"references": [{"n": 1, "text": "X.", "kind": "title", "source_doc_id": "d1"}],
"renumber_map": {}, "disclaimer": "d"}
if name == "get_paper":
return {"ok": True, "paper_id": self.paper_id, "title": "水稻SPL基因家族研究进展",
"status": "complete", "full_markdown": "# 全文",
"references": [{"n": 1, "text": "X.", "kind": "title", "source_doc_id": "d1"}],
"disclaimer": "d"}
if name == "cancel_paper":
return {"ok": True}
if name == "revise_paper":
return {"ok": True, "affected_section_ids": ["s1"], "plan": "修订引言"}
if name == "revise_paper_section":
return {"ok": True, "section_id": arguments["section_id"], "markdown": "改后", "citations_used": []}
raise AssertionError(f"unexpected tool: {name}")
@pytest.fixture
def bff_database(tmp_path):
_run(bff_db.init_db(tmp_path / "bff.db"))
_run(bff_db.db.execute(
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
"VALUES ('s1', 't', 'idle', '{}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"))
yield bff_db.db
_run(bff_db.close_db())
def _fake_brief(complete=True):
async def _complete_json(system, user, **kwargs):
if complete:
return {"topic": "水稻SPL基因家族", "paper_type": "综述", "language": "中文",
"length": "5000字", "format_req": "GB/T 7714", "style": "学术严谨", "requirements": ""}
return {"topic": "水稻SPL", "paper_type": "", "language": "", "length": ""}
return _complete_json
@pytest.fixture
def fake_insert_artifact(monkeypatch):
async def _fake(db, **kwargs):
return "art-fake"
monkeypatch.setattr(docgen_mod, "insert_artifact", _fake)
return _fake
def test_trigger_complete_brief_proposes_outline(bff_database, monkeypatch, fake_insert_artifact):
svc = FakePaperService()
monkeypatch.setattr(agent, "_new_client", lambda: svc)
monkeypatch.setattr(agent, "llm_complete_json", _fake_brief(complete=True))
blocks = _run(agent.handle_turn(bff_database, "s1", "帮我写一篇水稻SPL基因家族综述"))
assert blocks[-1]["type"] == "paper_outline"
assert blocks[-1]["paper_id"] == "p1"
rows = _run(bff_database.fetchall("SELECT * FROM messages WHERE session_id = 's1'"))
assert len(rows) == 1
assert rows[0]["kind"] == "paper"
def test_trigger_incomplete_brief_returns_form(bff_database, monkeypatch):
monkeypatch.setattr(agent, "_new_client", lambda: FakePaperService())
monkeypatch.setattr(agent, "llm_complete_json", _fake_brief(complete=False))
blocks = _run(agent.handle_turn(bff_database, "s1", "帮我写论文"))
assert blocks[-1]["type"] == "paper_brief"
assert blocks[-1]["status"] == "pending"
def test_writing_loop_completes_to_artifact(bff_database, monkeypatch, fake_insert_artifact):
svc = FakePaperService()
monkeypatch.setattr(agent, "_new_client", lambda: svc)
_run(agent.run_writing_loop(bff_database, "p1", "s1"))
assert svc.s1_done and svc.s2_done
rows = _run(bff_database.fetchall("SELECT * FROM messages WHERE session_id = 's1' ORDER BY rowid"))
# 进度卡由前端确认时本地 seed + 轮询驱动,后端只落「开始撰写」文本 + 逐节小结 + artifact
assert "开始逐节撰写" in rows[0]["blocks"]
assert "artifact" in rows[-1]["blocks"]
assert "art-fake" in rows[-1]["blocks"]
+35
View File
@@ -0,0 +1,35 @@
"""tests/paper/test_paper_blocks_schema.py —— 块 schema 回归(未知块拒绝 + 新块通过)。"""
from __future__ import annotations
import pytest
from pydantic import TypeAdapter
from app import schemas
_BLOCK = TypeAdapter(schemas.Block)
def test_paper_blocks_validate():
cases = [
{"type": "paper_brief", "paper_id": "p1", "status": "pending", "fields": []},
{"type": "paper_outline", "paper_id": "p1", "title": "t", "status": "pending", "sections": []},
{"type": "paper_progress", "paper_id": "p1", "status": "writing", "sections": [], "current_section_id": ""},
{"type": "paper_refs", "paper_id": "p1", "references": [], "disclaimer": ""},
]
for c in cases:
b = _BLOCK.validate_python(c)
assert b.type == c["type"]
def test_artifact_block_paper_fields():
b = _BLOCK.validate_python({
"type": "artifact", "artifact_id": "a1", "title": "t",
"paper_id": "p1", "citation_count": 3,
})
assert b.paper_id == "p1"
assert b.citation_count == 3
def test_unknown_block_rejected():
with pytest.raises(Exception):
_BLOCK.validate_python({"type": "totally_unknown_block"})
@@ -0,0 +1,42 @@
// tests/paper/test_paper_intent_frontend.mjs —— 论文撰写意图前端单测(node 直测)
// 验证 paperIntent 纯函数 + route() 零变化抽样(30 条全量由 test_intent_frontend.mjs 兜底)。
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(here, '../..');
const { paperIntent, route } = await import(pathToFileURL(path.join(repo, 'ui/src/intent.js')).href);
const cases = [
['帮我写一篇水稻 SPL 基因家族综述论文', true],
['撰写一篇关于小麦育种的论文', true],
['拟一份综述', true],
['帮我写一个关于疫情防控的通知', false],
['写一份项目总结报告', false],
['写一首诗', false],
['水稻基因是什么?', false],
['', false],
];
let fail = 0;
for (const [t, exp] of cases) {
const got = paperIntent(t);
const ok = got === exp;
if (!ok) fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} paperIntent(${JSON.stringify(t)}) = ${got}(期望 ${exp})`);
}
const routeChecks = [
['给我写一篇关于小麦育种的论文', false, false, 'docgen'],
['写一段水稻基因的介绍', false, false, 'qa'],
];
for (const [t, a, b, exp] of routeChecks) {
const got = route(t, a, b);
const ok = got === exp;
if (!ok) fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} route(${JSON.stringify(t)}) = ${got}(期望 ${exp})`);
}
const total = cases.length + routeChecks.length;
console.log(`===== paperIntent 前端单测:${total - fail}/${total} PASS =====`);
process.exit(fail === 0 ? 0 : 1);
+272
View File
@@ -0,0 +1,272 @@
"""tests/paper/test_paper_tools.py —— 论文服务工具逻辑单元测试(假 LLM + 假检索)。
覆盖:propose_paper_outline / write_paper_section(含引用校验重试)/ get_paper_progress /
revise_paper_section / revise_paper / get_paper / complete_paper(重排 + disclaimer)/ cancel_paper +
引用协议纯函数(GB/T 7714 渲染、[n] 重排)。
"""
from __future__ import annotations
import pytest
from conftest import _run
from paper_writer_service.app import db as dbmod
from paper_writer_service.app.services.paper import cite, complete, outline, write
OUTLINE_RES = {
"title": "水稻SPL基因家族研究进展",
"abstract_expect": "综述水稻SPL基因家族的结构、功能与调控机制。",
"sections": [
{"section_id": "s1", "title": "引言", "key_points": ["SPL家族概述"], "target_words": 1500},
{"section_id": "s2", "title": "SPL家族结构与进化", "key_points": ["结构特征"], "target_words": 2000},
{"section_id": "s3", "title": "SPL功能与调控", "key_points": ["功能"], "target_words": 2000},
{"section_id": "s4", "title": "讨论与展望", "key_points": ["展望"], "target_words": 1500},
{"section_id": "s5", "title": "结论", "key_points": ["总结"], "target_words": 1000},
],
}
def _propose(fake_llm, fake_search):
fake_llm.responses.append(dict(OUTLINE_RES))
return _run(outline.propose_paper_outline("水稻SPL基因家族", paper_type="综述", language="中文"))
# ---------------------------------------------------------------------------
# propose_paper_outline
# ---------------------------------------------------------------------------
def test_propose_outline_creates_paper_and_sections(paper_db, fake_llm, fake_search):
r = _propose(fake_llm, fake_search)
assert r["ok"] is True
assert r["title"] == "水稻SPL基因家族研究进展"
assert len(r["sections"]) == 5
papers = _run(dbmod.db.fetchall("SELECT * FROM papers"))
assert len(papers) == 1
secs = _run(dbmod.db.fetchall("SELECT * FROM paper_sections ORDER BY rowid"))
assert [s["section_id"] for s in secs] == ["s1", "s2", "s3", "s4", "s5"]
assert all(s["status"] == "pending" for s in secs)
def test_propose_outline_llm_error(paper_db, fake_llm, fake_search):
fake_llm.error = "deepseek 超时"
r = _run(outline.propose_paper_outline("水稻SPL"))
assert r["ok"] is False
assert r["code"] == "LLM_ERROR"
# ---------------------------------------------------------------------------
# write_paper_section(引用校验 + 落库)
# ---------------------------------------------------------------------------
def test_write_section_creates_citations(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "SPL 是植物特有的转录因子家族[1],水稻有 19 个成员[2]。",
"citations_used": [
{"temp_n": 1, "title": "水稻SPL基因家族研究进展", "journal": "植物学报", "year": "2024",
"doi": "", "pmid": "", "source_doc_id": "d1", "snippet": "x"},
{"temp_n": 2, "title": "植物SPL转录因子功能", "journal": "", "year": "2023",
"doi": "", "pmid": "", "source_doc_id": "d2", "snippet": "y"},
],
})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is True
assert r["status"] == "done"
assert len(r["new_citations"]) == 2
cits = _run(dbmod.db.fetchall("SELECT * FROM citations ORDER BY temp_n"))
assert [c["temp_n"] for c in cits] == [1, 2]
assert cits[0]["kind"] == "title"
sec = _run(dbmod.db.fetchone("SELECT * FROM paper_sections WHERE paper_id = ? AND section_id = 's1'", (paper_id,)))
assert sec["status"] == "done"
assert "[1]" in sec["markdown"]
def test_write_section_drops_fabricated_doi(paper_db, fake_llm, fake_search):
"""杜撰的 doi/pmid/authors/journal 应被丢弃,强制 title kind 降级格式。"""
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "正文[1]。",
"citations_used": [{"temp_n": 1, "title": "水稻SPL研究", "authors": "X", "journal": "J",
"year": "2020", "doi": "10.1234/fake", "pmid": "123", "source_doc_id": "d1"}],
})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is True
cit = _run(dbmod.db.fetchone("SELECT * FROM citations WHERE temp_n = 1"))
assert cit["kind"] == "title"
assert "知识库资料" in cit["ref_text"]
assert "10.1234" not in cit["ref_text"]
def test_write_section_invalid_citation_retries_once(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.calls.clear() # 重置计数,只统计写节调用(大纲已消费 1 次)
# 第一次引用非法编号 [99],第二次合法
fake_llm.responses.append({
"markdown": "SPL 家族[99]。",
"citations_used": [{"temp_n": 1, "title": "t", "source_doc_id": "d1"}],
})
fake_llm.responses.append({
"markdown": "SPL 家族[1]。",
"citations_used": [{"temp_n": 1, "title": "t", "source_doc_id": "d1"}],
})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is True
assert len(fake_llm.calls) == 2 # 一次失败 + 一次重试成功
def test_write_section_invalid_citation_fails_after_retries(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({"markdown": "SPL 家族[99]。", "citations_used": []})
fake_llm.responses.append({"markdown": "SPL 家族[88]。", "citations_used": []})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is False
assert r["code"] == "CITE_VALIDATION_FAILED"
def test_write_section_not_found(paper_db, fake_llm):
r = _run(write.write_paper_section("nope", "s1"))
assert r["ok"] is False
assert r["code"] == "NOT_FOUND"
# ---------------------------------------------------------------------------
# get_paper_progress / get_paper
# ---------------------------------------------------------------------------
def test_progress_reflects_done_section(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "正文[1]。",
"citations_used": [{"temp_n": 1, "title": "t", "source_doc_id": "d1"}],
})
_run(write.write_paper_section(paper_id, "s1"))
r = _run(complete.get_paper_progress(paper_id))
assert r["ok"] is True
assert r["status"] == "writing"
s1 = next(s for s in r["sections"] if s["section_id"] == "s1")
assert s1["status"] == "done"
assert s1["citation_count"] == 1
# ---------------------------------------------------------------------------
# revise_paper_section / revise_paper
# ---------------------------------------------------------------------------
def test_revise_section_appends_new_citation(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "旧正文[1]。",
"citations_used": [{"temp_n": 1, "title": "旧文献", "source_doc_id": "d1"}],
})
_run(write.write_paper_section(paper_id, "s1"))
# 修订:新增 [2]
fake_llm.responses.append({
"markdown": "新正文[1][2]。",
"citations_used": [
{"temp_n": 1, "title": "旧文献", "source_doc_id": "d1"},
{"temp_n": 2, "title": "新文献", "source_doc_id": "d3"},
],
})
r = _run(write.revise_paper_section(paper_id, "s1", "更严谨"))
assert r["ok"] is True
assert r["citation_delta"] == 1
cits = _run(dbmod.db.fetchall("SELECT temp_n FROM citations ORDER BY temp_n"))
assert [c["temp_n"] for c in cits] == [1, 2]
def test_revise_paper_returns_affected(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({"affected_section_ids": ["s2", "s3"], "plan": "修订讨论与结论"})
r = _run(write.revise_paper(paper_id, "把讨论改严谨"))
assert r["ok"] is True
assert r["affected_section_ids"] == ["s2", "s3"]
# ---------------------------------------------------------------------------
# complete_paper(重排 + GB/T 7714 + disclaimer)
# ---------------------------------------------------------------------------
def test_complete_renumbers_by_first_appearance(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
# s1 先引用 [2],s2 后引用 [1] → complete 后 [2] 应重排为 [1]
fake_llm.responses.append({
"markdown": "引言提到 B[2]。",
"citations_used": [{"temp_n": 2, "title": "文献B", "source_doc_id": "d2"}],
})
_run(write.write_paper_section(paper_id, "s1"))
fake_llm.responses.append({
"markdown": "正文提到 A[1]。",
"citations_used": [{"temp_n": 1, "title": "文献A", "source_doc_id": "d1"}],
})
_run(write.write_paper_section(paper_id, "s2"))
r = _run(complete.complete_paper(paper_id))
assert r["ok"] is True
# 首次出现顺序:B(temp 2) 在 s1,A(temp 1) 在 s2 → 2→1, 1→2
assert r["renumber_map"] == {2: 1, 1: 2}
assert r["references"][0]["n"] == 1
assert r["references"][0]["text"].startswith("文献B")
assert r["disclaimer"] != "" # title kind → 降级 disclaimer
paper = _run(dbmod.db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,)))
assert paper["status"] == "complete"
assert "[1]" in paper["final_markdown"] and "[2]" in paper["final_markdown"]
def test_complete_paper_no_citations_no_disclaimer(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({"markdown": "无引用正文。", "citations_used": []})
_run(write.write_paper_section(paper_id, "s1"))
r = _run(complete.complete_paper(paper_id))
assert r["ok"] is True
assert r["references"] == []
assert r["disclaimer"] == ""
def test_get_paper_returns_full_markdown(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({"markdown": "正文[1]。", "citations_used": [
{"temp_n": 1, "title": "t", "source_doc_id": "d1"}]})
_run(write.write_paper_section(paper_id, "s1"))
_run(complete.complete_paper(paper_id))
r = _run(complete.get_paper(paper_id))
assert r["ok"] is True
assert r["status"] == "complete"
assert "水稻SPL基因家族研究进展" in r["full_markdown"]
assert len(r["references"]) == 1
# ---------------------------------------------------------------------------
# cancel_paper
# ---------------------------------------------------------------------------
def test_cancel_paper(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
r = _run(complete.cancel_paper(paper_id))
assert r["ok"] is True
paper = _run(dbmod.db.fetchone("SELECT status FROM papers WHERE id = ?", (paper_id,)))
assert paper["status"] == "cancelled"
def test_cancel_paper_not_found(paper_db):
r = _run(complete.cancel_paper("nope"))
assert r["ok"] is False
assert r["code"] == "NOT_FOUND"
# ---------------------------------------------------------------------------
# 引用协议纯函数(cite)
# ---------------------------------------------------------------------------
def test_gbt7714_doi_pmid_title():
assert "DOI:10.1000/x" in cite.render_gbt7714({"title": "T", "journal": "J", "year": "2024", "doi": "10.1000/x"})
assert "PMID:123" in cite.render_gbt7714({"title": "T", "journal": "J", "year": "2024", "pmid": "123"})
degraded = cite.render_gbt7714({"title": "知识库标题", "year": "2023"})
assert degraded.endswith("知识库资料, 2023. [EB/OL].")
def test_renumber_map_first_appearance():
mapping = cite.renumber_map([1, 2, 3], "见[3]和[1],以及[2]")
assert mapping == {3: 1, 1: 2, 2: 3}
assert cite.replace_numbers("见[3]和[1]", {3: 1, 1: 2}) == "见[1]和[2]"
+5 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# 渲染 active_library.env:读 <库目录>/manifest.yaml,输出五触点 env。
# 用法: render_active_library_env.sh <manifest.yaml> <输出文件>
# config 类路径:manifest 值为相对路径(如 configs/rag/entity_alias.yaml)时渲染为 $QA_ROOT/<值>;
# 为空时回退 $QA_ROOT/configs/rag/<缺省文件名>。registry_path 为空则整行省略。
# config 类路径:manifest 值为相对路径(如 configs/rag/entity_alias.yaml)时渲染为 $GENE_FAMILY_ROOT/<值>;
# 为空时回退 $GENE_FAMILY_ROOT/configs/rag/<缺省文件名>。registry_path 为空则整行省略。
set -euo pipefail
MANIFEST="$1"; OUT="$2"
release_id="$(awk -F': *' '/^release_id:/ {gsub(/[" ]/, "", $2); print $2; exit}' "$MANIFEST")"
@@ -15,8 +15,8 @@ tmp="$OUT.tmp"
{
echo "NORMALIZED_RELEASE_ID=$release_id"
echo "V15_QDRANT_COLLECTION=${collection:-$release_id}"
[ -n "$alias_cfg" ] && echo "QA_V1_ALIAS_CONFIG=\$QA_ROOT/$alias_cfg" || echo "QA_V1_ALIAS_CONFIG=\$QA_ROOT/configs/rag/entity_alias.yaml"
[ -n "$tax_cfg" ] && echo "QA_V1_TAXONOMY_CONFIG=\$QA_ROOT/$tax_cfg" || echo "QA_V1_TAXONOMY_CONFIG=\$QA_ROOT/configs/rag/relation_taxonomy.yaml"
[ -n "$registry" ] && echo "QA_PAPER_METADATA_REGISTRY=\$QA_ROOT/$registry"
[ -n "$alias_cfg" ] && echo "QA_V1_ALIAS_CONFIG=\$GENE_FAMILY_ROOT/$alias_cfg" || echo "QA_V1_ALIAS_CONFIG=\$GENE_FAMILY_ROOT/configs/rag/entity_alias.yaml"
[ -n "$tax_cfg" ] && echo "QA_V1_TAXONOMY_CONFIG=\$GENE_FAMILY_ROOT/$tax_cfg" || echo "QA_V1_TAXONOMY_CONFIG=\$GENE_FAMILY_ROOT/configs/rag/relation_taxonomy.yaml"
[ -n "$registry" ] && echo "QA_PAPER_METADATA_REGISTRY=\$GENE_FAMILY_ROOT/$registry"
} > "$tmp"
mv "$tmp" "$OUT"
+1 -1
View File
@@ -31,7 +31,7 @@ rsync -a --exclude '__pycache__' --exclude 'test_*.py' --exclude 'data' --exclud
"$SOURCE_ROOT/" "$OUTPUT_DIR/"
# 白名单明文:main.py + config.py + __init__.py(编译前从发现集排除)
WHITELIST_PY=("app/main.py" "app/config.py" "app/schemas.py" "app/db.py" "app/routers/qa.py" "app/services/sse_relay.py" "app/routers/bioflow_proxy.py")
WHITELIST_PY=("app/main.py" "app/config.py" "app/schemas.py" "app/db.py" "app/routers/qa.py" "app/services/sse_relay.py" "app/routers/bioflow_proxy.py" "app/routers/paper.py" "app/services/paper/agent.py" "app/services/paper/mcp_client.py")
# 白名单理由:main.py=uvicorn 入口;config.py=运维可读面;
# schemas.py=pydantic v2 模型(validator 方法被 Cython 转 cyfunction 触发
# PydanticUserError,实测编译后无法导入——纯数据契约面,无敏感逻辑,明文保留);
-59
View File
@@ -1,59 +0,0 @@
// 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);
+34
View File
@@ -0,0 +1,34 @@
// api/paper.js —— 论文撰写端点(paper-writer)
// 全部经 http.js 封装。turns 短同步;confirm/resume 启动后台写节循环;
// progress/citations 透传 paper 服务。
import { get, post } from './http.js';
/** 探活(前端 30s 轮询;离线时触发词落 docgen 单发降级) */
export function paperHealth() {
return get('/api/paper/health');
}
/** 一轮对话(短同步):POST /api/paper/turns → {ok, blocks} */
export function paperTurn(payload) {
return post('/api/paper/turns', payload);
}
/** 确认大纲并启动后台写节循环 */
export function confirmPaper(paperId, sessionId) {
return post(`/api/paper/papers/${encodeURIComponent(paperId)}/confirm`, { session_id: sessionId });
}
/** 断写后重启循环(跳过已完成节) */
export function resumePaper(paperId, sessionId) {
return post(`/api/paper/papers/${encodeURIComponent(paperId)}/resume`, { session_id: sessionId });
}
/** 节级进度(写稿期 2s 轮询) */
export function paperProgress(paperId) {
return get(`/api/paper/papers/${encodeURIComponent(paperId)}`);
}
/** 单条引用来源(正文 [n] 点击 popover) */
export function paperCitation(paperId, n) {
return get(`/api/paper/papers/${encodeURIComponent(paperId)}/citations/${encodeURIComponent(n)}`);
}
-10
View File
@@ -164,16 +164,6 @@ 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) {
+59 -1
View File
@@ -15,6 +15,8 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useDocgenStore } from '../../stores/docgen.js';
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
import { renderMarkdownPlain, sanitizeHtml } from '../chat/markdown.js';
// 论文撰写:正文 [n] → cite-chip,点击拉 citation popover
import { paperCitation } from '../../api/paper.js';
// FR-PRIV-006:「我的资料」管理面板(挂空态区顶部,UI_SPEC §9.2)
import PrivateLibraryPane from './PrivateLibraryPane.vue';
@@ -70,10 +72,66 @@ function mountShadow() {
// 缺陷 #7b:md 回退预览不注入标题编号——序列化会把标题写回正本,编号不得进源文
inner = `<div class="doc-body md-text">${renderMarkdownPlain(docgen.md)}</div>`;
}
if (docgen.activeArtifact && docgen.activeArtifact.paper_id) {
inner = addCiteChips(inner); // 论文制品:[n] → cite-chip
}
root.innerHTML = inner;
if (editing.value) activateEditDom();
}
// ---------- 论文引用 cite-chip ----------
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function addCiteChips(inner) {
return inner.replace(/\[(\d+)\]/g, (m, n) =>
`<sup class="cite-chip" data-n="${n}" style="color:#2563eb;cursor:pointer;font-weight:600;margin:0 1px;">[${n}]</sup>`);
}
function removeCitePopover() {
const host = hostEl.value;
if (host) {
const p = host.querySelector(':scope > .cite-popover');
if (p) p.remove();
}
}
function showCitePopover(chip, c) {
removeCitePopover();
const host = hostEl.value;
if (!host || !chip) return;
const rect = chip.getBoundingClientRect();
const hostRect = host.getBoundingClientRect();
const pop = document.createElement('div');
pop.className = 'cite-popover';
pop.innerHTML = `<div class="cp-title">${escapeHtml(c.title || c.text || '')}</div>` +
`<div class="cp-text">${escapeHtml(c.text || '')}</div>` +
(c.snippet ? `<div class="cp-snippet">${escapeHtml(c.snippet)}</div>` : '');
pop.style.cssText = `position:absolute;left:${rect.left - hostRect.left}px;top:${rect.bottom - hostRect.top + 4}px;max-width:360px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.12);padding:8px 10px;z-index:50;font-size:12px;`;
host.appendChild(pop);
}
function wireCiteChips() {
const root = shadowRoot();
if (!root || root._citeWired) return;
root._citeWired = true;
root.addEventListener('click', (e) => {
const chip = e.target && e.target.closest ? e.target.closest('.cite-chip') : null;
if (!chip) { removeCitePopover(); return; }
const n = chip.getAttribute('data-n');
const paperId = docgen.activeArtifact && docgen.activeArtifact.paper_id;
if (!paperId || !n) return;
paperCitation(paperId, n).then((data) => {
const c = data && data.citation;
if (c) showCitePopover(chip, c);
}).catch(() => {});
});
}
watch(() => [docgen.html, docgen.css, docgen.renderNonce], () => {
// 2026-09-04 问题5:宿主元素可能随 loading 态被 v-if 卸载重建,
// nextTick 后 mountShadow 才能拿到新宿主;renderNonce 兜底 html 拉取失败
@@ -90,7 +148,7 @@ watch(() => docgen.md, () => {
if (mode.value === 'preview') mountShadow();
});
});
onMounted(() => nextTick(mountShadow));
onMounted(() => nextTick(() => { mountShadow(); wireCiteChips(); }));
// ---------- HTML 所见即所得编辑 ----------
+29 -3
View File
@@ -17,7 +17,6 @@ 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';
@@ -29,6 +28,11 @@ import ExtensionConfirmCard from './ExtensionConfirmCard.vue';
import ExtensionRunCard from './ExtensionRunCard.vue';
// FR-PRIV-005:私有资料参考块(独立 i18n 文件,不碰 labels.js 红线)
import { PRIV } from '../../i18n/private.js';
// 论文撰写:四类块(brief 表单卡 / outline 大纲卡 / progress 进度卡 / refs 内联列表)
import { PP } from '../../i18n/paper.js';
import PaperBriefCard from './PaperBriefCard.vue';
import PaperOutlineCard from './PaperOutlineCard.vue';
import PaperProgressCard from './PaperProgressCard.vue';
const props = defineProps({
message: { type: Object, required: true },
@@ -225,8 +229,15 @@ const degradedNote = computed(() => props.message.degradedNote || null);
:block="b"
/>
<!-- P2 生信:流程知识库引用块(FR-BIO-026)→ S3 降级为只读 LegacyBioflowBlock -->
<LegacyBioflowBlock v-if="b.type === 'bioflow_qa'" :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>
<!-- 用户自定义扩展分析:上传引导卡(extension_upload) -->
<ExtensionUploadCard
@@ -292,6 +303,21 @@ const degradedNote = computed(() => props.message.degradedNote || null);
<!-- artifact:文档产物卡(P3;「打开预览」→ docgen store + bench 文档 TAB) -->
<DocCard v-if="b.type === 'artifact'" :block="b" />
<!-- 论文撰写:brief 引导表单卡 -->
<PaperBriefCard v-if="b.type === 'paper_brief'" :block="b" />
<!-- 论文撰写:大纲确认卡 -->
<PaperOutlineCard v-if="b.type === 'paper_outline'" :block="b" />
<!-- 论文撰写:节级进度卡 -->
<PaperProgressCard v-if="b.type === 'paper_progress'" :block="b" />
<!-- 论文撰写:参考文献内联列表 -->
<div v-if="b.type === 'paper_refs'" class="paper-refs">
<div class="pr-title">{{ PP.paperRefsTitle }}</div>
<ol class="pr-list">
<li v-for="r in b.references" :key="r.n">[{{ r.n }}] {{ r.text }}</li>
</ol>
<div v-if="b.disclaimer" class="pr-disclaimer">{{ b.disclaimer }}</div>
</div>
<!-- sources:内联引用 chip(FR-CHAT-015) -->
<div v-if="b.type === 'sources' && b.items && b.items.length" class="inline-sources">
<span class="src-label">{{ T.sourcesTitle }}</span>
+119 -28
View File
@@ -13,16 +13,17 @@
// qa → 原 emit('send') 路径(附件注记进消息文本)。
// 路由收敛在本组件内,ChatPanel 等上游组件零改动。
import { ref, computed, onMounted } from 'vue';
import { MODES as MODE_LABELS, T } from '../../i18n/labels.js';
import { T } from '../../i18n/labels.js';
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
import { BF } from '../../i18n/bioflow.js';
import { route } from '../../intent.js';
import { isExtensionCreateIntent } from '../../utils/intent.js';
import { route, paperIntent } from '../../intent.js';
import { route as routeBioflow, isExtensionCreateIntent } from '../../utils/intent.js';
import { uploadLibrary, listLibrary } from '../../api/docgen.js';
import { uploadBioflowFile } from '../../api/bioflow.js';
import { useDocgenStore } from '../../stores/docgen.js';
import { useChatStore } from '../../stores/chat.js';
import { useBioflowStore } from '../../stores/bioflow.js';
import { usePaperStore } from '../../stores/paper.js';
import { useSettingsStore } from '../../stores/settings.js';
const props = defineProps({
@@ -59,6 +60,9 @@ onMounted(() => {
settings.load(sid).catch(() => { /* 静默:失败不阻断(设置面板打开时会重试) */ });
}
} catch { /* settings store 未就绪(测试/降级):不阻断 */ }
try {
usePaperStore().start(); // 论文撰写探活(30s;离线时触发词落 docgen 单发降级)
} catch { /* paper store 未就绪:不阻断 */ }
});
const canSend = computed(() =>
@@ -124,6 +128,36 @@ const hasBioflowContext = computed(() => {
}
});
/** 论文上下文(当前会话消息含 paper 块;用于把修改意见路由回 sendPaper) */
const hasPaperContext = computed(() => {
try {
const chat = useChatStore();
const list = chat.messages || [];
return list.some((m) => m && m.role === 'assistant' && Array.isArray(m.blocks)
&& m.blocks.some((b) => b && ['paper_brief', 'paper_outline', 'paper_progress'].includes(b.type)));
} catch {
return false;
}
});
/** 当前活跃论文 id(从最近的 paper_outline/paper_progress 块取) */
const activePaperId = computed(() => {
try {
const chat = useChatStore();
const list = chat.messages || [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
for (const b of m.blocks) {
if (b && (b.type === 'paper_outline' || b.type === 'paper_progress') && b.paper_id) return b.paper_id;
}
}
return null;
} catch {
return null;
}
});
/** 生信可用(探活显隐 FR-BIO-025;未探活视为可用不阻断分流——后端不注册时由请求失败兜底) */
const bioflowEnabled = computed(() => {
try {
@@ -134,6 +168,16 @@ const bioflowEnabled = computed(() => {
}
});
/** 论文撰写可用(探活显隐;离线时触发词落 docgen 单发降级——后端不注册时由请求失败兜底) */
const paperEnabled = computed(() => {
try {
const st = usePaperStore();
return st.availability !== 'offline';
} catch {
return true;
}
});
const hintText = computed(() =>
attachments.value.length ? T.composerHint : `${T.composerHint} · ${DG.composerDropHint}`,
);
@@ -235,13 +279,20 @@ 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 (bioflowEnabled.value) {
if (bioflowRoute.intent !== 'bioflow_qa' && bioflowRoute.intent !== 'bioflow' && bioflowEnabled.value) {
try {
extIntent = isExtensionCreateIntent(text);
} catch {
@@ -255,18 +306,63 @@ const submit = () => {
} catch {
emit('send', text); // chat store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else if (intent === 'docgen') {
// 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 异步生成 + 自动切文档 TAB)
// 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐)
} else if (bioflowRoute.intent === 'bioflow_qa') {
// 生信流内流程咨询(FR-BIO-026):也走 sendBioflow(后端流内返引用块,不新建任务)
try {
useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), {
templateId: selectedTemplateId.value || '',
}).catch(() => {
/* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */
});
useChatStore().sendBioflow(text, { mode: 'ask' }).catch(() => { /* 内部已标注错误 */ });
} catch {
/* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */
emit('send', text);
emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else if (bioflowRoute.intent === 'bioflow') {
// 生信分析意图:chat.sendBioflow(plan_ready → 路径卡 → 任务事件)
try {
const jobId = null; // 任务未创建:文件上传待任务生成后由 FlowFileCard 引导
const chat = useChatStore();
chat.sendBioflow(text, { mode: 'ask', reuseName: bioflowRoute.reuseName }).catch(() => { /* 内部已标注错误 */ });
// 附件按生信文件上传(XHR 进度;FR-BIO-006):任务 id 由 sendBioflow 创建任务后
// 回填——任务创建前的拖入文件由 FlowFileCard 阶段引导卡处理,此处兼容
// 已存在任务场景(当前会话任务 id 已知时直接上传)。
if (readyAttachments.value.length) {
const bioflowStore = useBioflowStore();
const jobIdNow = jobId || (bioflowStore.currentJob && bioflowStore.currentJob.jobId);
if (jobIdNow) {
for (const a of readyAttachments.value) {
chat.uploadBioflowFileToJob(jobIdNow, a.file).catch(() => { /* 失败可见:chips 显示错误 */ });
}
}
}
} catch {
emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else if (intent === 'docgen') {
// 文档生成分支:论文撰写 sub-route(paperIntent && paperEnabled → sendPaper),
// 否则 sendDocgen(降级路径零改动——paper 未启用/未命中时触发词落 docgen 单发)。
let paperRoute = false;
try {
paperRoute = paperEnabled.value && paperIntent(text);
} catch {
paperRoute = false;
}
if (paperRoute) {
try {
useChatStore().sendPaper(text, { templateId: selectedTemplateId.value || '' }).catch(() => {
/* sendPaper 内部已按失败可见原则标注错误 */
});
} catch {
emit('send', text); // chat store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else {
// 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐)
try {
useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), {
templateId: selectedTemplateId.value || '',
}).catch(() => {
/* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */
});
} catch {
/* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */
emit('send', text);
}
}
} else if (intent === 'edit') {
// 编辑分支:预填 DocPane「用 prompt 修改」并切文档 TAB
@@ -278,6 +374,13 @@ const submit = () => {
const dg = useDocgenStore();
dg.requestExport();
dg.switchToDocTab();
} else if (hasPaperContext.value && paperEnabled.value) {
// 活跃论文(outline/writing/complete 阶段)的修改意见 → sendPaper(带 paper_id)
try {
useChatStore().sendPaper(text, { paperId: activePaperId.value || '' }).catch(() => {});
} catch {
emit('send', text);
}
} else {
// QA 原路径:附件注记进消息文本
const note = attachmentNoteText();
@@ -324,18 +427,6 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?.
@drop="onDrop"
>
<div class="composer">
<div class="mode-pills">
<button
v-for="pm in MODE_LABELS"
:key="pm.key"
type="button"
:class="['mode-pill', { on: mode === pm.key }]"
:disabled="busy || disabled"
@click="emit('change-mode', pm.key)"
>
{{ pm.label }}
</button>
</div>
<!-- 缺陷 #42:docgen 模式模板下拉(「自动推荐」+ 内置模板 + as_template 资料,懒加载) -->
<div v-if="isDocgenMode" class="template-row">
<label class="tpl-label" for="pa-template-select">{{ DG.docPaneTemplateLabel }}</label>
+16 -16
View File
@@ -5,7 +5,7 @@
// 复用 FlowFormCard 视觉令牌(字段行 label 96px / input 同款 / 主按钮 gradient)。
import { computed, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
import { buildExtension } from '../../api/bioflow.js';
const props = defineProps({
block: { type: Object, required: true },
@@ -13,8 +13,6 @@ const props = defineProps({
const emit = defineEmits(['submitted', 'error']);
const chat = useChatStore();
const submitting = ref(false);
const submitError = ref('');
@@ -45,25 +43,27 @@ const hasDeps = computed(() =>
(Array.isArray(reqs.value.pip) && reqs.value.pip.length > 0)
|| (Array.isArray(reqs.value.apt) && reqs.value.apt.length > 0));
/** 提交(S4 改走 card_result 进 QA 流):
* - skip=true(跳过/采纳 AI 结果)→ 重触发 AI 转换(build)
* - skip=false(编辑修正)→ confirm 语义(复用 confirm_extension 编辑值入参) */
/** 提交修正:POST build 带修正值(后端按 ext_id 重入转换) */
async function submit({ skip = false } = {}) {
if (submitting.value) return;
submitting.value = true;
submitError.value = '';
try {
if (skip) {
await chat.sendCardResult('extension_action', { action: 'build', ext_id: props.block.extId });
} else {
const body = {};
if (!skip) {
if (!templateDraft.value.trim()) { submitError.value = BF.extBuildFieldRequired; submitting.value = false; return; }
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(),
});
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;
}
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.sendCardResult('extension_action', {
action: 'run',
ext_id: props.block.extId,
const result = await chat.confirmExtensionRun(props.block, {
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 { useChatStore } from '../../stores/chat.js';
import { publishExtension } from '../../api/bioflow.js';
const props = defineProps({
block: { type: Object, required: true },
@@ -12,8 +12,6 @@ const props = defineProps({
const emit = defineEmits(['published', 'error']);
const chat = useChatStore();
const submitting = ref(false);
const submitError = ref('');
const triggersDraft = ref('');
@@ -31,12 +29,11 @@ async function publish() {
submitError.value = '';
try {
const nlTriggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
await chat.sendCardResult('extension_action', {
action: 'publish',
ext_id: props.block.extId,
nl_triggers: nlTriggers,
});
props.block.nlTriggers = nlTriggers;
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;
props.block.published = true;
emit('published', { extId: props.block.extId, nlTriggers: props.block.nlTriggers });
} catch (err) {
@@ -6,6 +6,7 @@
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 },
@@ -35,17 +36,17 @@ function onGoldenChange(e) {
if (e.target) e.target.value = '';
}
/** 试运行(S4 改走 card_result 进 QA 流;测试输入文件上传为后置上传,另行登记) */
/** 上传测试输入 → 触发试运行(服务端跑容器,结果经 extension_trial 帧回流) */
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 result = await chat.sendCardResult('extension_action', {
action: 'trial_run',
ext_id: props.block.extId,
});
const files = [testInput.value];
if (goldenInput.value) files.push(goldenInput.value);
const result = await chat.trialRunExtension(props.block.extId, files);
emit('trialled', { extId: props.block.extId, result });
} catch (err) {
submitError.value = err?.message || BF.extUploadError;
@@ -62,7 +63,7 @@ async function doRepair() {
repairing.value = true;
submitError.value = '';
try {
await chat.sendCardResult('extension_action', { action: 'repair', ext_id: props.block.extId });
await repairExtension(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 { useChatStore } from '../../stores/chat.js';
import { buildExtension } from '../../api/bioflow.js';
const props = defineProps({
block: { type: Object, required: true },
@@ -12,8 +12,6 @@ const props = defineProps({
const emit = defineEmits(['submitted', 'error']);
const chat = useChatStore();
const submitting = ref(false);
const submitError = ref('');
const values = ref({});
@@ -39,7 +37,7 @@ const goldenOk = computed(() => {
return null;
});
/** 确认(S4 改走 card_result):编辑修正(form_defaults 参数)→ confirm 语义。 */
/** 确认:重新 build(门禁纪律:确认前不推进生命周期;修正值随 build 重入)。 */
async function confirm() {
if (submitting.value) return;
submitting.value = true;
@@ -51,13 +49,7 @@ async function confirm() {
if (v === '' || v === null || v === undefined) continue;
params[f.name] = v;
}
// 编辑修正走 confirm 环节(复用 confirm_extension 编辑值入参)
await chat.sendCardResult('extension_action', {
action: 'confirm',
ext_id: props.block.extId,
parameters: props.block.parameters || [],
runtime_image: '',
});
await buildExtension(props.block.extId, { params });
props.block.confirmed = true;
emit('submitted', { extId: props.block.extId, params });
} catch (err) {
@@ -7,7 +7,6 @@
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 },
@@ -16,7 +15,6 @@ 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 || '');
@@ -52,12 +50,6 @@ 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;
+3 -3
View File
@@ -84,16 +84,16 @@ function onInputChange(e) {
if (e.target) e.target.value = ''; // 允许重复选择同一文件
}
// ---- 服务器路径登记(FR-BIO-007;S3 改走 card_result 进 QA 流) ----
// ---- 服务器路径登记(FR-BIO-007) ----
async function registerPath() {
const p = pathDraft.value.trim();
if (!p || registering.value || !jobId.value) return;
registering.value = true;
registerError.value = '';
try {
await chat.sendCardResult('file_upload', { job_id: jobId.value, paths: [p] });
const res = await chat.registerBioflowPath(jobId.value, p);
pathDraft.value = '';
emit('registered', { path: p });
emit('registered', { path: p, res });
} catch (err) {
registerError.value = err?.message || BF.filePathError;
emit('error', err);
+1 -4
View File
@@ -10,7 +10,6 @@
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 },
@@ -19,7 +18,6 @@ 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 || '');
@@ -155,8 +153,7 @@ async function submit() {
} else {
const jobId = bioflow.currentJob.jobId || props.block.jobId;
if (!jobId) throw new Error('no job');
// S3:卡片不直连控制面——参数确认改为 card_result 消息进 QA 流(BioflowAgent 经 confirm_stage 执行)
await chat.sendCardResult('form_confirm', { job_id: jobId, stage: stage.value, params });
await bioflow.confirmStage(stage.value, params);
props.block.submitted = true;
emit('submitted', { stage: stage.value, params });
}
+14 -8
View File
@@ -7,7 +7,7 @@
// 复用句式(FR-BIO-023):候选来自存档装载,卡片头部明示「复用存档 XXX 的方法」。
import { computed, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useBioflowStore, inferBioflowChain } from '../../stores/bioflow.js';
import { useBioflowStore } from '../../stores/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({
@@ -66,16 +66,22 @@ async function confirmCreate() {
confirming.value = true;
error.value = '';
try {
// 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,
// 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) || '生信任务',
auto_continue: false,
stages: cand.stages,
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);
+203 -13
View File
@@ -1,18 +1,35 @@
<script setup>
// chat/FlowProgressCard.vue —— 生信阶段进度卡(S3 精简为只读)
// 只读展示:阶段列表(状态点三态)+ 进度条 + 最近一行日志尾 + 折叠分组。
// S3 起去掉暂停/恢复、失败重跑/跳过、改参数重跑等直连控制按钮与 locate 点击——
// 任务控制一律走自然语言(QA 流),本卡不直连生信控制面。
// 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 被快照整体替换(引用变化)时本卡自动整体刷新。
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);
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)
// 相位色映射(与 PipelineCard PHASE_COLOR_VAR 一致;阶段序号对应五相位循环)
function phaseVar(i) {
@@ -20,6 +37,7 @@ function phaseVar(i) {
return PHASE_COLOR_VAR[keys[i % keys.length]] || PHASE_COLOR_VAR.planning;
}
/** 状态点符号与颜色(三重编码:颜色 + 图标 + 文字) */
function dotClass(st) {
return {
done: 'done',
@@ -36,13 +54,20 @@ 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({});
@@ -66,12 +91,13 @@ watch(
{ deep: true },
);
// ---- 折叠(阶段 >10 自动分组折叠;默认展开运行中所在组) ----
// ---- 折叠(FR-BIO-013:阶段 >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 }];
@@ -92,6 +118,7 @@ const groups = computed(() => {
}));
});
/** 组展开状态:快照整体替换(引用变化)时按运行中所在组重算默认展开 */
watch(
() => props.block.stages,
() => {
@@ -117,6 +144,120 @@ 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>
@@ -124,6 +265,12 @@ function isGroupOpen(i) {
<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 }}
@@ -131,7 +278,7 @@ function isGroupOpen(i) {
</div>
</div>
<!-- 折叠分组 -->
<!-- 折叠分组(FR-BIO-013) -->
<template v-for="(g, gi) in groups" :key="gi">
<button
v-if="g.collapsible"
@@ -154,14 +301,17 @@ function isGroupOpen(i) {
>{{ dotSymbol(st) }}</span>
<span class="p-name">{{ st.stage }}</span>
<span class="p-desc" :title="st.error || (st.progressMessage || '')">{{ st.progressMessage || st.error || '' }}</span>
<!-- 进度条(S3 只读,不可点击定位) -->
<div
<!-- 进度条:有 percent → 确定性;running 无 percent → 不确定扫光;其余 → 不显示 -->
<button
v-if="hasPct(st) || (isLiveRunning(st) && !hasPct(st))"
class="prog-track"
class="prog-track clickable"
type="button"
:title="BF.progLocateHint"
@click.stop="locateStage(st)"
>
<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>
</div>
</button>
<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>
@@ -171,17 +321,57 @@ function isGroupOpen(i) {
</div>
</template>
<!-- 日志尾区(仅 running/failed 展开;最近一行,随 SSE 追加自动滚底) -->
<!-- 日志尾区(§8.4:仅 running/failed 展开;--pa-code-bg mono 11px padding 8px 10px max-height 96px 滚动) -->
<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 {
@@ -1,94 +0,0 @@
<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>
+64
View File
@@ -0,0 +1,64 @@
<script setup>
// PaperBriefCard.vue —— 论文引导表单卡(预填已抽取 brief,缺失字段待补)
// 卡片辅助、NL 主导:提交时把已填字段拼成一句 NL,回 Composer 走 sendPaper 提交。
import { ref, computed } from 'vue';
import { PP } from '../../i18n/paper.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({ block: { type: Object, required: true } });
const fields = computed(() => (Array.isArray(props.block.fields) ? props.block.fields : []));
const submitting = ref(false);
const values = ref({});
for (const f of fields.value) {
if (!(f.key in values.value)) values.value[f.key] = f.value || '';
}
function submit() {
if (submitting.value || props.block.submitted) return;
submitting.value = true;
const parts = fields.value
.filter((f) => values.value[f.key] && String(values.value[f.key]).trim())
.map((f) => `${f.label}:${String(values.value[f.key]).trim()}`);
const text = `帮我写一篇论文:${parts.join(';')}`;
props.block.submitted = true;
try {
useChatStore().sendPaper(text).catch(() => { /* 内部已标注错误 */ });
} catch { /* chat store 未就绪:降级 */ }
submitting.value = false;
}
</script>
<template>
<div class="paper-brief-card">
<div class="pbc-title">{{ PP.paperBriefTitle }}</div>
<div class="pbc-hint">{{ PP.paperBriefHint }}</div>
<div v-for="f in fields" :key="f.key" class="pbc-field">
<label class="pbc-label">{{ f.label }}</label>
<select v-if="Array.isArray(f.options) && f.options.length" v-model="values[f.key]" class="pbc-input">
<option value=""></option>
<option v-for="o in f.options" :key="o" :value="o">{{ o }}</option>
</select>
<input v-else v-model="values[f.key]" class="pbc-input" type="text" />
</div>
<div class="pbc-footer">
<button v-if="!props.block.submitted" class="pbc-submit" type="button" :disabled="submitting" @click="submit">
{{ submitting ? PP.paperBriefSubmitting : PP.paperBriefSubmit }}
</button>
<span v-else class="pbc-done">✓</span>
</div>
</div>
</template>
<style scoped>
.paper-brief-card { border: 1px solid var(--border, #e5e7eb); border-radius: 8px; padding: 12px; margin: 8px 0; }
.pbc-title { font-weight: 600; margin-bottom: 4px; }
.pbc-hint { color: #374151; font-size: 12px; margin-bottom: 8px; }
.pbc-field { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.pbc-label { min-width: 72px; font-size: 13px; color: #111827; }
.pbc-input { flex: 1; padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 4px; color: #111827; }
.pbc-footer { margin-top: 8px; }
.pbc-submit { padding: 6px 14px; border: none; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
.pbc-done { color: #16a34a; }
</style>
@@ -0,0 +1,51 @@
<script setup>
// PaperOutlineCard.vue —— 论文大纲确认卡(章节勾选/编辑要点 + 确认开写)
import { ref, computed } from 'vue';
import { PP } from '../../i18n/paper.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({ block: { type: Object, required: true } });
const sections = computed(() => (Array.isArray(props.block.sections) ? props.block.sections : []));
const confirming = ref(false);
function confirm() {
if (confirming.value || props.block.status === 'confirmed') return;
confirming.value = true;
props.block.status = 'confirmed';
try {
useChatStore().confirmPaper(props.block.paper_id);
} catch { /* chat store 未就绪:降级 */ }
confirming.value = false;
}
</script>
<template>
<div class="paper-outline-card">
<div class="poc-title">{{ PP.paperOutlineTitle }}:{{ props.block.title }}</div>
<div v-for="s in sections" :key="s.section_id" class="poc-section">
<input type="checkbox" :checked="s.selected !== false" @change="s.selected = $event.target.checked" />
<span class="poc-sec-title">{{ s.title }}</span>
<span v-if="s.key_points && s.key_points.length" class="poc-sec-pts">{{ s.key_points.join(';') }}</span>
</div>
<div class="poc-footer">
<button v-if="props.block.status !== 'confirmed'" class="poc-confirm" type="button" :disabled="confirming" @click="confirm">
{{ confirming ? PP.paperOutlineConfirming : PP.paperOutlineConfirm }}
</button>
<span v-else class="poc-confirmed">✓ 已启动撰写</span>
</div>
<div class="poc-modify-hint">{{ PP.paperOutlineModify }}</div>
</div>
</template>
<style scoped>
.paper-outline-card { border: 1px solid var(--border, #e5e7eb); border-radius: 8px; padding: 12px; margin: 8px 0; }
.poc-title { font-weight: 600; margin-bottom: 8px; }
.poc-section { display: flex; align-items: baseline; gap: 6px; margin-bottom: 4px; }
.poc-sec-title { font-weight: 500; }
.poc-sec-pts { color: #6b7280; font-size: 12px; }
.poc-footer { margin-top: 10px; }
.poc-confirm { padding: 6px 14px; border: none; border-radius: 6px; background: #16a34a; color: #fff; cursor: pointer; }
.poc-confirmed { color: #16a34a; }
.poc-modify-hint { margin-top: 6px; color: #9ca3af; font-size: 12px; }
</style>

Some files were not shown because too many files have changed in this diff Show More