Files
Mutual_Fund/rag/intent.py
T

163 lines
6.9 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"
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: 用户询问华夏科技公司本身的信息,如公司全称、成立时间、牌照、总部地址、"
"客服电话、服务时间、官网、投诉渠道等\n"
"- nl2sql_request: 用户要求查询具体数据或账户信息\n"
"- chitchat: 普通寒暄,如问候、致谢、告别、询问你是谁/你能做什么、在吗等一两句话的闲聊\n"
"- off_topic: 用户要求你实质性地处理与金融、基金、公司业务无关的事情,"
"如写代码、讲笑话、写作文、问天气、聊政治、情感咨询、做数学题等\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))