记忆召回:按 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
+103
View File
@@ -0,0 +1,103 @@
"""记忆可读范围的**唯一判定口径**:谁有权读谁的长期记忆。
## 为什么必须单独一个模块
长期记忆是**客户级私密数据**。此前它被三处各自判断、口径还不一致:
- `governance.recall()`:把**登录者自己的 user_id 当客户号**用
(`customer_id = int(context.user_id)`);
- `BaseAgent.recall_memory()`:用"每条记忆的 `customer_id` 必须 == `context.user_id`"守范围;
- `review_output()`:引用校验只承认 `customer_id == context.user_id` 的记忆是"本轮已知"。
同一个问题三个答案,于是员工身份(风控专员/投顾/运营/管理员/system)**恒空**:
员工不是客户,`int(context.user_id)` 是一个不存在的客户号,召回永远 0 条 ——
而日志只说 "empty",看不出是"设计如此"还是"记忆坏了"。
比恒空更危险的是**权限语义陷阱**:员工号与客户号落在同一号段(本仓演示数据里
客户 9001-9020、员工 9002/9020 并存),`int(context.user_id)` 会**读到那个陌生客户的
长期记忆**并注入提示词。金融场景最不能接受的就是这一类"看起来正常"的越权。
## 定下来的口径(2026-09-14 已拍板:按归属,最小可见)
| 身份 | 可读范围 |
|---|---|
| **客户**(`customer` / `authenticated_user`) | **只有自己**(自身 `user_id` 当客户号);别家客户一律不可读 |
| **员工**(风控/投顾/运营/管理员/system) | 只有 `sys_customer_assignment` 里**分配给自己**的客户;归属未维护 ⇒ 读不到(失败关闭) |
| **访客** | 无(调用方在上游已拦) |
`sys_customer_assignment` 由 `IdentityRepository.load_context()` 读入 `context.customer_ids`,
即本模块的输入。**归属未维护不是故障,是"没有授权"** —— 所以日志必须点名它,
让运维知道该去维护分配表,而不是去查"记忆是不是坏了"。
"""
import logging
from app.core.contracts import RequestContext
logger = logging.getLogger(__name__)
#: 认定为"客户身份"的角色码。与 `app/worker/runtime.py` 的画像候选判定同一口径。
CUSTOMER_ROLES: frozenset[str] = frozenset({"customer", "authenticated_user"})
#: 单次运行最多召回多少个归属客户。
#:
#: 为什么要有上限:员工可能有成百上千个归属客户,逐个召回会变成 N 次库查询 + N 次
#: 向量检索,且把成百上千条他人记忆塞进一个提示词。上限取 10 与 `RECALL_ITEM_LIMIT`
#: 同量级,并按客户号**升序**截断 —— 升序是为了**确定性**:同样的身份每次截断到同一批客户,
#: 不随数据库返回顺序漂移(金融场景里"同一输入结果不稳定"本身就是缺陷)。
MAX_RECALL_CUSTOMERS = 10
#: 跨客户合并后最多保留多少条记忆。
RECALL_ITEM_LIMIT = 10
def is_customer_identity(context: RequestContext) -> bool:
"""本身份是否"以客户身份"登录。"""
return bool(CUSTOMER_ROLES.intersection(context.roles))
def customer_memory_scope(context: RequestContext) -> tuple[int, ...]:
"""本次运行**有权读取记忆**的客户号(升序去重,已按上限截断)。
客户身份只含自己;员工身份只含归属客户(`sys_customer_assignment`);
任何情况下都**不把员工自己的 user_id 当客户号**——那正是越权陷阱的来源。
"""
if is_customer_identity(context):
try:
own = int(context.user_id)
except (TypeError, ValueError):
logger.warning("客户身份但 user_id 非数字,记忆范围为空 user_id=%r", context.user_id)
return ()
return (own,) if own > 0 else ()
parsed: set[int] = set()
for raw in context.customer_ids:
try:
value = int(str(raw).strip())
except (TypeError, ValueError):
# 非数字客户号只可能来自脏数据;跳过并留痕,不让它把整次召回带崩。
logger.warning("归属客户号非数字,已跳过 raw=%r", raw)
continue
if value > 0:
parsed.add(value)
ordered = sorted(parsed)
if len(ordered) > MAX_RECALL_CUSTOMERS:
logger.warning(
"归属客户数 %s 超过单次召回上限 %s,按客户号升序只取前 %s 个"
"(升序截断以保证同一身份每次结果一致)",
len(ordered), MAX_RECALL_CUSTOMERS, MAX_RECALL_CUSTOMERS,
)
ordered = ordered[:MAX_RECALL_CUSTOMERS]
return tuple(ordered)
def memory_customer_in_scope(memory_customer_id: str, scope: tuple[int, ...]) -> bool:
"""某条记忆的归属客户是否落在本次可读范围内。
无法解析成数字的客户号一律判为**越界**(失败关闭):宁可拒掉一条来路不明的记忆,
也不放过一条可能属于他人客户的记忆。
"""
try:
return int(str(memory_customer_id).strip()) in scope
except (TypeError, ValueError):
return False