Files
ai-companion/app/tests/test_flows_runtime.py
T
ylhan 4f621dc95b Phase 3:Flows 接管网页文字路径(默认仍 legacy)
按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 3 完成。
**语音路径保持 legacy**,文字路径可由 AI_AGENT_RUNTIME=flows 切换。
服务器实测通过工具、姓名、记忆三项验收,随后按文档要求改回 legacy。

新增 app/agent/flows_runtime.py
- FlowsBridge:LLM 文本帧 → RTVI 消息 + 分句 TTS,产出与 legacy 的
  _run_turn 一致(bot-llm-started → 逐 token bot-llm-text → stopped
  → bot-transcription / bot-output)
- UserTextRecorder:截下用户原话供策略层判定语义等级
- build_flows_parts / make_flow_manager:拆成两段,解决
  "FlowManager 要 worker、worker 要等管线" 的先后依赖
- 节点只用 task_messages,**不用 role_message**(避免两条 system 消息)
- respond_immediately=False(不自动开口)
- handler 用闭包捕获 ctx_factory,不用模块级全局(并发会话会串身份)

新增 app/agent/llm_service.py
- 固化 Phase 1 spike 的选型:复用 DeepSeekLLMService,显式覆盖模型名
  (Pipecat 默认的 deepseek-v4-flash 不存在)

配置 app/config.py
- 新增 AgentConfig + AI_AGENT_RUNTIME,**默认 legacy**

改造 services/bot_server.py
- EngineProcessor 加 owns_text:flows 模式下语音仍归它,文字帧往下传
- runtime_deps() 暴露 memory/identity/session/tts,两条路径共用同一份资源
  (避免各建一份 SQLite 连接或各加载一次 TTS)
- **_run_task 改用 PipelineRunner**:直接 task.run() 时 runner 没挂上,
  Flows 的工具执行会失败("runner is not set; call attach() first"),
  异常被转成给 LLM 的通用错误串,默认日志看不到。这正是 Phase 1 spike
  记录的坑 2,集成时又踩了一次。

新增 app/tests/test_flows_runtime.py(22 项,不依赖网络)

新增 deploy.sh
- **不用 --delete**、一次只同步一个目录、显式排除清单。
  起因见下。

客户端 client/test_client.py 加 --text 模式(RTVI send-text)
- 信封字段实测校正两次:**是 id 不是 msg_id**,**data.content 不是 text**;
  写错会被服务端以 "Invalid RTVI transport message" 拒掉且客户端无感知
- 等待条件改为"等到非静音音频",不能数音频块——WebRTC 空闲时也在推静音

服务器实测(2026-09-16)
- legacy 文字:11.2s 音频 / 25 个有声段
- flows 文字:9.3s 音频 / 18 个有声段,首音约 2.8s
- flows 记忆闭环:只说"记住我不吃香菜" → 工具写入 confirmed 记忆
  → 数据库确认 #4 [confirmed] preference 饮食偏好=不吃香菜
- 称呼"林"从档案恢复

已知问题(留给 Phase 4/7)
- Pipecat 报 "Both system_instruction and an initial system message in
  context are set" 警告。来源未查明(我只用了 task_messages)。
  切回本地 vLLM 时可能 400,因为该后端拒绝多条 system 消息。
- Flows 路径暂不暴露 web_search(交接文档要求分阶段,搜索仍走 legacy)
2026-09-16 14:13:32 +08:00

224 lines
8.3 KiB
Python
Raw 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("u_a")
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"]}}},
]
node = FR._conversation_node(display_name="林", 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")
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())