2026-09-11 11:02:15 +08:00
|
|
|
|
"""Customer-service intent recognition contract."""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-09-13 18:46:05 +08:00
|
|
|
|
import json
|
2026-09-11 11:02:15 +08:00
|
|
|
|
import logging
|
2026-09-12 15:06:34 +08:00
|
|
|
|
import re
|
2026-09-13 18:46:05 +08:00
|
|
|
|
from dataclasses import dataclass
|
2026-09-11 11:02:15 +08:00
|
|
|
|
from enum import StrEnum
|
2026-09-13 18:46:05 +08:00
|
|
|
|
from inspect import isawaitable
|
2026-09-11 11:02:15 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("rag.intent")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Intent(StrEnum):
|
|
|
|
|
|
GUIDE_PURCHASE = "guide_purchase"
|
|
|
|
|
|
WANT_ADVISOR = "want_advisor"
|
|
|
|
|
|
NL2SQL_REQUEST = "nl2sql_request"
|
|
|
|
|
|
KNOWLEDGE_QA = "knowledge_qa"
|
2026-09-12 15:06:34 +08:00
|
|
|
|
COMPANY_INFO = "company_info"
|
|
|
|
|
|
CHITCHAT = "chitchat"
|
|
|
|
|
|
OFF_TOPIC = "off_topic"
|
2026-09-11 11:02:15 +08:00
|
|
|
|
NO_MATCH = "no_match"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
INTENT_VALUES = frozenset(item.value for item in Intent)
|
|
|
|
|
|
|
2026-09-12 15:06:34 +08:00
|
|
|
|
# 按长度降序匹配,避免短标签误命中长标签的一部分
|
|
|
|
|
|
_INTENT_PATTERN = re.compile(
|
|
|
|
|
|
"|".join(re.escape(value) for value in sorted(INTENT_VALUES, key=len, reverse=True))
|
|
|
|
|
|
)
|
2026-09-13 18:46:05 +08:00
|
|
|
|
_JSON_PATTERN = re.compile(r"\{.*\}", re.S)
|
|
|
|
|
|
|
|
|
|
|
|
# 改写句允许的最大长度:相对原句的倍数与绝对下限取大者,防止模型把历史大段塞进改写句
|
|
|
|
|
|
_REWRITE_MAX_RATIO = 4
|
|
|
|
|
|
_REWRITE_MIN_LIMIT = 200
|
2026-09-12 15:06:34 +08:00
|
|
|
|
|
|
|
|
|
|
INTENT_SYSTEM_PROMPT = (
|
|
|
|
|
|
"你是华夏科技(一家基金代销金融机构)智能客服的意图分类器。"
|
2026-09-13 18:46:05 +08:00
|
|
|
|
"你会看到最近几轮对话(可能为空)和用户的【当前输入】。\n"
|
|
|
|
|
|
"任务:\n"
|
|
|
|
|
|
"1. 只对【当前输入】判定意图;【最近对话】仅用于理解当前输入中省略的主语或指代"
|
|
|
|
|
|
"(如“它”“那个”“呢”“还有吗”)。\n"
|
|
|
|
|
|
"2. 若当前输入依赖上文才能理解,请把它改写为一句不依赖上文的完整问题,"
|
|
|
|
|
|
"只能补全上文已明确出现的对象,不得添加新信息、不得改变原意;"
|
|
|
|
|
|
"若当前输入本身已完整,query 原样返回。\n"
|
|
|
|
|
|
"3. 当前输入若明显切换到新话题,以当前输入为准,不要延续上一轮的意图。\n"
|
|
|
|
|
|
"4. 致谢、告别、寒暄即使出现在知识问答之后也归为 chitchat。\n"
|
|
|
|
|
|
"意图选项:\n"
|
2026-09-12 15:06:34 +08:00
|
|
|
|
"- guide_purchase: 用户询问如何购买基金、开户、注册等引导类问题\n"
|
|
|
|
|
|
"- want_advisor: 用户希望获得个性化基金推荐或投资顾问服务\n"
|
|
|
|
|
|
"- knowledge_qa: 用户询问基金相关的知识性问题,如净值、费率、风险、申赎规则等\n"
|
2026-09-14 00:19:31 +08:00
|
|
|
|
"- company_info: 用户询问华夏科技公司本身的静态档案信息,如公司全称、成立时间、牌照、总部地址、"
|
|
|
|
|
|
"客服电话、服务时间、官网、投诉渠道等;"
|
|
|
|
|
|
"注意:公司的产品清单、产品数量、产品数据不属于公司信息,应归为 nl2sql_request\n"
|
2026-09-13 20:48:42 +08:00
|
|
|
|
"- nl2sql_request: 用户要求查询具体数据或账户信息,"
|
|
|
|
|
|
"如“我的持仓有哪些”“我买了多少XX基金”“我的交易记录”"
|
2026-09-14 00:19:31 +08:00
|
|
|
|
"“最近一周XX基金的净值数据”“XX基金最新的规模/费率数据”"
|
|
|
|
|
|
"“你们公司有哪些股票型基金产品”“公司在售的债券基金有哪些”等"
|
|
|
|
|
|
"询问公司产品清单/产品数据或个人账户数据、要求数据本身而非知识解释的问题\n"
|
2026-09-12 15:06:34 +08:00
|
|
|
|
"- chitchat: 普通寒暄,如问候、致谢、告别、询问你是谁/你能做什么、在吗等一两句话的闲聊\n"
|
|
|
|
|
|
"- off_topic: 用户要求你实质性地处理与金融、基金、公司业务无关的事情,"
|
|
|
|
|
|
"如写代码、讲笑话、写作文、问天气、聊政治、情感咨询、做数学题等\n"
|
|
|
|
|
|
"- no_match: 无法归入以上任何一类\n"
|
|
|
|
|
|
"注意:寒暄性质的一两句话归为 chitchat;一旦用户提出金融之外的实质性请求,归为 off_topic。\n"
|
2026-09-13 18:46:05 +08:00
|
|
|
|
"只输出一行 JSON,格式:{\"intent\": \"<小写英文标签>\", \"query\": \"<完整问题>\"},"
|
|
|
|
|
|
"不要输出解释、Markdown 或其他内容。"
|
2026-09-12 15:06:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 18:46:05 +08:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class IntentResult:
|
|
|
|
|
|
intent: Intent
|
|
|
|
|
|
# 结合历史补全后的独立问题;无需补全或解析失败时等于原句
|
|
|
|
|
|
query: str
|
|
|
|
|
|
used_history: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _config_value(config_getter, key: str, default):
|
|
|
|
|
|
value = config_getter(key, str(default))
|
|
|
|
|
|
if isawaitable(value):
|
|
|
|
|
|
value = await value
|
|
|
|
|
|
return type(default)(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-12 15:06:34 +08:00
|
|
|
|
def parse_intent(raw: str | None) -> Intent:
|
|
|
|
|
|
"""从模型原始输出中提取第一个合法标签,提取不到则返回 NO_MATCH。"""
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return Intent.NO_MATCH
|
|
|
|
|
|
match = _INTENT_PATTERN.search(raw.lower())
|
|
|
|
|
|
return Intent(match.group(0)) if match else Intent.NO_MATCH
|
|
|
|
|
|
|
2026-09-11 11:02:15 +08:00
|
|
|
|
|
2026-09-13 18:46:05 +08:00
|
|
|
|
def parse_intent_result(raw: str | None, *, fallback_query: str, used_history: bool = False) -> IntentResult:
|
|
|
|
|
|
"""优先按 JSON 解析意图与改写句;JSON 不可用时退回正则抽标签、原句作为 query。"""
|
|
|
|
|
|
match = _JSON_PATTERN.search(raw) if raw else None
|
|
|
|
|
|
data = None
|
|
|
|
|
|
if match:
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(match.group(0))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
data = None
|
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
|
return IntentResult(parse_intent(raw), fallback_query, used_history)
|
|
|
|
|
|
intent = parse_intent(str(data.get("intent") or ""))
|
|
|
|
|
|
rewritten = str(data.get("query") or "").strip()
|
|
|
|
|
|
limit = max(len(fallback_query) * _REWRITE_MAX_RATIO, _REWRITE_MIN_LIMIT)
|
|
|
|
|
|
query = rewritten if rewritten and len(rewritten) <= limit else fallback_query
|
|
|
|
|
|
return IntentResult(intent, query, used_history)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_history(history, *, max_turns: int, max_chars: int) -> str:
|
|
|
|
|
|
"""把最近 max_turns 轮 user/assistant 消息折叠成一段文本,每条截断到 max_chars。"""
|
|
|
|
|
|
if max_turns <= 0 or not history:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
recent = [
|
|
|
|
|
|
message for message in history
|
|
|
|
|
|
if isinstance(message, dict)
|
|
|
|
|
|
and message.get("role") in ("user", "assistant")
|
|
|
|
|
|
and message.get("content")
|
|
|
|
|
|
][-max_turns * 2:]
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
for message in recent:
|
|
|
|
|
|
role = "用户" if message["role"] == "user" else "客服"
|
|
|
|
|
|
content = str(message["content"]).replace("\n", " ").strip()
|
|
|
|
|
|
if len(content) > max_chars:
|
|
|
|
|
|
content = content[:max_chars] + "…"
|
|
|
|
|
|
lines.append(f"{role}:{content}")
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_intent_messages(query: str, rendered_history: str) -> list[dict]:
|
|
|
|
|
|
if rendered_history:
|
|
|
|
|
|
user_content = f"【最近对话】\n{rendered_history}\n\n【当前输入】\n{query}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
user_content = f"【当前输入】\n{query}"
|
|
|
|
|
|
return [
|
2026-09-12 15:06:34 +08:00
|
|
|
|
{"role": "system", "content": INTENT_SYSTEM_PROMPT},
|
2026-09-13 18:46:05 +08:00
|
|
|
|
{"role": "user", "content": user_content},
|
2026-09-11 11:02:15 +08:00
|
|
|
|
]
|
2026-09-13 18:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def intent_recognize(
|
|
|
|
|
|
query: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
llm_client,
|
|
|
|
|
|
history: list[dict] | None = None,
|
|
|
|
|
|
config_getter=None,
|
|
|
|
|
|
) -> IntentResult:
|
|
|
|
|
|
"""结合最近几轮对话识别当前输入的意图,并给出补全指代后的独立问题。
|
|
|
|
|
|
|
|
|
|
|
|
history 为空或 agent.customer.intent.history_turns 配置为 0 时退化为仅看当前句。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not query or not query.strip():
|
|
|
|
|
|
return IntentResult(Intent.NO_MATCH, query or "")
|
|
|
|
|
|
max_turns, max_chars = 3, 200
|
|
|
|
|
|
if config_getter is not None:
|
|
|
|
|
|
max_turns = await _config_value(config_getter, "agent.customer.intent.history_turns", max_turns)
|
|
|
|
|
|
max_chars = await _config_value(config_getter, "agent.customer.intent.history_max_chars", max_chars)
|
|
|
|
|
|
rendered = render_history(history, max_turns=max_turns, max_chars=max_chars)
|
|
|
|
|
|
messages = build_intent_messages(query, rendered)
|
2026-09-11 11:02:15 +08:00
|
|
|
|
try:
|
|
|
|
|
|
raw = await llm_client.chat(messages)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.exception("intent recognition failed")
|
2026-09-13 18:46:05 +08:00
|
|
|
|
return IntentResult(Intent.NO_MATCH, query, bool(rendered))
|
|
|
|
|
|
return parse_intent_result(raw, fallback_query=query, used_history=bool(rendered))
|