Files
PlantAI-rag-new/bioflow_service/app/routers/biojobs.py
T
admin 0282cfeeeb feat(bioflow): 阶段进度条+滚动日志+点击定位全链;图片注入三修;QA启动就绪等待
- 生信进度:后端新增 stage_progress 事件帧+进度解析白名单(rnaseq_qc/sample_qc),
  kernel 日志流式落盘(20行/1s flush+fsync),logs 分页端点(offset/limit/eof),
  事件 seq 跨重启扫描续增(2026-09-17 断流修复);前端 FlowProgressCard 确定性/
  不确定两态进度条+点击定位到生信任务 TAB,BioJobsPane DAG 行展开滚动日志区
  (3s 轮询),log_tail 4000 截断,重跑 stage_started 清零进度
- 图片注入:figures 三源重构(标准DOI图库主路径/遗留体系兜底/PMC兜底),
  [[FIG:]] 宽松归一化识别 PMC/PMID/DOI 三种形态,引文兜底扩展至
  [PMCID:]/[DOI:] 且标记未满配额继续注入,注入预算 90s/10篇(175 实测校准),
  PMC graphic 无 content-type 宽松正则,空结果不缓存(176 冷连接三连败修复)
- QA 启动自愈:start.sh 启动前等 Qdrant readyz(30min)+Neo4j TCP(10min);
  backend_guard 标签校验改子集判定
- 样本配对:同 inode 去重优先真实文件,R1/R2 归一配对一对一认领
- gitignore 新增 *.bak-*/*.before-*/bioflow_service/bioflow.db

测试:bioflow_service pytest 358/358,前端 bioflow vitest 205/205
2026-09-24 16:48:52 +08:00

