Files
group_fqcd_jr/tools/memory_demo_chain.py
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +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())