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)
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user