客服 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,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -166,32 +164,6 @@ def test_api_client_registers_onboarding_risk_and_admin_endpoints() -> None:
|
||||
assert f"{endpoint_id}:" in source
|
||||
|
||||
|
||||
def test_advisor_workspace_registers_documented_operation_endpoints() -> None:
|
||||
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
|
||||
dashboard = (PORTAL / "employee-advisor" / "dashboard" / "index.html").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
for endpoint_id in (
|
||||
"ADVISOR_PUBLISHED", "ADVISOR_GOAL", "ADVISOR_ANALYSIS",
|
||||
"ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL",
|
||||
):
|
||||
assert f"{endpoint_id}:" in source
|
||||
for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标"):
|
||||
assert label in dashboard
|
||||
|
||||
|
||||
def test_advisor_dashboard_is_composed_from_feature_modules() -> None:
|
||||
source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "./actions-module.js" in source
|
||||
assert "./published-module.js" in source
|
||||
config = (PORTAL / "employee-advisor" / "dashboard" / "advisor-config.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "ACTION_LABELS" in config
|
||||
|
||||
|
||||
def test_no_portal_page_includes_the_same_script_twice() -> None:
|
||||
"""同一个入口 JS 被引两次(哪怕 `?v=` 不同)会让页面出现两份顶部导航。
|
||||
|
||||
@@ -225,26 +197,6 @@ def test_mount_shell_is_idempotent() -> None:
|
||||
assert "if (document.querySelector('.site-header')) return;" in source
|
||||
|
||||
|
||||
def test_portal_feature_modules_have_consistent_imports() -> None:
|
||||
"""拆分前端模块时最容易漏 import:定义搬走了,使用处却留在原文件。
|
||||
|
||||
这类问题**上面那些字符串断言全都看不见** —— 只会在浏览器里以
|
||||
`ReferenceError: XXX is not defined` 爆出来,表现为"投顾工作台打开是白板",
|
||||
而 Python 测试一片绿。2026-09-13 合并进来的提交就真的发生了:
|
||||
`dashboard.js` 还在用已经搬进 `advisor-config.js` 的 `CONTENT_TYPE_LABELS`。
|
||||
|
||||
检查逻辑在 `tools/check_portal_modules.py`(语法 + import 可解析 + 常量有来源),
|
||||
这里只是把它接进测试,保证以后每次跑测试都会执行到。
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "tools" / "check_portal_modules.py")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, f"{result.stdout}\n{result.stderr}"
|
||||
|
||||
|
||||
def test_risk_scan_endpoint_uses_extended_timeout() -> None:
|
||||
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
|
||||
assert (
|
||||
@@ -459,3 +411,77 @@ def test_public_home_uses_a_local_hero_image() -> None:
|
||||
assert "/static/portal/guest/home/assets/wealth_architecture_hero.jpg" in home
|
||||
assert image.is_file()
|
||||
assert image.stat().st_size > 100_000
|
||||
|
||||
|
||||
def test_customer_service_widget_is_mounted_by_the_shell_for_public_and_customer_modes() -> None:
|
||||
"""浮窗由 `mountShell()` **单点挂载**,且只挂公开页与客户工作台。
|
||||
|
||||
挂载点选在 shell 而不是九个页面的 JS:写九份就意味着九处 `?v=` 版本号要一起改,
|
||||
漏一个就是"某个页面浮窗样式陈旧"。员工四类工作台(risk / advisor / operator / admin)
|
||||
刻意不挂 —— 它们各自有业务 Agent,挂上只会让"当前账号能不能用这个入口"变成
|
||||
一道需要解释的问题。
|
||||
"""
|
||||
shell = (PORTAL / "common" / "layout" / "app-shell.js").read_text(encoding="utf-8")
|
||||
widget = PORTAL / "common" / "customer-service-widget"
|
||||
assert (widget / "widget.js").is_file()
|
||||
assert (widget / "widget.css").is_file()
|
||||
import_line = (
|
||||
"import { mountCustomerServiceWidget } from "
|
||||
"'/static/portal/common/customer-service-widget/widget.js';"
|
||||
)
|
||||
assert import_line in shell
|
||||
assert "const CUSTOMER_SERVICE_MODES = Object.freeze(['public', 'customer']);" in shell
|
||||
assert "if (!CUSTOMER_SERVICE_MODES.includes(mode)) return;" in shell
|
||||
assert "mountCustomerServiceFor(mode);" in shell
|
||||
assert "'/static/portal/common/customer-service-widget/widget.css?v=" in shell
|
||||
|
||||
|
||||
def test_customer_service_widget_reuses_shared_visitor_token_and_endpoint_table() -> None:
|
||||
"""浮窗不得自带第二份访客令牌实现,也不得绕过端点表直接 `fetch`。"""
|
||||
source = (
|
||||
PORTAL / "common" / "customer-service-widget" / "widget.js"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "common/visitor-token.js" in source
|
||||
assert "common/api-client.js" in source
|
||||
assert "visitorHeaders()" in source
|
||||
# 访客令牌只应有 `visitor-token.js` 一份实现:存储 key 与 JWT 解析都不该出现在这里。
|
||||
assert "portalVisitorToken" not in source
|
||||
assert "sessionStorage" not in source
|
||||
assert "atob(" not in source
|
||||
# 所有请求走端点表(`test_business_pages_do_not_call_fetch_directly` 的同一口径)。
|
||||
assert "fetch(" not in source
|
||||
for endpoint_id in ("'C001'", "'R001'", "'R002'", "'C005'"):
|
||||
assert endpoint_id in source, endpoint_id
|
||||
# 轮询预算要覆盖 Worker 的整条链路(实测 4.1–4.8 秒),不得退回"几次就放弃"。
|
||||
assert "const POLL_ATTEMPTS = 40;" in source
|
||||
|
||||
|
||||
def test_customer_service_widget_does_not_show_tool_names_as_sources() -> None:
|
||||
"""`result.source_references` 目前只有 `tool` 类型(标题就是工具名)。
|
||||
|
||||
知识来源引用是 `C-10` 乙的**降级项**:治理层不认可 `knowledge` 来源,一旦输出会让
|
||||
整个 run 失败。所以浮窗**刻意不渲染「参考:」行** —— 显示「参考:query_knowledge」
|
||||
对客户毫无意义。可追溯性由审计承接。此断言防止有人"顺手"把它加回来。
|
||||
"""
|
||||
source = (
|
||||
PORTAL / "common" / "customer-service-widget" / "widget.js"
|
||||
).read_text(encoding="utf-8")
|
||||
css = (
|
||||
PORTAL / "common" / "customer-service-widget" / "widget.css"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "snapshot.result?.source_references" not in source
|
||||
assert "addReferences" not in source
|
||||
assert "cs-widget__references" not in source
|
||||
assert "cs-widget__references" not in css
|
||||
assert "C-10" in source # 降级理由留痕,不能只删代码不写原因
|
||||
|
||||
|
||||
def test_api_client_lets_callers_pin_an_explicit_bearer_token() -> None:
|
||||
"""调用方显式传入的 `Authorization` 优先于自动附加的登录令牌。
|
||||
|
||||
否则"带访客令牌取公开数据"会在浏览器恰好有登录令牌时静默变成"用登录身份取数据",
|
||||
症状是同一个公开页对访客与已登录用户显示不同内容(`README.md` 明令禁止混用)。
|
||||
"""
|
||||
source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8")
|
||||
assert "const callerAuth = options.headers?.Authorization;" in source
|
||||
assert "if (endpoint.auth !== false && token && !callerAuth) {" in source
|
||||
|
||||
Reference in New Issue
Block a user