Files
PlantAI-dev/tests/unit/test_subfamily_prediction_plan.py

624 lines
20 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from gfa_core.gene_family_stage_context import (
enrich_stage_params_with_context,
infer_analysis_route,
)
from gfa_core.mas.artifact_resolver import ArtifactResolver
def _load_vue3_main():
module_path = Path(__file__).resolve().parents[2] / "apps" / "web" / "vue3" / "main.py"
spec = importlib.util.spec_from_file_location("bbav5_vue3_main_plan_test", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_subfamily_prediction_plan_uses_per_target_evidence_route():
main = _load_vue3_main()
plan = {"stages": ["identify", "reference_classification"], "stages_detail": []}
intent = {"stages": ["identify", "reference_classification"]}
stage_ids, details = main._normalize_subfamily_prediction_plan(
plan,
intent,
plan["stages"],
plan["stages_detail"],
)
assert stage_ids == [
"identify",
"phylogeny",
"motif",
"domain",
"structure",
"combo",
"reference_classification",
]
detail_by_id = {item["id"]: item for item in details}
assert detail_by_id["phylogeny"]["dependencies"] == ["identify"]
assert detail_by_id["combo"]["dependencies"] == ["motif", "domain", "structure"]
assert detail_by_id["reference_classification"]["dependencies"] == ["combo"]
assert detail_by_id["reference_classification"]["params"]["subfamilyPipeline"] is True
assert detail_by_id["reference_classification"]["params"]["perTargetEvidenceOnly"] is True
assert detail_by_id["reference_classification"]["params"]["analysisRoute"] == "subfamily_prediction"
assert plan["total_stages"] == 7
assert intent["primary_stage"] == "reference_classification"
def test_subfamily_request_roles_do_not_promote_reference_to_target(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_configured_species_scope",
lambda _job_id: {
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
"comparativeSpecies": [],
},
)
monkeypatch.setattr(
main,
"_extract_species_codes_from_text_for_job",
lambda _job_id, _message: ["Os", "At"],
)
roles = main._resolve_subfamily_request_species_roles(
"job-real",
"执行目标物种 Os 和参考物种 At 的亚家族分类",
{
"target_species": ["Os"],
"reference_species": ["At"],
"params": {"targetSpecies": ["Os"], "referenceSpecies": ["At"]},
},
)
assert roles == {
"target_species": ["Os"],
"reference_species": ["At"],
"comparative_species": [],
"classification_species": ["Os"],
}
def test_contextual_gene_family_request_handles_explicit_multi_target_scope(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_canonicalize_species_codes_for_job",
lambda _job_id, species: list(species),
)
monkeypatch.setattr(
main,
"_reconcile_confirmed_gene_family_species",
lambda *_args, **_kwargs: {"applicable": False, "conflicts": []},
)
monkeypatch.setattr(main, "_load_identify_species_selection", lambda _job_id: {})
monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "SPL")
monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda *_args: [])
monkeypatch.setattr(
main,
"_extract_species_codes_from_text_for_job",
lambda *_args: [],
)
monkeypatch.setattr(
main,
"_subfamily_state_for_targets",
lambda *_args: {
"all_confirmed": False,
"by_species": {
"Ft": {"status": "not_classified"},
"At": {"status": "not_classified"},
},
"issues": [],
},
)
monkeypatch.setattr(
main,
"_validate_gene_family_stage_context",
lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
)
contextual = main._resolve_contextual_gene_family_request(
"job-multi-target",
"执行基因家族分析",
requested_stages=["phylogeny"],
request_intent={
"gene_family": "SPL",
"target_species": ["Ft", "At"],
"reference_species": [],
"params": {
"targetSpecies": ["Ft", "At"],
"referenceSpecies": [],
},
},
)
assert contextual["targetSpecies"] == ["Ft", "At"]
assert contextual["needs_species_confirmation"] is False
def test_generic_target_reference_scope_does_not_guess_subfamily_from_stage_name():
scope = {
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": ["At"],
"comparative_species": [],
}
assert infer_analysis_route("phylogeny", scope) == "multi_species"
assert infer_analysis_route("motif", scope) == "multi_species"
def test_newick_manifest_artifact_satisfies_tree_requirement():
assert ArtifactResolver._kind_matches("tree", "newick") is True
def test_semantic_preflight_materializes_selected_tree(monkeypatch):
main = _load_vue3_main()
class Selected:
path = "/workspace/final/Target.PEBP.subfamily_evidence.tree.nwk"
class Decision:
resolved = True
selected = Selected()
candidates = []
def to_dict(self):
return {
"resolved": True,
"selected": {
"path": self.selected.path,
"kind": "newick",
"source": "artifact:phylogeny:tree",
},
"candidates": [],
}
class Resolver:
def __init__(self, _context):
pass
def resolve_stage(self, stage, species="", family=""):
return {
"family_members_pep": Decision(),
"tree": Decision(),
}
context = SimpleNamespace(
family="PEBP",
target_species=["Os"],
reference_species=["At"],
comparative_species=[],
config_path="/workspace/input/config.xlsx",
)
monkeypatch.setattr(main, "build_project_context", lambda **_kwargs: context)
monkeypatch.setattr(main, "ArtifactResolver", Resolver)
monkeypatch.setattr(
main,
"_validate_gene_family_stage_context",
lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
)
result = main._api_semantic_preflight(
"job-direct-motif",
"motif",
"Os.PEBP",
{
"geneFamily": "PEBP",
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
"scopeMode": "target_plus_reference",
"analysisRoute": "subfamily_evidence",
},
)
assert result["valid"] is True
assert result["materialized_params"]["treeFile"] == Selected.path
def test_scoped_tree_resolution_prefers_preflight_selected_tree(tmp_path):
from apps.web import pipeline_backend
workspace = tmp_path / "workspace"
final_dir = workspace / "final"
final_dir.mkdir(parents=True)
scope_file = final_dir / "Os.PEBP.scope.json"
scope_file.write_text(
json.dumps({
"scope_id": "PEBP.target-Os.ref-At.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Os"],
"reference_species": ["At"],
}),
encoding="utf-8",
)
selected_tree = final_dir / "Target.PEBP.subfamily_evidence.tree.nwk"
selected_tree.write_text("(Os1,At1);\n", encoding="utf-8")
resolved, _scope = pipeline_backend.resolve_scoped_tree_from_phylogeny(
workspace,
final_dir,
"Os.PEBP",
scope_file,
explicit_tree_file=str(selected_tree),
)
assert resolved == selected_tree.resolve()
def test_explicit_subfamily_and_regular_routes_remain_separate():
shared = {
"geneFamily": "SPL",
"targetSpecies": ["Ft", "Os"],
"referenceSpecies": ["At"],
"scopeMode": "target_plus_reference",
"analysisMode": "joint_multi_species",
}
evidence = enrich_stage_params_with_context(
"motif",
"Target.SPL",
{**shared, "analysisRoute": "subfamily_evidence"},
)
prediction = enrich_stage_params_with_context(
"reference_classification",
"Target.SPL",
{**shared, "analysisRoute": "subfamily_prediction"},
)
regular = enrich_stage_params_with_context("motif", "Target.SPL", shared)
assert evidence["analysisRoute"] == "subfamily_evidence"
assert evidence["memberSetId"].endswith(":subfamily_evidence:domain_refined")
assert evidence["analysisMode"] == "joint_multi_species"
assert prediction["analysisRoute"] == "subfamily_prediction"
assert regular["analysisRoute"] == "multi_species"
assert regular["memberSetId"].endswith(":multi_species:all_members")
def test_direct_motif_request_uses_job_subfamily_state(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_load_identify_species_selection",
lambda _job_id: {
"groups": {
"target": [{"species": "Ft"}],
"reference": [{"species": "At"}],
"comparative": [],
}
},
)
monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "SPL")
monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda _job_id, _family: [])
monkeypatch.setattr(main, "_extract_species_codes_from_text_for_job", lambda _job_id, _text: [])
monkeypatch.setattr(
main,
"_subfamily_state_for_targets",
lambda _job_id, _species, _family: {
"all_confirmed": False,
"by_species": {"Ft": {"status": "not_classified"}},
"issues": [{"code": "subfamily_classification_missing"}],
},
)
monkeypatch.setattr(
main,
"_validate_gene_family_stage_context",
lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
)
contextual = main._resolve_contextual_gene_family_request(
"job-direct-motif",
"执行 Motif 分析",
"Ft.SPL",
requested_stages=["motif"],
)
assert contextual["stages"] == ["motif"]
assert contextual["targetSpecies"] == ["Ft"]
assert contextual["referenceSpecies"] == ["At"]
assert contextual["contextGate"]["needs_subfamily_decision"] is True
def test_direct_stage_preserves_species_roles_from_intent_when_selector_is_empty(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(main, "_load_identify_species_selection", lambda _job_id: {})
monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "PEBP")
monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda _job_id, _family: [])
monkeypatch.setattr(main, "_extract_species_codes_from_text_for_job", lambda _job_id, _text: [])
monkeypatch.setattr(
main,
"_subfamily_state_for_targets",
lambda _job_id, _species, _family: {
"all_confirmed": False,
"by_species": {"Os": {"status": "not_classified"}},
"issues": [],
},
)
monkeypatch.setattr(
main,
"_validate_gene_family_stage_context",
lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
)
contextual = main._resolve_contextual_gene_family_request(
"job-direct-motif",
"执行 Motif 分析",
"Os.PEBP",
requested_stages=["motif"],
request_intent={
"target_species": ["Os"],
"reference_species": ["At"],
"params": {
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
},
},
)
assert contextual["targetSpecies"] == ["Os"]
assert contextual["referenceSpecies"] == ["At"]
assert contextual["scopeMode"] == "target_plus_reference"
request = SimpleNamespace(
job_id="job-direct-motif",
message="执行 Motif 分析",
prefix="Os.PEBP",
intent={
"stages": ["motif"],
"params": {
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
},
},
)
response = main._build_contextual_gene_family_plan_response(
request,
"session-direct-motif",
contextual,
)
details = {item["id"]: item for item in response["plan"]["all_stages_detail"]}
assert details["phylogeny"]["params"]["analysisRoute"] == "subfamily_evidence"
assert details["motif"]["params"]["analysisRoute"] == "subfamily_evidence"
assert details["phylogeny"]["params"]["memberSetId"] == details["motif"]["params"]["memberSetId"]
def test_contextual_handoff_uses_confirmed_config_roles_when_selector_is_empty(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(main, "_load_identify_species_selection", lambda _job_id: {})
monkeypatch.setattr(
main,
"_confirmed_config_species_scope",
lambda _job_id: {
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
"comparativeSpecies": [],
},
)
monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "PEBP")
monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda _job_id, _family: [])
monkeypatch.setattr(
main,
"_extract_species_codes_from_text_for_job",
lambda _job_id, _text: ["Os", "At"],
)
monkeypatch.setattr(
main,
"_subfamily_state_for_targets",
lambda _job_id, species, _family: {
"all_confirmed": species == ["Os"],
"by_species": {"Os": {"status": "confirmed"}},
"issues": [],
},
)
monkeypatch.setattr(
main,
"_validate_gene_family_stage_context",
lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
)
contextual = main._resolve_contextual_gene_family_request(
"job-real",
(
"执行水稻PEBP基因家族分析。基因家族 PEBP;"
"目标物种 Os;参考物种 At;亚家族分类已经确认。"
),
"Os.PEBP",
requested_stages=["phylogeny", "motif", "domain", "structure", "combo"],
request_intent={
"gene_family": "PEBP",
"target_species": ["Os"],
"reference_species": ["At"],
"params": {
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
},
},
)
assert contextual["targetSpecies"] == ["Os"]
assert contextual["referenceSpecies"] == ["At"]
assert contextual["prefix"] == "Os.PEBP"
assert contextual["context_route"] == "post_subfamily_gene_family_analysis"
def test_confirmed_multi_species_roles_remain_separate(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_confirmed_config_species_scope",
lambda _job_id: {
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"comparativeSpecies": ["Ppy"],
},
)
resolved = main._reconcile_confirmed_gene_family_species(
"job-multi",
"Ft是目标物种,Ppy是比较物种,At是参考物种,执行联合亚家族分类",
{
"gene_family": "SPL",
"target_species": ["Ft", "Ppy"],
"reference_species": ["At"],
"params": {
"targetSpecies": ["Ft", "Ppy"],
"referenceSpecies": ["At"],
},
},
)
assert resolved["conflicts"] == []
assert resolved["target_species"] == ["Ft"]
assert resolved["comparative_species"] == ["Ppy"]
assert resolved["reference_species"] == ["At"]
assert resolved["classification_species"] == ["Ft", "Ppy"]
def test_confirmation_ack_normalizes_structured_role_drift(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_confirmed_config_species_scope",
lambda _job_id: {
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"comparativeSpecies": [],
},
)
resolved = main._reconcile_confirmed_gene_family_species(
"job-confirmed-ack",
"是",
{
"gene_family": "SPL",
"stages": ["identify"],
"target_species": ["Ft", "At"],
"params": {"targetSpecies": ["Ft", "At"]},
},
)
assert resolved["conflicts"] == []
assert resolved["target_species"] == ["Ft"]
assert resolved["reference_species"] == ["At"]
def test_unassigned_reference_species_analysis_request_remains_blocked(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_confirmed_config_species_scope",
lambda _job_id: {
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"comparativeSpecies": [],
},
)
resolved = main._reconcile_confirmed_gene_family_species(
"job-unassigned-reference",
"分析 At 的 SPL 基因家族",
{
"gene_family": "SPL",
"stages": ["identify"],
"target_species": ["At"],
"params": {"targetSpecies": ["At"]},
},
)
assert any(
conflict["code"] == "analysis_target_config_role_conflict"
and conflict["species"] == "At"
for conflict in resolved["conflicts"]
)
def test_confirmed_config_duplicate_roles_are_blocked(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_confirmed_config_species_scope",
lambda _job_id: {
"targetSpecies": ["Ft"],
"referenceSpecies": ["Ft", "At"],
"comparativeSpecies": [],
},
)
resolved = main._reconcile_confirmed_gene_family_species(
"job-invalid-config",
"执行SPL基因家族分析",
{"gene_family": "SPL", "stages": ["identify"]},
)
assert any(
conflict["code"] == "config_species_role_conflict"
and conflict["species"] == "Ft"
for conflict in resolved["conflicts"]
)
assert "打开 config.xlsx" in main._species_role_conflict_message(resolved["conflicts"])
@pytest.mark.asyncio
async def test_plan_api_blocks_confirmed_config_role_conflict(monkeypatch, tmp_path):
import pandas as pd
from gfa_core.mas import confirmation_snapshot
main = _load_vue3_main()
input_root = tmp_path / "input"
job_dir = input_root / "job-os"
job_dir.mkdir(parents=True)
pd.DataFrame(
[
{"Species_Code": "Os", "Species_Role": "target", "Ref": False},
{"Species_Code": "At", "Species_Role": "reference", "Ref": True},
]
).to_excel(job_dir / "config.xlsx", index=False)
monkeypatch.setattr(main, "INPUT_ROOT", input_root)
monkeypatch.setattr(
confirmation_snapshot,
"build_confirmation_snapshot",
lambda *_args, **_kwargs: {
"config_confirmed": True,
"samples_confirmed": False,
"subfamily_confirmed": False,
"config": {"artifact_path": str(job_dir / "config.xlsx")},
"samples": None,
"subfamily": None,
"errors": (),
},
)
result = await main.agent_plan(
main.AgentPlanRequest(
message="At是目标物种,Os是参考物种,请执行PEBP基因家族分析",
session_id="role-conflict",
job_id="job-os",
prefix="Os.PEBP",
intent={
"gene_family": "PEBP",
"stages": ["identify"],
"target_species": ["At"],
"reference_species": ["Os"],
"params": {
"geneFamily": "PEBP",
"targetSpecies": ["At"],
"referenceSpecies": ["Os"],
},
},
)
)
assert result["success"] is False
assert result["status"] == "species_role_conflict"
assert "打开 config.xlsx" in result["message"]
assert "plan" not in result