Files
PlantAI-dev/gfa_core/rnaseq_report.py
T

1305 lines
54 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.
"""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,
)