feat(bioflow): extension lifecycle backend (P0) - spec whitelist, state machine, AI build, intent layers
服务端生命周期部分(契约 §1/§2/§4/§5/§6;Dev-A):
- extension_spec.py: spec_v2 白名单 12 占位符 + {output_dir} 必含 + 白名单外拒绝
- extensions.py: 生命周期状态机(契约 §2 枚举) + {extensions}/{ext_id} 文件存储
+ ExtensionExecutor 接口占位(Dev-B 实现 extension_exec.py, inject_executor 注入)
+ extension_* 事件帧经 EventBus 发布(events.py 注册, 不改 job_* 语义)
- extension_llm.py: build/repair AI 转换(DOCGEN_LLM_* 优先, BIOFLOW_EXTENSION_* 别名)
LLM 不可用/坏输出一律回退规则版(_detect_requirements/_command_to_template 移植), 绝不 500
- routers/extension.py: 契约 §6 端点全集; trial/run 执行器未接入返回 503
- intent.py: 契约 §5 两层判定纯函数(A 创建词库 × B 已发布扩展打分 + 歧义消解 + 漏判安全)
chat/stream 接线留给 Dev-C 主分支整合
- config/schemas/main: upload_ext_allow 加 .zip/.py/.r/.sh; extensions_dir +
extension_max_repair_rounds(20) + LLM 键注释; 模型; router 注册
- tests: 5 个测试文件 71 用例; 全仓 286 用例通过
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,13 @@ class Settings(BaseSettings):
|
||||
default_memory: str = "32g"
|
||||
log_tail_lines: int = 80 # manifest 接口日志尾行数(≤80)
|
||||
max_upload_gb: float = 100.0 # 上传大小上限(GB;超限 413)
|
||||
upload_ext_allow: str = ".fasta,.fa,.faa,.fna,.pep,.cds,.gz,.gff,.gff3,.gtf,.txt,.tsv,.csv,.xlsx,.xls,.bed,.vcf,.vcf.gz,.bam,.cram,.sam,.hmm,.nwk,.newick,.treefile,.json,.yaml,.yml,.pdf"
|
||||
upload_ext_allow: str = ".fasta,.fa,.faa,.fna,.pep,.cds,.gz,.gff,.gff3,.gtf,.txt,.tsv,.csv,.xlsx,.xls,.bed,.vcf,.vcf.gz,.bam,.cram,.sam,.hmm,.nwk,.newick,.treefile,.json,.yaml,.yml,.pdf,.zip,.py,.r,.sh"
|
||||
|
||||
# ---- 用户自定义扩展分析(并行链路;存储根 + AI 转换 LLM 键 + 修复上限)----
|
||||
extensions_dir: str = "bioflow_data/extensions" # 扩展存储根({ext_id}/extension.json...)
|
||||
extension_max_repair_rounds: int = 20 # AI 修复循环上限(契约 §6 可配)
|
||||
# 扩展转换 LLM 键:DOCGEN_LLM_* 直接复用(docgen 直连模式),
|
||||
# BIOFLOW_EXTENSION_* 为可选别名(extension_llm.llm_config 兜底读取)。
|
||||
|
||||
# ---- 规划(planner)----
|
||||
llm_plan_timeout: float = 60.0 # 规划 LLM 单次调用超时(秒)
|
||||
@@ -120,6 +126,10 @@ class Settings(BaseSettings):
|
||||
def flow_docs_dir_abs(self) -> str:
|
||||
return _resolve_root(self.flow_docs_dir)
|
||||
|
||||
@property
|
||||
def extensions_dir_abs(self) -> str:
|
||||
return _resolve_root(self.extensions_dir)
|
||||
|
||||
@property
|
||||
def upload_ext_set(self) -> set[str]:
|
||||
return {ext.strip().lower() for ext in self.upload_ext_allow.split(",") if ext.strip()}
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 .routers import bioarchives, bioflow, biojobs
|
||||
from .routers import bioarchives, bioflow, biojobs, extension
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -74,6 +74,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(bioflow.router)
|
||||
app.include_router(biojobs.router)
|
||||
app.include_router(bioarchives.router)
|
||||
app.include_router(extension.router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
"""app/routers/extension.py —— 用户自定义扩展分析端点(契约 §6 全集)。
|
||||
|
||||
- POST /api/bioflow/extensions 上传 zip(multipart)+ display_name
|
||||
+ command + runtime_image? + golden?
|
||||
- GET /api/bioflow/extensions 列表(published 优先)
|
||||
- GET /api/bioflow/extensions/{id} 详情
|
||||
- DELETE /api/bioflow/extensions/{id} 删除
|
||||
- POST /api/bioflow/extensions/{id}/build AI 转换(LLM 失败回退规则版)
|
||||
- POST /api/bioflow/extensions/{id}/trial-run 试运行(docker 执行占位 Executor)
|
||||
- POST /api/bioflow/extensions/{id}/validate 金标/试运行校验
|
||||
- POST /api/bioflow/extensions/{id}/confirm 用户确认/参数调整(nl_triggers 可编辑)
|
||||
- POST /api/bioflow/extensions/{id}/repair LLM 修复(上限 20 轮可配)
|
||||
- POST /api/bioflow/extensions/{id}/publish 发布(仅 published 可被 NL 触发)
|
||||
- POST /api/bioflow/extensions/{id}/run 正式运行(同步返回 run_result;
|
||||
SSE 由 Dev-B 执行对接)
|
||||
- GET /api/bioflow/extensions/{id}/runs 运行历史
|
||||
- GET /api/bioflow/extensions/{id}/runs/{run_id}/events SSE(占位:先返回
|
||||
已完成 run 的快照帧;live 流由
|
||||
Dev-B 执行链对接后补齐)
|
||||
|
||||
docker 执行:trial/run 接 extensions.ExtensionExecutor 接口(Dev-B 的
|
||||
extension_exec.py 实现;inject_executor 注入)。未接入时返回 503(不是 500)。
|
||||
|
||||
服务层全部经 services/bioflow/extensions.py 状态机;LLM 转换不可用自动降级
|
||||
规则版(extension_llm.py 保证),端点绝不因 LLM 挂掉而 500。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from ..schemas import ExtensionConfirmRequest, ExtensionRunRequest
|
||||
from ..services.bioflow import extensions as ext_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/bioflow/extensions", tags=["bioflow-extensions"])
|
||||
|
||||
_MAX_ZIP_ENTRIES = 500 # zip 条目数上限(防炸弹)
|
||||
_MAX_ZIP_TOTAL = 200 * 1024 * 1024 # 解压总量上限 200MB
|
||||
|
||||
|
||||
def _err(code: str, message: str, status: int) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={
|
||||
"ok": False,
|
||||
"error": {"code": code, "message": message, "degraded": False},
|
||||
})
|
||||
|
||||
|
||||
def _store() -> ext_mod.ExtensionStore:
|
||||
return ext_mod.get_store()
|
||||
|
||||
|
||||
def _ext_or_404(ext_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return _store().load_extension(ext_id)
|
||||
except ext_mod.ExtensionNotFoundError:
|
||||
raise HTTPException(status_code=404, detail={
|
||||
"ok": False,
|
||||
"error": {"code": "NOT_FOUND", "message": "扩展不存在", "degraded": False},
|
||||
}) from None
|
||||
|
||||
|
||||
def _state_error(exc: ext_mod.ExtensionError, status: int = 400) -> JSONResponse:
|
||||
code = "EXT_STATUS" if isinstance(exc, ext_mod.ExtensionStatusError) else (
|
||||
"EXT_BUILD" if isinstance(exc, ext_mod.ExtensionBuildError) else (
|
||||
"EXT_RUN" if isinstance(exc, ext_mod.ExtensionRunError) else "EXT_ERROR"))
|
||||
return _err(code, str(exc), status)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上传/列表/详情/删除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_extension(
|
||||
request: Request,
|
||||
display_name: str | None = Form(default=None),
|
||||
command: str | None = Form(default=None),
|
||||
runtime_image: str = Form(default=""),
|
||||
golden: str | None = Form(default=None),
|
||||
code_zip: UploadFile | None = File(default=None),
|
||||
test_file: UploadFile | None = File(default=None),
|
||||
) -> JSONResponse:
|
||||
"""上传 zip(代码包)+ 可选测试数据/金标 → 创建 draft 扩展(契约 §6)。
|
||||
|
||||
multipart:display_name/command 为表单字段;JSON body:同名字段在 JSON 内
|
||||
(Form 参数可选化以兼容两种形态——fastapi 在 multipart 缺失表单时不再 422)。
|
||||
golden 为 JSON 字符串({test_input, expected_outputs}),测试数据可随
|
||||
test_file 上传;code_files 为 base64 清单(Dev-C 前端上传卡可裁剪)。
|
||||
"""
|
||||
code_files: list[tuple[str, bytes]] = []
|
||||
data_files: list[tuple[str, bytes]] = []
|
||||
golden_dict: dict[str, Any] | None = None
|
||||
|
||||
content_type = request.headers.get("content-type", "")
|
||||
try:
|
||||
if "multipart/form-data" in content_type:
|
||||
if golden:
|
||||
try:
|
||||
golden_dict = json.loads(golden)
|
||||
except ValueError:
|
||||
return _err("VALIDATION", "golden 非合法 JSON", 422)
|
||||
if test_file is not None and test_file.filename:
|
||||
data_files.append((test_file.filename, await test_file.read()))
|
||||
if code_zip is not None and code_zip.filename:
|
||||
code_files = _read_zip(await code_zip.read())
|
||||
else:
|
||||
# JSON 形态(前端可先不传 zip;代码文件经 base64 清单)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return _err("VALIDATION", "请求体须为 multipart 或 JSON", 400)
|
||||
display_name = str(body.get("display_name") or display_name or "")
|
||||
command = str(body.get("command") or command or "")
|
||||
runtime_image = str(body.get("runtime_image") or runtime_image)
|
||||
golden_dict = body.get("golden")
|
||||
for item in body.get("code_files") or []:
|
||||
if not isinstance(item, dict) or not item.get("path"):
|
||||
continue
|
||||
try:
|
||||
blob = base64.b64decode(str(item.get("data") or ""))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
code_files.append((str(item["path"]), blob))
|
||||
for item in body.get("data_files") or []:
|
||||
if not isinstance(item, dict) or not item.get("path"):
|
||||
continue
|
||||
try:
|
||||
blob = base64.b64decode(str(item.get("data") or ""))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
data_files.append((str(item["path"]), blob))
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return _state_error(exc, 422)
|
||||
|
||||
if not code_files:
|
||||
return _err("VALIDATION", "扩展代码包不能为空(zip 解压无代码文件)", 422)
|
||||
|
||||
try:
|
||||
ext = _store().create_extension(
|
||||
display_name=str(display_name or ""),
|
||||
command=str(command or ""),
|
||||
runtime_image=str(runtime_image),
|
||||
code_files=code_files,
|
||||
data_files=data_files,
|
||||
golden=golden_dict if isinstance(golden_dict, dict) else None,
|
||||
)
|
||||
except ext_mod.ExtensionError as exc:
|
||||
return _state_error(exc, 422)
|
||||
return JSONResponse(status_code=201, content={"ok": True, "data": ext})
|
||||
|
||||
|
||||
def _read_zip(blob: bytes) -> list[tuple[str, bytes]]:
|
||||
"""zip 解压(安全护栏:条目数/总量/路径净化;失败抛 ExtensionError 语义由调用方处理)。"""
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(blob))
|
||||
except zipfile.BadZipFile:
|
||||
raise ext_mod.ExtensionError("zip 文件损坏") from None
|
||||
items: list[tuple[str, bytes]] = []
|
||||
total = 0
|
||||
with zf:
|
||||
infos = zf.infolist()
|
||||
if len(infos) > _MAX_ZIP_ENTRIES:
|
||||
raise ext_mod.ExtensionError(f"zip 条目过多(> {_MAX_ZIP_ENTRIES})")
|
||||
for info in infos:
|
||||
if info.is_dir():
|
||||
continue
|
||||
total += info.file_size
|
||||
if total > _MAX_ZIP_TOTAL:
|
||||
raise ext_mod.ExtensionError("zip 解压总量超限")
|
||||
name = Path(info.filename).name
|
||||
if not name:
|
||||
continue
|
||||
items.append((name, zf.read(info)))
|
||||
return items
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_extensions() -> dict[str, Any]:
|
||||
items = _store().list_extensions()
|
||||
return {"ok": True, "data": {"items": items, "total": len(items)}}
|
||||
|
||||
|
||||
@router.get("/{ext_id}")
|
||||
async def get_extension(ext_id: str) -> dict[str, Any]:
|
||||
ext = _ext_or_404(ext_id)
|
||||
return {"ok": True, "data": ext}
|
||||
|
||||
|
||||
@router.delete("/{ext_id}")
|
||||
async def delete_extension(ext_id: str) -> JSONResponse:
|
||||
try:
|
||||
_store().delete(ext_id)
|
||||
except ext_mod.ExtensionNotFoundError:
|
||||
raise HTTPException(status_code=404, detail={
|
||||
"ok": False, "error": {"code": "NOT_FOUND", "message": "扩展不存在", "degraded": False},
|
||||
}) from None
|
||||
except ext_mod.ExtensionStatusError as exc:
|
||||
return _state_error(exc, 409)
|
||||
return JSONResponse(status_code=204, content=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 生命周期动作(build/trial/validate/repair/confirm/publish)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{ext_id}/build")
|
||||
async def build_extension(ext_id: str) -> JSONResponse:
|
||||
_ext_or_404(ext_id)
|
||||
try:
|
||||
ext = await _store().build_extension(ext_id)
|
||||
except ext_mod.ExtensionBuildError as exc:
|
||||
return _state_error(exc, 400)
|
||||
except ext_mod.ExtensionStatusError as exc:
|
||||
return _state_error(exc, 409)
|
||||
return JSONResponse(content={"ok": True, "data": ext})
|
||||
|
||||
|
||||
@router.post("/{ext_id}/trial-run")
|
||||
async def trial_run(ext_id: str) -> JSONResponse:
|
||||
_ext_or_404(ext_id)
|
||||
try:
|
||||
ext = await _store().trial_run(ext_id)
|
||||
except ext_mod.ExtensionStatusError as exc:
|
||||
return _state_error(exc, 409)
|
||||
except ext_mod.ExtensionRunError as exc:
|
||||
return _state_error(exc, 503)
|
||||
return JSONResponse(content={"ok": True, "data": ext})
|
||||
|
||||
|
||||
@router.post("/{ext_id}/validate")
|
||||
async def validate(ext_id: str) -> JSONResponse:
|
||||
_ext_or_404(ext_id)
|
||||
try:
|
||||
ext = _store().validate(ext_id)
|
||||
except ext_mod.ExtensionStatusError as exc:
|
||||
return _state_error(exc, 409)
|
||||
return JSONResponse(content={"ok": True, "data": ext})
|
||||
|
||||
|
||||
@router.post("/{ext_id}/repair")
|
||||
async def repair(ext_id: str) -> JSONResponse:
|
||||
_ext_or_404(ext_id)
|
||||
try:
|
||||
ext = await _store().repair(ext_id)
|
||||
except ext_mod.ExtensionBuildError as exc:
|
||||
return _state_error(exc, 400)
|
||||
except ext_mod.ExtensionStatusError as exc:
|
||||
return _state_error(exc, 409)
|
||||
return JSONResponse(content={"ok": True, "data": ext})
|
||||
|
||||
|
||||
@router.post("/{ext_id}/confirm")
|
||||
async def confirm(ext_id: str, body: ExtensionConfirmRequest) -> JSONResponse:
|
||||
_ext_or_404(ext_id)
|
||||
try:
|
||||
ext = _store().confirm(
|
||||
ext_id,
|
||||
nl_triggers=body.nl_triggers,
|
||||
parameters=body.parameters,
|
||||
runtime_image=body.runtime_image,
|
||||
)
|
||||
except ext_mod.ExtensionStatusError as exc:
|
||||
return _state_error(exc, 409)
|
||||
return JSONResponse(content={"ok": True, "data": ext})
|
||||
|
||||
|
||||
@router.post("/{ext_id}/publish")
|
||||
async def publish(ext_id: str) -> JSONResponse:
|
||||
_ext_or_404(ext_id)
|
||||
try:
|
||||
ext = _store().publish(ext_id)
|
||||
except ext_mod.ExtensionStatusError as exc:
|
||||
return _state_error(exc, 409)
|
||||
return JSONResponse(content={"ok": True, "data": ext})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 运行(run/runs/events)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{ext_id}/run")
|
||||
async def run(ext_id: str, body: ExtensionRunRequest) -> JSONResponse:
|
||||
"""正式运行:同步执行并返回 run_result(run 事件 SSE 由 Dev-B 执行对接)。"""
|
||||
_ext_or_404(ext_id)
|
||||
try:
|
||||
result = await _store().run(ext_id, params=body.params, files=body.files)
|
||||
except ext_mod.ExtensionStatusError as exc:
|
||||
return _state_error(exc, 409)
|
||||
except ext_mod.ExtensionRunError as exc:
|
||||
return _state_error(exc, 400 if "不存在" in str(exc) or "白名单" in str(exc) else 503)
|
||||
return JSONResponse(content={"ok": True, "data": result})
|
||||
|
||||
|
||||
@router.get("/{ext_id}/runs")
|
||||
async def list_runs(ext_id: str) -> dict[str, Any]:
|
||||
_ext_or_404(ext_id)
|
||||
items = _store().list_runs(ext_id)
|
||||
return {"ok": True, "data": {"items": items, "total": len(items)}}
|
||||
|
||||
|
||||
@router.get("/{ext_id}/runs/{run_id}/events")
|
||||
async def run_events(ext_id: str, run_id: str, request: Request) -> StreamingResponse:
|
||||
"""运行事件 SSE(契约 §6;首帧快照)。
|
||||
|
||||
P0 占位实现:run 为同步执行,此处返回已完成 run 的快照帧
|
||||
(run_started/run_log/run_done|run_failed)后关闭;live 进度流在 Dev-B
|
||||
把执行改造为后台任务 + 事件推送后,本端点改为订阅事件总线。
|
||||
"""
|
||||
ext = _ext_or_404(ext_id)
|
||||
result_file = _store().runs_dir(ext_id) / run_id / "run_result.json"
|
||||
log_file = _store().runs_dir(ext_id) / run_id / "run.log"
|
||||
|
||||
def _frames():
|
||||
result: dict[str, Any] = {}
|
||||
if result_file.is_file():
|
||||
try:
|
||||
result = json.loads(result_file.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError):
|
||||
result = {}
|
||||
log_text = ""
|
||||
if log_file.is_file():
|
||||
try:
|
||||
log_text = log_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
log_text = ""
|
||||
yield _sse_frame("extension_run_started", {
|
||||
"run_id": run_id, "ext_id": ext_id,
|
||||
"started_at": result.get("started_at") or "",
|
||||
})
|
||||
if log_text:
|
||||
yield _sse_frame("extension_run_log", {"run_id": run_id, "log": log_text})
|
||||
if result.get("status") == "done":
|
||||
yield _sse_frame("extension_run_done", {
|
||||
"run_id": run_id, "outputs": result.get("outputs") or [],
|
||||
})
|
||||
elif result:
|
||||
yield _sse_frame("extension_run_failed", {
|
||||
"run_id": run_id, "exit_code": result.get("exit_code"),
|
||||
"error": result.get("error") or "", "log_tail": log_text[-2000:],
|
||||
})
|
||||
|
||||
return StreamingResponse(
|
||||
_frames(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
def _sse_frame(event: str, data: dict[str, Any]) -> bytes:
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode("utf-8")
|
||||
@@ -110,3 +110,26 @@ class ReportReviseRequest(BaseModel):
|
||||
target: str | None = Field(default=None, max_length=500)
|
||||
instruction: str = Field(min_length=1, max_length=2000)
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---- extensions(用户自定义扩展分析;契约 §2/§6)----
|
||||
|
||||
EXTENSION_STATUSES = (
|
||||
"draft", "building", "built", "trial_running", "trial_passed", "trial_failed",
|
||||
"validating", "validated", "repairing", "confirming", "published", "build_failed",
|
||||
)
|
||||
ExtensionStatus = Literal[
|
||||
"draft", "building", "built", "trial_running", "trial_passed", "trial_failed",
|
||||
"validating", "validated", "repairing", "confirming", "published", "build_failed",
|
||||
]
|
||||
|
||||
|
||||
class ExtensionRunRequest(BaseModel):
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
files: list[str] = Field(default_factory=list, max_length=50)
|
||||
|
||||
|
||||
class ExtensionConfirmRequest(BaseModel):
|
||||
nl_triggers: list[str] | None = Field(default=None, max_length=50)
|
||||
parameters: list[dict[str, Any]] | None = None
|
||||
runtime_image: str | None = Field(default=None, max_length=128)
|
||||
|
||||
@@ -26,6 +26,11 @@ EVENT_TYPES = (
|
||||
"job_created", "plan_ready", "stage_started", "stage_log", "stage_done",
|
||||
"stage_failed", "stage_skipped", "stage_await_confirm", "stage_await_files",
|
||||
"job_done", "job_paused", "job_archived", "job_recovered",
|
||||
# 用户自定义扩展分析事件帧(契约 §4;并行链路,不触碰现有 job_* 语义)
|
||||
"extension_created", "extension_build", "extension_trial", "extension_repair",
|
||||
"extension_validate", "extension_published", "extension_candidates",
|
||||
"extension_confirm", "extension_run_started", "extension_run_log",
|
||||
"extension_run_done", "extension_run_failed",
|
||||
)
|
||||
|
||||
# 断线重连时需要回放的关键状态事件(含历史事件全量)
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""app/services/bioflow/extension_llm.py —— 扩展 build/repair 的 AI 转换(DeepSeek 直连)。
|
||||
|
||||
- LLM 配置键:DOCGEN_LLM_*(沿用 docgen 直连模式);BIOFLOW_EXTENSION_*
|
||||
为可选别名(缺 DOCGEN 值时尝试)。
|
||||
- LLM 不可用/失败:一律回退规则版(_command_to_template/_detect_requirements/
|
||||
_infer_language 概念移植自 gfa_core/extension_stage/manager.py),**绝不抛 500**;
|
||||
回退无法完成才抛 LLMUnavailable(上层收敛 build_failed)。
|
||||
- 复用 app/services/bioflow/llm.py 的 complete_json(httpx 直连 + JSON 修复重试)。
|
||||
|
||||
服务层纪律:纯 stdlib + httpx,无 pydantic。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import extension_spec as spec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMUnavailable(RuntimeError):
|
||||
"""LLM 不可用且规则版无法完成转换(上层收敛 build_failed,不冒 500)。"""
|
||||
|
||||
|
||||
# 标准库白名单(_detect_requirements 移植:这些 import 不视为第三方依赖)
|
||||
STDLIB_MODULES = {
|
||||
"os", "sys", "re", "json", "csv", "argparse", "pathlib", "math",
|
||||
"statistics", "subprocess", "time", "datetime", "collections",
|
||||
"itertools", "functools", "glob", "gzip", "shutil", "logging", "random",
|
||||
"string", "typing", "io", "tempfile", "warnings", "hashlib", "urllib",
|
||||
}
|
||||
|
||||
CODE_SUFFIXES = {".py", ".r", ".sh", ".bash", ".command"}
|
||||
|
||||
_LLM_SYSTEM = (
|
||||
"You convert an already-runnable bioinformatics script package into a "
|
||||
"PlantAI custom extension. Reply with strict JSON only, no markdown fences."
|
||||
)
|
||||
|
||||
|
||||
def llm_config() -> dict[str, Any]:
|
||||
"""DOCGEN_LLM_* 优先,BIOFLOW_EXTENSION_* 别名兜底(base/key/model/timeout)。"""
|
||||
cfg: dict[str, Any] = {}
|
||||
for field, keys in (
|
||||
("base_url", ("DOCGEN_LLM_BASE_URL", "BIOFLOW_EXTENSION_BASE_URL")),
|
||||
("api_key", ("DOCGEN_LLM_API_KEY", "BIOFLOW_EXTENSION_API_KEY")),
|
||||
("model", ("DOCGEN_LLM_MODEL", "BIOFLOW_EXTENSION_MODEL")),
|
||||
("timeout", ("DOCGEN_LLM_TIMEOUT", "BIOFLOW_EXTENSION_TIMEOUT")),
|
||||
):
|
||||
value = ""
|
||||
for key in keys:
|
||||
raw = os.environ.get(key, "")
|
||||
if raw:
|
||||
value = raw
|
||||
break
|
||||
cfg[field] = value
|
||||
cfg["base_url"] = (cfg["base_url"] or "https://api.deepseek.com/v1").rstrip("/")
|
||||
cfg["model"] = cfg["model"] or "deepseek-chat"
|
||||
try:
|
||||
cfg["timeout"] = float(cfg["timeout"] or "300")
|
||||
except ValueError:
|
||||
cfg["timeout"] = 300.0
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 规则版探测(_detect_requirements/_infer_language 概念移植,纯函数)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def infer_language(path: str) -> str:
|
||||
suffix = Path(str(path)).suffix.lower()
|
||||
if suffix == ".py":
|
||||
return "python"
|
||||
if suffix == ".r":
|
||||
return "r"
|
||||
if suffix in {".sh", ".bash", ".command"}:
|
||||
return "shell"
|
||||
return suffix.lstrip(".") or "unknown"
|
||||
|
||||
|
||||
def detect_requirements(code_dir: str | Path) -> dict[str, list[str]]:
|
||||
"""规则版依赖探测:grep .py import / requirements.txt / .r library / .sh 命令。
|
||||
|
||||
返回 {"pip": [...], "apt": [...]}(契约 §2 requirements 形态)。
|
||||
"""
|
||||
pip: set[str] = set()
|
||||
apt: set[str] = set()
|
||||
root = Path(code_dir)
|
||||
if not root.is_dir():
|
||||
return {"pip": [], "apt": []}
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
name = path.name.lower()
|
||||
if name in {"requirements.txt", "pip_requirements.txt"}:
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
dep = re.split(r"[=<>!~\s]", line)[0].strip()
|
||||
if dep:
|
||||
pip.add(dep)
|
||||
continue
|
||||
if path.suffix.lower() == ".py":
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for match in re.finditer(r"^\s*(?:import|from)\s+([A-Za-z_][A-Za-z0-9_]*)", text, re.M):
|
||||
mod = match.group(1)
|
||||
if mod not in STDLIB_MODULES:
|
||||
pip.add(mod)
|
||||
continue
|
||||
if path.suffix.lower() == ".r":
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for match in re.finditer(r"\b(?:library|require)\(([^)]+)\)", text):
|
||||
pkg = match.group(1).strip().strip("'\"")
|
||||
if pkg:
|
||||
pip.add(pkg)
|
||||
continue
|
||||
if path.suffix.lower() in {".sh", ".bash", ".command"}:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
apt.update(_shell_commands(text))
|
||||
return {"pip": sorted(pip), "apt": sorted(apt)}
|
||||
|
||||
|
||||
_SHELL_COMMAND_WHITELIST = {
|
||||
"python", "python3", "rscript", "perl", "bash", "sh", "awk", "sed", "grep",
|
||||
"cut", "sort", "uniq", "head", "tail", "cat", "echo", "cd", "mkdir", "rm",
|
||||
"cp", "mv", "ls", "wc", "tr", "paste", "join", "find", "xargs", "tar", "gzip",
|
||||
"gunzip", "zcat", "chmod", "export", "conda", "source", "time", "date", "ln",
|
||||
"set", "if", "then", "else", "fi", "for", "while", "do", "done", "exit",
|
||||
"return", "case", "esac", "unset", "shift", "read",
|
||||
}
|
||||
|
||||
|
||||
def _shell_commands(text: str) -> set[str]:
|
||||
""".sh 脚本中的可执行命令探测(_detect_shell_commands 移植裁剪)。
|
||||
|
||||
白名单外且形如命令名者计为 apt 依赖(如 fastqc/samtools)。
|
||||
"""
|
||||
commands: set[str] = set()
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
parts = shlex.split(line, comments=True)
|
||||
except ValueError:
|
||||
continue
|
||||
if not parts:
|
||||
continue
|
||||
cmd = Path(parts[0]).name.lower()
|
||||
if not re.fullmatch(r"[a-z][a-z0-9_+.-]*", cmd or ""):
|
||||
continue
|
||||
if cmd in _SHELL_COMMAND_WHITELIST:
|
||||
continue
|
||||
commands.add(cmd)
|
||||
return commands
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 规则版命令模板化(LLM 失败回退;_command_to_template 概念移植)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def command_to_template(command: str, code_dir: str | Path, *, data_files: list[str] | None = None) -> dict[str, Any]:
|
||||
"""规则版:把 original_command 转为 command_template + 依赖探测 + 参数派生。
|
||||
|
||||
步骤:
|
||||
1. 已知脚本路径片段 → {code_dir}/<脚本>;
|
||||
2. 输入文件 token → 语义占位符(扩展名优先,其次 {input_file});
|
||||
3. results/ 类输出目录 → {output_dir};裸 --out/--output_dir 类 token 补 {output_dir};
|
||||
4. 无 {output_dir} 时追加 " > {output_dir}/output.txt"(output_dir 必含硬约束)。
|
||||
"""
|
||||
command = str(command or "").strip()
|
||||
code_dir = Path(code_dir)
|
||||
data_files = data_files or []
|
||||
|
||||
# 1. 代码脚本 → {code_dir}/<name>
|
||||
template = command
|
||||
for path in code_dir.rglob("*"):
|
||||
if not path.is_file() or path.suffix.lower() not in CODE_SUFFIXES:
|
||||
continue
|
||||
name = path.name
|
||||
template = re.sub(
|
||||
rf"(?<![A-Za-z0-9_./-]){re.escape(name)}\b",
|
||||
f"{{code_dir}}/{name}",
|
||||
template,
|
||||
)
|
||||
template = re.sub(r"\{code_dir\}/?\{code_dir\}", "{code_dir}", template)
|
||||
|
||||
# 2. 输入文件 → 语义占位符(扩展名优先)
|
||||
known_inputs = {Path(x).name for x in data_files}
|
||||
for fname in sorted(known_inputs, key=len, reverse=True):
|
||||
placeholder = _placeholder_for_filename(fname)
|
||||
template = re.sub(
|
||||
rf"(?<![A-Za-z0-9_./-]){re.escape(fname)}\b",
|
||||
placeholder,
|
||||
template,
|
||||
)
|
||||
|
||||
# 3. 输出目录 → {output_dir}
|
||||
for token in ("results/", "result/", "out_dir/", "output_dir/", "outputs/", "out/", "output/"):
|
||||
template = template.replace(token, "{output_dir}/")
|
||||
template = re.sub(r"\{output_dir\}+/+", "{output_dir}/", template)
|
||||
template = template.replace("{output_dir}//", "{output_dir}/")
|
||||
|
||||
# 4. 末尾裸目录 token(--out xxx 等)→ {output_dir}
|
||||
for token in ("--out", "--outdir", "--output", "--output_dir", "--output-dir", "-o"):
|
||||
template = re.sub(
|
||||
rf"({re.escape(token)})\s+([A-Za-z0-9_./-]+/?)",
|
||||
lambda m: f"{m.group(1)} {{output_dir}}",
|
||||
template,
|
||||
)
|
||||
|
||||
# 5. 无 {output_dir}:追加重定向兜底(output_dir 必含硬约束)
|
||||
if "{output_dir}" not in template:
|
||||
template = f"{template.rstrip()} > {{output_dir}}/output.txt"
|
||||
|
||||
# 6. 依赖探测 + 参数派生
|
||||
requirements = detect_requirements(code_dir)
|
||||
parameters: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for match in re.finditer(r"\{param_([A-Za-z_][A-Za-z0-9_]*)\}", template):
|
||||
name = match.group(1)
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
parameters.append({"name": name, "type": "string", "required": True,
|
||||
"description": "", "default": None})
|
||||
return {
|
||||
"command_template": template,
|
||||
"requirements": requirements,
|
||||
"parameters": parameters,
|
||||
"source": "rules",
|
||||
}
|
||||
|
||||
|
||||
def _placeholder_for_filename(fname: str) -> str:
|
||||
lower = fname.lower()
|
||||
if lower.endswith(".hmm"):
|
||||
return "{input_hmm}"
|
||||
if lower.endswith((".pep.fasta", ".pep.fa", ".pep")):
|
||||
return "{input_pep_fasta}"
|
||||
if lower.endswith((".cds.fasta", ".cds.fa")):
|
||||
return "{input_cds_fasta}"
|
||||
if lower.endswith((".fasta", ".fa", ".faa", ".fna")):
|
||||
return "{input_fasta}"
|
||||
if lower.endswith((".id", ".ids", ".geneid", ".geneids", ".list", ".lst")):
|
||||
return "{input_id_file}"
|
||||
return "{input_file}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build:LLM 转换(失败回退规则版)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sanitize_conversion(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""LLM 输出净化:字段形态归一 + 白名单校验(spec_v2 sanitize 概念裁剪)。"""
|
||||
out: dict[str, Any] = {}
|
||||
template = str(raw.get("command_template") or "").strip()
|
||||
spec.validate_command_template(template) # 不通过抛 ExtensionSpecError
|
||||
out["command_template"] = template
|
||||
|
||||
requirements: dict[str, Any] = {}
|
||||
raw_req = raw.get("requirements") or {}
|
||||
if isinstance(raw_req, dict):
|
||||
for key in ("pip", "apt"):
|
||||
values = raw_req.get(key) or []
|
||||
if isinstance(values, str):
|
||||
values = [values]
|
||||
requirements[key] = sorted({str(x).strip() for x in values if str(x).strip()})
|
||||
out["requirements"] = {"pip": requirements.get("pip") or [], "apt": requirements.get("apt") or []}
|
||||
|
||||
parameters: list[dict[str, Any]] = []
|
||||
raw_params = raw.get("parameters") or []
|
||||
if isinstance(raw_params, dict):
|
||||
raw_params = [{"name": k, **({"description": v} if isinstance(v, str) else v)} for k, v in raw_params.items()]
|
||||
if isinstance(raw_params, list):
|
||||
for item in raw_params:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = spec.safe_parameter_name(item.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
ptype = str(item.get("type") or "string")
|
||||
if ptype not in {"string", "file", "int", "float", "bool"}:
|
||||
ptype = "string"
|
||||
parameters.append({
|
||||
"name": name,
|
||||
"type": ptype,
|
||||
"required": bool(item.get("required", False)),
|
||||
"description": str(item.get("description") or ""),
|
||||
"default": item.get("default"),
|
||||
})
|
||||
# 模板 {param_*} 未在 parameters 声明的一律补齐(契约完整性)
|
||||
out["parameters"] = spec.template_parameters(template, parameters)
|
||||
|
||||
input_roles = raw.get("input_roles")
|
||||
out["input_roles"] = input_roles if isinstance(input_roles, dict) else {}
|
||||
|
||||
nl_triggers = raw.get("nl_triggers") or []
|
||||
if isinstance(nl_triggers, str):
|
||||
nl_triggers = [nl_triggers]
|
||||
out["nl_triggers"] = [str(x).strip() for x in nl_triggers if str(x).strip()][:50]
|
||||
|
||||
out["analysis_type"] = spec.normalize_analysis_type(str(raw.get("analysis_type") or ""))
|
||||
runtime_image = str(raw.get("runtime_image") or raw.get("runtime_env") or "")
|
||||
out["runtime_image"] = spec.RUNTIME_IMAGE_MAP.get(runtime_image, runtime_image)
|
||||
out["source"] = str(raw.get("source") or "llm")
|
||||
return out
|
||||
|
||||
|
||||
async def _llm_build(ext: dict[str, Any], store: Any) -> dict[str, Any]:
|
||||
"""LLM 转换:把 original_command 转 command_template + 元数据。"""
|
||||
from ...config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
cfg = llm_config()
|
||||
if not cfg["api_key"]:
|
||||
raise LLMUnavailable("扩展转换 LLM 未配置(DOCGEN_LLM_API_KEY 为空)")
|
||||
|
||||
from .llm import complete_json, LLMError, LLMTimeout, LLMUnavailable as PlanLLMUnavailable
|
||||
|
||||
raw_code = store.ext_dir(ext["ext_id"]) / "raw_code"
|
||||
code_files = _inventory(raw_code)
|
||||
data_files = _inventory(store.ext_dir(ext["ext_id"]) / "raw_test_data")
|
||||
user = _build_prompt(ext, code_files=code_files, data_files=data_files)
|
||||
try:
|
||||
payload = await complete_json(
|
||||
_LLM_SYSTEM,
|
||||
user,
|
||||
temperature=0,
|
||||
max_tokens=6000,
|
||||
timeout=float(s.docgen_llm_timeout),
|
||||
)
|
||||
except (PlanLLMUnavailable, LLMTimeout, LLMError, ValueError):
|
||||
raise
|
||||
return _sanitize_conversion(payload if isinstance(payload, dict) else {})
|
||||
|
||||
|
||||
def _build_prompt(ext: dict[str, Any], *, code_files: list[str], data_files: list[str]) -> str:
|
||||
lines = [
|
||||
"Convert the following extension upload into an extension spec JSON.",
|
||||
"The JSON must contain exactly these keys:",
|
||||
" command_template: the original_command rewritten with placeholders only from",
|
||||
" the whitelist: {code_dir} {input_dir} {input_file} {input_hmm} {input_fasta}",
|
||||
" {input_query_fasta} {input_reference_fasta} {input_pep_fasta} {input_cds_fasta}",
|
||||
" {input_id_file} {output_dir} {work_dir}, or {param_<name>} for CLI parameters.",
|
||||
" {output_dir} is REQUIRED (the extension must write outputs below it).",
|
||||
" requirements: {\"pip\": [...], \"apt\": [...]} - third-party dependencies only",
|
||||
" (stdlib imports are not dependencies).",
|
||||
" parameters: [{\"name\", \"type\": string|file|int|float|bool, \"required\",",
|
||||
" \"description\", \"default\"}] for each {param_*} used.",
|
||||
" input_roles: {\"<placeholder>\": \"<role>\"} for semantic placeholders used.",
|
||||
" nl_triggers: 3-6 short Chinese phrases users would say to invoke this",
|
||||
" extension in conversation (plus the display_name itself).",
|
||||
" analysis_type: one of " + ", ".join(sorted(spec.ANALYSIS_TYPES)) + ".",
|
||||
" runtime_image: one of " + ", ".join(sorted(spec.RUNTIME_IMAGE_MAP.values())) + ".",
|
||||
"",
|
||||
f"display_name: {ext.get('display_name')}",
|
||||
f"original_command: {ext.get('original_command')}",
|
||||
f"runtime_image (uploaded): {ext.get('runtime_image')}",
|
||||
"code files: " + (", ".join(code_files[:30]) or "(none)"),
|
||||
"test data files: " + (", ".join(data_files[:20]) or "(none)"),
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _inventory(root: Path) -> list[str]:
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
str(p.relative_to(root)) for p in root.rglob("*") if p.is_file()
|
||||
)[:200]
|
||||
|
||||
|
||||
def _rules_build(ext: dict[str, Any], store: Any) -> dict[str, Any]:
|
||||
"""规则版回退:command_to_template + 依赖探测 + analysis_type 推断。"""
|
||||
raw_code = store.ext_dir(ext["ext_id"]) / "raw_code"
|
||||
data_files = _inventory(store.ext_dir(ext["ext_id"]) / "raw_test_data")
|
||||
converted = command_to_template(
|
||||
ext.get("original_command") or "",
|
||||
raw_code,
|
||||
data_files=[Path(x).name for x in data_files],
|
||||
)
|
||||
spec.validate_command_template(converted["command_template"])
|
||||
converted["parameters"] = spec.template_parameters(converted["command_template"], converted.get("parameters"))
|
||||
converted["input_roles"] = {}
|
||||
converted["nl_triggers"] = _default_triggers(ext, converted["command_template"])
|
||||
converted["analysis_type"] = spec.infer_analysis_type(
|
||||
template=converted["command_template"],
|
||||
display_name=str(ext.get("display_name") or ""),
|
||||
requirements=converted["requirements"],
|
||||
)
|
||||
converted["runtime_image"] = str(ext.get("runtime_image") or spec.DEFAULT_RUNTIME_IMAGE)
|
||||
converted["source"] = "rules"
|
||||
return converted
|
||||
|
||||
|
||||
def _default_triggers(ext: dict[str, Any], template: str) -> list[str]:
|
||||
"""无 LLM 时的 nl_triggers 兜底:display_name + analysis_type 关键词。"""
|
||||
name = str(ext.get("display_name") or "").strip()
|
||||
triggers = [name] if name else []
|
||||
atype = spec.infer_analysis_type(
|
||||
template=template, display_name=name,
|
||||
requirements=ext.get("requirements") or {},
|
||||
)
|
||||
keywords = {
|
||||
"blast_search": "blast 比对",
|
||||
"hmmer_search": "hmm 搜索",
|
||||
"table_filter": "表格过滤",
|
||||
"sequence_extraction": "序列提取",
|
||||
"plot_render": "绘图",
|
||||
}
|
||||
if atype in keywords and keywords[atype] not in triggers:
|
||||
triggers.append(keywords[atype])
|
||||
return [t for t in triggers if t]
|
||||
|
||||
|
||||
async def build_conversion(ext: dict[str, Any], *, store: Any) -> dict[str, Any]:
|
||||
"""build 主入口:LLM 优先,失败回退规则版;两者皆失败抛 LLMUnavailable。"""
|
||||
try:
|
||||
return await _llm_build(ext, store)
|
||||
except Exception as exc: # noqa: BLE001 - 全部 LLM 失败路径(连接/超时/鉴权/坏 JSON)
|
||||
logger.warning("extensions: LLM build 失败,回退规则版: %s", exc)
|
||||
try:
|
||||
return _rules_build(ext, store)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("extensions: 规则版 build 也失败")
|
||||
raise LLMUnavailable("扩展转换失败:LLM 与规则版回退均无法完成") from None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# repair:失败上下文 → LLM 修复(失败回退规则版)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def repair_conversion(ext: dict[str, Any], *, failure_context: dict[str, Any], store: Any) -> dict[str, Any]:
|
||||
"""repair:把 trial 失败日志交给 LLM 修复 command_template(或 requirements)。
|
||||
|
||||
LLM 失败回退规则版(log_tail 里出现 ModuleNotFoundError 时把缺失模块并入
|
||||
requirements——B2「AI 修复循环兜底」的最小规则实现;镜像重建由 Dev-B)。
|
||||
"""
|
||||
cfg = llm_config()
|
||||
if cfg["api_key"]:
|
||||
from ...config import get_settings
|
||||
from .llm import complete_json
|
||||
|
||||
log_tail = str(failure_context.get("log_tail") or "")[-4000:]
|
||||
user = (
|
||||
"The extension trial run failed. Repair the extension spec JSON "
|
||||
"(same keys as build: command_template with whitelist placeholders "
|
||||
"and REQUIRED {output_dir}, requirements, parameters, input_roles, "
|
||||
"nl_triggers, analysis_type, runtime_image, plus a \"suggestion\" "
|
||||
"string describing the fix in Chinese).\n\n"
|
||||
f"current extension.json: {json.dumps(_repair_view(ext), ensure_ascii=False)}\n"
|
||||
f"trial log tail:\n{log_tail}\n"
|
||||
)
|
||||
try:
|
||||
payload = await complete_json(
|
||||
_LLM_SYSTEM,
|
||||
user,
|
||||
temperature=0,
|
||||
max_tokens=6000,
|
||||
timeout=float(get_settings().docgen_llm_timeout),
|
||||
)
|
||||
if isinstance(payload, dict) and payload.get("command_template"):
|
||||
converted = _sanitize_conversion(payload)
|
||||
converted["suggestion"] = str(payload.get("suggestion") or "")
|
||||
converted["source"] = "repair_llm"
|
||||
return converted
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("extensions: LLM repair 失败,回退规则版: %s", exc)
|
||||
|
||||
# 规则版:ModuleNotFoundError → requirements 补缺;模板保持
|
||||
template = str(ext.get("command_template") or "")
|
||||
if not template:
|
||||
raise LLMUnavailable("repair 需要已有 command_template")
|
||||
log_tail = str(failure_context.get("log_tail") or "")
|
||||
added: list[str] = []
|
||||
for match in re.finditer(r"No module named ['\"]?([A-Za-z_][A-Za-z0-9_]*)", log_tail):
|
||||
added.append(match.group(1))
|
||||
requirements = dict(ext.get("requirements") or {"pip": [], "apt": []})
|
||||
requirements["pip"] = sorted({str(x) for x in (requirements.get("pip") or []) if str(x)} | set(added))
|
||||
if not added:
|
||||
raise LLMUnavailable("repair 失败:LLM 不可用且日志中未发现缺失依赖,无法规则修复")
|
||||
return {
|
||||
"command_template": template,
|
||||
"requirements": {"pip": requirements.get("pip") or [], "apt": requirements.get("apt") or []},
|
||||
"parameters": ext.get("parameters") or [],
|
||||
"input_roles": ext.get("input_roles") or {},
|
||||
"nl_triggers": ext.get("nl_triggers") or [],
|
||||
"analysis_type": ext.get("analysis_type") or spec.DEFAULT_ANALYSIS_TYPE,
|
||||
"runtime_image": ext.get("runtime_image") or spec.DEFAULT_RUNTIME_IMAGE,
|
||||
"suggestion": "已并入缺失依赖: " + ", ".join(added),
|
||||
"source": "repair_rules",
|
||||
}
|
||||
|
||||
|
||||
def _repair_view(ext: dict[str, Any]) -> dict[str, Any]:
|
||||
"""传给 LLM 的扩展视图(剔除易超长字段:source_files 明细等)。"""
|
||||
keys = ("ext_id", "display_name", "status", "command_template", "original_command",
|
||||
"runtime_image", "requirements", "parameters", "input_roles",
|
||||
"nl_triggers", "analysis_type", "repair_count")
|
||||
return {k: ext.get(k) for k in keys}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""app/services/bioflow/extension_spec.py —— 扩展 command_template 白名单契约(spec_v2 移植)。
|
||||
|
||||
从 gfa_core/extension_stage/spec_v2.py 只移植契约常量与纯校验函数(旧模块只读):
|
||||
- 占位符白名单 12 个 + {param_<name>} 受控参数占位符;
|
||||
- 硬约束:{output_dir} 必含;白名单外占位符拒绝;
|
||||
- analysis_type 枚举(发布扩展的 intent 打分维度之一);
|
||||
- template → parameters 派生(未配置 parameters 时从 {param_*} 占位符生成)。
|
||||
|
||||
服务层纪律:纯 stdlib,无 pydantic、无 httpx。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
SPEC_VERSION = "extension_stage_v1"
|
||||
|
||||
# ---- 占位符白名单(契约 §2,spec_v2.PLACEHOLDERS 原表 12 个)----
|
||||
|
||||
PLACEHOLDERS = {
|
||||
"code_dir",
|
||||
"input_dir",
|
||||
"input_file",
|
||||
"input_hmm",
|
||||
"input_fasta",
|
||||
"input_query_fasta",
|
||||
"input_reference_fasta",
|
||||
"input_pep_fasta",
|
||||
"input_cds_fasta",
|
||||
"input_id_file",
|
||||
"output_dir",
|
||||
"work_dir",
|
||||
}
|
||||
|
||||
# 语义占位符(与旧版语义同表:绑定具体输入文件的角色化占位符)
|
||||
SEMANTIC_PLACEHOLDERS = {
|
||||
"input_hmm",
|
||||
"input_fasta",
|
||||
"input_query_fasta",
|
||||
"input_reference_fasta",
|
||||
"input_pep_fasta",
|
||||
"input_cds_fasta",
|
||||
"input_id_file",
|
||||
}
|
||||
|
||||
# ---- analysis_type 枚举(spec_v2.ANALYSIS_TYPES 移植)----
|
||||
|
||||
ANALYSIS_TYPES = {
|
||||
"sequence_extraction",
|
||||
"blast_search",
|
||||
"hmmer_search",
|
||||
"table_filter",
|
||||
"id_cleaning",
|
||||
"fasta_header_cleaning",
|
||||
"gff_processing",
|
||||
"plot_render",
|
||||
"enrichment_or_stats",
|
||||
"generic_cli",
|
||||
}
|
||||
|
||||
DEFAULT_ANALYSIS_TYPE = "generic_cli"
|
||||
|
||||
# 默认运行时镜像(B2 决策:工具链最全的 bioflow-gf)
|
||||
DEFAULT_RUNTIME_IMAGE = "bioflow-gf"
|
||||
|
||||
# 运行时环境 → 镜像映射(B2 定稿;上传/LLM 推荐时翻译旧 conda 环境名)
|
||||
RUNTIME_IMAGE_MAP = {
|
||||
"test4py311": "bioflow-gf",
|
||||
"gfpy312": "bioflow-gfpy",
|
||||
"rnaseq": "bioflow-rnaseq",
|
||||
"rnaR": "bioflow-rnaR",
|
||||
"ASAW": "bioflow-wgs",
|
||||
"interproscanpy311": "bioflow-interproscan",
|
||||
"panfamily": "bioflow-pan",
|
||||
"r_de_analysis": "bioflow-rde",
|
||||
}
|
||||
|
||||
_PARAM_RE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
|
||||
class ExtensionSpecError(RuntimeError):
|
||||
"""command_template 违反扩展契约(白名单/必含约束)。"""
|
||||
|
||||
|
||||
def used_placeholders(template: str) -> set[str]:
|
||||
"""模板中出现的全部 {占位符} 名(不含 {param_*} 前缀剥离)。"""
|
||||
return set(_PARAM_RE.findall(template or ""))
|
||||
|
||||
|
||||
def unsupported_placeholders(template: str) -> set[str]:
|
||||
"""白名单外且非 {param_*} 的占位符名集合。"""
|
||||
names = used_placeholders(template)
|
||||
return {name for name in names if name not in PLACEHOLDERS and not name.startswith("param_")}
|
||||
|
||||
|
||||
def param_placeholder_names(template: str) -> set[str]:
|
||||
"""{param_<name>} 占位符的参数名集合(剔除前缀)。"""
|
||||
return {
|
||||
name[len("param_"):]
|
||||
for name in used_placeholders(template)
|
||||
if name.startswith("param_") and len(name) > len("param_")
|
||||
}
|
||||
|
||||
|
||||
def validate_command_template(template: str) -> None:
|
||||
"""command_template 契约校验:{output_dir} 必含;白名单外占位符拒绝。
|
||||
|
||||
通过无异常;违反抛 ExtensionSpecError(spec_v2.normalize_spec_v2 同序)。
|
||||
"""
|
||||
if not (template or "").strip():
|
||||
raise ExtensionSpecError("command_template 不能为空")
|
||||
if "{output_dir}" not in template:
|
||||
raise ExtensionSpecError("command_template 必须包含 {output_dir}(输出契约硬约束)")
|
||||
unsupported = sorted(unsupported_placeholders(template))
|
||||
if unsupported:
|
||||
raise ExtensionSpecError("command_template 含白名单外占位符: " + ", ".join(unsupported))
|
||||
|
||||
|
||||
def normalize_analysis_type(value: str | None) -> str:
|
||||
"""analysis_type 白名单归一:非法值回落 generic_cli(漏判安全)。"""
|
||||
v = str(value or "").strip().lower()
|
||||
return v if v in ANALYSIS_TYPES else DEFAULT_ANALYSIS_TYPE
|
||||
|
||||
|
||||
def infer_analysis_type(*, template: str, display_name: str, requirements: dict[str, Any] | None = None) -> str:
|
||||
"""规则版 analysis_type 探测(spec_v2.infer_analysis_type 移植裁剪:模板/依赖/名称关键词)。"""
|
||||
t = (template or "").lower()
|
||||
pip_deps = " ".join(
|
||||
str(x).lower() for x in ((requirements or {}).get("pip") or [])
|
||||
)
|
||||
name = (display_name or "").lower()
|
||||
if "blastp" in t or "blastp" in pip_deps:
|
||||
return "blast_search"
|
||||
if any(k in t or k in pip_deps for k in ("hmmsearch", "hmmscan", "hmmbuild", "hmmer")):
|
||||
return "hmmer_search"
|
||||
if any(k in t for k in ("awk", "cut ", "grep ")):
|
||||
return "table_filter"
|
||||
if any(k in name for k in ("clean_id", "id_clean", "header_clean")):
|
||||
return "fasta_header_cleaning"
|
||||
if "seqtk" in t and "subseq" in t:
|
||||
return "sequence_extraction"
|
||||
if "matplotlib" in t or "plot" in name or any(k in name for k in ("画图", "绘图", "作图")):
|
||||
return "plot_render"
|
||||
return DEFAULT_ANALYSIS_TYPE
|
||||
|
||||
|
||||
def template_parameters(template: str, existing: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
|
||||
"""由 {param_*} 占位符派生/补齐 parameters(已配置的定义优先,缺省的补齐为空定义)。"""
|
||||
merged: dict[str, dict[str, Any]] = {}
|
||||
for item in existing or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = safe_parameter_name(item.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
merged[name] = {
|
||||
"name": name,
|
||||
"type": str(item.get("type") or "string"),
|
||||
"required": bool(item.get("required", False)),
|
||||
"description": str(item.get("description") or ""),
|
||||
"default": item.get("default"),
|
||||
}
|
||||
for name in sorted(param_placeholder_names(template)):
|
||||
merged.setdefault(name, {
|
||||
"name": name,
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "",
|
||||
"default": None,
|
||||
})
|
||||
return [merged[name] for name in sorted(merged)]
|
||||
|
||||
|
||||
def safe_parameter_name(value: Any) -> str:
|
||||
"""参数名净化(spec_v2.safe_parameter_name 同语义:非字母数字下划线→_,去 param_ 前缀)。"""
|
||||
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(value or "").strip())
|
||||
text = re.sub(r"_+", "_", text).strip("_")
|
||||
if not text:
|
||||
return ""
|
||||
if text[0].isdigit():
|
||||
text = "p_" + text
|
||||
if text.startswith("param_"):
|
||||
text = text[len("param_"):]
|
||||
return text
|
||||
@@ -0,0 +1,884 @@
|
||||
"""app/services/bioflow/extensions.py —— 扩展生命周期状态机与文件存储(契约 §1/§2/§4 服务端)。
|
||||
|
||||
从 gfa_core/extension_stage/manager.py 只取生命周期概念(draft→build→trial→validate
|
||||
→confirm→repair→publish→run),按现行服务风格重写:纯 stdlib + dataclass/dict,
|
||||
零新增依赖。旧模块只读,不做任何 import。
|
||||
|
||||
职责:
|
||||
- 存储:{data_root}/extensions/{ext_id}/{extension.json, raw_code/, raw_test_data/,
|
||||
converted_code/, validation/, runs/}(契约 §1 布局)。
|
||||
- extension.json 唯一事实源(契约 §2 schema);写盘 = 读-合并-原子替换,
|
||||
同进程多协程交错写不丢字段。
|
||||
- 状态机:契约 §2 status 枚举(合法流转表 _TRANSITIONS,非法流转 409)。
|
||||
- docker 执行占位:trial/run 接 ExtensionExecutor 接口(extension_exec.py 由
|
||||
Dev-B 实现;本模块定义接口签名并注入 ctx.executor)。
|
||||
- build/repair 的 LLM 转换委托 extension_llm.py(LLM 不可用时规则版回退,
|
||||
绝不抛 500)。
|
||||
|
||||
服务层纪律:纯 dataclass/dict/stdlib,无 pydantic、无 httpx。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...db import now_iso
|
||||
from . import extension_spec as spec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---- 契约 §2 状态枚举 ----
|
||||
|
||||
STATUS_DRAFT = "draft"
|
||||
STATUS_BUILDING = "building"
|
||||
STATUS_BUILT = "built"
|
||||
STATUS_TRIAL_RUNNING = "trial_running"
|
||||
STATUS_TRIAL_PASSED = "trial_passed"
|
||||
STATUS_TRIAL_FAILED = "trial_failed"
|
||||
STATUS_VALIDATING = "validating"
|
||||
STATUS_VALIDATED = "validated"
|
||||
STATUS_REPAIRING = "repairing"
|
||||
STATUS_CONFIRMING = "confirming"
|
||||
STATUS_PUBLISHED = "published"
|
||||
STATUS_BUILD_FAILED = "build_failed"
|
||||
|
||||
EXTENSION_STATUSES = {
|
||||
STATUS_DRAFT, STATUS_BUILDING, STATUS_BUILT, STATUS_TRIAL_RUNNING,
|
||||
STATUS_TRIAL_PASSED, STATUS_TRIAL_FAILED, STATUS_VALIDATING, STATUS_VALIDATED,
|
||||
STATUS_REPAIRING, STATUS_CONFIRMING, STATUS_PUBLISHED, STATUS_BUILD_FAILED,
|
||||
}
|
||||
|
||||
# 试运行可进入的状态(含重试)
|
||||
TRIAL_RETRYABLE = {
|
||||
STATUS_BUILT, STATUS_TRIAL_RUNNING, STATUS_TRIAL_FAILED, STATUS_TRIAL_PASSED,
|
||||
STATUS_VALIDATED,
|
||||
}
|
||||
|
||||
# 正式运行状态
|
||||
RUN_DONE = "done"
|
||||
RUN_FAILED = "failed"
|
||||
|
||||
# 输出契约默认:输出目录非空即通过(契约 §3;run_extension.py 退出码 3)
|
||||
OUTPUT_CONTRACT_EXIT = 3
|
||||
|
||||
|
||||
class ExtensionError(RuntimeError):
|
||||
"""扩展状态机业务错误(路由层转 4xx/503)。"""
|
||||
|
||||
|
||||
class ExtensionNotFoundError(ExtensionError):
|
||||
"""扩展不存在(404)。"""
|
||||
|
||||
|
||||
class ExtensionStatusError(ExtensionError):
|
||||
"""状态不可流转(409)。"""
|
||||
|
||||
|
||||
class ExtensionBuildError(ExtensionError):
|
||||
"""build/repair 转换失败(含规则版回退后的最终失败;状态已收敛 build_failed)。"""
|
||||
|
||||
|
||||
class ExtensionRunError(ExtensionError):
|
||||
"""执行器未接入/运行前置失败(503/400)。"""
|
||||
|
||||
|
||||
_ID_COUNTER = 0
|
||||
_ID_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _short_id() -> str:
|
||||
"""唯一短 id:时间戳 + 线程号 + 进程内自增计数(同秒同线程不冲突)。"""
|
||||
global _ID_COUNTER
|
||||
with _ID_LOCK:
|
||||
_ID_COUNTER = (_ID_COUNTER + 1) % 1000
|
||||
counter = _ID_COUNTER
|
||||
return f"{int(time.time()) % 1000000:06d}{threading.get_ident() % 1000:03d}{counter:03d}"
|
||||
|
||||
|
||||
def new_ext_id() -> str:
|
||||
return f"ext_{_short_id()}"
|
||||
|
||||
|
||||
def new_run_id() -> str:
|
||||
return f"run_{_short_id()}"
|
||||
|
||||
|
||||
def safe_filename(value: str, fallback: str = "item") -> str:
|
||||
"""zip 条目名净化(去路径穿越;保留中文;空名给 fallback)。"""
|
||||
name = Path(str(value or "")).name or fallback
|
||||
return name.replace("\x00", "") or fallback
|
||||
|
||||
|
||||
def _merge(target: dict[str, Any], patch: dict[str, Any]) -> None:
|
||||
for key, value in patch.items():
|
||||
target[key] = value
|
||||
|
||||
|
||||
async def _call_executor(executor: Any, spec_in: ExtensionRunSpec) -> Any:
|
||||
"""执行器统一调用:兼容 async 方法/协程函数/同步函数三类实现。
|
||||
|
||||
Dev-B 的真实执行器为 async(docker subprocess);测试 fake 可 sync。
|
||||
"""
|
||||
raw = executor.execute(spec_in)
|
||||
if inspect.isawaitable(raw):
|
||||
raw = await raw
|
||||
return raw
|
||||
|
||||
|
||||
class _JsonLock:
|
||||
"""extension.json 写锁(RLock:_apply_and_write 的读-改-写全程持锁)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def __enter__(self) -> "threading.RLock":
|
||||
self._lock.acquire()
|
||||
return self._lock
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self._lock.release()
|
||||
|
||||
|
||||
# ---- Executor 接口(Dev-B 实现 extension_exec.py;签名即契约)----
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtensionRunSpec:
|
||||
"""ExtensionExecutor.execute 入参(Dev-B 拼装 docker 命令的唯一依据,契约 §3)。"""
|
||||
|
||||
ext_dir: str # {data_root}/extensions/{ext_id}(宿主路径)
|
||||
input_dir: str # 输入目录(宿主路径;可为空目录)
|
||||
output_dir: str # 输出目录(宿主路径,{output_dir} 渲染目标)
|
||||
output_contract_exit: int = OUTPUT_CONTRACT_EXIT # 输出非空校验退出码
|
||||
params: dict[str, Any] = field(default_factory=dict) # {param_*} 与运行时参数
|
||||
cpu: str | None = None
|
||||
memory: str | None = None
|
||||
runtime_image: str = spec.DEFAULT_RUNTIME_IMAGE
|
||||
log_path: str | None = None # 可选:全量日志落点(宿主路径)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtensionRunResult:
|
||||
"""ExtensionExecutor.execute 返回。
|
||||
|
||||
Executor 承诺:exit_code=0 表示命令成功且输出目录非空;非 0/超时/启动
|
||||
失败一律算失败并给 error 文案;log_tail 为截尾日志文本。
|
||||
"""
|
||||
|
||||
run_id: str
|
||||
exit_code: int
|
||||
log_tail: str
|
||||
outputs: list[str] = field(default_factory=list) # 输出文件相对 output_dir 路径
|
||||
error: str = "" # 失败原因(命令失败/启动失败)
|
||||
timed_out: bool = False
|
||||
started_at: str = ""
|
||||
finished_at: str = ""
|
||||
|
||||
|
||||
class ExtensionExecutor:
|
||||
"""docker 执行占位(Dev-B 的 extension_exec.py 替换实现)。
|
||||
|
||||
本模块经 inject_executor(executor) 注入;路由层从 context 取。
|
||||
占位实现 = 留痕执行(不走 docker,也不假装成功)。
|
||||
"""
|
||||
|
||||
async def execute(self, spec_in: ExtensionRunSpec) -> ExtensionRunResult:
|
||||
raise ExtensionRunError(
|
||||
"扩展执行器未接入(extension_exec.py 由 Dev-B 实现;"
|
||||
"接口签名见 extensions.ExtensionExecutor)"
|
||||
)
|
||||
|
||||
|
||||
# ---- 扩展生命周期状态机 ----
|
||||
|
||||
|
||||
# 合法流转表(契约 §2 status 枚举;模块级常量,dataclass 外)
|
||||
TRANSITIONS: dict[str, set[str]] = {
|
||||
STATUS_DRAFT: {STATUS_BUILDING, STATUS_BUILD_FAILED},
|
||||
STATUS_BUILDING: {STATUS_BUILT, STATUS_BUILD_FAILED},
|
||||
STATUS_BUILT: {STATUS_TRIAL_RUNNING, STATUS_BUILDING},
|
||||
STATUS_TRIAL_RUNNING: {STATUS_TRIAL_PASSED, STATUS_TRIAL_FAILED},
|
||||
STATUS_TRIAL_PASSED: {STATUS_VALIDATING, STATUS_TRIAL_RUNNING, STATUS_CONFIRMING, STATUS_BUILDING},
|
||||
STATUS_TRIAL_FAILED: {STATUS_REPAIRING, STATUS_TRIAL_RUNNING, STATUS_BUILDING},
|
||||
STATUS_VALIDATING: {STATUS_VALIDATED, STATUS_TRIAL_FAILED},
|
||||
STATUS_VALIDATED: {STATUS_CONFIRMING, STATUS_PUBLISHED, STATUS_BUILDING, STATUS_TRIAL_RUNNING},
|
||||
STATUS_REPAIRING: {STATUS_BUILT, STATUS_TRIAL_RUNNING, STATUS_TRIAL_FAILED, STATUS_BUILD_FAILED},
|
||||
STATUS_CONFIRMING: {STATUS_PUBLISHED, STATUS_VALIDATED, STATUS_TRIAL_RUNNING, STATUS_TRIAL_PASSED},
|
||||
STATUS_PUBLISHED: set(), # 终态:只可 run/删除
|
||||
STATUS_BUILD_FAILED: {STATUS_BUILDING, STATUS_REPAIRING, STATUS_DRAFT},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtensionStore:
|
||||
"""扩展存储(文件布局 §1)。"""
|
||||
|
||||
base_dir: str # {data_root}/extensions
|
||||
executor: ExtensionExecutor | None = None # 注入的执行器(Dev-B 替换点)
|
||||
max_repair_rounds: int = 20 # repair 上限(契约 §6 可配)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
Path(self.base_dir).mkdir(parents=True, exist_ok=True)
|
||||
self._locks: dict[str, _JsonLock] = {}
|
||||
self._lock_guard = threading.Lock()
|
||||
|
||||
# ---- 路径 ----
|
||||
|
||||
def ext_dir(self, ext_id: str) -> Path:
|
||||
return Path(self.base_dir) / str(ext_id)
|
||||
|
||||
def manifest_path(self, ext_id: str) -> Path:
|
||||
return self.ext_dir(ext_id) / "extension.json"
|
||||
|
||||
def runs_dir(self, ext_id: str) -> Path:
|
||||
return self.ext_dir(ext_id) / "runs"
|
||||
|
||||
def validation_dir(self, ext_id: str) -> Path:
|
||||
return self.ext_dir(ext_id) / "validation"
|
||||
|
||||
def _lock_for(self, ext_id: str) -> _JsonLock:
|
||||
with self._lock_guard:
|
||||
return self._locks.setdefault(str(ext_id), _JsonLock())
|
||||
|
||||
# ---- 持久化 ----
|
||||
|
||||
def save_extension(self, ext: dict[str, Any]) -> dict[str, Any]:
|
||||
"""extension.json 落盘:读-合并-原子替换(本次字段覆盖磁盘版本)。"""
|
||||
ext_id = str(ext.get("ext_id") or "")
|
||||
ext["updated_at"] = now_iso()
|
||||
path = self.manifest_path(ext_id)
|
||||
if path.exists():
|
||||
try:
|
||||
on_disk = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(on_disk, dict):
|
||||
merged = dict(on_disk)
|
||||
_merge(merged, ext)
|
||||
ext = merged
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
with self._lock_for(ext_id):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(ext, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
return ext
|
||||
|
||||
def load_extension(self, ext_id: str, *, required: bool = True) -> dict[str, Any] | None:
|
||||
path = self.manifest_path(ext_id)
|
||||
if not path.exists():
|
||||
if required:
|
||||
raise ExtensionNotFoundError(f"扩展不存在: {ext_id}")
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError) as exc:
|
||||
if required:
|
||||
raise ExtensionError(f"extension.json 损坏: {ext_id}: {exc}") from exc
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
if required:
|
||||
raise ExtensionError(f"extension.json 非对象: {ext_id}")
|
||||
return None
|
||||
return data
|
||||
|
||||
# ---- 状态机(合法流转表;契约 §2)----
|
||||
|
||||
def _set_status(self, ext: dict[str, Any], new_status: str) -> None:
|
||||
old = str(ext.get("status") or STATUS_DRAFT)
|
||||
if new_status not in EXTENSION_STATUSES:
|
||||
raise ExtensionStatusError(f"未知状态: {new_status}")
|
||||
if old not in TRANSITIONS:
|
||||
raise ExtensionStatusError(f"未知当前状态: {old}")
|
||||
if new_status != old and new_status not in TRANSITIONS[old]:
|
||||
raise ExtensionStatusError(f"状态不可流转: {old} → {new_status}")
|
||||
ext["status"] = new_status
|
||||
|
||||
def _apply_and_write(self, ext_id: str, mutate: Any) -> dict[str, Any]:
|
||||
"""读-改-写骨架(全程持该扩展锁):mutate(fresh) 内可 _set_status。"""
|
||||
with self._lock_for(ext_id):
|
||||
fresh = self.load_extension(ext_id)
|
||||
mutate(fresh)
|
||||
return self.save_extension(fresh)
|
||||
|
||||
# ---- 创建(POST /extensions)----
|
||||
|
||||
def create_extension(
|
||||
self,
|
||||
*,
|
||||
display_name: str,
|
||||
command: str,
|
||||
runtime_image: str = "",
|
||||
code_files: list[tuple[str, bytes]] | None = None,
|
||||
data_files: list[tuple[str, bytes]] | None = None,
|
||||
golden: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""创建扩展(draft):保存代码/测试数据包,登记 extension.json。
|
||||
|
||||
code_files: [(rel_path, bytes)] zip 解压条目(路由层完成解压;服务层
|
||||
只做落盘与净化);data_files: 测试数据/金标输入。
|
||||
"""
|
||||
name = str(display_name or "").strip()
|
||||
if not name:
|
||||
raise ExtensionError("display_name 不能为空")
|
||||
if not (command or "").strip():
|
||||
raise ExtensionError("运行命令(command)不能为空")
|
||||
if not code_files:
|
||||
raise ExtensionError("扩展代码包不能为空")
|
||||
|
||||
ext_id = new_ext_id()
|
||||
ext_dir = self.ext_dir(ext_id)
|
||||
raw_code = ext_dir / "raw_code"
|
||||
raw_test_data = ext_dir / "raw_test_data"
|
||||
converted_code = ext_dir / "converted_code"
|
||||
for path in (raw_code, raw_test_data, converted_code, self.validation_dir(ext_id)):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
saved_code: list[str] = []
|
||||
for rel, blob in code_files:
|
||||
dest = raw_code / safe_filename(rel)
|
||||
dest.write_bytes(blob)
|
||||
saved_code.append(str(dest.relative_to(ext_dir)))
|
||||
mirror = converted_code / dest.name
|
||||
mirror.write_bytes(blob) # converted_code 初始 = raw_code 拷贝(175 同款)
|
||||
saved_data: list[str] = []
|
||||
for rel, blob in data_files or []:
|
||||
dest = raw_test_data / safe_filename(rel)
|
||||
dest.write_bytes(blob)
|
||||
saved_data.append(str(dest.relative_to(ext_dir)))
|
||||
|
||||
runtime_image = spec.RUNTIME_IMAGE_MAP.get(runtime_image, runtime_image) or spec.DEFAULT_RUNTIME_IMAGE
|
||||
now = now_iso()
|
||||
ext: dict[str, Any] = {
|
||||
"schema_version": spec.SPEC_VERSION,
|
||||
"ext_id": ext_id,
|
||||
"display_name": name,
|
||||
"status": STATUS_DRAFT,
|
||||
"command_template": "",
|
||||
"original_command": str(command).strip(),
|
||||
"runtime_image": runtime_image,
|
||||
"requirements": {"pip": [], "apt": []},
|
||||
"parameters": [],
|
||||
"input_roles": {},
|
||||
"nl_triggers": [],
|
||||
"analysis_type": spec.DEFAULT_ANALYSIS_TYPE,
|
||||
"golden": golden if isinstance(golden, dict) else None,
|
||||
"repair_count": 0,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"trial": None,
|
||||
"validation": None,
|
||||
"last_build": None,
|
||||
"source_files": {"code": saved_code, "test_data": saved_data},
|
||||
}
|
||||
ext = self.save_extension(ext)
|
||||
self._publish("extension_created", {
|
||||
"ext_id": ext_id, "display_name": name, "status": STATUS_DRAFT,
|
||||
})
|
||||
logger.info("extensions: created %s (%s)", ext_id, name)
|
||||
return ext
|
||||
|
||||
# ---- build(POST /{id}/build:AI 转换)----
|
||||
|
||||
async def build_extension(self, ext_id: str) -> dict[str, Any]:
|
||||
self._apply_and_write(ext_id, lambda ext: self._set_status(ext, STATUS_BUILDING))
|
||||
# LLM 转换委托 extension_llm(内部保证:LLM 失败回退规则版,绝不抛 500)
|
||||
from .extension_llm import build_conversion
|
||||
|
||||
try:
|
||||
conversion = await build_conversion(self.load_extension(ext_id), store=self)
|
||||
except ExtensionError as exc:
|
||||
# 失败收敛 build_failed(契约状态枚举)再抛出(路由层 4xx,非 500)
|
||||
try:
|
||||
self._apply_and_write(ext_id, lambda ext: self._set_status(ext, STATUS_BUILD_FAILED))
|
||||
except ExtensionStatusError:
|
||||
pass
|
||||
raise ExtensionBuildError(str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001 - 防御:未知异常同样收敛,绝不 500
|
||||
try:
|
||||
self._apply_and_write(ext_id, lambda ext: self._set_status(ext, STATUS_BUILD_FAILED))
|
||||
except ExtensionStatusError:
|
||||
pass
|
||||
raise ExtensionBuildError(f"扩展转换失败: {exc}") from exc
|
||||
template = str(conversion.get("command_template") or "")
|
||||
spec.validate_command_template(template) # 白名单契约终检(不通过抛 → build_failed)
|
||||
fresh = self.load_extension(ext_id)
|
||||
fresh["command_template"] = template
|
||||
fresh["requirements"] = conversion.get("requirements") or {"pip": [], "apt": []}
|
||||
fresh["runtime_image"] = str(conversion.get("runtime_image") or fresh.get("runtime_image") or spec.DEFAULT_RUNTIME_IMAGE)
|
||||
fresh["parameters"] = spec.template_parameters(
|
||||
template, conversion.get("parameters") or fresh.get("parameters") or []
|
||||
)
|
||||
fresh["input_roles"] = conversion.get("input_roles") or {}
|
||||
fresh["nl_triggers"] = [str(x).strip() for x in (conversion.get("nl_triggers") or []) if str(x).strip()]
|
||||
fresh["analysis_type"] = spec.normalize_analysis_type(conversion.get("analysis_type"))
|
||||
fresh["last_build"] = {"source": conversion.get("source", "unknown"), "at": now_iso()}
|
||||
self._set_status(fresh, STATUS_BUILT)
|
||||
fresh = self.save_extension(fresh)
|
||||
self._publish("extension_build", {
|
||||
"ext_id": ext_id,
|
||||
"command_template": fresh["command_template"],
|
||||
"requirements": fresh["requirements"],
|
||||
"runtime_image": fresh["runtime_image"],
|
||||
"nl_triggers": fresh["nl_triggers"],
|
||||
"analysis_type": fresh["analysis_type"],
|
||||
"parameters": fresh["parameters"],
|
||||
"status": fresh["status"],
|
||||
})
|
||||
logger.info("extensions: build ok %s -> %s", ext_id, template)
|
||||
return fresh
|
||||
|
||||
# ---- trial-run(POST /{id}/trial-run)----
|
||||
|
||||
async def trial_run(self, ext_id: str) -> dict[str, Any]:
|
||||
ext = self.load_extension(ext_id)
|
||||
status = str(ext.get("status") or STATUS_DRAFT)
|
||||
if status not in TRIAL_RETRYABLE:
|
||||
raise ExtensionStatusError(f"状态不可试运行: {status}")
|
||||
if not (ext.get("command_template") or "").strip():
|
||||
raise ExtensionStatusError("试运行前必须先 build(command_template 为空)")
|
||||
|
||||
trial = ext.get("trial") if isinstance(ext.get("trial"), dict) else {}
|
||||
n = max(0, int(trial.get("n") or 0)) + 1
|
||||
validation = self.validation_dir(ext_id)
|
||||
log_path = validation / f"trial_{n}.log"
|
||||
out_dir = validation / f"trial_{n}.outputs"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
input_dir = self.ext_dir(ext_id) / "raw_test_data"
|
||||
if not input_dir.exists() or not any(input_dir.iterdir()):
|
||||
input_dir = self.runs_dir(ext_id) / "inputs" # 无测试数据 → 空输入目录
|
||||
input_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run_id = f"trial_{n}"
|
||||
self._apply_and_write(ext_id, lambda ext: (
|
||||
self._set_status(ext, STATUS_TRIAL_RUNNING),
|
||||
ext.__setitem__("trial", {"n": n, "run_id": run_id,
|
||||
"log_path": str(log_path.relative_to(self.ext_dir(ext_id)))}),
|
||||
))
|
||||
|
||||
executor = self.executor or ExtensionExecutor()
|
||||
if isinstance(executor, ExtensionExecutor) and type(executor) is ExtensionExecutor:
|
||||
# 占位执行器(Dev-B 未接入):明确 503 语义,绝不伪装成 trial_failed 触发修复循环
|
||||
raise ExtensionRunError(
|
||||
"扩展执行器未接入(extension_exec.py 由 Dev-B 实现;"
|
||||
"接口签名见 extensions.ExtensionExecutor)"
|
||||
)
|
||||
try:
|
||||
result = await _call_executor(executor, ExtensionRunSpec(
|
||||
ext_dir=str(self.ext_dir(ext_id)),
|
||||
input_dir=str(input_dir),
|
||||
output_dir=str(out_dir),
|
||||
runtime_image=str(ext.get("runtime_image") or spec.DEFAULT_RUNTIME_IMAGE),
|
||||
log_path=str(log_path),
|
||||
))
|
||||
except Exception as exc: # noqa: BLE001 - 执行器异常 → trial_failed 语义(可修复),绝不 500
|
||||
result = ExtensionRunResult(
|
||||
run_id=run_id, exit_code=1, log_tail="", outputs=[],
|
||||
error=str(exc) or "扩展执行失败",
|
||||
)
|
||||
if isinstance(result, dict): # 容错:dict 形态 executor 也接受
|
||||
result = _result_from_dict(result, run_id=run_id)
|
||||
result.started_at = result.started_at or now_iso()
|
||||
result.finished_at = result.finished_at or now_iso()
|
||||
|
||||
ok = result.exit_code == 0 and not result.error
|
||||
fresh = self.load_extension(ext_id)
|
||||
fresh["trial"] = {
|
||||
"n": n,
|
||||
"run_id": run_id,
|
||||
"ok": ok,
|
||||
"exit_code": result.exit_code,
|
||||
"log_tail": result.log_tail,
|
||||
"outputs": result.outputs,
|
||||
"error": result.error,
|
||||
}
|
||||
self._set_status(fresh, STATUS_TRIAL_PASSED if ok else STATUS_TRIAL_FAILED)
|
||||
fresh = self.save_extension(fresh)
|
||||
self._publish("extension_trial", {
|
||||
"ext_id": ext_id,
|
||||
"status": "done" if ok else "failed",
|
||||
"log_tail": result.log_tail,
|
||||
"exit_code": result.exit_code,
|
||||
"error": result.error,
|
||||
})
|
||||
if ok:
|
||||
logger.info("extensions: trial ok %s (run %s)", ext_id, run_id)
|
||||
else:
|
||||
logger.warning("extensions: trial failed %s (run %s): %s", ext_id, run_id, result.error)
|
||||
return fresh
|
||||
|
||||
# ---- validate(POST /{id}/validate;有金标时对比,无金标按 trial 结果放行)----
|
||||
|
||||
def validate(self, ext_id: str) -> dict[str, Any]:
|
||||
ext = self.load_extension(ext_id)
|
||||
self._apply_and_write(ext_id, lambda e: self._set_status(e, STATUS_VALIDATING))
|
||||
ext = self.load_extension(ext_id)
|
||||
|
||||
golden = ext.get("golden")
|
||||
report: dict[str, Any] = {"golden": golden, "compared": False}
|
||||
if isinstance(golden, dict) and (golden.get("expected_outputs") or golden.get("test_input")):
|
||||
# 金标校验(文件名/存在性级;全量内容对比由 Dev-B 执行链扩展 comparators)
|
||||
test_input = str(golden.get("test_input") or "")
|
||||
input_dir = self.ext_dir(ext_id) / "raw_test_data"
|
||||
missing_input = bool(test_input) and not (input_dir / safe_filename(test_input)).exists()
|
||||
expected = [str(x) for x in (golden.get("expected_outputs") or []) if str(x)]
|
||||
trial = ext.get("trial") if isinstance(ext.get("trial"), dict) else {}
|
||||
produced = {Path(str(x)).name for x in (trial.get("outputs") or [])}
|
||||
missing_outputs = [x for x in expected if Path(x).name not in produced]
|
||||
ok = not missing_input and not missing_outputs and bool(trial.get("ok"))
|
||||
report.update({
|
||||
"compared": True,
|
||||
"ok": ok,
|
||||
"missing_input": bool(missing_input),
|
||||
"missing_outputs": missing_outputs,
|
||||
})
|
||||
fresh = self.load_extension(ext_id)
|
||||
fresh["validation"] = report
|
||||
self._set_status(fresh, STATUS_VALIDATED if ok else STATUS_TRIAL_FAILED)
|
||||
return self.save_extension(fresh)
|
||||
|
||||
# 无金标:trial 通过即 validated
|
||||
trial = ext.get("trial") if isinstance(ext.get("trial"), dict) else {}
|
||||
ok = bool(trial.get("ok"))
|
||||
report["ok"] = ok
|
||||
fresh = self.load_extension(ext_id)
|
||||
fresh["validation"] = report
|
||||
self._set_status(fresh, STATUS_VALIDATED if ok else STATUS_TRIAL_FAILED)
|
||||
return self.save_extension(fresh)
|
||||
|
||||
# ---- repair(POST /{id}/repair:LLM 修复,上限 max_repair_rounds)----
|
||||
|
||||
async def repair(self, ext_id: str) -> dict[str, Any]:
|
||||
ext = self.load_extension(ext_id)
|
||||
status = str(ext.get("status") or STATUS_DRAFT)
|
||||
if status not in {STATUS_TRIAL_FAILED, STATUS_BUILD_FAILED}:
|
||||
raise ExtensionStatusError(f"状态不可修复: {status}")
|
||||
max_rounds = max(1, int(self.max_repair_rounds))
|
||||
repair_count = max(0, int(ext.get("repair_count") or 0))
|
||||
if repair_count >= max_rounds:
|
||||
raise ExtensionStatusError(f"修复轮次已达上限({max_rounds})")
|
||||
|
||||
trial = ext.get("trial") if isinstance(ext.get("trial"), dict) else {}
|
||||
context = {
|
||||
"log_tail": trial.get("log_tail") or "",
|
||||
"error": trial.get("error") or "",
|
||||
"exit_code": trial.get("exit_code"),
|
||||
}
|
||||
self._apply_and_write(ext_id, lambda e: (
|
||||
self._set_status(e, STATUS_REPAIRING),
|
||||
e.__setitem__("repair_count", repair_count + 1),
|
||||
))
|
||||
|
||||
from .extension_llm import repair_conversion
|
||||
|
||||
try:
|
||||
conversion = await repair_conversion(self.load_extension(ext_id), failure_context=context, store=self)
|
||||
except Exception as exc: # noqa: BLE001 - 修复失败收敛回 trial_failed/build_failed
|
||||
try:
|
||||
def _back(ext: dict[str, Any]) -> None:
|
||||
if status == STATUS_BUILD_FAILED:
|
||||
self._set_status(ext, STATUS_BUILD_FAILED)
|
||||
else:
|
||||
self._set_status(ext, STATUS_TRIAL_FAILED)
|
||||
self._apply_and_write(ext_id, _back)
|
||||
except ExtensionStatusError:
|
||||
pass
|
||||
raise ExtensionBuildError(f"扩展修复失败: {exc}") from exc
|
||||
template = str(conversion.get("command_template") or "")
|
||||
spec.validate_command_template(template)
|
||||
fresh = self.load_extension(ext_id)
|
||||
fresh["command_template"] = template
|
||||
if conversion.get("requirements"):
|
||||
fresh["requirements"] = conversion["requirements"]
|
||||
fresh["repair_suggestion"] = str(conversion.get("suggestion") or "")
|
||||
fresh["repair_count"] = repair_count + 1
|
||||
self._set_status(fresh, STATUS_BUILT)
|
||||
fresh = self.save_extension(fresh)
|
||||
self._publish("extension_repair", {
|
||||
"ext_id": ext_id,
|
||||
"repair_count": fresh["repair_count"],
|
||||
"suggestion": fresh.get("repair_suggestion") or "",
|
||||
"status": fresh["status"],
|
||||
})
|
||||
logger.info("extensions: repair ok %s (round %d)", ext_id, repair_count + 1)
|
||||
return fresh
|
||||
|
||||
# ---- confirm(用户确认/参数调整 → confirming)----
|
||||
|
||||
def confirm(self, ext_id: str, *, nl_triggers: list[str] | None = None,
|
||||
parameters: list[dict[str, Any]] | None = None,
|
||||
runtime_image: str | None = None) -> dict[str, Any]:
|
||||
ext = self.load_extension(ext_id)
|
||||
status = str(ext.get("status") or STATUS_DRAFT)
|
||||
if status not in {STATUS_VALIDATED, STATUS_CONFIRMING, STATUS_TRIAL_PASSED, STATUS_PUBLISHED}:
|
||||
raise ExtensionStatusError(f"状态不可确认: {status}")
|
||||
|
||||
def _apply(fresh: dict[str, Any]) -> None:
|
||||
if nl_triggers is not None:
|
||||
fresh["nl_triggers"] = [str(x).strip() for x in nl_triggers if str(x).strip()][:50]
|
||||
if parameters is not None:
|
||||
template = fresh.get("command_template") or ""
|
||||
# 用户编辑后的 parameters 仍需覆盖模板中全部 {param_*}(漏参补齐)
|
||||
fresh["parameters"] = spec.template_parameters(template, parameters)
|
||||
if runtime_image:
|
||||
fresh["runtime_image"] = spec.RUNTIME_IMAGE_MAP.get(runtime_image, runtime_image)
|
||||
if status != STATUS_PUBLISHED:
|
||||
self._set_status(fresh, STATUS_CONFIRMING)
|
||||
|
||||
return self._apply_and_write(ext_id, _apply)
|
||||
|
||||
# ---- publish(POST /{id}/publish)----
|
||||
|
||||
def publish(self, ext_id: str) -> dict[str, Any]:
|
||||
ext = self.load_extension(ext_id)
|
||||
status = str(ext.get("status") or STATUS_DRAFT)
|
||||
if status not in {STATUS_CONFIRMING, STATUS_VALIDATED, STATUS_TRIAL_PASSED, STATUS_PUBLISHED}:
|
||||
raise ExtensionStatusError(f"状态不可发布: {status}")
|
||||
|
||||
def _apply(fresh: dict[str, Any]) -> None:
|
||||
if not fresh.get("nl_triggers"):
|
||||
fresh["nl_triggers"] = [str(fresh.get("display_name") or "")]
|
||||
if status != STATUS_PUBLISHED:
|
||||
self._set_status(fresh, STATUS_PUBLISHED)
|
||||
|
||||
fresh = self._apply_and_write(ext_id, _apply)
|
||||
self._publish("extension_published", {
|
||||
"ext_id": ext_id,
|
||||
"display_name": fresh.get("display_name"),
|
||||
"nl_triggers": fresh.get("nl_triggers"),
|
||||
})
|
||||
logger.info("extensions: published %s", ext_id)
|
||||
return fresh
|
||||
|
||||
# ---- run(POST /{id}/run:正式运行;同步返回 run_result,SSE 由 Dev-B 对接)----
|
||||
|
||||
async def run(self, ext_id: str, *, params: dict[str, Any] | None = None,
|
||||
files: list[str] | None = None) -> dict[str, Any]:
|
||||
ext = self.load_extension(ext_id)
|
||||
if str(ext.get("status") or "") != STATUS_PUBLISHED:
|
||||
raise ExtensionStatusError(f"仅已发布扩展可运行(当前 {ext.get('status')})")
|
||||
|
||||
run_id = new_run_id()
|
||||
run_dir = self.runs_dir(ext_id) / run_id
|
||||
input_dir = run_dir / "inputs"
|
||||
output_dir = run_dir / "outputs"
|
||||
for path in (input_dir, output_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 文件登记:路径须在白名单根内(data_root/扩展目录),拷贝入 runs/{id}/inputs
|
||||
registered: list[dict[str, Any]] = []
|
||||
for src in files or []:
|
||||
p = Path(str(src)).expanduser()
|
||||
if not p.is_file():
|
||||
raise ExtensionRunError(f"运行输入文件不存在: {src}")
|
||||
resolved = p.resolve()
|
||||
allowed_roots = (Path(self.base_dir).resolve().parent, Path(self.base_dir).resolve())
|
||||
if not any(resolved.is_relative_to(root) for root in allowed_roots):
|
||||
raise ExtensionRunError(f"输入路径不在白名单根内: {src}")
|
||||
dest = input_dir / safe_filename(p.name)
|
||||
if resolved != dest.resolve():
|
||||
shutil.copy2(p, dest)
|
||||
registered.append({"name": dest.name, "size": p.stat().st_size})
|
||||
if registered:
|
||||
logger.info("extensions: run %s inputs=%s", run_id, [x["name"] for x in registered])
|
||||
|
||||
executor = self.executor or ExtensionExecutor()
|
||||
started = now_iso()
|
||||
if isinstance(executor, ExtensionExecutor) and type(executor) is ExtensionExecutor:
|
||||
raise ExtensionRunError(
|
||||
"扩展执行器未接入(extension_exec.py 由 Dev-B 实现;"
|
||||
"接口签名见 extensions.ExtensionExecutor)"
|
||||
)
|
||||
try:
|
||||
result = await _call_executor(executor, ExtensionRunSpec(
|
||||
ext_dir=str(self.ext_dir(ext_id)),
|
||||
input_dir=str(input_dir),
|
||||
output_dir=str(output_dir),
|
||||
runtime_image=str(ext.get("runtime_image") or spec.DEFAULT_RUNTIME_IMAGE),
|
||||
params=params or {},
|
||||
))
|
||||
except Exception as exc: # noqa: BLE001 - 执行器异常 → failed 语义,绝不 500
|
||||
result = ExtensionRunResult(
|
||||
run_id=run_id, exit_code=1, log_tail="", outputs=[],
|
||||
error=str(exc) or "扩展执行失败",
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
result = _result_from_dict(result, run_id=run_id)
|
||||
result.started_at = result.started_at or started
|
||||
result.finished_at = result.finished_at or now_iso()
|
||||
|
||||
outputs = []
|
||||
if output_dir.is_dir():
|
||||
outputs = sorted(
|
||||
str(p.relative_to(output_dir)) for p in output_dir.rglob("*") if p.is_file()
|
||||
)
|
||||
ok = result.exit_code == 0 and not result.error
|
||||
run_result: dict[str, Any] = {
|
||||
"run_id": run_id,
|
||||
"ext_id": ext_id,
|
||||
"exit_code": result.exit_code,
|
||||
"started_at": result.started_at,
|
||||
"finished_at": result.finished_at,
|
||||
"outputs": outputs or result.outputs,
|
||||
"error": result.error,
|
||||
"status": RUN_DONE if ok else RUN_FAILED,
|
||||
}
|
||||
(run_dir / "run_result.json").write_text(
|
||||
json.dumps(run_result, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
if result.log_tail:
|
||||
(run_dir / "run.log").write_text(str(result.log_tail), encoding="utf-8")
|
||||
|
||||
# 事件帧(契约 §4;同步执行 → 先补帧再返回,Dev-B 后台化后改实时推送)
|
||||
# 全部帧都带 ext_id:EventBus 按 job_id(=ext_id) 落同一 jsonl 可回放
|
||||
self._publish("extension_run_started", {
|
||||
"run_id": run_id, "ext_id": ext_id, "started_at": result.started_at,
|
||||
})
|
||||
if result.log_tail:
|
||||
self._publish("extension_run_log", {"run_id": run_id, "ext_id": ext_id, "log": result.log_tail})
|
||||
if ok:
|
||||
self._publish("extension_run_done", {
|
||||
"run_id": run_id, "ext_id": ext_id, "outputs": run_result["outputs"],
|
||||
})
|
||||
else:
|
||||
self._publish("extension_run_failed", {
|
||||
"run_id": run_id, "ext_id": ext_id, "exit_code": result.exit_code,
|
||||
"error": result.error, "log_tail": result.log_tail,
|
||||
})
|
||||
return run_result
|
||||
|
||||
def list_runs(self, ext_id: str) -> list[dict[str, Any]]:
|
||||
runs_dir = self.runs_dir(ext_id)
|
||||
if not runs_dir.is_dir():
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for path in sorted(runs_dir.iterdir(), key=lambda p: p.name, reverse=True):
|
||||
result_file = path / "run_result.json"
|
||||
if result_file.is_file():
|
||||
try:
|
||||
items.append(json.loads(result_file.read_text(encoding="utf-8")))
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
return items
|
||||
|
||||
# ---- 事件总线(契约 §4 事件帧;惰性取 context.events,缺失静默降级)----
|
||||
|
||||
def _event_bus(self) -> Any:
|
||||
"""事件总线:优先测试/部署注入的 self._bus 覆盖,否则惰性取 context.events。"""
|
||||
override = getattr(self, "_bus_override", None)
|
||||
if override is not None:
|
||||
return override
|
||||
try:
|
||||
from ... import context as context_mod
|
||||
|
||||
return context_mod.context.events
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
def _publish(self, event_type: str, data: dict[str, Any]) -> None:
|
||||
"""发布 extension_* 事件帧;总线未初始化时静默降级(不阻断业务)。
|
||||
|
||||
data 统一补 job_id=ext_id:EventBus 持久日志按 job_id 落文件
|
||||
({ext_id}.jsonl),replay/snapshot 按扩展维度可回放(契约 §4 帧)。
|
||||
"""
|
||||
bus = self._event_bus()
|
||||
if bus is None:
|
||||
return
|
||||
payload = dict(data)
|
||||
if not payload.get("job_id"):
|
||||
payload["job_id"] = payload.get("ext_id") or payload.get("run_id") or ""
|
||||
try:
|
||||
bus.publish(event_type, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# ---- 列表 / 删除 ----
|
||||
|
||||
def list_extensions(self) -> list[dict[str, Any]]:
|
||||
"""全部扩展列表(published 优先,其余按 updated_at 倒序;契约 §6)。"""
|
||||
items: list[dict[str, Any]] = []
|
||||
for path in Path(self.base_dir).glob("ext_*"):
|
||||
manifest = path / "extension.json"
|
||||
if not manifest.is_file():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if isinstance(data, dict) and data.get("ext_id"):
|
||||
items.append(data)
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda x: (str(x.get("status")) != STATUS_PUBLISHED,
|
||||
str(x.get("updated_at") or x.get("created_at") or "")),
|
||||
)
|
||||
|
||||
def delete(self, ext_id: str) -> None:
|
||||
ext = self.load_extension(ext_id)
|
||||
if str(ext.get("status")) == STATUS_TRIAL_RUNNING:
|
||||
raise ExtensionStatusError("试运行中不可删除")
|
||||
shutil.rmtree(self.ext_dir(ext_id), ignore_errors=True)
|
||||
|
||||
|
||||
def _result_from_dict(data: dict[str, Any], *, run_id: str) -> ExtensionRunResult:
|
||||
return ExtensionRunResult(
|
||||
run_id=str(data.get("run_id") or run_id),
|
||||
exit_code=int(data.get("exit_code") or 0),
|
||||
log_tail=str(data.get("log_tail") or ""),
|
||||
outputs=[str(x) for x in (data.get("outputs") or [])],
|
||||
error=str(data.get("error") or ""),
|
||||
timed_out=bool(data.get("timed_out")),
|
||||
started_at=str(data.get("started_at") or ""),
|
||||
finished_at=str(data.get("finished_at") or ""),
|
||||
)
|
||||
|
||||
|
||||
# ---- 模块级默认实例(路由直接使用;测试可构造独立实例)----
|
||||
|
||||
_store: ExtensionStore | None = None
|
||||
|
||||
|
||||
def get_store() -> ExtensionStore:
|
||||
"""惰性单例:数据根来自配置(测试经 monkeypatch get_settings 生效)。"""
|
||||
global _store
|
||||
if _store is None:
|
||||
from ...config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
# 扩展存储根来自配置 BIOFLOW_EXTENSIONS_DIR(默认 bioflow_data/extensions,
|
||||
# 与 data_root 同根的契约 §1 布局;生产可按需指向 data_root 外可写区)
|
||||
_store = ExtensionStore(
|
||||
base_dir=str(Path(s.extensions_dir_abs)),
|
||||
max_repair_rounds=int(s.extension_max_repair_rounds),
|
||||
)
|
||||
inject_executor(_store)
|
||||
return _store
|
||||
|
||||
|
||||
def inject_executor(store: ExtensionStore | None = None) -> None:
|
||||
"""Dev-B 替换点:extension_exec.py 实现后调用本函数注入真执行器。
|
||||
|
||||
executor 为 ExtensionExecutor 实例或可调用对象(协程:
|
||||
execute(ExtensionRunSpec) -> ExtensionRunResult | dict)。
|
||||
"""
|
||||
target = store or get_store()
|
||||
try:
|
||||
from ... import context as context_mod
|
||||
|
||||
executor = context_mod.context.executor
|
||||
if executor is not None:
|
||||
target.executor = executor
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def reset_store() -> None:
|
||||
"""测试重置单例(config monkeypatch 后重建)。"""
|
||||
global _store
|
||||
_store = None
|
||||
@@ -358,3 +358,178 @@ def _family_hit(text_lower: str) -> bool:
|
||||
if not tail_stripped.strip(",。!?;:,.!?;:()() "):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 用户自定义扩展分析意图(契约 §5 两层判定;Dev-A 纯函数导出)
|
||||
#
|
||||
# 匹配位置 = 生信服务 chat/stream 入口(先于 LLM 阶段规划);chat/stream 的
|
||||
# 接线由 Dev-C 联调时在主分支整合,本节只导出纯函数。
|
||||
#
|
||||
# A 层创建:动作词 × 目标词同句命中 → 创建流程(上传引导卡)。
|
||||
# B 层运行:仅 published 扩展参与打分(display_name 模糊 + nl_triggers +
|
||||
# analysis_type 加权,动作词加分)。
|
||||
# 歧义消解:目标词命中但无动作词 → 有 B 层命中走运行;无命中回
|
||||
# 「没有匹配的已发布扩展,是否创建?」引导卡。
|
||||
# 漏判安全:无命中且非创建 → None 路径(回落正常生信阶段规划,零影响)。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 创建意图目标词(契约 §5 A 层)
|
||||
EXTENSION_TARGET_WORD = ["拓展分析", "分析模板", "分析脚本", "分析工具", "扩展"]
|
||||
|
||||
# 创建意图动作词(契约 §5 A 层;全词边界匹配——「建」不误命中「建议」等)
|
||||
EXTENSION_ACTION_WORD = ["创建", "新建", "上传", "自定义", "做一个", "建", "写", "弄"]
|
||||
|
||||
# 运行意图动作词(契约 §5 B 层加分)
|
||||
EXTENSION_RUN_ACTION_WORD = ["帮我跑", "帮我用", "做一下", "分析一下", "跑一下", "用一下"]
|
||||
|
||||
# 运行打分阈值:高于此分才算 B 层命中(漏判安全)
|
||||
EXTENSION_SCORE_THRESHOLD = 2.0
|
||||
|
||||
# 单字动作词的后随禁用字(防「建议/建设/写作」等复合词误命中;
|
||||
# 契约例「建个分析模板」→「个」不在禁用集 → 命中)
|
||||
_ACTION_STOP_SUFFIX: dict[str, set[str]] = {
|
||||
"建": {"议", "设", "筑", "造", "国"},
|
||||
"写": {"作", "字"},
|
||||
"弄": set(),
|
||||
}
|
||||
|
||||
|
||||
def _create_action_hit(text_lower: str) -> bool:
|
||||
"""创建动作词命中(长词子串 + 单字词禁用后缀检查)。"""
|
||||
for w in sorted(EXTENSION_ACTION_WORD, key=len, reverse=True):
|
||||
if len(w) > 1:
|
||||
if w in text_lower:
|
||||
return True
|
||||
continue
|
||||
start = 0
|
||||
while True:
|
||||
idx = text_lower.find(w, start)
|
||||
if idx < 0:
|
||||
break
|
||||
nxt = text_lower[idx + 1] if idx + 1 < len(text_lower) else ""
|
||||
if nxt not in _ACTION_STOP_SUFFIX.get(w, set()):
|
||||
return True
|
||||
start = idx + 1
|
||||
return False
|
||||
|
||||
|
||||
def extension_create_intent(text: str) -> bool:
|
||||
"""A 层创建意图:动作词 × 目标词同句命中(契约 §5)。"""
|
||||
tl = (text or "").lower()
|
||||
if not _contains_any_longest_first(tl, EXTENSION_TARGET_WORD):
|
||||
return False
|
||||
return _create_action_hit(tl)
|
||||
|
||||
|
||||
def extension_run_actions(text: str) -> bool:
|
||||
"""句子中是否含运行动作词(B 层加分项)。"""
|
||||
return _contains_any_longest_first((text or "").lower(), EXTENSION_RUN_ACTION_WORD)
|
||||
|
||||
|
||||
_ANALYSIS_TYPE_KEYWORDS: dict[str, tuple[str, ...]] = {
|
||||
"blast_search": ("blast", "比对"),
|
||||
"hmmer_search": ("hmm",),
|
||||
"table_filter": ("过滤", "筛选", "表格"),
|
||||
"sequence_extraction": ("提取", "抽取"),
|
||||
"plot_render": ("绘图", "画图", "作图"),
|
||||
"enrichment_or_stats": ("富集", "统计"),
|
||||
"gff_processing": ("gff",),
|
||||
"id_cleaning": ("去重", "清洗"),
|
||||
}
|
||||
|
||||
|
||||
def score_extension(text: str, ext: dict[str, object]) -> float:
|
||||
"""单扩展打分(纯函数):display_name 模糊 + nl_triggers + analysis_type,
|
||||
动作词加分。分项权重与 detect 内注释一致。"""
|
||||
score, _reasons = _score_extension(text, ext)
|
||||
return score
|
||||
|
||||
|
||||
def _score_extension(text: str, ext: dict[str, object]) -> tuple[float, list[str]]:
|
||||
tl = (text or "").lower()
|
||||
score = 0.0
|
||||
reasons: list[str] = []
|
||||
display = str(ext.get("display_name") or "").strip().lower()
|
||||
if display:
|
||||
if display in tl: # display_name 全文命中(最强信号)
|
||||
score += 4.0
|
||||
reasons.append("display_name")
|
||||
else:
|
||||
# 字符 2-gram 覆盖度(2-6 字名称模糊匹配;漏判安全:过半才计分)
|
||||
grams = {display[i:i + 2] for i in range(len(display) - 1)} if len(display) >= 2 else {display}
|
||||
hit = sum(1 for g in grams if g in tl)
|
||||
if hit == len(grams):
|
||||
score += 3.0
|
||||
reasons.append("display_fuzzy")
|
||||
elif hit >= max(1, len(grams) // 2):
|
||||
score += 1.5
|
||||
reasons.append("display_partial")
|
||||
triggers = [str(t).strip().lower() for t in (ext.get("nl_triggers") or []) if str(t).strip()]
|
||||
for t in triggers: # nl_triggers 关键词(只计一次最高)
|
||||
if t and t in tl:
|
||||
score += 2.5
|
||||
reasons.append(f"trigger:{t}")
|
||||
break
|
||||
for kw in _ANALYSIS_TYPE_KEYWORDS.get(str(ext.get("analysis_type") or ""), ()):
|
||||
if kw in tl:
|
||||
score += 1.5
|
||||
reasons.append(f"analysis_type:{kw}")
|
||||
break
|
||||
if extension_run_actions(tl):
|
||||
score += 1.0
|
||||
reasons.append("action_verb")
|
||||
return score, reasons
|
||||
|
||||
|
||||
def match_extensions(
|
||||
text: str,
|
||||
extensions: list[dict[str, object]],
|
||||
*,
|
||||
threshold: float = EXTENSION_SCORE_THRESHOLD,
|
||||
) -> list[dict[str, object]]:
|
||||
"""B 层运行意图打分:仅 published 扩展参与,得分 > 阈值,按分降序。
|
||||
|
||||
返回 [{ext_id, display_name, analysis_type, score, reasons, ext}]。
|
||||
"""
|
||||
out: list[dict[str, object]] = []
|
||||
for ext in extensions or []:
|
||||
if str(ext.get("status") or "") != "published":
|
||||
continue
|
||||
score, reasons = _score_extension(text, ext)
|
||||
if score > threshold:
|
||||
out.append({
|
||||
"ext_id": ext.get("ext_id"),
|
||||
"display_name": ext.get("display_name"),
|
||||
"analysis_type": ext.get("analysis_type"),
|
||||
"score": score,
|
||||
"reasons": reasons,
|
||||
"ext": ext,
|
||||
})
|
||||
out.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def detect_extension_intent(
|
||||
text: str,
|
||||
extensions: list[dict[str, object]],
|
||||
) -> dict[str, object]:
|
||||
"""扩展意图两层判定主入口(契约 §5)。
|
||||
|
||||
返回(Dev-C 据此发卡):
|
||||
- {"layer": "create", ...} 创建流程(上传引导卡)
|
||||
- {"layer": "run", "candidates": [...]} 运行意图(高置信单命中/多命中候选)
|
||||
- {"layer": "create_ambiguous", ...} 歧义引导卡(「没有匹配的已发布扩展,是否创建?」)
|
||||
- {"layer": "none", ...} 漏判安全:回落正常生信阶段规划
|
||||
"""
|
||||
has_target = _contains_any_longest_first((text or "").lower(), EXTENSION_TARGET_WORD)
|
||||
has_create_action = _create_action_hit((text or "").lower())
|
||||
if has_target and has_create_action:
|
||||
return {"layer": "create", "create": True, "candidates": [], "ambiguous": False}
|
||||
candidates = match_extensions(text, extensions)
|
||||
if candidates:
|
||||
return {"layer": "run", "create": False, "candidates": candidates, "ambiguous": False}
|
||||
if has_target:
|
||||
# 目标词命中、无创建动作词、无 B 层命中 → 歧义引导卡(不误判为创建)
|
||||
return {"layer": "create_ambiguous", "create": False, "candidates": [], "ambiguous": True}
|
||||
return {"layer": "none", "create": False, "candidates": [], "ambiguous": False}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""tests/test_extension_api.py —— 扩展端点(L2;契约 §6 全集)。
|
||||
|
||||
mock LLM(无 key → 规则版回退)/mock 执行器(注入 store.executor)。
|
||||
SSE 用例端点直调迭代 body_iterator(同 test_api.py 纪律)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.bioflow_api
|
||||
|
||||
from app.services.bioflow import extensions as ext_mod # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 传输辅助(同 test_api.py ApiSession 模式)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ApiSession:
|
||||
_instance = None
|
||||
|
||||
def __init__(self):
|
||||
import httpx
|
||||
|
||||
from app.main import app as _app
|
||||
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
transport = httpx.ASGITransport(app=_app)
|
||||
self._client = httpx.AsyncClient(transport=transport, base_url="http://t")
|
||||
|
||||
@classmethod
|
||||
def shared(cls) -> "ApiSession":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
asyncio.set_event_loop(cls._instance.loop)
|
||||
return cls._instance
|
||||
|
||||
def request(self, method, url, **kw):
|
||||
resp = self.loop.run_until_complete(self._client.request(method, url, **kw))
|
||||
return resp
|
||||
|
||||
def get(self, url, **kw):
|
||||
return self.request("GET", url, **kw)
|
||||
|
||||
def post(self, url, **kw):
|
||||
return self.request("POST", url, **kw)
|
||||
|
||||
def delete(self, url, **kw):
|
||||
return self.request("DELETE", url, **kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeExtExecutor:
|
||||
"""async 执行器:可编程退出码/输出;记录调用。"""
|
||||
|
||||
def __init__(self, exit_code: int = 0, log: str = "trial ok\n", outputs=("result.txt",)):
|
||||
self.exit_code = exit_code
|
||||
self.log = log
|
||||
self.outputs = outputs
|
||||
self.calls = []
|
||||
|
||||
async def execute(self, spec_in):
|
||||
from pathlib import Path
|
||||
|
||||
self.calls.append(spec_in)
|
||||
out_dir = Path(spec_in.output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in self.outputs:
|
||||
(out_dir / name).write_text("data", encoding="utf-8")
|
||||
if spec_in.log_path:
|
||||
Path(spec_in.log_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(spec_in.log_path).write_text(self.log, encoding="utf-8")
|
||||
return ext_mod.ExtensionRunResult(
|
||||
run_id="", exit_code=self.exit_code, log_tail=self.log,
|
||||
outputs=list(self.outputs),
|
||||
error="" if self.exit_code == 0 else "命令失败",
|
||||
)
|
||||
|
||||
|
||||
def _make_zip(entries: dict[str, bytes]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for name, blob in entries.items():
|
||||
zf.writestr(name, blob)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ext_api(test_settings, monkeypatch):
|
||||
"""扩展 API 会话:settings 指 tmp、store 重置、无 LLM key。"""
|
||||
monkeypatch.delenv("DOCGEN_LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BIOFLOW_EXTENSION_API_KEY", raising=False)
|
||||
ext_mod.reset_store()
|
||||
session = ApiSession.shared()
|
||||
yield session
|
||||
ext_mod.reset_store()
|
||||
|
||||
|
||||
def _store():
|
||||
return ext_mod.get_store()
|
||||
|
||||
|
||||
def _post_ext(session, name="测序质量统计", command="python quality_stats.py --fastq input.fastq --out results/",
|
||||
code=None, test_blob=None, golden=None, runtime_image="test4py311"):
|
||||
files = {"code_zip": ("code.zip", _make_zip(code or {"quality_stats.py": b"import sys\nprint('hi')\n"}), "application/zip")}
|
||||
data = {"display_name": name, "command": command, "runtime_image": runtime_image}
|
||||
if golden:
|
||||
data["golden"] = json.dumps(golden)
|
||||
if test_blob is not None:
|
||||
files["test_file"] = ("input.fastq", test_blob, "application/octet-stream")
|
||||
return session.post("/api/bioflow/extensions", files=files, data=data)
|
||||
|
||||
|
||||
def _body(resp) -> dict:
|
||||
try:
|
||||
return json.loads(resp.content.decode("utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 创建 / 列表 / 详情 / 删除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_multipart_zip(ext_api):
|
||||
resp = _post_ext(ext_api, test_blob=b">s1\nACGT\n")
|
||||
assert resp.status_code == 201
|
||||
body = _body(resp)
|
||||
assert body["ok"] is True
|
||||
ext = body["data"]
|
||||
assert ext["status"] == "draft"
|
||||
assert ext["runtime_image"] == "bioflow-gf" # test4py311 映射
|
||||
root = _store().ext_dir(ext["ext_id"])
|
||||
assert (root / "raw_code" / "quality_stats.py").is_file()
|
||||
assert (root / "raw_test_data" / "input.fastq").is_file()
|
||||
assert (root / "converted_code" / "quality_stats.py").is_file()
|
||||
|
||||
|
||||
def test_create_json_form(ext_api):
|
||||
import base64
|
||||
|
||||
body = {
|
||||
"display_name": "json 扩展",
|
||||
"command": "python a.py --out results/",
|
||||
"runtime_image": "bioflow-gf",
|
||||
"code_files": [{"path": "a.py", "data": base64.b64encode(b"print(1)\n").decode()}],
|
||||
}
|
||||
resp = ext_api.post("/api/bioflow/extensions", json=body)
|
||||
assert resp.status_code == 201
|
||||
assert _body(resp)["data"]["display_name"] == "json 扩展"
|
||||
|
||||
|
||||
def test_create_bad_zip(ext_api):
|
||||
files = {"code_zip": ("bad.zip", b"not a zip", "application/zip")}
|
||||
resp = ext_api.post(
|
||||
"/api/bioflow/extensions",
|
||||
files=files,
|
||||
data={"display_name": "x", "command": "python a.py"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "zip" in _body(resp)["error"]["message"]
|
||||
|
||||
|
||||
def test_create_missing_fields(ext_api):
|
||||
resp = ext_api.post(
|
||||
"/api/bioflow/extensions",
|
||||
data={"display_name": "", "command": ""},
|
||||
)
|
||||
assert resp.status_code in (422, 400)
|
||||
|
||||
|
||||
def test_get_404(ext_api):
|
||||
resp = ext_api.get("/api/bioflow/extensions/ext_nope")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_list_published_first(ext_api):
|
||||
a = _body(_post_ext(ext_api, name="扩展 A"))["data"]
|
||||
b = _body(_post_ext(ext_api, name="扩展 B"))["data"]
|
||||
_publish_quick(ext_api, b["ext_id"], executor=FakeExtExecutor(0))
|
||||
resp = ext_api.get("/api/bioflow/extensions")
|
||||
items = _body(resp)["data"]["items"]
|
||||
assert items[0]["ext_id"] == b["ext_id"]
|
||||
|
||||
|
||||
def test_delete(ext_api):
|
||||
e = _body(_post_ext(ext_api))["data"]
|
||||
resp = ext_api.delete(f"/api/bioflow/extensions/{e['ext_id']}")
|
||||
assert resp.status_code == 204
|
||||
assert ext_api.get(f"/api/bioflow/extensions/{e['ext_id']}").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 生命周期动作(build/trial/validate/repair/confirm/publish)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _publish_quick(session, ext_id, *, executor=None):
|
||||
"""build → trial → validate → confirm → publish 快速链。"""
|
||||
if executor is not None:
|
||||
_store().executor = executor
|
||||
r = session.post(f"/api/bioflow/extensions/{ext_id}/build")
|
||||
assert r.status_code == 200, _body(r)
|
||||
r = session.post(f"/api/bioflow/extensions/{ext_id}/trial-run")
|
||||
assert r.status_code == 200, _body(r)
|
||||
r = session.post(f"/api/bioflow/extensions/{ext_id}/validate")
|
||||
assert r.status_code == 200, _body(r)
|
||||
r = session.post(f"/api/bioflow/extensions/{ext_id}/confirm", json={"nl_triggers": ["质量统计"]})
|
||||
assert r.status_code == 200, _body(r)
|
||||
r = session.post(f"/api/bioflow/extensions/{ext_id}/publish")
|
||||
assert r.status_code == 200, _body(r)
|
||||
return _body(r)["data"]
|
||||
|
||||
|
||||
def test_build_rules_fallback(ext_api):
|
||||
e = _body(_post_ext(ext_api))["data"]
|
||||
resp = ext_api.post(f"/api/bioflow/extensions/{e['ext_id']}/build")
|
||||
assert resp.status_code == 200
|
||||
ext = _body(resp)["data"]
|
||||
assert ext["status"] == "built"
|
||||
assert ext["last_build"]["source"] == "rules"
|
||||
assert "{code_dir}" in ext["command_template"]
|
||||
assert "{output_dir}" in ext["command_template"]
|
||||
|
||||
|
||||
def test_build_wrong_state_409(ext_api):
|
||||
e = _body(_post_ext(ext_api))["data"]
|
||||
# draft → publish 非法(须 build/trial/validate/confirm 链)
|
||||
resp = ext_api.post(f"/api/bioflow/extensions/{e['ext_id']}/publish")
|
||||
assert resp.status_code == 409
|
||||
assert _body(resp)["error"]["code"] == "EXT_STATUS"
|
||||
|
||||
|
||||
def test_trial_without_executor_503(ext_api):
|
||||
e = _body(_post_ext(ext_api))["data"]
|
||||
ext_api.post(f"/api/bioflow/extensions/{e['ext_id']}/build")
|
||||
resp = ext_api.post(f"/api/bioflow/extensions/{e['ext_id']}/trial-run")
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_full_lifecycle(ext_api):
|
||||
e = _body(_post_ext(ext_api, test_blob=b">s\nACGT\n",
|
||||
golden={"test_input": "input.fastq", "expected_outputs": ["result.txt"]}))["data"]
|
||||
ext_id = e["ext_id"]
|
||||
_publish_quick(ext_api, ext_id, executor=FakeExtExecutor(0, "ok\n", ("result.txt",)))
|
||||
ext = _body(ext_api.get(f"/api/bioflow/extensions/{ext_id}"))["data"]
|
||||
assert ext["status"] == "published"
|
||||
assert ext["nl_triggers"] == ["质量统计"]
|
||||
# 执行器收到 ext_dir/output_dir(Dev-B 契约字段)
|
||||
assert _store().executor.calls[-1].ext_dir.endswith(ext_id)
|
||||
assert _store().executor.calls[-1].output_dir
|
||||
|
||||
|
||||
def test_trial_fail_then_repair(ext_api):
|
||||
e = _body(_post_ext(ext_api))["data"]
|
||||
ext_id = e["ext_id"]
|
||||
_store().executor = FakeExtExecutor(1, "No module named 'pandas'\n", ())
|
||||
r = ext_api.post(f"/api/bioflow/extensions/{ext_id}/build")
|
||||
assert r.status_code == 200
|
||||
r = ext_api.post(f"/api/bioflow/extensions/{ext_id}/trial-run")
|
||||
assert _body(r)["data"]["status"] == "trial_failed"
|
||||
r = ext_api.post(f"/api/bioflow/extensions/{ext_id}/repair")
|
||||
assert r.status_code == 200
|
||||
ext = _body(r)["data"]
|
||||
assert ext["status"] == "built"
|
||||
assert "pandas" in ext["requirements"]["pip"]
|
||||
|
||||
|
||||
def test_repair_limit_reached(ext_api):
|
||||
e = _body(_post_ext(ext_api))["data"]
|
||||
ext_id = e["ext_id"]
|
||||
_store().max_repair_rounds = 1
|
||||
_store().executor = FakeExtExecutor(1, "No module named 'pandas'\n", ())
|
||||
ext_api.post(f"/api/bioflow/extensions/{ext_id}/build")
|
||||
ext_api.post(f"/api/bioflow/extensions/{ext_id}/trial-run")
|
||||
assert ext_api.post(f"/api/bioflow/extensions/{ext_id}/repair").status_code == 200
|
||||
ext_api.post(f"/api/bioflow/extensions/{ext_id}/trial-run") # 再失败
|
||||
resp = ext_api.post(f"/api/bioflow/extensions/{ext_id}/repair")
|
||||
assert resp.status_code == 409
|
||||
assert "上限" in _body(resp)["error"]["message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 运行(run/runs/events)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_not_published_409(ext_api):
|
||||
e = _body(_post_ext(ext_api))["data"]
|
||||
resp = ext_api.post(f"/api/bioflow/extensions/{e['ext_id']}/run", json={"params": {}})
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_run_and_events(ext_api):
|
||||
e = _body(_post_ext(ext_api))["data"]
|
||||
ext_id = e["ext_id"]
|
||||
_publish_quick(ext_api, ext_id, executor=FakeExtExecutor(0, "run ok\n", ("out.tsv",)))
|
||||
resp = ext_api.post(f"/api/bioflow/extensions/{ext_id}/run", json={"params": {"cpu": "4"}})
|
||||
assert resp.status_code == 200
|
||||
result = _body(resp)["data"]
|
||||
assert result["status"] == "done"
|
||||
assert "out.tsv" in result["outputs"]
|
||||
|
||||
runs = _body(ext_api.get(f"/api/bioflow/extensions/{ext_id}/runs"))["data"]
|
||||
assert runs["total"] == 1
|
||||
assert runs["items"][0]["run_id"] == result["run_id"]
|
||||
|
||||
# events SSE(占位实现:已完成 run 的快照帧)
|
||||
from app.routers.extension import run_events
|
||||
|
||||
async def _collect():
|
||||
resp = await run_events(ext_id, result["run_id"], request=None)
|
||||
chunks = []
|
||||
async for chunk in resp.body_iterator:
|
||||
chunks.append(chunk)
|
||||
return b"".join(c.encode("utf-8") if isinstance(c, str) else c for c in chunks).decode("utf-8")
|
||||
|
||||
raw = asyncio.get_event_loop().run_until_complete(_collect())
|
||||
assert "extension_run_started" in raw
|
||||
assert "extension_run_done" in raw
|
||||
assert "run ok" in raw
|
||||
|
||||
|
||||
def test_run_failed_events(ext_api):
|
||||
e = _body(_post_ext(ext_api))["data"]
|
||||
ext_id = e["ext_id"]
|
||||
_publish_quick(ext_api, ext_id, executor=FakeExtExecutor(0, "trial ok\n"))
|
||||
_store().executor = FakeExtExecutor(3, "output contract failed\n", ())
|
||||
resp = ext_api.post(f"/api/bioflow/extensions/{ext_id}/run", json={"params": {}})
|
||||
result = _body(resp)["data"]
|
||||
assert result["status"] == "failed"
|
||||
assert result["exit_code"] == 3
|
||||
|
||||
from app.routers.extension import run_events
|
||||
|
||||
async def _collect():
|
||||
resp = await run_events(ext_id, result["run_id"], request=None)
|
||||
chunks = []
|
||||
async for chunk in resp.body_iterator:
|
||||
chunks.append(chunk)
|
||||
return b"".join(c.encode("utf-8") if isinstance(c, str) else c for c in chunks).decode("utf-8")
|
||||
|
||||
raw = asyncio.get_event_loop().run_until_complete(_collect())
|
||||
assert "extension_run_failed" in raw
|
||||
@@ -0,0 +1,168 @@
|
||||
"""tests/test_extension_intent.py —— 扩展意图两层判定(契约 §5)。
|
||||
|
||||
覆盖 A 层创建(动作词×目标词)/ B 层运行打分(仅 published、display_name
|
||||
模糊/nl_triggers/analysis_type/动作词加分)/ 歧义消解(目标词无动作词 →
|
||||
有命中走运行,无命中回 create_ambiguous)/ 漏判安全(无命中且非创建 →
|
||||
layer none,不影响现有 route() 返回路径)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.intent import (
|
||||
detect_extension_intent,
|
||||
extension_create_intent,
|
||||
match_extensions,
|
||||
route,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.bioflow_unit
|
||||
|
||||
|
||||
def _pub_ext(ext_id="ext_1", name="测序质量统计", triggers=None, analysis_type="table_filter"):
|
||||
return {
|
||||
"ext_id": ext_id,
|
||||
"display_name": name,
|
||||
"status": "published",
|
||||
"nl_triggers": triggers or ["质量统计", "fastq 统计"],
|
||||
"analysis_type": analysis_type,
|
||||
}
|
||||
|
||||
|
||||
def _draft_ext(ext_id="ext_9", name="未发布扩展"):
|
||||
return {"ext_id": ext_id, "display_name": name, "status": "draft",
|
||||
"nl_triggers": ["未发布"], "analysis_type": "generic_cli"}
|
||||
|
||||
|
||||
# ---- A 层创建 ----
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"帮我创建一个扩展:统计 fastq 质量",
|
||||
"建个分析模板",
|
||||
"新建一个分析脚本",
|
||||
"上传我的分析工具",
|
||||
"自定义一个扩展",
|
||||
"写一个分析模板",
|
||||
"弄个扩展统计 fastq",
|
||||
])
|
||||
def test_create_intent_hits(text):
|
||||
assert extension_create_intent(text) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"帮我跑水稻 SPL 家族鉴定", # 无目标词
|
||||
"质量统计", # 目标词+运行词,无创建动作词(消歧走 B 层)
|
||||
"做拓展分析 统计 fastq 质量", # 契约歧义消解句:无创建动作词 → 引导卡而非直接创建
|
||||
"建议新建文件夹", # 「建」后随「议」→ 不误命中
|
||||
"这是写作练习", # 「写」后随「作」→ 不误命中
|
||||
"扩展名为 fasta 的文件", # 「扩展名」≠ 目标词语义(漏判安全,不误入创建)
|
||||
"今天天气不错",
|
||||
])
|
||||
def test_create_intent_misses(text):
|
||||
assert extension_create_intent(text) is False
|
||||
|
||||
|
||||
# ---- B 层打分 ----
|
||||
|
||||
def test_match_only_published():
|
||||
exts = [_pub_ext(), _draft_ext()]
|
||||
hits = match_extensions("帮我做一下质量统计", exts)
|
||||
assert [h["ext_id"] for h in hits] == ["ext_1"]
|
||||
|
||||
|
||||
def test_match_display_name_fuzzy():
|
||||
hits = match_extensions("测序质量统计", [_pub_ext()])
|
||||
assert len(hits) == 1
|
||||
assert hits[0]["ext_id"] == "ext_1"
|
||||
assert hits[0]["score"] >= 4.0
|
||||
|
||||
|
||||
def test_match_nl_trigger_keyword():
|
||||
hits = match_extensions("帮我把这个 fastq 做一下 fastq 统计", [_pub_ext()])
|
||||
assert len(hits) == 1
|
||||
assert any(r.startswith("trigger:") for r in hits[0]["reasons"])
|
||||
|
||||
|
||||
def test_match_analysis_type_keyword():
|
||||
ext = _pub_ext(ext_id="ext_2", name="表达量统计", triggers=["表达量"],
|
||||
analysis_type="enrichment_or_stats")
|
||||
# 动作词 + analysis_type 关键词 = 1.0 + 1.5 = 2.5 > 阈值 → 命中
|
||||
hits = match_extensions("帮我做一下富集分析", [ext])
|
||||
assert len(hits) == 1
|
||||
assert hits[0]["ext_id"] == "ext_2"
|
||||
assert any(r == "analysis_type:富集" for r in hits[0]["reasons"])
|
||||
# 漏判安全:仅 analysis_type 关键词(无动作词/触发词)→ 1.5 ≤ 阈值 → 不命中
|
||||
assert match_extensions("富集分析", [ext]) == []
|
||||
|
||||
|
||||
def test_match_action_verb_bonus():
|
||||
ext = _pub_ext(ext_id="ext_3", name="表格过滤工具", triggers=["过滤表格"],
|
||||
analysis_type="table_filter")
|
||||
# 无动作词:nl_trigger 2.5 + analysis_type 1.5 = 4.0(过阈值 2.0)
|
||||
base = match_extensions("过滤表格", [ext])
|
||||
assert len(base) == 1
|
||||
# 动作词加分后更高
|
||||
boosted = match_extensions("帮我跑过滤表格", [ext])
|
||||
assert boosted[0]["score"] > base[0]["score"]
|
||||
|
||||
|
||||
def test_match_no_hit_empty():
|
||||
assert match_extensions("帮我跑水稻 SPL 家族鉴定", [_pub_ext()]) == []
|
||||
assert match_extensions("随便说点什么", [_pub_ext()]) == []
|
||||
|
||||
|
||||
# ---- 两层判定主入口 ----
|
||||
|
||||
def test_detect_create_layer():
|
||||
r = detect_extension_intent("帮我创建一个扩展:统计 fastq 质量", [_pub_ext()])
|
||||
assert r["layer"] == "create"
|
||||
assert r["create"] is True
|
||||
|
||||
|
||||
def test_detect_run_layer_high_confidence():
|
||||
r = detect_extension_intent("帮我把这个 fastq 做一下质量统计", [_pub_ext()])
|
||||
assert r["layer"] == "run"
|
||||
assert len(r["candidates"]) == 1
|
||||
assert r["candidates"][0]["ext_id"] == "ext_1"
|
||||
|
||||
|
||||
def test_detect_run_layer_multi_candidates():
|
||||
exts = [
|
||||
_pub_ext("ext_1", "测序质量统计", ["质量统计"]),
|
||||
_pub_ext("ext_2", "表达量统计", ["统计"]),
|
||||
]
|
||||
r = detect_extension_intent("帮我做个统计分析", exts)
|
||||
assert r["layer"] == "run"
|
||||
assert len(r["candidates"]) >= 1
|
||||
|
||||
|
||||
def test_detect_ambiguity_no_published():
|
||||
"""目标词命中、无创建动作词、无 B 层命中 → create_ambiguous(引导卡)。
|
||||
|
||||
契约歧义消解句「做拓展分析 质量统计」:无创建动作词 → 走消解而非直接创建。
|
||||
"""
|
||||
r = detect_extension_intent("做拓展分析 质量统计", [])
|
||||
assert r["layer"] == "create_ambiguous"
|
||||
assert r["ambiguous"] is True
|
||||
|
||||
|
||||
def test_detect_ambiguity_target_no_action_but_run_hit():
|
||||
"""歧义消解:目标词命中但无创建动作词 → 有 B 层命中走运行。"""
|
||||
r = detect_extension_intent("做拓展分析 质量统计", [_pub_ext()])
|
||||
assert r["layer"] == "run"
|
||||
|
||||
|
||||
def test_detect_none_fallback():
|
||||
"""漏判安全:无命中且非创建 → layer none(回落正常生信阶段规划)。"""
|
||||
r = detect_extension_intent("帮我跑水稻 SPL 家族鉴定", [_pub_ext()])
|
||||
assert r["layer"] == "none"
|
||||
|
||||
|
||||
# ---- 漏判安全:现有 route() 返回路径零改动 ----
|
||||
|
||||
def test_route_unchanged_by_extension_words():
|
||||
"""含「扩展」等的句子不得改变现有意图分流结论(扩展判定是独立纯函数层)。"""
|
||||
assert route("帮我跑水稻 SPL 家族鉴定") == "bioflow"
|
||||
assert route("今天天气不错") == "qa"
|
||||
# 「建个分析模板」不命中生信任务动词表 → 仍 qa(创建流程由扩展层接管)
|
||||
assert route("建个分析模板") == "qa"
|
||||
@@ -0,0 +1,273 @@
|
||||
"""tests/test_extension_llm.py —— 扩展 AI 转换(LLM 优先 + 规则版回退,绝不 500)。
|
||||
|
||||
覆盖:llm_config(DOCGEN 优先/BIOFLOW_EXTENSION 别名兜底)/
|
||||
detect_requirements(import/requirements.txt/R library/sh 命令)/
|
||||
command_to_template(脚本路径/输入占位/output_dir 必含兜底)/
|
||||
build_conversion(无 key 回退 rules;LLM 坏输出回退 rules)/
|
||||
repair_conversion(ModuleNotFoundError 规则并入 pip;无可修复抛 LLMUnavailable)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow import extension_llm as extllm
|
||||
from app.services.bioflow import extension_spec as spec
|
||||
|
||||
pytestmark = pytest.mark.bioflow_unit
|
||||
|
||||
|
||||
# ---- llm_config ----
|
||||
|
||||
def test_llm_config_docgen_priority(monkeypatch):
|
||||
monkeypatch.setenv("DOCGEN_LLM_API_KEY", "docgen-key")
|
||||
monkeypatch.setenv("BIOFLOW_EXTENSION_API_KEY", "ext-key")
|
||||
monkeypatch.setenv("DOCGEN_LLM_BASE_URL", "https://doc.example/v1")
|
||||
monkeypatch.setenv("BIOFLOW_EXTENSION_BASE_URL", "https://ext.example/v1")
|
||||
cfg = extllm.llm_config()
|
||||
assert cfg["api_key"] == "docgen-key"
|
||||
assert cfg["base_url"] == "https://doc.example/v1"
|
||||
assert cfg["model"] == "deepseek-chat"
|
||||
|
||||
|
||||
def test_llm_config_alias_fallback(monkeypatch):
|
||||
monkeypatch.delenv("DOCGEN_LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("DOCGEN_LLM_MODEL", raising=False)
|
||||
monkeypatch.setenv("BIOFLOW_EXTENSION_API_KEY", "ext-key")
|
||||
monkeypatch.setenv("BIOFLOW_EXTENSION_MODEL", "deepseek-chat-v2")
|
||||
cfg = extllm.llm_config()
|
||||
assert cfg["api_key"] == "ext-key"
|
||||
assert cfg["model"] == "deepseek-chat-v2"
|
||||
assert cfg["base_url"] == "https://api.deepseek.com/v1"
|
||||
|
||||
|
||||
# ---- 规则版依赖探测 ----
|
||||
|
||||
def _make_code_dir(tmp_path, files: dict[str, str]) -> Path:
|
||||
root = tmp_path / "code"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for name, text in files.items():
|
||||
(root / name).write_text(text, encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def test_detect_requirements_python(tmp_path):
|
||||
root = _make_code_dir(tmp_path, {
|
||||
"a.py": "import pandas\nfrom Bio import SeqIO\nimport os\nfrom sklearn import svm\n",
|
||||
})
|
||||
req = extllm.detect_requirements(root)
|
||||
assert set(req["pip"]) == {"pandas", "Bio", "sklearn"} # os 标准库剔除
|
||||
assert req["apt"] == []
|
||||
|
||||
|
||||
def test_detect_requirements_file(tmp_path):
|
||||
root = _make_code_dir(tmp_path, {
|
||||
"requirements.txt": "pandas>=1.0\nnumpy==1.2\n# 注释\n",
|
||||
})
|
||||
req = extllm.detect_requirements(root)
|
||||
assert set(req["pip"]) == {"pandas", "numpy"}
|
||||
|
||||
|
||||
def test_detect_requirements_r_and_shell(tmp_path):
|
||||
root = _make_code_dir(tmp_path, {
|
||||
"a.r": "library(ggplot2)\nrequire(dplyr)\n",
|
||||
"run.sh": "#!/bin/bash\nset -e\npython a.py\nsamtools view -b input.bam\nfastqc input.fastq\ngrep foo\n",
|
||||
})
|
||||
req = extllm.detect_requirements(root)
|
||||
assert set(req["pip"]) == {"ggplot2", "dplyr"}
|
||||
assert set(req["apt"]) == {"samtools", "fastqc"} # 白名单命令(python/grep)不计
|
||||
|
||||
|
||||
def test_infer_language():
|
||||
assert extllm.infer_language("a.py") == "python"
|
||||
assert extllm.infer_language("a.R") == "r"
|
||||
assert extllm.infer_language("run.sh") == "shell"
|
||||
assert extllm.infer_language("a.pl") == "pl"
|
||||
|
||||
|
||||
# ---- 规则版模板化 ----
|
||||
|
||||
def test_command_to_template_full(tmp_path):
|
||||
root = _make_code_dir(tmp_path, {"quality_stats.py": "print('x')\n"})
|
||||
out = extllm.command_to_template(
|
||||
"python quality_stats.py --fastq input.fastq --out results/",
|
||||
root,
|
||||
data_files=["input.fastq"],
|
||||
)
|
||||
# fastq 非 fasta 系扩展名 → 契约 §2 示例语义 {input_file}
|
||||
assert out["command_template"] == (
|
||||
"python {code_dir}/quality_stats.py --fastq {input_file} --out {output_dir}/"
|
||||
)
|
||||
spec.validate_command_template(out["command_template"])
|
||||
assert out["source"] == "rules"
|
||||
|
||||
|
||||
def test_command_to_template_no_output_appends_redirect(tmp_path):
|
||||
root = _make_code_dir(tmp_path, {"a.py": "print('x')\n"})
|
||||
out = extllm.command_to_template("python a.py --in x.txt", root)
|
||||
assert "{output_dir}" in out["command_template"]
|
||||
spec.validate_command_template(out["command_template"])
|
||||
|
||||
|
||||
def test_command_to_template_param_derived(tmp_path):
|
||||
root = _make_code_dir(tmp_path, {"a.py": "print('x')\n"})
|
||||
out = extllm.command_to_template(
|
||||
"python a.py --cpu {param_cpu} --out results/",
|
||||
root,
|
||||
)
|
||||
assert "{param_cpu}" in out["command_template"]
|
||||
params = spec.template_parameters(out["command_template"], out["parameters"])
|
||||
assert {p["name"] for p in params} == {"cpu"}
|
||||
|
||||
|
||||
# ---- build_conversion 回退 ----
|
||||
|
||||
def _make_ext(store, name="测序质量统计"):
|
||||
return store.create_extension(
|
||||
display_name=name,
|
||||
command="python quality_stats.py --fastq input.fastq --out results/",
|
||||
code_files=[("quality_stats.py", b"import sys\nprint('hi')\n")],
|
||||
data_files=[("input.fastq", b">s\nACGT\n")],
|
||||
)
|
||||
|
||||
|
||||
async def test_build_conversion_no_key_falls_back_rules(store, monkeypatch):
|
||||
monkeypatch.delenv("DOCGEN_LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BIOFLOW_EXTENSION_API_KEY", raising=False)
|
||||
e = _make_ext(store)
|
||||
out = await extllm.build_conversion(e, store=store)
|
||||
assert out["source"] == "rules"
|
||||
assert "{code_dir}" in out["command_template"]
|
||||
assert "{output_dir}" in out["command_template"]
|
||||
assert out["requirements"]["pip"] == []
|
||||
assert out["nl_triggers"] # display_name 兜底
|
||||
assert out["analysis_type"] in spec.ANALYSIS_TYPES
|
||||
spec.validate_command_template(out["command_template"])
|
||||
|
||||
|
||||
async def test_build_conversion_llm_ok(store, monkeypatch):
|
||||
monkeypatch.setenv("DOCGEN_LLM_API_KEY", "k")
|
||||
monkeypatch.setattr("app.services.bioflow.extension_llm.llm_config",
|
||||
lambda: {"base_url": "http://x", "api_key": "k", "model": "m", "timeout": 1.0})
|
||||
|
||||
async def fake_complete_json(system, user, **kw):
|
||||
return {
|
||||
"command_template": "python {code_dir}/quality_stats.py --fastq {input_fasta} --out {output_dir}",
|
||||
"requirements": {"pip": ["pandas"], "apt": []},
|
||||
"parameters": [{"name": "fastq", "type": "file", "required": True, "description": "输入"}],
|
||||
"input_roles": {"input_fasta": "generic_input_file"},
|
||||
"nl_triggers": ["质量统计", "fastq 统计"],
|
||||
"analysis_type": "table_filter",
|
||||
"runtime_image": "bioflow-gf",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.services.bioflow.llm.complete_json", fake_complete_json)
|
||||
e = _make_ext(store)
|
||||
out = await extllm.build_conversion(e, store=store)
|
||||
assert out["source"] == "llm"
|
||||
assert out["command_template"].startswith("python {code_dir}/")
|
||||
assert out["requirements"]["pip"] == ["pandas"]
|
||||
assert out["nl_triggers"] == ["质量统计", "fastq 统计"]
|
||||
assert out["analysis_type"] == "table_filter"
|
||||
assert {p["name"] for p in out["parameters"]} == {"fastq"}
|
||||
|
||||
|
||||
async def test_build_conversion_llm_bad_output_falls_back(store, monkeypatch):
|
||||
"""LLM 返回白名单外占位符 → sanitize 抛 → 回退规则版(绝不 500)。"""
|
||||
monkeypatch.setenv("DOCGEN_LLM_API_KEY", "k")
|
||||
monkeypatch.setattr("app.services.bioflow.extension_llm.llm_config",
|
||||
lambda: {"base_url": "http://x", "api_key": "k", "model": "m", "timeout": 1.0})
|
||||
|
||||
async def fake_complete_json(system, user, **kw):
|
||||
return {"command_template": "python {code_dir}/a.py --out {evil}"}
|
||||
|
||||
monkeypatch.setattr("app.services.bioflow.llm.complete_json", fake_complete_json)
|
||||
e = _make_ext(store)
|
||||
out = await extllm.build_conversion(e, store=store)
|
||||
assert out["source"] == "rules"
|
||||
spec.validate_command_template(out["command_template"])
|
||||
|
||||
|
||||
async def test_build_conversion_llm_unavailable_falls_back(store, monkeypatch):
|
||||
from app.services.bioflow.llm import LLMUnavailable as PlanLLMUnavailable
|
||||
|
||||
monkeypatch.setenv("DOCGEN_LLM_API_KEY", "k")
|
||||
monkeypatch.setattr("app.services.bioflow.extension_llm.llm_config",
|
||||
lambda: {"base_url": "http://x", "api_key": "k", "model": "m", "timeout": 1.0})
|
||||
|
||||
async def fake_complete_json(system, user, **kw):
|
||||
raise PlanLLMUnavailable("down")
|
||||
|
||||
monkeypatch.setattr("app.services.bioflow.llm.complete_json", fake_complete_json)
|
||||
e = _make_ext(store)
|
||||
out = await extllm.build_conversion(e, store=store)
|
||||
assert out["source"] == "rules"
|
||||
|
||||
|
||||
# ---- repair ----
|
||||
|
||||
async def _built_ext(store):
|
||||
e = _make_ext(store)
|
||||
await store.build_extension(e["ext_id"])
|
||||
return e["ext_id"]
|
||||
|
||||
|
||||
async def test_repair_rules_module_not_found(store, monkeypatch):
|
||||
monkeypatch.delenv("DOCGEN_LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BIOFLOW_EXTENSION_API_KEY", raising=False)
|
||||
ext_id = await _built_ext(store)
|
||||
ext = store.load_extension(ext_id)
|
||||
out = await extllm.repair_conversion(
|
||||
ext,
|
||||
failure_context={"log_tail": "Traceback: ModuleNotFoundError: No module named 'pandas'", "error": "x"},
|
||||
store=store,
|
||||
)
|
||||
assert "pandas" in out["requirements"]["pip"]
|
||||
assert out["source"] == "repair_rules"
|
||||
assert out["command_template"] == ext["command_template"]
|
||||
|
||||
|
||||
async def test_repair_no_fixable_hint_raises(store, monkeypatch):
|
||||
monkeypatch.delenv("DOCGEN_LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BIOFLOW_EXTENSION_API_KEY", raising=False)
|
||||
ext_id = await _built_ext(store)
|
||||
ext = store.load_extension(ext_id)
|
||||
with pytest.raises(extllm.LLMUnavailable):
|
||||
await extllm.repair_conversion(
|
||||
ext,
|
||||
failure_context={"log_tail": "exit 1: 未知错误", "error": "x"},
|
||||
store=store,
|
||||
)
|
||||
|
||||
|
||||
async def test_repair_llm_ok(store, monkeypatch):
|
||||
monkeypatch.setenv("DOCGEN_LLM_API_KEY", "k")
|
||||
monkeypatch.setattr("app.services.bioflow.extension_llm.llm_config",
|
||||
lambda: {"base_url": "http://x", "api_key": "k", "model": "m", "timeout": 1.0})
|
||||
|
||||
async def fake_complete_json(system, user, **kw):
|
||||
return {
|
||||
"command_template": "python {code_dir}/quality_stats.py --fastq {input_fasta} --out {output_dir}",
|
||||
"requirements": {"pip": ["pandas"], "apt": []},
|
||||
"parameters": [],
|
||||
"nl_triggers": ["质量统计"],
|
||||
"analysis_type": "table_filter",
|
||||
"runtime_image": "bioflow-gf",
|
||||
"suggestion": "把输入参数改为 {input_fasta}",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.services.bioflow.llm.complete_json", fake_complete_json)
|
||||
ext_id = await _built_ext(store)
|
||||
ext = store.load_extension(ext_id)
|
||||
out = await extllm.repair_conversion(ext, failure_context={"log_tail": "boom"}, store=store)
|
||||
assert out["source"] == "repair_llm"
|
||||
assert "pandas" in out["requirements"]["pip"]
|
||||
assert "input_fasta" in out["suggestion"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store(tmp_path):
|
||||
from app.services.bioflow import extensions as ext_mod
|
||||
|
||||
return ext_mod.ExtensionStore(base_dir=str(tmp_path / "data" / "extensions"))
|
||||
@@ -0,0 +1,110 @@
|
||||
"""tests/test_extension_spec.py —— 扩展模板白名单契约(契约 §2 spec_v2 移植)。
|
||||
|
||||
覆盖:占位符白名单 12 个 / output_dir 必含 / 白名单外拒绝 / param_* 放行 /
|
||||
analysis_type 归一 / 规则版 analysis_type 探测 / template_parameters 派生补齐。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow import extension_spec as spec
|
||||
|
||||
pytestmark = pytest.mark.bioflow_unit
|
||||
|
||||
|
||||
# ---- 白名单 ----
|
||||
|
||||
def test_placeholders_whitelist_has_12():
|
||||
assert len(spec.PLACEHOLDERS) == 12
|
||||
assert spec.PLACEHOLDERS == {
|
||||
"code_dir", "input_dir", "input_file", "input_hmm", "input_fasta",
|
||||
"input_query_fasta", "input_reference_fasta", "input_pep_fasta",
|
||||
"input_cds_fasta", "input_id_file", "output_dir", "work_dir",
|
||||
}
|
||||
|
||||
|
||||
def test_output_dir_required():
|
||||
with pytest.raises(spec.ExtensionSpecError, match="output_dir"):
|
||||
spec.validate_command_template("python {code_dir}/a.py --in {input_file}")
|
||||
|
||||
|
||||
def test_empty_template_rejected():
|
||||
with pytest.raises(spec.ExtensionSpecError):
|
||||
spec.validate_command_template("")
|
||||
|
||||
|
||||
def test_unknown_placeholder_rejected():
|
||||
with pytest.raises(spec.ExtensionSpecError, match="白名单外"):
|
||||
spec.validate_command_template("python {code_dir}/a.py --out {output_dir} --x {input_bam}")
|
||||
|
||||
|
||||
def test_whitelist_all_accepted():
|
||||
tpl = (
|
||||
"python {code_dir}/run.py --dir {input_dir} --file {input_file} "
|
||||
"--hmm {input_hmm} --fa {input_fasta} --q {input_query_fasta} "
|
||||
"--ref {input_reference_fasta} --pep {input_pep_fasta} "
|
||||
"--cds {input_cds_fasta} --ids {input_id_file} --out {output_dir} "
|
||||
"--work {work_dir}"
|
||||
)
|
||||
spec.validate_command_template(tpl) # 不抛即通过
|
||||
|
||||
|
||||
def test_param_placeholders_allowed():
|
||||
tpl = "python {code_dir}/a.py --in {input_file} --out {output_dir} --cpu {param_cpu} --mem {param_memory}"
|
||||
spec.validate_command_template(tpl)
|
||||
assert spec.param_placeholder_names(tpl) == {"cpu", "memory"}
|
||||
|
||||
|
||||
def test_unsupported_detection():
|
||||
tpl = "python {code_dir}/a.py --out {output_dir} --db {database}"
|
||||
assert spec.unsupported_placeholders(tpl) == {"database"}
|
||||
assert spec.unsupported_placeholders(
|
||||
"python {code_dir}/a.py --out {output_dir} --cpu {param_cpu}"
|
||||
) == set()
|
||||
|
||||
|
||||
# ---- analysis_type ----
|
||||
|
||||
def test_normalize_analysis_type():
|
||||
assert spec.normalize_analysis_type("table_filter") == "table_filter"
|
||||
assert spec.normalize_analysis_type("") == "generic_cli"
|
||||
assert spec.normalize_analysis_type("bogus") == "generic_cli"
|
||||
assert spec.normalize_analysis_type(None) == "generic_cli"
|
||||
|
||||
|
||||
def test_infer_analysis_type_rules():
|
||||
assert spec.infer_analysis_type(template="blastp -query {input_fasta}", display_name="x") == "blast_search"
|
||||
assert spec.infer_analysis_type(template="hmmsearch {input_hmm} {input_fasta}", display_name="x") == "hmmer_search"
|
||||
assert spec.infer_analysis_type(template="awk -F'\\t' '{print $1}' {input_file}", display_name="x") == "table_filter"
|
||||
assert spec.infer_analysis_type(template="python {code_dir}/a.py", display_name="id 清洗 clean_id") == "fasta_header_cleaning"
|
||||
assert spec.infer_analysis_type(template="python {code_dir}/a.py", display_name="画图工具") == "plot_render"
|
||||
assert spec.infer_analysis_type(template="python {code_dir}/a.py", display_name="随便") == "generic_cli"
|
||||
|
||||
|
||||
# ---- template_parameters ----
|
||||
|
||||
def test_template_parameters_derived():
|
||||
tpl = "python {code_dir}/a.py --cpu {param_cpu} --out {output_dir}"
|
||||
params = spec.template_parameters(tpl)
|
||||
names = {p["name"] for p in params}
|
||||
assert names == {"cpu"}
|
||||
assert params[0]["type"] == "string"
|
||||
|
||||
|
||||
def test_template_parameters_keeps_existing():
|
||||
tpl = "python {code_dir}/a.py --cpu {param_cpu} --mem {param_mem} --out {output_dir}"
|
||||
params = spec.template_parameters(tpl, [
|
||||
{"name": "cpu", "type": "int", "required": True, "description": "核数", "default": 4},
|
||||
])
|
||||
by_name = {p["name"]: p for p in params}
|
||||
assert set(by_name) == {"cpu", "mem"}
|
||||
assert by_name["cpu"]["type"] == "int"
|
||||
assert by_name["cpu"]["default"] == 4
|
||||
assert by_name["mem"]["required"] is False
|
||||
|
||||
|
||||
def test_safe_parameter_name():
|
||||
assert spec.safe_parameter_name("fastq 文件") == "fastq"
|
||||
assert spec.safe_parameter_name("2x") == "p_2x"
|
||||
assert spec.safe_parameter_name("param_cpu") == "cpu"
|
||||
assert spec.safe_parameter_name("") == ""
|
||||
@@ -0,0 +1,404 @@
|
||||
"""tests/test_extensions.py —— 扩展生命周期状态机与存储(契约 §1/§2)。
|
||||
|
||||
覆盖:创建布局(extension.json/raw_code/raw_test_data/converted_code)/
|
||||
状态机合法流转 / 非法流转 409 语义 / build 规则版回退(无 LLM key)/
|
||||
trial 执行(fake executor,成/败)/ validate(无金标+有金标)/
|
||||
repair(轮次上限/LLM 不可用规则回退)/
|
||||
confirm(nl_triggers 编辑/parameters 补齐)/ publish / run(仅 published、
|
||||
run_result 落盘、输出登记)/ 列表 published 优先 / 删除守卫。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow import extension_spec as spec
|
||||
from app.services.bioflow import extensions as ext
|
||||
|
||||
pytestmark = pytest.mark.bioflow_unit
|
||||
|
||||
_CODE = "#!/usr/bin/env python\nimport sys\nprint('hello')\n"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("DOCGEN_LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BIOFLOW_EXTENSION_API_KEY", raising=False)
|
||||
return ext.ExtensionStore(base_dir=str(tmp_path / "data" / "extensions"))
|
||||
|
||||
|
||||
def _create(store, *, name="测序质量统计", command="python quality_stats.py --fastq input.fastq --out results/",
|
||||
code=None, data=None, golden=None):
|
||||
return store.create_extension(
|
||||
display_name=name,
|
||||
command=command,
|
||||
runtime_image="test4py311", # 旧 conda 环境名 → 映射 bioflow-gf
|
||||
code_files=code or [("quality_stats.py", _CODE.encode())],
|
||||
data_files=data or [],
|
||||
golden=golden,
|
||||
)
|
||||
|
||||
|
||||
def _fake_executor(exit_code=0, log="run ok\n", outputs=("result.txt",)):
|
||||
"""sync 可调用 executor:写输出 + 返回 ExtensionRunResult(兼容 _execute 三类形态)。"""
|
||||
|
||||
class FakeExec:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def execute(self, spec_in):
|
||||
self.calls.append(spec_in)
|
||||
out_dir = Path(spec_in.output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in outputs:
|
||||
(out_dir / name).write_text("data", encoding="utf-8")
|
||||
if spec_in.log_path:
|
||||
Path(spec_in.log_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(spec_in.log_path).write_text(log, encoding="utf-8")
|
||||
return ext.ExtensionRunResult(
|
||||
run_id="", exit_code=exit_code, log_tail=log,
|
||||
outputs=list(outputs),
|
||||
error="" if exit_code == 0 else "命令失败",
|
||||
)
|
||||
|
||||
return FakeExec()
|
||||
|
||||
|
||||
# ---- 创建与布局(契约 §1)----
|
||||
|
||||
async def test_create_layout(tmp_path):
|
||||
s = ext.ExtensionStore(base_dir=str(tmp_path / "ext"))
|
||||
e = _create(s)
|
||||
assert e["status"] == "draft"
|
||||
assert e["ext_id"].startswith("ext_")
|
||||
assert e["runtime_image"] == "bioflow-gf" # test4py311 → 映射
|
||||
assert e["schema_version"] == spec.SPEC_VERSION
|
||||
root = s.ext_dir(e["ext_id"])
|
||||
assert (root / "extension.json").is_file()
|
||||
assert (root / "raw_code" / "quality_stats.py").read_text(encoding="utf-8") == _CODE
|
||||
assert (root / "converted_code" / "quality_stats.py").is_file()
|
||||
assert (root / "raw_test_data").is_dir()
|
||||
assert (root / "validation").is_dir()
|
||||
s.delete(e["ext_id"])
|
||||
|
||||
|
||||
def test_create_requires_command_and_code(tmp_path):
|
||||
s = ext.ExtensionStore(base_dir=str(tmp_path / "ext_req"))
|
||||
with pytest.raises(ext.ExtensionError, match="display_name"):
|
||||
s.create_extension(display_name=" ", command="python a.py", code_files=[("a.py", b"x")])
|
||||
with pytest.raises(ext.ExtensionError, match="command"):
|
||||
s.create_extension(display_name="n", command="", code_files=[("a.py", b"x")])
|
||||
with pytest.raises(ext.ExtensionError, match="代码包"):
|
||||
s.create_extension(display_name="n", command="python a.py", code_files=[])
|
||||
|
||||
|
||||
def test_create_zip_path_traversal_sanitized(store):
|
||||
e = _create(store, code=[("../../evil.py", b"x"), ("ok.py", b"y")])
|
||||
root = store.ext_dir(e["ext_id"])
|
||||
assert (root / "raw_code" / "evil.py").is_file()
|
||||
assert not (root.parent / "evil.py").exists()
|
||||
|
||||
|
||||
def test_load_missing_404(store):
|
||||
with pytest.raises(ext.ExtensionNotFoundError):
|
||||
store.load_extension("ext_nope")
|
||||
|
||||
|
||||
# ---- 状态机 ----
|
||||
|
||||
async def test_status_transitions_valid(store):
|
||||
e = _create(store)
|
||||
assert (await store.build_extension(e["ext_id"]))["status"] == "built"
|
||||
|
||||
|
||||
async def test_invalid_transition(store):
|
||||
e = _create(store)
|
||||
with pytest.raises(ext.ExtensionStatusError):
|
||||
await store.trial_run(e["ext_id"]) # draft → trial 非法
|
||||
await store.build_extension(e["ext_id"])
|
||||
with pytest.raises(ext.ExtensionStatusError):
|
||||
store.publish(e["ext_id"]) # built → publish 非法(须 validate/confirm)
|
||||
|
||||
|
||||
async def test_build_failed_converges(store):
|
||||
e = _create(store)
|
||||
# 命令无 .py 文件可引用 + 无 LLM → 规则版仍能给出模板(不会 build_failed);
|
||||
# 构造一个规则版也会失败的场景:空原始命令被创建层拦截。这里直接验证
|
||||
# 规则版回退路径本身可用(无 LLM key 时 build 成功且 source=rules)。
|
||||
out = await store.build_extension(e["ext_id"])
|
||||
assert out["status"] == "built"
|
||||
assert out["last_build"]["source"] == "rules"
|
||||
assert "{output_dir}" in out["command_template"]
|
||||
spec.validate_command_template(out["command_template"])
|
||||
|
||||
|
||||
async def test_llm_invalid_output_falls_back_rules(store, monkeypatch):
|
||||
"""LLM 返回白名单外占位符 → 视为转换失败 → 规则版回退(build 仍成功)。"""
|
||||
e = _create(store)
|
||||
monkeypatch.setattr("app.services.bioflow.extension_llm.llm_config",
|
||||
lambda: {"base_url": "http://x", "api_key": "k", "model": "m", "timeout": 1.0})
|
||||
|
||||
async def fake_complete_json(system, user, **kw):
|
||||
return {"command_template": "python {code_dir}/a.py --out {not_allowed}",
|
||||
"requirements": {}, "nl_triggers": ["t"]}
|
||||
|
||||
monkeypatch.setattr("app.services.bioflow.llm.complete_json", fake_complete_json)
|
||||
out = await store.build_extension(e["ext_id"])
|
||||
assert out["status"] == "built"
|
||||
assert out["last_build"]["source"] == "rules"
|
||||
spec.validate_command_template(out["command_template"])
|
||||
|
||||
|
||||
# ---- trial ----
|
||||
|
||||
async def test_trial_pass(store):
|
||||
e = _create(store, data=[("input.fastq", b">s1\nACGT\n")])
|
||||
await store.build_extension(e["ext_id"])
|
||||
store.executor = _fake_executor(0, "ok\n", ("result.txt",))
|
||||
out = await store.trial_run(e["ext_id"])
|
||||
assert out["status"] == "trial_passed"
|
||||
assert out["trial"]["ok"] is True
|
||||
assert out["trial"]["outputs"] == ["result.txt"]
|
||||
vdir = store.validation_dir(e["ext_id"])
|
||||
assert (vdir / "trial_1.log").is_file()
|
||||
assert (vdir / "trial_1.outputs" / "result.txt").is_file()
|
||||
|
||||
|
||||
async def test_trial_fail(store):
|
||||
e = _create(store)
|
||||
await store.build_extension(e["ext_id"])
|
||||
store.executor = _fake_executor(2, "boom\n", ())
|
||||
out = await store.trial_run(e["ext_id"])
|
||||
assert out["status"] == "trial_failed"
|
||||
assert out["trial"]["ok"] is False
|
||||
assert "命令失败" in out["trial"]["error"]
|
||||
|
||||
|
||||
async def test_trial_retry_increments(store):
|
||||
e = _create(store)
|
||||
await store.build_extension(e["ext_id"])
|
||||
store.executor = _fake_executor(1, "fail\n", ())
|
||||
await store.trial_run(e["ext_id"])
|
||||
store.executor = _fake_executor(0, "ok\n", ("r.txt",))
|
||||
out = await store.trial_run(e["ext_id"])
|
||||
assert out["trial"]["n"] == 2
|
||||
assert out["status"] == "trial_passed"
|
||||
|
||||
|
||||
async def test_trial_without_executor_503_semantics(store):
|
||||
e = _create(store)
|
||||
await store.build_extension(e["ext_id"])
|
||||
with pytest.raises(ext.ExtensionRunError):
|
||||
await store.trial_run(e["ext_id"]) # 占位执行器 → 明确错误(路由层 503)
|
||||
|
||||
|
||||
# ---- validate ----
|
||||
|
||||
async def test_validate_without_golden(store):
|
||||
e = _create(store)
|
||||
await store.build_extension(e["ext_id"])
|
||||
store.executor = _fake_executor(0, "ok\n")
|
||||
await store.trial_run(e["ext_id"])
|
||||
out = store.validate(e["ext_id"])
|
||||
assert out["status"] == "validated"
|
||||
assert out["validation"]["compared"] is False
|
||||
|
||||
|
||||
async def test_validate_golden_pass(store):
|
||||
e = _create(store, data=[("sample.fastq", b">s\nACGT\n")],
|
||||
golden={"test_input": "sample.fastq", "expected_outputs": ["result.txt"]})
|
||||
await store.build_extension(e["ext_id"])
|
||||
store.executor = _fake_executor(0, "ok\n", ("result.txt",))
|
||||
await store.trial_run(e["ext_id"])
|
||||
out = store.validate(e["ext_id"])
|
||||
assert out["status"] == "validated"
|
||||
assert out["validation"]["compared"] is True
|
||||
assert out["validation"]["ok"] is True
|
||||
|
||||
|
||||
async def test_validate_golden_missing_output(store):
|
||||
e = _create(store, golden={"test_input": "sample.fastq", "expected_outputs": ["gold.tsv"]})
|
||||
await store.build_extension(e["ext_id"])
|
||||
store.executor = _fake_executor(0, "ok\n", ("other.txt",))
|
||||
await store.trial_run(e["ext_id"])
|
||||
out = store.validate(e["ext_id"])
|
||||
assert out["status"] == "trial_failed"
|
||||
assert out["validation"]["missing_outputs"] == ["gold.tsv"]
|
||||
|
||||
|
||||
# ---- repair ----
|
||||
|
||||
async def test_repair_limit(store):
|
||||
e = _create(store)
|
||||
await store.build_extension(e["ext_id"])
|
||||
store.executor = _fake_executor(1, "No module named 'pandas'\n", ())
|
||||
await store.trial_run(e["ext_id"])
|
||||
store.max_repair_rounds = 1
|
||||
out = await store.repair(e["ext_id"]) # 第 1 轮成功(规则版并入 pandas)
|
||||
assert out["repair_count"] == 1
|
||||
assert "pandas" in out["requirements"]["pip"]
|
||||
store.executor = _fake_executor(1, "still fail\n", ())
|
||||
await store.trial_run(e["ext_id"])
|
||||
with pytest.raises(ext.ExtensionStatusError, match="上限"):
|
||||
await store.repair(e["ext_id"])
|
||||
|
||||
|
||||
async def test_repair_requires_failure(store):
|
||||
e = _create(store)
|
||||
await store.build_extension(e["ext_id"])
|
||||
with pytest.raises(ext.ExtensionStatusError):
|
||||
await store.repair(e["ext_id"]) # built(未失败)不可修复
|
||||
|
||||
|
||||
# ---- confirm / publish ----
|
||||
|
||||
async def test_full_flow_to_publish(store):
|
||||
e = _create(store)
|
||||
ext_id = e["ext_id"]
|
||||
await store.build_extension(ext_id)
|
||||
store.executor = _fake_executor(0, "ok\n")
|
||||
await store.trial_run(ext_id)
|
||||
store.validate(ext_id)
|
||||
out = store.confirm(ext_id, nl_triggers=["质量统计", "fastq 统计"])
|
||||
assert out["status"] == "confirming"
|
||||
assert out["nl_triggers"] == ["质量统计", "fastq 统计"]
|
||||
pub = store.publish(ext_id)
|
||||
assert pub["status"] == "published"
|
||||
# 已发布不可再发布流转(幂等返回)
|
||||
assert store.publish(ext_id)["status"] == "published"
|
||||
|
||||
|
||||
async def test_confirm_parameters_reconcile(store):
|
||||
e = _create(store, command="python quality_stats.py --fastq input.fastq --cpu {param_cpu} --out results/")
|
||||
await store.build_extension(e["ext_id"])
|
||||
store.executor = _fake_executor(0)
|
||||
await store.trial_run(e["ext_id"])
|
||||
store.validate(e["ext_id"])
|
||||
# 用户编辑后的 parameters 丢掉 {param_cpu} → 自动补齐
|
||||
out = store.confirm(e["ext_id"], parameters=[])
|
||||
names = {p["name"] for p in out["parameters"]}
|
||||
assert "cpu" in names
|
||||
|
||||
|
||||
# ---- run ----
|
||||
|
||||
async def test_run_requires_published(store):
|
||||
e = _create(store)
|
||||
await store.build_extension(e["ext_id"])
|
||||
with pytest.raises(ext.ExtensionStatusError, match="仅已发布"):
|
||||
await store.run(e["ext_id"], params={}, files=[])
|
||||
|
||||
|
||||
async def test_run_full(store):
|
||||
e = _create(store)
|
||||
ext_id = e["ext_id"]
|
||||
await store.build_extension(ext_id)
|
||||
store.executor = _fake_executor(0, "run ok\n", ("out.tsv",))
|
||||
await store.trial_run(ext_id)
|
||||
store.validate(ext_id)
|
||||
store.confirm(ext_id)
|
||||
store.publish(ext_id)
|
||||
|
||||
# 输入文件登记(须在白名单根内:data_root 或扩展目录)
|
||||
data_root = Path(store.base_dir).parent
|
||||
input_file = data_root / "input.fastq"
|
||||
input_file.write_text(">s\nACGT\n", encoding="utf-8")
|
||||
result = await store.run(ext_id, params={"cpu": "4"}, files=[str(input_file)])
|
||||
assert result["status"] == "done"
|
||||
assert result["exit_code"] == 0
|
||||
assert "out.tsv" in result["outputs"]
|
||||
run_dir = store.runs_dir(ext_id) / result["run_id"]
|
||||
assert (run_dir / "run_result.json").is_file()
|
||||
assert (run_dir / "inputs" / "input.fastq").is_file()
|
||||
assert (run_dir / "outputs" / "out.tsv").is_file()
|
||||
runs = store.list_runs(ext_id)
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["run_id"] == result["run_id"]
|
||||
|
||||
|
||||
async def test_run_rejects_outside_file(store):
|
||||
e = _create(store)
|
||||
ext_id = e["ext_id"]
|
||||
await store.build_extension(ext_id)
|
||||
store.executor = _fake_executor(0)
|
||||
await store.trial_run(ext_id)
|
||||
store.validate(ext_id)
|
||||
store.publish(ext_id)
|
||||
with pytest.raises(ext.ExtensionRunError, match="白名单"):
|
||||
await store.run(ext_id, params={}, files=["/etc/passwd"])
|
||||
|
||||
|
||||
async def test_run_failed_status(store):
|
||||
e = _create(store)
|
||||
ext_id = e["ext_id"]
|
||||
await store.build_extension(ext_id)
|
||||
store.executor = _fake_executor(3, "output contract failed\n", ())
|
||||
await store.trial_run(ext_id) # 空输出 → 失败(假执行器 exit=3)
|
||||
assert store.load_extension(ext_id)["status"] == "trial_failed"
|
||||
|
||||
|
||||
# ---- 列表 / 删除 ----
|
||||
|
||||
async def test_list_published_first(store):
|
||||
a = _create(store, name="扩展 A")
|
||||
await store.build_extension(a["ext_id"])
|
||||
b = _create(store, name="扩展 B")
|
||||
await store.build_extension(b["ext_id"])
|
||||
store.executor = _fake_executor(0)
|
||||
await store.trial_run(b["ext_id"])
|
||||
store.validate(b["ext_id"])
|
||||
store.publish(b["ext_id"])
|
||||
items = store.list_extensions()
|
||||
assert items[0]["ext_id"] == b["ext_id"] # published 优先
|
||||
assert items[1]["ext_id"] == a["ext_id"]
|
||||
|
||||
|
||||
def test_delete_guard_trial_running(store):
|
||||
e = _create(store)
|
||||
# 手工置 trial_running(不可删除守卫;无需 build——直接构造状态)
|
||||
store.save_extension({**store.load_extension(e["ext_id"]), "status": "trial_running"})
|
||||
with pytest.raises(ext.ExtensionStatusError, match="试运行"):
|
||||
store.delete(e["ext_id"])
|
||||
store.save_extension({**store.load_extension(e["ext_id"]), "status": "draft"})
|
||||
store.delete(e["ext_id"])
|
||||
assert store.load_extension(e["ext_id"], required=False) is None
|
||||
|
||||
|
||||
# ---- 并发写合并 ----
|
||||
|
||||
async def test_apply_and_write_preserves_fields(store):
|
||||
e = _create(store)
|
||||
ext_id = e["ext_id"]
|
||||
# 两次锁内读-改-写(模拟交错协程写不同字段):后写不丢先写字段
|
||||
store._apply_and_write(ext_id, lambda ext: ext.__setitem__(
|
||||
"command_template", "python {code_dir}/a.py --out {output_dir}"))
|
||||
store._apply_and_write(ext_id, lambda ext: ext.__setitem__("nl_triggers", ["触发词"]))
|
||||
merged = store.load_extension(ext_id)
|
||||
assert merged["command_template"] == "python {code_dir}/a.py --out {output_dir}"
|
||||
assert merged["nl_triggers"] == ["触发词"]
|
||||
assert merged["original_command"] # 其余字段保留
|
||||
|
||||
|
||||
# ---- 事件总线发布(契约 §4;run 完成后可回放)----
|
||||
|
||||
async def test_run_publishes_events(store, tmp_path):
|
||||
from app.services.bioflow.events import EventBus
|
||||
|
||||
bus = EventBus(tmp_path / "events")
|
||||
e = _create(store)
|
||||
ext_id = e["ext_id"]
|
||||
await store.build_extension(ext_id)
|
||||
store.executor = _fake_executor(0, "run ok\n", ("o.txt",))
|
||||
await store.trial_run(ext_id)
|
||||
store.validate(ext_id)
|
||||
store.publish(ext_id)
|
||||
|
||||
store._bus_override = bus
|
||||
result = await store.run(ext_id, params={}, files=[])
|
||||
replay = bus.replay(ext_id)
|
||||
types = [ev["type"] for ev in replay]
|
||||
assert "extension_run_started" in types
|
||||
assert "extension_run_done" in types
|
||||
assert result["status"] == "done"
|
||||
@@ -0,0 +1,113 @@
|
||||
# 扩展分析契约文档(Dev-A/B/C 与测试 agent 对齐基线)
|
||||
|
||||
> 生成 2026-09-12,方案见 docs/DEPLOY_SKILL.md 同源的扩展分析方案。三个开发分支与本契约对齐,测试 agent 按本契约+计划文档验收。
|
||||
|
||||
## 1. 扩展资产目录布局(服务端存储)
|
||||
|
||||
```
|
||||
{BIOFLOW_DATA}/extensions/{ext_id}/
|
||||
├── extension.json # 唯一事实源(schema 见 §2)
|
||||
├── raw_code/ # 上传 zip 解压的原始代码(含 run.sh 等)
|
||||
├── raw_test_data/ # 上传的测试数据(可选)
|
||||
├── converted_code/ # AI 转换后写入的代码(与 raw_code 相同或改写版)
|
||||
├── validation/ # 试运行/金标/修复循环中间产物
|
||||
│ ├── trial_{n}.log
|
||||
│ ├── trial_{n}.outputs/
|
||||
│ └── golden_report.json
|
||||
└── runs/{run_id}/ # 每次正式运行
|
||||
├── inputs/ # 运行输入(路径登记或复制的文件)
|
||||
├── outputs/ # 运行输出(输出契约校验对象)
|
||||
├── run.log
|
||||
└── run_result.json # {run_id, ext_id, exit_code, started_at, finished_at, outputs: [...], error}
|
||||
```
|
||||
|
||||
## 2. extension.json schema(唯一事实源)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "extension_stage_v1",
|
||||
"ext_id": "ext_xxxxxxxx",
|
||||
"display_name": "测序质量统计",
|
||||
"status": "draft|building|built|trial_running|trial_passed|trial_failed|validating|validated|repairing|confirming|published|build_failed",
|
||||
"command_template": "python {code_dir}/quality_stats.py --fastq {input_file} --out {output_dir}",
|
||||
"original_command": "python quality_stats.py --fastq input.fastq --out results/",
|
||||
"runtime_image": "bioflow-gf",
|
||||
"requirements": { "pip": [], "apt": [] },
|
||||
"parameters": [ { "name": "fastq", "type": "file", "required": true, "description": "..." } ],
|
||||
"input_roles": { "input_file": "fastq" },
|
||||
"nl_triggers": ["质量统计", "fastq 统计"],
|
||||
"analysis_type": "table_filter",
|
||||
"golden": { "test_input": "sample.fastq", "expected_outputs": ["..."] } | null,
|
||||
"repair_count": 0,
|
||||
"created_at": "...", "updated_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**占位符白名单(spec_v2 移植)**:`{code_dir} {input_dir} {input_file} {input_hmm} {input_fasta} {input_query_fasta} {input_reference_fasta} {input_pep_fasta} {input_cds_fasta} {input_id_file} {output_dir} {work_dir}` + `{param_<name>}`。硬约束:`{output_dir}` 必含;白名单外占位符拒绝。
|
||||
|
||||
## 3. docker 执行契约(Dev-B 负责实现)
|
||||
|
||||
宿主挂载入口 `bioflow_kernel/cli/run_extension.py`,命令形态:
|
||||
|
||||
```
|
||||
docker run --rm --user <uid> \
|
||||
-v <ext_dir>:/job/ext:ro \
|
||||
-v <input_dir>:/job/input \
|
||||
-v <output_dir>:/job/output \
|
||||
-v <宿主 bioflow_kernel/cli>:/opt/bioflow-entry:ro \
|
||||
--cpus <白名单> --memory <白名单> \
|
||||
<image>:dev python -B /opt/bioflow-entry/run_extension.py \
|
||||
--ext-dir /job/ext --input-dir /job/input --output-dir /job/output [--param name=value ...]
|
||||
```
|
||||
|
||||
run_extension.py 职责:读 /job/ext/extension.json → 渲染 command_template(code_dir=/job/ext/converted_code)→ subprocess 执行 → 日志/退出码返回 → **输出契约校验(输出目录非空,否则退出码 3)**。8 个 base 镜像与自动生成镜像零改动。
|
||||
|
||||
## 4. 事件帧契约(chat/stream 扩展分支下发,Dev-C 消费)
|
||||
|
||||
帧 = `{event, data}`,事件集:
|
||||
|
||||
| event | data 关键字段 | 触发 |
|
||||
|---|---|---|
|
||||
| `extension_upload` | {session_ext_id} | 创建意图命中 → 上传引导卡 |
|
||||
| `extension_created` | {ext_id, display_name, status:"draft"} | 上传完成 |
|
||||
| `extension_build` | {ext_id, command_template, requirements, runtime_image, nl_triggers, analysis_type, parameters, status} | AI 转换完成 → 转换卡 |
|
||||
| `extension_trial` | {ext_id, status:"running"\|"done"\|"failed", log_tail, exit_code, error} | 试运行状态/结果 |
|
||||
| `extension_repair` | {ext_id, repair_count, suggestion, status} | 修复循环 |
|
||||
| `extension_validate` | {ext_id, golden: {...}\|null, form_defaults} | 金标/确认卡 |
|
||||
| `extension_published` | {ext_id, display_name, nl_triggers} | 发布完成 |
|
||||
| `extension_candidates` | {candidates: [{ext_id, display_name, purpose}]} | 多命中候选卡 |
|
||||
| `extension_confirm` | {ext_id, params_schema, extracted_params, files_required} | 运行前参数确认卡 |
|
||||
| `extension_run_started` | {run_id, ext_id} | 运行开始 |
|
||||
| `extension_run_log` | {run_id, log} | 运行日志追加 |
|
||||
| `extension_run_done` | {run_id, outputs} | 运行完成 |
|
||||
| `extension_run_failed` | {run_id, exit_code, error, log_tail} | 运行失败 |
|
||||
|
||||
## 5. 意图两层判定(Dev-A 负责,intent.py 扩展)
|
||||
|
||||
- **A 层创建**:动作词{创建,建,新建,上传,自定义,写,弄,做一个} × 目标词{扩展,拓展分析,分析模板,分析脚本,分析工具},同句命中即进创建流程。
|
||||
- **B 层运行**:已发布扩展打分 = display_name 模糊 + nl_triggers 关键词 + analysis_type;动作词{帮我跑,用,做一下,分析一下}加分。仅 published 可被触发。
|
||||
- **歧义消解**:目标词命中但无动作词 → 有 B 层命中走运行;无命中回「没有匹配的已发布扩展,是否创建?」引导卡。
|
||||
- **漏判安全**:无命中且非创建 → 回落正常生信阶段规划,现网零影响。
|
||||
|
||||
## 6. 端点清单(Dev-A 实现,Dev-C 调用)
|
||||
|
||||
```
|
||||
POST /api/bioflow/extensions (multipart zip + display_name + command + runtime_image? + golden?)
|
||||
GET /api/bioflow/extensions (列表,published 优先)
|
||||
GET /api/bioflow/extensions/{id}
|
||||
DELETE /api/bioflow/extensions/{id}
|
||||
POST /api/bioflow/extensions/{id}/build (AI 转换)
|
||||
POST /api/bioflow/extensions/{id}/trial-run (test_inputs 文件)
|
||||
POST /api/bioflow/extensions/{id}/repair (LLM 修复,上限 20 轮可配)
|
||||
POST /api/bioflow/extensions/{id}/publish
|
||||
POST /api/bioflow/extensions/{id}/run (params + files → run_id)
|
||||
GET /api/bioflow/extensions/{id}/runs
|
||||
GET /api/bioflow/extensions/{id}/runs/{run_id}/events (SSE,首帧快照)
|
||||
```
|
||||
|
||||
## 7. 约束(全部 agent)
|
||||
|
||||
- 代码仅限 175 仓库 `/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new`(worktree 副本)。
|
||||
- 不 push、不部署 176、不动生产部署树;dev 栈不得重启生产服务。
|
||||
- 依赖安装:服务端零新增依赖(纯 stdlib+现有 httpx/pydantic/fastapi);镜像内依赖走自动镜像生成(Dev-B)。
|
||||
- 现有 bioflow 路径零改动:kernel.py/runner.py/planner.py/catalog.py/stage_image_map.json 一律不碰(扩展是并行链路)。
|
||||
Reference in New Issue
Block a user