From 4a80793b4afa8afbcc3a17d24077014d8dc1ca7b Mon Sep 17 00:00:00 2001 From: potaser Date: Tue, 11 Aug 2026 04:48:38 +0800 Subject: [PATCH] fix: integrate deployed reports with PPI KaKs handoff --- apps/cli/run_rnaseq_report.py | 66 + apps/web/pipeline_backend.py | 66 +- apps/web/vue3/index_ml.html | 1150 ++++++++++++++--- apps/web/vue3/main.py | 85 +- docs/plantai_stage_revision_template.md | 2 +- gfa_core/gene_family_report_data.py | 11 +- gfa_core/mas/agents/diagnosis.py | 6 + gfa_core/mas/agents/intent_v2.py | 17 + gfa_core/mas/agents/planning_v2.py | 1 + gfa_core/mas/core/fast_intent.py | 17 + gfa_core/mas/stage_capabilities.py | 25 +- gfa_core/mas/stage_catalog.py | 8 + gfa_core/mas/tools/stage_status.py | 1 + gfa_core/mas/tools/stage_tools.py | 13 + gfa_core/rnaseq_evidence.py | 313 +++++ gfa_core/rnaseq_report.py | 19 +- .../stages/rnaseq_report_contract_v1.md | 45 + gfa_core/templates/gene_family_report_v1.md | 4 - .../test_multilingual_ppi_scheduler_parity.py | 36 + 19 files changed, 1654 insertions(+), 231 deletions(-) create mode 100755 apps/cli/run_rnaseq_report.py create mode 100644 gfa_core/rnaseq_evidence.py create mode 100644 gfa_core/schemas/stages/rnaseq_report_contract_v1.md create mode 100644 tests/unit/test_multilingual_ppi_scheduler_parity.py diff --git a/apps/cli/run_rnaseq_report.py b/apps/cli/run_rnaseq_report.py new file mode 100755 index 000000000..3a3a9b48c --- /dev/null +++ b/apps/cli/run_rnaseq_report.py @@ -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()) diff --git a/apps/web/pipeline_backend.py b/apps/web/pipeline_backend.py index 38a17a464..000b97ba3 100644 --- a/apps/web/pipeline_backend.py +++ b/apps/web/pipeline_backend.py @@ -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"], diff --git a/apps/web/vue3/index_ml.html b/apps/web/vue3/index_ml.html index 8f2cd4ec7..2f08370ea 100644 --- a/apps/web/vue3/index_ml.html +++ b/apps/web/vue3/index_ml.html @@ -191,6 +191,26 @@ .tip-popup strong { color: #303133; } .tip-popup .tip-section { margin-bottom: 8px; } + /* 参考数据后台下载进度 */ + .reference-download-progress { position: relative; display: flex; align-items: center; } + .reference-download-progress::before { + content: ''; position: absolute; left: 0; right: 0; bottom: 100%; height: 12px; + z-index: 1999; pointer-events: auto; + } + .reference-download-progress__label { + font-size: 12px; white-space: nowrap; padding: 2px 8px; + border: 1px solid #409eff; color: #409eff; background: #fff; cursor: pointer; + } + .reference-download-progress__label.is-success { border-color: #67c23a; color: #67c23a; } + .reference-download-progress__label.is-failed { border-color: #f56c6c; color: #f56c6c; } + .reference-download-progress__popup { + display: none; position: absolute; right: 0; bottom: calc(100% + 8px); width: 320px; + background: #fff; border: 1px solid #dcdfe6; padding: 10px 12px; z-index: 2000; + box-shadow: 0 4px 16px rgba(0,0,0,0.15); font-size: 12px; color: #606266; + } + .reference-download-progress:hover .reference-download-progress__popup, + .reference-download-progress__popup:hover { display: block; } + /* Upload hover 弹出 — 显示已上传文件列表 */ .upload-hover-wrap { position: relative; display: flex; align-items: center; } .upload-hover-wrap::before { @@ -384,6 +404,10 @@ .ppi-ref-actions { display: flex; flex-direction: column; gap: 8px; } .ppi-ref-actions .el-radio-group { display: flex; flex-wrap: wrap; gap: 8px; } .ppi-ref-actions .el-radio { margin-right: 0; } + .ppi-ref-candidate-list { margin: 0; padding-left: 22px; display: flex; flex-direction: column; gap: 10px; } + .ppi-ref-candidate-list__item { padding: 10px 12px; background: #f6f8fa; border: 1px solid #e4e7ed; border-radius: 6px; } + .ppi-ref-candidate-list__main { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; color: #303133; font-weight: 700; } + .ppi-ref-candidate-list__meta { margin-top: 6px; color: #606266; font-size: 12px; line-height: 1.6; } .autofill-prepend-field { display: flex; align-items: stretch; width: 100%; min-width: 0; } .autofill-prepend-field__label { display: inline-flex; align-items: center; justify-content: center; min-height: 32px; padding: 0 12px; color: #909399; background: #f5f7fa; border: 1px solid #dcdfe6; border-right: 0; border-radius: 4px 0 0 4px; font-size: 14px; white-space: nowrap; } .autofill-prepend-field .el-select { flex: 1; min-width: 0; } @@ -1243,9 +1267,12 @@
-
-

CropAI

- Evidence-grounded multi-agent artificial intelligence for crop gene-family discovery and transcriptomic decision support +
+ OMaaX +
+

CropAI

+ Multi-Agent Platform for crop Bioinformatics Analysis +
@@ -1282,6 +1309,7 @@ + @@ -1297,6 +1325,7 @@ +
+
+ + + + + +
+
+
+ PPI and Ka/Ks Reference Species Data Download +
{{ referenceBundleDownloadState.speciesLabel || 'Reference species' }}
+
+ Close +
+ + +
+ Downloaded: {{ formatReferenceDownloadBytes(referenceBundleDownloadState.bytesDownloaded) }} / {{ formatReferenceDownloadBytes(referenceBundleDownloadState.totalBytes) }} / Total size unknown +
+
{{ referenceBundleDownloadState.message || 'Downloading PPI and Ka/Ks reference species PEP and CDS' }}
+
+
正在执行: {{ typeof batchExecuteStages[batchExecuteIndex] === 'object' ? (batchExecuteStages[batchExecuteIndex].name || getStageLabel(batchExecuteStages[batchExecuteIndex].id)) : getStageLabel(batchExecuteStages[batchExecuteIndex]) }} ({{ batchExecuteIndex + 1 }}/{{ batchExecuteStages.length }})
-
-
- 参考数据下载:{{ referenceBundleDownloadState.speciesLabel || 'reference' }} - {{ referenceBundleDownloadState.status || 'running' }} -
- - -
{{ referenceBundleDownloadState.message || '正在后台下载参考物种注释包' }}
-
-
-
PPI/KaKs 参考数据处理
+
+
Confirm the STRING reference species for PPI
+ +
+
Ranked related STRING species
+
+
    +
  1. +
    + {{ candidate.name }} + Recommended + {{ candidate.code }} +
    +
    + STRING TaxID: {{ candidate.taxon || '-' }}
    + LCA: {{ candidate.lca || '-' }}
    + Topology distance: {{ candidate.distance || '-' }}
    + STRING version: {{ candidate.version || '-' }} +
    +
  2. +
+
No related STRING species was returned.
+
+
+
+
+ +
+
PPI/KaKs reference data and Ortholog mapping required
+
+ + +
+
+ Target species +
{{ confirmParams.ppiKaksTargetSpecies || 'Unknown' }}
+
+
+ Requested stages +
{{ (confirmParams.ppiKaksRequestedStages || []).map(getStageLabel).join('、') || 'PPI/KaKs' }}
+
+
+ Current reference species +
{{ confirmParams.ppiKaksCurrentReferenceSpecies || 'Not selected' }}
+
+
+ Ortholog mapping +
{{ confirmParams.ppiKaksOrthologMapping || 'Not generated' }}
+
+
+
+
Data requirements
+
+
+ Required data + {{ String(item).toUpperCase() }} +
+
+ Missing data + {{ String(item).toUpperCase() }} + None +
+
+
+
+
Recommended related STRING species
+
+
    +
  1. +
    + {{ candidate.name }} + {{ candidate.code }} + STRING TaxID {{ candidate.taxon }} +
    +
    + LCA: {{ candidate.lca }} +
    Topology distance: {{ candidate.distance }}
    +
    Relationship: {{ candidate.relationship }}
    +
    Source: {{ candidate.source }}
    +
    STRING version: {{ candidate.version }}
    +
    +
  2. +
