Files
group_fqcd_jr/tools/memory_recall_demo.py
T
lzf_0626 9490e5043b 记忆系统演示文档 + 修掉两处会让演示断链的真问题
## 新增: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`:配套文档清单与"记忆链路"备选场景都指向新演示文档。
2026-09-15 00:55:44 +08:00

103 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""记忆召回演示(只读):**同一个客户,换不同身份去读,看谁能读到、谁读不到。**
## 为什么需要它
"记忆系统的权限"这套东西光讲没用 —— 演示时最好当场看到:
用**客户本人**的身份能读到自己,用**别的客户**身份读到的是空的,
用**有权限且有归属**的风控身份能读到,用**没权限**的运营身份读不到(而且日志会点名原因)。
这个脚本就是干这个的:按平台**真实**的身份解析(`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())