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

451 lines
15 KiB
Python

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