480 lines
17 KiB
Python
480 lines
17 KiB
Python
import asyncio
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from apps.web.vue3 import main as api
|
|
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
|
|
from gfa_core.mas.context_index import ProjectContextIndex
|
|
from gfa_core.mas.core.fast_intent import fast_intent_recognition
|
|
from gfa_core.mas.stage_catalog import STAGE_DEPENDENCIES, complete_dependencies
|
|
from gfa_core.mas.utils.intent_parser import parse_intent_from_query, resolve_gwas_intent
|
|
|
|
|
|
GWAS_FMT_COMMANDS = [
|
|
"tassel转换",
|
|
"使用 TASSEL 进行 VCF 格式转换",
|
|
"使用 TASSEL 将上传的 VCF 转换为 HapMap",
|
|
"GWAS格式转换",
|
|
"VCF转HapMap",
|
|
"请对外部 VCF 进行 GWAS 格式转换,使用 TASSEL 排序并转换为 HapMap",
|
|
]
|
|
|
|
GENERIC_GWAS_COMMANDS = [
|
|
"gwas",
|
|
"执行gwas分析",
|
|
"gwas流程",
|
|
"运行完整 GWAS",
|
|
]
|
|
|
|
FULL_GWAS_CHAIN = complete_dependencies(["gwas_plot", "gwas_emplot", "gwas_glm"])
|
|
|
|
|
|
class _ShortenedGwasOrchestrator:
|
|
def __init__(self) -> None:
|
|
self.initial_state = None
|
|
|
|
async def plan(self, initial_state: dict) -> dict:
|
|
self.initial_state = initial_state
|
|
return {
|
|
"status": "completed",
|
|
"mode": "orchestrator_v2",
|
|
"final_output": {
|
|
"analysis_plan": {
|
|
"analysis_type": "gene_family",
|
|
"stages": ["gwas_emmax", "gwas_emplot"],
|
|
"stages_detail": [
|
|
api._stage_detail_defaults("gwas_emmax"),
|
|
api._stage_detail_defaults("gwas_emplot"),
|
|
],
|
|
"total_stages": 2,
|
|
"description": "执行gwas_glm分析",
|
|
"params": {"prefix": ""},
|
|
},
|
|
"intent": {
|
|
"stages": ["gwas_emmax", "gwas_emplot"],
|
|
"primary_stage": "gwas_glm",
|
|
"description": "执行gwas_glm分析",
|
|
"params": {},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("query", GENERIC_GWAS_COMMANDS)
|
|
def test_agent_plan_keeps_bare_gwas_full_chain_authoritative(monkeypatch, query: str) -> None:
|
|
orchestrator = _ShortenedGwasOrchestrator()
|
|
monkeypatch.setattr(api, "orchestrator", orchestrator)
|
|
monkeypatch.setattr(api, "MAS_AVAILABLE", True)
|
|
|
|
response = asyncio.run(api.agent_plan(api.AgentPlanRequest(message=query)))
|
|
|
|
assert orchestrator.initial_state["results"]["intent"]["stages"] == FULL_GWAS_CHAIN
|
|
assert response["success"] is True
|
|
assert response["intent"]["stages"] == FULL_GWAS_CHAIN
|
|
assert response["plan"]["stages"] == FULL_GWAS_CHAIN
|
|
assert response["plan"]["all_stages"] == FULL_GWAS_CHAIN
|
|
assert [item["id"] for item in response["plan"]["stages_detail"]] == FULL_GWAS_CHAIN
|
|
assert [item["id"] for item in response["plan"]["all_stages_detail"]] == FULL_GWAS_CHAIN
|
|
assert {
|
|
item["id"]: item["dependencies"]
|
|
for item in response["plan"]["stages_detail"]
|
|
} == {
|
|
stage: STAGE_DEPENDENCIES[stage]
|
|
for stage in FULL_GWAS_CHAIN
|
|
}
|
|
assert {
|
|
item["id"]: item["dependencies"]
|
|
for item in response["plan"]["all_stages_detail"]
|
|
} == {
|
|
stage: STAGE_DEPENDENCIES[stage]
|
|
for stage in FULL_GWAS_CHAIN
|
|
}
|
|
assert response["plan"]["analysis_type"] == "gwas"
|
|
assert response["plan"]["prefix"] == "cohort"
|
|
assert response["plan"]["params"]["prefix"] == "cohort"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("query", "target"),
|
|
[
|
|
("使用 TASSEL 将上传的 VCF 转换为 HapMap", "gwas_fmt"),
|
|
("使用 TASSEL 执行 GWAS GLM 分析", "gwas_glm"),
|
|
("执行 EMMAX GWAS 分析", "gwas_emmax"),
|
|
("绘制 EMMAX Manhattan 和 QQ 图", "gwas_emplot"),
|
|
("绘制 GWAS MLM Manhattan 和 QQ 图", "gwas_plot"),
|
|
],
|
|
)
|
|
def test_agent_plan_does_not_expand_explicit_gwas_stage(monkeypatch, query: str, target: str) -> None:
|
|
expected = complete_dependencies([target])
|
|
|
|
class _ExactOrchestrator:
|
|
async def plan(self, initial_state: dict) -> dict:
|
|
intent = initial_state["results"].get("intent") or {
|
|
"stages": expected,
|
|
"primary_stage": target,
|
|
"description": f"执行{target}分析",
|
|
"params": {"prefix": "cohort"},
|
|
"prefix": "cohort",
|
|
}
|
|
stage_details = []
|
|
for stage in expected:
|
|
detail = api._stage_detail_defaults(stage)
|
|
stage_details.append(detail)
|
|
return {
|
|
"status": "completed",
|
|
"mode": "orchestrator_v2",
|
|
"final_output": {
|
|
"analysis_plan": {
|
|
"analysis_type": "gwas",
|
|
"stages": expected,
|
|
"stages_detail": stage_details,
|
|
"total_stages": len(expected),
|
|
"description": f"执行{target}分析",
|
|
"params": {"prefix": "cohort"},
|
|
},
|
|
"intent": intent,
|
|
},
|
|
}
|
|
|
|
monkeypatch.setattr(api, "orchestrator", _ExactOrchestrator())
|
|
monkeypatch.setattr(api, "MAS_AVAILABLE", True)
|
|
|
|
response = asyncio.run(api.agent_plan(api.AgentPlanRequest(message=query)))
|
|
|
|
assert response["plan"]["all_stages"] == expected
|
|
assert response["intent"]["stages"] == expected
|
|
assert response["plan"]["all_stages"] != FULL_GWAS_CHAIN or expected == FULL_GWAS_CHAIN
|
|
assert {
|
|
item["id"]: item["dependencies"]
|
|
for item in response["plan"]["all_stages_detail"]
|
|
} == {
|
|
stage: STAGE_DEPENDENCIES[stage]
|
|
for stage in expected
|
|
}
|
|
|
|
|
|
def _recognize_with_public_agent(query: str) -> dict:
|
|
agent = IntentAgentV2(object())
|
|
state = AgentState(session_id="gwas-intent-test", job_id="job_test")
|
|
message = AgentMessage(
|
|
sender="router",
|
|
receiver="intent",
|
|
message_type="recognize",
|
|
payload={"query": query, "workspace": "/tmp/job_test"},
|
|
)
|
|
|
|
result = asyncio.run(agent.execute(state, message))
|
|
|
|
return result.results["results"]["intent"]
|
|
|
|
|
|
@pytest.mark.parametrize("query", GWAS_FMT_COMMANDS)
|
|
def test_gwas_fmt_commands_use_deterministic_parser(query: str) -> None:
|
|
parsed = parse_intent_from_query(query)
|
|
|
|
assert parsed["stages"] == ["gwas_fmt"]
|
|
assert parsed["primary_stage"] == "gwas_fmt"
|
|
assert parsed["task_kind"] == "pipeline"
|
|
|
|
|
|
@pytest.mark.parametrize("query", GWAS_FMT_COMMANDS)
|
|
def test_gwas_fmt_commands_use_fast_intent_path(query: str) -> None:
|
|
parsed = fast_intent_recognition(query)
|
|
|
|
assert parsed is not None
|
|
assert parsed["stage"] == "gwas_fmt"
|
|
assert parsed["stages"] == ["gwas_fmt"]
|
|
|
|
|
|
@pytest.mark.parametrize("query", GWAS_FMT_COMMANDS)
|
|
def test_gwas_fmt_commands_use_public_intent_agent(query: str) -> None:
|
|
parsed = _recognize_with_public_agent(query)
|
|
|
|
assert parsed["stages"] == ["gwas_fmt"]
|
|
assert parsed["primary_stage"] == "gwas_fmt"
|
|
|
|
|
|
@pytest.mark.parametrize("query", GENERIC_GWAS_COMMANDS)
|
|
def test_generic_gwas_commands_plan_full_chain_in_deterministic_parser(query: str) -> None:
|
|
parsed = parse_intent_from_query(query)
|
|
|
|
assert parsed["stages"] == FULL_GWAS_CHAIN
|
|
assert parsed["primary_stage"] == "gwas_glm"
|
|
assert parsed["need_clarify"] is False
|
|
assert "蛋白" not in (parsed.get("message") or "")
|
|
|
|
|
|
@pytest.mark.parametrize("query", GENERIC_GWAS_COMMANDS)
|
|
def test_generic_gwas_commands_use_full_chain_fast_intent_path(query: str) -> None:
|
|
parsed = fast_intent_recognition(query)
|
|
assert parsed is not None
|
|
assert parsed["stage"] == "gwas_glm"
|
|
assert parsed["stages"] == FULL_GWAS_CHAIN
|
|
|
|
|
|
@pytest.mark.parametrize("query", GENERIC_GWAS_COMMANDS)
|
|
def test_generic_gwas_commands_use_public_agent_pipeline_response(query: str) -> None:
|
|
parsed = _recognize_with_public_agent(query)
|
|
|
|
assert parsed["stages"] == FULL_GWAS_CHAIN
|
|
assert parsed["primary_stage"] == "gwas_glm"
|
|
assert parsed["task_kind"] == "pipeline"
|
|
assert parsed["taskType"] == "pipeline"
|
|
assert "蛋白" not in str(parsed)
|
|
|
|
|
|
def test_gwas_fmt_plan_blocks_when_no_job_vcf_exists(tmp_path: Path) -> None:
|
|
input_dir = tmp_path / "input"
|
|
workspace = tmp_path / "workspace"
|
|
input_dir.mkdir()
|
|
workspace.mkdir()
|
|
agent = PlanningAgentV2.__new__(PlanningAgentV2)
|
|
|
|
issues = agent._check_input_files(
|
|
"gwas_fmt",
|
|
input_dir,
|
|
workspace,
|
|
"cohort",
|
|
context_index=None,
|
|
planned_stages=["gwas_fmt"],
|
|
)
|
|
|
|
assert any(issue["severity"] == "error" for issue in issues)
|
|
assert any(issue.get("code") == "missing_external_vcf" for issue in issues)
|
|
|
|
|
|
def _empty_project_context(tmp_path: Path) -> ProjectContextIndex:
|
|
workspace = tmp_path / "workspace" / "job_test"
|
|
return ProjectContextIndex(
|
|
job_id="job_test",
|
|
workspace=str(workspace),
|
|
input_dir=str(tmp_path / "input" / "job_test"),
|
|
final_dir=str(workspace / "final"),
|
|
logs_dir=str(workspace / "logs"),
|
|
)
|
|
|
|
|
|
def _planning_input_issues(tmp_path: Path, stage: str, planned_stages: list[str]) -> list[dict]:
|
|
context = _empty_project_context(tmp_path)
|
|
agent = PlanningAgentV2.__new__(PlanningAgentV2)
|
|
return agent._check_input_files(
|
|
stage,
|
|
Path(context.input_dir),
|
|
Path(context.workspace),
|
|
"cohort",
|
|
context_index=context,
|
|
planned_stages=planned_stages,
|
|
)
|
|
|
|
|
|
def _root_record(path: Path, role: str) -> dict:
|
|
return {
|
|
"role": role,
|
|
"path": str(path.resolve()),
|
|
"size": path.stat().st_size,
|
|
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
}
|
|
|
|
|
|
def _write_covariate_root_pair(tmp_path: Path, *, audited: bool) -> tuple[Path, Path]:
|
|
input_dir = tmp_path / "input" / "job_test"
|
|
final_dir = tmp_path / "workspace" / "job_test" / "final"
|
|
input_dir.mkdir(parents=True)
|
|
final_dir.mkdir(parents=True)
|
|
q_file = input_dir / "cohort.2.Q"
|
|
fam_file = input_dir / "cohort.fam"
|
|
q_file.write_text("0.4 0.6\n", encoding="utf-8")
|
|
fam_file.write_text("F1 I1 0 0 0 -9\n", encoding="utf-8")
|
|
if audited:
|
|
(final_dir / "cohort.pop_struct.manifest.json").write_text(
|
|
json.dumps({
|
|
"stage": "pop_struct",
|
|
"status": "success",
|
|
"genotype_scope_id": "genotype:" + "a" * 64,
|
|
"artifacts": {
|
|
"admixture_q": _root_record(q_file, "admixture_q"),
|
|
"sample_fam": _root_record(fam_file, "sample_fam"),
|
|
},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
return q_file, fam_file
|
|
|
|
|
|
def test_gwas_cov_planning_resolves_audited_q_fam_pair_without_generic_artifact_errors(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
q_file, fam_file = _write_covariate_root_pair(tmp_path, audited=True)
|
|
# Distinct scan candidates must not override the authoritative upstream Manifest.
|
|
(tmp_path / "input" / "job_test" / "other.Q").write_text("0.2 0.8\n", encoding="utf-8")
|
|
(tmp_path / "input" / "job_test" / "other.fam").write_text(
|
|
"F2 I2 0 0 0 -9\n", encoding="utf-8",
|
|
)
|
|
|
|
issues = _planning_input_issues(tmp_path, "gwas_cov", FULL_GWAS_CHAIN)
|
|
|
|
assert not any(issue["severity"] == "error" for issue in issues)
|
|
resolved = {issue.get("role"): issue for issue in issues if issue.get("category") == "input_resolved"}
|
|
assert set(resolved) == {"admixture_q", "sample_fam"}
|
|
assert resolved["admixture_q"]["suggestion"] == str(q_file.resolve())
|
|
assert resolved["sample_fam"]["suggestion"] == str(fam_file.resolve())
|
|
|
|
|
|
def test_gwas_cov_planning_preserves_unverified_lineage_error_instead_of_missing_roles(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
_write_covariate_root_pair(tmp_path, audited=False)
|
|
|
|
issues = _planning_input_issues(tmp_path, "gwas_cov", FULL_GWAS_CHAIN)
|
|
|
|
assert [issue.get("code") for issue in issues] == ["unverified_covariate_lineage"]
|
|
assert issues[0]["severity"] == "error"
|
|
assert issues[0]["resolver"]["needs_confirmation"] is False
|
|
assert "lineage" in issues[0]["message"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("case", "expected_code", "needs_confirmation"),
|
|
[
|
|
("invalid_manifest", "invalid_upstream_manifest", False),
|
|
("invalid_binding", "invalid_external_binding", False),
|
|
("ambiguous", "ambiguous_root_input", True),
|
|
],
|
|
)
|
|
def test_gwas_cov_planning_preserves_root_pair_fail_closed_issues(
|
|
tmp_path: Path,
|
|
case: str,
|
|
expected_code: str,
|
|
needs_confirmation: bool,
|
|
) -> None:
|
|
_write_covariate_root_pair(tmp_path, audited=case == "invalid_manifest")
|
|
workspace = tmp_path / "workspace" / "job_test"
|
|
if case == "invalid_manifest":
|
|
manifest = workspace / "final" / "cohort.pop_struct.manifest.json"
|
|
payload = json.loads(manifest.read_text(encoding="utf-8"))
|
|
payload["status"] = "failed"
|
|
manifest.write_text(json.dumps(payload), encoding="utf-8")
|
|
elif case == "invalid_binding":
|
|
(workspace / "external_inputs.manifest.json").write_text(
|
|
json.dumps({"manifest_version": "1.0", "job_id": "job_test", "inputs": "malformed"}),
|
|
encoding="utf-8",
|
|
)
|
|
else:
|
|
(tmp_path / "input" / "job_test" / "other.Q").write_text("0.2 0.8\n", encoding="utf-8")
|
|
|
|
issues = _planning_input_issues(tmp_path, "gwas_cov", FULL_GWAS_CHAIN)
|
|
|
|
matching = [issue for issue in issues if issue.get("code") == expected_code]
|
|
assert matching
|
|
assert all(issue["needs_confirmation"] is needs_confirmation for issue in matching)
|
|
assert all(issue["message"] in [item["message"] for item in issue["resolver"]["issues"]] for issue in matching)
|
|
assert all(issue["severity"] == ("warning" if needs_confirmation else "error") for issue in matching)
|
|
|
|
|
|
def test_full_gwas_dag_treats_derived_roles_as_dependency_provided(tmp_path: Path) -> None:
|
|
expected = {
|
|
("gwas_kinship", "sorted_vcf"): "gwas_fmt",
|
|
("gwas_mlm", "sorted_vcf"): "gwas_fmt",
|
|
("gwas_mlm", "kinship_matrix"): "gwas_kinship",
|
|
("gwas_mlm", "trait_table"): "gwas_trait",
|
|
("gwas_mlm", "covariate_table"): "gwas_cov",
|
|
("gwas_plot", "mlm_pvalues"): "gwas_mlm",
|
|
("gwas_plot", "cmlm_pvalues"): "gwas_mlm",
|
|
("gwas_emmax", "sorted_vcf"): "gwas_fmt",
|
|
("gwas_emmax", "trait_table"): "gwas_trait",
|
|
("gwas_emplot", "emmax_pvalues"): "gwas_emmax",
|
|
("gwas_glm", "sorted_vcf"): "gwas_fmt",
|
|
("gwas_glm", "trait_table"): "gwas_trait",
|
|
("gwas_glm", "covariate_table"): "gwas_cov",
|
|
}
|
|
|
|
for (stage, role), producer in expected.items():
|
|
issues = _planning_input_issues(tmp_path, stage, FULL_GWAS_CHAIN)
|
|
matching = [issue for issue in issues if role in issue.get("message", "")]
|
|
assert matching, (stage, role, issues)
|
|
assert not any(issue["severity"] == "error" for issue in matching)
|
|
assert any(
|
|
issue["severity"] == "warning"
|
|
and issue.get("category") == "dependency"
|
|
and producer in issue.get("message", "")
|
|
for issue in matching
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stage", "root_roles"),
|
|
[
|
|
("gwas_trait", {"phenotype_table"}),
|
|
("gwas_cov", {"admixture_q", "sample_fam"}),
|
|
],
|
|
)
|
|
def test_full_gwas_dag_keeps_unresolved_root_inputs_blocking(
|
|
tmp_path: Path,
|
|
stage: str,
|
|
root_roles: set[str],
|
|
) -> None:
|
|
issues = _planning_input_issues(tmp_path, stage, FULL_GWAS_CHAIN)
|
|
|
|
if stage == "gwas_cov":
|
|
assert any(
|
|
issue["severity"] == "error" and issue.get("code") == "missing_root_input"
|
|
for issue in issues
|
|
)
|
|
return
|
|
for role in root_roles:
|
|
assert any(
|
|
issue["severity"] == "error" and role in issue.get("message", "")
|
|
for issue in issues
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("query", "stage", "derived_roles"),
|
|
[
|
|
("运行 EMMAX", "gwas_emmax", {"sorted_vcf", "trait_table"}),
|
|
("绘制 EMMAX Manhattan 和 QQ 图", "gwas_emplot", {"emmax_pvalues"}),
|
|
],
|
|
)
|
|
def test_single_gwas_consumer_intent_includes_its_artifact_producers(
|
|
tmp_path: Path,
|
|
query: str,
|
|
stage: str,
|
|
derived_roles: set[str],
|
|
) -> None:
|
|
planned_stages = resolve_gwas_intent(query)["stages"]
|
|
issues = _planning_input_issues(tmp_path, stage, planned_stages)
|
|
|
|
for role in derived_roles:
|
|
assert any(
|
|
issue["severity"] == "warning"
|
|
and issue.get("category") == "dependency"
|
|
and role in issue.get("message", "")
|
|
for issue in issues
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stage", "derived_roles"),
|
|
[
|
|
("gwas_emmax", {"sorted_vcf", "trait_table"}),
|
|
("gwas_emplot", {"emmax_pvalues"}),
|
|
],
|
|
)
|
|
def test_gwas_consumer_without_planned_producer_still_blocks(
|
|
tmp_path: Path,
|
|
stage: str,
|
|
derived_roles: set[str],
|
|
) -> None:
|
|
issues = _planning_input_issues(tmp_path, stage, [stage])
|
|
|
|
for role in derived_roles:
|
|
assert any(
|
|
issue["severity"] == "error" and role in issue.get("message", "")
|
|
for issue in issues
|
|
)
|