一、客服 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 生成的本地产物
70 lines
3.3 KiB
Python
70 lines
3.3 KiB
Python
"""档位推导(「主体能看哪些档位」)的**唯一落点**(`G-03`)。
|
||
|
||
## 为什么单独成文件
|
||
|
||
档位规则此前内联在 `app/core/knowledge_contracts.py` —— 契约模块同时承担了
|
||
「请求/响应形状」与「权限推导规则」两种职责。`D2.1` 批次 G 的 `G-02`(身份轴,方案甲)
|
||
落地时要把「访客」从 RBAC 角色轴迁到身份轴,那次改动**只需要动本文件**,
|
||
不必再动契约、检索服务与工具层。
|
||
|
||
## 硬约束
|
||
|
||
1. **档位只由鉴权结果推导**,不由查询内容、请求参数或工具层自判身份字段决定。
|
||
2. **失败关闭**:认不出任何角色 ⇒ `{public}`(不是全集);空集合同样收敛为 `{public}`。
|
||
3. **`visibility` 是 Milvus 分区键** ⇒ 过滤表达式必须能下推为分区裁剪,故用 `in [...]`。
|
||
4. 取值域只有 `public` / `registered`。`internal` **不是**本系统的档位:
|
||
金标 `M-8`(档位越权)会把它计为越权,因此不要往取值域里加。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Iterable
|
||
|
||
#: 可见性档位取值域(与 Milvus 集合的 `visibility` 字段、分区键同源)。
|
||
PUBLIC_TIER = "public"
|
||
REGISTERED_TIER = "registered"
|
||
#: 全集;`visibility_expression()` 只在请求档位**恰好等于全集**时返回 None(即不过滤)。
|
||
ALL_TIERS: frozenset[str] = frozenset({PUBLIC_TIER, REGISTERED_TIER})
|
||
|
||
#: 主体类型 → 允许的档位集合(`D2.4` §5.4 的 `TIERS_BY_SUBJECT`)。
|
||
#:
|
||
#: ⚠️ 表内**只有** `visitor` 与 `customer`。`advisor` / `operator` / `admin` 不在表内,
|
||
#: 按 `tiers_for_roles` 的收敛规则只拿到 `{public}` —— 要放宽它们属**权限决策**,
|
||
#: 须单独裁定,不在此静默扩张。
|
||
TIERS_BY_SUBJECT: dict[str, frozenset[str]] = {
|
||
"visitor": frozenset({PUBLIC_TIER}),
|
||
"customer": ALL_TIERS,
|
||
}
|
||
|
||
#: 主体无法识别时的收敛档位。**是 `{public}` 而非 `ALL_TIERS`** —— fail-closed。
|
||
DEFAULT_TIERS: frozenset[str] = frozenset({PUBLIC_TIER})
|
||
|
||
|
||
def tiers_for_roles(roles: Iterable[str]) -> frozenset[str]:
|
||
"""把 `RequestContext.roles` 映射成「主体被允许的档位集合」。
|
||
|
||
多角色取**并集**(`customer` 已含 `public`,故并集即该主体的最大可见范围);
|
||
一个角色都识别不出时收敛为 `DEFAULT_TIERS` —— fail-closed,不是放行全集。
|
||
"""
|
||
allowed: frozenset[str] = frozenset()
|
||
for role in roles:
|
||
allowed |= TIERS_BY_SUBJECT.get(role, frozenset())
|
||
return allowed or DEFAULT_TIERS
|
||
|
||
|
||
def visibility_expression(tiers: frozenset[str]) -> str | None:
|
||
"""档位集合 → Milvus 过滤表达式;返回 `None` 表示**无需过滤**。
|
||
|
||
只有档位**恰好等于 `ALL_TIERS`** 时才返回 `None`;其余情况(含空集合 —— 收敛为
|
||
`DEFAULT_TIERS`)一律显式列出,不做「空集合 = 不过滤」这种 fail-open。
|
||
|
||
用 `in [...]` 而不是 `== "public"`:`visibility` 是**分区键**,Milvus 在分区键模式下
|
||
只支持可下推为分区裁剪的表达式;`in` 同时支持多档位,正好对应「客户 = public +
|
||
registered」。
|
||
"""
|
||
normalized = frozenset(tiers) or DEFAULT_TIERS
|
||
if normalized == ALL_TIERS:
|
||
return None
|
||
listed = ", ".join(f'"{tier}"' for tier in sorted(normalized))
|
||
return f"visibility in [{listed}]"
|