wip: 客服Agent + RAG + 画像收尾(基于 6516ccb)
This commit is contained in:
@@ -53,6 +53,80 @@ RISK_LEVEL_NAMES = {
|
||||
4: "R4(中高风险)", 5: "R5(高风险)",
|
||||
}
|
||||
|
||||
# 客户风险测评等级(C 级)到中文表述。与上面的 `RISK_LEVEL_NAMES`(产品 R 级)是两套编码:
|
||||
# 产品讲 R1–R5、客户测评讲 C1–C5,回答里**不能混用**,否则客户会误读自己的等级。
|
||||
RISK_LEVEL_LABELS: dict[str, str] = {
|
||||
"C1": "保守型(C1)",
|
||||
"C2": "稳健型(C2)",
|
||||
"C3": "平衡型(C3)",
|
||||
"C4": "成长型(C4)",
|
||||
"C5": "进取型(C5)",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 画像查询(确定性关键词识别,不经过 LLM 意图分类)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 为什么用**确定性关键词**而不是交给意图分类器:画像问题要求"要么给出该客户的真实数据、
|
||||
# 要么明确说查不到"。走分类器会引入"模型把「风险等级怎么划分」也判成画像问题"的风险,
|
||||
# 而那一类问的是**规则**、该由知识库回答(见下面的负向词表)。
|
||||
PROFILE_TOOL_NAME = "query_customer_profile"
|
||||
|
||||
PROFILE_KEYWORDS: tuple[str, ...] = (
|
||||
"我的风险等级", "我的风险测评", "我是什么风险", "我的投资者类型", "我的投资类型",
|
||||
"我的风险承受", "测评过期", "测评到期", "我的测评", "我的画像",
|
||||
"我的投资偏好", "我的偏好", "我的投资期限", "我的交易频率",
|
||||
)
|
||||
|
||||
#: 问题里出现这些词时**不**按画像处理(问的是政策规则,不是本人数据)。
|
||||
PROFILE_NEGATIVE_KEYWORDS: tuple[str, ...] = (
|
||||
"怎么划分", "如何划分", "什么标准", "分类标准", "怎么分", "如何分",
|
||||
"有哪些等级", "等级划分", "怎么定义", "如何定义",
|
||||
)
|
||||
|
||||
#: 调用画像工具时用的意图 key。必须复用**已发布**的 `faq`:
|
||||
#: `call_tool(intent=...)` 决定"当前意图允许哪些工具",而发布配置里只有
|
||||
#: `agent_tools/customer_service:faq` 一个 key;换新意图码会让交集为空 →
|
||||
#: `AGENT_PERMISSION_DENIED`。这与"知识检索用哪个意图"是两个层面,不冲突。
|
||||
PROFILE_WHITELIST_INTENT = INTENT_FAQ
|
||||
|
||||
|
||||
def is_profile_question(message: str) -> bool:
|
||||
"""是否在问**本人画像**(确定性判定,模型不参与)。"""
|
||||
if any(word in message for word in PROFILE_NEGATIVE_KEYWORDS):
|
||||
return False
|
||||
return any(word in message for word in PROFILE_KEYWORDS)
|
||||
|
||||
|
||||
def render_profile(profile: dict[str, object]) -> str:
|
||||
"""把画像投影渲染成客户可读的一段话。
|
||||
|
||||
**只陈述该客户自己的字段**,不推断、不承诺收益;测评过期时**必须明说**并引导重新测评
|
||||
(与适当性服务的 `ASSESSMENT_EXPIRED` 失败关闭口径一致)。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
investor_type = str(profile.get("investor_type") or "").strip().upper()
|
||||
if investor_type:
|
||||
lines.append(f"您的风险测评等级是 {RISK_LEVEL_LABELS.get(investor_type, investor_type)}。")
|
||||
horizon = profile.get("investment_horizon")
|
||||
if horizon:
|
||||
lines.append(f"投资期限偏好:{horizon}。")
|
||||
frequency = profile.get("trading_frequency")
|
||||
if frequency:
|
||||
lines.append(f"交易频率:{frequency}。")
|
||||
assets = profile.get("preferred_asset_class")
|
||||
if isinstance(assets, (list, tuple)) and assets:
|
||||
lines.append("偏好资产类别:" + "、".join(str(a) for a in assets) + "。")
|
||||
tier = profile.get("customer_tier")
|
||||
if tier:
|
||||
lines.append(f"客户分层:{tier}。")
|
||||
if profile.get("assessment_expired") is True:
|
||||
lines.append("注意:您的风险测评已过有效期,需要重新完成测评后才能继续匹配产品风险等级。")
|
||||
|
||||
if not lines:
|
||||
return "暂时查不到您的画像信息,建议转人工客服核实。"
|
||||
return "\n".join(lines)
|
||||
|
||||
# 三档置信阈值:方案 §2.3 要求的是「绝对阈值 AND(相对间隙 OR 分布优势)」混合判定,
|
||||
# 只做绝对阈值会把口语化问法误判成"答不了"(这是实测踩到的坑)。
|
||||
#
|
||||
@@ -101,7 +175,7 @@ class CustomerServiceAgent(BaseAgent):
|
||||
allowed_roles=("customer",),
|
||||
allowed_portals=("api",),
|
||||
# 代码上限:实际可用范围由发布配置的意图白名单收窄(两者取交集)
|
||||
allowed_tools=(TOOL_NAME, SUITABILITY_TOOL),
|
||||
allowed_tools=(TOOL_NAME, SUITABILITY_TOOL, PROFILE_TOOL_NAME),
|
||||
supported_intents=(
|
||||
INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY, INTENT_SUITABILITY,
|
||||
INTENT_CHITCHAT, INTENT_TRANSFER,
|
||||
@@ -109,6 +183,12 @@ class CustomerServiceAgent(BaseAgent):
|
||||
)
|
||||
|
||||
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
|
||||
# 画像问题优先处理(确定性关键词,不走意图分类):知识库答不了"我的风险等级是多少",
|
||||
# 那需要读该客户的画像数据,必须走 `query_customer_profile` 工具取权威字段。
|
||||
# 放在意图分发**之前**是有意的:让画像能力不依赖意图分类是否恰好给出 faq。
|
||||
# 连带效果:该分支的 `intent` 恒为 `faq`,与 `PROFILE_WHITELIST_INTENT` 同源。
|
||||
if is_profile_question(request.message):
|
||||
return await self._answer_profile(request, context)
|
||||
intent = self._intent_code()
|
||||
if intent == INTENT_CHITCHAT:
|
||||
return await self._chitchat(request)
|
||||
@@ -123,6 +203,42 @@ class CustomerServiceAgent(BaseAgent):
|
||||
return await self._answer_suitability(request, context)
|
||||
return await self._answer_from_knowledge(request, context, intent)
|
||||
|
||||
# ---- 出口零:本人画像(确定性识别 + 权威字段) ----
|
||||
|
||||
async def _answer_profile(
|
||||
self, request: AgentRequest, context: RequestContext
|
||||
) -> CoreResult:
|
||||
"""查本人画像并渲染。
|
||||
|
||||
**只查 `context.user_id`**,不从用户消息里取编号(否则客户可以靠一句话读别人的画像)。
|
||||
`customer_id` 仍然显式传给工具:底座会把它落进工具审计,事后能追溯"谁读了谁的画像"。
|
||||
工具内部还会再做一次数据范围校验(`self` / `own_customers` / `all`)。
|
||||
查不到时**失败关闭为转人工**,绝不猜一个等级出来。
|
||||
"""
|
||||
del request
|
||||
try:
|
||||
output = await self.call_tool(
|
||||
PROFILE_TOOL_NAME,
|
||||
{"customer_id": str(context.user_id)},
|
||||
intent=PROFILE_WHITELIST_INTENT,
|
||||
context=context,
|
||||
)
|
||||
except ForbiddenAgentError:
|
||||
# 权限/白名单类失败必须冒泡(与知识出口同一口径):那是配置错误,
|
||||
# 用兜底话术吞掉会让"工具没被授权"表现成"客户画像查不到"。
|
||||
raise
|
||||
except Exception:
|
||||
logger.warning("画像查询失败,改为引导人工客服", exc_info=True)
|
||||
return self._guide_to_human("画像查询失败")
|
||||
profile = output.get("profile") if isinstance(output, dict) else None
|
||||
if not isinstance(profile, dict) or not profile:
|
||||
logger.info("画像为空或不可用,改为引导人工客服")
|
||||
return self._guide_to_human("画像为空或不可用")
|
||||
return CoreResult(
|
||||
text=render_profile(profile),
|
||||
intent=IntentResult(intent=PROFILE_WHITELIST_INTENT, confidence=1.0),
|
||||
)
|
||||
|
||||
# ---- 出口一:知识直返(faq / 产品 / 政策) ----
|
||||
|
||||
async def _answer_from_knowledge(
|
||||
|
||||
Reference in New Issue
Block a user