forked from admin/PlantAI-rag-new
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bd86e535 |
+2
-8
@@ -37,11 +37,5 @@ tests/extension_cases_18/
|
||||
bioflow_service/bioflow.db
|
||||
*.before-*
|
||||
|
||||
# paper_writer_service 运行时产物(不入库)
|
||||
paper_writer_service/paper.db
|
||||
paper_writer_service/paper.db-shm
|
||||
paper_writer_service/paper.db-wal
|
||||
paper_writer_service/paper.log
|
||||
|
||||
# 部署方案文档(含主机凭据,不入库)
|
||||
docs/DEPLOY_PAPER_176.md
|
||||
# Python 覆盖率运行时产物(coverage.py --cov 生成)
|
||||
.coverage
|
||||
|
||||
@@ -16,3 +16,70 @@ capabilities:
|
||||
endpoint: https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi
|
||||
method: GET
|
||||
timeout: 8
|
||||
|
||||
# ---- 生信能力单元(S1):两个 MCP 端点,各自独立装卸 ----
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析(路径规划/建任务/参数确认/文件获取与预处理/状态查询),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers:
|
||||
- 基因家族
|
||||
- 家族成员
|
||||
- 亚家族
|
||||
- 系统发育
|
||||
- 建树
|
||||
- 进化树
|
||||
- 保守基序
|
||||
- motif
|
||||
- 结构域
|
||||
- 基因结构
|
||||
- 共线性
|
||||
- Ka/Ks
|
||||
- 选择压力
|
||||
- 组合证据
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析(实验设计确认/样本配置/路径规划/建任务/差异表达/富集),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers:
|
||||
- 转录组
|
||||
- RNA-seq
|
||||
- RNAseq
|
||||
- rnaseq
|
||||
- 差异表达
|
||||
- 表达定量
|
||||
- 富集
|
||||
- WGCNA
|
||||
- GSEA
|
||||
- 实验设计
|
||||
- fastq
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
|
||||
# ---- 生信能力单元(S4):第三个 MCP 端点,用户自定义扩展分析 ----
|
||||
- name: bioflow-extension
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 用户自定义扩展分析(创建/转换/试运行/校验/发布/运行),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers:
|
||||
- 扩展
|
||||
- 拓展分析
|
||||
- 扩展分析
|
||||
- 自定义分析
|
||||
- 分析模板
|
||||
- 分析脚本
|
||||
- 分析工具
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/extension/
|
||||
prompt_pack:
|
||||
file: bioflow_extension_agent_rules.md
|
||||
|
||||
Vendored
+3
@@ -31,3 +31,6 @@ QA_V1_WEB_SEARCH_MAX_RESULTS=4
|
||||
QA_V1_WEB_SEARCH_TIMEOUT=8
|
||||
QA_V1_WEB_SEARCH_MAX_CHARS=1200
|
||||
QA_V1_ENABLE_SUPERPOWERS=1
|
||||
|
||||
# 生信对话式能力熔断开关(S1):默认关(=0)。置 1 才启用 QA 前门 bioflow_agent。
|
||||
QA_V1_ENABLE_BIOFLOW_AGENT=0
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 用户自定义扩展分析对话规范(bioflow_extension_agent_rules)
|
||||
|
||||
你是用户自定义扩展分析助手。引导用户把一段可运行的分析脚本/工具,通过「创建 → AI 转换 → 试运行 → 校验 → 确认 → 发布 → 运行」流程,做成 PlantAI 可自然语言触发的自定义扩展。
|
||||
|
||||
## 对话节奏(自然语言主导 + 卡片辅助)
|
||||
每次需要用户输入/选择时,遵循固定节奏:
|
||||
1. 先用自然语言说明并列表展示需要什么;
|
||||
2. 提示用户可用卡片快捷操作,也可直接打字(两条通道语义一致);
|
||||
3. 用户回答后,用自然语言复述,确认无误再继续。
|
||||
|
||||
## 创建(create_extension)
|
||||
- 需要用户提供:显示名、运行命令、代码文件(可上传/粘贴文本内容)、运行时镜像、可选测试数据/金标。
|
||||
- 代码文件以「路径 + 文本内容」提交;引导用户说明脚本是做什么的、依赖什么。
|
||||
|
||||
## AI 转换与校验(build → trial_run → validate)
|
||||
- 创建后调用 build 做 AI 转换(原始命令 → command_template + 参数 + 依赖),失败时规则版兜底。
|
||||
- 转换结果先向用户说明(识别到的参数/依赖/输入角色),请用户确认参数名与默认值。
|
||||
- 试运行/校验的结果要转述给用户,失败时说明原因并引导修复(repair 循环)。
|
||||
|
||||
## 确认与发布(confirm_extension → publish_extension)
|
||||
- 确认前请用户确认触发词(nl_triggers)与参数——触发词决定用户以后说什么话能唤起这个扩展。
|
||||
- 发布后扩展才可被自然语言触发运行;发布是不可逆的对外可用状态,须经用户确认。
|
||||
|
||||
## 运行(run_extension)
|
||||
- 仅已发布扩展可运行;运行参数须在白名单内(参数 schema),未知参数明确追问。
|
||||
- 运行结果(run_result)转述给用户。
|
||||
|
||||
## 其它
|
||||
- 工具名/参数名/内部实现一律不外泄;
|
||||
- 状态不确定时主动查 extension_status 并向用户转述当前阶段与下一步;
|
||||
- 人工确认边界(确认触发词、发布)不可跳过、不可代用户拍板。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 基因家族分析对话规范(bioflow_gene_family_agent_rules)
|
||||
|
||||
你是基因家族分析助手。用自然语言引导用户从需求到分析完成,全程对话式交互。
|
||||
|
||||
## 人工确认边界(不可跳过、不可代确认)
|
||||
- **subfamily_confirm(亚家族预测人工确认)是硬性人工边界**:系统预测的亚家族划分结果,
|
||||
必须停下来请用户查看并确认或修改。你绝不能代用户确认,也不能在用户未确认前继续下游分析。
|
||||
- 用户提出修改(如"把某成员从亚家族 A 移到亚家族 B")时,先复述修改内容,再请用户做最终确认。
|
||||
|
||||
## 对话节奏(自然语言主导 + 卡片辅助)
|
||||
每次需要用户输入/选择时,遵循固定节奏:
|
||||
1. 先用自然语言说明并**列表展示**选项及含义;
|
||||
2. 提示用户可用卡片快捷操作,也可直接打字回复(两条通道语义一致);
|
||||
3. 用户选择后,用自然语言**复述**其选择,确认无误再继续。
|
||||
|
||||
## 路径规划与建任务
|
||||
- 规划后列出"核心流程(必选)+ 可选分析"候选路径,说明每个可选分析的生物学意义;
|
||||
- **建任务前必须经用户确认阶段链**,收到确认后再创建任务,绝不擅自建任务。
|
||||
|
||||
## 文件获取与预处理
|
||||
- 源文件支持上传、http/https 链接、OSS 地址、局域网机器路径;
|
||||
- 文件到达后先探测格式,**格式异常(如 Excel/文本)时先向用户说明列结构并提问确认**
|
||||
(哪列是序列、是否去重、是否去表头等),再由模型整理成 fasta/gff 等标准格式;
|
||||
- 转换结果过格式验证,失败时说明问题并追问修正(循环不超过 3 次后建议人工整理)。
|
||||
|
||||
## 参数确认
|
||||
- 列"参数名(默认值,含义)"请用户确认或修改;未识别参数时明确指出并给出允许范围。
|
||||
|
||||
## 其它
|
||||
- 工具名、参数名、内部实现一律不外泄;
|
||||
- 任务运行中用户提问(流程/参数/生物学意义)随时回答,不打断任务;
|
||||
- 卡住或不确定时主动查询任务状态,向用户转述当前进度与下一步。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 转录组分析对话规范(bioflow_rnaseq_agent_rules)
|
||||
|
||||
你是转录组分析助手。用自然语言引导用户从实验设计确认到差异表达/富集分析完成。
|
||||
|
||||
## 人工确认边界(不可跳过、不可代确认)
|
||||
- **实验设计确认(rnaseq_de 之前的必确认信息)是硬性边界**:开始分析前必须与用户确认——
|
||||
① 物种与参考基因组版本;② 每个样本的 FASTQ(上传/链接/局域网路径)及单端双端;
|
||||
③ 对照组/处理组各几个重复、有无时间点/批次;④ 建库是否链特异性。
|
||||
- 统计模型正确性依赖上述信息,未确认前不得创建差异表达任务,也不能代用户拍板基线/比较方向。
|
||||
|
||||
## 对话节奏(自然语言主导 + 卡片辅助)
|
||||
每次需要用户输入/选择时,遵循固定节奏:
|
||||
1. 先用自然语言说明并**列表展示**必确认信息与可选分析;
|
||||
2. 提示用户可用卡片快捷操作,也可直接打字回复(两条通道语义一致);
|
||||
3. 用户回答后,用自然语言**复述**其样本设计与选择(如"干旱 3 vs 对照 3,双端,非链特异,对照为基线"),
|
||||
确认无误再继续。
|
||||
|
||||
## 样本配置与文件预处理
|
||||
- 用户用自然语言描述样本时,可解析生成 samples.yaml(样本名/对照处理组/重复/单双端);
|
||||
- FASTQ 支持上传、http/https 链接、OSS 地址、局域网路径;核对成对完整;
|
||||
- 非 FASTQ 的输入文件(表格/文本等)先探测格式,**异常时先说明列结构并提问确认**,再整理为可用格式。
|
||||
|
||||
## 参数确认
|
||||
- 列"参数名(默认值,含义)"请用户确认或修改;未识别参数时明确指出并给出允许范围。
|
||||
|
||||
## 其它
|
||||
- 工具名、参数名、内部实现一律不外泄;
|
||||
- 任务运行中用户提问(流程/参数/实验设计意义)随时回答,不打断任务;
|
||||
- 卡住或不确定时主动查询任务状态,向用户转述当前进度与下一步(含样本质控 PCA、差异基因数目等结果转述)。
|
||||
@@ -13,6 +13,7 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import __version__
|
||||
from .bioflow_agent import try_bioflow_agent
|
||||
from .config import QAConfig
|
||||
from .pipeline import QAAnswerError, QAV1Pipeline
|
||||
|
||||
@@ -47,6 +48,9 @@ class QARequest(BaseModel):
|
||||
# ---- Capability Gateway(S1):纯数据字段,无 validator(Cython 编译安全)----
|
||||
history: List[Dict[str, str]] = Field(default_factory=list)
|
||||
capabilities: Dict[str, Any] = Field(default_factory=dict)
|
||||
# ---- Bioflow 对话式编排(S2):卡片回填协议 + session↔job 绑定 ----
|
||||
card_result: Optional[Dict[str, Any]] = None
|
||||
bioflow: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -150,6 +154,18 @@ async def chat(request: QARequest) -> Dict[str, Any]:
|
||||
detail={"error_code": "llm_unavailable", "answer": "", "message": "Final-answer LLM is not configured"},
|
||||
)
|
||||
try:
|
||||
# ---- Bioflow Agent 前门(S1):命中则短路返回,否则走原 pipeline ----
|
||||
bioflow_result = await asyncio.to_thread(
|
||||
try_bioflow_agent,
|
||||
request.question,
|
||||
llm_client=pipeline.llm,
|
||||
history=request.history,
|
||||
capabilities=request.capabilities,
|
||||
card_result=request.card_result,
|
||||
bioflow=request.bioflow,
|
||||
)
|
||||
if bioflow_result is not None:
|
||||
return bioflow_result
|
||||
ablation = dict(request.ablation or {})
|
||||
ablation["use_final_llm"] = True
|
||||
return await asyncio.to_thread(
|
||||
@@ -216,6 +232,19 @@ async def chat_stream(request: QARequest) -> StreamingResponse:
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
# ---- Bioflow Agent 前门(S1):命中则短路,否则走原 pipeline ----
|
||||
bioflow_result = try_bioflow_agent(
|
||||
request.question,
|
||||
llm_client=pipeline.llm,
|
||||
history=request.history,
|
||||
capabilities=request.capabilities,
|
||||
card_result=request.card_result,
|
||||
bioflow=request.bioflow,
|
||||
progress_callback=emit,
|
||||
)
|
||||
if bioflow_result is not None:
|
||||
events.put(("complete", bioflow_result))
|
||||
return
|
||||
ablation = dict(request.ablation or {})
|
||||
ablation["use_final_llm"] = True
|
||||
result = pipeline.answer(
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""bioflow_agent.py —— 生信 MCP 能力前门路由(S1 + S2)。
|
||||
|
||||
S1:三重门(会话开关/注册表/意图预筛)→ ToolRunner 工具循环 → 自然语言转述。
|
||||
S2:对话式编排闭环——card_result 卡片回填协议 + session↔job 绑定 + 多轮记忆
|
||||
+ 参数抽取闭环 + job_status 主动查询转述。
|
||||
|
||||
card_result 协议(前端卡片操作 → 结构化用户消息 → QA 流):
|
||||
{card: path_selection|form_confirm|file_upload|archive_choice, payload: {...}}
|
||||
BioflowAgent 读取后经工具执行,再自然语言复述;卡片与自然语言两条通道在
|
||||
服务端语义一致(QA 对话状态机是唯一决策点)。
|
||||
|
||||
session↔job 绑定:BFF sessions.meta.bioflow = {active_job_id, chain},经请求
|
||||
body["bioflow"] 注入(含 session_id);job_status 工具是任务真相唯一权威。
|
||||
|
||||
金标路径零改动:任何不命中/异常都返回 None 回退原 pipeline。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .capabilities.manifests import CapabilityManifest
|
||||
from .capabilities.providers import PromptPackProvider, get_dispatcher
|
||||
from .capabilities.registry import get_registry
|
||||
from .tool_runner import ToolRunner, make_llm_complete
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 三个生信能力单元(与 bioflow_service 三个 MCP 端点一一对应)
|
||||
BIOFLOW_CAPABILITY_NAMES = ("bioflow-gene-family", "bioflow-rnaseq", "bioflow-extension")
|
||||
ENV_FUSE = "QA_V1_ENABLE_BIOFLOW_AGENT"
|
||||
MAX_ROUNDS = 5
|
||||
|
||||
# card_result 卡片类型(S2 前端卡片回填协议)
|
||||
CARD_PATH_SELECTION = "path_selection"
|
||||
CARD_FORM_CONFIRM = "form_confirm"
|
||||
CARD_FILE_UPLOAD = "file_upload"
|
||||
CARD_ARCHIVE_CHOICE = "archive_choice"
|
||||
CARD_EXTENSION_ACTION = "extension_action" # S4:扩展能力单元操作(build/trial/validate/confirm/publish/run)
|
||||
CARD_TYPES = (CARD_PATH_SELECTION, CARD_FORM_CONFIRM, CARD_FILE_UPLOAD, CARD_ARCHIVE_CHOICE, CARD_EXTENSION_ACTION)
|
||||
|
||||
# chain 名 → 能力名(建任务时选择哪条链的 create 工具)
|
||||
_CHAIN_TO_CAPABILITY = {
|
||||
"gene_family": "bioflow-gene-family",
|
||||
"rnaseq": "bioflow-rnaseq",
|
||||
}
|
||||
|
||||
|
||||
def _chain_to_capability(chain: str) -> Optional[str]:
|
||||
"""把 chain 名(gene_family/rnaseq)映射为能力名;未知返回 None。"""
|
||||
return _CHAIN_TO_CAPABILITY.get(str(chain or "").strip().lower())
|
||||
|
||||
_SYSTEM_BASE = (
|
||||
"你是 PlantAI 生信分析助手,用自然语言引导用户完成基因家族或转录组分析。\n"
|
||||
"你可以调用生信工具完成规划、建任务、确认参数、登记文件、查状态等操作。\n"
|
||||
"硬性规则:\n"
|
||||
"1. 回复只使用自然语言,绝不向用户泄露工具名、参数名或内部实现细节。\n"
|
||||
"2. 需要用户提供文件/参数/做选择时,先自然语言说明并列表展示选项与含义,再请用户回答,"
|
||||
"绝不擅自替用户做不可逆决策。\n"
|
||||
"3. 人工确认边界(基因家族 subfamily_confirm、转录组实验设计确认)不可跳过、不可代用户确认。\n"
|
||||
"4. 任务运行中可随时回答流程/参数/生物学意义类问题;卡住或不确定时主动查询任务状态,"
|
||||
"再向用户转述当前进度与下一步(等待文件、等待参数确认、失败可重跑等)。\n"
|
||||
"5. 文件格式异常时先向用户说明并提问确认,再由用户决定如何处理。\n"
|
||||
"6. 用户用自然语言给出参数(如「用 8 核、padj 用 0.01」)时,抽取为结构化参数后经确认步骤提交;"
|
||||
"只提交白名单内的参数名,未知参数明确指出并追问(最多 3 次)。\n"
|
||||
"7. 若用户消息是对上一轮卡片/提问的确认或选择,先复述用户的选择,确认无误再执行对应动作。\n"
|
||||
)
|
||||
|
||||
|
||||
def _env_enabled() -> bool:
|
||||
"""门 1:QA_V1_ENABLE_BIOFLOW_AGENT 熔断开关。默认关(未设置 = 关)。"""
|
||||
raw = os.environ.get(ENV_FUSE)
|
||||
return raw is not None and str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _intent_match(question: str, triggers: List[str]) -> bool:
|
||||
"""门 3:意图预筛——问句是否命中任一触发词(大小写不敏感子串匹配)。"""
|
||||
if not triggers:
|
||||
return False
|
||||
q = (question or "").lower()
|
||||
return any(t and t.lower() in q for t in triggers)
|
||||
|
||||
|
||||
def _registered_capabilities(capabilities: Optional[Dict[str, Any]]) -> List[CapabilityManifest]:
|
||||
"""注册表 + 会话开关:返回所有已注册且会话开启的生信能力 manifest(不判意图)。"""
|
||||
try:
|
||||
registry = get_registry()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: registry 访问失败,回退原 pipeline: %s", exc)
|
||||
return []
|
||||
session_caps = capabilities or {}
|
||||
out: List[CapabilityManifest] = []
|
||||
for name in BIOFLOW_CAPABILITY_NAMES:
|
||||
try:
|
||||
spec = registry.get(name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: registry.get(%s) 失败: %s", name, exc)
|
||||
continue
|
||||
if spec is None:
|
||||
continue # 未注册
|
||||
manifest = spec.manifest
|
||||
if not bool(session_caps.get(name, manifest.session_default)):
|
||||
continue # 会话开关关
|
||||
out.append(manifest)
|
||||
return out
|
||||
|
||||
|
||||
def _candidate_capabilities(
|
||||
question: str,
|
||||
capabilities: Optional[Dict[str, Any]],
|
||||
) -> List[CapabilityManifest]:
|
||||
"""门 2 + 门 3:注册表 + 会话开关 + 意图预筛,返回命中的候选能力清单。"""
|
||||
return [
|
||||
m for m in _registered_capabilities(capabilities)
|
||||
if _intent_match(question, m.nl_triggers)
|
||||
]
|
||||
|
||||
|
||||
def _build_tools(
|
||||
manifests: List[CapabilityManifest],
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[str, CapabilityManifest]]:
|
||||
"""经 MCP list_tools 发现各能力工具,构造 LLM 工具表 + ToolRunner manifests。
|
||||
|
||||
返回 (tools, tool_manifests):
|
||||
- tools: OpenAI function schema 列表(真实 server 工具名/描述/入参 schema)
|
||||
- tool_manifests: {真实工具名: manifest},manifest.name=能力名(共享连接),
|
||||
mcp.tool=真实工具名
|
||||
|
||||
多能力同时命中时:专有工具名本就不同,各自保留;共享工具(两端点语义一致)
|
||||
按名去重,只保留首个能力的一份,避免 LLM tools 重名 + 同名覆盖。
|
||||
"""
|
||||
dispatcher = get_dispatcher()
|
||||
tools: List[Dict[str, Any]] = []
|
||||
tool_manifests: Dict[str, CapabilityManifest] = {}
|
||||
for cap_manifest in manifests:
|
||||
url = str((cap_manifest.config.get("mcp") or {}).get("url") or "").strip()
|
||||
if not url:
|
||||
LOGGER.warning("bioflow agent: 能力 %s 缺 mcp.url,跳过", cap_manifest.name)
|
||||
continue
|
||||
try:
|
||||
discovered = dispatcher.list_mcp_tools(cap_manifest)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: 能力 %s MCP 工具发现失败: %s", cap_manifest.name, exc)
|
||||
continue
|
||||
for item in discovered:
|
||||
tool_name = str(item.get("name") or "").strip()
|
||||
if not tool_name:
|
||||
continue
|
||||
if tool_name in tool_manifests:
|
||||
# 共享工具去重(见 S1 修复说明)
|
||||
continue
|
||||
input_schema = item.get("input_schema") or {}
|
||||
if not isinstance(input_schema, dict):
|
||||
input_schema = {"type": "object", "properties": {}}
|
||||
tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"description": str(item.get("description") or "")[:1000],
|
||||
"parameters": input_schema,
|
||||
},
|
||||
}
|
||||
)
|
||||
tool_manifests[tool_name] = CapabilityManifest(
|
||||
name=cap_manifest.name,
|
||||
provider="mcp",
|
||||
permission="read_only",
|
||||
description=str(item.get("description") or ""),
|
||||
config={"mcp": {"url": url, "tool": tool_name}},
|
||||
)
|
||||
return tools, tool_manifests
|
||||
|
||||
|
||||
def _build_system(manifests: List[CapabilityManifest]) -> str:
|
||||
"""基础系统提示 + 各能力对话规范(prompt_pack 注入)。"""
|
||||
qa_root = Path(os.environ.get("GENE_FAMILY_ROOT", "/data/gene-family-mas-v2"))
|
||||
parts = [_SYSTEM_BASE]
|
||||
seen: set[str] = set()
|
||||
for manifest in manifests:
|
||||
file_name = str((manifest.config.get("prompt_pack") or {}).get("file") or "").strip()
|
||||
if not file_name or file_name in seen:
|
||||
continue
|
||||
seen.add(file_name)
|
||||
try:
|
||||
text = PromptPackProvider.load(manifest, qa_root)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: prompt_pack 加载失败 %s: %s", file_name, exc)
|
||||
text = ""
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S2 工具执行 / 转述 / 参数抽取 辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _execute_tool(
|
||||
tool_manifests: Dict[str, CapabilityManifest], tool_name: str, arguments: Dict[str, Any]
|
||||
) -> str:
|
||||
"""执行单个工具,返回结果文本(异常转文本,不炸主流程)。"""
|
||||
manifest = tool_manifests.get(tool_name)
|
||||
if manifest is None:
|
||||
return f"工具 {tool_name} 未注册"
|
||||
try:
|
||||
return get_dispatcher().execute(manifest, arguments)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"工具执行失败: {type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
def _paraphrase(llm_client: Any, user_text: str, system: Optional[str] = None) -> str:
|
||||
"""把工具执行结果转述为自然语言(LLM 优先,失败回退原文,绝不抛异常)。"""
|
||||
system = system or (
|
||||
"你是 PlantAI 生信分析助手。把下面的工具执行结果转述为对用户的自然语言回复,"
|
||||
"绝不提及工具名/参数名/内部实现;需要用户下一步操作时明确说明。"
|
||||
)
|
||||
try:
|
||||
text = llm_client.complete(system, user_text, temperature=0.2, max_tokens=600)
|
||||
if text and str(text).strip():
|
||||
return str(text).strip()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: paraphrase LLM 失败,回退原文: %s", exc)
|
||||
return str(user_text or "").strip()
|
||||
|
||||
|
||||
def _extract_params(
|
||||
llm_client: Any, user_text: str, form_defaults: Dict[str, Any]
|
||||
) -> Tuple[Dict[str, Any], List[str]]:
|
||||
"""LLM 抽取用户自然语言中的阶段确认参数 → (已识别参数, 未识别参数列表)。
|
||||
|
||||
白名单 = form_defaults 的键;未识别参数(不在白名单)单独返回供追问(≤3 次)。
|
||||
参照 extension_llm 的「LLM 出 JSON → 白名单净化」模式,LLM 失败/坏 JSON 返回空。
|
||||
"""
|
||||
whitelist = {str(k) for k in (form_defaults or {}).keys()}
|
||||
if not whitelist:
|
||||
return {}, []
|
||||
system = (
|
||||
"你是参数抽取助手。从用户的话中抽取阶段确认参数,只输出 JSON。\n"
|
||||
'输出形态:{"params": {"<参数名>": <值>}, "unknown": ["<未识别参数名>"]}。\n'
|
||||
f"允许的参数名(白名单):{', '.join(sorted(whitelist))}。\n"
|
||||
"只抽取用户明确提到的参数;不在白名单里的参数名放进 unknown。"
|
||||
)
|
||||
try:
|
||||
raw = llm_client.complete_json(system, user_text, max_tokens=500)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: 参数抽取 LLM 失败: %s", exc)
|
||||
return {}, []
|
||||
if not isinstance(raw, dict):
|
||||
return {}, []
|
||||
params = raw.get("params") if isinstance(raw.get("params"), dict) else {}
|
||||
known = {str(k): v for k, v in params.items() if str(k) in whitelist}
|
||||
unknown_raw = raw.get("unknown") or []
|
||||
if isinstance(unknown_raw, str):
|
||||
unknown_raw = [unknown_raw]
|
||||
unknown = [str(x).strip() for x in unknown_raw if isinstance(x, str) and str(x).strip()]
|
||||
return known, unknown
|
||||
|
||||
|
||||
def _try_parse_json(text: str) -> Optional[Any]:
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_job_id(result_text: str) -> Optional[str]:
|
||||
"""从 create_job 工具结果文本里提取 job_id(结果形如 {"ok":true,"data":{"id":...}})。"""
|
||||
data = _try_parse_json(result_text)
|
||||
if isinstance(data, dict):
|
||||
inner = data.get("data") if isinstance(data.get("data"), dict) else data
|
||||
jid = inner.get("id") or inner.get("job_id")
|
||||
if jid:
|
||||
return str(jid)
|
||||
return None
|
||||
|
||||
|
||||
def _query_job_status(job_id: str, tool_manifests: Dict[str, CapabilityManifest], llm_client: Any) -> str:
|
||||
"""主动查 job_status 并转述(任务真相唯一权威)。"""
|
||||
result = _execute_tool(tool_manifests, "job_status", {"job_id": job_id})
|
||||
return _paraphrase(llm_client, f"查询任务状态的结果:\n{result}")
|
||||
|
||||
|
||||
def _empty_audit() -> Dict[str, Any]:
|
||||
return {"rounds": 0, "tool_calls": 0, "used_json_fallback": False, "tool_names": [], "errors": []}
|
||||
|
||||
|
||||
def _make_payload(
|
||||
question: str,
|
||||
answer: str,
|
||||
*,
|
||||
audit: Optional[Dict[str, Any]] = None,
|
||||
capabilities: Optional[List[str]] = None,
|
||||
active_job_id: Optional[str] = None,
|
||||
chain: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"success": True,
|
||||
"version": "bioflow-agent-s2",
|
||||
"question": question,
|
||||
"answer": answer,
|
||||
"sources": [],
|
||||
"source_count": 0,
|
||||
"citation_validation": {"valid": True, "cited_pmids": [], "unknown_pmids": []},
|
||||
"bioflow": {
|
||||
"handled": True,
|
||||
"audit": audit or _empty_audit(),
|
||||
"capabilities": capabilities or [],
|
||||
},
|
||||
}
|
||||
if active_job_id:
|
||||
payload["bioflow"]["active_job_id"] = str(active_job_id)
|
||||
if chain:
|
||||
payload["bioflow"]["chain"] = str(chain)
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# card_result 回填处理(S2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _card_path_selection(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""路径卡确认 → create_*_job → 复述;带 reuse_archive 时先复用存档的阶段链/参数再建任务。"""
|
||||
chain = str(payload.get("chain") or (bioflow or {}).get("chain") or "").strip()
|
||||
stages = payload.get("stages")
|
||||
reuse_name = str(payload.get("reuse_archive") or payload.get("reuse_name") or "").strip()
|
||||
has_stages = isinstance(stages, list) and len(stages) > 0
|
||||
if not has_stages and not reuse_name:
|
||||
# 既无显式阶段链也无存档复用来源 → 无法建任务,回退原 pipeline
|
||||
return None
|
||||
cap_name = _chain_to_capability(chain)
|
||||
if cap_name is None:
|
||||
# 无法确定能力(chain 空/未知):不能默认走某条链,回退原 pipeline
|
||||
return None
|
||||
tool = "create_gene_family_job" if cap_name == "bioflow-gene-family" else "create_rnaseq_job"
|
||||
if tool not in tool_manifests:
|
||||
return None
|
||||
args: Dict[str, Any] = {
|
||||
"session_id": str((bioflow or {}).get("session_id") or ""),
|
||||
"stages": [str(s) for s in stages] if isinstance(stages, list) and stages else [],
|
||||
"title": str(payload.get("title") or ""),
|
||||
"auto_continue": bool(payload.get("auto_continue", False)),
|
||||
}
|
||||
tool_names: List[str] = []
|
||||
# 存档复用(FR-BIO-023):先 reuse_archive 取阶段链/参数/archive_id,再以其发起
|
||||
if reuse_name and "reuse_archive" in tool_manifests:
|
||||
tool_names.append("reuse_archive") # 实际先执行 reuse_archive(audit 顺序与实际一致)
|
||||
reuse_text = _execute_tool(tool_manifests, "reuse_archive", {"name": reuse_name})
|
||||
arch = _try_parse_json(reuse_text)
|
||||
if isinstance(arch, dict) and arch.get("ok"):
|
||||
data = arch.get("data") if isinstance(arch.get("data"), dict) else {}
|
||||
if data.get("id"):
|
||||
args["archive_id"] = str(data["id"])
|
||||
if not args["stages"] and isinstance(data.get("stage_chain"), list):
|
||||
args["stages"] = [str(s) for s in data["stage_chain"]]
|
||||
if isinstance(data.get("params"), dict):
|
||||
merged = dict(data["params"])
|
||||
if isinstance(payload.get("params"), dict):
|
||||
merged.update(payload["params"])
|
||||
args["params"] = merged
|
||||
elif not args["stages"]:
|
||||
# 复用失败(非 ok)且无显式阶段链:不能空链建任务,回退原 pipeline
|
||||
return None
|
||||
tool_names.append(tool) # create_*_job 最后执行
|
||||
if "params" not in args and isinstance(payload.get("params"), dict):
|
||||
args["params"] = payload["params"]
|
||||
result = _execute_tool(tool_manifests, tool, args)
|
||||
job_id = _extract_job_id(result)
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户确认了分析路径并创建任务({'基因家族' if cap_name == 'bioflow-gene-family' else '转录组'}链"
|
||||
f"{',复用存档 ' + reuse_name if reuse_name else ''})。工具结果:\n{result}",
|
||||
)
|
||||
return _make_payload(
|
||||
"确认路径并创建任务", answer,
|
||||
audit={"rounds": 1, "tool_calls": len(tool_names), "used_json_fallback": False, "tool_names": tool_names, "errors": []},
|
||||
capabilities=[cap_name] if cap_name else [],
|
||||
active_job_id=job_id,
|
||||
chain=chain or None,
|
||||
)
|
||||
|
||||
|
||||
def _card_form_confirm(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""参数卡确认 → confirm_stage(白名单校验)→ 复述;rerun:true 走 rerun_stage(改参数重跑)。"""
|
||||
job_id = str(payload.get("job_id") or (bioflow or {}).get("active_job_id") or "").strip()
|
||||
stage = str(payload.get("stage") or "").strip()
|
||||
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
||||
rerun = bool(payload.get("rerun", False))
|
||||
if not job_id or not stage:
|
||||
return None
|
||||
tool = "rerun_stage" if rerun else "confirm_stage"
|
||||
result = _execute_tool(tool_manifests, tool, {"job_id": job_id, "stage": stage, "params": params})
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户{'重跑并确认' if rerun else '确认'}了阶段参数(任务 {job_id} 的 {stage})。工具结果:\n{result}",
|
||||
)
|
||||
return _make_payload(
|
||||
f"{'重跑' if rerun else '确认'}阶段参数:{stage}", answer,
|
||||
audit={"rounds": 1, "tool_calls": 1, "used_json_fallback": False, "tool_names": [tool], "errors": []},
|
||||
active_job_id=job_id,
|
||||
)
|
||||
|
||||
|
||||
def _card_file_upload(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""文件卡 → 网络源 fetch_source_file / 服务器·局域网路径 register_job_files → 复述。"""
|
||||
job_id = str(payload.get("job_id") or (bioflow or {}).get("active_job_id") or "").strip()
|
||||
raw_files = payload.get("paths") or payload.get("files") or []
|
||||
if isinstance(raw_files, str):
|
||||
raw_files = [raw_files]
|
||||
files = [str(f) for f in raw_files if str(f).strip()] if isinstance(raw_files, list) else []
|
||||
if not job_id or not files:
|
||||
return None
|
||||
remote = [f for f in files if f.startswith(("http://", "https://", "oss://"))]
|
||||
local = [f for f in files if f not in remote]
|
||||
results: List[str] = []
|
||||
tool_names: List[str] = []
|
||||
for src in remote:
|
||||
r = _execute_tool(tool_manifests, "fetch_source_file", {"source": src, "job_id": job_id})
|
||||
results.append(r)
|
||||
tool_names.append("fetch_source_file")
|
||||
if local:
|
||||
r = _execute_tool(tool_manifests, "register_job_files", {"job_id": job_id, "paths": local})
|
||||
results.append(r)
|
||||
tool_names.append("register_job_files")
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户提供了文件({'远程 ' + str(len(remote)) + ' 个' if remote else ''}"
|
||||
f"{'本地 ' + str(len(local)) + ' 个' if local else ''})。工具结果:\n" + "\n".join(results),
|
||||
)
|
||||
return _make_payload(
|
||||
"登记任务文件", answer,
|
||||
audit={"rounds": 1, "tool_calls": len(tool_names), "used_json_fallback": False, "tool_names": tool_names, "errors": []},
|
||||
active_job_id=job_id,
|
||||
)
|
||||
|
||||
|
||||
def _card_archive_choice(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""存档卡 → archive_job 工具执行实际存档(或跳过)→ 复述。"""
|
||||
job_id = str(payload.get("job_id") or (bioflow or {}).get("active_job_id") or "").strip()
|
||||
do_archive = bool(payload.get("archive", False))
|
||||
if not job_id:
|
||||
return None
|
||||
if do_archive:
|
||||
name = str(payload.get("name") or "")
|
||||
description = str(payload.get("description") or "")
|
||||
result = _execute_tool(
|
||||
tool_manifests, "archive_job",
|
||||
{"job_id": job_id, "name": name, "description": description},
|
||||
)
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户选择把任务 {job_id} 存档(名称:{name or '未命名'})。工具结果:\n{result}",
|
||||
)
|
||||
return _make_payload(
|
||||
"存档选择", answer,
|
||||
audit={"rounds": 1, "tool_calls": 1, "used_json_fallback": False, "tool_names": ["archive_job"], "errors": []},
|
||||
active_job_id=job_id,
|
||||
)
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户选择不存档任务 {job_id}。请用自然语言告知用户:已跳过存档,"
|
||||
"本次分析结果仍可在报告/结果中查看。",
|
||||
)
|
||||
return _make_payload(
|
||||
"存档选择", answer,
|
||||
audit=_empty_audit(),
|
||||
active_job_id=job_id,
|
||||
)
|
||||
|
||||
|
||||
def _card_extension_action(
|
||||
payload: Dict[str, Any], llm_client: Any, bioflow: Dict[str, Any], tool_manifests: Dict[str, CapabilityManifest]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""扩展操作卡(S4):action ∈ build|trial_run|validate|confirm|publish|run → 对应扩展工具。"""
|
||||
ext_id = str(payload.get("ext_id") or "").strip()
|
||||
action = str(payload.get("action") or "").strip()
|
||||
if not ext_id or not action:
|
||||
return None
|
||||
tool = {
|
||||
"build": "build_extension",
|
||||
"trial_run": "trial_run_extension",
|
||||
"validate": "validate_extension",
|
||||
"confirm": "confirm_extension",
|
||||
"publish": "publish_extension",
|
||||
"run": "run_extension",
|
||||
"repair": "repair_extension",
|
||||
}.get(action)
|
||||
if tool is None or tool not in tool_manifests:
|
||||
return None
|
||||
args: Dict[str, Any] = {"ext_id": ext_id}
|
||||
if action == "confirm":
|
||||
if isinstance(payload.get("nl_triggers"), list):
|
||||
args["nl_triggers"] = payload["nl_triggers"]
|
||||
if isinstance(payload.get("parameters"), list):
|
||||
args["parameters"] = payload["parameters"]
|
||||
if payload.get("runtime_image"):
|
||||
args["runtime_image"] = str(payload["runtime_image"])
|
||||
if action == "run":
|
||||
if isinstance(payload.get("params"), dict):
|
||||
args["params"] = payload["params"]
|
||||
if isinstance(payload.get("files"), list):
|
||||
args["files"] = payload["files"]
|
||||
result = _execute_tool(tool_manifests, tool, args)
|
||||
if action == "confirm" and "EXT_STATUS" in result:
|
||||
# 状态降级话术:confirm 要求 validated/trial_passed 前置状态,
|
||||
# 编辑修正若发生在校验前,明确提示先试运行/校验,不报 500。
|
||||
answer = (
|
||||
"该扩展当前状态还不能确认。请先完成 AI 转换与试运行、通过校验后,"
|
||||
"再确认参数与触发词。"
|
||||
)
|
||||
else:
|
||||
answer = _paraphrase(
|
||||
llm_client,
|
||||
f"用户对扩展 {ext_id} 执行了操作 {action}。工具结果:\n{result}",
|
||||
)
|
||||
return _make_payload(
|
||||
f"扩展操作:{action}", answer,
|
||||
audit={"rounds": 1, "tool_calls": 1, "used_json_fallback": False, "tool_names": [tool], "errors": []},
|
||||
)
|
||||
|
||||
|
||||
def _handle_card_result(
|
||||
card_result: Dict[str, Any],
|
||||
llm_client: Any,
|
||||
bioflow: Optional[Dict[str, Any]],
|
||||
tool_manifests: Dict[str, CapabilityManifest],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
card = str(card_result.get("card") or "").strip()
|
||||
payload = card_result.get("payload") if isinstance(card_result.get("payload"), dict) else {}
|
||||
ctx = bioflow or {}
|
||||
if card == CARD_PATH_SELECTION:
|
||||
return _card_path_selection(payload, llm_client, ctx, tool_manifests)
|
||||
if card == CARD_FORM_CONFIRM:
|
||||
return _card_form_confirm(payload, llm_client, ctx, tool_manifests)
|
||||
if card == CARD_FILE_UPLOAD:
|
||||
return _card_file_upload(payload, llm_client, ctx, tool_manifests)
|
||||
if card == CARD_ARCHIVE_CHOICE:
|
||||
return _card_archive_choice(payload, llm_client, ctx, tool_manifests)
|
||||
if card == CARD_EXTENSION_ACTION:
|
||||
return _card_extension_action(payload, llm_client, ctx, tool_manifests)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前门路由
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def try_bioflow_agent(
|
||||
question: str,
|
||||
*,
|
||||
llm_client: Any,
|
||||
history: Optional[List[Dict[str, str]]] = None,
|
||||
capabilities: Optional[Dict[str, Any]] = None,
|
||||
card_result: Optional[Dict[str, Any]] = None,
|
||||
bioflow: Optional[Dict[str, Any]] = None,
|
||||
progress_callback: Optional[Any] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""前门路由入口。命中生信能力则返回答案 payload,否则返回 None(走原 pipeline)。
|
||||
|
||||
- card_result:非空时走卡片回填协议(直接路由到对应工具,不做意图预筛)。
|
||||
- 任何异常都不上抛——记日志后返回 None,保证金标路径永不因本模块失败。
|
||||
"""
|
||||
try:
|
||||
return _try_bioflow_agent_impl(
|
||||
question,
|
||||
llm_client=llm_client,
|
||||
history=history,
|
||||
capabilities=capabilities,
|
||||
card_result=card_result,
|
||||
bioflow=bioflow,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
LOGGER.warning("bioflow agent: 执行失败,回退原 pipeline: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _try_bioflow_agent_impl(
|
||||
question: str,
|
||||
*,
|
||||
llm_client: Any,
|
||||
history: Optional[List[Dict[str, str]]],
|
||||
capabilities: Optional[Dict[str, Any]],
|
||||
card_result: Optional[Dict[str, Any]],
|
||||
bioflow: Optional[Dict[str, Any]],
|
||||
progress_callback: Optional[Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
# 门 1:会话开关(熔断)
|
||||
if not _env_enabled():
|
||||
return None
|
||||
|
||||
# card_result 卡片操作优先:用户已在卡片上明确选择,不做意图预筛,直接路由
|
||||
if isinstance(card_result, dict) and str(card_result.get("card") or "").strip() in CARD_TYPES:
|
||||
return _route_card_result(card_result, llm_client, capabilities, bioflow, progress_callback)
|
||||
|
||||
# NL 路径:门 2 + 门 3
|
||||
manifests = _candidate_capabilities(question, capabilities)
|
||||
if not manifests:
|
||||
return None
|
||||
|
||||
tools, tool_manifests = _build_tools(manifests)
|
||||
if not tools:
|
||||
LOGGER.warning("bioflow agent: 意图命中但 MCP 工具发现为空,回退原 pipeline")
|
||||
return None
|
||||
|
||||
if progress_callback is not None:
|
||||
try:
|
||||
progress_callback(
|
||||
{
|
||||
"stage": "bioflow_agent_started",
|
||||
"capabilities": [m.name for m in manifests],
|
||||
"tool_count": len(tools),
|
||||
}
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
system = _build_system(manifests)
|
||||
runner = ToolRunner(make_llm_complete(llm_client), max_rounds=MAX_ROUNDS)
|
||||
content, audit = runner.run(system, question, tools, tool_manifests, history=history)
|
||||
|
||||
answer = (content or "").strip()
|
||||
if not answer:
|
||||
return None
|
||||
|
||||
return _make_payload(
|
||||
question,
|
||||
answer,
|
||||
audit=audit,
|
||||
capabilities=[m.name for m in manifests],
|
||||
active_job_id=(bioflow or {}).get("active_job_id"),
|
||||
chain=(bioflow or {}).get("chain"),
|
||||
)
|
||||
|
||||
|
||||
def _route_card_result(
|
||||
card_result: Dict[str, Any],
|
||||
llm_client: Any,
|
||||
capabilities: Optional[Dict[str, Any]],
|
||||
bioflow: Optional[Dict[str, Any]],
|
||||
progress_callback: Optional[Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""卡片操作路由:确定相关能力 → 构建工具 → 分发到对应 card handler。"""
|
||||
card = str(card_result.get("card") or "").strip()
|
||||
payload = card_result.get("payload") if isinstance(card_result.get("payload"), dict) else {}
|
||||
chain = str(payload.get("chain") or (bioflow or {}).get("chain") or "").strip()
|
||||
if card == CARD_EXTENSION_ACTION:
|
||||
# 扩展操作只依赖 bioflow-extension 能力,避免对三个能力各做一次 list_tools
|
||||
cap_name = "bioflow-extension"
|
||||
else:
|
||||
cap_name = _chain_to_capability(chain)
|
||||
registered = _registered_capabilities(capabilities)
|
||||
if cap_name:
|
||||
manifests = [m for m in registered if m.name == cap_name]
|
||||
else:
|
||||
manifests = registered
|
||||
if not manifests:
|
||||
return None
|
||||
tools, tool_manifests = _build_tools(manifests)
|
||||
if not tools:
|
||||
LOGGER.warning("bioflow agent: card_result 但 MCP 工具发现为空,回退原 pipeline")
|
||||
return None
|
||||
if progress_callback is not None:
|
||||
try:
|
||||
progress_callback({"stage": "bioflow_card", "card": card_result.get("card")})
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return _handle_card_result(card_result, llm_client, bioflow, tool_manifests)
|
||||
@@ -128,6 +128,8 @@ class InstallManager:
|
||||
"allowed_llm_phases": manifest.allowed_llm_phases,
|
||||
"session_default": manifest.session_default,
|
||||
}
|
||||
if manifest.nl_triggers:
|
||||
raw["nl_triggers"] = manifest.nl_triggers
|
||||
if manifest.schema:
|
||||
raw["schema"] = manifest.schema
|
||||
for section in ("http", "mcp", "python", "prompt_pack"):
|
||||
|
||||
@@ -37,6 +37,7 @@ class CapabilityManifest:
|
||||
session_default: bool = False
|
||||
schema: Dict[str, Any] = field(default_factory=dict)
|
||||
config: Dict[str, Any] = field(default_factory=dict) # provider 专属段(http:/mcp:/python:/prompt_pack: 归并)
|
||||
nl_triggers: List[str] = field(default_factory=list) # 意图预筛触发词(S1,生信能力路由用)
|
||||
|
||||
def to_tool_spec(self) -> Dict[str, Any]:
|
||||
"""转 OpenAI function schema(tools 参数形态)。"""
|
||||
@@ -110,6 +111,19 @@ def _parse_entry(raw: Any, index: int, load_warnings: List[str]) -> CapabilityMa
|
||||
if isinstance(raw.get(section), dict):
|
||||
config[section] = raw[section]
|
||||
config["session_default"] = bool(raw.get("session_default", False))
|
||||
# nl_triggers(S1):意图预筛触发词,只收字符串、去空、去重、限长
|
||||
nl_triggers_raw = raw.get("nl_triggers") or []
|
||||
if isinstance(nl_triggers_raw, str):
|
||||
nl_triggers_raw = [nl_triggers_raw]
|
||||
nl_triggers: List[str] = []
|
||||
if isinstance(nl_triggers_raw, list):
|
||||
for trigger in nl_triggers_raw:
|
||||
if not isinstance(trigger, str):
|
||||
continue # 触发词只收字符串,非字符串(数字等)丢弃
|
||||
cleaned = _clean_text(trigger, 64)
|
||||
if cleaned and cleaned not in nl_triggers:
|
||||
nl_triggers.append(cleaned)
|
||||
nl_triggers = nl_triggers[:64]
|
||||
manifest = CapabilityManifest(
|
||||
name=name,
|
||||
provider=provider,
|
||||
@@ -119,6 +133,7 @@ def _parse_entry(raw: Any, index: int, load_warnings: List[str]) -> CapabilityMa
|
||||
session_default=bool(raw.get("session_default", False)),
|
||||
schema=schema,
|
||||
config=config,
|
||||
nl_triggers=nl_triggers,
|
||||
)
|
||||
# 大小上限:单条目序列化后 ≤8KB
|
||||
import json
|
||||
|
||||
@@ -130,6 +130,7 @@ class _McpConnection:
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._session: Any = None
|
||||
self._server_tools: List[str] = []
|
||||
self._server_tool_schemas: List[Dict[str, Any]] = []
|
||||
self._started_at = 0.0
|
||||
self._last_used = 0.0
|
||||
self._restarts = 0
|
||||
@@ -200,6 +201,18 @@ class _McpConnection:
|
||||
raise
|
||||
self._ctx = ctx
|
||||
self._server_tools = [t.name for t in tools_result.tools]
|
||||
self._server_tool_schemas = [
|
||||
{
|
||||
"name": t.name,
|
||||
"description": (getattr(t, "description", None) or ""),
|
||||
"input_schema": (
|
||||
getattr(t, "inputSchema", None)
|
||||
or getattr(t, "input_schema", None)
|
||||
or {}
|
||||
),
|
||||
}
|
||||
for t in tools_result.tools
|
||||
]
|
||||
self._session = session
|
||||
self._started_at = time.time()
|
||||
|
||||
@@ -225,6 +238,7 @@ class _McpConnection:
|
||||
ctx = getattr(self, "_ctx", None)
|
||||
self._ctx = None
|
||||
self._server_tools = []
|
||||
self._server_tool_schemas = []
|
||||
if session is not None:
|
||||
try:
|
||||
await session.__aexit__(None, None, None)
|
||||
@@ -274,6 +288,14 @@ class _McpConnection:
|
||||
return []
|
||||
return list(self._server_tools)
|
||||
|
||||
def list_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""返回 MCP server 全量工具 schema(name/description/input_schema)。连接失败返回空。"""
|
||||
try:
|
||||
self._ensure_session()
|
||||
except ToolExecutionError:
|
||||
return []
|
||||
return list(self._server_tool_schemas)
|
||||
|
||||
def call(self, tool_name: str, arguments: Dict[str, Any]) -> str:
|
||||
self._ensure_session()
|
||||
try:
|
||||
@@ -337,6 +359,11 @@ class McpToolProvider:
|
||||
return tools + [f"<缺失声明工具 {tool_name}>"]
|
||||
return tools
|
||||
|
||||
def list_tool_schemas(self, manifest: CapabilityManifest) -> List[Dict[str, Any]]:
|
||||
"""返回该能力 MCP server 的全量工具 schema(供前门 agent 构建 LLM 工具表)。"""
|
||||
conn = self._connection(manifest)
|
||||
return conn.list_tool_schemas()
|
||||
|
||||
def reap_idle(self) -> int:
|
||||
reaped = 0
|
||||
with self._lock:
|
||||
@@ -448,6 +475,10 @@ class ToolDispatcher:
|
||||
return PythonToolProvider.execute(manifest, arguments)
|
||||
raise ToolExecutionError(f"工具 {manifest.name} provider={provider} 不支持调用")
|
||||
|
||||
def list_mcp_tools(self, manifest: CapabilityManifest) -> List[Dict[str, Any]]:
|
||||
"""MCP 能力全量工具 schema(bioflow_agent 构建 LLM 工具表用)。"""
|
||||
return self._mcp.list_tool_schemas(manifest)
|
||||
|
||||
def reap_idle(self) -> int:
|
||||
return self._mcp.reap_idle()
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""test_bioflow_agent.py —— bioflow_agent 前门三重门 + nl_triggers 解析单测。
|
||||
|
||||
覆盖 S1 验收要点:
|
||||
- 熔断开关(QA_V1_ENABLE_BIOFLOW_AGENT)默认关 → 返回 None(走原 pipeline)
|
||||
- 意图预筛(nl_triggers)各自评估,未命中返回 None
|
||||
- 注册表未注册 / 会话开关关 → 返回 None
|
||||
- 命中后短路返回自然语言 payload(bioflow.handled=True)
|
||||
- 只装一个能力时,另一个能力的触发语不响应(隔离)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import parse_capabilities_text # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry # noqa: E402
|
||||
|
||||
BIOFLOW_YAML = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 家族成员, 亚家族, 建树]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析
|
||||
session_default: true
|
||||
nl_triggers: [转录组, RNA-seq, 差异表达, 富集]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
"""
|
||||
|
||||
|
||||
def _make_registry(tmp_path: Path) -> CapabilityRegistry:
|
||||
path = tmp_path / "capabilities.yaml"
|
||||
path.write_text(BIOFLOW_YAML, encoding="utf-8")
|
||||
registry = CapabilityRegistry(path)
|
||||
registry.load()
|
||||
return registry
|
||||
|
||||
|
||||
class FakeLLMClient:
|
||||
def __init__(self, content: str = "已为您规划分析路径。") -> None:
|
||||
self.content = content
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kwargs):
|
||||
return {"content": self.content, "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
class TestNlTriggers:
|
||||
def test_parse_nl_triggers(self):
|
||||
manifests, warnings = parse_capabilities_text(BIOFLOW_YAML)
|
||||
assert warnings == []
|
||||
gf = next(m for m in manifests if m.name == "bioflow-gene-family")
|
||||
assert gf.nl_triggers == ["基因家族", "家族成员", "亚家族", "建树"]
|
||||
assert gf.config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
assert gf.config["prompt_pack"]["file"] == "bioflow_gene_family_agent_rules.md"
|
||||
|
||||
def test_nl_triggers_dedupe_and_clean(self):
|
||||
text = """
|
||||
capabilities:
|
||||
- name: t
|
||||
provider: mcp
|
||||
nl_triggers: [基因家族, 基因家族, "", " 亚家族 ", 123]
|
||||
"""
|
||||
manifests, _ = parse_capabilities_text(text)
|
||||
assert manifests[0].nl_triggers == ["基因家族", "亚家族"]
|
||||
|
||||
|
||||
class TestIntentMatch:
|
||||
def test_match(self):
|
||||
assert ba._intent_match("帮我做水稻SPL基因家族分析", ["基因家族"]) is True
|
||||
assert ba._intent_match("转录组差异表达分析", ["转录组", "RNA-seq"]) is True
|
||||
assert ba._intent_match("什么是光合作用", ["基因家族", "转录组"]) is False
|
||||
|
||||
|
||||
class TestGateLogic:
|
||||
def test_fuse_off_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_no_intent_match_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"光合作用的电子传递链是什么", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_not_registered_returns_none(self, tmp_path):
|
||||
empty = CapabilityRegistry(tmp_path / "empty.yaml")
|
||||
empty.load()
|
||||
with mock.patch.object(ba, "get_registry", return_value=empty):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_session_switch_off_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析",
|
||||
llm_client=FakeLLMClient(),
|
||||
capabilities={"bioflow-gene-family": False},
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_isolation_only_installed_responds(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
caps = ba._candidate_capabilities("帮我做基因家族分析", None)
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family"]
|
||||
caps = ba._candidate_capabilities("转录组差异表达", None)
|
||||
assert [m.name for m in caps] == ["bioflow-rnaseq"]
|
||||
|
||||
def test_full_flow_short_circuits(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "plan_gene_family",
|
||||
"description": "规划基因家族路径",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {"plan_gene_family": mock.ANY})):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is not None
|
||||
assert result["success"] is True
|
||||
assert result["answer"] == "已为您规划分析路径。"
|
||||
assert result["bioflow"]["handled"] is True
|
||||
assert result["bioflow"]["capabilities"] == ["bioflow-gene-family"]
|
||||
|
||||
def test_exception_falls_back_to_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.object(ba, "_candidate_capabilities", side_effect=RuntimeError("boom")):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
@@ -0,0 +1,255 @@
|
||||
"""test_bioflow_agent_s2.py —— S2 对话式编排闭环单测。
|
||||
|
||||
覆盖:card_result 回填协议(四类卡片)、参数抽取(白名单)、多轮记忆注入、
|
||||
session↔job 绑定上下文(active_job_id/chain)、job_status 主动查询。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import CapabilityManifest # noqa: E402
|
||||
from rag.qa_v1_1_73.tool_runner import ToolRunner # noqa: E402
|
||||
|
||||
|
||||
def _manifest(name="bioflow-gene-family", url="http://x/mcp/gene-family/"):
|
||||
return CapabilityManifest(name=name, provider="mcp", config={"mcp": {"url": url, "tool": name}})
|
||||
|
||||
|
||||
def _tool_manifests():
|
||||
"""覆盖四类卡片用到的工具 + job_status。"""
|
||||
names = ("create_gene_family_job", "create_rnaseq_job", "confirm_stage",
|
||||
"register_job_files", "fetch_source_file", "job_status")
|
||||
out = {}
|
||||
for t in names:
|
||||
out[t] = CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/mcp/gene-family/", "tool": t}},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self, results=None):
|
||||
self.results = results or {}
|
||||
self.calls = []
|
||||
|
||||
def execute(self, manifest, arguments):
|
||||
tool = manifest.config.get("mcp", {}).get("tool")
|
||||
self.calls.append((tool, arguments))
|
||||
return self.results.get(tool, '{"ok": true, "data": {"id": "job-1"}}')
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, complete_text="已收到,已为您处理。", json_payload=None):
|
||||
self.complete_text = complete_text
|
||||
self.json_payload = json_payload or {}
|
||||
|
||||
def complete(self, system, user, **kw):
|
||||
return self.complete_text
|
||||
|
||||
def complete_json(self, system, user, **kw):
|
||||
return self.json_payload
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "answer", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 参数抽取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractParams:
|
||||
def test_extract_known_and_unknown(self):
|
||||
llm = FakeLLM(json_payload={"params": {"threads": 8, "padj": 0.01}, "unknown": ["bogus"]})
|
||||
form_defaults = {"threads": {"default": 4}, "padj": {"default": 0.05}}
|
||||
known, unknown = ba._extract_params(llm, "用 8 核,padj 用 0.01", form_defaults)
|
||||
assert known == {"threads": 8, "padj": 0.01}
|
||||
assert unknown == ["bogus"]
|
||||
|
||||
def test_non_whitelist_param_filtered(self):
|
||||
llm = FakeLLM(json_payload={"params": {"evil": 1, "threads": 4}, "unknown": []})
|
||||
form_defaults = {"threads": {"default": 4}}
|
||||
known, unknown = ba._extract_params(llm, "x", form_defaults)
|
||||
assert known == {"threads": 4}
|
||||
|
||||
def test_bad_json_returns_empty(self):
|
||||
llm = FakeLLM(json_payload="not-a-dict")
|
||||
form_defaults = {"threads": {}}
|
||||
known, unknown = ba._extract_params(llm, "x", form_defaults)
|
||||
assert known == {} and unknown == []
|
||||
|
||||
def test_empty_whitelist(self):
|
||||
llm = FakeLLM()
|
||||
known, unknown = ba._extract_params(llm, "x", {})
|
||||
assert known == {} and unknown == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# card_result 回填协议
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCardResult:
|
||||
def test_path_selection_creates_job_and_extracts_job_id(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
llm = FakeLLM()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "stages": ["identify", "tree"], "title": "水稻SPL"}},
|
||||
llm, {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["active_job_id"] == "job-1"
|
||||
assert out["bioflow"]["chain"] == "gene_family"
|
||||
assert dispatcher.calls[0][0] == "create_gene_family_job"
|
||||
assert dispatcher.calls[0][1]["stages"] == ["identify", "tree"]
|
||||
assert dispatcher.calls[0][1]["session_id"] == "s1"
|
||||
|
||||
def test_path_selection_missing_stages_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection", "payload": {"chain": "gene_family"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_form_confirm_calls_confirm_stage(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm",
|
||||
"payload": {"job_id": "job-9", "stage": "identify", "params": {"evalue": 1e-5}}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "confirm_stage"
|
||||
assert dispatcher.calls[0][1] == {"job_id": "job-9", "stage": "identify", "params": {"evalue": 1e-5}}
|
||||
|
||||
def test_form_confirm_uses_active_job_id_fallback(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result(
|
||||
{"card": "form_confirm", "payload": {"stage": "identify", "params": {}}},
|
||||
FakeLLM(), {"active_job_id": "job-7"}, _tool_manifests(),
|
||||
)
|
||||
assert dispatcher.calls[0][1]["job_id"] == "job-7"
|
||||
|
||||
def test_file_upload_splits_remote_and_local(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload",
|
||||
"payload": {"job_id": "j1", "paths": ["https://x/a.fa", "/data/b.fa"]}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
tools = [c[0] for c in dispatcher.calls]
|
||||
assert "fetch_source_file" in tools and "register_job_files" in tools
|
||||
|
||||
def test_archive_choice_paraphrases(self):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice", "payload": {"job_id": "j1", "archive": True}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert out["answer"] == "已收到,已为您处理。"
|
||||
|
||||
def test_unknown_card_returns_none(self):
|
||||
out = ba._handle_card_result({"card": "bogus", "payload": {}}, FakeLLM(), {}, _tool_manifests())
|
||||
assert out is None
|
||||
|
||||
def test_path_selection_reuse_failure_no_stages_returns_none(self):
|
||||
"""bug ②:复用失败且无显式 stages → 返回 None(不空链建任务)。"""
|
||||
dispatcher = FakeDispatcher({"reuse_archive": '{"ok": false, "code": "ARCHIVE_NOT_FOUND"}'})
|
||||
tm = _tool_manifests()
|
||||
tm["reuse_archive"] = CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x", "tool": "reuse_archive"}},
|
||||
)
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "reuse_archive": "不存在的存档"}},
|
||||
FakeLLM(), {}, tm,
|
||||
)
|
||||
assert out is None
|
||||
# 只执行了 reuse_archive,未调用 create_gene_family_job
|
||||
assert [c[0] for c in dispatcher.calls] == ["reuse_archive"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前门路由(card_result / bioflow 上下文)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRouteCardResult:
|
||||
def test_try_bioflow_agent_card_result_short_circuits(self):
|
||||
registry_manifest = _manifest()
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[registry_manifest]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"type": "function", "function": {"name": "confirm_stage", "description": "", "parameters": {"type": "object", "properties": {}}}}], {"confirm_stage": registry_manifest})):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba.try_bioflow_agent(
|
||||
"确认", llm_client=FakeLLM(),
|
||||
card_result={"card": "form_confirm", "payload": {"job_id": "j1", "stage": "s", "params": {}}},
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["handled"] is True
|
||||
|
||||
def test_try_bioflow_agent_fuse_off_ignores_card_result(self):
|
||||
with mock.patch.dict("os.environ", {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
out = ba.try_bioflow_agent(
|
||||
"确认", llm_client=FakeLLM(),
|
||||
card_result={"card": "form_confirm", "payload": {}},
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 多轮记忆注入(ToolRunner.run history)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiTurnHistory:
|
||||
def test_history_injected_into_messages(self):
|
||||
class RecordingLLM:
|
||||
def __init__(self):
|
||||
self.captured_messages = None
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
self.captured_messages = list(messages)
|
||||
return {"content": "ok", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
llm = RecordingLLM()
|
||||
runner = ToolRunner(make_llm_complete(llm), max_rounds=1)
|
||||
history = [{"role": "user", "content": "上一轮问题"}, {"role": "assistant", "content": "上一轮答案"}]
|
||||
runner.run("sys", "当前问题", [], {}, history=history)
|
||||
msgs = llm.captured_messages
|
||||
assert msgs[0] == {"role": "user", "content": "上一轮问题"}
|
||||
assert msgs[1] == {"role": "assistant", "content": "上一轮答案"}
|
||||
assert msgs[-1] == {"role": "user", "content": "当前问题"}
|
||||
|
||||
def test_history_filters_bad_roles(self):
|
||||
class RecordingLLM:
|
||||
def __init__(self):
|
||||
self.captured_messages = None
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
self.captured_messages = list(messages)
|
||||
return {"content": "ok", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
llm = RecordingLLM()
|
||||
runner = ToolRunner(make_llm_complete(llm), max_rounds=1)
|
||||
history = [{"role": "system", "content": "evil"}, {"role": "user", "content": ""}, {"role": "user", "content": "ok"}]
|
||||
runner.run("sys", "q", [], {}, history=history)
|
||||
roles = [m["role"] for m in llm.captured_messages]
|
||||
assert "system" not in roles
|
||||
assert llm.captured_messages[-1] == {"role": "user", "content": "q"}
|
||||
@@ -0,0 +1,511 @@
|
||||
"""test_bioflow_agent_s2_supplement.py —— S2 覆盖清单补强测试。
|
||||
|
||||
补强 dev 的 test_bioflow_agent_s2.py 未覆盖的分支:
|
||||
- _chain_to_capability / _extract_job_id / _execute_tool / _paraphrase / _query_job_status
|
||||
- card 四类 handler 缺必需字段回退 None、files 键、archive=False、非 dict payload
|
||||
- _route_card_result:chain 过滤 / 未注册能力→None / tools 空→None
|
||||
- NL 路径注入 bioflow 上下文(active_job_id/chain)
|
||||
- system prompt 硬规则在位(追问 ≤3 / 不可代确认 / 工具名不外泄)
|
||||
- ToolRunner.run(history) 边界(non-dict 条目 / history=None / 空内容剔除)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import CapabilityManifest # noqa: E402
|
||||
from rag.qa_v1_1_73.tool_runner import ToolRunner, make_llm_complete # noqa: E402
|
||||
|
||||
|
||||
def _manifest(name="bioflow-gene-family", url="http://x/mcp/gene-family/"):
|
||||
return CapabilityManifest(name=name, provider="mcp", config={"mcp": {"url": url, "tool": name}})
|
||||
|
||||
|
||||
def _tool_manifests():
|
||||
names = ("create_gene_family_job", "create_rnaseq_job", "confirm_stage",
|
||||
"register_job_files", "fetch_source_file", "job_status",
|
||||
"rerun_stage", "reuse_archive", "archive_job")
|
||||
out = {}
|
||||
for t in names:
|
||||
out[t] = CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/mcp/gene-family/", "tool": t}},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, complete_text="已收到。", json_payload=None, complete_error=None):
|
||||
self.complete_text = complete_text
|
||||
self.json_payload = json_payload or {}
|
||||
self.complete_error = complete_error
|
||||
|
||||
def complete(self, system, user, **kw):
|
||||
if self.complete_error:
|
||||
raise self.complete_error
|
||||
return self.complete_text
|
||||
|
||||
def complete_json(self, system, user, **kw):
|
||||
return self.json_payload
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "answer", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self, results=None):
|
||||
self.results = results or {}
|
||||
self.calls = []
|
||||
|
||||
def execute(self, manifest, arguments):
|
||||
tool = manifest.config.get("mcp", {}).get("tool")
|
||||
self.calls.append((tool, arguments))
|
||||
return self.results.get(tool, '{"ok": true, "data": {"id": "job-1"}}')
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 辅助函数
|
||||
# ===========================================================================
|
||||
|
||||
class TestChainToCapability:
|
||||
def test_known(self):
|
||||
assert ba._chain_to_capability("gene_family") == "bioflow-gene-family"
|
||||
assert ba._chain_to_capability("rnaseq") == "bioflow-rnaseq"
|
||||
|
||||
def test_unknown_and_empty(self):
|
||||
assert ba._chain_to_capability("wgs") is None
|
||||
assert ba._chain_to_capability("") is None
|
||||
assert ba._chain_to_capability(None) is None
|
||||
|
||||
def test_case_and_whitespace(self):
|
||||
assert ba._chain_to_capability(" Gene_Family ") == "bioflow-gene-family"
|
||||
|
||||
|
||||
class TestExtractJobId:
|
||||
def test_data_id(self):
|
||||
assert ba._extract_job_id('{"ok": true, "data": {"id": "job-9"}}') == "job-9"
|
||||
|
||||
def test_data_job_id(self):
|
||||
assert ba._extract_job_id('{"ok": true, "data": {"job_id": "job-8"}}') == "job-8"
|
||||
|
||||
def test_flat_id(self):
|
||||
assert ba._extract_job_id('{"ok": true, "id": "job-7"}') == "job-7"
|
||||
|
||||
def test_non_json_and_no_id(self):
|
||||
assert ba._extract_job_id("not json") is None
|
||||
assert ba._extract_job_id('{"ok": false}') is None
|
||||
assert ba._extract_job_id("") is None
|
||||
|
||||
|
||||
class TestExecuteTool:
|
||||
def test_unknown_tool(self):
|
||||
out = ba._execute_tool(_tool_manifests(), "ghost", {})
|
||||
assert "未注册" in out
|
||||
|
||||
def test_tool_exception_becomes_text(self):
|
||||
class Boom:
|
||||
def execute(self, manifest, arguments):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=Boom()):
|
||||
out = ba._execute_tool(_tool_manifests(), "job_status", {"job_id": "j"})
|
||||
assert "工具执行失败" in out
|
||||
|
||||
|
||||
class TestParaphrase:
|
||||
def test_llm_success(self):
|
||||
out = ba._paraphrase(FakeLLM(complete_text="已转述。"), "原文")
|
||||
assert out == "已转述。"
|
||||
|
||||
def test_llm_exception_falls_back_to_raw(self):
|
||||
out = ba._paraphrase(FakeLLM(complete_error=RuntimeError("boom")), "原文")
|
||||
assert out == "原文"
|
||||
|
||||
def test_llm_empty_falls_back_to_raw(self):
|
||||
out = ba._paraphrase(FakeLLM(complete_text=" "), "原文")
|
||||
assert out == "原文"
|
||||
|
||||
|
||||
class TestQueryJobStatus:
|
||||
def test_calls_job_status_and_paraphrases(self):
|
||||
with mock.patch.object(ba, "_execute_tool", return_value='{"ok": true, "data": {"status": "running"}}'):
|
||||
out = ba._query_job_status("j1", _tool_manifests(), FakeLLM(complete_text="任务进行中。"))
|
||||
assert out == "任务进行中。"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# card 四类 handler 边界(缺必需字段 / files 键 / archive=False / 非 dict payload)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCardHandlersEdge:
|
||||
def test_form_confirm_missing_job_and_stage_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm", "payload": {"params": {}}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_file_upload_missing_job_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload", "payload": {"paths": ["/data/a.fa"]}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_file_upload_missing_files_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload", "payload": {"job_id": "j1"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_file_upload_uses_files_key(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload",
|
||||
"payload": {"job_id": "j1", "files": ["https://x/a.fa"]}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "fetch_source_file"
|
||||
|
||||
def test_file_upload_string_files_coerced(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "file_upload",
|
||||
"payload": {"job_id": "j1", "files": "https://x/a.fa"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "fetch_source_file"
|
||||
|
||||
def test_archive_choice_missing_job_returns_none(self):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice", "payload": {"archive": True}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_archive_choice_skip(self):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice", "payload": {"job_id": "j1", "archive": False}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["audit"]["tool_calls"] == 0 # 存档不调工具(走 REST)
|
||||
|
||||
def test_path_selection_rnaseq_chain(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "rnaseq", "stages": ["rnaseq_qc"]}},
|
||||
FakeLLM(), {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "create_rnaseq_job"
|
||||
|
||||
def test_card_non_dict_payload_treated_empty(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm", "payload": "not-a-dict"},
|
||||
FakeLLM(), {"active_job_id": "j1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is None # payload 非 dict → 无 stage/job_id → None
|
||||
|
||||
def test_card_result_with_non_dict_is_ignored(self):
|
||||
# try_bioflow_agent 对非 dict 的 card_result 不做 card 路由,走 NL 意图
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_candidate_capabilities", return_value=[]):
|
||||
out = ba.try_bioflow_agent("确认", llm_client=FakeLLM(), card_result="bogus")
|
||||
assert out is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _route_card_result 边界
|
||||
# ===========================================================================
|
||||
|
||||
class TestRouteCardResultEdge:
|
||||
def test_chain_filters_to_capability(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
rn = _manifest("bioflow-rnaseq", "http://x/mcp/rnaseq/")
|
||||
captured = {}
|
||||
|
||||
def fake_build(manifests):
|
||||
captured["manifests"] = [m.name for m in manifests]
|
||||
return ([], {})
|
||||
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf, rn]):
|
||||
with mock.patch.object(ba, "_build_tools", side_effect=fake_build):
|
||||
ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {"chain": "rnaseq"}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
assert captured["manifests"] == ["bioflow-rnaseq"] # chain 过滤到 rnaseq 能力
|
||||
|
||||
def test_chain_to_unregistered_capability_returns_none(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"x": 1}], {})):
|
||||
out = ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {"chain": "rnaseq"}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
assert out is None # rnaseq 未注册 → manifests 空 → None
|
||||
|
||||
def test_tools_empty_returns_none(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([], {})):
|
||||
out = ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_progress_callback_invoked(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
calls = []
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"x": 1}], {})):
|
||||
with mock.patch.object(ba, "_handle_card_result", return_value=None):
|
||||
ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {}},
|
||||
FakeLLM(), None, None, lambda p: calls.append(p),
|
||||
)
|
||||
assert calls and calls[0]["stage"] == "bioflow_card"
|
||||
assert calls[0]["card"] == "form_confirm"
|
||||
|
||||
def test_progress_callback_exception_swallowed(self):
|
||||
gf = _manifest("bioflow-gene-family")
|
||||
|
||||
def boom(p):
|
||||
raise RuntimeError("callback boom")
|
||||
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf]):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([{"x": 1}], {})):
|
||||
with mock.patch.object(ba, "_handle_card_result", return_value={"ok": True}):
|
||||
out = ba._route_card_result(
|
||||
{"card": "form_confirm", "payload": {}},
|
||||
FakeLLM(), None, None, boom,
|
||||
)
|
||||
assert out == {"ok": True} # 回调异常被吞,不影响主流程
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# NL 路径:bioflow 上下文注入 / 卡片与 NL 语义等价
|
||||
# ===========================================================================
|
||||
|
||||
class TestNLPathBioflowContext:
|
||||
def test_nl_path_injects_bioflow_context(self, tmp_path):
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry
|
||||
from rag.qa_v1_1_73.capabilities.manifests import parse_capabilities_text
|
||||
yaml = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [基因家族]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
"""
|
||||
(tmp_path / "c.yaml").write_text(yaml, encoding="utf-8")
|
||||
registry = CapabilityRegistry(tmp_path / "c.yaml")
|
||||
registry.load()
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("已答复", {"rounds": 1})
|
||||
out = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLM(),
|
||||
bioflow={"session_id": "s1", "active_job_id": "job-9", "chain": "gene_family"},
|
||||
)
|
||||
assert out is not None
|
||||
assert out["bioflow"]["active_job_id"] == "job-9"
|
||||
assert out["bioflow"]["chain"] == "gene_family"
|
||||
|
||||
def test_card_and_nl_share_tool_chain(self):
|
||||
"""卡片操作与纯 NL 服务端语义等价:卡片 handler 只调 MCP 工具,不直连控制面。"""
|
||||
# 卡片 handler 用到的工具名 = MCP 工具(job_status/confirm_stage/create_* 等)
|
||||
tm = _tool_manifests()
|
||||
# 四类卡片触发的工具都在 tool_manifests(即 MCP 工具表)里
|
||||
for tool in ("create_gene_family_job", "create_rnaseq_job", "confirm_stage",
|
||||
"register_job_files", "fetch_source_file", "job_status"):
|
||||
assert tool in tm
|
||||
|
||||
|
||||
class TestExtractParamsEdge:
|
||||
def test_llm_exception_returns_empty(self):
|
||||
class RaisingLLM(FakeLLM):
|
||||
def complete_json(self, system, user, **kw):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
known, unknown = ba._extract_params(RaisingLLM(), "x", {"threads": {}})
|
||||
assert known == {} and unknown == []
|
||||
|
||||
def test_unknown_as_string_coerced_to_list(self):
|
||||
llm = FakeLLM(json_payload={"params": {"threads": 8}, "unknown": "bogus_param"})
|
||||
known, unknown = ba._extract_params(llm, "x", {"threads": {}})
|
||||
assert known == {"threads": 8}
|
||||
assert unknown == ["bogus_param"]
|
||||
|
||||
def test_path_selection_with_params(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "stages": ["identify"],
|
||||
"params": {"prefix": "Ft.SPL"}}},
|
||||
FakeLLM(), {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert dispatcher.calls[0][1]["params"] == {"prefix": "Ft.SPL"}
|
||||
|
||||
def test_path_selection_create_tool_missing_returns_none(self):
|
||||
tm = _tool_manifests()
|
||||
tm.pop("create_gene_family_job")
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "stages": ["identify"]}},
|
||||
FakeLLM(), {"session_id": "s1"}, tm,
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# system prompt 硬规则在位
|
||||
# ===========================================================================
|
||||
|
||||
class TestSystemPromptRules:
|
||||
def test_param_retry_rule(self):
|
||||
assert "最多 3 次" in ba._SYSTEM_BASE
|
||||
|
||||
def test_human_boundary_rule(self):
|
||||
assert "不可跳过" in ba._SYSTEM_BASE
|
||||
assert "不可代用户确认" in ba._SYSTEM_BASE
|
||||
|
||||
def test_tool_name_no_leak_rule(self):
|
||||
assert "绝不向用户泄露工具名" in ba._SYSTEM_BASE
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ToolRunner.run(history) 边界
|
||||
# ===========================================================================
|
||||
|
||||
class TestHistoryEdge:
|
||||
def _run_with_history(self, history):
|
||||
captured = {}
|
||||
|
||||
class RecordingLLM:
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
captured["messages"] = list(messages)
|
||||
return {"content": "ok", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
runner = ToolRunner(make_llm_complete(RecordingLLM()), max_rounds=1)
|
||||
runner.run("sys", "当前", [], {}, history=history)
|
||||
return captured["messages"]
|
||||
|
||||
def test_none_history(self):
|
||||
msgs = self._run_with_history(None)
|
||||
assert msgs == [{"role": "user", "content": "当前"}]
|
||||
|
||||
def test_non_dict_items_skipped(self):
|
||||
msgs = self._run_with_history(["not-a-dict", {"role": "user", "content": "ok"}])
|
||||
assert msgs == [{"role": "user", "content": "ok"}, {"role": "user", "content": "当前"}]
|
||||
|
||||
def test_empty_content_filtered(self):
|
||||
msgs = self._run_with_history([{"role": "user", "content": " "}])
|
||||
assert msgs == [{"role": "user", "content": "当前"}]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S3:card_result rerun / reuse_archive / archive_job 语义
|
||||
# ===========================================================================
|
||||
|
||||
class TestS3CardSemantics:
|
||||
def test_form_confirm_rerun_true_uses_rerun_stage(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "form_confirm",
|
||||
"payload": {"job_id": "j1", "stage": "identify",
|
||||
"params": {"evalue": 1e-5}, "rerun": True}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "rerun_stage"
|
||||
assert out["bioflow"]["audit"]["tool_names"] == ["rerun_stage"]
|
||||
|
||||
def test_form_confirm_rerun_false_uses_confirm_stage(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result(
|
||||
{"card": "form_confirm",
|
||||
"payload": {"job_id": "j1", "stage": "identify", "params": {}}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert dispatcher.calls[0][0] == "confirm_stage"
|
||||
|
||||
def test_path_selection_reuse_archive(self):
|
||||
dispatcher = FakeDispatcher({
|
||||
"reuse_archive": '{"ok": true, "data": {"id": "arch-1", '
|
||||
'"stage_chain": ["identify", "phylogeny"], '
|
||||
'"params": {"identify": {"prefix": "Ft"}}}}',
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "path_selection",
|
||||
"payload": {"chain": "gene_family", "reuse_archive": "SPL 鉴定"}},
|
||||
FakeLLM(), {"session_id": "s1"}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
tools = [c[0] for c in dispatcher.calls]
|
||||
assert tools[0] == "reuse_archive"
|
||||
assert tools[1] == "create_gene_family_job"
|
||||
create_args = dispatcher.calls[1][1]
|
||||
assert create_args["archive_id"] == "arch-1"
|
||||
assert create_args["stages"] == ["identify", "phylogeny"]
|
||||
assert create_args["params"] == {"identify": {"prefix": "Ft"}}
|
||||
assert out["bioflow"]["audit"]["tool_names"] == ["reuse_archive", "create_gene_family_job"]
|
||||
|
||||
def test_archive_choice_archive_true_calls_archive_job(self):
|
||||
dispatcher = FakeDispatcher({"archive_job": '{"ok": true, "data": {"id": "a1"}}'})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice",
|
||||
"payload": {"job_id": "j1", "archive": True,
|
||||
"name": "SPL 存档", "description": "d"}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == "archive_job"
|
||||
assert dispatcher.calls[0][1] == {"job_id": "j1", "name": "SPL 存档", "description": "d"}
|
||||
assert out["bioflow"]["audit"]["tool_names"] == ["archive_job"]
|
||||
|
||||
def test_archive_choice_archive_false_no_tool_call(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "archive_choice",
|
||||
"payload": {"job_id": "j1", "archive": False}},
|
||||
FakeLLM(), {}, _tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls == [] # 跳过存档不发工具请求
|
||||
assert out["bioflow"]["audit"]["tool_calls"] == 0
|
||||
@@ -0,0 +1,206 @@
|
||||
"""test_bioflow_agent_s4_supplement.py —— S4 覆盖清单补强测试。
|
||||
|
||||
覆盖:
|
||||
- _card_extension_action 六种 action 分发到对应扩展工具
|
||||
- 未知 action / 缺 ext_id/action 回退 None
|
||||
- confirm 携带 nl_triggers/parameters/runtime_image;EXT_STATUS 降级话术(确定性)
|
||||
- run 携带 params/files
|
||||
- 真实 configs/capabilities.yaml 三条目(含 bioflow-extension)可解析
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import CapabilityManifest, parse_capabilities_file # noqa: E402
|
||||
|
||||
EXT_TOOLS = (
|
||||
"create_extension", "extension_status", "build_extension", "trial_run_extension",
|
||||
"validate_extension", "confirm_extension", "publish_extension", "run_extension",
|
||||
"repair_extension",
|
||||
)
|
||||
|
||||
|
||||
def _ext_tool_manifests():
|
||||
out = {}
|
||||
for t in EXT_TOOLS:
|
||||
out[t] = CapabilityManifest(
|
||||
name="bioflow-extension", provider="mcp",
|
||||
config={"mcp": {"url": "http://127.0.0.1:8098/mcp/extension/", "tool": t}},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def complete(self, system, user, **kw):
|
||||
return "已收到。"
|
||||
|
||||
def complete_json(self, system, user, **kw):
|
||||
return {}
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "answer", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self, results=None):
|
||||
self.results = results or {}
|
||||
self.calls = []
|
||||
|
||||
def execute(self, manifest, arguments):
|
||||
tool = manifest.config.get("mcp", {}).get("tool")
|
||||
self.calls.append((tool, arguments))
|
||||
return self.results.get(tool, '{"ok": true, "data": {"ext_id": "e1"}}')
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _card_extension_action 分发
|
||||
# ===========================================================================
|
||||
|
||||
class TestExtensionAction:
|
||||
@pytest.mark.parametrize("action,expected_tool", [
|
||||
("build", "build_extension"),
|
||||
("trial_run", "trial_run_extension"),
|
||||
("validate", "validate_extension"),
|
||||
("confirm", "confirm_extension"),
|
||||
("publish", "publish_extension"),
|
||||
("run", "run_extension"),
|
||||
("repair", "repair_extension"),
|
||||
])
|
||||
def test_action_dispatches_to_tool(self, action, expected_tool):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": action}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert dispatcher.calls[0][0] == expected_tool
|
||||
assert dispatcher.calls[0][1] == {"ext_id": "e1"}
|
||||
assert out["bioflow"]["audit"]["tool_names"] == [expected_tool]
|
||||
|
||||
def test_unknown_action_returns_none(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "bogus"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
def test_missing_ext_id_or_action_returns_none(self):
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
assert ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"action": "build"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
) is None
|
||||
assert ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
) is None
|
||||
|
||||
def test_confirm_passes_triggers_params_image(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
payload = {"ext_id": "e1", "action": "confirm",
|
||||
"nl_triggers": ["扩展", "触发词"], "parameters": [{"name": "x"}],
|
||||
"runtime_image": "img"}
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result({"card": "extension_action", "payload": payload},
|
||||
FakeLLM(), {}, _ext_tool_manifests())
|
||||
args = dispatcher.calls[0][1]
|
||||
assert args == {"ext_id": "e1", "nl_triggers": ["扩展", "触发词"],
|
||||
"parameters": [{"name": "x"}], "runtime_image": "img"}
|
||||
|
||||
def test_confirm_status_insufficient_uses_deterministic_script(self):
|
||||
# confirm 返回 EXT_STATUS(状态不足)→ 确定性降级话术(不报 500、不经 LLM)
|
||||
dispatcher = FakeDispatcher({
|
||||
"confirm_extension": '{"ok": false, "code": "EXT_STATUS", "message": "状态不可确认"}',
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "confirm"}},
|
||||
FakeLLM(), {}, _ext_tool_manifests(),
|
||||
)
|
||||
assert out is not None
|
||||
assert "还不能确认" in out["answer"]
|
||||
assert "先完成" in out["answer"] # 确定性话术(非 LLM 转述)
|
||||
|
||||
def test_run_passes_params_and_files(self):
|
||||
dispatcher = FakeDispatcher()
|
||||
payload = {"ext_id": "e1", "action": "run",
|
||||
"params": {"cpu": 8}, "files": ["/data/a.fa"]}
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
ba._handle_card_result({"card": "extension_action", "payload": payload},
|
||||
FakeLLM(), {}, _ext_tool_manifests())
|
||||
assert dispatcher.calls[0][1] == {"ext_id": "e1", "params": {"cpu": 8}, "files": ["/data/a.fa"]}
|
||||
|
||||
def test_extension_tool_not_in_manifests_returns_none(self):
|
||||
# tool_manifests 里没有扩展工具 → 回退 None(能力未安装/未发现工具)
|
||||
empty = CapabilityManifest(name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x", "tool": "job_status"}})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=FakeDispatcher()):
|
||||
out = ba._handle_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "build"}},
|
||||
FakeLLM(), {}, {"job_status": empty},
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 真实 capabilities.yaml 三条目可解析
|
||||
# ===========================================================================
|
||||
|
||||
class TestCapabilitiesThreeEntries:
|
||||
def _real_yaml(self) -> Path:
|
||||
return Path(__file__).resolve().parents[5] / "agent" / "qa" / "configs" / "capabilities.yaml"
|
||||
|
||||
def test_three_bioflow_entries(self):
|
||||
manifests, warnings = parse_capabilities_file(self._real_yaml())
|
||||
by_name = {m.name: m for m in manifests}
|
||||
assert {"bioflow-gene-family", "bioflow-rnaseq", "bioflow-extension"} <= set(by_name)
|
||||
|
||||
ext = by_name["bioflow-extension"]
|
||||
assert ext.provider == "mcp"
|
||||
assert ext.permission == "read_only"
|
||||
assert ext.nl_triggers
|
||||
assert ext.config["mcp"]["url"].endswith("/mcp/extension/")
|
||||
assert ext.config["prompt_pack"]["file"] == "bioflow_extension_agent_rules.md"
|
||||
|
||||
def test_bioflow_extension_in_capability_names(self):
|
||||
assert "bioflow-extension" in ba.BIOFLOW_CAPABILITY_NAMES
|
||||
assert ba.CARD_EXTENSION_ACTION == "extension_action"
|
||||
assert ba.CARD_EXTENSION_ACTION in ba.CARD_TYPES
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S5:_route_card_result 对 extension_action 只发现 extension 能力(一次 list_tools)
|
||||
# ===========================================================================
|
||||
|
||||
class TestRouteExtensionOptimization:
|
||||
def test_extension_action_discovers_only_extension_capability(self):
|
||||
gf = CapabilityManifest(name="bioflow-gene-family", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/gene-family/"}})
|
||||
rn = CapabilityManifest(name="bioflow-rnaseq", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/rnaseq/"}})
|
||||
ext = CapabilityManifest(name="bioflow-extension", provider="mcp",
|
||||
config={"mcp": {"url": "http://x/extension/"}})
|
||||
captured = {}
|
||||
|
||||
def fake_build(manifests):
|
||||
captured["names"] = [m.name for m in manifests]
|
||||
return ([], {})
|
||||
|
||||
with mock.patch.object(ba, "_registered_capabilities", return_value=[gf, rn, ext]):
|
||||
with mock.patch.object(ba, "_build_tools", side_effect=fake_build):
|
||||
ba._route_card_result(
|
||||
{"card": "extension_action", "payload": {"ext_id": "e1", "action": "build"}},
|
||||
FakeLLM(), None, None, None,
|
||||
)
|
||||
# 只把 bioflow-extension manifest 交给 _build_tools(避免对三能力各 list_tools 一次)
|
||||
assert captured["names"] == ["bioflow-extension"]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""test_bioflow_agent_s6_config.py —— S6 多配置回归(QA 单元层)。
|
||||
|
||||
模拟四种部署配置,断言候选能力集合 + 意图预筛结果正确(真实 E2E/金标 20 题依赖部署,
|
||||
此处以 monkeypatch/env 在单元层覆盖漏判安全语义)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry # noqa: E402
|
||||
|
||||
THREE_YAML = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 亚家族]
|
||||
mcp: {url: "http://x/gene-family/"}
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [转录组, 差异表达]
|
||||
mcp: {url: "http://x/rnaseq/"}
|
||||
- name: bioflow-extension
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
session_default: true
|
||||
nl_triggers: [自定义扩展]
|
||||
mcp: {url: "http://x/extension/"}
|
||||
"""
|
||||
|
||||
|
||||
def _registry(tmp_path, yaml_text=THREE_YAML):
|
||||
path = tmp_path / "caps.yaml"
|
||||
path.write_text(yaml_text, encoding="utf-8")
|
||||
reg = CapabilityRegistry(path)
|
||||
reg.load()
|
||||
return reg
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def complete_raw_messages(self, *a, **kw):
|
||||
return {"content": "x", "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 1:全关(QA_V1_ENABLE_BIOFLOW_AGENT=0 + 三能力全卸载)
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigAllOff:
|
||||
def test_fuse_off_returns_none(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
out = ba.try_bioflow_agent("帮我做基因家族分析", llm_client=FakeLLM())
|
||||
assert out is None # 走原 pipeline
|
||||
|
||||
def test_all_uninstalled_no_candidate(self, tmp_path):
|
||||
reg = _registry(tmp_path, "capabilities: []\n") # 三能力全卸载
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
assert ba._candidate_capabilities("帮我做基因家族分析", None) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 2:只开基因家族
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigGeneFamilyOnly:
|
||||
def test_gene_family_hit_rnaseq_miss(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
caps = {"bioflow-gene-family": True, "bioflow-rnaseq": False, "bioflow-extension": False}
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
names = [m.name for m in ba._candidate_capabilities("帮我做水稻 SPL 基因家族分析", caps)]
|
||||
assert names == ["bioflow-gene-family"]
|
||||
assert ba._candidate_capabilities("帮我做转录组差异表达分析", caps) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 3:只开转录组
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigRnaseqOnly:
|
||||
def test_rnaseq_hit_gene_family_miss(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
caps = {"bioflow-gene-family": False, "bioflow-rnaseq": True, "bioflow-extension": False}
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
names = [m.name for m in ba._candidate_capabilities("转录组差异表达分析", caps)]
|
||||
assert names == ["bioflow-rnaseq"]
|
||||
assert ba._candidate_capabilities("帮我做水稻 SPL 基因家族分析", caps) == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 配置 4:全开但非生信提问
|
||||
# ===========================================================================
|
||||
|
||||
class TestConfigAllOnNonBioflow:
|
||||
def test_non_bioflow_question_no_candidate(self, tmp_path):
|
||||
reg = _registry(tmp_path)
|
||||
caps = {"bioflow-gene-family": True, "bioflow-rnaseq": True, "bioflow-extension": True}
|
||||
with mock.patch.object(ba, "get_registry", return_value=reg):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
assert ba._candidate_capabilities("光合作用的电子传递链是什么", caps) == []
|
||||
assert ba._candidate_capabilities("什么是转录因子", caps) == []
|
||||
@@ -0,0 +1,496 @@
|
||||
"""test_bioflow_agent_supplement.py —— S1 覆盖清单补强测试。
|
||||
|
||||
补强 dev 的 test_bioflow_agent.py 未覆盖的分支(对照 S1 覆盖清单):
|
||||
- 熔断开关值解析(1/true/yes/on 与 0/false/off/空/缺失)
|
||||
- nl_triggers 同时命中 → 两个候选(交 LLM 依 description 选)
|
||||
- session_default 回退(True/False)/ registry 访问异常 / registry.get 异常
|
||||
- _build_tools:MCP 工具发现(空 url 跳过 / 发现异常 / 共享连接按能力名 / 无名跳过 / bad schema 兜底)
|
||||
- _build_system:prompt_pack 注入 / 去重 / 加载异常兜底
|
||||
- try_bioflow_agent:tools 空→None、answer 空→None、progress_callback 触发与吞异常
|
||||
- ToolRunner max_rounds=5 / bioflow_agent.MAX_ROUNDS 常量
|
||||
- make_llm_complete 包装(正常 / 异常)
|
||||
- 真实 configs/capabilities.yaml 两条目可解析
|
||||
- bioflow_agent 不 import pipeline/answer_composer(红线静态守卫)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "src"))
|
||||
|
||||
from rag.qa_v1_1_73 import bioflow_agent as ba # noqa: E402
|
||||
from rag.qa_v1_1_73.capabilities.manifests import ( # noqa: E402
|
||||
CapabilityManifest,
|
||||
parse_capabilities_file,
|
||||
)
|
||||
from rag.qa_v1_1_73.capabilities.registry import CapabilityRegistry # noqa: E402
|
||||
|
||||
BIOFLOW_YAML = """
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 家族成员, 亚家族, 建树]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析
|
||||
session_default: true
|
||||
nl_triggers: [转录组, RNA-seq, 差异表达, 富集]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
"""
|
||||
|
||||
|
||||
def _make_registry(tmp_path: Path, yaml_text: str = BIOFLOW_YAML) -> CapabilityRegistry:
|
||||
path = tmp_path / "capabilities.yaml"
|
||||
path.write_text(yaml_text, encoding="utf-8")
|
||||
registry = CapabilityRegistry(path)
|
||||
registry.load()
|
||||
return registry
|
||||
|
||||
|
||||
def _gf_manifest(**overrides) -> CapabilityManifest:
|
||||
cfg = {
|
||||
"mcp": {"url": "http://127.0.0.1:8098/mcp/gene-family/"},
|
||||
"prompt_pack": {"file": "bioflow_gene_family_agent_rules.md"},
|
||||
}
|
||||
cfg.update(overrides.pop("config", {}))
|
||||
return CapabilityManifest(
|
||||
name="bioflow-gene-family", provider="mcp", permission="read_only",
|
||||
description="基因家族分析", config=cfg, nl_triggers=["基因家族"], **overrides,
|
||||
)
|
||||
|
||||
|
||||
class FakeLLMClient:
|
||||
def __init__(self, content: str = "已为您规划分析路径。") -> None:
|
||||
self.content = content
|
||||
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kwargs):
|
||||
return {"content": self.content, "tool_calls": [], "finish_reason": "stop"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 门 1:熔断开关值解析
|
||||
# ===========================================================================
|
||||
|
||||
class TestEnvFuse:
|
||||
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", " On ", "YES"])
|
||||
def test_truthy(self, value):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: value}):
|
||||
assert ba._env_enabled() is True
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "off", "", "no", " "])
|
||||
def test_falsy(self, value):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: value}):
|
||||
assert ba._env_enabled() is False
|
||||
|
||||
def test_missing_defaults_off(self):
|
||||
with mock.patch.dict(ba.os.environ, {}, clear=False):
|
||||
ba.os.environ.pop(ba.ENV_FUSE, None)
|
||||
assert ba._env_enabled() is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 门 3:nl_triggers 意图预筛
|
||||
# ===========================================================================
|
||||
|
||||
class TestIntentMatchEdge:
|
||||
def test_empty_triggers_never_match(self):
|
||||
assert ba._intent_match("做基因家族分析", []) is False
|
||||
assert ba._intent_match("做基因家族分析", [""]) is False
|
||||
|
||||
def test_case_insensitive_substring(self):
|
||||
assert ba._intent_match("RNA-SEQ 分析", ["rna-seq"]) is True
|
||||
assert ba._intent_match("ka/ks 选择压力", ["Ka/Ks"]) is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 门 2 + 门 3:候选能力(registry 异常 / session_default 回退 / 同时命中)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCandidateCapabilities:
|
||||
def test_registry_access_exception_returns_empty(self):
|
||||
with mock.patch.object(ba, "get_registry", side_effect=RuntimeError("boom")):
|
||||
assert ba._candidate_capabilities("做基因家族", None) == []
|
||||
|
||||
def test_registry_get_exception_skips(self):
|
||||
class BadRegistry:
|
||||
def get(self, name):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with mock.patch.object(ba, "get_registry", return_value=BadRegistry()):
|
||||
assert ba._candidate_capabilities("做基因家族", None) == []
|
||||
|
||||
def test_both_triggers_match_returns_both(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
caps = ba._candidate_capabilities("做基因家族和转录组差异表达分析", None)
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family", "bioflow-rnaseq"]
|
||||
|
||||
def test_session_default_false_skips(self, tmp_path):
|
||||
yaml = BIOFLOW_YAML.replace("session_default: true", "session_default: false")
|
||||
registry = _make_registry(tmp_path, yaml)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
# 无会话开关时回退 session_default=false → 不候选
|
||||
assert ba._candidate_capabilities("做基因家族分析", None) == []
|
||||
# 显式会话开关 true → 候选
|
||||
caps = ba._candidate_capabilities("做基因家族分析", {"bioflow-gene-family": True})
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family"]
|
||||
|
||||
def test_session_switch_true_overrides_default_false(self, tmp_path):
|
||||
yaml = BIOFLOW_YAML.replace("session_default: true", "session_default: false", 1)
|
||||
registry = _make_registry(tmp_path, yaml)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
caps = ba._candidate_capabilities("做基因家族分析", {"bioflow-gene-family": True})
|
||||
assert [m.name for m in caps] == ["bioflow-gene-family"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _build_tools:MCP 工具发现
|
||||
# ===========================================================================
|
||||
|
||||
class _FakeDispatcher:
|
||||
def __init__(self, tools_by_name=None, exc=None):
|
||||
self.tools_by_name = tools_by_name or {}
|
||||
self.exc = exc
|
||||
|
||||
def list_mcp_tools(self, manifest):
|
||||
if self.exc:
|
||||
raise self.exc
|
||||
return self.tools_by_name.get(manifest.name, [])
|
||||
|
||||
|
||||
class TestBuildTools:
|
||||
def test_empty_url_skipped(self):
|
||||
manifest = CapabilityManifest(
|
||||
name="x", provider="mcp", config={"mcp": {"url": ""}}
|
||||
)
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=_FakeDispatcher()):
|
||||
tools, tool_manifests = ba._build_tools([manifest])
|
||||
assert tools == []
|
||||
assert tool_manifests == {}
|
||||
|
||||
def test_discovery_exception_skipped(self):
|
||||
manifest = _gf_manifest()
|
||||
with mock.patch.object(
|
||||
ba, "get_dispatcher",
|
||||
return_value=_FakeDispatcher(exc=RuntimeError("mcp down")),
|
||||
):
|
||||
tools, tool_manifests = ba._build_tools([manifest])
|
||||
assert tools == []
|
||||
assert tool_manifests == {}
|
||||
|
||||
def test_builds_schema_and_shared_connection(self):
|
||||
dispatcher = _FakeDispatcher({
|
||||
"bioflow-gene-family": [
|
||||
{"name": "plan_gene_family", "description": "规划路径",
|
||||
"input_schema": {"type": "object", "properties": {"message": {"type": "string"}}}},
|
||||
]
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
tools, tool_manifests = ba._build_tools([_gf_manifest()])
|
||||
assert len(tools) == 1
|
||||
assert tools[0]["function"]["name"] == "plan_gene_family"
|
||||
assert tools[0]["function"]["description"] == "规划路径"
|
||||
# 共享连接:manifest.name 用能力名,mcp.tool 指向真实 server 工具名
|
||||
tm = tool_manifests["plan_gene_family"]
|
||||
assert tm.name == "bioflow-gene-family"
|
||||
assert tm.config["mcp"]["tool"] == "plan_gene_family"
|
||||
assert tm.config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
|
||||
def test_skips_nameless_and_bad_schema(self):
|
||||
dispatcher = _FakeDispatcher({
|
||||
"bioflow-gene-family": [
|
||||
{"name": "", "description": "无名", "input_schema": {}},
|
||||
{"name": "job_status", "description": "查状态", "input_schema": "not-a-dict"},
|
||||
]
|
||||
})
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
tools, tool_manifests = ba._build_tools([_gf_manifest()])
|
||||
assert [t["function"]["name"] for t in tools] == ["job_status"]
|
||||
assert tools[0]["function"]["parameters"] == {"type": "object", "properties": {}}
|
||||
|
||||
def test_shared_tool_deduped_across_capabilities(self):
|
||||
"""两能力同时命中:共享工具去重(只保留首个能力),专有工具各自保留。
|
||||
|
||||
覆盖 bug ① 修复:job_status 等 12 个共享工具两端点语义一致,按名去重后
|
||||
LLM tools 列表无重名,且 job_status 路由到首个能力(gene-family)。
|
||||
"""
|
||||
dispatcher = _FakeDispatcher({
|
||||
"bioflow-gene-family": [
|
||||
{"name": "plan_gene_family", "description": "规划基因家族", "input_schema": {}},
|
||||
{"name": "job_status", "description": "查状态", "input_schema": {}},
|
||||
{"name": "confirm_stage", "description": "确认参数", "input_schema": {}},
|
||||
],
|
||||
"bioflow-rnaseq": [
|
||||
{"name": "plan_rnaseq", "description": "规划转录组", "input_schema": {}},
|
||||
{"name": "job_status", "description": "查状态", "input_schema": {}},
|
||||
{"name": "confirm_stage", "description": "确认参数", "input_schema": {}},
|
||||
],
|
||||
})
|
||||
rn = CapabilityManifest(
|
||||
name="bioflow-rnaseq", provider="mcp", permission="read_only",
|
||||
description="转录组分析", nl_triggers=["转录组"],
|
||||
config={"mcp": {"url": "http://127.0.0.1:8098/mcp/rnaseq/"}},
|
||||
)
|
||||
with mock.patch.object(ba, "get_dispatcher", return_value=dispatcher):
|
||||
tools, tool_manifests = ba._build_tools([_gf_manifest(), rn])
|
||||
|
||||
names = [t["function"]["name"] for t in tools]
|
||||
# 专有工具各保留 + 共享工具只保留一份,且无重名
|
||||
assert names == ["plan_gene_family", "job_status", "confirm_stage", "plan_rnaseq"]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
# 共享工具路由到首个能力(gene-family)
|
||||
assert tool_manifests["job_status"].name == "bioflow-gene-family"
|
||||
assert tool_manifests["job_status"].config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
assert tool_manifests["confirm_stage"].name == "bioflow-gene-family"
|
||||
# 专有工具各自路由到自身能力
|
||||
assert tool_manifests["plan_rnaseq"].name == "bioflow-rnaseq"
|
||||
assert tool_manifests["plan_rnaseq"].config["mcp"]["url"].endswith("/mcp/rnaseq/")
|
||||
assert tool_manifests["plan_gene_family"].name == "bioflow-gene-family"
|
||||
|
||||
# ===========================================================================
|
||||
# _build_system:prompt_pack 注入
|
||||
# ===========================================================================
|
||||
|
||||
class TestBuildSystem:
|
||||
def test_appends_prompt_pack(self):
|
||||
manifest = _gf_manifest()
|
||||
with mock.patch.object(ba.PromptPackProvider, "load", return_value="基因家族规则正文"):
|
||||
system = ba._build_system([manifest])
|
||||
assert ba._SYSTEM_BASE in system
|
||||
assert "基因家族规则正文" in system
|
||||
|
||||
def test_dedup_same_file_once(self):
|
||||
m1 = _gf_manifest()
|
||||
m2 = _gf_manifest() # 同 file
|
||||
calls = []
|
||||
with mock.patch.object(ba.PromptPackProvider, "load", side_effect=lambda m, root: calls.append(m) or "RULES"):
|
||||
system = ba._build_system([m1, m2])
|
||||
assert len(calls) == 1 # 去重后只加载一次
|
||||
assert system.count("RULES") == 1
|
||||
|
||||
def test_empty_file_skipped(self):
|
||||
manifest = CapabilityManifest(name="x", provider="mcp", config={"prompt_pack": {"file": ""}})
|
||||
system = ba._build_system([manifest])
|
||||
assert system == ba._SYSTEM_BASE
|
||||
|
||||
def test_load_exception_falls_back(self):
|
||||
manifest = _gf_manifest()
|
||||
with mock.patch.object(ba.PromptPackProvider, "load", side_effect=RuntimeError("boom")):
|
||||
system = ba._build_system([manifest])
|
||||
assert ba._SYSTEM_BASE in system # 不炸,只用基础提示
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# try_bioflow_agent:tools 空 / answer 空 / progress_callback
|
||||
# ===========================================================================
|
||||
|
||||
class TestFullFlowEdge:
|
||||
def test_tools_empty_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=([], {})):
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_answer_empty_returns_none(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("", {"rounds": 1})
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(), capabilities=None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_progress_callback_invoked(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
calls = []
|
||||
|
||||
def cb(payload):
|
||||
calls.append(payload)
|
||||
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("已答复", {"rounds": 1})
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(),
|
||||
capabilities=None, progress_callback=cb,
|
||||
)
|
||||
assert result is not None
|
||||
assert calls and calls[0]["stage"] == "bioflow_agent_started"
|
||||
assert calls[0]["capabilities"] == ["bioflow-gene-family"]
|
||||
assert calls[0]["tool_count"] == 1
|
||||
|
||||
def test_progress_callback_exception_swallowed(self, tmp_path):
|
||||
registry = _make_registry(tmp_path)
|
||||
fake_tools = [{"type": "function", "function": {"name": "plan_gene_family",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
|
||||
def cb(payload):
|
||||
raise RuntimeError("callback boom")
|
||||
|
||||
with mock.patch.object(ba, "get_registry", return_value=registry):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {})):
|
||||
with mock.patch.object(ba, "ToolRunner") as FakeRunner:
|
||||
FakeRunner.return_value.run.return_value = ("已答复", {"rounds": 1})
|
||||
result = ba.try_bioflow_agent(
|
||||
"帮我做基因家族分析", llm_client=FakeLLMClient(),
|
||||
capabilities=None, progress_callback=cb,
|
||||
)
|
||||
assert result is not None # 回调异常被吞,不影响主流程
|
||||
assert result["answer"] == "已答复"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ToolRunner max_rounds=5 / MAX_ROUNDS 常量
|
||||
# ===========================================================================
|
||||
|
||||
class TestMaxRounds:
|
||||
def test_max_rounds_constant_is_5(self):
|
||||
assert ba.MAX_ROUNDS == 5
|
||||
|
||||
def test_tool_runner_respects_max_rounds_5(self):
|
||||
from rag.qa_v1_1_73.tool_runner import ToolRunner
|
||||
|
||||
class LLM:
|
||||
def __call__(self, system, messages, tools):
|
||||
return "", [{"id": "c", "name": "echo", "arguments": "{}"}], None
|
||||
|
||||
class Dispatcher:
|
||||
def execute(self, manifest, arguments):
|
||||
return "ok"
|
||||
|
||||
manifest = CapabilityManifest(name="echo", provider="python")
|
||||
tools = [{"type": "function", "function": {"name": "echo", "description": "e",
|
||||
"parameters": {"type": "object", "properties": {}}}}]
|
||||
runner = ToolRunner(LLM(), dispatcher=Dispatcher(), max_rounds=5)
|
||||
content, audit = runner.run("sys", "user", tools, {"echo": manifest})
|
||||
assert audit["rounds"] == 5
|
||||
assert audit["tool_calls"] == 5
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# make_llm_complete 包装
|
||||
# ===========================================================================
|
||||
|
||||
class TestMakeLLMComplete:
|
||||
def test_normal(self):
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
class Client:
|
||||
def complete_raw_messages(self, system, messages, tools=None, **kw):
|
||||
return {"content": "hi", "tool_calls": [{"name": "x"}], "finish_reason": "stop"}
|
||||
|
||||
complete = make_llm_complete(Client())
|
||||
content, tool_calls, error = complete("sys", [{"role": "user", "content": "q"}], [])
|
||||
assert content == "hi"
|
||||
assert tool_calls == [{"name": "x"}]
|
||||
assert error is None
|
||||
|
||||
def test_exception_becomes_error(self):
|
||||
from rag.qa_v1_1_73.tool_runner import make_llm_complete
|
||||
|
||||
class Client:
|
||||
def complete_raw_messages(self, *a, **kw):
|
||||
raise RuntimeError("llm down")
|
||||
|
||||
complete = make_llm_complete(Client())
|
||||
content, tool_calls, error = complete("sys", [], [])
|
||||
assert content == ""
|
||||
assert tool_calls == []
|
||||
assert "RuntimeError" in error
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 真实 configs/capabilities.yaml 两条目可解析
|
||||
# ===========================================================================
|
||||
|
||||
class TestRealCapabilitiesYaml:
|
||||
def _real_yaml(self) -> Path:
|
||||
repo_root = Path(__file__).resolve().parents[5]
|
||||
return repo_root / "agent" / "qa" / "configs" / "capabilities.yaml"
|
||||
|
||||
def test_both_bioflow_entries_parse(self):
|
||||
manifests, warnings = parse_capabilities_file(self._real_yaml())
|
||||
by_name = {m.name: m for m in manifests}
|
||||
assert "bioflow-gene-family" in by_name
|
||||
assert "bioflow-rnaseq" in by_name
|
||||
|
||||
gf = by_name["bioflow-gene-family"]
|
||||
assert gf.provider == "mcp"
|
||||
assert gf.permission == "read_only"
|
||||
assert gf.nl_triggers
|
||||
assert gf.config["mcp"]["url"].endswith("/mcp/gene-family/")
|
||||
assert gf.config["prompt_pack"]["file"] == "bioflow_gene_family_agent_rules.md"
|
||||
|
||||
rn = by_name["bioflow-rnaseq"]
|
||||
assert rn.permission == "read_only"
|
||||
assert rn.nl_triggers
|
||||
assert rn.config["mcp"]["url"].endswith("/mcp/rnaseq/")
|
||||
assert rn.config["prompt_pack"]["file"] == "bioflow_rnaseq_agent_rules.md"
|
||||
|
||||
def test_nl_triggers_cover_expected_words(self):
|
||||
manifests, _ = parse_capabilities_file(self._real_yaml())
|
||||
gf = next(m for m in manifests if m.name == "bioflow-gene-family")
|
||||
rn = next(m for m in manifests if m.name == "bioflow-rnaseq")
|
||||
# 关键触发词在位(意图预筛依赖)
|
||||
assert "基因家族" in gf.nl_triggers
|
||||
assert "转录组" in rn.nl_triggers
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 红线静态守卫:bioflow_agent 不依赖 pipeline / answer_composer
|
||||
# ===========================================================================
|
||||
|
||||
class TestRedLineNoPipelineDep:
|
||||
def test_no_pipeline_or_answer_composer_import(self):
|
||||
import ast
|
||||
|
||||
src = Path(ba.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(src)
|
||||
imported = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Import):
|
||||
imported += [a.name for a in node.names]
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
imported.append(node.module or "")
|
||||
assert not any("pipeline" in m or "answer_composer" in m for m in imported)
|
||||
|
||||
def test_never_raises(self, tmp_path):
|
||||
# 任何异常都返回 None/正常 payload,绝不外抛
|
||||
with mock.patch.object(ba, "get_registry", side_effect=RuntimeError("boom")):
|
||||
with mock.patch.dict(ba.os.environ, {ba.ENV_FUSE: "1"}):
|
||||
result = ba.try_bioflow_agent("做基因家族", llm_client=FakeLLMClient())
|
||||
assert result is None
|
||||
@@ -51,9 +51,13 @@ class ToolRunner:
|
||||
user: str,
|
||||
tools: List[Dict[str, Any]],
|
||||
manifests: Dict[str, CapabilityManifest],
|
||||
history: Optional[List[Dict[str, str]]] = None,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""执行工具调用循环。返回 (content, audit)。
|
||||
|
||||
history:多轮记忆(S2),List[{role: user|assistant, content}],旧→新,
|
||||
不含当前这轮提问;注入为 messages 前置轮次(作为上下文,不参与工具结果回填)。
|
||||
|
||||
audit: {"rounds": n, "tool_calls": k, "used_json_fallback": bool,
|
||||
"tool_names": [...], "errors": [...]}
|
||||
"""
|
||||
@@ -64,7 +68,16 @@ class ToolRunner:
|
||||
"tool_names": [],
|
||||
"errors": [],
|
||||
}
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": user}]
|
||||
messages: List[Dict[str, Any]] = []
|
||||
for h in history or []:
|
||||
if not isinstance(h, dict):
|
||||
continue
|
||||
role = str(h.get("role") or "").strip()
|
||||
content = str(h.get("content") or "").strip()
|
||||
if role not in {"user", "assistant"} or not content:
|
||||
continue
|
||||
messages.append({"role": role, "content": content})
|
||||
messages.append({"role": "user", "content": user})
|
||||
system_text = system
|
||||
for round_index in range(1, self.max_rounds + 1):
|
||||
audit["rounds"] = round_index
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
- 独立部署树 bioflow_service/(FR-BIO-028:独立进程 8098 + 独立 SQLite)。
|
||||
- lifespan:init_db(幂等迁移)→ init_context(kernel/catalog/planner/runner)
|
||||
→ recover(服务重启恢复 in-flight 续跑,FR-BIO-031)→ shutdown 清理。
|
||||
→ recover(服务重启恢复 in-flight 续跑,FR-BIO-031)→ MCP session manager
|
||||
(anyio task group,streamable-http 处理请求必需)→ shutdown 清理。
|
||||
- 端点:health(/api/bioflow/health,前端探活 FR-BIO-025)。
|
||||
- 不依赖 BFF/生产服务(8090/8091 等绝不探测/连接)。
|
||||
"""
|
||||
@@ -17,11 +18,18 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from .config import get_settings
|
||||
from .context import context, init_context, shutdown_context
|
||||
from .db import close_db, init_db
|
||||
from .mcp_server import extension_mcp, gene_family_mcp, rnaseq_mcp
|
||||
from .routers import bioarchives, bioflow, biojobs, extension
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 三个 MCP streamable-http app(调用即创建 session_manager,供 lifespan 进入 run())。
|
||||
# 端点地址:/mcp/gene-family、/mcp/rnaseq、/mcp/extension(streamable_http_path="/" + 挂载前缀)。
|
||||
_gene_family_mcp_app = gene_family_mcp.streamable_http_app(streamable_http_path="/")
|
||||
_rnaseq_mcp_app = rnaseq_mcp.streamable_http_app(streamable_http_path="/")
|
||||
_extension_mcp_app = extension_mcp.streamable_http_app(streamable_http_path="/")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
@@ -60,14 +68,21 @@ def create_app() -> FastAPI:
|
||||
logger.info("main: 重启恢复 %d 个 in-flight 任务续跑", len(recovered))
|
||||
except Exception:
|
||||
logger.exception("main: 重启恢复扫描失败(任务不重跑,等待后续操作)")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await shutdown_context()
|
||||
try:
|
||||
await close_db()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# MCP session manager 的 task group 必须在服务运行期内保持活跃
|
||||
# (streamable-http 处理请求时要求已初始化)。Starlette 不会自动运行
|
||||
# 子 Mount 的 lifespan,故在此显式进入三个 MCP 的 run()。
|
||||
async with gene_family_mcp.session_manager.run():
|
||||
async with rnaseq_mcp.session_manager.run():
|
||||
async with extension_mcp.session_manager.run():
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await shutdown_context()
|
||||
try:
|
||||
await close_db()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app.router.lifespan_context = _lifespan
|
||||
|
||||
@@ -75,6 +90,12 @@ def create_app() -> FastAPI:
|
||||
app.include_router(biojobs.router)
|
||||
app.include_router(bioarchives.router)
|
||||
app.include_router(extension.router)
|
||||
|
||||
# 生信 MCP 能力外挂(mcp 2.x:MCPServer.streamable_http_app)。
|
||||
# 三个独立 streamable-http 端点,各自装卸(REST 面不受影响)。
|
||||
app.mount("/mcp/gene-family", _gene_family_mcp_app)
|
||||
app.mount("/mcp/rnaseq", _rnaseq_mcp_app)
|
||||
app.mount("/mcp/extension", _extension_mcp_app)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
"""app/mcp_server.py —— 生信 MCP server(两个 streamable-http 端点)。
|
||||
|
||||
- `/mcp/gene-family`:基因家族能力单元(plan/create/ask + 共享控制组 + 文件组)
|
||||
- `/mcp/rnaseq`:转录组能力单元(plan/create/generate_samples_yaml/ask + 共享控制组 + 文件组)
|
||||
|
||||
设计约束(实施方案 S0):
|
||||
- 工具只承诺「已受理」,执行进度/日志由 SSE 事件总线直推,不走 MCP。
|
||||
- 每个能力单元只接受本链阶段(create 跨链拒绝 CROSS_CHAIN)。
|
||||
- 人工确认边界(subfamily_confirm / rnaseq_de 实验设计确认)在 job_status 的
|
||||
pending_actions 里标 requires_human=true,工具不提供绕过。
|
||||
- 白名单沿用 runner._allowed_param_names(单一事实源)。
|
||||
- 工具名不外泄由 QA agent(S1)负责,本模块只负责工具语义。
|
||||
|
||||
mcp 2.x 注意:MCPServer(非 FastMCP,来自 mcp.server.mcpserver);挂载用
|
||||
`mcp.streamable_http_app()`。本文件位于 app/ 层(非 services/),可用 pydantic(mcp
|
||||
自身也依赖 pydantic 构造 schema)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import get_settings
|
||||
from .context import load_record
|
||||
from .services.bioflow import extensions as ext_mod
|
||||
from .services.bioflow import store
|
||||
from .services.bioflow.archive import archive_from_job, normalize_archive_name
|
||||
from .services.bioflow.catalog import GENE_FAMILY_CHAIN
|
||||
from .services.bioflow.file_transform import (
|
||||
inspect_file as _ft_inspect_file,
|
||||
transform_file as _ft_transform_file,
|
||||
validate_format,
|
||||
)
|
||||
from .services.bioflow.reports import locate_report
|
||||
from .services.bioflow.runner import RunnerError, ensure_samples_yaml, llm_sample_fallback
|
||||
from .services.bioflow.source_fetcher import FetchError, fetch_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---- 能力单元阶段白名单 ----
|
||||
|
||||
GENE_FAMILY_STAGES = set(GENE_FAMILY_CHAIN)
|
||||
|
||||
RNASEQ_STAGES = {
|
||||
"rnaseq_qc", "rnaseq_align", "rnaseq_quant", "rnaseq_sample_qc", "rnaseq_de",
|
||||
"rnaseq_wgcna", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_enrich",
|
||||
"rnaseq_fgsea", "rnaseq_candidate_family", "rnaseq_report",
|
||||
"annotation", "expression",
|
||||
}
|
||||
|
||||
# 人工确认边界:这些阶段 waiting_confirm 时 requires_human=true,工具/QA 不可代确认
|
||||
HUMAN_CONFIRM_STAGES = {"subfamily_confirm", "rnaseq_de"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# context 访问(动态读模块全局,测试 monkeypatch 兼容)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ctx():
|
||||
from . import context as context_mod
|
||||
|
||||
return context_mod.context
|
||||
|
||||
|
||||
def _job_detail(record: Any) -> dict[str, Any]:
|
||||
stages = []
|
||||
for stage in record.stage_chain:
|
||||
st = record._stage_states.get(stage, {})
|
||||
stages.append({
|
||||
"stage": stage,
|
||||
"status": st.get("status", "waiting"),
|
||||
"params": st.get("params") or {},
|
||||
"attempt": int(st.get("attempt") or 0),
|
||||
"manifest_path": st.get("manifest_path"),
|
||||
"log_tail": st.get("log_tail") or "",
|
||||
"error": st.get("error"),
|
||||
})
|
||||
return {
|
||||
"id": record.id,
|
||||
"session_id": record.session_id,
|
||||
"title": record.title,
|
||||
"status": record.status,
|
||||
"stages": stages,
|
||||
"params": record.params,
|
||||
"auto_continue": record.auto_continue,
|
||||
"archive_id": record.archive_id,
|
||||
"progress": record.progress(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pending_actions 派生(job_status 工具返回给 QA agent 的下一步动作)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def derive_pending_actions(record: Any, runner: Any) -> list[dict[str, Any]]:
|
||||
"""由任务/阶段状态派生待办动作(QA agent 据此引导用户,人工边界不可代确认)。
|
||||
|
||||
按阶段链顺序找第一个「卡住」的环节,返回动作清单(通常 0-1 条)。
|
||||
"""
|
||||
if record.status == "paused":
|
||||
return [{"action": "resume", "stage": None, "requires_human": False,
|
||||
"message": "任务已暂停,可恢复"}]
|
||||
if record.status == "done":
|
||||
return [{"action": "archive", "stage": None, "requires_human": False,
|
||||
"message": "任务已完成,可存档复用"}]
|
||||
if record.status == "archived":
|
||||
return [{"action": "none", "stage": None, "requires_human": False,
|
||||
"message": "任务已存档"}]
|
||||
|
||||
for stage in record.stage_chain:
|
||||
status = record._stage_states.get(stage, {}).get("status", "waiting")
|
||||
if status == "await_files":
|
||||
return [{"action": "provide_files", "stage": stage, "requires_human": False,
|
||||
"message": f"阶段 {stage} 等待输入文件"}]
|
||||
if status == "waiting_confirm":
|
||||
human = stage in HUMAN_CONFIRM_STAGES
|
||||
return [{"action": "confirm", "stage": stage, "requires_human": human,
|
||||
"form_defaults": runner._form_defaults(stage, record),
|
||||
"message": (f"阶段 {stage} 需人工确认(不可代确认)" if human
|
||||
else f"阶段 {stage} 等待参数确认")}]
|
||||
if status == "failed":
|
||||
return [{"action": "rerun_or_skip", "stage": stage, "requires_human": False,
|
||||
"message": f"阶段 {stage} 失败,可重跑或跳过"}]
|
||||
# waiting/running/queued/done/skipped → 继续
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具实现(命名 async 函数,带类型提示,供两个 MCPServer 注册)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _plan(message: str, allowed_stages: set[str]) -> dict[str, Any]:
|
||||
from .services.bioflow.planner import PlanCandidate, PlannerError, PlanResult
|
||||
|
||||
ctx = _ctx()
|
||||
if ctx.planner is None:
|
||||
return {"ok": False, "code": "NOT_READY", "message": "规划器未就绪"}
|
||||
try:
|
||||
plan = await ctx.planner.plan(message)
|
||||
except PlannerError as exc:
|
||||
fallback = [s for s in ("identify", "rnaseq_qc") if s in allowed_stages and ctx.catalog.has(s)]
|
||||
plan = PlanResult(candidates=[PlanCandidate(purpose="内置默认流程", stages=fallback)],
|
||||
source="llm_only", message=exc.message)
|
||||
candidates = []
|
||||
for c in plan.candidates:
|
||||
stages = [s for s in c.stages if s in allowed_stages]
|
||||
if stages:
|
||||
candidates.append({"purpose": c.purpose, "stages": stages})
|
||||
return {
|
||||
"ok": True,
|
||||
"candidates": candidates,
|
||||
"source": plan.source,
|
||||
"reuse_archive": plan.reuse_archive_id,
|
||||
"reuse_name": plan.reuse_name,
|
||||
"fallback": plan.message or None,
|
||||
}
|
||||
|
||||
|
||||
async def _create_job(session_id: str, title: str, stages: list[str],
|
||||
params: dict[str, Any] | None, auto_continue: bool,
|
||||
archive_id: str | None, allowed_stages: set[str]) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
unknown = [s for s in stages if s not in allowed_stages]
|
||||
if unknown:
|
||||
return {"ok": False, "code": "CROSS_CHAIN",
|
||||
"message": f"阶段不属于该能力单元: {', '.join(unknown)}"}
|
||||
try:
|
||||
record = await ctx.runner.create_job(
|
||||
session_id, title or "生信任务", stages, params or {},
|
||||
auto_continue=auto_continue, archive_id=archive_id,
|
||||
)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
ctx.records[record.id] = record
|
||||
return {"ok": True, "data": _job_detail(record)}
|
||||
|
||||
|
||||
async def _job_status(job_id: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
detail = _job_detail(record)
|
||||
detail["pending_actions"] = derive_pending_actions(record, ctx.runner)
|
||||
return {"ok": True, "data": detail}
|
||||
|
||||
|
||||
async def _confirm_stage(job_id: str, stage: str, params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
if stage not in record.stage_chain:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": f"阶段不存在: {stage}"}
|
||||
try:
|
||||
await ctx.runner.confirm_stage(record, stage, params or {})
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "stage": stage,
|
||||
"status": ctx.runner.stage_status(record, stage)}}
|
||||
|
||||
|
||||
async def _rerun_stage(job_id: str, stage: str, params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
if stage not in record.stage_chain:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": f"阶段不存在: {stage}"}
|
||||
try:
|
||||
await ctx.runner.rerun_stage(record, stage, params)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "stage": stage,
|
||||
"status": ctx.runner.stage_status(record, stage)}}
|
||||
|
||||
|
||||
async def _skip_stage(job_id: str, stage: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
if stage not in record.stage_chain:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": f"阶段不存在: {stage}"}
|
||||
try:
|
||||
await ctx.runner.skip_stage(record, stage)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "stage": stage,
|
||||
"status": ctx.runner.stage_status(record, stage)}}
|
||||
|
||||
|
||||
async def _pause_job(job_id: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
try:
|
||||
await ctx.runner.pause_job(record)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "status": record.status}}
|
||||
|
||||
|
||||
async def _resume_job(job_id: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
try:
|
||||
await ctx.runner.resume_job(record)
|
||||
except RunnerError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
return {"ok": True, "data": {"job_id": job_id, "status": record.status}}
|
||||
|
||||
|
||||
async def _archive_job(job_id: str, name: str, description: str,
|
||||
include_intermediate: bool) -> dict[str, Any]:
|
||||
"""任务存档:打包任务目录 + 落 bio_archives 行(复用 archive_from_job + store.insert_archive)。"""
|
||||
ctx = _ctx()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
archive_name = normalize_archive_name(name)
|
||||
existing = await store.find_archive_by_name(ctx.db, archive_name)
|
||||
if existing is not None:
|
||||
return {"ok": False, "code": "ARCHIVE_EXISTS", "message": f"归档名已存在: {archive_name}"}
|
||||
try:
|
||||
report_path = locate_report(record.workspace_dir)
|
||||
result = await archive_from_job(
|
||||
{
|
||||
"id": record.id, "session_id": record.session_id,
|
||||
"title": record.title, "stage_chain": record.stage_chain,
|
||||
"params": record.params,
|
||||
},
|
||||
record.job_dir,
|
||||
get_settings().archives_dir_abs,
|
||||
name=archive_name,
|
||||
description=description or "",
|
||||
include_intermediate=bool(include_intermediate),
|
||||
report_artifact=str(report_path) if report_path else None,
|
||||
)
|
||||
await store.insert_archive(ctx.db, {
|
||||
"id": result.id,
|
||||
"job_id": record.id,
|
||||
"name": archive_name,
|
||||
"description": description or "",
|
||||
"stage_chain": record.stage_chain,
|
||||
"params": record.params,
|
||||
"report_artifact": result.report_artifact,
|
||||
"disk_path": result.disk_path,
|
||||
"size_bytes": result.size_bytes,
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "code": "ARCHIVE_FAILED",
|
||||
"message": f"存档失败: {type(exc).__name__}: {exc}"}
|
||||
ctx.events.publish("job_archived", {"job_id": record.id, "archive_id": result.id})
|
||||
return {"ok": True, "data": {
|
||||
"id": result.id,
|
||||
"job_id": record.id,
|
||||
"name": archive_name,
|
||||
"description": description or "",
|
||||
"disk_path": result.disk_path,
|
||||
"size_bytes": result.size_bytes,
|
||||
"report_artifact": result.report_artifact,
|
||||
}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 扩展能力单元(第三个能力单元 bioflow-extension):复用 extensions.py 状态机
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EXT_VIEW_KEYS = (
|
||||
"ext_id", "display_name", "status", "command_template", "original_command",
|
||||
"runtime_image", "requirements", "parameters", "input_roles", "nl_triggers",
|
||||
"analysis_type", "golden", "repair_count", "trial", "validation", "last_build",
|
||||
"created_at", "updated_at",
|
||||
)
|
||||
|
||||
|
||||
def _ext_view(ext: dict[str, Any]) -> dict[str, Any]:
|
||||
"""扩展精简视图(剔除 source_files 等超长字段,供 MCP 工具结果返回)。"""
|
||||
return {k: ext.get(k) for k in _EXT_VIEW_KEYS if k in ext}
|
||||
|
||||
|
||||
def _file_items(files: Any) -> list[tuple[str, bytes]]:
|
||||
"""把 MCP 入参 [{path, content|data}] 转为 [(rel_path, bytes)]。"""
|
||||
out: list[tuple[str, bytes]] = []
|
||||
if not isinstance(files, list):
|
||||
return out
|
||||
for f in files:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
path = str(f.get("path") or f.get("name") or "").strip()
|
||||
if not path:
|
||||
continue
|
||||
content = f.get("content")
|
||||
if content is None:
|
||||
content = f.get("data") or ""
|
||||
if isinstance(content, (bytes, bytearray)):
|
||||
blob = bytes(content)
|
||||
else:
|
||||
blob = str(content).encode("utf-8")
|
||||
out.append((path, blob))
|
||||
return out
|
||||
|
||||
|
||||
def _ext_err(exc: Exception) -> str:
|
||||
if isinstance(exc, ext_mod.ExtensionNotFoundError):
|
||||
return "NOT_FOUND"
|
||||
if isinstance(exc, ext_mod.ExtensionStatusError):
|
||||
return "EXT_STATUS"
|
||||
if isinstance(exc, ext_mod.ExtensionBuildError):
|
||||
return "EXT_BUILD"
|
||||
if isinstance(exc, ext_mod.ExtensionRunError):
|
||||
return "EXT_RUN"
|
||||
return "EXT_ERROR"
|
||||
|
||||
|
||||
async def _create_extension(display_name: str, command: str, runtime_image: str,
|
||||
code_files: Any, data_files: Any) -> dict[str, Any]:
|
||||
code = _file_items(code_files)
|
||||
if not code:
|
||||
return {"ok": False, "code": "VALIDATION", "message": "扩展代码包不能为空"}
|
||||
try:
|
||||
ext = ext_mod.get_store().create_extension(
|
||||
display_name=display_name, command=command, runtime_image=runtime_image or "",
|
||||
code_files=code, data_files=_file_items(data_files),
|
||||
)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _extension_status(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = ext_mod.get_store().load_extension(ext_id)
|
||||
except ext_mod.ExtensionNotFoundError:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "扩展不存在"}
|
||||
return {"ok": True, "data": _ext_view(ext) if ext else None}
|
||||
|
||||
|
||||
async def _build_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = await ext_mod.get_store().build_extension(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _trial_run_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = await ext_mod.get_store().trial_run(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _validate_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = ext_mod.get_store().validate(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _confirm_extension(ext_id: str, nl_triggers: Any, parameters: Any,
|
||||
runtime_image: str) -> dict[str, Any]:
|
||||
triggers = [str(x).strip() for x in nl_triggers if str(x).strip()] if isinstance(nl_triggers, list) else None
|
||||
params = parameters if isinstance(parameters, list) else None
|
||||
try:
|
||||
ext = ext_mod.get_store().confirm(
|
||||
ext_id, nl_triggers=triggers, parameters=params,
|
||||
runtime_image=runtime_image or None,
|
||||
)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _publish_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = ext_mod.get_store().publish(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
async def _run_extension(ext_id: str, params: Any, files: Any) -> dict[str, Any]:
|
||||
try:
|
||||
result = await ext_mod.get_store().run(
|
||||
ext_id,
|
||||
params=params if isinstance(params, dict) else None,
|
||||
files=[str(f) for f in files] if isinstance(files, list) else None,
|
||||
)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": result}
|
||||
|
||||
|
||||
async def _repair_extension(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
ext = await ext_mod.get_store().repair(ext_id)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return {"ok": False, "code": _ext_err(exc), "message": str(exc)}
|
||||
return {"ok": True, "data": _ext_view(ext)}
|
||||
|
||||
|
||||
# ---- 文件登记(复刻 biojobs._register_path 核心,无 FastAPI 依赖)----
|
||||
|
||||
def _blocked_reason(path: Path, settings: Any) -> str | None:
|
||||
s = str(path)
|
||||
for prefix in settings.sensitive_path_prefixes:
|
||||
if s == prefix or s.startswith(prefix + "/"):
|
||||
return f"敏感路径前缀 {prefix}"
|
||||
for pat in settings.sensitive_filename_patterns:
|
||||
if re.match(pat, path.name):
|
||||
return f"敏感文件名 {path.name}"
|
||||
return None
|
||||
|
||||
|
||||
async def _register_job_files(job_id: str, paths: list[str]) -> dict[str, Any]:
|
||||
"""登记服务器/局域网路径(单文件或目录展开)到任务 input。"""
|
||||
ctx = _ctx()
|
||||
settings = get_settings()
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
|
||||
input_dir = record.input_dir
|
||||
input_dir.mkdir(parents=True, exist_ok=True)
|
||||
items: list[dict[str, Any]] = []
|
||||
registered: list[str] = []
|
||||
|
||||
for raw in paths:
|
||||
p = Path(raw).expanduser().resolve()
|
||||
if not p.exists():
|
||||
return {"ok": False, "code": "PATH_NOT_EXIST", "message": f"路径不存在: {raw}"}
|
||||
blocked = _blocked_reason(p, settings)
|
||||
if blocked:
|
||||
return {"ok": False, "code": "PATH_NOT_ALLOWED",
|
||||
"message": f"路径不允许: {raw}({blocked})"}
|
||||
files: list[Path] = []
|
||||
if p.is_file():
|
||||
files = [p]
|
||||
else:
|
||||
ext_set = settings.upload_ext_set
|
||||
for f in sorted(p.rglob("*")):
|
||||
if f.is_file() and (not ext_set or f.suffix.lower() in ext_set) and not _blocked_reason(f, settings):
|
||||
files.append(f)
|
||||
if not files:
|
||||
return {"ok": False, "code": "VALIDATION", "message": f"目录内无可登记文件: {raw}"}
|
||||
for f in files:
|
||||
link = input_dir / f.name
|
||||
try:
|
||||
if not link.exists():
|
||||
link.symlink_to(f)
|
||||
except OSError as exc: # noqa: BLE001
|
||||
logger.warning("mcp register symlink 失败: %s -> %s (%s)", f, link, exc)
|
||||
items.append({"file_id": f"{record.id}:{f.name}", "name": f.name,
|
||||
"size": f.stat().st_size, "path": str(f), "source": "server_path"})
|
||||
registered.append(str(f))
|
||||
|
||||
# rnaseq 系:samples.yaml 生成(描述文件优先 + 实况分析;规则不可归纳走 LLM 兜底)
|
||||
try:
|
||||
source_roots = [str(Path(x).parent) for x in paths if Path(x).exists()]
|
||||
extra_mounts = ensure_samples_yaml(input_dir, source_roots=[Path(r) for r in source_roots])
|
||||
if not extra_mounts:
|
||||
try:
|
||||
await llm_sample_fallback(input_dir, [Path(r) for r in source_roots])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("samples.yaml LLM 兜底失败: %s", exc)
|
||||
if extra_mounts:
|
||||
record.registered_server_paths = list(
|
||||
dict.fromkeys((getattr(record, "registered_server_paths", None) or [])
|
||||
+ [str(Path(m)) for m in extra_mounts]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("samples.yaml 分析失败: %s", exc)
|
||||
|
||||
if registered:
|
||||
import json
|
||||
record.registered_server_paths = list(
|
||||
dict.fromkeys((getattr(record, "registered_server_paths", None) or []) + registered)
|
||||
)
|
||||
(record.job_dir / "server_registered.json").write_text(
|
||||
json.dumps({"registered_server_paths": record.registered_server_paths},
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# 自动续跑(await_inputs 任务重推;非等待态 no-op)
|
||||
await ctx.runner.retry_after_files(record)
|
||||
return {"ok": True, "data": {"items": items, "total": len(items)}}
|
||||
|
||||
|
||||
async def _ask_question(question: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
hits: list[dict[str, Any]] = []
|
||||
if ctx.flow_store is not None:
|
||||
try:
|
||||
hits = ctx.flow_store.search(question, top_k=8).get("items") or []
|
||||
except Exception: # noqa: BLE001
|
||||
hits = []
|
||||
citations = []
|
||||
for h in hits:
|
||||
citations.append({
|
||||
"source": h.get("source") or h.get("doc_kind") or "",
|
||||
"title_path": h.get("title_path") or "",
|
||||
"path": h.get("path") or "",
|
||||
"text": str(h.get("text") or "")[:1200],
|
||||
})
|
||||
return {"ok": True, "citations": citations, "degraded": not citations}
|
||||
|
||||
|
||||
async def _list_archives(page: int, page_size: int) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
items, total = await store.list_archives(ctx.db, page=page, page_size=page_size)
|
||||
return {"ok": True, "items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
async def _reuse_archive(name: str) -> dict[str, Any]:
|
||||
ctx = _ctx()
|
||||
arch = await store.find_archive_by_name(ctx.db, (name or "").strip())
|
||||
if arch is None:
|
||||
return {"ok": False, "code": "ARCHIVE_NOT_FOUND", "message": f"未找到匹配存档: {name}"}
|
||||
return {"ok": True, "data": {
|
||||
"id": arch["id"], "name": arch["name"], "stage_chain": arch["stage_chain"],
|
||||
"params": arch["params"], "description": arch["description"],
|
||||
}}
|
||||
|
||||
|
||||
async def _fetch_source_file(job_id: str | None, source: str,
|
||||
credentials: dict[str, Any] | None) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
dest_dir: Path
|
||||
if job_id:
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
dest_dir = record.input_dir
|
||||
else:
|
||||
dest_dir = Path(settings.jobs_dir_abs) / "_incoming" / uuid.uuid4().hex[:8]
|
||||
try:
|
||||
result = await fetch_source(source, dest_dir, credentials=credentials)
|
||||
except FetchError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message}
|
||||
result["job_id"] = job_id
|
||||
return result
|
||||
|
||||
|
||||
async def _inspect_file(path: str) -> dict[str, Any]:
|
||||
return _ft_inspect_file(path)
|
||||
|
||||
|
||||
async def _transform_file(src_path: str, target_format: str,
|
||||
column_map: dict[str, str] | None,
|
||||
dest_path: str | None) -> dict[str, Any]:
|
||||
from .services.bioflow.file_transform import TransformError
|
||||
from .services.bioflow.llm import complete_json
|
||||
|
||||
async def llm_adapter(user: str, system: str) -> Any:
|
||||
return await complete_json(system, user, temperature=0.2, max_tokens=2000)
|
||||
|
||||
try:
|
||||
return await _ft_transform_file(
|
||||
src_path, target_format, column_map=column_map, dest_path=dest_path, llm=llm_adapter,
|
||||
)
|
||||
except TransformError as exc:
|
||||
return {"ok": False, "code": exc.code, "message": exc.message, "details": exc.details}
|
||||
|
||||
|
||||
async def _generate_samples_yaml(job_id: str, sample_description: str) -> dict[str, Any]:
|
||||
import yaml
|
||||
|
||||
from .services.bioflow.llm import complete_json
|
||||
|
||||
record = await load_record(job_id)
|
||||
if record is None:
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": "任务不存在"}
|
||||
system = (
|
||||
"你是 RNA-seq 样本配置助手。根据用户的自然语言描述,输出 samples.yaml 结构 JSON:"
|
||||
'{"samples": {"样本名": {"r1": "fastq路径", "r2": "fastq路径", "condition": "...", ...}}}。'
|
||||
"只输出 JSON。"
|
||||
)
|
||||
try:
|
||||
raw = await complete_json(system, sample_description, temperature=0.2, max_tokens=2000)
|
||||
samples = raw.get("samples") if isinstance(raw, dict) else None
|
||||
if not isinstance(samples, dict) or not samples:
|
||||
return {"ok": False, "code": "EMPTY_SAMPLES", "message": "未能从描述解析出样本"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "code": "SAMPLES_GEN_FAILED", "message": f"样本配置生成失败: {exc}"}
|
||||
record.input_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = record.input_dir / "samples.yaml"
|
||||
out.write_text(yaml.safe_dump({"samples": samples}, allow_unicode=True), encoding="utf-8")
|
||||
v = validate_format(out, "samples.yaml")
|
||||
if not v.get("ok"):
|
||||
out.unlink(missing_ok=True)
|
||||
return {"ok": False, "code": "VALIDATION_FAILED", "message": f"样本配置校验失败: {v.get('reason')}"}
|
||||
return {"ok": True, "data": {"path": str(out), "samples": samples, "validated": v}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 能力单元工具(命名 async,带类型提示 + 中文描述)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def plan_gene_family(message: str) -> dict[str, Any]:
|
||||
"""根据用户自然语言需求规划基因家族分析候选路径(仅基因家族链阶段)。"""
|
||||
return await _plan(message, GENE_FAMILY_STAGES)
|
||||
|
||||
|
||||
async def create_gene_family_job(session_id: str, stages: list[str], title: str = "",
|
||||
params: dict[str, Any] | None = None,
|
||||
auto_continue: bool = False,
|
||||
archive_id: str | None = None) -> dict[str, Any]:
|
||||
"""按确认的阶段链创建基因家族任务(stages 只能是基因家族链阶段,跨链拒绝)。"""
|
||||
return await _create_job(session_id, title, stages, params, auto_continue, archive_id, GENE_FAMILY_STAGES)
|
||||
|
||||
|
||||
async def ask_gene_family_question(question: str) -> dict[str, Any]:
|
||||
"""回答基因家族流程/参数/生物学意义类问题(流程知识库检索,带引用)。"""
|
||||
return await _ask_question(question)
|
||||
|
||||
|
||||
async def plan_rnaseq(message: str) -> dict[str, Any]:
|
||||
"""根据用户自然语言需求规划转录组分析候选路径(仅转录组链阶段)。"""
|
||||
return await _plan(message, RNASEQ_STAGES)
|
||||
|
||||
|
||||
async def create_rnaseq_job(session_id: str, stages: list[str], title: str = "",
|
||||
params: dict[str, Any] | None = None,
|
||||
auto_continue: bool = False,
|
||||
archive_id: str | None = None) -> dict[str, Any]:
|
||||
"""按确认的阶段链创建转录组任务(stages 只能是转录组链阶段,跨链拒绝)。"""
|
||||
return await _create_job(session_id, title, stages, params, auto_continue, archive_id, RNASEQ_STAGES)
|
||||
|
||||
|
||||
async def generate_samples_yaml(job_id: str, sample_description: str) -> dict[str, Any]:
|
||||
"""把用户的自然语言样本描述(样本名/对照处理组/重复/单双端等)解析为 samples.yaml 写入任务 input。"""
|
||||
return await _generate_samples_yaml(job_id, sample_description)
|
||||
|
||||
|
||||
async def ask_rnaseq_question(question: str) -> dict[str, Any]:
|
||||
"""回答转录组流程/参数/实验设计类问题(流程知识库检索,带引用)。"""
|
||||
return await _ask_question(question)
|
||||
|
||||
|
||||
async def job_status(job_id: str) -> dict[str, Any]:
|
||||
"""查询生信任务状态:任务/各阶段状态 + 下一步待办动作(pending_actions)。卡住或需确认时用本工具查真相。"""
|
||||
return await _job_status(job_id)
|
||||
|
||||
|
||||
async def confirm_stage(job_id: str, stage: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""确认某阶段参数并执行。仅 waiting_confirm 状态可确认;参数名须在白名单内(未知参数被拒)。人工确认边界阶段(subfamily_confirm/rnaseq_de)不可代用户确认。"""
|
||||
return await _confirm_stage(job_id, stage, params)
|
||||
|
||||
|
||||
async def register_job_files(job_id: str, paths: list[str]) -> dict[str, Any]:
|
||||
"""把服务器/局域网路径(单文件或目录)登记为任务输入文件。目录按扩展名白名单展开。"""
|
||||
return await _register_job_files(job_id, paths)
|
||||
|
||||
|
||||
async def rerun_stage(job_id: str, stage: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""重跑失败阶段(带参数则先等确认)。仅 failed 状态可重跑。"""
|
||||
return await _rerun_stage(job_id, stage, params)
|
||||
|
||||
|
||||
async def skip_stage(job_id: str, stage: str) -> dict[str, Any]:
|
||||
"""跳过失败阶段并推进后续。仅 failed 状态可跳过。"""
|
||||
return await _skip_stage(job_id, stage)
|
||||
|
||||
|
||||
async def pause_job(job_id: str) -> dict[str, Any]:
|
||||
"""暂停运行中的任务(running 阶段会立即 kill 容器)。"""
|
||||
return await _pause_job(job_id)
|
||||
|
||||
|
||||
async def resume_job(job_id: str) -> dict[str, Any]:
|
||||
"""恢复已暂停的任务(从下一阶段继续,确认点不跳过)。"""
|
||||
return await _resume_job(job_id)
|
||||
|
||||
|
||||
async def list_archives(page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
"""列出历史存档任务(供复用参考)。"""
|
||||
return await _list_archives(page, page_size)
|
||||
|
||||
|
||||
async def reuse_archive(name: str) -> dict[str, Any]:
|
||||
"""按名称匹配存档,返回其阶段链+参数(供复用发起)。"""
|
||||
return await _reuse_archive(name)
|
||||
|
||||
|
||||
async def archive_job(job_id: str, name: str, description: str = "",
|
||||
include_intermediate: bool = False) -> dict[str, Any]:
|
||||
"""把已完成任务存档(打包结果/日志/事件 + 落档登记);重名拒绝(ARCHIVE_EXISTS)。"""
|
||||
return await _archive_job(job_id, name, description, include_intermediate)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 扩展能力单元工具(第三个能力单元 bioflow-extension)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def create_extension(display_name: str, command: str, runtime_image: str = "",
|
||||
code_files: list[dict[str, Any]] | None = None,
|
||||
data_files: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
"""创建扩展草稿(draft):上传代码文件清单(每项 {path, content 文本}) + 运行命令 + 显示名。"""
|
||||
return await _create_extension(display_name, command, runtime_image, code_files, data_files)
|
||||
|
||||
|
||||
async def extension_status(ext_id: str) -> dict[str, Any]:
|
||||
"""查询扩展详情与状态(draft/build/trial/validated/confirming/published)。"""
|
||||
return await _extension_status(ext_id)
|
||||
|
||||
|
||||
async def build_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""AI 转换扩展(原始命令→command_template+参数+dependencies);LLM 失败自动回退规则版。"""
|
||||
return await _build_extension(ext_id)
|
||||
|
||||
|
||||
async def trial_run_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""试运行扩展(docker 执行;未接入执行器时返回 EXT_RUN 503 语义)。"""
|
||||
return await _trial_run_extension(ext_id)
|
||||
|
||||
|
||||
async def validate_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""校验扩展(金标/试运行校验)。仅 built/trial_passed 可校验。"""
|
||||
return await _validate_extension(ext_id)
|
||||
|
||||
|
||||
async def confirm_extension(ext_id: str, nl_triggers: list[str] | None = None,
|
||||
parameters: list[dict[str, Any]] | None = None,
|
||||
runtime_image: str = "") -> dict[str, Any]:
|
||||
"""确认扩展参数/触发词(用户可编辑 nl_triggers);仅 validated/trial_passed 可确认。"""
|
||||
return await _confirm_extension(ext_id, nl_triggers, parameters, runtime_image)
|
||||
|
||||
|
||||
async def publish_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""发布扩展(仅 published 状态可被自然语言触发运行)。"""
|
||||
return await _publish_extension(ext_id)
|
||||
|
||||
|
||||
async def run_extension(ext_id: str, params: dict[str, Any] | None = None,
|
||||
files: list[str] | None = None) -> dict[str, Any]:
|
||||
"""正式运行已发布扩展(同步返回 run_result)。"""
|
||||
return await _run_extension(ext_id, params, files)
|
||||
|
||||
|
||||
async def repair_extension(ext_id: str) -> dict[str, Any]:
|
||||
"""AI 修复扩展(试运行失败上下文→LLM 修复 command_template/依赖;LLM 失败回退规则版)。"""
|
||||
return await _repair_extension(ext_id)
|
||||
|
||||
|
||||
async def fetch_source_file(source: str, job_id: str | None = None,
|
||||
credentials: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""从 web(http/https 流式)、OSS(oss:// 或预签名 URL)或局域网路径拉取源文件到任务 input;下载前检查磁盘余量并记录 provenance。"""
|
||||
return await _fetch_source_file(job_id, source, credentials)
|
||||
|
||||
|
||||
async def inspect_file(path: str) -> dict[str, Any]:
|
||||
"""探测文件格式(扩展名仅参考):返回预览/列结构/大小/格式猜测/乱码检测,供判断是否需要预处理。"""
|
||||
return await _inspect_file(path)
|
||||
|
||||
|
||||
async def transform_file(src_path: str, target_format: str,
|
||||
column_map: dict[str, str] | None = None,
|
||||
dest_path: str | None = None) -> dict[str, Any]:
|
||||
"""把非标准文件(xlsx/csv/tsv/txt)转换为 fasta/gff3/tsv/samples.yaml;LLM 定列映射规则+脚本执行,结果过格式验证器。"""
|
||||
return await _transform_file(src_path, target_format, column_map, dest_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 构建三个 MCPServer(基因家族 / 转录组 / 扩展)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_mcp_servers() -> tuple[Any, Any, Any]:
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
gf = MCPServer(
|
||||
"bioflow-gene-family",
|
||||
title="PlantAI 基因家族分析",
|
||||
description="基因家族分析能力单元:路径规划/建任务/确认参数/查状态/文件获取与预处理。",
|
||||
)
|
||||
rn = MCPServer(
|
||||
"bioflow-rnaseq",
|
||||
title="PlantAI 转录组分析",
|
||||
description="转录组分析能力单元:路径规划/样本配置生成/建任务/确认参数/查状态/文件获取与预处理。",
|
||||
)
|
||||
ext = MCPServer(
|
||||
"bioflow-extension",
|
||||
title="PlantAI 用户自定义扩展分析",
|
||||
description="用户自定义扩展分析能力单元:创建/转换/试运行/校验/确认/发布/运行。",
|
||||
)
|
||||
|
||||
gf.tool(name="plan_gene_family", description="根据用户自然语言需求规划基因家族分析候选路径(仅基因家族链阶段)。返回 2-3 条候选路径供用户确认。")(plan_gene_family)
|
||||
gf.tool(name="create_gene_family_job", description="按确认的阶段链创建基因家族任务。stages 只能是基因家族链阶段(跨链拒绝)。")(create_gene_family_job)
|
||||
gf.tool(name="ask_gene_family_question", description="回答基因家族流程/参数/生物学意义类问题(流程知识库检索,带引用)。")(ask_gene_family_question)
|
||||
|
||||
rn.tool(name="plan_rnaseq", description="根据用户自然语言需求规划转录组分析候选路径(仅转录组链阶段)。返回候选路径供用户确认。")(plan_rnaseq)
|
||||
rn.tool(name="create_rnaseq_job", description="按确认的阶段链创建转录组任务。stages 只能是转录组链阶段(跨链拒绝)。")(create_rnaseq_job)
|
||||
rn.tool(name="generate_samples_yaml", description="把用户的自然语言样本描述解析为 samples.yaml 写入任务 input。")(generate_samples_yaml)
|
||||
rn.tool(name="ask_rnaseq_question", description="回答转录组流程/参数/实验设计类问题(流程知识库检索,带引用)。")(ask_rnaseq_question)
|
||||
|
||||
ext.tool(name="create_extension", description="创建扩展草稿(draft):上传代码文件清单(每项 {path, content 文本}) + 运行命令 + 显示名。")(create_extension)
|
||||
ext.tool(name="extension_status", description="查询扩展详情与状态(draft/build/trial/validated/confirming/published)。")(extension_status)
|
||||
ext.tool(name="build_extension", description="AI 转换扩展(原始命令→command_template+参数+依赖);LLM 失败自动回退规则版。")(build_extension)
|
||||
ext.tool(name="trial_run_extension", description="试运行扩展(docker 执行;未接入执行器时返回 EXT_RUN)。")(trial_run_extension)
|
||||
ext.tool(name="validate_extension", description="校验扩展(金标/试运行校验)。仅 built/trial_passed 可校验。")(validate_extension)
|
||||
ext.tool(name="confirm_extension", description="确认扩展参数/触发词(用户可编辑 nl_triggers);仅 validated/trial_passed 可确认。")(confirm_extension)
|
||||
ext.tool(name="publish_extension", description="发布扩展(仅 published 状态可被自然语言触发运行)。")(publish_extension)
|
||||
ext.tool(name="run_extension", description="正式运行已发布扩展(同步返回 run_result)。")(run_extension)
|
||||
ext.tool(name="repair_extension", description="AI 修复扩展(试运行失败上下文→LLM 修复;LLM 失败回退规则版)。")(repair_extension)
|
||||
|
||||
shared = [
|
||||
job_status, confirm_stage, register_job_files, rerun_stage, skip_stage,
|
||||
pause_job, resume_job, list_archives, reuse_archive, archive_job,
|
||||
fetch_source_file, inspect_file, transform_file,
|
||||
]
|
||||
for mcp in (gf, rn):
|
||||
for fn in shared:
|
||||
mcp.tool()(fn)
|
||||
return gf, rn, ext
|
||||
|
||||
|
||||
gene_family_mcp, rnaseq_mcp, extension_mcp = build_mcp_servers()
|
||||
@@ -0,0 +1,438 @@
|
||||
"""app/services/bioflow/file_transform.py —— 非标准文件预处理(探测 + 转换 + 校验)。
|
||||
|
||||
FR-BIO-014 文件医生闭环:
|
||||
- inspect_file:内容嗅探(扩展名仅参考),返回结构化摘要(预览行/列结构/大小/
|
||||
格式猜测/乱码检测/与目标阶段期望格式的差异)。
|
||||
- transform_file:LLM 抽取列映射/清洗规则 + 规则脚本执行,把 xlsx/csv/tsv/txt 转换
|
||||
为 fasta/gff3/tsv/samples.yaml;转换结果过格式验证器硬校验,失败返回原因供追问
|
||||
循环(≤3 次由上层 QA 控制)。
|
||||
|
||||
服务层纪律:纯 dataclass/dict/stdlib + yaml/openpyxl,无 pydantic。
|
||||
LLM 可注入(默认走 .llm.complete_json),测试注入 fake。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PREVIEW_LINES = 10
|
||||
PREVIEW_BYTES = 4000
|
||||
|
||||
# 支持的目标格式
|
||||
TARGET_FORMATS = ("fasta", "gff3", "tsv", "samples.yaml")
|
||||
|
||||
# 乱码检测:非 ASCII 可打印且非 CJK 的异常字节比例阈值
|
||||
GARBLED_RATIO = 0.30
|
||||
|
||||
|
||||
class TransformError(Exception):
|
||||
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 探测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _guess_format(name: str, head: bytes) -> str:
|
||||
lower = name.lower()
|
||||
if lower.endswith((".fa", ".fasta", ".faa", ".fna", ".pep", ".cds")):
|
||||
return "fasta"
|
||||
if lower.endswith((".gff", ".gff3")):
|
||||
return "gff3"
|
||||
if lower.endswith((".gtf",)):
|
||||
return "gtf"
|
||||
if lower.endswith((".vcf", ".vcf.gz")):
|
||||
return "vcf"
|
||||
if lower.endswith((".tsv",)):
|
||||
return "tsv"
|
||||
if lower.endswith((".csv",)):
|
||||
return "csv"
|
||||
if lower.endswith((".xlsx", ".xls")):
|
||||
return "xlsx"
|
||||
if lower.endswith((".yaml", ".yml")):
|
||||
return "yaml"
|
||||
# 内容嗅探兜底
|
||||
text = head.decode("utf-8", errors="replace")
|
||||
if text.lstrip().startswith(">"):
|
||||
return "fasta"
|
||||
if "\t" in text and len(text.splitlines()[0].split("\t")) >= 3:
|
||||
return "tsv"
|
||||
return "txt"
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
raw = path.read_bytes()
|
||||
for enc in ("utf-8", "gb18030", "latin-1"):
|
||||
try:
|
||||
return raw.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _garbled_ratio(text: str) -> float:
|
||||
"""异常字符比例:控制字符 + 替换符 + 私有区(不含常规空白)。"""
|
||||
if not text:
|
||||
return 0.0
|
||||
bad = 0
|
||||
for ch in text:
|
||||
o = ord(ch)
|
||||
if o == 0xFFFD or (o < 32 and ch not in "\t\n\r") or (0xE000 <= o <= 0xF8FF):
|
||||
bad += 1
|
||||
return bad / len(text)
|
||||
|
||||
|
||||
def _preview(text: str) -> list[str]:
|
||||
return text.splitlines()[:PREVIEW_LINES]
|
||||
|
||||
|
||||
def _columns(path: Path, fmt: str) -> list[str]:
|
||||
"""表格列名提取(tsv/csv/xlsx)。"""
|
||||
try:
|
||||
if fmt == "xlsx":
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
ws = wb.active
|
||||
row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
||||
return [str(c or "").strip() for c in (row or [])]
|
||||
finally:
|
||||
wb.close()
|
||||
if fmt in ("tsv", "csv"):
|
||||
text = _read_text(path)
|
||||
reader = csv.reader(io.StringIO(text), delimiter="\t" if fmt == "tsv" else ",")
|
||||
first = next(reader, None)
|
||||
return [str(c or "").strip() for c in (first or [])]
|
||||
except Exception as exc: # noqa: BLE001 - 列提取失败降级空
|
||||
logger.warning("file_transform: 列提取失败 %s: %s", path, exc)
|
||||
return []
|
||||
|
||||
|
||||
def inspect_file(path: str | Path, *, preview_lines: int = PREVIEW_LINES) -> dict[str, Any]:
|
||||
"""内容嗅探摘要(扩展名仅参考;失败返回 ok=False + 原因,不抛)。"""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "code": "NOT_FOUND", "message": f"文件不存在: {path}"}
|
||||
try:
|
||||
size = p.stat().st_size
|
||||
head = p.read_bytes()[:PREVIEW_BYTES]
|
||||
fmt = _guess_format(p.name, head)
|
||||
text = _read_text(p) if size <= 4 * 1024 * 1024 else _read_text_preview(p)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(p),
|
||||
"name": p.name,
|
||||
"ext": p.suffix.lower() or "",
|
||||
"size": size,
|
||||
"format": fmt,
|
||||
"preview": _preview(text)[:preview_lines],
|
||||
"columns": _columns(p, fmt),
|
||||
"garbled": _garbled_ratio(text),
|
||||
"line_count": text.count("\n") + 1 if text else 0,
|
||||
}
|
||||
except OSError as exc:
|
||||
return {"ok": False, "code": "READ_ERROR", "message": f"文件读取失败: {exc}"}
|
||||
|
||||
|
||||
def _read_text_preview(path: Path) -> str:
|
||||
"""大文件只读头部做预览/乱码检测,避免整读内存。"""
|
||||
with path.open("rb") as fh:
|
||||
head = fh.read(PREVIEW_BYTES)
|
||||
for enc in ("utf-8", "gb18030", "latin-1"):
|
||||
try:
|
||||
return head.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return head.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 校验器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def validate_fasta(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
text = _read_text(p)
|
||||
records = re.findall(r"^>.*$", text, flags=re.M)
|
||||
if not records:
|
||||
return {"ok": False, "reason": "无 FASTA 记录(缺 '>' 头行)"}
|
||||
# 序列非空:头行之间至少存在非空白序列行
|
||||
body = re.sub(r"^>.*$", "", text, flags=re.M)
|
||||
if not body.strip():
|
||||
return {"ok": False, "reason": "FASTA 记录缺少序列"}
|
||||
return {"ok": True, "records": len(records)}
|
||||
|
||||
|
||||
def validate_gff3(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
bad = 0
|
||||
total = 0
|
||||
for line in _read_text(p).splitlines():
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
total += 1
|
||||
cols = line.split("\t")
|
||||
if len(cols) != 9:
|
||||
bad += 1
|
||||
continue
|
||||
try:
|
||||
int(cols[3])
|
||||
int(cols[4])
|
||||
except ValueError:
|
||||
bad += 1
|
||||
continue
|
||||
if cols[6] not in ("+", "-", "."):
|
||||
bad += 1
|
||||
if total == 0:
|
||||
return {"ok": False, "reason": "无 GFF3 数据行"}
|
||||
if bad:
|
||||
return {"ok": False, "reason": f"{bad}/{total} 行列数或字段非法"}
|
||||
return {"ok": True, "records": total}
|
||||
|
||||
|
||||
def validate_tsv(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
widths: set[int] = set()
|
||||
for line in _read_text(p).splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
widths.add(len(line.split("\t")))
|
||||
if len(widths) > 1:
|
||||
return {"ok": False, "reason": f"列数不一致: {sorted(widths)}"}
|
||||
if not widths:
|
||||
return {"ok": False, "reason": "空文件"}
|
||||
return {"ok": True, "columns": widths.pop()}
|
||||
|
||||
|
||||
def validate_samples_yaml(path: str | Path) -> dict[str, Any]:
|
||||
import yaml
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "reason": "文件不存在"}
|
||||
try:
|
||||
raw = yaml.safe_load(_read_text(p))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "reason": f"YAML 解析失败: {exc}"}
|
||||
samples = (raw or {}).get("samples") if isinstance(raw, dict) else None
|
||||
if not isinstance(samples, dict) or not samples:
|
||||
return {"ok": False, "reason": "缺 samples 映射或为空"}
|
||||
for name, entry in samples.items():
|
||||
if not isinstance(entry, dict) or not (entry.get("r1") or entry.get("r2")):
|
||||
return {"ok": False, "reason": f"样本 {name} 缺 r1/r2"}
|
||||
return {"ok": True, "samples": len(samples)}
|
||||
|
||||
|
||||
_VALIDATORS = {
|
||||
"fasta": validate_fasta,
|
||||
"gff3": validate_gff3,
|
||||
"tsv": validate_tsv,
|
||||
"samples.yaml": validate_samples_yaml,
|
||||
}
|
||||
|
||||
|
||||
def validate_format(path: str | Path, target_format: str) -> dict[str, Any]:
|
||||
fn = _VALIDATORS.get(target_format)
|
||||
if fn is None:
|
||||
return {"ok": False, "reason": f"不支持的目标格式: {target_format}"}
|
||||
return fn(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 转换
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LLMFn = Callable[[str, str], Awaitable[Any]]
|
||||
|
||||
|
||||
def _read_table(path: Path) -> tuple[list[str], list[dict[str, str]]]:
|
||||
"""读表格(tsv/csv/xlsx)→ (表头, 行 dict 列表)。"""
|
||||
fmt = _guess_format(path.name, path.read_bytes()[:64])
|
||||
if fmt == "xlsx":
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
ws = wb.active
|
||||
rows = [list(r) for r in ws.iter_rows(values_only=True)]
|
||||
finally:
|
||||
wb.close()
|
||||
if not rows:
|
||||
return [], []
|
||||
headers = [str(c or "").strip() for c in rows[0]]
|
||||
return headers, [dict(zip(headers, ["" if v is None else str(v).strip() for v in r])) for r in rows[1:]]
|
||||
text = _read_text(path)
|
||||
delimiter = "\t" if fmt == "tsv" else ","
|
||||
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
return [], []
|
||||
headers = [str(c or "").strip() for c in rows[0]]
|
||||
return headers, [dict(zip(headers, ["" if v is None else str(v).strip() for v in r])) for r in rows[1:]]
|
||||
|
||||
|
||||
def _default_column_map(headers: list[str], target: str) -> dict[str, str]:
|
||||
"""按表头名启发式推断列映射(名称/序列/基因名等)。"""
|
||||
m: dict[str, str] = {}
|
||||
for h in headers:
|
||||
lh = h.lower()
|
||||
if target == "fasta":
|
||||
if not m.get("seq") and any(k in lh for k in ("seq", "sequence", "序列", "蛋白", "pep", "cds")):
|
||||
m["seq"] = h
|
||||
elif not m.get("name") and any(k in lh for k in ("name", "id", "gene", "名称", "基因", "基因名", "accession")):
|
||||
m["name"] = h
|
||||
elif target == "samples.yaml":
|
||||
if "r1" in lh or "read1" in lh or "forward" in lh:
|
||||
m.setdefault("r1", h)
|
||||
elif "r2" in lh or "read2" in lh or "reverse" in lh:
|
||||
m.setdefault("r2", h)
|
||||
elif "run" in lh or "sample" in lh or "样本" in lh or "name" in lh:
|
||||
m.setdefault("name", h)
|
||||
return m
|
||||
|
||||
|
||||
async def _llm_column_map(headers: list[str], target: str, llm: LLMFn, sample_rows: list[dict[str, str]]) -> dict[str, str]:
|
||||
"""LLM 抽取列映射(启发式不充分时)。返回 {目标字段: 列名}。"""
|
||||
import json as _json
|
||||
prompt = (
|
||||
f"你是生信数据整理助手。下表头 {_json.dumps(headers, ensure_ascii=False)} "
|
||||
f"需转换为 {target} 格式。样本行:{_json.dumps(sample_rows[:3], ensure_ascii=False)[:2000]}。"
|
||||
"请输出列映射 JSON,键为目标字段名,值为表头列名。"
|
||||
)
|
||||
try:
|
||||
result = await llm(prompt, "只输出 JSON 对象,如 {\"name\":\"基因名\",\"seq\":\"序列\"}。")
|
||||
if isinstance(result, dict):
|
||||
return {str(k): str(v) for k, v in result.items() if str(v) in headers}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("file_transform: LLM 列映射失败 %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_fasta(rows: list[dict[str, str]], name_col: str, seq_col: str, dest: Path) -> int:
|
||||
n = 0
|
||||
with dest.open("w", encoding="utf-8") as fh:
|
||||
for r in rows:
|
||||
name = r.get(name_col) or f"seq{n + 1}"
|
||||
seq = (r.get(seq_col) or "").replace("\n", "").replace("\r", "")
|
||||
if not seq:
|
||||
continue
|
||||
fh.write(f">{name}\n{seq}\n")
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _write_tsv(rows: list[dict[str, str]], cols: list[str], dest: Path) -> int:
|
||||
n = 0
|
||||
with dest.open("w", encoding="utf-8", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t")
|
||||
w.writerow(cols)
|
||||
for r in rows:
|
||||
w.writerow([r.get(c, "") for c in cols])
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _write_samples_yaml(rows: list[dict[str, str]], name_col: str, r1_col: str, r2_col: str, dest: Path) -> int:
|
||||
import yaml
|
||||
samples: dict[str, dict[str, str]] = {}
|
||||
for r in rows:
|
||||
name = r.get(name_col) or ""
|
||||
r1 = r.get(r1_col) or ""
|
||||
r2 = r.get(r2_col) or ""
|
||||
if not name and not r1 and not r2:
|
||||
continue
|
||||
entry: dict[str, str] = {}
|
||||
if r1:
|
||||
entry["r1"] = r1
|
||||
if r2:
|
||||
entry["r2"] = r2
|
||||
# 额外列作为 meta(run/condition/group 等)
|
||||
for k, v in r.items():
|
||||
if v and k not in (name_col, r1_col, r2_col):
|
||||
entry[k] = v
|
||||
samples[name or Path(r1).stem] = entry
|
||||
dest.write_text(yaml.safe_dump({"samples": samples}, allow_unicode=True), encoding="utf-8")
|
||||
return len(samples)
|
||||
|
||||
|
||||
async def transform_file(
|
||||
src_path: str | Path,
|
||||
target_format: str,
|
||||
*,
|
||||
column_map: dict[str, str] | None = None,
|
||||
dest_path: str | Path | None = None,
|
||||
llm: LLMFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""非标准文件转换(LLM 列映射兜底 + 规则执行 + 格式校验)。
|
||||
|
||||
column_map:用户/QA 确认的 {目标字段: 源列名}(可部分),缺列经启发式/LLM 补齐。
|
||||
返回 ok=True + path/size/validated/records,或 ok=False + code/message(供追问)。
|
||||
"""
|
||||
src = Path(src_path)
|
||||
if not src.is_file():
|
||||
raise TransformError("NOT_FOUND", f"源文件不存在: {src_path}")
|
||||
if target_format not in TARGET_FORMATS:
|
||||
raise TransformError("BAD_FORMAT", f"不支持的目标格式: {target_format}")
|
||||
|
||||
dest = Path(dest_path) if dest_path else src.with_suffix("").with_name(
|
||||
f"{src.stem}.{target_format.replace('.', '_')}"
|
||||
)
|
||||
|
||||
headers, rows = _read_table(src)
|
||||
if not headers:
|
||||
raise TransformError("EMPTY_TABLE", "表格为空或无表头,无法转换")
|
||||
cm = dict(column_map or {})
|
||||
cm = {**cm, **_default_column_map(headers, target_format)}
|
||||
|
||||
if target_format == "fasta":
|
||||
if "seq" not in cm or "name" not in cm:
|
||||
if llm is not None:
|
||||
inferred = await _llm_column_map(headers, target_format, llm, rows)
|
||||
cm = {**cm, **inferred}
|
||||
if "seq" not in cm or "name" not in cm:
|
||||
raise TransformError("MISSING_COLUMNS", "无法确定序列列/名称列,请确认列映射")
|
||||
n = _write_fasta(rows, cm["name"], cm["seq"], dest)
|
||||
elif target_format == "tsv":
|
||||
cols = [cm.get("name")] if cm.get("name") else []
|
||||
cols = [c for c in (cols + [h for h in headers if h not in cols]) if c]
|
||||
n = _write_tsv(rows, cols, dest)
|
||||
elif target_format == "samples.yaml":
|
||||
if "r1" not in cm and "r2" not in cm:
|
||||
if llm is not None:
|
||||
inferred = await _llm_column_map(headers, target_format, llm, rows)
|
||||
cm = {**cm, **inferred}
|
||||
if "r1" not in cm and "r2" not in cm:
|
||||
raise TransformError("MISSING_COLUMNS", "无法确定 fastq 路径列(r1/r2),请确认列映射")
|
||||
n = _write_samples_yaml(rows, cm.get("name", ""), cm.get("r1", ""), cm.get("r2", ""), dest)
|
||||
else: # gff3:表格式 GFF 需严格 9 列映射,LLM 定规则后由上层脚本执行
|
||||
raise TransformError("UNSUPPORTED", f"{target_format} 需 9 列映射,请经 LLM 定规则后转换")
|
||||
|
||||
validated = validate_format(dest, target_format)
|
||||
if not validated.get("ok"):
|
||||
# 校验失败:删除产物,返回原因供追问
|
||||
dest.unlink(missing_ok=True)
|
||||
raise TransformError("VALIDATION_FAILED", f"转换结果校验失败: {validated.get('reason')}", validated)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(dest),
|
||||
"name": dest.name,
|
||||
"size": dest.stat().st_size,
|
||||
"format": target_format,
|
||||
"records": n,
|
||||
"validated": validated,
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"""app/services/bioflow/source_fetcher.py —— 源文件拉取(web/OSS/局域网)+ provenance。
|
||||
|
||||
三类来源(FR-BIO-013 网络源扩展):
|
||||
- web:http/https。httpx 流式下载,不设大小上限,超时 300s,重定向跟随。
|
||||
- OSS:`oss://bucket/key` 或预签名 URL。无 OSS SDK 依赖:`oss://` 经可配 endpoint
|
||||
(BIOFLOW_OSS_ENDPOINT)转 http 下载;预签名 URL 直接走 http 通道。
|
||||
- 局域网:本机已挂载/可达路径直读(不复制,登记原路径);或 `[user@]host:/abs/path`
|
||||
经 scp 拉取(密钥认证优先;密码需本机有 sshpass)。凭据仅单次内存使用,不落盘。
|
||||
|
||||
- 下载前检查磁盘剩余空间(Content-Length 已知时硬校验;否则保底阈值 512MB)。
|
||||
- 每次拉取记录 provenance(来源/哈希/时间/kind)到返回值并落 sidecar .provenance.json,
|
||||
不记明文密码。
|
||||
服务层纪律:纯 dataclass/dict/stdlib + httpx,无 pydantic。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_SCHEMES = ("http", "https")
|
||||
DEFAULT_TIMEOUT = 300.0
|
||||
MIN_FREE_FALLBACK = 512 * 1024 * 1024 # 未知大小时保底磁盘余量检查(512MB)
|
||||
CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
class FetchError(Exception):
|
||||
"""源文件拉取错误(带错误码;MCP 工具层转自然语言提示)。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for block in iter(lambda: fh.read(CHUNK), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""URL 解码后取文件名(去路径分隔符),空则占位。"""
|
||||
name = urllib.parse.unquote(name or "")
|
||||
base = Path(name).name or "download"
|
||||
return base
|
||||
|
||||
|
||||
def _name_from_url(url: str) -> str:
|
||||
return _safe_name(urllib.parse.urlparse(url).path.rsplit("/", 1)[-1])
|
||||
|
||||
|
||||
def check_disk_space(dest_dir: str | Path, needed: int) -> tuple[bool, int]:
|
||||
"""检查目标目录所在盘剩余空间是否 ≥ needed 字节。返回 (够, 剩余字节)。"""
|
||||
dest_dir = Path(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
free = shutil.disk_usage(dest_dir).free
|
||||
return (free >= needed, free)
|
||||
|
||||
|
||||
def _write_provenance(dest: Path, source: str, kind: str, sha256: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""落 provenance sidecar({dest}.provenance.json),返回 provenance dict。"""
|
||||
prov: dict[str, Any] = {
|
||||
"source": source,
|
||||
"kind": kind,
|
||||
"sha256": sha256,
|
||||
"fetched_at": _now_iso(),
|
||||
}
|
||||
if extra:
|
||||
prov.update(extra)
|
||||
sidecar = dest.parent / f"{dest.name}.provenance.json"
|
||||
try:
|
||||
sidecar.write_text(json.dumps(prov, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except OSError: # noqa: BLE001 - provenance 落盘失败不阻断拉取
|
||||
logger.warning("source_fetcher: provenance 落盘失败 %s", sidecar)
|
||||
return prov
|
||||
|
||||
|
||||
# ---- web ----
|
||||
|
||||
async def fetch_web(url: str, dest_dir: str | Path, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||
"""http/https 流式下载(不设大小上限;Content-Length 已知时下载前查磁盘余量)。"""
|
||||
scheme = urllib.parse.urlparse(url).scheme.lower()
|
||||
if scheme not in ALLOWED_SCHEMES:
|
||||
raise FetchError("BAD_SCHEME", f"仅支持 http/https 协议: {url}")
|
||||
dest_dir = Path(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = _name_from_url(url)
|
||||
dest = dest_dir / name
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
try:
|
||||
async with client.stream("GET", url, timeout=httpx.Timeout(connect=30.0, read=timeout, write=60.0, pool=10.0)) as resp:
|
||||
if resp.status_code >= 400:
|
||||
raise FetchError("HTTP_ERROR", f"下载失败 HTTP {resp.status_code}: {url}")
|
||||
total = int(resp.headers.get("content-length") or 0)
|
||||
if total:
|
||||
ok, free = check_disk_space(dest_dir, total)
|
||||
if not ok:
|
||||
raise FetchError("DISK_FULL", f"磁盘空间不足:需 {total} 字节,剩余 {free} 字节")
|
||||
written = 0
|
||||
with dest.open("wb") as fh:
|
||||
async for chunk in resp.aiter_bytes(CHUNK):
|
||||
fh.write(chunk)
|
||||
written += len(chunk)
|
||||
if total and written != total:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise FetchError("TRUNCATED", f"下载不完整:期望 {total} 字节,实收 {written} 字节")
|
||||
except FetchError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise FetchError("NETWORK_ERROR", f"网络错误: {exc}") from exc
|
||||
|
||||
size = dest.stat().st_size
|
||||
sha = _sha256(dest)
|
||||
prov = _write_provenance(dest, url, "web", sha)
|
||||
return {"ok": True, "path": str(dest), "name": name, "size": size, "kind": "web", "provenance": prov}
|
||||
|
||||
|
||||
# ---- OSS ----
|
||||
|
||||
def _resolve_oss_url(spec: str) -> str:
|
||||
"""oss://bucket/key → http 下载 URL(public-read 或预签名;无 SDK,需 endpoint 配置)。
|
||||
|
||||
环境键:BIOFLOW_OSS_ENDPOINT(如 oss-cn-hangzhou.aliyuncs.com,或内网 endpoint)。
|
||||
预签名 URL(含 aliyuncs.com 的 http/https)原样返回,走 web 通道。
|
||||
"""
|
||||
if not spec.startswith("oss://"):
|
||||
return spec # 已是 http/https 预签名 URL
|
||||
rest = spec[len("oss://"):]
|
||||
bucket, _, key = rest.partition("/")
|
||||
endpoint = (os.environ.get("BIOFLOW_OSS_ENDPOINT") or "").strip().rstrip("/")
|
||||
if not bucket or not key:
|
||||
raise FetchError("BAD_OSS", f"OSS 地址格式非法(应为 oss://bucket/key): {spec}")
|
||||
if not endpoint:
|
||||
raise FetchError("OSS_UNCONFIGURED", "未配置 BIOFLOW_OSS_ENDPOINT,无法解析 oss:// 地址;请提供预签名 URL")
|
||||
# 内网/公网 endpoint 统一拼 http(https 由 endpoint 决定;公网 OSS 建议 https)
|
||||
scheme = "https" if "aliyuncs.com" in endpoint else "http"
|
||||
return f"{scheme}://{bucket}.{endpoint}/{key}"
|
||||
|
||||
|
||||
async def fetch_oss(spec: str, dest_dir: str | Path, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||
url = _resolve_oss_url(spec)
|
||||
result = await fetch_web(url, dest_dir, timeout=timeout)
|
||||
result["kind"] = "oss"
|
||||
# 重写 provenance kind(sidecar 已由 fetch_web 写为 web;此处修正)
|
||||
if result.get("provenance"):
|
||||
result["provenance"]["kind"] = "oss"
|
||||
result["provenance"]["oss_spec"] = spec
|
||||
try:
|
||||
sidecar = Path(result["path"]).parent / f"{Path(result['path']).name}.provenance.json"
|
||||
sidecar.write_text(json.dumps(result["provenance"], ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# ---- 局域网 ----
|
||||
|
||||
def _parse_lan_remote(spec: str) -> tuple[str, str] | None:
|
||||
"""解析 `[user@]host:/abs/path` 远端形态;非远端返回 None。"""
|
||||
if ":" not in spec:
|
||||
return None
|
||||
# 排除协议(http:// / oss:// 含 ://,已在上层分流)
|
||||
if "://" in spec:
|
||||
return None
|
||||
host_part, _, path_part = spec.partition(":")
|
||||
if not path_part.startswith("/"):
|
||||
return None
|
||||
return host_part, path_part
|
||||
|
||||
|
||||
async def _scp_fetch(host_part: str, remote_path: str, dest_dir: Path, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""scp 拉取远端文件(密钥认证;密码需本机 sshpass)。"""
|
||||
creds = credentials or {}
|
||||
user = str(creds.get("user") or "").strip()
|
||||
password = str(creds.get("password") or "").strip()
|
||||
port = str(creds.get("port") or "22").strip()
|
||||
target = f"{user}@{host_part}" if user else host_part
|
||||
name = _safe_name(remote_path.rsplit("/", 1)[-1])
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd: list[str] = []
|
||||
if password:
|
||||
if shutil.which("sshpass") is None:
|
||||
raise FetchError("SSHPASS_MISSING", "本机未安装 sshpass,无法使用密码拉取;请改用密钥认证或提供已挂载路径")
|
||||
cmd += ["sshpass", "-p", password]
|
||||
cmd += ["scp", "-P", port, "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=30",
|
||||
f"{target}:{remote_path}", str(dest_dir / name)]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
_out, err = await asyncio.wait_for(proc.communicate(), timeout=DEFAULT_TIMEOUT)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise FetchError("LAN_TIMEOUT", f"局域网拉取超时: {target}:{remote_path}") from exc
|
||||
except OSError as exc:
|
||||
raise FetchError("LAN_ERROR", f"局域网拉取失败: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise FetchError("LAN_ERROR", f"scp 失败: {(err or b'').decode(errors='replace')[:500]}")
|
||||
dest = dest_dir / name
|
||||
if not dest.is_file():
|
||||
raise FetchError("LAN_ERROR", f"scp 未产出文件: {dest}")
|
||||
size = dest.stat().st_size
|
||||
sha = _sha256(dest)
|
||||
prov = _write_provenance(dest, f"{target}:{remote_path}", "lan", sha)
|
||||
return {"ok": True, "path": str(dest), "name": name, "size": size, "kind": "lan", "provenance": prov}
|
||||
|
||||
|
||||
async def fetch_lan(spec: str, dest_dir: str | Path, *, credentials: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""局域网路径:本机已挂载/可达直读(返回原路径,不复制);远端走 scp。"""
|
||||
dest_dir = Path(dest_dir)
|
||||
remote = _parse_lan_remote(spec)
|
||||
if remote is not None:
|
||||
return await _scp_fetch(remote[0], remote[1], dest_dir, credentials)
|
||||
# 本机路径:直读(存在即成功;不复制,登记语义由 register_job_files 处理)
|
||||
p = Path(spec).expanduser()
|
||||
if not p.exists():
|
||||
raise FetchError("PATH_NOT_EXIST", f"局域网路径不存在或不可达: {spec}")
|
||||
if p.is_dir():
|
||||
raise FetchError("IS_DIR", f"路径是目录(仅支持单文件拉取): {spec}")
|
||||
sha = _sha256(p)
|
||||
prov = _write_provenance(p, str(p), "lan", sha)
|
||||
return {"ok": True, "path": str(p), "name": p.name, "size": p.stat().st_size, "kind": "lan", "provenance": prov}
|
||||
|
||||
|
||||
# ---- 总入口 ----
|
||||
|
||||
async def fetch_source(
|
||||
source: str,
|
||||
dest_dir: str | Path,
|
||||
*,
|
||||
credentials: dict[str, Any] | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict[str, Any]:
|
||||
"""按来源形态分流拉取(web/OSS/局域网/本机路径)。
|
||||
|
||||
credentials(可选,仅局域网 scp 用):{"user": ..., "password": ..., "port": ...}。
|
||||
凭据不落盘、不进入 provenance。
|
||||
"""
|
||||
source = (source or "").strip()
|
||||
if not source:
|
||||
raise FetchError("EMPTY_SOURCE", "源地址为空")
|
||||
lower = source.lower()
|
||||
# 协议白名单:含 "://" 的 URL 只允许 http/https/oss(其余如 ftp/file/sftp 拒绝)
|
||||
if "://" in source and not lower.startswith(("http://", "https://", "oss://")):
|
||||
raise FetchError("BAD_SCHEME", f"不支持的协议(仅 http/https/oss): {source}")
|
||||
if lower.startswith(("http://", "https://")):
|
||||
if lower.startswith("oss://") or "aliyuncs.com" in lower:
|
||||
return await fetch_oss(source, dest_dir, timeout=timeout)
|
||||
return await fetch_web(source, dest_dir, timeout=timeout)
|
||||
if lower.startswith("oss://"):
|
||||
return await fetch_oss(source, dest_dir, timeout=timeout)
|
||||
return await fetch_lan(source, dest_dir, credentials=credentials)
|
||||
@@ -0,0 +1,403 @@
|
||||
"""tests/test_file_transform.py —— 非标准文件预处理(探测 + 转换 + 校验)。
|
||||
|
||||
分层标记 -m bioflow_unit。覆盖(S0 验收):
|
||||
- inspect_file:fasta/tsv 探测、乱码检测、列结构提取。
|
||||
- transform_file:xlsx→fasta(显式列映射)、csv→fasta(启发式列映射)、
|
||||
samples.yaml 转换 + 校验。
|
||||
- 校验失败 → TransformError VALIDATION_FAILED(追问循环依据)。
|
||||
- 缺列映射且无 LLM → MISSING_COLUMNS。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.file_transform import (
|
||||
TransformError,
|
||||
_garbled_ratio,
|
||||
inspect_file,
|
||||
transform_file,
|
||||
validate_fasta,
|
||||
validate_format,
|
||||
validate_gff3,
|
||||
validate_samples_yaml,
|
||||
validate_tsv,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inspect_file 探测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_inspect_file_fasta(tmp_path):
|
||||
f = tmp_path / "proteins.fa"
|
||||
f.write_text(">SPL1\nMKTA\n>SPL2\nMKTB\n")
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "fasta"
|
||||
assert r["size"] > 0
|
||||
|
||||
|
||||
def test_inspect_file_tsv_columns(tmp_path):
|
||||
f = tmp_path / "samples.tsv"
|
||||
f.write_text("run\tfastq_1\tfastq_2\nSRR1\ta_1.fq\ta_2.fq\n")
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "tsv"
|
||||
assert r["columns"] == ["run", "fastq_1", "fastq_2"]
|
||||
|
||||
|
||||
def test_inspect_file_garbled(tmp_path):
|
||||
f = tmp_path / "messy.txt"
|
||||
f.write_bytes(b"\x00\x01\x02\x03\xff\xfe garbage \x00\x00")
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["garbled"] > 0.3
|
||||
|
||||
|
||||
def test_garbled_ratio_clean():
|
||||
assert _garbled_ratio("ATCGATCG\n>seq\n") < 0.01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transform_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_transform_xlsx_to_fasta(tmp_path):
|
||||
import openpyxl
|
||||
src = tmp_path / "spl_list.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["基因名", "序列", "备注"])
|
||||
ws.append(["SPL1", "ATGGCTA", "水稻"])
|
||||
ws.append(["SPL2", "ATGGCTB", "水稻"])
|
||||
wb.save(src)
|
||||
|
||||
r = _run(transform_file(src, "fasta", column_map={"name": "基因名", "seq": "序列"}))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
assert r["validated"]["ok"] is True
|
||||
text = Path(r["path"]).read_text()
|
||||
assert ">SPL1" in text and "ATGGCTA" in text
|
||||
|
||||
|
||||
def test_transform_csv_to_fasta_heuristic(tmp_path):
|
||||
src = tmp_path / "genes.csv"
|
||||
src.write_text("gene_id,protein_sequence\nG1,MKTA\nG2,MKTB\n")
|
||||
r = _run(transform_file(src, "fasta"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
|
||||
|
||||
def test_transform_csv_to_samples_yaml(tmp_path):
|
||||
src = tmp_path / "design.csv"
|
||||
src.write_text("sample,fastq_1,fastq_2,condition\nA,a_1.fq,a_2.fq,ctrl\nB,b_1.fq,b_2.fq,treated\n")
|
||||
r = _run(transform_file(src, "samples.yaml", column_map={"name": "sample", "r1": "fastq_1", "r2": "fastq_2"}))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
assert r["validated"]["ok"] is True
|
||||
import yaml
|
||||
samples = yaml.safe_load(Path(r["path"]).read_text())["samples"]
|
||||
assert "A" in samples and samples["A"]["r1"] == "a_1.fq"
|
||||
|
||||
|
||||
def test_transform_validation_failed(tmp_path):
|
||||
# seq 列全空 → 产物无序列 → 校验失败
|
||||
src = tmp_path / "bad.csv"
|
||||
src.write_text("name,seq\nG1,\nG2,\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta", column_map={"name": "name", "seq": "seq"}))
|
||||
assert ei.value.code == "VALIDATION_FAILED"
|
||||
|
||||
|
||||
def test_transform_missing_columns(tmp_path):
|
||||
# 无列映射、无 LLM、表头无启发式命中 → MISSING_COLUMNS
|
||||
src = tmp_path / "unknown.csv"
|
||||
src.write_text("col_a,col_b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta"))
|
||||
assert ei.value.code == "MISSING_COLUMNS"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 校验器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_validate_fasta(tmp_path):
|
||||
f = tmp_path / "ok.fa"
|
||||
f.write_text(">a\nACGT\n")
|
||||
assert validate_fasta(f)["ok"] is True
|
||||
|
||||
|
||||
def test_validate_fasta_empty(tmp_path):
|
||||
f = tmp_path / "bad.fa"
|
||||
f.write_text("no records here\n")
|
||||
assert validate_fasta(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_tsv_inconsistent(tmp_path):
|
||||
f = tmp_path / "bad.tsv"
|
||||
f.write_text("a\tb\tc\n1\t2\n")
|
||||
assert validate_tsv(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_samples_yaml(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples:\n A:\n r1: a.fq\n r2: a2.fq\n")
|
||||
assert validate_samples_yaml(f)["ok"] is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(1)inspect_file:NOT_FOUND / xlsx 列 / csv 格式 / 大文件 / 内容嗅探
|
||||
# ===========================================================================
|
||||
|
||||
def test_inspect_file_not_found():
|
||||
r = inspect_file("/no/such/file.fa")
|
||||
assert r["ok"] is False
|
||||
assert r["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_inspect_file_xlsx_columns(tmp_path):
|
||||
import openpyxl
|
||||
f = tmp_path / "spl.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["基因名", "序列"])
|
||||
ws.append(["SPL1", "MKTA"])
|
||||
wb.save(f)
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "xlsx"
|
||||
assert r["columns"] == ["基因名", "序列"]
|
||||
|
||||
|
||||
def test_inspect_file_csv_format(tmp_path):
|
||||
f = tmp_path / "genes.csv"
|
||||
f.write_text("id,seq\nG1,MKTA\n")
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "csv"
|
||||
assert r["columns"] == ["id", "seq"]
|
||||
|
||||
|
||||
def test_inspect_file_large_uses_preview(tmp_path):
|
||||
f = tmp_path / "big.fa"
|
||||
f.write_bytes(b">seq\nACGT\n" + b"A" * (4 * 1024 * 1024))
|
||||
r = inspect_file(f)
|
||||
assert r["ok"] is True
|
||||
assert r["format"] == "fasta"
|
||||
|
||||
|
||||
def test_guess_format_content_sniffing_fasta(tmp_path):
|
||||
from app.services.bioflow.file_transform import _guess_format
|
||||
assert _guess_format("unknown.dat", b">seq1\nACGT\n") == "fasta"
|
||||
|
||||
|
||||
def test_guess_format_content_sniffing_tsv(tmp_path):
|
||||
from app.services.bioflow.file_transform import _guess_format
|
||||
assert _guess_format("unknown.dat", b"a\tb\tc\n1\t2\t3\n") == "tsv"
|
||||
|
||||
|
||||
def test_garbled_ratio_empty():
|
||||
assert _garbled_ratio("") == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(2)transform_file:NOT_FOUND / BAD_FORMAT / EMPTY_TABLE / tsv / gff3 /
|
||||
# samples.yaml 启发式 / LLM 兜底 / LLM 异常兜底
|
||||
# ===========================================================================
|
||||
|
||||
def test_transform_not_found(tmp_path):
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(tmp_path / "nope.csv", "fasta"))
|
||||
assert ei.value.code == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_transform_bad_format(tmp_path):
|
||||
src = tmp_path / "x.csv"
|
||||
src.write_text("a,b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "bogus"))
|
||||
assert ei.value.code == "BAD_FORMAT"
|
||||
|
||||
|
||||
def test_transform_empty_table(tmp_path):
|
||||
src = tmp_path / "empty.csv"
|
||||
src.write_text("")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta"))
|
||||
assert ei.value.code == "EMPTY_TABLE"
|
||||
|
||||
|
||||
def test_transform_to_tsv(tmp_path):
|
||||
src = tmp_path / "genes.csv"
|
||||
src.write_text("gene_id,protein_sequence\nG1,MKTA\nG2,MKTB\n")
|
||||
r = _run(transform_file(src, "tsv"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
assert r["validated"]["ok"] is True
|
||||
|
||||
|
||||
def test_transform_to_gff3_unsupported(tmp_path):
|
||||
src = tmp_path / "x.csv"
|
||||
src.write_text("a,b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "gff3"))
|
||||
assert ei.value.code == "UNSUPPORTED"
|
||||
|
||||
|
||||
def test_transform_samples_yaml_heuristic(tmp_path):
|
||||
src = tmp_path / "design.csv"
|
||||
src.write_text("name,r1,r2,condition\nA,a_1.fq,a_2.fq,ctrl\nB,b_1.fq,b_2.fq,trt\n")
|
||||
r = _run(transform_file(src, "samples.yaml"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
|
||||
|
||||
def test_transform_samples_yaml_missing_columns(tmp_path):
|
||||
src = tmp_path / "unknown.csv"
|
||||
src.write_text("col_a,col_b\n1,2\n")
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "samples.yaml"))
|
||||
assert ei.value.code == "MISSING_COLUMNS"
|
||||
|
||||
|
||||
def test_transform_fasta_llm_column_map(tmp_path):
|
||||
src = tmp_path / "candidates.csv"
|
||||
src.write_text("col_a,col_b\nSPL1,ATGGCTA\nSPL2,ATGGCTB\n")
|
||||
|
||||
async def fake_llm(user, system):
|
||||
return {"name": "col_a", "seq": "col_b"}
|
||||
|
||||
r = _run(transform_file(src, "fasta", llm=fake_llm))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 2
|
||||
|
||||
|
||||
def test_transform_fasta_llm_exception_falls_back_missing(tmp_path):
|
||||
src = tmp_path / "candidates.csv"
|
||||
src.write_text("col_a,col_b\nSPL1,ATGGCTA\n")
|
||||
|
||||
async def fake_llm(user, system):
|
||||
raise RuntimeError("llm down")
|
||||
|
||||
with pytest.raises(TransformError) as ei:
|
||||
_run(transform_file(src, "fasta", llm=fake_llm))
|
||||
assert ei.value.code == "MISSING_COLUMNS"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(3)校验器:gff3 各失败态 / samples.yaml 缺 r1r2 / YAML 解析失败 /
|
||||
# tsv 空文件 / validate_format 未知格式
|
||||
# ===========================================================================
|
||||
|
||||
def test_validate_gff3_ok(tmp_path):
|
||||
f = tmp_path / "ok.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\t1\t100\t.\t+\t.\tID=x\n")
|
||||
assert validate_gff3(f)["ok"] is True
|
||||
|
||||
|
||||
def test_validate_gff3_no_data(tmp_path):
|
||||
f = tmp_path / "empty.gff3"
|
||||
f.write_text("# just a comment\n")
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_gff3_bad_columns(tmp_path):
|
||||
f = tmp_path / "bad.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\t1\t100\n") # 5 列 ≠ 9
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_gff3_bad_strand(tmp_path):
|
||||
f = tmp_path / "bad.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\t1\t100\t.\tx\t.\tID=x\n") # 第 7 列非法
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_gff3_bad_int(tmp_path):
|
||||
f = tmp_path / "bad.gff3"
|
||||
f.write_text("chr1\tsrc\tgene\tabc\t100\t.\t+\t.\tID=x\n") # 起止非整数
|
||||
assert validate_gff3(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_fasta_no_sequence(tmp_path):
|
||||
f = tmp_path / "bad.fa"
|
||||
f.write_text(">a\n")
|
||||
assert validate_fasta(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_samples_yaml_missing_r1r2(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples:\n A:\n condition: ctrl\n")
|
||||
assert validate_samples_yaml(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_samples_yaml_parse_fail(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples: [unclosed\n")
|
||||
assert validate_samples_yaml(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_tsv_empty(tmp_path):
|
||||
f = tmp_path / "empty.tsv"
|
||||
f.write_text("")
|
||||
assert validate_tsv(f)["ok"] is False
|
||||
|
||||
|
||||
def test_validate_format_unknown(tmp_path):
|
||||
f = tmp_path / "x.txt"
|
||||
f.write_text("x")
|
||||
r = validate_format(f, "bogus")
|
||||
assert r["ok"] is False
|
||||
assert "不支持" in r["reason"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(4)校验器缺失文件 / 格式猜测扩展名 / samples 非 dict / 空行跳过
|
||||
# ===========================================================================
|
||||
|
||||
def test_validators_missing_file(tmp_path):
|
||||
missing = tmp_path / "nope"
|
||||
assert validate_fasta(missing)["ok"] is False
|
||||
assert validate_gff3(missing)["ok"] is False
|
||||
assert validate_tsv(missing)["ok"] is False
|
||||
assert validate_samples_yaml(missing)["ok"] is False
|
||||
|
||||
|
||||
def test_guess_format_all_extensions():
|
||||
from app.services.bioflow.file_transform import _guess_format
|
||||
assert _guess_format("a.gff3", b"") == "gff3"
|
||||
assert _guess_format("a.gff", b"") == "gff3"
|
||||
assert _guess_format("a.gtf", b"") == "gtf"
|
||||
assert _guess_format("a.vcf", b"") == "vcf"
|
||||
assert _guess_format("a.vcf.gz", b"") == "vcf"
|
||||
assert _guess_format("a.yaml", b"") == "yaml"
|
||||
assert _guess_format("a.yml", b"") == "yaml"
|
||||
assert _guess_format("a.xls", b"") == "xlsx"
|
||||
assert _guess_format("a.faa", b"") == "fasta"
|
||||
assert _guess_format("a.fna", b"") == "fasta"
|
||||
assert _guess_format("a.pep", b"") == "fasta"
|
||||
assert _guess_format("a.cds", b"") == "fasta"
|
||||
assert _guess_format("a.dat", b"no marker") == "txt"
|
||||
|
||||
|
||||
def test_validate_samples_yaml_not_dict(tmp_path):
|
||||
f = tmp_path / "s.yaml"
|
||||
f.write_text("samples: just_a_string\n")
|
||||
assert validate_samples_yaml(f)["ok"] is False
|
||||
|
||||
|
||||
def test_transform_samples_yaml_skips_empty_rows(tmp_path):
|
||||
src = tmp_path / "d.csv"
|
||||
src.write_text("name,r1,r2\n,,\nA,a.fq,a2.fq\n")
|
||||
r = _run(transform_file(src, "samples.yaml"))
|
||||
assert r["ok"] is True
|
||||
assert r["records"] == 1
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
"""tests/test_source_fetcher.py —— 源文件拉取(web/OSS/局域网)+ 磁盘余量 + provenance。
|
||||
|
||||
分层标记 -m bioflow_unit。覆盖(S0 验收):
|
||||
- web http 流式下载(本地 http.server 实测 httpx 通道)+ provenance 落盘。
|
||||
- 协议白名单(ftp:// 拒绝 BAD_SCHEME)。
|
||||
- oss:// 无 endpoint 拒绝 / 预签名 URL 直通 web / endpoint 解析。
|
||||
- 局域网本机路径直读;远端 host:/path 解析。
|
||||
- 磁盘余量不足 DISK_FULL(check_disk_space 纯函数)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import http.server
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.source_fetcher import (
|
||||
FetchError,
|
||||
_parse_lan_remote,
|
||||
_resolve_oss_url,
|
||||
check_disk_space,
|
||||
fetch_lan,
|
||||
fetch_source,
|
||||
fetch_web,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地 http 服务器(测 httpx 通道,不依赖外网)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def http_url(tmp_path):
|
||||
content = b">seq1\nACGTACGT\n>seq2\nTGCATGCA\n"
|
||||
(tmp_path / "test.fa").write_bytes(content)
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(tmp_path))
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}/test.fa"
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_fetch_web_download_and_provenance(http_url, tmp_path):
|
||||
out = tmp_path / "out"
|
||||
r = _run(fetch_web(http_url, out))
|
||||
assert r["ok"] is True
|
||||
assert r["name"] == "test.fa"
|
||||
assert r["size"] > 0
|
||||
assert r["kind"] == "web"
|
||||
assert r["provenance"]["source"] == http_url
|
||||
assert len(r["provenance"]["sha256"]) == 64
|
||||
assert (out / "test.fa").read_text().startswith(">seq1")
|
||||
# sidecar provenance
|
||||
assert (out / "test.fa.provenance.json").is_file()
|
||||
|
||||
|
||||
def test_fetch_web_http_error(http_url, tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web(http_url + "/missing", tmp_path / "out"))
|
||||
assert ei.value.code == "HTTP_ERROR"
|
||||
|
||||
|
||||
def test_fetch_source_bad_scheme(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_source("ftp://host/x.fa", tmp_path))
|
||||
assert ei.value.code == "BAD_SCHEME"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OSS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_oss_url_no_endpoint_rejected():
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_resolve_oss_url("oss://bucket/key.fa")
|
||||
assert ei.value.code == "OSS_UNCONFIGURED"
|
||||
|
||||
|
||||
def test_oss_url_endpoint_resolve(monkeypatch):
|
||||
monkeypatch.setenv("BIOFLOW_OSS_ENDPOINT", "oss-cn-hangzhou.aliyuncs.com")
|
||||
url = _resolve_oss_url("oss://seq-bucket/data/x.fa")
|
||||
assert url == "https://seq-bucket.oss-cn-hangzhou.aliyuncs.com/data/x.fa"
|
||||
|
||||
|
||||
def test_oss_presigned_passthrough():
|
||||
url = _resolve_oss_url("https://bucket.oss-cn-hangzhou.aliyuncs.com/x.fa?sig=abc")
|
||||
assert url.startswith("https://")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 局域网 / 本机路径
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_lan_remote():
|
||||
assert _parse_lan_remote("192.168.1.5:/data/x.fa") == ("192.168.1.5", "/data/x.fa")
|
||||
assert _parse_lan_remote("xzq@192.168.1.5:/data/x.fa") == ("xzq@192.168.1.5", "/data/x.fa")
|
||||
assert _parse_lan_remote("/local/path/x.fa") is None
|
||||
assert _parse_lan_remote("http://x/y") is None
|
||||
|
||||
|
||||
def test_fetch_lan_local_path(tmp_path):
|
||||
f = tmp_path / "genome.fa"
|
||||
f.write_text(">g1\nACGT\n")
|
||||
r = _run(fetch_lan(str(f), tmp_path / "out"))
|
||||
assert r["ok"] is True
|
||||
assert r["kind"] == "lan"
|
||||
assert r["path"] == str(f)
|
||||
assert r["provenance"]["sha256"]
|
||||
|
||||
|
||||
def test_fetch_lan_missing_path(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("/no/such/path.fa", tmp_path))
|
||||
assert ei.value.code == "PATH_NOT_EXIST"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 磁盘余量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_check_disk_space_insufficient(tmp_path):
|
||||
ok, free = check_disk_space(tmp_path, 10 ** 18) # 1EB,必不足
|
||||
assert ok is False
|
||||
assert free < 10 ** 18
|
||||
|
||||
|
||||
def test_check_disk_space_sufficient(tmp_path):
|
||||
ok, _free = check_disk_space(tmp_path, 1) # 1 字节
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(1)web:BAD_SCHEME / 磁盘满 DISK_FULL / 断点 TRUNCATED / NETWORK_ERROR
|
||||
# ===========================================================================
|
||||
|
||||
def test_fetch_web_bad_scheme(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web("ftp://host/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "BAD_SCHEME"
|
||||
|
||||
|
||||
def test_fetch_web_disk_full(http_url, tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
monkeypatch.setattr(sf, "check_disk_space", lambda dest_dir, needed: (False, 0))
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web(http_url, tmp_path / "out"))
|
||||
assert ei.value.code == "DISK_FULL"
|
||||
|
||||
|
||||
def test_fetch_web_truncated(tmp_path, monkeypatch):
|
||||
"""断点/不完整下载:aiter_bytes 实收字节 < Content-Length → TRUNCATED。
|
||||
|
||||
用 fake httpx 确定性触发(真实服务器下 httpx 会因短读抛 HTTPError)。
|
||||
"""
|
||||
import types as _types
|
||||
|
||||
import httpx as real_httpx
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
headers = {"content-length": "100"}
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def aiter_bytes(self, chunk_size):
|
||||
yield b"short"
|
||||
|
||||
class FakeStream:
|
||||
async def __aenter__(self):
|
||||
return FakeResp()
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, follow_redirects=True):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, timeout=None):
|
||||
return FakeStream()
|
||||
|
||||
monkeypatch.setattr(sf, "httpx", _types.SimpleNamespace(
|
||||
AsyncClient=FakeClient, Timeout=real_httpx.Timeout, HTTPError=real_httpx.HTTPError,
|
||||
))
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web("http://x/y.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "TRUNCATED"
|
||||
|
||||
|
||||
def test_fetch_web_network_error(tmp_path, monkeypatch):
|
||||
"""网络错误:httpx.HTTPError → NETWORK_ERROR(fake 确定性触发)。"""
|
||||
import types as _types
|
||||
|
||||
import httpx as real_httpx
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
class FailingStream:
|
||||
async def __aenter__(self):
|
||||
raise real_httpx.HTTPError("connection reset")
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
class FailingClient:
|
||||
def __init__(self, follow_redirects=True):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, timeout=None):
|
||||
return FailingStream()
|
||||
|
||||
monkeypatch.setattr(sf, "httpx", _types.SimpleNamespace(
|
||||
AsyncClient=FailingClient, Timeout=real_httpx.Timeout, HTTPError=real_httpx.HTTPError,
|
||||
))
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_web("http://x/y.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "NETWORK_ERROR"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(2)OSS:BAD_OSS / endpoint 解析(内网 http)/ fetch_oss 走 web + kind 修正
|
||||
# ===========================================================================
|
||||
|
||||
def test_oss_url_bad_format():
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_resolve_oss_url("oss://onlybucket")
|
||||
assert ei.value.code == "BAD_OSS"
|
||||
|
||||
|
||||
def test_oss_url_intranet_endpoint_http(monkeypatch):
|
||||
monkeypatch.setenv("BIOFLOW_OSS_ENDPOINT", "oss-internal.example.com")
|
||||
url = _resolve_oss_url("oss://seq-bucket/data/x.fa")
|
||||
assert url == "http://seq-bucket.oss-internal.example.com/data/x.fa"
|
||||
|
||||
|
||||
def test_fetch_oss_marks_kind_and_provenance(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
from pathlib import Path as _Path
|
||||
|
||||
async def fake_web(url, dest_dir, timeout=None):
|
||||
dest = _Path(dest_dir) / "x.fa"
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(">x\nACGT\n")
|
||||
return {"ok": True, "path": str(dest), "name": "x.fa", "size": 4,
|
||||
"kind": "web", "provenance": {"source": url, "kind": "web",
|
||||
"sha256": "0" * 64, "fetched_at": "t"}}
|
||||
|
||||
monkeypatch.setattr(sf, "fetch_web", fake_web)
|
||||
monkeypatch.setenv("BIOFLOW_OSS_ENDPOINT", "oss-cn-hangzhou.aliyuncs.com")
|
||||
r = _run(sf.fetch_oss("oss://b/k.fa", tmp_path / "out"))
|
||||
assert r["ok"] is True
|
||||
assert r["kind"] == "oss"
|
||||
assert r["provenance"]["kind"] == "oss"
|
||||
assert r["provenance"]["oss_spec"] == "oss://b/k.fa"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(3)fetch_source:EMPTY_SOURCE / oss 路由 / aliyuncs 预签名路由
|
||||
# ===========================================================================
|
||||
|
||||
def test_fetch_source_empty(tmp_path):
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_source("", tmp_path))
|
||||
assert ei.value.code == "EMPTY_SOURCE"
|
||||
|
||||
|
||||
def test_fetch_source_oss_routing(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
calls = []
|
||||
|
||||
async def fake_oss(spec, dest_dir, timeout=None):
|
||||
calls.append(spec)
|
||||
return {"ok": True, "kind": "oss"}
|
||||
|
||||
monkeypatch.setattr(sf, "fetch_oss", fake_oss)
|
||||
r = _run(sf.fetch_source("oss://bucket/k.fa", tmp_path))
|
||||
assert r["kind"] == "oss"
|
||||
assert calls == ["oss://bucket/k.fa"]
|
||||
|
||||
|
||||
def test_fetch_source_aliyuncs_presigned_routes_to_oss(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
calls = []
|
||||
|
||||
async def fake_oss(spec, dest_dir, timeout=None):
|
||||
calls.append(spec)
|
||||
return {"ok": True, "kind": "oss"}
|
||||
|
||||
monkeypatch.setattr(sf, "fetch_oss", fake_oss)
|
||||
presigned = "https://bucket.aliyuncs.com/x.fa?sig=abc"
|
||||
r = _run(sf.fetch_source(presigned, tmp_path))
|
||||
assert r["kind"] == "oss"
|
||||
assert calls == [presigned]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S0 补强(4)局域网:目录 IS_DIR / scp 拉取 / sshpass 缺失 / scp 失败 / 解析边界
|
||||
# ===========================================================================
|
||||
|
||||
def test_fetch_lan_dir_rejected(tmp_path):
|
||||
d = tmp_path / "adir"
|
||||
d.mkdir()
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan(str(d), tmp_path / "out"))
|
||||
assert ei.value.code == "IS_DIR"
|
||||
|
||||
|
||||
def test_parse_lan_remote_relative_rejected():
|
||||
assert _parse_lan_remote("host:relative/path") is None
|
||||
|
||||
|
||||
def test_fetch_lan_scp_success(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
from pathlib import Path as _Path
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
dest = _Path(args[-1])
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(">g\nACGT\n")
|
||||
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
r = _run(fetch_lan("xzq@192.168.1.5:/data/genome.fa", tmp_path / "out",
|
||||
credentials={"user": "xzq"}))
|
||||
assert r["ok"] is True
|
||||
assert r["kind"] == "lan"
|
||||
assert r["name"] == "genome.fa"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_sshpass_missing(tmp_path, monkeypatch):
|
||||
import shutil
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out", credentials={"password": "p"}))
|
||||
assert ei.value.code == "SSHPASS_MISSING"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_failed(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
class P:
|
||||
returncode = 1
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"scp: no such file")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "LAN_ERROR"
|
||||
|
||||
|
||||
def test_scp_fetch_remote_uses_port_and_user(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
from pathlib import Path as _Path
|
||||
captured = {}
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
captured["args"] = list(args)
|
||||
dest = _Path(args[-1])
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(">g\nACGT\n")
|
||||
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out",
|
||||
credentials={"user": "u", "port": "2222"}))
|
||||
# scp -P 2222 u@host:/data/x.fa <dest>
|
||||
assert captured["args"][1] == "-P"
|
||||
assert captured["args"][2] == "2222"
|
||||
assert captured["args"][-2] == "u@host:/data/x.fa"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_timeout(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
async def fake_wait_for(coro, timeout=None):
|
||||
coro.close() # 未 await 的 communicate 协程关闭,避免 never-awaited 警告
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
monkeypatch.setattr(asyncio, "wait_for", fake_wait_for)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "LAN_TIMEOUT"
|
||||
|
||||
|
||||
def test_fetch_lan_scp_no_file(tmp_path, monkeypatch):
|
||||
from app.services.bioflow import source_fetcher as sf
|
||||
|
||||
async def fake_scp(*args, **kwargs):
|
||||
# returncode 0 但未产出目标文件
|
||||
class P:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_scp)
|
||||
with pytest.raises(FetchError) as ei:
|
||||
_run(fetch_lan("host:/data/x.fa", tmp_path / "out"))
|
||||
assert ei.value.code == "LAN_ERROR"
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/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)"
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/toggle_bioflow.sh —— 生信能力按需装卸(热,不重启 QA)+ bioflow_service 启停 + 注册状态自检。
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy/toggle_bioflow.sh <gene-family|rnaseq|extension|all> <on|off>
|
||||
#
|
||||
# 原理:
|
||||
# - 能力装卸经 BFF POST /api/capabilities/install(透传 QA install_manager,热加载生效不重启 QA)
|
||||
# - on = register(manifest 校验 + 原子写 capabilities.yaml + registry 差分刷新)
|
||||
# - off = unregister(按 name 移除能力段,其余能力不受影响)
|
||||
# - 自检 = GET /api/capabilities 的 installed 数组是否含对应能力
|
||||
# - bioflow_service 启停:全部能力关闭时停 8098;任一开启时启动 8098
|
||||
# - 卸载活跃任务:三表已持久化,服务恢复后 runner.recover 续跑;QA 侧按「生信服务未启动」降级话术
|
||||
set -uo pipefail
|
||||
|
||||
BFF="http://127.0.0.1:8090"
|
||||
BIOFLOW_SERVICE_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/bioflow_service"
|
||||
PY="/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python"
|
||||
BIOFLOW_PORT="${BIOFLOW_PORT:-8098}"
|
||||
|
||||
CAP="${1:-}"
|
||||
ACTION="${2:-}"
|
||||
|
||||
CAPS=(gene-family rnaseq extension)
|
||||
|
||||
usage() {
|
||||
echo "用法: $0 <gene-family|rnaseq|extension|all> <on|off>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# capability key -> registry 能力名
|
||||
cap_name() {
|
||||
case "$1" in
|
||||
gene-family) echo "bioflow-gene-family" ;;
|
||||
rnaseq) echo "bioflow-rnaseq" ;;
|
||||
extension) echo "bioflow-extension" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# capability key -> register manifest YAML(与 agent/qa/configs/capabilities.yaml 一致)
|
||||
manifest() {
|
||||
case "$1" in
|
||||
gene-family) cat <<'YAML'
|
||||
capabilities:
|
||||
- name: bioflow-gene-family
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 基因家族分析(路径规划/建任务/参数确认/文件获取与预处理/状态查询),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers: [基因家族, 家族成员, 亚家族, 系统发育, 建树, 进化树, 保守基序, motif, 结构域, 基因结构, 共线性, Ka/Ks, 选择压力, 组合证据]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/gene-family/
|
||||
prompt_pack:
|
||||
file: bioflow_gene_family_agent_rules.md
|
||||
YAML
|
||||
;;
|
||||
rnaseq) cat <<'YAML'
|
||||
capabilities:
|
||||
- name: bioflow-rnaseq
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 转录组分析(实验设计确认/样本配置/路径规划/建任务/差异表达/富集),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers: [转录组, RNA-seq, RNAseq, rnaseq, 差异表达, 表达定量, 富集, WGCNA, GSEA, 实验设计, fastq]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/rnaseq/
|
||||
prompt_pack:
|
||||
file: bioflow_rnaseq_agent_rules.md
|
||||
YAML
|
||||
;;
|
||||
extension) cat <<'YAML'
|
||||
capabilities:
|
||||
- name: bioflow-extension
|
||||
provider: mcp
|
||||
permission: read_only
|
||||
description: 用户自定义扩展分析(创建/转换/试运行/校验/发布/运行),自然语言触发
|
||||
session_default: true
|
||||
nl_triggers: [扩展, 拓展分析, 扩展分析, 自定义分析, 分析模板, 分析脚本, 分析工具]
|
||||
mcp:
|
||||
url: http://127.0.0.1:8098/mcp/extension/
|
||||
prompt_pack:
|
||||
file: bioflow_extension_agent_rules.md
|
||||
YAML
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
|
||||
[ -n "$CAP" ] && [ -n "$ACTION" ] || usage
|
||||
[ "$ACTION" = "on" ] || [ "$ACTION" = "off" ] || usage
|
||||
if [ "$CAP" != "all" ]; then
|
||||
cap_name "$CAP" >/dev/null 2>&1 || usage
|
||||
fi
|
||||
|
||||
# 1. 逐能力 register/unregister(热加载不重启 QA)
|
||||
targets=("$CAP")
|
||||
[ "$CAP" = "all" ] && targets=("${CAPS[@]}")
|
||||
for key in "${targets[@]}"; do
|
||||
name="$(cap_name "$key")"
|
||||
if [ "$ACTION" = "on" ]; then
|
||||
body="$(manifest "$key")"
|
||||
resp=$(curl -s -m 30 -X POST "$BFF/api/capabilities/install" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(python3 -c 'import json,sys; print(json.dumps({"action":"register","manifest":sys.stdin.read()}))' <<<"$body")")
|
||||
else
|
||||
resp=$(curl -s -m 30 -X POST "$BFF/api/capabilities/install" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"action\":\"unregister\",\"name\":\"$name\"}")
|
||||
fi
|
||||
echo "[$ACTION] $name -> $(echo "$resp" | head -c 300)"
|
||||
done
|
||||
|
||||
# 2. 自检:GET /api/capabilities 的 installed 数组
|
||||
installed=$(curl -s -m 10 "$BFF/api/capabilities")
|
||||
echo "注册状态: $(echo "$installed" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i.get("name") for i in d.get("installed") or []])' 2>/dev/null || echo 'parse-fail')"
|
||||
|
||||
# 3. bioflow_service 按需启停:任一能力开启则启动,全部关闭则停止
|
||||
any_on=false
|
||||
for key in "${CAPS[@]}"; do
|
||||
name="$(cap_name "$key")"
|
||||
if echo "$installed" | grep -q "\"$name\""; then
|
||||
any_on=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$any_on" = true ]; then
|
||||
if ! lsof -ti tcp:$BIOFLOW_PORT -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "启动 bioflow_service :$BIOFLOW_PORT"
|
||||
( cd "$BIOFLOW_SERVICE_DIR" && setsid "$PY" -m uvicorn app.main:app --host 127.0.0.1 --port "$BIOFLOW_PORT" \
|
||||
> /tmp/plantai-bioflow.log 2>&1 < /dev/null & ) && disown -a
|
||||
sleep 2
|
||||
fi
|
||||
curl -s -m 8 "http://127.0.0.1:$BIOFLOW_PORT/api/bioflow/health" | head -c 150
|
||||
echo
|
||||
else
|
||||
echo "全部能力已卸载:停止 bioflow_service :$BIOFLOW_PORT"
|
||||
kill $(lsof -ti tcp:$BIOFLOW_PORT -sTCP:LISTEN) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "DONE"
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,177 @@
|
||||
# 生信流程 · 对话式手工测试手册(S6 更新版)
|
||||
|
||||
> 供用户逐项实测。生信模块已从「卡片式交互」改造为「自然语言对话式交互 + MCP 外挂」,
|
||||
> 本手册以**对话剧本**形式给出每个场景的:对话示例 → 系统应如何回应 → 通过判据。
|
||||
> 环境:内网 http://192.168.0.175:8090(BFF);生信服务 8098、QA 8091 已部署;
|
||||
> 三能力(基因家族/转录组/扩展)按 §0 装卸。
|
||||
|
||||
---
|
||||
|
||||
## 0. 前置条件
|
||||
|
||||
1. 浏览器打开 http://192.168.0.175:8090。
|
||||
2. **能力安装检查**:设置面板(SettingsPanel)应能看到三个生信开关(基因家族/转录组/扩展)。
|
||||
打开任意一个后,工作区出现「生信任务」「生信档案」TAB;全关则 TAB 消失(**TAB 显隐 = 能力注册状态**,非服务健康)。
|
||||
3. 服务器 docker 已有 8 个 bioflow 镜像;金标数据在 `bioflow_kernel/assets/examples/test_input/`(Ft 四件套 pep/cds/genome/gff3)。
|
||||
4. 熔断开关:`QA_V1_ENABLE_BIOFLOW_AGENT=1`(默认 0,关时生信句走普通 QA)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 多配置开关回归(S6)
|
||||
|
||||
| 配置 | 操作 | 期望 | 判据 |
|
||||
|---|---|---|---|
|
||||
| 全关(熔断=0 + 三能力卸载) | 发「帮我做基因家族分析」 | 普通 QA 礼貌回答(说明未安装生信能力),**无**路径卡 | 无生信分流 |
|
||||
| 只开基因家族 | 发「帮我做基因家族分析」 | 进入生信对话(列路径+挂路径卡) | 命中 |
|
||||
| 只开基因家族 | 发「帮我做转录组差异表达」 | 走普通 QA(转录组能力未装) | 不响应 |
|
||||
| 只开转录组 | 发「帮我做转录组」 | 进入生信对话 | 命中 |
|
||||
| 只开转录组 | 发「帮我做基因家族分析」 | 走普通 QA | 不响应 |
|
||||
| 全开但非生信提问 | 发「光合作用的电子传递链是什么」 | 走普通 QA(非生信意图不调工具) | 不调工具 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 对话剧本 A:基因家族分析(卡片辅助通道)
|
||||
|
||||
**第 1 轮 —— 需求与路径**
|
||||
> 用户:帮我做水稻 SPL 基因家族分析
|
||||
> 系统(自然语言):列「核心流程(必选)+ 可选分析」,说明每个可选项生物学意义;
|
||||
> **同帧挂出路径选择卡**。
|
||||
> 判据:回复含路径列表 + 路径卡出现(卡片不替代自然语言)。
|
||||
|
||||
**第 2 轮 —— 卡片操作 → 系统复述**
|
||||
> 用户(路径卡勾选「核心流程 + Ka/Ks + 启动子」,点确认;打字补充文件位置)
|
||||
> 系统:先自然语言**复述**用户选择("您选择了核心流程 + Ka/Ks + 启动子分析…"),再请确认。
|
||||
> 判据:系统复述内容与卡片勾选一致;建任务前必有一轮复述确认(不直接建任务)。
|
||||
|
||||
**第 3 轮 —— 网络文件 + 非标准文件预处理**
|
||||
> 用户:文件在 `https://…/rice.fa` 与 `oss://seq-bucket/rice.gff3`,另传 `spl_list.xlsx`
|
||||
> 系统:拉取 web/OSS 文件(**下载前检查磁盘余量、记录 provenance**);对 `spl_list.xlsx`
|
||||
> 做内容嗅探后**自然语言说明列结构并提问**("推测第二列是蛋白序列,备注列忽略?")。
|
||||
> 判据:文件获取成功;Excel 触发预处理提问(列表方式,可卡片点选也可打字回答)。
|
||||
|
||||
**第 4 轮 —— 建任务执行**
|
||||
> 用户:都对,备注列不要;确认,开始吧
|
||||
> 系统:复述整理结果("已整理为 FASTA 3 条序列")→ 创建任务 → 消息内出现**只读精简进度块**,
|
||||
> 右侧「生信任务」TAB 完整滚动日志实时跟随。
|
||||
> 判据:进度块只读(无按钮);TAB 日志自动滚底。
|
||||
|
||||
**执行中 —— 进度转述 + 随时提问**
|
||||
> 用户:什么是组合证据?
|
||||
> 系统:经流程知识库自然语言回答(带引用),**不打断任务**。
|
||||
> 判据:提问得到引用回答,任务继续推进。
|
||||
|
||||
**人工确认边界(不可跳过)**
|
||||
> 系统:第 8 步亚家族预测完成(6 个亚家族)。⚠ 需您人工确认,我不能代您确认。
|
||||
> 用户:把 SPL3 从亚家族 A 移到 B → 系统复述修改 → 用户「确认」。
|
||||
> 判据:subfamily_confirm 必停、必复述、绝不代确认。
|
||||
|
||||
**收尾 —— 存档**
|
||||
> 系统:全部完成(19 成员 6 亚家族),同帧挂出存档卡。
|
||||
> 用户(存档卡填名称点「存档」或打字「存档,叫 SPL 水稻鉴定」)
|
||||
> 系统:实际执行 `archive_job` 存档(复用 REST 语义),复述存档三要素(名称/位置/大小)。
|
||||
> 判据:存档成功(重名提示 ARCHIVE_EXISTS)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 对话剧本 B:转录组分析(纯自然语言通道,全程不点卡片)
|
||||
|
||||
**第 1 轮 —— 必确认信息**
|
||||
> 用户:帮我做转录组,比较干旱处理和对照
|
||||
> 系统:列主流程 + 可选;**必须确认**四项(物种/参考基因组版本、FASTQ 单双端、
|
||||
> 对照处理组重复数、链特异性),列表展示。
|
||||
> 判据:实验设计确认边界被明确列出,未确认前不得建差异表达任务。
|
||||
|
||||
**第 2 轮 —— OSS 文件 + 样本信息(纯打字,不点卡)**
|
||||
> 用户:数据在 oss://seq-bucket/drought/,水稻日本晴,干旱 3 重复对照 3 重复,双端非链特异
|
||||
> 系统:从 OSS 拉取 12 个 FASTQ 并核对成对;复述样本设计("干旱 3 vs 对照 3,双端,非链特异"),
|
||||
> 请确认基线(对照为基线、比较方向「干旱 vs 对照」)。
|
||||
> 判据:纯自然语言路径走通(**卡片与打字两条通道服务端语义一致**)。
|
||||
|
||||
**第 3 轮 —— 执行 + 结果转述**
|
||||
> 用户:对 → 系统执行 QC→比对→定量→样本质控→差异表达→富集,逐段转述(接头率/Q30/唯一比对率/差异基因数/PCA 分离)。
|
||||
> 判据:每步完成后自然语言转述关键指标;样本质控 PCA 结论有转述。
|
||||
|
||||
---
|
||||
|
||||
## 4. 三种网络文件来源用例
|
||||
|
||||
| 来源 | 示例 | 期望 |
|
||||
|---|---|---|
|
||||
| web | 「文件在 https://…/x.fa」 | httpx 流式下载,不设大小上限;下载前查磁盘余量;记录 provenance |
|
||||
| OSS | 「数据在 oss://bucket/key 或预签名 URL」 | 经 BIOFLOW_OSS_ENDPOINT 解析或预签名直通;无 endpoint 时提示改用预签名 |
|
||||
| 局域网 | 「文件在 192.168.x.x:/data/x.fa,账号 xx 密码 xx」 | ssh/scp 拉取(凭据单次会话内存用,不落盘、审计不记明文) |
|
||||
|
||||
判据:文件落 `bioflow_data/jobs/{job}/input/`,先内容嗅探后登记,provenance 记录来源/哈希/时间。
|
||||
|
||||
---
|
||||
|
||||
## 5. 非标准文件预处理(Excel / 乱码文本)闭环
|
||||
|
||||
1. 上传 `xxx.xlsx` / 乱码 txt → 系统 `inspect_file` 探测(列结构/格式猜测/乱码检测)。
|
||||
2. 系统自然语言说明 + 列表提问(哪列是序列、是否去重、是否去表头)。
|
||||
3. 用户回答 → `transform_file` 转换(LLM 定规则 + 脚本执行,大文件抽样定规则)。
|
||||
4. 结果过格式验证器(fasta 可解析/gff3 9 列/tsv 列一致/samples.yaml schema);
|
||||
失败说明问题并追问,循环 ≤3 次后建议人工整理。
|
||||
5. 转换后 `register_job_files` 登记,provenance 记「原始→规则→产物」链。
|
||||
|
||||
判据:全程自然语言引导;转换产物格式校验通过;原文件永不覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 6. 卡片辅助 vs 纯自然语言双通道等价
|
||||
|
||||
| 场景 | 卡片通道 | 纯 NL 通道 | 等价判据 |
|
||||
|---|---|---|---|
|
||||
| 路径选择 | 路径卡勾选 | 打字「核心流程 + Ka/Ks」 | 两者复述的确认内容一致,建任务入参一致 |
|
||||
| 参数确认 | 参数卡填值 | 打字「用 8 核、padj 用 0.01」 | confirm_stage 入参一致 |
|
||||
| 文件提供 | 文件卡拖拽/填路径 | 打字「文件在 …」 | register_job_files / fetch_source_file 入参一致 |
|
||||
| 存档 | 存档卡点存档 | 打字「存档,叫 X」 | archive_job 入参一致 |
|
||||
|
||||
判据:卡片操作产生的是 `card_result` 结构化消息进 QA 流(**不直连 bioflow 控制面**),
|
||||
与打字在服务端语义完全一致(QA 对话状态机是唯一决策点)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 旧会话兼容(Legacy 块只读)
|
||||
|
||||
1. 打开一个改造前创建、含旧生信块(bioflow_qa / 旧 bioflow_* 块)的会话。
|
||||
2. 期望:旧块以只读形式降级渲染(标题 + 只读引用/提示),**不报错、无可交互控件**。
|
||||
|
||||
---
|
||||
|
||||
## 8. 扩展子系统对话化(第三能力单元)
|
||||
|
||||
1. 打开「扩展」能力开关。
|
||||
2. 对话创建扩展:描述分析脚本(代码文件 + 运行命令)→ `create_extension`(draft)。
|
||||
3. `build` → `trial_run` → `validate` → `confirm` → `publish` → `run` 逐步走(卡片或打字等价)。
|
||||
4. 试运行失败 → 「AI 修复」触发 `repair_extension`(修复上限 20 轮);校验失败/状态不足时系统给出确定性提示。
|
||||
|
||||
判据:扩展生命周期全程对话化;非法状态返回清晰提示(EXT_STATUS),不报 500。
|
||||
|
||||
---
|
||||
|
||||
## 9. 后端金标验证(服务器命令行,S6 回归)
|
||||
|
||||
```bash
|
||||
cd /home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new
|
||||
BIOFLOW_DATA=/home/xzq/5090/teamWorkspace/zh/bioflow-data \
|
||||
bash bioflow_kernel/tests/smoke_golden.sh bioflow-gf:dev
|
||||
```
|
||||
|
||||
期望:4 步全绿——python 3.11.14 / ldd 0 缺库 / 无 conda / 金标 identify 24 ID diff 空(`GOLDEN_DIFF_OK 24 IDs`)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 端到端主线(一次性走全流程,双通道各一遍)
|
||||
|
||||
1. 卡片通道:§2 全程(路径卡→文件卡→参数卡→进度块→存档卡)。
|
||||
2. 纯 NL 通道:§3 全程(不点任何卡片)。
|
||||
3. 含一次 Excel 预处理、一次局域网/OSS 文件拉取。
|
||||
4. 任务完成后存档 → 生信档案 TAB 回看 → 复用发起。
|
||||
|
||||
期望:两条通道均走通,7 项引导(路径/文件/参数/报告/存档/复用/过程展示)逐项达成,
|
||||
进度块实时流转、右侧日志滚底。
|
||||
|
||||
---
|
||||
|
||||
*本手册为 S6 对话式改造更新版,替代旧卡片式 `MANUAL_TEST_BIOFLOW.md` 的生信流程部分。*
|
||||
@@ -1 +0,0 @@
|
||||
"""paper_writer_service —— 论文撰写独立 MCP 服务(8097)。"""
|
||||
@@ -1,53 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,168 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,84 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,48 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,10 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,65 +0,0 @@
|
||||
"""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 "")
|
||||
@@ -1,109 +0,0 @@
|
||||
"""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}
|
||||
@@ -1,187 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,113 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"""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}
|
||||
@@ -1,251 +0,0 @@
|
||||
"""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", "")}
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,6 +0,0 @@
|
||||
[pytest]
|
||||
python_files = test_*.py
|
||||
markers =
|
||||
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
|
||||
paper_mcp: MCP 协议互操作测试
|
||||
asyncio_mode = auto
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/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"
|
||||
@@ -76,11 +76,6 @@ 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
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
|
||||
@@ -60,8 +59,6 @@ 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:
|
||||
|
||||
+40
-69
@@ -184,60 +184,6 @@ 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 实现)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -570,7 +516,13 @@ async def _run_docgen_task(
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
# ---- 落盘 + artifact 落库(抽公共 insert_artifact,论文撰写共用)----
|
||||
# ---- 以下与修复前同步路径一致:落盘 + 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_dir = _artifact_dir(get_settings())
|
||||
md_rel = ""
|
||||
raw_md_path = str(result.get("md_path") or "")
|
||||
@@ -583,6 +535,17 @@ 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":
|
||||
@@ -594,26 +557,34 @@ 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)
|
||||
|
||||
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],
|
||||
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()),
|
||||
)
|
||||
# 终态 progress 快照:全部节 done(限频写库可能截断最后一帧)
|
||||
final_progress = [
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"""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}] 不存在"}
|
||||
+11
-1
@@ -286,11 +286,21 @@ async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]:
|
||||
history = await assemble_history(bff_db, session_id)
|
||||
if history:
|
||||
body["history"] = history
|
||||
# 会话开关注入上游(web_search/superpowers 经 QA 门控/注入点生效)
|
||||
# 会话开关注入上游(web_search/superpowers/bioflow 经 QA 门控/注入点生效)
|
||||
body["capabilities"] = {
|
||||
"web_search": bool(caps.get("web_search", False)),
|
||||
"superpowers": bool(caps.get("superpowers", False)),
|
||||
"bioflow": bool(caps.get("bioflow", False)),
|
||||
}
|
||||
# session↔job 绑定(S2):meta.bioflow = {active_job_id, chain} 注入上游,
|
||||
# 供 BioflowAgent 复用当前任务(job_status 是任务真相唯一权威)。
|
||||
bioflow_meta = meta.get("bioflow")
|
||||
if isinstance(bioflow_meta, dict):
|
||||
body["bioflow"] = {
|
||||
"session_id": session_id,
|
||||
"active_job_id": str(bioflow_meta.get("active_job_id") or ""),
|
||||
"chain": str(bioflow_meta.get("chain") or ""),
|
||||
}
|
||||
humanize = bool(caps.get("humanize", False))
|
||||
except Exception as exc:
|
||||
logger.warning("多轮记忆注入失败(降级为无历史): %s", exc)
|
||||
|
||||
@@ -30,7 +30,7 @@ from ..schemas import (
|
||||
SessionOut,
|
||||
SessionPatch,
|
||||
)
|
||||
from ..services.capabilities import sanitize_capabilities
|
||||
from ..services.capabilities import sanitize_bioflow_meta, sanitize_capabilities
|
||||
from ..upstream import envelope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -159,11 +159,17 @@ async def patch_session(
|
||||
)
|
||||
if payload.meta is not None:
|
||||
caps = sanitize_capabilities(payload.meta.get("capabilities"))
|
||||
if caps:
|
||||
has_bioflow = "bioflow" in payload.meta
|
||||
bioflow_patch = sanitize_bioflow_meta(payload.meta.get("bioflow"))
|
||||
if caps or has_bioflow:
|
||||
meta = session.meta.model_dump()
|
||||
merged_caps = dict(meta.get("capabilities") or {})
|
||||
merged_caps.update(caps)
|
||||
meta["capabilities"] = merged_caps
|
||||
if caps:
|
||||
merged_caps = dict(meta.get("capabilities") or {})
|
||||
merged_caps.update(caps)
|
||||
meta["capabilities"] = merged_caps
|
||||
if has_bioflow:
|
||||
# None 表示「清空绑定」(前端传 bioflow: {} 或 null)
|
||||
meta["bioflow"] = bioflow_patch
|
||||
await db.execute(
|
||||
"UPDATE sessions SET meta = ?, updated_at = ? WHERE id = ?",
|
||||
(dumps_json(meta), _db_now(), session_id),
|
||||
|
||||
+2
-53
@@ -99,58 +99,6 @@ 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 = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -297,7 +245,6 @@ 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,
|
||||
@@ -393,6 +340,8 @@ class GraphData(BaseModel):
|
||||
class SessionMeta(BaseModel):
|
||||
title_manual: bool = False
|
||||
capabilities: dict[str, bool] = Field(default_factory=dict)
|
||||
# S2 session↔job 绑定:{active_job_id, chain},由 QA 流 complete 回填、前门注入
|
||||
bioflow: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
|
||||
@@ -39,6 +39,13 @@ MANIFEST: Dict[str, Dict[str, Any]] = {
|
||||
"provider": "qa_gateway",
|
||||
"session_default": False,
|
||||
},
|
||||
"bioflow": {
|
||||
"name": "bioflow",
|
||||
"label": "生信对话式分析",
|
||||
"description": "自然语言对话式生信分析(基因家族/转录组,MCP 外挂,热装卸)",
|
||||
"provider": "qa_gateway",
|
||||
"session_default": False,
|
||||
},
|
||||
"mcp_plugins": {
|
||||
"name": "mcp_plugins",
|
||||
"label": "MCP 插件",
|
||||
@@ -55,7 +62,7 @@ DEFAULTS: Dict[str, bool] = {
|
||||
}
|
||||
|
||||
# 会话 meta.capabilities 白名单(允许 PATCH 写入的键)
|
||||
SESSION_KEYS = ("memory", "web_search", "humanize")
|
||||
SESSION_KEYS = ("memory", "web_search", "humanize", "bioflow")
|
||||
|
||||
|
||||
def sanitize_capabilities(raw: Any) -> Dict[str, bool]:
|
||||
@@ -75,6 +82,21 @@ def sanitize_capabilities(raw: Any) -> Dict[str, bool]:
|
||||
return out
|
||||
|
||||
|
||||
def sanitize_bioflow_meta(raw: Any) -> Dict[str, str] | None:
|
||||
"""S2 session↔job 绑定白名单:只收 {active_job_id, chain} 两个字符串键。
|
||||
|
||||
返回 None 表示「无有效绑定(清空/不改)」;调用方据此决定是否写 meta.bioflow。
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
out: Dict[str, str] = {}
|
||||
if raw.get("active_job_id") not in (None, ""):
|
||||
out["active_job_id"] = str(raw.get("active_job_id"))[:128]
|
||||
if raw.get("chain") not in (None, ""):
|
||||
out["chain"] = str(raw.get("chain"))[:64]
|
||||
return out or None
|
||||
|
||||
|
||||
def merged_capabilities(
|
||||
session_caps: Dict[str, bool] | None,
|
||||
qa_registry: List[Dict[str, Any]] | None = None,
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
"""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
|
||||
@@ -1,104 +0,0 @@
|
||||
"""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
|
||||
@@ -10,6 +10,7 @@ from app.services.capabilities import ( # noqa: E402
|
||||
DEFAULTS,
|
||||
MANIFEST,
|
||||
merged_capabilities,
|
||||
sanitize_bioflow_meta,
|
||||
sanitize_capabilities,
|
||||
)
|
||||
|
||||
@@ -50,3 +51,39 @@ class TestMerged:
|
||||
)
|
||||
assert body["installed"] == [{"name": "web_search", "description": "x"}]
|
||||
assert body["load_warnings"] == ["bad entry skipped"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# S2 sanitize_bioflow_meta(session↔job 绑定白名单)
|
||||
# ===========================================================================
|
||||
|
||||
class TestSanitizeBioflowMeta:
|
||||
def test_valid(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "job-1", "chain": "gene_family"}) == {
|
||||
"active_job_id": "job-1",
|
||||
"chain": "gene_family",
|
||||
}
|
||||
|
||||
def test_evil_key_dropped(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "j", "chain": "c", "evil": "x"}) == {
|
||||
"active_job_id": "j",
|
||||
"chain": "c",
|
||||
}
|
||||
|
||||
def test_non_dict_returns_none(self):
|
||||
assert sanitize_bioflow_meta(None) is None
|
||||
assert sanitize_bioflow_meta("x") is None
|
||||
assert sanitize_bioflow_meta(["a"]) is None
|
||||
|
||||
def test_empty_or_null_returns_none(self):
|
||||
assert sanitize_bioflow_meta({}) is None
|
||||
assert sanitize_bioflow_meta({"active_job_id": "", "chain": ""}) is None
|
||||
assert sanitize_bioflow_meta({"active_job_id": None, "chain": None}) is None
|
||||
|
||||
def test_partial(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "j"}) == {"active_job_id": "j"}
|
||||
assert sanitize_bioflow_meta({"chain": "c"}) == {"chain": "c"}
|
||||
|
||||
def test_truncation(self):
|
||||
assert sanitize_bioflow_meta({"active_job_id": "x" * 200})["active_job_id"] == "x" * 128
|
||||
assert sanitize_bioflow_meta({"chain": "y" * 100})["chain"] == "y" * 64
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""test_qa_inject_history.py —— _inject_history 的 bioflow 注入单测(S2)。
|
||||
|
||||
覆盖:session↔job 绑定(meta.bioflow)注入 body["bioflow"]、capabilities.bioflow 开关注入、
|
||||
memory 关跳过、显式 history 优先、会话不存在原样返回。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.routers import qa as qa_mod # noqa: E402
|
||||
|
||||
|
||||
class FakeRow(dict):
|
||||
"""aiosqlite.Row 最小替身。"""
|
||||
|
||||
def __getattr__(self, key):
|
||||
return self[key]
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self, row=None):
|
||||
self.row = row
|
||||
|
||||
async def fetchone(self, sql, params):
|
||||
return self.row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_bioflow_binding(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
row = FakeRow(meta='{"capabilities": {"bioflow": true, "memory": true}, '
|
||||
'"bioflow": {"active_job_id": "job-1", "chain": "gene_family"}}')
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(row))
|
||||
|
||||
async def fake_assemble(db, session_id):
|
||||
return [{"role": "user", "content": "上轮"}, {"role": "assistant", "content": "上答"}]
|
||||
|
||||
monkeypatch.setattr(qa_mod, "assemble_history", fake_assemble)
|
||||
body, humanize = await qa_mod._inject_history({"session_id": "s1", "question": "继续"})
|
||||
assert body["bioflow"] == {"session_id": "s1", "active_job_id": "job-1", "chain": "gene_family"}
|
||||
assert body["capabilities"] == {"web_search": False, "superpowers": False, "bioflow": True}
|
||||
assert body["history"] == [{"role": "user", "content": "上轮"}, {"role": "assistant", "content": "上答"}]
|
||||
assert humanize is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_bioflow_meta_injects_nothing(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
row = FakeRow(meta='{"capabilities": {"memory": true}}') # 无 bioflow 绑定
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(row))
|
||||
|
||||
async def fake_assemble(db, session_id):
|
||||
return [{"role": "user", "content": "上轮"}]
|
||||
|
||||
monkeypatch.setattr(qa_mod, "assemble_history", fake_assemble)
|
||||
body, _ = await qa_mod._inject_history({"session_id": "s1"})
|
||||
assert "bioflow" not in body
|
||||
assert body["capabilities"]["bioflow"] is False # 未开 bioflow 开关
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_off_skips_injection(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
row = FakeRow(meta='{"capabilities": {"memory": false, "bioflow": true}, '
|
||||
'"bioflow": {"active_job_id": "j"}}')
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(row))
|
||||
body, _ = await qa_mod._inject_history({"session_id": "s1"})
|
||||
assert "bioflow" not in body
|
||||
assert "capabilities" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_not_found_returns_body_unchanged(monkeypatch):
|
||||
import app.db as db_mod
|
||||
|
||||
monkeypatch.setattr(db_mod, "db", FakeDB(None)) # fetchone → None
|
||||
body, _ = await qa_mod._inject_history({"session_id": "s1", "question": "q"})
|
||||
assert body == {"question": "q"} # session_id 被 pop,其余原样
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_history_takes_priority(monkeypatch):
|
||||
body, _ = await qa_mod._inject_history(
|
||||
{"session_id": "s1", "history": [{"role": "user", "content": "x"}]}
|
||||
)
|
||||
assert body["history"] == [{"role": "user", "content": "x"}]
|
||||
assert "bioflow" not in body # 显式 history 优先 → 不读 meta
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_session_id_returns_body_unchanged(monkeypatch):
|
||||
body, _ = await qa_mod._inject_history({"question": "q"})
|
||||
assert body == {"question": "q"}
|
||||
@@ -73,7 +73,7 @@ def test_session_crud_full_flow(client):
|
||||
assert sess["title"] == "新会话"
|
||||
assert sess["status"] == "idle"
|
||||
assert sess["message_count"] == 0
|
||||
assert sess["meta"] == {"title_manual": False, "capabilities": {}}
|
||||
assert sess["meta"] == {"title_manual": False, "capabilities": {}, "bioflow": None}
|
||||
|
||||
r2 = client.post("/api/sessions", json={"title": " 水稻SPL "})
|
||||
sess2 = r2.json()
|
||||
@@ -132,7 +132,7 @@ def test_session_crud_full_flow(client):
|
||||
# PATCH 会话标题 → title_manual=true
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"title": "手工标题"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"] == {"title_manual": True, "capabilities": {}}
|
||||
assert r.json()["meta"] == {"title_manual": True, "capabilities": {}, "bioflow": None}
|
||||
|
||||
# PATCH 会话状态
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"status": "done"})
|
||||
@@ -149,6 +149,29 @@ def test_session_crud_full_flow(client):
|
||||
assert body["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_session_bioflow_binding_patch(client):
|
||||
"""S2 session↔job 绑定:PATCH meta.bioflow 白名单落库 + 串行往返。"""
|
||||
r = client.post("/api/sessions", json={})
|
||||
sess = r.json()
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
# 只收 active_job_id / chain 两个键,非法键丢弃
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={
|
||||
"meta": {"bioflow": {"active_job_id": "job-42", "chain": "gene_family", "evil": "x"}}
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["meta"]["bioflow"] == {"active_job_id": "job-42", "chain": "gene_family"}
|
||||
|
||||
# 回读持久化
|
||||
r = client.get(f"/api/sessions/{sess['id']}")
|
||||
assert r.json()["meta"]["bioflow"] == {"active_job_id": "job-42", "chain": "gene_family"}
|
||||
|
||||
# 空 dict → 清空绑定
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"meta": {"bioflow": {}}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"]["bioflow"] is None
|
||||
|
||||
|
||||
def test_not_found_envelopes(client):
|
||||
cases = [
|
||||
("get", "/api/sessions/nope", None),
|
||||
|
||||
@@ -8,6 +8,10 @@ import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useBenchStore } from '../../../../ui/src/stores/bench.js';
|
||||
|
||||
vi.mock('../../../../ui/src/api/capabilities.js', () => ({
|
||||
getCapabilities: vi.fn(async () => ({ installed: [{ name: 'bioflow-gene-family' }] })),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
|
||||
bioflowHealth: vi.fn(async () => ({ ok: true, status: 'ok', db: { connected: true } })),
|
||||
listBioflowJobs: vi.fn(async () => ({ ok: true, data: { items: [], total: 0 } })),
|
||||
@@ -68,6 +72,7 @@ vi.mock('../../../../ui/src/api/qa.js', () => ({
|
||||
import BenchPanel from '../../../../ui/src/components/bench/BenchPanel.vue';
|
||||
import { useBioflowStore } from '../../../../ui/src/stores/bioflow.js';
|
||||
import * as bioflowApi from '../../../../ui/src/api/bioflow.js';
|
||||
import * as capabilitiesApi from '../../../../ui/src/api/capabilities.js';
|
||||
|
||||
describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
let pinia;
|
||||
@@ -78,14 +83,15 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
});
|
||||
|
||||
it('探活成功:五个既有 TAB + 生信任务/生信档案两 TAB(TC-BIO-JOB-001/PANE-001)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
expect(labels).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档', '生信任务', '生信档案']);
|
||||
});
|
||||
|
||||
it('探活失败(health 不可达):生信 TAB 不渲染(TC-BIO-JOB-012/PANE-013)', async () => {
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
it('能力未注册:生信 TAB 不渲染(S3 主信号 = 能力注册状态)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
@@ -94,13 +100,23 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
expect(labels).not.toContain('生信档案');
|
||||
});
|
||||
|
||||
it('探活恢复:强制重探后 TAB 出现(TC-BIO-JOB-013/PANE-014)', async () => {
|
||||
it('已注册但 health 不可达:TAB 仍显示(次信号不影响显隐)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const labels = w.findAll('.bench-tab').map((t) => t.text());
|
||||
expect(labels).toContain('生信任务');
|
||||
expect(labels).toContain('生信档案');
|
||||
});
|
||||
|
||||
it('探活恢复:能力注册后强制重探 TAB 出现(TC-BIO-JOB-013/PANE-014)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
expect(w.findAll('.bench-tab').map((t) => t.text())).not.toContain('生信任务');
|
||||
// 服务恢复:强制重探
|
||||
bioflowApi.bioflowHealth.mockResolvedValueOnce({ ok: true });
|
||||
// 能力安装:强制重探
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const bioflow = useBioflowStore();
|
||||
await bioflow.probe({ force: true });
|
||||
await flushPromises();
|
||||
@@ -109,6 +125,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
});
|
||||
|
||||
it('切到生信任务 TAB:渲染 BioJobsPane(TC-BIO-JOB-001)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const biojobsTab = w.findAll('.bench-tab').find((t) => t.text() === '生信任务');
|
||||
@@ -119,6 +136,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => {
|
||||
});
|
||||
|
||||
it('切到生信档案 TAB:渲染 BioArchivePane(TC-BIO-PANE-001)', async () => {
|
||||
capabilitiesApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
const w = mount(BenchPanel, { global: { plugins: [pinia] } });
|
||||
await flushPromises();
|
||||
const bioarchiveTab = w.findAll('.bench-tab').find((t) => t.text() === '生信档案');
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
STAGE_STATUS_BY_EVENT,
|
||||
} from '../../../../ui/src/stores/bioflow.js';
|
||||
|
||||
// mock api/capabilities.js(S3 双信号主信号源)
|
||||
vi.mock('../../../../ui/src/api/capabilities.js', () => ({
|
||||
getCapabilities: vi.fn(async () => ({ installed: [{ name: 'bioflow-gene-family' }] })),
|
||||
}));
|
||||
|
||||
// mock api/bioflow.js(SSE 用 mock,不依赖生产服务;vi.mock 工厂内不得引用顶层变量)
|
||||
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
|
||||
bioflowHealth: vi.fn(async () => ({ ok: true, status: 'ok', db: { connected: true } })),
|
||||
@@ -176,20 +181,36 @@ describe('bioflow store —— 探活 / 动作 / 面板 / 复用', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('探活 200 → online;失败 → offline(TC-BIO-STORE-016)', async () => {
|
||||
it('探活 能力注册 → online;未注册 → offline(TC-BIO-STORE-016 / S3 双信号)', async () => {
|
||||
const capsApi = await import('../../../../ui/src/api/capabilities.js');
|
||||
const store = useBioflowStore();
|
||||
capsApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-gene-family' }] });
|
||||
bioflowApi.bioflowHealth.mockResolvedValueOnce({ ok: true });
|
||||
await store.probe({ force: true });
|
||||
expect(store.capabilityInstalled).toBe(true);
|
||||
expect(store.bioflowAvailable).toBe(true);
|
||||
|
||||
const s2 = useBioflowStore();
|
||||
s2.availability = 'unknown';
|
||||
capsApi.getCapabilities.mockResolvedValueOnce({ installed: [] });
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
await s2.probe({ force: true });
|
||||
expect(s2.capabilityInstalled).toBe(false);
|
||||
expect(s2.availability).toBe('offline');
|
||||
expect(s2.bioflowAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('探活 已注册但服务不可达 → TAB 仍显示(次信号不影响显隐)', async () => {
|
||||
const capsApi = await import('../../../../ui/src/api/capabilities.js');
|
||||
const store = useBioflowStore();
|
||||
capsApi.getCapabilities.mockResolvedValueOnce({ installed: [{ name: 'bioflow-rnaseq' }] });
|
||||
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
|
||||
await store.probe({ force: true });
|
||||
expect(store.capabilityInstalled).toBe(true);
|
||||
expect(store.healthOnline).toBe(false);
|
||||
expect(store.bioflowAvailable).toBe(true); // 已注册 → TAB 仍显示
|
||||
});
|
||||
|
||||
it('applyChatEvent plan_ready 装载候选路径(TC-BIO-STORE-003)', () => {
|
||||
const store = useBioflowStore();
|
||||
store.applyChatEvent('plan_ready', {
|
||||
|
||||
@@ -82,9 +82,18 @@ function setup() {
|
||||
bioflow.currentJob.jobId = 'job-1';
|
||||
const chat = useChatStore();
|
||||
chat.sessionId = 'sess-1';
|
||||
// S3:卡片操作改走 card_result 进 QA 流——单测把 sendCardResult 打桩为「挂起中」
|
||||
//(模拟真实 QA 流在 complete 前不 resolve),断言其被以正确 card/payload 调用;
|
||||
// 挂起态也保证 confirming 防双击守卫在测试里不被 finally 提前复位。
|
||||
vi.spyOn(chat, 'sendCardResult').mockImplementation(() => new Promise(() => {}));
|
||||
return { bioflow, chat };
|
||||
}
|
||||
|
||||
/** 当前活跃 chat store 的 sendCardResult spy(setup 已打桩)。 */
|
||||
function sendCardSpy() {
|
||||
return useChatStore().sendCardResult;
|
||||
}
|
||||
|
||||
const PATH_BLOCK = () => ({
|
||||
type: 'bioflow_path',
|
||||
status: 'done',
|
||||
@@ -169,9 +178,11 @@ describe('FlowPathCard —— TC-BIO-PATH-001~022(L4)', () => {
|
||||
await w.find('.fpc-confirm').trigger('click'); // 进确认视图
|
||||
await w.findAll('.fpc-confirm')[0].trigger('click'); // 确认创建
|
||||
await flushPromises();
|
||||
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
|
||||
const body = bioflowApi.createBioflowJob.mock.calls[0][0];
|
||||
expect(body.stages).toEqual(['identify', 'align', 'phylogeny']);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
const [card, payload] = sendCardSpy().mock.calls[0];
|
||||
expect(card).toBe('path_selection');
|
||||
expect(payload.chain).toBe('gene_family');
|
||||
expect(payload.stages).toEqual(['identify', 'align', 'phylogeny']);
|
||||
});
|
||||
|
||||
it('确认按钮双击只触发一次创建(TC-BIO-ACT-008 防重复)', async () => {
|
||||
@@ -182,7 +193,7 @@ describe('FlowPathCard —— TC-BIO-PATH-001~022(L4)', () => {
|
||||
await confirmBtn.trigger('click');
|
||||
await confirmBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('键盘 Enter 触发确认(TC-BIO-ACT-010)', async () => {
|
||||
@@ -272,11 +283,12 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
await w.findAll('.ffc-input')[0].setValue('8');
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
|
||||
const [jobId, stage, params] = bioflowApi.confirmBioflowStage.mock.calls[0];
|
||||
expect(jobId).toBe('job-1');
|
||||
expect(stage).toBe('identify');
|
||||
expect(params.cpu).toBe(8);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
const [card, payload] = sendCardSpy().mock.calls[0];
|
||||
expect(card).toBe('form_confirm');
|
||||
expect(payload.job_id).toBe('job-1');
|
||||
expect(payload.stage).toBe('identify');
|
||||
expect(payload.params.cpu).toBe(8);
|
||||
});
|
||||
|
||||
it('继续按钮双击仅一次请求(TC-BIO-ACT-021 提交中禁用)', async () => {
|
||||
@@ -285,7 +297,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
await btn.trigger('click');
|
||||
await btn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('数字类型校验:输入 abc 拦截(TC-BIO-FORM-014)', async () => {
|
||||
@@ -293,7 +305,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
await w.findAll('.ffc-input')[0].setValue('abc');
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
|
||||
expect(sendCardSpy()).not.toHaveBeenCalled();
|
||||
expect(w.find('.ffc-err').text()).toContain('类型');
|
||||
});
|
||||
|
||||
@@ -303,7 +315,7 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
const w = mount(FlowFormCard, { props: { block: blk } });
|
||||
await w.findAll('.ffc-input')[0].setValue('999');
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
|
||||
expect(sendCardSpy()).not.toHaveBeenCalled();
|
||||
expect(w.find('.ffc-err').text()).toContain('1 ~ 64');
|
||||
});
|
||||
|
||||
@@ -312,15 +324,15 @@ describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
|
||||
// species 默认空字符串
|
||||
await w.find('.ffc-continue').trigger('click');
|
||||
await flushPromises();
|
||||
const [, , params] = bioflowApi.confirmBioflowStage.mock.calls[0];
|
||||
expect(params.species).toBeUndefined();
|
||||
const [, payload] = sendCardSpy().mock.calls[0];
|
||||
expect(payload.params.species).toBeUndefined();
|
||||
});
|
||||
|
||||
it('键盘 Enter 提交(TC-BIO-FORM-023/ACT-016)', async () => {
|
||||
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
|
||||
await w.findAll('.ffc-input')[0].trigger('keydown', { key: 'Enter' });
|
||||
await flushPromises();
|
||||
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
|
||||
expect(sendCardSpy()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('键盘 Esc 恢复默认值(TC-BIO-FORM-024/ACT-017)', async () => {
|
||||
@@ -432,7 +444,7 @@ describe('FlowFileCard —— TC-BIO-FILE-001~022(L4)', () => {
|
||||
await w.find('.ffile-path-input').setValue('/data/examples/SPL.fa');
|
||||
await w.find('.ffile-path-row .pane-btn').trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalledWith('job-1', { server_path: '/data/examples/SPL.fa' });
|
||||
expect(sendCardSpy()).toHaveBeenCalledWith('file_upload', expect.objectContaining({ job_id: 'job-1', paths: ['/data/examples/SPL.fa'] }));
|
||||
});
|
||||
|
||||
it('登记路径按钮空 input 禁用(TC-BIO-ACT-048)', () => {
|
||||
@@ -445,7 +457,7 @@ describe('FlowFileCard —— TC-BIO-FILE-001~022(L4)', () => {
|
||||
await w.find('.ffile-path-input').setValue('/data/SPL.fa');
|
||||
await w.find('.ffile-path-input').trigger('keydown', { key: 'Enter' });
|
||||
await flushPromises();
|
||||
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalled();
|
||||
expect(sendCardSpy()).toHaveBeenCalledWith('file_upload', expect.objectContaining({ job_id: 'job-1', paths: ['/data/SPL.fa'] }));
|
||||
});
|
||||
|
||||
it('路径输入 Esc 清空(TC-BIO-ACT-044)', async () => {
|
||||
@@ -524,46 +536,15 @@ describe('FlowProgressCard —— TC-BIO-PROG-001~024(L4)', () => {
|
||||
expect(w.find('.pc-log-body').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('失败标红 ✕ + 状态文字 error 色 + 三选一按钮出现(TC-BIO-PROG-006/007)', () => {
|
||||
it('失败标红 ✕ + 状态文字 error 色(TC-BIO-PROG-006/007;S3 无三选一按钮)', () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: 'error', error: 'manifest_failed' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
const failedStep = w.findAll('.p-step')[1];
|
||||
expect(failedStep.classes()).toContain('failed');
|
||||
expect(failedStep.find('.p-dot').text()).toBe('✕');
|
||||
const actions = failedStep ? w.findAll('.pc-actions')[0] : null;
|
||||
expect(actions.text()).toContain('重跑');
|
||||
expect(actions.text()).toContain('跳过');
|
||||
expect(actions.text()).toContain('改参数重跑');
|
||||
});
|
||||
|
||||
it('重跑:发出 rerun 请求(TC-BIO-PROG-008/ACT-050)', async () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
const btn = w.findAll('.pc-actions .pane-btn')[0];
|
||||
await btn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.rerunBioflowStage).toHaveBeenCalledWith('job-1', 'align', null);
|
||||
});
|
||||
|
||||
it('跳过:发出 skip 请求(TC-BIO-PROG-009/ACT-051)', async () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
await w.findAll('.pc-actions .pane-btn')[1].trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.skipBioflowStage).toHaveBeenCalledWith('job-1', 'align');
|
||||
});
|
||||
|
||||
it('改参数重跑:弹出参数表单(TC-BIO-PROG-010/ACT-052)', async () => {
|
||||
const blk = PROG_BLOCK();
|
||||
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
|
||||
const w = mount(FlowProgressCard, { props: { block: blk } });
|
||||
await w.findAll('.pc-actions .pane-btn')[2].trigger('click');
|
||||
await flushPromises();
|
||||
expect(w.find('.pc-rerun-form').exists()).toBe(true);
|
||||
expect(bioflowApi.bioflowCatalog).toHaveBeenCalledWith({ stage: 'align' });
|
||||
// S3 精简:失败态不再渲染直连控制按钮(重跑/跳过/改参数重跑)
|
||||
expect(w.findAll('.pc-actions').length).toBe(0);
|
||||
});
|
||||
|
||||
it('skipped 态视觉(TC-BIO-PROG-011)', () => {
|
||||
@@ -604,24 +585,6 @@ describe('FlowProgressCard —— TC-BIO-PROG-001~024(L4)', () => {
|
||||
expect(w.findAll('.p-step').length).toBe(10);
|
||||
});
|
||||
|
||||
it('暂停/恢复按钮随任务状态显隐;点击发请求(TC-BIO-PROG-017/018)', async () => {
|
||||
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
|
||||
// running → 暂停按钮
|
||||
let pauseBtn = w.findAll('.pc-head-right .pane-btn').find((b) => b.text().includes('暂停'));
|
||||
expect(pauseBtn).toBeTruthy();
|
||||
await pauseBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.pauseBioflowJob).toHaveBeenCalledWith('job-1');
|
||||
// paused → 恢复按钮
|
||||
const blk = { ...PROG_BLOCK(), jobStatus: 'paused' };
|
||||
const w2 = mount(FlowProgressCard, { props: { block: blk } });
|
||||
const resumeBtn = w2.findAll('.pc-head-right .pane-btn').find((b) => b.text().includes('继续'));
|
||||
expect(resumeBtn).toBeTruthy();
|
||||
await resumeBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(bioflowApi.resumeBioflowJob).toHaveBeenCalledWith('job-1');
|
||||
});
|
||||
|
||||
it('快照整体替换:块 stages 引用变化后自动整体刷新(TC-BIO-PROG-015)', async () => {
|
||||
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
|
||||
const before = w.findAll('.p-step').length;
|
||||
|
||||
@@ -190,141 +190,3 @@ describe('消息更新器 —— 五类块 patch', () => {
|
||||
expect(blk.citations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBioflow —— 全流程(SSE 用 mock,不依赖生产服务)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
chatFrames = [];
|
||||
});
|
||||
|
||||
it('plan_ready + job_created + stage_await_confirm + job_done 事件流 patch 消息 blocks(TC-BIO-STORE-003/004/005)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: '标准流程', stages: ['identify', 'align'] }], source: 'llm_rag', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
frame('job_created', { job_id: 'j1', session_id: 'sess-1', title: 'SPL', stages: [{ stage: 'identify', status: 'waiting' }, { stage: 'align', status: 'waiting' }], auto_continue: false }),
|
||||
frame('stage_started', { job_id: 'j1', stage: 'identify', attempt: 1 }),
|
||||
frame('stage_await_confirm', { job_id: 'j1', stage: 'align', form_defaults: [{ name: 'cpu', default: 4, source: 'contract' }] }),
|
||||
frame('job_done', { job_id: 'j1', progress: 100 }),
|
||||
];
|
||||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||||
expect(chat.messages).toHaveLength(2);
|
||||
expect(chat.messages[0].role).toBe('user');
|
||||
expect(chat.messages[0].kind).toBe('bioflow');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.kind).toBe('bioflow');
|
||||
expect(aMsg.status).toBe('done');
|
||||
const types = aMsg.blocks.map((b) => b.type);
|
||||
expect(types).toContain('bioflow_path');
|
||||
expect(types).toContain('bioflow_progress');
|
||||
expect(types).toContain('bioflow_form');
|
||||
expect(types).toContain('bioflow_archive');
|
||||
const path = aMsg.blocks.find((b) => b.type === 'bioflow_path');
|
||||
expect(path.candidates).toHaveLength(1);
|
||||
const prog = aMsg.blocks.find((b) => b.type === 'bioflow_progress');
|
||||
expect(prog.jobId).toBe('j1');
|
||||
expect(prog.stages.map((s) => s.status)).toEqual(['running', 'waiting_confirm']);
|
||||
// 用户消息落库(骨架 + 终态 patch)
|
||||
expect(sessionCalls.messages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(sessionCalls.messages[0].body.role).toBe('user');
|
||||
});
|
||||
|
||||
it('plan_ready 后心跳流断连(无任务事件):path 卡保留,消息收口 done', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
];
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('done');
|
||||
expect(aMsg.blocks.find((b) => b.type === 'bioflow_path').candidates).toHaveLength(1);
|
||||
expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false);
|
||||
});
|
||||
|
||||
it('message_done flow_qa 分支:引用块渲染(FR-BIO-026 不新建任务)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('message_done', { kind: 'flow_qa', citations: [{ source: 'flow_docs', title_path: 'identify.md', text: '输入 fasta 与 hmm' }], degraded: false }),
|
||||
];
|
||||
await chat.sendBioflow('identify 需要什么输入');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('done');
|
||||
const qa = aMsg.blocks.find((b) => b.type === 'bioflow_qa');
|
||||
expect(qa.citations).toHaveLength(1);
|
||||
expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false);
|
||||
});
|
||||
|
||||
it('错误帧(统一信封):标注 error + 可重试', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('error', { code: 'UPSTREAM_DOWN', message: '生信服务当前不可用' }),
|
||||
];
|
||||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('error');
|
||||
expect(aMsg.errText).toBe('生信服务当前不可用');
|
||||
expect(chat.lastResult.ok).toBe(false);
|
||||
expect(chat.lastResult.error.code).toBe('UPSTREAM_DOWN');
|
||||
});
|
||||
|
||||
it('网络中断:标注 disconnected;任务状态保留(快照兜底不丢进度)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
frame('job_created', { job_id: 'j1', stages: [{ stage: 'identify', status: 'waiting' }], auto_continue: false }),
|
||||
frame('stage_started', { job_id: 'j1', stage: 'identify', attempt: 1 }),
|
||||
];
|
||||
// 让 fetch 在流消费中途失败:mock 抛网络错误
|
||||
bioflowApi.bioflowChatStream.mockRejectedValueOnce(new Error('network'));
|
||||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('error');
|
||||
expect(aMsg.streamErr).toBe('disconnected');
|
||||
expect(chat.lastResult.ok).toBe(false);
|
||||
expect(chat.lastResult.error.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('retry:bioflow 失败消息同位置替换走生信路径(TC-BIO-STORE-022 语义)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [frame('error', { code: 'UPSTREAM_DOWN', message: 'x' })];
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
expect(chat.messages).toHaveLength(2);
|
||||
// 重试成功路径
|
||||
chatFrames = [
|
||||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||||
];
|
||||
await chat.retry();
|
||||
expect(chat.messages).toHaveLength(2); // 同位置替换不新增
|
||||
const aMsg = chat.messages[1];
|
||||
expect(aMsg.status).toBe('done');
|
||||
expect(aMsg.kind).toBe('bioflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('分流纪律(漏判安全)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sendBioflow 不经过 qa 流(bioflowChatStream 独立端点)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
chatFrames = [frame('plan_ready', { candidates: [], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null })];
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
expect(bioflowApi.bioflowChatStream).toHaveBeenCalledTimes(1);
|
||||
const body = bioflowApi.bioflowChatStream.mock.calls[0][0];
|
||||
expect(body.session_id).toBe('sess-1');
|
||||
expect(body.message).toBe('跑 SPL 家族');
|
||||
expect(body).toHaveProperty('has_bioflow_context');
|
||||
});
|
||||
|
||||
it('busy 时不重复发起(并发防护)', async () => {
|
||||
const { chat } = await setupChat();
|
||||
vi.clearAllMocks();
|
||||
chat.streamCtx['sess-1'] = { busy: true };
|
||||
await chat.sendBioflow('跑 SPL 家族');
|
||||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,18 +100,12 @@ describe('Composer 生信分流(漏判安全)', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('bioflow 意图:走 chat.sendBioflow(用户消息 kind=bioflow + path 块)', async () => {
|
||||
chatFrames = [{
|
||||
event: 'plan_ready',
|
||||
data: { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null },
|
||||
}];
|
||||
it('生信意图:统一走 QA 流(emit send,不产生 bioflow 直连请求;S3)', async () => {
|
||||
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
|
||||
await submit(w, '帮我跑水稻 SPL 家族鉴定');
|
||||
expect(bioflowApi.bioflowChatStream).toHaveBeenCalledTimes(1);
|
||||
const chat = useChatStore();
|
||||
expect(chat.messages).toHaveLength(2);
|
||||
expect(chat.messages[0].kind).toBe('bioflow');
|
||||
expect(chat.messages[1].blocks[0].type).toBe('bioflow_path');
|
||||
expect(w.emitted('send')).toBeTruthy();
|
||||
expect(w.emitted('send')[0][0]).toContain('帮我跑水稻 SPL 家族鉴定');
|
||||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('未命中词典:走原 send 路径(不产生 bioflow 请求;TC-BIO-STORE-002)', async () => {
|
||||
|
||||
@@ -1,118 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/frontend/unit/bioflow/intent.test.js —— 生信意图分流(L3)
|
||||
// 与 docs/team/cases_bioflow_intent.md 冻结词典 40 用例表同表双跑
|
||||
// (TC-BIO-INTENT-001~047;后端 intent.py 同词典同规则,L2 侧由 T 执行同表校验)。
|
||||
// tests/frontend/unit/bioflow/intent.test.js —— 意图分流(S3:生信词典删除后等价语义)
|
||||
// S3 起生信分析意图统一走 QA 流(bioflow_agent 服务端 nl_triggers 判定),
|
||||
// 前端 route() 不再产生 bioflow/bioflow_qa——本测试锁定该等价语义:
|
||||
// 生信句 → qa;文档意图(docgen/export/edit)不变;扩展创建意图保留。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { route, extractReuseName, hasAnyWord } from '../../../../ui/src/utils/intent.js';
|
||||
import { route, hasAnyWord, isExtensionCreateIntent } from '../../../../ui/src/utils/intent.js';
|
||||
|
||||
/** 词典用例表(cases_bioflow_intent.md §4):[text, attachments, activeArtifact, bioflowContext, expected] */
|
||||
const DICT_CASES = [
|
||||
// # text 附件 活动文档 生信上下文 期望
|
||||
['帮我跑水稻 SPL 家族鉴定', false, false, false, 'bioflow'],
|
||||
['跑一下拟南芥 MADS-box 家族分析', false, false, false, 'bioflow'],
|
||||
['请帮我鉴定水稻 SPL 家族', false, false, false, 'bioflow'],
|
||||
['识别玉米 bZIP 家族成员', false, false, false, 'bioflow'],
|
||||
['帮我研究一下水稻 SPL 基因家族', false, false, false, 'bioflow'],
|
||||
['分析小麦 WRKY 转录因子家族', false, false, false, 'bioflow'],
|
||||
['做个大豆 NAC 家族分析', false, false, false, 'bioflow'],
|
||||
['跑 SPL 家族', false, false, false, 'bioflow'],
|
||||
['水稻 SPL 家族,帮我跑', false, false, false, 'bioflow'],
|
||||
['基因家族鉴定:水稻 SPL', false, false, false, 'bioflow'],
|
||||
['帮我跑水稻叶片 RNA-seq 差异表达分析', false, false, false, 'bioflow'],
|
||||
['帮我做水稻全基因组变异检测', false, false, false, 'bioflow'],
|
||||
['帮我跑水稻产量性状 GWAS 关联分析', false, false, false, 'bioflow'],
|
||||
['什么是基因家族?', false, false, false, 'qa'],
|
||||
['帮我跑一下', false, false, false, 'qa'],
|
||||
['SPL 家族', false, false, false, 'qa'],
|
||||
['今天天气不错', false, false, false, 'qa'],
|
||||
['跑 SPL 家族鉴定需要准备什么输入?', false, false, false, 'qa'], // 无生信上下文漏判安全
|
||||
['帮我跑一个数据分析', false, false, false, 'qa'],
|
||||
['你好呀', false, false, false, 'qa'],
|
||||
['', false, false, false, 'qa'], // 空字符串
|
||||
['帮我跑香蕉 XYK 家族鉴定', false, false, false, 'qa'], // XYK 不在家族词典
|
||||
['跑水稻 spl 家族鉴定', false, false, false, 'bioflow'], // 家族名大小写不敏感
|
||||
['Please run rice SPL family identification', false, false, false, 'bioflow'],
|
||||
['帮我跑(水稻 SPL 家族)鉴定!', false, false, false, 'bioflow'],
|
||||
['用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定', false, false, false, 'bioflow'], // 复用意图
|
||||
['根据 XXX 存档任务的方法帮我跑一下玉米 bZIP', false, false, false, 'bioflow'],
|
||||
['参考 XXX 存档的方法再跑一个水稻 SPL', false, false, false, 'bioflow'],
|
||||
['用《XXX》存档任务的方法帮我跑小麦 WRKY 家族', false, false, false, 'bioflow'],
|
||||
['用 XXX 存档任务的方法', false, false, false, 'qa'], // 无目标任务词
|
||||
['用 XXX 存档任务的方法帮我跑 yyy', false, false, false, 'qa'], // yyy 无家族/对象匹配
|
||||
['帮我生成一份水稻 SPL 家族分析报告', false, false, false, 'docgen'], // docgen 优先于 bioflow
|
||||
['帮我写一篇水稻基因家族研究论文', false, false, false, 'docgen'],
|
||||
['导出成 PDF', false, true, false, 'export'],
|
||||
['把这句话改成红色', false, true, false, 'edit'], // 无图表词
|
||||
['xxx 图表颜色换成红色', false, true, false, 'bioflow'], // 图表词走 FR-BIO-019
|
||||
['identify 需要什么输入', false, false, true, 'bioflow_qa'], // 不新建任务
|
||||
['帮我总结一下这个问题的要点', false, false, false, 'qa'],
|
||||
['写一段水稻基因的介绍', false, false, false, 'qa'], // 介绍在 docgen 排除表
|
||||
['跑水稻 SPL 家族鉴定', false, true, false, 'bioflow'], // 活动文档不改判定
|
||||
];
|
||||
|
||||
describe('intent route —— 词典 40 用例同表双跑', () => {
|
||||
DICT_CASES.forEach(([text, att, artifact, ctx, expected], i) => {
|
||||
const label = text ? text.slice(0, 24) : '(空字符串)';
|
||||
it(`#${i + 1} 「${label}」 → ${expected}`, () => {
|
||||
const { intent } = route(text, att, artifact, ctx);
|
||||
expect(intent).toBe(expected);
|
||||
});
|
||||
describe('route —— S3 生信意图一律 qa(漏判安全)', () => {
|
||||
it('生信分析句 → qa(不再产生 bioflow 分流)', () => {
|
||||
for (const t of [
|
||||
'帮我跑水稻 SPL 家族鉴定',
|
||||
'跑一下拟南芥 MADS-box 家族分析',
|
||||
'帮我跑水稻叶片 RNA-seq 差异表达分析',
|
||||
'帮我做水稻全基因组变异检测',
|
||||
'识别玉米 bZIP 家族成员',
|
||||
'用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定',
|
||||
'xxx 图表颜色换成红色',
|
||||
]) {
|
||||
expect(route(t, false, false, false).intent).toBe('qa');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('intent route —— 边界与实现注意(词典 §5)', () => {
|
||||
it('文本长度 ≤1 或纯空白 → qa', () => {
|
||||
it('生信流程咨询句 → qa(bioflow_qa 已删除)', () => {
|
||||
expect(route('identify 需要什么输入', false, false, true).intent).toBe('qa');
|
||||
});
|
||||
|
||||
it('文档意图 docgen 不变', () => {
|
||||
expect(route('帮我写一份水稻分析报告').intent).toBe('docgen');
|
||||
expect(route('帮我整理一下', true, false, false).intent).toBe('docgen');
|
||||
});
|
||||
|
||||
it('导出/编辑需活动文档才生效(docgen 语义不变)', () => {
|
||||
expect(route('导出成 PDF', false, true, false).intent).toBe('export');
|
||||
expect(route('导出成 PDF', false, false, false).intent).toBe('qa');
|
||||
expect(route('把这句话改成红色', false, true, false).intent).toBe('edit');
|
||||
});
|
||||
|
||||
it('边界:长度 ≤1/空/纯空白 → qa', () => {
|
||||
expect(route('a').intent).toBe('qa');
|
||||
expect(route(' ').intent).toBe('qa');
|
||||
expect(route(null).intent).toBe('qa');
|
||||
expect(route(undefined).intent).toBe('qa');
|
||||
});
|
||||
it('>2000 字符按截断前 2000 判定(长前缀不淹没意图)', () => {
|
||||
const long = '啊'.repeat(3000) + ' 帮我跑水稻 SPL 家族鉴定';
|
||||
expect(route(long).intent).toBe('qa'); // 意图词在 2000 之后被截掉
|
||||
const short = '帮我跑水稻 SPL 家族鉴定' + '啊'.repeat(3000);
|
||||
expect(route(short).intent).toBe('bioflow');
|
||||
});
|
||||
it('bioflow_qa 仅在 has_bioflow_context=true 生效', () => {
|
||||
expect(route('identify 需要什么输入', false, false, true).intent).toBe('bioflow_qa');
|
||||
expect(route('identify 需要什么输入', false, false, false).intent).toBe('qa');
|
||||
});
|
||||
it('家族名后须跟语境词或位于句尾(SPL 会议不命中)', () => {
|
||||
expect(route('跑 SPL 会议').intent).toBe('qa');
|
||||
expect(route('帮我跑玉米 bZIP').intent).toBe('bioflow'); // 句尾命中
|
||||
});
|
||||
it('「做」动词需对象组合(分析对象词命中)', () => {
|
||||
expect(route('帮我做水稻全基因组变异检测').intent).toBe('bioflow');
|
||||
expect(route('帮我做个分析').intent).toBe('qa'); // 无对象词
|
||||
});
|
||||
it('附件 + 写类动词 → docgen(兜底信号,词典规则 4)', () => {
|
||||
expect(route('帮我整理一下', true, false, false).intent).toBe('docgen');
|
||||
});
|
||||
it('导出需 has_active_artifact 才生效', () => {
|
||||
expect(route('导出成 PDF', false, false, false).intent).toBe('qa');
|
||||
});
|
||||
it('命中句不打乱后续 QA 判定(无状态残留)', () => {
|
||||
expect(route('什么是 SPL 家族').intent).toBe('qa');
|
||||
expect(route('帮我跑水稻 SPL 家族鉴定').intent).toBe('bioflow');
|
||||
expect(route('什么是 SPL 家族').intent).toBe('qa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractReuseName —— 复用方法名提取(词典实现注意 2)', () => {
|
||||
it('标准句式:方法名 = REUSE_PHRASE 之前最近的 3-20 字符片段', () => {
|
||||
expect(extractReuseName('用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定')).toBe('XXX');
|
||||
expect(extractReuseName('根据 XXX 存档任务的方法帮我跑一下玉米 bZIP')).toBe('XXX');
|
||||
expect(extractReuseName('参考 XXX 存档的方法再跑一个水稻 SPL')).toBe('XXX');
|
||||
expect(extractReuseName('用《XXX》存档任务的方法帮我跑小麦 WRKY 家族')).toBe('XXX');
|
||||
describe('isExtensionCreateIntent —— 扩展创建意图(S4 保留)', () => {
|
||||
it('创建/上传 扩展句式命中', () => {
|
||||
expect(isExtensionCreateIntent('帮我创建一个分析扩展')).toBe(true);
|
||||
expect(isExtensionCreateIntent('上传一个自定义分析工具')).toBe(true);
|
||||
});
|
||||
it('非复用句式返回 null', () => {
|
||||
expect(extractReuseName('帮我跑水稻 SPL 家族鉴定')).toBeNull();
|
||||
expect(extractReuseName('')).toBeNull();
|
||||
});
|
||||
it('片段长度不足 3 或超 20 返回 null', () => {
|
||||
expect(extractReuseName('用 AB 存档任务的方法帮我跑水稻 SPL 家族鉴定')).toBeNull();
|
||||
// 21 字符片段超上限
|
||||
expect(extractReuseName('用 一二三四五六七八九十一二三四五六七八九十一 存档任务的方法帮我跑水稻 SPL')).toBeNull();
|
||||
it('非扩展句不命中', () => {
|
||||
expect(isExtensionCreateIntent('帮我跑水稻 SPL 家族鉴定')).toBe(false);
|
||||
expect(isExtensionCreateIntent('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/frontend/unit/bioflow/legacy.block.test.js —— S3 旧生信块只读降级(LegacyBioflowBlock)
|
||||
// 覆盖:四类旧块标题映射 / bioflow_qa 引用列表(含空态)/ 任务类块 jobId 前缀 / 只读(无可交互控件)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import LegacyBioflowBlock from '../../../../ui/src/components/chat/LegacyBioflowBlock.vue';
|
||||
|
||||
describe('LegacyBioflowBlock —— 旧生信块只读降级(S3)', () => {
|
||||
it('bioflow_qa 渲染只读引用列表(title + citations)', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: {
|
||||
block: {
|
||||
type: 'bioflow_qa',
|
||||
citations: [{ text: '引用正文', source: 's1', title_path: 't1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(w.find('.legacy-bioflow-block').exists()).toBe(true);
|
||||
expect(w.findAll('.lbf-cite')).toHaveLength(1);
|
||||
expect(w.text()).toContain('引用正文');
|
||||
});
|
||||
|
||||
it('bioflow_qa 空引用显示空态文案', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: { block: { type: 'bioflow_qa', citations: [] } },
|
||||
});
|
||||
expect(w.find('.lbf-empty').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('任务类块(bioflow_progress)显示 jobId 前缀 + 只读提示', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: { block: { type: 'bioflow_progress', jobId: 'job-9' } },
|
||||
});
|
||||
expect(w.find('.lbf-job').exists()).toBe(true);
|
||||
expect(w.text()).toContain('job-9');
|
||||
expect(w.find('.lbf-empty').exists()).toBe(true); // 只读提示
|
||||
});
|
||||
|
||||
it('各类旧块标题映射非空(path/form/file/archive/progress)', () => {
|
||||
for (const type of ['bioflow_path', 'bioflow_form', 'bioflow_file', 'bioflow_archive', 'bioflow_progress']) {
|
||||
const w = mount(LegacyBioflowBlock, { props: { block: { type } } });
|
||||
expect(w.find('.lbf-title').text()).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('未知类型回退 legacyBlockTitle 不抛错', () => {
|
||||
const w = mount(LegacyBioflowBlock, { props: { block: { type: 'bioflow_unknown' } } });
|
||||
expect(w.find('.lbf-title').text()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('只读:不渲染 button/input/a 等可交互控件(旧块 hydrate 不报错)', () => {
|
||||
const w = mount(LegacyBioflowBlock, {
|
||||
props: { block: { type: 'bioflow_progress', jobId: 'j1', citations: [] } },
|
||||
});
|
||||
expect(w.find('button').exists()).toBe(false);
|
||||
expect(w.find('input').exists()).toBe(false);
|
||||
expect(w.find('a').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -331,3 +331,27 @@ describe('BioArchivePane —— TC-BIO-PANE-001~016(L4)', () => {
|
||||
expect(sizes[0].text()).toContain('KB');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// S3:BioJobsPane 日志自动滚底(实时跟随最新一行)
|
||||
// ===========================================================================
|
||||
describe('BioJobsPane 日志自动滚底(S3)', () => {
|
||||
it('展开日志后追加行 → scrollTop 置为 scrollHeight(保持在最新一行)', async () => {
|
||||
const w = mount(BioJobsPane);
|
||||
await flushPromises();
|
||||
await w.findAll('.bj-item')[0].trigger('click');
|
||||
await flushPromises();
|
||||
await w.findAll('.bj-dag-row')[0].trigger('click');
|
||||
await flushPromises();
|
||||
const pre = w.find('.bj-log-body').element;
|
||||
// jsdom 不实现 scrollHeight(恒 0),此处 mock 为确定值以验证滚底赋值
|
||||
Object.defineProperty(pre, 'scrollHeight', { configurable: true, get: () => 800 });
|
||||
// 追加一行日志(轮询增量)→ watch 触发 → nextTick 滚底
|
||||
const bioflow = useBioflowStore();
|
||||
bioflow.stageLogs['job-1:identify'].lines.push('appended-log-line');
|
||||
await flushPromises();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // 放行 nextTick 链
|
||||
expect(pre.scrollTop).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/frontend/unit/bioflow/settings.test.js —— S5 生信能力装卸(settings store)
|
||||
// 覆盖:isInstalled 判定 / toggleInstalled register↔unregister / 未知能力抛错 / 装卸后重拉清单。
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
vi.mock('../../../../ui/src/api/capabilities.js', () => ({
|
||||
installCapability: vi.fn(async () => ({ ok: true })),
|
||||
BIOFLOW_MANIFESTS: {
|
||||
'bioflow-gene-family': 'capabilities:\n - name: bioflow-gene-family',
|
||||
'bioflow-rnaseq': 'capabilities:\n - name: bioflow-rnaseq',
|
||||
'bioflow-extension': 'capabilities:\n - name: bioflow-extension',
|
||||
},
|
||||
getCapabilities: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../ui/src/api/http.js', () => ({
|
||||
get: vi.fn(async () => ({ capabilities: [], defaults: {}, installed: [], load_warnings: [] })),
|
||||
post: vi.fn(async () => ({})),
|
||||
patch: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
import { useSettingsStore } from '../../../../ui/src/stores/settings.js';
|
||||
import * as capabilitiesApi from '../../../../ui/src/api/capabilities.js';
|
||||
|
||||
function setup() {
|
||||
setActivePinia(createPinia());
|
||||
return useSettingsStore();
|
||||
}
|
||||
|
||||
describe('SettingsStore —— 生信能力装卸(S5)', () => {
|
||||
beforeEach(() => {
|
||||
capabilitiesApi.installCapability.mockClear();
|
||||
});
|
||||
|
||||
it('isInstalled:installed 数组含该能力名 → true,否则 false', () => {
|
||||
const s = setup();
|
||||
s.installed = [{ name: 'bioflow-gene-family' }];
|
||||
expect(s.isInstalled('bioflow-gene-family')).toBe(true);
|
||||
expect(s.isInstalled('bioflow-rnaseq')).toBe(false);
|
||||
expect(s.isInstalled('bioflow-extension')).toBe(false);
|
||||
});
|
||||
|
||||
it('toggleInstalled:未安装 → register;已安装 → unregister', async () => {
|
||||
const s = setup();
|
||||
s.installed = [];
|
||||
await s.toggleInstalled('bioflow-gene-family');
|
||||
expect(capabilitiesApi.installCapability).toHaveBeenLastCalledWith(
|
||||
'register', expect.any(String), 'bioflow-gene-family',
|
||||
);
|
||||
|
||||
s.installed = [{ name: 'bioflow-gene-family' }];
|
||||
await s.toggleInstalled('bioflow-gene-family');
|
||||
expect(capabilitiesApi.installCapability).toHaveBeenLastCalledWith(
|
||||
'unregister', '', 'bioflow-gene-family',
|
||||
);
|
||||
});
|
||||
|
||||
it('toggleInstalled:未知生信能力抛错', async () => {
|
||||
const s = setup();
|
||||
await expect(s.toggleInstalled('bogus')).rejects.toThrow('未知生信能力');
|
||||
});
|
||||
|
||||
it('toggleInstalled:装卸后重拉清单刷新 installed(load 被调用)', async () => {
|
||||
const s = setup();
|
||||
s.installed = [];
|
||||
const loadSpy = vi.spyOn(s, 'load').mockResolvedValue(undefined);
|
||||
await s.toggleInstalled('bioflow-rnaseq');
|
||||
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
"""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
|
||||
@@ -1,33 +0,0 @@
|
||||
# 论文撰写功能 手工验收手册
|
||||
|
||||
## 前置(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 按首次出现顺序重排编号;正文与文末列表同源生成。
|
||||
@@ -1,5 +0,0 @@
|
||||
[pytest]
|
||||
python_files = test_*.py
|
||||
markers =
|
||||
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
|
||||
paper_mcp: MCP 协议层测试(list_tools/call_tool)
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,69 +0,0 @@
|
||||
"""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
|
||||
@@ -1,124 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,35 +0,0 @@
|
||||
"""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"})
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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);
|
||||
@@ -1,272 +0,0 @@
|
||||
"""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]"
|
||||
@@ -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)时渲染为 $GENE_FAMILY_ROOT/<值>;
|
||||
# 为空时回退 $GENE_FAMILY_ROOT/configs/rag/<缺省文件名>。registry_path 为空则整行省略。
|
||||
# config 类路径:manifest 值为相对路径(如 configs/rag/entity_alias.yaml)时渲染为 $QA_ROOT/<值>;
|
||||
# 为空时回退 $QA_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=\$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"
|
||||
[ -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"
|
||||
} > "$tmp"
|
||||
mv "$tmp" "$OUT"
|
||||
|
||||
@@ -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" "app/routers/paper.py" "app/services/paper/agent.py" "app/services/paper/mcp_client.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")
|
||||
# 白名单理由:main.py=uvicorn 入口;config.py=运维可读面;
|
||||
# schemas.py=pydantic v2 模型(validator 方法被 Cython 转 cyfunction 触发
|
||||
# PydanticUserError,实测编译后无法导入——纯数据契约面,无敏感逻辑,明文保留);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// api/capabilities.js —— 能力清单端点(S4 BFF;GET /api/capabilities)+ 装卸接口(S5)。
|
||||
// 生信 TAB 显隐的主信号源:installed 数组含 bioflow-* 能力即视为「生信能力已安装」。
|
||||
import { get, post } from './http.js';
|
||||
|
||||
/** GET /api/capabilities —— 能力清单 + 默认值 + 当前会话开关 + QA 已装插件 */
|
||||
export function getCapabilities(params = {}, opts = {}) {
|
||||
return get('/api/capabilities', params, { timeout: 5000, ...opts });
|
||||
}
|
||||
|
||||
/** POST /api/capabilities/install —— 按能力 register/unregister(透传 QA,热加载不重启) */
|
||||
export function installCapability(action, manifest, name) {
|
||||
return post('/api/capabilities/install', { action, manifest, name });
|
||||
}
|
||||
|
||||
// 三个生信能力的 register manifest(与 agent/qa/configs/capabilities.yaml 一致,热装卸用)。
|
||||
export const BIOFLOW_MANIFESTS = {
|
||||
'bioflow-gene-family': [
|
||||
'capabilities:',
|
||||
' - name: bioflow-gene-family',
|
||||
' provider: mcp',
|
||||
' permission: read_only',
|
||||
' description: 基因家族分析(路径规划/建任务/参数确认/文件获取与预处理/状态查询),自然语言触发',
|
||||
' session_default: true',
|
||||
' nl_triggers: [基因家族, 家族成员, 亚家族, 系统发育, 建树, 进化树, 保守基序, motif, 结构域, 基因结构, 共线性, Ka/Ks, 选择压力, 组合证据]',
|
||||
' mcp:',
|
||||
' url: http://127.0.0.1:8098/mcp/gene-family/',
|
||||
' prompt_pack:',
|
||||
' file: bioflow_gene_family_agent_rules.md',
|
||||
].join('\n'),
|
||||
'bioflow-rnaseq': [
|
||||
'capabilities:',
|
||||
' - name: bioflow-rnaseq',
|
||||
' provider: mcp',
|
||||
' permission: read_only',
|
||||
' description: 转录组分析(实验设计确认/样本配置/路径规划/建任务/差异表达/富集),自然语言触发',
|
||||
' session_default: true',
|
||||
' nl_triggers: [转录组, RNA-seq, RNAseq, rnaseq, 差异表达, 表达定量, 富集, WGCNA, GSEA, 实验设计, fastq]',
|
||||
' mcp:',
|
||||
' url: http://127.0.0.1:8098/mcp/rnaseq/',
|
||||
' prompt_pack:',
|
||||
' file: bioflow_rnaseq_agent_rules.md',
|
||||
].join('\n'),
|
||||
'bioflow-extension': [
|
||||
'capabilities:',
|
||||
' - name: bioflow-extension',
|
||||
' provider: mcp',
|
||||
' permission: read_only',
|
||||
' description: 用户自定义扩展分析(创建/转换/试运行/校验/发布/运行),自然语言触发',
|
||||
' session_default: true',
|
||||
' nl_triggers: [扩展, 拓展分析, 扩展分析, 自定义分析, 分析模板, 分析脚本, 分析工具]',
|
||||
' mcp:',
|
||||
' url: http://127.0.0.1:8098/mcp/extension/',
|
||||
' prompt_pack:',
|
||||
' file: bioflow_extension_agent_rules.md',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
// 生信能力名(与 BIOFLOW_MANIFESTS 键一致;SettingsPanel 三开关与 TAB 显隐共用)
|
||||
export const BIOFLOW_CAPABILITY_NAMES = Object.keys(BIOFLOW_MANIFESTS);
|
||||
@@ -1,34 +0,0 @@
|
||||
// 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)}`);
|
||||
}
|
||||
@@ -164,6 +164,16 @@ function closeStageLog() {
|
||||
logStage.value = null;
|
||||
}
|
||||
|
||||
// 实时跟随:日志行追加(轮询增量)时自动滚底,保持在最新一行。
|
||||
watch(
|
||||
() => (logRec.value ? logRec.value.lines.length : 0),
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 面板定位消费(聊天进度条点击 → scrollIntoView + hl-fade + 展开 log) ----
|
||||
const hlStage = ref(null);
|
||||
function consumeLocate(payload) {
|
||||
|
||||
@@ -15,8 +15,6 @@ 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';
|
||||
|
||||
@@ -72,66 +70,10 @@ 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 拉取失败
|
||||
@@ -148,7 +90,7 @@ watch(() => docgen.md, () => {
|
||||
if (mode.value === 'preview') mountShadow();
|
||||
});
|
||||
});
|
||||
onMounted(() => nextTick(() => { mountShadow(); wireCiteChips(); }));
|
||||
onMounted(() => nextTick(mountShadow));
|
||||
|
||||
// ---------- HTML 所见即所得编辑 ----------
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import FlowFormCard from './FlowFormCard.vue';
|
||||
import FlowFileCard from './FlowFileCard.vue';
|
||||
import FlowProgressCard from './FlowProgressCard.vue';
|
||||
import FlowArchiveCard from './FlowArchiveCard.vue';
|
||||
import LegacyBioflowBlock from './LegacyBioflowBlock.vue';
|
||||
// 用户自定义扩展分析:生命周期引导卡 + 运行卡(EXTENSION_CONTRACT.md §4 块)
|
||||
import ExtensionUploadCard from './ExtensionUploadCard.vue';
|
||||
import ExtensionBuildCard from './ExtensionBuildCard.vue';
|
||||
@@ -28,11 +29,6 @@ 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 },
|
||||
@@ -229,15 +225,8 @@ const degradedNote = computed(() => props.message.degradedNote || null);
|
||||
:block="b"
|
||||
/>
|
||||
|
||||
<!-- P2 生信:流程知识库引用块(FR-BIO-026;引用携带 cite 供跳转) -->
|
||||
<div v-if="b.type === 'bioflow_qa'" class="bioflow-qa-block">
|
||||
<div v-for="(c, ci) in b.citations" :key="ci" class="bf-qa-cite">
|
||||
<span class="bf-qa-idx">{{ ci + 1 }}</span>
|
||||
<span class="bf-qa-text">{{ c.text || c.title_path || c.source }}</span>
|
||||
<span class="bf-qa-src">{{ c.title_path || c.source }}</span>
|
||||
</div>
|
||||
<div v-if="!b.citations || !b.citations.length" class="bf-qa-empty">{{ BF.flowQaEmpty }}</div>
|
||||
</div>
|
||||
<!-- P2 生信:流程知识库引用块(FR-BIO-026)→ S3 降级为只读 LegacyBioflowBlock -->
|
||||
<LegacyBioflowBlock v-if="b.type === 'bioflow_qa'" :block="b" />
|
||||
|
||||
<!-- 用户自定义扩展分析:上传引导卡(extension_upload) -->
|
||||
<ExtensionUploadCard
|
||||
@@ -303,21 +292,6 @@ 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>
|
||||
|
||||
@@ -13,17 +13,16 @@
|
||||
// qa → 原 emit('send') 路径(附件注记进消息文本)。
|
||||
// 路由收敛在本组件内,ChatPanel 等上游组件零改动。
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import { MODES as MODE_LABELS, T } from '../../i18n/labels.js';
|
||||
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { route, paperIntent } from '../../intent.js';
|
||||
import { route as routeBioflow, isExtensionCreateIntent } from '../../utils/intent.js';
|
||||
import { route } from '../../intent.js';
|
||||
import { isExtensionCreateIntent } from '../../utils/intent.js';
|
||||
import { uploadLibrary, listLibrary } from '../../api/docgen.js';
|
||||
import { uploadBioflowFile } from '../../api/bioflow.js';
|
||||
import { useDocgenStore } from '../../stores/docgen.js';
|
||||
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({
|
||||
@@ -60,9 +59,6 @@ onMounted(() => {
|
||||
settings.load(sid).catch(() => { /* 静默:失败不阻断(设置面板打开时会重试) */ });
|
||||
}
|
||||
} catch { /* settings store 未就绪(测试/降级):不阻断 */ }
|
||||
try {
|
||||
usePaperStore().start(); // 论文撰写探活(30s;离线时触发词落 docgen 单发降级)
|
||||
} catch { /* paper store 未就绪:不阻断 */ }
|
||||
});
|
||||
|
||||
const canSend = computed(() =>
|
||||
@@ -128,36 +124,6 @@ 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 {
|
||||
@@ -168,16 +134,6 @@ 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}`,
|
||||
);
|
||||
@@ -279,20 +235,13 @@ const submit = () => {
|
||||
} catch {
|
||||
intent = 'qa'; // 路由异常按漏判安全处理
|
||||
}
|
||||
// 生信意图(含图表调整走生信 FR-BIO-019):词典同表双跑(utils/intent.js)
|
||||
let bioflowRoute = { intent: 'qa', reuseName: null };
|
||||
if (intent !== 'docgen' && intent !== 'edit' && intent !== 'export' && bioflowEnabled.value) {
|
||||
try {
|
||||
bioflowRoute = routeBioflow(text, hasAttachments.value, hasActiveArtifact.value, hasBioflowContext.value);
|
||||
} catch {
|
||||
bioflowRoute = { intent: 'qa', reuseName: null };
|
||||
}
|
||||
}
|
||||
// 用户自定义扩展分析:A 层创建意图(EXTENSION_CONTRACT.md §5 词典同表双跑;
|
||||
// 「创建/上传 一个扩展」→ sendExtension 上传引导卡。歧义消解与运行触发由
|
||||
// 服务端 intent.py 在 chat/stream 内判定——前端只做确定性创建识别,漏判安全。)
|
||||
// 生信意图(基因家族/转录组)S3 起不再走前端词典分流:一律进 QA 流,
|
||||
// 由 bioflow_agent 在服务端经 nl_triggers 判定(统一由 QA 流承载)。
|
||||
let extIntent = false;
|
||||
if (bioflowRoute.intent !== 'bioflow_qa' && bioflowRoute.intent !== 'bioflow' && bioflowEnabled.value) {
|
||||
if (bioflowEnabled.value) {
|
||||
try {
|
||||
extIntent = isExtensionCreateIntent(text);
|
||||
} catch {
|
||||
@@ -306,63 +255,18 @@ const submit = () => {
|
||||
} catch {
|
||||
emit('send', text); // chat store 未就绪(测试/降级):漏判安全退化为 QA 路径
|
||||
}
|
||||
} else if (bioflowRoute.intent === 'bioflow_qa') {
|
||||
// 生信流内流程咨询(FR-BIO-026):也走 sendBioflow(后端流内返引用块,不新建任务)
|
||||
try {
|
||||
useChatStore().sendBioflow(text, { mode: 'ask' }).catch(() => { /* 内部已标注错误 */ });
|
||||
} catch {
|
||||
emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径
|
||||
}
|
||||
} else if (bioflowRoute.intent === 'bioflow') {
|
||||
// 生信分析意图:chat.sendBioflow(plan_ready → 路径卡 → 任务事件)
|
||||
try {
|
||||
const jobId = null; // 任务未创建:文件上传待任务生成后由 FlowFileCard 引导
|
||||
const chat = useChatStore();
|
||||
chat.sendBioflow(text, { mode: 'ask', reuseName: bioflowRoute.reuseName }).catch(() => { /* 内部已标注错误 */ });
|
||||
// 附件按生信文件上传(XHR 进度;FR-BIO-006):任务 id 由 sendBioflow 创建任务后
|
||||
// 回填——任务创建前的拖入文件由 FlowFileCard 阶段引导卡处理,此处兼容
|
||||
// 已存在任务场景(当前会话任务 id 已知时直接上传)。
|
||||
if (readyAttachments.value.length) {
|
||||
const bioflowStore = useBioflowStore();
|
||||
const jobIdNow = jobId || (bioflowStore.currentJob && bioflowStore.currentJob.jobId);
|
||||
if (jobIdNow) {
|
||||
for (const a of readyAttachments.value) {
|
||||
chat.uploadBioflowFileToJob(jobIdNow, a.file).catch(() => { /* 失败可见:chips 显示错误 */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径
|
||||
}
|
||||
} else if (intent === 'docgen') {
|
||||
// 文档生成分支:论文撰写 sub-route(paperIntent && paperEnabled → sendPaper),
|
||||
// 否则 sendDocgen(降级路径零改动——paper 未启用/未命中时触发词落 docgen 单发)。
|
||||
let paperRoute = false;
|
||||
// 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 异步生成 + 自动切文档 TAB)
|
||||
// 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐)
|
||||
try {
|
||||
paperRoute = paperEnabled.value && paperIntent(text);
|
||||
useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), {
|
||||
templateId: selectedTemplateId.value || '',
|
||||
}).catch(() => {
|
||||
/* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
/* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */
|
||||
emit('send', text);
|
||||
}
|
||||
} else if (intent === 'edit') {
|
||||
// 编辑分支:预填 DocPane「用 prompt 修改」并切文档 TAB
|
||||
@@ -374,13 +278,6 @@ 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();
|
||||
@@ -427,6 +324,18 @@ 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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 复用 FlowFormCard 视觉令牌(字段行 label 96px / input 同款 / 主按钮 gradient)。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { buildExtension } from '../../api/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -13,6 +13,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['submitted', 'error']);
|
||||
|
||||
const chat = useChatStore();
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitError = ref('');
|
||||
|
||||
@@ -43,27 +45,25 @@ const hasDeps = computed(() =>
|
||||
(Array.isArray(reqs.value.pip) && reqs.value.pip.length > 0)
|
||||
|| (Array.isArray(reqs.value.apt) && reqs.value.apt.length > 0));
|
||||
|
||||
/** 提交修正:POST build 带修正值(后端按 ext_id 重入转换) */
|
||||
/** 提交(S4 改走 card_result 进 QA 流):
|
||||
* - skip=true(跳过/采纳 AI 结果)→ 重触发 AI 转换(build)
|
||||
* - skip=false(编辑修正)→ confirm 语义(复用 confirm_extension 编辑值入参) */
|
||||
async function submit({ skip = false } = {}) {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
submitError.value = '';
|
||||
try {
|
||||
const body = {};
|
||||
if (!skip) {
|
||||
if (skip) {
|
||||
await chat.sendCardResult('extension_action', { action: 'build', ext_id: props.block.extId });
|
||||
} else {
|
||||
if (!templateDraft.value.trim()) { submitError.value = BF.extBuildFieldRequired; submitting.value = false; return; }
|
||||
body.command_template = templateDraft.value.trim();
|
||||
body.runtime_image = imageDraft.value.trim() || undefined;
|
||||
body.nl_triggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
body.analysis_type = typeDraft.value.trim() || undefined;
|
||||
}
|
||||
await buildExtension(props.block.extId, body);
|
||||
// 提交成功以本地修正为准(后端重入结果由 extension_build 帧再次刷新)
|
||||
if (!skip) {
|
||||
props.block.commandTemplate = templateDraft.value.trim();
|
||||
props.block.runtimeImage = imageDraft.value.trim() || props.block.runtimeImage;
|
||||
props.block.nlTriggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
props.block.analysisType = typeDraft.value.trim() || props.block.analysisType;
|
||||
await chat.sendCardResult('extension_action', {
|
||||
action: 'confirm',
|
||||
ext_id: props.block.extId,
|
||||
nl_triggers: triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean),
|
||||
parameters: props.block.parameters || [],
|
||||
runtime_image: imageDraft.value.trim(),
|
||||
});
|
||||
}
|
||||
props.block.submitted = true;
|
||||
emit('submitted', { extId: props.block.extId, skip });
|
||||
|
||||
@@ -47,10 +47,10 @@ async function run() {
|
||||
else if (f.type === 'float' || f.type === 'number') params[f.name] = parseFloat(v);
|
||||
else params[f.name] = String(v);
|
||||
}
|
||||
const result = await chat.confirmExtensionRun(props.block, {
|
||||
const result = await chat.sendCardResult('extension_action', {
|
||||
action: 'run',
|
||||
ext_id: props.block.extId,
|
||||
params,
|
||||
files: [],
|
||||
aMsg: props.message || null,
|
||||
});
|
||||
emit('submitted', { extId: props.block.extId, result });
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 发布成功后 extension_published 帧挂发布确认块(本卡随试运行卡同帧刷新)。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { publishExtension } from '../../api/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['published', 'error']);
|
||||
|
||||
const chat = useChatStore();
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitError = ref('');
|
||||
const triggersDraft = ref('');
|
||||
@@ -29,11 +31,12 @@ async function publish() {
|
||||
submitError.value = '';
|
||||
try {
|
||||
const nlTriggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
const data = await publishExtension(props.block.extId, { nl_triggers: nlTriggers });
|
||||
const result = (data && data.data) || data || {};
|
||||
if (Array.isArray(result.nl_triggers)) props.block.nlTriggers = result.nl_triggers;
|
||||
else props.block.nlTriggers = nlTriggers;
|
||||
if (result.display_name) props.block.displayName = result.display_name;
|
||||
await chat.sendCardResult('extension_action', {
|
||||
action: 'publish',
|
||||
ext_id: props.block.extId,
|
||||
nl_triggers: nlTriggers,
|
||||
});
|
||||
props.block.nlTriggers = nlTriggers;
|
||||
props.block.published = true;
|
||||
emit('published', { extId: props.block.extId, nlTriggers: props.block.nlTriggers });
|
||||
} catch (err) {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
import { repairExtension } from '../../api/bioflow.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -36,17 +35,17 @@ function onGoldenChange(e) {
|
||||
if (e.target) e.target.value = '';
|
||||
}
|
||||
|
||||
/** 上传测试输入 → 触发试运行(服务端跑容器,结果经 extension_trial 帧回流) */
|
||||
/** 试运行(S4 改走 card_result 进 QA 流;测试输入文件上传为后置上传,另行登记) */
|
||||
async function runTrial() {
|
||||
if (running.value || repairing.value) return;
|
||||
if (!props.block.extId) { submitError.value = BF.extTrialNoExtId; return; }
|
||||
if (!testInput.value) { submitError.value = BF.extTrialInputEmpty; return; }
|
||||
running.value = true;
|
||||
submitError.value = '';
|
||||
try {
|
||||
const files = [testInput.value];
|
||||
if (goldenInput.value) files.push(goldenInput.value);
|
||||
const result = await chat.trialRunExtension(props.block.extId, files);
|
||||
const result = await chat.sendCardResult('extension_action', {
|
||||
action: 'trial_run',
|
||||
ext_id: props.block.extId,
|
||||
});
|
||||
emit('trialled', { extId: props.block.extId, result });
|
||||
} catch (err) {
|
||||
submitError.value = err?.message || BF.extUploadError;
|
||||
@@ -63,7 +62,7 @@ async function doRepair() {
|
||||
repairing.value = true;
|
||||
submitError.value = '';
|
||||
try {
|
||||
await repairExtension(props.block.extId, {});
|
||||
await chat.sendCardResult('extension_action', { action: 'repair', ext_id: props.block.extId });
|
||||
} catch (err) {
|
||||
submitError.value = err?.message || BF.extTrialRepairFailed;
|
||||
emit('error', err);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 复用 FlowFormCard 字段行令牌。确认成功后由服务端 extension_published 帧收尾。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { buildExtension } from '../../api/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['submitted', 'error']);
|
||||
|
||||
const chat = useChatStore();
|
||||
|
||||
const submitting = ref(false);
|
||||
const submitError = ref('');
|
||||
const values = ref({});
|
||||
@@ -37,7 +39,7 @@ const goldenOk = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
/** 确认:重新 build(门禁纪律:确认前不推进生命周期;修正值随 build 重入)。 */
|
||||
/** 确认(S4 改走 card_result):编辑修正(form_defaults 参数)→ confirm 语义。 */
|
||||
async function confirm() {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
@@ -49,7 +51,13 @@ async function confirm() {
|
||||
if (v === '' || v === null || v === undefined) continue;
|
||||
params[f.name] = v;
|
||||
}
|
||||
await buildExtension(props.block.extId, { params });
|
||||
// 编辑修正走 confirm 环节(复用 confirm_extension 编辑值入参)
|
||||
await chat.sendCardResult('extension_action', {
|
||||
action: 'confirm',
|
||||
ext_id: props.block.extId,
|
||||
parameters: props.block.parameters || [],
|
||||
runtime_image: '',
|
||||
});
|
||||
props.block.confirmed = true;
|
||||
emit('submitted', { extId: props.block.extId, params });
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -15,6 +16,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['dismissed', 'archived', 'error']);
|
||||
|
||||
const bioflow = useBioflowStore();
|
||||
const chat = useChatStore();
|
||||
|
||||
const nameDraft = ref(props.block.name || '');
|
||||
const descDraft = ref(props.block.description || '');
|
||||
@@ -50,6 +52,12 @@ async function doArchive() {
|
||||
// 存档状态回复(FR-BIO-021):名称/位置/大小三要素写回块状态
|
||||
props.block.status = 'done';
|
||||
props.block.result = result;
|
||||
// S3:同步发 card_result 进 QA 流,让 BioflowAgent 自然语言复述存档决定(语义一致)
|
||||
chat.sendCardResult('archive_choice', {
|
||||
job_id: props.block.jobId,
|
||||
archive: true,
|
||||
name: props.block.name,
|
||||
}).catch(() => { /* 复述失败不影响已完成的存档 */ });
|
||||
emit('archived', result);
|
||||
} catch (err) {
|
||||
errorText.value = err?.message || BF.archiveError;
|
||||
|
||||
@@ -84,16 +84,16 @@ function onInputChange(e) {
|
||||
if (e.target) e.target.value = ''; // 允许重复选择同一文件
|
||||
}
|
||||
|
||||
// ---- 服务器路径登记(FR-BIO-007) ----
|
||||
// ---- 服务器路径登记(FR-BIO-007;S3 改走 card_result 进 QA 流) ----
|
||||
async function registerPath() {
|
||||
const p = pathDraft.value.trim();
|
||||
if (!p || registering.value || !jobId.value) return;
|
||||
registering.value = true;
|
||||
registerError.value = '';
|
||||
try {
|
||||
const res = await chat.registerBioflowPath(jobId.value, p);
|
||||
await chat.sendCardResult('file_upload', { job_id: jobId.value, paths: [p] });
|
||||
pathDraft.value = '';
|
||||
emit('registered', { path: p, res });
|
||||
emit('registered', { path: p });
|
||||
} catch (err) {
|
||||
registerError.value = err?.message || BF.filePathError;
|
||||
emit('error', err);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
@@ -18,6 +19,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['submitted', 'error']);
|
||||
|
||||
const bioflow = useBioflowStore();
|
||||
const chat = useChatStore();
|
||||
|
||||
const fields = computed(() => props.block.fields || []);
|
||||
const stage = computed(() => props.block.stage || '');
|
||||
@@ -153,7 +155,8 @@ async function submit() {
|
||||
} else {
|
||||
const jobId = bioflow.currentJob.jobId || props.block.jobId;
|
||||
if (!jobId) throw new Error('no job');
|
||||
await bioflow.confirmStage(stage.value, params);
|
||||
// S3:卡片不直连控制面——参数确认改为 card_result 消息进 QA 流(BioflowAgent 经 confirm_stage 执行)
|
||||
await chat.sendCardResult('form_confirm', { job_id: jobId, stage: stage.value, params });
|
||||
props.block.submitted = true;
|
||||
emit('submitted', { stage: stage.value, params });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// 复用句式(FR-BIO-023):候选来自存档装载,卡片头部明示「复用存档 XXX 的方法」。
|
||||
import { computed, ref } from 'vue';
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { useBioflowStore, inferBioflowChain } from '../../stores/bioflow.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -66,22 +66,16 @@ async function confirmCreate() {
|
||||
confirming.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
// r4 修复:不再 emit('updated')(该事件全链无人监听,任务创建后 UI 静默)——
|
||||
// 改走 chat store 一站式 confirmBioflowPath:创建 → 挂进度块 → 订阅任务事件
|
||||
// 桥接推进 → 落库。aMsg 从 chat 消息流定位(当前消息 = 含本 path 块的 assistant 消息)。
|
||||
const aMsg = (chat.messages || []).find(
|
||||
(m) => m && m.role === 'assistant' && Array.isArray(m.blocks) && m.blocks.includes(props.block),
|
||||
);
|
||||
const detail = await chat.confirmBioflowPath({
|
||||
title: (chat.messages || []).find((m) => m.role === 'user')?.text?.slice(0, 50) || '生信任务',
|
||||
// S3:卡片不直连控制面——确认路径改为生成 card_result 消息进 QA 流,
|
||||
// 由 BioflowAgent 经 create_*_job 工具执行并自然语言复述。
|
||||
props.block.jobId = null; // 由 QA 流返回的 active_job_id 回填
|
||||
await chat.sendCardResult('path_selection', {
|
||||
chain: inferBioflowChain(cand.stages),
|
||||
stages: cand.stages,
|
||||
title: (chat.messages || []).find((m) => m.role === 'user')?.text?.slice(0, 50) || '生信任务',
|
||||
auto_continue: false,
|
||||
params: {},
|
||||
autoContinue: false,
|
||||
archiveId: reuseArchive.value || null,
|
||||
aMsg: aMsg || null,
|
||||
});
|
||||
// 进度块由 confirmBioflowPath 挂载;此处标记 path 块已确认
|
||||
props.block.jobId = (detail && detail.id) || null;
|
||||
} catch (err) {
|
||||
error.value = err?.message || BF.pathStageCreating;
|
||||
emit('flow-error', err);
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
<script setup>
|
||||
// chat/FlowProgressCard.vue —— 生信阶段进度卡(FR-BIO-011/012/013/014,UI_SPEC §8.4)
|
||||
// 结构/状态点/连接线/相位色复用 §4.4 pipeline-card 全部令牌(PipelineCard 零改动,
|
||||
// 本卡按 §8.4 同款视觉独立实现):
|
||||
// done=--pa-t-done 底白 ✓;running=相位色底 ● + pulse;waiting=surface-3 底 ◌
|
||||
// 新增两态与附加区(§8.4):
|
||||
// failed 态:--pa-error 底 ✕,状态文字 error 色 12px/600
|
||||
// skipped 态:--pa-ink-subtle 底 ◌,状态文字 ink-subtle
|
||||
// 日志尾区:仅 running/failed 展开:--pa-code-bg 底,mono 11px,padding 8px 10px,
|
||||
// max-height 96px 滚动,随 SSE 追加
|
||||
// 三选一按钮:仅 failed:重跑/跳过/改参数重跑,pane-btn 同款 12px,gap 8px
|
||||
// 折叠:阶段 >10 自动分组折叠(组头 12px/600 + 展开箭头),默认展开运行中所在组
|
||||
// 断线回放(FR-BIO-014):block.stages 被快照整体替换(引用变化)时本卡自动整体刷新。
|
||||
// chat/FlowProgressCard.vue —— 生信阶段进度卡(S3 精简为只读)
|
||||
// 只读展示:阶段列表(状态点三态)+ 进度条 + 最近一行日志尾 + 折叠分组。
|
||||
// S3 起去掉暂停/恢复、失败重跑/跳过、改参数重跑等直连控制按钮与 locate 点击——
|
||||
// 任务控制一律走自然语言(QA 流),本卡不直连生信控制面。
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { BF, stageStatusText } from '../../i18n/bioflow.js';
|
||||
import { PHASE_COLOR_VAR } from '../../i18n/labels.js';
|
||||
import { useBioflowStore } from '../../stores/bioflow.js';
|
||||
import { bioflowCatalog } from '../../api/bioflow.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['rerun', 'skip', 'rerun-with-params', 'pause', 'resume', 'action-error', 'locate']);
|
||||
|
||||
const bioflow = useBioflowStore();
|
||||
|
||||
const stages = computed(() => props.block.stages || []);
|
||||
const jobStatus = computed(() => props.block.jobStatus || bioflow.currentJob.jobStatus);
|
||||
const acting = ref(''); // 'pause' | 'resume' | `rerun:${stage}` | `skip:${stage}`
|
||||
const actionError = ref('');
|
||||
const rerunFormStage = ref(null); // 改参数重跑弹出该阶段参数表单(FR-BIO-012)
|
||||
const jobStatus = computed(() => props.block.jobStatus);
|
||||
|
||||
// 相位色映射(与 PipelineCard PHASE_COLOR_VAR 一致;阶段序号对应五相位循环)
|
||||
function phaseVar(i) {
|
||||
@@ -37,7 +20,6 @@ function phaseVar(i) {
|
||||
return PHASE_COLOR_VAR[keys[i % keys.length]] || PHASE_COLOR_VAR.planning;
|
||||
}
|
||||
|
||||
/** 状态点符号与颜色(三重编码:颜色 + 图标 + 文字) */
|
||||
function dotClass(st) {
|
||||
return {
|
||||
done: 'done',
|
||||
@@ -54,20 +36,13 @@ function dotSymbol(st) {
|
||||
}
|
||||
|
||||
const showLog = (st) => st.status === 'running' || st.status === 'failed';
|
||||
const showActions = (st) => st.status === 'failed';
|
||||
|
||||
// ---- 进度条(进度帧 stage_progress 驱动;点击 → 定位到生信任务 TAB) ----
|
||||
/** 阶段是否有确定性进度(白名单 rnaseq 阶段才有 percent) */
|
||||
function hasPct(st) {
|
||||
return typeof st.progress === 'number' && st.progress >= 0 && st.progress <= 100;
|
||||
}
|
||||
/** 阶段是否处于真运行态(收到过 stage_started;queued 无事件不显示条) */
|
||||
function isLiveRunning(st) {
|
||||
return st.status === 'running';
|
||||
}
|
||||
function locateStage(st) {
|
||||
emit('locate', { jobId: props.block.jobId, stage: st.stage });
|
||||
}
|
||||
|
||||
// ---- 日志尾区自动滚底(SSE 追加,2s 节流) ----
|
||||
const logRefs = ref({});
|
||||
@@ -91,13 +66,12 @@ watch(
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// ---- 折叠(FR-BIO-013:阶段 >10 自动分组折叠;默认展开运行中所在组) ----
|
||||
// ---- 折叠(阶段 >10 自动分组折叠;默认展开运行中所在组) ----
|
||||
const FOLD_THRESHOLD = 10;
|
||||
const COLLAPSED_GROUP_SIZE = 8;
|
||||
const needsFold = computed(() => stages.value.length > FOLD_THRESHOLD);
|
||||
const openGroups = ref(new Set());
|
||||
|
||||
/** 折叠分组:每组 8 阶段,全部可折叠;默认仅展开运行中所在组(其余折叠,组头可展开) */
|
||||
const groups = computed(() => {
|
||||
const all = stages.value;
|
||||
if (!needsFold.value) return [{ title: null, stages: all, collapsible: false }];
|
||||
@@ -118,7 +92,6 @@ const groups = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
/** 组展开状态:快照整体替换(引用变化)时按运行中所在组重算默认展开 */
|
||||
watch(
|
||||
() => props.block.stages,
|
||||
() => {
|
||||
@@ -144,120 +117,6 @@ function toggleGroup(i) {
|
||||
function isGroupOpen(i) {
|
||||
return openGroups.value.has(i);
|
||||
}
|
||||
|
||||
// ---- 失败三选一(FR-BIO-012) ----
|
||||
async function doRerun(st) {
|
||||
if (acting.value) return;
|
||||
acting.value = `rerun:${st.stage}`;
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.rerunStage(st.stage, null);
|
||||
emit('rerun', st.stage);
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
async function doSkip(st) {
|
||||
if (acting.value) return;
|
||||
acting.value = `skip:${st.stage}`;
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.skipStage(st.stage);
|
||||
emit('skip', st.stage);
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
function doRerunWithParams(st) {
|
||||
rerunFormStage.value = st.stage;
|
||||
// 拉取该阶段目录参数 schema(来源标注=阶段契约文档),表单值空提交即按默认重跑
|
||||
loadRerunSchema(st.stage);
|
||||
}
|
||||
function closeRerunForm() {
|
||||
rerunFormStage.value = null;
|
||||
}
|
||||
|
||||
// ---- 改参数重跑表单(目录 schema 驱动;提交 rerun 带参数) ----
|
||||
const rerunSchema = ref([]);
|
||||
const rerunValues = ref({});
|
||||
const rerunLoading = ref(false);
|
||||
const rerunSubmitting = ref(false);
|
||||
|
||||
async function loadRerunSchema(stage) {
|
||||
rerunSchema.value = [];
|
||||
rerunValues.value = {};
|
||||
rerunLoading.value = true;
|
||||
try {
|
||||
const data = await bioflowCatalog({ stage });
|
||||
const entry = (data && data.data && data.data.stage) || {};
|
||||
rerunSchema.value = Array.isArray(entry.params) ? entry.params : [];
|
||||
for (const p of rerunSchema.value) rerunValues.value[p.name] = p.default ?? '';
|
||||
} catch {
|
||||
rerunSchema.value = []; // 目录不可达:空表单直接重跑(默认参数)
|
||||
} finally {
|
||||
rerunLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRerunForm() {
|
||||
if (rerunSubmitting.value) return;
|
||||
rerunSubmitting.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const params = {};
|
||||
for (const p of rerunSchema.value) {
|
||||
const v = rerunValues.value[p.name];
|
||||
if (v === '' || v === null || v === undefined) continue;
|
||||
params[p.name] = (p.type === 'int' || p.type === 'float' || p.type === 'number') ? Number(v) : String(v);
|
||||
}
|
||||
await bioflow.rerunStage(rerunFormStage.value, params);
|
||||
emit('rerun-with-params', rerunFormStage.value);
|
||||
rerunFormStage.value = null;
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
rerunSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 暂停/恢复(FR-BIO-010) ----
|
||||
const showPause = computed(() => jobStatus.value === 'running');
|
||||
const showResume = computed(() => jobStatus.value === 'paused');
|
||||
async function doPause() {
|
||||
if (acting.value) return;
|
||||
acting.value = 'pause';
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.pauseJob();
|
||||
emit('pause');
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
async function doResume() {
|
||||
if (acting.value) return;
|
||||
acting.value = 'resume';
|
||||
actionError.value = '';
|
||||
try {
|
||||
await bioflow.resumeJob();
|
||||
emit('resume');
|
||||
} catch (err) {
|
||||
actionError.value = err?.message || BF.progActionError;
|
||||
emit('action-error', err);
|
||||
} finally {
|
||||
acting.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -265,12 +124,6 @@ async function doResume() {
|
||||
<div class="pc-head">
|
||||
<span class="pc-title">{{ BF.progTitle }}</span>
|
||||
<div class="pc-head-right">
|
||||
<button v-if="showPause" class="pane-btn" type="button" :disabled="!!acting" @click="doPause">
|
||||
{{ acting === 'pause' ? BF.progPausing : BF.progPause }}
|
||||
</button>
|
||||
<button v-if="showResume" class="pane-btn" type="button" :disabled="!!acting" @click="doResume">
|
||||
{{ acting === 'resume' ? BF.progResuming : BF.progResume }}
|
||||
</button>
|
||||
<span class="badge" :class="jobStatus === 'running' ? 'running' : jobStatus === 'paused' ? 'idle' : jobStatus === 'done' ? 'done' : jobStatus === 'failed' ? 'error' : 'idle'">
|
||||
<span class="dot"></span>
|
||||
{{ jobStatus === 'running' ? BF.progBadgeRunning : jobStatus === 'paused' ? BF.progBadgePaused : jobStatus === 'done' ? BF.progBadgeDone : jobStatus === 'failed' ? BF.progBadgeFailed : BF.progBadgeCreated }}
|
||||
@@ -278,7 +131,7 @@ async function doResume() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 折叠分组(FR-BIO-013) -->
|
||||
<!-- 折叠分组 -->
|
||||
<template v-for="(g, gi) in groups" :key="gi">
|
||||
<button
|
||||
v-if="g.collapsible"
|
||||
@@ -301,17 +154,14 @@ async function doResume() {
|
||||
>{{ dotSymbol(st) }}</span>
|
||||
<span class="p-name">{{ st.stage }}</span>
|
||||
<span class="p-desc" :title="st.error || (st.progressMessage || '')">{{ st.progressMessage || st.error || '' }}</span>
|
||||
<!-- 进度条:有 percent → 确定性;running 无 percent → 不确定扫光;其余 → 不显示 -->
|
||||
<button
|
||||
<!-- 进度条(S3 只读,不可点击定位) -->
|
||||
<div
|
||||
v-if="hasPct(st) || (isLiveRunning(st) && !hasPct(st))"
|
||||
class="prog-track clickable"
|
||||
type="button"
|
||||
:title="BF.progLocateHint"
|
||||
@click.stop="locateStage(st)"
|
||||
class="prog-track"
|
||||
>
|
||||
<span v-if="hasPct(st)" class="prog-fill" :class="{ full: st.progress >= 100 }" :style="{ width: `${Math.min(st.progress, 100)}%` }"></span>
|
||||
<span v-else class="prog-indeterminate"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="hasPct(st)" class="prog-pct">{{ Math.min(st.progress, 100) }}%</span>
|
||||
<span class="p-state" :style="st.status === 'running' ? { color: `var(${phaseVar(gi * COLLAPSED_GROUP_SIZE + si)})` } : null">
|
||||
<span v-if="st.status === 'running'" class="spin"></span>
|
||||
@@ -321,57 +171,17 @@ async function doResume() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 日志尾区(§8.4:仅 running/failed 展开;--pa-code-bg mono 11px padding 8px 10px max-height 96px 滚动) -->
|
||||
<!-- 日志尾区(仅 running/failed 展开;最近一行,随 SSE 追加自动滚底) -->
|
||||
<template v-for="st in stages" :key="'log-' + st.stage">
|
||||
<div v-if="showLog(st)" class="pc-log">
|
||||
<div class="pc-log-head">{{ st.stage }} · {{ BF.jobsLogTitle }}</div>
|
||||
<pre class="pc-log-body" :ref="(el) => setLogRef(st.stage, el)">{{ st.log_tail || BF.progLogPlaceholder }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 失败三选一(§8.4:仅 failed;pane-btn 同款 12px,gap 8px) -->
|
||||
<div v-if="showActions(st)" class="pc-actions">
|
||||
<button
|
||||
class="pane-btn"
|
||||
type="button"
|
||||
:disabled="!!acting"
|
||||
@click="doRerun(st)"
|
||||
>{{ acting === 'rerun:' + st.stage ? BF.formSubmitting : BF.progRerun }}</button>
|
||||
<button
|
||||
class="pane-btn"
|
||||
type="button"
|
||||
:disabled="!!acting"
|
||||
@click="doSkip(st)"
|
||||
>{{ acting === 'skip:' + st.stage ? BF.formSubmitting : BF.progSkip }}</button>
|
||||
<button class="pane-btn" type="button" @click="doRerunWithParams(st)">{{ BF.progRerunWithParams }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 改参数重跑:该阶段参数表单(FR-BIO-012;由阶段目录 schema 构造) -->
|
||||
<div v-if="rerunFormStage" class="pc-rerun-form">
|
||||
<div class="pc-rerun-head">
|
||||
<span>{{ BF.rerunFormTitle.replace('{stage}', rerunFormStage) }}</span>
|
||||
<button class="pane-btn" type="button" @click="closeRerunForm">✕</button>
|
||||
</div>
|
||||
<div v-if="rerunLoading" class="pc-rerun-hint">{{ BF.jobsManifestLoading }}</div>
|
||||
<div v-else-if="rerunSchema.length" class="pc-rerun-fields">
|
||||
<div v-for="p in rerunSchema" :key="p.name" class="pc-rerun-field">
|
||||
<span class="pc-rerun-label">{{ p.name }}</span>
|
||||
<input v-model="rerunValues[p.name]" class="pc-rerun-input" type="text" />
|
||||
<span class="pc-rerun-source">{{ BF.formSourceContract }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-rerun-actions">
|
||||
<button class="pane-btn" type="button" @click="closeRerunForm">{{ BF.jobsConfirmNo }}</button>
|
||||
<button class="pane-btn run" type="button" :disabled="rerunSubmitting" @click="submitRerunForm">
|
||||
{{ rerunSubmitting ? BF.formSubmitting : BF.rerunFormSubmit }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="actionError" class="pc-error">{{ actionError }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
/* UI_SPEC §8.4:结构/状态点/连接线/相位色复用 §4.4 pipeline-card 全部令牌 */
|
||||
.bioflow-progress-card {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
// chat/LegacyBioflowBlock.vue —— 旧生信块只读降级渲染(S3)
|
||||
// 用途:旧会话历史里遗留的生信块(bioflow_qa 引用块 / 旧版 bioflow_* 块)在新架构下
|
||||
// 以只读形式降级渲染,保证 hydrate 打开旧会话不报错、不渲染可交互控件。
|
||||
// 仅展示,不发任何控制面请求。
|
||||
import { BF } from '../../i18n/bioflow.js';
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true },
|
||||
});
|
||||
|
||||
function titleFor(type) {
|
||||
switch (type) {
|
||||
case 'bioflow_qa':
|
||||
return BF.legacyQaTitle;
|
||||
case 'bioflow_path':
|
||||
return BF.legacyPathTitle;
|
||||
case 'bioflow_form':
|
||||
return BF.legacyFormTitle;
|
||||
case 'bioflow_file':
|
||||
return BF.legacyFileTitle;
|
||||
case 'bioflow_progress':
|
||||
return BF.legacyProgressTitle;
|
||||
case 'bioflow_archive':
|
||||
return BF.legacyArchiveTitle;
|
||||
default:
|
||||
return BF.legacyBlockTitle;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="legacy-bioflow-block">
|
||||
<div class="lbf-title">{{ titleFor(block.type) }}</div>
|
||||
<!-- 旧 bioflow_qa 引用块:只读引用列表 -->
|
||||
<template v-if="block.type === 'bioflow_qa'">
|
||||
<div v-for="(c, ci) in (block.citations || [])" :key="ci" class="lbf-cite">
|
||||
<span class="lbf-idx">{{ ci + 1 }}</span>
|
||||
<span class="lbf-text">{{ c.text || c.title_path || c.source }}</span>
|
||||
<span class="lbf-src">{{ c.title_path || c.source }}</span>
|
||||
</div>
|
||||
<div v-if="!block.citations || !block.citations.length" class="lbf-empty">{{ BF.flowQaEmpty }}</div>
|
||||
</template>
|
||||
<!-- 旧任务/进度类块:只读摘要(若携带 task/job 信息则显示,否则显示占位) -->
|
||||
<template v-else>
|
||||
<div v-if="block.jobId" class="lbf-job">{{ BF.legacyJobPrefix }} {{ block.jobId }}</div>
|
||||
<div class="lbf-empty">{{ BF.legacyReadonlyHint }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-bioflow-block {
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
opacity: 0.72;
|
||||
background: var(--surface-1, #fff);
|
||||
}
|
||||
.lbf-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #6b7280);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.lbf-cite {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 3px 0;
|
||||
}
|
||||
.lbf-idx {
|
||||
color: var(--accent, #2563eb);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lbf-text {
|
||||
color: var(--text, #111827);
|
||||
}
|
||||
.lbf-src {
|
||||
color: var(--text-dim, #6b7280);
|
||||
font-size: 12px;
|
||||
}
|
||||
.lbf-job {
|
||||
font-size: 13px;
|
||||
color: var(--text, #111827);
|
||||
}
|
||||
.lbf-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim, #9ca3af);
|
||||
}
|
||||
</style>
|
||||
@@ -1,64 +0,0 @@
|
||||
<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>
|
||||
@@ -1,51 +0,0 @@
|
||||
<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
Reference in New Issue
Block a user