Merge remote-tracking branch 'origin/master'
# Conflicts: # scripts/legacy_startup/restart_with_prompts.sh # scripts/legacy_startup/start-webui.sh # scripts/legacy_startup/start_celery.sh # scripts/legacy_startup/start_celery_7880.sh # scripts/legacy_startup/start_celery_port9000.sh
This commit is contained in:
@@ -92,6 +92,7 @@ async def test_agent_chat_maps_router_actions_and_passes_context(
|
||||
"history": history,
|
||||
"session_id": "session-7",
|
||||
"job_id": "job-7",
|
||||
"language": "zh",
|
||||
}
|
||||
]
|
||||
assert result["success"] is True
|
||||
@@ -108,6 +109,40 @@ async def test_agent_chat_maps_router_actions_and_passes_context(
|
||||
assert result["intent"]["stages"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_keeps_english_language_separate_from_business_query(
|
||||
monkeypatch,
|
||||
web_main,
|
||||
):
|
||||
captured = []
|
||||
_install_router(
|
||||
monkeypatch,
|
||||
{
|
||||
"status": "ok",
|
||||
"action": "reply",
|
||||
"message": "I can help with that.",
|
||||
"intent": None,
|
||||
"sources": [],
|
||||
"tool_trace": [],
|
||||
},
|
||||
captured,
|
||||
)
|
||||
request = web_main.AgentChatRequest(
|
||||
message="执行接下来的分析",
|
||||
language="en",
|
||||
session_id="english-business-query",
|
||||
job_id="job-english-business-query",
|
||||
history=[],
|
||||
)
|
||||
|
||||
result = await web_main.agent_chat(request)
|
||||
|
||||
assert result["intent"]["message"] == "I can help with that."
|
||||
assert request.message == "执行接下来的分析"
|
||||
assert captured[0]["query"] == "执行接下来的分析"
|
||||
assert captured[0]["language"] == "en"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_persists_router_proposal_without_analysis_intent(
|
||||
monkeypatch,
|
||||
@@ -122,6 +157,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(
|
||||
@@ -157,6 +193,68 @@ async def test_agent_chat_persists_router_proposal_without_analysis_intent(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_exposes_and_persists_current_recommendation(
|
||||
monkeypatch,
|
||||
web_main,
|
||||
tmp_path,
|
||||
):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
monkeypatch.setattr(web_main, "agent_conversation_store", store)
|
||||
recommendation = {
|
||||
"recommendation_id": "rec-build-subfamily-evidence",
|
||||
"job_id": "job-recommendation",
|
||||
"workflow_id": "gene_family",
|
||||
"transition_id": "build_subfamily_evidence",
|
||||
"from_state_id": "family_identified",
|
||||
"to_state_id": "subfamily_evidence_ready",
|
||||
"target_stage": "combo",
|
||||
"authorized_stages": [
|
||||
"domain",
|
||||
"motif",
|
||||
"phylogeny",
|
||||
"structure",
|
||||
"combo",
|
||||
],
|
||||
"evidence_digest": "a" * 64,
|
||||
"family": "SPL",
|
||||
"scope_id": "SPL.target-Ft.target_only",
|
||||
}
|
||||
_install_router(
|
||||
monkeypatch,
|
||||
{
|
||||
"status": "ok",
|
||||
"action": "show_result",
|
||||
"message": "下一步是构建亚家族证据。",
|
||||
"intent": None,
|
||||
"recommendation": recommendation,
|
||||
"sources": [],
|
||||
"tool_trace": [],
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
result = await web_main.agent_chat(
|
||||
web_main.AgentChatRequest(
|
||||
message="接下来的分析有哪些?",
|
||||
session_id="recommendation-session",
|
||||
job_id="job-recommendation",
|
||||
history=[],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["action"] == "show_result"
|
||||
assert result["recommendation"] == recommendation
|
||||
ledger = repository.get_goal_ledger(result["session_id"])
|
||||
assert ledger is not None
|
||||
assert (
|
||||
ledger.model_dump(mode="json")["user_terms"]["current_recommendation"]
|
||||
== recommendation
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_exposes_confirmed_request_for_original_planner(
|
||||
monkeypatch,
|
||||
@@ -166,6 +264,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 +291,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,137 @@
|
||||
import asyncio
|
||||
|
||||
from apps.web.vue3 import main as api
|
||||
|
||||
|
||||
def test_build_subfamily_evidence_plan_keeps_confirmed_stage_boundary():
|
||||
intent = {
|
||||
"workflow_action_id": "build_subfamily_evidence",
|
||||
"stages": ["domain", "motif", "phylogeny", "structure", "combo"],
|
||||
"params": {
|
||||
"workflowActionId": "build_subfamily_evidence",
|
||||
"authorizedStages": [
|
||||
"domain",
|
||||
"motif",
|
||||
"phylogeny",
|
||||
"structure",
|
||||
"combo",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
stages = api._authorized_workflow_action_stages(intent)
|
||||
|
||||
assert stages == ["phylogeny", "motif", "domain", "structure", "combo"]
|
||||
assert "identify" not in stages
|
||||
assert "reference_classification" not in stages
|
||||
|
||||
|
||||
def test_unbound_plan_does_not_invent_an_authorized_stage_boundary():
|
||||
assert api._authorized_workflow_action_stages(
|
||||
{"stages": ["identify", "phylogeny", "reference_classification"]}
|
||||
) == []
|
||||
|
||||
|
||||
def test_agent_plan_honors_authorized_workflow_action_without_keyword_reclassification(
|
||||
monkeypatch,
|
||||
):
|
||||
authorized = ["domain", "motif", "phylogeny", "structure", "combo"]
|
||||
intent = {
|
||||
"gene_family": "SPL",
|
||||
"target_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
"stages": authorized,
|
||||
"primary_stage": "combo",
|
||||
"workflow_action_id": "build_subfamily_evidence",
|
||||
"params": {
|
||||
"workflowActionId": "build_subfamily_evidence",
|
||||
"authorizedStages": authorized,
|
||||
"targetSpecies": ["Ft"],
|
||||
"referenceSpecies": ["At"],
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_reconcile_confirmed_gene_family_species",
|
||||
lambda *args, **kwargs: {"applicable": False, "conflicts": []},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api, "_resolve_authoritative_gene_family", lambda *args, **kwargs: "SPL"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_resolve_subfamily_request_species_roles",
|
||||
lambda *args, **kwargs: {
|
||||
"classification_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_latest_gene_family_scope_defaults",
|
||||
lambda *args, **kwargs: {
|
||||
"target_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
"comparative_species": [],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_load_identify_species_selection",
|
||||
lambda *args, **kwargs: {
|
||||
"groups": {
|
||||
"target": [{"species": "Ft"}],
|
||||
"reference": [{"species": "At"}],
|
||||
"comparative": [],
|
||||
}
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_subfamily_state_for_targets",
|
||||
lambda *args, **kwargs: {"overall": "not_classified", "by_species": {}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api, "_identity_completed_stages_for_plan", lambda *args, **kwargs: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_resolve_contextual_gene_family_request",
|
||||
lambda *args, **kwargs: {
|
||||
"family": "SPL",
|
||||
"prefix": "Ft.SPL",
|
||||
"stages": authorized,
|
||||
"targetSpecies": ["Ft"],
|
||||
"referenceSpecies": ["At"],
|
||||
"comparativeSpecies": [],
|
||||
"scopeMode": "target_plus_reference",
|
||||
"contextGate": {"valid": False, "issues": []},
|
||||
"context_route": "gene_family_default_scope",
|
||||
"requiresConfirmedSubfamily": False,
|
||||
"skipSubfamilyLabels": False,
|
||||
"subfamilyState": {
|
||||
"overall": "not_classified",
|
||||
"by_species": {"Ft": {"status": "not_classified"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
api.agent_plan(
|
||||
api.AgentPlanRequest(
|
||||
message="执行接下来的步骤",
|
||||
session_id="authorized-action-test",
|
||||
job_id="job-test",
|
||||
prefix="Ft.SPL",
|
||||
intent=intent,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
expected = ["phylogeny", "motif", "domain", "structure", "combo"]
|
||||
assert response["plan"]["all_stages"] == expected
|
||||
assert response["plan"]["stages"] == expected
|
||||
assert response["intent"]["workflow_action_id"] == "build_subfamily_evidence"
|
||||
assert response["intent"]["params"]["authorizedStages"] == authorized
|
||||
assert "reference_classification" not in response["plan"]["all_stages"]
|
||||
assert "subfamily_confirm" not in response["plan"]["all_stages"]
|
||||
@@ -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,450 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository
|
||||
from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry
|
||||
from gfa_core.mas.agents.router_runtime import RouterRuntime
|
||||
from gfa_core.mas.llm import LLMResponse, LLMToolCall
|
||||
from gfa_core.mas.persistent_conversation import PersistentConversationStore
|
||||
from gfa_core.mas.workflow_models import CurrentRecommendation, GoalLedger
|
||||
|
||||
|
||||
class _NoArgs(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class _IntentArgs(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
class _SequenceLLM:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
|
||||
async def acall(self, *_args, **_kwargs):
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def _response(content="", *, tool_calls=None):
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
tool_calls=list(tool_calls or []),
|
||||
)
|
||||
|
||||
|
||||
def _recommendation(job_id="job-current-recommendation"):
|
||||
return CurrentRecommendation(
|
||||
recommendation_id="rec-build-subfamily-evidence",
|
||||
job_id=job_id,
|
||||
workflow_id="gene_family",
|
||||
transition_id="build_subfamily_evidence",
|
||||
from_state_id="family_identified",
|
||||
to_state_id="subfamily_evidence_ready",
|
||||
target_stage="combo",
|
||||
authorized_stages=("domain", "motif", "phylogeny", "structure", "combo"),
|
||||
evidence_digest="a" * 64,
|
||||
family="SPL",
|
||||
scope_id="SPL.target-Ft.target_only",
|
||||
)
|
||||
|
||||
|
||||
def test_identified_project_context_projects_one_typed_recommendation():
|
||||
recommendation = RouterRuntime._current_workflow_recommendation(
|
||||
"gene_family",
|
||||
"job-project-recommendation",
|
||||
{
|
||||
"family": "SPL",
|
||||
"scopes": [{"scope_id": "SPL.target-Ft.target_only"}],
|
||||
"manifests": [{"stage": "identify", "status": "success"}],
|
||||
"stage_status": {
|
||||
"identify": {"stage": "identify", "status": "success"}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(recommendation, CurrentRecommendation)
|
||||
assert recommendation.transition_id == "build_subfamily_evidence"
|
||||
assert recommendation.target_stage == "combo"
|
||||
assert recommendation.authorized_stages == (
|
||||
"domain",
|
||||
"motif",
|
||||
"phylogeny",
|
||||
"structure",
|
||||
"combo",
|
||||
)
|
||||
|
||||
|
||||
def test_next_analysis_question_stays_read_only_and_returns_typed_recommendation():
|
||||
job_id = "job-next-analysis-question"
|
||||
context = {
|
||||
"job_id": job_id,
|
||||
"family": "SPL",
|
||||
"scopes": [{"scope_id": "SPL.target-Ft.target_only"}],
|
||||
"manifests": [{"stage": "identify", "status": "success"}],
|
||||
"stage_status": {
|
||||
"identify": {"stage": "identify", "status": "success"}
|
||||
},
|
||||
}
|
||||
registry = ConversationToolRegistry()
|
||||
registry.register(
|
||||
name="inspect_project_context",
|
||||
description="Inspect current project context.",
|
||||
args_model=_NoArgs,
|
||||
handler=lambda _args: context,
|
||||
permission="read_only",
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"project_status","needs":["project_context"],'
|
||||
'"subject":"当前工作流的下一业务动作",'
|
||||
'"referent":null,"clarification_question":null,'
|
||||
'"interaction_kind":"status","workflow_id":"gene_family",'
|
||||
'"analysis_request_kind":null,'
|
||||
'"uses_current_recommendation":false,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
),
|
||||
_response(
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="inspect-next-analysis-1",
|
||||
name="inspect_project_context",
|
||||
arguments="{}",
|
||||
)
|
||||
]
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"show_result",'
|
||||
'"message":"下一步是构建亚家族证据。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, registry, max_iterations=5).run(
|
||||
query="接下来的分析有哪些?",
|
||||
history=[],
|
||||
session_id="session-next-analysis-question",
|
||||
job_id=job_id,
|
||||
goal_ledger=GoalLedger(
|
||||
conversation_id="conversation-next-analysis-question",
|
||||
revision=0,
|
||||
user_terms={"active_job_id": job_id, "gene_family": "SPL"},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "show_result"
|
||||
assert outcome.proposal is None
|
||||
assert outcome.recommendation is not None
|
||||
assert outcome.recommendation.transition_id == "build_subfamily_evidence"
|
||||
|
||||
|
||||
def test_show_result_persists_and_cancel_clears_current_recommendation(tmp_path):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
turn = store.begin_turn(
|
||||
session_id="session-current-recommendation",
|
||||
job_id="job-current-recommendation",
|
||||
query="接下来的分析有哪些?",
|
||||
client_history=[],
|
||||
)
|
||||
recommendation = _recommendation().model_dump(mode="json")
|
||||
|
||||
ledger = store.finish_turn(
|
||||
turn,
|
||||
assistant_message="下一步是构建亚家族证据。",
|
||||
interaction_kind="status",
|
||||
action="show_result",
|
||||
recommendation=recommendation,
|
||||
)
|
||||
assert (
|
||||
ledger.model_dump(mode="json")["user_terms"]["current_recommendation"]
|
||||
== recommendation
|
||||
)
|
||||
|
||||
cancel_turn = store.begin_turn(
|
||||
session_id="session-current-recommendation",
|
||||
job_id="job-current-recommendation",
|
||||
query="取消",
|
||||
client_history=[],
|
||||
)
|
||||
ledger = store.finish_turn(
|
||||
cancel_turn,
|
||||
assistant_message="已取消。",
|
||||
interaction_kind="cancel",
|
||||
action="reply",
|
||||
)
|
||||
assert "current_recommendation" not in ledger.user_terms
|
||||
|
||||
|
||||
def test_pending_proposal_preserves_complete_workflow_action_binding(tmp_path):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
turn = store.begin_turn(
|
||||
session_id="session-action-binding",
|
||||
job_id="job-action-binding",
|
||||
query="执行当前推荐",
|
||||
client_history=[],
|
||||
)
|
||||
proposal = {
|
||||
"proposal_id": "proposal-action-binding",
|
||||
"request": "执行 build_subfamily_evidence",
|
||||
"subject": "执行当前推荐",
|
||||
"workflow_id": "gene_family",
|
||||
"workflow_action_id": "build_subfamily_evidence",
|
||||
"authorized_stages": "domain,motif,phylogeny,structure,combo",
|
||||
"target_stage": "combo",
|
||||
}
|
||||
|
||||
ledger = store.finish_turn(
|
||||
turn,
|
||||
assistant_message="请确认。",
|
||||
interaction_kind="supplement",
|
||||
action="clarify",
|
||||
proposal=proposal,
|
||||
)
|
||||
|
||||
assert ledger.user_terms["pending_proposal"] == {
|
||||
**proposal,
|
||||
"job_id": "job-action-binding",
|
||||
}
|
||||
|
||||
|
||||
def test_execute_current_recommendation_revalidates_and_limits_intent():
|
||||
job_id = "job-execute-current-recommendation"
|
||||
context = {
|
||||
"job_id": job_id,
|
||||
"family": "SPL",
|
||||
"scopes": [{"scope_id": "SPL.target-Ft.target_only"}],
|
||||
"manifests": [{"stage": "identify", "status": "success"}],
|
||||
"stage_status": {
|
||||
"identify": {"stage": "identify", "status": "success"}
|
||||
},
|
||||
}
|
||||
recommendation = RouterRuntime._current_workflow_recommendation(
|
||||
"gene_family", job_id, context
|
||||
)
|
||||
registry = ConversationToolRegistry()
|
||||
registry.register(
|
||||
name="inspect_project_context",
|
||||
description="Inspect current project context.",
|
||||
args_model=_NoArgs,
|
||||
handler=lambda _args: context,
|
||||
permission="read_only",
|
||||
)
|
||||
registry.register(
|
||||
name="recognize_analysis_intent",
|
||||
description="Recognize an analysis intent.",
|
||||
args_model=_IntentArgs,
|
||||
handler=lambda _args: {
|
||||
"gene_family": "SPL",
|
||||
"stages": ["identify"],
|
||||
"primary_stage": "identify",
|
||||
"params": {"targetSpecies": ["Ft"], "referenceSpecies": ["At"]},
|
||||
},
|
||||
permission="pure",
|
||||
)
|
||||
ledger = GoalLedger(
|
||||
conversation_id="conversation-execute-current-recommendation",
|
||||
revision=0,
|
||||
user_terms={
|
||||
"active_job_id": job_id,
|
||||
"current_recommendation": recommendation.model_dump(mode="json"),
|
||||
},
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"new_analysis","needs":["analysis_intent"],'
|
||||
'"subject":"执行当前推荐动作","referent":"current_recommendation",'
|
||||
'"clarification_question":null,"interaction_kind":"supplement",'
|
||||
'"analysis_request_kind":"execution",'
|
||||
'"uses_current_recommendation":true,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
),
|
||||
_response(
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="recognize-current-recommendation-1",
|
||||
name="recognize_analysis_intent",
|
||||
arguments=json.dumps(
|
||||
{"query": "执行接下来的分析"}, ensure_ascii=False
|
||||
),
|
||||
)
|
||||
]
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"propose_analysis",'
|
||||
'"message":"已交给现有分析系统。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, registry, max_iterations=5).run(
|
||||
query="执行接下来的分析",
|
||||
history=[],
|
||||
session_id="session-execute-current-recommendation",
|
||||
job_id=job_id,
|
||||
goal_ledger=ledger,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "propose_analysis"
|
||||
assert outcome.intent["stages"] == [
|
||||
"domain",
|
||||
"motif",
|
||||
"phylogeny",
|
||||
"structure",
|
||||
"combo",
|
||||
]
|
||||
assert outcome.intent["primary_stage"] == "combo"
|
||||
assert outcome.intent["workflow_action_id"] == "build_subfamily_evidence"
|
||||
|
||||
|
||||
def test_semantic_business_referent_hands_off_when_workflow_is_terminal():
|
||||
captured_queries = []
|
||||
registry = ConversationToolRegistry()
|
||||
registry.register(
|
||||
name="recognize_analysis_intent",
|
||||
description="Recognize an analysis intent.",
|
||||
args_model=_IntentArgs,
|
||||
handler=lambda args: captured_queries.append(args.query)
|
||||
or {
|
||||
"gene_family": "SPL",
|
||||
"stages": ["promoter", "synteny", "ppi"],
|
||||
"primary_stage": "promoter",
|
||||
},
|
||||
permission="pure",
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"new_analysis","needs":["analysis_intent"],'
|
||||
'"subject":"执行上一轮说明的后续业务",'
|
||||
'"referent":"常规基因家族分析",'
|
||||
'"clarification_question":null,"interaction_kind":"supplement",'
|
||||
'"workflow_id":"gene_family","analysis_request_kind":"execution",'
|
||||
'"uses_current_recommendation":true,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
),
|
||||
_response(
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="recognize-semantic-business-referent-1",
|
||||
name="recognize_analysis_intent",
|
||||
arguments=json.dumps(
|
||||
{"query": "执行常规基因家族分析"}, ensure_ascii=False
|
||||
),
|
||||
)
|
||||
]
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"propose_analysis",'
|
||||
'"message":"分析请求已交给现有意图系统。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, registry, max_iterations=5).run(
|
||||
query="执行接下来的分析",
|
||||
history=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"亚家族分类与确认已经完成,"
|
||||
"接下来进入常规基因家族分析。"
|
||||
),
|
||||
}
|
||||
],
|
||||
session_id="session-semantic-business-referent",
|
||||
job_id="job-confirmed-family",
|
||||
goal_ledger=GoalLedger(
|
||||
conversation_id="conversation-semantic-business-referent",
|
||||
revision=0,
|
||||
user_terms={"active_job_id": "job-confirmed-family"},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "propose_analysis"
|
||||
assert captured_queries == ["执行常规基因家族分析"]
|
||||
assert outcome.intent["stages"] == ["promoter", "synteny", "ppi"]
|
||||
|
||||
|
||||
def test_unresolved_current_recommendation_without_binding_still_clarifies():
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"new_analysis","needs":["analysis_intent"],'
|
||||
'"subject":"执行当前推荐动作",'
|
||||
'"referent":"current_recommendation",'
|
||||
'"clarification_question":null,"interaction_kind":"supplement",'
|
||||
'"workflow_id":"gene_family","analysis_request_kind":"execution",'
|
||||
'"uses_current_recommendation":true,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="执行接下来的分析",
|
||||
history=[],
|
||||
session_id="session-unresolved-recommendation",
|
||||
job_id="job-unresolved-recommendation",
|
||||
goal_ledger=GoalLedger(
|
||||
conversation_id="conversation-unresolved-recommendation",
|
||||
revision=0,
|
||||
user_terms={"active_job_id": "job-unresolved-recommendation"},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "clarify"
|
||||
assert "明确" in outcome.message
|
||||
|
||||
|
||||
def test_execute_current_recommendation_rejects_cross_job_binding():
|
||||
recommendation = _recommendation(job_id="job-original")
|
||||
ledger = GoalLedger(
|
||||
conversation_id="conversation-cross-job-recommendation",
|
||||
revision=0,
|
||||
user_terms={
|
||||
"current_recommendation": recommendation.model_dump(mode="json")
|
||||
},
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"new_analysis","needs":["analysis_intent"],'
|
||||
'"subject":"执行当前推荐动作","referent":"current_recommendation",'
|
||||
'"clarification_question":null,"interaction_kind":"supplement",'
|
||||
'"analysis_request_kind":"execution",'
|
||||
'"uses_current_recommendation":true,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="执行接下来的分析",
|
||||
history=[],
|
||||
session_id="session-cross-job-recommendation",
|
||||
job_id="job-other",
|
||||
goal_ledger=ledger,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "clarify"
|
||||
assert "当前 job" in outcome.message
|
||||
@@ -0,0 +1,88 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry
|
||||
from gfa_core.mas.agents.router_runtime import RouterRuntime
|
||||
from gfa_core.mas.llm import LLMMessage, LLMResponse
|
||||
from gfa_core.mas.llm.providers.deepseek import DeepSeekProvider
|
||||
|
||||
|
||||
def test_deepseek_provider_forwards_requested_json_mode():
|
||||
captured = {}
|
||||
|
||||
class _Completions:
|
||||
def create(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content='{"ok":true}', tool_calls=[]),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=1,
|
||||
completion_tokens=1,
|
||||
total_tokens=2,
|
||||
),
|
||||
)
|
||||
|
||||
provider = DeepSeekProvider(api_key="test-key")
|
||||
provider.client = SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=_Completions())
|
||||
)
|
||||
|
||||
provider.call(
|
||||
[LLMMessage(role="user", content="Return JSON")],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
|
||||
assert captured["response_format"] == {"type": "json_object"}
|
||||
|
||||
|
||||
def test_router_route_but_not_freeform_reply_requests_native_json_mode():
|
||||
class _RecordingLLM:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.responses = [
|
||||
LLMResponse(
|
||||
content=(
|
||||
'{"goal":"general","needs":[],"subject":"编写脚本",'
|
||||
'"referent":null,"clarification_question":null,'
|
||||
'"interaction_kind":"supplement"}'
|
||||
),
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
),
|
||||
LLMResponse(
|
||||
content=(
|
||||
'{"status":"ok","action":"reply",'
|
||||
'"message":"已生成脚本。"}'
|
||||
),
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
),
|
||||
]
|
||||
|
||||
async def acall(self, _messages, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return self.responses.pop(0)
|
||||
|
||||
llm = _RecordingLLM()
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="帮我写一个 FASTA 脚本",
|
||||
history=[],
|
||||
session_id="deepseek-json-protocol",
|
||||
job_id="",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "reply"
|
||||
assert [call.get("response_format") for call in llm.calls] == [
|
||||
{"type": "json_object"},
|
||||
None,
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gfa_core.mas.agents.base import AgentMessage, AgentState
|
||||
from gfa_core.mas.agents.intent_v2 import IntentAgentV2
|
||||
|
||||
|
||||
def test_fallback_does_not_turn_unrecognized_continuation_into_identify():
|
||||
intent = IntentAgentV2(None)._fallback_recognition("执行接下来的分析")
|
||||
|
||||
assert intent["stages"] == []
|
||||
assert intent["needs_clarification"] is True
|
||||
assert intent["clarification_type"] == "analysis_stage"
|
||||
|
||||
|
||||
def test_validation_does_not_turn_empty_model_stages_into_identify():
|
||||
intent = IntentAgentV2(None)._validate_intent(
|
||||
{
|
||||
"gene_family": "SPL",
|
||||
"species": "Ft",
|
||||
"stages": [],
|
||||
"params": {},
|
||||
},
|
||||
"执行接下来的步骤",
|
||||
)
|
||||
|
||||
assert intent["stages"] == []
|
||||
assert intent["needs_clarification"] is True
|
||||
assert intent["clarification_type"] == "analysis_stage"
|
||||
|
||||
|
||||
def test_fallback_still_recognizes_explicit_identify():
|
||||
intent = IntentAgentV2(None)._fallback_recognition("执行 identify 分析")
|
||||
|
||||
assert intent["stages"] == ["identify"]
|
||||
assert intent.get("needs_clarification") is not True
|
||||
|
||||
|
||||
def test_empty_model_response_cannot_authorize_identify_and_keeps_goal_context():
|
||||
class _EmptyLLM:
|
||||
async def acall(self, *_args, **_kwargs):
|
||||
return SimpleNamespace(content="")
|
||||
|
||||
state = AgentState(session_id="empty-intent", job_id="job-empty-intent")
|
||||
message = AgentMessage(
|
||||
sender="router",
|
||||
receiver="intent_v2",
|
||||
message_type="recognize_intent",
|
||||
payload={
|
||||
"query": "执行接下来的分析",
|
||||
"job_id": "job-empty-intent",
|
||||
"workspace": "/tmp/job-empty-intent",
|
||||
"goal_context": {
|
||||
"user_terms": {
|
||||
"gene_family": "SPL",
|
||||
"target_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
"comparative_species": [],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
returned = asyncio.run(IntentAgentV2(_EmptyLLM()).execute(state, message))
|
||||
intent = returned.results["results"]["intent"]
|
||||
|
||||
assert intent["stages"] == []
|
||||
assert intent["needs_clarification"] is True
|
||||
assert intent["target_species"] == ["Ft"]
|
||||
assert intent["reference_species"] == ["At"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,116 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from gfa_core.mas.agents.intent_v2 import IntentAgentV2
|
||||
from gfa_core.mas.agents.conversation_tools import build_plantai_conversation_tools
|
||||
from gfa_core.mas.llm import LLMManager
|
||||
from gfa_core.mas.workflow_models import GoalLedger
|
||||
|
||||
|
||||
def _registry(tmp_path, *, job_id="job-data-preparation-handoff"):
|
||||
ledger = GoalLedger(
|
||||
conversation_id="conversation-data-preparation-handoff",
|
||||
revision=0,
|
||||
user_terms={
|
||||
"active_job_id": job_id,
|
||||
"gene_family": "SPL",
|
||||
"target_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
"config_confirmed": True,
|
||||
},
|
||||
)
|
||||
return build_plantai_conversation_tools(
|
||||
llm_manager=LLMManager({}),
|
||||
project_root=tmp_path,
|
||||
bound_job_id=job_id,
|
||||
goal_ledger=ledger,
|
||||
)
|
||||
|
||||
|
||||
def test_analysis_handoff_is_not_rewritten_to_genome_prepare(tmp_path):
|
||||
resolved = _registry(tmp_path).resolve_analysis_handoff_request(
|
||||
"执行亚家族分类",
|
||||
workflow_id="gene_family",
|
||||
guidance=True,
|
||||
)
|
||||
|
||||
assert resolved.startswith("执行亚家族分类\n")
|
||||
assert "执行数据准备(genome_prepare)" not in resolved
|
||||
assert "目标物种 Ft" in resolved
|
||||
assert "参考物种 At" in resolved
|
||||
assert "正式 config.xlsx 已确认" in resolved
|
||||
|
||||
|
||||
def test_genome_prepare_manifest_does_not_change_analysis_handoff(tmp_path):
|
||||
job_id = "job-manifest-independent-handoff"
|
||||
registry = _registry(tmp_path, job_id=job_id)
|
||||
before = registry.resolve_analysis_handoff_request(
|
||||
"执行亚家族分类",
|
||||
workflow_id="gene_family",
|
||||
guidance=True,
|
||||
)
|
||||
final_dir = tmp_path / "workspace" / job_id / "final"
|
||||
final_dir.mkdir(parents=True)
|
||||
(final_dir / "genome_prepare.genome_prepare.manifest.json").write_text(
|
||||
json.dumps({"stage": "genome_prepare", "status": "success"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
after = registry.resolve_analysis_handoff_request(
|
||||
"执行亚家族分类",
|
||||
workflow_id="gene_family",
|
||||
guidance=True,
|
||||
)
|
||||
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_explicit_data_preparation_request_is_preserved(tmp_path):
|
||||
resolved = _registry(tmp_path).resolve_analysis_handoff_request(
|
||||
"执行数据准备",
|
||||
workflow_id="data_preparation",
|
||||
guidance=False,
|
||||
)
|
||||
|
||||
assert resolved.startswith("执行数据准备\n")
|
||||
assert "当前业务动作已由数据准备契约唯一确定" not in resolved
|
||||
|
||||
|
||||
def test_next_step_question_is_not_rewritten_to_genome_prepare(tmp_path):
|
||||
resolved = _registry(tmp_path).resolve_analysis_handoff_request(
|
||||
"接下来需要做什么?",
|
||||
workflow_id="gene_family",
|
||||
guidance=True,
|
||||
)
|
||||
|
||||
assert resolved.startswith("接下来需要做什么?\n")
|
||||
assert "执行数据准备(genome_prepare)" not in resolved
|
||||
|
||||
|
||||
def test_bound_analysis_intent_uses_default_language(monkeypatch, tmp_path):
|
||||
captured_queries = []
|
||||
|
||||
async def fake_execute(_self, state, message):
|
||||
captured_queries.append(message.payload["query"])
|
||||
state.results["results"] = {
|
||||
"intent": {
|
||||
"gene_family": "SPL",
|
||||
"stages": ["ppi", "kaks"],
|
||||
"primary_stage": "ppi",
|
||||
}
|
||||
}
|
||||
return state
|
||||
|
||||
monkeypatch.setattr(IntentAgentV2, "execute", fake_execute)
|
||||
result = json.loads(
|
||||
asyncio.run(
|
||||
_registry(tmp_path).dispatch(
|
||||
"recognize_analysis_intent",
|
||||
json.dumps({"query": "执行Ft的PPI和kaks分析"}, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["data"]["stages"] == ["ppi", "kaks"]
|
||||
assert captured_queries[0].startswith("执行Ft的PPI和kaks分析\n")
|
||||
@@ -0,0 +1,204 @@
|
||||
import asyncio
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry
|
||||
from gfa_core.mas.agents.router_runtime import RouterRuntime
|
||||
from gfa_core.mas.llm import LLMResponse, LLMToolCall
|
||||
|
||||
|
||||
_HISTORY = [
|
||||
{"role": "user", "content": "如果我想写个脚本来修订我的蛋白id"},
|
||||
{"role": "assistant", "content": "请提供输入格式、修订规则和输出要求。"},
|
||||
{"role": "user", "content": ">id.02020202 | name:123 | xxx"},
|
||||
{"role": "assistant", "content": "请确认修订规则。"},
|
||||
{"role": "user", "content": "仅保留第一个字段(id.02020202)"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "请确认输入文件格式,以及覆盖原文件还是输出到新文件?",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _response(content: str) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
)
|
||||
|
||||
|
||||
class _SequenceLLM:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = 0
|
||||
|
||||
async def acall(self, _messages, **_kwargs):
|
||||
self.calls += 1
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class _QueryArgs(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
def test_drafting_profile_preserves_short_specification_follow_ups():
|
||||
messages = RouterRuntime._route_messages(
|
||||
query="FASTA格式,输出到新文件",
|
||||
history=_HISTORY,
|
||||
session_id="drafting-continuation",
|
||||
job_id="job-bound-to-gene-family",
|
||||
goal_ledger=None,
|
||||
)
|
||||
prompt = " ".join(messages[0].content.split())
|
||||
|
||||
assert "Multi-turn drafting continuity" in prompt
|
||||
assert "a supplement to the same general goal" in prompt
|
||||
assert "must not be routed to utility" in prompt
|
||||
assert "actually transform a current or supplied file" in prompt
|
||||
|
||||
|
||||
def test_explicit_script_request_cannot_be_promoted_to_utility_by_router():
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"utility","needs":["utility_intent"],'
|
||||
'"subject":"简化FASTA ID","referent":"test.fa",'
|
||||
'"clarification_question":null,"interaction_kind":"independent"}'
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"reply",'
|
||||
'"message":"下面给出一个只生成、不执行的 Python 脚本。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="帮我写脚本简化 test.fa 的id",
|
||||
history=[],
|
||||
session_id="explicit-script-request",
|
||||
job_id="",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "reply"
|
||||
assert outcome.tool_trace == []
|
||||
assert llm.calls == 2
|
||||
|
||||
|
||||
def test_drafting_follow_up_uses_responder_instead_of_router_clarification():
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"general","needs":[],"subject":"补充FASTA脚本规则",'
|
||||
'"referent":"蛋白ID脚本",'
|
||||
'"clarification_question":"请重新提供全部需求。",'
|
||||
'"interaction_kind":"supplement"}'
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"reply",'
|
||||
'"message":"已记录保留第一个字段;还需要确认输入格式和输出方式。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="仅保留第一个字段(id.02020202)",
|
||||
history=_HISTORY[:4],
|
||||
session_id="drafting-follow-up",
|
||||
job_id="",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "reply"
|
||||
assert outcome.message.startswith("已记录保留第一个字段")
|
||||
assert llm.calls == 2
|
||||
|
||||
|
||||
def test_locked_general_reply_accepts_bare_drafting_text_without_retry():
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"general","needs":[],"subject":"生成FASTA脚本",'
|
||||
'"referent":null,"clarification_question":null,'
|
||||
'"interaction_kind":"supplement"}'
|
||||
),
|
||||
_response("```python\nprint('FASTA')\n```"),
|
||||
_response(
|
||||
'{"status":"ok","action":"reply",'
|
||||
'"message":"recovered after retry"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="FASTA格式,输出到新文件",
|
||||
history=_HISTORY,
|
||||
session_id="bare-drafting-reply",
|
||||
job_id="",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "reply"
|
||||
assert outcome.message == "```python\nprint('FASTA')\n```"
|
||||
assert outcome.tool_trace == []
|
||||
assert llm.calls == 2
|
||||
|
||||
|
||||
def test_unrecognized_file_utility_returns_actionable_chinese_clarification():
|
||||
registry = ConversationToolRegistry()
|
||||
registry.register(
|
||||
name="recognize_utility_intent",
|
||||
description="Recognize a supported utility request.",
|
||||
args_model=_QueryArgs,
|
||||
handler=lambda _args: {
|
||||
"success": False,
|
||||
"error_code": "utility_intent_not_found",
|
||||
"message": "No supported utility request was recognized.",
|
||||
},
|
||||
permission="pure",
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"utility","needs":["utility_intent"],'
|
||||
'"subject":"处理文件","referent":null,'
|
||||
'"clarification_question":null,"interaction_kind":"independent"}'
|
||||
),
|
||||
LLMResponse(
|
||||
content="",
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="utility-not-found-1",
|
||||
name="recognize_utility_intent",
|
||||
arguments='{"query":"帮我处理一下文件"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, registry, max_iterations=5).run(
|
||||
query="帮我处理一下文件",
|
||||
history=[],
|
||||
session_id="unrecognized-utility",
|
||||
job_id="job-utility",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "clarify"
|
||||
assert "实际文件路径" in outcome.message
|
||||
assert "处理规则" in outcome.message
|
||||
assert "I couldn't identify" not in outcome.message
|
||||
@@ -0,0 +1,113 @@
|
||||
from gfa_core.mas.agents.router_runtime import RouteDecision, RouterRuntime
|
||||
|
||||
|
||||
def _general_prompt(query: str, history=None) -> str:
|
||||
return RouterRuntime._initial_messages(
|
||||
query=query,
|
||||
history=list(history or []),
|
||||
session_id="product-business-session",
|
||||
job_id="job-bound-to-other-workflow",
|
||||
decision=RouteDecision(
|
||||
goal="general",
|
||||
needs=[],
|
||||
subject="解释产品业务",
|
||||
interaction_kind="independent",
|
||||
),
|
||||
goal_ledger=None,
|
||||
)[0].content
|
||||
|
||||
|
||||
def _route_prompt(query: str) -> str:
|
||||
return RouterRuntime._route_messages(
|
||||
query=query,
|
||||
history=[],
|
||||
session_id="product-business-route",
|
||||
job_id="job-bound-to-other-workflow",
|
||||
goal_ledger=None,
|
||||
)[0].content
|
||||
|
||||
|
||||
def test_gene_family_product_help_defines_two_level_business_view():
|
||||
prompt = _general_prompt("基因家族有哪些分析步骤?")
|
||||
compact = " ".join(prompt.split())
|
||||
|
||||
assert "亚家族分类与确认(前置)" in prompt
|
||||
assert "常规基因家族分析(主体)" in prompt
|
||||
assert "First answer with these two parts only" in prompt
|
||||
assert "Do not enumerate any individual stage in that first answer" in compact
|
||||
assert "must not use RAG or project tools" in compact
|
||||
assert (
|
||||
"phylogeny,motif,domain,structure,combo,promoter,tfbs,synteny,"
|
||||
"interspecies_synteny,karyotype,pep,ortholog,kaks,ppi"
|
||||
) in prompt
|
||||
|
||||
|
||||
def test_rnaseq_product_help_defines_modes_and_authoritative_inputs():
|
||||
prompt = _general_prompt("系统中 RNA-seq 分析有哪些步骤?")
|
||||
compact = " ".join(prompt.split())
|
||||
|
||||
assert "全基因组 RNA-seq 分析(主体)" in prompt
|
||||
assert "已确认基因家族成员的 RNA 结果投影" in prompt
|
||||
assert "First answer with these two modes only" in compact
|
||||
assert "config.xlsx and samples.yaml are equally authoritative inputs" in compact
|
||||
assert (
|
||||
"rna_fact_projection,rnaseq_qc,rnaseq_align,rnaseq_quant,"
|
||||
"rnaseq_sample_qc,rnaseq_de"
|
||||
) in prompt
|
||||
assert "must not rerun a separate family-mode DE" in compact
|
||||
|
||||
|
||||
def test_rnaseq_product_question_and_execution_have_distinct_route_examples():
|
||||
prompt = _route_prompt("全基因组 RNA-seq 分析的正式输入和具体步骤是什么?")
|
||||
|
||||
assert (
|
||||
'"全基因组 RNA-seq 分析的正式输入和具体步骤是什么?" -> '
|
||||
"general + needs=[]."
|
||||
) in prompt
|
||||
assert (
|
||||
'"执行全基因组 RNA-seq 分析" -> new_analysis + analysis_intent.'
|
||||
) in prompt
|
||||
|
||||
|
||||
def test_product_follow_up_keeps_all_prior_business_context():
|
||||
history = [
|
||||
{"role": "user", "content": "系统中转录组分析有哪些步骤?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "分为全基因组 RNA-seq 和已确认家族成员的 RNA 结果投影。",
|
||||
},
|
||||
{"role": "user", "content": "先说全基因组这一部分。"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "需要先确认 config.xlsx 和 samples.yaml。",
|
||||
},
|
||||
]
|
||||
messages = RouterRuntime._initial_messages(
|
||||
query="那家族成员这一部分呢?",
|
||||
history=history,
|
||||
session_id="product-follow-up-session",
|
||||
job_id="job-product-follow-up",
|
||||
decision=RouteDecision(
|
||||
goal="general",
|
||||
needs=[],
|
||||
subject="继续解释产品业务",
|
||||
interaction_kind="supplement",
|
||||
),
|
||||
goal_ledger=None,
|
||||
)
|
||||
|
||||
assert [message.content for message in messages[1:-1]] == [
|
||||
item["content"] for item in history
|
||||
]
|
||||
|
||||
|
||||
def test_completed_subfamily_or_stage_list_is_not_full_analysis_completion():
|
||||
assert not RouterRuntime._claims_gene_family_complete_at_readiness_boundary(
|
||||
"亚家族分类与确认已经完成,接下来进入常规基因家族分析,包括 ppi。"
|
||||
)
|
||||
assert not RouterRuntime._claims_gene_family_complete_at_readiness_boundary(
|
||||
"已完成的阶段包括 identify;后续还有常规基因家族分析。"
|
||||
)
|
||||
assert RouterRuntime._claims_gene_family_complete_at_readiness_boundary(
|
||||
"亚家族确认完成,因此完整基因家族分析已经全部完成。"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _block(source: str, start: str, end: str) -> str:
|
||||
begin = source.index(start)
|
||||
finish = source.index(end, begin)
|
||||
return source[begin:finish]
|
||||
|
||||
|
||||
def test_multilingual_frontend_keeps_critical_ppi_kaks_state_chain_in_sync() -> None:
|
||||
canonical = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
|
||||
multilingual = (ROOT / "apps/web/vue3/index_ml.html").read_text(encoding="utf-8")
|
||||
boundaries = (
|
||||
(
|
||||
"const resumePpiKaksPausedStages = async",
|
||||
"const downloadRecommendedPpiReferenceBundle = async",
|
||||
),
|
||||
(
|
||||
"const monitorPpiReferenceBundleDownload =",
|
||||
"const ppiHasNearReferenceBeforeArabidopsis =",
|
||||
),
|
||||
(
|
||||
"const ensurePpiKaksDependencyOwner = async",
|
||||
"const submitExecuteRequestWithAutoAnnotation = async",
|
||||
),
|
||||
(
|
||||
"const kaksReferenceChoiceParams =",
|
||||
"const confirmedStageParamsForExecution =",
|
||||
),
|
||||
)
|
||||
|
||||
for start, end in boundaries:
|
||||
assert _block(multilingual, start, end) == _block(canonical, start, end)
|
||||
@@ -0,0 +1,100 @@
|
||||
from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository
|
||||
from gfa_core.mas.agents.router_runtime import RouteDecision, RouterRuntime
|
||||
from gfa_core.mas.persistent_conversation import PersistentConversationStore
|
||||
|
||||
|
||||
def test_begin_turn_imports_and_returns_complete_valid_history(tmp_path):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
client_history = [
|
||||
{
|
||||
"role": "user" if index % 2 == 0 else "assistant",
|
||||
"content": f"历史消息-{index:02d}",
|
||||
}
|
||||
for index in range(14)
|
||||
]
|
||||
|
||||
turn = store.begin_turn(
|
||||
session_id="full-history-session",
|
||||
job_id="job-full-history",
|
||||
query="当前问题",
|
||||
client_history=client_history,
|
||||
)
|
||||
|
||||
assert list(turn.history) == client_history
|
||||
stored = repository.list_messages(turn.conversation_id)
|
||||
assert [item.content for item in stored] == [
|
||||
*(item["content"] for item in client_history),
|
||||
"当前问题",
|
||||
]
|
||||
|
||||
|
||||
def test_begin_turn_returns_all_persisted_messages_not_only_latest_ten(tmp_path):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
first_turn = store.begin_turn(
|
||||
session_id="persisted-full-history-session",
|
||||
job_id="job-full-history",
|
||||
query="第一个问题",
|
||||
client_history=[],
|
||||
)
|
||||
store.finish_turn(
|
||||
first_turn,
|
||||
assistant_message="第一个回答",
|
||||
interaction_kind="independent",
|
||||
action="reply",
|
||||
)
|
||||
for index in range(1, 7):
|
||||
turn = store.begin_turn(
|
||||
session_id="persisted-full-history-session",
|
||||
job_id="job-full-history",
|
||||
query=f"问题-{index}",
|
||||
client_history=[],
|
||||
)
|
||||
store.finish_turn(
|
||||
turn,
|
||||
assistant_message=f"回答-{index}",
|
||||
interaction_kind="supplement",
|
||||
action="reply",
|
||||
)
|
||||
|
||||
final_turn = store.begin_turn(
|
||||
session_id="persisted-full-history-session",
|
||||
job_id="job-full-history",
|
||||
query="最终问题",
|
||||
client_history=[],
|
||||
)
|
||||
|
||||
assert len(final_turn.history) == 14
|
||||
assert final_turn.history[0]["content"] == "第一个问题"
|
||||
assert final_turn.history[-1]["content"] == "回答-6"
|
||||
|
||||
|
||||
def test_main_agent_prompt_receives_the_complete_history():
|
||||
history = [
|
||||
{
|
||||
"role": "user" if index % 2 == 0 else "assistant",
|
||||
"content": f"消息-{index}",
|
||||
}
|
||||
for index in range(12)
|
||||
]
|
||||
|
||||
messages = RouterRuntime._initial_messages(
|
||||
query="当前问题",
|
||||
history=history,
|
||||
session_id="full-history-session",
|
||||
job_id="job-full-history",
|
||||
decision=RouteDecision(
|
||||
goal="general",
|
||||
needs=[],
|
||||
subject="继续普通问答",
|
||||
interaction_kind="supplement",
|
||||
),
|
||||
goal_ledger=None,
|
||||
)
|
||||
|
||||
assert [message.content for message in messages[1:-1]] == [
|
||||
f"消息-{index}" for index in range(12)
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry
|
||||
from gfa_core.mas.agents.router_runtime import (
|
||||
RouteDecision,
|
||||
RouterRuntime,
|
||||
)
|
||||
from gfa_core.mas.llm import LLMResponse, LLMToolCall
|
||||
from gfa_core.mas.workflow_models import GoalLedger
|
||||
|
||||
|
||||
@@ -19,10 +23,48 @@ class _PassThroughTools:
|
||||
*,
|
||||
workflow_id: str | None = None,
|
||||
guidance: bool = False,
|
||||
language: str = "zh",
|
||||
) -> str:
|
||||
return query
|
||||
|
||||
|
||||
class _ContextEnrichingTools:
|
||||
@staticmethod
|
||||
def resolve_analysis_handoff_request(
|
||||
query: str,
|
||||
*,
|
||||
workflow_id: str | None = None,
|
||||
guidance: bool = False,
|
||||
language: str = "zh",
|
||||
) -> str:
|
||||
return (
|
||||
f"{query}\n已确认上下文:当前 job 的 subfamily_confirm 已成功完成,"
|
||||
"家族成员和亚家族分类表已确认。"
|
||||
)
|
||||
|
||||
|
||||
class _IntentArgs(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
class _SequenceLLM:
|
||||
def __init__(self, responses: list[LLMResponse]) -> None:
|
||||
self.responses = list(responses)
|
||||
|
||||
async def acall(self, *_args: Any, **_kwargs: Any) -> LLMResponse:
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def _response(content: str = "", *, tool_calls: list[LLMToolCall] | None = None) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
tool_calls=list(tool_calls or []),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "workflow_id", "clarification_question"),
|
||||
(
|
||||
@@ -77,10 +119,118 @@ 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_confirmed_analysis_proposal_skips_second_confirmation() -> None:
|
||||
job_id = "job-confirmed-analysis-handoff"
|
||||
enriched_request = (
|
||||
"执行Ft的PPI和kaks分析\n"
|
||||
"已确认上下文:基因家族 SPL;目标物种 Ft;参考物种 At。"
|
||||
)
|
||||
recognized_queries: list[str] = []
|
||||
registry = ConversationToolRegistry()
|
||||
registry.register(
|
||||
name="recognize_analysis_intent",
|
||||
description="Recognize an analysis intent.",
|
||||
args_model=_IntentArgs,
|
||||
handler=lambda args: recognized_queries.append(args.query)
|
||||
or {
|
||||
"gene_family": "SPL",
|
||||
"stages": ["ppi", "kaks"],
|
||||
"primary_stage": "ppi",
|
||||
"params": {"targetSpecies": ["Ft"], "referenceSpecies": ["At"]},
|
||||
},
|
||||
permission="pure",
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="recognize-confirmed-analysis-1",
|
||||
name="recognize_analysis_intent",
|
||||
arguments=json.dumps(
|
||||
{"query": enriched_request}, ensure_ascii=False
|
||||
),
|
||||
)
|
||||
]
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"propose_analysis",'
|
||||
'"message":"已交给现有分析系统。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
ledger = GoalLedger(
|
||||
conversation_id="session-confirmed-analysis-handoff",
|
||||
revision=1,
|
||||
user_terms={
|
||||
"active_job_id": job_id,
|
||||
"pending_proposal": {
|
||||
"proposal_id": "proposal-confirmed-analysis",
|
||||
"request": enriched_request,
|
||||
"original_request": "执行Ft的PPI和kaks分析",
|
||||
"subject": "执行Ft的PPI和kaks分析",
|
||||
"job_id": job_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, registry, max_iterations=5).run(
|
||||
query="继续",
|
||||
history=[],
|
||||
session_id="session-confirmed-analysis-handoff",
|
||||
job_id=job_id,
|
||||
goal_ledger=ledger,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "propose_analysis"
|
||||
assert outcome.intent is not None
|
||||
assert outcome.intent["stages"] == ["ppi", "kaks"]
|
||||
assert recognized_queries == [enriched_request]
|
||||
|
||||
|
||||
def test_new_analysis_decision_discards_main_agent_workflow_and_clarification() -> None:
|
||||
decision = RouteDecision(
|
||||
goal="new_analysis",
|
||||
@@ -99,3 +249,23 @@ def test_new_analysis_decision_discards_main_agent_workflow_and_clarification()
|
||||
assert normalized.workflow_id is None
|
||||
assert normalized.clarification_question is None
|
||||
assert normalized.analysis_request_kind == "execution"
|
||||
|
||||
|
||||
def test_router_response_language_does_not_rewrite_business_query() -> None:
|
||||
query = "执行接下来的分析"
|
||||
messages = RouterRuntime._initial_messages(
|
||||
query=query,
|
||||
history=[],
|
||||
session_id="english-router-session",
|
||||
job_id="job-english-router",
|
||||
decision=RouteDecision(
|
||||
goal="general",
|
||||
needs=[],
|
||||
subject=query,
|
||||
),
|
||||
goal_ledger=None,
|
||||
language="en",
|
||||
)
|
||||
|
||||
assert messages[-1].content == query
|
||||
assert "Respond to the user in English." in messages[0].content
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gfa_core.mas import context_index, stage_requirements
|
||||
from gfa_core.mas.context_index import ProjectContextIndex
|
||||
from gfa_core.mas.stage_requirements import (
|
||||
resolve_many_stage_requirements,
|
||||
resolve_stage_requirements,
|
||||
)
|
||||
|
||||
|
||||
def test_stage_requirements_does_not_load_unconsumed_global_stage_status(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
|
||||
def fake_get_stage_status(**kwargs):
|
||||
calls.append(kwargs["stage"])
|
||||
return SimpleNamespace(status="missing", manifest_path="", to_dict=lambda: {})
|
||||
|
||||
monkeypatch.setattr(context_index, "get_stage_status", fake_get_stage_status)
|
||||
|
||||
report = resolve_stage_requirements(
|
||||
stage="identify",
|
||||
prefix="Ft.SPL",
|
||||
job_id="job-test",
|
||||
project_root=tmp_path,
|
||||
)
|
||||
|
||||
assert report.stage == "identify"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def _empty_context(tmp_path, job_id):
|
||||
workspace = tmp_path / "workspace" / job_id
|
||||
return ProjectContextIndex(
|
||||
job_id=job_id,
|
||||
workspace=str(workspace),
|
||||
input_dir=str(tmp_path / "input" / job_id),
|
||||
final_dir=str(workspace / "final"),
|
||||
logs_dir=str(workspace / "logs"),
|
||||
)
|
||||
|
||||
|
||||
def test_many_stage_requirements_reuses_context_without_mutating_shared_identity(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
base_context = _empty_context(tmp_path, "job-test")
|
||||
|
||||
def fake_build_project_context(**kwargs):
|
||||
calls.append(kwargs["prefix"])
|
||||
return base_context
|
||||
|
||||
monkeypatch.setattr(
|
||||
stage_requirements,
|
||||
"build_project_context",
|
||||
fake_build_project_context,
|
||||
)
|
||||
|
||||
reports = resolve_many_stage_requirements(
|
||||
stages=["identify", "phylogeny"],
|
||||
prefix="Ft.SPL",
|
||||
job_id="job-test",
|
||||
params_by_stage={
|
||||
"identify": {"targetSpecies": ["Ft"]},
|
||||
"phylogeny": {"targetSpecies": ["Os"]},
|
||||
},
|
||||
project_root=tmp_path,
|
||||
)
|
||||
|
||||
assert calls == ["Ft.SPL"]
|
||||
assert base_context.target_species == []
|
||||
assert reports["identify"]["identity"]["target_species"] == ["Ft"]
|
||||
assert reports["phylogeny"]["identity"]["target_species"] == ["Os"]
|
||||
|
||||
|
||||
def test_many_stage_requirements_caches_context_per_effective_prefix(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
|
||||
def fake_build_project_context(**kwargs):
|
||||
calls.append(kwargs["prefix"])
|
||||
return _empty_context(tmp_path, kwargs["job_id"])
|
||||
|
||||
monkeypatch.setattr(
|
||||
stage_requirements,
|
||||
"build_project_context",
|
||||
fake_build_project_context,
|
||||
)
|
||||
|
||||
resolve_many_stage_requirements(
|
||||
stages=["identify", "phylogeny", "motif"],
|
||||
prefix="Ft.SPL",
|
||||
job_id="job-test",
|
||||
params_by_stage={
|
||||
"identify": {"prefix": "Ft.SPL"},
|
||||
"phylogeny": {"prefix": "Ft.SPL"},
|
||||
"motif": {"prefix": "At.SPL"},
|
||||
},
|
||||
project_root=tmp_path,
|
||||
)
|
||||
|
||||
assert calls == ["Ft.SPL", "At.SPL"]
|
||||
@@ -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