feat:修改投顾agent和nl2sql的功能
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from agent.advisor_agent.auth import ensure_customer_access
|
||||
from agent.data_query.agent import DataQueryAgent
|
||||
@@ -15,6 +16,51 @@ from nl2sql.schema import load_authoritative_schema
|
||||
from service.nl2sql.permission_service import load_query_permission
|
||||
from service.nl2sql.query_service import QueryServiceError
|
||||
from tool.llm import llm as default_llm
|
||||
from repositories.fin_holdings import FinHoldingsRepo
|
||||
from repositories.fin_product import FinProductRepo
|
||||
|
||||
|
||||
def _is_current_holdings_query(question: str) -> bool:
|
||||
text = "".join((question or "").split())
|
||||
return any(term in text for term in ("当前持仓", "目前持仓", "现有持仓", "持仓明细", "持仓情况"))
|
||||
|
||||
|
||||
async def _query_current_holdings(db, *, customer_id: int, trace_id: str) -> dict[str, Any]:
|
||||
holdings = await FinHoldingsRepo(db).list_by_customer(customer_id, status="持有中")
|
||||
product_repo = FinProductRepo(db)
|
||||
rows: list[dict[str, Any]] = []
|
||||
total_value = 0
|
||||
for holding in holdings:
|
||||
product = await product_repo.get(holding.product_id)
|
||||
rows.append(
|
||||
{
|
||||
"产品代码": product.product_code if product else None,
|
||||
"产品名称": product.product_name if product else None,
|
||||
"风险等级": product.risk_level if product else None,
|
||||
"持有份额": f"{holding.shares:.4f}",
|
||||
"成本金额": f"{holding.cost_amount:.2f}",
|
||||
"当前市值": f"{holding.current_value:.2f}",
|
||||
"盈亏": f"{holding.profit_loss:.2f}",
|
||||
"收益率": f"{holding.profit_ratio:.4f}",
|
||||
"状态": holding.status,
|
||||
}
|
||||
)
|
||||
total_value += holding.current_value
|
||||
names = [row["产品名称"] for row in rows if row["产品名称"]]
|
||||
return {
|
||||
"query_id": f"holdings-{uuid4().hex}",
|
||||
"trace_id": trace_id,
|
||||
"columns": list(rows[0].keys()) if rows else ["产品代码", "产品名称", "风险等级", "持有份额", "成本金额", "当前市值", "盈亏", "收益率", "状态"],
|
||||
"rows": rows,
|
||||
"row_count": len(rows),
|
||||
"truncated": False,
|
||||
"summary": f"当前持仓共 {len(rows)} 条记录。",
|
||||
"answer": (
|
||||
f"当前持有 {len(rows)} 只基金,总市值约 {total_value:.2f} 元。"
|
||||
+ (f"包括:{'、'.join(names[:6])}。" if names else "")
|
||||
),
|
||||
"sql": None,
|
||||
}
|
||||
|
||||
|
||||
async def execute_advisor_data_query(
|
||||
@@ -42,6 +88,12 @@ async def execute_advisor_data_query(
|
||||
``customer_id``,避免投顾借助 NL2SQL 查询其他客户数据。
|
||||
"""
|
||||
await ensure_customer_access(db, advisor_id=advisor_id, customer_id=customer_id)
|
||||
if _is_current_holdings_query(question):
|
||||
return await _query_current_holdings(
|
||||
db,
|
||||
customer_id=customer_id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
permission = await load_query_permission(db, advisor_id)
|
||||
if not permission.get("can_query", False):
|
||||
raise QueryServiceError("当前投顾没有 NL2SQL 查询权限")
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""投顾聊天意图分类: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 = """你是基金投顾工作台的意图分类器,只负责分类,不回答用户问题。
|
||||
只能从以下分类中选择一个:
|
||||
- recommend:基金推荐、组合配置或投资方案
|
||||
- rebalance:组合偏离、调仓、再平衡、仓位调整
|
||||
- fund_analysis:单只基金分析、净值、收益、回撤、波动率、夏普比率
|
||||
- dialogue-script:给客户准备沟通话术、解释、安抚、投诉或风险提醒
|
||||
- data_query:查询客户持仓、资产、余额、收益、交易、账户明细
|
||||
- casual_chat:问候、闲聊、感谢、身份询问或无法归入业务分类的内容
|
||||
|
||||
只输出 JSON,不要 Markdown,不要额外文字:
|
||||
{"intent":"分类值","confidence":0到1之间的数字,"reason":"不超过30字的原因"}
|
||||
"""
|
||||
|
||||
_RECOMMENDATION_TERMS = ("推荐", "组合建议", "配置建议", "买什么", "适合配置", "筛选基金", "投资方案")
|
||||
|
||||
|
||||
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 "模型分类"),
|
||||
)
|
||||
|
||||
|
||||
async def classify_advisor_intent(
|
||||
query: str | None,
|
||||
llm_client=None,
|
||||
*,
|
||||
explicit_intent: str | None = None,
|
||||
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 llm_client is not None:
|
||||
try:
|
||||
raw = await asyncio.wait_for(
|
||||
llm_client.chat(
|
||||
[
|
||||
{"role": "system", "content": _CLASSIFIER_PROMPT},
|
||||
{"role": "user", "content": query.strip()},
|
||||
],
|
||||
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_RECOMMEND
|
||||
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"]
|
||||
@@ -3,7 +3,13 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from common.common_const import AGENT_INTENT_DATA_QUERY
|
||||
from common.common_const import (
|
||||
AGENT_INTENT_DATA_QUERY,
|
||||
AGENT_INTENT_DIALOGUE_SCRIPT,
|
||||
AGENT_INTENT_FUND_ANALYSIS,
|
||||
AGENT_INTENT_REBALANCE,
|
||||
AGENT_INTENT_RECOMMEND,
|
||||
)
|
||||
|
||||
|
||||
_QUERY_ACTIONS = (
|
||||
@@ -30,7 +36,14 @@ _DATA_TERMS = (
|
||||
"客户数据",
|
||||
"账户",
|
||||
)
|
||||
_NON_QUERY_INTENTS = ("推荐", "调仓", "再平衡", "话术", "沟通")
|
||||
_REBALANCE_TERMS = ("调仓", "再平衡", "组合调整", "配置偏离", "偏离目标", "降低仓位", "增加仓位")
|
||||
_FUND_ANALYSIS_TERMS = ("基金分析", "分析基金", "基金表现", "净值走势", "最大回撤", "夏普比率", "年化波动")
|
||||
_DIALOGUE_TERMS = ("话术", "沟通", "怎么跟客户说", "如何向客户解释", "安抚客户", "投诉处理")
|
||||
_RECOMMEND_TERMS = ("推荐", "组合建议", "配置建议", "买什么", "适合配置", "筛选基金", "投资方案")
|
||||
|
||||
|
||||
def _contains_any(text: str, terms: tuple[str, ...]) -> bool:
|
||||
return any(term in text for term in terms)
|
||||
|
||||
|
||||
def recognize_advisor_intent(query: str | None, explicit_intent: str | None = None) -> str | None:
|
||||
@@ -42,12 +55,23 @@ def recognize_advisor_intent(query: str | None, explicit_intent: str | None = No
|
||||
if explicit_intent:
|
||||
return explicit_intent
|
||||
text = re.sub(r"\s+", "", query or "")
|
||||
if not text or any(term in text for term in _NON_QUERY_INTENTS):
|
||||
if not text:
|
||||
return None
|
||||
|
||||
# 先处理最明确的任务词,避免“查询基金收益”被误判成普通数据查询。
|
||||
if _contains_any(text, _REBALANCE_TERMS):
|
||||
return AGENT_INTENT_REBALANCE
|
||||
if _contains_any(text, _FUND_ANALYSIS_TERMS):
|
||||
return AGENT_INTENT_FUND_ANALYSIS
|
||||
if _contains_any(text, _DIALOGUE_TERMS):
|
||||
return AGENT_INTENT_DIALOGUE_SCRIPT
|
||||
|
||||
has_action = any(term in text for term in _QUERY_ACTIONS)
|
||||
has_data = any(term in text for term in _DATA_TERMS)
|
||||
if has_data and (has_action or "客户" in text or "近一年" in text or "本月" in text):
|
||||
return AGENT_INTENT_DATA_QUERY
|
||||
if _contains_any(text, _RECOMMEND_TERMS):
|
||||
return AGENT_INTENT_RECOMMEND
|
||||
return None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user