- 将业务无关输入细分为 greeting / meaningless / off_business 三类场景 - 新增 StewardOffTopicAgent,用 function calling 生成管家语气引导回复 - steward endpoint 与 user_agent_application 串联 off_topic 引导话术 - 补充 planner 与 user agent 的 off_topic 覆盖测试
158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
"""小财管家业务无关输入的引导生成 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"],
|
||
},
|
||
},
|
||
}
|