451 lines
15 KiB
Python
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
|