"""记忆系统一键演示(写数据!):客户说一句话 → 候选 → 两把钥匙 → 记忆与画像收敛。 **它做什么**(全程走真实 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())