Files
X-Financial/server/src/app/services/steward_off_topic_agent.py
caoxiaozhu a6674a1e76 feat(steward): off_topic 场景细分与引导回复
- 将业务无关输入细分为 greeting / meaningless / off_business 三类场景
- 新增 StewardOffTopicAgent,用 function calling 生成管家语气引导回复
- steward endpoint 与 user_agent_application 串联 off_topic 引导话术
- 补充 planner 与 user agent 的 off_topic 覆盖测试
2026-06-18 22:12:10 +08:00

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