Align planner, runtime rules, and policy assets so travel guidance matches the updated reimbursement workflow.
277 lines
13 KiB
Python
277 lines
13 KiB
Python
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_reimbursement,tasks 保持空数组。"
|
||
"所有 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"],
|
||
},
|
||
},
|
||
}
|