fix: integrate deployed reports with PPI KaKs handoff

This commit is contained in:
potaser
2026-08-11 04:48:38 +08:00
parent 8b61269d3f
commit 4a80793b4a
19 changed files with 1654 additions and 231 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Generate or open a formal RNA-seq evidence report."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).absolute().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from gfa_core.rnaseq_report import NoSuccessfulRNASeqEvidence, generate_rnaseq_report
def _csv(value: str) -> list[str] | None:
values = [item.strip() for item in str(value or "").split(",") if item.strip()]
return values or None
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate a deterministic formal RNA-seq result report from successful manifests."
)
parser.add_argument("--workspace", required=True, help="RNA-seq job workspace containing final/")
parser.add_argument("--prefix", required=True, help="RNA-seq output prefix")
parser.add_argument("--job-id", default="")
parser.add_argument("--rnaseq-scope-id", default="")
parser.add_argument(
"--expected-stages",
default="",
help="Comma-separated stages selected for the current RNA-seq batch",
)
parser.add_argument("--action", choices=("generate", "open"), default="generate")
parser.add_argument("--no-model", action="store_true", help="Disable optional LLM language enhancement")
parser.add_argument("--log-file", default="", help="Accepted for pipeline runner compatibility")
args = parser.parse_args()
try:
result = generate_rnaseq_report(
Path(args.workspace),
args.prefix,
rnaseq_scope_id=args.rnaseq_scope_id,
expected_stages=_csv(args.expected_stages),
use_model=not args.no_model,
action=args.action,
job_id=args.job_id,
)
except NoSuccessfulRNASeqEvidence as exc:
print(str(exc), file=sys.stderr)
return 2
except Exception as exc:
print(f"RNA-seq 结果报告生成失败:{type(exc).__name__}: {exc}", file=sys.stderr)
return 1
reuse = "(复用最新报告)" if result.reused else ""
print(
f"RNA-seq 结果报告已就绪{reuse}: {result.report_path} "
f"[coverage={result.coverage_status}, stages={','.join(result.included_stages)}]"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+65 -1
View File
@@ -3412,6 +3412,7 @@ RNA_SEQ_STAGES = {
"rnaseq_wgcna",
"rnaseq_candidate_family",
"rnaseq_family_score",
"rnaseq_report",
}
@@ -8441,7 +8442,32 @@ def run_stage(
expected_stages = [str(value).strip() for value in expected_stages if str(value).strip()]
report_action = str(kwargs.get("report_action") or kwargs.get("reportAction") or "generate").strip()
if report_action == "generate" and not expected_stages:
return "❌ 基因家族结果报告生成需要非空 expected_stages", manifest
report_stage_order = (
"identify", "phylogeny", "reference_classification", "motif",
"domain", "structure", "combo", "promoter", "tfbs",
"synteny", "karyotype", "interspecies_synteny", "pep",
)
recovered_stages = set()
for candidate in final_dir.glob("*.manifest.json"):
try:
payload = json.loads(candidate.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
candidate_stage = str(payload.get("stage") or "").strip()
candidate_prefix = str(
payload.get("prefix")
or (payload.get("parameters") or {}).get("prefix")
or ""
).strip()
if (
candidate_stage in report_stage_order
and str(payload.get("status") or "") == "success"
and candidate_prefix == prefix
):
recovered_stages.add(candidate_stage)
expected_stages = [stage_id for stage_id in report_stage_order if stage_id in recovered_stages]
if report_action == "generate" and not expected_stages:
return "❌ 基因家族结果报告生成需要非空 expected_stages,且当前前缀没有可恢复的成功 manifest", manifest
stage_statuses = kwargs.get("stage_statuses") or kwargs.get("stageStatuses") or {}
if isinstance(stage_statuses, str):
try:
@@ -8450,6 +8476,8 @@ def run_stage(
return "❌ 基因家族结果报告的 stage_statuses 不是合法 JSON", manifest
if not isinstance(stage_statuses, dict):
return "❌ 基因家族结果报告的 stage_statuses 必须是对象", manifest
if not stage_statuses:
stage_statuses = {stage_id: "success" for stage_id in expected_stages}
report_use_model_raw = kwargs.get("report_use_model", kwargs.get("reportUseModel", True))
rnaseq_scope_id = str(kwargs.get("rnaseq_scope_id") or kwargs.get("rnaseqScopeId") or "").strip()
cmd = [
@@ -11465,6 +11493,42 @@ def run_stage(
return "✅ RNA-seq WGCNA completed.", wgcna_manifest
return f"❌ RNA-seq WGCNA failed (exit code: {code}). Log: {log_file}", wgcna_manifest if wgcna_manifest.exists() else manifest
if stage == "rnaseq_report":
report_action = str(kwargs.get("rnaseq_report_action") or kwargs.get("report_action") or "generate").strip().lower()
if report_action not in {"generate", "open"}:
report_action = "generate"
expected_raw = kwargs.get("rnaseq_expected_stages") or kwargs.get("expected_stages") or ""
if isinstance(expected_raw, (list, tuple, set)):
expected_arg = ",".join(str(item).strip() for item in expected_raw if str(item).strip())
else:
expected_arg = str(expected_raw or "").strip()
report_scope_id = str(kwargs.get("rnaseq_scope_id") or kwargs.get("rnaseqScopeId") or "").strip()
report_use_model = kwargs.get("rnaseq_report_use_model", kwargs.get("report_use_model", True))
report_cmd = [
sys.executable,
"apps/cli/run_rnaseq_report.py",
"--workspace", str(ws),
"--prefix", prefix,
"--job-id", job.job_id,
"--action", report_action,
"--log-file", str(log_file),
]
if report_scope_id:
report_cmd.extend(["--rnaseq-scope-id", report_scope_id])
if expected_arg:
report_cmd.extend(["--expected-stages", expected_arg])
if not _truthy(report_use_model):
report_cmd.append("--no-model")
print(f"[CMD] {' '.join(report_cmd)}")
code = run_cmd_logged(report_cmd, log_file, cwd=Path("."))
report_manifest = final_dir / f"{prefix}.rnaseq_report.manifest.json"
if code == 0 and report_manifest.exists():
action_label = "打开" if report_action == "open" else "生成"
return f"✅ RNA-seq 结果报告已{action_label}。日志:{log_file}", report_manifest
if code == 2:
return "⚠️ 暂无可生成的 RNA-seq 结果:当前 prefix/scope 下没有成功阶段。", None
return f"❌ RNA-seq 结果报告生成失败(exit code: {code})。日志:{log_file}", report_manifest if report_manifest.exists() else None
if stage == "rnaseq_candidate_family":
_SPECIES_RESOURCE_ALIASES = {
"os": ["Os", "Oryza", "Oryza_sativa", "rice"],
File diff suppressed because it is too large Load Diff
+80 -5
View File
@@ -4746,7 +4746,7 @@ def _celery_start_script_for_current_profile() -> Path:
port = os.environ.get("PLANTAI_WEB_PORT", "").strip()
if prefix.endswith("_9000") or redis_url.endswith("/10") or port == "9000":
return PROJECT_ROOT / "start_celery_9000.sh"
return PROJECT_ROOT / "start_celery_7880.sh"
return PROJECT_ROOT / "scripts" / "legacy_startup" / "start_celery_7880.sh"
def _current_celery_profile() -> Dict[str, Any]:
@@ -4756,7 +4756,11 @@ def _current_celery_profile() -> Dict[str, Any]:
prefix = get_queue_prefix()
except Exception:
prefix = os.environ.get("BBAV5_QUEUE_PREFIX", "plantai_dev_7880").strip() or "plantai_dev_7880"
redis_default = "redis://localhost:6379/10" if prefix.endswith("_9000") or os.environ.get("PLANTAI_WEB_PORT", "").strip() == "9000" else "redis://localhost:6379/9"
current_port = os.environ.get("PLANTAI_WEB_PORT", "").strip()
if prefix.endswith("_9000") or current_port == "9000":
redis_default = "redis://localhost:6379/10"
else:
redis_default = "redis://localhost:6379/9"
redis_url = os.environ.get("BBAV5_REDIS_URL", redis_default).strip() or redis_default
script = _celery_start_script_for_current_profile()
log_dir = Path(os.environ.get("BBAV5_CELERY_LOG_DIR", "") or f"logs/celery_{prefix}")
@@ -8647,7 +8651,7 @@ def run_stage_sync(task_id: str, stage: str, job_id: str, params: dict):
async def root():
"""返回Vue3前端页面"""
return FileResponse(
Path(__file__).parent / "index.html",
Path(__file__).parent / "index_ml.html",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
@@ -17217,7 +17221,78 @@ def _gene_family_report_plan_context(job_id: str, prefix: str = "") -> Optional[
"rnaseqScopeId": "",
}
reports.append((path.stat().st_mtime, context))
return max(reports, key=lambda item: item[0])[1] if reports else None
if reports:
return max(reports, key=lambda item: item[0])[1]
# Older jobs may have complete, successful stage manifests but no saved
# confirmation-plan snapshot. Reconstruct a deterministic report plan from
# one manifest prefix rather than rejecting those verifiable results.
report_stage_order = (
"identify",
"phylogeny",
"reference_classification",
"motif",
"domain",
"structure",
"combo",
"promoter",
"tfbs",
"synteny",
"karyotype",
"interspecies_synteny",
"pep",
)
report_stage_set = set(report_stage_order)
recovered_by_prefix: Dict[str, Dict[str, tuple[float, Dict[str, Any]]]] = {}
for path in final_dir.glob("*.manifest.json") if final_dir.is_dir() else ():
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
stage = str(manifest.get("stage") or "").strip()
manifest_prefix = str(
manifest.get("prefix")
or (manifest.get("parameters") or {}).get("prefix")
or ""
).strip()
if (
stage not in report_stage_set
or str(manifest.get("status") or "") != "success"
or not manifest_prefix
or (requested_prefix and manifest_prefix != requested_prefix)
):
continue
stage_map = recovered_by_prefix.setdefault(manifest_prefix, {})
candidate = (path.stat().st_mtime, manifest)
if stage not in stage_map or candidate[0] > stage_map[stage][0]:
stage_map[stage] = candidate
if not recovered_by_prefix:
return None
def recovered_rank(item: tuple[str, Dict[str, tuple[float, Dict[str, Any]]]]) -> tuple[int, int, float, str]:
manifest_prefix, stage_map = item
latest_mtime = max((entry[0] for entry in stage_map.values()), default=0.0)
return (
1 if "identify" in stage_map else 0,
len(stage_map),
latest_mtime,
manifest_prefix,
)
recovered_prefix, recovered_stages = max(recovered_by_prefix.items(), key=recovered_rank)
planned = [stage for stage in report_stage_order if stage in recovered_stages]
if not planned:
return None
return {
"prefix": recovered_prefix,
"expectedStages": planned,
"stageStatuses": {stage: "success" for stage in planned},
"scopeId": "",
"analysisRoute": "",
"memberSetId": "",
"rnaseqScopeId": "",
}
async def _run_agent_chat(
@@ -25417,7 +25492,7 @@ async def root():
"""
返回前端首页
"""
index_file = WEB_DIR / "index.html"
index_file = WEB_DIR / "index_ml.html"
if index_file.exists():
return FileResponse(
str(index_file),
+1 -1
View File
@@ -63,7 +63,7 @@ env BBAV5_QUEUE_PREFIX=plantai_dev_7908 \
BBAV5_REDIS_URL=redis://localhost:6379/9 \
BBAV5_CELERY_LOG_DIR=logs/celery_plantai_dev_7908 \
BBAV5_WORKER_NAME_PREFIX=plantai_dev_7908 \
./start_celery_7880.sh
./start_celery_9000.sh
env BBAV5_QUEUE_PREFIX=plantai_dev_7908 \
BBAV5_REDIS_URL=redis://localhost:6379/9 \
+7 -4
View File
@@ -700,11 +700,14 @@ def build_report_data(
),
]
sections.extend(stage_sections)
# The integrated conclusion has a fixed evidence contract. Do not take the
# first arbitrary observations because plan ordering can displace one of
# the three core facts requested for delivery.
integrated_observations = [
section.observations[0]
for section in stage_sections
if section.observations
][:3]
stage_sections_by_name[stage].observations[0]
for stage in ("identify", "phylogeny", "reference_classification")
if stage in stage_sections_by_name and stage_sections_by_name[stage].observations
]
sections.extend(
[
ReportSection(
+6
View File
@@ -52,6 +52,7 @@ STAGE_SCRIPT_MAP = {
"annotation_kegg": "run_annotation.py",
"rnaseq_wgcna": "run_rnaseq_wgcna.py",
"rnaseq_candidate_family": "run_rnaseq_candidate_family.py",
"rnaseq_report": "run_rnaseq_report.py",
"rnaseq_sample_qc": "run_rnaseq_sample_qc.py",
"genome_prepare": "run_genome_prepare.py",
"genome_prepare_v2": "run_genome_prepare.py",
@@ -341,6 +342,11 @@ DIAGNOSIS_STAGE_COVERAGE = {
"postflight": ["candidate_family_ranking", "evidence_matrix", "recommendation", "rag_queries", "rag_references"],
"failure": ["annotation_missing", "id_mapping"],
},
"rnaseq_report": {
"preflight": ["successful_rnaseq_manifest", "prefix_scope_identity"],
"postflight": ["report_markdown", "evidence_snapshot", "coverage_status"],
"failure": ["no_successful_rnaseq_evidence", "scope_mismatch", "artifact_missing"],
},
"annotation": {
"preflight": ["protein_background", "tool_availability"],
"postflight": ["gene2go", "gene2ko", "annotation_coverage"],
+17
View File
@@ -74,6 +74,7 @@ class IntentAgentV2(BaseAgent):
"rnaseq_de": "RNA-seq差异表达分析 - 从原始FASTQ数据开始,进行质控→比对→定量→差异表达分析(DESeq2)",
"rnaseq_enrich": "RNA-seq富集分析 - GO/KEGG通路富集分析(RNA-seq流程最后一步)",
"rnaseq_candidate_family": "RNA-seq候选家族筛选 - 整合DEG、InterPro/Pfam和GO/KEGG富集,输出候选基因家族排名",
"rnaseq_report": "RNA-seq结果报告 - 从当前成功阶段生成可追溯的正式Markdown报告",
"enrichment": "富集分析 - 基于基因家族鉴定结果进行GO/KEGG通路富集分析",
"ncbi_fetch_sequences": "NCBI序列下载 - 根据GeneID、基因名或基因list下载protein/CDS序列"
}
@@ -521,6 +522,22 @@ class IntentAgentV2(BaseAgent):
has_rnaseq_marker = any(kw in query_lower for kw in ["rnaseq", "rna-seq", "rna seq", "转录组"])
explicit_gene_family_context = any(kw in query_lower for kw in ["基因家族", "家族表达", "表达量", "热图", "heatmap"])
if has_rnaseq_marker and any(
keyword in query_lower
for keyword in ["生成结果报告", "打开结果报告", "结果报告", "生成报告", "打开报告"]
):
action = "open" if "打开" in query_lower else "generate"
return {
"gene_family": "",
"species": "",
"stages": ["rnaseq_report"],
"primary_stage": "rnaseq_report",
"description": "打开或生成当前RNA-seq正式结果报告",
"requirements": ["至少一个成功的RNA-seq阶段manifest"],
"params": {"rnaseqReportAction": action},
"confidence": 0.99,
}
stage_force_rules = [
("rnaseq_candidate_family", ["候选家族", "候选基因家族", "家族排名", "tf注释", "tf family", "tffamily", "pfam注释", "deg注释", "差异基因注释"], "执行RNA-seq候选家族筛选"),
("rnaseq_fgsea", ["fgsea", "gsea", "排序富集", "基因集富集"], "执行RNA-seq fgsea排序富集分析"),
+1
View File
@@ -376,6 +376,7 @@ class PlanningAgentV2(BaseAgent):
"rnaseq_fgsea": [],
"rnaseq_wgcna": [],
"rnaseq_candidate_family": [],
"rnaseq_report": [],
}
# 各阶段依赖 (来自PreFlight)
+17
View File
@@ -20,6 +20,7 @@ ANALYSIS_KEYWORDS = {
"rnaseq_kegg_enrich": ["kegg富集", "kegg分析", "kegg", "pathway"],
"rnaseq_fgsea": ["fgsea", "gsea", "排序富集", "基因集富集"],
"rnaseq_candidate_family": ["候选家族", "候选基因家族", "家族排名", "tf注释", "tf family", "tffamily", "pfam注释", "deg注释", "差异基因注释"],
"rnaseq_report": ["rnaseq结果报告", "rna-seq结果报告", "转录组结果报告", "rnaseq报告", "rna-seq报告"],
"rnaseq_enrich": ["富集分析", "功能富集"],
"identify": ["鉴定", "identify", "识别", "找出", "鉴定基因家族"],
"identify_refine": ["修订成员", "修订鉴定成员", "修改成员", "删除基因", "删除", "去除基因", "去掉基因", "移除基因", "补充基因", "遗漏基因", "重建蛋白", "人工修订id", "curated members", "refine identify"],
@@ -61,6 +62,7 @@ RNASEQ_STAGES = {
"rnaseq_kegg_enrich",
"rnaseq_fgsea",
"rnaseq_candidate_family",
"rnaseq_report",
"rnaseq_enrich",
}
@@ -179,6 +181,21 @@ def fast_intent_recognition(query: str) -> Optional[Dict[str, Any]]:
except Exception:
pass
has_rnaseq_marker = any(marker in query_lower for marker in RNASEQ_MARKERS)
if has_rnaseq_marker and any(
keyword in query_lower
for keyword in ["生成结果报告", "打开结果报告", "结果报告", "生成报告", "打开报告"]
):
action = "open" if "打开" in query_lower else "generate"
return {
"stage": "rnaseq_report",
"stages": ["rnaseq_report"],
"gene_family": "",
"species": "",
"prefix": "",
"params": {"rnaseqReportAction": action},
"confidence": 0.99,
"method": "rnaseq_report_deterministic",
}
header_clean_context = any(kw in query_lower for kw in [
"id处理", "处理id", "修订id", "简化id", "清理id", "id简化",
"去掉注释", "去除注释", "注释信息", "长注释",
+24 -1
View File
@@ -793,6 +793,26 @@ CAPABILITIES: Dict[str, StageCapability] = {
repair_candidates=["gff_id_overlap_report", "motif_database_fallback_to_jaspar", "artifact_reresolve"],
rerun_policy="manual",
),
"rnaseq_report": StageCapability(
stage="rnaseq_report",
cli="apps/cli/run_rnaseq_report.py",
env="test4py311",
category="rnaseq",
consumes=[
ArtifactNeed("successful_rnaseq_manifests", "manifest", alternatives=["final_manifest_scan"]),
],
provides=[
ArtifactProduct("rnaseq_report", "markdown", "{prefix}.rnaseq_report.md"),
ArtifactProduct("rnaseq_report_evidence", "json", "{prefix}.rnaseq_report.evidence.json"),
ArtifactProduct("rnaseq_report_model", "json", "{prefix}.rnaseq_report.model.json"),
ArtifactProduct("rnaseq_report_manifest", "manifest", "{prefix}.rnaseq_report.manifest.json"),
],
scope_modes=["rnaseq_scope_id"],
preflight_checks=["at_least_one_successful_rnaseq_manifest", "prefix_scope_identity_consistent"],
postflight_checks=["deterministic_markdown_exists", "evidence_snapshot_exists", "coverage_recorded"],
repair_candidates=["manifest_recovery", "regenerate_from_current_snapshot"],
rerun_policy="manual_or_after_selected_batch_terminal",
),
"summary": StageCapability(
stage="summary",
cli="apps/cli/run_summary.py",
@@ -804,11 +824,14 @@ CAPABILITIES: Dict[str, StageCapability] = {
],
provides=[
ArtifactProduct("summary_report", "markdown", "{prefix}.summary_report.md"),
ArtifactProduct("summary_evidence_index", "markdown", "{prefix}.summary_evidence_index.md"),
ArtifactProduct("summary_evidence", "json", "{prefix}.summary.evidence.json"),
ArtifactProduct("deepseek_metadata", "json", "{prefix}.summary.deepseek.json"),
ArtifactProduct("summary_manifest", "manifest", "{prefix}.summary.manifest.json"),
],
scope_modes=["target_only", "target_plus_reference", "all_selected", "rnaseq"],
preflight_checks=["at_least_one_success_manifest"],
postflight_checks=["summary_contains_stage_evidence"],
postflight_checks=["summary_contains_stage_evidence", "deepseek_report_generation_proven"],
repair_candidates=["manifest_recovery", "artifact_reresolve"],
rerun_policy="auto_after_manifest_recovery",
),
+8
View File
@@ -90,6 +90,7 @@ PIPELINE_STAGES = {
"rnaseq_go_enrich",
"rnaseq_kegg_enrich",
"rnaseq_candidate_family",
"rnaseq_report",
}
STAGE_DEPENDENCIES = {
@@ -173,6 +174,9 @@ STAGE_DEPENDENCIES = {
"rnaseq_go_enrich": ["rnaseq_de"],
"rnaseq_kegg_enrich": ["rnaseq_de"],
"rnaseq_candidate_family": ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"],
# A report is a snapshot of whatever RNA-seq evidence is already successful.
# Static dependencies would incorrectly prevent intentional mid-pipeline reports.
"rnaseq_report": [],
}
PARALLEL_GROUPS = [
@@ -271,6 +275,7 @@ STAGE_RESOURCE_PROFILES = {
"rnaseq_kegg_enrich": {"queue": "rnaseq", "resource_class": "rnaseq", "default_cpu": 4, "max_cpu": 4},
"rnaseq_fgsea": {"queue": "rnaseq", "resource_class": "rnaseq", "default_cpu": 4, "max_cpu": 4},
"rnaseq_candidate_family": {"queue": "rnaseq", "resource_class": "rnaseq", "default_cpu": 4, "max_cpu": 4},
"rnaseq_report": {"queue": "light", "resource_class": "light", "default_cpu": 4, "max_cpu": 4},
}
DEFAULT_STAGE_RESOURCE_PROFILE = {
@@ -391,6 +396,7 @@ STAGE_ORDER = [
"rnaseq_kegg_enrich",
"rnaseq_fgsea",
"rnaseq_candidate_family",
"rnaseq_report",
"rnaseq_enrich",
"gene_family_report",
]
@@ -469,6 +475,7 @@ STAGE_INFO = {
"rnaseq_enrich": {"name": "RNA-seq富集分析", "estimated_time": 15, "cpu": 4},
"rnaseq_fgsea": {"name": "RNA-seq fgsea分析", "estimated_time": 15, "cpu": 4},
"rnaseq_candidate_family": {"name": "RNA-seq候选家族筛选", "estimated_time": 20, "cpu": 4},
"rnaseq_report": {"name": "RNA-seq结果报告", "estimated_time": 2, "cpu": 4},
}
EXECUTABLE_STAGES = {
@@ -544,6 +551,7 @@ EXECUTABLE_STAGES = {
"rnaseq_enrich",
"rnaseq_fgsea",
"rnaseq_candidate_family",
"rnaseq_report",
}
+1
View File
@@ -33,6 +33,7 @@ RNASEQ_STATUS_STAGES = {
"rnaseq_fgsea",
"rnaseq_wgcna",
"rnaseq_candidate_family",
"rnaseq_report",
"annotation",
"annotation_go",
"annotation_kegg",
+13
View File
@@ -1517,6 +1517,19 @@ class StageToolRegistry:
"timeout": 7200,
"conda_env": "rnaseq"
},
# 24. Formal RNA-seq Result Report
{
"id": "rnaseq_report",
"name": "RNA-seq Result Report",
"script": "run_rnaseq_report.py",
"category": "rnaseq",
"description": "Generate a scoped formal Markdown report from successful RNA-seq manifests",
"required_inputs": [],
"outputs": ["rnaseq_report", "rnaseq_report_evidence", "rnaseq_report_model"],
"parameters": {"cpu": 4},
"timeout": 1800,
"conda_env": "test4py311"
},
# Combo Plot - 组合图
{
"id": "combo",
+313
View File
@@ -0,0 +1,313 @@
"""Lightweight, provider-free RNA-seq manifest evidence snapshot.
The live interpretation view and the formal report share this module so scope
selection and manifest provenance have one implementation. It intentionally
does not import the MAS agent package.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
REPORT_STAGE = "rnaseq_report"
SUCCESS_STATUSES = {"success", "partial_success"}
GLOBAL_LEGACY_STAGES = {
"rnaseq_qc",
"rnaseq_align",
"rnaseq_quant",
"annotation",
"annotation_go",
"annotation_kegg",
}
RNASEQ_STAGE_NAMES = {
"rnaseq_qc": "RNA-seq质控",
"rnaseq_align": "RNA-seq比对",
"rnaseq_quant": "RNA-seq定量",
"rnaseq_sample_qc": "RNA-seq样本质量评估",
"rnaseq_de": "RNA-seq差异表达",
"annotation": "背景功能注释",
"annotation_go": "GO背景自动注释",
"annotation_kegg": "KEGG/KO背景自动注释",
"rnaseq_go_enrich": "RNA-seq GO富集分析",
"rnaseq_kegg_enrich": "RNA-seq KEGG富集分析",
"rnaseq_enrich": "RNA-seq富集分析(兼容入口)",
"rnaseq_fgsea": "RNA-seq fgsea排序富集",
"rnaseq_wgcna": "RNA-seq WGCNA共表达分析",
"rnaseq_candidate_family": "RNA-seq候选家族筛选",
"rnaseq_family_score": "RNA-seq候选家族评分",
}
RNASEQ_STAGE_ORDER = list(RNASEQ_STAGE_NAMES)
@dataclass(frozen=True)
class RNASeqManifestSelection:
rnaseq_scope_id: str
discovered_scope_ids: tuple[str, ...]
manifests: dict[str, dict[str, Any]]
def read_json(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def manifest_identity_value(manifest: dict[str, Any], key: str) -> str:
context = manifest.get("stage_context") or manifest.get("stageContext") or {}
params = manifest.get("parameters") or {}
return str(manifest.get(key) or params.get(key) or context.get(key) or "").strip()
def is_rnaseq_evidence_stage(stage: str) -> bool:
stage = str(stage or "").strip()
return stage != REPORT_STAGE and (
stage.startswith("rnaseq_") or stage in {"annotation", "annotation_go", "annotation_kegg"}
)
def stage_sort_key(stage: str) -> tuple[int, str]:
try:
return RNASEQ_STAGE_ORDER.index(stage), stage
except ValueError:
return len(RNASEQ_STAGE_ORDER), stage
def _manifest_score(path: Path, manifest: dict[str, Any]) -> tuple[int, int, int, float]:
status = str(manifest.get("status") or "")
status_rank = 2 if status == "success" else 1 if status == "partial_success" else 0
metrics = manifest.get("metrics") if isinstance(manifest.get("metrics"), dict) else {}
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {}
try:
mtime = path.stat().st_mtime
except OSError:
mtime = 0.0
return status_rank, len(metrics), len(artifacts), mtime
def select_rnaseq_manifests(
final_dir: Path,
prefix: str,
*,
rnaseq_scope_id: str = "",
) -> RNASeqManifestSelection:
"""Select one non-mixing manifest per RNA-seq stage for a prefix/scope."""
final_dir = Path(final_dir)
pattern = f"{prefix}.*.manifest.json" if prefix else "*.manifest.json"
candidates: list[tuple[Path, dict[str, Any]]] = []
latest_by_scope: dict[str, float] = {}
for path in sorted(final_dir.glob(pattern)) if final_dir.exists() else []:
manifest = read_json(path)
stage = str(manifest.get("stage") or "")
if not is_rnaseq_evidence_stage(stage):
continue
manifest_prefix = str(manifest.get("prefix") or "").strip()
if prefix and manifest_prefix and manifest_prefix != prefix:
continue
candidates.append((path, manifest))
scope = manifest_identity_value(manifest, "rnaseq_scope_id")
if scope:
try:
latest_by_scope[scope] = max(latest_by_scope.get(scope, 0.0), path.stat().st_mtime)
except OSError:
latest_by_scope.setdefault(scope, 0.0)
discovered = sorted(latest_by_scope, key=lambda item: (-latest_by_scope[item], item))
resolved_scope = str(rnaseq_scope_id or "").strip() or (discovered[0] if discovered else "")
selected_paths: dict[str, Path] = {}
selected: dict[str, dict[str, Any]] = {}
for path, manifest in candidates:
stage = str(manifest.get("stage") or "")
scope = manifest_identity_value(manifest, "rnaseq_scope_id")
if resolved_scope and scope and scope != resolved_scope:
continue
if resolved_scope and not scope and stage not in GLOBAL_LEGACY_STAGES:
continue
existing = selected.get(stage)
if existing is not None and _manifest_score(selected_paths[stage], existing) >= _manifest_score(path, manifest):
continue
manifest = dict(manifest)
manifest["_manifest_path"] = str(path)
selected[stage] = manifest
selected_paths[stage] = path
return RNASeqManifestSelection(resolved_scope, tuple(discovered), selected)
def _compact_value(value: Any, limit: int = 240) -> str:
if isinstance(value, (dict, list)):
text = json.dumps(value, ensure_ascii=False, sort_keys=True)
else:
text = str(value)
return text if len(text) <= limit else text[: limit - 1] + "…"
def resolve_artifact_path(
final_dir: Path,
item: dict[str, Any],
*,
require_exists: bool = False,
) -> Path | None:
"""Resolve artifacts after a workspace has been moved or mounted elsewhere."""
final_dir = Path(final_dir)
workspace = final_dir.parent
candidates: list[Path] = []
for raw in (item.get("full_path"), item.get("path")):
if not raw:
continue
path = Path(str(raw))
candidates.append(path if path.is_absolute() else workspace / path)
if path.is_absolute():
candidates.append(final_dir / path.name)
unique: list[Path] = []
seen: set[str] = set()
for path in candidates:
identity = str(path)
if identity not in seen:
seen.add(identity)
unique.append(path)
for path in unique:
if path.exists():
return path
if require_exists:
return None
return unique[0] if unique else None
def build_rnaseq_evidence_snapshot(
final_dir: Path,
prefix: str,
*,
rnaseq_scope_id: str = "",
include_stages: Iterable[str] | None = None,
selection: RNASeqManifestSelection | None = None,
) -> dict[str, Any]:
"""Build a compact evidence object without invoking an LLM or agent cache."""
selection = selection or select_rnaseq_manifests(
final_dir, prefix, rnaseq_scope_id=rnaseq_scope_id
)
allowed = {str(stage) for stage in include_stages or [] if str(stage).strip()}
manifests = {
stage: manifest
for stage, manifest in selection.manifests.items()
if not allowed or stage in allowed
}
stages = sorted(manifests, key=stage_sort_key)
stage_flow: list[dict[str, Any]] = []
findings: list[dict[str, Any]] = []
reviews: list[dict[str, Any]] = []
cards: list[dict[str, Any]] = []
risks: list[dict[str, Any]] = []
card_index = 0
for stage in stages:
manifest = manifests[stage]
status = str(manifest.get("status") or "unknown")
metrics = manifest.get("metrics") if isinstance(manifest.get("metrics"), dict) else {}
parameters = manifest.get("parameters") if isinstance(manifest.get("parameters"), dict) else {}
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {}
metric_items = list(sorted(metrics.items()))[:12]
metric_summary = ", ".join(f"{key}={_compact_value(value, 80)}" for key, value in metric_items[:5])
key_result = metric_summary or f"登记产物 {len(artifacts)} 个"
stage_flow.append(
{
"id": stage,
"name": RNASEQ_STAGE_NAMES.get(stage, stage),
"status": status,
"key_result": key_result,
}
)
findings.append(
{
"stage": stage,
"title": RNASEQ_STAGE_NAMES.get(stage, stage),
"description": f"manifest 状态为 {status};{key_result}。",
}
)
limitations: list[str] = []
if status == "partial_success":
limitations.append("该阶段为 partial_success,结果可用但不完整。")
elif status != "success":
limitations.append("该阶段未成功,不纳入正式结论。")
missing_artifacts: list[str] = []
for name, item in sorted(artifacts.items()):
if not isinstance(item, dict):
continue
path = resolve_artifact_path(final_dir, item)
exists = bool(path and path.exists())
card_index += 1
cards.append(
{
"id": f"RNA-E{card_index:04d}",
"stage": stage,
"claim": f"产物 {name}",
"evidence": f"path={path or ''}, exists={exists}",
"source_type": "raw_file" if exists else "manifest",
}
)
if not exists:
missing_artifacts.append(str(name))
for key, value in metric_items:
card_index += 1
cards.append(
{
"id": f"RNA-E{card_index:04d}",
"stage": stage,
"claim": f"指标 {key}",
"evidence": _compact_value(value),
"source_type": "manifest",
}
)
safe_parameter_items = [
(key, value)
for key, value in sorted(parameters.items())
if not any(secret in str(key).lower() for secret in ("api_key", "token", "secret", "password"))
][:10]
for key, value in safe_parameter_items:
card_index += 1
cards.append(
{
"id": f"RNA-E{card_index:04d}",
"stage": stage,
"claim": f"参数 {key}",
"evidence": _compact_value(value),
"source_type": "manifest",
}
)
if missing_artifacts:
shown = missing_artifacts[:12]
remainder = len(missing_artifacts) - len(shown)
suffix = f"(另有 {remainder} 个,详见 evidence JSON)" if remainder else ""
message = f"manifest 登记但文件不存在的产物:{', '.join(shown)}{suffix}"
limitations.append(message)
risks.append({"stage": stage, "severity": "high", "message": message})
if status not in SUCCESS_STATUSES:
risks.append({"stage": stage, "severity": "high", "message": f"阶段状态为 {status},不得作为成功证据。"})
reviews.append({"stage": stage, "stage_name": RNASEQ_STAGE_NAMES.get(stage, stage), "limitations": limitations})
successful = [stage for stage in stages if str(manifests[stage].get("status") or "") in SUCCESS_STATUSES]
return {
"schema": "rnaseq_manifest_evidence_v1",
"metadata": {
"prefix": prefix,
"rnaseq_scope_id": selection.rnaseq_scope_id,
"discovered_scope_ids": list(selection.discovered_scope_ids),
"included_stages": stages,
"successful_stages": successful,
"interpretation_boundary": "仅陈述当前 prefix 与 RNA-seq scope 下 manifest/产物支持的事实;富集、相关性和候选排序不得写成调控因果。",
},
"stage_flow": {"visualization": " -> ".join(stages), "stages": stage_flow},
"key_findings": findings,
"stage_reviews": reviews,
"evidence_cards": cards,
"risk_flags": risks,
"major_claims": [],
"biological_story": (
f"当前报告汇总了 {len(successful)} 个成功或部分成功的 RNA-seq 阶段。"
"跨阶段生物学解释必须以各阶段数值、图表和风险标记为边界。"
),
"next_steps": ["补齐缺失或失败阶段后重新生成报告,以刷新证据快照。"],
}
+16 -3
View File
@@ -18,7 +18,7 @@ import mimetypes
import os
import re
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import unquote
@@ -79,8 +79,21 @@ class RNASeqReportResult:
reused: bool = False
BEIJING_TIMEZONE = timezone(timedelta(hours=8))
def _now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
return datetime.now(BEIJING_TIMEZONE).isoformat(timespec="seconds")
def _display_generated_at(value: str) -> str:
try:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=BEIJING_TIMEZONE)
return parsed.astimezone(BEIJING_TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError):
return str(value or "").replace("T", " ")
def _read_json(path: Path) -> dict[str, Any]:
@@ -1224,7 +1237,7 @@ def _render_deterministic_markdown(
lines = [
f"# {prefix} RNA-seq 结果解读报告",
"",
f"生成时间:{generated_at} ",
f"生成时间(北京时间):{_display_generated_at(generated_at)} ",
f"任务编号:`{workspace.name}`",
"",
"**任务结果路径:** ",
@@ -0,0 +1,45 @@
# RNA-seq Result Report Contract v1
## Stage
- Stage id: `rnaseq_report`
- Script: `apps/cli/run_rnaseq_report.py`
- Scope: one exact `prefix + rnaseq_scope_id` evidence snapshot
- Purpose: materialize a formal, reproducible RNA-seq Markdown result report without depending on frontend session state.
## Inputs
- At least one RNA-seq/annotation manifest with status `success` or `partial_success` under `final/`.
- All selected evidence must match the requested prefix and RNA-seq scope. Legacy unscoped QC/alignment/quantification/annotation manifests may be treated as experiment-global evidence.
- `expected_stages` is optional and records the stages selected for the current batch. It controls coverage only; it must not become a static execution dependency.
- An existing configured `LLMManager` provider is optional. No provider or model name is required by this stage.
If no successful RNA-seq evidence exists, the stage must return `暂无可生成的 RNA-seq 结果` and must not create an empty report.
## Required Outputs
- `final/{prefix}.rnaseq_report.md`
- `final/{prefix}.rnaseq_report.evidence.json`
- `final/{prefix}.rnaseq_report.model.json`
- `final/{prefix}.rnaseq_report.manifest.json`
The manifest stage status is `success` whenever these outputs are generated. Content completeness is recorded separately as `metrics.coverage_status = complete|partial`.
## Generation Semantics
- `generate`: always rebuild from the current manifest snapshot.
- `open`: reuse a fresh report; regenerate first when the source/request fingerprint changed.
- The deterministic Markdown body is always complete enough to read without an API.
- Optional model output is a labeled language-enhancement section only. Model failure must retain the deterministic report and be recorded in `.model.json`.
- `rnaseq_report` manifests are excluded from their own evidence scan.
## Boundary With Deep Interpretation
Both features may call the pure `gfa_core.rnaseq_evidence` snapshot builder. The formal report must not call `/api/agent/interpret`, read its session cache, or replace the frontend deep-interpretation view.
## Validation
- Every manifest artifact path exists and its digest is recorded.
- Evidence contains only the selected prefix/scope.
- `coverage_status=complete` only when every expected stage has status `success`.
- Missing, failed, or `partial_success` expected stages yield `coverage_status=partial` while the report manifest remains `success`.
@@ -33,10 +33,6 @@
各阶段依次写明分析对象、源结果中的直接观察、谨慎解释、限制,并仅在有助复核时列出少量相对路径。
### 2.2 语言边界
预测关系统一表述为线索;没有逐成员表或独立证据时,不指定候选基因,不声称功能改变或调控关系已经证实。
<!-- GFA_SECTION common.completeness -->
## 三、结果总览与完整性检查
@@ -0,0 +1,36 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _block(source: str, start: str, end: str) -> str:
begin = source.index(start)
finish = source.index(end, begin)
return source[begin:finish]
def test_multilingual_frontend_keeps_critical_ppi_kaks_state_chain_in_sync() -> None:
canonical = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
multilingual = (ROOT / "apps/web/vue3/index_ml.html").read_text(encoding="utf-8")
boundaries = (
(
"const resumePpiKaksPausedStages = async",
"const downloadRecommendedPpiReferenceBundle = async",
),
(
"const monitorPpiReferenceBundleDownload =",
"const ppiHasNearReferenceBeforeArabidopsis =",
),
(
"const ensurePpiKaksDependencyOwner = async",
"const submitExecuteRequestWithAutoAnnotation = async",
),
(
"const kaksReferenceChoiceParams =",
"const confirmedStageParamsForExecution =",
),
)
for start, end in boundaries:
assert _block(multilingual, start, end) == _block(canonical, start, end)