from __future__ import annotations import json from dataclasses import dataclass from typing import Any from app.schemas.steward import ( StewardSlotDecisionRequest, StewardSlotDecisionResponse, StewardSlotOption, ) from app.services.ontology_field_registry import normalize_ontology_form_values from app.services.runtime_chat import RuntimeChatService from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER, BUSINESS_CANONICAL_FIELDS STEWARD_SLOT_DECISION_FUNCTION_NAME = "submit_steward_slot_decision" FIELD_CATALOG: dict[str, dict[str, str]] = { "expense_type": {"label": "费用类型", "description": "申请或报销所属费用场景,如差旅、交通、住宿、业务招待。"}, "time_range": {"label": "时间", "description": "申请时为出差起止日期,报销时为费用发生日期。"}, "location": {"label": "地点", "description": "出差目的地、费用发生地或业务活动地点。"}, "reason": {"label": "事由", "description": "出差、报销或业务活动的业务原因。"}, "amount": {"label": "金额", "description": "报销时为实际金额;申请时金额可由系统估算,不应默认要求用户填写。"}, "transport_mode": {"label": "出行方式", "description": "差旅申请交通费用测算所需字段,由用户明确选择或表达。"}, "attachments": {"label": "附件/凭证", "description": "发票、行程单、付款截图或其他证明材料。"}, "customer_name": {"label": "客户或项目对象", "description": "业务招待、客户拜访或项目支撑涉及的对象。"}, "merchant_name": {"label": "商户/开票方", "description": "报销票据上的商户或开票方。"}, "department_name": {"label": "所属部门", "description": "申请人或费用归属部门。"}, "employee_name": {"label": "申请人", "description": "发起申请或报销的员工。"}, "employee_no": {"label": "员工编号", "description": "公司内部员工编号。"}, } APPLICATION_NON_BLOCKING_FIELDS = {"amount", "attachments", "employee_no", "department_name", "employee_name"} @dataclass(frozen=True, slots=True) class StewardSlotDecisionAgentResult: payload: dict[str, Any] model_call_traces: list[dict[str, Any]] class StewardSlotDecisionAgent: """用大模型 function calling 判断当前任务缺什么,以及下一步是否应先追问。""" def __init__(self, runtime_chat_service: RuntimeChatService) -> None: self.runtime_chat_service = runtime_chat_service def decide(self, request: StewardSlotDecisionRequest) -> StewardSlotDecisionResponse: normalized_request = self._normalize_request(request) 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_SLOT_DECISION_FUNCTION_NAME}, }, max_tokens=1200, temperature=0.05, timeout_seconds=30, max_attempts=1, ) if result.tool_call is not None and result.tool_call.name == STEWARD_SLOT_DECISION_FUNCTION_NAME: response = self._build_response_from_model_payload( result.tool_call.arguments, normalized_request, result.calls_as_dicts(), ) if response is not None: return response return self._build_rule_fallback(normalized_request, result.calls_as_dicts()) @staticmethod def _normalize_request(request: StewardSlotDecisionRequest) -> StewardSlotDecisionRequest: normalized_fields = { key: value for key, value in normalize_ontology_form_values(request.ontology_fields).items() if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip() } missing_fields: list[str] = [] for item in request.missing_fields: key = str(item or "").strip() if request.task_type == "expense_application" and key in APPLICATION_NON_BLOCKING_FIELDS: continue if key in BUSINESS_CANONICAL_FIELDS and key not in missing_fields and not normalized_fields.get(key): missing_fields.append(key) return StewardSlotDecisionRequest( task_type=request.task_type, user_message=str(request.user_message or "").strip(), ontology_fields=normalized_fields, missing_fields=missing_fields, task_context=request.task_context if isinstance(request.task_context, dict) else {}, ) @staticmethod def _build_messages(request: StewardSlotDecisionRequest) -> list[dict[str, Any]]: context_payload = { "task_type": request.task_type, "user_message": request.user_message, "ontology_fields": request.ontology_fields, "missing_fields_from_intent_agent": request.missing_fields, "field_catalog": { key: FIELD_CATALOG[key] for key in BUSINESS_CANONICAL_FIELD_ORDER if key in FIELD_CATALOG }, "task_context": request.task_context, } return [ { "role": "system", "content": ( "你是 X-Financial 小财管家的任务字段决策智能体。" "你必须通过 function calling 返回下一步动作。" "你的任务不是关键词匹配,而是结合用户意图、当前任务类型、canonical ontology 字段、" "上游意图识别给出的缺失字段和字段目录,判断现在应先追问用户,还是可以展示核对结果。" "所有 required_fields 和 missing_fields 只能使用 field_catalog 中的 canonical 字段。" "如果字段是内部提示、示例、系统指令或可选项,不能当作用户已经提供。" "费用申请场景中 amount 可由系统估算,不应作为用户必须手填字段。" "费用申请生成核对表阶段,attachments 不阻塞生成,可在报销或归档阶段补充;" "employee_no、department_name、employee_name 属于系统用户档案字段,必须从上下文读取,不能向用户追问。" "差旅申请通常只有 transport_mode 这类会影响费用测算的字段才需要先追问。" "如果缺失字段会影响后续测算、入库、附件归集或合规判断,应返回 ask_user;" "如果信息足以生成可核对但未提交的结果,应返回 render_preview。" "question 和 rationale 必须是面向用户的业务说明,不暴露内部推理链。" ), }, { "role": "user", "content": json.dumps(context_payload, ensure_ascii=False), }, ] @staticmethod def _build_tool_schema() -> dict[str, Any]: canonical_fields = list(BUSINESS_CANONICAL_FIELD_ORDER) return { "type": "function", "function": { "name": STEWARD_SLOT_DECISION_FUNCTION_NAME, "description": "提交小财管家当前任务的字段缺口和下一步动作决策。", "parameters": { "type": "object", "properties": { "next_action": { "type": "string", "enum": ["ask_user", "render_preview"], }, "required_fields": { "type": "array", "items": {"type": "string", "enum": canonical_fields}, }, "missing_fields": { "type": "array", "items": {"type": "string", "enum": canonical_fields}, }, "question": {"type": "string"}, "options": { "type": "array", "items": { "type": "object", "properties": { "label": {"type": "string"}, "value": {"type": "string"}, "field_key": {"type": "string", "enum": canonical_fields}, "description": {"type": "string"}, }, "required": ["label", "value", "field_key"], }, }, "rationale": {"type": "string"}, }, "required": ["next_action", "required_fields", "missing_fields", "question", "options", "rationale"], }, }, } def _build_response_from_model_payload( self, payload: dict[str, Any], request: StewardSlotDecisionRequest, traces: list[dict[str, Any]], ) -> StewardSlotDecisionResponse | None: next_action = str(payload.get("next_action") or "").strip() if next_action not in {"ask_user", "render_preview"}: return None required_fields = self._sanitize_fields(payload.get("required_fields")) missing_fields = self._sanitize_fields(payload.get("missing_fields")) required_fields = self._filter_blocking_fields(required_fields, request.task_type) missing_fields = self._filter_blocking_fields(missing_fields, request.task_type) missing_fields = [ key for key in missing_fields if key in required_fields or key in request.missing_fields ] if next_action == "ask_user" and not missing_fields: missing_fields = list(request.missing_fields) if next_action == "ask_user" and not missing_fields: next_action = "render_preview" options = [] question = "" rationale = "当前申请信息足以先生成核对结果;附件和员工编号不应作为用户补填项阻塞申请预览。" else: options = self._sanitize_options(payload.get("options"), missing_fields) question = self._clean_text(payload.get("question")) rationale = self._clean_text(payload.get("rationale")) return StewardSlotDecisionResponse( decision_source="llm_function_call", next_action=next_action, # type: ignore[arg-type] required_fields=required_fields, missing_fields=missing_fields, question=question, options=options, rationale=rationale, model_call_traces=traces, ) @staticmethod def _filter_blocking_fields(fields: list[str], task_type: str) -> list[str]: if task_type != "expense_application": return fields return [field for field in fields if field not in APPLICATION_NON_BLOCKING_FIELDS] @staticmethod def _sanitize_fields(raw_fields: Any) -> list[str]: fields: list[str] = [] if not isinstance(raw_fields, list): return fields for item in raw_fields: key = str(item or "").strip() if key in BUSINESS_CANONICAL_FIELDS and key not in fields: fields.append(key) return fields def _sanitize_options(self, raw_options: Any, missing_fields: list[str]) -> list[StewardSlotOption]: options: list[StewardSlotOption] = [] if isinstance(raw_options, list): for item in raw_options: if not isinstance(item, dict): continue field_key = str(item.get("field_key") or "").strip() label = self._clean_text(item.get("label")) value = self._clean_text(item.get("value")) or label if not field_key or field_key not in BUSINESS_CANONICAL_FIELDS or not label or not value: continue options.append( StewardSlotOption( field_key=field_key, label=label, value=value, description=self._clean_text(item.get("description")), ) ) if not options and missing_fields and missing_fields[0] == "transport_mode": options = [ StewardSlotOption(field_key="transport_mode", label="火车", value="火车", description="选择火车或高铁出行。"), StewardSlotOption(field_key="transport_mode", label="飞机", value="飞机", description="选择飞机出行。"), StewardSlotOption(field_key="transport_mode", label="轮船", value="轮船", description="选择轮船出行。"), ] return options[:6] def _build_rule_fallback( self, request: StewardSlotDecisionRequest, traces: list[dict[str, Any]], ) -> StewardSlotDecisionResponse: missing_fields = list(request.missing_fields) if missing_fields: field = missing_fields[0] return StewardSlotDecisionResponse( decision_source="rule_fallback", next_action="ask_user", required_fields=list(dict.fromkeys([*request.ontology_fields.keys(), *missing_fields])), missing_fields=missing_fields, question=self._build_fallback_question(field), options=self._sanitize_options([], [field]), rationale="模型字段决策暂不可用,我先按上游意图识别给出的本体缺口向你确认。", model_call_traces=traces, ) return StewardSlotDecisionResponse( decision_source="rule_fallback", next_action="render_preview", required_fields=list(request.ontology_fields.keys()), missing_fields=[], question="", options=[], rationale="当前任务没有上游标记的关键字段缺口,可以先生成核对结果供你确认。", model_call_traces=traces, ) @staticmethod def _build_fallback_question(field: str) -> str: label = FIELD_CATALOG.get(field, {}).get("label") or field if field == "transport_mode": return "请问你这次打算怎么出行?可以选择火车、飞机或轮船。" return f"当前还缺少{label},请先补充后我再继续处理。" @staticmethod def _clean_text(value: Any) -> str: return str(value or "").strip()