This commit is contained in:
potaser
2026-08-19 20:23:24 +08:00
62 changed files with 6726 additions and 865 deletions
+7 -4
View File
@@ -700,11 +700,14 @@ def build_report_data(
),
]
sections.extend(stage_sections)
# The integrated conclusion has a fixed evidence contract. Do not take the
# first arbitrary observations because plan ordering can displace one of
# the three core facts requested for delivery.
integrated_observations = [
section.observations[0]
for section in stage_sections
if section.observations
][:3]
stage_sections_by_name[stage].observations[0]
for stage in ("identify", "phylogeny", "reference_classification")
if stage in stage_sections_by_name and stage_sections_by_name[stage].observations
]
sections.extend(
[
ReportSection(
@@ -299,6 +299,7 @@ class BoundRecognizeAnalysisIntentArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
query: str
prefix: str = ""
language: str = "zh"
_prefix_is_literal = field_validator("prefix")(_validate_prefix)
+6
View File
@@ -52,6 +52,7 @@ STAGE_SCRIPT_MAP = {
"annotation_kegg": "run_annotation.py",
"rnaseq_wgcna": "run_rnaseq_wgcna.py",
"rnaseq_candidate_family": "run_rnaseq_candidate_family.py",
"rnaseq_report": "run_rnaseq_report.py",
"rnaseq_sample_qc": "run_rnaseq_sample_qc.py",
"genome_prepare": "run_genome_prepare.py",
"genome_prepare_v2": "run_genome_prepare.py",
@@ -341,6 +342,11 @@ DIAGNOSIS_STAGE_COVERAGE = {
"postflight": ["candidate_family_ranking", "evidence_matrix", "recommendation", "rag_queries", "rag_references"],
"failure": ["annotation_missing", "id_mapping"],
},
"rnaseq_report": {
"preflight": ["successful_rnaseq_manifest", "prefix_scope_identity"],
"postflight": ["report_markdown", "evidence_snapshot", "coverage_status"],
"failure": ["no_successful_rnaseq_evidence", "scope_mismatch", "artifact_missing"],
},
"annotation": {
"preflight": ["protein_background", "tool_availability"],
"postflight": ["gene2go", "gene2ko", "annotation_coverage"],
+17
View File
@@ -74,6 +74,7 @@ class IntentAgentV2(BaseAgent):
"rnaseq_de": "RNA-seq差异表达分析 - 从原始FASTQ数据开始,进行质控→比对→定量→差异表达分析(DESeq2)",
"rnaseq_enrich": "RNA-seq富集分析 - GO/KEGG通路富集分析(RNA-seq流程最后一步)",
"rnaseq_candidate_family": "RNA-seq候选家族筛选 - 整合DEG、InterPro/Pfam和GO/KEGG富集,输出候选基因家族排名",
"rnaseq_report": "RNA-seq结果报告 - 从当前成功阶段生成可追溯的正式Markdown报告",
"enrichment": "富集分析 - 基于基因家族鉴定结果进行GO/KEGG通路富集分析",
"ncbi_fetch_sequences": "NCBI序列下载 - 根据GeneID、基因名或基因list下载protein/CDS序列"
}
@@ -521,6 +522,22 @@ class IntentAgentV2(BaseAgent):
has_rnaseq_marker = any(kw in query_lower for kw in ["rnaseq", "rna-seq", "rna seq", "转录组"])
explicit_gene_family_context = any(kw in query_lower for kw in ["基因家族", "家族表达", "表达量", "热图", "heatmap"])
if has_rnaseq_marker and any(
keyword in query_lower
for keyword in ["生成结果报告", "打开结果报告", "结果报告", "生成报告", "打开报告"]
):
action = "open" if "打开" in query_lower else "generate"
return {
"gene_family": "",
"species": "",
"stages": ["rnaseq_report"],
"primary_stage": "rnaseq_report",
"description": "打开或生成当前RNA-seq正式结果报告",
"requirements": ["至少一个成功的RNA-seq阶段manifest"],
"params": {"rnaseqReportAction": action},
"confidence": 0.99,
}
stage_force_rules = [
("rnaseq_candidate_family", ["候选家族", "候选基因家族", "家族排名", "tf注释", "tf family", "tffamily", "pfam注释", "deg注释", "差异基因注释"], "执行RNA-seq候选家族筛选"),
("rnaseq_fgsea", ["fgsea", "gsea", "排序富集", "基因集富集"], "执行RNA-seq fgsea排序富集分析"),
+2 -13
View File
@@ -44,7 +44,7 @@ from gfa_core.wgs_upstream_manifest import (
)
GF_TARGET_ONLY_STAGE_PARAMS = {"promoter", "synteny", "karyotype", "pep", "tfbs"}
GF_TARGET_ONLY_STAGE_PARAMS = {"promoter", "synteny", "karyotype", "pep", "tfbs", "ppi"}
GF_REFERENCE_STAGE_PARAMS = {
"phylogeny",
"motif",
@@ -54,7 +54,6 @@ GF_REFERENCE_STAGE_PARAMS = {
"interspecies_synteny",
"ortholog",
"kaks",
"ppi",
}
GF_SCOPE_IDENTITY_KEYS = (
"scopeFile",
@@ -377,6 +376,7 @@ class PlanningAgentV2(BaseAgent):
"rnaseq_fgsea": [],
"rnaseq_wgcna": [],
"rnaseq_candidate_family": [],
"rnaseq_report": [],
}
# 各阶段依赖 (来自PreFlight)
@@ -1958,17 +1958,6 @@ class PlanningAgentV2(BaseAgent):
recommended_params = learning_recommendations.get("params", {})
print(f"[PlanningAgentV2] 应用Learning推荐参数: {recommended_params}")
if "ppi" in stage_ids:
intent_params = dict(intent_params or {})
ppi_species = str(intent_params.get("ppiSpecies") or intent_params.get("ppi_species") or "").strip().lower()
if ppi_species and ppi_species != "auto" and not intent_params.get("referenceSpecies"):
ppi_ref_species = {"ath": ["At"], "osa": ["Os"], "zma": ["Zm"], "gma": ["Gm"], "sly": ["Sl"]}.get(ppi_species, [])
if ppi_ref_species:
intent_params["referenceSpecies"] = ppi_ref_species
intent_params["reference_species"] = ppi_ref_species
intent_params["refSpeciesList"] = ppi_ref_species
intent_params.setdefault("scopeMode", "target_plus_reference")
for i, stage_id in enumerate(stage_ids):
info = self.STAGE_INFO.get(stage_id, {"name": stage_id, "estimated_time": 10, "cpu": 8})
deps = self.DEPENDENCIES.get(stage_id, [])
+8 -2
View File
@@ -620,6 +620,9 @@ class RouterRuntime:
}
if not all(proposal.values()):
return None
original_request = str(raw.get("original_request") or "").strip()
if original_request:
proposal["original_request"] = original_request
workflow_id = str(raw.get("workflow_id") or "").strip()
if workflow_id in {
"data_preparation",
@@ -705,6 +708,7 @@ class RouterRuntime:
session_id: str,
job_id: str,
query: str,
original_query: str,
subject: str,
workflow_id: WorkflowId | None = None,
) -> dict[str, str]:
@@ -718,6 +722,7 @@ class RouterRuntime:
proposal = {
"proposal_id": f"proposal-{digest}",
"request": request,
"original_request": str(original_query or "").strip() or request,
"subject": normalized_subject,
}
if workflow_id is not None:
@@ -1400,8 +1405,8 @@ class RouterRuntime:
tool_trace=decision_trace,
interaction_kind=decision.interaction_kind,
)
if decision.goal == "new_analysis":
effective_query = self.tools.resolve_analysis_handoff_request(
if decision.goal == "new_analysis" and confirmed_proposal is None:
proposal_query = self.tools.resolve_analysis_handoff_request(
effective_query,
workflow_id=decision.workflow_id,
guidance=decision.analysis_request_kind == "guidance",
@@ -1431,6 +1436,7 @@ class RouterRuntime:
session_id=session_id,
job_id=job_id,
query=proposal_query,
original_query=query,
subject=decision.subject,
workflow_id=decision.workflow_id,
)
+30 -6
View File
@@ -21,6 +21,35 @@ def _is_beneath(path: Path, parent: Path) -> bool:
return True
def _resolve_job_layout(root: Path, job_id: str) -> tuple[Path, Path, Path]:
logical_input_job = root / "input" / job_id
logical_workspace = root / "workspace" / job_id
input_root = (root / "input").resolve(strict=False)
workspace_root = (root / "workspace").resolve(strict=False)
input_job = logical_input_job.resolve(strict=False)
job_workspace = logical_workspace.resolve(strict=False)
if _is_beneath(input_job, input_root) and _is_beneath(
job_workspace,
workspace_root,
):
return root, input_job, job_workspace
if not (logical_input_job.is_symlink() and logical_workspace.is_symlink()):
if not _is_beneath(input_job, input_root):
raise ValueError("input job path escapes project root")
raise ValueError("workspace job path escapes project root")
input_job = logical_input_job.resolve(strict=True)
job_workspace = logical_workspace.resolve(strict=True)
linked_root = input_job.parents[1]
if (
input_job != linked_root / "input" / job_id
or job_workspace != linked_root / "workspace" / job_id
):
raise ValueError("linked job paths do not share one project root")
return linked_root, input_job, job_workspace
def _read_manifest(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if type(payload) is not dict:
@@ -263,12 +292,7 @@ def build_confirmation_snapshot(
if not isinstance(job_id, str) or not _JOB_ID.fullmatch(job_id):
raise ValueError("invalid job_id")
root = Path(project_root).expanduser().resolve(strict=True)
input_job = (root / "input" / job_id).resolve(strict=False)
job_workspace = (root / "workspace" / job_id).resolve(strict=False)
if not _is_beneath(input_job, (root / "input").resolve(strict=False)):
raise ValueError("input job path escapes project root")
if not _is_beneath(job_workspace, (root / "workspace").resolve(strict=False)):
raise ValueError("workspace job path escapes project root")
root, input_job, job_workspace = _resolve_job_layout(root, job_id)
final_dir = job_workspace / "final"
result: dict[str, Any] = {
+17
View File
@@ -20,6 +20,7 @@ ANALYSIS_KEYWORDS = {
"rnaseq_kegg_enrich": ["kegg富集", "kegg分析", "kegg", "pathway"],
"rnaseq_fgsea": ["fgsea", "gsea", "排序富集", "基因集富集"],
"rnaseq_candidate_family": ["候选家族", "候选基因家族", "家族排名", "tf注释", "tf family", "tffamily", "pfam注释", "deg注释", "差异基因注释"],
"rnaseq_report": ["rnaseq结果报告", "rna-seq结果报告", "转录组结果报告", "rnaseq报告", "rna-seq报告"],
"rnaseq_enrich": ["富集分析", "功能富集"],
"identify": ["鉴定", "identify", "识别", "找出", "鉴定基因家族"],
"identify_refine": ["修订成员", "修订鉴定成员", "修改成员", "删除基因", "删除", "去除基因", "去掉基因", "移除基因", "补充基因", "遗漏基因", "重建蛋白", "人工修订id", "curated members", "refine identify"],
@@ -61,6 +62,7 @@ RNASEQ_STAGES = {
"rnaseq_kegg_enrich",
"rnaseq_fgsea",
"rnaseq_candidate_family",
"rnaseq_report",
"rnaseq_enrich",
}
@@ -179,6 +181,21 @@ def fast_intent_recognition(query: str) -> Optional[Dict[str, Any]]:
except Exception:
pass
has_rnaseq_marker = any(marker in query_lower for marker in RNASEQ_MARKERS)
if has_rnaseq_marker and any(
keyword in query_lower
for keyword in ["生成结果报告", "打开结果报告", "结果报告", "生成报告", "打开报告"]
):
action = "open" if "打开" in query_lower else "generate"
return {
"stage": "rnaseq_report",
"stages": ["rnaseq_report"],
"gene_family": "",
"species": "",
"prefix": "",
"params": {"rnaseqReportAction": action},
"confidence": 0.99,
"method": "rnaseq_report_deterministic",
}
header_clean_context = any(kw in query_lower for kw in [
"id处理", "处理id", "修订id", "简化id", "清理id", "id简化",
"去掉注释", "去除注释", "注释信息", "长注释",
+5
View File
@@ -380,6 +380,11 @@ class PersistentConversationStore:
for key in ("proposal_id", "request", "subject")
}
if all(normalized_proposal.values()):
original_request = str(
proposal.get("original_request") or ""
).strip()
if original_request:
normalized_proposal["original_request"] = original_request
workflow_id = str(proposal.get("workflow_id") or "").strip()
if workflow_id in {
"data_preparation",
+24 -1
View File
@@ -793,6 +793,26 @@ CAPABILITIES: Dict[str, StageCapability] = {
repair_candidates=["gff_id_overlap_report", "motif_database_fallback_to_jaspar", "artifact_reresolve"],
rerun_policy="manual",
),
"rnaseq_report": StageCapability(
stage="rnaseq_report",
cli="apps/cli/run_rnaseq_report.py",
env="test4py311",
category="rnaseq",
consumes=[
ArtifactNeed("successful_rnaseq_manifests", "manifest", alternatives=["final_manifest_scan"]),
],
provides=[
ArtifactProduct("rnaseq_report", "markdown", "{prefix}.rnaseq_report.md"),
ArtifactProduct("rnaseq_report_evidence", "json", "{prefix}.rnaseq_report.evidence.json"),
ArtifactProduct("rnaseq_report_model", "json", "{prefix}.rnaseq_report.model.json"),
ArtifactProduct("rnaseq_report_manifest", "manifest", "{prefix}.rnaseq_report.manifest.json"),
],
scope_modes=["rnaseq_scope_id"],
preflight_checks=["at_least_one_successful_rnaseq_manifest", "prefix_scope_identity_consistent"],
postflight_checks=["deterministic_markdown_exists", "evidence_snapshot_exists", "coverage_recorded"],
repair_candidates=["manifest_recovery", "regenerate_from_current_snapshot"],
rerun_policy="manual_or_after_selected_batch_terminal",
),
"summary": StageCapability(
stage="summary",
cli="apps/cli/run_summary.py",
@@ -804,11 +824,14 @@ CAPABILITIES: Dict[str, StageCapability] = {
],
provides=[
ArtifactProduct("summary_report", "markdown", "{prefix}.summary_report.md"),
ArtifactProduct("summary_evidence_index", "markdown", "{prefix}.summary_evidence_index.md"),
ArtifactProduct("summary_evidence", "json", "{prefix}.summary.evidence.json"),
ArtifactProduct("deepseek_metadata", "json", "{prefix}.summary.deepseek.json"),
ArtifactProduct("summary_manifest", "manifest", "{prefix}.summary.manifest.json"),
],
scope_modes=["target_only", "target_plus_reference", "all_selected", "rnaseq"],
preflight_checks=["at_least_one_success_manifest"],
postflight_checks=["summary_contains_stage_evidence"],
postflight_checks=["summary_contains_stage_evidence", "deepseek_report_generation_proven"],
repair_candidates=["manifest_recovery", "artifact_reresolve"],
rerun_policy="auto_after_manifest_recovery",
),
+9 -1
View File
@@ -90,6 +90,7 @@ PIPELINE_STAGES = {
"rnaseq_go_enrich",
"rnaseq_kegg_enrich",
"rnaseq_candidate_family",
"rnaseq_report",
}
STAGE_DEPENDENCIES = {
@@ -160,7 +161,7 @@ STAGE_DEPENDENCIES = {
"interspecies_synteny": ["identify"],
"karyotype": ["identify", "synteny"],
"ppi": ["identify"],
"kaks": ["identify", "ortholog"],
"kaks": ["identify"],
"combo": ["identify", "phylogeny", "motif", "structure", "domain"],
"rnaseq_align": ["rnaseq_qc"],
"rnaseq_quant": ["rnaseq_align"],
@@ -173,6 +174,9 @@ STAGE_DEPENDENCIES = {
"rnaseq_go_enrich": ["rnaseq_de"],
"rnaseq_kegg_enrich": ["rnaseq_de"],
"rnaseq_candidate_family": ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"],
# A report is a snapshot of whatever RNA-seq evidence is already successful.
# Static dependencies would incorrectly prevent intentional mid-pipeline reports.
"rnaseq_report": [],
}
PARALLEL_GROUPS = [
@@ -271,6 +275,7 @@ STAGE_RESOURCE_PROFILES = {
"rnaseq_kegg_enrich": {"queue": "rnaseq", "resource_class": "rnaseq", "default_cpu": 4, "max_cpu": 4},
"rnaseq_fgsea": {"queue": "rnaseq", "resource_class": "rnaseq", "default_cpu": 4, "max_cpu": 4},
"rnaseq_candidate_family": {"queue": "rnaseq", "resource_class": "rnaseq", "default_cpu": 4, "max_cpu": 4},
"rnaseq_report": {"queue": "light", "resource_class": "light", "default_cpu": 4, "max_cpu": 4},
}
DEFAULT_STAGE_RESOURCE_PROFILE = {
@@ -391,6 +396,7 @@ STAGE_ORDER = [
"rnaseq_kegg_enrich",
"rnaseq_fgsea",
"rnaseq_candidate_family",
"rnaseq_report",
"rnaseq_enrich",
"gene_family_report",
]
@@ -469,6 +475,7 @@ STAGE_INFO = {
"rnaseq_enrich": {"name": "RNA-seq富集分析", "estimated_time": 15, "cpu": 4},
"rnaseq_fgsea": {"name": "RNA-seq fgsea分析", "estimated_time": 15, "cpu": 4},
"rnaseq_candidate_family": {"name": "RNA-seq候选家族筛选", "estimated_time": 20, "cpu": 4},
"rnaseq_report": {"name": "RNA-seq结果报告", "estimated_time": 2, "cpu": 4},
}
EXECUTABLE_STAGES = {
@@ -544,6 +551,7 @@ EXECUTABLE_STAGES = {
"rnaseq_enrich",
"rnaseq_fgsea",
"rnaseq_candidate_family",
"rnaseq_report",
}
+1
View File
@@ -33,6 +33,7 @@ RNASEQ_STATUS_STAGES = {
"rnaseq_fgsea",
"rnaseq_wgcna",
"rnaseq_candidate_family",
"rnaseq_report",
"annotation",
"annotation_go",
"annotation_kegg",
+14
View File
@@ -546,6 +546,7 @@ class StageToolExecutor:
ppi_reference_selection_mode=(extra_params or {}).get("ppiReferenceSelectionMode") or (extra_params or {}).get("ppi_reference_selection_mode") or "",
ppi_reference_user_confirmed=bool_param("ppiReferenceUserConfirmed", "ppi_reference_user_confirmed", default=False),
dependency_owner=(extra_params or {}).get("dependencyOwner") or (extra_params or {}).get("dependency_owner") or "",
ortholog_mapping_owner=(extra_params or {}).get("orthologMappingOwner") or (extra_params or {}).get("ortholog_mapping_owner") or "",
ppi_score=(extra_params or {}).get("ppiScore", 200),
genome_prepare_type=(extra_params or {}).get("genomePrepareType", "auto"),
kaks_neutral_rate=(extra_params or {}).get("kaksNeutralRate", 7.08e-9),
@@ -1516,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",
+21 -6
View File
@@ -9,6 +9,16 @@ from pathlib import Path
from typing import Any, Optional
ORTHOLOG_DEPENDENCY_OWNERS = {"", "ppi", "kaks"}
def ortholog_output_stem(prefix: str, dependency_owner: str = "") -> str:
owner = str(dependency_owner or "").strip().lower()
if owner not in ORTHOLOG_DEPENDENCY_OWNERS:
raise ValueError(f"unsupported Ortholog dependency owner: {dependency_owner}")
return f"{prefix}.{owner}" if owner else prefix
@dataclass
class OrthologRunRecord:
query_species: str
@@ -151,6 +161,7 @@ def materialize_ortholog_final_outputs(
*,
final_dir: Path,
prefix: str,
dependency_owner: str = "",
run_records: list[OrthologRunRecord],
job_id: str,
workspace: Path,
@@ -170,10 +181,12 @@ def materialize_ortholog_final_outputs(
final_dir.mkdir(parents=True, exist_ok=True)
scope_fields = dict(scope_fields or {})
owner = str(dependency_owner or "").strip().lower()
output_stem = ortholog_output_stem(prefix, owner)
pairs_dst = final_dir / f"{prefix}.ortholog_pairs.txt"
orthologs_dst = final_dir / f"{prefix}.orthologs.txt"
raw_pairs_dst = final_dir / f"{prefix}.ortholog_pairs.raw.txt"
pairs_dst = final_dir / f"{output_stem}.ortholog_pairs.txt"
orthologs_dst = final_dir / f"{output_stem}.orthologs.txt"
raw_pairs_dst = final_dir / f"{output_stem}.ortholog_pairs.raw.txt"
# run_ortholog.py writes subject<TAB>query<TAB>identity.
# Downstream PPI expects query/source -> target/reference.
@@ -203,7 +216,7 @@ def materialize_ortholog_final_outputs(
pairs_handle.write(out_line)
orthologs_handle.write(out_line)
blast_dst = final_dir / f"{prefix}.ortholog_blastp.txt"
blast_dst = final_dir / f"{output_stem}.ortholog_blastp.txt"
with blast_dst.open("w", encoding="utf-8") as blast_handle:
for record in run_records:
blast_src = record.blast_src
@@ -266,7 +279,7 @@ def materialize_ortholog_final_outputs(
"sha256": _sha256_file(blast_dst),
}
ortholog_manifest = final_dir / f"{prefix}.ortholog.manifest.json"
ortholog_manifest = final_dir / f"{output_stem}.ortholog.manifest.json"
scope_file = str(scope_fields.pop("scope_file", "") or "").strip()
scope_data = dict(scope_fields)
@@ -283,6 +296,7 @@ def materialize_ortholog_final_outputs(
"stage": "ortholog",
"status": "success",
"prefix": prefix,
"dependency_owner": owner,
"job_id": job_id,
"workspace": str(workspace),
"workspace_root": str(workspace),
@@ -303,6 +317,7 @@ def materialize_ortholog_final_outputs(
],
"parameters": {
"prefix": prefix,
"dependency_owner": owner,
"blast_evalue": blast_evalue,
"cpu": cpu,
"reference_species": reference_species,
@@ -340,7 +355,7 @@ def materialize_ortholog_final_outputs(
if scope_id:
from gfa_core.gene_family_scope import sanitize_token
scoped_manifest = final_dir / f"{prefix}.{sanitize_token(scope_id)}.ortholog.manifest.json"
scoped_manifest = final_dir / f"{output_stem}.{sanitize_token(scope_id)}.ortholog.manifest.json"
manifest_data["scope_manifest"] = str(scoped_manifest.resolve())
with scoped_manifest.open("w", encoding="utf-8") as handle:
json.dump(manifest_data, handle, ensure_ascii=False, indent=2)
+313
View File
@@ -0,0 +1,313 @@
"""Lightweight, provider-free RNA-seq manifest evidence snapshot.
The live interpretation view and the formal report share this module so scope
selection and manifest provenance have one implementation. It intentionally
does not import the MAS agent package.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
REPORT_STAGE = "rnaseq_report"
SUCCESS_STATUSES = {"success", "partial_success"}
GLOBAL_LEGACY_STAGES = {
"rnaseq_qc",
"rnaseq_align",
"rnaseq_quant",
"annotation",
"annotation_go",
"annotation_kegg",
}
RNASEQ_STAGE_NAMES = {
"rnaseq_qc": "RNA-seq质控",
"rnaseq_align": "RNA-seq比对",
"rnaseq_quant": "RNA-seq定量",
"rnaseq_sample_qc": "RNA-seq样本质量评估",
"rnaseq_de": "RNA-seq差异表达",
"annotation": "背景功能注释",
"annotation_go": "GO背景自动注释",
"annotation_kegg": "KEGG/KO背景自动注释",
"rnaseq_go_enrich": "RNA-seq GO富集分析",
"rnaseq_kegg_enrich": "RNA-seq KEGG富集分析",
"rnaseq_enrich": "RNA-seq富集分析(兼容入口)",
"rnaseq_fgsea": "RNA-seq fgsea排序富集",
"rnaseq_wgcna": "RNA-seq WGCNA共表达分析",
"rnaseq_candidate_family": "RNA-seq候选家族筛选",
"rnaseq_family_score": "RNA-seq候选家族评分",
}
RNASEQ_STAGE_ORDER = list(RNASEQ_STAGE_NAMES)
@dataclass(frozen=True)
class RNASeqManifestSelection:
rnaseq_scope_id: str
discovered_scope_ids: tuple[str, ...]
manifests: dict[str, dict[str, Any]]
def read_json(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def manifest_identity_value(manifest: dict[str, Any], key: str) -> str:
context = manifest.get("stage_context") or manifest.get("stageContext") or {}
params = manifest.get("parameters") or {}
return str(manifest.get(key) or params.get(key) or context.get(key) or "").strip()
def is_rnaseq_evidence_stage(stage: str) -> bool:
stage = str(stage or "").strip()
return stage != REPORT_STAGE and (
stage.startswith("rnaseq_") or stage in {"annotation", "annotation_go", "annotation_kegg"}
)
def stage_sort_key(stage: str) -> tuple[int, str]:
try:
return RNASEQ_STAGE_ORDER.index(stage), stage
except ValueError:
return len(RNASEQ_STAGE_ORDER), stage
def _manifest_score(path: Path, manifest: dict[str, Any]) -> tuple[int, int, int, float]:
status = str(manifest.get("status") or "")
status_rank = 2 if status == "success" else 1 if status == "partial_success" else 0
metrics = manifest.get("metrics") if isinstance(manifest.get("metrics"), dict) else {}
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {}
try:
mtime = path.stat().st_mtime
except OSError:
mtime = 0.0
return status_rank, len(metrics), len(artifacts), mtime
def select_rnaseq_manifests(
final_dir: Path,
prefix: str,
*,
rnaseq_scope_id: str = "",
) -> RNASeqManifestSelection:
"""Select one non-mixing manifest per RNA-seq stage for a prefix/scope."""
final_dir = Path(final_dir)
pattern = f"{prefix}.*.manifest.json" if prefix else "*.manifest.json"
candidates: list[tuple[Path, dict[str, Any]]] = []
latest_by_scope: dict[str, float] = {}
for path in sorted(final_dir.glob(pattern)) if final_dir.exists() else []:
manifest = read_json(path)
stage = str(manifest.get("stage") or "")
if not is_rnaseq_evidence_stage(stage):
continue
manifest_prefix = str(manifest.get("prefix") or "").strip()
if prefix and manifest_prefix and manifest_prefix != prefix:
continue
candidates.append((path, manifest))
scope = manifest_identity_value(manifest, "rnaseq_scope_id")
if scope:
try:
latest_by_scope[scope] = max(latest_by_scope.get(scope, 0.0), path.stat().st_mtime)
except OSError:
latest_by_scope.setdefault(scope, 0.0)
discovered = sorted(latest_by_scope, key=lambda item: (-latest_by_scope[item], item))
resolved_scope = str(rnaseq_scope_id or "").strip() or (discovered[0] if discovered else "")
selected_paths: dict[str, Path] = {}
selected: dict[str, dict[str, Any]] = {}
for path, manifest in candidates:
stage = str(manifest.get("stage") or "")
scope = manifest_identity_value(manifest, "rnaseq_scope_id")
if resolved_scope and scope and scope != resolved_scope:
continue
if resolved_scope and not scope and stage not in GLOBAL_LEGACY_STAGES:
continue
existing = selected.get(stage)
if existing is not None and _manifest_score(selected_paths[stage], existing) >= _manifest_score(path, manifest):
continue
manifest = dict(manifest)
manifest["_manifest_path"] = str(path)
selected[stage] = manifest
selected_paths[stage] = path
return RNASeqManifestSelection(resolved_scope, tuple(discovered), selected)
def _compact_value(value: Any, limit: int = 240) -> str:
if isinstance(value, (dict, list)):
text = json.dumps(value, ensure_ascii=False, sort_keys=True)
else:
text = str(value)
return text if len(text) <= limit else text[: limit - 1] + "…"
def resolve_artifact_path(
final_dir: Path,
item: dict[str, Any],
*,
require_exists: bool = False,
) -> Path | None:
"""Resolve artifacts after a workspace has been moved or mounted elsewhere."""
final_dir = Path(final_dir)
workspace = final_dir.parent
candidates: list[Path] = []
for raw in (item.get("full_path"), item.get("path")):
if not raw:
continue
path = Path(str(raw))
candidates.append(path if path.is_absolute() else workspace / path)
if path.is_absolute():
candidates.append(final_dir / path.name)
unique: list[Path] = []
seen: set[str] = set()
for path in candidates:
identity = str(path)
if identity not in seen:
seen.add(identity)
unique.append(path)
for path in unique:
if path.exists():
return path
if require_exists:
return None
return unique[0] if unique else None
def build_rnaseq_evidence_snapshot(
final_dir: Path,
prefix: str,
*,
rnaseq_scope_id: str = "",
include_stages: Iterable[str] | None = None,
selection: RNASeqManifestSelection | None = None,
) -> dict[str, Any]:
"""Build a compact evidence object without invoking an LLM or agent cache."""
selection = selection or select_rnaseq_manifests(
final_dir, prefix, rnaseq_scope_id=rnaseq_scope_id
)
allowed = {str(stage) for stage in include_stages or [] if str(stage).strip()}
manifests = {
stage: manifest
for stage, manifest in selection.manifests.items()
if not allowed or stage in allowed
}
stages = sorted(manifests, key=stage_sort_key)
stage_flow: list[dict[str, Any]] = []
findings: list[dict[str, Any]] = []
reviews: list[dict[str, Any]] = []
cards: list[dict[str, Any]] = []
risks: list[dict[str, Any]] = []
card_index = 0
for stage in stages:
manifest = manifests[stage]
status = str(manifest.get("status") or "unknown")
metrics = manifest.get("metrics") if isinstance(manifest.get("metrics"), dict) else {}
parameters = manifest.get("parameters") if isinstance(manifest.get("parameters"), dict) else {}
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {}
metric_items = list(sorted(metrics.items()))[:12]
metric_summary = ", ".join(f"{key}={_compact_value(value, 80)}" for key, value in metric_items[:5])
key_result = metric_summary or f"登记产物 {len(artifacts)} 个"
stage_flow.append(
{
"id": stage,
"name": RNASEQ_STAGE_NAMES.get(stage, stage),
"status": status,
"key_result": key_result,
}
)
findings.append(
{
"stage": stage,
"title": RNASEQ_STAGE_NAMES.get(stage, stage),
"description": f"manifest 状态为 {status};{key_result}。",
}
)
limitations: list[str] = []
if status == "partial_success":
limitations.append("该阶段为 partial_success,结果可用但不完整。")
elif status != "success":
limitations.append("该阶段未成功,不纳入正式结论。")
missing_artifacts: list[str] = []
for name, item in sorted(artifacts.items()):
if not isinstance(item, dict):
continue
path = resolve_artifact_path(final_dir, item)
exists = bool(path and path.exists())
card_index += 1
cards.append(
{
"id": f"RNA-E{card_index:04d}",
"stage": stage,
"claim": f"产物 {name}",
"evidence": f"path={path or ''}, exists={exists}",
"source_type": "raw_file" if exists else "manifest",
}
)
if not exists:
missing_artifacts.append(str(name))
for key, value in metric_items:
card_index += 1
cards.append(
{
"id": f"RNA-E{card_index:04d}",
"stage": stage,
"claim": f"指标 {key}",
"evidence": _compact_value(value),
"source_type": "manifest",
}
)
safe_parameter_items = [
(key, value)
for key, value in sorted(parameters.items())
if not any(secret in str(key).lower() for secret in ("api_key", "token", "secret", "password"))
][:10]
for key, value in safe_parameter_items:
card_index += 1
cards.append(
{
"id": f"RNA-E{card_index:04d}",
"stage": stage,
"claim": f"参数 {key}",
"evidence": _compact_value(value),
"source_type": "manifest",
}
)
if missing_artifacts:
shown = missing_artifacts[:12]
remainder = len(missing_artifacts) - len(shown)
suffix = f"(另有 {remainder} 个,详见 evidence JSON)" if remainder else ""
message = f"manifest 登记但文件不存在的产物:{', '.join(shown)}{suffix}"
limitations.append(message)
risks.append({"stage": stage, "severity": "high", "message": message})
if status not in SUCCESS_STATUSES:
risks.append({"stage": stage, "severity": "high", "message": f"阶段状态为 {status},不得作为成功证据。"})
reviews.append({"stage": stage, "stage_name": RNASEQ_STAGE_NAMES.get(stage, stage), "limitations": limitations})
successful = [stage for stage in stages if str(manifests[stage].get("status") or "") in SUCCESS_STATUSES]
return {
"schema": "rnaseq_manifest_evidence_v1",
"metadata": {
"prefix": prefix,
"rnaseq_scope_id": selection.rnaseq_scope_id,
"discovered_scope_ids": list(selection.discovered_scope_ids),
"included_stages": stages,
"successful_stages": successful,
"interpretation_boundary": "仅陈述当前 prefix 与 RNA-seq scope 下 manifest/产物支持的事实;富集、相关性和候选排序不得写成调控因果。",
},
"stage_flow": {"visualization": " -> ".join(stages), "stages": stage_flow},
"key_findings": findings,
"stage_reviews": reviews,
"evidence_cards": cards,
"risk_flags": risks,
"major_claims": [],
"biological_story": (
f"当前报告汇总了 {len(successful)} 个成功或部分成功的 RNA-seq 阶段。"
"跨阶段生物学解释必须以各阶段数值、图表和风险标记为边界。"
),
"next_steps": ["补齐缺失或失败阶段后重新生成报告,以刷新证据快照。"],
}
+16 -3
View File
@@ -18,7 +18,7 @@ import mimetypes
import os
import re
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import unquote
@@ -79,8 +79,21 @@ class RNASeqReportResult:
reused: bool = False
BEIJING_TIMEZONE = timezone(timedelta(hours=8))
def _now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
return datetime.now(BEIJING_TIMEZONE).isoformat(timespec="seconds")
def _display_generated_at(value: str) -> str:
try:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=BEIJING_TIMEZONE)
return parsed.astimezone(BEIJING_TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError):
return str(value or "").replace("T", " ")
def _read_json(path: Path) -> dict[str, Any]:
@@ -1224,7 +1237,7 @@ def _render_deterministic_markdown(
lines = [
f"# {prefix} RNA-seq 结果解读报告",
"",
f"生成时间:{generated_at} ",
f"生成时间(北京时间):{_display_generated_at(generated_at)} ",
f"任务编号:`{workspace.name}`",
"",
"**任务结果路径:** ",
@@ -27,6 +27,11 @@
- 若 reference CDS 在 config/scope 中缺失,可从已知 reference 数据目录解析,例如 At 的 TAIR10 CDS。
- KaKs 是 reference dependency owner:若缺少 ortholog mapping,可以要求调度器先自动插入 Ortholog;但若 reference CDS/PEP 不完整,必须先提示上传/下载完整参考注释包,不能只跑 Ortholog 后再失败。
- 若 PPI 与 KaKs 同批执行,reference resolver 必须按阶段分别检查;KaKs 使用 `requested_stages=["kaks"]` 检查 reference PEP/CDS 与 Ortholog,而其缺失状态不得覆盖 PPI 的独立就绪状态。
- 普通计划不显示隐式 Ortholog,KaKs 也不声明对 Ortholog 或 PPI 的静态硬依赖。执行层仅用软顺序保证 KaKs 参数确认发生在 PPI 真实终态之后。
- KaKs 确认必须显式选择参考来源:复用本次 PPI 技术参考,或使用项目配置参考。选择只写入 KaKs 参数,不得修改 PPI owner 或其它阶段的参考 scope。
- 复用 PPI 参考时,只有 identity 完全匹配的 `final/{prefix}.ppi.orthologs.txt` 可用,并以 `orthologMappingOwner=ppi` 提交。
- 选择项目参考时,缺少精确映射则运行 `dependency_owner=kaks` 的内部 Ortholog,输出 `final/{prefix}.kaks.orthologs.txt`,随后以 `orthologMappingOwner=kaks` 提交。
- 两种 owner 都不得回落到另一 owner 或 standalone 映射。缺少所选参考的 PEP/CDS/映射只暂停 KaKs,不影响已完成 PPI 或其它阶段。
### Legacy 模式
@@ -59,6 +59,19 @@
| `{prefix}.ortholog.manifest.json` | json | 标准 manifest |
| `{prefix}.{scope_id}.ortholog.manifest.json` | json | scope 模式下的 scoped manifest 副本 |
Owner namespace:
| producer 身份 | 映射/manifest stem | 用途 |
|---|---|---|
| standalone (`dependency_owner=""`) | `{prefix}.ortholog*` | 用户显式请求的 Ortholog,保持原契约 |
| PPI (`dependency_owner="ppi"`) | `{prefix}.ppi.ortholog*` | PPI STRING 技术参考专属内部映射 |
| KaKs (`dependency_owner="kaks"`) | `{prefix}.kaks.ortholog*` | KaKs 显式项目参考专属内部映射 |
- owner namespace 同时作用于 orthologs、pairs、raw pairs、BLAST、identity sidecar、标准 manifest 与 scoped manifest。
- manifest 的 `stage` 仍为 `ortholog`;`parameters.dependency_owner` 记录 producer 身份。
- standalone、PPI、KaKs 三套产物可为同一 prefix 并存;重跑一个 owner 不得覆盖或改变另一个 owner。
- 下游按 owner token 解析候选路径,不接受前端传入任意文件路径,也不得跨 owner 回落。
## Manifest 最小字段
```json
@@ -104,6 +117,7 @@
- 单 target + `target_only` scope 时,`analysis_route` 通常为 `single_species`;含 reference 的 comparative scope 通常为 `multi_species`。
- postflight strict identity 诊断依赖上述字段;materialize 落盘时必须补齐,不能只写 `scope_id`。
- PPI-owned producer 的 `reference_taxon_id` 来自已确认 STRING TaxID,`reference_storage_key` 决定本地 PEP 路径及 mapping 的 Target_Species。
- PPI-owned producer 的生物学 scope 必须与 PPI consumer 相同,固定为目标家族 `target_only`;技术参考物种只写入 owner/provenance 参数。
- sidecar 与当前请求必须在 `reference_taxon_id`、`reference_storage_key`、reference PEP SHA256、target/query PEP identity、scope/member identity、mapping path/SHA256 和 `mapping_identity` 上完全一致,才允许跳过 BLAST 并复用中间 pairs。
- sidecar 缺失、无法解析、字段缺失或任一 identity 冲突都视为不可复用;应按原 Ortholog 命令重新生成,不得根据“文件存在”推断身份。
- PPI preflight 不得只读取 final mapping 的 Target_Species 后判定 ready;还必须同时验证 final mapping sidecar、对应 Ortholog manifest,以及当前 reference PEP、target/query PEP、scope/member 和 mapping identity。任一输入变化或身份材料缺失均返回 `needs_ortholog` / stale identity conflict。
@@ -181,6 +181,17 @@ python skills/ppi/run.py \
## 下游阶段
- summary: 可汇总PPI结果
## 内部 Ortholog 身份与调度
- 普通基因家族计划只展示 PPI;PPI 所需的 Ortholog 是执行层内部 producer,不作为可见计划阶段或独立确认弹窗。
- PPI 的生物学分析身份固定为目标家族成员 `target_only`;项目参考物种不得进入 PPI 的 `scope_id`、`analysis_route` 或 `member_set_id`。
- STRING TaxID、技术参考 storage key 和同源映射参考只属于 PPI 技术溯源,不得被当作生物学 `referenceSpecies` 重建 PPI scope。
- 非目标物种直连 STRING 路线使用 `dependency_owner=ppi`,映射输出为 `final/{prefix}.ppi.orthologs.txt`(及同 owner 的 pairs/raw/BLAST/manifest/sidecar)。
- PPI 只能读取 PPI owner 映射;不得因 standalone 或 KaKs owner 文件存在而静默复用。
- PPI 的 STRING 技术参考只属于 PPI。它不得覆盖项目参考物种或 KaKs 的显式参考选择。
- PPI 成功、失败或用户明确跳过都会释放后续 KaKs 确认;正在进行的 PPI 参考包下载不是终态。
- PPI-owned Ortholog 完成后必须用同一 target-only 身份重新执行预检;只有返回 `ready` 才能提交 PPI。
## 网络统计指标
| 指标 | 描述 |
@@ -0,0 +1,45 @@
# RNA-seq Result Report Contract v1
## Stage
- Stage id: `rnaseq_report`
- Script: `apps/cli/run_rnaseq_report.py`
- Scope: one exact `prefix + rnaseq_scope_id` evidence snapshot
- Purpose: materialize a formal, reproducible RNA-seq Markdown result report without depending on frontend session state.
## Inputs
- At least one RNA-seq/annotation manifest with status `success` or `partial_success` under `final/`.
- All selected evidence must match the requested prefix and RNA-seq scope. Legacy unscoped QC/alignment/quantification/annotation manifests may be treated as experiment-global evidence.
- `expected_stages` is optional and records the stages selected for the current batch. It controls coverage only; it must not become a static execution dependency.
- An existing configured `LLMManager` provider is optional. No provider or model name is required by this stage.
If no successful RNA-seq evidence exists, the stage must return `暂无可生成的 RNA-seq 结果` and must not create an empty report.
## Required Outputs
- `final/{prefix}.rnaseq_report.md`
- `final/{prefix}.rnaseq_report.evidence.json`
- `final/{prefix}.rnaseq_report.model.json`
- `final/{prefix}.rnaseq_report.manifest.json`
The manifest stage status is `success` whenever these outputs are generated. Content completeness is recorded separately as `metrics.coverage_status = complete|partial`.
## Generation Semantics
- `generate`: always rebuild from the current manifest snapshot.
- `open`: reuse a fresh report; regenerate first when the source/request fingerprint changed.
- The deterministic Markdown body is always complete enough to read without an API.
- Optional model output is a labeled language-enhancement section only. Model failure must retain the deterministic report and be recorded in `.model.json`.
- `rnaseq_report` manifests are excluded from their own evidence scan.
## Boundary With Deep Interpretation
Both features may call the pure `gfa_core.rnaseq_evidence` snapshot builder. The formal report must not call `/api/agent/interpret`, read its session cache, or replace the frontend deep-interpretation view.
## Validation
- Every manifest artifact path exists and its digest is recorded.
- Evidence contains only the selected prefix/scope.
- `coverage_status=complete` only when every expected stage has status `success`.
- Missing, failed, or `partial_success` expected stages yield `coverage_status=partial` while the report manifest remains `success`.
@@ -33,10 +33,6 @@
各阶段依次写明分析对象、源结果中的直接观察、谨慎解释、限制,并仅在有助复核时列出少量相对路径。
### 2.2 语言边界
预测关系统一表述为线索;没有逐成员表或独立证据时,不指定候选基因,不声称功能改变或调控关系已经证实。
<!-- GFA_SECTION common.completeness -->
## 三、结果总览与完整性检查