Files
ai-companion/app/tests/test_engine_e2e.py
T
ylhan 35e107c290 助手本体:会话引擎、LLM 路由、记忆、身份、语音适配
从 Obsidian 笔记库剥离为独立仓库。规划文档与 P0 报告留在笔记库,
代码(助手本体、TTS 服务、P0 基准脚本、ESP32 固件)在此。

架构:服务器是助手,本地是客户端
- 助手本体跑在 cadadmin-server,N5095 只做 USB 外设桥与网络桥接
- 起因:实测 N5095 无 AVX2,跑不动可用的语音模型

当前技术栈
- LLM:DeepSeek deepseek-flash(云端,必须关思考)
- TTS:Qwen3-TTS 1.7B(服务器 3090,独立 HTTP 服务)
- STT:Zipformer 中英双语 int8(CPU,中文识别率未测)

实测:端到端 16/16 通过,首音延迟 1545ms
(对比:本地 LLM + vits-zh-ll 是 179ms,但音色不满意——音质与延迟
在此硬件上此消彼长)

设计要点
- MemoryStore 接口按"可能远程"设计(全 async、可超时、可失败)
- user_id / session_id / track_id 三者分开,人脸只作候选不自动切身份
- 记忆查询强制带 owner_user_id,无按 id 单独取的口子
- 自动抽取的记忆是"候选",默认不参与检索
- 异步写记忆绑定创建时的身份,不读"当前用户"
- 引擎与传输解耦,只用文件音频即可端到端测试

模型与音频产物不进版本管理(1.1GB),由 download_models.sh 恢复
2026-09-15 11:05:09 +08:00

