fix: preserve gene-family stage intent and completed results

This commit is contained in:
wtx
2026-08-24 09:34:13 +08:00
parent 311a49110f
commit 4a5fdff6f0
10 changed files with 910 additions and 20 deletions
@@ -0,0 +1,90 @@
import json
import subprocess
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
FRONTEND_PATH = PROJECT_ROOT / "apps/web/vue3/index_ml.html"
I18N_PATH = PROJECT_ROOT / "apps/web/vue3/static/i18n_en.js"
def _run_empty_plan_branch(tmp_path: Path, plan_data: dict) -> dict:
frontend = FRONTEND_PATH.read_text(encoding="utf-8")
start = frontend.index("const plan = planData.plan;")
end = frontend.index("// 显示智能规划结果", start)
branch = frontend[start:end]
script = tmp_path / "empty-plan-branch.cjs"
script.write_text(
"const planData=" + json.dumps(plan_data, ensure_ascii=False) + ";"
"const events={messages:[],filters:[],refreshes:[]};"
"const t=value=>value;"
"const getStageLabel=id=>({genome_prepare:'0️⃣ Genome Prepare',ppi:'PPI分析'}[id]||id);"
"const setResultStageFilter=id=>events.filters.push(id);"
"const refreshResults=async(jobId,id)=>events.refreshes.push([jobId,id]);"
"const addAgentMessage=(role,message,meta)=>events.messages.push({role,message,meta});"
"const jobInfo={job_id:'job-test'};"
"const nlLoading={value:true};const nlInput={value:'request'};"
"async function run(){" + branch + "}"
"run().then(()=>process.stdout.write(JSON.stringify(events)));",
encoding="utf-8",
)
completed = subprocess.run(
["/usr/bin/node", str(script)],
text=True,
capture_output=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_completed_full_workflow_does_not_masquerade_as_genome_prepare(tmp_path: Path) -> None:
requested = ["phylogeny", "motif", "domain", "structure", "combo", "promoter"]
result = _run_empty_plan_branch(
tmp_path,
{
"success": True,
"plan": {
"stages": [],
"all_stages": requested,
"description": "所有阶段已完成",
},
"completed_stages": [*requested, "genome_prepare"],
},
)
assert result["filters"] == []
assert result["refreshes"] == []
assert [item["message"] for item in result["messages"]] == [
"当前请求中的分析阶段均已有同一分析范围的有效结果,无需重复执行。"
]
def test_completed_single_stage_keeps_stage_specific_result_handoff(tmp_path: Path) -> None:
result = _run_empty_plan_branch(
tmp_path,
{
"success": True,
"plan": {
"stages": [],
"all_stages": ["ppi"],
"description": "所有阶段已完成",
},
"completed_stages": ["ppi"],
},
)
assert result["filters"] == ["ppi"]
assert result["refreshes"] == [["job-test", "ppi"]]
assert [item["message"] for item in result["messages"]] == [
"PPI分析 已有同一分析范围的有效结果,无需重复执行。"
]
def test_generic_completed_plan_label_has_english_translation() -> None:
translations = I18N_PATH.read_text(encoding="utf-8")
assert (
'"当前请求中的分析阶段均已有同一分析范围的有效结果,无需重复执行。": '
'"All analysis stages in the current request already have valid results for the same analysis scope. No need to re-execute."'
) in translations
@@ -0,0 +1,124 @@
from pathlib import Path
_ORIGINAL_PATH_MKDIR = Path.mkdir
def _ignore_broken_remote_mount_mkdir(path: Path, *args, **kwargs):
if path.is_symlink() and not path.exists():
return None
return _ORIGINAL_PATH_MKDIR(path, *args, **kwargs)
Path.mkdir = _ignore_broken_remote_mount_mkdir
try:
from apps.web.vue3 import main as web_main
finally:
Path.mkdir = _ORIGINAL_PATH_MKDIR
def _full_plan_inputs():
stage_ids = ["identify", *web_main.FULL_GENE_FAMILY_WORKFLOW_STAGES]
details = [web_main._stage_detail_defaults(stage_id) for stage_id in stage_ids]
params = {
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"geneFamily": "SPL",
}
return stage_ids, details, params
def test_full_workflow_normalization_respects_intent_stage_exclusions():
stage_ids, details, params = _full_plan_inputs()
for excluded_stage in ("kaks", "karyotype"):
authorized = [stage for stage in stage_ids if stage != excluded_stage]
normalized, _ = web_main._normalize_full_gene_family_workflow_plan(
message=f"执行基因家族分析,但是不要执行{excluded_stage}",
stage_ids=stage_ids,
stages_detail=details,
plan_params=dict(params),
plan_prefix="Ft.SPL",
authorized_stage_ids=authorized,
)
assert excluded_stage not in normalized
assert set(normalized) == set(authorized)
def test_full_workflow_normalization_keeps_full_intent_and_required_dependencies():
stage_ids, details, params = _full_plan_inputs()
normalized, _ = web_main._normalize_full_gene_family_workflow_plan(
message="执行基因家族分析",
stage_ids=stage_ids,
stages_detail=details,
plan_params=dict(params),
plan_prefix="Ft.SPL",
authorized_stage_ids=stage_ids,
)
assert normalized == stage_ids
assert normalized.index("ppi") < normalized.index("kaks")
normalized_without_supplied_intent, _ = web_main._normalize_full_gene_family_workflow_plan(
message="执行基因家族分析",
stage_ids=stage_ids,
stages_detail=details,
plan_params=dict(params),
plan_prefix="Ft.SPL",
authorized_stage_ids=[],
)
assert normalized_without_supplied_intent == stage_ids
dependency_stages = ["identify", "synteny", "karyotype"]
dependency_details = [web_main._stage_detail_defaults(stage) for stage in dependency_stages]
normalized_dependencies, _ = web_main._normalize_full_gene_family_workflow_plan(
message="执行核型分析",
stage_ids=dependency_stages,
stages_detail=dependency_details,
plan_params=dict(params),
plan_prefix="Ft.SPL",
authorized_stage_ids=dependency_stages,
)
assert normalized_dependencies == dependency_stages
def test_contextual_full_workflow_uses_request_intent_as_stage_authority():
stage_ids, _, params = _full_plan_inputs()
authorized = [stage for stage in stage_ids if stage != "kaks"]
request = web_main.AgentPlanRequest(
message="执行基因家族分析,但是不要执行kaks分析",
prefix="Ft.SPL",
intent={"stages": authorized, "params": params},
)
contextual_request = {
"family": "SPL",
"prefix": "Ft.SPL",
"stages": stage_ids,
"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_intent_stage_authority",
contextual_request,
)
assert "kaks" not in response["plan"]["stages"]
def test_llm_plan_normalization_calls_keep_original_intent_stage_authority():
source = (web_main.PROJECT_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
start = source.index("full_workflow_normalized = False")
end = source.index("stage_ids, stages_detail = _apply_standard_combo_panel_plan", start)
normalization_block = source[start:end]
assert "plan_authorized_stage_ids = authoritative_intent_stage_ids or" in source
assert normalization_block.count("authorized_stage_ids=plan_authorized_stage_ids") == 2
+144
View File
@@ -0,0 +1,144 @@
import asyncio
import json
from types import SimpleNamespace
from gfa_core.mas.agents.base import AgentMessage, AgentState
from gfa_core.mas.agents.intent_v2 import IntentAgentV2
from gfa_core.mas.core.fast_intent import fast_intent_recognition
from gfa_core.mas.utils.intent_parser import CORE_STAGES, parse_intent_from_query
def test_complete_gene_family_workflow_excludes_explicitly_negated_ppi():
intent = parse_intent_from_query("执行完整流程,但不执行PPI")
assert intent["stages"] == [stage for stage in CORE_STAGES if stage != "ppi"]
def test_symbolic_minus_excludes_stage_from_complete_workflow():
intent = parse_intent_from_query("完整流程 - PPI")
assert intent["stages"] == [stage for stage in CORE_STAGES if stage != "ppi"]
def test_mixed_positive_and_negative_stages_keep_only_positive_targets():
intent = parse_intent_from_query("做系统发育和Motif,不要做结构域分析")
assert intent["stages"] == ["phylogeny", "motif"]
def test_positive_ppi_and_kaks_request_is_unchanged():
intent = parse_intent_from_query("执行PPI和KaKs分析")
assert intent["stages"] == ["kaks", "ppi"]
def test_generic_rnaseq_workflow_excludes_wgcna_target():
intent = parse_intent_from_query("执行RNA-seq分析,但不要WGCNA")
assert intent["stages"] == [
"rnaseq_de",
"rnaseq_go_enrich",
"rnaseq_kegg_enrich",
"rnaseq_fgsea",
"rnaseq_candidate_family",
]
def test_pure_negative_request_never_becomes_positive_in_rule_fallbacks():
parsed = parse_intent_from_query("不要执行PPI")
fallback = IntentAgentV2(None)._fallback_recognition("不要执行PPI")
fast = fast_intent_recognition("不要执行PPI")
assert parsed["stages"] == []
assert parsed["need_clarify"] is True
assert fallback["stages"] == []
assert fallback["needs_clarification"] is True
assert fast is None
def test_model_output_cannot_reintroduce_a_negated_stage():
class _PpiLLM:
async def acall(self, *_args, **_kwargs):
return SimpleNamespace(
content=json.dumps(
{
"gene_family": "SPL",
"species": "Ft",
"stages": ["ppi"],
"primary_stage": "ppi",
"description": "执行PPI分析",
"requirements": [],
"confidence": 0.9,
}
)
)
state = AgentState(session_id="negated-stage", job_id="job-negated-stage")
message = AgentMessage(
sender="router",
receiver="intent_v2",
message_type="recognize_intent",
payload={"query": "不要执行PPI"},
)
returned = asyncio.run(IntentAgentV2(_PpiLLM()).execute(state, message))
intent = returned.results["results"]["intent"]
assert intent["stages"] == []
assert intent["needs_clarification"] is True
def test_dependency_completion_can_restore_required_non_target_stage():
class _ForbiddenLLM:
async def acall(self, *_args, **_kwargs):
raise AssertionError("deterministic intent must not call the LLM")
state = AgentState(session_id="dependency", job_id="job-dependency")
message = AgentMessage(
sender="router",
receiver="intent_v2",
message_type="recognize_intent",
payload={"query": "执行核型分析,不单独执行种内共线性"},
)
returned = asyncio.run(IntentAgentV2(_ForbiddenLLM()).execute(state, message))
intent = returned.results["results"]["intent"]
assert intent["primary_stage"] == "karyotype"
assert intent["stages"] == ["identify", "synteny", "karyotype"]
def test_completed_subfamily_context_does_not_override_current_stage_negation():
class _ForbiddenLLM:
async def acall(self, *_args, **_kwargs):
raise AssertionError("deterministic intent must not call the LLM")
state = AgentState(session_id="context-negation", job_id="job-context-negation")
message = AgentMessage(
sender="router",
receiver="intent_v2",
message_type="recognize_intent",
payload={
"query": (
"帮我执行Ft的基因家族分析,但是不要执行kaks分析\n"
"已确认上下文:基因家族 SPL;目标物种 Ft;参考物种 At;"
"当前 job 的 subfamily_confirm 已成功完成,家族成员和亚家族分类表已确认。"
),
},
)
returned = asyncio.run(IntentAgentV2(_ForbiddenLLM()).execute(state, message))
intent = returned.results["results"]["intent"]
assert intent["gene_family"] == "SPL"
assert intent["species"] == "Ft"
assert "ppi" in intent["stages"]
assert "kaks" not in intent["stages"]
assert "reference_classification" not in intent["stages"]
def test_subfamily_classification_bypasses_generic_stage_negation():
intent = parse_intent_from_query("执行亚家族分类,不要结构域分析")
assert "reference_classification" in intent["stages"]
assert "domain" in intent["stages"]
+218
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import json
import os
def _write_ft_technical_reference_manifest(final_dir, stage, *, status="success"):
@@ -34,6 +35,50 @@ def _write_ft_technical_reference_manifest(final_dir, stage, *, status="success"
return manifest
def _write_regular_gene_family_manifest(
final_dir,
stage,
*,
scope_id="SPL.target-Ft.ref-At.target_plus_reference",
route="multi_species",
references=None,
member_set_id="",
filename="",
with_artifact=True,
):
references = ["At"] if references is None else list(references)
member_set_id = member_set_id or f"{scope_id}:{route}:all_members"
artifact = final_dir / f"Ft.SPL.{stage}.result.tsv"
if with_artifact:
artifact.write_text(f"{stage}\tresult\n", encoding="utf-8")
manifest = final_dir / (filename or f"Ft.SPL.scoped.{stage}.manifest.json")
manifest.write_text(
json.dumps(
{
"manifest_version": "1.0",
"stage": stage,
"status": "success",
"prefix": "Ft.SPL",
"scope_id": scope_id,
"scope_mode": "target_plus_reference" if references else "target_only",
"analysis_route": route,
"member_set_id": member_set_id,
"family": "SPL",
"target_species": ["Ft"],
"reference_species": references,
"artifacts": (
{"result": {"path": f"final/{artifact.name}", "type": "tsv"}}
if with_artifact
else {"result": {"path": "final/missing.tsv", "type": "tsv"}}
),
},
ensure_ascii=False,
),
encoding="utf-8",
)
return manifest
def _confirmed_contextual_ppi_fixture(web_main, workspace_root, job_id, intent_params):
from types import SimpleNamespace
@@ -416,6 +461,179 @@ def test_identity_completed_stages_force_rerun_keeps_technical_stages_pending(tm
assert completed == []
def test_post_confirm_plan_recovers_regular_manifest_identity(tmp_path, monkeypatch):
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
final_dir = workspace_root / "job_regular_recovery" / "final"
final_dir.mkdir(parents=True)
confirm = final_dir / "Ft.SPL.subfamily_confirm.manifest.json"
confirm.write_text('{"stage":"subfamily_confirm","status":"success","prefix":"Ft.SPL"}', encoding="utf-8")
manifest = _write_regular_gene_family_manifest(final_dir, "phylogeny")
os.utime(confirm, (1, 1))
os.utime(manifest, (2, 2))
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
scope_id = "SPL.target-Ft.ref-At.target_plus_reference"
plan_params = {
"prefix": "Ft.SPL",
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"workflowPhase": "post_subfamily_confirm",
"requiresConfirmedSubfamily": True,
}
stage_params = {
**plan_params,
"scopeMode": "target_plus_reference",
"analysisRoute": "multi_species",
"memberSetId": f"{scope_id}:confirmed_subfamily:all_members",
}
completed = web_main._identity_completed_stages_for_plan(
"job_regular_recovery",
["phylogeny"],
plan_params,
[{"id": "phylogeny", "params": stage_params}],
request_prefix="Ft.SPL",
)
assert completed == ["phylogeny"]
def test_post_confirm_plan_tries_next_manifest_after_incomplete_alias(tmp_path, monkeypatch):
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
final_dir = workspace_root / "job_regular_alias" / "final"
final_dir.mkdir(parents=True)
confirm = final_dir / "Ft.SPL.subfamily_confirm.manifest.json"
confirm.write_text('{"stage":"subfamily_confirm","status":"success","prefix":"Ft.SPL"}', encoding="utf-8")
canonical = _write_regular_gene_family_manifest(
final_dir,
"synteny",
route="single_species",
filename="Ft.SPL.scoped.synteny.manifest.json",
)
alias = _write_regular_gene_family_manifest(
final_dir,
"synteny",
route="single_species",
filename="Ft.SPL.synteny.manifest.json",
with_artifact=False,
)
alias_payload = json.loads(alias.read_text(encoding="utf-8"))
for key in ("scope_id", "scope_mode", "analysis_route", "member_set_id"):
alias_payload.pop(key, None)
alias.write_text(json.dumps(alias_payload), encoding="utf-8")
os.utime(confirm, (1, 1))
os.utime(canonical, (2, 2))
os.utime(alias, (3, 3))
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
target_scope = "SPL.target-Ft.target_only"
plan_params = {
"prefix": "Ft.SPL",
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"workflowPhase": "post_subfamily_confirm",
"requiresConfirmedSubfamily": True,
}
stage_params = {
**plan_params,
"referenceSpecies": [],
"scopeMode": "target_only",
"analysisRoute": "single_species",
"memberSetId": f"{target_scope}:confirmed_subfamily:all_members",
}
completed = web_main._identity_completed_stages_for_plan(
"job_regular_alias",
["synteny"],
plan_params,
[{"id": "synteny", "params": stage_params}],
request_prefix="Ft.SPL",
)
assert completed == ["synteny"]
def test_post_confirm_plan_rejects_result_older_than_confirmation(tmp_path, monkeypatch):
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
final_dir = workspace_root / "job_regular_stale" / "final"
final_dir.mkdir(parents=True)
manifest = _write_regular_gene_family_manifest(final_dir, "phylogeny")
confirm = final_dir / "Ft.SPL.subfamily_confirm.manifest.json"
confirm.write_text('{"stage":"subfamily_confirm","status":"success","prefix":"Ft.SPL"}', encoding="utf-8")
os.utime(manifest, (1, 1))
os.utime(confirm, (2, 2))
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
scope_id = "SPL.target-Ft.ref-At.target_plus_reference"
plan_params = {
"prefix": "Ft.SPL",
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"workflowPhase": "post_subfamily_confirm",
"requiresConfirmedSubfamily": True,
}
stage_params = {
**plan_params,
"scopeMode": "target_plus_reference",
"analysisRoute": "multi_species",
"memberSetId": f"{scope_id}:confirmed_subfamily:all_members",
}
completed = web_main._identity_completed_stages_for_plan(
"job_regular_stale",
["phylogeny"],
plan_params,
[{"id": "phylogeny", "params": stage_params}],
request_prefix="Ft.SPL",
)
assert completed == []
def test_post_confirm_kaks_inherits_completed_shared_ppi_reference(tmp_path, monkeypatch):
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
final_dir = workspace_root / "job_contextual_kaks" / "final"
final_dir.mkdir(parents=True)
_write_ft_technical_reference_manifest(final_dir, "kaks")
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
plan_params = {
"prefix": "Ft.SPL",
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"workflowPhase": "post_subfamily_confirm",
"requiresConfirmedSubfamily": True,
}
stage_params = {
**plan_params,
"scopeMode": "target_plus_reference",
"analysisRoute": "multi_species",
"memberSetId": "SPL.target-Ft.ref-At.target_plus_reference:confirmed_subfamily:all_members",
}
completed = web_main._identity_completed_stages_for_plan(
"job_contextual_kaks",
["kaks"],
plan_params,
[{"id": "kaks", "params": stage_params}],
request_prefix="Ft.SPL",
)
assert completed == ["kaks"]
def test_contextual_plan_returns_only_pending_stages(tmp_path, monkeypatch):