"""业务事项轴(`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), }