247 lines
11 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
"""端到端测试:音频/文本 → STT → 身份 → 记忆 → LLM → TTS → 音频。
**全程不需要麦克风、音箱、摄像头、ESP32。**
这是"服务器即助手"架构的直接好处:链路可以在任何有 CPU 的地方验证,
硬件到货后再接最后一段(真实音频进出)即可。
跑完会把合成音频写到 out/ 目录,可以人工试听整条链路的实际效果。
运行:
cd <项目根>
~/venvs/ai_assistant/bin/python app/tests/test_engine_e2e.py
"""
from __future__ import annotations
import asyncio
import shutil
import sys
import tempfile
import time
import wave
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(ROOT))
from app.config import AppConfig # noqa: E402
from app.engine import ConversationEngine, EventKind # noqa: E402
from app.identity import GUEST_USER_ID, ManualIdentityProvider # noqa: E402
from app.llm import FallbackProvider, create_provider # noqa: E402
from app.memory import SQLiteMemoryStore # noqa: E402
from app.voice import StreamingSTT, create_tts # noqa: E402
OK, FAIL = [], []
OUT = ROOT / "out"
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 ""))
def save_wav(path: Path, audio: np.ndarray, sr: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
pcm = (np.clip(audio, -1, 1) * 32767).astype(np.int16)
with wave.open(str(path), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(pcm.tobytes())
async def collect(engine: ConversationEngine, text: str) -> tuple[str, np.ndarray, list]:
"""跑一轮,返回 (回复文本, 拼接音频, 事件列表)。"""
reply, audios, events = [], [], []
async for ev in engine.respond_to_text(text):
events.append(ev)
if ev.kind == EventKind.RESPONSE_DELTA:
reply.append(ev.text)
elif ev.kind == EventKind.SPEAK and ev.audio is not None:
audios.append(ev.audio)
audio = np.concatenate(audios) if audios else np.zeros(0, dtype=np.float32)
return "".join(reply).strip(), audio, events
async def main() -> int:
cfg = AppConfig.load()
tmp = Path(tempfile.mkdtemp(prefix="e2e_"))
OUT.mkdir(parents=True, exist_ok=True)
print("=== 0. 装配 ===")
llm = FallbackProvider(create_provider(cfg.llm),
create_provider(cfg.llm_fallback))
mem = SQLiteMemoryStore(tmp / "memory.db")
await mem.open()
tts = create_tts(cfg.voice)
stt = StreamingSTT(cfg.voice)
ident = ManualIdentityProvider()
engine = ConversationEngine(cfg, llm, mem, ident, tts, stt)
print(f" LLM={cfg.llm.model} 备用={cfg.llm_fallback.model}")
tts_desc = (f"{cfg.voice.tts_backend}/{cfg.voice.tts_speaker}"
if cfg.voice.tts_backend == "remote" else f"local/{cfg.voice.tts_threads}线程")
print(f" STT={cfg.voice.stt_threads}线程 TTS={tts_desc}")
check("四个组件装配成功", True)
ok, detail = await llm.health()
check("LLM 可用", ok, detail)
if not ok:
await mem.close()
shutil.rmtree(tmp, ignore_errors=True)
return 1
await engine.start_session("e2e_sess", user_id="u_ylhan")
# ---------------------------------------------------------- 1
print("\n=== 1. 纯文本一轮(LLM → 分句 → TTS)===")
reply, audio, events = await collect(engine, "现在几点了?随便说一句就行。")
print(f" 回复: {reply[:60]!r}")
m = [e for e in events if e.kind == EventKind.METRICS][0].metrics
print(f" {m.summary()}")
check("有回复", bool(reply))
check("有音频输出", len(audio) > 0, f"{len(audio)} 采样点")
check("回复不含 Markdown",
not any(c in reply for c in "*#`|[]"), reply[:40])
# 首音延迟这里只做"没离谱"的兜底检查,不设严格阈值:
# 本机 N5095 的 TTS RTF 是 1.86(实测),首音必然被 TTS 拖到秒级;
# 服务器上同一模型是 0.09,首音在 150ms 量级。
# 真正该卡指标的是部署机(服务器),所以那个断言放在服务器上的验收里。
check("首音延迟未失控", m.first_audio_ms < 8000, f"{m.first_audio_ms:.0f}ms")
save_wav(OUT / "e2e_1_text.wav", audio, tts.sample_rate)
# ---------------------------------------------------------- 2
print("\n=== 2. 多句回复按句播(每句独立音频)===")
_, _, events2 = await collect(
engine, "用三句话介绍一下你自己,每句短一点。")
speaks = [e for e in events2 if e.kind == EventKind.SPEAK]
print(f" 分 {len(speaks)} 段: {[e.text[:14] for e in speaks]}")
check("按句切成了多段", len(speaks) >= 2, f"{len(speaks)} 段")
audio2 = np.concatenate([e.audio for e in speaks]) if speaks else np.zeros(0, np.float32)
save_wav(OUT / "e2e_2_multisentence.wav", audio2, tts.sample_rate)
# ---------------------------------------------------------- 3
print("\n=== 3. 记忆:显式记住 → 下一轮被检索到 ===")
await collect(engine, "记住我喜欢喝美式咖啡,不加糖。")
await asyncio.sleep(0.5) # 等异步写入落库
stored = await mem.list_memories("u_ylhan", include_candidates=True)
print(f" 库中记忆 {len(stored)} 条: {[m.text[:20] for m in stored]}")
check("记忆已落库", len(stored) >= 1, f"{len(stored)} 条")
check("自动抽取的是候选状态",
all(m.confirm_status.value == "candidate" for m in stored))
# 手动确认一条,看它是否参与检索
await mem.remember(type(stored[0])(
owner_user_id="u_ylhan", kind=stored[0].kind, key="",
value="用户喜欢喝美式咖啡,不加糖",
confirm_status=__import__("app.memory", fromlist=["ConfirmStatus"]).ConfirmStatus.CONFIRMED))
_, _, events3 = await collect(engine, "我想喝点东西,推荐一下?")
used = [e for e in events3 if e.kind == EventKind.MEMORY_USED]
n_used = len(used[0].memories) if used else 0
print(f" 本轮用到记忆 {n_used} 条")
check("确认的记忆参与检索", n_used >= 1, f"{n_used} 条")
# ---------------------------------------------------------- 4
print("\n=== 4. 身份隔离:换人后拿不到上一个人的记忆 ===")
ident.switch("u_other", reason="测试切换")
_, _, events4 = await collect(engine, "我想喝点什么?")
used4 = [e for e in events4 if e.kind == EventKind.MEMORY_USED]
n4 = len(used4[0].memories) if used4 else 0
check("新身份没有旧记忆", n4 == 0, f"{n4} 条")
reply4 = "".join(e.text for e in events4 if e.kind == EventKind.RESPONSE_DELTA)
check("回复未泄露咖啡偏好", "美式" not in reply4 and "咖啡" not in reply4,
reply4[:50])
# ---------------------------------------------------------- 5
print("\n=== 5. 访客身份不做个性化 ===")
ident.reset()
print(f" 当前身份: {ident.current().user_id} (confirmed={ident.current().confirmed})")
_, _, events5 = await collect(engine, "我喜欢什么?")
used5 = [e for e in events5 if e.kind == EventKind.MEMORY_USED]
check("访客不检索私人记忆", len(used5) == 0 or not used5[0].memories)
reply5 = "".join(e.text for e in events5 if e.kind == EventKind.RESPONSE_DELTA)
check("访客回复不编造偏好", "美式" not in reply5, reply5[:50])
# ---------------------------------------------------------- 6
print("\n=== 6. 音频输入(真人录音 → STT → LLM → TTS)===")
# 测试音频不在代码目录里,和模型放在一起(都是可重新下载的产物)。
# 用模型目录反推,注意要 resolve()——服务器上模型目录是个软链,
# 不解析的话 .parent 会指到 ~/ai-companion 而不是真实位置。
wav_path = None
for cand in (
cfg.voice.model_dir.resolve().parent / "wavs" / "stt_1.wav",
ROOT / "p0" / "stt_tts_bench" / "wavs" / "stt_1.wav",
):
if cand.is_file():
wav_path = cand
break
if wav_path is not None:
with wave.open(str(wav_path)) as w:
samples = np.frombuffer(w.readframes(w.getnframes()),
dtype=np.int16).astype(np.float32) / 32768.0
ident.switch("u_ylhan", reason="回到主人")
t0 = time.perf_counter()
transcript, audios, events6 = [], [], []
async for ev in engine.respond_to_audio(samples):
events6.append(ev)
if ev.kind == EventKind.TRANSCRIPT:
transcript.append(ev.text)
elif ev.kind == EventKind.SPEAK and ev.audio is not None:
audios.append(ev.audio)
got = "".join(transcript)
m6 = [e for e in events6 if e.kind == EventKind.METRICS]
print(f" STT: {got[:50]!r}")
if m6 and m6[0].metrics:
print(f" {m6[0].metrics.summary()}")
check("音频链路跑通(能出转写)", bool(got.strip()))
if audios:
save_wav(OUT / "e2e_6_fromaudio.wav", np.concatenate(audios), tts.sample_rate)
else:
check("音频链路跑通", False, f"测试音频不存在: {wav_path}")
# ---------------------------------------------------------- 7
print("\n=== 7. 打断:取消正在进行的回复 ===")
cancelled = "未取消"
chunks_seen = 0
async def long_turn():
# 必须把整个流消费完才谈得上"取消"。
# 第一版写成 `async for _ in ...: return "已开始"`——
# 第一个事件就 return,生成器随之关闭,根本没有正在进行的回复可取消,
# 测试于是永远"失败",但失败原因跟取消能力毫无关系。
nonlocal chunks_seen
async for _ev in engine.respond_to_text("请详细讲一讲中国历史,越长越好。"):
chunks_seen += 1
return "已完成"
task = asyncio.create_task(long_turn())
await asyncio.sleep(0.7)
task.cancel()
try:
await task
except asyncio.CancelledError:
cancelled = "已取消"
check("长回复可被取消", cancelled == "已取消" and chunks_seen > 0,
f"{cancelled},取消前已收到 {chunks_seen} 个事件")
await engine.end_session()
await mem.close()
await llm.aclose()
shutil.rmtree(tmp, ignore_errors=True)
print(f"\n合成音频已写入 {OUT}/ ,可人工试听:")
for f in sorted(OUT.glob("e2e_*.wav")):
print(f" {f.name}")
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(asyncio.run(main()))