客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
(输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)
二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数
三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"
四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
(端点与账号均已不存在,此前稳定报 3 条假红)
五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期
六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
_chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
This commit is contained in:
@@ -32,6 +32,19 @@
|
||||
仍然是"命中即拦",豁免是在匹配**之后**按语境判定。这样:
|
||||
- 规则的**入库/审核**流程不变(合规人员照常录入);
|
||||
- 判定变严的只有"明显在禁止或谈论该表述"的场景,且**有测试固定**。
|
||||
|
||||
## C-04 补充的两类豁免(**放宽方向**,与上面「宁可误拦」相反)
|
||||
|
||||
1. **短否定式**(`SHORT_NEGATION_CUES`):`不保本` / `非保本` 这类线索**跨越命中词本身**,
|
||||
窗口判定读不到(命中的「保本」之前只剩一个「不」)。改为**与命中位置重叠**判定,
|
||||
只有压住命中词的那一次才生效 —— 避免「前文否定 → 后文真实承诺被放过」。
|
||||
2. **指标名 / 概念词**(`FACTUAL_TERMS`,目前为 `安全` / `年化收益率`):它们本身是合规的
|
||||
业务名词(「资金安全」「七日年化收益率」天天出现在正式披露里),命中不等于违规。
|
||||
判据与输入侧句式判定同构:**同句内出现承诺性线索(`CLAIM_CUES`)才算违规**。
|
||||
刻意**不含 `预期收益率`** —— 该词是明令禁用的营销表述、输入侧也未放行它,保持命中即拦。
|
||||
|
||||
⚠️ 这两条是**放宽**方向,因此:覆盖它们的测试必须同时给出**反例**(真实承诺仍被拦);
|
||||
`CLAIM_CUES` 的失效方向是偏严(漏收线索只会更容易判违规),可以按需增长。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -45,6 +58,11 @@ NEGATION_CUES: tuple[str, ...] = (
|
||||
"避免", "勿", "拒绝", "防止", "防范", "打击", "处罚", "违规",
|
||||
"不承诺", "不保证", "不作", "不能", "绝不", "从不",
|
||||
"且不", "并不", "未保证", "不予保证",
|
||||
# C-04 补充:否定**存在**的短式。`预期收益率` 不是指标名(理由见 `FACTUAL_TERMS` 说明),
|
||||
# 仍按「命中即拦」处理,靠这条线索豁免「净值型产品是没有固定预期收益率…」这类定义性表述。
|
||||
# 刻意**不收**单字或两字的「没有」—— 它会在同一句里把后面的真实承诺一并豁免
|
||||
# (实测反例:「这款产品没有风险,保本保收益」)。
|
||||
"没有固定",
|
||||
)
|
||||
|
||||
#: 元语言线索:说明"在谈论这个词本身"而不是在作承诺(如『保证收益』等违规表述)。
|
||||
@@ -59,6 +77,35 @@ QUESTION_CUES: tuple[str, ...] = (
|
||||
"能否", "是否", "可否", "会不会", "能不能", "请问", "吗",
|
||||
)
|
||||
|
||||
#: 短否定式线索(`C-04` DoD ①):这类线索**跨越命中词本身**,例如 `不保本` 里的 `保本`。
|
||||
#: 窗口判定读不到它 —— 命中词之前的窗口里只剩一个「不」,线索被命中词截断。
|
||||
#: 因此改按**与命中位置重叠**判定(见 `_short_negation_overlaps`),且只收明确的否定短语、
|
||||
#: 不收单字「不」(单字会把「不可保本?」这类边界写法也放过)。
|
||||
SHORT_NEGATION_CUES: tuple[str, ...] = (
|
||||
"不保本", "不保收益", "非保本", "并非保本", "不确保", "不担保",
|
||||
)
|
||||
|
||||
#: 指标名 / 概念词(`C-04` DoD ②):它们**本身是合规的业务名词**,命中不等于违规。
|
||||
#: 判据与输入侧句式判定同构:**同句内出现承诺性线索(`CLAIM_CUES`)才算违规**,
|
||||
#: 否则视为事实性提及(「资金安全」「七日年化收益率」天天出现在正式披露里)。
|
||||
#:
|
||||
#: 为什么必须成对处理:输入侧已放行「什么是年化收益率」「你们的平台安全吗」这类概念题,
|
||||
#: 若输出侧仍按裸词拦,检索回来的**正确答案**会被整条替换成转人工 —— 输入侧的收益被输出侧抹掉。
|
||||
#: 所以口径是「**输入侧放行了哪个概念词,输出侧就必须同口径放行**」。
|
||||
#:
|
||||
#: 刻意**不含 `预期收益率`**:该词是明令禁用的营销表述,且输入侧并未放行它
|
||||
#: (它不在含义题句式里),故输出侧**保持命中即拦** —— 收紧方向不失守。
|
||||
FACTUAL_TERMS: tuple[str, ...] = ("安全", "年化收益率")
|
||||
|
||||
#: 承诺性线索:与输入侧的承诺词表同一套语义,另加**收益可实现性断言**与**推销/比较口吻**。
|
||||
#: ⚠️ 失效方向是**偏严**(漏收线索 = 更容易判违规),与「白名单漏词 = 更容易放行」正好相反,
|
||||
#: 所以这份表可以按需增长而不牺牲安全。
|
||||
CLAIM_CUES: tuple[str, ...] = (
|
||||
"保证", "承诺", "一定", "绝对", "肯定", "百分百", "万无一失", "确保",
|
||||
"可达", "达到", "可获", "可获得", "至少", "不低于", "保底",
|
||||
"划算", "最高", "优于", "领先", "稳赚", "包赚",
|
||||
)
|
||||
|
||||
#: 引号字符:命中词被引号包裹时视为引用(在谈论该表述本身)。
|
||||
QUOTE_CHARS: tuple[str, ...] = ('"', "'", "“", "”", "‘", "’", "「", "」", "『", "』")
|
||||
|
||||
@@ -98,11 +145,45 @@ def _is_quoted(text: str, index: int, length: int) -> bool:
|
||||
return text[index - 1] in QUOTE_CHARS and text[index + length] in QUOTE_CHARS
|
||||
|
||||
|
||||
def is_prohibited_context(text: str, index: int, pattern: str) -> bool:
|
||||
"""命中位置是否处于"禁止/否定/引用"语境(是则豁免该次命中)。
|
||||
def _short_negation_overlaps(text: str, index: int, length: int) -> bool:
|
||||
"""短否定式是否**与命中位置重叠**(如 `不保本` 中的 `保本`)。
|
||||
|
||||
保守口径:任一明确线索命中即豁免;没有线索一律**不**豁免。
|
||||
窗口判定做不到这件事:命中词之前的窗口里只剩「不」,`不保本` 这个线索被命中词截断。
|
||||
判据是「线索与命中词**相接或重叠**」:`不保本` 压住命中词,`不确保` / `不担保` 紧贴在它
|
||||
之前(中间不得有标点或别的字)。这样既读得到跨词线索,又不会因为「前文出现过否定」就放过
|
||||
后文的真实承诺 —— 实测反例「不保本的产品也很多,这只基金保本」仍然判违规。
|
||||
"""
|
||||
for cue in SHORT_NEGATION_CUES:
|
||||
start = 0
|
||||
while True:
|
||||
position = text.find(cue, start)
|
||||
if position < 0:
|
||||
break
|
||||
if position <= index + length and index <= position + len(cue):
|
||||
return True
|
||||
start = position + 1
|
||||
return False
|
||||
|
||||
|
||||
def _is_factual_term_mention(text: str, index: int, pattern: str) -> bool:
|
||||
"""指标名 / 概念词在**同句内没有承诺性线索**时,视为事实性提及(豁免)。"""
|
||||
if pattern not in FACTUAL_TERMS:
|
||||
return False
|
||||
before, after = _window(text, index, len(pattern))
|
||||
local = before + pattern + after
|
||||
return not any(cue in local for cue in CLAIM_CUES)
|
||||
|
||||
|
||||
def is_prohibited_context(text: str, index: int, pattern: str) -> bool:
|
||||
"""命中位置是否处于「禁止/否定/引用」语境(是则豁免该次命中)。
|
||||
|
||||
判定顺序:短否定式(重叠)→ 引号 → 否定线索 → 询问语境 → 元语言线索 → 指标名句式判定。
|
||||
**只有最后一条是放宽方向**(`C-04` DoD ②),前面几条都是既有的收紧项;顺序如此是为了让
|
||||
「不得使用『安全』等误导性表述」这类**引用语境**优先成立,不让指标名判定抢先给结论。
|
||||
保守口径不变:没有明确线索一律**不**豁免。
|
||||
"""
|
||||
if _short_negation_overlaps(text, index, len(pattern)):
|
||||
return True
|
||||
before, after = _window(text, index, len(pattern))
|
||||
if _is_quoted(text, index, len(pattern)):
|
||||
return True
|
||||
@@ -113,7 +194,9 @@ def is_prohibited_context(text: str, index: int, pattern: str) -> bool:
|
||||
if any(cue in before or cue in after for cue in QUESTION_CUES):
|
||||
return True
|
||||
# 元语言线索:前后任一侧出现"表述/话术/字样"等,说明在谈论该词本身。
|
||||
return any(cue in before or cue in after for cue in METALANGUAGE_CUES)
|
||||
if any(cue in before or cue in after for cue in METALANGUAGE_CUES):
|
||||
return True
|
||||
return _is_factual_term_mention(text, index, pattern)
|
||||
|
||||
|
||||
def match_with_exemptions(text: str, pattern: str) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user