forked from admin/PlantAI-rag-new
- 生信进度:后端新增 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
623 lines
25 KiB
Python
623 lines
25 KiB
Python
"""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 {},
|
||
}}
|