1305 lines
54 KiB
Python
1305 lines
54 KiB
Python
"""Evidence-bound RNA-seq result report generation.
|
||
|
||
This module deliberately keeps the formal report independent from the live
|
||
frontend interpretation cache. It reuses the same pure evidence builder, but
|
||
materializes a deterministic Markdown report and provenance artifacts under a
|
||
job's ``final/`` directory.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import ast
|
||
import csv
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Iterable
|
||
|
||
from gfa_core.rnaseq_evidence import (
|
||
RNASEQ_STAGE_NAMES,
|
||
build_rnaseq_evidence_snapshot,
|
||
resolve_artifact_path,
|
||
manifest_identity_value,
|
||
select_rnaseq_manifests,
|
||
)
|
||
|
||
|
||
REPORT_STAGE = "rnaseq_report"
|
||
REPORT_RENDERER_VERSION = "4"
|
||
SUCCESS_STATUSES = {"success", "partial_success"}
|
||
DEFAULT_EXPECTED_STAGES = [
|
||
"rnaseq_qc",
|
||
"rnaseq_align",
|
||
"rnaseq_quant",
|
||
"rnaseq_sample_qc",
|
||
"rnaseq_de",
|
||
]
|
||
PREFERRED_STAGE_ORDER = [
|
||
"rnaseq_qc",
|
||
"rnaseq_align",
|
||
"rnaseq_quant",
|
||
"rnaseq_sample_qc",
|
||
"rnaseq_de",
|
||
"annotation",
|
||
"annotation_go",
|
||
"annotation_kegg",
|
||
"rnaseq_go_enrich",
|
||
"rnaseq_kegg_enrich",
|
||
"rnaseq_enrich",
|
||
"rnaseq_fgsea",
|
||
"rnaseq_wgcna",
|
||
"rnaseq_candidate_family",
|
||
"rnaseq_family_score",
|
||
]
|
||
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".svg", ".webp"}
|
||
STAGE_NAMES = RNASEQ_STAGE_NAMES
|
||
|
||
|
||
class NoSuccessfulRNASeqEvidence(RuntimeError):
|
||
"""Raised when a report would otherwise be empty."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RNASeqReportResult:
|
||
report_path: Path
|
||
evidence_path: Path
|
||
model_path: Path
|
||
manifest_path: Path
|
||
coverage_status: str
|
||
included_stages: tuple[str, ...]
|
||
rnaseq_scope_id: str
|
||
reused: bool = False
|
||
|
||
|
||
def _now() -> str:
|
||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||
|
||
|
||
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 _sha256(path: Path) -> str:
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def _write_text_atomic(path: Path, text: str) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||
temporary.write_text(text, encoding="utf-8")
|
||
temporary.replace(path)
|
||
|
||
|
||
def _identity(manifest: dict[str, Any], key: str) -> str:
|
||
return manifest_identity_value(manifest, key)
|
||
|
||
|
||
def is_rnaseq_report_source(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 (PREFERRED_STAGE_ORDER.index(stage), stage)
|
||
except ValueError:
|
||
return (len(PREFERRED_STAGE_ORDER), stage)
|
||
|
||
|
||
def _normalize_expected_stages(stages: Iterable[str] | None) -> list[str]:
|
||
raw = list(stages) if stages is not None else list(DEFAULT_EXPECTED_STAGES)
|
||
result: list[str] = []
|
||
for stage in raw:
|
||
value = str(stage or "").strip()
|
||
if value and is_rnaseq_report_source(value) and value not in result:
|
||
result.append(value)
|
||
return result
|
||
|
||
|
||
def _source_selection(
|
||
final_dir: Path,
|
||
prefix: str,
|
||
rnaseq_scope_id: str,
|
||
) -> tuple[str, list[str], dict[str, dict[str, Any]]]:
|
||
selection = select_rnaseq_manifests(
|
||
final_dir,
|
||
prefix,
|
||
rnaseq_scope_id=rnaseq_scope_id,
|
||
)
|
||
return selection.rnaseq_scope_id, list(selection.discovered_scope_ids), selection.manifests
|
||
|
||
|
||
def _source_records(manifests: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||
records: list[dict[str, Any]] = []
|
||
for stage in sorted(manifests, key=_stage_sort_key):
|
||
manifest = manifests[stage]
|
||
path = Path(str(manifest.get("_manifest_path") or ""))
|
||
records.append(
|
||
{
|
||
"stage": stage,
|
||
"status": str(manifest.get("status") or "unknown"),
|
||
"rnaseq_scope_id": _identity(manifest, "rnaseq_scope_id"),
|
||
"path": str(path),
|
||
"sha256": _sha256(path) if path.is_file() else "",
|
||
"mtime": datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds")
|
||
if path.is_file()
|
||
else "",
|
||
}
|
||
)
|
||
return records
|
||
|
||
|
||
def _fingerprint(
|
||
records: list[dict[str, Any]], expected_stages: list[str], use_model: bool
|
||
) -> tuple[str, str]:
|
||
source_payload = json.dumps(records, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||
source_fingerprint = hashlib.sha256(source_payload).hexdigest()
|
||
request_payload = json.dumps(
|
||
{
|
||
"source_fingerprint": source_fingerprint,
|
||
"expected_stages": expected_stages,
|
||
"use_model": use_model,
|
||
"report_renderer_version": REPORT_RENDERER_VERSION,
|
||
},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
).encode("utf-8")
|
||
return source_fingerprint, hashlib.sha256(request_payload).hexdigest()
|
||
|
||
|
||
def _artifact_path(workspace: Path, manifest: dict[str, Any], item: dict[str, Any]) -> Path | None:
|
||
return resolve_artifact_path(workspace / "final", item, require_exists=True)
|
||
|
||
|
||
def _enrichment_figure_identity(stage: str, path: Path) -> tuple[str, str, str] | None:
|
||
name = path.name
|
||
lower = name.lower()
|
||
comparison = ""
|
||
category = ""
|
||
pathway_id = ""
|
||
if stage == "rnaseq_go_enrich" or (stage == "rnaseq_enrich" and ".go_enrich" in lower):
|
||
match = re.search(r"rnaseq_(?:go_enrich|enrich)\.(.+?)\.go_enrich", name, flags=re.IGNORECASE)
|
||
comparison = match.group(1) if match else ""
|
||
if ".go_enrich.ontology_barplot." in lower:
|
||
category = "go_ontology"
|
||
elif ".go_enrich.barplot." in lower:
|
||
category = "go_bar"
|
||
elif ".go_enrich.dotplot." in lower:
|
||
category = "go_dot"
|
||
elif re.search(r"\.go_enrich\.svg$", lower):
|
||
category = "go_bar"
|
||
elif stage == "rnaseq_kegg_enrich" or (stage == "rnaseq_enrich" and "kegg" in lower):
|
||
match = re.search(r"rnaseq_(?:kegg_enrich|enrich)\.(.+?)(?:\.kegg|_kegg)", name, flags=re.IGNORECASE)
|
||
comparison = match.group(1) if match else ""
|
||
pathway_match = re.search(r"\.kegg_pathway_map\.(ko\d+)\.", lower)
|
||
if pathway_match:
|
||
category = "kegg_pathway"
|
||
pathway_id = pathway_match.group(1)
|
||
elif re.search(r"(?:\.|_)kegg_enrich\.(?:png|jpe?g|webp|svg|pdf)$", lower):
|
||
category = "kegg_bar"
|
||
elif re.search(r"_kegg_dotplot\.(?:png|jpe?g|webp|svg|pdf)$", lower):
|
||
category = "kegg_dot"
|
||
elif stage == "rnaseq_fgsea":
|
||
match = re.search(r"rnaseq_fgsea\.(.+?)\.fgsea", name, flags=re.IGNORECASE)
|
||
comparison = match.group(1) if match else ""
|
||
if ".fgsea_kegg_dotplot." in lower:
|
||
category = "fgsea_dot"
|
||
elif ".fgsea_kegg_top_enrichment." in lower:
|
||
category = "fgsea_curve"
|
||
if not category:
|
||
return None
|
||
identity = f"{category}:{comparison}:{pathway_id}" if category == "kegg_pathway" else category
|
||
return identity, comparison, pathway_id
|
||
|
||
|
||
def _artifact_comparison(stage: str, path: Path, category: str = "") -> str:
|
||
patterns = {
|
||
"rnaseq_go_enrich": r"rnaseq_go_enrich\.(.+?)\.go_enrich",
|
||
"rnaseq_kegg_enrich": r"rnaseq_kegg_enrich\.(.+?)(?:\.kegg|_kegg)",
|
||
"rnaseq_fgsea": r"rnaseq_fgsea\.(.+?)\.fgsea",
|
||
}
|
||
if stage == "rnaseq_enrich":
|
||
pattern = (
|
||
r"rnaseq_enrich\.(.+?)\.go_enrich"
|
||
if category.startswith("go_")
|
||
else r"rnaseq_enrich\.(.+?)(?:\.kegg|_kegg)"
|
||
)
|
||
else:
|
||
pattern = patterns.get(stage)
|
||
match = re.search(pattern, path.name, flags=re.IGNORECASE) if pattern else None
|
||
return match.group(1) if match else ""
|
||
|
||
|
||
def _table_rows(path: Path) -> list[dict[str, str]]:
|
||
try:
|
||
with path.open("r", encoding="utf-8", errors="replace", newline="") as handle:
|
||
delimiter = "," if path.suffix.lower() == ".csv" else "\t"
|
||
return [
|
||
{str(key): str(value or "") for key, value in row.items() if key is not None}
|
||
for row in csv.DictReader(handle, delimiter=delimiter)
|
||
]
|
||
except OSError:
|
||
return []
|
||
|
||
|
||
def _supporting_tables(
|
||
workspace: Path,
|
||
manifest: dict[str, Any],
|
||
comparison: str,
|
||
category: str = "",
|
||
) -> list[tuple[Path, list[dict[str, str]]]]:
|
||
tables: list[tuple[Path, list[dict[str, str]]]] = []
|
||
for item in (manifest.get("artifacts") or {}).values():
|
||
if not isinstance(item, dict):
|
||
continue
|
||
path = _artifact_path(workspace, manifest, item)
|
||
if (
|
||
not path
|
||
or not path.is_file()
|
||
or path.suffix.lower() not in {".txt", ".tsv", ".csv"}
|
||
or (
|
||
comparison
|
||
and _artifact_comparison(str(manifest.get("stage") or ""), path, category) != comparison
|
||
)
|
||
):
|
||
continue
|
||
lower_name = path.name.lower()
|
||
if category.startswith("go_") and not lower_name.endswith(".go_enrich.txt"):
|
||
continue
|
||
if category in {"kegg_bar", "kegg_dot"} and not lower_name.endswith(".kegg_enrich.txt"):
|
||
continue
|
||
if category == "kegg_pathway" and not (
|
||
lower_name.endswith(".kegg_enrich.txt")
|
||
or ".kegg_pathway_gene_hits." in lower_name
|
||
or ".kegg_pathway_mapper." in lower_name
|
||
or ".kegg_pathway_map.ko_log2fc." in lower_name
|
||
):
|
||
continue
|
||
if category.startswith("fgsea_") and not lower_name.endswith(".fgsea_kegg.tsv"):
|
||
continue
|
||
rows = _table_rows(path)
|
||
if rows:
|
||
tables.append((path, rows))
|
||
return tables
|
||
|
||
|
||
def _best_adjusted_p(tables: list[tuple[Path, list[dict[str, str]]]]) -> float:
|
||
values: list[float] = []
|
||
for _, rows in tables:
|
||
for row in rows:
|
||
raw = row.get("p.adjust") or row.get("padj") or row.get("FDR") or ""
|
||
try:
|
||
value = float(raw)
|
||
if math.isfinite(value) and 0.0 <= value <= 1.0:
|
||
values.append(value)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return min(values, default=float("inf"))
|
||
|
||
|
||
def _fgsea_selection_score(tables: list[tuple[Path, list[dict[str, str]]]]) -> tuple[float, float]:
|
||
scores: list[tuple[float, float]] = []
|
||
for _, rows in tables:
|
||
for row in rows:
|
||
try:
|
||
padj = float(row.get("padj") or row.get("p.adjust") or "")
|
||
nes = float(row.get("NES") or "")
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if math.isfinite(padj) and 0.0 <= padj <= 1.0 and math.isfinite(nes):
|
||
scores.append((padj, -abs(nes)))
|
||
return min(scores, default=(float("inf"), float("inf")))
|
||
|
||
|
||
def _support_labels(value: str) -> list[str]:
|
||
"""Parse both the real semicolon serialization and legacy list encodings."""
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return []
|
||
if raw[:1] in {"[", "("}:
|
||
try:
|
||
parsed = json.loads(raw) if raw.startswith("[") else ast.literal_eval(raw)
|
||
except (ValueError, SyntaxError, json.JSONDecodeError):
|
||
try:
|
||
parsed = ast.literal_eval(raw)
|
||
except (ValueError, SyntaxError):
|
||
parsed = None
|
||
if isinstance(parsed, (list, tuple)):
|
||
return list(dict.fromkeys(str(item).strip() for item in parsed if str(item).strip()))
|
||
return list(dict.fromkeys(item.strip() for item in raw.split(";") if item.strip()))
|
||
|
||
|
||
def _support_identity(label: str) -> tuple[str, str]:
|
||
raw = str(label or "").strip()
|
||
go_match = re.match(r"(?i)^GO:(GO:\d+)(?::.*)?$", raw)
|
||
if go_match:
|
||
return "go", go_match.group(1)
|
||
match = re.match(r"(?i)^(KEGG|fgsea):([^:]+)(?::.*)?$", raw)
|
||
if match:
|
||
return match.group(1).lower(), match.group(2).lower()
|
||
return "", ""
|
||
|
||
|
||
def _candidate_rows(workspace: Path, manifests: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||
manifest = manifests.get("rnaseq_family_score") or {}
|
||
item = (manifest.get("artifacts") or {}).get("analysis_ready_family")
|
||
if not isinstance(item, dict):
|
||
return []
|
||
path = _artifact_path(workspace, manifest, item)
|
||
if not path or not path.is_file():
|
||
return []
|
||
rows: list[dict[str, Any]] = []
|
||
for index, row in enumerate(_table_rows(path), 1):
|
||
family = str(row.get("family_id") or row.get("family_name") or "").strip()
|
||
comparison = str(row.get("best_comparison") or "").strip()
|
||
if not family or not comparison:
|
||
continue
|
||
try:
|
||
rank = int(str(row.get("rank") or index))
|
||
except ValueError:
|
||
rank = index
|
||
rows.append(
|
||
{
|
||
"family": family,
|
||
"family_name": str(row.get("family_name") or "").strip(),
|
||
"rank": rank,
|
||
"comparison": comparison,
|
||
"support_labels": _support_labels(str(row.get("best_support_labels") or "")),
|
||
"source_table": str(path),
|
||
}
|
||
)
|
||
return sorted(rows, key=lambda row: (row["rank"], row["family"]))
|
||
|
||
|
||
def _pathway_adjusted_p(
|
||
tables: list[tuple[Path, list[dict[str, str]]]], pathway_id: str
|
||
) -> float:
|
||
row = _first_matching_row(tables, pathway_id=pathway_id)
|
||
try:
|
||
value = float(row.get("p.adjust") or row.get("padj") or row.get("FDR") or "")
|
||
except (TypeError, ValueError):
|
||
return float("inf")
|
||
return value if math.isfinite(value) and 0.0 <= value <= 1.0 else float("inf")
|
||
|
||
|
||
def _figures(
|
||
workspace: Path, manifests: dict[str, dict[str, Any]]
|
||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str]:
|
||
candidates: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
for stage in sorted(manifests, key=_stage_sort_key):
|
||
artifacts = manifests[stage].get("artifacts") or {}
|
||
if not isinstance(artifacts, dict):
|
||
continue
|
||
for key, item in sorted(artifacts.items()):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
path = _artifact_path(workspace, manifests[stage], item)
|
||
if not path or not path.is_file():
|
||
continue
|
||
classified = _enrichment_figure_identity(stage, path)
|
||
if path.suffix.lower() not in IMAGE_SUFFIXES and not (path.suffix.lower() == ".pdf" and classified):
|
||
continue
|
||
identity = str(path.resolve())
|
||
if identity in seen:
|
||
continue
|
||
seen.add(identity)
|
||
row = {"stage": stage, "artifact": str(key), "path": str(path)}
|
||
if classified:
|
||
identity, comparison, pathway_id = classified
|
||
row.update(
|
||
{
|
||
"selection_identity": identity,
|
||
"category": identity.split(":", 1)[0],
|
||
"comparison": comparison,
|
||
"pathway_id": pathway_id,
|
||
}
|
||
)
|
||
candidates.append(row)
|
||
|
||
selected: list[dict[str, Any]] = []
|
||
enrichment_candidates: list[dict[str, Any]] = []
|
||
for row in candidates:
|
||
if not row.get("selection_identity"):
|
||
row.update(
|
||
candidate_families=[], support_labels=[], selection_reason="all_stage_figures",
|
||
source_tables=[],
|
||
)
|
||
selected.append(row)
|
||
else:
|
||
enrichment_candidates.append(row)
|
||
|
||
formal_candidates = _candidate_rows(workspace, manifests)
|
||
format_rank = {".png": 0, ".jpg": 1, ".jpeg": 1, ".webp": 2, ".svg": 3, ".pdf": 4}
|
||
table_cache: dict[tuple[str, str, str], list[tuple[Path, list[dict[str, str]]]]] = {}
|
||
|
||
def tables_for(row: dict[str, Any]) -> list[tuple[Path, list[dict[str, str]]]]:
|
||
cache_key = (row["stage"], row.get("comparison", ""), row.get("category", ""))
|
||
if cache_key not in table_cache:
|
||
table_cache[cache_key] = _supporting_tables(
|
||
workspace, manifests[row["stage"]], cache_key[1], cache_key[2]
|
||
)
|
||
return table_cache[cache_key]
|
||
|
||
def table_contains(category: str, identifier: str, row: dict[str, Any]) -> bool:
|
||
target_identifier = identifier.lower()
|
||
if category == "kegg_pathway":
|
||
return True
|
||
tables = tables_for(row)
|
||
if category == "fgsea_curve":
|
||
top = _best_fgsea_row(tables)
|
||
top_identifier = str(top.get("pathway") or top.get("PathwayID") or top.get("ID") or "")
|
||
return bool(top_identifier) and top_identifier.lower() == target_identifier
|
||
for _, rows in tables:
|
||
for table_row in rows:
|
||
value = str(
|
||
table_row.get("ID")
|
||
or table_row.get("PathwayID")
|
||
or table_row.get("pathway")
|
||
or ""
|
||
)
|
||
if value.lower() == target_identifier:
|
||
return True
|
||
return False
|
||
|
||
def row_support(row: dict[str, Any]) -> tuple[list[str], list[str], int, list[str]]:
|
||
category = row.get("category", "")
|
||
wanted_kind = (
|
||
"go" if category.startswith("go_") else "fgsea" if category.startswith("fgsea_") else "kegg"
|
||
)
|
||
pathway_id = str(row.get("pathway_id") or "").lower()
|
||
matched: list[tuple[int, str, str, str]] = []
|
||
for candidate in formal_candidates:
|
||
if candidate["comparison"] != row.get("comparison"):
|
||
continue
|
||
for label in candidate["support_labels"]:
|
||
kind, identifier = _support_identity(label)
|
||
if (
|
||
kind != wanted_kind
|
||
or (category == "kegg_pathway" and identifier != pathway_id)
|
||
or not table_contains(category, identifier, row)
|
||
):
|
||
continue
|
||
matched.append(
|
||
(candidate["rank"], candidate["family"], label, candidate["source_table"])
|
||
)
|
||
matched.sort(key=lambda value: (value[0], value[1], value[2]))
|
||
return (
|
||
list(dict.fromkeys(value[1] for value in matched)),
|
||
list(dict.fromkeys(value[2] for value in matched)),
|
||
matched[0][0] if matched else 10**9,
|
||
list(dict.fromkeys(value[3] for value in matched)),
|
||
)
|
||
|
||
def evidence_score(row: dict[str, Any]) -> tuple[Any, ...]:
|
||
tables = tables_for(row)
|
||
if row.get("category") == "fgsea_curve":
|
||
evidence: tuple[Any, ...] = _fgsea_selection_score(tables)
|
||
elif row.get("category") == "kegg_pathway":
|
||
evidence = (_pathway_adjusted_p(tables, row.get("pathway_id", "")),)
|
||
else:
|
||
evidence = (_best_adjusted_p(tables),)
|
||
return (*evidence, format_rank.get(Path(row["path"]).suffix.lower(), 9), Path(row["path"]).name)
|
||
|
||
for row in enrichment_candidates:
|
||
families, labels, candidate_rank, candidate_tables = row_support(row)
|
||
row["candidate_families"] = families
|
||
row["support_labels"] = labels
|
||
row["candidate_rank"] = candidate_rank
|
||
row["source_tables"] = candidate_tables + [str(path) for path, _ in tables_for(row)]
|
||
|
||
selection_mode = (
|
||
"candidate_guided"
|
||
if any(row.get("support_labels") for row in enrichment_candidates)
|
||
else "representative_fallback"
|
||
)
|
||
if selection_mode == "representative_fallback":
|
||
for row in enrichment_candidates:
|
||
row["candidate_families"] = []
|
||
row["support_labels"] = []
|
||
row["candidate_rank"] = 10**9
|
||
row["source_tables"] = [str(path) for path, _ in tables_for(row)]
|
||
|
||
groups: dict[str, list[dict[str, Any]]] = {}
|
||
for row in enrichment_candidates:
|
||
category = row.get("category", "")
|
||
if selection_mode == "candidate_guided" and not row.get("support_labels"):
|
||
continue
|
||
key = (
|
||
f"{category}:{row.get('comparison', '')}:{row.get('pathway_id', '')}"
|
||
if selection_mode == "candidate_guided" and category == "kegg_pathway"
|
||
else f"{category}:{row.get('pathway_id', '')}"
|
||
if category == "kegg_pathway"
|
||
else category
|
||
)
|
||
groups.setdefault(key, []).append(row)
|
||
|
||
for rows in groups.values():
|
||
rows.sort(
|
||
key=lambda row: (
|
||
row.get("candidate_rank", 10**9) if selection_mode == "candidate_guided" else 0,
|
||
*evidence_score(row),
|
||
)
|
||
)
|
||
chosen = rows[0]
|
||
chosen["selection_reason"] = (
|
||
"candidate_family_support" if selection_mode == "candidate_guided" else "representative_fallback"
|
||
)
|
||
selected.append(chosen)
|
||
|
||
selected_paths = {row["path"] for row in selected}
|
||
omitted: list[dict[str, Any]] = []
|
||
for row in enrichment_candidates:
|
||
if row["path"] in selected_paths:
|
||
continue
|
||
row.setdefault("candidate_families", [])
|
||
row.setdefault("support_labels", [])
|
||
row.setdefault("source_tables", [])
|
||
row["selection_reason"] = (
|
||
"not_candidate_supported"
|
||
if selection_mode == "candidate_guided" and not row["support_labels"]
|
||
else "lower_ranked_representative"
|
||
)
|
||
omitted.append(row)
|
||
stage_rank = {stage: index for index, stage in enumerate(PREFERRED_STAGE_ORDER)}
|
||
ordered = sorted(
|
||
selected,
|
||
key=lambda row: (stage_rank.get(row["stage"], len(stage_rank)), Path(row["path"]).name),
|
||
)
|
||
omitted.sort(key=lambda row: (stage_rank.get(row["stage"], len(stage_rank)), Path(row["path"]).name))
|
||
return ordered, omitted, selection_mode
|
||
|
||
|
||
def _first_matching_row(
|
||
tables: list[tuple[Path, list[dict[str, str]]]],
|
||
*,
|
||
pathway_id: str = "",
|
||
) -> dict[str, str]:
|
||
rows = [row for _, table_rows in tables for row in table_rows]
|
||
if pathway_id:
|
||
for row in rows:
|
||
identifier = row.get("ID") or row.get("PathwayID") or row.get("pathway") or ""
|
||
if identifier.lower() == pathway_id.lower():
|
||
return row
|
||
return {}
|
||
return rows[0] if rows else {}
|
||
|
||
|
||
def _best_fgsea_row(tables: list[tuple[Path, list[dict[str, str]]]]) -> dict[str, str]:
|
||
ranked: list[tuple[float, float, int, dict[str, str]]] = []
|
||
for _, rows in tables:
|
||
for index, row in enumerate(rows):
|
||
try:
|
||
padj = float(row.get("padj") or row.get("p.adjust") or "")
|
||
nes = float(row.get("NES") or "")
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if math.isfinite(padj) and 0.0 <= padj <= 1.0 and math.isfinite(nes):
|
||
ranked.append((padj, -abs(nes), index, row))
|
||
return min(ranked, default=(float("inf"), float("inf"), 0, {}))[-1]
|
||
|
||
|
||
def _value(row: dict[str, str], *keys: str) -> str:
|
||
for key in keys:
|
||
value = str(row.get(key) or "").strip()
|
||
if value:
|
||
return value
|
||
return "未登记"
|
||
|
||
|
||
def _probability_value(row: dict[str, str], *keys: str) -> str:
|
||
raw = _value(row, *keys)
|
||
try:
|
||
value = float(raw)
|
||
except (TypeError, ValueError):
|
||
return "未登记"
|
||
return raw if math.isfinite(value) and 0.0 <= value <= 1.0 else "未登记"
|
||
|
||
|
||
def _field_items(row: dict[str, str], *keys: str) -> set[str]:
|
||
value = next((str(row.get(key) or "").strip() for key in keys if str(row.get(key) or "").strip()), "")
|
||
return {item for item in re.split(r"[/,;\s]+", value) if item}
|
||
|
||
|
||
def _ordered_field_items(row: dict[str, str], *keys: str) -> list[str]:
|
||
value = next((str(row.get(key) or "").strip() for key in keys if str(row.get(key) or "").strip()), "")
|
||
return list(dict.fromkeys(item for item in re.split(r"[/,;\s]+", value) if item))
|
||
|
||
|
||
def _item_summary(label: str, items: set[str]) -> str:
|
||
ordered = sorted(items)
|
||
preview = "/".join(ordered[:12]) if ordered else "未登记"
|
||
return f"{label}总数={len(ordered)},前12项={preview}"
|
||
|
||
|
||
def _source_table_links(report_path: Path, tables: list[tuple[Path, list[dict[str, str]]]], markers: tuple[str, ...]) -> str:
|
||
links = [
|
||
f"[{path.name}]({_relative_link(report_path, path)})"
|
||
for path, _ in tables
|
||
if any(marker in path.name.lower() for marker in markers)
|
||
]
|
||
return ";完整源表:" + "、".join(links) if links else ""
|
||
|
||
|
||
def _figure_interpretation(
|
||
figure: dict[str, str], workspace: Path, manifests: dict[str, dict[str, Any]], report_path: Path
|
||
) -> list[str]:
|
||
category = figure.get("category", "")
|
||
if not category:
|
||
return [f"该图来自 `{figure['stage']}` 阶段。具体趋势应与该阶段的数值表和 manifest 指标联合判断。"]
|
||
comparison = figure.get("comparison", "")
|
||
tables = _supporting_tables(workspace, manifests[figure["stage"]], comparison, category)
|
||
pathway_id = figure.get("pathway_id", "")
|
||
row = (
|
||
_best_fgsea_row(tables)
|
||
if category.startswith("fgsea_")
|
||
else _first_matching_row(tables, pathway_id=pathway_id)
|
||
)
|
||
if not row:
|
||
result = f"比较组 `{comparison or '未识别'}` 证据不足:缺少可匹配的数值表,无法可靠判断本次数据的具体趋势。"
|
||
elif category == "go_ontology":
|
||
ontology_rows: dict[str, dict[str, str]] = {}
|
||
for _, table_rows in tables:
|
||
for candidate in table_rows:
|
||
ontology_value = str(candidate.get("Ontology") or "").strip().lower()
|
||
ontology = {
|
||
"bp": "BP",
|
||
"biological process": "BP",
|
||
"cc": "CC",
|
||
"cellular component": "CC",
|
||
"mf": "MF",
|
||
"molecular function": "MF",
|
||
}.get(ontology_value, "")
|
||
if ontology in {"BP", "CC", "MF"} and ontology not in ontology_rows:
|
||
ontology_rows[ontology] = candidate
|
||
summaries = [
|
||
f"{ontology}={_value(ontology_rows[ontology], 'Description')}"
|
||
for ontology in ("BP", "CC", "MF")
|
||
if ontology in ontology_rows
|
||
]
|
||
result = (
|
||
f"比较组 `{comparison}` 的三类本体代表条目为"
|
||
f"{';'.join(summaries) if summaries else '未登记'}。"
|
||
)
|
||
elif category.startswith("go_"):
|
||
result = (
|
||
f"比较组 `{comparison}` 的代表条目为 {_value(row, 'ID')}({_value(row, 'Description')}),"
|
||
f"Ontology={_value(row, 'Ontology')},GeneRatio={_value(row, 'GeneRatio')},"
|
||
f"Count={_value(row, 'Count')},p.adjust={_probability_value(row, 'p.adjust')}。"
|
||
)
|
||
elif category in {"kegg_bar", "kegg_dot"}:
|
||
genes = _field_items(row, "geneID", "Genes")
|
||
kos = set(re.findall(r"K\d+", " ".join(str(row.get(key) or "") for key in ("KO", "KOs", "ko"))))
|
||
result = (
|
||
f"比较组 `{comparison}` 的代表通路为 {_value(row, 'ID')}({_value(row, 'Description')}),"
|
||
f"GeneRatio={_value(row, 'GeneRatio')},Count={_value(row, 'Count')},"
|
||
f"p.adjust={_probability_value(row, 'p.adjust', 'FDR')},"
|
||
f"{_item_summary('命中基因', genes)},{_item_summary('KO', kos)}"
|
||
f"{_source_table_links(report_path, tables, ('.kegg_enrich.txt',))}。"
|
||
)
|
||
elif category == "kegg_pathway":
|
||
pathway_genes: set[str] = set()
|
||
pathway_kos: set[str] = set()
|
||
for _, table_rows in tables:
|
||
for candidate in table_rows:
|
||
identifier = candidate.get("PathwayID") or candidate.get("ID") or candidate.get("pathway") or ""
|
||
if identifier.lower() == pathway_id.lower():
|
||
pathway_genes.update(
|
||
gene
|
||
for gene in re.split(r"[/,;\s]+", candidate.get("Genes") or candidate.get("geneID") or "")
|
||
if gene
|
||
)
|
||
pathway_kos.update(re.findall(r"K\d+", candidate.get("KO") or ""))
|
||
if not pathway_genes:
|
||
pathway_genes.update(
|
||
gene for gene in re.split(r"[/,;\s]+", _value(row, "Genes", "geneID")) if gene != "未登记"
|
||
)
|
||
ko_changes: list[float] = []
|
||
if pathway_kos:
|
||
for table_path, table_rows in tables:
|
||
if "ko_log2fc" not in table_path.name.lower():
|
||
continue
|
||
for candidate in table_rows:
|
||
ko = candidate.get("KO") or candidate.get("ko") or ""
|
||
if ko not in pathway_kos:
|
||
continue
|
||
try:
|
||
ko_changes.append(float(candidate.get("log2FoldChange") or candidate.get("log2FC") or ""))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
direction = ""
|
||
if ko_changes:
|
||
direction = f",KO变化中上调{sum(value > 0 for value in ko_changes)}个、下调{sum(value < 0 for value in ko_changes)}个"
|
||
elif not pathway_kos:
|
||
direction = ",KO对应关系证据不足,未汇总KO表达方向"
|
||
result = (
|
||
f"比较组 `{comparison}` 的通路 {pathway_id}({_value(row, 'PathwayName', 'Description', 'pathway_name')}),"
|
||
f"p.adjust={_probability_value(row, 'p.adjust', 'padj', 'FDR')},"
|
||
f"{_item_summary('命中基因', pathway_genes)},{_item_summary('KO', pathway_kos)}"
|
||
f"{direction}"
|
||
f"{_source_table_links(report_path, tables, ('kegg_pathway_gene_hits', 'ko_log2fc'))}。"
|
||
)
|
||
else:
|
||
leading_edge = _ordered_field_items(row, "leadingEdge")
|
||
leading_edge_preview = "/".join(leading_edge[:12]) if leading_edge else "未登记"
|
||
result = (
|
||
f"比较组 `{comparison}` 的通路 {_value(row, 'pathway', 'ID')}({_value(row, 'pathway_name', 'Description')}),"
|
||
f"NES={_value(row, 'NES')},padj={_probability_value(row, 'padj', 'p.adjust')},"
|
||
f"leadingEdge总数={len(leading_edge)},原顺序前12项={leading_edge_preview}"
|
||
f"{_source_table_links(report_path, tables, ('.fgsea_kegg.tsv',))}。"
|
||
)
|
||
|
||
meanings = {
|
||
"go_bar": "GO富集条形图概括显著功能条目及其富集强度。",
|
||
"go_dot": "GO富集气泡图同时呈现功能条目、富集比例、基因数和显著性。",
|
||
"go_ontology": "GO本体分面图并列展示BP、CC和MF三类功能条目。",
|
||
"kegg_bar": "KEGG富集条形图概括显著通路及其富集比例。",
|
||
"kegg_dot": "KEGG富集气泡图同时呈现通路富集比例、命中基因数和显著性。",
|
||
"kegg_pathway": "KEGG Pathway图将该比较组的KO表达变化映射到具体通路节点。",
|
||
"fgsea_dot": "FGSEA气泡图汇总基于全基因排序的KEGG通路NES及显著性。",
|
||
"fgsea_curve": "FGSEA富集曲线展示代表通路成员在全基因排序中的累积富集位置。",
|
||
}
|
||
readings = {
|
||
"go_bar": "结合条目名称、柱长、Count及校正后P值阅读。",
|
||
"go_dot": "结合横轴富集比例、点大小所示Count及颜色所示显著性阅读。",
|
||
"go_ontology": "分别比较BP、CC和MF分面中的条目、比例和显著性。",
|
||
"kegg_bar": "结合通路名称、柱长、GeneRatio、Count及校正后P值阅读。",
|
||
"kegg_dot": "结合GeneRatio、点大小所示Count及颜色所示显著性阅读。",
|
||
"kegg_pathway": "按图例读取节点颜色,并结合命中基因、KO及log2FoldChange表判断变化方向。",
|
||
"fgsea_dot": "NES正负表示富集方向,点大小和颜色需结合size与padj阅读。",
|
||
"fgsea_curve": "结合运行富集分数、成员命中位置和leadingEdge基因阅读。",
|
||
}
|
||
boundary = (
|
||
"富集结果表示统计关联,不足以单独证明功能或通路被激活、抑制或具有因果调控关系。"
|
||
if not category.startswith("fgsea_")
|
||
else "FGSEA基于全基因排序,不能与仅使用显著差异基因的ORA结果混为一谈,也不证明因果关系。"
|
||
)
|
||
return [
|
||
f"图形含义:{meanings[category]}",
|
||
f"阅读方法:{readings[category]}",
|
||
f"本次结果:{result}",
|
||
f"解释边界:{boundary}",
|
||
]
|
||
|
||
|
||
def _relative_link(report_path: Path, target: Path) -> str:
|
||
try:
|
||
return os.path.relpath(target, report_path.parent).replace(os.sep, "/")
|
||
except ValueError:
|
||
return str(target)
|
||
|
||
|
||
def _render_deterministic_markdown(
|
||
*,
|
||
prefix: str,
|
||
scope_id: str,
|
||
evidence: dict[str, Any],
|
||
manifests: dict[str, dict[str, Any]],
|
||
expected_stages: list[str],
|
||
stage_statuses: dict[str, str],
|
||
coverage_status: str,
|
||
report_path: Path,
|
||
workspace: Path,
|
||
generated_at: str,
|
||
figures: list[dict[str, Any]],
|
||
model_summary: str = "",
|
||
) -> str:
|
||
metadata = evidence.get("metadata") or {}
|
||
included = list(metadata.get("included_stages") or [])
|
||
flow_rows = {row.get("id"): row for row in (evidence.get("stage_flow") or {}).get("stages", [])}
|
||
findings = {row.get("stage"): row for row in evidence.get("key_findings") or []}
|
||
reviews = {row.get("stage"): row for row in evidence.get("stage_reviews") or []}
|
||
evidence_cards = evidence.get("evidence_cards") or []
|
||
risks = evidence.get("risk_flags") or []
|
||
missing = [stage for stage in expected_stages if stage_statuses.get(stage) not in SUCCESS_STATUSES]
|
||
stage_status_text = "; ".join(
|
||
f"{stage}={stage_statuses.get(stage, 'missing')}" for stage in expected_stages
|
||
)
|
||
|
||
lines = [
|
||
f"# {prefix} RNA-seq 结果报告",
|
||
"",
|
||
"> 报告生成原则:结果判断只来自脚本、Manifest 和结果表;API 仅参与已有证据语句的排序和组织,不参与结果分析或推断。",
|
||
"",
|
||
"## 0. 报告摘要",
|
||
"",
|
||
f"- 生成时间:{generated_at}",
|
||
f"- RNA-seq scope:{scope_id or 'legacy/unscoped'}",
|
||
f"- 证据覆盖:{coverage_status}(成功/可用阶段 {len(included)} 个)",
|
||
f"- 已纳入阶段:{', '.join(included)}",
|
||
f"- 当前缺失或未成功阶段:{', '.join(missing) if missing else '无'}",
|
||
"",
|
||
str(evidence.get("biological_story") or "现有证据不足以形成跨阶段生物学叙事,请查看分阶段结果。"),
|
||
]
|
||
major_claims = evidence.get("major_claims") or []
|
||
if major_claims:
|
||
lines.extend(["", "### 主要证据结论", ""])
|
||
for claim in major_claims[:10]:
|
||
text = claim.get("claim") or claim.get("text") or claim.get("title") or str(claim)
|
||
lines.append(f"- {text}")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"### 模型辅助语言润色(不作为新增证据)",
|
||
"",
|
||
"> API 仅参与已有证据语句的排序和组织,不参与结果分析或推断;不能新增数字、基因、通路、显著性、变化方向、机制或因果结论。",
|
||
"",
|
||
model_summary or "本次未采用模型组织结果;正式结论以脚本生成的确定性正文和可追溯结果数据为准。",
|
||
]
|
||
)
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## 1. 分析范围与证据边界",
|
||
"",
|
||
f"- 计划覆盖阶段:{', '.join(expected_stages)}",
|
||
f"- 阶段状态:{stage_status_text}",
|
||
f"- 解读边界:{metadata.get('interpretation_boundary') or '仅陈述可由当前产物支持的结果,不推断调控因果。'}",
|
||
"- API 边界:只允许排序和分组脚本批准的证据语句;任何自由文本、未知证据编号或无依据内容均被拒绝并回退。",
|
||
"- 本报告不读取前端会话缓存,也不调用“深度解读”接口;二者只共享纯证据构建逻辑。",
|
||
"",
|
||
"## 2. 数据质量与流程概览",
|
||
"",
|
||
(evidence.get("stage_flow") or {}).get("visualization", " -> ".join(included)),
|
||
]
|
||
)
|
||
for stage in included:
|
||
flow = flow_rows.get(stage) or {}
|
||
finding = findings.get(stage) or {}
|
||
review = reviews.get(stage) or {}
|
||
stage_cards = [item for item in evidence_cards if item.get("stage") == stage]
|
||
metric_cards = [item for item in stage_cards if str(item.get("claim") or "").startswith("指标 ")]
|
||
artifact_cards = [item for item in stage_cards if str(item.get("claim") or "").startswith("产物 ")]
|
||
displayed_cards = (metric_cards[:8] + artifact_cards[:4])[:12]
|
||
lines.extend(["", f"## 3.{included.index(stage) + 1} {STAGE_NAMES.get(stage, stage)}", ""])
|
||
lines.append(f"- 状态:{flow.get('status') or manifests.get(stage, {}).get('status', 'unknown')}")
|
||
if flow.get("key_result"):
|
||
lines.append(f"- 关键结果:{flow['key_result']}")
|
||
description = finding.get("description") or finding.get("summary") or ""
|
||
if description:
|
||
lines.extend(["", str(description)])
|
||
for card in displayed_cards:
|
||
lines.append(
|
||
f"- 证据 {card.get('id', '')}:{card.get('claim', '')};{card.get('evidence', '')}"
|
||
)
|
||
limitations = review.get("limitations") or []
|
||
if limitations:
|
||
lines.append(f"- 阶段局限:{';'.join(map(str, limitations))}")
|
||
|
||
lines.extend(["", "## 4. 图表与产物说明", ""])
|
||
selection_mode = (evidence.get("formal_report") or {}).get("selection_mode")
|
||
if selection_mode == "representative_fallback":
|
||
lines.extend(
|
||
[
|
||
"> 候选家族驱动筛选不可用;当前富集图按图形类型与统计证据选取代表,不声称与候选家族相关。",
|
||
"",
|
||
]
|
||
)
|
||
elif selection_mode == "candidate_guided":
|
||
lines.extend([
|
||
"> GO/KEGG/FGSEA 图按正式候选家族榜的比较组与功能支持标签筛选。",
|
||
"",
|
||
])
|
||
if figures:
|
||
for index, figure in enumerate(figures, 1):
|
||
path = Path(figure["path"])
|
||
media_line = (
|
||
f"[{figure['artifact']}]({_relative_link(report_path, path)})"
|
||
if path.suffix.lower() == ".pdf"
|
||
else f"![{figure['artifact']}]({_relative_link(report_path, path)})"
|
||
)
|
||
lines.extend(
|
||
[
|
||
f"### 图 {index}. {STAGE_NAMES.get(figure['stage'], figure['stage'])} — {figure['artifact']}",
|
||
"",
|
||
media_line,
|
||
"",
|
||
]
|
||
)
|
||
if figure.get("candidate_families"):
|
||
lines.append(f"- 候选家族关联:{', '.join(figure['candidate_families'])}")
|
||
if figure.get("support_labels"):
|
||
lines.append(f"- 正式支持标签:{'; '.join(figure['support_labels'])}")
|
||
lines.extend([*_figure_interpretation(figure, workspace, manifests, report_path), ""])
|
||
else:
|
||
lines.append("当前成功阶段的 manifest 未登记可嵌入图像;请以分阶段表格和日志为准。")
|
||
|
||
lines.extend(["", "## 5. 风险、局限性与解释约束", ""])
|
||
if risks:
|
||
for risk in risks:
|
||
lines.append(f"- [{risk.get('severity', 'unknown')}] {risk.get('message', '')}")
|
||
else:
|
||
lines.append("- 自动证据审计未发现已登记风险;这不等同于不存在实验设计或批次效应风险。")
|
||
if missing:
|
||
lines.append(f"- 当前报告为部分覆盖,尚不能替代缺失阶段:{', '.join(missing)}。")
|
||
|
||
lines.extend(["", "## 6. 下一步建议", ""])
|
||
next_steps = evidence.get("next_steps") or []
|
||
if next_steps:
|
||
for item in next_steps:
|
||
if isinstance(item, dict):
|
||
text = item.get("action") or item.get("recommendation") or item.get("text") or str(item)
|
||
else:
|
||
text = str(item)
|
||
lines.append(f"- {text}")
|
||
else:
|
||
lines.append("- 根据缺失阶段和风险项补齐分析,再重新生成报告以刷新证据快照。")
|
||
|
||
lines.extend(["", "## 7. 证据追溯", ""])
|
||
for stage in included:
|
||
manifest_path = manifests.get(stage, {}).get("_manifest_path") or ""
|
||
lines.append(f"- `{stage}`:`{manifest_path}`")
|
||
lines.extend(["", "---", "", "报告正文结束。完整结构化证据见同名 `.evidence.json`。", ""])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _model_claim_catalog(evidence: dict[str, Any]) -> list[dict[str, str]]:
|
||
catalog: list[dict[str, str]] = []
|
||
|
||
def add(claim_id: str, text: Any, source_type: str, stage: str = "") -> None:
|
||
value = str(text or "").strip()
|
||
if value:
|
||
catalog.append(
|
||
{
|
||
"claim_id": claim_id,
|
||
"text": value,
|
||
"source_type": source_type,
|
||
"stage": stage,
|
||
}
|
||
)
|
||
|
||
add("story_1", evidence.get("biological_story"), "biological_story")
|
||
for index, row in enumerate(evidence.get("key_findings") or [], 1):
|
||
if not isinstance(row, dict):
|
||
continue
|
||
add(
|
||
f"finding_{index}",
|
||
row.get("description") or row.get("summary") or row.get("claim") or row.get("text"),
|
||
"key_finding",
|
||
str(row.get("stage") or ""),
|
||
)
|
||
for index, row in enumerate(evidence.get("major_claims") or [], 1):
|
||
if isinstance(row, dict):
|
||
text = row.get("claim") or row.get("text") or row.get("title")
|
||
stage = str(row.get("stage") or "")
|
||
else:
|
||
text = row
|
||
stage = ""
|
||
add(f"claim_{index}", text, "major_claim", stage)
|
||
for index, row in enumerate(evidence.get("risk_flags") or [], 1):
|
||
if isinstance(row, dict):
|
||
text = row.get("message") or row.get("text")
|
||
stage = str(row.get("stage") or "")
|
||
else:
|
||
text = row
|
||
stage = ""
|
||
add(f"risk_{index}", text, "risk_flag", stage)
|
||
return catalog
|
||
|
||
|
||
def _render_claim_groups(
|
||
plan: dict[str, Any], catalog: list[dict[str, str]]
|
||
) -> tuple[str, list[str]]:
|
||
groups = plan.get("paragraph_groups")
|
||
if not isinstance(groups, list) or not groups or len(groups) > 4:
|
||
raise ValueError("paragraph_groups must contain between 1 and 4 groups")
|
||
claim_text = {row["claim_id"]: row["text"] for row in catalog}
|
||
selected: list[str] = []
|
||
paragraphs: list[str] = []
|
||
for group in groups:
|
||
if not isinstance(group, list) or not group:
|
||
raise ValueError("each paragraph group must be a non-empty claim-id list")
|
||
sentences: list[str] = []
|
||
for raw_claim_id in group:
|
||
claim_id = str(raw_claim_id or "").strip()
|
||
if claim_id not in claim_text:
|
||
raise ValueError(f"unknown evidence claim id: {claim_id}")
|
||
if claim_id in selected:
|
||
raise ValueError(f"duplicate evidence claim id: {claim_id}")
|
||
selected.append(claim_id)
|
||
text = claim_text[claim_id]
|
||
sentences.append(text if text.endswith(("。", "!", "?", ".", "!", "?")) else f"{text}。")
|
||
paragraphs.append("".join(sentences))
|
||
return "\n\n".join(paragraphs), selected
|
||
|
||
|
||
def _model_enhancement(evidence: dict[str, Any], enabled: bool) -> tuple[str, dict[str, Any]]:
|
||
claim_catalog = _model_claim_catalog(evidence)
|
||
metadata: dict[str, Any] = {
|
||
"schema": "rnaseq_report_model_v2",
|
||
"enabled": enabled,
|
||
"status": "disabled" if not enabled else "pending",
|
||
"provider": "",
|
||
"model": "",
|
||
"content": "",
|
||
"error": "",
|
||
"generated_at": _now(),
|
||
"policy": "evidence_claim_ordering_only",
|
||
"claim_catalog": claim_catalog,
|
||
"organization_plan": {},
|
||
"selected_claim_ids": [],
|
||
}
|
||
if not enabled:
|
||
return "", metadata
|
||
if not claim_catalog:
|
||
metadata.update({"status": "fallback", "error": "no approved evidence claims available"})
|
||
return "", metadata
|
||
try:
|
||
from gfa_core.mas.config import config
|
||
from gfa_core.mas.llm import LLMManager
|
||
from gfa_core.mas.llm.base import LLMMessage
|
||
|
||
manager = LLMManager(config.load_llm_config())
|
||
response = manager.call(
|
||
[
|
||
LLMMessage(
|
||
role="system",
|
||
content=(
|
||
"你只能组织已批准的RNA-seq证据语句,不能撰写或改写结论。"
|
||
"只返回JSON对象:paragraph_groups为1至4个段落,每个段落只能列出输入中的claim_id。"
|
||
"不得返回自由文本、未知claim_id、数字、基因、通路、显著性、方向、机制或因果推断。"
|
||
),
|
||
),
|
||
LLMMessage(
|
||
role="user",
|
||
content=json.dumps(
|
||
{"task": "order_and_group_evidence_claims", "claim_catalog": claim_catalog},
|
||
ensure_ascii=False,
|
||
)[:30000],
|
||
),
|
||
],
|
||
allow_fallback=True,
|
||
max_tokens=600,
|
||
track_agent_name="RNASeqReport",
|
||
)
|
||
content = str(response.content or "").strip()
|
||
metadata.update(
|
||
{
|
||
"provider": str(response.provider or ""),
|
||
"model": str(response.model or ""),
|
||
"content": content,
|
||
}
|
||
)
|
||
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.IGNORECASE)
|
||
plan = json.loads(cleaned)
|
||
if not isinstance(plan, dict):
|
||
raise ValueError("model organization plan must be a JSON object")
|
||
rendered, selected_claim_ids = _render_claim_groups(plan, claim_catalog)
|
||
metadata.update(
|
||
{
|
||
"status": "embedded_evidence_only",
|
||
"organization_plan": plan,
|
||
"selected_claim_ids": selected_claim_ids,
|
||
}
|
||
)
|
||
return rendered, metadata
|
||
except Exception as exc: # deterministic report remains authoritative
|
||
metadata.update({"status": "fallback", "error": f"{type(exc).__name__}: {exc}"})
|
||
return "", metadata
|
||
|
||
|
||
def _artifact(workspace: Path, path: Path, kind: str) -> dict[str, Any]:
|
||
return {
|
||
"artifact": kind,
|
||
"path": str(path.relative_to(workspace)),
|
||
"full_path": str(path.absolute()),
|
||
"type": "markdown" if path.suffix == ".md" else "json",
|
||
"size": path.stat().st_size,
|
||
"sha256": _sha256(path),
|
||
}
|
||
|
||
|
||
def generate_rnaseq_report(
|
||
workspace: Path | str,
|
||
prefix: str,
|
||
*,
|
||
rnaseq_scope_id: str = "",
|
||
expected_stages: Iterable[str] | None = None,
|
||
use_model: bool = True,
|
||
action: str = "generate",
|
||
job_id: str = "",
|
||
) -> RNASeqReportResult:
|
||
workspace = Path(workspace).absolute()
|
||
final_dir = workspace / "final"
|
||
final_dir.mkdir(parents=True, exist_ok=True)
|
||
prefix = str(prefix or "").strip()
|
||
if not prefix:
|
||
raise ValueError("prefix is required")
|
||
if action not in {"generate", "open"}:
|
||
raise ValueError("action must be 'generate' or 'open'")
|
||
|
||
expected = _normalize_expected_stages(expected_stages)
|
||
resolved_scope, discovered_scopes, all_sources = _source_selection(final_dir, prefix, rnaseq_scope_id)
|
||
successful = {
|
||
stage: manifest
|
||
for stage, manifest in all_sources.items()
|
||
if str(manifest.get("status") or "") in SUCCESS_STATUSES
|
||
}
|
||
if not successful:
|
||
raise NoSuccessfulRNASeqEvidence("暂无可生成的 RNA-seq 结果:未找到当前 prefix/scope 下成功的 RNA-seq 阶段。")
|
||
|
||
included = sorted(successful, key=_stage_sort_key)
|
||
source_records = _source_records(all_sources)
|
||
source_fingerprint, request_fingerprint = _fingerprint(source_records, expected, use_model)
|
||
report_path = final_dir / f"{prefix}.rnaseq_report.md"
|
||
evidence_path = final_dir / f"{prefix}.rnaseq_report.evidence.json"
|
||
model_path = final_dir / f"{prefix}.rnaseq_report.model.json"
|
||
manifest_path = final_dir / f"{prefix}.rnaseq_report.manifest.json"
|
||
|
||
stage_statuses = {stage: str(all_sources.get(stage, {}).get("status") or "missing") for stage in expected}
|
||
coverage_status = "complete" if expected and all(stage_statuses[s] == "success" for s in expected) else "partial"
|
||
if action == "open" and all(path.is_file() for path in (report_path, evidence_path, model_path, manifest_path)):
|
||
previous = _read_json(manifest_path)
|
||
metrics = previous.get("metrics") or {}
|
||
if (
|
||
previous.get("status") == "success"
|
||
and _identity(previous, "rnaseq_scope_id") == resolved_scope
|
||
and metrics.get("request_fingerprint") == request_fingerprint
|
||
and metrics.get("report_renderer_version") == REPORT_RENDERER_VERSION
|
||
):
|
||
return RNASeqReportResult(
|
||
report_path,
|
||
evidence_path,
|
||
model_path,
|
||
manifest_path,
|
||
str(metrics.get("coverage_status") or coverage_status),
|
||
tuple(metrics.get("included_stages") or included),
|
||
resolved_scope,
|
||
reused=True,
|
||
)
|
||
|
||
evidence = build_rnaseq_evidence_snapshot(
|
||
final_dir,
|
||
prefix,
|
||
rnaseq_scope_id=resolved_scope,
|
||
include_stages=included,
|
||
)
|
||
if not evidence:
|
||
raise NoSuccessfulRNASeqEvidence("暂无可生成的 RNA-seq 结果:成功 manifest 未能构建有效证据。")
|
||
generated_at = _now()
|
||
figures, omitted_figures, selection_mode = _figures(workspace, successful)
|
||
enrichment_figures = [figure for figure in figures if figure.get("category")]
|
||
|
||
def inventory_row(figure: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"stage": figure["stage"],
|
||
"artifact": figure["artifact"],
|
||
"filename": Path(figure["path"]).name,
|
||
"category": figure.get("category", "other"),
|
||
"comparison": figure.get("comparison", ""),
|
||
"pathway_id": figure.get("pathway_id", ""),
|
||
"candidate_families": list(figure.get("candidate_families") or []),
|
||
"support_labels": list(figure.get("support_labels") or []),
|
||
"selection_reason": figure.get("selection_reason", ""),
|
||
"source_tables": list(figure.get("source_tables") or []),
|
||
}
|
||
|
||
figure_inventory = [inventory_row(figure) for figure in figures]
|
||
omitted_figure_inventory = [inventory_row(figure) for figure in omitted_figures]
|
||
evidence["formal_report"] = {
|
||
"schema": "rnaseq_formal_report_evidence_v1",
|
||
"report_renderer_version": REPORT_RENDERER_VERSION,
|
||
"generated_at": generated_at,
|
||
"prefix": prefix,
|
||
"rnaseq_scope_id": resolved_scope,
|
||
"discovered_scope_ids": discovered_scopes,
|
||
"expected_stages": expected,
|
||
"stage_statuses": stage_statuses,
|
||
"coverage_status": coverage_status,
|
||
"source_fingerprint": source_fingerprint,
|
||
"request_fingerprint": request_fingerprint,
|
||
"source_manifests": source_records,
|
||
"selection_mode": selection_mode,
|
||
"figure_inventory": figure_inventory,
|
||
"omitted_figure_inventory": omitted_figure_inventory,
|
||
"omitted_figure_count": len(omitted_figure_inventory),
|
||
"figure_count": len(figures),
|
||
"enrichment_figure_count": len(enrichment_figures),
|
||
}
|
||
model_summary, model_metadata = _model_enhancement(evidence, use_model)
|
||
markdown = _render_deterministic_markdown(
|
||
prefix=prefix,
|
||
scope_id=resolved_scope,
|
||
evidence=evidence,
|
||
manifests=successful,
|
||
expected_stages=expected,
|
||
stage_statuses=stage_statuses,
|
||
coverage_status=coverage_status,
|
||
report_path=report_path,
|
||
workspace=workspace,
|
||
generated_at=generated_at,
|
||
figures=figures,
|
||
model_summary=model_summary,
|
||
)
|
||
_write_text_atomic(evidence_path, json.dumps(evidence, ensure_ascii=False, indent=2) + "\n")
|
||
_write_text_atomic(model_path, json.dumps(model_metadata, ensure_ascii=False, indent=2) + "\n")
|
||
_write_text_atomic(report_path, markdown)
|
||
|
||
manifest = {
|
||
"manifest_version": "1.0",
|
||
"stage": REPORT_STAGE,
|
||
"status": "success",
|
||
"job_id": job_id or workspace.name,
|
||
"prefix": prefix,
|
||
"rnaseq_scope_id": resolved_scope,
|
||
"generated_at": generated_at,
|
||
"parameters": {
|
||
"action": action,
|
||
"model_enhancement_requested": use_model,
|
||
"expected_stages": expected,
|
||
"rnaseq_scope_id": resolved_scope,
|
||
},
|
||
"metrics": {
|
||
"coverage_status": coverage_status,
|
||
"included_stage_count": len(included),
|
||
"included_stages": included,
|
||
"expected_stages": expected,
|
||
"stage_statuses": stage_statuses,
|
||
"missing_or_failed_stages": [stage for stage in expected if stage_statuses.get(stage) != "success"],
|
||
"model_status": model_metadata["status"],
|
||
"source_fingerprint": source_fingerprint,
|
||
"request_fingerprint": request_fingerprint,
|
||
"report_renderer_version": REPORT_RENDERER_VERSION,
|
||
"selection_mode": selection_mode,
|
||
"figure_count": len(figures),
|
||
"enrichment_figure_count": len(enrichment_figures),
|
||
"figure_inventory": figure_inventory,
|
||
"omitted_figure_inventory": omitted_figure_inventory,
|
||
"omitted_figure_count": len(omitted_figure_inventory),
|
||
"figure_categories": {
|
||
category: sum(1 for figure in enrichment_figures if figure.get("category") == category)
|
||
for category in sorted({figure.get("category", "") for figure in enrichment_figures})
|
||
},
|
||
},
|
||
"provenance": {"source_manifests": source_records},
|
||
"artifacts": {
|
||
"rnaseq_report": _artifact(workspace, report_path, "rnaseq_report"),
|
||
"rnaseq_report_evidence": _artifact(workspace, evidence_path, "rnaseq_report_evidence"),
|
||
"rnaseq_report_model": _artifact(workspace, model_path, "rnaseq_report_model"),
|
||
},
|
||
}
|
||
_write_text_atomic(manifest_path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||
return RNASeqReportResult(
|
||
report_path,
|
||
evidence_path,
|
||
model_path,
|
||
manifest_path,
|
||
coverage_status,
|
||
tuple(included),
|
||
resolved_scope,
|
||
)
|