Files
PlantAI-dev/tests/unit/test_router_analysis_handoff_boundary.py
T

272 lines
8.4 KiB
Python

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
class _PassThroughTools:
@staticmethod
def resolve_analysis_handoff_request(
query: str,
*,
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"),
(
(
"执行rnaseq",
"rnaseq_whole_genome",
"请选择全基因组还是基因家族成员RNA-seq",
),
("执行全基因组rnaseq", "rnaseq_whole_genome", None),
("对已确认家族成员执行rnaseq", "rnaseq_family_member", None),
("执行motif分析", "gene_family", None),
),
)
def test_new_analysis_proposal_preserves_original_request_without_main_agent_planning(
monkeypatch: pytest.MonkeyPatch,
query: str,
workflow_id: str,
clarification_question: str | None,
) -> None:
runtime = RouterRuntime(object(), _PassThroughTools(), 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=query,
clarification_question=clarification_question,
workflow_id=workflow_id, # type: ignore[arg-type]
analysis_request_kind="execution",
),
1,
[],
)
monkeypatch.setattr(runtime, "_decide_route", decide_route)
outcome = asyncio.run(
runtime.run(
query=query,
history=[],
session_id="analysis-handoff-session",
job_id="job-analysis-handoff",
goal_ledger=GoalLedger(
conversation_id="analysis-handoff-session",
revision=0,
user_terms={"active_job_id": "job-analysis-handoff"},
),
)
)
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",
needs=["analysis_intent"],
subject="执行rnaseq",
clarification_question="请选择RNA-seq模式",
workflow_id="rnaseq_whole_genome",
analysis_request_kind="execution",
)
normalized = RouterRuntime._analysis_handoff_decision(decision)
assert normalized.goal == "new_analysis"
assert normalized.needs == ["analysis_intent"]
assert normalized.subject == decision.subject
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