记忆召回:按 sys_customer_assignment 归属定范围,不再把员工号当客户号

## 修的是什么

`governance.recall()` 把 `int(context.user_id)` 当客户号用。后果有两个,
方向相反但都致命:

1. **员工身份(风控/投顾/运营/管理员/system)恒空** —— 员工不是客户,
   那是个不存在的客户号;日志只说 "empty",看不出是"设计如此"还是"记忆坏了"。
2. **越权陷阱** —— 员工号与客户号同号段(演示数据里客户 9001-9020、
   员工 9002/9020 并存)。`int(user_id)` 一旦与真实客户号重合,就会把
   **陌生客户的长期记忆读进来并注入提示词**,且不报错、看起来正常。

同一个问题在代码里还有另外两处**各自判断**、口径互不一致:
`BaseAgent.recall_memory()` 要求"每条记忆 customer_id == context.user_id"
(否则抛"越过客户范围"),`review_output()` 的引用校验只认同一条件。

## 怎么修的

新增 `app/core/memory_scope.py` 作为**唯一判定口径**,三处共用:

- 客户身份(customer / authenticated_user):**只读自己**,分配表里有别行也不读别人;
- 员工身份:**只读 `sys_customer_assignment` 分配给自己**的客户
  (`context.customer_ids`,由 `IdentityRepository.load_context()` 读入);
  归属未维护 ⇒ **失败关闭**,并在日志里点名"归属未维护",与"库里确实没有记忆"区分开;
- 访客:无(上游已拦)。

细节约定:
- 归属客户按客户号**升序**召回、单次上限 `MAX_RECALL_CUSTOMERS=10`
  —— 升序是为了确定性(同一身份每次取同一批,不随数据库返回顺序漂移),
  上限是为了别把成百上千条他人记忆塞进一个提示词;
- 跨客户合并后按置信度降序、`(客户号, uuid)` 兜底排序,最多 10 条;
- 员工同时持有多个归属客户的记忆时,`memory_context_text()` **逐行标注客户号**
  并把提示词改成"多个客户的长期事实" —— 否则模型会把 A 客户的事实当成 B 客户的。
  单一客户时保持原格式(客户身份的提示词与改动前逐字相同);
- 引用校验与范围守卫都改用同一口径:员工引用**归属客户**的记忆不再被判成伪造引用;
  引用**非归属客户**的记忆即便被塞进 memories 也照样拦下。

## 验证(真实身份链路 + 生产召回装配)

`IdentityRepository.load_context` → `PlatformGovernance.recall`(含 Milvus 语义通道):

- 身份展开:roles=('advisor',)、customer_ids=('9001',)(sys_customer_assignment
  里唯一那行 9020→9001)、可读范围 (9001,);
- **修复前** `recall(int(user_id)=9020)` → **0 条**;
- **修复后** `recall(按归属)` → **2 条**(客户9001:进取型 / 约三年);
- 边界:客户身份 9001 可读范围 (9001,);无归属员工 9002 = ()(失败关闭,
  且**没有**把 9002 当客户号);未分配时的 9020 = ()。

测试:`pytest tests/unit tests/contract` → **1445 passed, 2 skipped, 1 failed**
(1432 + 新增 13;唯一失败是组员正在改的投顾页面,与记忆链路无关)。
新增用例:`tests/unit/core/test_memory_scope.py`(8 条,含"员工号不得被当成客户号"
的反例断言)、`tests/unit/service/test_agent_governance.py`(+5 条:归属召回/
无归属失败关闭且不碰数据库/客户只读自己/引用校验/越界守卫)。

## 遗留(已在 AGENTS.md 与文档里写明,未自行实施)

风控扫描这条线**仍读不到记忆**:它是唯一消费召回内容的地方
(`risk_agent.py:224`),而扫描上下文是 user_id="0"/roles=("system",) 且无归属行。
根因是**顺序问题**:召回发生在 handle() 之前,上下文里没有"本次目标客户"这个概念。
出路有两条:① 给风控专员补 sys_customer_assignment 行(运维动作,立即可用);
② 在 RequestContext 加显式的 target_customer_id 并校验它落在归属集合内
(推荐,但属跨线协议改动,等确认)。

