Files
X-Financial/server/src/app/services/steward_runtime_decision_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

427 lines
20 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
import re
from typing import Any
from app.schemas.steward import (
StewardFlowStatePatch,
StewardRuntimeDecisionRequest,
StewardRuntimeDecisionResponse,
)
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_flow_state import StewardFlowStateService
STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision"
RUNTIME_NEXT_ACTIONS = {
"plan_new_tasks",
"continue_selected_flow",
"submit_current_application",
"continue_next_task",
"fill_current_slot",
"ask_user",
"cancel_current_action",
"no_op",
}
FIELD_LABELS = {
"transport_mode": "出行方式",
"expense_type": "费用类型",
"time_range": "时间",
"location": "地点",
"reason": "事由",
"amount": "金额",
"attachments": "附件",
}
class StewardRuntimeDecisionAgent:
"""用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。"""
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
self.runtime_chat_service = runtime_chat_service
def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse:
normalized_request = self._normalize_request(request)
selected_flow_decision = self._build_selected_flow_decision(normalized_request, [])
if selected_flow_decision is not None:
return selected_flow_decision
result = self.runtime_chat_service.complete_with_tool_call(
self._build_messages(normalized_request),
tools=[self._build_tool_schema()],
tool_choice={
"type": "function",
"function": {"name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME},
},
max_tokens=1000,
temperature=0.05,
timeout_seconds=30,
max_attempts=1,
)
traces = result.calls_as_dicts()
if result.tool_call is not None and result.tool_call.name == STEWARD_RUNTIME_DECISION_FUNCTION_NAME:
response = self._build_response_from_model_payload(result.tool_call.arguments, normalized_request, traces)
if response is not None:
return self._attach_updated_steward_state(response, normalized_request)
return self._attach_updated_steward_state(
self._build_rule_fallback(normalized_request, traces),
normalized_request,
)
def _build_selected_flow_decision(
self,
request: StewardRuntimeDecisionRequest,
traces: list[dict[str, Any]],
) -> StewardRuntimeDecisionResponse | None:
selected_flow_id = self._resolve_selected_pending_flow_id(
request.runtime_state,
request.user_message,
)
if not selected_flow_id:
return None
next_state = StewardFlowStateService().confirm_flow(
request.runtime_state.get("steward_state") if isinstance(request.runtime_state.get("steward_state"), dict) else {},
selected_flow_id,
)
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="continue_selected_flow",
target_task_id=selected_flow_id,
response_text=self._build_selected_flow_response_text(selected_flow_id),
rationale="已按你选择的候选流程继续处理。",
steward_state=next_state,
model_call_traces=traces,
)
@staticmethod
def _normalize_request(request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionRequest:
context_json = request.context_json if isinstance(request.context_json, dict) else {}
runtime_state = request.runtime_state if isinstance(request.runtime_state, dict) else {}
return StewardRuntimeDecisionRequest(
user_message=str(request.user_message or "").strip(),
session_type=str(request.session_type or "steward").strip() or "steward",
runtime_state=StewardRuntimeDecisionAgent._hydrate_runtime_state(runtime_state, context_json),
context_json=context_json,
)
@staticmethod
def _hydrate_runtime_state(
runtime_state: dict[str, Any],
context_json: dict[str, Any],
) -> dict[str, Any]:
hydrated = dict(runtime_state or {})
steward_state = StewardRuntimeDecisionAgent._resolve_steward_state(context_json)
if steward_state:
hydrated.setdefault("steward_state", steward_state)
if StewardRuntimeDecisionAgent._has_runtime_anchor(hydrated) or not steward_state:
return hydrated
active_flow = str(steward_state.get("active_flow") or "").strip()
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
flow = flows.get(active_flow) if isinstance(flows, dict) else None
if not isinstance(flow, dict):
return hydrated
missing_fields = [
str(item or "").strip()
for item in list(flow.get("missing_fields") or [])
if str(item or "").strip()
]
hydrated["current_task"] = {
"task_id": active_flow,
"task_type": "expense_application" if active_flow == "travel_application" else "reimbursement",
"ontology_fields": dict(flow.get("fields") or {}),
"missing_fields": missing_fields,
}
if missing_fields:
hydrated["waiting_for"] = "steward_flow_field_completion"
else:
hydrated["waiting_for"] = "steward_flow_confirmation"
return hydrated
@staticmethod
def _resolve_steward_state(context_json: dict[str, Any]) -> dict[str, Any]:
direct_state = context_json.get("steward_state") or context_json.get("stewardState")
if isinstance(direct_state, dict) and direct_state:
return direct_state
conversation_state = context_json.get("conversation_state")
if isinstance(conversation_state, dict):
nested_state = conversation_state.get("steward_state") or conversation_state.get("stewardState")
if isinstance(nested_state, dict) and nested_state:
return nested_state
return {}
@staticmethod
def _has_runtime_anchor(runtime_state: dict[str, Any]) -> bool:
if str(runtime_state.get("waiting_for") or "").strip():
return True
for key in ("pending_application", "pending_steward_action", "pending_slot_action", "current_task"):
if isinstance(runtime_state.get(key), dict) and runtime_state[key]:
return True
return bool(runtime_state.get("remaining_tasks") or runtime_state.get("completed_tasks"))
@staticmethod
def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]:
payload = {
"user_message": request.user_message,
"session_type": request.session_type,
"runtime_state": request.runtime_state,
"context_json": request.context_json,
}
return [
{
"role": "system",
"content": (
"你是 X-Financial 小财管家的运行时决策智能体。"
"你必须基于 runtime_state 判断用户当前输入对应哪个等待动作,不能把每次输入都当成全新任务。"
"runtime_state 会包含 current_task、remaining_tasks、completed_tasks、pending_application、"
"pending_steward_action、waiting_for、recent_structured_result 等上下文。"
"如果用户是在确认当前申请核对表无误,应返回 submit_current_application"
"如果用户是在确认继续下一项,应返回 continue_next_task"
"如果用户补充了当前等待字段,应返回 fill_current_slot"
"如果当前结构化结果仍缺字段,应返回 ask_user"
"只有当前没有可匹配上下文,且用户输入明显是新财务事项时,才返回 plan_new_tasks。"
"提交、入库、绑定、审批等高风险动作只返回结构化意图,实际执行由系统安全校验完成。"
"rationale 和 response_text 必须面向用户,不暴露内部推理链。"
),
},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
]
@staticmethod
def _build_tool_schema() -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME,
"description": "提交小财管家基于运行时上下文的下一步动作决策。",
"parameters": {
"type": "object",
"properties": {
"next_action": {
"type": "string",
"enum": sorted(RUNTIME_NEXT_ACTIONS),
},
"target_task_id": {"type": "string"},
"target_message_id": {"type": "string"},
"field_key": {"type": "string"},
"field_value": {"type": "string"},
"confirmation_required": {"type": "boolean"},
"question": {"type": "string"},
"response_text": {"type": "string"},
"rationale": {"type": "string"},
},
"required": [
"next_action",
"target_task_id",
"target_message_id",
"field_key",
"field_value",
"confirmation_required",
"question",
"response_text",
"rationale",
],
},
},
}
def _build_response_from_model_payload(
self,
payload: dict[str, Any],
request: StewardRuntimeDecisionRequest,
traces: list[dict[str, Any]],
) -> StewardRuntimeDecisionResponse | None:
next_action = str(payload.get("next_action") or "").strip()
if next_action not in RUNTIME_NEXT_ACTIONS:
return None
return StewardRuntimeDecisionResponse(
decision_source="llm_function_call",
next_action=next_action, # type: ignore[arg-type]
target_task_id=self._clean_text(payload.get("target_task_id")),
target_message_id=self._clean_text(payload.get("target_message_id")),
field_key=self._clean_text(payload.get("field_key")),
field_value=self._clean_text(payload.get("field_value")),
confirmation_required=bool(payload.get("confirmation_required")),
question=self._clean_text(payload.get("question")),
response_text=self._clean_text(payload.get("response_text")),
rationale=self._clean_text(payload.get("rationale")),
model_call_traces=traces,
)
def _build_rule_fallback(
self,
request: StewardRuntimeDecisionRequest,
traces: list[dict[str, Any]],
) -> StewardRuntimeDecisionResponse:
state = request.runtime_state
pending_application = state.get("pending_application") if isinstance(state.get("pending_application"), dict) else {}
pending_steward_action = state.get("pending_steward_action") if isinstance(state.get("pending_steward_action"), dict) else {}
waiting_for = str(state.get("waiting_for") or "").strip()
message = request.user_message.replace(" ", "")
confirmation_text = message in {"确认", "确定", "无误", "确认提交", "可以提交", "提交", "没问题"}
if confirmation_text and pending_application.get("ready_to_submit"):
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="submit_current_application",
target_message_id=str(pending_application.get("message_id") or ""),
target_task_id=str(pending_application.get("task_id") or ""),
rationale="模型运行时决策暂不可用,我先按当前待提交申请单上下文处理你的确认。",
model_call_traces=traces,
)
if confirmation_text and pending_steward_action:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="continue_next_task",
target_message_id=str(pending_steward_action.get("message_id") or ""),
target_task_id=str(pending_steward_action.get("target_task_id") or ""),
rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。",
model_call_traces=traces,
)
if waiting_for == "steward_flow_field_completion":
current_task = state.get("current_task") if isinstance(state.get("current_task"), dict) else {}
missing_fields = [
str(item or "").strip()
for item in list(current_task.get("missing_fields") or [])
if str(item or "").strip()
]
field_key = missing_fields[0] if missing_fields else ""
if field_key and request.user_message:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="fill_current_slot",
target_task_id=str(current_task.get("task_id") or ""),
field_key=field_key,
field_value=request.user_message,
rationale="模型运行时决策暂不可用,我先把你的补充写入当前小财管家流程字段。",
model_call_traces=traces,
)
if field_key:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="ask_user",
target_task_id=str(current_task.get("task_id") or ""),
field_key=field_key,
question=f"请补充{FIELD_LABELS.get(field_key, field_key)}",
rationale="当前小财管家流程仍缺少必要字段。",
model_call_traces=traces,
)
if waiting_for:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="ask_user",
question="我需要先确认当前等待事项,请补充或选择当前问题对应的信息。",
rationale="模型运行时决策暂不可用,当前仍存在等待用户补充的信息。",
model_call_traces=traces,
)
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="plan_new_tasks",
rationale="模型运行时决策暂不可用,当前没有可安全匹配的等待动作,回到任务规划。",
model_call_traces=traces,
)
@staticmethod
def _resolve_selected_pending_flow_id(runtime_state: dict[str, Any], user_message: str) -> str:
steward_state = runtime_state.get("steward_state")
if not isinstance(steward_state, dict):
return ""
pending = steward_state.get("pending_flow_confirmation")
if not isinstance(pending, dict) or pending.get("status") != "pending":
return ""
message = re.sub(r"\s+", "", str(user_message or ""))
if not message:
return ""
candidates = pending.get("candidate_flows") if isinstance(pending.get("candidate_flows"), list) else []
for candidate in candidates:
if not isinstance(candidate, dict):
continue
flow_id = str(candidate.get("flow_id") or "").strip()
label = re.sub(r"\s+", "", str(candidate.get("label") or ""))
if flow_id == "travel_application" and (
message in {"补办出差申请", "出差申请", "申请", "补申请"}
or (label and message == label)
):
return flow_id
if flow_id == "travel_reimbursement" and (
message in {"发起费用报销", "费用报销", "报销", "发起报销"}
or (label and message == label)
):
return flow_id
return ""
@staticmethod
def _build_selected_flow_response_text(flow_id: str) -> str:
if flow_id == "travel_application":
return "已确认按 **补办出差申请** 继续,我会基于当前出差信息整理申请材料。"
return "已确认按 **发起费用报销** 继续,我会基于当前出差信息整理报销材料。"
@staticmethod
def _clean_text(value: Any) -> str:
return str(value or "").strip()
def _attach_updated_steward_state(
self,
response: StewardRuntimeDecisionResponse,
request: StewardRuntimeDecisionRequest,
) -> StewardRuntimeDecisionResponse:
steward_state = request.runtime_state.get("steward_state")
if not isinstance(steward_state, dict) or not steward_state:
return response
if response.next_action == "continue_selected_flow":
flow_id = self._resolve_target_flow_id(response, steward_state)
if flow_id:
next_state = StewardFlowStateService().confirm_flow(steward_state, flow_id)
return response.model_copy(update={"steward_state": next_state})
return response.model_copy(update={"steward_state": steward_state})
if response.next_action != "fill_current_slot" or not response.field_key:
return response.model_copy(update={"steward_state": steward_state})
flow_id = self._resolve_target_flow_id(response, steward_state)
if not flow_id:
return response.model_copy(update={"steward_state": steward_state})
current_flow = self._resolve_flow(steward_state, flow_id)
remaining_missing_fields = [
key
for key in list(current_flow.get("missing_fields") or [])
if str(key or "").strip() and str(key or "").strip() != response.field_key
]
next_state = StewardFlowStateService().merge_state(
steward_state,
StewardFlowStatePatch(
active_flow=flow_id, # type: ignore[arg-type]
flow_id=flow_id, # type: ignore[arg-type]
intent=str(current_flow.get("intent") or "").strip(),
status="collecting" if remaining_missing_fields else "ready_for_confirmation",
fields={response.field_key: response.field_value},
missing_fields=remaining_missing_fields,
evidence=[
{
"source": "runtime_user_message",
"field": response.field_key,
"text": request.user_message,
}
],
),
)
return response.model_copy(update={"steward_state": next_state})
@staticmethod
def _resolve_target_flow_id(
response: StewardRuntimeDecisionResponse,
steward_state: dict[str, Any],
) -> str:
target = str(response.target_task_id or "").strip()
if target in {"travel_application", "travel_reimbursement"}:
return target
active_flow = str(steward_state.get("active_flow") or "").strip()
return active_flow if active_flow in {"travel_application", "travel_reimbursement"} else ""
@staticmethod
def _resolve_flow(steward_state: dict[str, Any], flow_id: str) -> dict[str, Any]:
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
flow = flows.get(flow_id) if isinstance(flows, dict) else {}
return dict(flow) if isinstance(flow, dict) else {}