314 lines
12 KiB
Python
314 lines
12 KiB
Python
"""Lightweight, provider-free RNA-seq manifest evidence snapshot.
|
||||
|
|
|
|||
|
|
The live interpretation view and the formal report share this module so scope
|
|||
|
|
selection and manifest provenance have one implementation. It intentionally
|
|||
|
|
does not import the MAS agent package.
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any, Iterable
|
|||
|
|
|
|||
|
|
|
|||
|
|
REPORT_STAGE = "rnaseq_report"
|
|||
|
|
SUCCESS_STATUSES = {"success", "partial_success"}
|
|||
|
|
GLOBAL_LEGACY_STAGES = {
|
|||
|
|
"rnaseq_qc",
|
|||
|
|
"rnaseq_align",
|
|||
|
|
"rnaseq_quant",
|
|||
|
|
"annotation",
|
|||
|
|
"annotation_go",
|
|||
|
|
"annotation_kegg",
|
|||
|
|
}
|
|||
|
|
RNASEQ_STAGE_NAMES = {
|
|||
|
|
"rnaseq_qc": "RNA-seq质控",
|
|||
|
|
"rnaseq_align": "RNA-seq比对",
|
|||
|
|
"rnaseq_quant": "RNA-seq定量",
|
|||
|
|
"rnaseq_sample_qc": "RNA-seq样本质量评估",
|
|||
|
|
"rnaseq_de": "RNA-seq差异表达",
|
|||
|
|
"annotation": "背景功能注释",
|
|||
|
|
"annotation_go": "GO背景自动注释",
|
|||
|
|
"annotation_kegg": "KEGG/KO背景自动注释",
|
|||
|
|
"rnaseq_go_enrich": "RNA-seq GO富集分析",
|
|||
|
|
"rnaseq_kegg_enrich": "RNA-seq KEGG富集分析",
|
|||
|
|
"rnaseq_enrich": "RNA-seq富集分析(兼容入口)",
|
|||
|
|
"rnaseq_fgsea": "RNA-seq fgsea排序富集",
|
|||
|
|
"rnaseq_wgcna": "RNA-seq WGCNA共表达分析",
|
|||
|
|
"rnaseq_candidate_family": "RNA-seq候选家族筛选",
|
|||
|
|
"rnaseq_family_score": "RNA-seq候选家族评分",
|
|||
|
|
}
|
|||
|
|
RNASEQ_STAGE_ORDER = list(RNASEQ_STAGE_NAMES)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class RNASeqManifestSelection:
|
|||
|
|
rnaseq_scope_id: str
|
|||
|
|
discovered_scope_ids: tuple[str, ...]
|
|||
|
|
manifests: dict[str, dict[str, Any]]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def read_json(path: Path) -> dict[str, Any]:
|
|||
|
|
try:
|
|||
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|||
|
|
return data if isinstance(data, dict) else {}
|
|||
|
|
except (OSError, json.JSONDecodeError):
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def manifest_identity_value(manifest: dict[str, Any], key: str) -> str:
|
|||
|
|
context = manifest.get("stage_context") or manifest.get("stageContext") or {}
|
|||
|
|
params = manifest.get("parameters") or {}
|
|||
|
|
return str(manifest.get(key) or params.get(key) or context.get(key) or "").strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_rnaseq_evidence_stage(stage: str) -> bool:
|
|||
|
|
stage = str(stage or "").strip()
|
|||
|
|
return stage != REPORT_STAGE and (
|
|||
|
|
stage.startswith("rnaseq_") or stage in {"annotation", "annotation_go", "annotation_kegg"}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def stage_sort_key(stage: str) -> tuple[int, str]:
|
|||
|
|
try:
|
|||
|
|
return RNASEQ_STAGE_ORDER.index(stage), stage
|
|||
|
|
except ValueError:
|
|||
|
|
return len(RNASEQ_STAGE_ORDER), stage
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _manifest_score(path: Path, manifest: dict[str, Any]) -> tuple[int, int, int, float]:
|
|||
|
|
status = str(manifest.get("status") or "")
|
|||
|
|
status_rank = 2 if status == "success" else 1 if status == "partial_success" else 0
|
|||
|
|
metrics = manifest.get("metrics") if isinstance(manifest.get("metrics"), dict) else {}
|
|||
|
|
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {}
|
|||
|
|
try:
|
|||
|
|
mtime = path.stat().st_mtime
|
|||
|
|
except OSError:
|
|||
|
|
mtime = 0.0
|
|||
|
|
return status_rank, len(metrics), len(artifacts), mtime
|
|||
|
|
|
|||
|
|
|
|||
|
|
def select_rnaseq_manifests(
|
|||
|
|
final_dir: Path,
|
|||
|
|
prefix: str,
|
|||
|
|
*,
|
|||
|
|
rnaseq_scope_id: str = "",
|
|||
|
|
) -> RNASeqManifestSelection:
|
|||
|
|
"""Select one non-mixing manifest per RNA-seq stage for a prefix/scope."""
|
|||
|
|
final_dir = Path(final_dir)
|
|||
|
|
pattern = f"{prefix}.*.manifest.json" if prefix else "*.manifest.json"
|
|||
|
|
candidates: list[tuple[Path, dict[str, Any]]] = []
|
|||
|
|
latest_by_scope: dict[str, float] = {}
|
|||
|
|
for path in sorted(final_dir.glob(pattern)) if final_dir.exists() else []:
|
|||
|
|
manifest = read_json(path)
|
|||
|
|
stage = str(manifest.get("stage") or "")
|
|||
|
|
if not is_rnaseq_evidence_stage(stage):
|
|||
|
|
continue
|
|||
|
|
manifest_prefix = str(manifest.get("prefix") or "").strip()
|
|||
|
|
if prefix and manifest_prefix and manifest_prefix != prefix:
|
|||
|
|
continue
|
|||
|
|
candidates.append((path, manifest))
|
|||
|
|
scope = manifest_identity_value(manifest, "rnaseq_scope_id")
|
|||
|
|
if scope:
|
|||
|
|
try:
|
|||
|
|
latest_by_scope[scope] = max(latest_by_scope.get(scope, 0.0), path.stat().st_mtime)
|
|||
|
|
except OSError:
|
|||
|
|
latest_by_scope.setdefault(scope, 0.0)
|
|||
|
|
|
|||
|
|
discovered = sorted(latest_by_scope, key=lambda item: (-latest_by_scope[item], item))
|
|||
|
|
resolved_scope = str(rnaseq_scope_id or "").strip() or (discovered[0] if discovered else "")
|
|||
|
|
selected_paths: dict[str, Path] = {}
|
|||
|
|
selected: dict[str, dict[str, Any]] = {}
|
|||
|
|
for path, manifest in candidates:
|
|||
|
|
stage = str(manifest.get("stage") or "")
|
|||
|
|
scope = manifest_identity_value(manifest, "rnaseq_scope_id")
|
|||
|
|
if resolved_scope and scope and scope != resolved_scope:
|
|||
|
|
continue
|
|||
|
|
if resolved_scope and not scope and stage not in GLOBAL_LEGACY_STAGES:
|
|||
|
|
continue
|
|||
|
|
existing = selected.get(stage)
|
|||
|
|
if existing is not None and _manifest_score(selected_paths[stage], existing) >= _manifest_score(path, manifest):
|
|||
|
|
continue
|
|||
|
|
manifest = dict(manifest)
|
|||
|
|
manifest["_manifest_path"] = str(path)
|
|||
|
|
selected[stage] = manifest
|
|||
|
|
selected_paths[stage] = path
|
|||
|
|
return RNASeqManifestSelection(resolved_scope, tuple(discovered), selected)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _compact_value(value: Any, limit: int = 240) -> str:
|
|||
|
|
if isinstance(value, (dict, list)):
|
|||
|
|
text = json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|||
|
|
else:
|
|||
|
|
text = str(value)
|
|||
|
|
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_artifact_path(
|
|||
|
|
final_dir: Path,
|
|||
|
|
item: dict[str, Any],
|
|||
|
|
*,
|
|||
|
|
require_exists: bool = False,
|
|||
|
|
) -> Path | None:
|
|||
|
|
"""Resolve artifacts after a workspace has been moved or mounted elsewhere."""
|
|||
|
|
final_dir = Path(final_dir)
|
|||
|
|
workspace = final_dir.parent
|
|||
|
|
candidates: list[Path] = []
|
|||
|
|
for raw in (item.get("full_path"), item.get("path")):
|
|||
|
|
if not raw:
|
|||
|
|
continue
|
|||
|
|
path = Path(str(raw))
|
|||
|
|
candidates.append(path if path.is_absolute() else workspace / path)
|
|||
|
|
if path.is_absolute():
|
|||
|
|
candidates.append(final_dir / path.name)
|
|||
|
|
|
|||
|
|
unique: list[Path] = []
|
|||
|
|
seen: set[str] = set()
|
|||
|
|
for path in candidates:
|
|||
|
|
identity = str(path)
|
|||
|
|
if identity not in seen:
|
|||
|
|
seen.add(identity)
|
|||
|
|
unique.append(path)
|
|||
|
|
for path in unique:
|
|||
|
|
if path.exists():
|
|||
|
|
return path
|
|||
|
|
if require_exists:
|
|||
|
|
return None
|
|||
|
|
return unique[0] if unique else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_rnaseq_evidence_snapshot(
|
|||
|
|
final_dir: Path,
|
|||
|
|
prefix: str,
|
|||
|
|
*,
|
|||
|
|
rnaseq_scope_id: str = "",
|
|||
|
|
include_stages: Iterable[str] | None = None,
|
|||
|
|
selection: RNASeqManifestSelection | None = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""Build a compact evidence object without invoking an LLM or agent cache."""
|
|||
|
|
selection = selection or select_rnaseq_manifests(
|
|||
|
|
final_dir, prefix, rnaseq_scope_id=rnaseq_scope_id
|
|||
|
|
)
|
|||
|
|
allowed = {str(stage) for stage in include_stages or [] if str(stage).strip()}
|
|||
|
|
manifests = {
|
|||
|
|
stage: manifest
|
|||
|
|
for stage, manifest in selection.manifests.items()
|
|||
|
|
if not allowed or stage in allowed
|
|||
|
|
}
|
|||
|
|
stages = sorted(manifests, key=stage_sort_key)
|
|||
|
|
stage_flow: list[dict[str, Any]] = []
|
|||
|
|
findings: list[dict[str, Any]] = []
|
|||
|
|
reviews: list[dict[str, Any]] = []
|
|||
|
|
cards: list[dict[str, Any]] = []
|
|||
|
|
risks: list[dict[str, Any]] = []
|
|||
|
|
card_index = 0
|
|||
|
|
for stage in stages:
|
|||
|
|
manifest = manifests[stage]
|
|||
|
|
status = str(manifest.get("status") or "unknown")
|
|||
|
|
metrics = manifest.get("metrics") if isinstance(manifest.get("metrics"), dict) else {}
|
|||
|
|
parameters = manifest.get("parameters") if isinstance(manifest.get("parameters"), dict) else {}
|
|||
|
|
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {}
|
|||
|
|
metric_items = list(sorted(metrics.items()))[:12]
|
|||
|
|
metric_summary = ", ".join(f"{key}={_compact_value(value, 80)}" for key, value in metric_items[:5])
|
|||
|
|
key_result = metric_summary or f"登记产物 {len(artifacts)} 个"
|
|||
|
|
stage_flow.append(
|
|||
|
|
{
|
|||
|
|
"id": stage,
|
|||
|
|
"name": RNASEQ_STAGE_NAMES.get(stage, stage),
|
|||
|
|
"status": status,
|
|||
|
|
"key_result": key_result,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
findings.append(
|
|||
|
|
{
|
|||
|
|
"stage": stage,
|
|||
|
|
"title": RNASEQ_STAGE_NAMES.get(stage, stage),
|
|||
|
|
"description": f"manifest 状态为 {status};{key_result}。",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
limitations: list[str] = []
|
|||
|
|
if status == "partial_success":
|
|||
|
|
limitations.append("该阶段为 partial_success,结果可用但不完整。")
|
|||
|
|
elif status != "success":
|
|||
|
|
limitations.append("该阶段未成功,不纳入正式结论。")
|
|||
|
|
missing_artifacts: list[str] = []
|
|||
|
|
for name, item in sorted(artifacts.items()):
|
|||
|
|
if not isinstance(item, dict):
|
|||
|
|
continue
|
|||
|
|
path = resolve_artifact_path(final_dir, item)
|
|||
|
|
exists = bool(path and path.exists())
|
|||
|
|
card_index += 1
|
|||
|
|
cards.append(
|
|||
|
|
{
|
|||
|
|
"id": f"RNA-E{card_index:04d}",
|
|||
|
|
"stage": stage,
|
|||
|
|
"claim": f"产物 {name}",
|
|||
|
|
"evidence": f"path={path or ''}, exists={exists}",
|
|||
|
|
"source_type": "raw_file" if exists else "manifest",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
if not exists:
|
|||
|
|
missing_artifacts.append(str(name))
|
|||
|
|
for key, value in metric_items:
|
|||
|
|
card_index += 1
|
|||
|
|
cards.append(
|
|||
|
|
{
|
|||
|
|
"id": f"RNA-E{card_index:04d}",
|
|||
|
|
"stage": stage,
|
|||
|
|
"claim": f"指标 {key}",
|
|||
|
|
"evidence": _compact_value(value),
|
|||
|
|
"source_type": "manifest",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
safe_parameter_items = [
|
|||
|
|
(key, value)
|
|||
|
|
for key, value in sorted(parameters.items())
|
|||
|
|
if not any(secret in str(key).lower() for secret in ("api_key", "token", "secret", "password"))
|
|||
|
|
][:10]
|
|||
|
|
for key, value in safe_parameter_items:
|
|||
|
|
card_index += 1
|
|||
|
|
cards.append(
|
|||
|
|
{
|
|||
|
|
"id": f"RNA-E{card_index:04d}",
|
|||
|
|
"stage": stage,
|
|||
|
|
"claim": f"参数 {key}",
|
|||
|
|
"evidence": _compact_value(value),
|
|||
|
|
"source_type": "manifest",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
if missing_artifacts:
|
|||
|
|
shown = missing_artifacts[:12]
|
|||
|
|
remainder = len(missing_artifacts) - len(shown)
|
|||
|
|
suffix = f"(另有 {remainder} 个,详见 evidence JSON)" if remainder else ""
|
|||
|
|
message = f"manifest 登记但文件不存在的产物:{', '.join(shown)}{suffix}"
|
|||
|
|
limitations.append(message)
|
|||
|
|
risks.append({"stage": stage, "severity": "high", "message": message})
|
|||
|
|
if status not in SUCCESS_STATUSES:
|
|||
|
|
risks.append({"stage": stage, "severity": "high", "message": f"阶段状态为 {status},不得作为成功证据。"})
|
|||
|
|
reviews.append({"stage": stage, "stage_name": RNASEQ_STAGE_NAMES.get(stage, stage), "limitations": limitations})
|
|||
|
|
|
|||
|
|
successful = [stage for stage in stages if str(manifests[stage].get("status") or "") in SUCCESS_STATUSES]
|
|||
|
|
return {
|
|||
|
|
"schema": "rnaseq_manifest_evidence_v1",
|
|||
|
|
"metadata": {
|
|||
|
|
"prefix": prefix,
|
|||
|
|
"rnaseq_scope_id": selection.rnaseq_scope_id,
|
|||
|
|
"discovered_scope_ids": list(selection.discovered_scope_ids),
|
|||
|
|
"included_stages": stages,
|
|||
|
|
"successful_stages": successful,
|
|||
|
|
"interpretation_boundary": "仅陈述当前 prefix 与 RNA-seq scope 下 manifest/产物支持的事实;富集、相关性和候选排序不得写成调控因果。",
|
|||
|
|
},
|
|||
|
|
"stage_flow": {"visualization": " -> ".join(stages), "stages": stage_flow},
|
|||
|
|
"key_findings": findings,
|
|||
|
|
"stage_reviews": reviews,
|
|||
|
|
"evidence_cards": cards,
|
|||
|
|
"risk_flags": risks,
|
|||
|
|
"major_claims": [],
|
|||
|
|
"biological_story": (
|
|||
|
|
f"当前报告汇总了 {len(successful)} 个成功或部分成功的 RNA-seq 阶段。"
|
|||
|
|
"跨阶段生物学解释必须以各阶段数值、图表和风险标记为边界。"
|
|||
|
|
),
|
|||
|
|
"next_steps": ["补齐缺失或失败阶段后重新生成报告,以刷新证据快照。"],
|
|||
|
|
}
|