按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 4 完成。 新增 p1/phase4_acceptance.py:跑真实服务端验 7 条,每条之间查数据库。 实测结果(2026-09-16) 1. "记住我不吃香菜" → confirmed 落库 ✅ 2. **新连接** "我不吃什么?" → "你不吃香菜。" ✅ 连接开始时加载档案生效 3. "我今天吃了香菜" → 不新增 confirmed,模型还追问了是否口味变了 ✅ 4. "其实我现在吃香菜了" → 同键仍只一条 confirmed,值已更新 ✅ 5. "忘掉我的饮食偏好" → 已删除 ✅ 6/7. 访客与跨用户隔离 → 由单测覆盖(客户端不支持切身份) 实现:会话开始时加载稳定档案 - load_stable_memories():取已确认、未过期记忆(身份未确认返回空) - _conversation_node 改用 build_system_prompt,与 legacy 共用同一套 人格与记忆注入格式;记忆进**同一条** system 消息,仍不用 role_message 修掉一个真实缺陷:Flows 路径误用了语音的 max_tokens - 症状:一句"忘掉我的饮食偏好",服务端日志 "Failed to parse function call arguments: {"query": "饮食"(JSON 被截断), 回复为空、记忆没删掉,且**没有任何报错** - 根因:Flows 路径沿用了语音的 max_tokens=200(为"回复要短"而设)。 原始 API 对照实测:max_tokens=200 时模型 finish=stop、根本不调工具; 600 时正常产生 tool_calls 且参数 JSON 完整 - 修法:新增 AgentConfig.max_tokens(AI_AGENT_MAX_TOKENS,默认 600), Flows 路径用它,语音路径仍用 200 新增 client/test_client.py 能力(为验收所需) - RTVIReplies:收服务端 RTVI 文本消息(bot-output / bot-transcription) - 打印 BOT_REPLY 供验收脚本断言 回归:test_agent_tools 45/45、test_flows_runtime 24/24、 test_memory 30/30、test_identity 19/19 服务器已改回 AI_AGENT_RUNTIME=legacy(改默认是 Phase 7 的事)
163 lines
7.1 KiB
Python
163 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Phase 4 验收:通用记忆闭环。
|
|
|
|
对应交接文档 §九 Phase 4 的 7 条,**跑真实服务端**(flows 文字路径)。
|
|
每条之间查一次服务器数据库,确认"说进数据库"和"以后能用"是同一件事。
|
|
|
|
1. "记住我不吃香菜" → confirmed
|
|
2. 新连接 "我不吃什么?" → 能召回香菜(验证连接开始时加载档案)
|
|
3. "我今天吃了香菜" → 不得变成稳定偏好
|
|
4. "其实我现在吃香菜了" → 明确更新旧偏好,不留冲突值
|
|
5. "忘掉我的饮食偏好" → 删除;模糊时先确认
|
|
6. 访客无法读写私人记忆 → 单测覆盖(需要身份切换,客户端不支持)
|
|
7. 用户 A 的记忆不出现在 B → 单测覆盖 + prompt 不含内部 ID
|
|
|
|
用法:
|
|
~/venvs/ai_assistant/bin/python p1/phase4_acceptance.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
ROOT = HERE.parent
|
|
SERVER = os.environ.get(
|
|
"AI_SERVER", "http://cadadmin-server.taild0f91d.ts.net:7860")
|
|
SSH = os.environ.get("AI_SSH", "yonglin@cadadmin-server.taild0f91d.ts.net")
|
|
PY = str(Path.home() / "venvs" / "ai_assistant" / "bin" / "python")
|
|
|
|
OK, FAIL = [], []
|
|
|
|
|
|
def check(name: str, cond: bool, detail: str = "") -> None:
|
|
(OK if cond else FAIL).append(name)
|
|
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
|
|
|
|
|
|
def say(text: str, *, timeout: int = 180) -> str:
|
|
"""跑一轮文字对话,返回助手回复全文(从 RTVI bot-output 取)。"""
|
|
r = subprocess.run(
|
|
[PY, str(ROOT / "client" / "test_client.py"),
|
|
"--server", SERVER, "--text", text, "--out", "/tmp/p4_turn.wav"],
|
|
capture_output=True, text=True, timeout=timeout)
|
|
for line in r.stdout.splitlines():
|
|
if line.strip().startswith("BOT_REPLY:"):
|
|
return line.split("BOT_REPLY:", 1)[1].strip()
|
|
return ""
|
|
|
|
|
|
def db_memories() -> list[dict]:
|
|
"""查服务器的记忆表。"""
|
|
code = (
|
|
"import sqlite3,pathlib,json;"
|
|
"db=pathlib.Path.home()/'.local/share/ai_assistant/memory.db';"
|
|
"c=sqlite3.connect(db);c.row_factory=sqlite3.Row;"
|
|
"rows=c.execute('SELECT memory_id,owner_user_id,kind,mem_key,mem_value,"
|
|
"confirm_status FROM memories ORDER BY memory_id').fetchall();"
|
|
"print(json.dumps([dict(r) for r in rows],ensure_ascii=False))"
|
|
)
|
|
r = subprocess.run(["ssh", "-o", "BatchMode=yes", SSH,
|
|
f"~/ai-companion/.venv/bin/python -c \"{code}\""],
|
|
capture_output=True, text=True, timeout=60)
|
|
for line in r.stdout.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("["):
|
|
try:
|
|
return json.loads(line)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return []
|
|
|
|
|
|
def find(rows, key_contains: str) -> list[dict]:
|
|
return [m for m in rows if key_contains in (m.get("mem_key") or "")]
|
|
|
|
|
|
def main() -> int:
|
|
print("=" * 64)
|
|
print("Phase 4 验收:通用记忆闭环(跑真实服务端)")
|
|
print(f" 服务端 {SERVER}")
|
|
print("=" * 64)
|
|
|
|
if os.environ.get("AI_SKIP_CLEAN") != "1":
|
|
print("\n=== 0. 前置:清掉待测键,保证从干净状态开始 ===")
|
|
say("忘掉我的饮食偏好")
|
|
rows = db_memories()
|
|
print(f" 当前记忆 {len(rows)} 条: "
|
|
f"{[(m['mem_key'], m['mem_value']) for m in rows]}")
|
|
|
|
# ---------------------------------------------------------- 1
|
|
print("\n=== 1. 明确要求 → confirmed ===")
|
|
reply = say("记住我不吃香菜")
|
|
rows = db_memories()
|
|
hits = [m for m in find(rows, "饮食") if m["confirm_status"] == "confirmed"]
|
|
print(f" 回复: {reply[:50]!r}")
|
|
check("确认的记忆已落库", bool(hits),
|
|
str([(m["mem_key"], m["mem_value"], m["confirm_status"]) for m in hits]))
|
|
if hits:
|
|
check("值是香菜相关", "香菜" in hits[0]["mem_value"], hits[0]["mem_value"])
|
|
|
|
# ---------------------------------------------------------- 2
|
|
print("\n=== 2. **新连接**后能召回(验证连接开始时加载档案)===")
|
|
print(" (上一轮连接已关闭,这是全新会话)")
|
|
reply = say("我不吃什么?")
|
|
print(f" 回复: {reply[:80]!r}")
|
|
check("回复提到香菜(说明记忆被加载进上下文)", "香菜" in reply, reply[:60])
|
|
check("没有把内部 ID 说出去", "u_ylhan" not in reply and "u_a" not in reply)
|
|
|
|
# ---------------------------------------------------------- 3
|
|
print("\n=== 3. 一次性事件不得变成稳定偏好 ===")
|
|
before = len([m for m in find(db_memories(), "饮食")
|
|
if m["confirm_status"] == "confirmed"])
|
|
reply = say("我今天吃了香菜")
|
|
rows = db_memories()
|
|
after = len([m for m in find(rows, "饮食") if m["confirm_status"] == "confirmed"])
|
|
print(f" 回复: {reply[:50]!r}")
|
|
check("没有新增 confirmed 饮食偏好", after <= before, f"{before} → {after}")
|
|
|
|
# ---------------------------------------------------------- 4
|
|
print("\n=== 4. 明确更正 → 更新旧值,不留冲突 ===")
|
|
reply = say("其实我现在吃香菜了,帮我改一下")
|
|
rows = db_memories()
|
|
hits = [m for m in find(rows, "饮食") if m["confirm_status"] == "confirmed"]
|
|
print(f" 回复: {reply[:50]!r}")
|
|
print(f" 库中饮食偏好: {[(m['mem_value'], m['confirm_status']) for m in hits]}")
|
|
check("同键只保留一条 confirmed", len(hits) <= 1, f"{len(hits)} 条")
|
|
check("值已更新为吃香菜", any("吃" in m["mem_value"] and "不吃" not in m["mem_value"]
|
|
for m in hits),
|
|
str([m["mem_value"] for m in hits]))
|
|
|
|
# ---------------------------------------------------------- 5
|
|
print("\n=== 5. 明确遗忘 → 删除 ===")
|
|
reply = say("忘掉我的饮食偏好")
|
|
rows = db_memories()
|
|
left = find(rows, "饮食")
|
|
print(f" 回复: {reply[:60]!r}")
|
|
check("饮食偏好已删除", not left, str([m["mem_value"] for m in left]))
|
|
|
|
# ---------------------------------------------------------- 6/7
|
|
print("\n=== 6/7. 访客隔离与跨用户隔离 ===")
|
|
print(" 这两条需要身份切换,WebRTC 客户端不支持;由单元测试覆盖:")
|
|
print(" app/tests/test_agent_tools.py → '身份未确认时不暴露记忆工具'")
|
|
print(" → '未确认身份拒绝 memory_search'")
|
|
print(" → '用户 B 查不到用户 A 的记忆'")
|
|
print(" → '跨 owner 删除失败'")
|
|
print(" app/tests/test_flows_runtime.py → '**不含内部 user_id**'")
|
|
check("由单测覆盖(见上)", True)
|
|
|
|
print("\n" + "=" * 64)
|
|
ok = len(OK)
|
|
print(f"通过 {ok} / {ok + len(FAIL)}")
|
|
for n in FAIL:
|
|
print(f" 未通过: {n}")
|
|
return 0 if not FAIL else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|