## 对话内图表(W29) 按「零新数字」路径实现:图只画答复正文里**已经写出来**的数字,不引入任何新数值 (新不变量 INV-8:图内每个数值必须能在同轮文字中找到)。 - E6 出口 data 增 trend_chart(纯增量,只搬运模板已写出的数字:最新净值 / 四个区间涨跌 / 区间首末净值 / 区间高低 / 数据日期) - 新增 trend-chart.js:**全 DOM API 构建**(widget.js 的 addMessage 一律 textContent, 图表若拼 HTML 串等于把那层 XSS 防护重新打开),涨红跌绿(中国市场惯例) - widget.js 从 result.tool_calls.data 取数 ⇒ **零后端读取改动** (result.data 只对 financial_nl2sql 暴露;走 tool_calls 这个已落库的 JSON 列免会签) 验证(真实净值数据 511810 / 160 个净值点、区间 -9.19/5.28/15.27/-6.53): - jsdom 渲染 11/11 通过;颜色序列 [绿,红,红,绿] - INV-8 独立断言:图上 18 个可见数字 **100% 命中答复正文**(extra_numbers=[]) - 空数据三态返回 null;label 里塞 <img onerror=...> 后 DOM 中 img 元素数 0 ## 客服业务层(W28 遗留,本次一并提交) - app/core/service_topic.py(新增):业务事项轴 —— 12 个 SVC-* 事项码 × 处理主体 × 留痕等级;只做留痕统计,不参与任何判定 - customer_service.py 的 E5b 双段话术(删掉「换个说法再问我一次」= 把问题推回客户)、 kb_miss 显式声明(不再靠文本比对,话术一变化比对就静默失效)、 意图漂移护栏(分类器判闲聊但含业务实质则不采信) - customer_service_rules.py:substantive_business_request 等判据 注:customer_service.py 同时承载 W29 的 trend_chart 与上述 W28 改动, 两者无法按文件切分,故同批提交。
355 lines
16 KiB
Python
355 lines
16 KiB
Python
"""业务事项轴(`SVC-*`):把「问句」翻译成「业务上要办什么事」。
|
||
|
||
## 为什么要有这个模块
|
||
|
||
`D3.9` §3.1 的 `L0` 回答的是「**走哪个出口**」——那是**技术**问题。但真实客服组织
|
||
每天处理的是「**要办什么事、该谁办、留什么痕**」——那是**业务**问题。两者不是一回事:
|
||
|
||
- 技术视角:`E3` 直返 / `E4` 生成 / `E5b` 部分答 / `E5c` 转人工;
|
||
- 业务视角:**告知** / **拒绝** / **引导** / **建单**,且每一类的**处理主体**与
|
||
**留痕等级**不同。
|
||
|
||
`D2.2` 的 52 条 `FR-CS` 里约 30 条服务于身份 / 权限 / 档位 / 回退(工程口径),
|
||
而「渠道归属」「账户状态」两类真实高频事项在需求里**零覆盖**。本模块即为此补的
|
||
**业务事项轴**:与出口轴(`exit_codes`)**正交**,不替换、不重命名任何出口。
|
||
|
||
## 三条纪律
|
||
|
||
1. **纯函数、零副作用**:不查库、不调模型、不读上下文 —— 只按问句文本判定,
|
||
因此可以在单测里跑成千上万条用例;
|
||
2. **只做分类,不改行为**:本模块**不决定**答复内容(唯一例外见 ``next_step_block``,
|
||
它只提供「下一步该去哪办」的**业务动作**文案,不生成任何事实);
|
||
3. **判据可证伪**:每个事项码都有明确的关键词组,覆盖率与误命中率都能在评测集上量。
|
||
|
||
## 与安全路由的关系
|
||
|
||
**本模块不参与安全判定**。`route_message()` 的 `P0`—`P2` 在它之前生效,
|
||
安全红线不依赖本模块;`SVC-RISK` / `SVC-ACCT-OP` / `SVC-COMPLAINT` 三个事项码
|
||
只是**事后留痕口径**,用来统计「哪些事项进了人工」,不构成拦截。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 事项码
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: 公开信息与概念答疑(公司信息 / 术语解释 / 业务边界声明)。
|
||
SVC_INFO = "SVC-INFO"
|
||
#: 产品参数(费率 / 起投 / 期限 / 规模)—— 数值型产品要素,档位相关。
|
||
SVC_PROD_PARAM = "SVC-PROD-PARAM"
|
||
#: 本人数据(画像 / 风评等级 / 分层 / 可购买范围)。
|
||
SVC_MINE = "SVC-MINE"
|
||
#: 适当性裁决与匹配规则(能不能买 / C—R 矩阵)。
|
||
SVC_SUIT = "SVC-SUIT"
|
||
#: 行情与走势(净值 / 涨跌 / 区间)。
|
||
SVC_QUOTE = "SVC-QUOTE"
|
||
#: 本人账户状态(为什么不能买 / 被限制 / 风评到期 / 证件过期)。
|
||
SVC_STATE = "SVC-STATE"
|
||
#: 渠道归属(在别家买的找谁 / 直销与代销 / 非本公司产品)—— `D6.1.2` Q45。
|
||
SVC_CHANNEL = "SVC-CHANNEL"
|
||
#: 自助办理引导(改卡 / 改手机号 / 重置密码 / 开户 / 测评)。
|
||
SVC_SELF = "SVC-SELF"
|
||
#: 交易时限与在途(`T+n` 确认与到账 / 撤单)—— `D6.1.2` Q25 / Q26。
|
||
SVC_TXN_DAY = "SVC-TXN-DAY"
|
||
#: 投诉、不满与要求升级。
|
||
SVC_COMPLAINT = "SVC-COMPLAINT"
|
||
#: 反诈 / 盗号 / 可疑交易(安全事项)。
|
||
SVC_RISK = "SVC-RISK"
|
||
#: 写操作(代买 / 改资料 / 销户 / 代办交易)。
|
||
SVC_ACCT_OP = "SVC-ACCT-OP"
|
||
#: 无法归类。
|
||
SVC_UNKNOWN = "SVC-UNKNOWN"
|
||
|
||
ALL_SVC_TOPICS: tuple[str, ...] = (
|
||
SVC_INFO,
|
||
SVC_PROD_PARAM,
|
||
SVC_MINE,
|
||
SVC_SUIT,
|
||
SVC_QUOTE,
|
||
SVC_STATE,
|
||
SVC_CHANNEL,
|
||
SVC_SELF,
|
||
SVC_TXN_DAY,
|
||
SVC_COMPLAINT,
|
||
SVC_RISK,
|
||
SVC_ACCT_OP,
|
||
SVC_UNKNOWN,
|
||
)
|
||
|
||
#: 快速成员判定用(报表 / 校验器不必遍历元组)。
|
||
ALL_TOPIC_SET = frozenset(ALL_SVC_TOPICS)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 处理主体(业务上「该谁办」)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: 机器可直接办结(不建单)。
|
||
OWNER_BOT = "bot"
|
||
#: 机器只做引导,客户到自助页面自己办。
|
||
OWNER_SELF_SERVICE = "self-service"
|
||
#: 必须人工办(工单 / 热线 / 线下网点)。
|
||
OWNER_HUMAN = "human"
|
||
|
||
_OWNER_BY_TOPIC: dict[str, str] = {
|
||
SVC_INFO: OWNER_BOT,
|
||
SVC_PROD_PARAM: OWNER_BOT,
|
||
SVC_MINE: OWNER_BOT,
|
||
SVC_SUIT: OWNER_BOT,
|
||
SVC_QUOTE: OWNER_BOT,
|
||
# 状态类:机器能说清「三类可公开原因」,但**原因归属与解除**要客户去自助页或人工。
|
||
SVC_STATE: OWNER_SELF_SERVICE,
|
||
# 渠道归属:机器给边界声明 + 引导回原购买渠道,**不代办**。
|
||
SVC_CHANNEL: OWNER_SELF_SERVICE,
|
||
SVC_SELF: OWNER_SELF_SERVICE,
|
||
SVC_TXN_DAY: OWNER_BOT,
|
||
SVC_COMPLAINT: OWNER_HUMAN,
|
||
SVC_RISK: OWNER_HUMAN,
|
||
SVC_ACCT_OP: OWNER_HUMAN,
|
||
SVC_UNKNOWN: OWNER_BOT,
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 留痕等级(合规上「留什么证」)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: 无需专门留痕(公开信息、通用规则)。
|
||
RETENTION_NONE = "none"
|
||
#: 需留痕(本次答复依据、工具调用、出口码随会话落库)。
|
||
RETENTION_TRACE = "trace"
|
||
#: 需留痕且**须提示线下动作**(双录 / 面签 / 风险揭示书)——`D6.3.2` 第十七条。
|
||
RETENTION_DISCLOSURE = "disclosure"
|
||
#: 必须留痕(建单 / 升级 / 反洗钱相关,`D6.3.1` 第二十三条档案要求)。
|
||
RETENTION_REQUIRED = "required"
|
||
|
||
_RETENTION_BY_TOPIC: dict[str, str] = {
|
||
SVC_INFO: RETENTION_NONE,
|
||
SVC_PROD_PARAM: RETENTION_NONE,
|
||
SVC_MINE: RETENTION_TRACE,
|
||
# 适当性结论属适当性档案范围(自业务关系终止起 20 年,`D6.3.1` 第二十三条)。
|
||
SVC_SUIT: RETENTION_DISCLOSURE,
|
||
# 行情答复的数字必须可解析到数据源,且带日期(`INV-6`)。
|
||
SVC_QUOTE: RETENTION_TRACE,
|
||
SVC_STATE: RETENTION_TRACE,
|
||
SVC_CHANNEL: RETENTION_NONE,
|
||
SVC_SELF: RETENTION_TRACE,
|
||
SVC_TXN_DAY: RETENTION_NONE,
|
||
SVC_COMPLAINT: RETENTION_REQUIRED,
|
||
SVC_RISK: RETENTION_REQUIRED,
|
||
SVC_ACCT_OP: RETENTION_REQUIRED,
|
||
SVC_UNKNOWN: RETENTION_TRACE,
|
||
}
|
||
|
||
|
||
def owner_of(topic: str) -> str:
|
||
"""该事项业务上「该谁办」。未登记的事项按机器办结(最宽口径)。"""
|
||
return _OWNER_BY_TOPIC.get(topic, OWNER_BOT)
|
||
|
||
|
||
def retention_of(topic: str) -> str:
|
||
"""该事项的留痕等级。未登记按「需留痕」处理(宁严勿宽)。"""
|
||
return _RETENTION_BY_TOPIC.get(topic, RETENTION_TRACE)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 关键词组(判据)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# 顺序即优先级:**安全事项在前、可自助的在前、泛化的在后**。
|
||
# 判定采用「第一个命中的事项码胜出」,所以顺序本身就是判据的一部分。
|
||
|
||
#: 反诈 / 盗号 / 可疑交易(最高优先 —— 与 `route_message()` 的 `P0` 同向)。
|
||
_RISK_TERMS = (
|
||
"验证码", "盗号", "被盗", "诈骗", "骗子", "冒充", "仿冒", "钓鱼",
|
||
"止损", "被骗", "转账给他", "可疑电话", "96110",
|
||
)
|
||
#: 写操作 / 代办。
|
||
_ACCT_OP_TERMS = (
|
||
"帮我买", "代我买", "替我买", "帮我卖", "代客", "帮我改", "帮我注销",
|
||
"销户", "帮我换", "代我操作", "帮我操作", "帮我下", "帮我转",
|
||
)
|
||
#: 投诉与升级。
|
||
_COMPLAINT_TERMS = (
|
||
"投诉", "举报", "维权", "不满意", "要个说法", "找你们领导", "找你们经理",
|
||
"监管部门", "12386", "起诉", "消协",
|
||
)
|
||
#: 账户状态类(可公开的三类原因)。
|
||
_STATE_TERMS = (
|
||
"被限制", "限制交易", "不能买", "买不了", "无法购买", "冻结", "终止交易",
|
||
"风评过期", "测评过期", "风险测评到期", "证件过期", "身份证过期",
|
||
"为什么不能", "交易失败", "被暂停",
|
||
)
|
||
#: 渠道归属类。
|
||
_CHANNEL_TERMS = (
|
||
"银行买的", "券商买的", "在支付宝", "在微信", "第三方平台", "代销", "别的渠道",
|
||
"其他渠道", "不在你们这买", "不是在你们", "通过银行", "通过券商", "销售机构",
|
||
)
|
||
#: 本人数据类。
|
||
_MINE_TERMS = (
|
||
"我的风险", "我够哪一档", "我什么等级", "我的等级", "我能买什么", "我能买哪些",
|
||
"我的画像", "我的测评", "客户分层", "我属于哪", "我的偏好",
|
||
)
|
||
#: 适当性规则类。
|
||
_SUIT_TERMS = (
|
||
"适合我", "适合我吗", "能不能买", "能买", "可买", "可以买", "可以买吗",
|
||
"匹配", "适当性", "风险等级是",
|
||
"超出我的", "跨级", "风险揭示书", "双录",
|
||
)
|
||
#: 行情类。
|
||
_QUOTE_TERMS = (
|
||
"走势", "行情", "净值", "涨跌", "涨了", "跌了", "最近表现", "今天多少",
|
||
"最大回撤", "波动率", "夏普",
|
||
)
|
||
#: 交易时限类(`D6.1.2` Q25 / Q26)。
|
||
_TXN_TERMS = (
|
||
"几天到账", "多久到账", "什么时候到账", "多久确认", "几天确认",
|
||
"什么时候确认", "何时确认", "几点确认", "T+1", "T+2",
|
||
"T+3", "t+1", "t+2", "撤单", "撤销", "份额确认", "赎回到账", "在途",
|
||
)
|
||
#: 产品参数类。
|
||
_PROD_PARAM_TERMS = (
|
||
"费率", "管理费", "托管费", "申购费", "赎回费", "认购费", "销售服务费",
|
||
"起投", "起点", "门槛", "规模", "期限", "封闭期", "代码是多少", "怎么收费",
|
||
"手续费", "多少钱起",
|
||
)
|
||
#: 自助办理类。
|
||
_SELF_TERMS = (
|
||
"怎么开户", "如何开户", "开户", "改银行卡", "换银行卡", "修改银行卡",
|
||
"换手机号", "修改手机号", "忘记密码", "重置密码", "找回密码", "令牌",
|
||
"定投怎么", "怎么设置定投", "怎么撤销定投", "怎么赎回", "怎么申购",
|
||
)
|
||
|
||
|
||
def _hit(text: str, terms: tuple[str, ...]) -> bool:
|
||
"""任一关键词组命中即真。**大小写不敏感**(`T+1` 与 `t+1` 是同一条规则)。"""
|
||
lowered = text.lower()
|
||
return any(term.lower() in lowered for term in terms)
|
||
|
||
|
||
#: 判定顺序表:`(事项码, 关键词组)`。**顺序即优先级**,见文件头说明。
|
||
TOPIC_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
(SVC_RISK, _RISK_TERMS),
|
||
(SVC_ACCT_OP, _ACCT_OP_TERMS),
|
||
(SVC_COMPLAINT, _COMPLAINT_TERMS),
|
||
(SVC_STATE, _STATE_TERMS),
|
||
(SVC_CHANNEL, _CHANNEL_TERMS),
|
||
(SVC_MINE, _MINE_TERMS),
|
||
(SVC_SUIT, _SUIT_TERMS),
|
||
(SVC_QUOTE, _QUOTE_TERMS),
|
||
(SVC_TXN_DAY, _TXN_TERMS),
|
||
(SVC_PROD_PARAM, _PROD_PARAM_TERMS),
|
||
(SVC_SELF, _SELF_TERMS),
|
||
)
|
||
|
||
|
||
def topic_of(message: str) -> str:
|
||
"""把问句归到**一个**业务事项码;无法归类时返回 :data:`SVC_UNKNOWN`。
|
||
|
||
**为什么是「一个」而不是「多标签」**:事项码的第一用途是**分流与留痕**——
|
||
一次对话要有唯一的第一责任事项(真实客服的受理单也是这么开的)。
|
||
需要多标签的场合(统计、复盘)从关键词组本身就能回溯,不必在这一层做。
|
||
"""
|
||
text = (message or "").strip()
|
||
if not text:
|
||
return SVC_UNKNOWN
|
||
for topic, terms in TOPIC_RULES:
|
||
if _hit(text, terms):
|
||
return topic
|
||
return SVC_UNKNOWN
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 「下一步」业务动作(`E5b` 引导段)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# ## 为什么按事项给「下一步」
|
||
#
|
||
# `E5b` 原话术是「**您可以换个说法再问我一次**」——在真实业务里这句话等于**把问题
|
||
# 推回给客户**:客户不知道换个什么说法,也不知道自己其实可以走哪条现成的路。
|
||
#
|
||
# 真实客服的「办不了」必须跟一个**具体动作**。动作只有三类(`OWNER_*`):
|
||
# 机器能办 / 客户自助能办 / 必须人工办。所以「下一步」按**事项**给,
|
||
# 而不是按**检索结果**给 —— 检索结果只说明「这次没查到」,不说明「该去哪办」。
|
||
|
||
_NEXT_STEP_GENERIC = (
|
||
"如果方便,请补充具体产品名、费率或条款名再问我一次,我可以更准确地帮您定位;"
|
||
"也可以直接拨打官方客服热线 {phone}({hours}),由人工同事为您处理。"
|
||
)
|
||
|
||
#: 事项 → 「下一步」动作文案。**只描述去哪办,不描述结论**(不生成任何事实)。
|
||
_NEXT_STEP_BY_TOPIC: dict[str, str] = {
|
||
SVC_STATE: (
|
||
"账户状态类的问题,您可以先在「南方基金」APP 的「我的—安全中心」查看具体原因提示,"
|
||
"按提示补充材料后一般会自动解除;"
|
||
"如果提示与您的情况不符,请拨打官方客服热线 {phone}({hours}),由风控专员协助核实。"
|
||
),
|
||
SVC_CHANNEL: (
|
||
"需要说明的是:南方基金是公募基金管理人,只销售本公司管理的产品。"
|
||
"如果您持有的不是本公司管理的基金,赎回、转换、账户资料变更等操作需要"
|
||
"回到您当初购买该产品的机构(银行 / 券商 / 第三方销售平台)办理;"
|
||
"本公司无法代为查询或操作其他机构的产品。"
|
||
"若要核对本公司产品的公开资料,随时可以问我;"
|
||
"也可以拨打官方客服热线 {phone}({hours}),我们帮您确认产品归属。"
|
||
),
|
||
SVC_SELF: (
|
||
"这类自助业务可以在「南方基金」APP 办理:"
|
||
"修改银行卡 / 手机号在「我的—安全中心」(需人脸识别),"
|
||
"忘记密码在登录页「忘记密码」用绑定手机号重置;"
|
||
"也可以携带身份证到就近的客户服务中心由客户顾问协助。"
|
||
"出于账户信息保护,智能客服不能替您代办这类操作。"
|
||
),
|
||
SVC_SUIT: (
|
||
"关于适当性,需要提醒的是:购买超出您风险等级的产品的,"
|
||
"须签署产品风险揭示书、并由本人完成录音录像(双录),"
|
||
"这类环节需要到客户服务中心或通过官方 APP 的视频见证流程办理。"
|
||
"我可以先按公开规则告诉您匹配范围,具体购买流程请以正式渠道为准。"
|
||
),
|
||
SVC_COMPLAINT: (
|
||
"如果您要正式提出投诉,请通过以下任一渠道(受理后会告知本案的具体时限):"
|
||
"① 「南方基金」APP 内「我的—帮助与反馈—我要投诉」;"
|
||
"② 客服热线 {phone} 转投诉专线;③ 邮件 complaint@nffund.com;"
|
||
"④ 就近客户服务中心现场。"
|
||
),
|
||
SVC_RISK: (
|
||
"这件事需要马上处理,请立即拨打官方客服热线 {phone}({hours})核实;"
|
||
"如已发生资金损失,请同时拨打 96110 全国反诈热线。"
|
||
),
|
||
SVC_ACCT_OP: (
|
||
"交易、资料变更、销户这类操作属于必须本人办理的事项,"
|
||
"智能客服不能代办。请在「南方基金」APP 自助办理,"
|
||
"或携带本人身份证到就近客户服务中心;也可拨打 {phone}({hours})转人工。"
|
||
),
|
||
SVC_MINE: (
|
||
"您的风险测评结果与客户分层可以在「南方基金」APP 的「我的账户」页面查看;"
|
||
"如果页面显示与您的情况不一致,请拨打 {phone}({hours})由人工核实。"
|
||
),
|
||
SVC_TXN_DAY: (
|
||
"到账时间以交易确认结果为准:"
|
||
"可在「南方基金」APP 的「交易查询」查看确认状态与电子交易确认单;"
|
||
"如需核对某一笔具体交易,请拨打 {phone}({hours})并提供交易流水号。"
|
||
),
|
||
SVC_QUOTE: (
|
||
"净值与走势数据请以「南方基金」官方渠道公布为准;"
|
||
"如需核对某只产品的历史净值,可以在 APP 的产品详情页查看净值走势。"
|
||
),
|
||
}
|
||
|
||
|
||
def next_step_block(topic: str, *, phone: str, hours: str) -> str:
|
||
"""给出该事项的**具体下一步动作**(`E5b` 引导段)。
|
||
|
||
**只描述去哪办,不描述结论**:这保证它既能在 `E5b` 兜底时使用,
|
||
又不会引入任何需要证据的事实(不触碰 `INV-2`)。
|
||
"""
|
||
template = _NEXT_STEP_BY_TOPIC.get(topic, _NEXT_STEP_GENERIC)
|
||
return template.format(phone=phone, hours=hours)
|
||
|
||
|
||
def describe(topic: str) -> dict[str, str]:
|
||
"""事项码的完整业务画像(答复留痕 / 复盘 / 报表用)。"""
|
||
return {
|
||
"topic": topic,
|
||
"owner": owner_of(topic),
|
||
"retention": retention_of(topic),
|
||
}
|