Files
group_fqcd_jr/app/core/service_topic.py
T
张胜宇 2b408dc602 feat(W29): 对话内区间涨跌图(零新数字路径)+ 客服业务层事项轴
## 对话内图表(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 改动,
两者无法按文件切分,故同批提交。
2026-09-22 10:12:14 +08:00

355 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""业务事项轴(`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),
}