diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py
index b727ff36f..79490f6d9 100644
--- a/apps/web/vue3/main.py
+++ b/apps/web/vue3/main.py
@@ -6911,6 +6911,10 @@ class ExecuteRequest(BaseModel):
speciesCode: str = ""
rnaseqPrefix: str = ""
rnaseqOutputPrefix: str = ""
+ rnaseqScopeId: str = ""
+ rnaseqExpectedStages: List[str] = Field(default_factory=list)
+ rnaseqReportAction: str = "generate"
+ rnaseqReportUseModel: bool = True
# RNA-seq QC 参数
rnaseqDataMode: str = "upload"
rnaseqDataPath: str = ""
@@ -7794,6 +7798,10 @@ def run_stage_sync(task_id: str, stage: str, job_id: str, params: dict):
wgcna_powers=params.get("wgcnaPowers", "1,2,3,4,5,6,7,8,9,10,12,14,16,18,20"),
candidate_max_deg_proteins=params.get("candidateMaxDegProteins", 500),
candidate_run_interproscan=params.get("candidateRunInterproscan", "auto"),
+ rnaseq_scope_id=params.get("rnaseqScopeId", ""),
+ rnaseq_expected_stages=params.get("rnaseqExpectedStages") or params.get("expectedStages") or [],
+ rnaseq_report_action=params.get("rnaseqReportAction", "generate"),
+ rnaseq_report_use_model=_bool_param("rnaseqReportUseModel", "report_use_model", default=True),
# PPI参数
ppi_species=params.get("ppiSpecies") or params.get("ppi_species") or "auto",
normalization_species_label=params.get("normalizationSpeciesLabel") or params.get("normalization_species_label") or "",
@@ -12093,6 +12101,10 @@ async def execute_stage(req: ExecuteRequest):
"wgcnaPowers": getattr(req, 'wgcnaPowers', "1,2,3,4,5,6,7,8,9,10,12,14,16,18,20"),
"candidateMaxDegProteins": getattr(req, 'candidateMaxDegProteins', 500),
"candidateRunInterproscan": getattr(req, 'candidateRunInterproscan', "auto"),
+ "rnaseqScopeId": getattr(req, 'rnaseqScopeId', ""),
+ "rnaseqExpectedStages": getattr(req, 'rnaseqExpectedStages', []),
+ "rnaseqReportAction": getattr(req, 'rnaseqReportAction', "generate"),
+ "rnaseqReportUseModel": getattr(req, 'rnaseqReportUseModel', True),
"ncbiQuery": getattr(req, 'ncbiQuery', ''),
"ncbiGeneIds": getattr(req, 'ncbiGeneIds', ''),
"ncbiMode": getattr(req, 'ncbiMode', 'gene'),
@@ -13679,6 +13691,7 @@ async def get_results(
"rnaseq_fgsea": {"images": [], "files": [], "elapsed_time": 0},
"rnaseq_wgcna": {"images": [], "files": [], "elapsed_time": 0},
"rnaseq_candidate_family": {"images": [], "files": [], "elapsed_time": 0},
+ "rnaseq_report": {"images": [], "files": [], "elapsed_time": 0},
"genome_prepare": {"images": [], "files": [], "elapsed_time": 0},
"prepare_organize": {"images": [], "files": [], "elapsed_time": 0},
"prepare_normalize_gff": {"images": [], "files": [], "elapsed_time": 0},
@@ -13780,6 +13793,7 @@ async def get_results(
"rnaseq_fgsea",
"rnaseq_wgcna",
"rnaseq_candidate_family",
+ "rnaseq_report",
"expression",
}
RNASEQ_FILE_TOKENS = (
@@ -14801,6 +14815,7 @@ async def get_results(
"genome_prepare": ["genome_prepare", "species_config"],
"msa_region_plot": ["msa_region", "region.tsv", "conservation_profile"],
"subfamily_confirm": ["subfamily_confirm", "confirmed_subfamily"],
+ "rnaseq_report": ["rnaseq_report"],
"rnaseq_fgsea": ["rnaseq_fgsea", "fgsea"],
"rnaseq_go_enrich": ["rnaseq_go_enrich", ".go_enrich", "_go_"],
"rnaseq_kegg_enrich": ["rnaseq_kegg_enrich", ".kegg_enrich", "_kegg_", "kegg_pathway", "kegg_pathview"],
@@ -16029,6 +16044,7 @@ def parse_natural_language(message: str) -> dict:
"rnaseq_align": ["rnaseq比对", "rna比对", "hisat2", "比对"],
"rnaseq_quant": ["rnaseq定量", "rna定量", "stringtie", "定量"],
"rnaseq_sample_qc": ["样本质量", "样本检测", "sample qc", "sample quality", "pca", "样本相关性"],
+ "rnaseq_report": ["rnaseq结果报告", "rna-seq结果报告", "转录组结果报告", "rnaseq报告"],
"rnaseq_wgcna": ["wgcna", "共表达", "加权共表达", "模块性状", "module trait"],
"rnaseq_de": ["rnaseq差异", "rna差异", "差异表达", "de分析"],
"rnaseq_fgsea": ["fgsea", "gsea", "基因集富集", "排序富集"],
@@ -16838,7 +16854,10 @@ async def _run_agent_chat(
)
router_context_terms = dict(terms)
- from gfa_core.mas.utils.intent_parser import parse_sequence_extract_request
+ from gfa_core.mas.utils.intent_parser import (
+ deterministic_agent_chat_intent,
+ parse_sequence_extract_request,
+ )
utility_params = parse_sequence_extract_request(request.message)
if utility_params:
@@ -16884,6 +16903,58 @@ async def _run_agent_chat(
"session_id": session_id,
}
+ deterministic_intent = deterministic_agent_chat_intent(
+ request.message,
+ rnaseq_context=any(
+ str(stage).startswith("rnaseq_") for stage in completed_stages
+ ),
+ )
+ if deterministic_intent is not None:
+ report_action = str(
+ (deterministic_intent.get("params") or {}).get("rnaseqReportAction")
+ or "generate"
+ )
+ report_message = (
+ "已识别为打开 RNA-seq 结果报告。"
+ if report_action == "open"
+ else "已识别为生成 RNA-seq 结果报告。"
+ )
+ report_intent = {
+ **deterministic_intent,
+ "stage": "rnaseq_report",
+ "primary_stage": "rnaseq_report",
+ "taskType": "analysis",
+ "task_kind": "analysis",
+ "job_id": request.job_id,
+ "workspace": (
+ str(PROJECT_ROOT / "workspace" / request.job_id)
+ if request.job_id
+ else ""
+ ),
+ "message": report_message,
+ }
+ if conversation_turn is not None:
+ active_store.finish_turn(
+ conversation_turn,
+ assistant_message=report_message,
+ interaction_kind="independent",
+ action="propose_analysis",
+ intent=report_intent,
+ )
+ return {
+ "success": True,
+ "status": "ok",
+ "action": "propose_analysis",
+ "intent": report_intent,
+ "sources": [],
+ "tool_trace": [],
+ "mode": "analysis",
+ "completed_stages": completed_stages,
+ "model_used": "rule",
+ "session_id": session_id,
+ "handoff_request": request.message,
+ }
+
global llm_manager
active_llm_manager = llm_manager
if active_llm_manager is None:
@@ -20012,6 +20083,63 @@ async def agent_plan(request: AgentPlanRequest):
request.intent["params"] = dict(request.intent.get("params") or {})
_apply_reconciled_species_to_intent(request.intent, plan_role_resolution)
query_lower_for_context = (request.message or "").lower()
+ result_report_query = (
+ "结果报告" in query_lower_for_context
+ and any(action in query_lower_for_context for action in ["生成", "打开"])
+ )
+ if result_report_query:
+ from gfa_core.rnaseq_evidence import is_rnaseq_evidence_stage, manifest_identity_value
+
+ current_intent = dict(request.intent or {})
+ current_params = dict(current_intent.get("params") or {})
+ current_stages = [str(stage) for stage in (current_intent.get("stages") or [])]
+ explicit_rnaseq_report = any(
+ marker in query_lower_for_context
+ for marker in ["rnaseq", "rna-seq", "rna seq", "转录组"]
+ )
+ rnaseq_context = any(is_rnaseq_evidence_stage(stage) for stage in current_stages)
+ latest_rnaseq_manifest = None
+ if request.job_id:
+ report_final_dir = WORKSPACE_ROOT / request.job_id / "final"
+ report_prefix = _nonempty_prefix(request.prefix or current_intent.get("prefix") or current_params.get("prefix"))
+ report_pattern = f"{report_prefix}.*.manifest.json" if report_prefix else "*.manifest.json"
+ candidates = []
+ for candidate in report_final_dir.glob(report_pattern) if report_final_dir.exists() else []:
+ try:
+ payload = json.loads(candidate.read_text(encoding="utf-8"))
+ except Exception:
+ continue
+ if (
+ is_rnaseq_evidence_stage(str(payload.get("stage") or ""))
+ and str(payload.get("status") or "") in {"success", "partial_success"}
+ ):
+ candidates.append((candidate.stat().st_mtime, candidate, payload))
+ if candidates:
+ _mtime, _path, latest_rnaseq_manifest = max(candidates, key=lambda item: item[0])
+ rnaseq_context = True
+ if explicit_rnaseq_report or rnaseq_context:
+ current_params["rnaseqReportAction"] = "open" if "打开" in query_lower_for_context else "generate"
+ if latest_rnaseq_manifest:
+ current_params.setdefault(
+ "rnaseqScopeId",
+ manifest_identity_value(latest_rnaseq_manifest, "rnaseq_scope_id"),
+ )
+ report_prefix = _nonempty_prefix(
+ request.prefix
+ or current_intent.get("prefix")
+ or current_params.get("prefix")
+ or (latest_rnaseq_manifest or {}).get("prefix")
+ )
+ request.intent = {
+ **current_intent,
+ "stages": ["rnaseq_report"],
+ "primary_stage": "rnaseq_report",
+ "task_type": "report",
+ "task_kind": "pipeline",
+ "prefix": report_prefix,
+ "params": {**current_params, "prefix": report_prefix},
+ "confidence": 0.99,
+ }
subfamily_confirm_query_for_context = any(
kw in query_lower_for_context
for kw in ["确认亚家族", "亚家族确认", "确认亚家族标签", "人工确认亚家族", "subfamily confirm", "confirmed subfamily"]
@@ -20907,7 +21035,10 @@ async def agent_plan(request: AgentPlanRequest):
"manifest_path": str(status.manifest_path) if status.manifest_path else "",
"missing_count": len(status.missing_artifacts or []) + len(status.errors or []),
}
- if status.completed:
+ # Report requests are actions, not a request to skip an
+ # already completed computation. The CLI decides whether
+ # an "open" request can reuse the fresh snapshot.
+ if status.completed and stage_id != "rnaseq_report":
completed_rnaseq_stages.append(stage_id)
else:
remaining_rnaseq_stages.append(stage_id)
@@ -20923,6 +21054,7 @@ async def agent_plan(request: AgentPlanRequest):
"rnaseq_fgsea": "RNA-seq fgsea",
"rnaseq_wgcna": "RNA-seq WGCNA",
"rnaseq_candidate_family": "RNA-seq Candidate Family",
+ "rnaseq_report": "RNA-seq Result Report",
}
plan_prefix = effective_plan_prefix or request.prefix or incoming_intent.get("prefix", "")
final_dir_for_rnaseq = PROJECT_ROOT / "workspace" / request.job_id / "final"
@@ -20942,6 +21074,7 @@ async def agent_plan(request: AgentPlanRequest):
"rnaseq_fgsea": ["rnaseq_de"],
"rnaseq_wgcna": ["rnaseq_sample_qc"],
"rnaseq_candidate_family": ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"],
+ "rnaseq_report": [],
}
if (
"rnaseq_fgsea" in remaining_rnaseq_stages
@@ -20965,6 +21098,7 @@ async def agent_plan(request: AgentPlanRequest):
"rnaseq_fgsea": 10,
"rnaseq_wgcna": 20,
"rnaseq_candidate_family": 10,
+ "rnaseq_report": 2,
}
stages_detail = []
for order, stage_id in enumerate(remaining_rnaseq_stages, start=1):
diff --git a/gfa_core/mas/utils/intent_parser.py b/gfa_core/mas/utils/intent_parser.py
index f75329b21..48cc0f25a 100755
--- a/gfa_core/mas/utils/intent_parser.py
+++ b/gfa_core/mas/utils/intent_parser.py
@@ -87,6 +87,7 @@ STAGE_KEYWORDS = {
"rnaseq_kegg_enrich": ["rnaseq kegg", "rnaseq_kegg", "rna kegg", "kegg富集", "kegg分析", "kegg", "富集分析", "enrich"],
"rnaseq_enrich": ["富集分析", "enrich", "go分析", "kegg", "clusterprofiler"],
"rnaseq_candidate_family": ["候选家族", "候选基因家族", "家族排名", "tf注释", "tf family", "tffamily", "pfam注释", "deg注释", "差异基因注释", "差异基因家族", "candidate family"],
+ "rnaseq_report": ["rnaseq结果报告", "rna-seq结果报告", "转录组结果报告", "rnaseq报告", "rna-seq报告"],
"ncbi_fetch_sequences": ["ncbi下载", "ncbi序列", "下载序列", "下载蛋白", "获取蛋白", "下载protein", "下载cds", "protein和cds", "蛋白和cds", "基因list", "gene list", "gene_list", "id列表", ".ids"],
}
@@ -256,6 +257,7 @@ RNASEQ_STAGES = {
"rnaseq_kegg_enrich",
"rnaseq_enrich",
"rnaseq_candidate_family",
+ "rnaseq_report",
}
# 物种关键词映射
@@ -732,6 +734,10 @@ def parse_intent_from_query(query: str) -> Dict:
"clarify_type": result.get("clarify_type"),
"message": result.get("message")
}
+ if parsed["primary_stage"] == "rnaseq_report":
+ parsed["params"] = {
+ "rnaseqReportAction": "open" if "打开" in query_lower else "generate"
+ }
if ncbi_params:
parsed["params"] = ncbi_params
parsed["prefix"] = ncbi_params.get("prefix", "")
@@ -781,6 +787,43 @@ def parse_intent_from_query(query: str) -> Dict:
return parsed
+def deterministic_agent_chat_intent(
+ query: str,
+ *,
+ rnaseq_context: bool = False,
+) -> Optional[Dict]:
+ """Return explicit chat intents that must not depend on the Router model."""
+ intent = parse_intent_from_query(query)
+ if intent.get("stages") == ["rnaseq_report"]:
+ return intent
+ query_lower = str(query or "").lower()
+ query_without_space = re.sub(r"\s+", "", query_lower)
+ is_generic_report_action = (
+ rnaseq_context
+ and "结果报告" in query_without_space
+ and any(action in query_without_space for action in ("生成", "打开"))
+ )
+ if is_generic_report_action:
+ return {
+ "gene_family": "",
+ "species": "",
+ "stages": ["rnaseq_report"],
+ "primary_stage": "rnaseq_report",
+ "task_type": "report",
+ "task_kind": "pipeline",
+ "confidence": 0.99,
+ "need_clarify": False,
+ "clarify_type": None,
+ "message": None,
+ "params": {
+ "rnaseqReportAction": (
+ "open" if "打开" in query_without_space else "generate"
+ )
+ },
+ }
+ return None
+
+
def parse_msa_region_plot_request(query: str) -> Dict:
"""Extract parameters for drawing a selected MSA alignment region."""
text = str(query or "").strip()
@@ -1367,6 +1410,12 @@ def _extract_stages(query: str, query_lower: str) -> List[str]:
stages.append("gwas_fmt")
matched_keywords = [] # 记录匹配到的关键词长度,用于优先级判断
has_rnaseq_marker = any(marker in query_lower for marker in RNASEQ_MARKERS)
+ requests_result_report = any(
+ keyword in query_lower
+ for keyword in ["生成结果报告", "打开结果报告", "结果报告", "生成报告", "打开报告"]
+ )
+ if has_rnaseq_marker and requests_result_report:
+ return ["rnaseq_report"]
extraction_context = any(kw in query_lower for kw in ["提取", "extract", "gffread", "筛选", "subseq"])
explicit_gene_family_context = any(kw in query_lower for kw in ["基因家族", "家族表达", "表达量", "热图", "heatmap"])
curated_member_context = (
diff --git a/gfa_core/rnaseq_report.py b/gfa_core/rnaseq_report.py
new file mode 100644
index 000000000..e7f42adf0
--- /dev/null
+++ b/gfa_core/rnaseq_report.py
@@ -0,0 +1,1304 @@
+"""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,
+ )
diff --git a/tests/unit/test_rnaseq_report.py b/tests/unit/test_rnaseq_report.py
new file mode 100644
index 000000000..aa3f2579b
--- /dev/null
+++ b/tests/unit/test_rnaseq_report.py
@@ -0,0 +1,2131 @@
+from __future__ import annotations
+
+import json
+import importlib.util
+import re
+from pathlib import Path
+
+import pytest
+
+import gfa_core.rnaseq_report as rnaseq_report_module
+from gfa_core.rnaseq_report import NoSuccessfulRNASeqEvidence, generate_rnaseq_report
+from gfa_core.mas.utils import intent_parser as intent_parser_module
+from gfa_core.mas.utils.intent_parser import parse_intent_from_query
+
+
+def _fast_intent_recognition(query: str) -> dict | None:
+ module_path = Path(__file__).resolve().parents[2] / "gfa_core" / "mas" / "core" / "fast_intent.py"
+ spec = importlib.util.spec_from_file_location("rnaseq_report_fast_intent", module_path)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module.fast_intent_recognition(query)
+
+
+def _write_manifest(
+ workspace: Path,
+ prefix: str,
+ stage: str,
+ status: str,
+ scope_id: str,
+ *,
+ suffix: str = "",
+) -> Path:
+ final_dir = workspace / "final"
+ final_dir.mkdir(parents=True, exist_ok=True)
+ data_path = final_dir / f"{prefix}.{stage}{suffix}.table.tsv"
+ data_path.write_text("metric\tvalue\nreads\t100\n", encoding="utf-8")
+ manifest_path = final_dir / f"{prefix}.{stage}{suffix}.manifest.json"
+ manifest = {
+ "manifest_version": "1.0",
+ "stage": stage,
+ "status": status,
+ "prefix": prefix,
+ "rnaseq_scope_id": scope_id,
+ "metrics": {"sample_count": 2, "total_reads": 100},
+ "parameters": {"rnaseq_scope_id": scope_id},
+ "artifacts": {
+ "result_table": {
+ "artifact": "result_table",
+ "path": f"final/{data_path.name}",
+ "full_path": str(data_path),
+ "type": "table",
+ }
+ },
+ }
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
+ return manifest_path
+
+
+def _write_artifact_manifest(
+ workspace: Path,
+ prefix: str,
+ stage: str,
+ scope_id: str,
+ artifacts: dict[str, tuple[str, str]],
+) -> Path:
+ final_dir = workspace / "final"
+ final_dir.mkdir(parents=True, exist_ok=True)
+ manifest_artifacts = {}
+ for key, (filename, content) in artifacts.items():
+ artifact_path = final_dir / filename
+ artifact_path.write_text(content, encoding="utf-8")
+ manifest_artifacts[key] = {
+ "artifact": key,
+ "path": f"final/{filename}",
+ "full_path": str(artifact_path),
+ "type": artifact_path.suffix.lstrip("."),
+ }
+ manifest_path = final_dir / f"{prefix}.{stage}.manifest.json"
+ manifest_path.write_text(
+ json.dumps(
+ {
+ "manifest_version": "1.0",
+ "stage": stage,
+ "status": "success",
+ "prefix": prefix,
+ "rnaseq_scope_id": scope_id,
+ "parameters": {"rnaseq_scope_id": scope_id},
+ "metrics": {"comparison_count": 2},
+ "artifacts": manifest_artifacts,
+ },
+ ensure_ascii=False,
+ ),
+ encoding="utf-8",
+ )
+ return manifest_path
+
+
+def _figure_section(markdown: str, filename: str) -> str:
+ figure_area = markdown.index("## 4. 图表与产物说明")
+ image_at = markdown.index(filename, figure_area)
+ section_start = markdown.rfind("### 图 ", 0, image_at)
+ assert section_start >= 0, f"missing figure heading for {filename}"
+ next_figure = markdown.find("### 图 ", image_at)
+ section_end = next_figure if next_figure >= 0 else markdown.find("\n## 5.", image_at)
+ return markdown[section_start : section_end if section_end >= 0 else None]
+
+
+def _embedded_image_names(markdown: str) -> list[str]:
+ return [Path(link).name for link in re.findall(r"!\[[^]]*]\(([^)]+)\)", markdown)]
+
+
+def _write_enrichment_manifests(workspace: Path) -> None:
+ prefix = "Os"
+ scope = "scope-enrichment"
+ go_a = f"{prefix}.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich"
+ go_b = f"{prefix}.rnaseq_go_enrich.IR29__6_vs_IR29__0.GO_enrich"
+ go_table_header = "ID\tDescription\tOntology\tGeneRatio\tBgRatio\tpvalue\tp.adjust\tCount\tgeneID\n"
+ _write_artifact_manifest(
+ workspace,
+ prefix,
+ "rnaseq_go_enrich",
+ scope,
+ {
+ "go_a_table": (f"{go_a}.txt", go_table_header + "GO:0009651\tsalt stress response\tBP\t4/20\t20/1000\t0.0001\t0.001\t4\tOs01/Os02/Os03/Os04\n"),
+ "go_b_table": (f"{go_b}.txt", go_table_header + "GO:0015979\tphotosynthesis\tBP\t3/20\t18/1000\t0.01\t0.04\t3\tOs05/Os06/Os07\n"),
+ "go_a_bar_png": (f"{go_a}.barplot.png", "png"),
+ "go_a_bar_pdf": (f"{go_a}.barplot.pdf", "pdf"),
+ "go_a_bar_svg": (f"{go_a}.barplot.svg", ""),
+ "go_b_bar_png": (f"{go_b}.barplot.png", "png"),
+ "go_a_dot": (f"{go_a}.dotplot.png", "png"),
+ "go_b_dot": (f"{go_b}.dotplot.png", "png"),
+ "go_a_ontology": (f"{go_a}.ontology_barplot.png", "png"),
+ "go_b_ontology": (f"{go_b}.ontology_barplot.png", "png"),
+ },
+ )
+
+ kegg_a = f"{prefix}.rnaseq_kegg_enrich.IR29__6_vs_IR29__0"
+ kegg_b = f"{prefix}.rnaseq_kegg_enrich.HH11__48_vs_HH11__0"
+ kegg_header = "ID\tDescription\tGeneRatio\tBgRatio\tpvalue\tp.adjust\tCount\tgeneID\n"
+ _write_artifact_manifest(
+ workspace,
+ prefix,
+ "rnaseq_kegg_enrich",
+ scope,
+ {
+ "kegg_a_table": (
+ f"{kegg_a}.KEGG_enrich.txt",
+ kegg_header
+ + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t10/1000\t0.0001\t0.001\t2\tOsWax1/OsWax2\n"
+ + "ko00196\tPhotosynthesis - antenna proteins\t2/20\t12/1000\t0.0002\t0.002\t2\tOsLight1/OsLight2\n",
+ ),
+ "kegg_b_table": (
+ f"{kegg_b}.KEGG_enrich.txt",
+ kegg_header
+ + "ko01200\tCarbon metabolism\t3/20\t30/1000\t0.01\t0.04\t3\tOsC1/OsC2/OsC3\n"
+ + "ko00073\tCutin, suberine and wax biosynthesis\t1/20\t10/1000\t0.02\t0.05\t1\tOsWaxH\n",
+ ),
+ "kegg_a_hits": (
+ f"{kegg_a}.KEGG_pathway_gene_hits.txt",
+ "PathwayID\tPathwayName\tKO\tKOName\tGeneCount\tGenes\n"
+ "ko00073\tCutin, suberine and wax biosynthesis\tK00073\twax enzyme\t2\tOsWax1/OsWax2\n"
+ "ko00196\tPhotosynthesis - antenna proteins\tK00196\tantenna protein\t2\tOsLight1/OsLight2\n",
+ ),
+ "kegg_a_fc": (
+ f"{kegg_a}.KEGG_pathway_map.ko_log2fc.tsv",
+ "KO\tlog2FoldChange\nK00073\t2.5\nK00196\t-1.5\n",
+ ),
+ "kegg_a_bar_png": (f"{kegg_a}_KEGG_enrich.png", "png"),
+ "kegg_a_bar_pdf": (f"{kegg_a}_KEGG_enrich.pdf", "pdf"),
+ "kegg_b_bar": (f"{kegg_b}_KEGG_enrich.png", "png"),
+ "kegg_a_dot": (f"{kegg_a}_KEGG_dotplot.png", "png"),
+ "kegg_b_dot": (f"{kegg_b}_KEGG_dotplot.png", "png"),
+ "pathway_a_00073": (f"{kegg_a}.KEGG_pathway_map.ko00073.png", "png"),
+ "pathway_a_00196": (f"{kegg_a}.KEGG_pathway_map.ko00196.png", "png"),
+ "pathway_b_00073": (f"{kegg_b}.KEGG_pathway_map.ko00073.png", "png"),
+ },
+ )
+
+ fgsea_a = f"{prefix}.rnaseq_fgsea.IR29__6_vs_IR29__0"
+ fgsea_b = f"{prefix}.rnaseq_fgsea.HH11__48_vs_HH11__0"
+ fgsea_header = "pathway\tpathway_name\tpval\tpadj\tES\tNES\tsize\tleadingEdge\n"
+ _write_artifact_manifest(
+ workspace,
+ prefix,
+ "rnaseq_fgsea",
+ scope,
+ {
+ "fgsea_a_table": (
+ f"{fgsea_a}.fgsea_kegg.tsv",
+ fgsea_header
+ + "ko00196\tPhotosynthesis - antenna proteins\t0.0001\t0.001\t0.7\t2.1\t18\tOsLight1,OsLight2\n",
+ ),
+ "fgsea_b_table": (
+ f"{fgsea_b}.fgsea_kegg.tsv",
+ fgsea_header
+ + "ko01200\tCarbon metabolism\t0.01\t0.04\t-0.4\t-1.2\t30\tOsC1,OsC2\n",
+ ),
+ "fgsea_a_dot": (f"{fgsea_a}.fgsea_kegg_dotplot.png", "png"),
+ "fgsea_b_dot": (f"{fgsea_b}.fgsea_kegg_dotplot.png", "png"),
+ "fgsea_a_curve": (f"{fgsea_a}.fgsea_kegg_top_enrichment.png", "png"),
+ "fgsea_b_curve": (f"{fgsea_b}.fgsea_kegg_top_enrichment.png", "png"),
+ },
+ )
+
+
+def _write_analysis_ready_family_manifest(
+ workspace: Path,
+ *,
+ prefix: str,
+ scope_id: str,
+ rows: list[tuple[str, str, str, str]],
+) -> Path:
+ """Write the real analysis-ready TSV shape used by rnaseq_family_score."""
+ header = "rank\tfamily_id\tfamily_name\tbest_comparison\tbest_support_labels\n"
+ body = "".join(
+ f"{rank}\t{family_id}\t{family_name}\t{comparison}\t{support_labels}\n"
+ for rank, (family_id, family_name, comparison, support_labels) in enumerate(rows, 1)
+ )
+ return _write_artifact_manifest(
+ workspace,
+ prefix,
+ "rnaseq_family_score",
+ scope_id,
+ {
+ "analysis_ready_family": (
+ f"{prefix}.rnaseq_family_score.analysis_ready_family.tsv",
+ header + body,
+ )
+ },
+ )
+
+
+def _write_candidate_guided_enrichment_manifests(
+ workspace: Path,
+ *,
+ combined: bool = False,
+ include_fgsea_support: bool = True,
+) -> dict[str, str]:
+ """Create arbitrary identifiers so selection tests cannot rely on the sample job."""
+ prefix = "Zm"
+ scope = "scope-random-candidate-guided"
+ candidate_comparison = "LineX__17_vs_LineX__0"
+ unrelated_comparison = "LineY__29_vs_LineY__0"
+ supported_pathway_a = "ko54321"
+ supported_pathway_b = "ko67890"
+ unrelated_pathway = "ko13579"
+ go_id = "GO:9876543"
+ enrich_stage = "rnaseq_enrich" if combined else ""
+ go_stage = enrich_stage or "rnaseq_go_enrich"
+ kegg_stage = enrich_stage or "rnaseq_kegg_enrich"
+
+ go_candidate = f"{prefix}.{go_stage}.{candidate_comparison}.GO_enrich"
+ go_unrelated = f"{prefix}.{go_stage}.{unrelated_comparison}.GO_enrich"
+ go_header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ go_artifacts = {
+ "candidate_go_table": (
+ f"{go_candidate}.txt",
+ go_header
+ + f"{go_id}\trandom candidate function\tBP\t3/20\t0.02\t3\tGeneA/GeneB/GeneC\n",
+ ),
+ "unrelated_go_table": (
+ f"{go_unrelated}.txt",
+ go_header
+ + "GO:1111111\tunrelated stronger function\tBP\t8/20\t0.000001\t8\tOther1/Other2\n",
+ ),
+ "candidate_go_bar": (f"{go_candidate}.barplot.png", "png"),
+ "candidate_go_dot": (f"{go_candidate}.dotplot.png", "png"),
+ "candidate_go_ontology": (f"{go_candidate}.ontology_barplot.png", "png"),
+ "unrelated_go_bar": (f"{go_unrelated}.barplot.png", "png"),
+ "unrelated_go_dot": (f"{go_unrelated}.dotplot.png", "png"),
+ "unrelated_go_ontology": (f"{go_unrelated}.ontology_barplot.png", "png"),
+ }
+
+ kegg_candidate = f"{prefix}.{kegg_stage}.{candidate_comparison}"
+ kegg_unrelated = f"{prefix}.{kegg_stage}.{unrelated_comparison}"
+ kegg_header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ kegg_artifacts = {
+ "candidate_kegg_table": (
+ f"{kegg_candidate}.KEGG_enrich.txt",
+ kegg_header
+ + f"{supported_pathway_a}\trandom pathway alpha\t2/20\t0.02\t2\tGeneA/GeneB\n"
+ + f"{supported_pathway_b}\trandom pathway beta\t2/20\t0.03\t2\tGeneB/GeneC\n",
+ ),
+ "unrelated_kegg_table": (
+ f"{kegg_unrelated}.KEGG_enrich.txt",
+ kegg_header
+ + f"{supported_pathway_a}\trandom pathway alpha\t7/20\t0.000001\t7\tOther1/Other2\n"
+ + f"{unrelated_pathway}\tunrelated pathway\t9/20\t0.0000001\t9\tOther3/Other4\n",
+ ),
+ "candidate_kegg_bar": (f"{kegg_candidate}_KEGG_enrich.png", "png"),
+ "candidate_kegg_dot": (f"{kegg_candidate}_KEGG_dotplot.png", "png"),
+ "unrelated_kegg_bar": (f"{kegg_unrelated}_KEGG_enrich.png", "png"),
+ "unrelated_kegg_dot": (f"{kegg_unrelated}_KEGG_dotplot.png", "png"),
+ "candidate_pathway_a": (
+ f"{kegg_candidate}.KEGG_pathway_map.{supported_pathway_a}.png",
+ "png",
+ ),
+ "candidate_pathway_b": (
+ f"{kegg_candidate}.KEGG_pathway_map.{supported_pathway_b}.png",
+ "png",
+ ),
+ "other_time_supported_pathway": (
+ f"{kegg_unrelated}.KEGG_pathway_map.{supported_pathway_a}.png",
+ "png",
+ ),
+ "unrelated_pathway": (
+ f"{kegg_unrelated}.KEGG_pathway_map.{unrelated_pathway}.png",
+ "png",
+ ),
+ }
+
+ if combined:
+ _write_artifact_manifest(
+ workspace,
+ prefix,
+ "rnaseq_enrich",
+ scope,
+ {**go_artifacts, **kegg_artifacts},
+ )
+ else:
+ _write_artifact_manifest(workspace, prefix, go_stage, scope, go_artifacts)
+ _write_artifact_manifest(workspace, prefix, kegg_stage, scope, kegg_artifacts)
+
+ fgsea_candidate = f"{prefix}.rnaseq_fgsea.{candidate_comparison}"
+ fgsea_unrelated = f"{prefix}.rnaseq_fgsea.{unrelated_comparison}"
+ fgsea_header = "pathway\tpathway_name\tpadj\tNES\tsize\tleadingEdge\n"
+ _write_artifact_manifest(
+ workspace,
+ prefix,
+ "rnaseq_fgsea",
+ scope,
+ {
+ "candidate_fgsea_table": (
+ f"{fgsea_candidate}.fgsea_kegg.tsv",
+ fgsea_header
+ + f"{supported_pathway_b}\trandom pathway beta\t0.04\t2.1\t12\tGeneB,GeneC\n",
+ ),
+ "unrelated_fgsea_table": (
+ f"{fgsea_unrelated}.fgsea_kegg.tsv",
+ fgsea_header
+ + f"{unrelated_pathway}\tunrelated pathway\t0.0001\t3.0\t20\tOther1,Other2\n",
+ ),
+ "candidate_fgsea_dot": (f"{fgsea_candidate}.fgsea_kegg_dotplot.png", "png"),
+ "candidate_fgsea_curve": (
+ f"{fgsea_candidate}.fgsea_kegg_top_enrichment.png",
+ "png",
+ ),
+ "unrelated_fgsea_dot": (f"{fgsea_unrelated}.fgsea_kegg_dotplot.png", "png"),
+ "unrelated_fgsea_curve": (
+ f"{fgsea_unrelated}.fgsea_kegg_top_enrichment.png",
+ "png",
+ ),
+ },
+ )
+
+ support = (
+ f"GO:{go_id}:random candidate function;"
+ f"KEGG:{supported_pathway_a}:random pathway alpha;"
+ f"KEGG:{supported_pathway_b}:random pathway beta"
+ )
+ if include_fgsea_support:
+ support += f";fgsea:{supported_pathway_b}:random pathway beta"
+ _write_analysis_ready_family_manifest(
+ workspace,
+ prefix=prefix,
+ scope_id=scope,
+ rows=[
+ ("FAM_RANDOM_ALPHA", "random alpha family", candidate_comparison, support),
+ (
+ "FAM_RANDOM_BETA",
+ "random beta family",
+ candidate_comparison,
+ f"KEGG:{supported_pathway_a}:random pathway alpha",
+ ),
+ ],
+ )
+ return {
+ "prefix": prefix,
+ "scope": scope,
+ "candidate_comparison": candidate_comparison,
+ "unrelated_comparison": unrelated_comparison,
+ "supported_pathway_a": supported_pathway_a,
+ "supported_pathway_b": supported_pathway_b,
+ "unrelated_pathway": unrelated_pathway,
+ "go_stage": go_stage,
+ "kegg_stage": kegg_stage,
+ }
+
+def test_report_is_deterministic_scope_bound_and_reusable(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_scope"
+ _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-alpha")
+ _write_manifest(workspace, "Os", "rnaseq_de", "success", "scope-beta")
+ _write_manifest(workspace, "Os", "rnaseq_report", "success", "scope-alpha", suffix=".old")
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-alpha",
+ expected_stages=["rnaseq_qc"],
+ use_model=False,
+ )
+
+ assert result.coverage_status == "complete"
+ assert result.included_stages == ("rnaseq_qc",)
+ assert result.rnaseq_scope_id == "scope-alpha"
+ markdown = result.report_path.read_text(encoding="utf-8")
+ assert "## 0. 报告摘要" in markdown
+ assert "## 7. 证据追溯" in markdown
+ assert "rnaseq_de" not in markdown
+ evidence = json.loads(result.evidence_path.read_text(encoding="utf-8"))
+ assert evidence["formal_report"]["source_fingerprint"]
+ assert {item["stage"] for item in evidence["formal_report"]["source_manifests"]} == {"rnaseq_qc"}
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ assert manifest["status"] == "success"
+ assert manifest["metrics"]["coverage_status"] == "complete"
+ assert manifest["metrics"]["model_status"] == "disabled"
+
+ reopened = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-alpha",
+ expected_stages=["rnaseq_qc"],
+ use_model=False,
+ action="open",
+ )
+ assert reopened.reused is True
+
+
+def test_successful_report_can_have_partial_coverage(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_partial"
+ _write_manifest(workspace, "Ft", "rnaseq_qc", "success", "scope-1")
+ _write_manifest(workspace, "Ft", "rnaseq_de", "failed", "scope-1")
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Ft",
+ rnaseq_scope_id="scope-1",
+ expected_stages=["rnaseq_qc", "rnaseq_de"],
+ use_model=False,
+ )
+
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ assert manifest["status"] == "success"
+ assert manifest["metrics"]["coverage_status"] == "partial"
+ assert manifest["metrics"]["stage_statuses"]["rnaseq_de"] == "failed"
+ assert result.included_stages == ("rnaseq_qc",)
+
+
+def test_report_relocates_stale_absolute_artifact_paths(tmp_path: Path) -> None:
+ workspace = tmp_path / "relocated_job"
+ manifest_path = _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-moved")
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ manifest["artifacts"]["result_table"]["full_path"] = "/old/server/workspace/final/result.tsv"
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-moved",
+ expected_stages=["rnaseq_qc"],
+ use_model=False,
+ )
+
+ markdown = result.report_path.read_text(encoding="utf-8")
+ assert "指标 sample_count" in markdown
+ assert "manifest 登记但文件不存在" not in markdown
+ evidence = json.loads(result.evidence_path.read_text(encoding="utf-8"))
+ result_card = next(card for card in evidence["evidence_cards"] if card["claim"] == "产物 result_table")
+ assert "exists=True" in result_card["evidence"]
+
+
+def test_no_successful_rnaseq_stage_does_not_create_empty_report(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_empty"
+ _write_manifest(workspace, "At", "rnaseq_de", "failed", "scope-empty")
+
+ with pytest.raises(NoSuccessfulRNASeqEvidence, match="暂无可生成"):
+ generate_rnaseq_report(
+ workspace,
+ "At",
+ rnaseq_scope_id="scope-empty",
+ expected_stages=["rnaseq_de"],
+ use_model=False,
+ )
+
+ assert not (workspace / "final" / "At.rnaseq_report.md").exists()
+
+
+def test_fallback_selects_one_of_each_enrichment_plot_and_deduplicates_pathway_ids(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_enrichment_figures"
+ _write_enrichment_manifests(workspace)
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-enrichment",
+ expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"],
+ use_model=False,
+ )
+ markdown = result.report_path.read_text(encoding="utf-8")
+ embedded = _embedded_image_names(markdown)
+ inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"][
+ "figure_inventory"
+ ]
+
+ assert sum(name.endswith(".GO_enrich.barplot.png") for name in embedded) == 1
+ assert sum(name.endswith(".GO_enrich.dotplot.png") for name in embedded) == 1
+ assert sum(name.endswith(".GO_enrich.ontology_barplot.png") for name in embedded) == 1
+ assert sum(name.endswith("_KEGG_enrich.png") for name in embedded) == 1
+ assert sum(name.endswith("_KEGG_dotplot.png") for name in embedded) == 1
+ assert sum(name.endswith(".fgsea_kegg_dotplot.png") for name in embedded) == 1
+ assert sum(name.endswith(".fgsea_kegg_top_enrichment.png") for name in embedded) == 1
+ assert not any(name.endswith("GO_enrich.barplot.pdf") for name in embedded)
+ assert not any(name.endswith("GO_enrich.barplot.svg") for name in embedded)
+ assert not any(name.endswith("KEGG_enrich.pdf") for name in embedded)
+
+ required_pathway_maps = [
+ "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00073.png",
+ "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00196.png",
+ ]
+ for filename in required_pathway_maps:
+ assert embedded.count(filename) == 1
+ assert not any(
+ name == "Os.rnaseq_kegg_enrich.HH11__48_vs_HH11__0.KEGG_pathway_map.ko00073.png"
+ for name in embedded
+ )
+
+
+def test_each_selected_enrichment_figure_has_four_part_table_bound_interpretation(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_enrichment_explanations"
+ _write_enrichment_manifests(workspace)
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-enrichment",
+ expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"],
+ use_model=False,
+ )
+ markdown = result.report_path.read_text(encoding="utf-8")
+ expected_evidence = {
+ "Os.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich.barplot.png": "salt stress response",
+ "Os.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich.dotplot.png": "GO:0009651",
+ "Os.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich.ontology_barplot.png": "BP",
+ "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0_KEGG_enrich.png": "Cutin, suberine and wax biosynthesis",
+ "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0_KEGG_dotplot.png": "ko00073",
+ "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00073.png": "OsWax1",
+ "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00196.png": "OsLight1",
+ "Os.rnaseq_fgsea.IR29__6_vs_IR29__0.fgsea_kegg_dotplot.png": "NES",
+ "Os.rnaseq_fgsea.IR29__6_vs_IR29__0.fgsea_kegg_top_enrichment.png": "leadingEdge",
+ }
+ for filename, evidence_value in expected_evidence.items():
+ section = _figure_section(markdown, filename)
+ assert "图形含义:" in section
+ assert "阅读方法:" in section
+ assert "本次结果:" in section
+ assert "解释边界:" in section
+ assert evidence_value in section
+
+
+def test_combined_rnaseq_enrich_stage_keeps_all_plot_forms_and_deduplicates_pathway_ids(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_combined_enrichment"
+ go_a = "Os.rnaseq_enrich.HH11__24_vs_HH11__0.GO_enrich"
+ go_b = "Os.rnaseq_enrich.IR29__6_vs_IR29__0.GO_enrich"
+ kegg_a = "Os.rnaseq_enrich.IR29__6_vs_IR29__0"
+ kegg_b = "Os.rnaseq_enrich.HH11__48_vs_HH11__0"
+ go_header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ kegg_header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_enrich",
+ "scope-combined-enrichment",
+ {
+ "go_a_table": (
+ f"{go_a}.txt",
+ go_header
+ + "GO:0009651\tsalt stress response\tBP\t4/20\t0.001\t4\tOsSalt1/OsSalt2\n"
+ + "GO:0009507\tchloroplast\tCC\t3/20\t0.002\t3\tOsChl1/OsChl2\n"
+ + "GO:0016491\toxidoreductase activity\tMF\t2/20\t0.003\t2\tOsOx1/OsOx2\n",
+ ),
+ "go_b_table": (
+ f"{go_b}.txt",
+ go_header + "GO:0015979\tphotosynthesis\tBP\t3/20\t0.04\t3\tOsLight1/OsLight2\n",
+ ),
+ "go_a_bar_png": (f"{go_a}.barplot.png", "png"),
+ "go_a_bar_pdf": (f"{go_a}.barplot.pdf", "pdf"),
+ "go_b_bar": (f"{go_b}.barplot.png", "png"),
+ "go_a_dot": (f"{go_a}.dotplot.png", "png"),
+ "go_b_dot": (f"{go_b}.dotplot.png", "png"),
+ "go_a_ontology": (f"{go_a}.ontology_barplot.png", "png"),
+ "go_b_ontology": (f"{go_b}.ontology_barplot.png", "png"),
+ "kegg_a_table": (
+ f"{kegg_a}.KEGG_enrich.txt",
+ kegg_header
+ + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t0.001\t2\tOsWax1/OsWax2\n"
+ + "ko00196\tPhotosynthesis - antenna proteins\t2/20\t0.002\t2\tOsLight1/OsLight2\n",
+ ),
+ "kegg_b_table": (
+ f"{kegg_b}.KEGG_enrich.txt",
+ kegg_header + "ko00073\tCutin, suberine and wax biosynthesis\t1/20\t0.04\t1\tOsWaxH\n",
+ ),
+ "kegg_a_hits": (
+ f"{kegg_a}.KEGG_pathway_gene_hits.txt",
+ "PathwayID\tPathwayName\tKO\tGenes\n"
+ "ko00073\tCutin, suberine and wax biosynthesis\tK00073\tOsWax1/OsWax2\n"
+ "ko00196\tPhotosynthesis - antenna proteins\tK00196\tOsLight1/OsLight2\n",
+ ),
+ "kegg_b_hits": (
+ f"{kegg_b}.KEGG_pathway_gene_hits.txt",
+ "PathwayID\tPathwayName\tKO\tGenes\n"
+ "ko00073\tCutin, suberine and wax biosynthesis\tK10073\tOsWaxH\n",
+ ),
+ "kegg_a_bar_png": (f"{kegg_a}_KEGG_enrich.png", "png"),
+ "kegg_a_bar_pdf": (f"{kegg_a}_KEGG_enrich.pdf", "pdf"),
+ "kegg_b_bar": (f"{kegg_b}_KEGG_enrich.png", "png"),
+ "kegg_a_dot": (f"{kegg_a}_KEGG_dotplot.png", "png"),
+ "kegg_b_dot": (f"{kegg_b}_KEGG_dotplot.png", "png"),
+ "pathway_a_00073_png": (f"{kegg_a}.KEGG_pathway_map.ko00073.png", "png"),
+ "pathway_a_00073_pdf": (f"{kegg_a}.KEGG_pathway_map.ko00073.pdf", "pdf"),
+ "pathway_a_00196": (f"{kegg_a}.KEGG_pathway_map.ko00196.png", "png"),
+ "pathway_b_00073": (f"{kegg_b}.KEGG_pathway_map.ko00073.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-combined-enrichment",
+ expected_stages=["rnaseq_enrich"],
+ use_model=False,
+ )
+ markdown = result.report_path.read_text(encoding="utf-8")
+ embedded = _embedded_image_names(markdown)
+ inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"][
+ "figure_inventory"
+ ]
+
+ assert sum(name.endswith(".GO_enrich.barplot.png") for name in embedded) == 1
+ assert sum(name.endswith(".GO_enrich.dotplot.png") for name in embedded) == 1
+ assert sum(name.endswith(".GO_enrich.ontology_barplot.png") for name in embedded) == 1
+ assert sum(name.endswith("_KEGG_enrich.png") for name in embedded) == 1
+ assert sum(name.endswith("_KEGG_dotplot.png") for name in embedded) == 1
+ assert not any(name.endswith((".pdf", ".svg")) for name in embedded)
+ assert {
+ category: sum(item["category"] == category for item in inventory)
+ for category in ("go_bar", "go_dot", "go_ontology", "kegg_bar", "kegg_dot", "kegg_pathway")
+ } == {
+ "go_bar": 1,
+ "go_dot": 1,
+ "go_ontology": 1,
+ "kegg_bar": 1,
+ "kegg_dot": 1,
+ "kegg_pathway": 2,
+ }
+ assert not any(item["filename"].endswith(".pdf") for item in inventory)
+
+ pathway_names = [name for name in embedded if ".KEGG_pathway_map.ko" in name]
+ assert pathway_names == sorted(
+ [
+ f"{kegg_a}.KEGG_pathway_map.ko00073.png",
+ f"{kegg_a}.KEGG_pathway_map.ko00196.png",
+ ]
+ )
+ expected_evidence = {
+ f"{go_a}.barplot.png": "salt stress response",
+ f"{go_a}.dotplot.png": "GO:0009651",
+ f"{go_a}.ontology_barplot.png": "MF=oxidoreductase activity",
+ f"{kegg_a}_KEGG_enrich.png": "Cutin, suberine and wax biosynthesis",
+ f"{kegg_a}_KEGG_dotplot.png": "ko00073",
+ f"{kegg_a}.KEGG_pathway_map.ko00073.png": "OsWax1",
+ f"{kegg_a}.KEGG_pathway_map.ko00196.png": "OsLight1",
+ }
+ for filename, evidence_value in expected_evidence.items():
+ section = _figure_section(markdown, filename)
+ assert all(
+ label in section
+ for label in ("图形含义:", "阅读方法:", "本次结果:", "解释边界:")
+ )
+ assert evidence_value in section
+
+
+def test_enrichment_selection_does_not_remove_de_or_other_stage_figures(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_non_enrichment_figures"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_de",
+ "scope-all-figures",
+ {
+ "de_volcano_a": ("Os.rnaseq_de.HH11__24_vs_HH11__0.volcano.png", "png"),
+ "de_volcano_b": ("Os.rnaseq_de.IR29__6_vs_IR29__0.volcano.png", "png"),
+ },
+ )
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_qc",
+ "scope-all-figures",
+ {
+ "qc_before": ("Os.rnaseq_qc.before.png", "png"),
+ "qc_after": ("Os.rnaseq_qc.after.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-all-figures",
+ expected_stages=["rnaseq_qc", "rnaseq_de"],
+ use_model=False,
+ )
+ markdown = result.report_path.read_text(encoding="utf-8")
+ embedded = _embedded_image_names(markdown)
+
+ for filename in (
+ "Os.rnaseq_de.HH11__24_vs_HH11__0.volcano.png",
+ "Os.rnaseq_de.IR29__6_vs_IR29__0.volcano.png",
+ "Os.rnaseq_qc.before.png",
+ "Os.rnaseq_qc.after.png",
+ ):
+ assert embedded.count(filename) == 1
+
+
+def test_open_regenerates_v3_report_with_renderer_v4(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_legacy_report"
+ _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-renderer")
+ generated = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-renderer",
+ expected_stages=["rnaseq_qc"],
+ use_model=False,
+ )
+ generated.report_path.write_text("STALE LEGACY REPORT\n", encoding="utf-8")
+ old_manifest = json.loads(generated.manifest_path.read_text(encoding="utf-8"))
+ old_manifest["metrics"]["report_renderer_version"] = "3"
+ generated.manifest_path.write_text(json.dumps(old_manifest, ensure_ascii=False), encoding="utf-8")
+
+ reopened = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-renderer",
+ expected_stages=["rnaseq_qc"],
+ use_model=False,
+ action="open",
+ )
+
+ assert reopened.reused is False
+ assert "STALE LEGACY REPORT" not in reopened.report_path.read_text(encoding="utf-8")
+ refreshed_manifest = json.loads(reopened.manifest_path.read_text(encoding="utf-8"))
+ assert refreshed_manifest["metrics"]["report_renderer_version"] == "4"
+
+
+def test_pathway_without_matching_kegg_row_reports_insufficient_evidence(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_pathway_mismatch"
+ base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-pathway-mismatch",
+ {
+ "kegg_table": (
+ f"{base}.KEGG_enrich.txt",
+ "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t0.001\t2\tOsWax1/OsWax2\n",
+ ),
+ "unmatched_pathway": (f"{base}.KEGG_pathway_map.ko99999.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-pathway-mismatch",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko99999.png")
+
+ assert "证据不足" in section
+ assert "Cutin, suberine and wax biosynthesis" not in section
+
+
+def test_pathway_without_ko_mapping_does_not_summarize_all_ko_fold_changes(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_pathway_without_ko_mapping"
+ base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-no-ko-map",
+ {
+ "kegg_table": (
+ f"{base}.KEGG_enrich.txt",
+ "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t0.001\t2\tOsWax1/OsWax2\n",
+ ),
+ "pathway_hits_without_ko": (
+ f"{base}.KEGG_pathway_gene_hits.txt",
+ "PathwayID\tPathwayName\tGenes\n"
+ "ko00073\tCutin, suberine and wax biosynthesis\tOsWax1/OsWax2\n",
+ ),
+ "unscoped_ko_fc": (
+ f"{base}.KEGG_pathway_map.ko_log2fc.tsv",
+ "KO\tlog2FoldChange\nK11111\t2.5\nK22222\t-1.5\n",
+ ),
+ "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-no-ko-map",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png")
+
+ assert "KO对应关系证据不足" in section
+ assert "KO变化中上调1个、下调1个" not in section
+
+
+def test_kegg_representative_selection_ignores_raw_enrichment_tables(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_kegg_raw_selection"
+ header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ a = "Os.rnaseq_kegg_enrich.AA__1_vs_AA__0"
+ b = "Os.rnaseq_kegg_enrich.BB__1_vs_BB__0"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-kegg-raw-selection",
+ {
+ "a_filtered": (f"{a}.KEGG_enrich.txt", header + "ko00001\tFiltered A\t1/20\t0.05\t1\tOsA\n"),
+ "a_raw": (f"{a}.KEGG_enrich.raw.txt", header + "ko00001\tRaw A\t1/20\t0.000000001\t1\tOsA\n"),
+ "a_bar": (f"{a}_KEGG_enrich.png", "png"),
+ "b_filtered": (f"{b}.KEGG_enrich.txt", header + "ko00002\tFiltered B\t2/20\t0.01\t2\tOsB1/OsB2\n"),
+ "b_raw": (f"{b}.KEGG_enrich.raw.txt", header + "ko00002\tRaw B\t2/20\t0.9\t2\tOsB1/OsB2\n"),
+ "b_bar": (f"{b}_KEGG_enrich.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-kegg-raw-selection",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ embedded = _embedded_image_names(result.report_path.read_text(encoding="utf-8"))
+
+ assert f"{b}_KEGG_enrich.png" in embedded
+ assert f"{a}_KEGG_enrich.png" not in embedded
+
+
+def test_kegg_figure_interpretation_uses_filtered_table_not_raw_table(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_kegg_raw_interpretation"
+ base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0"
+ header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-kegg-raw-interpretation",
+ {
+ "raw_first": (f"{base}.KEGG_enrich.raw.txt", header + "ko99998\tRaw-only pathway\t9/20\t0.0001\t9\tRawGene\n"),
+ "filtered": (f"{base}.KEGG_enrich.txt", header + "ko00073\tFiltered pathway\t2/20\t0.01\t2\tOsWax1/OsWax2\n"),
+ "bar": (f"{base}_KEGG_enrich.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-kegg-raw-interpretation",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), f"{base}_KEGG_enrich.png")
+
+ assert "Filtered pathway" in section
+ assert "Raw-only pathway" not in section
+
+
+def test_go_ontology_interpretation_covers_bp_cc_and_mf_rows(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_go_three_ontologies"
+ base = "Os.rnaseq_go_enrich.MixedCase__6_vs_MixedCase__0.GO_enrich"
+ header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_go_enrich",
+ "scope-go-ontologies",
+ {
+ "table": (
+ f"{base}.txt",
+ header
+ + "GO:0000001\tbiological process theme\tBP\t3/20\t0.001\t3\tA/B/C\n"
+ + "GO:0000002\tcellular component theme\tCC\t2/20\t0.002\t2\tD/E\n"
+ + "GO:0000003\tmolecular function theme\tMF\t1/20\t0.003\t1\tF\n",
+ ),
+ "ontology": (f"{base}.ontology_barplot.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-go-ontologies",
+ expected_stages=["rnaseq_go_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), "ontology_barplot.png")
+
+ assert "biological process theme" in section
+ assert "cellular component theme" in section
+ assert "molecular function theme" in section
+
+
+def test_svg_fallbacks_are_classified_and_selected_once_per_plot_form(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_svg_fallbacks"
+ go_header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ kegg_header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_go_enrich",
+ "scope-svg-fallback",
+ {
+ "go_a_table": ("Os.rnaseq_go_enrich.AA.GO_enrich.txt", go_header + "GO:1\tA\tBP\t1/2\t0.01\t1\tA\n"),
+ "go_b_table": ("Os.rnaseq_go_enrich.BB.GO_enrich.txt", go_header + "GO:2\tB\tBP\t1/2\t0.02\t1\tB\n"),
+ "go_a_svg": ("Os.rnaseq_go_enrich.AA.GO_enrich.svg", ""),
+ "go_b_svg": ("Os.rnaseq_go_enrich.BB.GO_enrich.svg", ""),
+ },
+ )
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-svg-fallback",
+ {
+ "kegg_a_table": ("Os.rnaseq_kegg_enrich.AA.KEGG_enrich.txt", kegg_header + "ko1\tA\t1/2\t0.01\t1\tA\n"),
+ "kegg_b_table": ("Os.rnaseq_kegg_enrich.BB.KEGG_enrich.txt", kegg_header + "ko2\tB\t1/2\t0.02\t1\tB\n"),
+ "kegg_a_svg": ("Os.rnaseq_kegg_enrich.AA.KEGG_enrich.svg", ""),
+ "kegg_b_svg": ("Os.rnaseq_kegg_enrich.BB.KEGG_enrich.svg", ""),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-svg-fallback",
+ expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ evidence = json.loads(result.evidence_path.read_text(encoding="utf-8"))
+ categories = [item["category"] for item in evidence["formal_report"]["figure_inventory"]]
+
+ assert categories.count("go_bar") == 1
+ assert categories.count("kegg_bar") == 1
+
+
+def test_comparison_case_is_preserved_in_figure_interpretation(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_comparison_case"
+ base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-case",
+ {
+ "table": (
+ f"{base}.KEGG_enrich.txt",
+ "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\nko00073\tWax\t2/20\t0.01\t2\tA/B\n",
+ ),
+ "bar": (f"{base}_KEGG_enrich.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-case",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), f"{base}_KEGG_enrich.png")
+
+ assert "本次结果:比较组 `IR29__6_vs_IR29__0`" in section
+
+
+def test_invalid_adjusted_p_values_do_not_participate_in_representative_selection(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_invalid_padj"
+ header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ artifacts: dict[str, tuple[str, str]] = {}
+ for label, padj in (("NEG", "-0.1"), ("NAN", "NaN"), ("INF", "Inf"), ("VALID", "0.02")):
+ base = f"Os.rnaseq_kegg_enrich.{label}__1_vs_{label}__0"
+ artifacts[f"{label}_table"] = (f"{base}.KEGG_enrich.txt", header + f"ko00001\t{label}\t1/20\t{padj}\t1\tGene\n")
+ artifacts[f"{label}_bar"] = (f"{base}_KEGG_enrich.png", "png")
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-invalid-padj",
+ artifacts,
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-invalid-padj",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ embedded = _embedded_image_names(result.report_path.read_text(encoding="utf-8"))
+
+ assert "Os.rnaseq_kegg_enrich.VALID__1_vs_VALID__0_KEGG_enrich.png" in embedded
+
+
+def test_fgsea_curve_tied_padj_uses_largest_absolute_nes_and_matching_pathway(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_fgsea_tie"
+ header = "pathway\tpathway_name\tpval\tpadj\tES\tNES\tsize\tleadingEdge\n"
+ aa = "Os.rnaseq_fgsea.AA__1_vs_AA__0"
+ zz = "Os.rnaseq_fgsea.ZZ__1_vs_ZZ__0"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_fgsea",
+ "scope-fgsea-tie",
+ {
+ "aa_table": (f"{aa}.fgsea_kegg.tsv", header + "ko00001\tAA pathway\t0.001\t0.01\t0.4\t2.0\t10\tA,B\n"),
+ "aa_curve": (f"{aa}.fgsea_kegg_top_enrichment.png", "png"),
+ "zz_table": (
+ f"{zz}.fgsea_kegg.tsv",
+ header
+ + "ko00002\tZZ weaker tied pathway\t0.001\t0.01\t0.2\t1.0\t10\tC,D\n"
+ + "ko00003\tZZ strongest tied pathway\t0.001\t0.01\t-0.7\t-3.0\t12\tE,F\n",
+ ),
+ "zz_curve": (f"{zz}.fgsea_kegg_top_enrichment.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-fgsea-tie",
+ expected_stages=["rnaseq_fgsea"],
+ use_model=False,
+ )
+ markdown = result.report_path.read_text(encoding="utf-8")
+ embedded = _embedded_image_names(markdown)
+
+ assert f"{zz}.fgsea_kegg_top_enrichment.png" in embedded
+ section = _figure_section(markdown, f"{zz}.fgsea_kegg_top_enrichment.png")
+ assert "ko00003" in section
+ assert "NES=-3.0" in section
+
+
+def test_pdf_only_pathway_is_linked_or_recorded_in_figure_inventory(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_pdf_pathway"
+ base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0"
+ pdf_name = f"{base}.KEGG_pathway_map.ko00073.pdf"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-pdf-pathway",
+ {
+ "table": (
+ f"{base}.KEGG_enrich.txt",
+ "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\nko00073\tWax\t2/20\t0.01\t2\tA/B\n",
+ ),
+ "pdf_pathway": (pdf_name, "pdf"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-pdf-pathway",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ markdown = result.report_path.read_text(encoding="utf-8")
+ evidence = json.loads(result.evidence_path.read_text(encoding="utf-8"))
+ inventory_names = {item["filename"] for item in evidence["formal_report"]["figure_inventory"]}
+ linked = re.search(rf"\[[^]]*\]\([^)]*{re.escape(pdf_name)}[^)]*\)", markdown) is not None
+
+ assert linked or pdf_name in inventory_names
+
+
+def test_renderer_and_figure_inventory_are_consistent_in_evidence_and_manifest(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_report_metadata"
+ _write_enrichment_manifests(workspace)
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-enrichment",
+ expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"],
+ use_model=False,
+ )
+ evidence = json.loads(result.evidence_path.read_text(encoding="utf-8"))
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ formal = evidence["formal_report"]
+ metrics = manifest["metrics"]
+
+ assert formal["report_renderer_version"] == metrics["report_renderer_version"]
+ assert formal["figure_inventory"]
+ assert metrics.get("figure_inventory") == formal["figure_inventory"]
+
+
+def test_go_ontology_interpretation_accepts_real_long_ontology_names(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_go_real_ontology_names"
+ base = "Os.rnaseq_go_enrich.A__1_vs_A__0.GO_enrich"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_go_enrich",
+ "scope-go-real-ontology",
+ {
+ "table": (
+ f"{base}.txt",
+ "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ "GO:0000001\tlong-name BP theme\tBiological process\t3/20\t0.001\t3\tA/B/C\n"
+ "GO:0000002\tlong-name CC theme\tCellular component\t2/20\t0.002\t2\tD/E\n"
+ "GO:0000003\tlong-name MF theme\tMolecular function\t1/20\t0.003\t1\tF\n",
+ ),
+ "ontology": (f"{base}.ontology_barplot.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-go-real-ontology",
+ expected_stages=["rnaseq_go_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), "ontology_barplot.png")
+
+ assert "BP=long-name BP theme" in section
+ assert "CC=long-name CC theme" in section
+ assert "MF=long-name MF theme" in section
+
+
+def test_pathway_interpretation_merges_genes_from_all_matching_ko_rows(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_pathway_multi_ko_hits"
+ base = "Os.rnaseq_kegg_enrich.A__1_vs_A__0"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-pathway-multi-ko",
+ {
+ "enrichment": (
+ f"{base}.KEGG_enrich.txt",
+ "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ "ko00073\tWax pathway\t3/20\t0.001\t3\tGeneA/GeneB/GeneC\n",
+ ),
+ "hits": (
+ f"{base}.KEGG_pathway_gene_hits.txt",
+ "PathwayID\tPathwayName\tKO\tGenes\n"
+ "ko00073\tWax pathway\tK00001\tGeneA/GeneB\n"
+ "ko00073\tWax pathway\tK00002\tGeneC\n",
+ ),
+ "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-pathway-multi-ko",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png")
+
+ assert "GeneA" in section
+ assert "GeneB" in section
+ assert "GeneC" in section
+
+
+def test_figure_uses_exact_comparison_table_when_names_contain_each_other(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_exact_comparison"
+ base_a = "Os.rnaseq_go_enrich.A.GO_enrich"
+ base_aa = "Os.rnaseq_go_enrich.AA.GO_enrich"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_go_enrich",
+ "scope-exact-comparison",
+ {
+ "aa_table": (
+ f"{base_aa}.txt",
+ "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ "GO:0000002\tAA-only evidence\tBP\t2/20\t0.0001\t2\tAA1/AA2\n",
+ ),
+ "a_table": (
+ f"{base_a}.txt",
+ "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ "GO:0000001\tA exact evidence\tBP\t1/20\t0.01\t1\tA1\n",
+ ),
+ "a_bar": (f"{base_a}.barplot.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-exact-comparison",
+ expected_stages=["rnaseq_go_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), f"{base_a}.barplot.png")
+
+ assert "A exact evidence" in section
+ assert "AA-only evidence" not in section
+
+
+@pytest.mark.parametrize("invalid_padj", ["NaN", "Inf", "-0.1"])
+def test_mandatory_pathway_does_not_render_invalid_padj_as_significance(
+ tmp_path: Path, invalid_padj: str
+) -> None:
+ workspace = tmp_path / f"job_invalid_pathway_padj_{invalid_padj.replace('-', 'neg')}"
+ base = "Os.rnaseq_kegg_enrich.A__1_vs_A__0"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-invalid-pathway-padj",
+ {
+ "table": (
+ f"{base}.KEGG_enrich.txt",
+ "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ f"ko00073\tWax pathway\t2/20\t{invalid_padj}\t2\tA/B\n",
+ ),
+ "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-invalid-pathway-padj",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png")
+
+ assert "p.adjust=未登记" in section
+ assert f"p.adjust={invalid_padj}" not in section
+
+
+def test_pdf_only_non_pathway_enrichment_forms_are_linked_and_in_inventory(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_pdf_only_enrichment_forms"
+ go_base = "Os.rnaseq_go_enrich.A__1_vs_A__0.GO_enrich"
+ kegg_base = "Os.rnaseq_kegg_enrich.A__1_vs_A__0"
+ fgsea_base = "Os.rnaseq_fgsea.A__1_vs_A__0"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_go_enrich",
+ "scope-pdf-forms",
+ {
+ "table": (
+ f"{go_base}.txt",
+ "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ "GO:1\tGO evidence\tBP\t1/20\t0.01\t1\tA\n",
+ ),
+ "bar": (f"{go_base}.barplot.pdf", "pdf"),
+ "dot": (f"{go_base}.dotplot.pdf", "pdf"),
+ "ontology": (f"{go_base}.ontology_barplot.pdf", "pdf"),
+ },
+ )
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-pdf-forms",
+ {
+ "table": (
+ f"{kegg_base}.KEGG_enrich.txt",
+ "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ "ko00001\tKEGG evidence\t1/20\t0.01\t1\tA\n",
+ ),
+ "bar": (f"{kegg_base}_KEGG_enrich.pdf", "pdf"),
+ "dot": (f"{kegg_base}_KEGG_dotplot.pdf", "pdf"),
+ },
+ )
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_fgsea",
+ "scope-pdf-forms",
+ {
+ "table": (
+ f"{fgsea_base}.fgsea_kegg.tsv",
+ "pathway\tpathway_name\tpadj\tNES\tleadingEdge\n"
+ "ko00001\tFGSEA evidence\t0.01\t2.0\tA,B\n",
+ ),
+ "dot": (f"{fgsea_base}.fgsea_kegg_dotplot.pdf", "pdf"),
+ "curve": (f"{fgsea_base}.fgsea_kegg_top_enrichment.pdf", "pdf"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-pdf-forms",
+ expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"],
+ use_model=False,
+ )
+ markdown = result.report_path.read_text(encoding="utf-8")
+ evidence = json.loads(result.evidence_path.read_text(encoding="utf-8"))
+ expected = {
+ f"{go_base}.barplot.pdf",
+ f"{go_base}.dotplot.pdf",
+ f"{go_base}.ontology_barplot.pdf",
+ f"{kegg_base}_KEGG_enrich.pdf",
+ f"{kegg_base}_KEGG_dotplot.pdf",
+ f"{fgsea_base}.fgsea_kegg_dotplot.pdf",
+ f"{fgsea_base}.fgsea_kegg_top_enrichment.pdf",
+ }
+ inventory_names = {item["filename"] for item in evidence["formal_report"]["figure_inventory"]}
+
+ assert expected <= inventory_names
+ for filename in expected:
+ assert re.search(rf"\[[^]]*\]\([^)]*{re.escape(filename)}[^)]*\)", markdown)
+
+
+def test_representative_selection_requires_legal_nonempty_evidence_before_format(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_representative_evidence_priority"
+ header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ artifacts: dict[str, tuple[str, str]] = {}
+ cases = (
+ ("EMPTY", "", "png"),
+ ("INVALID", header + "ko1\tinvalid\t1/20\t-0.1\t1\tA\n", "png"),
+ ("VALID_SVG", header + "ko2\tvalid svg\t1/20\t0.04\t1\tB\n", "svg"),
+ ("VALID_PNG", header + "ko3\tvalid png\t1/20\t0.03\t1\tC\n", "png"),
+ )
+ for label, table_content, extension in cases:
+ base = f"Os.rnaseq_kegg_enrich.{label}"
+ artifacts[f"{label}_table"] = (f"{base}.KEGG_enrich.txt", table_content)
+ artifacts[f"{label}_figure"] = (f"{base}_KEGG_enrich.{extension}", "figure")
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-representative-priority",
+ artifacts,
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-representative-priority",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]["figure_inventory"]
+ selected = [item["filename"] for item in inventory if item["category"] == "kegg_bar"]
+
+ assert selected == ["Os.rnaseq_kegg_enrich.VALID_PNG_KEGG_enrich.png"]
+
+
+def test_model_enhancement_rejects_numbers_and_activation_claims_absent_from_evidence(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from gfa_core.mas import llm as llm_module
+ from gfa_core.mas.config import config
+
+ class FakeManager:
+ def __init__(self, _config: object) -> None:
+ pass
+
+ def call(self, *_args: object, **_kwargs: object) -> object:
+ return type(
+ "Response",
+ (),
+ {
+ "content": (
+ "模型声称42个基因显著变化,因此该通路被激活并直接导致抗逆性增强;"
+ "同时还声称处理后产量提高了37%,这些数字与因果结论均不在输入证据中。"
+ ),
+ "provider": "fake",
+ "model": "fake-model",
+ },
+ )()
+
+ monkeypatch.setattr(config, "load_llm_config", lambda: {})
+ monkeypatch.setattr(llm_module, "LLMManager", FakeManager)
+ evidence = {
+ "metadata": {"included_stages": ["rnaseq_kegg_enrich"]},
+ "biological_story": "检测到KEGG富集结果,具体证据见结果表。",
+ "key_findings": [],
+ "major_claims": [],
+ "risk_flags": [],
+ }
+
+ summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True)
+
+ assert summary == ""
+ assert metadata["status"] == "fallback"
+ assert metadata["error"]
+
+
+def _install_fake_model_response(monkeypatch: pytest.MonkeyPatch, content: str) -> None:
+ from gfa_core.mas import llm as llm_module
+ from gfa_core.mas.config import config
+
+ class FakeManager:
+ def __init__(self, _config: object) -> None:
+ pass
+
+ def call(self, *_args: object, **_kwargs: object) -> object:
+ return type(
+ "Response",
+ (),
+ {"content": content, "provider": "fake", "model": "fake-model"},
+ )()
+
+ monkeypatch.setattr(config, "load_llm_config", lambda: {})
+ monkeypatch.setattr(llm_module, "LLMManager", FakeManager)
+
+
+def test_free_form_model_output_is_rejected_and_never_embedded_in_markdown(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ workspace = tmp_path / "job_model_not_embedded"
+ _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-model-not-embedded")
+ model_text = (
+ "现有证据仅支持对已完成阶段进行描述性总结,各项结果仍需结合实验设计和原始结果表审慎解释,"
+ "不应扩展为未被数据支持的生物学结论。"
+ )
+ _install_fake_model_response(monkeypatch, model_text)
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-model-not-embedded",
+ expected_stages=["rnaseq_qc"],
+ use_model=True,
+ )
+
+ markdown = result.report_path.read_text(encoding="utf-8")
+ model_record = json.loads(result.model_path.read_text(encoding="utf-8"))
+ assert model_text not in markdown
+ assert "### 模型辅助语言润色(不作为新增证据)" in markdown
+ assert "API 仅参与已有证据语句的排序和组织" in markdown
+ assert model_record["content"] == model_text
+ assert model_record["status"] == "fallback"
+ assert model_record["policy"] == "evidence_claim_ordering_only"
+ assert model_record["error"]
+
+
+def test_noncausal_free_form_model_text_is_still_rejected(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ model_text = (
+ "这些富集结果只能说明统计关联,不能证明因果关系,也无法认定相关通路被激活,"
+ "仍需结合独立实验进行验证。"
+ )
+ _install_fake_model_response(monkeypatch, model_text)
+ evidence = {
+ "metadata": {"included_stages": ["rnaseq_kegg_enrich"]},
+ "biological_story": "检测到KEGG富集结果,具体证据见结果表。",
+ "key_findings": [],
+ "major_claims": [],
+ "risk_flags": [],
+ }
+
+ summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True)
+
+ assert summary == ""
+ assert metadata["content"] == model_text
+ assert metadata["status"] == "fallback"
+ assert metadata["policy"] == "evidence_claim_ordering_only"
+ assert metadata["error"]
+
+
+def test_model_cannot_embed_mechanistic_claims_even_when_all_numbers_exist_in_evidence(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ model_text = (
+ "已有结果中的12个基因显著上调并促进耐盐,揭示了该通路控制抗逆性的调控机制,"
+ "因此可作为后续育种的确定依据。"
+ )
+ _install_fake_model_response(monkeypatch, model_text)
+ evidence = {
+ "metadata": {"included_stages": ["rnaseq_kegg_enrich"]},
+ "biological_story": "结果表登记12个命中基因。",
+ "key_findings": [],
+ "major_claims": [],
+ "risk_flags": [],
+ }
+
+ summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True)
+
+ assert summary == ""
+ assert metadata["content"] == model_text
+ assert metadata["status"] == "fallback"
+ assert metadata["policy"] == "evidence_claim_ordering_only"
+
+
+def test_model_can_only_order_and_group_approved_evidence_claims(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ evidence = {
+ "metadata": {"included_stages": ["rnaseq_qc"]},
+ "biological_story": "质控阶段已经完成,具体质量指标见结果表。",
+ "key_findings": [
+ {"stage": "rnaseq_qc", "description": "成功阶段提供了可追溯的质控结果。"}
+ ],
+ "major_claims": [],
+ "risk_flags": [],
+ }
+ organization = {
+ "paragraph_groups": [["finding_1", "story_1"]],
+ }
+ _install_fake_model_response(monkeypatch, json.dumps(organization, ensure_ascii=False))
+
+ summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True)
+
+ assert summary == "成功阶段提供了可追溯的质控结果。质控阶段已经完成,具体质量指标见结果表。"
+ assert metadata["status"] == "embedded_evidence_only"
+ assert metadata["policy"] == "evidence_claim_ordering_only"
+ assert metadata["organization_plan"] == organization
+ assert metadata["selected_claim_ids"] == ["finding_1", "story_1"]
+ assert {row["claim_id"] for row in metadata["claim_catalog"]} == {"story_1", "finding_1"}
+
+
+def test_unknown_model_claim_id_forces_deterministic_fallback(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ evidence = {
+ "metadata": {"included_stages": ["rnaseq_qc"]},
+ "biological_story": "质控阶段已经完成。",
+ "key_findings": [],
+ "major_claims": [],
+ "risk_flags": [],
+ }
+ _install_fake_model_response(
+ monkeypatch,
+ json.dumps({"paragraph_groups": [["story_1", "invented_claim"]]}, ensure_ascii=False),
+ )
+
+ summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True)
+
+ assert summary == ""
+ assert metadata["status"] == "fallback"
+ assert "invented_claim" in metadata["error"]
+
+
+def test_report_always_states_the_api_evidence_boundary(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_model_boundary"
+ _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-model-boundary")
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-model-boundary",
+ expected_stages=["rnaseq_qc"],
+ use_model=False,
+ )
+ markdown = result.report_path.read_text(encoding="utf-8")
+
+ assert "### 模型辅助语言润色(不作为新增证据)" in markdown
+ assert "API 仅参与已有证据语句的排序和组织" in markdown
+ assert "不参与结果分析或推断" in markdown
+ assert "未采用模型组织结果" in markdown
+
+
+def test_pathway_interpretation_previews_only_twelve_genes_and_kos_and_links_full_tables(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_pathway_long_lists"
+ base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0"
+ genes = [f"Gene{index:03d}" for index in range(1, 456)]
+ kos = [f"K{index:05d}" for index in range(1, 456)]
+ hit_rows = "".join(
+ f"ko00073\tWax pathway\t{ko}\t{gene}\n" for ko, gene in zip(kos, genes)
+ )
+ ko_rows = "".join(f"{ko}\t1.0\n" for ko in kos)
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_kegg_enrich",
+ "scope-pathway-long-lists",
+ {
+ "filtered": (
+ f"{base}.KEGG_enrich.txt",
+ "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n"
+ f"ko00073\tWax pathway\t455/1000\t0.001\t455\t{'/'.join(genes)}\n",
+ ),
+ "hits": (
+ f"{base}.KEGG_pathway_gene_hits.txt",
+ "PathwayID\tPathwayName\tKO\tGenes\n" + hit_rows,
+ ),
+ "ko_log2fc": (
+ f"{base}.KEGG_pathway_map.ko_log2fc.tsv",
+ "KO\tlog2FoldChange\n" + ko_rows,
+ ),
+ "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-pathway-long-lists",
+ expected_stages=["rnaseq_kegg_enrich"],
+ use_model=False,
+ )
+ section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png")
+
+ assert "命中基因总数=455" in section
+ assert "KO总数=455" in section
+ assert all(gene in section for gene in genes[:12])
+ assert all(ko in section for ko in kos[:12])
+ assert genes[12] not in section
+ assert kos[12] not in section
+ assert f"[{base}.KEGG_pathway_gene_hits.txt]" in section
+ assert f"[{base}.KEGG_pathway_map.ko_log2fc.tsv]" in section
+
+
+def test_fgsea_leading_edge_previews_twelve_genes_and_links_full_result_table(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_fgsea_long_leading_edge"
+ base = "Os.rnaseq_fgsea.IR29__6_vs_IR29__0"
+ leading_edge = [f"LeadGene{index:03d}" for index in range(1, 31)]
+ table_name = f"{base}.fgsea_kegg.tsv"
+ _write_artifact_manifest(
+ workspace,
+ "Os",
+ "rnaseq_fgsea",
+ "scope-fgsea-long-leading-edge",
+ {
+ "fgsea_table": (
+ table_name,
+ "pathway\tpathway_name\tpadj\tNES\tsize\tleadingEdge\n"
+ f"ko00196\tPhotosynthesis - antenna proteins\t0.001\t2.1\t30\t{','.join(leading_edge)}\n",
+ ),
+ "fgsea_curve": (f"{base}.fgsea_kegg_top_enrichment.png", "png"),
+ },
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-fgsea-long-leading-edge",
+ expected_stages=["rnaseq_fgsea"],
+ use_model=False,
+ )
+ markdown = result.report_path.read_text(encoding="utf-8")
+ section = _figure_section(markdown, f"{base}.fgsea_kegg_top_enrichment.png")
+
+ assert "leadingEdge总数=30" in section
+ assert "前12项=" in section
+ assert all(gene in section for gene in leading_edge[:12])
+ assert all(gene not in section for gene in leading_edge[12:])
+ assert f"[{table_name}](" in section
+ assert (
+ "> 报告生成原则:结果判断只来自脚本、Manifest 和结果表;API 仅参与已有证据语句的排序和组织,"
+ "不参与结果分析或推断。" in markdown
+ )
+
+
+def test_candidate_guided_pathways_keep_all_supported_contents_and_omit_unrelated(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_candidate_guided_pathways"
+ ids = _write_candidate_guided_enrichment_manifests(workspace)
+
+ result = generate_rnaseq_report(
+ workspace,
+ ids["prefix"],
+ rnaseq_scope_id=ids["scope"],
+ expected_stages=[
+ "rnaseq_go_enrich",
+ "rnaseq_kegg_enrich",
+ "rnaseq_fgsea",
+ "rnaseq_family_score",
+ ],
+ use_model=False,
+ )
+ embedded = _embedded_image_names(result.report_path.read_text(encoding="utf-8"))
+ evidence = json.loads(result.evidence_path.read_text(encoding="utf-8"))
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ formal = evidence["formal_report"]
+ pathway_rows = [
+ row for row in formal["figure_inventory"] if row["category"] == "kegg_pathway"
+ ]
+
+ assert formal["selection_mode"] == "candidate_guided"
+ assert {row["pathway_id"] for row in pathway_rows} == {
+ ids["supported_pathway_a"],
+ ids["supported_pathway_b"],
+ }
+ assert all(row["comparison"] == ids["candidate_comparison"] for row in pathway_rows)
+ assert not any(ids["unrelated_pathway"] in name for name in embedded)
+ assert not any(
+ ids["unrelated_comparison"] in name and ids["supported_pathway_a"] in name
+ for name in embedded
+ )
+
+ shared = next(
+ row for row in pathway_rows if row["pathway_id"] == ids["supported_pathway_a"]
+ )
+ assert shared["candidate_families"] == ["FAM_RANDOM_ALPHA", "FAM_RANDOM_BETA"]
+ assert any(
+ label.startswith(f"KEGG:{ids['supported_pathway_a']}:")
+ for label in shared["support_labels"]
+ )
+ assert shared["selection_reason"] == "candidate_family_support"
+ assert sum(ids["supported_pathway_a"] in name for name in embedded) == 1
+
+ omitted = formal["omitted_figure_inventory"]
+ assert any(row["pathway_id"] == ids["unrelated_pathway"] for row in omitted)
+ assert formal["omitted_figure_count"] == len(omitted)
+ assert manifest["metrics"]["selection_mode"] == formal["selection_mode"]
+ assert manifest["metrics"]["omitted_figure_count"] == formal["omitted_figure_count"]
+
+
+def test_candidate_guided_representatives_use_candidate_comparison_and_supported_fgsea(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_candidate_guided_forms"
+ ids = _write_candidate_guided_enrichment_manifests(workspace)
+
+ result = generate_rnaseq_report(
+ workspace,
+ ids["prefix"],
+ rnaseq_scope_id=ids["scope"],
+ expected_stages=[
+ "rnaseq_go_enrich",
+ "rnaseq_kegg_enrich",
+ "rnaseq_fgsea",
+ "rnaseq_family_score",
+ ],
+ use_model=False,
+ )
+ inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"][
+ "figure_inventory"
+ ]
+ selected_categories = {
+ category: [row for row in inventory if row["category"] == category]
+ for category in (
+ "go_bar",
+ "go_dot",
+ "go_ontology",
+ "kegg_bar",
+ "kegg_dot",
+ "fgsea_dot",
+ "fgsea_curve",
+ )
+ }
+
+ assert all(len(rows) == 1 for rows in selected_categories.values())
+ assert all(
+ rows[0]["comparison"] == ids["candidate_comparison"]
+ for rows in selected_categories.values()
+ )
+ assert all(rows[0]["candidate_families"] for rows in selected_categories.values())
+ assert all(
+ rows[0]["selection_reason"] == "candidate_family_support"
+ for rows in selected_categories.values()
+ )
+ fgsea_curve = selected_categories["fgsea_curve"][0]
+ assert fgsea_curve["candidate_families"] == ["FAM_RANDOM_ALPHA"]
+ assert fgsea_curve["support_labels"] == [
+ f"fgsea:{ids['supported_pathway_b']}:random pathway beta"
+ ]
+
+
+def test_fgsea_curve_requires_exact_candidate_support_for_its_actual_top_pathway(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_fgsea_curve_exact_identity"
+ ids = _write_candidate_guided_enrichment_manifests(workspace)
+ fgsea_table = (
+ workspace
+ / "final"
+ / f"{ids['prefix']}.rnaseq_fgsea.{ids['candidate_comparison']}.fgsea_kegg.tsv"
+ )
+ fgsea_table.write_text(
+ "pathway\tpathway_name\tpadj\tNES\tsize\tleadingEdge\n"
+ "ko99999\tnon-candidate top pathway\t0.000001\t3.5\t20\tOtherA,OtherB\n"
+ f"{ids['supported_pathway_b']}\trandom pathway beta\t0.04\t2.1\t12\tGeneB,GeneC\n",
+ encoding="utf-8",
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ ids["prefix"],
+ rnaseq_scope_id=ids["scope"],
+ expected_stages=[
+ "rnaseq_go_enrich",
+ "rnaseq_kegg_enrich",
+ "rnaseq_fgsea",
+ "rnaseq_family_score",
+ ],
+ use_model=False,
+ )
+ formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]
+ selected = formal["figure_inventory"]
+ omitted = formal["omitted_figure_inventory"]
+ fgsea_dots = [row for row in selected if row["category"] == "fgsea_dot"]
+ fgsea_curves = [row for row in selected if row["category"] == "fgsea_curve"]
+
+ assert len(fgsea_dots) == 1
+ assert fgsea_dots[0]["comparison"] == ids["candidate_comparison"]
+ assert fgsea_dots[0]["candidate_families"] == ["FAM_RANDOM_ALPHA"]
+ assert fgsea_curves == []
+ assert any(row["category"] == "fgsea_curve" for row in omitted)
+
+
+def test_fgsea_is_excluded_from_candidate_mainline_without_fgsea_support(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_candidate_without_fgsea"
+ ids = _write_candidate_guided_enrichment_manifests(
+ workspace,
+ include_fgsea_support=False,
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ ids["prefix"],
+ rnaseq_scope_id=ids["scope"],
+ expected_stages=[
+ "rnaseq_go_enrich",
+ "rnaseq_kegg_enrich",
+ "rnaseq_fgsea",
+ "rnaseq_family_score",
+ ],
+ use_model=False,
+ )
+ formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]
+ categories = {row["category"] for row in formal["figure_inventory"]}
+ omitted_categories = {row["category"] for row in formal["omitted_figure_inventory"]}
+
+ assert "fgsea_dot" not in categories
+ assert "fgsea_curve" not in categories
+ assert {"fgsea_dot", "fgsea_curve"}.issubset(omitted_categories)
+
+
+@pytest.mark.parametrize("analysis_ready_state", ["missing", "empty"])
+def test_missing_or_empty_analysis_ready_table_uses_non_candidate_fallback(
+ tmp_path: Path,
+ analysis_ready_state: str,
+) -> None:
+ workspace = tmp_path / f"job_fallback_{analysis_ready_state}"
+ _write_enrichment_manifests(workspace)
+ expected_stages = ["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"]
+ if analysis_ready_state == "empty":
+ _write_analysis_ready_family_manifest(
+ workspace,
+ prefix="Os",
+ scope_id="scope-enrichment",
+ rows=[],
+ )
+ expected_stages.append("rnaseq_family_score")
+
+ result = generate_rnaseq_report(
+ workspace,
+ "Os",
+ rnaseq_scope_id="scope-enrichment",
+ expected_stages=expected_stages,
+ use_model=False,
+ )
+ formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]
+ inventory = formal["figure_inventory"]
+ categories = [row["category"] for row in inventory]
+ pathway_rows = [row for row in inventory if row["category"] == "kegg_pathway"]
+
+ assert formal["selection_mode"] == "representative_fallback"
+ assert {
+ category: categories.count(category)
+ for category in ("go_bar", "go_dot", "go_ontology", "kegg_bar", "kegg_dot")
+ } == {
+ "go_bar": 1,
+ "go_dot": 1,
+ "go_ontology": 1,
+ "kegg_bar": 1,
+ "kegg_dot": 1,
+ }
+ assert {(row["comparison"], row["pathway_id"]) for row in pathway_rows} == {
+ ("IR29__6_vs_IR29__0", "ko00073"),
+ ("IR29__6_vs_IR29__0", "ko00196"),
+ }
+ assert all(row["candidate_families"] == [] for row in inventory)
+ assert all(row["support_labels"] == [] for row in inventory)
+ assert all(
+ row["selection_reason"] == "representative_fallback"
+ for row in inventory
+ if row["category"]
+ )
+ assert "候选家族驱动筛选不可用" in result.report_path.read_text(encoding="utf-8")
+
+
+def test_candidate_guided_selection_supports_combined_rnaseq_enrich_stage(tmp_path: Path) -> None:
+ workspace = tmp_path / "job_candidate_combined"
+ ids = _write_candidate_guided_enrichment_manifests(workspace, combined=True)
+
+ result = generate_rnaseq_report(
+ workspace,
+ ids["prefix"],
+ rnaseq_scope_id=ids["scope"],
+ expected_stages=["rnaseq_enrich", "rnaseq_fgsea", "rnaseq_family_score"],
+ use_model=False,
+ )
+ formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]
+ enrichment_rows = [row for row in formal["figure_inventory"] if row["category"]]
+
+ assert formal["selection_mode"] == "candidate_guided"
+ assert {row["category"] for row in enrichment_rows} == {
+ "go_bar",
+ "go_dot",
+ "go_ontology",
+ "kegg_bar",
+ "kegg_dot",
+ "kegg_pathway",
+ "fgsea_dot",
+ "fgsea_curve",
+ }
+ assert all(row["comparison"] == ids["candidate_comparison"] for row in enrichment_rows)
+ assert {
+ row["pathway_id"]
+ for row in enrichment_rows
+ if row["category"] == "kegg_pathway"
+ } == {ids["supported_pathway_a"], ids["supported_pathway_b"]}
+
+
+@pytest.mark.parametrize(
+ "support_labels",
+ ["", "KEGG:ko99998:pathway absent from all selected comparison outputs"],
+ ids=["empty-support-labels", "unmatched-support-labels"],
+)
+def test_analysis_ready_rows_without_matchable_support_use_representative_fallback(
+ tmp_path: Path,
+ support_labels: str,
+) -> None:
+ workspace = tmp_path / "job_analysis_ready_without_matchable_support"
+ ids = _write_candidate_guided_enrichment_manifests(workspace)
+ _write_analysis_ready_family_manifest(
+ workspace,
+ prefix=ids["prefix"],
+ scope_id=ids["scope"],
+ rows=[
+ (
+ "FAM_WITHOUT_MATCHABLE_EVIDENCE",
+ "family without usable evidence",
+ ids["candidate_comparison"],
+ support_labels,
+ )
+ ],
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ ids["prefix"],
+ rnaseq_scope_id=ids["scope"],
+ expected_stages=[
+ "rnaseq_go_enrich",
+ "rnaseq_kegg_enrich",
+ "rnaseq_fgsea",
+ "rnaseq_family_score",
+ ],
+ use_model=False,
+ )
+ formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]
+
+ assert formal["selection_mode"] == "representative_fallback"
+ assert all(row["candidate_families"] == [] for row in formal["figure_inventory"])
+ markdown = result.report_path.read_text(encoding="utf-8")
+ assert "候选家族驱动筛选不可用" in markdown
+ assert "candidate_guided" not in markdown
+
+
+def test_unmatched_fgsea_only_support_uses_fallback_and_does_not_mark_dot_candidate(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_unmatched_fgsea_only"
+ ids = _write_candidate_guided_enrichment_manifests(workspace)
+ _write_analysis_ready_family_manifest(
+ workspace,
+ prefix=ids["prefix"],
+ scope_id=ids["scope"],
+ rows=[
+ (
+ "FAM_FGSEA_PATHWAY_ABSENT",
+ "family with absent fgsea pathway",
+ ids["candidate_comparison"],
+ "fgsea:ko99998:pathway absent from comparison fgsea table",
+ )
+ ],
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ ids["prefix"],
+ rnaseq_scope_id=ids["scope"],
+ expected_stages=[
+ "rnaseq_go_enrich",
+ "rnaseq_kegg_enrich",
+ "rnaseq_fgsea",
+ "rnaseq_family_score",
+ ],
+ use_model=False,
+ )
+ formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]
+ fgsea_dots = [
+ row for row in formal["figure_inventory"] if row["category"] == "fgsea_dot"
+ ]
+
+ assert formal["selection_mode"] == "representative_fallback"
+ assert len(fgsea_dots) == 1
+ assert fgsea_dots[0]["candidate_families"] == []
+ assert fgsea_dots[0]["support_labels"] == []
+ assert fgsea_dots[0]["selection_reason"] == "representative_fallback"
+
+
+def test_candidate_mode_omits_fgsea_dot_when_only_fgsea_label_is_unmatched(
+ tmp_path: Path,
+) -> None:
+ workspace = tmp_path / "job_go_candidate_with_unmatched_fgsea"
+ ids = _write_candidate_guided_enrichment_manifests(workspace)
+ _write_analysis_ready_family_manifest(
+ workspace,
+ prefix=ids["prefix"],
+ scope_id=ids["scope"],
+ rows=[
+ (
+ "FAM_GO_ONLY_USABLE",
+ "family with usable go only",
+ ids["candidate_comparison"],
+ "GO:GO:9876543:random candidate function;"
+ "fgsea:ko99998:pathway absent from comparison fgsea table",
+ )
+ ],
+ )
+
+ result = generate_rnaseq_report(
+ workspace,
+ ids["prefix"],
+ rnaseq_scope_id=ids["scope"],
+ expected_stages=[
+ "rnaseq_go_enrich",
+ "rnaseq_kegg_enrich",
+ "rnaseq_fgsea",
+ "rnaseq_family_score",
+ ],
+ use_model=False,
+ )
+ formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]
+ selected = formal["figure_inventory"]
+ omitted = formal["omitted_figure_inventory"]
+
+ assert formal["selection_mode"] == "candidate_guided"
+ assert any(
+ row["category"] == "go_bar"
+ and row["candidate_families"] == ["FAM_GO_ONLY_USABLE"]
+ for row in selected
+ )
+ assert not any(row["category"] == "fgsea_dot" for row in selected)
+ assert any(
+ row["category"] == "fgsea_dot"
+ and row["comparison"] == ids["candidate_comparison"]
+ for row in omitted
+ )
+
+
+@pytest.mark.parametrize(
+ ("query", "action"),
+ [
+ ("生成rnaseq结果报告", "generate"),
+ ("生成 RNA-seq 结果报告", "generate"),
+ ("打开rnaseq结果报告", "open"),
+ ("生成转录组结果报告", "generate"),
+ ],
+)
+def test_explicit_rnaseq_report_intent_is_provider_independent(query: str, action: str) -> None:
+ parsed = parse_intent_from_query(query)
+ fast = _fast_intent_recognition(query)
+
+ assert parsed["stages"] == ["rnaseq_report"]
+ assert parsed["params"]["rnaseqReportAction"] == action
+ assert fast is not None
+ assert fast["stages"] == ["rnaseq_report"]
+ assert fast["params"]["rnaseqReportAction"] == action
+
+
+def test_generic_report_without_rnaseq_context_is_not_stolen() -> None:
+ parsed = parse_intent_from_query("生成结果报告")
+ assert parsed["stages"] != ["rnaseq_report"]
+
+
+def test_explicit_report_chat_intent_does_not_require_router_model() -> None:
+ assert hasattr(intent_parser_module, "deterministic_agent_chat_intent")
+ parser = intent_parser_module.deterministic_agent_chat_intent
+
+ opened = parser("打开rnaseq结果报告")
+ generated = parser("生成 RNA-seq 结果报告")
+
+ assert opened["stages"] == ["rnaseq_report"]
+ assert opened["params"]["rnaseqReportAction"] == "open"
+ assert generated["params"]["rnaseqReportAction"] == "generate"
+ assert parser("解释一下差异表达结果") is None
+
+
+def test_generic_report_chat_intent_requires_rnaseq_job_context() -> None:
+ parser = intent_parser_module.deterministic_agent_chat_intent
+
+ assert parser("生成结果报告") is None
+ generated = parser("生成结果报告", rnaseq_context=True)
+ opened = parser("打开结果报告", rnaseq_context=True)
+
+ assert generated["stages"] == ["rnaseq_report"]
+ assert generated["params"]["rnaseqReportAction"] == "generate"
+ assert opened["params"]["rnaseqReportAction"] == "open"