Files
ai-companion/app/tests/test_flows_runtime.py
ylhan 98bbba93d0 Phase 4:补齐通用记忆闭环(验收 9/9 通过)
按 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 的事)
2026-09-16 14:54:42 +08:00

233 lines
8.9 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.
#!/usr/bin/env python3
"""Phase 3 Flows 运行时装配测试。
**不依赖网络、不启动 WebRTC**。验证的是装配正确性:
- 节点配置形态(只用 task_messages、不自动开口)
- 工具 schema 随身份变化
- handler 桥接到工具层,且身份由服务端注入
- 运行时开关默认是 legacy
真正跑起来的行为(RTVI 消息、TTS、延迟)由现场测试和真人网页验收覆盖。
用法:
~/venvs/ai_assistant/bin/python app/tests/test_flows_runtime.py
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(ROOT))
from app.agent import flows_runtime as FR # noqa: E402
from app.agent.tools import ToolContext # noqa: E402
from app.config import AgentConfig, AppConfig # noqa: E402
from app.identity import ManualIdentityProvider # noqa: E402
from app.memory.base import ConfirmStatus, Memory, MemoryKind # noqa: E402
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 ""))
class FakeStore:
"""最小实现,只够装配与 handler 调用。"""
def __init__(self):
self.rows: list[Memory] = []
self._n = 1
async def retrieve(self, owner_user_id, query, **kw):
return [r for r in self.rows if r.owner_user_id == owner_user_id][:5]
async def remember(self, mem: Memory) -> int:
m = Memory(**{**mem.__dict__, "memory_id": self._n})
self._n += 1
self.rows.append(m)
return m.memory_id
async def forget(self, owner_user_id, memory_id):
before = len(self.rows)
self.rows = [r for r in self.rows
if not (r.memory_id == memory_id
and r.owner_user_id == owner_user_id)]
return len(self.rows) < before
class FakeTTS:
sample_rate = 24000
def synthesize(self, text):
import numpy as np
return np.zeros(2400, dtype=np.float32)
class FakeRTVI:
def __init__(self): self.msgs = []
async def push_transport_message(self, m): self.msgs.append(type(m).__name__)
# ---------------------------------------------------------------- 配置开关
def test_runtime_switch():
print("\n=== 1. 运行时开关 ===")
check("默认是 legacy", AgentConfig(runtime="legacy").use_flows is False)
check("显式 flows 才启用", AgentConfig(runtime="flows").use_flows is True)
check("大小写不敏感", AgentConfig(runtime="FLOWS").use_flows is True)
check("未知值当 legacy", AgentConfig(runtime="xyz").use_flows is False)
saved = os.environ.pop("AI_AGENT_RUNTIME", None)
try:
from app.config import load_env
import importlib
import app.config as C
C._loaded = False # 重新读一次环境
check("未设环境变量时默认 legacy",
AppConfig.load().agent.runtime == "legacy")
finally:
if saved is not None:
os.environ["AI_AGENT_RUNTIME"] = saved
# ---------------------------------------------------------------- 节点形态
def test_node_shape():
print("\n=== 2. 节点配置形态 ===")
identity = ManualIdentityProvider()
# 称呼来自 IdentityDecision.display_name(不是构造参数)
identity.switch("u_a", display_name="林")
store = FakeStore()
bridge = FR.FlowsBridge(rtvi=FakeRTVI(), tts=FakeTTS(),
cfg=AppConfig.load())
def ctx_factory():
d = identity.current()
return ToolContext(owner_user_id=d.user_id, session_id="s1",
turn_id="t1", identity_confirmed=d.confirmed,
store=store)
schemas = [
{"type": "function", "function": {
"name": "memory_search", "description": "x",
"parameters": {"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]}}},
]
from app.memory.base import Memory as M, MemoryKind as MK, ConfirmStatus as CS
memories = [M(memory_id=1, owner_user_id="u_a", kind=MK.PREFERENCE,
key="饮食偏好", value="不吃香菜", confirm_status=CS.CONFIRMED)]
node = FR._conversation_node(identity_decision=identity.current(),
memories=memories, tool_schemas=schemas,
ctx_factory=ctx_factory)
check("节点名是 conversation", node["name"] == "conversation")
check("**不用 role_message**", "role_message" not in node,
"避免两条 system 消息")
check("用 task_messages 承载人格",
len(node["task_messages"]) == 1
and node["task_messages"][0]["role"] == "system")
sysmsg = node["task_messages"][0]["content"]
check("人格里含称呼", "林" in sysmsg, sysmsg[-40:])
check("**不含内部 user_id**(验收第 7 条)", "u_a" not in sysmsg)
check("稳定记忆已注入", "香菜" in node["task_messages"][0]["content"])
check("respond_immediately=False(不自动开口)",
node["respond_immediately"] is False)
check("工具已注册", len(node["functions"]) == 1)
# ---------------------------------------------------------------- handler 桥接
async def test_handler_bridge():
print("\n=== 3. handler 桥接到工具层 ===")
identity = ManualIdentityProvider("u_a")
store = FakeStore()
FR.set_last_user_text("记住我不吃香菜")
def ctx_factory():
d = identity.current()
return ToolContext(owner_user_id=d.user_id, session_id="s1",
turn_id="t1", identity_confirmed=d.confirmed,
user_text=FR._LAST_USER_TEXT[0], store=store)
handler = FR._make_handler("memory_remember", ctx_factory)
out = await handler({"kind": "preference", "key": "饮食偏好",
"value": "不吃香菜"})
check("handler 返回文本", isinstance(out, str) and "已记住" in out, out[:40])
check("写入的 owner 来自服务端",
store.rows and store.rows[0].owner_user_id == "u_a")
# 身份未确认时,handler 应拒绝(策略层兜底)
identity.reset()
out2 = await handler({"kind": "preference", "value": "x"})
# 策略层给模型的话是"现在还不能记录私人信息。请先确认身份。",
# 断言要匹配实际措辞(早先写死"身份未确认"导致误判成 FAIL)。
check("身份未确认时 handler 拒绝",
"不能记录" in out2 or "确认身份" in out2, out2[:40])
# 越权参数应被工具层拦下
identity.switch("u_a")
out3 = await handler({"kind": "fact", "value": "x", "owner_user_id": "u_b"})
check("越权参数被拦", "不合法" in out3, out3[:40])
# ---------------------------------------------------------------- 零件装配
async def test_build_parts():
print("\n=== 4. build_flows_parts 装配 ===")
cfg = AppConfig.load()
# 强制用 deepseek 配置(本地 env 可能没设 AI_LLM_PRIMARY)
from app.config import deepseek_config
cfg.llm = deepseek_config()
if not cfg.llm.api_key:
check("跳过(无 DeepSeek 密钥)", True)
return
llm, aggregator, bridge, ctx_factory, schemas = await FR.build_flows_parts(
cfg=cfg, memory=FakeStore(), identity=ManualIdentityProvider("u_a"),
session_id="s1", tts=FakeTTS(), rtvi=FakeRTVI())
check("返回 LLM service", llm is not None and type(llm).__name__ == "DeepSeekLLMService")
check("模型名被覆盖为 deepseek-flash",
llm._settings.model == cfg.llm.model, llm._settings.model)
check("thinking 关闭",
getattr(llm._settings.thinking, "type", None) == "disabled")
check("aggregator 有 user/assistant",
aggregator.user() is not None and aggregator.assistant() is not None)
check("工具 schema 随身份给出(含记忆工具)",
any(s["function"]["name"].startswith("memory_") for s in schemas),
str([s["function"]["name"] for s in schemas]))
check("Phase 3 不含 web_search",
not any(s["function"]["name"] == "web_search" for s in schemas),
"搜索仍走 legacy 路径")
# 身份未确认时不暴露记忆工具
_, _, _, _, schemas2 = await FR.build_flows_parts(
cfg=cfg, memory=FakeStore(), identity=ManualIdentityProvider(),
session_id="s1", tts=FakeTTS(), rtvi=FakeRTVI())
check("身份未确认时不给记忆工具",
not any(s["function"]["name"].startswith("memory_") for s in schemas2))
def main() -> int:
test_runtime_switch()
test_node_shape()
asyncio.run(test_handler_bridge())
asyncio.run(test_build_parts())
print("\n" + "=" * 62)
print(f"通过 {len(OK)} / {len(OK) + len(FAIL)}")
for n in FAIL:
print(f" 未通过: {n}")
return 0 if not FAIL else 1
if __name__ == "__main__":
sys.exit(main())