Files
X-Financial/server/src/app/services/steward_intent_agent.py
caoxiaozhu 9f7b8b46a3 Refine travel reimbursement steward flow
Align planner, runtime rules, and policy assets so travel guidance
matches the updated reimbursement workflow.
2026-06-15 22:55:18 +08:00

277 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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。"
"如果用户只描述出差时间、地点和事由,但没有明确申请、报销、提交、保存草稿等动作,"
"且无法从上下文判断流程方向,必须返回 pending_flow_confirmation.status=pending"
"candidate_flows 同时给出 travel_application 和 travel_reimbursementtasks 保持空数组。"
"所有 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",
],
},
},
"pending_flow_confirmation": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["none", "pending"],
},
"source_message": {"type": "string"},
"reason": {"type": "string"},
"candidate_flows": {
"type": "array",
"items": {
"type": "object",
"properties": {
"flow_id": {
"type": "string",
"enum": ["travel_application", "travel_reimbursement"],
},
"label": {"type": "string"},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
},
"reason": {"type": "string"},
"ontology_fields": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"missing_fields": {
"type": "array",
"items": {
"type": "string",
"enum": canonical_fields,
},
},
},
"required": [
"flow_id",
"label",
"confidence",
"reason",
"ontology_fields",
"missing_fields",
],
},
},
},
"required": ["status", "source_message", "reason", "candidate_flows"],
},
"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"],
},
},
}