Files
PlantAI-rag-new/bioflow_service/app/services/bioflow/manifests.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

169 lines
6.1 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/services/bioflow/manifests.py —— manifest 解析与校验(schema 随内核树)。
- 按 gfa_core/schemas/core/manifest_v1.schema.json 必需字段解析
(manifest_version/job_id/stage/status/artifacts/parameters/metrics…)。
- 定位规则(同内核 cli 行为):final/{prefix}.{stage}.manifest.json 优先,
final/ 下任意 *.manifest.json 兜底。
- 跨阶段输入定位:上游 manifest 的 artifacts 路径(相对 workspace_root 解析
为容器内路径,供下游 docker -v/参数拼接)。
服务层纪律:纯 dataclass/dict/stdlib,无 pydantic。
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
REQUIRED_MANIFEST_KEYS = (
"manifest_version", "job_id", "stage", "status", "artifacts",
)
# 内核命名约定(workspace_contract_v1.md):final/{prefix}.{stage}.manifest.json
MANIFEST_GLOB = "*.manifest.json"
@dataclass
class Manifest:
"""解析后的 manifest(原始 dict 保留,供透传)。"""
stage: str
status: str
path: str
raw: dict[str, Any] = field(default_factory=dict)
@property
def artifacts(self) -> dict[str, Any]:
return self.raw.get("artifacts") or {}
@property
def parameters(self) -> dict[str, Any]:
return self.raw.get("parameters") or {}
@property
def metrics(self) -> dict[str, Any]:
return self.raw.get("metrics") or {}
@property
def prefix(self) -> str:
return str(self.raw.get("prefix") or "")
class ManifestError(Exception):
"""manifest 缺失/非法 JSON/必需字段缺失。"""
def locate_manifest(workspace: str | Path, stage: str, prefix: str | None = None) -> Path | None:
"""在 workspace final/ 下定位阶段 manifest(prefix + 阶段名双匹配)。
匹配优先级:
1. prefix 匹配 + 阶段名中缀:final/{prefix}.{stage}.manifest.json
2. 阶段名中缀:final/{任意}.{stage}.manifest.json
3. 无中缀名兜底(BUG-FIX 2026-09-18):final/{stage}.manifest.json
(rnaseq 系 CLI 空 prefix 时产出的文件名形态,如 rnaseq_qc.manifest.json;
此前无此分支 → 退出码 0 也被判 no_manifest)。
不存在返回 None(runner 据此判 reason=no_manifest)。
注意:兜底只按「文件名精确等于 {stage}.manifest.json」匹配——其他阶段的
manifest 绝不误配给当前阶段(重启恢复按 manifest 判成败时曾因任意
*.manifest.json 兜底把 identify 的 manifest 判给 phylogeny,导致 running
阶段被误判 done 不重跑)。
"""
final_dir = Path(workspace) / "final"
if not final_dir.is_dir():
return None
candidates = list(final_dir.glob(MANIFEST_GLOB))
if not candidates:
return None
if prefix:
for cand in candidates:
if cand.name.startswith(prefix) and f".{stage}." in cand.name:
return cand
for cand in candidates:
if f".{stage}." in cand.name:
return cand
for cand in candidates:
if cand.name == f"{stage}.manifest.json":
return cand
return None
def parse_manifest(workspace: str | Path, stage: str, prefix: str | None = None) -> Manifest:
"""定位并解析 manifest;失败抛 ManifestError(含具体原因)。"""
path = locate_manifest(workspace, stage, prefix)
if path is None:
raise ManifestError(f"manifest 缺失: workspace={workspace} stage={stage}")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except ValueError as exc:
raise ManifestError(f"manifest 非法 JSON: {path}") from exc
except OSError as exc:
raise ManifestError(f"manifest 读取失败: {path}") from exc
if not isinstance(raw, dict):
raise ManifestError(f"manifest 非对象: {path}")
missing = [k for k in REQUIRED_MANIFEST_KEYS if k not in raw]
if missing:
raise ManifestError(f"manifest 缺必需字段 {missing}: {path}")
status = str(raw.get("status") or "")
if status not in ("success", "failed", "partial_success"):
raise ManifestError(f"manifest status 非法: {status!r}")
return Manifest(stage=stage, status=status, path=str(path), raw=raw)
def locate_artifacts(workspace: str | Path, stage: str, prefix: str | None = None) -> dict[str, Any]:
"""解析上游 manifest 并返回 artifacts(跨阶段输入定位)。
解析失败返回 {}(调用方决定 fallback:上游失败则下游不应执行)。
"""
try:
m = parse_manifest(workspace, stage, prefix)
return m.artifacts
except ManifestError:
return {}
def artifact_paths(artifacts: dict[str, Any]) -> list[str]:
"""artifacts → 文件路径列表(full_path 优先,path/relpath_or_hint 兜底)。"""
out: list[str] = []
for value in artifacts.values():
if isinstance(value, str):
if value:
out.append(value)
continue
if isinstance(value, dict):
for key in ("full_path", "path", "relpath_or_hint"):
v = value.get(key)
if v:
out.append(str(v))
break
return out
def input_resolution(
upstream_workspace: str | Path,
upstream_stage: str,
prefix: str | None = None,
) -> dict[str, Any]:
"""下游阶段的跨阶段输入解析结果。
返回 {resolved: bool, inputs: [路径…], manifest_path: str|None, error: str|None}
"""
try:
m = parse_manifest(upstream_workspace, upstream_stage, prefix)
except ManifestError as exc:
return {"resolved": False, "inputs": [], "manifest_path": None, "error": str(exc)}
paths = artifact_paths(m.artifacts)
existing = [p for p in paths if Path(p).exists()]
if not existing:
return {
"resolved": False,
"inputs": [],
"manifest_path": m.path,
"error": f"上游 {upstream_stage} artifacts 文件均不存在",
}
return {"resolved": True, "inputs": existing, "manifest_path": m.path, "error": None}