2026-09-13 23:46:15 +08:00
|
|
|
"""投顾聊天意图分类:LLM 优先,规则识别兜底。"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import re
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
from common.common_const import (
|
|
|
|
|
AGENT_INTENT_CASUAL_CHAT,
|
|
|
|
|
AGENT_INTENT_DATA_QUERY,
|
|
|
|
|
AGENT_INTENT_DIALOGUE_SCRIPT,
|
|
|
|
|
AGENT_INTENT_FUND_ANALYSIS,
|
|
|
|
|
AGENT_INTENT_REBALANCE,
|
|
|
|
|
AGENT_INTENT_RECOMMEND,
|
|
|
|
|
)
|
|
|
|
|
from agent.advisor_agent.intent.recognizer import recognize_advisor_intent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
VALID_INTENTS = frozenset({
|
|
|
|
|
AGENT_INTENT_RECOMMEND,
|
|
|
|
|
AGENT_INTENT_REBALANCE,
|
|
|
|
|
AGENT_INTENT_FUND_ANALYSIS,
|
|
|
|
|
AGENT_INTENT_DIALOGUE_SCRIPT,
|
|
|
|
|
AGENT_INTENT_DATA_QUERY,
|
|
|
|
|
AGENT_INTENT_CASUAL_CHAT,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class IntentClassification:
|
|
|
|
|
intent: str
|
|
|
|
|
confidence: float
|
|
|
|
|
source: str
|
|
|
|
|
reason: str = ""
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 09:24:30 +08:00
|
|
|
_CLASSIFIER_PROMPT = """# 任务:投顾助手意图识别
|
|
|
|
|
你是投顾系统意图分类器,对客户输入文本做意图识别,**只能输出一个类别名称,禁止额外解释**。
|
|
|
|
|
|
|
|
|
|
## 类别说明
|
|
|
|
|
1. 基金推荐:客户希望推荐、筛选基金产品
|
|
|
|
|
2. 调仓建议:客户询问是否买卖、加减仓、更换基金,寻求调仓交易建议
|
|
|
|
|
3. 客户持仓基金分析:分析客户现有持仓组合、风险、收益情况,不涉及买卖操作建议
|
|
|
|
|
4. 跟客户的沟通话术:投顾需要生成一段发给客户的话术文案。【注意:客户发起提问,不会是该类别】
|
|
|
|
|
5. 数据查询:查询基金客观数据,如净值、基金经理、规模、持仓、费率等事实信息,不做推荐、诊断
|
|
|
|
|
6. 普通聊天:日常问候、单纯情绪吐槽,无明确业务诉求
|
|
|
|
|
|
|
|
|
|
## 判定优先级(同时存在多个诉求时,取优先级最高)
|
|
|
|
|
调仓建议 > 基金推荐 > 客户持仓基金分析 > 数据查询 > 跟客户的沟通话术 > 普通聊天
|
|
|
|
|
|
|
|
|
|
## 示例(xx表示我名下的客户名字)
|
|
|
|
|
输入:帮我名下XX推荐几只适合养老的基金
|
|
|
|
|
输出:基金推荐
|
|
|
|
|
|
|
|
|
|
输入:XX手上的某某基金现在要不要卖出?
|
|
|
|
|
输出:调仓建议
|
|
|
|
|
|
|
|
|
|
输入:帮我看看xx的基金组合风险高不高
|
|
|
|
|
输出:客户持仓基金分析
|
|
|
|
|
|
|
|
|
|
输入:帮我查一下XX基金最新规模
|
|
|
|
|
输出:数据查询
|
|
|
|
|
|
|
|
|
|
输入:帮我写一段话安抚客户,解释近期回撤
|
|
|
|
|
输出:跟客户的沟通话术
|
|
|
|
|
|
|
|
|
|
输入:今天天气不错
|
|
|
|
|
输出:普通聊天
|
|
|
|
|
|
|
|
|
|
输入:xx持有的这几只基金波动很大,要不要减仓?
|
|
|
|
|
输出:调仓建议
|
|
|
|
|
|
|
|
|
|
输入:帮我看下xx持仓的基金,它们的基金经理是谁
|
|
|
|
|
输出:数据查询
|
|
|
|
|
|
|
|
|
|
现在开始分类
|
|
|
|
|
输入:{{user_query}}
|
|
|
|
|
输出:
|
2026-09-13 23:46:15 +08:00
|
|
|
|
|
|
|
|
只输出 JSON,不要 Markdown,不要额外文字:
|
|
|
|
|
{"intent":"分类值","confidence":0到1之间的数字,"reason":"不超过30字的原因"}
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
_RECOMMENDATION_TERMS = ("推荐", "组合建议", "配置建议", "买什么", "适合配置", "筛选基金", "投资方案")
|
2026-09-15 09:24:30 +08:00
|
|
|
_REBALANCE_TERMS = ("调仓", "再平衡", "组合调整", "配置偏离", "偏离目标")
|
|
|
|
|
_DIALOGUE_TERMS = ("话术", "沟通", "怎么跟客户说", "如何向客户解释", "安抚客户", "投诉处理")
|
|
|
|
|
_CUSTOMER_DATA_TERMS = ("持仓", "资产", "余额", "交易", "账户", "份额", "市值", "盈亏", "资金")
|
|
|
|
|
_REFERENCE_TERMS = ("他", "她", "它", "这个客户", "该客户", "那个客户", "刚才", "上一轮")
|
2026-09-14 13:00:15 +08:00
|
|
|
_SCOPE_ERROR_MESSAGE = "投顾范围查询仅支持客户数据查询"
|
2026-09-13 23:46:15 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rule_fallback(query: str | None) -> IntentClassification:
|
|
|
|
|
intent = recognize_advisor_intent(query)
|
|
|
|
|
if intent:
|
|
|
|
|
return IntentClassification(intent=intent, confidence=0.72, source="rule", reason="关键词规则匹配")
|
|
|
|
|
return IntentClassification(intent=AGENT_INTENT_CASUAL_CHAT, confidence=0.0, source="fallback", reason="无法匹配业务意图")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_model_result(raw: str) -> IntentClassification | None:
|
|
|
|
|
text = raw.strip()
|
|
|
|
|
fenced = re.search(r"\{.*\}", text, re.DOTALL)
|
|
|
|
|
if fenced:
|
|
|
|
|
text = fenced.group(0)
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(text)
|
|
|
|
|
except (TypeError, json.JSONDecodeError):
|
|
|
|
|
return None
|
|
|
|
|
intent = payload.get("intent")
|
|
|
|
|
if intent not in VALID_INTENTS:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
confidence = max(0.0, min(1.0, float(payload.get("confidence", 0.0))))
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
confidence = 0.0
|
|
|
|
|
return IntentClassification(
|
|
|
|
|
intent=intent,
|
|
|
|
|
confidence=confidence,
|
|
|
|
|
source="llm",
|
|
|
|
|
reason=str(payload.get("reason") or "模型分类"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 09:24:30 +08:00
|
|
|
def resolve_advisor_intent(
|
|
|
|
|
query: str | None,
|
|
|
|
|
classified_intent: str,
|
|
|
|
|
*,
|
|
|
|
|
customer_resolved: bool,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""结合实体解析结果做最终业务路由,处理一句话中的明确优先意图。"""
|
|
|
|
|
text = re.sub(r"\s+", "", query or "")
|
|
|
|
|
if any(term in text for term in _REBALANCE_TERMS):
|
|
|
|
|
return AGENT_INTENT_REBALANCE
|
|
|
|
|
if any(term in text for term in _RECOMMENDATION_TERMS):
|
|
|
|
|
return AGENT_INTENT_RECOMMEND
|
|
|
|
|
if any(term in text for term in _DIALOGUE_TERMS):
|
|
|
|
|
return AGENT_INTENT_DIALOGUE_SCRIPT
|
|
|
|
|
if customer_resolved and any(term in text for term in _CUSTOMER_DATA_TERMS):
|
|
|
|
|
return AGENT_INTENT_DATA_QUERY
|
|
|
|
|
return classified_intent
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 23:46:15 +08:00
|
|
|
async def classify_advisor_intent(
|
|
|
|
|
query: str | None,
|
|
|
|
|
llm_client=None,
|
|
|
|
|
*,
|
|
|
|
|
explicit_intent: str | None = None,
|
2026-09-15 09:24:30 +08:00
|
|
|
conversation_context: str = "",
|
2026-09-13 23:46:15 +08:00
|
|
|
timeout: float = 2.0,
|
|
|
|
|
) -> IntentClassification:
|
|
|
|
|
"""分类用户意图;显式意图兼容旧客户端,模型失败时安全回退规则。"""
|
|
|
|
|
if explicit_intent in VALID_INTENTS:
|
|
|
|
|
return IntentClassification(explicit_intent, 1.0, "explicit", "客户端显式指定")
|
|
|
|
|
if not query or not query.strip():
|
|
|
|
|
return IntentClassification("", 0.0, "fallback", "空输入")
|
2026-09-14 13:00:15 +08:00
|
|
|
if re.sub(r"\s+", "", query) == _SCOPE_ERROR_MESSAGE:
|
|
|
|
|
return IntentClassification(
|
|
|
|
|
AGENT_INTENT_CASUAL_CHAT,
|
|
|
|
|
1.0,
|
|
|
|
|
"rule",
|
|
|
|
|
"识别为系统提示文本而非业务查询",
|
|
|
|
|
)
|
2026-09-14 21:41:40 +08:00
|
|
|
fast_intent = recognize_advisor_intent(query)
|
|
|
|
|
if (
|
|
|
|
|
fast_intent == AGENT_INTENT_DATA_QUERY
|
|
|
|
|
and not any(term in query for term in _RECOMMENDATION_TERMS)
|
|
|
|
|
):
|
|
|
|
|
return IntentClassification(
|
|
|
|
|
fast_intent,
|
|
|
|
|
0.95,
|
|
|
|
|
"rule_fast",
|
|
|
|
|
"明显查询类问题",
|
|
|
|
|
)
|
2026-09-15 09:24:30 +08:00
|
|
|
normalized_query = re.sub(r"\s+", "", query)
|
|
|
|
|
if (
|
|
|
|
|
any(term in normalized_query for term in _CUSTOMER_DATA_TERMS)
|
|
|
|
|
and conversation_context
|
|
|
|
|
and any(term in normalized_query for term in _REFERENCE_TERMS)
|
|
|
|
|
):
|
|
|
|
|
return IntentClassification(
|
|
|
|
|
AGENT_INTENT_DATA_QUERY,
|
|
|
|
|
0.9,
|
|
|
|
|
"rule_context",
|
|
|
|
|
"上下文中的客户数据追问",
|
|
|
|
|
)
|
2026-09-13 23:46:15 +08:00
|
|
|
if llm_client is not None:
|
|
|
|
|
try:
|
2026-09-15 09:24:30 +08:00
|
|
|
prompt = query.strip()
|
|
|
|
|
if conversation_context:
|
|
|
|
|
prompt = f"会话上下文:\n{conversation_context[:2000]}\n\n当前问题:\n{prompt}"
|
2026-09-13 23:46:15 +08:00
|
|
|
raw = await asyncio.wait_for(
|
|
|
|
|
llm_client.chat(
|
|
|
|
|
[
|
|
|
|
|
{"role": "system", "content": _CLASSIFIER_PROMPT},
|
2026-09-15 09:24:30 +08:00
|
|
|
{"role": "user", "content": prompt},
|
2026-09-13 23:46:15 +08:00
|
|
|
],
|
|
|
|
|
temperature=0,
|
|
|
|
|
),
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
)
|
|
|
|
|
parsed = _parse_model_result(raw)
|
|
|
|
|
if parsed is not None:
|
|
|
|
|
# LLM 偶尔会把“查询持仓/资产”等只读请求误判为推荐;
|
|
|
|
|
# 对明确的查询动作以规则结果为准,避免误进入草稿生成分支。
|
|
|
|
|
rule_intent = recognize_advisor_intent(query)
|
|
|
|
|
if (
|
|
|
|
|
rule_intent == AGENT_INTENT_DATA_QUERY
|
2026-09-14 13:00:15 +08:00
|
|
|
and parsed.intent != AGENT_INTENT_DATA_QUERY
|
2026-09-13 23:46:15 +08:00
|
|
|
and not any(term in (query or "") for term in _RECOMMENDATION_TERMS)
|
|
|
|
|
):
|
|
|
|
|
return IntentClassification(
|
|
|
|
|
intent=rule_intent,
|
|
|
|
|
confidence=max(parsed.confidence, 0.9),
|
|
|
|
|
source="rule_override",
|
|
|
|
|
reason="明确查询类关键词覆盖模型误判",
|
|
|
|
|
)
|
|
|
|
|
return parsed
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return _rule_fallback(query)
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 11:42:26 +08:00
|
|
|
__all__ = ["IntentClassification", "classify_advisor_intent", "resolve_advisor_intent"]
|