215 lines
8.6 KiB
Python
215 lines
8.6 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
|
|||
|
|
from app.main import create_app
|
|||
|
|
from app.schemas.steward import StewardAttachmentInput, StewardPlanRequest
|
|||
|
|
from app.services.steward_intent_agent import StewardIntentAgentResult
|
|||
|
|
from app.services.steward_planner import StewardPlannerService
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FakeFunctionCallingIntentAgent:
|
|||
|
|
def detect(self, request, *, base_date, canonical_fields):
|
|||
|
|
assert "expense_type" in canonical_fields
|
|||
|
|
assert base_date.isoformat() == "2026-06-04"
|
|||
|
|
return StewardIntentAgentResult(
|
|||
|
|
payload={
|
|||
|
|
"thinking_events": [
|
|||
|
|
{
|
|||
|
|
"stage": "task_split",
|
|||
|
|
"title": "识别复合报销意图",
|
|||
|
|
"content": "模型工具调用识别出 1 个报销任务,并关联本次上传的交通附件。",
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
"tasks": [
|
|||
|
|
{
|
|||
|
|
"task_type": "reimbursement",
|
|||
|
|
"title": "费用报销 2026-06-03 交通",
|
|||
|
|
"summary": "报销昨天客户现场沟通产生的交通费。",
|
|||
|
|
"confidence": 0.91,
|
|||
|
|
"ontology_fields": {
|
|||
|
|
"occurred_date": "昨天",
|
|||
|
|
"transport_type": "出租车",
|
|||
|
|
"reason_value": "客户现场沟通",
|
|||
|
|
"expense_type": "交通费",
|
|||
|
|
"unregistered_field": "不能进入业务字段",
|
|||
|
|
},
|
|||
|
|
"missing_fields": ["amount", "transport_type"],
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
"attachment_groups": [
|
|||
|
|
{
|
|||
|
|
"target_task_index": 1,
|
|||
|
|
"scene": "transport",
|
|||
|
|
"scene_label": "交通费用",
|
|||
|
|
"attachment_names": ["出租车票.png"],
|
|||
|
|
"excluded_attachment_names": ["客户招待发票.jpg"],
|
|||
|
|
"confidence": 0.86,
|
|||
|
|
"rationale": "出租车票与交通报销任务匹配,招待发票不归入该任务。",
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
model_call_traces=[
|
|||
|
|
{
|
|||
|
|
"slot": "main",
|
|||
|
|
"provider": "OpenAI Compatible",
|
|||
|
|
"model": "gpt-test",
|
|||
|
|
"attempt": 1,
|
|||
|
|
"status": "succeeded",
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class EmptyFunctionCallingIntentAgent:
|
|||
|
|
def detect(self, request, *, base_date, canonical_fields):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
|
|||
|
|
payload = StewardPlanRequest(
|
|||
|
|
message="我要报销昨天客户现场沟通的交通费",
|
|||
|
|
client_now_iso="2026-06-04T09:30:00+08:00",
|
|||
|
|
attachments=[
|
|||
|
|
StewardAttachmentInput(name="出租车票.png"),
|
|||
|
|
StewardAttachmentInput(name="客户招待发票.jpg"),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result = StewardPlannerService(intent_agent=FakeFunctionCallingIntentAgent()).build_plan(payload)
|
|||
|
|
|
|||
|
|
assert result.planning_source == "llm_function_call"
|
|||
|
|
assert result.model_call_traces[0]["status"] == "succeeded"
|
|||
|
|
assert len(result.tasks) == 1
|
|||
|
|
fields = result.tasks[0].ontology_fields
|
|||
|
|
assert fields["time_range"] == "2026-06-03"
|
|||
|
|
assert fields["transport_mode"] == "taxi"
|
|||
|
|
assert fields["reason"] == "客户现场沟通"
|
|||
|
|
assert fields["expense_type"] == "transport"
|
|||
|
|
assert "occurred_date" not in fields
|
|||
|
|
assert "transport_type" not in fields
|
|||
|
|
assert "reason_value" not in fields
|
|||
|
|
assert "unregistered_field" not in fields
|
|||
|
|
assert result.tasks[0].missing_fields == ["amount"]
|
|||
|
|
assert result.attachment_groups[0].attachment_names == ["出租车票.png"]
|
|||
|
|
assert result.attachment_groups[0].excluded_attachment_names == ["客户招待发票.jpg"]
|
|||
|
|
assert result.thinking_events[0].stage == "llm_function_call"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
|
|||
|
|
payload = StewardPlanRequest(
|
|||
|
|
message="我要报销昨天的交通费",
|
|||
|
|
client_now_iso="2026-06-04T09:30:00+08:00",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
|
|||
|
|
|
|||
|
|
assert result.planning_source == "rule_fallback"
|
|||
|
|
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
|
|||
|
|
assert result.thinking_events[0].stage == "rule_fallback"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
|
|||
|
|
payload = StewardPlanRequest(
|
|||
|
|
message=(
|
|||
|
|
"我想要申请7月2日去北京出差,辅助北京供电局的税务审核任务,"
|
|||
|
|
"并且我要报销昨天的交通费,还需要报销6月3日出差去上海的费用"
|
|||
|
|
),
|
|||
|
|
user_id="u001",
|
|||
|
|
client_now_iso="2026-06-04T09:30:00+08:00",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result = StewardPlannerService().build_plan(payload)
|
|||
|
|
|
|||
|
|
assert len(result.tasks) == 3
|
|||
|
|
assert [task.task_type for task in result.tasks] == [
|
|||
|
|
"expense_application",
|
|||
|
|
"reimbursement",
|
|||
|
|
"reimbursement",
|
|||
|
|
]
|
|||
|
|
assert result.tasks[0].assigned_agent == "application_assistant"
|
|||
|
|
assert result.tasks[0].ontology_fields["time_range"] == "2026-07-02"
|
|||
|
|
assert result.tasks[0].ontology_fields["location"] == "北京"
|
|||
|
|
assert result.tasks[0].ontology_fields["reason"] == "辅助北京供电局的税务审核任务"
|
|||
|
|
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
|
|||
|
|
assert result.tasks[1].ontology_fields["expense_type"] == "transport"
|
|||
|
|
assert result.tasks[2].ontology_fields["time_range"] == "2026-06-03"
|
|||
|
|
assert result.tasks[2].ontology_fields["location"] == "上海"
|
|||
|
|
assert result.tasks[2].ontology_fields["expense_type"] == "travel"
|
|||
|
|
assert all(action.status == "pending" for action in result.confirmation_groups)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_steward_planner_outputs_only_canonical_ontology_fields() -> None:
|
|||
|
|
payload = StewardPlanRequest(
|
|||
|
|
message="我要报销昨天的交通费",
|
|||
|
|
client_now_iso="2026-06-04T09:30:00+08:00",
|
|||
|
|
context_json={
|
|||
|
|
"review_form_values": {
|
|||
|
|
"occurred_date": "2026-06-03",
|
|||
|
|
"transport_type": "taxi",
|
|||
|
|
"reason_value": "客户现场沟通",
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result = StewardPlannerService().build_plan(payload)
|
|||
|
|
|
|||
|
|
fields = result.tasks[0].ontology_fields
|
|||
|
|
assert fields["time_range"] == "2026-06-03"
|
|||
|
|
assert fields["transport_mode"] == "taxi"
|
|||
|
|
assert fields["reason"] == "客户现场沟通"
|
|||
|
|
assert "occurred_date" not in fields
|
|||
|
|
assert "transport_type" not in fields
|
|||
|
|
assert "reason_value" not in fields
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_steward_planner_builds_travel_attachment_group_with_exclusions() -> None:
|
|||
|
|
payload = StewardPlanRequest(
|
|||
|
|
message="还需要报销6月3日出差去上海的费用",
|
|||
|
|
client_now_iso="2026-06-04T09:30:00+08:00",
|
|||
|
|
attachments=[
|
|||
|
|
StewardAttachmentInput(name="上海高铁票.jpg"),
|
|||
|
|
StewardAttachmentInput(name="上海酒店发票.pdf"),
|
|||
|
|
StewardAttachmentInput(name="出租车票.png"),
|
|||
|
|
StewardAttachmentInput(name="客户招待发票.jpg"),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result = StewardPlannerService().build_plan(payload)
|
|||
|
|
|
|||
|
|
assert len(result.attachment_groups) == 1
|
|||
|
|
group = result.attachment_groups[0]
|
|||
|
|
assert group.scene == "travel"
|
|||
|
|
assert group.attachment_names == ["上海高铁票.jpg", "上海酒店发票.pdf", "出租车票.png"]
|
|||
|
|
assert group.excluded_attachment_names == ["客户招待发票.jpg"]
|
|||
|
|
assert group.confirmation_required is True
|
|||
|
|
attachment_actions = [
|
|||
|
|
action for action in result.confirmation_groups if action.action_type == "confirm_attachment_group"
|
|||
|
|
]
|
|||
|
|
assert len(attachment_actions) == 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
|
|||
|
|
client = TestClient(create_app())
|
|||
|
|
|
|||
|
|
with client.stream(
|
|||
|
|
"POST",
|
|||
|
|
"/api/v1/steward/plans/stream",
|
|||
|
|
json={
|
|||
|
|
"message": "我要报销昨天的交通费",
|
|||
|
|
"client_now_iso": "2026-06-04T09:30:00+08:00",
|
|||
|
|
},
|
|||
|
|
) as response:
|
|||
|
|
assert response.status_code == 200
|
|||
|
|
events = [
|
|||
|
|
json.loads(line.decode("utf-8") if isinstance(line, bytes) else line)
|
|||
|
|
for line in response.iter_lines()
|
|||
|
|
if line
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
assert [event["event"] for event in events][:2] == ["thinking", "thinking"]
|
|||
|
|
assert events[-1]["event"] == "plan"
|
|||
|
|
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"
|