902 lines
30 KiB
Python
902 lines
30 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import asyncio
|
||
|
|
import json
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from gfa_core.mas.agents.base import AgentMessage, AgentState
|
||
|
|
from gfa_core.mas.agents.intent_v2 import IntentAgentV2
|
||
|
|
from gfa_core.mas.agents.planning_v2 import PlanningAgentV2, StageStatus
|
||
|
|
from gfa_core.mas.stage_catalog import complete_dependencies
|
||
|
|
from gfa_core.mas.utils.intent_parser import parse_intent_from_query
|
||
|
|
|
||
|
|
|
||
|
|
WGS_STAGES = [
|
||
|
|
"wgs_qc", "wgs_index", "wgs_align", "wgs_markdup", "wgs_bam_qc",
|
||
|
|
"wgs_haplotype_calling", "wgs_gvcf_combine", "wgs_joint_genotyping",
|
||
|
|
"wgs_variant_select", "wgs_variant_filter", "wgs_impute",
|
||
|
|
"wgs_maf_missing_filter",
|
||
|
|
]
|
||
|
|
EXPLICIT_STAGE_PHRASES = [
|
||
|
|
("WGS质控", "wgs_qc"), ("WGS quality control", "wgs_qc"),
|
||
|
|
("WGS参考索引", "wgs_index"), ("WGS reference index", "wgs_index"),
|
||
|
|
("WGS比对", "wgs_align"), ("WGS alignment", "wgs_align"),
|
||
|
|
("WGS去重复", "wgs_markdup"), ("WGS mark duplicates", "wgs_markdup"),
|
||
|
|
("WGS 去重复", "wgs_markdup"),
|
||
|
|
("WGS BAM质控", "wgs_bam_qc"), ("WGS BAM QC", "wgs_bam_qc"),
|
||
|
|
("WGS单样本变异检测", "wgs_haplotype_calling"), ("WGS haplotype calling", "wgs_haplotype_calling"),
|
||
|
|
("WGS GVCF合并", "wgs_gvcf_combine"), ("WGS GVCF combine", "wgs_gvcf_combine"),
|
||
|
|
("WGS联合分型", "wgs_joint_genotyping"), ("WGS joint genotyping", "wgs_joint_genotyping"),
|
||
|
|
("WGS变异选择", "wgs_variant_select"), ("WGS variant selection", "wgs_variant_select"),
|
||
|
|
("WGS变异过滤", "wgs_variant_filter"), ("WGS variant filtering", "wgs_variant_filter"),
|
||
|
|
("WGS基因型填充", "wgs_impute"), ("WGS genotype imputation", "wgs_impute"),
|
||
|
|
("WGS MAF缺失率过滤", "wgs_maf_missing_filter"), ("WGS MAF missingness filter", "wgs_maf_missing_filter"),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(("phrase", "stage"), EXPLICIT_STAGE_PHRASES)
|
||
|
|
def test_explicit_wgs_stage_phrase_is_deterministic(phrase: str, stage: str) -> None:
|
||
|
|
intent = parse_intent_from_query(f"请执行{phrase}")
|
||
|
|
assert intent["stages"] == [stage]
|
||
|
|
assert intent["primary_stage"] == stage
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("phrase", ["WGS全流程", "VCF全流程"])
|
||
|
|
def test_bare_wgs_vcf_full_chain_targets_stage12_with_complete_closure(phrase: str) -> None:
|
||
|
|
intent = parse_intent_from_query(phrase)
|
||
|
|
assert intent["primary_stage"] == "wgs_maf_missing_filter"
|
||
|
|
assert complete_dependencies(intent["stages"]) == WGS_STAGES
|
||
|
|
|
||
|
|
|
||
|
|
def test_wgs_deterministic_routing_precedes_llm_fallback(monkeypatch) -> None:
|
||
|
|
agent = IntentAgentV2(None)
|
||
|
|
|
||
|
|
async def forbidden_fallback(*_args, **_kwargs):
|
||
|
|
raise AssertionError("LLM fallback must not run for deterministic WGS intent")
|
||
|
|
|
||
|
|
monkeypatch.setattr(agent, "_recognize_intent_with_llm", forbidden_fallback)
|
||
|
|
state = AgentState(session_id="session")
|
||
|
|
message = AgentMessage(
|
||
|
|
sender="user", receiver="intent_v2", message_type="query",
|
||
|
|
payload={"query": "VCF全流程"},
|
||
|
|
)
|
||
|
|
result = asyncio.run(agent.execute(state, message))
|
||
|
|
intent = result.results["results"]["intent"]
|
||
|
|
assert intent["primary_stage"] == "wgs_maf_missing_filter"
|
||
|
|
assert intent["stages"] == WGS_STAGES
|
||
|
|
|
||
|
|
|
||
|
|
def test_non_wgs_intent_is_unchanged() -> None:
|
||
|
|
intent = parse_intent_from_query("构建系统发育树")
|
||
|
|
assert intent["primary_stage"] == "phylogeny"
|
||
|
|
assert "wgs_qc" not in intent["stages"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_wgs_full_workflow_is_not_gene_family_full_workflow() -> None:
|
||
|
|
from apps.web.vue3.main import _looks_like_gene_family_workflow_query
|
||
|
|
|
||
|
|
assert _looks_like_gene_family_workflow_query("执行 WGS 全流程") is False
|
||
|
|
assert _looks_like_gene_family_workflow_query("执行基因家族全流程") is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_wgs_plan_recovers_identity_from_success_manifest(tmp_path, monkeypatch) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
|
||
|
|
final_dir = tmp_path / "job" / "final"
|
||
|
|
final_dir.mkdir(parents=True)
|
||
|
|
(final_dir / "cohort.wgs_qc.manifest.json").write_text(
|
||
|
|
json.dumps({
|
||
|
|
"stage": "wgs_qc",
|
||
|
|
"status": "success",
|
||
|
|
"prefix": "cohort",
|
||
|
|
"wgs_sample_set_id": "samples-1",
|
||
|
|
"wgs_analysis_id": "analysis-1",
|
||
|
|
}),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
|
||
|
|
|
||
|
|
assert web_main._wgs_manifest_identity_for_plan("job", "wgs_qc", "cohort") == {
|
||
|
|
"wgsSampleSetId": "samples-1",
|
||
|
|
"wgsReferenceId": "",
|
||
|
|
"wgsAnalysisId": "analysis-1",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def test_wgs_plan_drops_bindings_for_upstreams_that_will_rerun() -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
|
||
|
|
params = {
|
||
|
|
"wgsQcManifest": "/old/cohort.wgs_qc.manifest.json",
|
||
|
|
"wgsQcAnalysisId": "old-qc",
|
||
|
|
"wgsIndexManifest": "/old/cohort.wgs_index.manifest.json",
|
||
|
|
"wgsReferenceId": "old-reference",
|
||
|
|
"wgsAlignManifest": "/old/cohort.wgs_align.manifest.json",
|
||
|
|
"wgsMethod": "gatk",
|
||
|
|
}
|
||
|
|
details = [{"id": "wgs_maf_missing_filter", "params": dict(params)}]
|
||
|
|
|
||
|
|
web_main._clear_planned_wgs_upstream_bindings(
|
||
|
|
["wgs_qc", "wgs_index", "wgs_align", "wgs_maf_missing_filter"], params, details
|
||
|
|
)
|
||
|
|
|
||
|
|
assert params == {"wgsMethod": "gatk"}
|
||
|
|
assert details[0]["params"] == {"wgsMethod": "gatk"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_uses_current_wgs_message_over_stale_intent(monkeypatch) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, completed: bool):
|
||
|
|
self.status = "success" if completed else "not_found"
|
||
|
|
self.completed = completed
|
||
|
|
self.manifest_path = None
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_load_methodology_plan_for_agent_request",
|
||
|
|
lambda _request: (None, None, None),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
stage_status,
|
||
|
|
"get_stage_status",
|
||
|
|
lambda **kwargs: Status(kwargs["stage"] != "wgs_maf_missing_filter"),
|
||
|
|
)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(
|
||
|
|
message="执行 WGS MAF缺失率过滤",
|
||
|
|
job_id="job-1",
|
||
|
|
intent={"stages": ["wgs_variant_select"]},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["success"] is True
|
||
|
|
assert response["mode"] == "deterministic_wgs_plan"
|
||
|
|
assert response["plan"]["stages"] == ["wgs_maf_missing_filter"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_keeps_only_missing_wgs_stages(monkeypatch) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, completed: bool):
|
||
|
|
self.status = "success" if completed else "not_found"
|
||
|
|
self.completed = completed
|
||
|
|
self.manifest_path = None
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_load_methodology_plan_for_agent_request",
|
||
|
|
lambda _request: (None, None, None),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
missing = {"wgs_qc", "wgs_index", "wgs_align", "wgs_markdup"}
|
||
|
|
monkeypatch.setattr(
|
||
|
|
stage_status,
|
||
|
|
"get_stage_status",
|
||
|
|
lambda **kwargs: Status(kwargs["stage"] not in missing),
|
||
|
|
)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(
|
||
|
|
message="执行 WGS 全流程分析",
|
||
|
|
job_id="job-1",
|
||
|
|
intent={"stages": ["wgs_variant_select"]},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["success"] is True
|
||
|
|
assert response["plan"]["stages"] == [
|
||
|
|
"wgs_qc", "wgs_index", "wgs_align", "wgs_markdup"
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_seeds_completed_qc_only_into_pending_align(monkeypatch) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core import wgs_upstream_manifest
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, stage: str):
|
||
|
|
self.status = "success" if stage == "wgs_qc" else "not_found"
|
||
|
|
self.completed = stage == "wgs_qc"
|
||
|
|
self.manifest_path = (
|
||
|
|
"/workspace/job-1/final/Os.wgs_qc.manifest.json"
|
||
|
|
if self.completed else None
|
||
|
|
)
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_load_methodology_plan_for_agent_request",
|
||
|
|
lambda _request: (None, None, None),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "wgs-analysis:" + "c" * 64,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
stage_status, "get_stage_status", lambda **kwargs: Status(kwargs["stage"])
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
wgs_upstream_manifest,
|
||
|
|
"materialize_completed_wgs_upstream_params",
|
||
|
|
lambda **kwargs: (
|
||
|
|
{
|
||
|
|
"wgsQcManifest": "/workspace/job-1/final/Os.wgs_qc.manifest.json",
|
||
|
|
"wgsSampleSetId": "wgs-samples:" + "a" * 64,
|
||
|
|
}
|
||
|
|
if kwargs["stage"] == "wgs_align"
|
||
|
|
and set(kwargs["completed_stages"]) == {"wgs_qc"}
|
||
|
|
else {}
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(
|
||
|
|
message="执行 WGS比对",
|
||
|
|
job_id="job-1",
|
||
|
|
prefix="Os",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["plan"]["stages"] == ["wgs_index", "wgs_align"]
|
||
|
|
details = {item["id"]: item["params"] for item in response["plan"]["stages_detail"]}
|
||
|
|
assert "wgsQcManifest" not in details["wgs_index"]
|
||
|
|
assert details["wgs_align"]["wgsQcManifest"].endswith(
|
||
|
|
"/Os.wgs_qc.manifest.json"
|
||
|
|
)
|
||
|
|
assert details["wgs_align"]["wgsSampleSetId"].startswith("wgs-samples:")
|
||
|
|
assert "wgsIndexManifest" not in details["wgs_align"]
|
||
|
|
assert "wgsQcManifest" not in response["plan"]["params"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_keeps_completed_downstream_stages_when_upstreams_were_deleted(
|
||
|
|
monkeypatch,
|
||
|
|
) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, completed: bool):
|
||
|
|
self.status = "success" if completed else "not_found"
|
||
|
|
self.completed = completed
|
||
|
|
self.manifest_path = None
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_load_methodology_plan_for_agent_request",
|
||
|
|
lambda _request: (None, None, None),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
missing = {
|
||
|
|
"wgs_qc", "wgs_index", "wgs_align", "wgs_markdup",
|
||
|
|
"wgs_maf_missing_filter",
|
||
|
|
}
|
||
|
|
|
||
|
|
def fake_status(**kwargs):
|
||
|
|
completed = (
|
||
|
|
kwargs["stage"] not in missing
|
||
|
|
and kwargs.get("validate_wgs_upstream_authority") is False
|
||
|
|
)
|
||
|
|
return Status(completed)
|
||
|
|
|
||
|
|
monkeypatch.setattr(stage_status, "get_stage_status", fake_status)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(
|
||
|
|
message="执行 WGS 全流程分析",
|
||
|
|
job_id="job-1",
|
||
|
|
prefix="Os",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["success"] is True
|
||
|
|
assert response["plan"]["stages"] == [
|
||
|
|
"wgs_qc", "wgs_index", "wgs_align", "wgs_markdup",
|
||
|
|
"wgs_maf_missing_filter",
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("message", "target"),
|
||
|
|
[
|
||
|
|
("执行 WGS BAM质控", "wgs_bam_qc"),
|
||
|
|
("执行 WGS变异过滤", "wgs_variant_filter"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_agent_plan_limits_every_status_set_to_requested_wgs_closure(
|
||
|
|
monkeypatch, message: str, target: str
|
||
|
|
) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, stage: str):
|
||
|
|
self.status = "success" if stage == "wgs_markdup" else "not_found"
|
||
|
|
self.completed = stage == "wgs_markdup"
|
||
|
|
self.manifest_path = "/final/Os.wgs_markdup.manifest.json" if self.completed else None
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main, "_load_methodology_plan_for_agent_request", lambda _request: (None, None, None)
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
stage_status, "get_stage_status", lambda **kwargs: Status(kwargs["stage"])
|
||
|
|
)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(message=message, job_id="job-1", prefix="Os")
|
||
|
|
)
|
||
|
|
)
|
||
|
|
closure = complete_dependencies([target])
|
||
|
|
|
||
|
|
assert response["plan"]["stages"] == [
|
||
|
|
stage for stage in closure if stage != "wgs_markdup"
|
||
|
|
]
|
||
|
|
assert [item["id"] for item in response["plan"]["stages_detail"]] == response["plan"]["stages"]
|
||
|
|
assert response["completed_stages"] == ["wgs_markdup"]
|
||
|
|
assert list(response["plan"]["stage_status_summary"]) == closure
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_rejects_multiple_manifest_prefixes_instead_of_using_cohort(
|
||
|
|
tmp_path, monkeypatch
|
||
|
|
) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
final_dir = tmp_path / "job-1" / "final"
|
||
|
|
final_dir.mkdir(parents=True)
|
||
|
|
for prefix in ("Os", "Rice"):
|
||
|
|
(final_dir / f"{prefix}.wgs_qc.manifest.json").write_text(
|
||
|
|
json.dumps({"stage": "wgs_qc", "status": "success", "prefix": prefix}),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
status = "not_found"
|
||
|
|
completed = False
|
||
|
|
manifest_path = None
|
||
|
|
missing_artifacts = []
|
||
|
|
errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main, "_load_methodology_plan_for_agent_request", lambda _request: (None, None, None)
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(stage_status, "get_stage_status", lambda **_kwargs: Status())
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(web_main.AgentPlanRequest(message="执行 WGS质控", job_id="job-1"))
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["success"] is False
|
||
|
|
assert "prefix" in response.get("message", "").lower()
|
||
|
|
|
||
|
|
|
||
|
|
def test_wgs_plan_recovers_unique_manifest_prefix_when_requested_prefix_is_stale(
|
||
|
|
tmp_path, monkeypatch
|
||
|
|
) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
|
||
|
|
final_dir = tmp_path / "job-1" / "final"
|
||
|
|
final_dir.mkdir(parents=True)
|
||
|
|
(final_dir / "Os.wgs_qc.manifest.json").write_text(
|
||
|
|
json.dumps({"stage": "wgs_qc", "status": "success", "prefix": "Os"}),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
|
||
|
|
|
||
|
|
assert web_main._wgs_prefix_for_plan(
|
||
|
|
"job-1", ["wgs_qc", "wgs_index", "wgs_align"], "At"
|
||
|
|
) == "Os"
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_rejects_multiple_scientific_identities_for_one_prefix(
|
||
|
|
tmp_path, monkeypatch
|
||
|
|
) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
|
||
|
|
final_dir = tmp_path / "job-1" / "final"
|
||
|
|
final_dir.mkdir(parents=True)
|
||
|
|
for suffix, identity in (("", "analysis-1"), (".copy", "analysis-2")):
|
||
|
|
(final_dir / f"Os.wgs_qc{suffix}.manifest.json").write_text(
|
||
|
|
json.dumps({
|
||
|
|
"stage": "wgs_qc",
|
||
|
|
"status": "success",
|
||
|
|
"prefix": "Os",
|
||
|
|
"wgs_sample_set_id": "samples",
|
||
|
|
"wgs_analysis_id": identity,
|
||
|
|
}),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main, "_load_methodology_plan_for_agent_request", lambda _request: (None, None, None)
|
||
|
|
)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(message="执行 WGS质控", job_id="job-1", prefix="Os")
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["success"] is False
|
||
|
|
assert "identity" in response.get("message", "").lower()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("producer", "message", "target"),
|
||
|
|
[
|
||
|
|
("wgs_variant_select", "执行 WGS变异过滤", "wgs_variant_filter"),
|
||
|
|
("wgs_variant_filter", "执行 WGS基因型填充", "wgs_impute"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_agent_plan_propagates_bcftools_method_from_completed_direct_authority(
|
||
|
|
tmp_path, monkeypatch, producer: str, message: str, target: str
|
||
|
|
) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
manifest = tmp_path / "job-1" / "final" / f"Cohort.{producer}.bcftools.manifest.json"
|
||
|
|
manifest.parent.mkdir(parents=True)
|
||
|
|
manifest.write_text(
|
||
|
|
json.dumps({
|
||
|
|
"stage": producer,
|
||
|
|
"status": "success",
|
||
|
|
"prefix": "Cohort",
|
||
|
|
"method": "bcftools",
|
||
|
|
"parameters": {"method": "bcftools"},
|
||
|
|
}),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, stage: str):
|
||
|
|
self.status = "not_found" if stage == target else "success"
|
||
|
|
self.completed = stage != target
|
||
|
|
self.manifest_path = manifest if stage == producer else None
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main, "_load_methodology_plan_for_agent_request", lambda _request: (None, None, None)
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
stage_status, "get_stage_status", lambda **kwargs: Status(kwargs["stage"])
|
||
|
|
)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(message=message, job_id="job-1", prefix="Cohort")
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["plan"]["stages"] == [target]
|
||
|
|
assert response["plan"]["params"]["wgsMethod"] == "bcftools"
|
||
|
|
assert response["plan"]["stages_detail"][0]["params"]["wgsMethod"] == "bcftools"
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_rejects_explicit_method_conflicting_with_completed_authority(
|
||
|
|
tmp_path, monkeypatch
|
||
|
|
) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
manifest = tmp_path / "job-1" / "final" / "Cohort.wgs_variant_select.bcftools.manifest.json"
|
||
|
|
manifest.parent.mkdir(parents=True)
|
||
|
|
manifest.write_text(
|
||
|
|
json.dumps({
|
||
|
|
"stage": "wgs_variant_select",
|
||
|
|
"status": "success",
|
||
|
|
"prefix": "Cohort",
|
||
|
|
"method": "bcftools",
|
||
|
|
}),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, stage: str):
|
||
|
|
self.status = "not_found" if stage == "wgs_variant_filter" else "success"
|
||
|
|
self.completed = stage != "wgs_variant_filter"
|
||
|
|
self.manifest_path = manifest if stage == "wgs_variant_select" else None
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main, "_load_methodology_plan_for_agent_request", lambda _request: (None, None, None)
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
stage_status, "get_stage_status", lambda **kwargs: Status(kwargs["stage"])
|
||
|
|
)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(
|
||
|
|
message="执行 WGS变异过滤",
|
||
|
|
job_id="job-1",
|
||
|
|
prefix="Cohort",
|
||
|
|
intent={"params": {"wgsMethod": "gatk"}},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["success"] is False
|
||
|
|
assert "method conflict" in response["message"].lower()
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_does_not_inherit_stale_authority_params(monkeypatch) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, stage: str):
|
||
|
|
self.status = "not_found" if stage == "wgs_variant_filter" else "success"
|
||
|
|
self.completed = stage != "wgs_variant_filter"
|
||
|
|
self.manifest_path = None
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main, "_load_methodology_plan_for_agent_request", lambda _request: (None, None, None)
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
stage_status, "get_stage_status", lambda **kwargs: Status(kwargs["stage"])
|
||
|
|
)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(
|
||
|
|
message="执行 WGS变异过滤",
|
||
|
|
job_id="job-1",
|
||
|
|
prefix="Os",
|
||
|
|
intent={
|
||
|
|
"params": {
|
||
|
|
"wgsQcManifest": "/stale/Os.wgs_qc.manifest.json",
|
||
|
|
"wgsQcAnalysisId": "stale-analysis",
|
||
|
|
}
|
||
|
|
},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert "wgsQcManifest" not in response["plan"]["params"]
|
||
|
|
assert "wgsQcAnalysisId" not in response["plan"]["params"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_uses_selected_job_and_manifest_prefix_when_request_omits_both(monkeypatch) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, completed: bool):
|
||
|
|
self.status = "success" if completed else "not_found"
|
||
|
|
self.completed = completed
|
||
|
|
self.manifest_path = None
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(web_main, "current_job_id", "job-selected")
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_load_methodology_plan_for_agent_request",
|
||
|
|
lambda _request: (None, None, None),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_prefix_for_plan",
|
||
|
|
lambda job_id, _stages, _prefix: "Os" if job_id == "job-selected" else "",
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
seen = []
|
||
|
|
|
||
|
|
def fake_status(**kwargs):
|
||
|
|
seen.append(kwargs)
|
||
|
|
return Status(kwargs["stage"] != "wgs_maf_missing_filter")
|
||
|
|
|
||
|
|
monkeypatch.setattr(stage_status, "get_stage_status", fake_status)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(message="执行 WGS MAF缺失率过滤")
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["mode"] == "deterministic_wgs_plan"
|
||
|
|
assert response["plan"]["prefix"] == "Os"
|
||
|
|
assert response["plan"]["stages"] == ["wgs_maf_missing_filter"]
|
||
|
|
assert seen and all(item["job_id"] == "job-selected" for item in seen)
|
||
|
|
assert all(item["prefix"] == "Os" for item in seen)
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_prefers_current_request_prefix_over_stale_chat_intent(monkeypatch) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
status = "not_found"
|
||
|
|
completed = False
|
||
|
|
manifest_path = None
|
||
|
|
missing_artifacts = []
|
||
|
|
errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_load_methodology_plan_for_agent_request",
|
||
|
|
lambda _request: (None, None, None),
|
||
|
|
)
|
||
|
|
seen_prefixes = []
|
||
|
|
|
||
|
|
def capture_prefix(_job_id, _stages, requested_prefix):
|
||
|
|
seen_prefixes.append(requested_prefix)
|
||
|
|
return requested_prefix
|
||
|
|
|
||
|
|
monkeypatch.setattr(web_main, "_wgs_prefix_for_plan", capture_prefix)
|
||
|
|
monkeypatch.setattr(web_main, "_wgs_manifest_identity_for_plan", lambda *_args: {})
|
||
|
|
monkeypatch.setattr(stage_status, "get_stage_status", lambda **_kwargs: Status())
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(
|
||
|
|
message="执行 WGS比对",
|
||
|
|
job_id="job-selected",
|
||
|
|
prefix="Os",
|
||
|
|
intent={
|
||
|
|
"stages": ["wgs_align"],
|
||
|
|
"taskType": "pipeline",
|
||
|
|
"prefix": "At",
|
||
|
|
"params": {"prefix": "At"},
|
||
|
|
},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["mode"] == "deterministic_wgs_plan"
|
||
|
|
assert response["plan"]["prefix"] == "Os"
|
||
|
|
assert seen_prefixes == ["Os"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_plan_recovers_wgs_job_id_from_chat_intent(monkeypatch) -> None:
|
||
|
|
from apps.web.vue3 import main as web_main
|
||
|
|
from gfa_core.mas.tools import stage_status
|
||
|
|
|
||
|
|
class Status:
|
||
|
|
def __init__(self, completed: bool):
|
||
|
|
self.status = "success" if completed else "not_found"
|
||
|
|
self.completed = completed
|
||
|
|
self.manifest_path = None
|
||
|
|
self.missing_artifacts = []
|
||
|
|
self.errors = []
|
||
|
|
|
||
|
|
monkeypatch.setattr(web_main, "current_job_id", "default_job")
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_load_methodology_plan_for_agent_request",
|
||
|
|
lambda _request: (None, None, None),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_prefix_for_plan",
|
||
|
|
lambda job_id, _stages, _prefix: "Os" if job_id == "job-selected" else "",
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
web_main,
|
||
|
|
"_wgs_manifest_identity_for_plan",
|
||
|
|
lambda *_args: {
|
||
|
|
"wgsSampleSetId": "samples",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
missing = {
|
||
|
|
"wgs_qc", "wgs_index", "wgs_align", "wgs_markdup",
|
||
|
|
"wgs_maf_missing_filter",
|
||
|
|
}
|
||
|
|
seen = []
|
||
|
|
|
||
|
|
def fake_status(**kwargs):
|
||
|
|
seen.append(kwargs)
|
||
|
|
return Status(kwargs["stage"] not in missing)
|
||
|
|
|
||
|
|
monkeypatch.setattr(stage_status, "get_stage_status", fake_status)
|
||
|
|
|
||
|
|
response = asyncio.run(
|
||
|
|
web_main.agent_plan(
|
||
|
|
web_main.AgentPlanRequest(
|
||
|
|
message="执行 WGS全流程",
|
||
|
|
intent={
|
||
|
|
"job_id": "job-selected",
|
||
|
|
"stages": WGS_STAGES,
|
||
|
|
"taskType": "pipeline",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert response["mode"] == "deterministic_wgs_plan"
|
||
|
|
assert response["plan"]["prefix"] == "Os"
|
||
|
|
assert response["plan"]["stages"] == [
|
||
|
|
"wgs_qc", "wgs_index", "wgs_align", "wgs_markdup",
|
||
|
|
"wgs_maf_missing_filter",
|
||
|
|
]
|
||
|
|
assert seen and all(item["job_id"] == "job-selected" for item in seen)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("status", "must_plan"),
|
||
|
|
[
|
||
|
|
("success", False),
|
||
|
|
("failed", True),
|
||
|
|
("partial_success", True),
|
||
|
|
("not_found", True),
|
||
|
|
("stale", True),
|
||
|
|
("hash_mismatch", True),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_planner_skips_only_revalidated_exact_wgs_success(
|
||
|
|
tmp_path, monkeypatch, status: str, must_plan: bool
|
||
|
|
) -> None:
|
||
|
|
agent = PlanningAgentV2(None, project_root=str(tmp_path))
|
||
|
|
monkeypatch.setattr(
|
||
|
|
agent,
|
||
|
|
"_check_stage_status_preflight",
|
||
|
|
lambda stage, prefix, workspace, stage_params=None: StageStatus(stage=stage, status=status),
|
||
|
|
)
|
||
|
|
plan = asyncio.run(
|
||
|
|
agent._generate_plan_with_llm(
|
||
|
|
query="WGS质控",
|
||
|
|
intent={"stages": ["wgs_qc"], "prefix": "Cohort"},
|
||
|
|
completed_stages=[],
|
||
|
|
job_id="job",
|
||
|
|
workspace=str(tmp_path / "job"),
|
||
|
|
preflight_result={
|
||
|
|
"stage_status": {
|
||
|
|
"wgs_qc": {
|
||
|
|
"status": "success",
|
||
|
|
"manifest_path": "final/Cohort.wgs_qc.manifest.json",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
assert bool(plan["stages"]) is must_plan
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("stage", WGS_STAGES)
|
||
|
|
def test_planner_passes_stage_specific_exact_wgs_identity_to_status(
|
||
|
|
tmp_path, monkeypatch, stage: str
|
||
|
|
) -> None:
|
||
|
|
agent = PlanningAgentV2(None, project_root=str(tmp_path))
|
||
|
|
captured = {}
|
||
|
|
|
||
|
|
def capture_status(
|
||
|
|
stage_name, prefix, workspace, stage_params=None
|
||
|
|
) -> StageStatus:
|
||
|
|
captured[stage_name] = dict(stage_params or {})
|
||
|
|
return StageStatus(stage=stage_name, status="failed")
|
||
|
|
|
||
|
|
monkeypatch.setattr(agent, "_check_stage_status_preflight", capture_status)
|
||
|
|
asyncio.run(
|
||
|
|
agent._generate_plan_with_llm(
|
||
|
|
query=f"execute {stage}",
|
||
|
|
intent={
|
||
|
|
"stages": [stage],
|
||
|
|
"prefix": "Cohort",
|
||
|
|
"params": {
|
||
|
|
"wgsSampleSetId": "sample-set",
|
||
|
|
"wgsReferenceId": "reference",
|
||
|
|
"wgsAnalysisId": "analysis",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
completed_stages=[],
|
||
|
|
job_id="job",
|
||
|
|
workspace=str(tmp_path / "job"),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
target_params = captured[stage]
|
||
|
|
expected = {"wgsAnalysisId": "analysis"}
|
||
|
|
if stage != "wgs_index":
|
||
|
|
expected["wgsSampleSetId"] = "sample-set"
|
||
|
|
if stage != "wgs_qc":
|
||
|
|
expected["wgsReferenceId"] = "reference"
|
||
|
|
assert {key: target_params.get(key) for key in expected} == expected
|