Files
X-Financial/server/src/app/services/steward_off_topic_agent.py

158 lines
6.4 KiB
Python
Raw Normal View History

"""小财管家业务无关输入的引导生成 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"],
},
},
}