Files
PlantAI-dev/gfa_core/rnaseq_evidence.py

314 lines
12 KiB
Python
Raw Permalink 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.
"""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": ["补齐缺失或失败阶段后重新生成报告,以刷新证据快照。"],
}