#!/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())