"""投顾聊天意图分类: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 = "" _CLASSIFIER_PROMPT = """# 任务:投顾助手意图识别 你是投顾系统意图分类器,对客户输入文本做意图识别,**只能输出一个类别名称,禁止额外解释**。 ## 类别说明 1. 基金推荐:客户希望推荐、筛选基金产品 2. 调仓建议:客户询问是否买卖、加减仓、更换基金,寻求调仓交易建议 3. 客户持仓基金分析:分析客户现有持仓组合、风险、收益情况,不涉及买卖操作建议 4. 跟客户的沟通话术:投顾需要生成一段发给客户的话术文案。【注意:客户发起提问,不会是该类别】 5. 数据查询:查询基金客观数据,如净值、基金经理、规模、持仓、费率等事实信息,不做推荐、诊断 6. 普通聊天:日常问候、单纯情绪吐槽,无明确业务诉求 ## 判定优先级(同时存在多个诉求时,取优先级最高) 调仓建议 > 基金推荐 > 客户持仓基金分析 > 数据查询 > 跟客户的沟通话术 > 普通聊天 ## 示例(xx表示我名下的客户名字) 输入:帮我名下XX推荐几只适合养老的基金 输出:基金推荐 输入:XX手上的某某基金现在要不要卖出? 输出:调仓建议 输入:帮我看看xx的基金组合风险高不高 输出:客户持仓基金分析 输入:帮我查一下XX基金最新规模 输出:数据查询 输入:帮我写一段话安抚客户,解释近期回撤 输出:跟客户的沟通话术 输入:今天天气不错 输出:普通聊天 输入:xx持有的这几只基金波动很大,要不要减仓? 输出:调仓建议 输入:帮我看下xx持仓的基金,它们的基金经理是谁 输出:数据查询 现在开始分类 输入:{{user_query}} 输出: 只输出 JSON,不要 Markdown,不要额外文字: {"intent":"分类值","confidence":0到1之间的数字,"reason":"不超过30字的原因"} """ _RECOMMENDATION_TERMS = ("推荐", "组合建议", "配置建议", "买什么", "适合配置", "筛选基金", "投资方案") _REBALANCE_TERMS = ("调仓", "再平衡", "组合调整", "配置偏离", "偏离目标") _DIALOGUE_TERMS = ("话术", "沟通", "怎么跟客户说", "如何向客户解释", "安抚客户", "投诉处理") _CUSTOMER_DATA_TERMS = ("持仓", "资产", "余额", "交易", "账户", "份额", "市值", "盈亏", "资金") _REFERENCE_TERMS = ("他", "她", "它", "这个客户", "该客户", "那个客户", "刚才", "上一轮") _SCOPE_ERROR_MESSAGE = "投顾范围查询仅支持客户数据查询" 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 "模型分类"), ) 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 async def classify_advisor_intent( query: str | None, llm_client=None, *, explicit_intent: str | None = None, conversation_context: str = "", 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", "空输入") if re.sub(r"\s+", "", query) == _SCOPE_ERROR_MESSAGE: return IntentClassification( AGENT_INTENT_CASUAL_CHAT, 1.0, "rule", "识别为系统提示文本而非业务查询", ) 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", "明显查询类问题", ) 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", "上下文中的客户数据追问", ) if llm_client is not None: try: prompt = query.strip() if conversation_context: prompt = f"会话上下文:\n{conversation_context[:2000]}\n\n当前问题:\n{prompt}" raw = await asyncio.wait_for( llm_client.chat( [ {"role": "system", "content": _CLASSIFIER_PROMPT}, {"role": "user", "content": prompt}, ], temperature=0, max_tokens=120, ), 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 and parsed.intent != AGENT_INTENT_DATA_QUERY 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) __all__ = ["IntentClassification", "classify_advisor_intent", "resolve_advisor_intent"]