fix: complete PPI and KaKs reference handoff

This commit is contained in:
potaser
2026-08-10 23:13:05 +08:00
parent 39a2c969de
commit 7b7e334213
37 changed files with 4770 additions and 561 deletions
+3 -1
View File
@@ -122,6 +122,7 @@ async def test_agent_chat_persists_router_proposal_without_analysis_intent(
proposal = {
"proposal_id": "proposal-123",
"request": "执行 Ft 的数据准备",
"original_request": "执行数据准备",
"subject": "执行 Ft 的数据准备",
}
_install_router(
@@ -166,6 +167,7 @@ async def test_agent_chat_exposes_confirmed_request_for_original_planner(
proposal = {
"proposal_id": "proposal-next",
"request": "我已确认 config.xlsx,接下来应该做什么分析",
"original_request": "执行kaks分析",
"subject": "启动苦荞SPL基因家族分析",
}
_install_router(
@@ -192,7 +194,7 @@ async def test_agent_chat_exposes_confirmed_request_for_original_planner(
)
assert result["action"] == "propose_analysis"
assert result["handoff_request"] == proposal["request"]
assert result["handoff_request"] == proposal["original_request"]
assert "尚未执行" in result["intent"]["message"]
@@ -0,0 +1,15 @@
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def test_analysis_plan_handoff_does_not_forward_main_agent_intent() -> None:
source = (PROJECT_ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
start = source.index("const planData = await fetchJsonWithTimeout('/api/agent/plan'")
end = source.index("}, 180000, '多智能体规划');", start)
payload_source = source[start:end]
assert "message: data.handoff_request || userInput" in payload_source
assert "...(data.action === 'propose_utility' ? { intent: data.intent } : {})" in payload_source
assert "intent: data.intent //" not in payload_source
@@ -0,0 +1,88 @@
import importlib.util
import json
from pathlib import Path
import pytest
def _load_confirmation_snapshot_module():
module_path = (
Path(__file__).resolve().parents[2]
/ "gfa_core"
/ "mas"
/ "confirmation_snapshot.py"
)
spec = importlib.util.spec_from_file_location(
"confirmation_snapshot_linked_job_test",
module_path,
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _write_legacy_config_confirmation(source_root: Path, job_id: str) -> Path:
input_job = source_root / "input" / job_id
final_dir = source_root / "workspace" / job_id / "final"
input_job.mkdir(parents=True)
final_dir.mkdir(parents=True)
config_path = input_job / "config.xlsx"
config_path.write_bytes(b"confirmed config")
manifest_path = final_dir / "config_autofill.manifest.json"
manifest_path.write_text(
json.dumps(
{
"manifest_version": "1.0",
"stage": "config_autofill",
"status": "success",
"artifacts": {
"confirmed_config": {
"path": str(config_path),
"type": "xlsx",
}
},
}
),
encoding="utf-8",
)
return config_path
def test_snapshot_accepts_paired_job_links_to_one_project_root(tmp_path):
snapshot_module = _load_confirmation_snapshot_module()
job_id = "job_linked"
project_root = tmp_path / "sj-2"
source_root = tmp_path / "source"
config_path = _write_legacy_config_confirmation(source_root, job_id)
(project_root / "input").mkdir(parents=True)
(project_root / "workspace").mkdir(parents=True)
(project_root / "input" / job_id).symlink_to(source_root / "input" / job_id)
(project_root / "workspace" / job_id).symlink_to(
source_root / "workspace" / job_id
)
snapshot = snapshot_module.build_confirmation_snapshot(project_root, job_id)
assert snapshot["config_confirmed"] is True
assert snapshot["config"]["artifact_path"] == str(config_path.resolve())
def test_snapshot_rejects_job_links_to_different_project_roots(tmp_path):
snapshot_module = _load_confirmation_snapshot_module()
job_id = "job_mismatch"
project_root = tmp_path / "sj-2"
input_source = tmp_path / "input-source"
workspace_source = tmp_path / "workspace-source"
(input_source / "input" / job_id).mkdir(parents=True)
(workspace_source / "workspace" / job_id / "final").mkdir(parents=True)
(project_root / "input").mkdir(parents=True)
(project_root / "workspace").mkdir(parents=True)
(project_root / "input" / job_id).symlink_to(
input_source / "input" / job_id
)
(project_root / "workspace" / job_id).symlink_to(
workspace_source / "workspace" / job_id
)
with pytest.raises(ValueError, match="linked job paths do not share one project root"):
snapshot_module.build_confirmation_snapshot(project_root, job_id)
@@ -0,0 +1,93 @@
from __future__ import annotations
from pathlib import Path
from openpyxl import Workbook
from apps.web.vue3 import main
def _write_config(input_dir: Path, pep: Path, cds: Path) -> None:
workbook = Workbook()
sheet = workbook.active
sheet.title = "config"
sheet.append(
[
"Species_Code",
"Species_Role",
"File_Type",
"Original_File_Name",
"Ref",
]
)
sheet.append(["At", "reference", "pep", pep.name, True])
sheet.append(["At", "reference", "cds", cds.name, True])
workbook.save(input_dir / "config.xlsx")
def test_execute_validation_accepts_config_resolved_reference_pep_and_cds(
tmp_path: Path,
monkeypatch,
) -> None:
input_root = tmp_path / "input"
job_input = input_root / "job_at"
external = tmp_path / "external"
job_input.mkdir(parents=True)
external.mkdir()
pep = external / "Arabidopsis_thaliana.TAIR10.pep.all.fa"
cds = external / "Arabidopsis_thaliana.TAIR10.cds.all.fa"
pep.write_text(">AT1G01010\nMA\n", encoding="utf-8")
cds.write_text(">AT1G01010\nATGGCT\n", encoding="utf-8")
_write_config(job_input, pep, cds)
monkeypatch.setattr(main, "INPUT_ROOT", input_root)
monkeypatch.setenv("PLANTAI_REF_DATA_ROOT", str(external))
available, source = main._reference_bundle_species_available(
"job_at",
"At",
require_cds=True,
)
assert available is True
assert source == str(job_input)
def test_execute_validation_keeps_accepting_downloaded_reference_bundle(
tmp_path: Path,
monkeypatch,
) -> None:
input_root = tmp_path / "input"
species_dir = input_root / "job_spinach" / "string_taxon_3562"
species_dir.mkdir(parents=True)
(species_dir / "string_taxon_3562.pep.fasta").write_text(">S1\nMA\n", encoding="utf-8")
(species_dir / "string_taxon_3562.cds.fasta").write_text(">S1\nATGGCT\n", encoding="utf-8")
monkeypatch.setattr(main, "INPUT_ROOT", input_root)
available, source = main._reference_bundle_species_available(
"job_spinach",
"string_taxon_3562",
require_cds=True,
)
assert available is True
assert source == str(species_dir)
def test_execute_validation_still_rejects_a_truly_incomplete_reference(
tmp_path: Path,
monkeypatch,
) -> None:
input_root = tmp_path / "input"
species_dir = input_root / "job_incomplete" / "At"
species_dir.mkdir(parents=True)
(species_dir / "At.pep.fasta").write_text(">AT1G01010\nMA\n", encoding="utf-8")
monkeypatch.setattr(main, "INPUT_ROOT", input_root)
available, reason = main._reference_bundle_species_available(
"job_incomplete",
"At",
require_cds=True,
)
assert available is False
assert "missing CDS" in reason
@@ -51,3 +51,28 @@ def test_rebind_to_same_job_keeps_conversation_identity(tmp_path: Path) -> None:
)
assert conversation_id == turn.conversation_id
def test_pending_proposal_preserves_original_analysis_request(tmp_path: Path) -> None:
store = _store(tmp_path)
turn = store.begin_turn(
session_id="kaks-session",
job_id="job_kaks",
query="执行kaks分析",
client_history=[],
)
ledger = store.finish_turn(
turn,
assistant_message="是否继续?",
interaction_kind="independent",
action="clarify",
proposal={
"proposal_id": "proposal-kaks",
"request": "执行kaks分析\n已确认上下文:subfamily_confirm 已完成。",
"original_request": "执行kaks分析",
"subject": "执行kaks分析",
},
)
assert ledger.user_terms["pending_proposal"]["original_request"] == "执行kaks分析"
+131 -17
View File
@@ -26,7 +26,7 @@ def _materialize_trusted_ppi_mapping(
pairs = workspace / "ortholog" / f"{prefix}.{target}_to_{storage_key}_pairs.txt"
pairs.parent.mkdir(parents=True, exist_ok=True)
pairs.write_text(f"{subject_id}\t{query_id}\t91\n", encoding="utf-8")
scope_id = build_scope_id(family, [target], [storage_key], [], "target_plus_reference")
scope_id = build_scope_id(family, [target], [], [], "target_only")
identity = build_ortholog_run_identity(
query_species=target, reference_taxon_id=taxon_id,
reference_storage_key=storage_key, query_fasta=query,
@@ -35,14 +35,15 @@ def _materialize_trusted_ppi_mapping(
)
materialize_ortholog_final_outputs(
final_dir=final_dir, prefix=prefix,
dependency_owner="ppi",
run_records=[OrthologRunRecord(target, storage_key, query, reference, pairs)],
job_id=job.job_id, workspace=workspace, reference_species=storage_key,
reference_taxon_id=taxon_id, reference_storage_key=storage_key,
ortholog_identity=identity,
scope_fields={
"scope_id": scope_id, "scope_mode": "target_plus_reference",
"target_species": [target], "reference_species": [storage_key],
"selected_species": [target, storage_key], "family": family,
"scope_id": scope_id, "scope_mode": "target_only",
"target_species": [target], "reference_species": [],
"selected_species": [target], "family": family,
},
)
@@ -125,6 +126,49 @@ def test_reference_bundle_id_map_preserves_loc_and_numeric_geneid(tmp_path):
assert row["ncbi_gene_id"] == "110806180"
def test_reference_bundle_id_map_reads_ensembl_gene_token_for_isoforms(tmp_path):
from gfa_core.id_resolution import ReferenceAliasIndex
pep_path = tmp_path / "At.pep.fasta"
cds_path = tmp_path / "At.cds.fasta"
output_path = tmp_path / "At.pep_cds_idmap.tsv"
pep_path.write_text(
">AT1G02065.1 pep chromosome:TAIR10:1:365209:367443:1 "
"gene:AT1G02065 transcript:AT1G02065.1 gene_symbol:SPL8\nMAAA\n"
">AT1G02065.2 pep chromosome:TAIR10:1:365165:367353:1 "
"gene:AT1G02065 transcript:AT1G02065.2 gene_symbol:SPL8\nMBBB\n",
encoding="utf-8",
)
cds_path.write_text(
">AT1G02065.1 cds chromosome:TAIR10:1:365209:367443:1 "
"gene:AT1G02065 transcript:AT1G02065.1 gene_symbol:SPL8\nATGGCG\n"
">AT1G02065.2 cds chromosome:TAIR10:1:365165:367353:1 "
"gene:AT1G02065 transcript:AT1G02065.2 gene_symbol:SPL8\nATGGCC\n",
encoding="utf-8",
)
backend._build_reference_bundle_id_map(
species_code="At",
pep_path=pep_path,
cds_path=cds_path,
output_path=output_path,
)
with output_path.open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle, delimiter="\t"))
assert {row["protein_id"]: row["gene_id"] for row in rows} == {
"AT1G02065.1": "AT1G02065",
"AT1G02065.2": "AT1G02065",
}
candidates = ReferenceAliasIndex.from_tsv(output_path, "At").candidates("AT1G02065")
assert {candidate.value for candidate in candidates} >= {
"AT1G02065",
"AT1G02065.1",
"AT1G02065.2",
}
def test_reference_bundle_download_extracts_ncbi_zip_and_unblocks_ppi_kaks_preflight(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test_ref_bundle",
@@ -601,7 +645,7 @@ def test_ppi_species_resolution_selected_near_string_species_still_needs_referen
assert resolution["reference_data_source"] == ""
def test_ppi_species_resolution_selected_near_string_species_with_pep_needs_ortholog(tmp_path):
def test_ppi_species_resolution_selected_near_string_species_with_complete_bundle_needs_ortholog(tmp_path):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -615,6 +659,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_pep_needs_orth
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
(input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATG\n", encoding="utf-8")
resolution = backend.resolve_ppi_species_resolution(
job=job,
@@ -692,7 +737,6 @@ def test_ppi_kaks_dependency_preflight_requires_reference_cds_before_ortholog(tm
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
resolution = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
@@ -790,6 +834,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_mapping_is_rea
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
(input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_ppi_mapping(
job, prefix="Ft.SPL", target="Ft", family="SPL",
storage_key="soe", taxon_id="3562", query_id="FtGene1", subject_id="SoeGene1",
@@ -812,7 +857,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_mapping_is_rea
assert resolution["status"] == "ready"
assert resolution["selected_string_species"] == "soe"
assert resolution["selected_taxon_id"] == "3562"
assert resolution["ortholog_mapping_source"].endswith("Ft.SPL.orthologs.txt")
assert resolution["ortholog_mapping_source"].endswith("Ft.SPL.ppi.orthologs.txt")
assert resolution["decision"] == "ready_reference_mapping"
@@ -830,6 +875,7 @@ def test_ppi_species_resolution_can_ignore_existing_mapping_for_pre_ortholog_che
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
(input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_ppi_mapping(
job, prefix="Ft.SPL", target="Ft", family="SPL",
storage_key="soe", taxon_id="3562", query_id="FtGene1", subject_id="SoeGene1",
@@ -864,7 +910,7 @@ def test_ppi_species_resolution_can_ignore_existing_mapping_for_pre_ortholog_che
)
assert ready["status"] == "ready"
assert ready["ortholog_mapping_source"].endswith("Ft.SPL.orthologs.txt")
assert ready["ortholog_mapping_source"].endswith("Ft.SPL.ppi.orthologs.txt")
assert pre_ortholog["status"] == "needs_ortholog"
assert pre_ortholog["ortholog_mapping_source"] == ""
assert pre_ortholog["selected_string_species"] == "soe"
@@ -970,7 +1016,7 @@ def test_kaks_preflight_recovers_non_ath_reference_and_prioritizes_missing_cds(t
assert resolution["reference_bundle_status"]["missing_required"] == ["cds"]
def test_ppi_species_resolution_reports_needs_ortholog_when_reference_pep_exists(tmp_path):
def test_ppi_species_resolution_requires_cds_when_only_reference_pep_exists(tmp_path):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -999,13 +1045,14 @@ def test_ppi_species_resolution_reports_needs_ortholog_when_reference_pep_exists
dependency_owner="ppi",
)
assert resolution["status"] == "needs_ortholog"
assert resolution["issue_code"] == "ppi_requires_ortholog_mapping"
assert resolution["status"] == "needs_reference_data"
assert resolution["issue_code"] == "ppi_reference_data_missing"
assert resolution["reference_bundle_status"]["missing_required"] == ["cds"]
assert resolution["reference_data_source"].endswith("soe/soe.pep.fasta")
assert resolution["selected_string_species"] == "soe"
def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
def test_ppi_reference_bundle_download_requests_pep_and_cds(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -1039,7 +1086,10 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
monkeypatch.setattr(
backend,
"_extract_ncbi_reference_bundle_zip",
lambda **kwargs: {"pep": str(tmp_path / "input" / "soe" / "soe.pep.fasta")},
lambda **kwargs: {
"pep": str(tmp_path / "input" / "soe" / "soe.pep.fasta"),
"cds": str(tmp_path / "input" / "soe" / "soe.cds.fasta"),
},
)
monkeypatch.setattr(
backend,
@@ -1047,8 +1097,8 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
lambda **kwargs: {
"id_map_path": str(tmp_path / "input" / "soe" / "soe.pep_cds_idmap.tsv"),
"pep_records": 1,
"cds_records": 0,
"pep_cds_overlap": 0,
"cds_records": 1,
"pep_cds_overlap": 1,
},
)
@@ -1060,10 +1110,10 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
requested_stages=["ppi"],
)
assert captured["file_types"] == ["pep"]
assert captured["file_types"] == ["pep", "cds"]
assert result["status"] == "success"
assert result["missing_required"] == []
assert result["requested_file_types"] == ["pep"]
assert result["requested_file_types"] == ["pep", "cds"]
def test_ppi_execution_scope_canonicalizes_full_species_names_before_mapping_filter(tmp_path, monkeypatch):
@@ -1323,6 +1373,70 @@ def test_ppi_projected_soe_rejects_missing_id_map(tmp_path, monkeypatch):
assert message == f"❌ PPI projected analysis requires species-matched ID map: {expected}"
def test_ppi_projected_builds_missing_id_map_from_species_matched_pep_and_cds(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
workspace_dir=str(tmp_path / "workspace"),
logs_dir=str(tmp_path / "workspace" / "logs"),
created_at="",
)
input_dir = tmp_path / "input"
workspace = tmp_path / "workspace"
final_dir = workspace / "final"
logs_dir = workspace / "logs"
soe_dir = input_dir / "soe"
soe_dir.mkdir(parents=True)
final_dir.mkdir(parents=True)
logs_dir.mkdir(parents=True)
(soe_dir / "soe.pep.fasta").write_text(">SoeGene1 [gene=SoeGene1]\nMAAA\n", encoding="utf-8")
(soe_dir / "soe.cds.fasta").write_text(">SoeGene1 [gene=SoeGene1]\nATGGCT\n", encoding="utf-8")
(final_dir / "Os.CRR.final_members.id").write_text("Os01t0810800-01\n", encoding="utf-8")
mapping_file = final_dir / "Os.CRR.ppi.orthologs.txt"
mapping_file.write_text("Os01t0810800-01\tSoeGene1\t90\tOs\tsoe\n", encoding="utf-8")
scope_file = final_dir / "Os.CRR.CRR.target-Os.target_only.scope.json"
scope_file.write_text(json.dumps({
"family": "CRR",
"prefix": "Os.CRR",
"scope_id": "CRR.target-Os.target_only",
"scope_mode": "target_only",
"target_species": ["Os"],
"reference_species": [],
"selected_species": ["Os"],
}), encoding="utf-8")
captured = {}
def fake_run_cmd_logged(cmd, log_path, cwd=None):
captured["cmd"] = cmd
_write_fake_ppi_outputs(workspace / "ppi")
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda *args, **kwargs: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run_cmd_logged)
monkeypatch.setattr(backend, "download_string_network_image", lambda *args, **kwargs: None)
message, manifest = backend.run_stage(
"ppi",
job,
"Os.CRR",
ppi_species="auto",
ppi_reference_storage_key="soe",
ppi_reference_taxon_id="3562",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
ppi_score=400,
scope_file=str(scope_file),
analysis_route="single_species",
)
expected = soe_dir / "soe.pep_cds_idmap.tsv"
assert "✅ PPI analysis completed" in message
assert manifest and manifest.exists()
assert expected.is_file()
assert captured["cmd"][captured["cmd"].index("--id-map") + 1] == str(expected)
assert captured["cmd"][captured["cmd"].index("--normalization-species-label") + 1] == "soe"
def test_ppi_explicit_absolute_species_rejected_before_outside_id_map_lookup(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
@@ -0,0 +1,54 @@
from gfa_core.mas.agents.planning_v2 import PlanningAgentV2
def test_ppi_species_does_not_change_unrelated_stage_biological_scope() -> None:
planner = PlanningAgentV2.__new__(PlanningAgentV2)
stages = planner._generate_default_stages(
["motif", "ppi"],
"Ft.SPL",
intent_params={
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"scopeMode": "target_only",
"analysisRoute": "single_species",
"ppiSpecies": "ath",
},
)
stage_by_id = {stage["id"]: stage for stage in stages}
motif_params = stage_by_id["motif"]["params"]
ppi_params = stage_by_id["ppi"]["params"]
assert motif_params["targetSpecies"] == ["Ft"]
assert motif_params["scopeMode"] == "target_only"
assert motif_params["analysisRoute"] == "single_species"
assert not motif_params.get("referenceSpecies")
assert ppi_params["ppiSpecies"] == "ath"
def test_ppi_plan_is_target_only_while_kaks_keeps_project_reference() -> None:
planner = PlanningAgentV2.__new__(PlanningAgentV2)
stages = planner._generate_default_stages(
["ppi", "kaks"],
"Ft.SPL",
intent_params={
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"scopeMode": "target_plus_reference",
"analysisRoute": "multi_species",
},
)
stage_by_id = {stage["id"]: stage for stage in stages}
ppi_params = stage_by_id["ppi"]["params"]
kaks_params = stage_by_id["kaks"]["params"]
assert ppi_params["targetSpecies"] == ["Ft"]
assert ppi_params["referenceSpecies"] == []
assert ppi_params["scopeMode"] == "target_only"
assert ppi_params["analysisRoute"] == "single_species"
assert kaks_params["referenceSpecies"] == ["At"]
assert kaks_params["scopeMode"] == "target_plus_reference"
+182 -17
View File
@@ -1,5 +1,7 @@
from pathlib import Path
from apps.web.vue3 import main as web_main
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -37,12 +39,12 @@ def test_ppi_alphafold_links_remain_in_file_list_and_use_table_preview():
assert "tablePreviewFilteredRows" in html
def test_natural_language_ppi_reset_intent_opens_reset_dialog():
def test_ppi_result_action_opens_reset_dialog():
html = _index_html()
assert "isPpiResetIntent" in html
assert "raw.includes('基因id')" in html
assert "openPpiResetDialog()" in html
assert "PPI Reset 基因列表编辑弹窗" in html
assert '@click="openPpiResetDialog(stage)"' in html
assert 'title="PPI Reset 输入基因列表"' in html
def test_ppi_reset_uses_current_stage_prefix_before_default_execution_prefix():
@@ -91,7 +93,28 @@ def test_ppi_execute_path_runs_species_resolution_preflight_before_execute():
assert "Spinacia oleracea" not in html
assert "const shouldRunPpiKaksPreflight =" in html
assert "const currentIsDependencyOwner = ppiKaksDependencyStages.has(currentStageId) || ppiKaksDependencyStages.has(requestBody.stage)" in html
assert "PPI/KaKs 需要先处理参考物种/Ortholog" in html
assert "id: 'ppi_kaks_reference_status'" in html
assert "PPI/KaKs reference data and Ortholog mapping required" in html
def test_ppi_kaks_temporary_breaker_is_disabled_and_stage_failure_keeps_scheduler_alive():
html = _index_html()
assert "const ppiKaksTemporaryCircuitBreakerEnabled = false;" in html
assert "await ensureOrthologProducerForPpiKaks();" in html
assert "executeStageWithConfirmation(stage" in html
assert ".finally(() => scheduleCheck(0));" in html
catch_start = html.index(" } catch (err) {", html.index("const executeStageWithConfirmation = async"))
catch_end = html.index(" } finally {", catch_start)
ordinary_failure = html[catch_start:catch_end]
assert "stageExecutionStatus.value[stageId] = 'failed';" in ordinary_failure
assert "console.error('[ERROR] Stage failed:', stageId, err);" in ordinary_failure
scheduler_start = html.index("const executeStagesWithDependencies = async")
scheduler_end = html.index("const NCBI_SPECIES_DEFAULTS", scheduler_start)
scheduler = html[scheduler_start:scheduler_end]
assert "return deps.some(dep => stageExecutionStatus.value[dep] === 'failed');" in scheduler
assert "stageExecutionStatus.value[stageId] = 'failed';" in scheduler
def test_ortholog_scope_dialog_and_ppi_queue_preflight_are_wired():
@@ -103,16 +126,20 @@ def test_ortholog_scope_dialog_and_ppi_queue_preflight_are_wired():
assert "ppiHasNearReferenceBeforeArabidopsis" in html
def test_ppi_kaks_dependency_owners_are_preflighted_independently():
def test_ppi_is_preflighted_first_and_kaks_is_deferred_to_its_confirmation():
html = _index_html()
assert "const ppiKaksDependencyStages = new Set(['ppi', 'kaks'])" in html
assert "const ensurePpiKaksDependencyOwner = async (ownerStageId) =>" in html
assert "const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>" in html
assert "const ensureOrthologProducerForPpiKaks = async" in html
assert "for (const ownerStageId of requestedStages)" in html
assert "await ensurePpiKaksDependencyOwner(ownerStageId)" in html
assert "const requestedStages = [ownerStageId];" in html
assert "autoInsertedFor: requestedStages" in html
assert "referencePolicySource: 'ppi_kaks_dependency_preflight'" in html
assert ".filter(stageId => stageId === 'ppi')" in html
assert "const prepareKaksSelectedReference = async" in html
assert "await ensurePpiKaksDependencyOwner('kaks', stageParams)" in html
assert "referencePolicySource: 'ppi_owner_ortholog_producer'" in html
assert "referencePolicySource: 'kaks_owner_ortholog_producer'" in html
assert "await ensureOrthologProducerForPpiKaks()" in html
@@ -125,18 +152,40 @@ def test_ppi_preflight_uses_current_stage_and_releases_direct_target_from_orthol
assert "releaseDirectTargetPpiFromOrtholog(preflight.resolution);" in html
def test_ppi_needing_mapping_attaches_to_an_existing_ortholog_stage():
def test_ppi_needing_mapping_uses_hidden_internal_ortholog_producer():
html = _index_html()
start = html.index("const ensureOrthologStageForPpiKaks =")
end = html.index("const ensurePpiKaksDependencyOwner =", start)
producer = html[start:end]
attach = "attachOrthologDependencyToOwners(requestedStages);"
existing = "if (stageIds.includes('ortholog'))"
assert attach in producer
assert producer.index(attach) < producer.index(existing)
assert "rememberPpiKaksInternalOrthologProducer('ppi'" in producer
assert "attachOrthologDependencyToOwners(requestedStages);" not in producer
assert "nextStages.splice" not in producer
assert "batchExecuteStages.value = nextStages" not in producer
def test_ppi_static_dependencies_exclude_ortholog_while_kaks_keeps_it():
def test_ppi_internal_ortholog_executes_without_visible_stage_scheduler():
html = _index_html()
start = html.index("const executePpiOwnedOrthologProducer =")
end = html.index("const waitForPpiProducerDependency =", start)
executor = html[start:end]
assert "executeStageDirect('ortholog', producerParams)" in executor
assert "executeStagesWithDependencies([producerStage]" not in executor
assert "stageExecutionStatus.value.ortholog" not in executor
def test_kaks_confirmation_chooses_ppi_or_project_reference_and_mapping_owner():
html = _index_html()
assert 'v-model="confirmParams.kaksReferenceSource"' in html
assert 'label="ppi"' in html
assert 'label="project"' in html
assert "orthologMappingOwner: usePpi ? 'ppi' : 'kaks'" in html
assert "requestBody.orthologMappingOwner = stageParams.orthologMappingOwner" in html
def test_ppi_and_kaks_static_dependencies_hide_internal_ortholog():
html = _index_html()
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
@@ -144,11 +193,127 @@ def test_ppi_static_dependencies_exclude_ortholog_while_kaks_keeps_it():
assert "'ppi': ['identify']," in html
assert '"ppi": ["identify"],' in catalog
assert '"ppi": ["identify"],' in main_py
assert "'kaks': ['identify', 'ortholog']," in html
assert '"kaks": ["identify", "ortholog"],' in catalog
assert '"kaks": ["identify", "ortholog"],' in main_py
assert "'kaks': ['identify']," in html
assert '"kaks": ["identify"],' in catalog
assert '"kaks": ["identify"],' in main_py
assert '"ortholog": ["identify"],' in catalog
def test_normal_full_gene_family_plan_excludes_ortholog_and_orders_ppi_before_kaks():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
workflow_start = main_py.index("FULL_GENE_FAMILY_WORKFLOW_STAGES = [")
workflow_end = main_py.index("]", workflow_start)
workflow_block = main_py[workflow_start:workflow_end]
assert '"ortholog"' not in workflow_block
assert workflow_block.index('"ppi"') < workflow_block.index('"kaks"')
context_start = main_py.index("def _downstream_gene_family_stages_for_context")
context_end = main_py.index("def _resolve_contextual_gene_family_request", context_start)
context_block = main_py[context_start:context_end]
full_workflow_start = context_block.index("if _looks_like_gene_family_workflow_query(text):")
full_workflow_end = context_block.index("return stages", full_workflow_start)
full_workflow_block = context_block[full_workflow_start:full_workflow_end]
assert '"ortholog"' not in full_workflow_block
assert full_workflow_block.index('"ppi"') < full_workflow_block.index('"kaks"')
normalize_start = main_py.index("def _normalize_full_gene_family_workflow_plan")
normalize_end = main_py.index("def _", normalize_start + 4)
normalize_block = main_py[normalize_start:normalize_end]
assert 'if sid == "ortholog":' in normalize_block
def test_confirmed_full_workflow_recovers_original_user_request_from_history():
history = [
{"role": "user", "content": "执行基因家族分析"},
{"role": "assistant", "content": "是否继续?"},
{"role": "user", "content": "继续"},
]
assert web_main._full_gene_family_workflow_query_text("继续", history) == "执行基因家族分析"
assert web_main._full_gene_family_workflow_query_text("执行直系同源基因鉴定", []) == ""
def test_confirmed_full_workflow_removes_visible_ortholog_and_stale_kaks_dependency():
stages = [
"phylogeny", "motif", "domain", "structure", "combo", "promoter",
"synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
]
details = [web_main._stage_detail_defaults(stage) for stage in stages]
next(item for item in details if item["id"] == "kaks")["dependencies"] = ["identify", "ortholog"]
normalized_stages, normalized_details = web_main._normalize_full_gene_family_workflow_plan(
message="执行基因家族分析",
stage_ids=stages,
stages_detail=details,
plan_params={"targetSpecies": ["Ft"], "referenceSpecies": ["At"], "geneFamily": "SPL"},
plan_prefix="Ft.SPL",
)
assert "ortholog" not in normalized_stages
assert normalized_stages.index("ppi") < normalized_stages.index("kaks")
detail_by_id = {item["id"]: item for item in normalized_details}
assert detail_by_id["ppi"]["dependencies"] == ["identify"]
assert detail_by_id["kaks"]["dependencies"] == ["identify"]
assert detail_by_id["ppi"]["params"]["targetSpecies"] == ["Ft"]
assert detail_by_id["ppi"]["params"]["referenceSpecies"] == []
assert detail_by_id["ppi"]["params"]["scopeMode"] == "target_only"
assert detail_by_id["ppi"]["params"]["analysisRoute"] == "single_species"
assert detail_by_id["kaks"]["params"]["referenceSpecies"] == ["At"]
def test_deterministic_context_exit_applies_confirmed_full_workflow_contract():
request = web_main.AgentPlanRequest(
message="继续",
prefix="Ft.SPL",
history=[
{"role": "user", "content": "执行基因家族分析"},
{"role": "assistant", "content": "是否继续?"},
{"role": "user", "content": "继续"},
],
intent={"params": {"referenceSpecies": ["At"]}},
)
contextual_request = {
"family": "SPL",
"prefix": "Ft.SPL",
"stages": [
"phylogeny", "motif", "domain", "structure", "combo", "promoter",
"synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
],
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"comparativeSpecies": [],
"scopeMode": "target_plus_reference",
"requiresConfirmedSubfamily": True,
"skipSubfamilyLabels": False,
"subfamilyState": {"by_species": {"Ft": {"status": "confirmed"}}},
"contextGate": {"status": "ready", "issues": []},
}
response = web_main._build_contextual_gene_family_plan_response(
request,
"test_confirmed_full_workflow",
contextual_request,
)
assert response["mode"] == "deterministic_context"
assert "ortholog" not in response["plan"]["stages"]
assert response["plan"]["stages"].index("ppi") < response["plan"]["stages"].index("kaks")
detail_by_id = {item["id"]: item for item in response["plan"]["stages_detail"]}
assert detail_by_id["kaks"]["dependencies"] == ["identify"]
def test_kaks_request_carries_selected_ortholog_mapping_owner_to_backend():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
assert 'orthologMappingOwner: str = ""' in main_py
assert '"orthologMappingOwner": getattr(req, \'orthologMappingOwner\', \'\')' in main_py
assert (
'ortholog_mapping_owner=params.get("orthologMappingOwner") '
'or params.get("ortholog_mapping_owner") or ""'
) in main_py
def test_ppi_plan_generation_never_inserts_static_ortholog_dependency():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
@@ -180,7 +345,7 @@ def test_ppi_preflight_recovered_reference_is_written_to_ppi_owner_only():
def test_ready_preflight_propagates_recovered_reference_before_early_return():
html = _index_html()
start = html.index("const ensurePpiKaksDependencyOwner = async (ownerStageId) =>")
start = html.index("const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>")
end = html.index("const ensureOrthologProducerForPpiKaks = async () =>", start)
producer = html[start:end]
propagation = "applyResolvedPpiReferenceToBatch(preflight.resolution, requestedStages, requestBody);"
@@ -47,6 +47,9 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
workbook.save(input_dir / "config.xlsx")
reference_pep = storage_dir / "string_taxon_4555.pep.fasta"
reference_pep.write_text(">Seita.1\nMA\n", encoding="utf-8")
(storage_dir / "string_taxon_4555.cds.fasta").write_text(
">Seita.1\nATGGCT\n", encoding="utf-8"
)
query_pep = final_dir / "Sv.HKL.final_members.pep.fa"
query_pep.write_text(">SvGene1\nMA\n", encoding="utf-8")
scope_file = final_dir / "Sv.HKL.scope.json"
@@ -78,9 +81,17 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(reference_pep)
assert (final_dir / "Sv.HKL.ppi.orthologs.txt").exists()
assert not (final_dir / "Sv.HKL.orthologs.txt").exists()
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
assert ".ppi." in Path(manifest_path).name
assert manifest["parameters"]["dependency_owner"] == "ppi"
assert manifest["parameters"]["reference_species"] == "string_taxon_4555"
assert manifest["parameters"]["reference_taxon_id"] == "4555"
assert manifest["scope_id"] == "HKL.target-Sv.target_only"
assert manifest["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
assert manifest["stage_context"]["selected_species"] == ["Sv"]
resolution = backend.resolve_ppi_species_resolution(
job=job, prefix="Sv.HKL", scope_file=str(scope_file),
target_species=["Sv"], family="HKL",
@@ -90,7 +101,6 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
)
assert resolution["status"] == "ready"
assert resolution["mapping_reference_species"] == "string_taxon_4555"
reference_pep.write_text(">Seita.1\nMAGA\n", encoding="utf-8")
stale_reference = backend.resolve_ppi_species_resolution(
job=job, prefix="Sv.HKL", target_species=["Sv"], family="HKL",
@@ -111,6 +121,145 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
assert stale_target["status"] == "needs_ortholog"
def test_ppi_owner_effective_scope_ignores_project_and_technical_references() -> None:
assert backend._effective_ppi_owner_scope_id(
family="SPL",
selected_targets=["Ft"],
scope_data={
"scope_id": "SPL.target-Ft.ref-At.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": ["At"],
"comparative_species": ["Os"],
},
reference_storage_key="string_taxon_3562",
) == "SPL.target-Ft.target_only"
def test_ppi_owned_ortholog_uses_whole_reference_pep_when_family_pep_also_exists(
tmp_path: Path, monkeypatch
) -> None:
job = _job(tmp_path)
input_dir = Path(job.input_dir)
final_dir = Path(job.workspace_dir) / "final"
reference_dir = input_dir / "At"
reference_dir.mkdir()
whole_reference_pep = reference_dir / "At.pep.fasta"
whole_reference_pep.write_text(
">AT5G16970.1 gene:AT5G16970\nMAGA\n", encoding="utf-8"
)
(reference_dir / "At.cds.fasta").write_text(
">AT5G16970.1 gene:AT5G16970\nATGGCTGGCGCT\n", encoding="utf-8"
)
family_reference_pep = input_dir / "At.SPL.pep.fasta"
family_reference_pep.write_text(">AT5G16970.1\nMA\n", encoding="utf-8")
query_pep = final_dir / "Ft.SPL.final_members.pep.fa"
query_pep.write_text(">FtSPL1\nMA\n", encoding="utf-8")
scope_file = final_dir / "Ft.SPL.scope.json"
scope_file.write_text(json.dumps({
"family": "SPL",
"scope_id": "SPL.target-Ft.target_only",
"scope_mode": "target_only",
"target_species": ["Ft"],
"reference_species": [],
"comparative_species": [],
"selected_species": ["Ft"],
}), encoding="utf-8")
launched: list[list[str]] = []
def fake_run(cmd, *_args, **_kwargs):
launched.append(list(cmd))
run_prefix = cmd[cmd.index("--prefix") + 1]
pairs = Path(job.workspace_dir) / "ortholog" / f"{run_prefix}_pairs.txt"
pairs.write_text("AT5G16970.1\tFtSPL1\t91\n", encoding="utf-8")
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
message, manifest_path = backend.run_stage(
"ortholog",
job,
"Ft.SPL",
scope_file=str(scope_file),
ppi_reference_taxon_id="3702",
ppi_reference_storage_key="At",
ppi_reference_selection_mode="model_fallback",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
)
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(whole_reference_pep)
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
assert manifest["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
resolution = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
scope_file=str(scope_file),
target_species=["Ft"],
family="SPL",
ppi_reference_taxon_id="3702",
ppi_reference_storage_key="At",
ppi_reference_selection_mode="model_fallback",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
requested_stages=["ppi"],
)
assert resolution["status"] == "ready", resolution
def test_independent_ortholog_keeps_family_reference_pep_priority(
tmp_path: Path, monkeypatch
) -> None:
job = _job(tmp_path)
input_dir = Path(job.input_dir)
final_dir = Path(job.workspace_dir) / "final"
reference_dir = input_dir / "At"
reference_dir.mkdir()
whole_reference_pep = reference_dir / "At.pep.fasta"
whole_reference_pep.write_text(">AT5G16970.1\nMAGA\n", encoding="utf-8")
family_reference_pep = input_dir / "At.SPL.pep.fasta"
family_reference_pep.write_text(">AT5G16970.1\nMA\n", encoding="utf-8")
(final_dir / "Ft.SPL.final_members.pep.fa").write_text(
">FtSPL1\nMA\n", encoding="utf-8"
)
scope_file = final_dir / "Ft.SPL.scope.json"
scope_file.write_text(json.dumps({
"family": "SPL",
"scope_id": "SPL.target-Ft.ref-At.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": ["At"],
"comparative_species": [],
"selected_species": ["Ft", "At"],
}), encoding="utf-8")
launched: list[list[str]] = []
def fake_run(cmd, *_args, **_kwargs):
launched.append(list(cmd))
run_prefix = cmd[cmd.index("--prefix") + 1]
pairs = Path(job.workspace_dir) / "ortholog" / f"{run_prefix}_pairs.txt"
pairs.write_text("AT5G16970.1\tFtSPL1\t91\n", encoding="utf-8")
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
message, _manifest_path = backend.run_stage(
"ortholog",
job,
"Ft.SPL",
scope_file=str(scope_file),
)
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(family_reference_pep)
assert launched[0][launched[0].index("--subject") + 1] != str(whole_reference_pep)
@pytest.mark.parametrize("scope_id_input", ["present", "empty", "missing"])
def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
tmp_path: Path, monkeypatch, scope_id_input: str
@@ -128,6 +277,9 @@ def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
workbook.save(input_dir / "config.xlsx")
reference_pep = storage_dir / "string_taxon_4555.pep.fasta"
reference_pep.write_text(">Seita.1\nMA\n", encoding="utf-8")
(storage_dir / "string_taxon_4555.cds.fasta").write_text(
">Seita.1\nATGGCT\n", encoding="utf-8"
)
query_pep = final_dir / "Sv.HKL.final_members.pep.fa"
query_pep.write_text(">SvGene1\nMA\n", encoding="utf-8")
scope_id = "HKL.target-Sv.target_only"
@@ -170,7 +322,7 @@ def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
assert manifest["stage_context"]["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
mapping = final_dir / "Sv.HKL.orthologs.txt"
mapping = final_dir / "Sv.HKL.ppi.orthologs.txt"
recorded = json.loads(Path(f"{mapping}.identity.json").read_text(encoding="utf-8"))
expected = build_ortholog_run_identity(
query_species="Sv", reference_taxon_id="4555",
@@ -279,3 +431,115 @@ def test_materialized_manifest_persists_owner_and_mapping_identity(tmp_path: Pat
"target_identity", "member_identity", "mapping_identity", "mapping_path",
):
assert manifest["parameters"][key]
def test_ppi_and_kaks_owned_mappings_coexist_without_overwrite(tmp_path: Path) -> None:
final_dir = tmp_path / "final"
query = tmp_path / "Ft.pep.fa"
query.write_text(">Ft1\nMA\n", encoding="utf-8")
def materialize(owner: str, reference_species: str, reference_id: str) -> tuple[Path, Path]:
reference = tmp_path / f"{reference_species}.pep.fasta"
pairs = tmp_path / f"{owner}.pairs.txt"
reference.write_text(f">{reference_id}\nMA\n", encoding="utf-8")
pairs.write_text(f"{reference_id}\tFt1\t90\n", encoding="utf-8")
_, manifest_path = materialize_ortholog_final_outputs(
final_dir=final_dir,
prefix="Ft.SPL",
dependency_owner=owner,
run_records=[OrthologRunRecord("Ft", reference_species, query, reference, pairs)],
job_id="task5b",
workspace=tmp_path,
reference_species=reference_species,
reference_storage_key=reference_species,
scope_fields={
"scope_id": f"SPL.target-Ft.ref-{reference_species}.target_plus_reference",
"target_species": ["Ft"],
"reference_species": [reference_species],
},
)
return final_dir / f"Ft.SPL.{owner}.orthologs.txt", manifest_path
ppi_mapping, ppi_manifest = materialize("ppi", "string_taxon_3562", "Soe1")
ppi_mapping_before = ppi_mapping.read_bytes()
ppi_manifest_before = ppi_manifest.read_bytes()
kaks_mapping, kaks_manifest = materialize("kaks", "At", "AT1G01010")
assert ppi_mapping.exists()
assert kaks_mapping.exists()
assert ppi_mapping != kaks_mapping
assert ppi_mapping.read_bytes() == ppi_mapping_before
assert ppi_manifest.read_bytes() == ppi_manifest_before
assert json.loads(ppi_manifest.read_text(encoding="utf-8"))["parameters"]["dependency_owner"] == "ppi"
assert json.loads(kaks_manifest.read_text(encoding="utf-8"))["parameters"]["dependency_owner"] == "kaks"
def test_owner_specific_mapping_candidates_never_fall_back_to_another_owner(tmp_path: Path) -> None:
final_dir = tmp_path / "final"
final_dir.mkdir()
ppi_candidates = backend.ortholog_mapping_candidates(
final_dir,
"Ft.SPL",
dependency_owner="ppi",
scope_id="scope-v1",
)
assert ppi_candidates == [
final_dir / "Ft.SPL.ppi.scope-v1.orthologs.txt",
final_dir / "Ft.SPL.ppi.scope-v1.ortholog_pairs.txt",
final_dir / "Ft.SPL.ppi.orthologs.txt",
final_dir / "Ft.SPL.ppi.ortholog_pairs.txt",
]
assert all(".kaks." not in path.name for path in ppi_candidates)
assert all(path.name != "Ft.SPL.orthologs.txt" for path in ppi_candidates)
def test_kaks_selected_ppi_owner_consumes_only_ppi_mapping(tmp_path: Path, monkeypatch) -> None:
job = _job(tmp_path)
input_dir = Path(job.input_dir)
final_dir = Path(job.workspace_dir) / "final"
reference = "string_taxon_3562"
scope_file = final_dir / "Ft.SPL.scope.json"
scope_file.write_text(json.dumps({
"family": "SPL",
"scope_id": f"SPL.target-Ft.ref-{reference}.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": [reference],
"selected_species": ["Ft", reference],
}), encoding="utf-8")
(input_dir / "Ft.cds.fasta").write_text(">Ft1\nATGGCT\n", encoding="utf-8")
(input_dir / f"{reference}.cds.fasta").write_text(">PpiGene\nATGGCT\n", encoding="utf-8")
(input_dir / f"{reference}.pep.fasta").write_text(">PpiGene\nMA\n", encoding="utf-8")
(final_dir / "Ft.SPL.final_members.pep.fa").write_text(">Ft1\nMA\n", encoding="utf-8")
(final_dir / "Ft.SPL.ppi.orthologs.txt").write_text(
f"Ft1\tPpiGene\t91\tFt\t{reference}\n", encoding="utf-8"
)
(final_dir / "Ft.SPL.kaks.orthologs.txt").write_text(
f"Ft1\tWrongKaksGene\t88\tFt\t{reference}\n", encoding="utf-8"
)
observed_pairs: list[str] = []
def fake_run(cmd, *_args, **_kwargs):
pair_path = Path(cmd[cmd.index("--pairs") + 1])
observed_pairs.append(pair_path.read_text(encoding="utf-8"))
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
message, manifest_path = backend.run_stage(
"kaks",
job,
"Ft.SPL",
scope_file=str(scope_file),
ortholog_mapping_owner="ppi",
)
assert message.startswith("✅")
assert observed_pairs and "PpiGene" in observed_pairs[0]
assert "WrongKaksGene" not in observed_pairs[0]
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
assert manifest["parameters"]["ortholog_mapping"].endswith("Ft.SPL.ppi.orthologs.txt")
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -65,6 +65,10 @@ def _assert_complete_identity(identity: dict) -> None:
def _prepare_owner_execution_fixture(job: backend.JobInfo) -> Path:
(Path(job.workspace_dir) / "final" / "Sv.HKL.final_members.id").write_text(
"SvGene1\n",
encoding="utf-8",
)
scope = Path(job.input_dir) / "ppi-owner.scope.json"
scope.write_text(
json.dumps(
@@ -79,7 +83,7 @@ def _prepare_owner_execution_fixture(job: backend.JobInfo) -> Path:
),
encoding="utf-8",
)
mapping = Path(job.workspace_dir) / "final" / "Sv.HKL.owner-target-only.orthologs.txt"
mapping = Path(job.workspace_dir) / "final" / "Sv.HKL.ppi.orthologs.txt"
mapping.write_text(
"SvGene1\tSeita.1\t91\tSv\tstring_taxon_4555\n",
encoding="utf-8",
@@ -300,6 +304,7 @@ def test_waiting_preflights_write_decision_records_without_failed_stage_manifest
storage = Path(job.input_dir) / "string_taxon_4555"
storage.mkdir()
(storage / "string_taxon_4555.pep.fasta").write_text(">Seita1\nMA\n", encoding="utf-8")
(storage / "string_taxon_4555.cds.fasta").write_text(">Seita1\nATGGCT\n", encoding="utf-8")
needs_ortholog = _sv_preflight(job, confirmed=True)
assert [confirmation["status"], missing_pep["status"], needs_ortholog["status"]] == [
@@ -316,6 +321,37 @@ def test_waiting_preflights_write_decision_records_without_failed_stage_manifest
assert not (Path(job.workspace_dir) / "final" / "Sv.HKL.ppi.manifest.json").exists()
def test_confirmed_arabidopsis_model_fallback_is_accepted_outside_nearest_candidates(
tmp_path: Path,
) -> None:
job = _job(tmp_path, job_id="ft-arabidopsis-fallback")
storage = Path(job.input_dir) / "At"
storage.mkdir()
(storage / "At.pep.fasta").write_text(">AT1G01010.1\nMA\n", encoding="utf-8")
result = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
target_species=["Ft"],
family="SPL",
target_scientific_name="Fagopyrum tataricum",
ppi_target_tree_taxon_id="62330",
ppi_reference_taxon_id="3702",
ppi_reference_storage_key="At",
ppi_reference_selection_mode="model_fallback",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
ignore_existing_ortholog=True,
requested_stages=["ppi"],
)
assert result["status"] == "needs_ortholog"
assert result["selected_taxon_id"] == "3702"
assert result["reference_species"] == "At"
assert result["ppi_provenance"]["selection_mode"] == "model_fallback"
assert result["ppi_provenance"]["user_confirmation"]["confirmed"] is True
@pytest.mark.parametrize(
("selection_mode", "status", "expected"),
[
@@ -0,0 +1,127 @@
from pathlib import Path
import json
import re
import subprocess
ROOT = Path(__file__).resolve().parents[2]
HTML_PATH = ROOT / "apps/web/vue3/index.html"
def _html() -> str:
return HTML_PATH.read_text(encoding="utf-8")
def _function_block(html: str, start: str, end: str) -> str:
return html.split(start, 1)[1].split(end, 1)[0]
def test_both_reference_messages_use_stage_confirm_dialog_shell():
html = _html()
nearest = _function_block(
html,
"const confirmNearestPpiReferenceCandidate = async",
"const ppiReferenceDecisionMessage =",
)
preflight = _function_block(
html,
"const runPpiSpeciesResolutionPreflight = async",
"const ppiKaksDependencyStages =",
)
assert "id: 'ppi_string_reference_confirmation'" in nearest
assert "showStageConfirmDialog" in nearest
assert "ElMessageBox.confirm" not in nearest
assert "id: 'ppi_kaks_reference_status'" in preflight
assert "showStageConfirmDialog" in preflight
assert "'PPI/KaKs reference data and Ortholog mapping required'" not in preflight
def test_stage_confirm_shell_renders_grouped_fields_and_ordered_candidates():
html = _html()
assert "confirmParams.stage === 'ppi_string_reference_confirmation'" in html
assert "confirmParams.stage === 'ppi_kaks_reference_status'" in html
assert html.count('<ol class="ppi-ref-candidate-list">') >= 2
assert 'v-for="(candidate, index) in confirmParams.ppiKaksReferenceCandidates"' in html
for label in (
"Target species",
"Requested stages",
"Required data",
"Missing data",
"Current reference species",
"Ortholog mapping",
"STRING TaxID",
"LCA",
"Topology distance",
):
assert label in html
def test_special_dialogs_preserve_manual_actions_without_batch_controls():
html = _html()
assert "Confirm top candidate" in html
assert "Cancel PPI" in html
assert "Reference status acknowledged" in html
assert "stageConfirmCountdown > 0" in html
def test_candidate_normalizer_supports_topology_and_recommendation_shapes():
html = _html()
match = re.search(
r"const normalizePpiReferenceCandidates = \(source = \{\}, fallbackVersion = ''\) => \{.*?\n\s*\};",
html,
re.S,
)
assert match, "normalizePpiReferenceCandidates helper is missing"
function_source = match.group(0).replace(
"const normalizePpiReferenceCandidates =",
"globalThis.normalizePpiReferenceCandidates =",
1,
)
script = f"""
{function_source}
const topology = normalizePpiReferenceCandidates({{
candidates: [{{
selected_string_name: 'Spinacia oleracea',
selected_string_taxon_id: 3562,
lca_name: 'Caryophyllales',
topology_distance: 4,
string_version: '12.0'
}}]
}});
const recommendation = normalizePpiReferenceCandidates({{
diagnostics: {{ reference_recommendations: [{{
species_name: 'Arabidopsis thaliana',
species_code: 'ath',
taxon_id: 3702,
relationship: 'model_fallback',
source: 'project_reference'
}}] }}
}}, '12.0');
process.stdout.write(JSON.stringify({{ topology, recommendation }}));
"""
completed = subprocess.run(
["node", "-e", script],
cwd=ROOT,
text=True,
capture_output=True,
check=True,
)
data = json.loads(completed.stdout)
assert data["topology"][0] == {
"name": "Spinacia oleracea",
"code": "",
"taxon": "3562",
"lca": "Caryophyllales",
"distance": "4",
"version": "12.0",
"relationship": "",
"source": "",
}
assert data["recommendation"][0]["name"] == "Arabidopsis thaliana"
assert data["recommendation"][0]["taxon"] == "3702"
assert data["recommendation"][0]["relationship"] == "model_fallback"
assert data["recommendation"][0]["source"] == "project_reference"
@@ -58,7 +58,7 @@ def _materialize_trusted_mapping(
pairs = workspace / "ortholog" / f"{prefix}.{target}_to_{storage_key}_pairs.txt"
pairs.parent.mkdir(parents=True, exist_ok=True)
pairs.write_text(f"{subject_id}\t{query_id}\t91\n", encoding="utf-8")
scope_id = build_scope_id(family, [target], [storage_key], [], "target_plus_reference")
scope_id = build_scope_id(family, [target], [], [], "target_only")
identity = build_ortholog_run_identity(
query_species=target, reference_taxon_id=taxon_id,
reference_storage_key=storage_key, query_fasta=query,
@@ -67,14 +67,15 @@ def _materialize_trusted_mapping(
)
materialize_ortholog_final_outputs(
final_dir=final_dir, prefix=prefix,
dependency_owner="ppi",
run_records=[OrthologRunRecord(target, storage_key, query, reference, pairs)],
job_id=job.job_id, workspace=workspace, reference_species=storage_key,
reference_taxon_id=taxon_id, reference_storage_key=storage_key,
ortholog_identity=identity,
scope_fields={
"scope_id": scope_id, "scope_mode": "target_plus_reference",
"target_species": [target], "reference_species": [storage_key],
"selected_species": [target, storage_key], "family": family,
"scope_id": scope_id, "scope_mode": "target_only",
"target_species": [target], "reference_species": [],
"comparative_species": [], "selected_species": [target], "family": family,
},
)
@@ -168,8 +169,8 @@ def test_confirmed_nearest_without_pep_requests_only_reference_data(tmp_path: Pa
assert result["status"] == "needs_reference_data"
assert result["issue_code"] == "ppi_reference_data_missing"
assert result["reference_bundle_status"]["required_files"] == ["pep"]
assert result["reference_bundle_status"]["missing_required"] == ["pep"]
assert result["reference_bundle_status"]["required_files"] == ["pep", "cds"]
assert result["reference_bundle_status"]["missing_required"] == ["pep", "cds"]
assert result["required_next_stage"] == ""
assert result["selected_taxon_id"] == "4555"
@@ -179,6 +180,7 @@ def test_confirmed_nearest_with_pep_needs_ortholog(tmp_path: Path) -> None:
reference_dir = Path(job.input_dir) / "string_taxon_4555"
reference_dir.mkdir()
(reference_dir / "string_taxon_4555.pep.fasta").write_text(">Seita.1\nMA\n", encoding="utf-8")
(reference_dir / "string_taxon_4555.cds.fasta").write_text(">Seita.1\nATGGCT\n", encoding="utf-8")
result = _resolve_sv(
job,
@@ -200,6 +202,7 @@ def test_confirmed_nearest_with_matching_mapping_is_ready(tmp_path: Path) -> Non
reference_dir = Path(job.input_dir) / "string_taxon_4555"
reference_dir.mkdir()
(reference_dir / "string_taxon_4555.pep.fasta").write_text(">Seita.1\nMA\n", encoding="utf-8")
(reference_dir / "string_taxon_4555.cds.fasta").write_text(">Seita.1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_mapping(
job, prefix="Sv.HKL", target="Sv", family="HKL",
storage_key="string_taxon_4555", taxon_id="4555",
@@ -0,0 +1,163 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
HTML_PATH = Path("apps/web/vue3/index.html")
def _source_block(source: str, start: str, end: str) -> str:
return source[source.index(start) : source.index(end)]
def _run_node(script: str) -> dict:
completed = subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout.strip())
def _progress_state_script(body: str) -> str:
html = HTML_PATH.read_text(encoding="utf-8")
updater = _source_block(
html,
"const updateReferenceBundleDownloadProgress =",
"const pollReferenceBundleDownloadTask =",
)
return f"""
const referenceBundleDownloadState = {{value: {{
visible: false, taskId: '', speciesLabel: '', message: '',
percentage: 0, indeterminate: true, status: '',
bytesDownloaded: 0, totalBytes: 0
}}}};
{updater}
{body}
"""
def test_unknown_total_keeps_indeterminate_progress_and_reports_downloaded_bytes() -> None:
result = _run_node(
_progress_state_script(
"""
updateReferenceBundleDownloadProgress({
id: 'task-download', status: 'running',
progress: {bytes_downloaded: 8388608, total_bytes: 0, indeterminate: true}
}, {species_name: 'Spinacia oleracea'});
console.log(JSON.stringify(referenceBundleDownloadState.value));
"""
)
)
assert result["visible"] is True
assert result["speciesLabel"] == "Spinacia oleracea"
assert result["bytesDownloaded"] == 8388608
assert result["totalBytes"] == 0
assert result["indeterminate"] is True
def test_failure_without_new_progress_preserves_last_downloaded_bytes() -> None:
result = _run_node(
_progress_state_script(
"""
updateReferenceBundleDownloadProgress({
id: 'task-download', status: 'running',
progress: {bytes_downloaded: 8388608, total_bytes: 0, indeterminate: true}
}, {species_name: 'Spinacia oleracea'});
updateReferenceBundleDownloadProgress({
status: 'failed', message: 'IncompleteRead(726589 bytes read)'
}, {species_name: 'Spinacia oleracea'});
console.log(JSON.stringify(referenceBundleDownloadState.value));
"""
)
)
assert result["status"] == "failed"
assert result["message"] == "IncompleteRead(726589 bytes read)"
assert result["bytesDownloaded"] == 8388608
assert result["totalBytes"] == 0
def test_clear_resets_terminal_download_state() -> None:
result = _run_node(
_progress_state_script(
"""
updateReferenceBundleDownloadProgress({
id: 'task-download', status: 'failed', message: 'download failed',
progress: {bytes_downloaded: 4096, total_bytes: 0, indeterminate: true}
}, {species_name: 'Spinacia oleracea'});
clearReferenceBundleDownloadProgress();
console.log(JSON.stringify(referenceBundleDownloadState.value));
"""
)
)
assert result == {
"visible": False,
"taskId": "",
"speciesLabel": "",
"message": "",
"percentage": 0,
"indeterminate": True,
"status": "",
"bytesDownloaded": 0,
"totalBytes": 0,
}
def test_download_indicator_is_beside_tip_and_not_inside_batch_dialog() -> None:
html = HTML_PATH.read_text(encoding="utf-8")
toolbar = _source_block(
html,
"<!-- 使用小Tip hover弹出 -->",
"<!-- Upload hover弹出已上传文件 -->",
)
batch_dialog = _source_block(
html,
"<!-- 批量执行确认对话框 -->",
"<!-- 阶段确认对话框(批量执行用) -->",
)
assert 'class="reference-download-progress"' in toolbar
assert "referenceBundleDownloadState.visible" in toolbar
assert "PPI and Ka/Ks Reference Species Data Download" in toolbar
assert ':show-text="!referenceBundleDownloadState.indeterminate"' in toolbar
assert 'class="reference-download-progress"' not in batch_dialog
assert "referenceBundleDownloadState.visible" not in batch_dialog
def test_download_byte_formatter_uses_binary_units() -> None:
html = HTML_PATH.read_text(encoding="utf-8")
assert "const formatReferenceDownloadBytes =" in html
formatter = _source_block(
html,
"const formatReferenceDownloadBytes =",
"const updateReferenceBundleDownloadProgress =",
)
result = _run_node(
formatter
+ """
console.log(JSON.stringify({
zero: formatReferenceDownloadBytes(0),
mib: formatReferenceDownloadBytes(8388608)
}));
"""
)
assert result == {"zero": "", "mib": "8.0 MB"}
def test_monitor_retains_success_and_passes_failed_task_progress() -> None:
html = HTML_PATH.read_text(encoding="utf-8")
monitor = _source_block(
html,
"const monitorPpiReferenceBundleDownload =",
"const ppiHasNearReferenceBeforeArabidopsis =",
)
assert "clearReferenceBundleDownloadProgress();" not in monitor
assert "err?.task" in monitor
@@ -1,5 +1,6 @@
from __future__ import annotations
import http.client
import zipfile
@@ -73,5 +74,51 @@ def test_reference_bundle_download_reports_progress_events(tmp_path, monkeypatch
assert "validate_ids" in phases
assert phases[-1] == "completed"
assert manifest["requested_stages"] == ["ppi"]
assert manifest["requested_file_types"] == ["pep"]
assert "cds" not in manifest["files"]
assert manifest["requested_file_types"] == ["pep", "cds"]
assert "cds" in manifest["files"]
def test_reference_bundle_download_retries_incomplete_read(tmp_path, monkeypatch):
from apps.web import pipeline_backend as backend
payload = b"complete archive"
calls = {"count": 0}
class FakeResponse:
def __init__(self, *, incomplete: bool):
self.incomplete = incomplete
self.headers = {"Content-Length": str(len(payload))}
self._sent = False
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, _size):
if self.incomplete:
raise http.client.IncompleteRead(b"partial", len(payload))
if self._sent:
return b""
self._sent = True
return payload
def fake_urlopen(_url, timeout):
calls["count"] += 1
return FakeResponse(incomplete=calls["count"] == 1)
monkeypatch.setattr(backend.urllib.request, "urlopen", fake_urlopen)
monkeypatch.setattr(backend.time, "sleep", lambda _seconds: None)
output_path = tmp_path / "reference.zip"
downloaded = backend._download_url_to_file(
"https://example.invalid/reference.zip",
output_path,
attempts=2,
)
assert calls["count"] == 2
assert downloaded == len(payload)
assert output_path.read_bytes() == payload
assert not output_path.with_suffix(".zip.part").exists()
@@ -71,3 +71,45 @@ def test_reference_bundle_task_can_be_recovered_from_persisted_status(tmp_path,
assert recovered is not None
assert recovered["job_id"] == "job_ref_recover"
assert recovered["status"] == "failed"
def test_reference_bundle_task_uses_english_user_messages(tmp_path, monkeypatch):
from apps.web import pipeline_backend
from apps.web.vue3 import main
monkeypatch.setattr(main, "WORKSPACE_ROOT", tmp_path / "workspace")
monkeypatch.setattr(main, "INPUT_ROOT", tmp_path / "input")
job_id = "job_ref_english"
task_id = "task_ref_english"
(tmp_path / "workspace" / job_id).mkdir(parents=True)
(tmp_path / "input" / job_id).mkdir(parents=True)
main.tasks[task_id] = {
"id": task_id,
"task_id": task_id,
"stage": "reference_bundle_download",
"job_id": job_id,
"status": "pending",
}
monkeypatch.setattr(
pipeline_backend,
"download_reference_annotation_bundle",
lambda **_kwargs: {
"status": "success",
"requested_file_types": ["pep", "cds"],
"files": {"pep": "ref.pep.fasta", "cds": "ref.cds.fasta"},
},
)
main.run_reference_bundle_download_task(
task_id,
{
"job_id": job_id,
"species_code": "string_taxon_3562",
"requested_stages": ["ppi"],
"timeout_sec": 30,
},
)
task = main.tasks.pop(task_id)
assert task["message"] == "PPI and Ka/Ks reference species data are ready"
assert task["progress"]["message"] == "PPI and Ka/Ks reference species data are ready"
@@ -23,6 +23,20 @@ class _PassThroughTools:
return query
class _ContextEnrichingTools:
@staticmethod
def resolve_analysis_handoff_request(
query: str,
*,
workflow_id: str | None = None,
guidance: bool = False,
) -> str:
return (
f"{query}\n已确认上下文:当前 job 的 subfamily_confirm 已成功完成,"
"家族成员和亚家族分类表已确认。"
)
@pytest.mark.parametrize(
("query", "workflow_id", "clarification_question"),
(
@@ -77,10 +91,47 @@ def test_new_analysis_proposal_preserves_original_request_without_main_agent_pla
assert outcome.action == "clarify"
assert outcome.proposal is not None
assert outcome.proposal["request"] == query
assert outcome.proposal["original_request"] == query
assert "workflow_id" not in outcome.proposal
assert "全基因组还是" not in outcome.message
def test_context_enrichment_does_not_replace_original_analysis_handoff() -> None:
runtime = RouterRuntime(object(), _ContextEnrichingTools(), max_iterations=5) # type: ignore[arg-type]
async def decide_route(**_: Any) -> tuple[RouteDecision, int, list[dict[str, Any]]]:
return (
RouteDecision(
goal="new_analysis",
needs=["analysis_intent"],
subject="执行kaks分析",
workflow_id="gene_family",
analysis_request_kind="execution",
),
1,
[],
)
runtime._decide_route = decide_route # type: ignore[method-assign]
outcome = asyncio.run(
runtime.run(
query="执行kaks分析",
history=[],
session_id="kaks-handoff-session",
job_id="job-kaks-handoff",
goal_ledger=GoalLedger(
conversation_id="kaks-handoff-session",
revision=0,
user_terms={"active_job_id": "job-kaks-handoff"},
),
)
)
assert outcome.proposal is not None
assert "subfamily_confirm" in outcome.proposal["request"]
assert outcome.proposal["original_request"] == "执行kaks分析"
def test_new_analysis_decision_discards_main_agent_workflow_and_clarification() -> None:
decision = RouteDecision(
goal="new_analysis",
@@ -87,6 +87,63 @@ def test_subfamily_request_roles_do_not_promote_reference_to_target(monkeypatch)
}
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",