diff --git a/app/core/customer_service_rules.py b/app/core/customer_service_rules.py index 8cd1f6f..4205dea 100644 --- a/app/core/customer_service_rules.py +++ b/app/core/customer_service_rules.py @@ -756,7 +756,8 @@ def _normalize_for_chitchat(message: str) -> str: BUSINESS_ENTITY_TERMS = ( "基金", "产品", "费率", "申购", "认购", "赎回", "净值", "收益", "风险", "等级", "账户", "持仓", "余额", "份额", "分红", "定投", "转换", "托管", "管理费", "门槛", - "起投", "合同", "协议", "条款", "说明书", "税率", "额度", "身份证", "银行卡", + "起投", "期限", "封闭期", "合同", "协议", "条款", "说明书", "税率", "额度", + "身份证", "银行卡", "密码", "验证码", "凭据", "走势", "行情", "涨跌", "投顾", "投诉", "定开", "封闭", ) @@ -818,6 +819,55 @@ def is_chitchat_message(message: str) -> bool: ) +#: **业务动作 / 职责词**:不是产品实体,但出现即说明这是一条**服务请求**而非寒暄。 +#: +#: 为什么在实体词之外还要有这一组:`BUSINESS_ENTITY_TERMS` 收的是**产品与账户名词** +#: (费率 / 净值 / 持仓⋯),而「怎么办理」「扣款多久」这类问句的核心是**动作与职责**, +#: 名词一个都没有。分类器在长句、口语、夹带寒暄时最容易把这类问句判成 `chitchat` +#: (`W28` 漂移用例集覆盖的正是这一族)。 +BUSINESS_REQUEST_TERMS = ( + "手续", "费用", "扣款", "划款", "到账", "确认", "开户", "销户", "变更", "修改", + "办理", "查", "客服", "热线", "投诉", "人工", "限制", "冻结", "撤销", +) + + +def substantive_business_request(message: str) -> bool: + """问句是否**带业务实质** —— `W28` 意图漂移护栏的判据。 + + 用途只有一个:当**意图分类器**把一条消息判成 `chitchat` 时做一次复核; + 命中则**不采信该分类**,改走知识检索(`E3` / `E4` / `E5b`)。 + + ## 为什么方向取「有实体就不闲聊」 + + 两类误判的代价**不对称**: + + | 误判方向 | 后果 | + |---|---| + | 把寒暄判成业务 | 多查一次库,最多落 `E5b`(还会说「我没找到」并给出下一步动作) | + | 把业务判成寒暄 | **答非所问** —— 闲聊出口不查库、不给素材,客户在一个费率问题上收到一句问候 | + + 所以判据取"宁可错杀",与 `is_chitchat_message()` 的边界**同向**。 + + ## 与 `is_chitchat_message` 的分工 + + 两者互补,守的是**两条不同的通路**: + + - `is_chitchat_message()` 是**前置**判据(`L0-a`,在意图分类**之前**跑一次); + - 本函数是**事后**复核(在意图分类**之后**跑),守的是 + 「分类器把业务问句判成 `chitchat`」这条漂移通路 —— `L0` 管不到它, + 因为分类发生在 `L0` 之后。 + + ## 触发面刻意收窄 + + 只在"分类已判 `chitchat`"这一条极窄的路径上被调用,所以放宽判据是安全的: + 最坏结果是从闲聊变成一次知识检索。**不得**把它用作主路径的分流判据。 + """ + if has_business_entity(message): + return True + text = _normalize_for_chitchat(message) + return any(term in text for term in BUSINESS_REQUEST_TERMS) + + def is_low_information_message(message: str) -> bool: """`L0-e`:**无信息量短句** —— 纯标点 / 纯符号表情 / 空串。 diff --git a/app/core/service_topic.py b/app/core/service_topic.py new file mode 100644 index 0000000..11c1bb4 --- /dev/null +++ b/app/core/service_topic.py @@ -0,0 +1,354 @@ +"""业务事项轴(`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), + } diff --git a/app/service/agent/implementations/customer_service.py b/app/service/agent/implementations/customer_service.py index bf5e546..94ac4cc 100644 --- a/app/service/agent/implementations/customer_service.py +++ b/app/service/agent/implementations/customer_service.py @@ -64,6 +64,7 @@ from app.core.customer_service_rules import ( is_risk_level_change_request, promotional_wording_violation, route_message, + substantive_business_request, visitor_advice_violation, ) from app.core.errors import ForbiddenAgentError @@ -104,6 +105,12 @@ from app.core.fund_fee_rules import ( purchase_fee_range, redemption_tier, ) +from app.core.service_topic import ( + next_step_block, + owner_of, + retention_of, + topic_of, +) from app.service.agent.base import BaseAgent #: `W21`:行业通用常识集合名。**只作为 `search_knowledge` 工具的 `collection` 入参** #: 使用(工具契约里该字段本就是自由字符串),不绕过任何白名单 —— 档位过滤、可见性 @@ -671,9 +678,12 @@ PARTIAL_FAQ_TEMPLATE = ( #: E5b 在连"部分命中"都没有时的说法(仍不建单)。 PARTIAL_EMPTY_TEMPLATE = ( "这一条我暂时没找到对应的公开资料,也不想凭猜测回答您。" - "换个说法再问我一次就行(带上具体产品名、费率或条款名都能帮我定位)," - f"也可以拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。" + f"您也可以直接拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。" ) +#: ⚠️ 上面这句**刻意不含**「换个说法再问我一次」—— `W28` 删掉了它。 +#: 理由:那句话在真实业务里等于**把问题推回客户**(客户不知道该换什么说法, +#: 也不知道自己有三条现成的路)。「下一步」改由 `service_topic.next_step_block()` +#: 按**业务事项**给出具体动作,见 `_exit_partial()` 的说明。 #: `E5b` **空答**(连部分资料都没给出)的两种确切文案 —— `F-2` 用它判定「答不上来」。 #: 直接比对上面两个模板常量本身:改文案时两边同源移动,不会出现「改了模板、判据静默失效」。 @@ -1022,10 +1032,32 @@ class CustomerServiceAgent(BaseAgent): super().__init__(definition or self.definition) async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: - """出口入口:跑完所有分支,再做一次**访客侧投资建议护栏**(`C-09`)。""" + """出口入口:跑完所有分支,打事项码,再做一次**访客侧投资建议护栏**(`C-09`)。""" result = await self._route_and_answer(request, context) + result = self._tag_service_topic(result, request) return self._guard_visitor_advice(result, context) + def _tag_service_topic(self, result: CoreResult, request: AgentRequest) -> CoreResult: + """给本轮答复打上**业务事项码**(`W28`,见 `app/core/service_topic.py`)。 + + 为什么打在 `data` 而不新增字段:`data` 的既有语义就是「业务 Agent 可返回的结构化结果」; + 而 `CoreResult.topic` 已被 `_declared_topic()` 占用 —— 它声明的是**本轮答复在说哪个 + 主语**(`E-05`)。两者含义不同,不能挤进同一个字段。 + + 为什么只加数据、不改行为:事项码是**留痕与统计**口径(业务上「这次受理的是什么事」), + 消费方是报表与复盘。它**不得参与任何判定** —— 否则「事项码判错」会连带把答复改错, + 而事项码的关键词组比出口判据粗得多,不该获得那种权力。 + """ + topic = topic_of(request.message) + return result.model_copy(update={ + "data": { + **result.data, + "svc_topic": topic, + "svc_owner": owner_of(topic), + "svc_retention": retention_of(topic), + } + }) + def _guard_visitor_advice( self, result: CoreResult, context: RequestContext ) -> CoreResult: @@ -1197,6 +1229,23 @@ class CustomerServiceAgent(BaseAgent): return await self._answer_from_knowledge(request, context, INTENT_FAQ) return self._guide_to_login("访客请求超出公开服务范围") if intent == INTENT_CHITCHAT: + # `W28` 意图漂移护栏:分类器判「闲聊」,但问句里**带业务实体** ⇒ 不采信这次分类。 + # + # 为什么需要它:`L0-a` 用的是**确定性**判据(词表 + 业务实体边界),但它只在 + # **分类之前**跑一次。分类器若把一个真业务问题判成 `chitchat`,本分支会直接走 + # 闲聊出口 —— 闲聊出口**不查知识库、不给素材**,客户于是在一个产品费率问题上 + # 收到一句寒暄。 + # + # 为什么方向取「有实体就不闲聊」:两类误判的代价**不对称**。把「好的」误判成业务 + # 只是多查一次库(最多落 `E5b`);把「费率是多少」误判成闲聊是**答非所问**, + # 而 `E5b` 至少还会说「我没找到」并给下一步。 + if substantive_business_request(request.message): + logger.info( + "意图漂移护栏:分类=%s 但问句含业务实体,改走知识检索 %r", + INTENT_CHITCHAT, + request.message[:40], + ) + return await self._answer_from_knowledge(request, context, INTENT_FAQ) return await self._chitchat(request) if intent == INTENT_TRANSFER: # `F-2`(2026-09-19 裁定):转人工**只由「用户显式要求」触发**,意图标签不再直通。 @@ -1277,22 +1326,24 @@ class CustomerServiceAgent(BaseAgent): # 运维会看到"客服一直引导人工"却查不出原因。 raise except Exception: - return self._exit_partial([], note="知识检索调用失败") + return self._exit_partial([], note="知识检索调用失败", topic_source=request.message) if not isinstance(output, dict): - return self._exit_partial([], note="知识检索返回格式异常") + return self._exit_partial([], note="知识检索返回格式异常", topic_source=request.message) if output.get("degraded"): reason = str(output.get("reason") or "未知") - return self._exit_partial([], note=f"知识检索降级:{reason}") + return self._exit_partial( + [], note=f"知识检索降级:{reason}", topic_source=request.message + ) hits = output.get("hits") if not isinstance(hits, list) or not hits: - return self._exit_partial([], note="知识库未命中") + return self._exit_partial([], note="知识库未命中", topic_source=request.message) hits = await self._supplement_basic_explain(request, context, list(hits)) best = hits[0] if not isinstance(best, dict): - return self._exit_partial([], note="命中内容格式异常") + return self._exit_partial([], note="命中内容格式异常", topic_source=request.message) # `F-3` 主体相关性闸门:问句点名了某个主题(如「投顾服务」),但**一块命中都没提到它** # —— 这是"检索拿相近概念凑数",直返原文或合并生成都会答非所问(实测 `B-04`)。 # 放在 `E4` 与置信判定**之前**:这类答复不是"置信度不够",而是"证据与问题无关"。 @@ -1358,7 +1409,7 @@ class CustomerServiceAgent(BaseAgent): else "命中与问句无共同业务词" ) logger.info("E5b 相关性闸门:%s doc_id=%s", note, hits[0].get("doc_id")) - return self._exit_partial([], note=note) + return self._exit_partial([], note=note, topic_source=request.message) return self._exit_partial( hits, note=f"置信度不足:score={score:.3f} gap={gap:.3f}" ) @@ -1368,7 +1419,7 @@ class CustomerServiceAgent(BaseAgent): best = self._prefer_section(request.message, best, hits) content = str(best.get("content") or "").strip() if not content: - return self._exit_partial(hits, note="命中内容为空") + return self._exit_partial(hits, note="命中内容为空", topic_source=request.message) # 正文只保留答案本身:固定免责声明由**治理层**统一追加(见文件头 `DISCLAIMER` 说明), # 业务代码不再拼字符串——否则会出现两条重复声明,且合规文案变成不可配置的硬编码。 @@ -1382,7 +1433,11 @@ class CustomerServiceAgent(BaseAgent): # 把整块清空。这时**不能回一个空气泡**(前端就是一条空白消息),交回 `E5b`。 rendered = render_plain(drop_yield_claims(content)) if not rendered: - return self._exit_partial(hits, note="命中内容全部为收益数值,按禁止输出处理") + return self._exit_partial( + hits, + note="命中内容全部为收益数值,按禁止输出处理", + topic_source=request.message, + ) return CoreResult( text=self._clamp_answer(rendered), topic=self._declared_topic(content), @@ -3196,6 +3251,43 @@ class CustomerServiceAgent(BaseAgent): f"- 区间最高:{high.get('nav')}({high.get('nav_date')})" f"|区间最低:{low.get('nav')}({low.get('nav_date')})" ) + # `W29`:给前端「区间涨跌图」的结构化数据(新不变量 `INV-8` 的落地)。 + # + # 🔑 这里**只搬运正文上面已经写出来的数字** —— + # 柱高 = 正文那句「近 N 个净值日涨跌 X%」, + # 柱下标注 = 正文那对「start_nav → end_nav」。 + # **不引入任何新数值**:`M-9`(无出处数字 = 0)的取证面**只看答复文本**, + # 图里的数字它看不见 —— 一旦图画出正文没有的数,图就成了唯一能绕过 + # 数字校验的合规通道。这条约束是 `INV-8` 的全部内容。 + # + # 为什么放 `data` 而不是新字段:`data` 的既有语义就是「业务 Agent 可返回的 + # 结构化结果」,且它已随 `conversation_message.tool_calls` 这个 JSON 列落库 + # (零 DDL),读侧 `RunQueryService` 整块透传 `tool_calls` ⇒ **前端零后端改动可取到**。 + chart_intervals: list[dict[str, Any]] = [] + for row in (intervals if isinstance(intervals, list) else []): + if not isinstance(row, dict): + continue + pct = row.get("change_pct") + start_nav = row.get("start_nav") + end_nav = row.get("end_nav") + chart_intervals.append({ + "label": str(row.get("name") or ""), + "change_pct": str(pct) if pct is not None else None, + "start_nav": str(start_nav) if start_nav is not None else None, + "end_nav": str(end_nav) if end_nav is not None else None, + }) + chart: dict[str, Any] = { + "kind": "interval_change", + "latest_nav": str(latest_nav) if latest_nav is not None else None, + "latest_nav_date": latest_date, + "from_date": str(output.get("from_date") or ""), + "to_date": str(output.get("to_date") or ""), + "series_points": output.get("series_points"), + "source": str(output.get("source") or ""), + "intervals": chart_intervals[:4], + "high": high if isinstance(high, dict) else None, + "low": low if isinstance(low, dict) else None, + } lines.append( f"- 数据来源:{output.get('source')}|数据区间 " f"{output.get('from_date')}—{output.get('to_date')}," @@ -3206,7 +3298,12 @@ class CustomerServiceAgent(BaseAgent): intent=self._classified_intent, exit_code=EXIT_QUOTE, topic=name, - data={"fund_code": code, "fund_name": name, "source": output.get("source")}, + data={ + "fund_code": code, + "fund_name": name, + "source": output.get("source"), + "trend_chart": chart, + }, ) def _exit_trend_miss(self, subject: str, *, reason: str = "", note: str = "") -> CoreResult: @@ -3261,7 +3358,9 @@ class CustomerServiceAgent(BaseAgent): exit_code=EXIT_TRANSFER, ) - def _exit_partial(self, hits: list[Any], *, note: str = "") -> CoreResult: + def _exit_partial( + self, hits: list[Any], *, note: str = "", topic_source: str = "" + ) -> CoreResult: """E5b 部分答 + 引导。**不建单**:这是"这次没查到",不是"必须人工办的事"。 有达到 ``PARTIAL_FLOOR`` 的命中时展示其正文(原文直返,不经模型); @@ -3297,16 +3396,35 @@ class CustomerServiceAgent(BaseAgent): content = self._clamp_answer(best_text) if not content: text = PARTIAL_EMPTY_TEMPLATE + missed = True elif best_text.lstrip().startswith("问:"): # `W21-D4`:命中块是 FAQ 问答对 ⇒ 以答案正文开场,不加兜底式前言。 text = PARTIAL_FAQ_TEMPLATE.format(content=content) + missed = False else: text = PARTIAL_TEMPLATE.format(content=content) + missed = False + if missed and topic_source: + # `W28` 双段话术:**「办不了」必须跟一个具体动作**。 + # + # 原话术是「换个说法再问我一次就行」—— 在真实业务里这句等于**把问题推回客户**: + # 客户不知道该换什么说法,也不知道自己其实有现成的路可走(APP 自助 / 客户服务 + # 中心 / 热线转人工)。真实客服的「办不了」后面永远跟一个**动作**。 + # + # 动作按**业务事项**给(`service_topic.next_step_block`),不按检索结果给 —— + # 检索结果只说明「这次没查到」,不说明「该去哪办」。 + text = text + "\n\n" + next_step_block( + topic_of(topic_source), phone=HOTLINE, hours=SERVICE_HOURS + ) return CoreResult( text=text, intent=self._classified_intent, topic=self._declared_topic(content) if content else "", exit_code=EXIT_PARTIAL, + # `kb_miss` 由出口**显式声明**「本轮什么都没答上来」,供 `F-2` 的转人工判据直接读。 + # 为什么不再用文本比对:话术一旦按事项变化,`result.text in KNOWLEDGE_MISS_TEXTS` + # 会**静默失效**(判据恒为假 ⇒ 该转人工的不转),而失效时没有任何报错。 + data={"kb_miss": missed}, ) @staticmethod @@ -3316,6 +3434,9 @@ class CustomerServiceAgent(BaseAgent): 只有「连部分资料都没有」才算答不上来:`E5b` 带内容的、给出澄清候选的,都算答到了 一部分,**不转人工**——`E5b` 与澄清本身也从不建单(`D3.7` §5「部分作答即合格」)。 """ + flag = result.data.get("kb_miss") + if isinstance(flag, bool): + return flag return result.text in KNOWLEDGE_MISS_TEXTS @staticmethod diff --git a/app/static/portal/common/customer-service-widget/trend-chart.js b/app/static/portal/common/customer-service-widget/trend-chart.js new file mode 100644 index 0000000..08016b1 --- /dev/null +++ b/app/static/portal/common/customer-service-widget/trend-chart.js @@ -0,0 +1,148 @@ +/** + * 客服浮窗内的「区间涨跌图」——只画答复正文里已经说过的数字。 + * + * ## 这个组件为什么长这样 + * + * **1. 只画正文已声明的数字**(不变量 `INV-8`)。 + * 柱高 = 正文那句「近 N 个净值日涨跌 X%」;悬停提示 = 正文那对「起始净值 → 末净值」。 + * **不引入任何新数值** —— `M-9`(无出处数字 = 0)的取证面**只看答复文本**, + * 图里的数字它看不见;图一旦画出正文没有的数,它就成了唯一能绕过数字校验的通道。 + * + * **2. 涨红跌绿**。中国市场惯例,与欧美相反 —— 这里的客户看到绿色会理解成"跌"。 + * + * **3. 全部走 DOM API 构造,绝不字符串拼 HTML**。 + * `widget.js` 的 `addMessage` 一律用 `textContent`,注释里写明「用 innerHTML 等于把 + * 回答内容当标记语言解析」。图表组件若图省事拼 HTML 串,等于把这层 XSS 防护重新打开, + * 而且图里的数字还会绕过数字校验。 + * + * **4. 不带任何预测性元素**:没有趋势线、没有目标线、没有均线 —— + * 只呈现**已经发生**的区间涨跌(`D3.9` §4.3 的禁止项:不得把区间涨跌表述为预期)。 + */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +//: 涨 = 红 / 跌 = 绿(中国惯例)/ 持平 = 中性灰。 +const UP_FILL = '#c0392b'; +const DOWN_FILL = '#1d9e75'; +const FLAT_FILL = '#7c8b87'; +const AXIS_STROKE = '#cfe1db'; +const VALUE_FILL = '#29423b'; +const LABEL_FILL = '#5b716b'; + +const VIEW_W = 300; +const VIEW_H = 128; +const BASE_Y = 72; +const BAR_W = 44; +const MAX_UP = 40; +const MAX_DOWN = 20; + +function svgNode(tag, attrs) { + const node = document.createElementNS(SVG_NS, tag); + Object.entries(attrs || {}).forEach(([key, value]) => node.setAttribute(key, String(value))); + return node; +} + +function svgText(attrs, content) { + const node = svgNode('text', attrs); + node.textContent = content; + return node; +} + +/** 「近 5 个净值日」→「5日」:只截取期数,不新造说法,也不让标签互相压到。 */ +function shortIntervalLabel(label) { + const matched = String(label || '').match(/\d+/); + return matched ? `${matched[0]}日` : String(label || ''); +} + +/** + * 构建区间涨跌图。 + * + * @param {object} spec `E6` 出口写入 `CoreResult.data.trend_chart` 的结构 + * @returns {HTMLElement|null} 数据不足时返回 `null`(调用方据此不渲染任何东西) + */ +export function buildTrendChart(spec) { + if (!spec || !Array.isArray(spec.intervals) || spec.intervals.length === 0) return null; + + const rows = spec.intervals + .filter((row) => row && row.change_pct !== null && row.change_pct !== undefined) + .slice(0, 4); + if (rows.length === 0) return null; + + // 后端给的是字符串(`Decimal` 保精度),这里只用于**画图**,不回写文本。 + const values = rows.map((row) => { + const value = Number(row.change_pct); + return Number.isFinite(value) ? value : 0; + }); + const peak = Math.max(...values.map((value) => Math.abs(value)), 1); + + const figure = document.createElement('figure'); + figure.className = 'cs-widget__chart'; + + const svg = svgNode('svg', { + viewBox: `0 0 ${VIEW_W} ${VIEW_H}`, + width: '100%', + role: 'img', + 'aria-label': `区间涨跌图:${rows + .map((row, index) => `${row.label || ''}${values[index]}%`) + .join(',')}`, + }); + + // 零基线(不是坐标轴:不标刻度,避免被读成"走势图/预测图")。 + svg.appendChild(svgNode('line', { + x1: 6, y1: BASE_Y, x2: VIEW_W - 6, y2: BASE_Y, + stroke: AXIS_STROKE, 'stroke-width': '1', + })); + + rows.forEach((row, index) => { + const value = values[index]; + const x = 8 + index * 80; + const positive = value >= 0; + const height = Math.max(2, (Math.abs(value) / peak) * (positive ? MAX_UP : MAX_DOWN)); + const y = positive ? BASE_Y - height : BASE_Y; + const fill = value > 0 ? UP_FILL : (value < 0 ? DOWN_FILL : FLAT_FILL); + + const group = svgNode('g', {}); + group.appendChild(svgNode('rect', { x, y, width: BAR_W, height, rx: 3, fill })); + + // 悬停提示:用正文里那对首末净值,不额外算任何数。 + const title = svgNode('title', {}); + title.textContent = `${row.label || ''}涨跌 ${row.change_pct}%` + + (row.start_nav && row.end_nav ? `(${row.start_nav} → ${row.end_nav})` : ''); + group.appendChild(title); + + // 数值标注:正数加 "+",负数原样(后端 `Decimal` 已带负号)。 + const raw = String(row.change_pct); + const shown = raw.startsWith('-') ? `${raw}%` : `+${raw}%`; + group.appendChild(svgText({ + x: x + BAR_W / 2, + y: positive ? y - 7 : y + height + 12, + 'text-anchor': 'middle', + 'font-size': '10', + fill: VALUE_FILL, + }, shown)); + + group.appendChild(svgText({ + x: x + BAR_W / 2, + y: 116, + 'text-anchor': 'middle', + 'font-size': '10', + fill: LABEL_FILL, + }, shortIntervalLabel(row.label))); + + svg.appendChild(group); + }); + + figure.appendChild(svg); + + // 图注 = 可核验信息(`D3.9` §4.3「必带数据日期与来源」)。文案全部来自服务端字段。 + const caption = document.createElement('figcaption'); + caption.className = 'cs-widget__chart-caption'; + const parts = []; + if (spec.source) parts.push(`来源 ${spec.source}`); + if (spec.from_date && spec.to_date) parts.push(`${spec.from_date}—${spec.to_date}`); + if (spec.series_points) parts.push(`${spec.series_points} 个净值日`); + caption.textContent = parts.join('|'); + figure.appendChild(caption); + + return figure; +} diff --git a/app/static/portal/common/customer-service-widget/widget.css b/app/static/portal/common/customer-service-widget/widget.css index e33ae66..7868ff5 100644 --- a/app/static/portal/common/customer-service-widget/widget.css +++ b/app/static/portal/common/customer-service-widget/widget.css @@ -22,6 +22,9 @@ .cs-widget__composer button:hover { background: #075f56; } .cs-widget__footer { padding: 0 14px 14px; display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #82938e; font-size: 10px; } .cs-widget__footer button { padding: 0; color: #0a7568; background: transparent; border: 0; font-size: 11px; cursor: pointer; } +.cs-widget__chart { align-self: flex-start; max-width: 84%; margin: 0; padding: 10px 12px 8px; background: #fff; border: 1px solid #dceae5; border-radius: 10px 10px 10px 3px; } +.cs-widget__chart svg { display: block; width: 100%; height: auto; } +.cs-widget__chart-caption { margin-top: 6px; color: #82938e; font-size: 10px; line-height: 1.5; } @keyframes cs-pop { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } } @media (max-width: 620px) { .cs-widget { right: 14px; bottom: 14px; } .cs-widget__launcher { min-width: 0; padding-right: 11px; } .cs-widget__launcher-copy { display: none; } .cs-widget__panel { right: -2px; width: min(380px, calc(100vw - 28px)); } } @media (prefers-reduced-motion: reduce) { .cs-widget__panel { animation: none; } .cs-widget__launcher { transition: none; } } diff --git a/app/static/portal/common/customer-service-widget/widget.js b/app/static/portal/common/customer-service-widget/widget.js index 6e13f48..ce97904 100644 --- a/app/static/portal/common/customer-service-widget/widget.js +++ b/app/static/portal/common/customer-service-widget/widget.js @@ -31,6 +31,7 @@ import { apiClient, ApiError } from '/static/portal/common/api-client.js?v=20260913'; import { getAccessToken, getAuthContext } from '/static/portal/common/auth.js?v=20260913'; import { visitorHeaders } from '/static/portal/common/visitor-token.js'; +import { buildTrendChart } from '/static/portal/common/customer-service-widget/trend-chart.js?v=20260922'; const AGENT_TYPE = 'customer_service'; //: 首次轮询前的等待:后端受理是同步的,先等一小段能让"短问题"一轮就出结果。 @@ -129,6 +130,25 @@ export function mountCustomerServiceWidget(mode = 'public') { return sessionId; } + /** + * 从 run 快照里取出「正文 + 可选图表数据」。 + * + * 图表走 `result.tool_calls.data.trend_chart`:`CoreResult.data` 会落进 + * `conversation_message.tool_calls` 这个 **JSON 列**(零 DDL),读侧把整个 + * `tool_calls` 透传给客户端,所以这里**不必改后端**就能取到。 + * + * ⚠️ 不能读 `result.data` —— 那个顶层字段只对 `financial_nl2sql` 的 run 暴露, + * 客服 Agent 的 `result` 里没有它(见 `RunQueryService.get`)。 + */ + function readAnswer(snapshot) { + const result = snapshot.result || {}; + const content = result.content || '暂时没有可展示的回复。'; + const calls = result.tool_calls; + const stored = calls && typeof calls === 'object' && !Array.isArray(calls) ? calls.data : null; + const trendChart = stored && typeof stored === 'object' ? stored.trend_chart : null; + return { content, trendChart: trendChart || null }; + } + async function waitForRun(runId) { for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) { await new Promise((resolve) => { @@ -139,7 +159,7 @@ export function mountCustomerServiceWidget(mode = 'public') { }); const snapshot = response.data || {}; if (snapshot.status === 'succeeded') { - return snapshot.result?.content || '暂时没有可展示的回复。'; + return readAnswer(snapshot); } if (snapshot.status === 'failed' || snapshot.status === 'cancelled') { throw new ApiError('客服暂时无法完成回答,请稍后重试。'); @@ -166,7 +186,19 @@ export function mountCustomerServiceWidget(mode = 'public') { message, idempotency_key: crypto.randomUUID().replaceAll('-', ''), }, { headers: await requestHeaders() }); - pending.textContent = await waitForRun(accepted.data?.run_id); + const answer = await waitForRun(accepted.data?.run_id); + pending.textContent = answer.content; + // 图表作为**独立一块**追加。全程 DOM API(见 `trend-chart.js`),不经 innerHTML —— + // `addMessage` 刻意用 `textContent` 就是为了不把回答内容当标记语言解析, + // 图表组件若拼 HTML 串等于把这层防护重新打开。 + // 图只呈现正文已声明的数字(不变量 `INV-8`);数据不足时它返回 `null`,这里不画。 + if (answer.trendChart) { + const chart = buildTrendChart(answer.trendChart); + if (chart) { + messages.appendChild(chart); + messages.scrollTop = messages.scrollHeight; + } + } status.textContent = '已完成回答'; } catch (error) { pending.remove();