Files
group_fqcd_jr/tools/memory_demo_chain.py
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

259 lines
10 KiB
Python
Raw Permalink 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.
"""记忆系统一键演示(写数据!):客户说一句话 → 候选 → 两把钥匙 → 记忆与画像收敛。
**它做什么**(全程走真实 HTTP,不绕过权限与幂等):
1. 打印"演示前"的记忆与画像快照;
2. 用客户账号在客服会话里说一句带**记忆信号**的话(默认:投资期限);
3. 等 Worker 把它抽成**画像候选**(`memory_unit.status='candidate'`);
4. 客户确认(第一把钥匙,`memory:candidate:confirm`);
5. 管理员批准(第二把钥匙,`memory:candidate:review` + admin);
6. 等 Worker 自动收敛(`profile.rebuild_requested`),打印"演示后"快照。
**前置**:API 与 **Worker 都要在跑**(`python -m app.worker`),否则第 3/6 步永远停在
`queued`。演示会**真的写入数据**(这正是它的意义),要用 `--customer` 换一个客户来隔离。
用法::
python tools/memory_demo_chain.py # 客户 9001,默认那句话
python tools/memory_demo_chain.py --customer 10001
python tools/memory_demo_chain.py --message "我的投资期限是十年以上"
"""
from __future__ import annotations
import argparse
import asyncio
import sys
import time
import uuid
from typing import Any
import httpx
from sqlalchemy import text
from app.infrastructure.db import SessionFactory
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace")
BASE = "http://127.0.0.1:8000"
DEFAULT_MESSAGE = "我的投资期限是五年以上,长期持有,不着急用钱。"
#: 演示账号(与演示数据一致;客户号 9001 对应 cust_t)。
CUSTOMER_ACCOUNT = ("cust_t", "123456")
ADMIN_ACCOUNT = ("admin_t", "88888888")
def _hdr(token: str, *, idempotent: bool = False) -> dict[str, str]:
headers = {"Authorization": f"Bearer {token}"}
if idempotent:
headers["Idempotency-Key"] = uuid.uuid4().hex
return headers
def _login(client: httpx.Client, account: tuple[str, str]) -> tuple[str, int]:
"""返回 `(令牌, 用户号)` —— 用户号从登录响应里取(`data.user_id`),别写死。"""
response = client.post(
"/api/v1/auth/tokens", json={"username": account[0], "password": account[1]}
)
if response.status_code != 200:
raise SystemExit(f"登录 {account[0]} 失败:HTTP {response.status_code} {response.text[:200]}")
data = response.json()["data"]
return str(data["access_token"]), int(data["user_id"])
async def _snapshot(customer_id: int) -> dict[str, Any]:
async with SessionFactory() as session:
memories = [
tuple(row)
for row in (
await session.execute(
text(
"SELECT memory_key, content, status, version FROM memory_unit "
"WHERE customer_id = :cid AND status = 'active' ORDER BY memory_key"
),
{"cid": customer_id},
)
).all()
]
profile = [
tuple(row)
for row in (
await session.execute(
text(
"SELECT investor_type, investment_horizon, LEFT(risk_tags, 90) "
"FROM fin_customer_profile WHERE customer_id = :cid"
),
{"cid": customer_id},
)
).all()
]
facts = [
tuple(row)
for row in (
await session.execute(
text(
"SELECT fact_key, fact_value FROM user_facts "
"WHERE customer_id = :cid ORDER BY fact_key"
),
{"cid": customer_id},
)
).all()
]
count, top = (
await session.execute(
text(
"SELECT COUNT(*), COALESCE(MAX(version), 0) FROM profile_snapshots "
"WHERE customer_id = :cid"
),
{"cid": customer_id},
)
).all()[0]
pending = (
await session.execute(
text(
"SELECT COUNT(*) FROM domain_event_outbox "
"WHERE event_type = 'profile.rebuild_requested' AND status = 'pending'"
)
)
).all()[0][0]
return {
"memories": memories,
"profile": profile,
"facts": facts,
"snapshots": f"共 {count} 版,最高 v{top}",
"pending_rebuild": pending,
}
def _dump(title: str, data: dict[str, Any]) -> None:
print(f"\n--- {title} ---")
memories = data["memories"]
print(" active 长期记忆:", memories or "(无)")
print(" 画像字段 :", data["profile"] or "(无画像行)")
print(" user_facts :", data["facts"] or "(无)")
print(" 画像快照 :", data["snapshots"])
print(" 待消费的重建事件:", data["pending_rebuild"])
def _wait_run(client: httpx.Client, token: str, run_id: str) -> str:
for _ in range(40):
time.sleep(1.5)
payload = client.get(f"/api/v1/agent-runs/{run_id}", headers=_hdr(token)).json()["data"]
if payload.get("status") in ("succeeded", "failed", "cancelled"):
return str(payload["status"])
return "timeout"
def run(message: str) -> int:
with httpx.Client(base_url=BASE, timeout=60) as client:
# 客户号从**登录响应**里取,不写死:否则"看的是 A、写的是 B",演示会前后矛盾。
customer, customer_id = _login(client, CUSTOMER_ACCOUNT)
admin, _admin_id = _login(client, ADMIN_ACCOUNT)
existing_ids = {
row["candidate_id"]
for row in (
client.get(
"/api/v1/users/me/memory-candidates", headers=_hdr(customer)
).json().get("data")
or []
)
}
before = asyncio.run(_snapshot(customer_id))
_dump(f"演示前(客户 {customer_id})", before)
print(f"\n【1】客户在客服会话里说:{message}")
conversation = client.post(
"/api/v1/conversations", headers=_hdr(customer, idempotent=True),
json={"agent_type": "customer_service"},
).json()["data"]
run_response = client.post(
"/api/v1/agent-runs", headers=_hdr(customer, idempotent=True),
json={
"agent_type": "customer_service",
"session_id": conversation["session_id"],
"message": message,
"idempotency_key": uuid.uuid4().hex,
},
)
run_id = run_response.json()["data"]["run_id"]
print(f" 受理 202,run_id={run_id}(等 Worker 处理…)")
status = _wait_run(client, customer, run_id)
print(f" 运行终态:{status}")
if status != "succeeded":
print(" ⚠️ 运行没有成功,后续演示会断在这里(先确认 Worker 在跑)")
return 1
print("\n【2】等它被抽成画像候选(memory_unit.status='candidate')")
candidate = None
for _ in range(20):
rows = client.get(
"/api/v1/users/me/memory-candidates", headers=_hdr(customer)
).json().get("data") or []
# 只认**这次新产生**的候选:库里可能躺着以前演示留下的 candidate,
# 按 id 差集挑,避免"批准了一条旧候选"这种假演示。
fresh = [
row for row in rows
if row.get("status") == "candidate" and row["candidate_id"] not in existing_ids
]
if fresh:
candidate = fresh[0]
break
time.sleep(1.5)
if candidate is None:
print(" ⚠️ 没有产生候选:这句话没命中记忆信号词表(换一句试试)")
return 1
print(f" 候选 #{candidate['candidate_id']}:{candidate['memory_key']} = "
f"{candidate['value']}(置信度 {candidate['confidence']})")
print("\n【3】第一把钥匙:客户本人确认")
decided = client.post(
f"/api/v1/users/me/memory-candidates/{candidate['candidate_id']}/decisions",
headers=_hdr(customer, idempotent=True), json={"decision": "confirmed"},
).json()["data"]
print(f" 状态:{decided['status']}(candidate → verified)")
print("\n【4】第二把钥匙:管理员批准(这一步才进正式记忆)")
review = client.post(
f"/api/v1/admin/customer-profile-candidates/{candidate['candidate_id']}/reviews",
headers=_hdr(admin, idempotent=True),
json={"decision": "approved", "comment": "演示:客户已确认"},
)
if review.status_code != 200:
print(f" ⚠️ 批准失败 HTTP {review.status_code}:{review.text[:200]}")
return 1
print(f" 状态:{review.json()['data']['status']}(verified → active)")
print("\n【5】等 Worker 自动收敛(记忆 → 事实 → 画像字段 → 投影)")
converged = False
for attempt in range(15):
time.sleep(2)
current = asyncio.run(_snapshot(customer_id))
# 比**内容**而不是条数:同一个记忆键的值变了(约三年 → 十年以上)条数不变,
# 只比条数会误判成"没收敛"(踩过)。
if current["facts"] != before["facts"]:
print(f" 第 {attempt + 1} 次轮询({(attempt + 1) * 2}s):user_facts 已更新")
converged = True
break
if not converged:
print(" ⚠️ 没等到收敛:确认 Worker 在跑,或看 memory_sync_outbox.status")
_dump(f"演示后(客户 {customer_id})", asyncio.run(_snapshot(customer_id)))
print(
"\n提示:想核对投影落没落库,跑 "
f"`python tools/probe_memory_state.py {customer_id}`;"
"想演「谁能读、谁读不到」跑 `python tools/memory_recall_demo.py`。"
)
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="记忆系统一键演示(会写数据)")
parser.add_argument("--message", default=DEFAULT_MESSAGE, help="客户说的那句话")
args = parser.parse_args()
return run(args.message)
if __name__ == "__main__":
raise SystemExit(main())