按 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 的事)
233 lines
8.9 KiB
Python
233 lines
8.9 KiB
Python
#!/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())
|