记忆系统演示文档 + 修掉两处会让演示断链的真问题

## 新增:docs/演示用/记忆系统演示文档-2026-09-14.md
按"操作 → 看到什么 → 这体现什么"写,五个场景(探针看存储 / 记住一件事 / 谁能读谁读不到 /
画像版本与投影 / 停 Worker),每个场景配可复现命令与**实测输出**,另附建议顺序与时长、
六条问答话术、演示前自检清单、受控信号词表摘录。

配套两个新工具(都已实跑):
- `tools/memory_demo_chain.py`:一键走完"客户说 → 候选 → 客户确认 → 管理员批准 →
  记忆与画像自动收敛",打印演示前后对比(实测 2 秒收敛)。
- `tools/memory_recall_demo.py`:同一客户换六个身份召回,当场看出"客户只读自己 /
  员工要权限码+归属 / 运营读不到 / 管理员有权限码但没归属也读不到"。

## 修掉两处会让演示当场断链的问题(都是真机复现的)

1. **候选批准后画像字段不收敛**
   `CustomerProfileCandidateService._promote` 只写画像快照、**不投**
   `profile.rebuild_requested`,而 `user_facts` 与画像字段是 `ProfileAssemblyService`
   的重建链路写的。后果:批准后记忆变 active、快照版本 +1,但**画像字段停在旧值**
   (客户说"三年以内",画像里还是"十年以上"),要等一次无关的重建才收敛 ——
   而"客户说完 → 批准 → 画像变了"正是演示主线。
   现补一条重建事件(走事件而不是就地重建:本方法所在事务还没提交,
   另开 session 看不到刚写入的记忆)。

2. **画像快照的唯一键从来没起作用,而且埋雷**
   `uk_profile_snapshot_current` 建在列 `current_customer_id` 上(不是 `is_current`),
   但 `ProfileAssemblyService._write_snapshot` 旧行只置 `is_current=False`(不清该列)、
   新行**不写**该列(实测 13 行该列全 NULL)。
   后果:只要客户**先被重建过一次**,旧 current 行仍占着 `current_customer_id=9001`,
   下一次"批准画像候选"就会撞 `Duplicate entry '9001' for key
   uk_profile_snapshot_current` → **整次批准 500**(本次实测踩到)。
   现按唯一键的真实语义写:清旧行的该列、新行显式写客户号。
   (`ProfileGenerationService` / 候选路径本来就是这么写的,只有这一处没对齐。)

## 守卫
新增 `tests/integration/test_profile_snapshot_current_invariant_mysql.py`:
按真实顺序"先重建再批准候选",断言 ① 只有一条 current 且它占着唯一键、
② 历史版本已归还该列、③ 批准不再 500、④ 批准投出了重建事件。
修复前这条用例会在第 ② 步失败。

## 验证
- `pytest tests/unit tests/contract` → 1490 passed, 2 skipped, 0 failed
- 新增集成用例通过;真机实测演示主线:候选 → verified → active → **2 秒内**
  `user_facts` 与 `fin_customer_profile.investment_horizon` 都变成新值
- `mypy tools/memory_demo_chain.py tools/memory_recall_demo.py` → 0 错;ruff 全绿

