客服 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:
@@ -1,4 +1,15 @@
|
||||
r"""合规层种子数据:7 个零容忍负面词(+4 变体)与 6 类固定话术。
|
||||
r"""合规层种子数据:7 个零容忍负面词(+4 变体 + C-05 三类词 14 条)与 6 类固定话术。
|
||||
|
||||
C-05(`D2.1` §1.4 D-a):在既有 11 行**之外追加**三类词 —— 收益比较 / 稀缺性 / 费率误导。
|
||||
三条硬约束:① `applicable_agents` 非空且含客服 Agent(空数组 = 失败关闭跳过,规则永不生效);
|
||||
② **不得改写既有 11 行的 `word_pattern`**(追加项单独放在 `EXTRA_NEGATIVE_RULES`,既有元组
|
||||
一字未动);③ 按**承诺性短语**收录而非形容词。
|
||||
|
||||
⚠️ 为什么落点是**本文件**而不是另存一份数据(甲案,用户 2026-09-18 已拍板):管理端
|
||||
`negative-word-rules` 通道只能建 `draft`(须审核才 `active`),而本脚本是唯一可复现、可重跑
|
||||
的装载器;另存第二份种子会让新环境**漏跑即静默缺失** —— 正是本项目反复踩过的坑。
|
||||
`C-05` 的 DoD 写「不改任何 `.py`」,此处登记为「不改 `app/**` 与底座 `.py`」:本文件是
|
||||
**数据装载器**,与 `D-b`(两条话术热线改定值)同源,追加的是数据行而不是拦截逻辑。
|
||||
|
||||
背景:`docs/02` §10.2/§10.3 要求这些数据,但迁移只建表、没有 seed。而
|
||||
`app/service/agent/governance.py` L62-65 的取数条件是
|
||||
@@ -30,6 +41,7 @@ r"""合规层种子数据:7 个零容忍负面词(+4 变体)与 6 类固
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -74,6 +86,41 @@ NEGATIVE_RULES: tuple[tuple[str, str, str, str], ...] = (
|
||||
("NEG-011", "坐享收益", "misleading", "误导性表述变体"),
|
||||
)
|
||||
|
||||
#: C-05 追加的三类词(`D2.1` §1.4 D-a 硬约束 ③:**承诺性短语**,不按形容词收录)。
|
||||
#:
|
||||
#: 入库前做过**误伤体检**:对 `knowledge/_chunks.jsonl` 逐词计数,下列 14 个词**全部 0 命中**,
|
||||
#: 且与 6 条话术零自绊(`verify()` 会强制复查)。两个刻意的排除:
|
||||
#: · **剔除 `X 折优惠`** —— 语料里「1 折优惠」「免申购费」是**公司自己的合法费率事实**
|
||||
#: (FAQ「申购和赎回的费率是多少」的答案正文就在写它),收进来会把费率问答整条拦成
|
||||
#: 转人工,与「放宽答不上来时的去向」正好相反。
|
||||
#: · **不收「高收益」「稳健」「低风险」这类形容词** —— 它们是风险提示里的正常用词
|
||||
#: (「高收益伴随高风险」会被一并拦下)。
|
||||
EXTRA_NEGATIVE_RULES: tuple[tuple[str, str, str, str], ...] = (
|
||||
("NEG-012", "承诺高收益", "yield_compare", "收益比较:以承诺口吻作横向比较"),
|
||||
("NEG-013", "保证高收益", "yield_compare", "收益比较:以保证口吻作横向比较"),
|
||||
("NEG-014", "比同类收益高", "yield_compare", "收益比较:与同类产品比较"),
|
||||
("NEG-015", "收益高于同类", "yield_compare", "收益比较:与同类产品比较"),
|
||||
("NEG-016", "跑赢同类", "yield_compare", "收益比较:比较性表述"),
|
||||
("NEG-017", "稀缺额度", "scarcity", "稀缺性:以额度稀缺促单"),
|
||||
("NEG-018", "额度有限", "scarcity", "稀缺性:以额度有限促单"),
|
||||
("NEG-019", "仅剩", "scarcity", "稀缺性:以示数量促单"),
|
||||
("NEG-020", "优先认购权", "scarcity", "稀缺性:暗示优先资格"),
|
||||
("NEG-021", "优先配置权", "scarcity", "稀缺性:暗示优先资格"),
|
||||
("NEG-022", "零费率", "fee_misleading", "费率误导:宣称零费率"),
|
||||
("NEG-023", "费率最低", "fee_misleading", "费率误导:最低化宣称"),
|
||||
("NEG-024", "手续费全免", "fee_misleading", "费率误导:宣称全免"),
|
||||
("NEG-025", "全网最低", "fee_misleading", "费率误导:全市场最低宣称"),
|
||||
)
|
||||
|
||||
#: C-05 三类词必须齐备的类目(`verify()` 逐类检查至少 1 条 active)。
|
||||
C05_CATEGORIES: tuple[str, ...] = ("yield_compare", "scarcity", "fee_misleading")
|
||||
|
||||
#: 真正落库的全部规则 = 既有 11 条 + C-05 追加 14 条。
|
||||
ALL_NEGATIVE_RULES = NEGATIVE_RULES + EXTRA_NEGATIVE_RULES
|
||||
|
||||
#: 规则必须挂到的 Agent(D-a 硬约束 ①:空数组 = 规则永不生效,且是「假成功」)。
|
||||
REQUIRED_APPLICABLE_AGENT = "customer_service"
|
||||
|
||||
#: (template_code, scene, title, content_text)
|
||||
#: scene 取自 `chk_template_scene` 的 7 个合法值里的 6 个,覆盖 6 类固定话术。
|
||||
#: TPL_AI_NOTICE 用 `clarification`(AI 生成标识属主动澄清披露);
|
||||
@@ -96,9 +143,9 @@ REPLY_TEMPLATES: tuple[tuple[str, str, str, str], ...] = (
|
||||
("TPL_LOW_CONFIDENCE", "low_confidence", "低置信兜底",
|
||||
"抱歉,我暂时无法准确回答您的问题,建议您转接人工客服获取更准确的帮助。"),
|
||||
("TPL_TRANSFER_HUMAN", "transfer", "转人工提示",
|
||||
"已为您转接人工客服,工作时间为工作日 09:00-18:00,客服电话 15936583816。"),
|
||||
"已为您转接人工客服,服务时间为每日 7:00—22:00,客服电话 400-889-8899。"),
|
||||
("TPL_SYSTEM_BUSY", "system_busy", "系统繁忙/模型故障",
|
||||
"系统繁忙,暂时无法回答,请稍后重试或联系人工客服 15936583816。"),
|
||||
"系统繁忙,暂时无法回答,请稍后重试或联系人工客服 400-889-8899。"),
|
||||
)
|
||||
|
||||
#: template_code -> content_text,供 `verify()` 做自绊检查(须定义在 REPLY_TEMPLATES 之后)。
|
||||
@@ -136,7 +183,7 @@ async def seed() -> None:
|
||||
"code": code, "scene": scene, "title": title, "content": content,
|
||||
"admin": ADMIN_ID, "now": now,
|
||||
})
|
||||
for rule_code, word, category, _reason in NEGATIVE_RULES:
|
||||
for rule_code, word, category, _reason in ALL_NEGATIVE_RULES:
|
||||
await session.execute(INSERT_NEGATIVE_RULE, {
|
||||
"rule_code": rule_code, "word": word, "category": category,
|
||||
"agents": APPLICABLE_AGENTS, "safe_code": SAFE_REPLY_TEMPLATE_CODE,
|
||||
@@ -148,33 +195,34 @@ async def verify() -> None:
|
||||
"""按 governance.py 的**同一口径**复查,确认真的能被查到。"""
|
||||
async with SessionFactory() as session:
|
||||
rules = (await session.execute(text(
|
||||
"SELECT rule_code, word_pattern, category, severity, match_type"
|
||||
"SELECT rule_code, word_pattern, category, severity, match_type, applicable_agents"
|
||||
" FROM agent_negative_word"
|
||||
" WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL"
|
||||
" ORDER BY rule_code"
|
||||
))).all()
|
||||
))).mappings().all()
|
||||
templates = (await session.execute(text(
|
||||
"SELECT template_code, scene, status FROM agent_reply_template"
|
||||
" WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL"
|
||||
" ORDER BY template_code"
|
||||
))).all()
|
||||
for code, word, category, severity, match_type in rules:
|
||||
print(f" rule {code} {word!r} category={category} severity={severity}"
|
||||
f" match_type={match_type}")
|
||||
for row in rules:
|
||||
print(f" rule {row['rule_code']} {row['word_pattern']!r}"
|
||||
f" category={row['category']} severity={row['severity']}"
|
||||
f" match_type={row['match_type']} agents={row['applicable_agents']}")
|
||||
for code, scene, status in templates:
|
||||
print(f" tpl {code} scene={scene} status={status}")
|
||||
# 种子层的自绊检查:话术不得命中自己这套规则。治理层是朴素子串匹配,
|
||||
# 没有否定式豁免——「非保本」这类写法会命中「保本」规则。
|
||||
# 在种子阶段就拦住,比等 Task 2 接入话术后再发现在替换结果里循环排查便宜得多。
|
||||
patterns = {row["word_pattern"] for row in rules}
|
||||
self_trips = [
|
||||
(code, word)
|
||||
for code, content in CONTENT_BY_TEMPLATE_CODE.items()
|
||||
for word in (word for _, word, *_ in rules)
|
||||
for word in patterns
|
||||
if word in content
|
||||
]
|
||||
if self_trips:
|
||||
raise SystemExit(f"话术命中了自己的禁用词(自绊):{self_trips}")
|
||||
patterns = {word for _, word, *_ in rules}
|
||||
missing = {
|
||||
word for word in REQUIRED_ZERO_TOLERANCE_WORDS
|
||||
if not any(word in pattern for pattern in patterns)
|
||||
@@ -184,13 +232,34 @@ async def verify() -> None:
|
||||
scene_count = len({scene for _, scene, _ in templates})
|
||||
if scene_count < 6:
|
||||
raise SystemExit(f"生效话术场景不足 6 类:{scene_count}")
|
||||
# D-a 硬约束 ①:`applicable_agents` 为空 = 治理层**失败关闭跳过**(规则永不生效)。
|
||||
# 这条最容易「看着种进去了、其实一条都没生效」,所以在种子阶段正面断言。
|
||||
ungated = [
|
||||
row["rule_code"] for row in rules
|
||||
if REQUIRED_APPLICABLE_AGENT not in {
|
||||
str(agent) for agent in (
|
||||
json.loads(row["applicable_agents"])
|
||||
if isinstance(row["applicable_agents"], str)
|
||||
else (row["applicable_agents"] or [])
|
||||
)
|
||||
}
|
||||
]
|
||||
if ungated:
|
||||
raise SystemExit(f"规则未挂到客服 Agent(等于永不生效):{ungated}")
|
||||
# C-05:三类词必须齐备 —— 否则「补了三类词」只是文档里的说法。
|
||||
active_categories = {row["category"] for row in rules}
|
||||
missing_categories = [c for c in C05_CATEGORIES if c not in active_categories]
|
||||
if missing_categories:
|
||||
raise SystemExit(f"C-05 三类词未齐备:{missing_categories}")
|
||||
print(f"verified: {len(rules)} active rules (含 7 个零容忍词), "
|
||||
f"{len(templates)} active templates covering {scene_count} scenes")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await seed()
|
||||
print(f"seeded {len(NEGATIVE_RULES)} negative rules, {len(REPLY_TEMPLATES)} reply templates")
|
||||
print(f"seeded {len(ALL_NEGATIVE_RULES)} negative rules"
|
||||
f"(既有 {len(NEGATIVE_RULES)} + C-05 {len(EXTRA_NEGATIVE_RULES)}),"
|
||||
f"{len(REPLY_TEMPLATES)} reply templates")
|
||||
await verify()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user