+
No related STRING species was returned.
+
+
+
+
+ +
+
PPI and Ka/Ks Reference Species Data
+
+ @@ -5945,6 +6098,17 @@
KaKs 参数
+
+
参考物种与同源映射来源
+ + + 复用本次 PPI 技术参考 {{ confirmParams.kaksPpiReferenceSpecies }} 及其专属映射 + + + 使用项目参考物种 {{ confirmParams.kaksProjectReferenceSpecies }},单独生成 KaKs 映射 + + +
@@ -6393,7 +6557,9 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
💡 提示:请先选择有效 VCF;当前不会自动执行。 - 💡 提示:下载失败后的拟南芥兜底必须手动确认,不会自动执行。 + 💡 Tip: The Arabidopsis fallback requires manual confirmation and will not run automatically. + 💡 Tip: PEP and CDS will be downloaded together before Ortholog mapping and PPI. + 💡 Tip: PPI prepares PEP and CDS with its Ortholog mapping; Ka/Ks can reuse the same reference identity. 💡 提示:40秒内不操作将使用当前参数自动执行,可随时暂停倒计时后手动确认。
@@ -8778,6 +8944,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 { stage: 'tfbs', label: 'tfbs', group: 'gene_family', default_cpu: 4, max_cpu: 4 }, { stage: 'ortholog', label: 'ortholog', group: 'gene_family', default_cpu: 4, max_cpu: 4 }, { stage: 'kaks', label: 'kaks', group: 'gene_family', default_cpu: 4, max_cpu: 4 }, + { stage: 'gene_family_report', label: 'report', group: 'gene_family', default_cpu: 4, max_cpu: 4 }, { stage: 'rnaseq_qc', label: 'qc', group: 'rnaseq', default_cpu: 8, max_cpu: 20 }, { stage: 'rnaseq_align', label: 'align', group: 'rnaseq', default_cpu: 8, max_cpu: 20 }, { stage: 'rnaseq_quant', label: 'quant', group: 'rnaseq', default_cpu: 6, max_cpu: 8 }, @@ -8785,7 +8952,8 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 { stage: 'rnaseq_enrich', label: 'enrich', group: 'rnaseq', default_cpu: 4, max_cpu: 4 }, { stage: 'rnaseq_go_enrich', label: 'go enrich', group: 'rnaseq', default_cpu: 4, max_cpu: 4 }, { stage: 'rnaseq_kegg_enrich', label: 'kegg enrich', group: 'rnaseq', default_cpu: 4, max_cpu: 4 }, - { stage: 'rnaseq_candidate_family', label: 'candidate', group: 'rnaseq', default_cpu: 4, max_cpu: 4 } + { stage: 'rnaseq_candidate_family', label: 'candidate', group: 'rnaseq', default_cpu: 4, max_cpu: 4 }, + { stage: 'rnaseq_report', label: 'report', group: 'rnaseq', default_cpu: 4, max_cpu: 4 } ]); let runtimeMonitorTimer = null; let resourceMonitorTimer = null; @@ -9076,6 +9244,22 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 requestBody.candidateMaxDegProteins = numberParam(firstDefinedParam(src.candidateMaxDegProteins, src.max_deg_proteins, src.maxDegProteins, confirmParams.candidateMaxDegProteins), 500); requestBody.candidateRunInterproscan = firstDefinedParam(src.candidateRunInterproscan, src.run_interproscan, src.runInterproscan, confirmParams.candidateRunInterproscan) || 'auto'; } + if (stageName === 'gene_family_report') { + requestBody.expectedStages = src.expectedStages || []; + requestBody.stageStatuses = src.stageStatuses || {}; + requestBody.reportAction = src.reportAction || 'generate'; + requestBody.reportUseModel = src.reportUseModel !== false; + requestBody.scopeId = src.scopeId || ''; + requestBody.analysisRoute = src.analysisRoute || ''; + requestBody.memberSetId = src.memberSetId || ''; + requestBody.rnaseqScopeId = src.rnaseqScopeId || ''; + } + if (stageName === 'rnaseq_report') { + requestBody.rnaseqScopeId = firstDefinedParam(src.rnaseqScopeId, src.rnaseq_scope_id, activeRnaseqScopeId.value) || ''; + requestBody.rnaseqExpectedStages = rnaseqListParam(firstDefinedParam(src.rnaseqExpectedStages, src.expected_stages), []); + requestBody.rnaseqReportAction = firstDefinedParam(src.rnaseqReportAction, src.report_action) || 'generate'; + requestBody.rnaseqReportUseModel = booleanParam(firstDefinedParam(src.rnaseqReportUseModel, src.report_use_model), true); + } return requestBody; }; const isRnaseqQueue = (name) => { @@ -9384,7 +9568,9 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 message: '', percentage: 0, indeterminate: true, - status: '' + status: '', + bytesDownloaded: 0, + totalBytes: 0 }); // 共线性模糊意图选择 @@ -9812,7 +9998,7 @@ compare: [[flesh, peel]]`; 'ppi': ['identify'], // Ka/Ks - 'kaks': ['identify', 'ortholog'], + 'kaks': ['identify'], // 组合图 'combo': ['identify', 'phylogeny', 'motif', 'structure', 'domain'], @@ -9832,7 +10018,9 @@ compare: [[flesh, peel]]`; // batch it may be the stage that generates gene2ko, so wait // for it to avoid submitting fgsea before the background exists. 'rnaseq_fgsea': ['rnaseq_de', 'rnaseq_kegg_enrich'], - 'rnaseq_candidate_family': ['rnaseq_de', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich', 'rnaseq_fgsea'] + 'rnaseq_candidate_family': ['rnaseq_de', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich', 'rnaseq_fgsea'], + 'gene_family_report': [], + 'rnaseq_report': [] }; // 阶段执行状态跟踪 @@ -10028,9 +10216,13 @@ compare: [[flesh, peel]]`; ppiKaksReferenceRecommendationCode: '', ppiKaksReferenceRecommendationTaxon: '', ppiKaksReferenceRecommendationMeta: '', + ppiKaksReferenceCandidates: [], ppiKaksDownloadError: '', // KaKs 参数 kaksNeutralRate: 7.08e-9, + kaksReferenceSource: 'project', + kaksPpiReferenceSpecies: '', + kaksProjectReferenceSpecies: '', // Genome Prepare 参数 genomePrepareType: 'auto', scopeMode: '', @@ -10185,6 +10377,7 @@ compare: [[flesh, peel]]`; { name: 'pep', label: '1️⃣2️⃣ PEP', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'expression', label: '1️⃣3️⃣ Expression (FPKM)', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'combo', label: '6️⃣ Combo', status: 'pending', images: [], files: [], elapsed_time: 0 }, + { name: 'gene_family_report', label: '📄 Gene Family Result Report', status: 'pending', images: [], files: [], elapsed_time: 0 }, // RNA-seq stages { name: 'rnaseq_qc', label: '1️⃣5️⃣ RNA-seq QC', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'rnaseq_align', label: '1️⃣6️⃣ RNA-seq Align', status: 'pending', images: [], files: [], elapsed_time: 0 }, @@ -10196,7 +10389,8 @@ compare: [[flesh, peel]]`; { name: 'rnaseq_kegg_enrich', label: '2️⃣2️⃣ RNA-seq KEGG Enrich', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'rnaseq_fgsea', label: '2️⃣3️⃣ RNA-seq fgsea', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'rnaseq_wgcna', label: '2️⃣4️⃣ RNA-seq WGCNA', status: 'pending', images: [], files: [], elapsed_time: 0 }, - { name: 'rnaseq_candidate_family', label: '2️⃣5️⃣ RNA-seq Candidate Family', status: 'pending', images: [], files: [], elapsed_time: 0 } + { name: 'rnaseq_candidate_family', label: '2️⃣5️⃣ RNA-seq Candidate Family', status: 'pending', images: [], files: [], elapsed_time: 0 }, + { name: 'rnaseq_report', label: '2️⃣6️⃣ RNA-seq Result Report', status: 'pending', images: [], files: [], elapsed_time: 0 } ]); // 新版结果区域 - 展开的阶段 const activeStages = ref('phylogeny'); @@ -10205,7 +10399,7 @@ compare: [[flesh, peel]]`; const rnaseqResultStageNames = new Set([ 'rnaseq_qc', 'rnaseq_align', 'rnaseq_quant', 'rnaseq_sample_qc', 'rnaseq_de', 'annotation', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich', - 'rnaseq_fgsea', 'rnaseq_wgcna', 'rnaseq_candidate_family' + 'rnaseq_fgsea', 'rnaseq_wgcna', 'rnaseq_candidate_family', 'rnaseq_report' ]); const wgsPopulationGwasStageNames = new Set([ 'wgs_qc', 'wgs_index', 'wgs_align', 'wgs_markdup', 'wgs_bam_qc', @@ -10653,12 +10847,15 @@ compare: [[flesh, peel]]`; const isJumpFile = (filename, stageName = '') => { const lower = String(filename || '').toLowerCase(); if (lower.endsWith('.ppi_url.txt')) return true; + if (stageName === 'rnaseq_report' && lower.endsWith('.rnaseq_report.html')) return true; + if (stageName === 'gene_family_report' && lower.endsWith('.gene_family_report.html')) return true; return stageName === 'rnaseq_qc' && lower.endsWith('_multiqc_report.html'); }; const isReportOrManifestFile = (filename) => { const lower = String(filename || '').toLowerCase(); - return lower.endsWith('.manifest.json') || lower.endsWith('_report.md') || lower.endsWith('.report.md') || lower.endsWith('report.md'); + return lower.endsWith('.manifest.json') || lower.endsWith('_report.md') || lower.endsWith('.report.md') || lower.endsWith('report.md') + || (lower.endsWith('.html') && (lower.includes('rnaseq_report') || lower.includes('gene_family_report'))); }; const resultDisplayFiles = (stage) => { @@ -10904,6 +11101,12 @@ compare: [[flesh, peel]]`; selectedResultStage.value = stageName; selectedFile.value = file.name; + const selectedLowerName = String(file.name || '').toLowerCase(); + if (['rnaseq_report', 'gene_family_report'].includes(stageName) && selectedLowerName.endsWith('.html')) { + window.open(file.path, '_blank'); + return; + } + if (stageName === 'gwas_fmt' && ['sorted_vcf', 'tassel_hapmap'].includes(String(file.role || ''))) { window.open(file.path, '_blank'); return; @@ -12192,6 +12395,13 @@ compare: [[flesh, peel]]`; nextParams.referenceSpecies = []; nextParams.comparativeSpecies = []; ['scopeId', 'scope_id', 'scopeFile', 'scope_file', 'memberSetId', 'member_set_id', 'stageContext', 'stage_context'].forEach(key => delete nextParams[key]); + } else if (stageId === "ppi") { + nextParams.referenceSpecies = []; + nextParams.refSpeciesList = []; + nextParams.comparativeSpecies = []; + nextParams.scopeMode = "target_only"; + nextParams.analysisRoute = "single_species"; + ['scopeId', 'scope_id', 'scopeFile', 'scope_file', 'memberSetId', 'member_set_id', 'stageContext', 'stage_context'].forEach(key => delete nextParams[key]); } else if (stageId === "interspecies_synteny") { nextParams.referenceSpecies = [...stageRefs]; nextParams.comparativeSpecies = [...stageComps]; @@ -12639,12 +12849,20 @@ compare: [[flesh, peel]]`; const applyPpiOwnedOrthologFieldsToRequest = (requestBody = {}, stageId = '', stageParams = {}) => { const owner = String(stageParams.dependencyOwner || requestBody.dependencyOwner || '').trim().toLowerCase(); const storageKey = String(stageParams.ppiReferenceStorageKey || requestBody.ppiReferenceStorageKey || '').trim(); - if (stageId !== 'ortholog' || owner !== 'ppi') return requestBody; + if (stageId !== 'ortholog' || !['ppi', 'kaks'].includes(owner)) return requestBody; + if (owner === 'kaks') { + requestBody.dependencyOwner = 'kaks'; + requestBody.requestedStages = ['kaks']; + requestBody.autoInsertedFor = ['kaks']; + return requestBody; + } requestBody.ppiReferenceTaxonId = stageParams.ppiReferenceTaxonId || requestBody.ppiReferenceTaxonId || ''; requestBody.ppiReferenceStorageKey = storageKey; requestBody.ppiReferenceSelectionMode = stageParams.ppiReferenceSelectionMode || requestBody.ppiReferenceSelectionMode || ''; requestBody.ppiReferenceUserConfirmed = stageParams.ppiReferenceUserConfirmed === true || requestBody.ppiReferenceUserConfirmed === true; requestBody.dependencyOwner = 'ppi'; + requestBody.requestedStages = ['ppi']; + requestBody.autoInsertedFor = ['ppi']; delete requestBody.referenceSpecies; delete requestBody.refSpeciesList; delete requestBody.scopeMode; @@ -12654,18 +12872,53 @@ compare: [[flesh, peel]]`; return requestBody; }; + const canonicalizePpiOwnerBiologicalScope = (requestBody = {}) => { + const targets = uniqueList(requestBody.targetSpecies || requestBody.target_species || []); + const prefix = String(requestBody.prefix || '').trim(); + const family = String( + requestBody.geneFamily + || requestBody.family + || (prefix.includes('.') ? prefix.split('.').slice(1).join('.') : '') + ).trim(); + requestBody.targetSpecies = targets; + requestBody.referenceSpecies = []; + requestBody.refSpeciesList = []; + requestBody.comparativeSpecies = []; + requestBody.scopeMode = 'target_only'; + requestBody.scope_mode = 'target_only'; + requestBody.analysisRoute = 'single_species'; + requestBody.analysis_route = 'single_species'; + requestBody.scopeFile = ''; + requestBody.scope_file = ''; + const scopeId = scopeIdForRoles(family, targets, [], [], 'target_only'); + if (scopeId) { + requestBody.scopeId = scopeId; + requestBody.scope_id = scopeId; + const memberSet = memberSetForScope(scopeId, 'single_species', 'ppi'); + requestBody.memberSetId = memberSet; + requestBody.member_set_id = memberSet; + } + return requestBody; + }; + const applyPpiOwnerFieldsToRequest = (requestBody = {}, stageId = '', stageParams = {}, batchParams = {}, planParams = {}) => { if (stageId !== 'ppi') return requestBody; delete requestBody.ppiKaksReferenceSpecies; delete requestBody.ppiReferenceSpecies; delete requestBody.referenceBundleSpecies; const firstDefined = (...values) => values.find(value => value !== undefined && value !== null && value !== ''); - requestBody.ppiReferenceTaxonId = firstDefined(stageParams.ppiReferenceTaxonId, batchParams.ppiReferenceTaxonId, planParams.ppiReferenceTaxonId, ''); - requestBody.ppiReferenceStorageKey = firstDefined(stageParams.ppiReferenceStorageKey, batchParams.ppiReferenceStorageKey, planParams.ppiReferenceStorageKey, ''); - requestBody.ppiReferenceSelectionMode = firstDefined(stageParams.ppiReferenceSelectionMode, batchParams.ppiReferenceSelectionMode, planParams.ppiReferenceSelectionMode, ''); - requestBody.ppiReferenceUserConfirmed = Boolean(firstDefined(stageParams.ppiReferenceUserConfirmed, batchParams.ppiReferenceUserConfirmed, planParams.ppiReferenceUserConfirmed, false)); + const confirmedFallbackOwner = batchParams.ppiReferenceSelectionMode === 'model_fallback' + && batchParams.ppiReferenceUserConfirmed === true + && String(batchParams.ppiReferenceTaxonId || '').trim() + && String(batchParams.ppiReferenceStorageKey || '').trim(); + const primaryOwner = confirmedFallbackOwner ? batchParams : stageParams; + const secondaryOwner = confirmedFallbackOwner ? stageParams : batchParams; + requestBody.ppiReferenceTaxonId = firstDefined(primaryOwner.ppiReferenceTaxonId, secondaryOwner.ppiReferenceTaxonId, planParams.ppiReferenceTaxonId, ''); + requestBody.ppiReferenceStorageKey = firstDefined(primaryOwner.ppiReferenceStorageKey, secondaryOwner.ppiReferenceStorageKey, planParams.ppiReferenceStorageKey, ''); + requestBody.ppiReferenceSelectionMode = firstDefined(primaryOwner.ppiReferenceSelectionMode, secondaryOwner.ppiReferenceSelectionMode, planParams.ppiReferenceSelectionMode, ''); + requestBody.ppiReferenceUserConfirmed = Boolean(firstDefined(primaryOwner.ppiReferenceUserConfirmed, secondaryOwner.ppiReferenceUserConfirmed, planParams.ppiReferenceUserConfirmed, false)); requestBody.dependencyOwner = 'ppi'; - return requestBody; + return canonicalizePpiOwnerBiologicalScope(requestBody); }; const applyGeneFamilyContextToRequest = (requestBody, stageId, stageParams = {}) => { @@ -12692,20 +12945,24 @@ compare: [[flesh, peel]]`; requestBody.geneFamily = firstDefined(stageParams.geneFamily, stageParams.family, stageContext.family, confirmParams.geneFamily, batchParams.geneFamily, batchPlanParams.geneFamily, ''); requestBody.targetSpecies = firstNonEmptyList(stageParams.targetSpecies, stageParams.target_species, stageContext.target_species, confirmParams.targetSpecies, batchParams.targetSpecies, batchPlanParams.targetSpecies); requestBody.referenceSpecies = firstNonEmptyList(stageParams.referenceSpecies, stageParams.reference_species, stageContext.reference_species, confirmParams.referenceSpecies, batchParams.referenceSpecies, batchPlanParams.referenceSpecies); - const technicalReferenceSpecies = firstNonEmptyList( - stageParams.ppiKaksReferenceSpecies, - stageParams.ppiReferenceSpecies, - stageParams.referenceBundleSpecies, - requestBody.ppiKaksReferenceSpecies, - requestBody.ppiReferenceSpecies, - requestBody.referenceBundleSpecies, - batchParams.ppiKaksReferenceSpecies, - batchParams.ppiReferenceSpecies, - batchParams.referenceBundleSpecies, - batchPlanParams.ppiKaksReferenceSpecies, - batchPlanParams.ppiReferenceSpecies, - batchPlanParams.referenceBundleSpecies - ); + const projectOwnedKaks = stageParams.kaksReferenceSource === 'project' + && (stageId === 'kaks' || stageParams.dependencyOwner === 'kaks'); + const technicalReferenceSpecies = projectOwnedKaks + ? [] + : firstNonEmptyList( + stageParams.ppiKaksReferenceSpecies, + stageParams.ppiReferenceSpecies, + stageParams.referenceBundleSpecies, + requestBody.ppiKaksReferenceSpecies, + requestBody.ppiReferenceSpecies, + requestBody.referenceBundleSpecies, + batchParams.ppiKaksReferenceSpecies, + batchParams.ppiReferenceSpecies, + batchParams.referenceBundleSpecies, + batchPlanParams.ppiKaksReferenceSpecies, + batchPlanParams.ppiReferenceSpecies, + batchPlanParams.referenceBundleSpecies + ); if (['ortholog', 'kaks'].includes(stageId) && technicalReferenceSpecies.length) { requestBody.referenceSpecies = technicalReferenceSpecies; requestBody.refSpeciesList = technicalReferenceSpecies; @@ -13555,9 +13812,9 @@ compare: [[flesh, peel]]`; const formatPpiReferenceRecommendations = (recommendations = []) => { const items = Array.isArray(recommendations) ? recommendations : []; - if (!items.length) return '无可用近源推荐'; + if (!items.length) return 'No related reference recommendation is available'; return items.map((item, idx) => { - const name = item.species_name || item.speciesCode || item.species_code || '未知物种'; + const name = item.species_name || item.speciesCode || item.species_code || 'Unknown species'; const code = item.species_code || ''; const taxon = item.taxon_id || ''; const relation = item.relationship || ''; @@ -13566,6 +13823,30 @@ compare: [[flesh, peel]]`; }).join('\n'); }; + const normalizePpiReferenceCandidates = (source = {}, fallbackVersion = '') => { + const candidates = Array.isArray(source) + ? source + : (Array.isArray(source.candidates) + ? source.candidates + : (Array.isArray(source.species_resolution?.candidates) + ? source.species_resolution.candidates + : (Array.isArray(source.diagnostics?.reference_recommendations) + ? source.diagnostics.reference_recommendations + : []))); + const versionFallback = fallbackVersion || source.string_version || source.species_resolution?.string_version || ''; + const text = value => value === undefined || value === null ? '' : String(value); + return candidates.map(candidate => ({ + name: text(candidate.selected_string_name || candidate.species_name || candidate.name || 'Unknown species'), + code: text(candidate.species_code || candidate.speciesCode || candidate.code), + taxon: text(candidate.selected_string_taxon_id || candidate.taxon_id || candidate.string_taxon_id), + lca: text(candidate.lca_name || candidate.lca_taxon_name || candidate.lca_taxon_id), + distance: text(candidate.topology_distance ?? candidate.distance_edges ?? candidate.distance), + version: text(candidate.string_version || versionFallback), + relationship: text(candidate.relationship), + source: text(candidate.source) + })); + }; + const formatPpiSpeciesResolutionBlockMessage = (resolution = {}) => { const diagnostics = resolution.diagnostics || {}; const recommendations = diagnostics.reference_recommendations || []; @@ -13575,17 +13856,17 @@ compare: [[flesh, peel]]`; const targetText = (resolution.target_species || []).join(', ') || '-'; const stageText = (resolution.requested_stages || diagnostics.dependency_owner_stages || []).join(', ') || 'ppi'; const lines = [ - 'PPI/KaKs 暂时不能直接执行:参考物种数据还没准备好。', - `目标物种:${targetText}`, - `涉及阶段:${stageText}`, - `需要的数据:${requiredBundle.join(', ') || 'pep'}${missingRequired.length ? `;当前缺失:${missingRequired.join(', ')}` : ''}`, - `当前参考物种:${resolution.reference_species || '未选择'}`, - `当前 Ortholog 映射:${resolution.ortholog_mapping_source || '未生成'}`, + 'PPI/KaKs cannot run yet because the reference species data are not ready.', + `Target species: ${targetText}`, + `Requested stages: ${stageText}`, + `Required data: PEP, CDS${missingRequired.length ? `; missing: ${missingRequired.join(', ')}` : ''}`, + `Current reference species: ${resolution.reference_species || 'Not selected'}`, + `Current Ortholog mapping: ${resolution.ortholog_mapping_source || 'Not generated'}`, '', - '推荐近源 STRING 参考物种:', + 'Recommended related STRING reference species:', formatPpiReferenceRecommendations(recommendations), '', - '说明:PPI 需要先把目标基因通过 Ortholog 映射到 STRING 支持物种;Ka/Ks 还需要参考 CDS。你可以先准备/下载参考数据,也可以本次先跳过 PPI/KaKs,继续其它不依赖该数据的分析。' + 'PPI prepares reference PEP and CDS and generates its Ortholog mapping. Ka/Ks can then reuse that same bundle and mapping.' ]; return lines.join('\n'); }; @@ -13644,17 +13925,17 @@ compare: [[flesh, peel]]`; const stageText = (resolution.requested_stages || resolution.diagnostics?.dependency_owner_stages || []).join(', ') || 'ppi'; const recText = recommendation ? `${recommendation.species_name || recommendation.species_code} (${recommendation.species_code || '-'})${recommendation.taxon_id ? ` / STRING taxon ${recommendation.taxon_id}` : ''}` - : '暂无可用推荐'; + : 'No recommendation is available'; return [ - '目标物种当前不能直接用于 STRING PPI/KaKs。', + 'The target species cannot be used directly for STRING PPI/KaKs.', '', - `目标物种:${targetText}`, - `涉及阶段:${stageText}`, - `推荐近源参考:${recText}`, - `需要数据:${requiredBundle.join(', ') || 'pep'}${missingRequired.length ? `;缺失:${missingRequired.join(', ')}` : ''}`, + `Target species: ${targetText}`, + `Requested stages: ${stageText}`, + `Recommended related reference: ${recText}`, + `Required data: PEP, CDS${missingRequired.length ? `; missing: ${missingRequired.join(', ')}` : ''}`, '', - '选择“下载推荐参考数据”后,系统会先下载参考 PEP/CDS,再重新检查并由 Ortholog 生成映射。', - '选择“本次先跳过”时,PPI/KaKs 会标记为暂停,其它阶段继续执行。' + 'Download prepares PEP and CDS together, then generates Ortholog mapping.', + 'Skip pauses PPI/KaKs while other independent stages continue.' ].join('\n'); }; @@ -13665,7 +13946,7 @@ compare: [[flesh, peel]]`; const missing = Array.isArray(resolution.missing_required_bundle) ? resolution.missing_required_bundle : []; - return bundle.length ? bundle : (missing.length ? missing : ['pep']); + return Array.from(new Set(['pep', 'cds', ...bundle, ...missing])); }; const ppiKaksRecommendationText = (recommendation = {}) => { @@ -13678,7 +13959,7 @@ compare: [[flesh, peel]]`; return [main || code || name, reason].filter(Boolean).join(' / '); }; - const referenceBundleErrorMessage = (data = {}, fallback = '参考数据下载失败') => { + const referenceBundleErrorMessage = (data = {}, fallback = 'Reference species data download failed') => { const detail = data?.detail; if (detail && typeof detail === 'object') { return detail.message || detail.error || JSON.stringify(detail); @@ -13693,7 +13974,7 @@ compare: [[flesh, peel]]`; confirmParams.ppiKaksReferenceDecision = 'skip'; await showStageConfirmDialog({ id: 'ppi_kaks_reference_data', - name: 'PPI/KaKs 参考数据', + name: 'PPI and Ka/Ks Reference Species Data', dependencies: [], params: { resolution, @@ -13709,7 +13990,7 @@ compare: [[flesh, peel]]`; } const accepted = await showStageConfirmDialog({ id: 'ppi_kaks_reference_data', - name: 'PPI/KaKs 参考数据', + name: 'PPI and Ka/Ks Reference Species Data', dependencies: [], params: { resolution, @@ -13728,13 +14009,13 @@ compare: [[flesh, peel]]`; const requestPpiDownloadFallbackDecision = async (error, resolution = {}, recommendation = {}, requestedStages = []) => { const accepted = await showStageConfirmDialog({ id: 'ppi_kaks_download_fallback', - name: 'PPI/KaKs 兜底确认', + name: 'PPI and Ka/Ks Download Failure Fallback', dependencies: [], params: { resolution, recommendation, requestedStages, - downloadError: error?.message || String(error || '参考数据下载失败'), + downloadError: error?.message || String(error || 'Reference species data download failed'), defaultDecision: 'skip' } }, 1, 1); @@ -13759,7 +14040,8 @@ compare: [[flesh, peel]]`; }; const applyArabidopsisFallbackForPpiKaks = (requestedStages = [], reason = '') => { - if (requestedStages.includes('ppi')) { + const appliesToPpi = requestedStages.includes('ppi'); + if (appliesToPpi) { applyPpiOwnerSelectionToBatch({ taxonId: '3702', storageKey: 'At', @@ -13767,10 +14049,7 @@ compare: [[flesh, peel]]`; userConfirmed: true }, {}); } - if (requestedStages.includes('kaks')) { - mergePpiReferenceSpeciesIntoBatch('At', ['kaks'], 'kaks_download_failed_arabidopsis_fallback', true); - } - const affected = new Set(['ortholog', ...(requestedStages || [])]); + const affected = new Set(appliesToPpi ? ['ortholog', 'ppi'] : []); batchExecuteStages.value = (batchExecuteStages.value || []).map(stage => { const stageId = getStageId(stage); if (!affected.has(stageId)) return stage; @@ -13784,7 +14063,6 @@ compare: [[flesh, peel]]`; } }; }); - clearPpiKaksReferencePause(requestedStages); }; const resumePpiKaksPausedStages = async (requestedStages = [], reason = '') => { @@ -13794,17 +14072,17 @@ compare: [[flesh, peel]]`; clearPpiKaksReferencePause(requestedStages); retryStages.forEach(stage => { const stageId = getStageId(stage); - if (!stageId || stageExecutionStatus.value[stageId] === 'completed') return; + if (!stageId || stageExecutionStatus.value[stageId] !== 'paused') return; stageExecutionStatus.value[stageId] = 'pending'; }); if (isBatchExecuting.value) { - addAgentMessage('assistant', '🔄 ' + (reason || t('参考数据已就绪')) + t(';Ortholog/PPI/KaKs 已恢复为待执行,将随当前批次继续调度。')); + addAgentMessage('assistant', `🔄 ${reason || '参考数据已就绪'};Ortholog/PPI/KaKs 已恢复为待执行,将随当前批次继续调度。`); return; } isBatchExecuting.value = true; shouldStopBatchExecute.value = false; try { - addAgentMessage('assistant', '🔄 ' + (reason || t('参考数据已就绪')) + t(';开始续跑 Ortholog/PPI/KaKs。')); + addAgentMessage('assistant', `🔄 ${reason || '参考数据已就绪'};开始续跑 Ortholog/PPI/KaKs。`); await executeStagesWithDependencies(retryStages, new Set(retryStages.map(stage => getStageId(stage)).filter(Boolean))); } finally { isBatchExecuting.value = false; @@ -13812,38 +14090,80 @@ compare: [[flesh, peel]]`; } }; + const ppiKaksFallbackRecoveryOwners = ( + requestedStages = [], stages = [], statuses = {}, pauseReasons = {} + ) => { + const owners = new Set((requestedStages || []).filter(Boolean)); + if (!owners.has('kaks') || owners.has('ppi')) return Array.from(owners); + + const ppiStage = (stages || []).find(stage => { + const stageId = typeof stage === 'object' ? stage?.id : stage; + return stageId === 'ppi'; + }); + if (!ppiStage || statuses.ppi !== 'paused') return Array.from(owners); + + const dependencies = typeof ppiStage === 'object' && Array.isArray(ppiStage.dependencies) + ? ppiStage.dependencies + : []; + if (!dependencies.includes('ortholog')) return Array.from(owners); + + const pause = pauseReasons.ppi || {}; + const inheritedOrthologPause = statuses.ortholog === 'paused' + && pause.type === 'dependency_paused' + && pause.reason === 'upstream_stage_paused'; + const explicitPpiReferencePause = pause.type === 'ppi_kaks_reference_data_required'; + if (inheritedOrthologPause || explicitPpiReferencePause) owners.add('ppi'); + return Array.from(owners); + }; + const handlePpiReferenceDownloadFailure = async (err, resolution = {}, recommendation = {}, requestedStages = []) => { - const decision = await requestPpiDownloadFallbackDecision(err, resolution, recommendation, requestedStages); + const fallbackStages = ppiKaksFallbackRecoveryOwners( + requestedStages, + batchExecuteStages.value || [], + stageExecutionStatus.value || {}, + stagePauseReasons.value || {} + ); + const decision = await requestPpiDownloadFallbackDecision(err, resolution, recommendation, fallbackStages); if (decision === 'ath') { - applyArabidopsisFallbackForPpiKaks(requestedStages, err?.message || String(err || 'reference_bundle_download_failed')); - addAgentMessage('assistant', t('✅ 已确认使用拟南芥作为 PPI/KaKs 的显式兜底参考;该选择只写入 Ortholog/PPI/KaKs 专用参数,其它常规阶段不受影响。')); - if (requestedStages.includes('ppi')) { - const requestBody = buildPpiKaksDependencyRequest(['ppi']); - await orchestratePpiOwnerAfterReferenceReady({ - requestBody, - requestedStages: ['ppi'], - runPreflight: runPpiSpeciesResolutionPreflight, - dependencyStatus: dependencyId => stageExecutionStatus.value[dependencyId], - waitForDependency: waitForPpiProducerDependency, - ensureOrtholog: async (nextResolution, nextRequest) => { - const ready = ensureOrthologStageForPpiKaks( - batchExecuteStages.value || [], ['ppi'], nextRequest, nextResolution - ); - return { - ready: Boolean(ready), - producerStage: (batchExecuteStages.value || []).find(stage => getStageId(stage) === 'ortholog') || null - }; - }, - executeProducer: executePpiOwnedOrthologProducer, - resume: async () => resumePpiKaksPausedStages(['ppi'], '拟南芥兜底参考已确认') - }); + if (!fallbackStages.includes('ppi')) { + return false; } - if (requestedStages.includes('kaks')) { - await resumePpiKaksPausedStages(['kaks'], '拟南芥兜底参考已确认'); + applyArabidopsisFallbackForPpiKaks(['ppi'], err?.message || String(err || 'reference_bundle_download_failed')); + addAgentMessage('assistant', '✅ 已确认使用拟南芥作为 PPI 的显式兜底参考;该选择只写入 PPI 专用 Ortholog 参数,其它阶段不受影响。'); + const requestBody = buildPpiKaksDependencyRequest(['ppi']); + const orchestrated = await orchestratePpiOwnerAfterReferenceReady({ + requestBody, + requestedStages: ['ppi'], + runPreflight: runPpiSpeciesResolutionPreflight, + dependencyStatus: dependencyId => stageExecutionStatus.value[dependencyId], + waitForDependency: waitForPpiProducerDependency, + ensureOrtholog: async (nextResolution, nextRequest) => { + const ready = ensureOrthologStageForPpiKaks( + batchExecuteStages.value || [], ['ppi'], nextRequest, nextResolution + ); + return { + ready: Boolean(ready), + producerStage: getPpiKaksInternalOrthologProducer('ppi') + }; + }, + executeProducer: executePpiOwnedOrthologProducer, + resume: async () => resumePpiKaksPausedStages(['ppi'], '拟南芥兜底参考已确认') + }); + if (orchestrated !== true) { + const pauseMessage = '拟南芥兜底参考尚未形成可用的 PPI Ortholog 映射;PPI 保持暂停。'; + storePpiKaksReferencePause(['ppi'], {}, pauseMessage); + addAgentMessage('assistant', `⏸️ ${pauseMessage}`); + return false; } return true; } const pauseMessage = `推荐参考数据下载失败:${err?.message || err}。已先暂停 Ortholog/PPI/KaKs,其它阶段继续;后续可上传参考 PEP/CDS 后重新执行。`; + if (fallbackStages.includes('ppi')) { + batchExecuteParams.value = { + ...(batchExecuteParams.value || {}), + ppiExplicitlySkipped: true + }; + } storePpiKaksReferencePause(requestedStages, resolution, pauseMessage); addAgentMessage('assistant', `⏸️ ${pauseMessage}`); return false; @@ -13851,7 +14171,7 @@ compare: [[flesh, peel]]`; const downloadRecommendedPpiReferenceBundle = async (resolution = {}, requestedStages = []) => { const recommendation = preferredPpiReferenceRecommendation(resolution); - if (!recommendation) throw new Error('没有可下载的推荐近源参考物种'); + if (!recommendation) throw new Error('No downloadable related reference species is available'); const selectedTaxon = String(recommendation.selected_string_taxon_id || recommendation.taxon_id || '').trim(); const storageKey = String(resolution.ppi_reference_storage_key || (selectedTaxon ? `string_taxon_${selectedTaxon}` : recommendation.species_code || '')).trim(); const response = await fetch('/api/reference-bundle/download', { @@ -13870,7 +14190,7 @@ compare: [[flesh, peel]]`; }); const data = await response.json().catch(() => ({})); if (!response.ok || data.success === false) { - throw new Error(referenceBundleErrorMessage(data, `参考数据下载失败(HTTP ${response.status})`)); + throw new Error(referenceBundleErrorMessage(data, `Reference species data download failed (HTTP ${response.status})`)); } if (data.async === true && data.task_id) { return { recommendation, task: data }; @@ -13878,20 +14198,46 @@ compare: [[flesh, peel]]`; return { recommendation, result: data }; }; + const formatReferenceDownloadBytes = (bytes) => { + const value = Number(bytes); + if (!Number.isFinite(value) || value <= 0) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + const scaled = value / (1024 ** unitIndex); + return `${unitIndex === 0 ? Math.round(scaled) : scaled.toFixed(1)} ${units[unitIndex]}`; + }; + const updateReferenceBundleDownloadProgress = (data = {}, recommendation = {}) => { const result = data.result || {}; const progress = data.progress || result.progress || {}; + const previous = referenceBundleDownloadState.value || {}; const rawPercentage = progress.percentage ?? data.percentage ?? result.percentage; - const percentage = Number.isFinite(Number(rawPercentage)) ? Math.max(0, Math.min(100, Math.round(Number(rawPercentage)))) : 0; - const speciesLabel = recommendation.species_name || recommendation.species_code || data.species_name || data.species_code || result.species_name || result.species_code || ''; + const status = String(data.status || result.status || 'running').toLowerCase(); + const success = ['success', 'completed'].includes(status); + const percentage = success + ? 100 + : (Number.isFinite(Number(rawPercentage)) ? Math.max(0, Math.min(100, Math.round(Number(rawPercentage)))) : 0); + const taskId = data.task_id || data.id || previous.taskId || ''; + const isNewTask = Boolean(taskId && previous.taskId && taskId !== previous.taskId); + const rawBytesDownloaded = progress.bytes_downloaded ?? data.bytes_downloaded ?? result.bytes_downloaded; + const rawTotalBytes = progress.total_bytes ?? data.total_bytes ?? result.total_bytes; + const bytesDownloaded = Number.isFinite(Number(rawBytesDownloaded)) + ? Math.max(0, Number(rawBytesDownloaded)) + : (isNewTask ? 0 : Number(previous.bytesDownloaded || 0)); + const totalBytes = Number.isFinite(Number(rawTotalBytes)) + ? Math.max(0, Number(rawTotalBytes)) + : (isNewTask ? 0 : Number(previous.totalBytes || 0)); + const speciesLabel = recommendation.species_name || recommendation.species_code || data.species_name || data.species_code || result.species_name || result.species_code || previous.speciesLabel || ''; referenceBundleDownloadState.value = { visible: true, - taskId: data.task_id || data.id || referenceBundleDownloadState.value.taskId || '', + taskId, speciesLabel, - message: progress.message || data.message || result.message || '正在后台下载参考物种注释包', + message: progress.message || data.message || result.message || 'Downloading PPI and Ka/Ks reference species PEP and CDS', percentage, - indeterminate: progress.indeterminate !== undefined ? Boolean(progress.indeterminate) : rawPercentage === undefined || rawPercentage === null, - status: String(data.status || result.status || 'running').toLowerCase() + indeterminate: success ? false : (progress.indeterminate !== undefined ? Boolean(progress.indeterminate) : rawPercentage === undefined || rawPercentage === null), + status, + bytesDownloaded, + totalBytes }; }; @@ -13903,7 +14249,9 @@ compare: [[flesh, peel]]`; message: '', percentage: 0, indeterminate: true, - status: '' + status: '', + bytesDownloaded: 0, + totalBytes: 0 }; }; @@ -13913,7 +14261,7 @@ compare: [[flesh, peel]]`; const response = await fetch(`/api/task/${encodeURIComponent(taskId)}`); const data = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(referenceBundleErrorMessage(data, `参考数据下载状态查询失败(HTTP ${response.status})`)); + throw new Error(referenceBundleErrorMessage(data, `Reference species data status request failed (HTTP ${response.status})`)); } updateReferenceBundleDownloadProgress(data, options.recommendation || {}); const status = String(data.status || data.result?.status || '').toLowerCase(); @@ -13921,7 +14269,7 @@ compare: [[flesh, peel]]`; return data.result || data; } if (['failed', 'failure', 'cancelled', 'revoked'].includes(status)) { - const err = new Error(data.message || data.result?.message || data.result?.error || '参考数据下载失败'); + const err = new Error(data.message || data.result?.message || data.result?.error || 'Reference species data download failed'); err.task = data; err.result = data.result || {}; throw err; @@ -14030,6 +14378,7 @@ compare: [[flesh, peel]]`; suppressNeedsReferenceDataAlert: true }); if (!preflight.allowed) return false; + let readyResolution = preflight.resolution; if (preflight.resolution?.status === 'needs_ortholog') { const identifyStatus = typeof options.dependencyStatus === 'function' ? String(options.dependencyStatus('identify') || '') @@ -14049,18 +14398,60 @@ compare: [[flesh, peel]]`; const producerSucceeded = await options.executeProducer(producerStage); if (producerSucceeded !== true) return false; } + const postProducerPreflight = await options.runPreflight(requestBody, { + requestedStages, + allowNeedsOrtholog: false, + ignoreExistingOrtholog: false, + suppressNeedsReferenceDataAlert: false + }); + if (!postProducerPreflight.allowed || postProducerPreflight.resolution?.status !== 'ready') { + return false; + } + readyResolution = postProducerPreflight.resolution; } - await options.resume(preflight.resolution, requestBody); + await options.resume(readyResolution, requestBody); return true; }; + const ppiKaksInternalOrthologIdentityKey = (producerStage = {}, fallbackOwner = 'ppi') => { + const producerParams = producerStage.params || {}; + return [ + String(producerParams.dependencyOwner || fallbackOwner || 'ppi').trim().toLowerCase(), + String(producerParams.ppiReferenceTaxonId || '').trim(), + String(producerParams.ppiReferenceStorageKey || '').trim() + ].join('::'); + }; + const executePpiOwnedOrthologProducer = async (producerStage) => { if (!producerStage || getStageId(producerStage) !== 'ortholog') return false; - if (stageExecutionStatus.value.ortholog !== 'running') { - stageExecutionStatus.value.ortholog = 'pending'; + const producerParams = producerStage.params || {}; + const owner = String(producerParams.dependencyOwner || 'ppi').trim().toLowerCase(); + const producerIdentity = ppiKaksInternalOrthologIdentityKey(producerStage, owner); + if (ppiKaksInternalOrthologStatus.get(producerIdentity) === 'completed') return true; + const inFlight = ppiKaksInternalOrthologPromises.get(producerIdentity); + if (inFlight) return inFlight; + const executionPromise = (async () => { + ppiKaksInternalOrthologStatus.set(producerIdentity, 'running'); + try { + addAgentMessage('assistant', `⚙️ 正在内部生成 ${getStageLabel(owner)} 专属同源映射...`); + await executeStageDirect('ortholog', producerParams); + ppiKaksInternalOrthologStatus.set(producerIdentity, 'completed'); + addAgentMessage('assistant', `✅ ${getStageLabel(owner)} 专属同源映射生成完成。`); + return true; + } catch (err) { + ppiKaksInternalOrthologStatus.set(producerIdentity, 'failed'); + addAgentMessage('assistant', `❌ ${getStageLabel(owner)} 专属同源映射生成失败:${err?.message || err}`); + return false; + } + })(); + ppiKaksInternalOrthologPromises.set(producerIdentity, executionPromise); + try { + return await executionPromise; + } finally { + if (ppiKaksInternalOrthologPromises.get(producerIdentity) === executionPromise) { + ppiKaksInternalOrthologPromises.delete(producerIdentity); + } } - await executeStagesWithDependencies([producerStage], new Set(['ortholog'])); - return stageExecutionStatus.value.ortholog === 'completed'; }; const waitForPpiProducerDependency = async (dependencyId) => { @@ -14074,19 +14465,30 @@ compare: [[flesh, peel]]`; if (!taskId) return; (async () => { try { - updateReferenceBundleDownloadProgress({ status: 'running', task_id: taskId, message: '推荐参考数据正在后台下载' }, recommendation); + updateReferenceBundleDownloadProgress({ status: 'running', task_id: taskId, message: 'Downloading PPI and Ka/Ks reference species PEP and CDS' }, recommendation); const result = await pollReferenceBundleDownloadTask(taskId, { recommendation }); - const refCode = recommendation.species_code || result.species_code || ''; - const ownerSelection = ppiOwnerSelectionFromResolution(resolution); - if (requestedStages.includes('ppi') && ownerSelection) { + const storageKey = result.species_code || resolution.ppi_reference_storage_key || recommendation.species_code || ''; + const displayCode = recommendation.species_code || result.species_code || ''; + const recoveryStages = ppiKaksFallbackRecoveryOwners( + requestedStages, + batchExecuteStages.value || [], + stageExecutionStatus.value || {}, + stagePauseReasons.value || {} + ); + const ownerSelection = ppiOwnerSelectionFromResolution({ + ...resolution, + selected_taxon_id: result.taxon_id || recommendation.selected_string_taxon_id || recommendation.taxon_id || resolution.selected_taxon_id || '', + ppi_reference_storage_key: storageKey, + ppi_reference_user_confirmed: true + }); + if (recoveryStages.includes('ppi') && ownerSelection) { applyPpiOwnerSelectionToBatch(ownerSelection, {}); } - if (requestedStages.includes('kaks') && refCode) { - mergePpiReferenceSpeciesIntoBatch(refCode, ['kaks']); + if (recoveryStages.includes('kaks') && storageKey) { + mergePpiReferenceSpeciesIntoBatch(storageKey, ['kaks']); } - clearReferenceBundleDownloadProgress(); - addAgentMessage('assistant', t('✅ 推荐参考数据下载完成:') + (recommendation.species_name || result.species_name || refCode) + ' (' + (refCode || 'reference') + t(')。已写入 Ortholog/PPI/Ka/Ks 专用参考物种。')); - if (requestedStages.includes('ppi')) { + addAgentMessage('assistant', `✅ PPI and Ka/Ks reference species data are ready: ${recommendation.species_name || result.species_name || displayCode} (${displayCode || 'reference'}).`); + if (recoveryStages.includes('ppi')) { const requestBody = buildPpiKaksDependencyRequest(['ppi']); await orchestratePpiOwnerAfterReferenceReady({ requestBody, @@ -14100,18 +14502,18 @@ compare: [[flesh, peel]]`; ); return { ready: Boolean(ready), - producerStage: (batchExecuteStages.value || []).find(stage => getStageId(stage) === 'ortholog') || null + producerStage: getPpiKaksInternalOrthologProducer('ppi') }; }, executeProducer: executePpiOwnedOrthologProducer, - resume: async () => resumePpiKaksPausedStages(['ppi'], '推荐参考数据已下载完成') + resume: async () => resumePpiKaksPausedStages(['ppi'], 'Reference PEP and CDS are ready') }); } - if (requestedStages.includes('kaks')) { - await resumePpiKaksPausedStages(['kaks'], '推荐参考数据已下载完成'); + if (recoveryStages.includes('kaks')) { + await resumePpiKaksPausedStages(['kaks'], 'Reference PEP and CDS are ready'); } } catch (err) { - updateReferenceBundleDownloadProgress({ status: 'failed', message: err?.message || '参考数据下载失败' }, recommendation); + updateReferenceBundleDownloadProgress({ ...(err?.task || {}), status: 'failed', message: err?.message || 'Reference species data download failed' }, recommendation); await handlePpiReferenceDownloadFailure(err, resolution || err.result?.resolution || {}, recommendation, requestedStages); } })(); @@ -14125,7 +14527,8 @@ compare: [[flesh, peel]]`; }); }; - const ppiNeedsNearReferenceDecision = (resolution = {}) => { + const ppiNeedsNearReferenceDecision = (resolution = {}, requestedStages = []) => { + if (!requestedStages.includes('ppi')) return false; const selected = String(resolution.selected_string_species || '').toLowerCase(); return resolution.status === 'needs_ortholog' && selected === 'ath' @@ -14151,10 +14554,15 @@ compare: [[flesh, peel]]`; ppiReferenceStorageKey: requestBody.ppiReferenceStorageKey || requestBody.ppi_reference_storage_key || '', ppiReferenceSelectionMode: requestBody.ppiReferenceSelectionMode || requestBody.ppi_reference_selection_mode || '', ppiReferenceUserConfirmed: requestBody.ppiReferenceUserConfirmed === true || requestBody.ppi_reference_user_confirmed === true, - dependencyOwner: requestBody.dependencyOwner || requestBody.dependency_owner || requestedStages[0] || 'ppi', + dependencyOwner: requestBody.dependencyOwner || requestBody.dependency_owner + || requestBody.orthologMappingOwner || requestBody.ortholog_mapping_owner + || requestedStages[0] || 'ppi', ignoreExistingOrtholog: options.ignoreExistingOrtholog === true, requestedStages }; + if (requestedStages.length === 1 && requestedStages[0] === 'ppi') { + canonicalizePpiOwnerBiologicalScope(payload); + } const response = await fetch('/api/ppi/species-resolution', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -14266,26 +14674,33 @@ compare: [[flesh, peel]]`; return true; }; - const buildPpiKaksDependencyRequest = (stageIds = []) => { + const buildPpiKaksDependencyRequest = (stageIds = [], stageParamsOverride = null) => { const batchParams = batchExecuteParams.value || {}; const planParams = batchParams.plan?.params || {}; const dependencyStage = (batchExecuteStages.value || []).find(stage => getStageId(stage) === stageIds[0]) || {}; - const stageParams = typeof dependencyStage === 'object' ? (dependencyStage.params || {}) : {}; + const storedStageParams = typeof dependencyStage === 'object' ? (dependencyStage.params || {}) : {}; + const stageParams = stageParamsOverride && typeof stageParamsOverride === 'object' + ? stageParamsOverride + : storedStageParams; const ownerStageId = stageIds[0] || 'ppi'; const isPpiOwner = ownerStageId === 'ppi'; + const mappingOwner = stageParams.orthologMappingOwner || ownerStageId; + const usesPpiOwner = mappingOwner === 'ppi'; const prefix = stageParams.prefix || batchParams.prefix || planParams.prefix || params.prefix || executionPrefix(ownerStageId); - const technicalReferenceSpecies = firstNonEmptyList( - stageParams.ppiKaksReferenceSpecies, - stageParams.ppiReferenceSpecies, - stageParams.referenceBundleSpecies, - batchParams.ppiKaksReferenceSpecies, - batchParams.ppiReferenceSpecies, - batchParams.referenceBundleSpecies, - planParams.ppiKaksReferenceSpecies, - planParams.ppiReferenceSpecies, - planParams.referenceBundleSpecies - ); - return { + const technicalReferenceSpecies = (!isPpiOwner && stageParams.kaksReferenceSource === 'project') + ? [] + : firstNonEmptyList( + stageParams.ppiKaksReferenceSpecies, + stageParams.ppiReferenceSpecies, + stageParams.referenceBundleSpecies, + batchParams.ppiKaksReferenceSpecies, + batchParams.ppiReferenceSpecies, + batchParams.referenceBundleSpecies, + planParams.ppiKaksReferenceSpecies, + planParams.ppiReferenceSpecies, + planParams.referenceBundleSpecies + ); + const requestBody = { stage: ownerStageId, prefix, scopeFile: stageParams.scopeFile || stageParams.scope_file || batchParams.scopeFile || batchParams.scope_file || planParams.scopeFile || planParams.scope_file || '', @@ -14299,11 +14714,11 @@ compare: [[flesh, peel]]`; ppiKaksReferenceSpecies: isPpiOwner ? [] : technicalReferenceSpecies, ppiReferenceSpecies: isPpiOwner ? '' : (technicalReferenceSpecies[0] || stageParams.ppiReferenceSpecies || batchParams.ppiReferenceSpecies || planParams.ppiReferenceSpecies || ''), referenceBundleSpecies: isPpiOwner ? '' : (technicalReferenceSpecies[0] || stageParams.referenceBundleSpecies || batchParams.referenceBundleSpecies || planParams.referenceBundleSpecies || ''), - ppiReferenceTaxonId: isPpiOwner ? (stageParams.ppiReferenceTaxonId || batchParams.ppiReferenceTaxonId || planParams.ppiReferenceTaxonId || '') : '', - ppiReferenceStorageKey: isPpiOwner ? (stageParams.ppiReferenceStorageKey || batchParams.ppiReferenceStorageKey || planParams.ppiReferenceStorageKey || '') : '', - ppiReferenceSelectionMode: isPpiOwner ? (stageParams.ppiReferenceSelectionMode || batchParams.ppiReferenceSelectionMode || planParams.ppiReferenceSelectionMode || '') : '', - ppiReferenceUserConfirmed: isPpiOwner && (stageParams.ppiReferenceUserConfirmed === true || batchParams.ppiReferenceUserConfirmed === true || planParams.ppiReferenceUserConfirmed === true), - dependencyOwner: ownerStageId, + ppiReferenceTaxonId: usesPpiOwner ? (stageParams.ppiReferenceTaxonId || batchParams.ppiReferenceTaxonId || planParams.ppiReferenceTaxonId || '') : '', + ppiReferenceStorageKey: usesPpiOwner ? (stageParams.ppiReferenceStorageKey || batchParams.ppiReferenceStorageKey || planParams.ppiReferenceStorageKey || '') : '', + ppiReferenceSelectionMode: usesPpiOwner ? (stageParams.ppiReferenceSelectionMode || batchParams.ppiReferenceSelectionMode || planParams.ppiReferenceSelectionMode || '') : '', + ppiReferenceUserConfirmed: usesPpiOwner && (stageParams.ppiReferenceUserConfirmed === true || batchParams.ppiReferenceUserConfirmed === true || planParams.ppiReferenceUserConfirmed === true), + dependencyOwner: mappingOwner, projectReferenceSpecies: firstNonEmptyList( stageParams.primaryReferenceSpecies, batchParams.primaryReferenceSpecies, @@ -14315,8 +14730,37 @@ compare: [[flesh, peel]]`; geneFamily: stageParams.geneFamily || stageParams.family || batchParams.geneFamily || planParams.geneFamily || params.geneFamily || ((prefix || '').split('.')[1] || ''), requestedStages: stageIds }; + return isPpiOwner ? canonicalizePpiOwnerBiologicalScope(requestBody) : requestBody; }; + const ppiKaksInternalOrthologProducers = new Map(); + const ppiKaksInternalOrthologStatus = new Map(); + const ppiKaksInternalOrthologPromises = new Map(); + const rememberPpiKaksInternalOrthologProducer = (owner, producerStage) => { + const key = String(owner || '').trim().toLowerCase(); + if (!key) return null; + if (!producerStage) { + const previousProducer = ppiKaksInternalOrthologProducers.get(key); + ppiKaksInternalOrthologProducers.delete(key); + if (previousProducer) { + const previousIdentity = ppiKaksInternalOrthologIdentityKey(previousProducer, key); + if (!ppiKaksInternalOrthologPromises.has(previousIdentity)) { + ppiKaksInternalOrthologStatus.delete(previousIdentity); + } + } + return null; + } + const producerIdentity = ppiKaksInternalOrthologIdentityKey(producerStage, key); + ppiKaksInternalOrthologProducers.set(key, producerStage); + if (!ppiKaksInternalOrthologPromises.has(producerIdentity)) { + ppiKaksInternalOrthologStatus.set(producerIdentity, 'pending'); + } + return producerStage; + }; + const getPpiKaksInternalOrthologProducer = (owner) => ( + ppiKaksInternalOrthologProducers.get(String(owner || '').trim().toLowerCase()) || null + ); + const buildPpiOwnedOrthologStage = (requestBody = {}, ownerState = {}, requestedStages = ['ppi']) => { const storageKey = String( ownerState.ppiReferenceStorageKey @@ -14351,6 +14795,19 @@ compare: [[flesh, peel]]`; return { id: 'ortholog', name: 'Ortholog', dependencies: ['identify'], params }; }; + const buildKaksOwnedOrthologStage = (stageParams = {}) => ({ + id: 'ortholog', + name: 'Ortholog', + dependencies: ['identify'], + params: { + ...stageParams, + dependencyOwner: 'kaks', + autoInsertedFor: ['kaks'], + requestedStages: ['kaks'], + referencePolicySource: 'kaks_owner_ortholog_producer' + } + }); + const attachOrthologDependencyToOwners = (requestedStages = []) => { batchExecuteStages.value = (batchExecuteStages.value || []).map(stage => { const stageId = getStageId(stage); @@ -14428,12 +14885,12 @@ compare: [[flesh, peel]]`; return true; }; - const ensurePpiKaksDependencyOwner = async (ownerStageId) => { + const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) => { const stages = batchExecuteStages.value || []; const stageIds = stages.map(stage => getStageId(stage)).filter(Boolean); if (!ppiKaksDependencyStages.has(ownerStageId) || !stageIds.includes(ownerStageId)) return true; const requestedStages = [ownerStageId]; - const requestBody = buildPpiKaksDependencyRequest(requestedStages); + const requestBody = buildPpiKaksDependencyRequest(requestedStages, stageParamsOverride); let preflight = await runPpiSpeciesResolutionPreflight(requestBody, { requestedStages, allowNeedsOrtholog: true, @@ -14444,6 +14901,10 @@ compare: [[flesh, peel]]`; const ownerSelection = await confirmNearestPpiReferenceCandidate(preflight.resolution); if (!ownerSelection) { const pauseMessage = 'PPI 近源 STRING 参考物种尚未确认;本次不提交 Ortholog 或 PPI。'; + batchExecuteParams.value = { + ...(batchExecuteParams.value || {}), + ppiExplicitlySkipped: true + }; storePpiKaksReferencePause(requestedStages, preflight.resolution, pauseMessage); addAgentMessage('assistant', `⏸️ ${pauseMessage}`); return true; @@ -14460,9 +14921,15 @@ compare: [[flesh, peel]]`; !preflight.allowed && ( preflight.resolution?.status === 'needs_reference_data' - || ppiNeedsNearReferenceDecision(preflight.resolution) + || ppiNeedsNearReferenceDecision(preflight.resolution, requestedStages) ) ) { + if (ownerStageId === 'kaks') { + const pauseMessage = 'Ka/Ks cannot reuse the selected PPI reference bundle or Ortholog mapping. Ka/Ks will not start another download.'; + storePpiKaksReferencePause(requestedStages, preflight.resolution, pauseMessage); + addAgentMessage('assistant', `⏸️ ${pauseMessage}`); + return false; + } const decision = await requestPpiReferenceDataDecision(preflight.resolution, requestBody); if (decision === 'project_reference') { if (ownerStageId === 'ppi') { @@ -14486,12 +14953,12 @@ compare: [[flesh, peel]]`; return { ...base, params: { ...(base.params || {}), ppiSpecies: explicitPpiSpecies } }; }); clearPpiKaksReferencePause(requestedStages); - addAgentMessage('assistant', t('✅ 已选择项目参考物种 ') + projectReference + t(';将按该参考检查或生成 Ortholog,并执行 ') + requestedStages.join('、') + '。'); + addAgentMessage('assistant', `✅ 已选择项目参考物种 ${projectReference};将按该参考检查或生成 Ortholog,并执行 ${requestedStages.join('、')}。`); return true; } if (decision === 'download') { try { - addAgentMessage('assistant', t('⬇️ 正在提交推荐近源参考物种注释包后台下载;Ortholog/PPI/KaKs 将等待参考数据,其它阶段继续执行。')); + addAgentMessage('assistant', '⬇️ Starting PPI and Ka/Ks reference species PEP/CDS download. PPI and Ka/Ks will wait; other stages continue.'); const download = await downloadRecommendedPpiReferenceBundle(preflight.resolution, requestedStages); if (download.task?.task_id) { const recommendation = download.recommendation || {}; @@ -14505,23 +14972,38 @@ compare: [[flesh, peel]]`; reference_download_task_id: download.task.task_id }, pauseMessage); monitorPpiReferenceBundleDownload(download.task.task_id, recommendation, requestedStages, preflight.resolution); - addAgentMessage('assistant', t('⏳ 推荐参考数据下载已转入后台:') + (recommendation.species_name || recommendation.species_code || 'reference') + t(',task_id=') + download.task.task_id + '。'); + addAgentMessage('assistant', `⏳ 推荐参考数据下载已转入后台:${recommendation.species_name || recommendation.species_code || 'reference'},task_id=${download.task.task_id}。`); return true; } const recommendation = download.recommendation || {}; const result = download.result || {}; - const refCode = recommendation.species_code || result.species_code || ''; - const ownerSelection = ppiOwnerSelectionFromResolution(preflight.resolution); - if (ownerStageId === 'ppi' && ownerSelection) { - applyPpiOwnerSelectionToBatch(ownerSelection, requestBody); + const storageKey = result.species_code || preflight.resolution.ppi_reference_storage_key || recommendation.species_code || ''; + const displayCode = recommendation.species_code || result.species_code || ''; + const recoveryStages = ppiKaksFallbackRecoveryOwners( + requestedStages, + batchExecuteStages.value || [], + stageExecutionStatus.value || {}, + stagePauseReasons.value || {} + ); + const ownerSelection = ppiOwnerSelectionFromResolution({ + ...preflight.resolution, + selected_taxon_id: result.taxon_id || recommendation.selected_string_taxon_id || recommendation.taxon_id || preflight.resolution.selected_taxon_id || '', + ppi_reference_storage_key: storageKey, + ppi_reference_user_confirmed: true + }); + if (recoveryStages.includes('ppi') && ownerSelection) { + applyPpiOwnerSelectionToBatch(ownerSelection, ownerStageId === 'ppi' ? requestBody : {}); } - if (ownerStageId === 'kaks' && refCode) { - mergePpiReferenceSpeciesIntoBatch(refCode, ['kaks']); + if (recoveryStages.includes('kaks') && storageKey) { + mergePpiReferenceSpeciesIntoBatch(storageKey, ['kaks']); } - addAgentMessage('assistant', t('✅ 推荐参考数据下载完成:') + (recommendation.species_name || refCode) + ' (' + refCode + t(')。已写入 Ortholog/PPI/Ka/Ks 专用参考物种。')); - if (ownerStageId === 'ppi') { + addAgentMessage('assistant', `✅ 推荐参考数据下载完成:${recommendation.species_name || displayCode} (${displayCode})。已写入 Ortholog/PPI/Ka/Ks 专用参考物种。`); + if (recoveryStages.includes('ppi')) { + const ppiRequestBody = ownerStageId === 'ppi' + ? requestBody + : buildPpiKaksDependencyRequest(['ppi']); await orchestratePpiOwnerAfterReferenceReady({ - requestBody, + requestBody: ppiRequestBody, requestedStages: ['ppi'], runPreflight: runPpiSpeciesResolutionPreflight, dependencyStatus: dependencyId => stageExecutionStatus.value[dependencyId], @@ -14532,13 +15014,14 @@ compare: [[flesh, peel]]`; ); return { ready: Boolean(ready), - producerStage: (batchExecuteStages.value || []).find(stage => getStageId(stage) === 'ortholog') || null + producerStage: getPpiKaksInternalOrthologProducer('ppi') }; }, executeProducer: executePpiOwnedOrthologProducer, resume: async () => resumePpiKaksPausedStages(['ppi'], '推荐参考数据已下载完成') }); - } else { + } + if (recoveryStages.includes('kaks')) { await resumePpiKaksPausedStages(['kaks'], '推荐参考数据已下载完成'); } return true; @@ -14549,7 +15032,7 @@ compare: [[flesh, peel]]`; } const pauseMessage = formatPpiSpeciesResolutionBlockMessage(preflight.resolution); storePpiKaksReferencePause(requestedStages, preflight.resolution, pauseMessage); - addAgentMessage('assistant', t('⏸️ 已按选择先暂停 Ortholog/PPI/KaKs,其它阶段继续执行。') + '\n' + pauseMessage); + addAgentMessage('assistant', `⏸️ 已按选择先暂停 Ortholog/PPI/KaKs,其它阶段继续执行。\n${pauseMessage}`); return true; } if (!preflight.allowed) return false; @@ -14558,21 +15041,57 @@ compare: [[flesh, peel]]`; if (preflight.resolution?.status !== 'needs_ortholog') { const mappingLabel = ppiOrthologMappingLabel(preflight.resolution); if (preflight.resolution?.status === 'ready' && mappingLabel && !stageIds.includes('ortholog')) { - addAgentMessage('assistant', t('✅ PPI/KaKs 前置数据检查通过:检测到已有 Ortholog 映射(') + mappingLabel + t('),将直接执行 ') + requestedStages.join('、') + '。'); + addAgentMessage('assistant', `✅ PPI/KaKs 前置数据检查通过:检测到已有 Ortholog 映射(${mappingLabel}),将直接执行 ${requestedStages.join('、')}。`); } return true; } return ensureOrthologStageForPpiKaks(stages, requestedStages, requestBody, preflight.resolution); }; + const preflightPpiKaksDependencyOwners = async (requestedStages = [], ensureOwner) => { + const outcomes = []; + for (const ownerStageId of requestedStages) { + const result = await ensureOwner(ownerStageId); + const status = result && typeof result === 'object' + ? (result.status || 'ready') + : (result ? 'ready' : 'paused'); + outcomes.push({ ownerStageId, status }); + } + return outcomes; + }; + const ensureOrthologProducerForPpiKaks = async () => { const requestedStages = (batchExecuteStages.value || []) .map(stage => getStageId(stage)) - .filter(stageId => ppiKaksDependencyStages.has(stageId)); - for (const ownerStageId of requestedStages) { - if (!await ensurePpiKaksDependencyOwner(ownerStageId)) return false; - } - return true; + .filter(stageId => stageId === 'ppi'); + return preflightPpiKaksDependencyOwners(requestedStages, async ownerStageId => { + try { + const ready = await ensurePpiKaksDependencyOwner(ownerStageId); + if (!ready) { + const pauseMessage = `${getStageLabel(ownerStageId)} 前置数据检查未通过;已只暂停该阶段,其它阶段继续执行。`; + storePpiKaksReferencePause([ownerStageId], { issue_code: 'owner_preflight_blocked' }, pauseMessage); + addAgentMessage('assistant', `⏸️ ${pauseMessage}`); + return { status: 'paused' }; + } + return ppiKaksDependencyPauses()[ownerStageId] + ? { status: 'paused' } + : { status: 'ready' }; + } catch (err) { + const pauseMessage = `${getStageLabel(ownerStageId)} 前置数据检查异常:${err?.message || err};已只暂停该阶段,其它阶段继续执行。`; + storePpiKaksReferencePause([ownerStageId], { issue_code: 'owner_preflight_error' }, pauseMessage); + addAgentMessage('assistant', `⏸️ ${pauseMessage}`); + return { status: 'paused' }; + } + }); + }; + + const prepareKaksSelectedReference = async (stageParams = {}) => { + const ready = await ensurePpiKaksDependencyOwner('kaks', stageParams); + if (!ready || ppiKaksDependencyPauses().kaks) return false; + if (stageParams.orthologMappingOwner === 'ppi') return true; + const producerStage = buildKaksOwnedOrthologStage(stageParams); + rememberPpiKaksInternalOrthologProducer('kaks', producerStage); + return executePpiOwnedOrthologProducer(producerStage); }; @@ -14622,7 +15141,7 @@ compare: [[flesh, peel]]`; return { success: false, status: preflight.resolution?.status || 'ppi_kaks_dependency_resolution_blocked', - message: preflight.message || 'PPI/KaKs 参考数据/同源映射预检未通过。', + message: preflight.message || 'PPI/KaKs reference data or Ortholog mapping preflight failed.', ppi_species_resolution: preflight.resolution }; } @@ -15027,6 +15546,87 @@ compare: [[flesh, peel]]`; await startBatchExecute(); }; + + const autoGenerateGeneFamilyReportAfterBatch = async (batchStages = []) => { + const stageIds = batchStages.map(stage => getStageId(stage)).filter(Boolean); + if (stageIds.includes('gene_family_report')) return; + const geneFamilyStages = new Set([ + 'identify', 'phylogeny', 'reference_classification', 'motif', 'domain', 'structure', + 'promoter', 'tfbs', 'synteny', 'karyotype', 'interspecies_synteny', 'ppi', 'kaks', + 'pep', 'expression', 'combo' + ]); + const expectedStages = [...new Set(stageIds.filter(stageId => geneFamilyStages.has(stageId)))]; + if (expectedStages.length === 0) return; + const hasSuccessfulEvidence = expectedStages.some( + stageId => stageExecutionStatus.value[stageId] === 'completed' + ); + if (!hasSuccessfulEvidence) { + addAgentMessage('assistant', t('暂无可生成的基因家族结果:本批次没有成功阶段。')); + return; + } + const stageStatuses = Object.fromEntries(expectedStages.map(stageId => { + const status = stageExecutionStatus.value[stageId]; + return [stageId, status === 'completed' ? 'success' : status === 'failed' ? 'failed' : 'missing']; + })); + const batchParams = batchExecuteParams.value || {}; + const planParams = batchParams.plan?.params || {}; + try { + stageExecutionStatus.value.gene_family_report = 'running'; + await executeSingleStageWithParams('gene_family_report', { + expectedStages, + stageStatuses, + reportAction: 'generate', + reportUseModel: true, + scopeId: batchParams.scopeId || planParams.scopeId || '', + analysisRoute: batchParams.analysisRoute || planParams.analysisRoute || '', + memberSetId: batchParams.memberSetId || planParams.memberSetId || '', + rnaseqScopeId: batchParams.rnaseqScopeId || planParams.rnaseqScopeId || '' + }); + stageExecutionStatus.value.gene_family_report = 'completed'; + setResultStageFilter('gene_family_report'); + await refreshResults(jobInfo.job_id, 'gene_family_report'); + addAgentMessage('assistant', t('已根据本批次成功的基因家族阶段自动生成结果报告。')); + } catch (error) { + stageExecutionStatus.value.gene_family_report = 'failed'; + addAgentMessage('assistant', `${t('基因家族批次已结束,但结果报告生成失败:')}${error?.message || error}`); + } + }; + + + const autoGenerateRnaseqReportAfterBatch = async (batchStages = []) => { + const stageIds = batchStages.map(stage => getStageId(stage)).filter(Boolean); + if (stageIds.includes('rnaseq_report')) return; + const expectedStages = [...new Set(stageIds.filter(stageId => + (String(stageId).startsWith('rnaseq_') || ['annotation', 'annotation_go', 'annotation_kegg'].includes(stageId)) + && stageId !== 'rnaseq_report' + ))]; + if (expectedStages.length === 0) return; + const hasSuccessfulEvidence = expectedStages.some( + stageId => stageExecutionStatus.value[stageId] === 'completed' + ); + if (!hasSuccessfulEvidence) { + addAgentMessage('assistant', t('暂无可生成的 RNA-seq 结果:本批次没有成功阶段。')); + return; + } + try { + stageExecutionStatus.value.rnaseq_report = 'running'; + await executeSingleStageWithParams('rnaseq_report', { + prefix: executionPrefix(expectedStages[0]), + rnaseqScopeId: activeRnaseqScopeId.value || '', + rnaseqExpectedStages: expectedStages, + rnaseqReportAction: 'generate', + rnaseqReportUseModel: true + }); + stageExecutionStatus.value.rnaseq_report = 'completed'; + setResultStageFilter('rnaseq_report'); + await refreshResults(jobInfo.job_id, 'rnaseq_report'); + addAgentMessage('assistant', t('已根据本批次成功的 RNA-seq 阶段自动生成结果报告。')); + } catch (error) { + stageExecutionStatus.value.rnaseq_report = 'failed'; + addAgentMessage('assistant', `${t('RNA-seq 批次已结束,但结果报告生成失败:')}${error?.message || error}`); + } + }; + const startBatchExecute = async () => { if (isBatchExecuting.value) return; if (!batchExecutionUserConfirmed.value) return; @@ -15076,10 +15676,10 @@ compare: [[flesh, peel]]`; const allStages = ['genome_prepare', 'identify', 'identify_refine', 'subfamily_confirm', 'phylogeny', 'reference_classification', 'motif', 'domain', 'structure', 'promoter', 'tfbs', 'synteny', 'karyotype', 'interspecies_synteny', 'ppi', 'kaks', 'pep', - 'expression', 'rnaseq_qc', 'rnaseq_align', 'rnaseq_quant', 'rnaseq_sample_qc', + 'expression', 'gene_family_report', 'rnaseq_qc', 'rnaseq_align', 'rnaseq_quant', 'rnaseq_sample_qc', 'rnaseq_de', 'annotation', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich', 'rnaseq_fgsea', 'rnaseq_wgcna', - 'rnaseq_candidate_family']; + 'rnaseq_candidate_family', 'rnaseq_report']; const jobId = jobInfo.job_id; for (const stage of allStages) { @@ -15115,10 +15715,12 @@ compare: [[flesh, peel]]`; runningStages.value.clear(); // 启动依赖驱动的并行执行 - await executeStagesWithDependencies(stages, plannedStageIds); - - isBatchExecuting.value = false; - currentBatchStage.value = ''; + try { + await executeStagesWithDependencies(stages, plannedStageIds); + } finally { + isBatchExecuting.value = false; + currentBatchStage.value = ''; + } if (!shouldStopBatchExecute.value) { // 只统计当前批次 stages 中的 completed 数量(避免累积历史状态) @@ -15136,6 +15738,11 @@ compare: [[flesh, peel]]`; }).length; const totalCount = stages.length; + if (pausedCount === 0) { + await autoGenerateGeneFamilyReportAfterBatch(stages); + await autoGenerateRnaseqReportAfterBatch(stages); + } + if (failedCount > 0) { addAgentMessage('assistant', t('⚠️ 批量执行完成:成功 ') + completedCount + '/' + totalCount + t(',失败 ') + failedCount + '/' + totalCount); } else if (pausedCount > 0) { @@ -15153,6 +15760,13 @@ compare: [[flesh, peel]]`; } }; + const ppiSettledForKaks = (stages = [], statuses = {}, batchParams = {}) => { + const hasPpi = (stages || []).some(stage => getStageId(stage) === 'ppi'); + if (!hasPpi) return true; + if (batchParams.ppiExplicitlySkipped === true) return true; + return ['completed', 'failed'].includes(String(statuses.ppi || '')); + }; + // 依赖驱动的并行执行核心逻辑 const executeStagesWithDependencies = async (stages, plannedStageIds = null) => { const currentPlannedStageIds = plannedStageIds || new Set(stages.map(stage => getStageId(stage)).filter(Boolean)); @@ -15202,6 +15816,11 @@ compare: [[flesh, peel]]`; if (stageId === 'rnaseq_fgsea' && !currentPlannedStageIds.has('rnaseq_kegg_enrich')) { deps = deps.filter(dep => dep !== 'rnaseq_kegg_enrich'); } + if (stageId === 'kaks' && !ppiSettledForKaks( + stages, + stageExecutionStatus.value, + batchExecuteParams.value || {} + )) return false; const depsCompleted = deps.every(dep => ( stageExecutionStatus.value[dep] === 'completed' || (!currentPlannedStageIds.has(dep) && stageExecutionStatus.value[dep] !== 'failed') @@ -15334,6 +15953,20 @@ compare: [[flesh, peel]]`; addAgentMessage("assistant", t('亚家族标签确认被跳过,已停止依赖 confirmed_subfamily.tsv 的后续阶段;如需普通无亚家族标签分析,请重新发起并明确说明"不需要亚家族"。')); }; + const kaksReferenceChoiceParams = (source = 'project', ppiReference = '', projectReference = '') => { + const usePpi = source === 'ppi' && Boolean(String(ppiReference || '').trim()); + const reference = String(usePpi ? ppiReference : projectReference || '').trim(); + return { + kaksReferenceSource: usePpi ? 'ppi' : 'project', + referenceSpecies: reference ? [reference] : [], + refSpeciesList: reference ? [reference] : [], + ppiKaksReferenceSpecies: usePpi ? [reference] : [], + ppiReferenceSpecies: usePpi ? reference : '', + referenceBundleSpecies: usePpi ? reference : '', + orthologMappingOwner: usePpi ? 'ppi' : 'kaks' + }; + }; + const confirmedStageParamsForExecution = (stageId, stageParams = {}) => { const merged = { ...(stageParams || {}) }; if (confirmParams.prefix) merged.prefix = confirmParams.prefix; @@ -15406,6 +16039,14 @@ compare: [[flesh, peel]]`; merged.tfbsTopMotifs = Number(confirmParams.tfbsTopMotifs || 30); merged.tfbsScanBothStrands = confirmParams.tfbsScanBothStrands !== false; } + if (stageId === 'kaks') { + merged.kaksNeutralRate = confirmParams.kaksNeutralRate || 7.08e-9; + Object.assign(merged, kaksReferenceChoiceParams( + confirmParams.kaksReferenceSource, + confirmParams.kaksPpiReferenceSpecies, + confirmParams.kaksProjectReferenceSpecies + )); + } return merged; }; @@ -15444,6 +16085,7 @@ compare: [[flesh, peel]]`; stageExecutionStatus.value[stageId] = 'running'; clearStagePause(stageId); currentBatchStage.value = stageId; + let confirmedStageParams = {}; console.log(`[DEBUG-STAGE] ${stageId}: added to runningStages, status set to running`); console.log(`[DEBUG-STAGE] runningStages:`, Array.from(runningStages.value)); @@ -15481,10 +16123,41 @@ compare: [[flesh, peel]]`; // 执行阶段 console.log(`[DEBUG-STAGE] ${stageId}: calling executeSingleStageWithParams`); const stageParams = (typeof stage === 'object' ? (stage.params || {}) : {}); - const confirmedStageParams = confirmedStageParamsForExecution(stageAction, stageParams); + confirmedStageParams = confirmedStageParamsForExecution(stageAction, stageParams); if (typeof stage === 'object') { stage.params = { ...(stage.params || {}), ...confirmedStageParams }; } + if (stageAction === 'ppi') { + const producerStage = getPpiKaksInternalOrthologProducer('ppi'); + if (producerStage) { + const producerSucceeded = await executePpiOwnedOrthologProducer(producerStage); + if (!producerSucceeded) { + throw new Error('PPI 专属同源映射生成失败,PPI 未提交。'); + } + } + const readinessRequest = buildPpiKaksDependencyRequest(['ppi']); + const readiness = await runPpiSpeciesResolutionPreflight(readinessRequest, { + requestedStages: ['ppi'], + allowNeedsOrtholog: false, + ignoreExistingOrtholog: false, + suppressNeedsReferenceDataAlert: false + }); + if (!readiness.allowed || readiness.resolution?.status !== 'ready') { + throw new Error('PPI 专属同源映射完成后仍未通过同一身份预检,PPI 未提交。'); + } + } + if (stageAction === 'kaks') { + const kaksReferenceReady = await prepareKaksSelectedReference(confirmedStageParams); + if (!kaksReferenceReady) { + stageExecutionStatus.value[stageId] = 'paused'; + recordStagePause(stageId, ppiKaksDependencyPauses().kaks || { + type: 'ppi_kaks_reference_data_required', + reason: 'kaks_selected_reference_not_ready', + message: 'KaKs 选择的参考物种或专属同源映射尚未准备好。' + }); + return; + } + } const expressionPause = shouldPauseForExpressionDataSource(stageAction, confirmedStageParams); if (expressionPause) { stageExecutionStatus.value[stageId] = 'paused'; @@ -15735,6 +16408,24 @@ compare: [[flesh, peel]]`; confirmParams.identifySpeciesOptions = null; confirmParams.geneFamilyScopeOptions = null; confirmParams.pipelineReferenceSpecies = uniqueList(mergedStageParams.referenceSpecies || batchExecuteParams.value.referenceSpecies || []); + if (stageId === 'ppi_string_reference_confirmation' || stageId === 'ppi_kaks_reference_status') { + const resolution = mergedStageParams.resolution || {}; + const bundleStatus = resolution.reference_bundle_status || resolution.diagnostics?.reference_bundle_status || {}; + const targetSpecies = mergedStageParams.targetSpecies || resolution.target_species || resolution.targetSpecies || ''; + confirmParams.ppiKaksTargetSpecies = Array.isArray(targetSpecies) ? targetSpecies.join(', ') : targetSpecies; + confirmParams.ppiKaksRequestedStages = Array.isArray(mergedStageParams.requestedStages) + ? [...mergedStageParams.requestedStages] + : (Array.isArray(resolution.requested_stages) ? [...resolution.requested_stages] : ['ppi']); + confirmParams.ppiKaksRequiredFiles = ppiKaksRequiredFilesFromResolution(resolution); + confirmParams.ppiKaksMissingFiles = Array.isArray(bundleStatus.missing_required) ? [...bundleStatus.missing_required] : []; + confirmParams.ppiKaksResolutionStatus = resolution.reason || resolution.message || resolution.status || ''; + confirmParams.ppiKaksCurrentReferenceSpecies = resolution.reference_species || ''; + confirmParams.ppiKaksOrthologMapping = resolution.ortholog_mapping_source || ''; + confirmParams.ppiKaksReferenceCandidates = normalizePpiReferenceCandidates( + Array.isArray(mergedStageParams.candidates) ? mergedStageParams.candidates : resolution, + resolution.string_version || resolution.species_resolution?.string_version || '' + ); + } if (stageId === 'ppi_kaks_reference_data') { const resolution = mergedStageParams.resolution || {}; const recommendation = mergedStageParams.recommendation || preferredPpiReferenceRecommendation(resolution) || {}; @@ -15750,7 +16441,7 @@ compare: [[flesh, peel]]`; ? [...mergedStageParams.requiredFiles] : ppiKaksRequiredFilesFromResolution(resolution); confirmParams.ppiKaksMissingFiles = Array.isArray(bundleStatus.missing_required) ? [...bundleStatus.missing_required] : []; - confirmParams.ppiKaksResolutionStatus = resolution.reason || resolution.message || resolution.status || '需要准备参考数据和 Ortholog 映射'; + confirmParams.ppiKaksResolutionStatus = resolution.reason || resolution.message || resolution.status || 'Reference PEP/CDS and Ortholog mapping are required'; confirmParams.ppiKaksCurrentReferenceSpecies = resolution.reference_species || ''; confirmParams.ppiKaksOrthologMapping = resolution.ortholog_mapping_source || ''; confirmParams.ppiKaksReferenceRecommendationText = ppiKaksRecommendationText(recommendation); @@ -15880,6 +16571,27 @@ compare: [[flesh, peel]]`; confirmParams.ppiSpecies = 'auto'; confirmParams.ppiScore = 200; } + if (stageId === 'kaks') { + const ppiReference = stageExecutionStatus.value.ppi === 'completed' + ? String( + mergedStageParams.ppiReferenceStorageKey + || batchExecuteParams.value.ppiReferenceStorageKey + || batchExecuteParams.value.plan?.params?.ppiReferenceStorageKey + || '' + ).trim() + : ''; + const projectReference = firstNonEmptyList( + mergedStageParams.primaryReferenceSpecies, + mergedStageParams.referenceSpecies, + batchExecuteParams.value.primaryReferenceSpecies, + batchExecuteParams.value.plan?.params?.primaryReferenceSpecies, + batchExecuteParams.value.plan?.params?.referenceSpecies + )[0] || ''; + confirmParams.kaksPpiReferenceSpecies = ppiReference; + confirmParams.kaksProjectReferenceSpecies = projectReference; + confirmParams.kaksReferenceSource = stageParams.kaksReferenceSource + || (ppiReference ? 'ppi' : 'project'); + } if (stageId === 'ncbi_fetch_sequences') { const ncbiParams = { ...batchExecuteParams.value, ...stageParams }; const ncbiDefaults = ncbiDefaultsForParams(ncbiParams); @@ -17361,6 +18073,7 @@ compare: [[flesh, peel]]`; if (stageName === 'kaks') { requestBody.kaksNeutralRate = confirmParams.kaksNeutralRate || 7.08e-9; + requestBody.orthologMappingOwner = activeStageParams.orthologMappingOwner || ''; } if (stageName === 'reference_classification') { @@ -17851,11 +18564,14 @@ compare: [[flesh, peel]]`; const jobId = jobInfo.job_id; const batchParams = batchExecuteParams.value || {}; const batchPlanParams = batchParams.plan?.params || {}; - const disableBatchIdentityFallback = stageParams.resetMemberSetId === true || stageParams.analysisMode === 'per_species'; - let route = stageParams.analysisRoute || stageParams.analysis_route || (!disableBatchIdentityFallback ? (batchParams.analysisRoute || batchPlanParams.analysisRoute) : '') || ''; - let memberSet = stageParams.memberSetId || stageParams.member_set_id || (!disableBatchIdentityFallback ? (batchParams.memberSetId || batchPlanParams.memberSetId) : '') || ''; - const scopeId = stageParams.scopeId || stageParams.scope_id || (!disableBatchIdentityFallback ? (batchParams.scopeId || batchParams.scope_id || batchPlanParams.scopeId || batchPlanParams.scope_id) : '') || ''; - const scopeFile = stageParams.scopeFile || stageParams.scope_file || (!disableBatchIdentityFallback ? (batchParams.scopeFile || batchParams.scope_file || batchPlanParams.scopeFile || batchPlanParams.scope_file) : '') || ''; + const identityStageParams = stageName === 'ppi' + ? canonicalizePpiOwnerBiologicalScope({ ...batchPlanParams, ...batchParams, ...stageParams }) + : stageParams; + const disableBatchIdentityFallback = identityStageParams.resetMemberSetId === true || identityStageParams.analysisMode === 'per_species'; + let route = identityStageParams.analysisRoute || identityStageParams.analysis_route || (!disableBatchIdentityFallback ? (batchParams.analysisRoute || batchPlanParams.analysisRoute) : '') || ''; + let memberSet = identityStageParams.memberSetId || identityStageParams.member_set_id || (!disableBatchIdentityFallback ? (batchParams.memberSetId || batchPlanParams.memberSetId) : '') || ''; + const scopeId = identityStageParams.scopeId || identityStageParams.scope_id || (!disableBatchIdentityFallback ? (batchParams.scopeId || batchParams.scope_id || batchPlanParams.scopeId || batchPlanParams.scope_id) : '') || ''; + const scopeFile = identityStageParams.scopeFile || identityStageParams.scope_file || (!disableBatchIdentityFallback ? (batchParams.scopeFile || batchParams.scope_file || batchPlanParams.scopeFile || batchPlanParams.scope_file) : '') || ''; if (stageName === 'subfamily_confirm') { route = 'confirmed_subfamily'; memberSet = scopeId ? memberSetForScope(scopeId, route, stageName) : ''; @@ -21674,10 +22390,13 @@ compare: [[flesh, peel]]`; const jobId = jobInfo.job_id; const batchParams = batchExecuteParams.value || {}; const batchPlanParams = batchParams.plan?.params || {}; - const disableBatchIdentityFallback = stageParams.resetMemberSetId === true || stageParams.analysisMode === 'per_species'; - let route = stageParams.analysisRoute || stageParams.analysis_route || (!disableBatchIdentityFallback ? (batchParams.analysisRoute || batchPlanParams.analysisRoute) : '') || ''; - let memberSet = stageParams.memberSetId || stageParams.member_set_id || (!disableBatchIdentityFallback ? (batchParams.memberSetId || batchPlanParams.memberSetId) : '') || ''; - const scopeId = stageParams.scopeId || stageParams.scope_id || (!disableBatchIdentityFallback ? (batchParams.scopeId || batchParams.scope_id || batchPlanParams.scopeId || batchPlanParams.scope_id) : '') || ''; + const identityStageParams = stageId === 'ppi' + ? canonicalizePpiOwnerBiologicalScope({ ...batchPlanParams, ...batchParams, ...stageParams }) + : stageParams; + const disableBatchIdentityFallback = identityStageParams.resetMemberSetId === true || identityStageParams.analysisMode === 'per_species'; + let route = identityStageParams.analysisRoute || identityStageParams.analysis_route || (!disableBatchIdentityFallback ? (batchParams.analysisRoute || batchPlanParams.analysisRoute) : '') || ''; + let memberSet = identityStageParams.memberSetId || identityStageParams.member_set_id || (!disableBatchIdentityFallback ? (batchParams.memberSetId || batchPlanParams.memberSetId) : '') || ''; + const scopeId = identityStageParams.scopeId || identityStageParams.scope_id || (!disableBatchIdentityFallback ? (batchParams.scopeId || batchParams.scope_id || batchPlanParams.scopeId || batchPlanParams.scope_id) : '') || ''; if (stageId === 'subfamily_confirm') { route = 'confirmed_subfamily'; memberSet = scopeId ? memberSetForScope(scopeId, route, stageId) : ''; @@ -21742,6 +22461,7 @@ compare: [[flesh, peel]]`; } if (stageId === 'kaks') { requestBody.kaksNeutralRate = stageParams.kaksNeutralRate || confirmParams.kaksNeutralRate || 7.08e-9; + requestBody.orthologMappingOwner = stageParams.orthologMappingOwner || ''; } if (stageId === 'identify') { const batchParams = batchExecuteParams.value || {}; @@ -21857,17 +22577,21 @@ compare: [[flesh, peel]]`; if (['ortholog', 'kaks'].includes(stageId)) { const batchParams = batchExecuteParams.value || {}; const planParams = batchParams.plan?.params || {}; - const technicalReferenceSpecies = uniqueSpeciesList( - stageParams.ppiKaksReferenceSpecies, - stageParams.ppiReferenceSpecies, - stageParams.referenceBundleSpecies, - batchParams.ppiKaksReferenceSpecies, - batchParams.ppiReferenceSpecies, - batchParams.referenceBundleSpecies, - planParams.ppiKaksReferenceSpecies, - planParams.ppiReferenceSpecies, - planParams.referenceBundleSpecies - ); + const projectOwnedKaks = stageParams.kaksReferenceSource === 'project' + && (stageId === 'kaks' || stageParams.dependencyOwner === 'kaks'); + const technicalReferenceSpecies = projectOwnedKaks + ? [] + : uniqueSpeciesList( + stageParams.ppiKaksReferenceSpecies, + stageParams.ppiReferenceSpecies, + stageParams.referenceBundleSpecies, + batchParams.ppiKaksReferenceSpecies, + batchParams.ppiReferenceSpecies, + batchParams.referenceBundleSpecies, + planParams.ppiKaksReferenceSpecies, + planParams.ppiReferenceSpecies, + planParams.referenceBundleSpecies + ); if (technicalReferenceSpecies.length) { requestBody.ppiKaksReferenceSpecies = technicalReferenceSpecies; requestBody.ppiReferenceSpecies = technicalReferenceSpecies[0]; @@ -23388,6 +24112,8 @@ compare: [[flesh, peel]]`; batchExecuteParams, batchExecuteIndex, referenceBundleDownloadState, + formatReferenceDownloadBytes, + clearReferenceBundleDownloadProgress, batchExecuteCountdown, isBatchExecuting, shouldStopBatchExecute, diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index 695ef95e8..895e37cb1 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -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), diff --git a/docs/plantai_stage_revision_template.md b/docs/plantai_stage_revision_template.md index 9e2c88087..e713e186f 100755 --- a/docs/plantai_stage_revision_template.md +++ b/docs/plantai_stage_revision_template.md @@ -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 \ diff --git a/gfa_core/gene_family_report_data.py b/gfa_core/gene_family_report_data.py index 108a94ae9..b643fec1a 100644 --- a/gfa_core/gene_family_report_data.py +++ b/gfa_core/gene_family_report_data.py @@ -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( diff --git a/gfa_core/mas/agents/diagnosis.py b/gfa_core/mas/agents/diagnosis.py index 0376de93e..f84a293fd 100755 --- a/gfa_core/mas/agents/diagnosis.py +++ b/gfa_core/mas/agents/diagnosis.py @@ -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"], diff --git a/gfa_core/mas/agents/intent_v2.py b/gfa_core/mas/agents/intent_v2.py index edb243612..d0860a5aa 100755 --- a/gfa_core/mas/agents/intent_v2.py +++ b/gfa_core/mas/agents/intent_v2.py @@ -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排序富集分析"), diff --git a/gfa_core/mas/agents/planning_v2.py b/gfa_core/mas/agents/planning_v2.py index f76fb1f91..c0d8deedb 100755 --- a/gfa_core/mas/agents/planning_v2.py +++ b/gfa_core/mas/agents/planning_v2.py @@ -376,6 +376,7 @@ class PlanningAgentV2(BaseAgent): "rnaseq_fgsea": [], "rnaseq_wgcna": [], "rnaseq_candidate_family": [], + "rnaseq_report": [], } # 各阶段依赖 (来自PreFlight) diff --git a/gfa_core/mas/core/fast_intent.py b/gfa_core/mas/core/fast_intent.py index abeea717b..d32dc204f 100755 --- a/gfa_core/mas/core/fast_intent.py +++ b/gfa_core/mas/core/fast_intent.py @@ -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简化", "去掉注释", "去除注释", "注释信息", "长注释", diff --git a/gfa_core/mas/stage_capabilities.py b/gfa_core/mas/stage_capabilities.py index 8349971d5..e02dba5d3 100755 --- a/gfa_core/mas/stage_capabilities.py +++ b/gfa_core/mas/stage_capabilities.py @@ -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", ), diff --git a/gfa_core/mas/stage_catalog.py b/gfa_core/mas/stage_catalog.py index f0c31a663..a298423d9 100755 --- a/gfa_core/mas/stage_catalog.py +++ b/gfa_core/mas/stage_catalog.py @@ -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", } diff --git a/gfa_core/mas/tools/stage_status.py b/gfa_core/mas/tools/stage_status.py index 953a5aad0..bcb848f80 100755 --- a/gfa_core/mas/tools/stage_status.py +++ b/gfa_core/mas/tools/stage_status.py @@ -33,6 +33,7 @@ RNASEQ_STATUS_STAGES = { "rnaseq_fgsea", "rnaseq_wgcna", "rnaseq_candidate_family", + "rnaseq_report", "annotation", "annotation_go", "annotation_kegg", diff --git a/gfa_core/mas/tools/stage_tools.py b/gfa_core/mas/tools/stage_tools.py index e65b007a5..d63edaeff 100755 --- a/gfa_core/mas/tools/stage_tools.py +++ b/gfa_core/mas/tools/stage_tools.py @@ -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", diff --git a/gfa_core/rnaseq_evidence.py b/gfa_core/rnaseq_evidence.py new file mode 100644 index 000000000..d58c646af --- /dev/null +++ b/gfa_core/rnaseq_evidence.py @@ -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": ["补齐缺失或失败阶段后重新生成报告,以刷新证据快照。"], + } diff --git a/gfa_core/rnaseq_report.py b/gfa_core/rnaseq_report.py index 273de49ec..88f69312b 100644 --- a/gfa_core/rnaseq_report.py +++ b/gfa_core/rnaseq_report.py @@ -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}`", "", "**任务结果路径:** ", diff --git a/gfa_core/schemas/stages/rnaseq_report_contract_v1.md b/gfa_core/schemas/stages/rnaseq_report_contract_v1.md new file mode 100644 index 000000000..9947b658a --- /dev/null +++ b/gfa_core/schemas/stages/rnaseq_report_contract_v1.md @@ -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`. diff --git a/gfa_core/templates/gene_family_report_v1.md b/gfa_core/templates/gene_family_report_v1.md index 5a673c580..d48447233 100644 --- a/gfa_core/templates/gene_family_report_v1.md +++ b/gfa_core/templates/gene_family_report_v1.md @@ -33,10 +33,6 @@ 各阶段依次写明分析对象、源结果中的直接观察、谨慎解释、限制,并仅在有助复核时列出少量相对路径。 -### 2.2 语言边界 - -预测关系统一表述为线索;没有逐成员表或独立证据时,不指定候选基因,不声称功能改变或调控关系已经证实。 - ## 三、结果总览与完整性检查 diff --git a/tests/unit/test_multilingual_ppi_scheduler_parity.py b/tests/unit/test_multilingual_ppi_scheduler_parity.py new file mode 100644 index 000000000..b6cbe43f9 --- /dev/null +++ b/tests/unit/test_multilingual_ppi_scheduler_parity.py @@ -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)