## 文档
`docs/44-演示流程.md`:配套文档清单与"记忆链路"备选场景都指向新演示文档。
This commit is contained in:
2026-09-15 00:55:44 +08:00
parent 076d786bc6
commit 9490e5043b
7 changed files with 906 additions and 5 deletions
+102
View File
@@ -0,0 +1,102 @@
"""记忆召回演示(只读):**同一个客户,换不同身份去读,看谁能读到、谁读不到。**
## 为什么需要它
"记忆系统的权限"这套东西光讲没用 —— 演示时最好当场看到:
用**客户本人**的身份能读到自己,用**别的客户**身份读到的是空的,
用**有权限且有归属**的风控身份能读到,用**没权限**的运营身份读不到(而且日志会点名原因)。
这个脚本就是干这个的:按平台**真实**的身份解析(`IdentityService`,读的是库里的
角色/权限/客户归属)构造上下文,再走**产线同一条召回链路**
(`PlatformGovernance` + `build_memory_recall_service`,含 Redis 热缓存与 Milvus 语义通道),
逐个身份打印召回结果与降级原因。全部只读,不写任何数据。
用法::
python tools/memory_recall_demo.py # 默认看客户 9001
python tools/memory_recall_demo.py --customer 10001
python tools/memory_recall_demo.py --identity 9002 # 只看某个身份
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from app.core.contracts import RequestContext
from app.core.memory_scope import REQUIRED_EMPLOYEE_PERMISSION, customer_memory_scope
from app.service.agent.bootstrap import build_memory_recall_service
from app.service.agent.governance import PlatformGovernance
from app.service.identity_service import IdentityService
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace")
#: 演示身份:(显示名, user_id, 说明)。真实的角色/权限/归属都从库里查,不在这里编。
DEMO_IDENTITIES: tuple[tuple[str, str, str], ...] = (
("客户本人(9001 / cust_t)", "9001", "客户身份:只读自己"),
("另一个客户(12001)", "12001", "客户身份:读的是**他自己**名下,不是 9001"),
("风控专员(9002 / risk_t)", "9002", "员工:有 memory:read:customer 且 9001 已分配给他"),
("投顾(9020 / advisor_t)", "9020", "员工:同上,看归属表里有没有"),
("运营(9006 / offsite_t)", "9006", "员工:**没有** memory:read:customer → 失败关闭"),
("管理员(9003 / admin_t)", "9003", "员工:有权限码,但要看归属表"),
)
def _reason(context: RequestContext, has_scope: bool) -> str:
"""把"为什么 0 条"讲成人话(判定本身仍由 `customer_memory_scope` 做,这里只解释)。"""
if not has_scope:
if REQUIRED_EMPLOYEE_PERMISSION not in context.permissions:
return (f"该身份缺少 {REQUIRED_EMPLOYEE_PERMISSION} 能力码 → **失败关闭**"
"(归属关系只解决'谁负责谁',不构成读他人客户数据的授权)")
return ("该身份在 `sys_customer_assignment` 里**没有生效的归属客户** → **失败关闭**"
"(这不是记忆坏了,是没授权;要读指定客户可先维护分配关系)")
return "可读范围内这些客户确实没有 active 的长期记忆(或都过期了)"
async def show(customer_id: int, only: str | None) -> None:
governance = PlatformGovernance(recall_factory=build_memory_recall_service)
identity = IdentityService()
for label, user_id, note in DEMO_IDENTITIES:
if only and only != user_id:
continue
context = await identity.resolve(
RequestContext(user_id=user_id, trace_id=f"memory-recall-demo-{user_id}")
)
items = await governance.recall(context)
scope = customer_memory_scope(context)
print("=" * 78)
print(f"{label} ({note})")
print(f" 角色={list(context.roles)} 数据范围={context.data_scope} "
f"归属客户={list(context.customer_ids)}")
print(f" memory:read:customer = "
f"{REQUIRED_EMPLOYEE_PERMISSION in context.permissions}")
print(f" 可读客户范围 = {list(scope) or '(空)'}")
if not items:
print(f" 召回:**0 条** —— 原因:{_reason(context, bool(scope))}")
continue
print(f" 召回:{len(items)} 条")
# `RecalledMemory` 就是**送进模型上下文**的那一份(只有 uuid/客户号/正文),
# 置信度与来源在更下层的 `RecallItem` 里,这里刻意不展开 —— 演示时讲清这一点即可。
for item in items:
print(f" · 客户 {item.customer_id}:{item.content[:60]} "
f"(uuid={item.memory_uuid[:8]}…)")
print("=" * 78)
print(f"提示:这里看的是**召回**(把记忆塞进模型上下文)。"
f"客户 {customer_id} 的记忆本体在 memory_unit 表里,用 "
f"`python tools/probe_memory_state.py {customer_id}` 看。")
def main() -> int:
parser = argparse.ArgumentParser(description="记忆召回按身份的演示(只读)")
parser.add_argument("--customer", type=int, default=9001, help="被观察的客户号(默认 9001)")
parser.add_argument("--identity", default=None, help="只看某一个身份(user_id)")
args = parser.parse_args()
asyncio.run(show(args.customer, args.identity))
return 0
if __name__ == "__main__":
raise SystemExit(main())