diff --git a/agent/advisor_agent/data_query.py b/agent/advisor_agent/data_query.py index a8c2f26..023fbff 100644 --- a/agent/advisor_agent/data_query.py +++ b/agent/advisor_agent/data_query.py @@ -56,7 +56,9 @@ def enrich_customer_rows( def _is_current_holdings_query(question: str) -> bool: text = "".join((question or "").split()) - return any(term in text for term in ("当前持仓", "目前持仓", "现有持仓", "持仓明细", "持仓情况")) + if any(term in text for term in ("当前持仓", "目前持仓", "现有持仓", "持仓明细", "持仓情况")): + return True + return "持仓" in text and not any(term in text for term in ("历史", "曾经", "已卖出", "交易记录")) async def _query_current_holdings( diff --git a/agent/advisor_agent/intent/classifier.py b/agent/advisor_agent/intent/classifier.py index 91dce68..c93e946 100644 --- a/agent/advisor_agent/intent/classifier.py +++ b/agent/advisor_agent/intent/classifier.py @@ -35,26 +35,58 @@ class IntentClassification: reason: str = "" -_CLASSIFIER_PROMPT = """你是基金投顾工作台的意图分类器,只负责分类,不回答用户问题。 -只能从以下分类中选择一个: -- recommend:基金推荐、组合配置或投资方案 - 例:“帮我推荐两只适合稳健型客户的基金”“给客户生成一份组合配置建议”“该买什么基金” -- rebalance:组合偏离、调仓、再平衡、仓位调整 - 例:“该客户组合偏离目标配置,请给出调仓方案”“组合需要再平衡,降低股票类仓位” -- fund_analysis:单只基金分析、净值、收益、回撤、波动率、夏普比率 - 例:“分析华夏回报近一年的净值走势和最大回撤”“这只基金的夏普比率和波动率如何” -- dialogue-script:给客户准备沟通话术、解释、安抚、投诉或风险提醒 - 例:“市场波动时怎么和客户解释”“帮我准备安抚客户的沟通话术”“客户投诉了,话术怎么准备” -- data_query:查询客户持仓、资产、余额、收益、交易、账户明细、客户名册 - 例:“查询客户48当前持仓和账户余额”“我名下有哪些客户”“统计名下客户数量” -- casual_chat:问候、闲聊、感谢、身份询问或无法归入业务分类的内容 - 例:“你好”“谢谢”“你是谁” +_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 = "投顾范围查询仅支持客户数据查询" @@ -89,11 +121,31 @@ def _parse_model_result(raw: str) -> IntentClassification | None: ) +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: """分类用户意图;显式意图兼容旧客户端,模型失败时安全回退规则。""" @@ -119,13 +171,28 @@ async def classify_advisor_intent( "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": query.strip()}, + {"role": "user", "content": prompt}, ], temperature=0, max_tokens=120, @@ -154,4 +221,4 @@ async def classify_advisor_intent( return _rule_fallback(query) -__all__ = ["IntentClassification", "classify_advisor_intent"] +__all__ = ["IntentClassification", "classify_advisor_intent", "resolve_advisor_intent"] diff --git a/agent/advisor_agent/intent/recognizer.py b/agent/advisor_agent/intent/recognizer.py index 88cd180..4209687 100644 --- a/agent/advisor_agent/intent/recognizer.py +++ b/agent/advisor_agent/intent/recognizer.py @@ -26,6 +26,7 @@ _QUERY_ACTIONS = ( _DATA_TERMS = ( "持仓", "资产", + "资金", "收益", "交易记录", "申购", @@ -42,6 +43,14 @@ _DIALOGUE_TERMS = ("话术", "沟通", "怎么跟客户说", "如何向客户解 _RECOMMEND_TERMS = ("推荐", "组合建议", "配置建议", "买什么", "适合配置", "筛选基金", "投资方案") _CUSTOMER_IDENTITY_TERMS = ("是谁", "姓名", "实名", "基本信息", "联系方式", "手机号") _CUSTOMER_ROSTER_TERMS = ("名单", "列表", "几个") +_CUSTOMER_RISK_TERMS = ( + "风险评级", "风险等级", "风险类型", "激进型", "进取型", "平衡型", "稳健型", "保守型", + "C1", "C2", "C3", "C4", "C5", +) +_IMPLICIT_QUERY_TERMS = ( + "谁", "哪个", "哪些", "多少", "最大", "最多", "最小", "最少", "最高", "最低", + "合计", "总额", "总资产", "排名", "前几", "前十", "超过", "低于", "是否", "有没有", +) _SCOPE_ERROR_MESSAGE = "投顾范围查询仅支持客户数据查询" @@ -88,7 +97,15 @@ def recognize_advisor_intent(query: str | None, explicit_intent: str | None = No ) if has_customer_roster: return AGENT_INTENT_DATA_QUERY - if has_data and (has_action or "客户" in text or "近一年" in text or "本月" in text): + if "客户" in text and _contains_any(text, _CUSTOMER_RISK_TERMS) and not _contains_any(text, _RECOMMEND_TERMS): + return AGENT_INTENT_DATA_QUERY + if has_data and ( + has_action + or "客户" in text + or "近一年" in text + or "本月" in text + or _contains_any(text, _IMPLICIT_QUERY_TERMS) + ): return AGENT_INTENT_DATA_QUERY if _contains_any(text, _RECOMMEND_TERMS): return AGENT_INTENT_RECOMMEND diff --git a/api/routers/advisor_agent.py b/api/routers/advisor_agent.py index e2c1a6c..b460eb5 100644 --- a/api/routers/advisor_agent.py +++ b/api/routers/advisor_agent.py @@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from agent.advisor_agent.auth import ensure_customer_access from agent.advisor_agent.data_query import execute_advisor_data_query from agent.advisor_agent.intent.fund_analysis import build_fund_analysis -from agent.advisor_agent.intent.classifier import classify_advisor_intent +from agent.advisor_agent.intent.classifier import classify_advisor_intent, resolve_advisor_intent from agent.advisor_agent.intent.talk_script import build_talk_script from agent.advisor_agent.llm import generate_text from agent.advisor_agent.intent.generation_flow import ( @@ -69,6 +69,7 @@ from schemas.advisor_agent import ( AdvisorDataQueryReq, ) from service.nl2sql.query_service import QueryServiceError +from nl2sql.query_rewriter import rewrite_query from service.advisor_agent.draft import ( detail_draft, discard_draft, @@ -248,10 +249,20 @@ async def chat_stream( ) runtime = _advisor_runtime(request) - classification = await classify_advisor_intent( + context_store = SessionContextStore(redis_db.client()) + conversation_context = build_conversation_context( + await context_store.load(user.id, chat_request.session_id) + ) + effective_question = await rewrite_query( chat_request.query, + conversation_context, + llm_client=getattr(runtime, "llm_client", None), + ) + classification = await classify_advisor_intent( + effective_question, getattr(runtime, "llm_client", None), explicit_intent=None, + conversation_context=conversation_context, ) inferred_intent = classification.intent scope = chat_request.scope @@ -277,11 +288,16 @@ async def chat_stream( await ensure_customer_access(db, advisor_id=user.id, customer_id=int(customer_id)) else: resolved_customer_id, _resolve_error = await _resolve_customer_from_query( - db, advisor_id=user.id, query=chat_request.query + db, advisor_id=user.id, query=effective_question ) if resolved_customer_id is not None: customer_id = resolved_customer_id scope = "customer" + inferred_intent = resolve_advisor_intent( + effective_question, + inferred_intent, + customer_resolved=customer_id is not None, + ) if customer_id is None: if inferred_intent == AGENT_INTENT_DATA_QUERY: @@ -317,16 +333,12 @@ async def chat_stream( if inferred_intent == AGENT_INTENT_DATA_QUERY: effective_scope = "customer" if customer_id is not None else "advisor" try: - context_store = SessionContextStore(redis_db.client()) - conversation_context = build_conversation_context( - await context_store.load(user.id, chat_request.session_id) - ) result = await execute_advisor_data_query( db, advisor_id=user.id, customer_id=int(customer_id) if customer_id is not None else None, scope=effective_scope, - question=chat_request.query, + question=effective_question, trace_id=trace_id, session_id=chat_request.session_id, conversation_context=conversation_context, diff --git a/nl2sql/query_rewriter.py b/nl2sql/query_rewriter.py index f0906bf..5598919 100644 --- a/nl2sql/query_rewriter.py +++ b/nl2sql/query_rewriter.py @@ -21,6 +21,24 @@ _REFERENCE_TERMS = ( "这个基金", "该基金", "那只基金", "这只基金", "这个结果", "上一轮", "刚才", "前面", ) +_CUSTOMER_RISK_LABELS = { + "保守型": "C1", + "稳健型": "C2", + "平衡型": "C3", + "进取型": "C4", + "激进型": "C5", +} + + +def _normalize_customer_risk_label(question: str) -> str: + """将展示层客户风险标签规范化为数据库 C1-C5 编码。""" + normalized = re.sub(r"\s+", "", question) + if "客户" not in normalized and "风险" not in normalized: + return normalized + for label, code in _CUSTOMER_RISK_LABELS.items(): + normalized = normalized.replace(label, code) + return normalized + def _rewrite_explicit_customer_identity(question: str) -> str | None: normalized = re.sub(r"\s+", "", question) @@ -63,7 +81,7 @@ async def rewrite_query( llm_client=llm, ) -> str: """使用有限会话上下文补全问题;改写失败时安全返回原问题。""" - original = (question or "").strip() + original = _normalize_customer_risk_label((question or "").strip()) context = (conversation_context or "").strip() if not original: return original diff --git a/nl2sql/semantic_catalog.json b/nl2sql/semantic_catalog.json index 9895b80..c6159b8 100644 --- a/nl2sql/semantic_catalog.json +++ b/nl2sql/semantic_catalog.json @@ -18,12 +18,12 @@ "value_hint": "fin_product.product_type 实际枚举值为:货币型/债券型/混合型/股票型/指数型/QDII;股票型基金必须用 product_type = '股票型',不要使用 Schema 注释里的'股票基金'" }, { - "term": "客户", + "term": "客户风险等级", "enabled": true, - "aliases": ["客户", "投资人", "持有人"], + "aliases": ["客户", "投资人", "持有人", "风险评级", "风险等级", "风险类型", "激进型", "进取型", "平衡型", "稳健型", "保守型", "C1", "C2", "C3", "C4", "C5"], "tables": ["fin_customer_profile", "fin_holdings", "fin_risk_assessment"], "fields": ["customer_id", "risk_level", "customer_level"], - "value_hint": "fin_customer_profile.risk_level / fin_risk_assessment.risk_level 实际枚举值为 R1-R5(R1 最保守,R5 最激进,与产品风险等级同一口径);查'保守型/稳健型客户'对应 R1/R2,'激进型客户'对应 R5" + "value_hint": "客户风险字段实际枚举值为 C1-C5(C1 最保守,C5 最激进);展示层映射为保守型=C1、稳健型=C2、平衡型=C3、进取型=C4、激进型=C5。产品风险等级才使用 R1-R5" }, { "term": "持仓", diff --git a/tool/llm.py b/tool/llm.py index ec811e6..d3b9955 100644 --- a/tool/llm.py +++ b/tool/llm.py @@ -200,6 +200,10 @@ class LLMClient: "max_tokens": request_max_tokens, "stream": False, } + # SQL/分类等短输出任务不需要深度思考;DeepSeek-V4 若开启思考, + # 可能耗尽 token 预算而返回空的 content。 + if model.lower().startswith("deepseek-v4"): + payload["enable_thinking"] = False try: client = self._client_for(backend) r = await client.post(url, headers=backend.headers, json=payload)