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 {}