Files
Mutual_Fund/rag/intent.py
T

170 lines
7.7 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.
"""Customer-service intent recognition contract."""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from enum import StrEnum
from inspect import isawaitable
logger = logging.getLogger("rag.intent")
class Intent(StrEnum):
GUIDE_PURCHASE = "guide_purchase"
WANT_ADVISOR = "want_advisor"
NL2SQL_REQUEST = "nl2sql_request"
KNOWLEDGE_QA = "knowledge_qa"
COMPANY_INFO = "company_info"
CHITCHAT = "chitchat"
OFF_TOPIC = "off_topic"
TRANSFER_HUMAN = "transfer_human"
NO_MATCH = "no_match"
INTENT_VALUES = frozenset(item.value for item in Intent)
# 按长度降序匹配,避免短标签误命中长标签的一部分
_INTENT_PATTERN = re.compile(
"|".join(re.escape(value) for value in sorted(INTENT_VALUES, key=len, reverse=True))
)
_JSON_PATTERN = re.compile(r"\{.*\}", re.S)
# 改写句允许的最大长度:相对原句的倍数与绝对下限取大者,防止模型把历史大段塞进改写句
_REWRITE_MAX_RATIO = 4
_REWRITE_MIN_LIMIT = 200
INTENT_SYSTEM_PROMPT = (
"你是华夏科技(一家基金代销金融机构)智能客服的意图分类器。"
"你会看到最近几轮对话(可能为空)和用户的【当前输入】。\n"
"任务:\n"
"1. 只对【当前输入】判定意图;【最近对话】仅用于理解当前输入中省略的主语或指代"
"(如“它”“那个”“呢”“还有吗”)。\n"
"2. 若当前输入依赖上文才能理解,请把它改写为一句不依赖上文的完整问题,"
"只能补全上文已明确出现的对象,不得添加新信息、不得改变原意;"
"若当前输入本身已完整,query 原样返回。\n"
"3. 当前输入若明显切换到新话题,以当前输入为准,不要延续上一轮的意图。\n"
"4. 致谢、告别、寒暄即使出现在知识问答之后也归为 chitchat。\n"
"意图选项:\n"
"- guide_purchase: 用户询问如何购买基金、开户、注册等引导类问题\n"
"- want_advisor: 用户希望获得个性化基金推荐或投资顾问服务\n"
"- knowledge_qa: 用户询问基金相关的知识性问题,如净值、费率、风险、申赎规则等\n"
"- company_info: 用户询问华夏科技公司本身的静态档案信息,如公司全称、成立时间、牌照、总部地址、"
"客服电话、服务时间、官网、投诉渠道等;"
"注意:公司的产品清单、产品数量、产品数据不属于公司信息,应归为 nl2sql_request\n"
"- nl2sql_request: 用户要求查询具体数据或账户信息,"
"如“我的持仓有哪些”“我买了多少XX基金”“我的交易记录”"
"“最近一周XX基金的净值数据”“XX基金最新的规模/费率数据”"
"“你们公司有哪些股票型基金产品”“公司在售的债券基金有哪些”等"
"询问公司产品清单/产品数据或个人账户数据、要求数据本身而非知识解释的问题\n"
"- chitchat: 普通寒暄,如问候、致谢、告别、询问你是谁/你能做什么、在吗等一两句话的闲聊\n"
"- off_topic: 用户要求你实质性地处理与金融、基金、公司业务无关的事情,"
"如写代码、讲笑话、写作文、问天气、聊政治、情感咨询、做数学题等\n"
"- transfer_human: 用户明确要求转接人工客服,或表达不满、投诉、对智能客服不信任,"
"希望由真人处理,如“转人工”“找人工客服”“我要投诉”“人工服务”“我要见真人”“别跟我说废话找客服”等\n"
"- no_match: 无法归入以上任何一类\n"
"注意:寒暄性质的一两句话归为 chitchat;一旦用户提出金融之外的实质性请求,归为 off_topic。\n"
"只输出一行 JSON,格式:{\"intent\": \"<小写英文标签>\", \"query\": \"<完整问题>\"},"
"不要输出解释、Markdown 或其他内容。"
)
@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)
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
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 [
{"role": "system", "content": INTENT_SYSTEM_PROMPT},
{"role": "user", "content": user_content},
]
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)
try:
raw = await llm_client.chat(messages)
except Exception:
logger.exception("intent recognition failed")
return IntentResult(Intent.NO_MATCH, query, bool(rendered))
return parse_intent_result(raw, fallback_query=query, used_history=bool(rendered))