文档:docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md 新增 §五(含 §5.4 遗留说明)、
AGENTS.md 新增"记忆可读范围只有一个判定口径"易错点,并按 2026-09-14 复测更新测试基线。
This commit is contained in:
2026-09-14 21:33:16 +08:00
parent 0c642133d4
commit 9eebf9627f
7 changed files with 595 additions and 45 deletions
@@ -17,7 +17,7 @@
| 你提的现象 | 根因 | 状态 |
|---|---|---|
| ① 语义召回(Milvus)没数据 | R1+R2+R3+R4(四层叠加) | ✅ **已修,端到端验证通过** |
| ② 员工身份召回恒空 | 授权范围语义未定义 | ❌ **未修 —— 需你拍板**(见 §4.1) |
| ② 员工身份召回恒空 | 身份语义陷阱(把员工号当客户号) | ✅ **已按归属口径修好,带对照组验证**(见 §五) |
| ③ 记忆更新传不到画像 | R1(重试计数把事件挡住) | ✅ **已修,有版本证据** |
---
@@ -159,20 +159,9 @@
## 四、未修 / 待你决策
### 4.1 ⚠️ 员工身份召回恒空 —— **卡在你的一个决策上**
(现象 ② 的员工召回已在 §五 修好并验证。下列为其余未办事项。)
`governance.recall` 场景下员工查客户记忆恒空。**技术上已定位**:召回入口按"身份"取
客户范围,而**员工的可见范围没有权威定义**,所以要么查不到、要么可能越权。
修它需要你先定**授权口径**,二选一:
- **(A) 按 `sys_customer_assignment` 归属**:员工只能召回"分配给我"的客户。
语义最严(谁负责谁可见),但依赖分配表数据完整 —— 数据没维护就会什么都查不到。
- **(B) 按角色 `data_scope`**:如投顾可见其服务范围内全部客户。
更贴近真实组织,但需要 `data_scope` 有明确定义,越权面更大。
**我倾向 (A)**:金融场景"最小可见"优先,且它天然可审计。**你拍板后我再动手改。**
### 4.2 其他(不阻塞演示)
### 4.1 其他(不阻塞演示)
| 项 | 说明 |
|---|---|
@@ -183,7 +172,98 @@
---
## 五、本轮改动文件
## 五、现象 ②:员工身份召回恒空(已按归属口径修好)
### 5.1 这不是"没数据",是**把员工号当成了客户号**
`governance.recall()` 里原先是:
```python
customer_id = int(context.user_id) # ← 员工身份下,这是一个不存在的客户号
result = await service.recall(customer_id)
```
员工不是客户,于是**恒空**;而日志只说 "empty",看不出是"设计如此"还是"记忆坏了"。
**比恒空更危险的是越权陷阱**:员工号与客户号落在**同一号段**(本仓演示数据里
客户 9001-9020、员工 9002/9020 并存)。`int(context.user_id)` 一旦与某个真实客户号
重合,就会**把那个陌生客户的长期记忆读进来、注入提示词**。这正是"确定性和安全性"
最不能接受的一类错误:它不报错、看起来正常。
同一个问题在代码里还有**另外两处各自判断**,口径互不一致:
| 位置 | 原判据 |
|---|---|
| `governance.recall()` | `customer_id = int(context.user_id)` |
| `BaseAgent.recall_memory()` | 每条记忆的 `customer_id` 必须 `== context.user_id`,否则抛"越过客户范围" |
| `review_output()` 引用校验 | 只有 `customer_id == context.user_id` 的记忆算"本轮已知" |
⇒ 所以这次不是改一行,而是把口径**收敛到一个模块**
(`app/core/memory_scope.py`),三处共用同一个判定。
### 5.2 定下来的口径(你拍板:按 `sys_customer_assignment` 归属,最小可见)
| 身份 | 可读范围 |
|---|---|
| **客户**(`customer` / `authenticated_user`) | 只有自己(自身 `user_id` 当客户号),分配表里有别行也不读别人 |
| **员工**(风控/投顾/运营/管理员/system) | 只有 `sys_customer_assignment` 里**分配给自己**的客户;归属未维护 ⇒ 读不到(失败关闭) |
| **访客** | 无(上游已拦) |
细节约定:
- **`user_id` 永不作为客户号**参与员工召回 —— 这是本节的根因,不可能再犯;
- 归属客户按**客户号升序**召回并**上限 10 个**(`MAX_RECALL_CUSTOMERS`):升序是为了
**确定性**(同一身份每次取同一批,不随数据库返回顺序漂移),上限是为了别把成百上千
条他人记忆塞进一个提示词;
- 跨客户合并后按置信度降序、按 `(客户号, uuid)` 兜底排序,最多 10 条;
- 员工同时持有**多个**归属客户的记忆时,`memory_context_text()` 会**逐行标注客户号**,
并把提示词改成"多个客户的长期事实" —— 否则模型会把 A 客户的事实当成 B 客户的
(单一客户时保持原格式,客户身份的提示词与改动前逐字相同);
- 引用校验与范围守卫都改用同一口径:员工引用**归属客户**的记忆不再被判成伪造引用,
引用**非归属客户**的记忆即便被塞进 `memories` 也照样拦下。
### 5.3 验证(真实身份链路 + 生产召回装配)
```powershell
# IdentityRepository.load_context → PlatformGovernance.recall(含 Milvus 语义通道)
```
| 口径 | 结果 |
|---|---|
| **修复前** `recall(int(user_id)=9020)` | **0 条** |
| **修复后** `recall(按归属)` | **2 条** —— 客户9001:进取型 / 约三年 |
身份展开(实测):`roles=('advisor',)`、`customer_ids=('9001',)`(来自
`sys_customer_assignment` 里唯一那行 `9020 → 9001`)、可读范围 `(9001,)`。
边界对照:
- 客户身份 9001 的可读范围 = `(9001,)`(只有自己);
- 无归属员工 9002(风控专员)= `()` —— 失败关闭,**且没有把 9002 当客户号**;
- 未分配时的 9020 = `()`。
### 5.4 ⚠️ 遗留:风控扫描这条线仍然读不到记忆(需你决定)
**真实消费方只有风控 Agent**(`risk_agent.py:224` 把记忆渲染进提示词),
而风控扫描的上下文是 `user_id="0"` / `roles=("system",)` / **没有任何归属行**,
所以按 §5.2 的严格口径它**仍然读不到**(日志会明确点名"归属未维护 ⇒ 无可读客户")。
根因是**顺序问题**,不是权限问题:召回发生在 `handle()` **之前**,
而"这次运行是针对哪个客户"要到执行工具时才从预警记录里读出来 ——
上下文里没有"本次目标客户"这个概念。两条出路:
1. **维护归属**:给风控专员补 `sys_customer_assignment` 行(运维动作,立即可用);
2. **引入显式目标客户**(推荐做,但需改协议):在 `RequestContext` 加
`target_customer_id`,由"针对某客户的运行"显式带上,召回时校验它
**必须落在归属集合内**。这样既保住最小可见,又能让风控/投顾对"正在看的那个客户"
精确召回,而不是把归属集合里所有客户的记忆一股脑塞进提示词。
**当前未自行实施第 2 条**:它要改 `RequestContext` 协议并让各调用方传值,
属跨线改动,等你确认后再动。
---
## 六、本轮改动文件
**新增**
- `tests/unit/infrastructure/test_memory_vector_collection_consistency.py`(集合名三侧同源守卫)
@@ -202,3 +282,11 @@
- `tests/unit/worker/test_episode_worker.py`(重试计数回归用例)
- `.env.example`(移除 `MILVUS_COLLECTION`)
- `docs/37-记忆投影链路实现说明.md`(补集合名契约与根因)
**现象 ② 的员工召回(§五)**
- `app/core/memory_scope.py`(新增:记忆可读范围的唯一判定口径)
- `app/service/agent/governance.py`(`recall()` 按归属召回;引用校验同口径)
- `app/service/agent/base.py`(范围守卫同口径;多客户时提示词标注客户号)
- `tests/unit/core/test_memory_scope.py`(新增,8 条)
- `tests/unit/service/test_agent_governance.py`(+5 条:归属召回/无归属失败关闭/客户只读自己/引用校验/越界守卫)
- `AGENTS.md`(补这条易错点)