2026-06-04 11:03:29 +08:00
|
|
|
|
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,
|
2026-06-04 14:25:14 +08:00
|
|
|
|
timeout_seconds=45,
|
2026-06-04 11:03:29 +08:00
|
|
|
|
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 两类任务;"
|
|
|
|
|
|
"你只做识别、拆解、归集和确认点规划,不能执行入库、绑定附件或提交审批。"
|
2026-06-04 14:25:14 +08:00
|
|
|
|
"用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时,"
|
|
|
|
|
|
"即使没有出现“申请”两个字,也必须优先识别为 expense_application。"
|
|
|
|
|
|
"用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。"
|
2026-06-15 22:55:18 +08:00
|
|
|
|
"如果用户只描述出差时间、地点和事由,但没有明确申请、报销、提交、保存草稿等动作,"
|
|
|
|
|
|
"且无法从上下文判断流程方向,必须返回 pending_flow_confirmation.status=pending,"
|
|
|
|
|
|
"candidate_flows 同时给出 travel_application 和 travel_reimbursement,tasks 保持空数组。"
|
2026-06-04 11:03:29 +08:00
|
|
|
|
"所有 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",
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-06-15 22:55:18 +08:00
|
|
|
|
"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"],
|
|
|
|
|
|
},
|
2026-06-04 11:03:29 +08:00
|
|
|
|
"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"],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|