fix: complete PPI and KaKs reference handoff
This commit is contained in:
@@ -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分析"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user