"""小财管家业务无关输入的引导生成 agent。 当用户的输入被识别为与财务任务无关时(问候、纯数字、闲聊等), 由该 agent 用 function calling 让主模型生成一句管家对主人语气的引导回复。 LLM 不可用或调用失败时,由调用方 fallback 到规则模板。 """ from __future__ import annotations import json from dataclasses import dataclass from typing import Any from app.schemas.steward import StewardPlanRequest from app.services.runtime_chat import RuntimeChatService STEWARD_OFF_TOPIC_FUNCTION_NAME = "submit_steward_off_topic_response" STEWARD_OFF_TOPIC_SCENARIO_PROMPTS: dict[str, str] = { "greeting": ( "用户发起了礼貌问候,例如「你好」「您好」「早上好」。" "请像管家一样礼貌地回应主人的问候,温和询问主人今天要办理什么业务," "并顺势说明小财管家能帮主人整理费用申请和费用报销两类事项。" "可以再提示主人试试用具体的话术来表达需求。" ), "off_business": ( "用户说了有意义的话但与财务无关,例如问天气、聊生活、聊工作日常。" "请温和地告诉主人:这句话里没有识别到财务事项," "小财管家目前只能帮主人整理费用申请和费用报销。" "再请主人用具体的话术告诉小财管家他/她想办什么业务。" ), "meaningless": ( "用户输入了与财务无关且难以理解的内容,例如纯数字、纯标点、重复字符。" "请温和地告诉主人:这句话里好像没有出现费用申请、报销、出差、交通、招待这些关键词," "请主人换种说法再告诉小财管家一次。" ), } @dataclass(frozen=True, slots=True) class StewardOffTopicAgentResult: response_text: str model_call_traces: list[dict[str, Any]] class StewardOffTopicAgent: """使用大模型 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 generate( self, request: StewardPlanRequest, *, scenario: str, ) -> StewardOffTopicAgentResult | None: messages = self._build_messages(request, scenario=scenario) try: result = self.runtime_chat_service.complete_with_tool_call( messages, tools=[self._build_tool_schema()], tool_choice={ "type": "function", "function": {"name": STEWARD_OFF_TOPIC_FUNCTION_NAME}, }, max_tokens=400, temperature=0.7, timeout_seconds=15, max_attempts=1, ) except Exception: return None self.last_call_traces = result.calls_as_dicts() if result.tool_call is None or result.tool_call.name != STEWARD_OFF_TOPIC_FUNCTION_NAME: return None arguments = result.tool_call.arguments or {} response_text = str(arguments.get("response_text") or "").strip() if not response_text: return None return StewardOffTopicAgentResult( response_text=response_text, model_call_traces=self.last_call_traces, ) @staticmethod def _build_messages( request: StewardPlanRequest, *, scenario: str, ) -> list[dict[str, Any]]: scenario_hint = STEWARD_OFF_TOPIC_SCENARIO_PROMPTS.get( scenario, STEWARD_OFF_TOPIC_SCENARIO_PROMPTS["off_business"], ) return [ { "role": "system", "content": ( "你是 X-Financial 的小财管家,要像一位贴身的管家那样为主人服务。" "用户输入的内容与财务任务无关,你需要生成一段温和、尊敬的引导回复。" "要求:\n" "1. 始终称呼用户为「主人」「您」,不要用「你」。\n" "2. 语气尊敬、温和、主动,体现管家的服务意识。\n" "3. 不要每次都用相同的句式,要根据用户的输入和当前场景变化表达。\n" "4. 控制在 2-4 句,不要过长。\n" "5. 必须使用 function calling 输出 response_text,不要返回普通文本。\n" "6. response_text 使用 Markdown,第一行用 ### 标题,正文与引导句之间留空行。\n" "7. 如果主人是在问候,先礼貌回应再引导;" "如果主人说的是非财务话题,温和说明小财管家能做什么;" "如果主人的内容没有意义,请他/她换种说法。\n" f"当前场景:{scenario_hint}" ), }, { "role": "user", "content": json.dumps( { "message": request.message, "scenario": scenario, }, ensure_ascii=False, ), }, ] @staticmethod def _build_tool_schema() -> dict[str, Any]: return { "type": "function", "function": { "name": STEWARD_OFF_TOPIC_FUNCTION_NAME, "description": ( "提交小财管家对业务无关输入的引导回复。" "response_text 是完整 Markdown 文本:" "第一行 ### 标题(基于场景),空行,正文(歉意或回应 + 能力范围 + 引导句)。" ), "parameters": { "type": "object", "properties": { "response_text": { "type": "string", "description": ( "面向用户的引导回复 Markdown 文本。" "称呼用户为「主人」「您」,不要用「你」。" ), }, }, "required": ["response_text"], }, }, }