Merge branch 'master' into wip/qa7920-rag-stream

This commit is contained in:
sj
2026-08-21 11:41:16 +08:00
86 changed files with 12873 additions and 1185 deletions
+100 -1
View File
@@ -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
+88
View File
@@ -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分析"
+131 -17
View File
@@ -26,7 +26,7 @@ def _materialize_trusted_ppi_mapping(
pairs = workspace / "ortholog" / f"{prefix}.{target}_to_{storage_key}_pairs.txt"
pairs.parent.mkdir(parents=True, exist_ok=True)
pairs.write_text(f"{subject_id}\t{query_id}\t91\n", encoding="utf-8")
scope_id = build_scope_id(family, [target], [storage_key], [], "target_plus_reference")
scope_id = build_scope_id(family, [target], [], [], "target_only")
identity = build_ortholog_run_identity(
query_species=target, reference_taxon_id=taxon_id,
reference_storage_key=storage_key, query_fasta=query,
@@ -35,14 +35,15 @@ def _materialize_trusted_ppi_mapping(
)
materialize_ortholog_final_outputs(
final_dir=final_dir, prefix=prefix,
dependency_owner="ppi",
run_records=[OrthologRunRecord(target, storage_key, query, reference, pairs)],
job_id=job.job_id, workspace=workspace, reference_species=storage_key,
reference_taxon_id=taxon_id, reference_storage_key=storage_key,
ortholog_identity=identity,
scope_fields={
"scope_id": scope_id, "scope_mode": "target_plus_reference",
"target_species": [target], "reference_species": [storage_key],
"selected_species": [target, storage_key], "family": family,
"scope_id": scope_id, "scope_mode": "target_only",
"target_species": [target], "reference_species": [],
"selected_species": [target], "family": family,
},
)
@@ -125,6 +126,49 @@ def test_reference_bundle_id_map_preserves_loc_and_numeric_geneid(tmp_path):
assert row["ncbi_gene_id"] == "110806180"
def test_reference_bundle_id_map_reads_ensembl_gene_token_for_isoforms(tmp_path):
from gfa_core.id_resolution import ReferenceAliasIndex
pep_path = tmp_path / "At.pep.fasta"
cds_path = tmp_path / "At.cds.fasta"
output_path = tmp_path / "At.pep_cds_idmap.tsv"
pep_path.write_text(
">AT1G02065.1 pep chromosome:TAIR10:1:365209:367443:1 "
"gene:AT1G02065 transcript:AT1G02065.1 gene_symbol:SPL8\nMAAA\n"
">AT1G02065.2 pep chromosome:TAIR10:1:365165:367353:1 "
"gene:AT1G02065 transcript:AT1G02065.2 gene_symbol:SPL8\nMBBB\n",
encoding="utf-8",
)
cds_path.write_text(
">AT1G02065.1 cds chromosome:TAIR10:1:365209:367443:1 "
"gene:AT1G02065 transcript:AT1G02065.1 gene_symbol:SPL8\nATGGCG\n"
">AT1G02065.2 cds chromosome:TAIR10:1:365165:367353:1 "
"gene:AT1G02065 transcript:AT1G02065.2 gene_symbol:SPL8\nATGGCC\n",
encoding="utf-8",
)
backend._build_reference_bundle_id_map(
species_code="At",
pep_path=pep_path,
cds_path=cds_path,
output_path=output_path,
)
with output_path.open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle, delimiter="\t"))
assert {row["protein_id"]: row["gene_id"] for row in rows} == {
"AT1G02065.1": "AT1G02065",
"AT1G02065.2": "AT1G02065",
}
candidates = ReferenceAliasIndex.from_tsv(output_path, "At").candidates("AT1G02065")
assert {candidate.value for candidate in candidates} >= {
"AT1G02065",
"AT1G02065.1",
"AT1G02065.2",
}
def test_reference_bundle_download_extracts_ncbi_zip_and_unblocks_ppi_kaks_preflight(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test_ref_bundle",
@@ -601,7 +645,7 @@ def test_ppi_species_resolution_selected_near_string_species_still_needs_referen
assert resolution["reference_data_source"] == ""
def test_ppi_species_resolution_selected_near_string_species_with_pep_needs_ortholog(tmp_path):
def test_ppi_species_resolution_selected_near_string_species_with_complete_bundle_needs_ortholog(tmp_path):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -615,6 +659,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_pep_needs_orth
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
(input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATG\n", encoding="utf-8")
resolution = backend.resolve_ppi_species_resolution(
job=job,
@@ -692,7 +737,6 @@ def test_ppi_kaks_dependency_preflight_requires_reference_cds_before_ortholog(tm
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
resolution = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
@@ -790,6 +834,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_mapping_is_rea
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
(input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_ppi_mapping(
job, prefix="Ft.SPL", target="Ft", family="SPL",
storage_key="soe", taxon_id="3562", query_id="FtGene1", subject_id="SoeGene1",
@@ -812,7 +857,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_mapping_is_rea
assert resolution["status"] == "ready"
assert resolution["selected_string_species"] == "soe"
assert resolution["selected_taxon_id"] == "3562"
assert resolution["ortholog_mapping_source"].endswith("Ft.SPL.orthologs.txt")
assert resolution["ortholog_mapping_source"].endswith("Ft.SPL.ppi.orthologs.txt")
assert resolution["decision"] == "ready_reference_mapping"
@@ -830,6 +875,7 @@ def test_ppi_species_resolution_can_ignore_existing_mapping_for_pre_ortholog_che
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
(input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_ppi_mapping(
job, prefix="Ft.SPL", target="Ft", family="SPL",
storage_key="soe", taxon_id="3562", query_id="FtGene1", subject_id="SoeGene1",
@@ -864,7 +910,7 @@ def test_ppi_species_resolution_can_ignore_existing_mapping_for_pre_ortholog_che
)
assert ready["status"] == "ready"
assert ready["ortholog_mapping_source"].endswith("Ft.SPL.orthologs.txt")
assert ready["ortholog_mapping_source"].endswith("Ft.SPL.ppi.orthologs.txt")
assert pre_ortholog["status"] == "needs_ortholog"
assert pre_ortholog["ortholog_mapping_source"] == ""
assert pre_ortholog["selected_string_species"] == "soe"
@@ -970,7 +1016,7 @@ def test_kaks_preflight_recovers_non_ath_reference_and_prioritizes_missing_cds(t
assert resolution["reference_bundle_status"]["missing_required"] == ["cds"]
def test_ppi_species_resolution_reports_needs_ortholog_when_reference_pep_exists(tmp_path):
def test_ppi_species_resolution_requires_cds_when_only_reference_pep_exists(tmp_path):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -999,13 +1045,14 @@ def test_ppi_species_resolution_reports_needs_ortholog_when_reference_pep_exists
dependency_owner="ppi",
)
assert resolution["status"] == "needs_ortholog"
assert resolution["issue_code"] == "ppi_requires_ortholog_mapping"
assert resolution["status"] == "needs_reference_data"
assert resolution["issue_code"] == "ppi_reference_data_missing"
assert resolution["reference_bundle_status"]["missing_required"] == ["cds"]
assert resolution["reference_data_source"].endswith("soe/soe.pep.fasta")
assert resolution["selected_string_species"] == "soe"
def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
def test_ppi_reference_bundle_download_requests_pep_and_cds(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -1039,7 +1086,10 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
monkeypatch.setattr(
backend,
"_extract_ncbi_reference_bundle_zip",
lambda **kwargs: {"pep": str(tmp_path / "input" / "soe" / "soe.pep.fasta")},
lambda **kwargs: {
"pep": str(tmp_path / "input" / "soe" / "soe.pep.fasta"),
"cds": str(tmp_path / "input" / "soe" / "soe.cds.fasta"),
},
)
monkeypatch.setattr(
backend,
@@ -1047,8 +1097,8 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
lambda **kwargs: {
"id_map_path": str(tmp_path / "input" / "soe" / "soe.pep_cds_idmap.tsv"),
"pep_records": 1,
"cds_records": 0,
"pep_cds_overlap": 0,
"cds_records": 1,
"pep_cds_overlap": 1,
},
)
@@ -1060,10 +1110,10 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
requested_stages=["ppi"],
)
assert captured["file_types"] == ["pep"]
assert captured["file_types"] == ["pep", "cds"]
assert result["status"] == "success"
assert result["missing_required"] == []
assert result["requested_file_types"] == ["pep"]
assert result["requested_file_types"] == ["pep", "cds"]
def test_ppi_execution_scope_canonicalizes_full_species_names_before_mapping_filter(tmp_path, monkeypatch):
@@ -1323,6 +1373,70 @@ def test_ppi_projected_soe_rejects_missing_id_map(tmp_path, monkeypatch):
assert message == f"❌ PPI projected analysis requires species-matched ID map: {expected}"
def test_ppi_projected_builds_missing_id_map_from_species_matched_pep_and_cds(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
workspace_dir=str(tmp_path / "workspace"),
logs_dir=str(tmp_path / "workspace" / "logs"),
created_at="",
)
input_dir = tmp_path / "input"
workspace = tmp_path / "workspace"
final_dir = workspace / "final"
logs_dir = workspace / "logs"
soe_dir = input_dir / "soe"
soe_dir.mkdir(parents=True)
final_dir.mkdir(parents=True)
logs_dir.mkdir(parents=True)
(soe_dir / "soe.pep.fasta").write_text(">SoeGene1 [gene=SoeGene1]\nMAAA\n", encoding="utf-8")
(soe_dir / "soe.cds.fasta").write_text(">SoeGene1 [gene=SoeGene1]\nATGGCT\n", encoding="utf-8")
(final_dir / "Os.CRR.final_members.id").write_text("Os01t0810800-01\n", encoding="utf-8")
mapping_file = final_dir / "Os.CRR.ppi.orthologs.txt"
mapping_file.write_text("Os01t0810800-01\tSoeGene1\t90\tOs\tsoe\n", encoding="utf-8")
scope_file = final_dir / "Os.CRR.CRR.target-Os.target_only.scope.json"
scope_file.write_text(json.dumps({
"family": "CRR",
"prefix": "Os.CRR",
"scope_id": "CRR.target-Os.target_only",
"scope_mode": "target_only",
"target_species": ["Os"],
"reference_species": [],
"selected_species": ["Os"],
}), encoding="utf-8")
captured = {}
def fake_run_cmd_logged(cmd, log_path, cwd=None):
captured["cmd"] = cmd
_write_fake_ppi_outputs(workspace / "ppi")
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda *args, **kwargs: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run_cmd_logged)
monkeypatch.setattr(backend, "download_string_network_image", lambda *args, **kwargs: None)
message, manifest = backend.run_stage(
"ppi",
job,
"Os.CRR",
ppi_species="auto",
ppi_reference_storage_key="soe",
ppi_reference_taxon_id="3562",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
ppi_score=400,
scope_file=str(scope_file),
analysis_route="single_species",
)
expected = soe_dir / "soe.pep_cds_idmap.tsv"
assert "✅ PPI analysis completed" in message
assert manifest and manifest.exists()
assert expected.is_file()
assert captured["cmd"][captured["cmd"].index("--id-map") + 1] == str(expected)
assert captured["cmd"][captured["cmd"].index("--normalization-species-label") + 1] == "soe"
def test_ppi_explicit_absolute_species_rejected_before_outside_id_map_lookup(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
@@ -0,0 +1,54 @@
from gfa_core.mas.agents.planning_v2 import PlanningAgentV2
def test_ppi_species_does_not_change_unrelated_stage_biological_scope() -> None:
planner = PlanningAgentV2.__new__(PlanningAgentV2)
stages = planner._generate_default_stages(
["motif", "ppi"],
"Ft.SPL",
intent_params={
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"scopeMode": "target_only",
"analysisRoute": "single_species",
"ppiSpecies": "ath",
},
)
stage_by_id = {stage["id"]: stage for stage in stages}
motif_params = stage_by_id["motif"]["params"]
ppi_params = stage_by_id["ppi"]["params"]
assert motif_params["targetSpecies"] == ["Ft"]
assert motif_params["scopeMode"] == "target_only"
assert motif_params["analysisRoute"] == "single_species"
assert not motif_params.get("referenceSpecies")
assert ppi_params["ppiSpecies"] == "ath"
def test_ppi_plan_is_target_only_while_kaks_keeps_project_reference() -> None:
planner = PlanningAgentV2.__new__(PlanningAgentV2)
stages = planner._generate_default_stages(
["ppi", "kaks"],
"Ft.SPL",
intent_params={
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"scopeMode": "target_plus_reference",
"analysisRoute": "multi_species",
},
)
stage_by_id = {stage["id"]: stage for stage in stages}
ppi_params = stage_by_id["ppi"]["params"]
kaks_params = stage_by_id["kaks"]["params"]
assert ppi_params["targetSpecies"] == ["Ft"]
assert ppi_params["referenceSpecies"] == []
assert ppi_params["scopeMode"] == "target_only"
assert ppi_params["analysisRoute"] == "single_species"
assert kaks_params["referenceSpecies"] == ["At"]
assert kaks_params["scopeMode"] == "target_plus_reference"
@@ -4,6 +4,15 @@ import json
import subprocess
from pathlib import Path
import pytest
# 主分支已完成 PPI/KaKs 修复,index.html 中 ppiKaksTemporaryCircuitBreakerEnabled 已置为 false。
# 以下用例仍假设临时熔断处于开启态,需按新状态重写后再启用。
_circuit_breaker_disabled = pytest.mark.skip(
reason="临时熔断已随 PPI/KaKs 修复关闭,用例待按新状态重写"
)
def _source_block(source: str, start: str, end: str) -> str:
start_index = source.index(start)
@@ -150,6 +159,7 @@ def test_batch_start_uses_temporary_circuit_breaker_without_owner_preflight() ->
assert "await executeStagesWithDependencies(stages, plannedStageIds);" in start_block
@_circuit_breaker_disabled
def test_temporary_circuit_breaker_pauses_only_owned_ortholog() -> None:
source = _index_html()
owner_helper = _source_block(
@@ -196,6 +206,7 @@ console.log(JSON.stringify({owned, explicit, unrelated, onePlannedOwner}));
}
@_circuit_breaker_disabled
def test_full_gene_family_plan_keeps_explicit_ortholog_and_other_stages_runnable() -> None:
source = _index_html()
owner_helper = _source_block(
@@ -242,6 +253,7 @@ console.log(JSON.stringify({paused, runnable}));
]
@_circuit_breaker_disabled
def test_dependency_scheduler_executes_later_stages_while_ppi_kaks_are_paused() -> None:
source = _index_html()
owner_helper = _source_block(
@@ -357,6 +369,7 @@ submitExecuteRequestWithAutoAnnotation({stage: 'ppi'}, 'ppi')
}
@_circuit_breaker_disabled
def test_submit_guard_blocks_owned_ortholog_but_not_independent_manual_ortholog() -> None:
source = _index_html()
owner_helper = _source_block(
+182 -17
View File
@@ -1,5 +1,7 @@
from pathlib import Path
from apps.web.vue3 import main as web_main
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -37,12 +39,12 @@ def test_ppi_alphafold_links_remain_in_file_list_and_use_table_preview():
assert "tablePreviewFilteredRows" in html
def test_natural_language_ppi_reset_intent_opens_reset_dialog():
def test_ppi_result_action_opens_reset_dialog():
html = _index_html()
assert "isPpiResetIntent" in html
assert "raw.includes('基因id')" in html
assert "openPpiResetDialog()" in html
assert "PPI Reset 基因列表编辑弹窗" in html
assert '@click="openPpiResetDialog(stage)"' in html
assert 'title="PPI Reset 输入基因列表"' in html
def test_ppi_reset_uses_current_stage_prefix_before_default_execution_prefix():
@@ -91,7 +93,28 @@ def test_ppi_execute_path_runs_species_resolution_preflight_before_execute():
assert "Spinacia oleracea" not in html
assert "const shouldRunPpiKaksPreflight =" in html
assert "const currentIsDependencyOwner = ppiKaksDependencyStages.has(currentStageId) || ppiKaksDependencyStages.has(requestBody.stage)" in html
assert "PPI/KaKs 需要先处理参考物种/Ortholog" in html
assert "id: 'ppi_kaks_reference_status'" in html
assert "PPI/KaKs reference data and Ortholog mapping required" in html
def test_ppi_kaks_temporary_breaker_is_disabled_and_stage_failure_keeps_scheduler_alive():
html = _index_html()
assert "const ppiKaksTemporaryCircuitBreakerEnabled = false;" in html
assert "await ensureOrthologProducerForPpiKaks();" in html
assert "executeStageWithConfirmation(stage" in html
assert ".finally(() => scheduleCheck(0));" in html
catch_start = html.index(" } catch (err) {", html.index("const executeStageWithConfirmation = async"))
catch_end = html.index(" } finally {", catch_start)
ordinary_failure = html[catch_start:catch_end]
assert "stageExecutionStatus.value[stageId] = 'failed';" in ordinary_failure
assert "console.error('[ERROR] Stage failed:', stageId, err);" in ordinary_failure
scheduler_start = html.index("const executeStagesWithDependencies = async")
scheduler_end = html.index("const NCBI_SPECIES_DEFAULTS", scheduler_start)
scheduler = html[scheduler_start:scheduler_end]
assert "return deps.some(dep => stageExecutionStatus.value[dep] === 'failed');" in scheduler
assert "stageExecutionStatus.value[stageId] = 'failed';" in scheduler
def test_ortholog_scope_dialog_and_ppi_queue_preflight_are_wired():
@@ -103,16 +126,20 @@ def test_ortholog_scope_dialog_and_ppi_queue_preflight_are_wired():
assert "ppiHasNearReferenceBeforeArabidopsis" in html
def test_ppi_kaks_dependency_owners_are_preflighted_independently():
def test_ppi_is_preflighted_first_and_kaks_is_deferred_to_its_confirmation():
html = _index_html()
assert "const ppiKaksDependencyStages = new Set(['ppi', 'kaks'])" in html
assert "const ensurePpiKaksDependencyOwner = async (ownerStageId) =>" in html
assert "const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>" in html
assert "const ensureOrthologProducerForPpiKaks = async" in html
assert "for (const ownerStageId of requestedStages)" in html
assert "await ensurePpiKaksDependencyOwner(ownerStageId)" in html
assert "const requestedStages = [ownerStageId];" in html
assert "autoInsertedFor: requestedStages" in html
assert "referencePolicySource: 'ppi_kaks_dependency_preflight'" in html
assert ".filter(stageId => stageId === 'ppi')" in html
assert "const prepareKaksSelectedReference = async" in html
assert "await ensurePpiKaksDependencyOwner('kaks', stageParams)" in html
assert "referencePolicySource: 'ppi_owner_ortholog_producer'" in html
assert "referencePolicySource: 'kaks_owner_ortholog_producer'" in html
assert "await ensureOrthologProducerForPpiKaks()" in html
@@ -125,18 +152,40 @@ def test_ppi_preflight_uses_current_stage_and_releases_direct_target_from_orthol
assert "releaseDirectTargetPpiFromOrtholog(preflight.resolution);" in html
def test_ppi_needing_mapping_attaches_to_an_existing_ortholog_stage():
def test_ppi_needing_mapping_uses_hidden_internal_ortholog_producer():
html = _index_html()
start = html.index("const ensureOrthologStageForPpiKaks =")
end = html.index("const ensurePpiKaksDependencyOwner =", start)
producer = html[start:end]
attach = "attachOrthologDependencyToOwners(requestedStages);"
existing = "if (stageIds.includes('ortholog'))"
assert attach in producer
assert producer.index(attach) < producer.index(existing)
assert "rememberPpiKaksInternalOrthologProducer('ppi'" in producer
assert "attachOrthologDependencyToOwners(requestedStages);" not in producer
assert "nextStages.splice" not in producer
assert "batchExecuteStages.value = nextStages" not in producer
def test_ppi_static_dependencies_exclude_ortholog_while_kaks_keeps_it():
def test_ppi_internal_ortholog_executes_without_visible_stage_scheduler():
html = _index_html()
start = html.index("const executePpiOwnedOrthologProducer =")
end = html.index("const waitForPpiProducerDependency =", start)
executor = html[start:end]
assert "executeStageDirect('ortholog', producerParams)" in executor
assert "executeStagesWithDependencies([producerStage]" not in executor
assert "stageExecutionStatus.value.ortholog" not in executor
def test_kaks_confirmation_chooses_ppi_or_project_reference_and_mapping_owner():
html = _index_html()
assert 'v-model="confirmParams.kaksReferenceSource"' in html
assert 'label="ppi"' in html
assert 'label="project"' in html
assert "orthologMappingOwner: usePpi ? 'ppi' : 'kaks'" in html
assert "requestBody.orthologMappingOwner = stageParams.orthologMappingOwner" in html
def test_ppi_and_kaks_static_dependencies_hide_internal_ortholog():
html = _index_html()
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
@@ -144,11 +193,127 @@ def test_ppi_static_dependencies_exclude_ortholog_while_kaks_keeps_it():
assert "'ppi': ['identify']," in html
assert '"ppi": ["identify"],' in catalog
assert '"ppi": ["identify"],' in main_py
assert "'kaks': ['identify', 'ortholog']," in html
assert '"kaks": ["identify", "ortholog"],' in catalog
assert '"kaks": ["identify", "ortholog"],' in main_py
assert "'kaks': ['identify']," in html
assert '"kaks": ["identify"],' in catalog
assert '"kaks": ["identify"],' in main_py
assert '"ortholog": ["identify"],' in catalog
def test_normal_full_gene_family_plan_excludes_ortholog_and_orders_ppi_before_kaks():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
workflow_start = main_py.index("FULL_GENE_FAMILY_WORKFLOW_STAGES = [")
workflow_end = main_py.index("]", workflow_start)
workflow_block = main_py[workflow_start:workflow_end]
assert '"ortholog"' not in workflow_block
assert workflow_block.index('"ppi"') < workflow_block.index('"kaks"')
context_start = main_py.index("def _downstream_gene_family_stages_for_context")
context_end = main_py.index("def _resolve_contextual_gene_family_request", context_start)
context_block = main_py[context_start:context_end]
full_workflow_start = context_block.index("if _looks_like_gene_family_workflow_query(text):")
full_workflow_end = context_block.index("return stages", full_workflow_start)
full_workflow_block = context_block[full_workflow_start:full_workflow_end]
assert '"ortholog"' not in full_workflow_block
assert full_workflow_block.index('"ppi"') < full_workflow_block.index('"kaks"')
normalize_start = main_py.index("def _normalize_full_gene_family_workflow_plan")
normalize_end = main_py.index("def _", normalize_start + 4)
normalize_block = main_py[normalize_start:normalize_end]
assert 'if sid == "ortholog":' in normalize_block
def test_confirmed_full_workflow_recovers_original_user_request_from_history():
history = [
{"role": "user", "content": "执行基因家族分析"},
{"role": "assistant", "content": "是否继续?"},
{"role": "user", "content": "继续"},
]
assert web_main._full_gene_family_workflow_query_text("继续", history) == "执行基因家族分析"
assert web_main._full_gene_family_workflow_query_text("执行直系同源基因鉴定", []) == ""
def test_confirmed_full_workflow_removes_visible_ortholog_and_stale_kaks_dependency():
stages = [
"phylogeny", "motif", "domain", "structure", "combo", "promoter",
"synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
]
details = [web_main._stage_detail_defaults(stage) for stage in stages]
next(item for item in details if item["id"] == "kaks")["dependencies"] = ["identify", "ortholog"]
normalized_stages, normalized_details = web_main._normalize_full_gene_family_workflow_plan(
message="执行基因家族分析",
stage_ids=stages,
stages_detail=details,
plan_params={"targetSpecies": ["Ft"], "referenceSpecies": ["At"], "geneFamily": "SPL"},
plan_prefix="Ft.SPL",
)
assert "ortholog" not in normalized_stages
assert normalized_stages.index("ppi") < normalized_stages.index("kaks")
detail_by_id = {item["id"]: item for item in normalized_details}
assert detail_by_id["ppi"]["dependencies"] == ["identify"]
assert detail_by_id["kaks"]["dependencies"] == ["identify"]
assert detail_by_id["ppi"]["params"]["targetSpecies"] == ["Ft"]
assert detail_by_id["ppi"]["params"]["referenceSpecies"] == []
assert detail_by_id["ppi"]["params"]["scopeMode"] == "target_only"
assert detail_by_id["ppi"]["params"]["analysisRoute"] == "single_species"
assert detail_by_id["kaks"]["params"]["referenceSpecies"] == ["At"]
def test_deterministic_context_exit_applies_confirmed_full_workflow_contract():
request = web_main.AgentPlanRequest(
message="继续",
prefix="Ft.SPL",
history=[
{"role": "user", "content": "执行基因家族分析"},
{"role": "assistant", "content": "是否继续?"},
{"role": "user", "content": "继续"},
],
intent={"params": {"referenceSpecies": ["At"]}},
)
contextual_request = {
"family": "SPL",
"prefix": "Ft.SPL",
"stages": [
"phylogeny", "motif", "domain", "structure", "combo", "promoter",
"synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
],
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"comparativeSpecies": [],
"scopeMode": "target_plus_reference",
"requiresConfirmedSubfamily": True,
"skipSubfamilyLabels": False,
"subfamilyState": {"by_species": {"Ft": {"status": "confirmed"}}},
"contextGate": {"status": "ready", "issues": []},
}
response = web_main._build_contextual_gene_family_plan_response(
request,
"test_confirmed_full_workflow",
contextual_request,
)
assert response["mode"] == "deterministic_context"
assert "ortholog" not in response["plan"]["stages"]
assert response["plan"]["stages"].index("ppi") < response["plan"]["stages"].index("kaks")
detail_by_id = {item["id"]: item for item in response["plan"]["stages_detail"]}
assert detail_by_id["kaks"]["dependencies"] == ["identify"]
def test_kaks_request_carries_selected_ortholog_mapping_owner_to_backend():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
assert 'orthologMappingOwner: str = ""' in main_py
assert '"orthologMappingOwner": getattr(req, \'orthologMappingOwner\', \'\')' in main_py
assert (
'ortholog_mapping_owner=params.get("orthologMappingOwner") '
'or params.get("ortholog_mapping_owner") or ""'
) in main_py
def test_ppi_plan_generation_never_inserts_static_ortholog_dependency():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
@@ -180,7 +345,7 @@ def test_ppi_preflight_recovered_reference_is_written_to_ppi_owner_only():
def test_ready_preflight_propagates_recovered_reference_before_early_return():
html = _index_html()
start = html.index("const ensurePpiKaksDependencyOwner = async (ownerStageId) =>")
start = html.index("const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>")
end = html.index("const ensureOrthologProducerForPpiKaks = async () =>", start)
producer = html[start:end]
propagation = "applyResolvedPpiReferenceToBatch(preflight.resolution, requestedStages, requestBody);"
@@ -47,6 +47,9 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
workbook.save(input_dir / "config.xlsx")
reference_pep = storage_dir / "string_taxon_4555.pep.fasta"
reference_pep.write_text(">Seita.1\nMA\n", encoding="utf-8")
(storage_dir / "string_taxon_4555.cds.fasta").write_text(
">Seita.1\nATGGCT\n", encoding="utf-8"
)
query_pep = final_dir / "Sv.HKL.final_members.pep.fa"
query_pep.write_text(">SvGene1\nMA\n", encoding="utf-8")
scope_file = final_dir / "Sv.HKL.scope.json"
@@ -78,9 +81,17 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(reference_pep)
assert (final_dir / "Sv.HKL.ppi.orthologs.txt").exists()
assert not (final_dir / "Sv.HKL.orthologs.txt").exists()
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
assert ".ppi." in Path(manifest_path).name
assert manifest["parameters"]["dependency_owner"] == "ppi"
assert manifest["parameters"]["reference_species"] == "string_taxon_4555"
assert manifest["parameters"]["reference_taxon_id"] == "4555"
assert manifest["scope_id"] == "HKL.target-Sv.target_only"
assert manifest["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
assert manifest["stage_context"]["selected_species"] == ["Sv"]
resolution = backend.resolve_ppi_species_resolution(
job=job, prefix="Sv.HKL", scope_file=str(scope_file),
target_species=["Sv"], family="HKL",
@@ -90,7 +101,6 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
)
assert resolution["status"] == "ready"
assert resolution["mapping_reference_species"] == "string_taxon_4555"
reference_pep.write_text(">Seita.1\nMAGA\n", encoding="utf-8")
stale_reference = backend.resolve_ppi_species_resolution(
job=job, prefix="Sv.HKL", target_species=["Sv"], family="HKL",
@@ -111,6 +121,145 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
assert stale_target["status"] == "needs_ortholog"
def test_ppi_owner_effective_scope_ignores_project_and_technical_references() -> None:
assert backend._effective_ppi_owner_scope_id(
family="SPL",
selected_targets=["Ft"],
scope_data={
"scope_id": "SPL.target-Ft.ref-At.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": ["At"],
"comparative_species": ["Os"],
},
reference_storage_key="string_taxon_3562",
) == "SPL.target-Ft.target_only"
def test_ppi_owned_ortholog_uses_whole_reference_pep_when_family_pep_also_exists(
tmp_path: Path, monkeypatch
) -> None:
job = _job(tmp_path)
input_dir = Path(job.input_dir)
final_dir = Path(job.workspace_dir) / "final"
reference_dir = input_dir / "At"
reference_dir.mkdir()
whole_reference_pep = reference_dir / "At.pep.fasta"
whole_reference_pep.write_text(
">AT5G16970.1 gene:AT5G16970\nMAGA\n", encoding="utf-8"
)
(reference_dir / "At.cds.fasta").write_text(
">AT5G16970.1 gene:AT5G16970\nATGGCTGGCGCT\n", encoding="utf-8"
)
family_reference_pep = input_dir / "At.SPL.pep.fasta"
family_reference_pep.write_text(">AT5G16970.1\nMA\n", encoding="utf-8")
query_pep = final_dir / "Ft.SPL.final_members.pep.fa"
query_pep.write_text(">FtSPL1\nMA\n", encoding="utf-8")
scope_file = final_dir / "Ft.SPL.scope.json"
scope_file.write_text(json.dumps({
"family": "SPL",
"scope_id": "SPL.target-Ft.target_only",
"scope_mode": "target_only",
"target_species": ["Ft"],
"reference_species": [],
"comparative_species": [],
"selected_species": ["Ft"],
}), encoding="utf-8")
launched: list[list[str]] = []
def fake_run(cmd, *_args, **_kwargs):
launched.append(list(cmd))
run_prefix = cmd[cmd.index("--prefix") + 1]
pairs = Path(job.workspace_dir) / "ortholog" / f"{run_prefix}_pairs.txt"
pairs.write_text("AT5G16970.1\tFtSPL1\t91\n", encoding="utf-8")
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
message, manifest_path = backend.run_stage(
"ortholog",
job,
"Ft.SPL",
scope_file=str(scope_file),
ppi_reference_taxon_id="3702",
ppi_reference_storage_key="At",
ppi_reference_selection_mode="model_fallback",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
)
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(whole_reference_pep)
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
assert manifest["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
resolution = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
scope_file=str(scope_file),
target_species=["Ft"],
family="SPL",
ppi_reference_taxon_id="3702",
ppi_reference_storage_key="At",
ppi_reference_selection_mode="model_fallback",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
requested_stages=["ppi"],
)
assert resolution["status"] == "ready", resolution
def test_independent_ortholog_keeps_family_reference_pep_priority(
tmp_path: Path, monkeypatch
) -> None:
job = _job(tmp_path)
input_dir = Path(job.input_dir)
final_dir = Path(job.workspace_dir) / "final"
reference_dir = input_dir / "At"
reference_dir.mkdir()
whole_reference_pep = reference_dir / "At.pep.fasta"
whole_reference_pep.write_text(">AT5G16970.1\nMAGA\n", encoding="utf-8")
family_reference_pep = input_dir / "At.SPL.pep.fasta"
family_reference_pep.write_text(">AT5G16970.1\nMA\n", encoding="utf-8")
(final_dir / "Ft.SPL.final_members.pep.fa").write_text(
">FtSPL1\nMA\n", encoding="utf-8"
)
scope_file = final_dir / "Ft.SPL.scope.json"
scope_file.write_text(json.dumps({
"family": "SPL",
"scope_id": "SPL.target-Ft.ref-At.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": ["At"],
"comparative_species": [],
"selected_species": ["Ft", "At"],
}), encoding="utf-8")
launched: list[list[str]] = []
def fake_run(cmd, *_args, **_kwargs):
launched.append(list(cmd))
run_prefix = cmd[cmd.index("--prefix") + 1]
pairs = Path(job.workspace_dir) / "ortholog" / f"{run_prefix}_pairs.txt"
pairs.write_text("AT5G16970.1\tFtSPL1\t91\n", encoding="utf-8")
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
message, _manifest_path = backend.run_stage(
"ortholog",
job,
"Ft.SPL",
scope_file=str(scope_file),
)
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(family_reference_pep)
assert launched[0][launched[0].index("--subject") + 1] != str(whole_reference_pep)
@pytest.mark.parametrize("scope_id_input", ["present", "empty", "missing"])
def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
tmp_path: Path, monkeypatch, scope_id_input: str
@@ -128,6 +277,9 @@ def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
workbook.save(input_dir / "config.xlsx")
reference_pep = storage_dir / "string_taxon_4555.pep.fasta"
reference_pep.write_text(">Seita.1\nMA\n", encoding="utf-8")
(storage_dir / "string_taxon_4555.cds.fasta").write_text(
">Seita.1\nATGGCT\n", encoding="utf-8"
)
query_pep = final_dir / "Sv.HKL.final_members.pep.fa"
query_pep.write_text(">SvGene1\nMA\n", encoding="utf-8")
scope_id = "HKL.target-Sv.target_only"
@@ -170,7 +322,7 @@ def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
assert manifest["stage_context"]["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
mapping = final_dir / "Sv.HKL.orthologs.txt"
mapping = final_dir / "Sv.HKL.ppi.orthologs.txt"
recorded = json.loads(Path(f"{mapping}.identity.json").read_text(encoding="utf-8"))
expected = build_ortholog_run_identity(
query_species="Sv", reference_taxon_id="4555",
@@ -279,3 +431,115 @@ def test_materialized_manifest_persists_owner_and_mapping_identity(tmp_path: Pat
"target_identity", "member_identity", "mapping_identity", "mapping_path",
):
assert manifest["parameters"][key]
def test_ppi_and_kaks_owned_mappings_coexist_without_overwrite(tmp_path: Path) -> None:
final_dir = tmp_path / "final"
query = tmp_path / "Ft.pep.fa"
query.write_text(">Ft1\nMA\n", encoding="utf-8")
def materialize(owner: str, reference_species: str, reference_id: str) -> tuple[Path, Path]:
reference = tmp_path / f"{reference_species}.pep.fasta"
pairs = tmp_path / f"{owner}.pairs.txt"
reference.write_text(f">{reference_id}\nMA\n", encoding="utf-8")
pairs.write_text(f"{reference_id}\tFt1\t90\n", encoding="utf-8")
_, manifest_path = materialize_ortholog_final_outputs(
final_dir=final_dir,
prefix="Ft.SPL",
dependency_owner=owner,
run_records=[OrthologRunRecord("Ft", reference_species, query, reference, pairs)],
job_id="task5b",
workspace=tmp_path,
reference_species=reference_species,
reference_storage_key=reference_species,
scope_fields={
"scope_id": f"SPL.target-Ft.ref-{reference_species}.target_plus_reference",
"target_species": ["Ft"],
"reference_species": [reference_species],
},
)
return final_dir / f"Ft.SPL.{owner}.orthologs.txt", manifest_path
ppi_mapping, ppi_manifest = materialize("ppi", "string_taxon_3562", "Soe1")
ppi_mapping_before = ppi_mapping.read_bytes()
ppi_manifest_before = ppi_manifest.read_bytes()
kaks_mapping, kaks_manifest = materialize("kaks", "At", "AT1G01010")
assert ppi_mapping.exists()
assert kaks_mapping.exists()
assert ppi_mapping != kaks_mapping
assert ppi_mapping.read_bytes() == ppi_mapping_before
assert ppi_manifest.read_bytes() == ppi_manifest_before
assert json.loads(ppi_manifest.read_text(encoding="utf-8"))["parameters"]["dependency_owner"] == "ppi"
assert json.loads(kaks_manifest.read_text(encoding="utf-8"))["parameters"]["dependency_owner"] == "kaks"
def test_owner_specific_mapping_candidates_never_fall_back_to_another_owner(tmp_path: Path) -> None:
final_dir = tmp_path / "final"
final_dir.mkdir()
ppi_candidates = backend.ortholog_mapping_candidates(
final_dir,
"Ft.SPL",
dependency_owner="ppi",
scope_id="scope-v1",
)
assert ppi_candidates == [
final_dir / "Ft.SPL.ppi.scope-v1.orthologs.txt",
final_dir / "Ft.SPL.ppi.scope-v1.ortholog_pairs.txt",
final_dir / "Ft.SPL.ppi.orthologs.txt",
final_dir / "Ft.SPL.ppi.ortholog_pairs.txt",
]
assert all(".kaks." not in path.name for path in ppi_candidates)
assert all(path.name != "Ft.SPL.orthologs.txt" for path in ppi_candidates)
def test_kaks_selected_ppi_owner_consumes_only_ppi_mapping(tmp_path: Path, monkeypatch) -> None:
job = _job(tmp_path)
input_dir = Path(job.input_dir)
final_dir = Path(job.workspace_dir) / "final"
reference = "string_taxon_3562"
scope_file = final_dir / "Ft.SPL.scope.json"
scope_file.write_text(json.dumps({
"family": "SPL",
"scope_id": f"SPL.target-Ft.ref-{reference}.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": [reference],
"selected_species": ["Ft", reference],
}), encoding="utf-8")
(input_dir / "Ft.cds.fasta").write_text(">Ft1\nATGGCT\n", encoding="utf-8")
(input_dir / f"{reference}.cds.fasta").write_text(">PpiGene\nATGGCT\n", encoding="utf-8")
(input_dir / f"{reference}.pep.fasta").write_text(">PpiGene\nMA\n", encoding="utf-8")
(final_dir / "Ft.SPL.final_members.pep.fa").write_text(">Ft1\nMA\n", encoding="utf-8")
(final_dir / "Ft.SPL.ppi.orthologs.txt").write_text(
f"Ft1\tPpiGene\t91\tFt\t{reference}\n", encoding="utf-8"
)
(final_dir / "Ft.SPL.kaks.orthologs.txt").write_text(
f"Ft1\tWrongKaksGene\t88\tFt\t{reference}\n", encoding="utf-8"
)
observed_pairs: list[str] = []
def fake_run(cmd, *_args, **_kwargs):
pair_path = Path(cmd[cmd.index("--pairs") + 1])
observed_pairs.append(pair_path.read_text(encoding="utf-8"))
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
message, manifest_path = backend.run_stage(
"kaks",
job,
"Ft.SPL",
scope_file=str(scope_file),
ortholog_mapping_owner="ppi",
)
assert message.startswith("✅")
assert observed_pairs and "PpiGene" in observed_pairs[0]
assert "WrongKaksGene" not in observed_pairs[0]
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
assert manifest["parameters"]["ortholog_mapping"].endswith("Ft.SPL.ppi.orthologs.txt")
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -65,6 +65,10 @@ def _assert_complete_identity(identity: dict) -> None:
def _prepare_owner_execution_fixture(job: backend.JobInfo) -> Path:
(Path(job.workspace_dir) / "final" / "Sv.HKL.final_members.id").write_text(
"SvGene1\n",
encoding="utf-8",
)
scope = Path(job.input_dir) / "ppi-owner.scope.json"
scope.write_text(
json.dumps(
@@ -79,7 +83,7 @@ def _prepare_owner_execution_fixture(job: backend.JobInfo) -> Path:
),
encoding="utf-8",
)
mapping = Path(job.workspace_dir) / "final" / "Sv.HKL.owner-target-only.orthologs.txt"
mapping = Path(job.workspace_dir) / "final" / "Sv.HKL.ppi.orthologs.txt"
mapping.write_text(
"SvGene1\tSeita.1\t91\tSv\tstring_taxon_4555\n",
encoding="utf-8",
@@ -300,6 +304,7 @@ def test_waiting_preflights_write_decision_records_without_failed_stage_manifest
storage = Path(job.input_dir) / "string_taxon_4555"
storage.mkdir()
(storage / "string_taxon_4555.pep.fasta").write_text(">Seita1\nMA\n", encoding="utf-8")
(storage / "string_taxon_4555.cds.fasta").write_text(">Seita1\nATGGCT\n", encoding="utf-8")
needs_ortholog = _sv_preflight(job, confirmed=True)
assert [confirmation["status"], missing_pep["status"], needs_ortholog["status"]] == [
@@ -316,6 +321,37 @@ def test_waiting_preflights_write_decision_records_without_failed_stage_manifest
assert not (Path(job.workspace_dir) / "final" / "Sv.HKL.ppi.manifest.json").exists()
def test_confirmed_arabidopsis_model_fallback_is_accepted_outside_nearest_candidates(
tmp_path: Path,
) -> None:
job = _job(tmp_path, job_id="ft-arabidopsis-fallback")
storage = Path(job.input_dir) / "At"
storage.mkdir()
(storage / "At.pep.fasta").write_text(">AT1G01010.1\nMA\n", encoding="utf-8")
result = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
target_species=["Ft"],
family="SPL",
target_scientific_name="Fagopyrum tataricum",
ppi_target_tree_taxon_id="62330",
ppi_reference_taxon_id="3702",
ppi_reference_storage_key="At",
ppi_reference_selection_mode="model_fallback",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
ignore_existing_ortholog=True,
requested_stages=["ppi"],
)
assert result["status"] == "needs_ortholog"
assert result["selected_taxon_id"] == "3702"
assert result["reference_species"] == "At"
assert result["ppi_provenance"]["selection_mode"] == "model_fallback"
assert result["ppi_provenance"]["user_confirmation"]["confirmed"] is True
@pytest.mark.parametrize(
("selection_mode", "status", "expected"),
[
@@ -0,0 +1,127 @@
from pathlib import Path
import json
import re
import subprocess
ROOT = Path(__file__).resolve().parents[2]
HTML_PATH = ROOT / "apps/web/vue3/index.html"
def _html() -> str:
return HTML_PATH.read_text(encoding="utf-8")
def _function_block(html: str, start: str, end: str) -> str:
return html.split(start, 1)[1].split(end, 1)[0]
def test_both_reference_messages_use_stage_confirm_dialog_shell():
html = _html()
nearest = _function_block(
html,
"const confirmNearestPpiReferenceCandidate = async",
"const ppiReferenceDecisionMessage =",
)
preflight = _function_block(
html,
"const runPpiSpeciesResolutionPreflight = async",
"const ppiKaksDependencyStages =",
)
assert "id: 'ppi_string_reference_confirmation'" in nearest
assert "showStageConfirmDialog" in nearest
assert "ElMessageBox.confirm" not in nearest
assert "id: 'ppi_kaks_reference_status'" in preflight
assert "showStageConfirmDialog" in preflight
assert "'PPI/KaKs reference data and Ortholog mapping required'" not in preflight
def test_stage_confirm_shell_renders_grouped_fields_and_ordered_candidates():
html = _html()
assert "confirmParams.stage === 'ppi_string_reference_confirmation'" in html
assert "confirmParams.stage === 'ppi_kaks_reference_status'" in html
assert html.count('<ol class="ppi-ref-candidate-list">') >= 2
assert 'v-for="(candidate, index) in confirmParams.ppiKaksReferenceCandidates"' in html
for label in (
"Target species",
"Requested stages",
"Required data",
"Missing data",
"Current reference species",
"Ortholog mapping",
"STRING TaxID",
"LCA",
"Topology distance",
):
assert label in html
def test_special_dialogs_preserve_manual_actions_without_batch_controls():
html = _html()
assert "Confirm top candidate" in html
assert "Cancel PPI" in html
assert "Reference status acknowledged" in html
assert "stageConfirmCountdown > 0" in html
def test_candidate_normalizer_supports_topology_and_recommendation_shapes():
html = _html()
match = re.search(
r"const normalizePpiReferenceCandidates = \(source = \{\}, fallbackVersion = ''\) => \{.*?\n\s*\};",
html,
re.S,
)
assert match, "normalizePpiReferenceCandidates helper is missing"
function_source = match.group(0).replace(
"const normalizePpiReferenceCandidates =",
"globalThis.normalizePpiReferenceCandidates =",
1,
)
script = f"""
{function_source}
const topology = normalizePpiReferenceCandidates({{
candidates: [{{
selected_string_name: 'Spinacia oleracea',
selected_string_taxon_id: 3562,
lca_name: 'Caryophyllales',
topology_distance: 4,
string_version: '12.0'
}}]
}});
const recommendation = normalizePpiReferenceCandidates({{
diagnostics: {{ reference_recommendations: [{{
species_name: 'Arabidopsis thaliana',
species_code: 'ath',
taxon_id: 3702,
relationship: 'model_fallback',
source: 'project_reference'
}}] }}
}}, '12.0');
process.stdout.write(JSON.stringify({{ topology, recommendation }}));
"""
completed = subprocess.run(
["node", "-e", script],
cwd=ROOT,
text=True,
capture_output=True,
check=True,
)
data = json.loads(completed.stdout)
assert data["topology"][0] == {
"name": "Spinacia oleracea",
"code": "",
"taxon": "3562",
"lca": "Caryophyllales",
"distance": "4",
"version": "12.0",
"relationship": "",
"source": "",
}
assert data["recommendation"][0]["name"] == "Arabidopsis thaliana"
assert data["recommendation"][0]["taxon"] == "3702"
assert data["recommendation"][0]["relationship"] == "model_fallback"
assert data["recommendation"][0]["source"] == "project_reference"
@@ -58,7 +58,7 @@ def _materialize_trusted_mapping(
pairs = workspace / "ortholog" / f"{prefix}.{target}_to_{storage_key}_pairs.txt"
pairs.parent.mkdir(parents=True, exist_ok=True)
pairs.write_text(f"{subject_id}\t{query_id}\t91\n", encoding="utf-8")
scope_id = build_scope_id(family, [target], [storage_key], [], "target_plus_reference")
scope_id = build_scope_id(family, [target], [], [], "target_only")
identity = build_ortholog_run_identity(
query_species=target, reference_taxon_id=taxon_id,
reference_storage_key=storage_key, query_fasta=query,
@@ -67,14 +67,15 @@ def _materialize_trusted_mapping(
)
materialize_ortholog_final_outputs(
final_dir=final_dir, prefix=prefix,
dependency_owner="ppi",
run_records=[OrthologRunRecord(target, storage_key, query, reference, pairs)],
job_id=job.job_id, workspace=workspace, reference_species=storage_key,
reference_taxon_id=taxon_id, reference_storage_key=storage_key,
ortholog_identity=identity,
scope_fields={
"scope_id": scope_id, "scope_mode": "target_plus_reference",
"target_species": [target], "reference_species": [storage_key],
"selected_species": [target, storage_key], "family": family,
"scope_id": scope_id, "scope_mode": "target_only",
"target_species": [target], "reference_species": [],
"comparative_species": [], "selected_species": [target], "family": family,
},
)
@@ -168,8 +169,8 @@ def test_confirmed_nearest_without_pep_requests_only_reference_data(tmp_path: Pa
assert result["status"] == "needs_reference_data"
assert result["issue_code"] == "ppi_reference_data_missing"
assert result["reference_bundle_status"]["required_files"] == ["pep"]
assert result["reference_bundle_status"]["missing_required"] == ["pep"]
assert result["reference_bundle_status"]["required_files"] == ["pep", "cds"]
assert result["reference_bundle_status"]["missing_required"] == ["pep", "cds"]
assert result["required_next_stage"] == ""
assert result["selected_taxon_id"] == "4555"
@@ -179,6 +180,7 @@ def test_confirmed_nearest_with_pep_needs_ortholog(tmp_path: Path) -> None:
reference_dir = Path(job.input_dir) / "string_taxon_4555"
reference_dir.mkdir()
(reference_dir / "string_taxon_4555.pep.fasta").write_text(">Seita.1\nMA\n", encoding="utf-8")
(reference_dir / "string_taxon_4555.cds.fasta").write_text(">Seita.1\nATGGCT\n", encoding="utf-8")
result = _resolve_sv(
job,
@@ -200,6 +202,7 @@ def test_confirmed_nearest_with_matching_mapping_is_ready(tmp_path: Path) -> Non
reference_dir = Path(job.input_dir) / "string_taxon_4555"
reference_dir.mkdir()
(reference_dir / "string_taxon_4555.pep.fasta").write_text(">Seita.1\nMA\n", encoding="utf-8")
(reference_dir / "string_taxon_4555.cds.fasta").write_text(">Seita.1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_mapping(
job, prefix="Sv.HKL", target="Sv", family="HKL",
storage_key="string_taxon_4555", taxon_id="4555",
@@ -0,0 +1,163 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
HTML_PATH = Path("apps/web/vue3/index.html")
def _source_block(source: str, start: str, end: str) -> str:
return source[source.index(start) : source.index(end)]
def _run_node(script: str) -> dict:
completed = subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout.strip())
def _progress_state_script(body: str) -> str:
html = HTML_PATH.read_text(encoding="utf-8")
updater = _source_block(
html,
"const updateReferenceBundleDownloadProgress =",
"const pollReferenceBundleDownloadTask =",
)
return f"""
const referenceBundleDownloadState = {{value: {{
visible: false, taskId: '', speciesLabel: '', message: '',
percentage: 0, indeterminate: true, status: '',
bytesDownloaded: 0, totalBytes: 0
}}}};
{updater}
{body}
"""
def test_unknown_total_keeps_indeterminate_progress_and_reports_downloaded_bytes() -> None:
result = _run_node(
_progress_state_script(
"""
updateReferenceBundleDownloadProgress({
id: 'task-download', status: 'running',
progress: {bytes_downloaded: 8388608, total_bytes: 0, indeterminate: true}
}, {species_name: 'Spinacia oleracea'});
console.log(JSON.stringify(referenceBundleDownloadState.value));
"""
)
)
assert result["visible"] is True
assert result["speciesLabel"] == "Spinacia oleracea"
assert result["bytesDownloaded"] == 8388608
assert result["totalBytes"] == 0
assert result["indeterminate"] is True
def test_failure_without_new_progress_preserves_last_downloaded_bytes() -> None:
result = _run_node(
_progress_state_script(
"""
updateReferenceBundleDownloadProgress({
id: 'task-download', status: 'running',
progress: {bytes_downloaded: 8388608, total_bytes: 0, indeterminate: true}
}, {species_name: 'Spinacia oleracea'});
updateReferenceBundleDownloadProgress({
status: 'failed', message: 'IncompleteRead(726589 bytes read)'
}, {species_name: 'Spinacia oleracea'});
console.log(JSON.stringify(referenceBundleDownloadState.value));
"""
)
)
assert result["status"] == "failed"
assert result["message"] == "IncompleteRead(726589 bytes read)"
assert result["bytesDownloaded"] == 8388608
assert result["totalBytes"] == 0
def test_clear_resets_terminal_download_state() -> None:
result = _run_node(
_progress_state_script(
"""
updateReferenceBundleDownloadProgress({
id: 'task-download', status: 'failed', message: 'download failed',
progress: {bytes_downloaded: 4096, total_bytes: 0, indeterminate: true}
}, {species_name: 'Spinacia oleracea'});
clearReferenceBundleDownloadProgress();
console.log(JSON.stringify(referenceBundleDownloadState.value));
"""
)
)
assert result == {
"visible": False,
"taskId": "",
"speciesLabel": "",
"message": "",
"percentage": 0,
"indeterminate": True,
"status": "",
"bytesDownloaded": 0,
"totalBytes": 0,
}
def test_download_indicator_is_beside_tip_and_not_inside_batch_dialog() -> None:
html = HTML_PATH.read_text(encoding="utf-8")
toolbar = _source_block(
html,
"<!-- 使用小Tip hover弹出 -->",
"<!-- Upload hover弹出已上传文件 -->",
)
batch_dialog = _source_block(
html,
"<!-- 批量执行确认对话框 -->",
"<!-- 阶段确认对话框(批量执行用) -->",
)
assert 'class="reference-download-progress"' in toolbar
assert "referenceBundleDownloadState.visible" in toolbar
assert "PPI and Ka/Ks Reference Species Data Download" in toolbar
assert ':show-text="!referenceBundleDownloadState.indeterminate"' in toolbar
assert 'class="reference-download-progress"' not in batch_dialog
assert "referenceBundleDownloadState.visible" not in batch_dialog
def test_download_byte_formatter_uses_binary_units() -> None:
html = HTML_PATH.read_text(encoding="utf-8")
assert "const formatReferenceDownloadBytes =" in html
formatter = _source_block(
html,
"const formatReferenceDownloadBytes =",
"const updateReferenceBundleDownloadProgress =",
)
result = _run_node(
formatter
+ """
console.log(JSON.stringify({
zero: formatReferenceDownloadBytes(0),
mib: formatReferenceDownloadBytes(8388608)
}));
"""
)
assert result == {"zero": "", "mib": "8.0 MB"}
def test_monitor_retains_success_and_passes_failed_task_progress() -> None:
html = HTML_PATH.read_text(encoding="utf-8")
monitor = _source_block(
html,
"const monitorPpiReferenceBundleDownload =",
"const ppiHasNearReferenceBeforeArabidopsis =",
)
assert "clearReferenceBundleDownloadProgress();" not in monitor
assert "err?.task" in monitor
@@ -1,5 +1,6 @@
from __future__ import annotations
import http.client
import zipfile
@@ -73,5 +74,51 @@ def test_reference_bundle_download_reports_progress_events(tmp_path, monkeypatch
assert "validate_ids" in phases
assert phases[-1] == "completed"
assert manifest["requested_stages"] == ["ppi"]
assert manifest["requested_file_types"] == ["pep"]
assert "cds" not in manifest["files"]
assert manifest["requested_file_types"] == ["pep", "cds"]
assert "cds" in manifest["files"]
def test_reference_bundle_download_retries_incomplete_read(tmp_path, monkeypatch):
from apps.web import pipeline_backend as backend
payload = b"complete archive"
calls = {"count": 0}
class FakeResponse:
def __init__(self, *, incomplete: bool):
self.incomplete = incomplete
self.headers = {"Content-Length": str(len(payload))}
self._sent = False
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, _size):
if self.incomplete:
raise http.client.IncompleteRead(b"partial", len(payload))
if self._sent:
return b""
self._sent = True
return payload
def fake_urlopen(_url, timeout):
calls["count"] += 1
return FakeResponse(incomplete=calls["count"] == 1)
monkeypatch.setattr(backend.urllib.request, "urlopen", fake_urlopen)
monkeypatch.setattr(backend.time, "sleep", lambda _seconds: None)
output_path = tmp_path / "reference.zip"
downloaded = backend._download_url_to_file(
"https://example.invalid/reference.zip",
output_path,
attempts=2,
)
assert calls["count"] == 2
assert downloaded == len(payload)
assert output_path.read_bytes() == payload
assert not output_path.with_suffix(".zip.part").exists()
@@ -71,3 +71,45 @@ def test_reference_bundle_task_can_be_recovered_from_persisted_status(tmp_path,
assert recovered is not None
assert recovered["job_id"] == "job_ref_recover"
assert recovered["status"] == "failed"
def test_reference_bundle_task_uses_english_user_messages(tmp_path, monkeypatch):
from apps.web import pipeline_backend
from apps.web.vue3 import main
monkeypatch.setattr(main, "WORKSPACE_ROOT", tmp_path / "workspace")
monkeypatch.setattr(main, "INPUT_ROOT", tmp_path / "input")
job_id = "job_ref_english"
task_id = "task_ref_english"
(tmp_path / "workspace" / job_id).mkdir(parents=True)
(tmp_path / "input" / job_id).mkdir(parents=True)
main.tasks[task_id] = {
"id": task_id,
"task_id": task_id,
"stage": "reference_bundle_download",
"job_id": job_id,
"status": "pending",
}
monkeypatch.setattr(
pipeline_backend,
"download_reference_annotation_bundle",
lambda **_kwargs: {
"status": "success",
"requested_file_types": ["pep", "cds"],
"files": {"pep": "ref.pep.fasta", "cds": "ref.cds.fasta"},
},
)
main.run_reference_bundle_download_task(
task_id,
{
"job_id": job_id,
"species_code": "string_taxon_3562",
"requested_stages": ["ppi"],
"timeout_sec": 30,
},
)
task = main.tasks.pop(task_id)
assert task["message"] == "PPI and Ka/Ks reference species data are ready"
assert task["progress"]["message"] == "PPI and Ka/Ks reference species data are ready"
@@ -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",