基线:姓名档案修复、疑问句防误写、搜索与传输测试

这是进入 Pipecat Flows 改造前的回滚点。
提交时已核对本地与线上代码 md5 一致(engine.py 7e97bad8、
bot_server.py 1cf543ae),所以这个提交就是当前线上状态。

内容(此前一直在线上运行但未提交的改动):
- 姓名档案:用户自报"我叫林"同步写 confirmed 姓名,重连后恢复
- 疑问句防误写:"谁吗"之类的疑问句不再被覆盖成姓名
- 新增 test_profile_memory.py(姓名档案专项回归)
- 新增 test_profile_live.py(需要真实 LLM 的现场测试)
- 搜索、身份、记忆测试的相应补充

背景:下一步要按 P1-Pipecat Flows Agent化与记忆改造交接.md
渐进引入 Pipecat Flows,交接文档明确要求先保护基线、不得
reset/checkout/覆盖现有改动。此提交即该基线。
This commit is contained in:
ylhan
2026-09-16 12:48:18 +08:00
parent 719fc13e48
commit 20cfaa6ef7
16 changed files with 408 additions and 11 deletions
+3
View File
@@ -36,6 +36,9 @@ def main() -> int:
d = p.switch("u_ylhan", reason="对话确认")
check("切换生效", d.user_id == "u_ylhan" and d.confirmed)
check("信任等级为 normal", d.trust_level == "normal")
named = p.switch("u_ylhan", display_name="林", reason="用户自报")
check("显示名与内部 ID 分离", named.display_name == "林")
check("同一身份空显示名不覆盖", p.switch("u_ylhan").display_name == "林")
check("版本号 +1", p.version == 1, str(p.version))
v = p.version
p.switch("u_ylhan") # 切到同一个人
+4
View File
@@ -138,6 +138,10 @@ async def run(store: SQLiteMemoryStore) -> None:
"SELECT display_name FROM users WHERE user_id=?", ("u_x",)).fetchone()["display_name"])
check("已存在则不改", name == "第一次", name)
await store.set_user_display_name("u_x", "林")
check("可显式更新用户称呼", await store.get_user_display_name("u_x") == "林")
check("不存在的用户称呼为空", await store.get_user_display_name("u_missing") == "")
print("\n=== 10. 兜底检索要限量(否则就成了每轮全量注入)===")
await store.ensure_user("u_big")
await store.remember(Memory(owner_user_id="u_big", kind=MemoryKind.FACT,
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""用生产记忆库和真实 LLM 验证当前用户称呼,不加载 STT/TTS。"""
from __future__ import annotations
import asyncio
import sys
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 ManualIdentityProvider # noqa: E402
from app.llm import FallbackProvider, create_provider # noqa: E402
from app.memory import SQLiteMemoryStore # noqa: E402
class SilentTTS:
sample_rate = 16000
def synthesize(self, _text):
return np.zeros(0, dtype=np.float32)
async def main() -> int:
cfg = AppConfig.load()
cfg.search.enabled = False
cfg.memory.auto_extract_enabled = False
memory = SQLiteMemoryStore(cfg.memory.db_path)
await memory.open()
llm = FallbackProvider(
create_provider(cfg.llm), create_provider(cfg.llm_fallback))
engine = ConversationEngine(
cfg, llm, memory, ManualIdentityProvider(), SilentTTS())
try:
await engine.start_session(user_id=cfg.default_user_id)
loaded_name = engine.identity.current().display_name
reply_parts = []
async for event in engine.respond_to_text("我是谁?请只说我的称呼。"):
if event.kind == EventKind.RESPONSE_DELTA:
reply_parts.append(event.text)
reply = "".join(reply_parts).strip()
if loaded_name != "林":
raise AssertionError(f"档案称呼未恢复:{loaded_name!r}")
if "林" not in reply or cfg.default_user_id in reply:
raise AssertionError(f"回答未正确使用称呼:{reply!r}")
print(f"恢复称呼: {loaded_name}")
print(f"真实回答: {reply}")
print("生产记忆 + 真实 LLM 验收:PASS")
return 0
finally:
await engine.end_session()
await memory.close()
await llm.aclose()
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""用户称呼跨会话持久化与内部 ID 隔离回归测试。"""
from __future__ import annotations
import asyncio
import shutil
import sys
import tempfile
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 ( # noqa: E402
ConversationEngine, extract_display_name, extract_memories,
)
from app.identity import ManualIdentityProvider # noqa: E402
from app.llm.base import Chunk, ChunkKind # noqa: E402
from app.memory import ConfirmStatus, SQLiteMemoryStore # noqa: E402
class FakeLLM:
name = "fake"
last_backend = "fake"
def __init__(self):
self.system_prompts = []
async def stream(self, messages, *, tools=None, max_tokens=None):
self.system_prompts.append(messages[0]["content"])
yield Chunk(kind=ChunkKind.CONTENT, text="好的。")
yield Chunk(kind=ChunkKind.DONE, finish_reason="stop")
class FakeTTS:
sample_rate = 16000
def synthesize(self, _text):
return np.ones(160, dtype=np.float32) * 0.1
async def consume(engine: ConversationEngine, text: str) -> None:
async for _event in engine.respond_to_text(text):
pass
async def test_cross_session_profile() -> None:
tmp = Path(tempfile.mkdtemp(prefix="profile_memory_"))
store = SQLiteMemoryStore(tmp / "memory.db")
await store.open()
try:
cfg = AppConfig()
cfg.search.enabled = False
cfg.memory.auto_extract_enabled = False
first_llm = FakeLLM()
first = ConversationEngine(
cfg, first_llm, store, ManualIdentityProvider(), FakeTTS())
await first.start_session("profile_s1", user_id="u_internal")
await consume(first, "你好,我叫林。")
await first.end_session()
assert await store.get_user_display_name("u_internal") == "林"
memories = await store.list_memories("u_internal")
names = [m for m in memories if m.key == "姓名"]
assert len(names) == 1
assert names[0].value == "林"
assert names[0].confirm_status == ConfirmStatus.CONFIRMED
# 模拟 disconnect 后重新创建整个 ConversationEngine/IdentityProvider。
second_llm = FakeLLM()
second = ConversationEngine(
cfg, second_llm, store, ManualIdentityProvider(), FakeTTS())
await second.start_session("profile_s2", user_id="u_internal")
assert second.identity.current().display_name == "林"
await consume(second, "我是谁?")
prompt = second_llm.system_prompts[-1]
assert "林" in prompt
assert "u_internal" not in prompt
assert "内部 user_id" in prompt
await consume(second, "知道我是谁吗?")
assert await store.get_user_display_name("u_internal") == "林"
await consume(second, "我叫什么?")
assert await store.get_user_display_name("u_internal") == "林"
await second.end_session()
finally:
await store.close()
shutil.rmtree(tmp, ignore_errors=True)
async def main() -> int:
assert extract_display_name("我叫林") == "林"
assert extract_display_name("你好我叫lin。") == "lin"
assert extract_display_name("我的名字是小林。") == "小林"
assert extract_display_name("你可以叫我老韩。") == "老韩"
assert extract_display_name("我是林。") == "林"
assert extract_display_name("我是程序员。") is None
assert extract_display_name("我叫什么?") is None
assert extract_display_name("我是谁?") is None
assert extract_display_name("知道我是谁吗?") is None
assert extract_display_name("我是林吗?") is None
assert extract_display_name("我叫什么名字?") is None
assert extract_memories("记住,我叫林。", "") == []
await test_cross_session_profile()
print("用户称呼跨会话回归测试:22/22 PASS")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+1
View File
@@ -62,6 +62,7 @@ class FakeSearch(SearXNGSearchClient):
class FakeMemory:
async def start_session(self, *_args): pass
async def end_session(self, *_args): pass
async def get_user_display_name(self, *_args): return ""
async def retrieve(self, *_args, **_kwargs): return []
async def add_turn(self, *_args): pass