客服 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:
张胜宇
2026-09-20 14:33:30 +08:00
parent 8643ad1efc
commit 5d0becb67d
258 changed files with 77004 additions and 26099 deletions
@@ -0,0 +1,165 @@
{
"artifact": "t2k-c10-source-reference-downgrade",
"generated_at": "2026-09-19T01:41:43.772361+00:00",
"date_local": "2026-09-19",
"purpose": "执行 C-10「知识出口来源引用」(关键路径 C-01 → C-03 → C-09 → C-06 → C-07 → C-10 的收口步);裁定取「乙 · 本期降级」并加双向护栏",
"scope": [
"app/service/agent/implementations/customer_service.py(业务层:_references 降级为死代码并写明依据)",
"tests/unit/service/test_customer_service_agent.py(护栏 A:AST 守无调用点)",
"tests/unit/service/test_agent_governance.py(护栏 B:knowledge 来源必被拒)",
"开发文档对应的 D2.2 / D2.4(口径回写,另见 D2.1 v6.6 / D1.6 §4.19)"
],
"decision": {
"chosen": "乙 · 本期降级(不向客户展示来源引用)",
"rejected": "甲 · 底座方先登记知识来源并放行(需会签)",
"why_b": "甲依赖底座方改造 ToolExecutor 与治理层放行 knowledge 来源,属不可控外部依赖;本模块今天要交付,不能把交付卡在别人会签上。乙的代价只是「不展示出处」,可追溯性并未丢失(见下)。",
"why_not_silent": "降级不是静默丢弃:D2.2 的 FR-CS-010 与验收表、D2.4 的 §5.7 与 G4/AC-06 都写明了「本期降级」及其技术原因与启用前提,避免下一轮误当成已完成功能。",
"reversibility": "完全可逆:代码本体(_references)保留在位,护栏只是禁止调用;底座方放行后删掉护栏断言即可接通。"
},
"technical_reason": {
"gate": "app/service/agent/governance.py:314-316(review_output 的来源白名单只认 memory / tool)",
"failure_mode": "knowledge 来源会被判为「引用未来自本次已授权召回结果」,抛出 ForbiddenAgentError ⇒ **整个 run 失败**,不是降级、也不是少一个字段",
"red_line": "S-8",
"traceability_instead": "可追溯性由审计承接:agent.tool_executed 的工具调用记录含命中 doc_id 与分数;消息表亦留痕"
},
"implementation": {
"docstring": "_references() 的 docstring 重写为「保留待用的死代码 —— 本期(MVP)不向客户展示来源引用」,写明 governance.py:314-316、S-8、审计承接、启用前提须会签",
"helper_state": "函数体原样保留(source_type=\"knowledge\" 的 SourceReference 组装逻辑未改)",
"no_call_sites": "改用其他出口返回正文;零调用点"
},
"guardrails": {
"A_ast_no_call": {
"file": "tests/unit/service/test_customer_service_agent.py",
"test": "test_knowledge_exit_never_calls_the_disabled_reference_helper",
"method": "ast.walk 找 ast.Call 且 func.attr == \"_references\",断言 calls == []",
"why_ast": "该名字会合法出现在注释与文档字符串里,纯文本搜索会把「提一句」误判成「调用」",
"second_assert": "同时断言 _references 函数定义仍在位(清理时不得被连带删除)"
},
"B_governance_rejects": {
"file": "tests/unit/service/test_agent_governance.py",
"test": "test_knowledge_reference_is_rejected_so_it_must_stay_disabled",
"method": "以 source_type=\"knowledge\" 的 SourceReference 调 review_output,断言抛 ForbiddenAgentError",
"why": "把「本期为何必须降级」的技术原因钉成可执行断言:若将来底座放行,这个测试会失败并提醒同步移除护栏 A"
}
},
"html_downgrade_points": [
{
"doc": "D2.2",
"locator": "FR-CS-010 行",
"change": "追加 ⏸ 2026-09-19 本期降级说明 + <span class=\"pill p2\">⏸ 本期降级</span>"
},
{
"doc": "D2.2",
"locator": "US-CS-01 行",
"change": "期望值补「⏸ 本期不展示来源引用:C-10 乙」"
},
{
"doc": "D2.2",
"locator": "验收表 · 一 · 功能正确性",
"change": "「每条须带来源引用」→「⏸ 本期豁免(改由审计承接)」"
},
{
"doc": "D2.4",
"locator": "§5.7 末尾",
"change": "追加降级段:governance.py:314-316、S-8、_references() 死代码 + 护栏、启用前提须会签"
},
{
"doc": "D2.4",
"locator": "G4 行",
"change": "「可追溯」补「⏸ 本期:来源引用降级,可追溯性由审计承接」"
},
{
"doc": "D2.4",
"locator": "§5.7 三条要求 ①",
"change": "「知识类回答必须附来源」补「⏸ 本期降级,见下段」(本轮新发现的口径残留,与降级冲突)"
},
{
"doc": "D2.4",
"locator": "AC-06 行",
"change": "判据补「⏸ 本期豁免:C-10 乙不展示来源引用,改由审计承接」(同上,本轮新发现)"
}
],
"eol_check": {
"D2.2": "CRLF(本轮编辑后复核)",
"D2.4": "LF —— 编辑后实测 bytes=147917, bareLF=1758, CRLF=0,与改前同为纯 LF,EOL 未被改写"
},
"gates": {
"targeted_pytest": {
"files": 4,
"passed": 164,
"failed": 0,
"command": "pytest -q tests/unit/service/test_customer_service_agent.py tests/unit/service/test_agent_governance.py tests/unit/core/test_customer_service_rules.py tests/unit/core/test_compliance_context.py"
},
"ruff": {
"scope": "app tests tools",
"errors": 22,
"note": "等于基线,无新增"
},
"mypy": {
"scope": "app",
"errors": 3,
"note": "等于基线(agent_persistence_service.py:82 / run_query_service.py:79 / promotion_renderer.py:84,均为既有问题)"
}
},
"not_done_in_this_step": [],
"open_questions": {
"C-10_enablement": "若后续要展示出处,需底座方:① ToolExecutor 把工具返回的知识 doc_id 登记为本次可引用来源;② 治理层放行 knowledge 来源。两步都要会签。"
},
"closure_after": {
"document_writeback": "已完成:D2.1 升 v6.6(C-10 行标 [x] + 修订要点)、D1.6 新增 §4.19(第十五轮会话记录)",
"full_gate": "已完成,见 full_gate"
},
"full_gate": {
"pytest": {
"command": "pytest -q -p no:cacheprovider",
"result": "2 failed / 1579 passed / 2 skipped",
"note": "2 failed = T0 基线同两项(profile_snapshot_current_invariant / worker_runtime[False]),不计入本轮回归;passed 1577 → 1579,增量 = 本步新增的 2 条护栏"
},
"ruff": {
"command": "ruff check app tests tools",
"errors": 22,
"note": "= 基线"
},
"mypy": {
"command": "mypy app",
"errors": 3,
"note": "= 基线"
},
"authoritative_docs": {
"command": "python tools/check_authoritative_docs.py",
"result": "checked 50 documents, no number collision"
}
},
"environment_finding": {
"title": "残留 Worker 抢消费 outbox 事件 ⇒ 集成测试偶发失败(环境级,非代码回归)",
"first_symptom": "全量首跑多出第 3 个失败:tests/integration/test_memory_extraction.py::test_memory_is_not_recallable_across_customers",
"diagnosis_steps": [
"① 单文件重跑两次 → 3/3 通过 ⇒ 不稳定,不是确定性缺陷",
"② 按同一链路脚本复现 seed → enqueue → consume_once → 返回 True ⇒ 消费机制本身正常",
"③ 无人消费检测:插入一条 pending 事件后只观察、不消费 → 1 秒内被外部进程改成 published ⇒ 存在外部消费者"
],
"root_cause": "2026-09-18 17:52 启动的残留服务一直存活,其中 python -m app.worker 会抢消费 outbox 事件,与测试自身的 consume_once(aggregate_id=...) 竞争,先到先得 ⇒ 测试偶发拿不到事件而返回 False",
"processes": [
{
"pid": 27204,
"child": 8740,
"cmd": "uvicorn app.main:app --host 127.0.0.1 --port 8000"
},
{
"pid": 27084,
"child": 2204,
"cmd": "python -m app.worker"
}
],
"action": "经用户批准,已 Stop-Process 停掉 4 个 PID;停止后全量 pytest 回到 2 failed 基线",
"reusable_lessons": [
"与 A-02「门禁基线前先停 Worker」完全吻合 —— 该前置不是形式主义,不停就会污染集成测试",
"印证 S-7:知识集合重建后这两个服务早已过期",
"遇到集成测试失败,先做「无人消费检测」排除外部消费者,再怀疑代码"
]
},
"demo_precondition": {
"issue": "API 与 Worker 现已停止(8000 端口无监听)",
"must_do": "演示前按 S-7 重启 API + Worker,否则前端问答直接连不上"
}
}