623 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""app/routers/biojobs.py —— 任务端点(jobs 全套 + files 上传 + stages 控制 + events SSE)。
端点(实施计划 §六清单):
- POST /api/bioflow/jobs 创建任务(201;FR-BIO-004)
- GET /api/bioflow/jobs 列表(分页/过滤;FR-BIO-024)
- GET /api/bioflow/jobs/{id} 详情(含 stages)
- DELETE /api/bioflow/jobs/{id} 删除(running 409)
- POST /api/bioflow/jobs/{id}/files 上传(multipart 流式,大文件超时单独调大)
或服务器路径登记(JSON body)
- GET /api/bioflow/jobs/{id}/files 文件列表
- GET /api/bioflow/jobs/{id}/files/{fid}/content 文件内容
- POST /api/bioflow/jobs/{id}/stages/{stage}/confirm 参数确认执行
- POST /api/bioflow/jobs/{id}/stages/{stage}/rerun 失败重跑(带参数→waiting_confirm)
- POST /api/bioflow/jobs/{id}/stages/{stage}/skip 失败跳过
- POST /api/bioflow/jobs/{id}/pause / resume 暂停/恢复
- GET /api/bioflow/jobs/{id}/stages/{stage}/manifest manifest + 日志尾 80 行
- GET /api/bioflow/jobs/{id}/events SSE 事件流(快照首帧 + 断线回放)
- POST /api/bioflow/report/{job_id}/revise 报告图表修订(FR-BIO-019,最小接口)
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from ..config import get_settings
from .. import context as context_mod
from ..context import Context, context, load_record # context 保留供 conftest monkeypatch
from ..db import Database, get_db
from ..schemas import (
ArchiveRunRequest,
JobCreateRequest,
PathRegisterRequest,
StageConfirmRequest,
StageRerunRequest,
ReportReviseRequest,
)
from ..services.bioflow import store
from ..services.bioflow.kernel import tail_lines
from ..services.bioflow.manifests import ManifestError, locate_manifest, parse_manifest
from ..services.bioflow.runner import (
JOB_RUNNING,
STAGE_FAILED,
STAGE_RUNNING,
RunnerError,
)
from .bioflow import job_or_404
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/bioflow", tags=["bioflow-jobs"])
KEEPALIVE_INTERVAL = 15.0
KEEPALIVE_COMMENT = b": keepalive\n\n"
SNAPSHOT_TYPE = "snapshot"
def _err(code: str, message: str, status: int) -> JSONResponse:
return JSONResponse(status_code=status, content={
"ok": False,
"error": {"code": code, "message": message, "degraded": False},
})
# ---------------------------------------------------------------------------
# jobs
# ---------------------------------------------------------------------------
@router.post("/jobs", status_code=201)
async def create_job(body: JobCreateRequest) -> dict[str, Any]:
"""创建任务(FR-BIO-004;未知阶段 422;复用归档 TC-BIO-API-029)。"""
ctx: Context = context_mod.context
params: dict[str, dict[str, Any]] = dict(body.params or {})
archive_id = body.archive_id
if body.archive_id:
archive = await store.get_archive(ctx.db, body.archive_id)
if archive is None:
return _err("ARCHIVE_NOT_FOUND", f"存档不存在: {body.archive_id}", 404)
params = {**dict(archive.get("params") or {}), **params}
await store.bump_archive_reuse(ctx.db, body.archive_id)
try:
record = await ctx.runner.create_job(
body.session_id, body.title, body.stages, params,
auto_continue=body.auto_continue, archive_id=archive_id,
)
except RunnerError as exc:
return _err(exc.code, exc.message, 422)
ctx.records[record.id] = record
return {"ok": True, "data": await _job_detail(record)}
async def _job_detail(record: Any) -> dict[str, Any]:
stages = []
for stage in record.stage_chain:
st = record._stage_states.get(stage, {})
stages.append({
"stage": stage,
"status": st.get("status", "waiting"),
"params": st.get("params") or {},
"attempt": int(st.get("attempt") or 0),
"manifest_path": st.get("manifest_path"),
"log_tail": st.get("log_tail") or "",
"error": st.get("error"),
"started_at": st.get("started_at"),
"finished_at": st.get("finished_at"),
})
return {
"id": record.id,
"session_id": record.session_id,
"title": record.title,
"status": record.status,
"stages": stages,
"params": record.params,
"auto_continue": record.auto_continue,
"archive_id": record.archive_id,
"progress": record.progress(),
}
@router.get("/jobs")
async def list_jobs(
session_id: str | None = Query(default=None),
status: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> dict[str, Any]:
ctx: Context = context_mod.context
items, total = await store.list_jobs(
ctx.db, session_id=session_id, status=status, page=page, page_size=page_size,
)
for item in items:
stages = await store.list_stages(ctx.db, item["id"])
done = sum(1 for s in stages if s["status"] in ("done", "skipped"))
total_stages = len(stages)
item["progress"] = int(done * 100 / total_stages) if total_stages else 100
item["stages_count"] = total_stages
return {"ok": True, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
@router.get("/jobs/{job_id}")
async def get_job(job_id: str) -> dict[str, Any]:
record = await load_record(job_id)
if record is None:
return _err("NOT_FOUND", "任务不存在", 404)
return {"ok": True, "data": await _job_detail(record)}
@router.delete("/jobs/{job_id}", status_code=204)
async def delete_job(job_id: str) -> JSONResponse:
record = await load_record(job_id)
if record is None:
return _err("NOT_FOUND", "任务不存在", 404)
if record.status in (JOB_RUNNING,):
return _err("JOB_RUNNING", "任务执行中不可删除", 409)
ctx: Context = context_mod.context
await store.delete_job(ctx.db, job_id) # bio_stages 级联删除
ctx.records.pop(job_id, None)
return JSONResponse(status_code=204, content=None)
# ---------------------------------------------------------------------------
# files(FR-BIO-006 上传 / FR-BIO-007 路径登记)
# ---------------------------------------------------------------------------
def _safe_name(name: str) -> str:
"""文件名净化(保留中文;去路径分隔符)。"""
return Path(name or "upload").name or "upload"
@router.post("/jobs/{job_id}/files")
async def upload_file(
job_id: str,
request: Request,
file: UploadFile | None = File(default=None),
) -> dict[str, Any]:
"""上传(multipart 流式)或服务器路径登记(JSON body)。
大文件超时单独调大:本端点自身逐块流式写盘(不整读内存,TC-BIO-API-041)。
"""
record = await job_or_404(job_id)
ctx: Context = context_mod.context
settings = get_settings()
input_dir = record.input_dir
input_dir.mkdir(parents=True, exist_ok=True)
content_type = request.headers.get("content-type", "")
if "multipart/form-data" not in content_type:
# JSON 路径登记(FR-BIO-007)
try:
body = await request.json()
except Exception: # noqa: BLE001
return _err("VALIDATION", "请求体须为 multipart 或 JSON 路径登记", 400)
try:
payload = PathRegisterRequest.model_validate(body)
except Exception as exc: # noqa: BLE001
return _err("VALIDATION", f"路径登记参数不合法: {exc}", 422)
return await _register_path(record, payload.server_path, settings)
if file is None or not file.filename:
return _err("VALIDATION", "缺少上传文件", 422)
ext = Path(file.filename).suffix.lower()
if ext and ext not in settings.upload_ext_set:
return _err("VALIDATION", f"扩展名不允许: {ext}", 422)
dest = input_dir / _safe_name(file.filename)
written = 0
max_bytes = int(settings.max_upload_gb * 1024 ** 3)
try:
with dest.open("wb") as fh:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
written += len(chunk)
if written > max_bytes:
fh.close()
dest.unlink(missing_ok=True)
return _err("UPLOAD_TOO_LARGE",
f"超过上传上限 {settings.max_upload_gb}GB(已收 {written} 字节)", 413)
fh.write(chunk)
except Exception as exc: # noqa: BLE001 - 写盘失败不登记(TC-BIO-API-050)
logger.exception("files: 上传写盘失败 %s", job_id)
dest.unlink(missing_ok=True)
return _err("INTERNAL", f"上传写盘失败: {exc}", 500)
file_id = f"{job_id}:{dest.name}"
# BUG-P7-01:文件落盘后自动续跑(await_inputs 任务重推;非等待态 no-op)
await ctx.runner.retry_after_files(record)
return {"ok": True, "data": {
"file_id": file_id, "name": dest.name, "size": written,
"path": str(dest), "source": "upload",
}}
async def _register_path(record: Any, server_path: str, settings: Any) -> dict[str, Any]:
"""服务器路径登记(FR-BIO-007):单文件或目录展开,任意数据位置(敏感路径黑名单拦截)。
目录:递归收集目录内可登记文件(扩展名白名单),登记为多条记录。
白名单根校验已按用户要求移除,改为敏感路径黑名单(见 settings)。
"""
path = Path(server_path).expanduser().resolve()
if not path.exists():
return _err("PATH_NOT_EXIST", f"路径不存在: {server_path}", 400)
if not path.is_file() and not path.is_dir():
return _err("NOT_A_FILE", f"非文件或目录: {server_path}", 400)
blocked = _blocked_reason(path, settings)
if blocked:
return _err("PATH_NOT_ALLOWED", f"路径不允许: {server_path}({blocked})", 403)
files: list[Path] = []
if path.is_file():
files = [path]
else:
ext_set = settings.upload_ext_set
for p in sorted(path.rglob("*")):
if p.is_file():
if ext_set and p.suffix.lower() not in ext_set:
continue
if _blocked_reason(p, settings):
continue
files.append(p)
if not files:
return _err("VALIDATION",
f"目录内无可登记文件(允许扩展名 {settings.upload_ext_allow[:80]}…)", 400)
# 登记文件 symlink 进任务 input(CLI 扫 /job/input 发现数据)+ 持久化挂载清单
input_dir = record.input_dir
input_dir.mkdir(parents=True, exist_ok=True)
for p in files:
link = input_dir / p.name
try:
if not link.exists():
link.symlink_to(p)
except OSError as exc: # noqa: BLE001
logger.warning("登记 symlink 失败(不阻断): %s -> %s (%s)", p, link, exc)
# rnaseq 系阶段需要 samples.yaml(CLI -c 必填):描述文件优先解析 + 实况分析生成,
# 返回引用 fastq 的父目录(并入挂载清单,跨重启持久化)
try:
from ..services.bioflow.runner import ensure_samples_yaml
source_roots = []
if path.is_dir():
source_roots.append(path)
else:
source_roots.append(path.parent)
extra_mounts = ensure_samples_yaml(input_dir, source_roots=source_roots)
if not extra_mounts:
# 规则无法归纳(无描述文件、实况配对 0 样本) → LLM 兜底
try:
from ..services.bioflow.runner import llm_sample_fallback
if await llm_sample_fallback(input_dir, source_roots):
logger.info("samples.yaml LLM 兜底生成: %s", input_dir)
except Exception as exc: # noqa: BLE001
logger.warning("samples.yaml LLM 兜底失败(不阻断登记): %s", exc)
if extra_mounts:
logger.info("samples.yaml 分析完成(挂载父目录 %d 个): %s", len(extra_mounts), input_dir)
record.registered_server_paths = list(
dict.fromkeys(
(getattr(record, "registered_server_paths", None) or [])
+ [str(Path(m)) for m in extra_mounts]
)
)
except Exception as exc: # noqa: BLE001
logger.warning("samples.yaml 分析失败(不阻断登记): %s", exc)
registered = [str(p) for p in files]
try:
_persist_registered_paths(record, registered)
except Exception as exc: # noqa: BLE001
logger.warning("登记路径持久化失败(不阻断登记): %s", exc)
# BUG-P7-01:路径登记后自动续跑(await_inputs 任务重推;非等待态 no-op)
ctx: Context = context_mod.context
await ctx.runner.retry_after_files(record)
items = [
{"file_id": f"{record.id}:{p.name}", "name": p.name,
"size": p.stat().st_size, "path": str(p), "source": "server_path"}
for p in files
]
return {"ok": True, "data": {"items": items, "total": len(items)}}
def _persist_registered_paths(record: Any, registered: list[str]) -> None:
"""登记路径落盘:任务目录 server_registered.json(跨重启可恢复挂载清单)。"""
record.registered_server_paths = list(
dict.fromkeys((getattr(record, "registered_server_paths", None) or []) + registered)
)
payload = {"registered_server_paths": record.registered_server_paths}
(record.job_dir / "server_registered.json").write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)
def _blocked_reason(path: Path, settings: Any) -> str | None:
"""敏感路径黑名单:命中返回原因字符串,未命中返回 None。"""
s = str(path)
for prefix in settings.sensitive_path_prefixes:
if s == prefix or s.startswith(prefix + "/"):
return f"敏感路径前缀 {prefix}"
name = path.name
for pat in settings.sensitive_filename_patterns:
if re.match(pat, name):
return f"敏感文件名 {name}"
return None
@router.get("/jobs/{job_id}/files")
async def list_files(job_id: str) -> dict[str, Any]:
record = await job_or_404(job_id)
items: list[dict[str, Any]] = []
input_dir = record.input_dir
if input_dir.is_dir():
for p in sorted(input_dir.iterdir()):
if p.is_file():
items.append({
"file_id": f"{job_id}:{p.name}", "name": p.name,
"size": p.stat().st_size, "path": str(p),
"source": "upload", "registered_at": None,
})
return {"ok": True, "data": {"items": items, "total": len(items)}}
@router.get("/jobs/{job_id}/files/{file_id}/content")
async def file_content(job_id: str, file_id: str) -> FileResponse:
record = await job_or_404(job_id)
name = file_id.split(":", 1)[-1] if ":" in file_id else file_id
path = record.input_dir / _safe_name(name)
if not path.is_file():
return _err("NOT_FOUND", "文件不存在", 404)
return FileResponse(path)
# ---------------------------------------------------------------------------
# stages 控制(confirm/rerun/skip/pause/resume/manifest)
# ---------------------------------------------------------------------------
def _stage_exists(record: Any, stage: str) -> None:
if stage not in record.stage_chain:
raise HTTPException(status_code=404, detail={
"ok": False, "error": {"code": "NOT_FOUND", "message": f"阶段不存在: {stage}", "degraded": False},
})
@router.post("/jobs/{job_id}/stages/{stage}/confirm")
async def confirm_stage(job_id: str, stage: str, body: StageConfirmRequest) -> dict[str, Any]:
record = await job_or_404(job_id)
_stage_exists(record, stage)
ctx: Context = context_mod.context
try:
await ctx.runner.confirm_stage(record, stage, dict(body.params or {}))
except RunnerError as exc:
return _err(exc.code, exc.message, 409)
# 确认已受理:执行任务已派生(异步推进中);当前快照可能仍为 waiting_confirm
return {"ok": True, "data": {
"job_id": job_id, "stage": stage,
"status": "running",
"params": record._stage_states.get(stage, {}).get("params") or {},
}}
@router.post("/jobs/{job_id}/stages/{stage}/rerun")
async def rerun_stage(job_id: str, stage: str, body: StageRerunRequest) -> dict[str, Any]:
record = await job_or_404(job_id)
_stage_exists(record, stage)
ctx: Context = context_mod.context
try:
await ctx.runner.rerun_stage(record, stage, dict(body.params) if body.params else None)
except RunnerError as exc:
return _err(exc.code, exc.message, 409)
return {"ok": True, "data": {
"job_id": job_id, "stage": stage,
"status": ctx.runner.stage_status(record, stage),
"attempt": record._stage_states.get(stage, {}).get("attempt"),
}}
@router.post("/jobs/{job_id}/stages/{stage}/skip")
async def skip_stage(job_id: str, stage: str) -> dict[str, Any]:
record = await job_or_404(job_id)
_stage_exists(record, stage)
ctx: Context = context_mod.context
try:
await ctx.runner.skip_stage(record, stage)
except RunnerError as exc:
return _err(exc.code, exc.message, 409)
return {"ok": True, "data": {
"job_id": job_id, "stage": stage,
"status": ctx.runner.stage_status(record, stage),
}}
@router.post("/jobs/{job_id}/pause")
async def pause_job(job_id: str) -> dict[str, Any]:
record = await job_or_404(job_id)
ctx: Context = context_mod.context
try:
await ctx.runner.pause_job(record)
except RunnerError as exc:
return _err(exc.code, exc.message, 409)
return {"ok": True, "data": {"job_id": job_id, "status": record.status}}
@router.post("/jobs/{job_id}/resume")
async def resume_job(job_id: str) -> dict[str, Any]:
record = await job_or_404(job_id)
ctx: Context = context_mod.context
try:
await ctx.runner.resume_job(record)
except RunnerError as exc:
return _err(exc.code, exc.message, 409)
return {"ok": True, "data": {"job_id": job_id, "status": record.status}}
@router.get("/jobs/{job_id}/stages/{stage}/manifest")
async def stage_manifest(job_id: str, stage: str) -> dict[str, Any]:
"""manifest + 日志尾 80 行(FR-BIO-011;TC-BIO-API-071~076)。"""
record = await job_or_404(job_id)
_stage_exists(record, stage)
manifest: dict[str, Any] | None = None
manifest_error: str | None = None
try:
parsed = parse_manifest(record.workspace_dir, stage, None)
manifest = parsed.raw
except ManifestError as exc:
manifest_error = str(exc)
log_tail = ""
log_path = record.logs_dir / f"{stage}.log"
if log_path.exists():
try:
log_tail = tail_lines(log_path.read_text(encoding="utf-8", errors="replace"),
get_settings().log_tail_lines)
except OSError:
log_tail = ""
if manifest is None and not manifest_error:
manifest_error = "manifest 未产出"
return {"ok": True, "data": {
"job_id": job_id, "stage": stage,
"manifest": manifest, "manifest_error": manifest_error,
"log_tail": log_tail,
"status": record._stage_states.get(stage, {}).get("status"),
}}
@router.get("/jobs/{job_id}/stages/{stage}/logs")
async def stage_logs(
job_id: str,
stage: str,
offset: int = Query(default=0, ge=0),
limit: int = Query(default=500, ge=1, le=2000),
) -> dict[str, Any]:
"""阶段日志分页读取(offset=已读行数;执行中可读,前端轮询滚动 log)。
- kernel 流式 fsync 追加落盘,本端点按行切片返回增量;
- offset 超尾 → 空 lines + eof:true;
- 返回 total_bytes 供 eof 判定(文件总行数随执行增长)。
"""
record = await job_or_404(job_id)
_stage_exists(record, stage)
log_path = record.logs_dir / f"{stage}.log"
if not log_path.exists():
return {"ok": True, "data": {
"job_id": job_id, "stage": stage,
"offset": offset, "limit": limit, "lines": [],
"total_bytes": 0, "eof": True, "log_tail": "",
}}
try:
text = log_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return _err("INTERNAL", "日志读取失败", 500)
lines = text.splitlines()
total = len(lines)
page = lines[offset:offset + limit]
eof = offset + len(page) >= total
return {"ok": True, "data": {
"job_id": job_id, "stage": stage,
"offset": offset, "limit": limit,
"lines": page,
"total_bytes": log_path.stat().st_size if log_path.exists() else 0,
"eof": eof,
"log_tail": "\n".join(lines[-get_settings().log_tail_lines:]) if total else "",
}}
# ---------------------------------------------------------------------------
# events SSE(FR-BIO-014 断线重连回放)
# ---------------------------------------------------------------------------
@router.get("/jobs/{job_id}/events")
async def job_events(
job_id: str,
stage: str | None = Query(default=None),
last_seq: int = Query(default=-1, ge=-1),
) -> StreamingResponse:
"""任务事件 SSE:快照首帧 + 历史回放(>last_seq)+ 实时订阅 + 心跳。
TC-BIO-API-081:任务终态(done/failed/archived)且回放完成后服务端正常关闭连接。
"""
record = await job_or_404(job_id)
ctx: Context = context_mod.context
async def gen():
# 1. 快照首帧(TC-BIO-SSE-022)
snap = ctx.events.snapshot(job_id)
if snap is not None:
yield f"event: snapshot\ndata: {json.dumps(snap, ensure_ascii=False)}\n\n".encode()
# 2. 历史回放(>last_seq;TC-BIO-SSE-024)
for ev in ctx.events.replay(job_id, after_seq=last_seq):
if stage and ev.get("data", {}).get("stage") != stage:
continue
yield _sse_bytes(ev)
# 3. 任务终态 → 回放后正常关闭(TC-BIO-API-081)
if record.status in ("done", "failed", "archived"):
return
# 4. 实时订阅
sub = await ctx.events.subscribe(job_filter=job_id, last_seq=last_seq)
try:
while True:
try:
ev = await asyncio.wait_for(sub.queue.get(), timeout=KEEPALIVE_INTERVAL)
except asyncio.TimeoutError:
yield KEEPALIVE_COMMENT
continue
if int(ev.get("seq") or 0) <= last_seq:
continue
if stage and ev.get("data", {}).get("stage") != stage:
continue
yield _sse_bytes(ev)
finally:
await ctx.events.unsubscribe(sub)
headers = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
def _sse_bytes(event: dict[str, Any]) -> bytes:
"""SSE 帧编码(data 行只携带载荷,与 bioflow.py 契约一致)。"""
data = json.dumps(event.get("data") or {}, ensure_ascii=False)
return f"event: {event['type']}\ndata: {data}\n\n".encode("utf-8")
# ---------------------------------------------------------------------------
# report revise(FR-BIO-019,最小接口:绘图阶段重跑预留;纯文字走 docgen 由 A3 协调)
# ---------------------------------------------------------------------------
@router.post("/report/{job_id}/revise")
async def report_revise(job_id: str, body: ReportReviseRequest) -> dict[str, Any]:
"""图表修订(FR-BIO-019):解析图表目标 → 重跑绘图阶段(预留)。
P1 最小实现:无绘图目标 → 422(纯文字排版指令由前端走 docgen 编辑链路)。
有目标但无对应阶段 → NOT_FOUND。绘图阶段重跑链路(rerun)复用现有端点。
"""
record = await job_or_404(job_id)
instruction = (body.instruction or "").strip()
target = (body.target or "").strip()
if not target or not re.search(r"图|表|颜色|色", instruction + target):
return _err("VALIDATION", "无明确图表修订目标(纯文字排版指令请走文档编辑)", 422)
# 候选绘图阶段(基因家族链含绘图能力的阶段)
plot_stages = [s for s in ("synteny", "karyotype", "motif", "combo", "gene_family_report")
if s in record.stage_chain]
rerun_stage = None
for s in plot_stages:
if s in target or target in s:
rerun_stage = s
break
if rerun_stage is None:
return _err("NOT_FOUND", "未找到可重跑的绘图阶段(P2 编排层细化)", 404)
return {"ok": True, "data": {
"job_id": job_id, "rerun_stage": rerun_stage,
"target": target, "instruction": instruction,
"params": body.params or {},
}}