from __future__ import annotations import json from dataclasses import dataclass from datetime import date from typing import Any from app.schemas.steward import StewardPlanRequest from app.services.ontology_field_registry import normalize_ontology_form_values from app.services.runtime_chat import RuntimeChatService STEWARD_INTENT_FUNCTION_NAME = "submit_steward_intent_plan" @dataclass(frozen=True, slots=True) class StewardIntentAgentResult: payload: dict[str, Any] model_call_traces: list[dict[str, Any]] class StewardIntentAgent: """使用大模型 function calling 识别小财管家的复合财务意图。""" def __init__(self, runtime_chat_service: RuntimeChatService) -> None: self.runtime_chat_service = runtime_chat_service self.last_call_traces: list[dict[str, Any]] = [] def detect( self, request: StewardPlanRequest, *, base_date: date, canonical_fields: list[str], ) -> StewardIntentAgentResult | None: result = self.runtime_chat_service.complete_with_tool_call( self._build_messages(request, base_date=base_date, canonical_fields=canonical_fields), tools=[self._build_intent_tool_schema(canonical_fields)], tool_choice={ "type": "function", "function": {"name": STEWARD_INTENT_FUNCTION_NAME}, }, max_tokens=1800, temperature=0.1, timeout_seconds=45, max_attempts=1, ) self.last_call_traces = result.calls_as_dicts() if result.tool_call is None or result.tool_call.name != STEWARD_INTENT_FUNCTION_NAME: return None return StewardIntentAgentResult( payload=result.tool_call.arguments, model_call_traces=self.last_call_traces, ) @staticmethod def _build_messages( request: StewardPlanRequest, *, base_date: date, canonical_fields: list[str], ) -> list[dict[str, Any]]: context_payload = { "message": request.message, "base_date": base_date.isoformat(), "client_now_iso": request.client_now_iso, "user_id": request.user_id, "canonical_ontology_fields": canonical_fields, "review_form_values": normalize_ontology_form_values( request.context_json.get("review_form_values") ), "context_json": { key: value for key, value in request.context_json.items() if key in { "entry_source", "session_type", "role_codes", "username", "name", "department_name", "employee_grade", "employee_no", "client_timezone_offset_minutes", } }, "attachments": [ { "index": index + 1, "name": item.name, "media_type": item.media_type, "ocr_summary": item.ocr_summary, "ocr_fields": item.ocr_fields, } for index, item in enumerate(request.attachments) if item.name ], } return [ { "role": "system", "content": ( "你是 X-Financial 的小财管家意图识别智能体。" "你必须通过 function calling 输出结构化计划,不能只返回普通文本。" "当前版本只支持 expense_application 和 reimbursement 两类任务;" "你只做识别、拆解、归集和确认点规划,不能执行入库、绑定附件或提交审批。" "用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时," "即使没有出现“申请”两个字,也必须优先识别为 expense_application。" "用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。" "所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields;" "如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。" "相对日期必须以 base_date 为准转换为明确日期。" "thinking_events 只能是面向用户的过程摘要,不能暴露内部推理链。" ), }, { "role": "user", "content": json.dumps(context_payload, ensure_ascii=False), }, ] @staticmethod def _build_intent_tool_schema(canonical_fields: list[str]) -> dict[str, Any]: return { "type": "function", "function": { "name": STEWARD_INTENT_FUNCTION_NAME, "description": "提交小财管家的复合财务意图识别结果。", "parameters": { "type": "object", "properties": { "thinking_events": { "type": "array", "items": { "type": "object", "properties": { "stage": {"type": "string"}, "title": {"type": "string"}, "content": {"type": "string"}, }, "required": ["stage", "title", "content"], }, }, "tasks": { "type": "array", "items": { "type": "object", "properties": { "task_type": { "type": "string", "enum": ["expense_application", "reimbursement"], }, "title": {"type": "string"}, "summary": {"type": "string"}, "confidence": { "type": "number", "minimum": 0, "maximum": 1, }, "ontology_fields": { "type": "object", "additionalProperties": {"type": "string"}, }, "missing_fields": { "type": "array", "items": { "type": "string", "enum": canonical_fields, }, }, }, "required": [ "task_type", "title", "summary", "confidence", "ontology_fields", "missing_fields", ], }, }, "attachment_groups": { "type": "array", "items": { "type": "object", "properties": { "target_task_index": { "type": "integer", "minimum": 1, }, "scene": {"type": "string"}, "scene_label": {"type": "string"}, "attachment_names": { "type": "array", "items": {"type": "string"}, }, "excluded_attachment_names": { "type": "array", "items": {"type": "string"}, }, "confidence": { "type": "number", "minimum": 0, "maximum": 1, }, "rationale": {"type": "string"}, }, "required": [ "scene", "scene_label", "attachment_names", "excluded_attachment_names", "confidence", "rationale", ], }, }, }, "required": ["thinking_events", "tasks", "attachment_groups"], }, }, }