客服 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
+296
View File
@@ -0,0 +1,296 @@
"""前端 ↔ 后端边界守卫(`W11` 批次补测,答辩前的前端边界盘点产物)。
## 为什么要单开这一组
答辩前的边界盘点发现一类**同族缺口**:前端 `widget.js` 的输入框已经用
`maxlength="8000"` 截断,但服务端请求模型当初**不设上限**(或上限与落库列宽不符)。
前端截断只是体验,绕过前端(curl / 脚本 / 改前端)直发就能把任意长度文本或超长
ID 送进入库、入模型链路;MySQL 严格模式下这时表现为 `500` 而不是 `422`——
一次本该是"参数不合格"的失败,被冒泡成了服务端故障。
本文件把三类不变量钉死:
1. **口径一致**:前端输入框的 `maxlength` 必须等于服务端 `max_length`,
否则前端"允许输入"与服务端"允许接收"会静默分叉;
2. **入参上限 = 落库列宽**:请求模型的 `max_length` 必须 ≤ 对应列的 `String(n)`
(见 `app/model/session.py` / `app/model/platform.py` / `app/model/conversation.py`),
超限必须在入口变 `422`,而不是落库时才炸 `500`;
3. **路径参数同宽**:`{session_id}` / `{run_id}` / `{handover_id}` 等路径参数与列宽
对齐——这一层用 OpenAPI 契约断言,避免单元环境连库。
另含一条端点表守卫:`api-client.js` 里 `ENDPOINTS` 的每一项都必须在 `app.openapi()`
里真实存在。历史上 `HEALTH` 写的是 `/health`,而后端只挂了 `/internal/health/live`
与 `/internal/health/ready`——前端因此恒判"后端未连接",正是缺了这条守卫。
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.api.dependencies.auth import build_request_context
from app.api.schemas.agent_runs import AgentRunCreateRequest
from app.api.schemas.conversations import FeedbackRequest
from app.core.contracts import RequestContext
from app.main import create_app
from app.model.conversation import ConversationFeedback
from app.model.platform import RequestIdempotency
from app.model.session import ConversationSession
ROOT = Path(__file__).resolve().parents[3]
PORTAL = ROOT / "app" / "static" / "portal"
WIDGET = PORTAL / "common" / "customer-service-widget" / "widget.js"
API_CLIENT = PORTAL / "common" / "api-client.js"
AGENT_RUNS = "/api/v1/agent-runs"
#: 一份**合法**的建运行请求,边界用例只在它上面改一个字段。
VALID_RUN: dict[str, Any] = {
"agent_type": "customer_service",
"message": "查询我的基金持仓",
"session_id": "session-1",
"idempotency_key": "1234567890123456",
}
def _column_width(model: type, column: str) -> int:
length = model.__table__.c[column].type.length # type: ignore[attr-defined]
assert isinstance(length, int), f"{model.__name__}.{column} 不是定长列,无法比对"
return length
def _rejected(**overrides: Any) -> bool:
payload = {**VALID_RUN, **overrides}
try:
AgentRunCreateRequest(**payload)
except ValidationError:
return True
return False
def _client(permissions: tuple[str, ...] = ("agent:run",)) -> TestClient:
application = create_app()
async def context() -> RequestContext:
return RequestContext(
user_id="1", trace_id="fe-boundary", portal="public", permissions=permissions
)
application.dependency_overrides[build_request_context] = context
return TestClient(application)
def _endpoints() -> dict[str, tuple[str, str]]:
source = API_CLIENT.read_text(encoding="utf-8")
pairs = re.findall(r"(\w+):\s*\{\s*method:\s*'(\w+)',\s*path:\s*'([^']+)'", source)
assert len(pairs) >= 50, "端点表解析异常,正则可能已与 api-client.js 脱节"
return {name: (method, path) for name, method, path in pairs}
def _normalized(path: str) -> str:
"""把 `{sessionId}` / `{session_id}` 之类占位符统一成 `{}` 再比对。"""
return re.sub(r"\{[^}]+\}", "{}", path)
# --------------------------------------------------------------------------- #
# 一、口径一致:前端输入框 ↔ 服务端校验
# --------------------------------------------------------------------------- #
def test_widget_message_maxlength_matches_backend_limit() -> None:
"""`widget.js` 的 `maxlength` 必须与 `AgentRunCreateRequest.message` 的上限逐字一致。
这条断言是"两端同宽"的唯一防线:只改一边(比如前端产品改到 4000、或后端放宽
到 20000)都会立刻变红,而不会拖到线上才以"用户输入被静默截断/提交 500"暴露。
"""
source = WIDGET.read_text(encoding="utf-8")
matched = re.search(r'name="message"[^>]*maxlength="(\d+)"', source)
assert matched, "widget.js 里找不到 message 输入框的 maxlength"
frontend_limit = int(matched.group(1))
assert not _rejected(message="问" * frontend_limit), "前端允许的长度,后端不该拒绝"
assert _rejected(message="问" * (frontend_limit + 1)), "前端截断之外的长度,后端必须拒绝"
def test_message_and_session_bounds_match_storage_widths() -> None:
"""入参上限必须 ≤ 落库列宽,否则超限会以 500 而不是 422 出现。"""
session_width = _column_width(ConversationSession, "session_id")
idempotency_width = _column_width(RequestIdempotency, "idempotency_key")
assert not _rejected(session_id="s" * session_width)
assert _rejected(session_id="s" * (session_width + 1))
assert not _rejected(idempotency_key="k" * idempotency_width)
assert _rejected(idempotency_key="k" * (idempotency_width + 1))
def test_feedback_type_bound_matches_storage_width() -> None:
width = _column_width(ConversationFeedback, "feedback_type")
assert FeedbackRequest(rating=1, feedback_type="t" * width).feedback_type is not None
with pytest.raises(ValidationError):
FeedbackRequest(rating=1, feedback_type="t" * (width + 1))
def test_degenerate_inputs_are_rejected_at_the_model_layer() -> None:
assert _rejected(message="")
assert _rejected(session_id="")
assert _rejected(idempotency_key="123456789012345")
# --------------------------------------------------------------------------- #
# 二、HTTP 层:超限必须是 422 信封,不是 500
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
("overrides", "field"),
[
({"message": "问" * 8001}, "body.message"),
({"message": ""}, "body.message"),
({"session_id": "s" * 65}, "body.session_id"),
({"session_id": ""}, "body.session_id"),
({"idempotency_key": "k" * 65}, "body.idempotency_key"),
({"idempotency_key": "123456789012345"}, "body.idempotency_key"),
],
)
def test_oversized_body_fields_return_422_envelope(
overrides: dict[str, Any], field: str
) -> None:
with _client() as client:
response = client.post(
AGENT_RUNS, json={**VALID_RUN, **overrides},
headers={"X-Trace-ID": "fe-boundary"},
)
assert response.status_code == 422, response.text
body = response.json()
assert set(body) == {"error", "meta"}
error = body["error"]
assert error["code"] == "AGENT_INPUT_INVALID"
assert error["retryable"] is False
assert field in {item["field"] for item in error["field_errors"]}
assert body["meta"]["trace_id"] == "fe-boundary"
@pytest.mark.parametrize(
("method", "path"),
[
("GET", "/api/v1/conversations/" + "s" * 65),
("POST", "/api/v1/conversations/" + "s" * 65 + "/closures"),
("POST", "/api/v1/conversations/" + "s" * 65 + "/handover-requests"),
("GET", "/api/v1/conversations/" + "s" * 65 + "/messages"),
("GET", "/api/v1/agent-runs/" + "r" * 65),
("GET", "/api/v1/agent-runs/" + "r" * 65 + "/events"),
("POST", "/api/v1/agent-runs/" + "r" * 65 + "/cancellations"),
("GET", "/api/v1/handover-requests/" + "t" * 65),
],
)
def test_oversized_path_params_return_422(method: str, path: str) -> None:
"""路径参数超宽必须在路由层被拦下——不能带着超长 ID 去查库/写库。"""
with _client(permissions=("agent:run", "conversation:read", "handover:write")) as client:
response = client.request(method, path, json={} if method == "POST" else None)
assert response.status_code == 422, response.text
assert response.json()["error"]["code"] == "AGENT_INPUT_INVALID"
@pytest.mark.parametrize(
("method", "route", "param"),
[
("GET", "/api/v1/conversations/{session_id}", "session_id"),
("GET", "/api/v1/conversations/{session_id}/messages", "session_id"),
("POST", "/api/v1/conversations/{session_id}/closures", "session_id"),
("POST", "/api/v1/conversations/{session_id}/handover-requests", "session_id"),
("GET", "/api/v1/agent-runs/{run_id}", "run_id"),
("GET", "/api/v1/agent-runs/{run_id}/events", "run_id"),
("POST", "/api/v1/agent-runs/{run_id}/cancellations", "run_id"),
("GET", "/api/v1/handover-requests/{handover_id}", "handover_id"),
],
)
def test_path_params_declare_width_constraints_in_openapi(
method: str, route: str, param: str
) -> None:
"""契约层守卫:OpenAPI 必须暴露路径参数的长度上限(前端据此生成校验/文档)。"""
spec = create_app().openapi()["paths"][route][method.lower()]
parameters = {item["name"]: item for item in spec.get("parameters", [])}
assert param in parameters, f"{route} 未声明路径参数 {param}"
schema = parameters[param]["schema"]
assert schema.get("maxLength") == 64
assert schema.get("pattern") == r"^[A-Za-z0-9_-]+$"
@pytest.mark.parametrize("path", ["/api/v1/conversations/有中文", "/api/v1/agent-runs/run id"])
def test_path_params_reject_illegal_characters(path: str) -> None:
with _client(permissions=("agent:run", "conversation:read")) as client:
response = client.get(path)
assert response.status_code == 422, response.text
# --------------------------------------------------------------------------- #
# 三、鉴权/权限:边界收紧了,入口守卫不能跟着松
# --------------------------------------------------------------------------- #
def test_agent_run_requires_authentication() -> None:
with TestClient(create_app()) as client:
response = client.post(AGENT_RUNS, json=VALID_RUN)
assert response.status_code == 401
assert response.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
def test_missing_agent_permission_is_forbidden() -> None:
"""权限集里没有 `agent:run` 就必须 403——长度约束不得掩盖权限判定。"""
with _client(permissions=()) as client:
response = client.post(AGENT_RUNS, json=VALID_RUN)
assert response.status_code == 403, response.text
assert response.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"
def test_visitor_role_can_run_but_only_with_public_scope() -> None:
"""访客**可以**提交运行(`agent:run` 是设计内),但权限集里不得有任何个人数据权限。
⚠️ 这一条曾经被写反。盘点时的第一直觉是「访客调 R001 必须 403」,实测打到
真机才发现访客令牌**本来就带** `agent:run`——客服浮窗的匿名提问正是走这条路;
真正的不变量不是「访客不能提问」,而是「访客只能拿公共档位、不得触个人数据」。
因此这里的判据改成**权限集内容**:一旦有人往访客权限里追加
`customer:` / `profile:` / `memory:` / `handover:` / `conversation:` 前缀的权限码,
这条会立刻变红(`app/core/actor.py` 的注释也明确写了"不允许在这里追加权限码")。
"""
from app.core.actor import VISITOR_DATA_SCOPE, VISITOR_PERMISSIONS
assert set(VISITOR_PERMISSIONS) == {"agent:run", "knowledge:query"}
assert VISITOR_DATA_SCOPE == "public"
personal_prefixes = ("customer:", "profile:", "memory:", "handover:", "conversation:")
assert [
name for name in VISITOR_PERMISSIONS if name.startswith(personal_prefixes)
] == []
# --------------------------------------------------------------------------- #
# 四、端点表守卫:前端调的每一条都必须是后端真实存在的路由
# --------------------------------------------------------------------------- #
def test_frontend_endpoint_table_matches_real_routes() -> None:
spec = create_app().openapi()["paths"]
available = {
(method.upper(), _normalized(path))
for path, operations in spec.items()
for method in operations
}
missing = [
f"{name} {method} {path}"
for name, (method, path) in _endpoints().items()
if (method.upper(), _normalized(path)) not in available
]
assert not missing, "api-client.js 引用了后端不存在的端点:\n" + "\n".join(missing)
def test_health_probe_uses_a_registered_route() -> None:
"""`HEALTH` 历史上写的是 `/health`(后端没有),前端因此恒判"后端离线"。"""
method, path = _endpoints()["HEALTH"]
assert method == "GET"
assert path in create_app().openapi()["paths"]