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 class EntertainmentFunctionCallingIntentAgent: def detect(self, request, *, base_date, canonical_fields): return StewardIntentAgentResult( payload={ "thinking_events": [], "tasks": [ { "task_type": "reimbursement", "title": "业务招待费报销", "summary": "报销昨天业务招待费。", "confidence": 0.9, "ontology_fields": { "time_range": "昨天", "expense_type": "业务招待费", "reason": "业务招待", }, "missing_fields": [], } ], "attachment_groups": [], }, model_call_traces=[], ) 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_normalizes_llm_business_entertainment_expense_type() -> None: payload = StewardPlanRequest( message="报销昨天业务招待费", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService(intent_agent=EntertainmentFunctionCallingIntentAgent()).build_plan(payload) assert result.planning_source == "llm_function_call" assert result.tasks[0].ontology_fields["expense_type"] == "entertainment" assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03" 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_treats_future_travel_without_apply_word_as_application() -> None: payload = StewardPlanRequest( message="明天出差北京3天,支撑国网仿生产部署,并且报销昨天业务招待费", user_id="u001", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService().build_plan(payload) assert [task.task_type for task in result.tasks] == [ "expense_application", "reimbursement", ] assert result.tasks[0].assigned_agent == "application_assistant" assert result.tasks[0].ontology_fields["time_range"] == "2026-06-05" assert result.tasks[0].ontology_fields["location"] == "北京" assert result.tasks[0].ontology_fields["expense_type"] == "travel" assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署" assert result.tasks[1].assigned_agent == "reimbursement_assistant" assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03" assert result.tasks[1].ontology_fields["expense_type"] == "entertainment" 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[0]["data"]["stage"] == "stream_start" assert events[-1]["event"] == "plan" assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"