from __future__ import annotations import json from datetime import UTC, datetime from decimal import Decimal from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.api.deps import get_db from app.db.base import Base from app.main import create_app from app.models.financial_record import ExpenseClaim 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 CountingFunctionCallingIntentAgent(FakeFunctionCallingIntentAgent): def __init__(self) -> None: self.calls = 0 def detect(self, request, *, base_date, canonical_fields): self.calls += 1 return super().detect(request, base_date=base_date, canonical_fields=canonical_fields) class CountingNoResultIntentAgent: def __init__(self) -> None: self.calls = 0 def detect(self, request, *, base_date, canonical_fields): self.calls += 1 return None 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=[], ) class ApplicationFunctionCallingIntentAgent: def detect(self, request, *, base_date, canonical_fields): return StewardIntentAgentResult( payload={ "thinking_events": [ { "stage": "task_split", "title": "识别出差申请", "content": "模型识别到用户要发起北京出差申请,并且后续还有报销事项。", } ], "tasks": [ { "task_type": "expense_application", "title": "北京出差申请", "summary": "明天前往北京出差3天,支撑国网仿生产部署。", "requested_action": "save_draft", "confidence": 0.94, "ontology_fields": { "time_range": "明天", "location": "北京", "expense_type": "差旅", "reason": "支撑国网仿生产部署", }, "missing_fields": [], } ], "attachment_groups": [], }, model_call_traces=[], ) class SingleTravelApplicationFunctionCallingIntentAgent: def __init__(self) -> None: self.calls = 0 def detect(self, request, *, base_date, canonical_fields): self.calls += 1 return StewardIntentAgentResult( payload={ "thinking_events": [ { "stage": "task_split", "title": "识别出差申请草稿", "content": "模型识别到用户要创建上海出差申请,并保存草稿。", } ], "tasks": [ { "task_type": "expense_application", "title": "上海出差申请", "summary": "2026-02-20 至 2026-02-23 前往上海,国网仿生产服务器部署,火车出行。", "requested_action": "save_draft", "confidence": 0.95, "ontology_fields": { "time_range": "2026-02-20 至 2026-02-23", "location": "上海", "expense_type": "差旅", "reason": "国网仿生产服务器部署", "transport_mode": "火车", }, "missing_fields": [], } ], "attachment_groups": [], }, model_call_traces=[ { "slot": "main", "provider": "OpenAI Compatible", "model": "gpt-test", "attempt": 1, "status": "succeeded", } ], ) class PendingFlowFunctionCallingIntentAgent: def detect(self, request, *, base_date, canonical_fields): return StewardIntentAgentResult( payload={ "thinking_events": [ { "stage": "flow_confirmation", "title": "识别到出差事项但动作不明确", "content": "用户提供了时间、地点和事由,但没有明确要补办申请还是发起报销。", } ], "pending_flow_confirmation": { "status": "pending", "source_message": request.message, "reason": "缺少申请或报销动作词,需要用户确认流程方向。", "candidate_flows": [ { "flow_id": "travel_application", "label": "补办出差申请", "confidence": 0.52, "reason": "这句话可以理解为补办出差申请。", "ontology_fields": { "time_range": "2月20日", "location": "上海", "expense_type": "差旅", "reason": "辅助国网仿生产环境部署", }, "missing_fields": ["transport_mode"], }, { "flow_id": "travel_reimbursement", "label": "发起费用报销", "confidence": 0.48, "reason": "这句话也可能是在为已发生出差发起报销。", "ontology_fields": { "time_range": "2月20日", "location": "上海", "expense_type": "差旅", "reason": "辅助国网仿生产环境部署", }, "missing_fields": [], }, ], }, "tasks": [], "attachment_groups": [], }, model_call_traces=[], ) class AmbiguousApplicationFunctionCallingIntentAgent: def detect(self, request, *, base_date, canonical_fields): return StewardIntentAgentResult( payload={ "thinking_events": [ { "stage": "task_split", "title": "模型直接判定为申请", "content": "模型误把无动作词的历史出差描述直接判定为申请。", } ], "tasks": [ { "task_type": "expense_application", "title": "上海出差申请", "summary": "2月20-23日去上海出差辅助国网仿生产环境部署。", "confidence": 0.9, "ontology_fields": { "time_range": "2月20日", "location": "上海", "expense_type": "差旅", "reason": "辅助国网仿生产环境部署", }, "missing_fields": ["transport_mode"], } ], "attachment_groups": [], }, model_call_traces=[{"status": "succeeded"}], ) def _create_steward_test_client_with_db(): engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = create_app() def override_db(): db = TestingSessionLocal() try: yield db finally: db.close() app.dependency_overrides[get_db] = override_db return TestClient(app), TestingSessionLocal, app def _build_fast_rule_fallback_steward_planner(_db): return StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()) def _patch_steward_endpoint_planner(monkeypatch) -> None: monkeypatch.setattr( "app.api.v1.endpoints.steward._build_steward_planner", _build_fast_rule_fallback_steward_planner, ) def _build_endpoint_application_claim( *, claim_no: str = "AP-202602-001", employee_name: str = "张小青", status: str = "approved", ) -> ExpenseClaim: return ExpenseClaim( id=claim_no.lower().replace("-", "_"), claim_no=claim_no, employee_name=employee_name, department_name="产品交付部", expense_type="travel_application", reason="辅助国网仿生产服务器部署", location="上海", amount=Decimal("1800.00"), currency="CNY", invoice_count=0, occurred_at=datetime(2026, 2, 20, tzinfo=UTC), submitted_at=datetime(2026, 2, 19, tzinfo=UTC), status=status, approval_stage="关联单据状态", risk_flags_json=[ { "source": "application_detail", "application_detail": { "application_business_time": "2026-02-20 至 2026-02-23", "location": "上海", "reason": "辅助国网仿生产服务器部署", }, } ], ) def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None: payload = StewardPlanRequest( message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u5ba2\u6237\u73b0\u573a\u6c9f\u901a\u7684\u4ea4\u901a\u8d39", 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="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u62a5\u9500\u6628\u5929\u4e1a\u52a1\u62db\u5f85\u8d39", 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_enforces_application_transport_gap_after_function_calling() -> None: payload = StewardPlanRequest( message="\u6211\u60f3\u7533\u8bf7\u660e\u5929\u51fa\u5dee\u5317\u4eac\u0033\u5929\uff0c\u652f\u6491\u56fd\u7f51\u4eff\u751f\u4ea7\u90e8\u7f72\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService(intent_agent=ApplicationFunctionCallingIntentAgent()).build_plan(payload) assert result.planning_source == "llm_function_call" assert result.tasks[0].requested_action == "save_draft" assert result.tasks[0].missing_fields == ["transport_mode"] gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"] assert gap_events assert "没有说明出行方式" in gap_events[0].content assert "火车、飞机或轮船" in gap_events[0].content def test_steward_planner_returns_pending_flow_confirmation_from_llm() -> None: payload = StewardPlanRequest( message="2月20-23日去上海出差辅助国网仿生产环境部署", client_now_iso="2026-06-15T09:30:00+08:00", ) result = StewardPlannerService(intent_agent=PendingFlowFunctionCallingIntentAgent()).build_plan(payload) assert result.planning_source == "llm_function_call" assert result.next_action == "confirm_flow" assert result.plan_status == "needs_flow_confirmation" assert result.pending_flow_confirmation.status == "pending" assert [item.flow_id for item in result.candidate_flows] == [ "travel_application", "travel_reimbursement", ] assert result.candidate_flows[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23" assert result.candidate_flows[0].ontology_fields["location"] == "上海" assert "申请" in result.summary and "报销" in result.summary def test_steward_planner_tries_llm_before_rule_fallback_for_single_ambiguous_travel_flow() -> None: payload = StewardPlanRequest( message="\u0032\u6708\u0032\u0030-\u0032\u0033\u65e5\u53bb\u4e0a\u6d77\u51fa\u5dee\u8f85\u52a9\u56fd\u7f51\u4eff\u751f\u4ea7\u73af\u5883\u90e8\u7f72", client_now_iso="2026-06-15T09:30:00+08:00", ) intent_agent = CountingNoResultIntentAgent() result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload) assert intent_agent.calls == 1 assert result.planning_source == "rule_fallback" assert result.next_action == "confirm_flow" assert result.plan_status == "needs_flow_confirmation" assert result.model_call_traces == [] assert [item.flow_id for item in result.candidate_flows] == [ "travel_application", "travel_reimbursement", ] def test_steward_planner_uses_llm_for_multi_financial_demands() -> None: payload = StewardPlanRequest( message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39", client_now_iso="2026-06-04T09:30:00+08:00", ) intent_agent = CountingFunctionCallingIntentAgent() result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload) assert intent_agent.calls == 1 assert result.planning_source == "llm_function_call" assert result.model_call_traces[0]["status"] == "succeeded" def test_steward_planner_uses_llm_for_single_explicit_travel_save_draft() -> None: payload = StewardPlanRequest( message="2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。", client_now_iso="2026-06-24T14:20:00+08:00", ) intent_agent = SingleTravelApplicationFunctionCallingIntentAgent() result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload) assert intent_agent.calls == 1 assert result.planning_source == "llm_function_call" assert result.tasks[0].requested_action == "save_draft" assert result.tasks[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23" assert result.tasks[0].ontology_fields["reason"] == "国网仿生产服务器部署" assert result.model_call_traces[0]["status"] == "succeeded" def test_steward_planner_rule_fallback_keeps_save_draft_action_and_date_range() -> None: payload = StewardPlanRequest( message="2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。", client_now_iso="2026-06-24T14:20:00+08:00", ) result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload) assert result.planning_source == "rule_fallback" assert result.tasks[0].requested_action == "save_draft" assert result.tasks[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23" assert result.tasks[0].ontology_fields["reason"] == "国网仿生产服务器部署" def test_steward_planner_overrides_llm_direct_application_for_ambiguous_travel_flow() -> None: payload = StewardPlanRequest( message="2月20-23日去上海出差辅助国网仿生产环境部署", client_now_iso="2026-06-15T09:30:00+08:00", ) result = StewardPlannerService(intent_agent=AmbiguousApplicationFunctionCallingIntentAgent()).build_plan(payload) assert result.planning_source == "llm_function_call" assert result.next_action == "confirm_flow" assert result.plan_status == "needs_flow_confirmation" assert result.tasks == [] assert [item.flow_id for item in result.candidate_flows] == [ "travel_application", "travel_reimbursement", ] def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None: payload = StewardPlanRequest( message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39", 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 [task.task_type for task in result.tasks] == ["expense_application", "reimbursement"] assert result.tasks[0].ontology_fields["time_range"] == "2026-07-02" assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03" assert result.thinking_events[0].stage == "rule_fallback" def test_steward_planner_rule_fallback_confirms_ambiguous_travel_flow() -> None: payload = StewardPlanRequest( message="2月20-23日去上海出差辅助国网仿生产环境部署", client_now_iso="2026-06-15T09:30:00+08:00", ) result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload) assert result.planning_source == "rule_fallback" assert result.next_action == "confirm_flow" assert result.pending_flow_confirmation.status == "pending" assert [item.flow_id for item in result.candidate_flows] == [ "travel_application", "travel_reimbursement", ] assert result.tasks == [] assert result.confirmation_groups == [] def test_steward_planner_prefers_application_when_checked_required_application_missing() -> None: payload = StewardPlanRequest( message="2月20-23日去上海出差辅助国网仿生产服务器部署", client_now_iso="2026-06-15T09:30:00+08:00", context_json={ "required_application_gate": { "travel": { "checked": True, "candidate_count": 0, "candidates": [], } } }, ) result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload) assert result.planning_source == "rule_fallback" assert result.next_action == "confirm_flow" assert result.pending_flow_confirmation.status == "pending" assert [item.flow_id for item in result.candidate_flows] == ["travel_application"] assert result.candidate_flows[0].label == "先发起出差申请" assert "未查到可关联" in result.pending_flow_confirmation.reason assert "先申请" in result.summary def test_steward_planner_asks_to_link_application_when_checked_required_application_exists() -> None: payload = StewardPlanRequest( message="2月20-23日去上海出差辅助国网仿生产服务器部署", client_now_iso="2026-06-15T09:30:00+08:00", context_json={ "required_application_gate": { "travel": { "checked": True, "candidate_count": 2, "candidates": [ {"claim_no": "AP-202602-001"}, {"claim_no": "AP-202602-002"}, ], } } }, ) result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload) assert [item.flow_id for item in result.candidate_flows] == [ "travel_application", "travel_reimbursement", ] assert result.candidate_flows[1].label == "关联已有申请单并发起报销" assert "查到 2 个可关联申请单" in result.pending_flow_confirmation.reason assert "关联已有申请单" in result.summary 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_keeps_bare_reimbursement_intent_generic() -> None: payload = StewardPlanRequest( message="我要报销", user_id="u001", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService().build_plan(payload) assert len(result.tasks) == 1 task = result.tasks[0] assert task.task_type == "reimbursement" assert task.assigned_agent == "reimbursement_assistant" assert task.ontology_fields.get("expense_type") == "other" assert "reason" not in task.ontology_fields assert task.missing_fields == ["time_range", "reason"] assert [step.action_type for step in task.action_steps] == [ "fill_reimbursement_fields", "build_reimbursement_preview", "validate_required_fields", "create_reimbursement_draft", ] assert task.action_steps[-1].status == "blocked" def test_steward_planner_builds_reimbursement_action_steps() -> None: payload = StewardPlanRequest( message="我要报销昨天客户现场沟通的交通费", user_id="u001", client_now_iso="2026-06-04T09:30:00+08:00", context_json={"review_form_values": {"amount": "128.50"}}, ) result = StewardPlannerService().build_plan(payload) assert result.tasks[0].task_type == "reimbursement" assert [step.action_type for step in result.tasks[0].action_steps] == [ "fill_reimbursement_fields", "build_reimbursement_preview", "validate_required_fields", "create_reimbursement_draft", ] assert result.tasks[0].action_steps[0].payload["ontology_fields"]["amount"] == "128.50" assert result.tasks[0].action_steps[-1].status == "planned" 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[0].missing_fields == ["transport_mode"] gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"] assert gap_events assert "没有说明出行方式" in gap_events[0].content 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(monkeypatch) -> None: _patch_steward_endpoint_planner(monkeypatch) 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" def test_steward_plan_endpoint_persists_application_and_reimbursement_state(monkeypatch) -> None: _patch_steward_endpoint_planner(monkeypatch) client = TestClient(create_app()) response = client.post( "/api/v1/steward/plans", json={ "message": "我想申请7月2日去北京出差,并且我要报销昨天的交通费", "user_id": "u-steward-state", "client_now_iso": "2026-06-04T09:30:00+08:00", "context_json": {"session_type": "steward", "entry_source": "personal_workbench"}, }, ) assert response.status_code == 200 payload = response.json() assert payload["conversation_id"].startswith("conv_") state = payload["steward_state"] assert state["active_flow"] == "travel_reimbursement" assert state["flows"]["travel_application"]["fields"]["location"] == "北京" assert state["flows"]["travel_application"]["fields"]["time_range"] == "2026-07-02" assert state["flows"]["travel_reimbursement"]["fields"]["time_range"] == "2026-06-03" assert state["flows"]["travel_reimbursement"]["fields"]["expense_type"] == "transport" assert all("invented_field" not in flow["fields"] for flow in state["flows"].values()) def test_steward_plan_endpoint_queries_applications_before_ambiguous_travel_choice(monkeypatch) -> None: _patch_steward_endpoint_planner(monkeypatch) client, SessionLocal, app = _create_steward_test_client_with_db() try: response = client.post( "/api/v1/steward/plans", json={ "message": "2月20-23日去上海出差,辅助国网仿生产服务器部署", "user_id": "zhang.xiaoqing", "client_now_iso": "2026-06-15T09:30:00+08:00", "context_json": { "session_type": "steward", "entry_source": "workbench_ai_inline", "name": "张小青", "username": "zhang.xiaoqing", }, }, ) assert response.status_code == 200 payload = response.json() assert [item["flow_id"] for item in payload["candidate_flows"]] == ["travel_application"] assert payload["candidate_flows"][0]["label"] == "先发起出差申请" assert "未查到可关联单据" in payload["pending_flow_confirmation"]["reason"] with SessionLocal() as db: db.add(_build_endpoint_application_claim()) db.commit() response = client.post( "/api/v1/steward/plans", json={ "message": "2月20-23日去上海出差,辅助国网仿生产服务器部署", "user_id": "zhang.xiaoqing", "client_now_iso": "2026-06-15T09:30:00+08:00", "context_json": { "session_type": "steward", "entry_source": "workbench_ai_inline", "name": "张小青", "username": "zhang.xiaoqing", }, }, ) assert response.status_code == 200 payload = response.json() assert [item["flow_id"] for item in payload["candidate_flows"]] == [ "travel_application", "travel_reimbursement", ] assert payload["candidate_flows"][1]["label"] == "关联已有申请单并发起报销" assert "查到 1 个可关联申请单" in payload["pending_flow_confirmation"]["reason"] finally: app.dependency_overrides.clear() def test_steward_planner_returns_off_topic_for_business_irrelevant_input() -> None: payload = StewardPlanRequest( message="123", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService().build_plan(payload) assert result.plan_status == "off_topic" assert result.next_action == "none" assert result.tasks == [] assert result.attachment_groups == [] assert result.confirmation_groups == [] assert result.candidate_flows == [] assert result.planning_source == "rule_fallback" assert len(result.suggested_prompts) == 3 assert result.thinking_events[0].stage == "off_topic" # 纯数字应归类为 meaningless 场景 assert "未识别到财务事项" in result.thinking_events[0].title def test_steward_planner_returns_off_topic_with_friendly_greeting_reply() -> None: payload = StewardPlanRequest( message="你好", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService().build_plan(payload) assert result.plan_status == "off_topic" assert result.next_action == "none" assert result.tasks == [] assert result.candidate_flows == [] assert result.planning_source == "rule_fallback" assert len(result.suggested_prompts) == 3 # 问候场景应礼貌回应主人,不使用"抱歉/没识别到"等生硬措辞 assert "您好主人" in result.summary assert "很高兴为您服务" in result.summary assert "先回应主人的问候" in result.thinking_events[0].title def test_steward_planner_returns_off_topic_for_pure_punctuation() -> None: payload = StewardPlanRequest( message="??? !!!", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService().build_plan(payload) assert result.plan_status == "off_topic" assert result.next_action == "none" assert result.tasks == [] assert result.candidate_flows == [] assert result.planning_source == "rule_fallback" assert len(result.suggested_prompts) == 3 assert result.thinking_events[0].stage == "off_topic" def test_steward_planner_returns_off_topic_for_off_business_with_llm_response() -> None: """有内容但与业务无关的场景:应优先使用 LLM 生成的引导文案。""" llm_text = ( "### 抱歉主人,这句话我暂时帮不上忙\n\n" "主人聊的是天气,目前小财管家只能帮您整理**费用申请**和**费用报销**。" "要不您把想办的财务事项告诉我?" ) class _FakeOffTopicAgent: def __init__(self) -> None: self.calls = 0 self.last_call_traces: list[dict[str, object]] = [] def generate(self, request, *, scenario): self.calls += 1 from app.services.steward_off_topic_agent import StewardOffTopicAgentResult return StewardOffTopicAgentResult( response_text=llm_text, model_call_traces=[{"slot": "main", "status": "succeeded", "model": "gpt-test"}], ) agent = _FakeOffTopicAgent() payload = StewardPlanRequest( message="想问候您一下", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService(off_topic_agent=agent).build_plan(payload) assert agent.calls == 1 assert result.plan_status == "off_topic" assert result.summary == llm_text assert result.model_call_traces and result.model_call_traces[0]["status"] == "succeeded" # 思考事件应是 off_business 场景对应文案 assert "不在服务范围内" in result.thinking_events[0].title def test_steward_planner_falls_back_to_template_when_off_topic_agent_raises() -> None: """LLM 失败时静默 fallback 到规则模板,不阻断业务无关拦截。""" class _ExplodingOffTopicAgent: def generate(self, request, *, scenario): raise RuntimeError("模型供应商不可用") payload = StewardPlanRequest( message="想问候您一下", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService(off_topic_agent=_ExplodingOffTopicAgent()).build_plan(payload) assert result.plan_status == "off_topic" # 仍使用 off_business 场景的默认模板 assert "抱歉主人" in result.summary assert "不在服务范围内" in result.thinking_events[0].title assert result.model_call_traces == [] def test_steward_planner_skips_off_topic_agent_for_greeting_and_meaningless() -> None: """问候与无意义场景不走 LLM,节省调用。""" class _CallCounterOffTopicAgent: def __init__(self) -> None: self.calls = 0 def generate(self, request, *, scenario): self.calls += 1 return None agent = _CallCounterOffTopicAgent() service = StewardPlannerService(off_topic_agent=agent) for message in ("你好", "123", "???"): result = service.build_plan(StewardPlanRequest(message=message)) assert result.plan_status == "off_topic" assert agent.calls == 0 def test_steward_planner_preserves_normal_business_flow_after_guard() -> None: payload = StewardPlanRequest( message="我要报销昨天的交通费", client_now_iso="2026-06-04T09:30:00+08:00", ) result = StewardPlannerService().build_plan(payload) assert result.plan_status != "off_topic" assert len(result.tasks) >= 1 assert [task.task_type for task in result.tasks] == ["reimbursement"]