From 20cfaa6ef7df3f48c34a659a8c45b9637d85659e Mon Sep 17 00:00:00 2001 From: ylhan Date: Wed, 16 Sep 2026 12:48:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E7=BA=BF=EF=BC=9A=E5=A7=93=E5=90=8D?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=E4=BF=AE=E5=A4=8D=E3=80=81=E7=96=91=E9=97=AE?= =?UTF-8?q?=E5=8F=A5=E9=98=B2=E8=AF=AF=E5=86=99=E3=80=81=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E4=B8=8E=E4=BC=A0=E8=BE=93=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这是进入 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/覆盖现有改动。此提交即该基线。 --- .gitignore | 3 + README.md | 34 +++++++-- app/config.py | 3 + app/engine.py | 106 +++++++++++++++++++++++++++- app/identity/base.py | 2 + app/identity/manual.py | 6 ++ app/memory/base.py | 8 +++ app/memory/sqlite_store.py | 19 ++++++ app/prompts.py | 12 +++- app/tests/test_identity.py | 3 + app/tests/test_memory.py | 4 ++ app/tests/test_profile_live.py | 62 +++++++++++++++++ app/tests/test_profile_memory.py | 114 +++++++++++++++++++++++++++++++ app/tests/test_search.py | 1 + services/bot_server.py | 7 +- 部署说明.md | 35 +++++++++- 16 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 app/tests/test_profile_live.py create mode 100644 app/tests/test_profile_memory.py diff --git a/.gitignore b/.gitignore index 11c1823..77a7400 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ deploy/searxng/settings.yml .idea/ *.swp .DS_Store + +# 工作前保存的基线备份(diff 与未跟踪文件) +.baseline/ diff --git a/README.md b/README.md index 12f2cf1..a5dccf5 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,12 @@ p0/ P0 阶段的验证与基准脚本(保留用于复现 # 服务器上(完整) cd ~/ai-companion .venv/bin/python app/tests/test_llm.py # 27 项 -.venv/bin/python app/tests/test_memory.py # 28 项 -.venv/bin/python app/tests/test_identity.py # 17 项 +.venv/bin/python app/tests/test_memory.py # 30 项 +.venv/bin/python app/tests/test_identity.py # 19 项 .venv/bin/python app/tests/test_engine_e2e.py # 16 项,端到端 .venv/bin/python app/tests/test_transport.py # 4 项,媒体时钟/VAD/静音/RTVI文本 .venv/bin/python app/tests/test_search.py # 12 项,搜索 API/工具回灌/TTS 链路过滤 +.venv/bin/python app/tests/test_profile_memory.py # 22 项,自报姓名/断开重连/疑问句防误写 ``` 端到端测试**不需要麦克风、音箱、摄像头、ESP32**—— @@ -85,11 +86,17 @@ DeepSeek 负责判断何时需要实时资料,`app/search.py` 提供 `web_sear `track_id`(画面里的一个目标)互不冒充。人脸认出来**不等于**确认对方在说话—— 所以人脸只产生"建议",不自动切换身份。做错的后果是路人经过就把你的私人记忆念出来。 +**内部 ID 与用户称呼分开。** `u_ylhan` 只用于数据库隔离,绝不是姓名。用户明确说 +“我叫林”时,会同步写入 `users.display_name` 和一条 confirmed 姓名记忆;新连接创建 +引擎时从档案恢复称呼。自报姓名只用于个性化,不提升身份信任等级。 + **记忆查询强制带 `owner_user_id`。** 没有"按 memory_id 单独取"的口子: 猜到 id 就能读别人的记忆,是这类系统最容易犯的错。删除同理,必须同时匹配 owner。 -**自动抽取的记忆是"候选",默认不参与检索。** 只有用户显式"记住"或工具调用 -写入的才是"已确认"。对应方案 §8 的"模型推测不当作确认事实"。 +**自动抽取的记忆是"候选",默认不参与检索。** 当前只有自报姓名走同步 confirmed +档案路径;“记住/我喜欢/我习惯”等通用规则仍统一写成 candidate,而且尚无候选确认/ +晋升入口。因此除姓名外,内容可能已经落库却不会参与后续回答。这是下一阶段必须补齐 +的功能,不应把“数据库里有记录”误报为“助手已经会使用这条记忆”。 **异步任务绑定创建时的身份。** 回复后写记忆的任务在创建时捕获 `user_id`, 不读"当前用户"——否则用户在这几百毫秒内切了身份,这条记忆会写到别人名下。 @@ -122,6 +129,25 @@ Pipecat 管传输、帧与 RTVI,自有 `ConversationEngine` 管会话语义。 完整记录见代码仓库外的 `P0-Pipecat传输层实验报告.md`(在 Obsidian 笔记库里)。 +### 当前打断边界(2026-09-16) + +当前 `SimpleVAD` 是实验性的 RMS 能量判定:阈值 350、最短语音 300ms、尾静音 +700ms。它能切分话段和触发下一轮,但不是完整的 barge-in。助手推送音频时检测到 +用户声音目前只记日志;只有等话段结束,或收到外部 `InterruptionFrame`,才会取消 +当前异步轮次。浏览器播放器虽有清空能力,服务端尚未把“用户开始说话”可靠地转换为 +取消生成、清空服务端输出和清空客户端缓冲三个动作。现有测试只证明任务可取消, +未证明声学打断端到端闭环。 + +### 下一阶段 Agent 方向:Pipecat Flows + +已决定不整体迁移 OpenClaw、Agno、LangGraph 或 LiveKit Agents,而是在现有 Pipecat +实时链路上渐进引入 `pipecat-ai-flows`。目标是把 `web_search`、`remember`、 +`search_memory`、`forget` 等能力变成受策略约束的工具,同时保留现有 +`MemoryStore`、身份隔离和低延迟语音链路。Flows 与当前自有 `ConversationEngine` +的接口并不直接兼容,必须先做独立兼容性实验,再通过 feature flag 灰度迁移;不得 +直接重写已部署链路。实施交接见笔记库 +`P1-Pipecat Flows Agent化与记忆改造交接.md`。 + ### 实验挖出的 10 个坑 每个都表现为"什么都没发生",极难排查: diff --git a/app/config.py b/app/config.py index f99fefa..6336c71 100644 --- a/app/config.py +++ b/app/config.py @@ -214,6 +214,9 @@ class MemoryConfig: @dataclass class AppConfig: + # 单用户原型的稳定内部档案 ID。它只用于数据隔离,绝不是用户可见姓名。 + default_user_id: str = field( + default_factory=lambda: _env("AI_DEFAULT_USER_ID", "u_ylhan")) #: 主 LLM 由 AI_LLM_PRIMARY 选择: #: "local" = 本机 vLLM(首 token 192ms,但占 21.5GB 显存) #: "deepseek" = 云端(首 token 786ms,网络往返约 700ms) diff --git a/app/engine.py b/app/engine.py index 8296fc8..db78b7b 100644 --- a/app/engine.py +++ b/app/engine.py @@ -58,6 +58,29 @@ _SOURCE_SECTION_RE = re.compile( re.IGNORECASE, ) +_DISPLAY_NAME_PATTERNS = ( + re.compile( + r"(?:我叫|我的(?:名字|姓名)(?:是|叫)|(?:你可以)?叫我)\s*" + r"[“\"']?([^,,。!?!?;;::\n\"”']{1,24})[”\"']?" + r"(?=$|[,,。!?!?;;::\n])", + re.IGNORECASE, + ), + # 兼容语音识别常见的“我是林”。只接受 1–4 个中文姓名字符, + # 避免把“我是程序员/中国人”一类身份描述当作姓名。 + re.compile( + r"我是\s*([\u4e00-\u9fff·]{1,4})" + r"(?=$|[,,。!?!?;;::\n])" + ), +) + +_NOT_NAMES = { + "程序员", "工程师", "中国人", "学生", "老师", "医生", "男生", "女生", + "用户", "访客", "谁", "什么", "哪位", "你", "他", "她", "它", "你的", "他的", "她的", +} + +_NAME_QUESTION_WORDS = ("谁", "什么", "哪位") +_NAME_QUESTION_PARTICLES = ("吗", "么", "呢") + class EventKind(str, Enum): IDENTITY = "identity" # 本轮的身份裁决结果 @@ -143,6 +166,18 @@ class ConversationEngine: self.identity.switch(user_id, reason="会话指定") d = self.identity.current() await self.memory.start_session(sid, d.user_id) + if d.confirmed: + try: + display_name = await self.memory.get_user_display_name(d.user_id) + if display_name: + self.identity.switch( + d.user_id, + display_name=display_name, + reason="从用户档案恢复称呼", + ) + d = self.identity.current() + except Exception as exc: # noqa: BLE001 — 档案读取失败不阻塞会话 + log.warning("读取用户称呼失败(本轮继续):%s", exc) self._session_id = sid self._history_identity_version = self._identity_version() log.info("会话开始 %s,身份 %s", sid, d.user_id) @@ -180,6 +215,10 @@ class ConversationEngine: self._sync_identity_scope() identity = self.identity.current() + display_name = extract_display_name(user_text) + if display_name and identity.confirmed: + await self._remember_display_name(identity, display_name, sid) + identity = self.identity.current() metrics.identity_ms = (time.perf_counter() - t_start) * 1000 yield Event(kind=EventKind.IDENTITY, identity=identity) @@ -306,6 +345,43 @@ class ConversationEngine: log.warning("记忆检索失败(%s),本轮不带记忆继续", e) return [] + async def _remember_display_name( + self, + identity: IdentityDecision, + display_name: str, + session_id: str, + ) -> None: + """同步保存用户明确自报的称呼,保证断开连接前已经落盘。""" + turn_id = f"t_{uuid.uuid4().hex[:10]}" + + async def write_profile() -> None: + await self.memory.set_user_display_name(identity.user_id, display_name) + await self.memory.remember(Memory( + owner_user_id=identity.user_id, + kind=MemoryKind.FACT, + key="姓名", + value=display_name, + confirm_status=ConfirmStatus.CONFIRMED, + source_session_id=session_id, + source_turn_id=turn_id, + )) + + try: + await asyncio.wait_for( + write_profile(), + timeout=max(1.0, self.cfg.context.memory_timeout_s), + ) + log.info("已确认并保存用户称呼:%s", display_name) + except Exception as exc: # noqa: BLE001 — 记忆失败仍允许本轮继续 + log.warning("保存用户称呼失败(仅本轮生效):%s", exc) + + # 自报称呼只用于个性化,保持原 user_id 和 trust_level,不构成认证。 + self.identity.switch( + identity.user_id, + display_name=display_name, + reason="用户明确自报称呼", + ) + async def _stream_and_speak(self, messages: list[dict], metrics: TurnMetrics) -> AsyncIterator[Event]: """执行 LLM/搜索工具闭环;最终文本仍然流式交给调用方做 TTS。""" @@ -476,6 +552,9 @@ def extract_memories(user_text: str, reply: str) -> list[Memory]: """ out: list[Memory] = [] text = (user_text or "").strip() + # 姓名由同步、已确认的档案路径处理;不要再产生一条 candidate 副本。 + if extract_display_name(text): + return out for trigger, kind_hint in _REMEMBER_PATTERNS: if trigger in text: # 取触发词之后的正文 @@ -491,6 +570,31 @@ def extract_memories(user_text: str, reply: str) -> list[Memory]: return out +def extract_display_name(user_text: str) -> str | None: + """从用户明确的自我介绍中提取称呼;不使用模型猜测。""" + text = " ".join((user_text or "").strip().split()) + if not text: + return None + for pattern in _DISPLAY_NAME_PATTERNS: + match = pattern.search(text) + if not match: + continue + name = " ".join(match.group(1).strip(" ‘'\"”").split()) + if not name or len(name) > 24 or name in _NOT_NAMES: + continue + # “知道我是谁吗”“我是林吗”不是自我介绍。语音识别可能不带问号, + # 所以不能只依赖标点,需同时过滤疑问词和句末疑问助词。 + if any(word in name for word in _NAME_QUESTION_WORDS): + continue + if name.endswith(_NAME_QUESTION_PARTICLES): + continue + if name.startswith(("你", "他", "她", "它")) and len(name) > 1: + continue + return name + return None + + __all__ = [ - "ConversationEngine", "Event", "EventKind", "TurnMetrics", "extract_memories", + "ConversationEngine", "Event", "EventKind", "TurnMetrics", + "extract_memories", "extract_display_name", ] diff --git a/app/identity/base.py b/app/identity/base.py index 796966c..6fdb857 100644 --- a/app/identity/base.py +++ b/app/identity/base.py @@ -66,6 +66,8 @@ class IdentityDecision: trust_level: str confirmed: bool # True = 经确认;False = 默认访客 reason: str + # 用户可见称呼,与内部稳定 user_id 分开;自报姓名不能提升信任等级。 + display_name: str = "" #: 未被采纳的候选,保留下来供上层提示"是否要切换成 X" suggestions: list[IdentityClaim] = field(default_factory=list) diff --git a/app/identity/manual.py b/app/identity/manual.py index 0a0e20a..697e033 100644 --- a/app/identity/manual.py +++ b/app/identity/manual.py @@ -61,11 +61,17 @@ class ManualIdentityProvider(IdentityProvider): if not user_id: raise ValueError("user_id 不能为空") prev = self._decision.user_id + visible_name = ( + display_name.strip() + if display_name.strip() + else self._decision.display_name if prev == user_id else "" + ) self._decision = IdentityDecision( user_id=user_id, trust_level=TRUST_NORMAL, confirmed=True, reason=reason, + display_name=visible_name, ) if prev != user_id: self._version += 1 diff --git a/app/memory/base.py b/app/memory/base.py index 1fbd99e..e66ebd5 100644 --- a/app/memory/base.py +++ b/app/memory/base.py @@ -105,6 +105,14 @@ class MemoryStore(ABC): trust_level: str = "normal") -> None: """建档。已存在则不动。身份认证与信任等级分开(方案 §6)。""" + @abstractmethod + async def set_user_display_name(self, user_id: str, display_name: str) -> None: + """更新用户自报的称呼。它用于个性化,不提高身份信任等级。""" + + @abstractmethod + async def get_user_display_name(self, user_id: str) -> str: + """读取用户称呼;未设置或用户不存在时返回空字符串。""" + @abstractmethod async def start_session(self, session_id: str, user_id: str) -> None: ... diff --git a/app/memory/sqlite_store.py b/app/memory/sqlite_store.py index acb05d1..69f5e53 100644 --- a/app/memory/sqlite_store.py +++ b/app/memory/sqlite_store.py @@ -176,6 +176,25 @@ class SQLiteMemoryStore(MemoryStore): c.commit() await self._run(op) + async def set_user_display_name(self, user_id: str, display_name: str) -> None: + display_name = " ".join((display_name or "").split()).strip() + if not user_id or not display_name: + raise ValueError("user_id 和 display_name 不能为空") + + def op(c: sqlite3.Connection): + _ensure_user_row(c, user_id) + c.execute("UPDATE users SET display_name=? WHERE user_id=?", + (display_name, user_id)) + c.commit() + await self._run(op) + + async def get_user_display_name(self, user_id: str) -> str: + def op(c: sqlite3.Connection): + row = c.execute("SELECT display_name FROM users WHERE user_id=?", + (user_id,)).fetchone() + return str(row["display_name"] or "") if row else "" + return await self._run(op) + async def start_session(self, session_id: str, user_id: str) -> None: def op(c: sqlite3.Connection): c.execute("INSERT INTO users(user_id, created_at) VALUES(?,?) " diff --git a/app/prompts.py b/app/prompts.py index b4ea6fe..6221aa5 100644 --- a/app/prompts.py +++ b/app/prompts.py @@ -47,7 +47,17 @@ def build_system_prompt( parts = [PERSONA_RULES] if identity.confirmed: - parts.append(f"\n当前用户:{identity.user_id}(已确认身份)。") + if identity.display_name: + parts.append( + f"\n当前用户希望被称为“{identity.display_name}”。" + "这是用户可见称呼,可以在合适时自然使用。" + ) + else: + parts.append("\n当前连接已绑定到一个用户档案,但尚未记录用户称呼。") + parts.append( + "内部 user_id 只用于数据隔离,绝不能把它当作用户名、姓名或称呼告诉用户。" + "如果用户问‘我是谁’而没有已知称呼,应坦白还不知道,而不是回答内部 ID。" + ) else: # 方案 §6:未知身份只用访客上下文,不做个性化 parts.append("\n当前用户身份未确认,按访客对待。" diff --git a/app/tests/test_identity.py b/app/tests/test_identity.py index 63dd41f..117dad5 100644 --- a/app/tests/test_identity.py +++ b/app/tests/test_identity.py @@ -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") # 切到同一个人 diff --git a/app/tests/test_memory.py b/app/tests/test_memory.py index 9f556e8..2b28946 100644 --- a/app/tests/test_memory.py +++ b/app/tests/test_memory.py @@ -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, diff --git a/app/tests/test_profile_live.py b/app/tests/test_profile_live.py new file mode 100644 index 0000000..b1864a3 --- /dev/null +++ b/app/tests/test_profile_live.py @@ -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())) diff --git a/app/tests/test_profile_memory.py b/app/tests/test_profile_memory.py new file mode 100644 index 0000000..0fb2e77 --- /dev/null +++ b/app/tests/test_profile_memory.py @@ -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())) diff --git a/app/tests/test_search.py b/app/tests/test_search.py index aeaf73e..44ba064 100644 --- a/app/tests/test_search.py +++ b/app/tests/test_search.py @@ -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 diff --git a/services/bot_server.py b/services/bot_server.py index 79a1ef6..896d264 100644 --- a/services/bot_server.py +++ b/services/bot_server.py @@ -234,7 +234,8 @@ class EngineProcessor(FrameProcessor): self._engine = ConversationEngine( self.cfg, self._llm, self._memory, ManualIdentityProvider(), create_tts(self.cfg.voice), stt=None) - self._session = await self._engine.start_session(user_id="u_ylhan") + self._session = await self._engine.start_session( + user_id=self.cfg.default_user_id) if require_stt and self._engine.stt is None: self._engine.stt = StreamingSTT(self.cfg.voice) return self._engine @@ -383,7 +384,7 @@ class EngineProcessor(FrameProcessor): if ev.kind == EventKind.TRANSCRIPT and ev.text: await self.push_frame(TranscriptionFrame( text=ev.text, - user_id="u_ylhan", + user_id=self.cfg.default_user_id, timestamp=datetime.now(UTC).isoformat(), finalized=True, )) @@ -391,7 +392,7 @@ class EngineProcessor(FrameProcessor): RTVI.UserTranscriptionMessage( data=RTVI.UserTranscriptionMessageData( text=ev.text, - user_id="u_ylhan", + user_id=self.cfg.default_user_id, timestamp=datetime.now(UTC).isoformat(), final=True, ) diff --git a/部署说明.md b/部署说明.md index df447b6..5b91e1d 100644 --- a/部署说明.md +++ b/部署说明.md @@ -88,6 +88,7 @@ DoA 读取和网络桥接,不跑任何推理。 | `AI_TTS_INSTRUCTIONS` | `用轻快活泼的语气说` | 风格指令 | | `AI_STT_THREADS` | `4` | **N5095 上必须改成 2**(见下) | | `AI_MODEL_DIR` | `/home/yonglin/ai-companion/models` | 两边模型位置不同 | +| `AI_DEFAULT_USER_ID` | `u_ylhan`(默认) | 单用户原型的内部档案 ID;不能作为姓名展示 | | `SEARCH_ENABLED` | `true` | 向 LLM 注册只读 `web_search` 工具 | | `SEARXNG_BASE_URL` | `http://127.0.0.1:18080` | 私有 SearXNG,仅服务器回环可达 | | `SEARCH_TIMEOUT_S` | `6`(默认) | 搜索总超时,失败后让模型明确降级 | @@ -161,13 +162,17 @@ rsync -az --delete --exclude '__pycache__' --exclude '*.pyc' \ 生效。不加限制就变成"每轮全量注入",不再是按需检索。 9. **"被引用的 user 必须存在"由存储层自己保证**(`_ensure_user_row`), 不能让每个调用点各自记得——漏一处就报 `FOREIGN KEY constraint failed`。 +10. **内部 `user_id` 不是用户姓名。** 网页客户端当前把每个连接绑定到稳定档案 + `AI_DEFAULT_USER_ID`,但提示词不得暴露该值。用户明确说“我叫林”时,称呼同步 + 写入用户表和 confirmed 姓名记忆;同步写是为了避免刚说完就断线导致后台任务丢失。 + 新连接从用户表恢复 `display_name`。自报姓名不构成身份认证,不改变 trust level。 ### 依赖层 -10. **qwen-tts 与 vllm-omni 依赖互斥**(transformers 4.57 vs 5.14、 +11. **qwen-tts 与 vllm-omni 依赖互斥**(transformers 4.57 vs 5.14、 hf_hub 0.36 vs 1.31)。装同一个 venv 会互相破坏,实测把 vllm-omni 搞崩过。 所以 TTS 跑独立进程 + HTTP。 -11. **flash-attn 装不上**:预编译 wheel 最高只支持 torch 2.9,本机是 2.14 +12. **flash-attn 装不上**:预编译 wheel 最高只支持 torch 2.9,本机是 2.14 (vLLM 0.28 拉上来的);源码编译要从 GitHub 拉依赖,而这台机器下 GitHub release 会断连。 @@ -197,6 +202,12 @@ P2 的本地唤醒和 20 秒连续对话窗口。 完整的 `bot-transcription` / `bot-output` 事件,不能只发送 token 级 `bot-llm-text`。 +**姓名记忆已修复并迁移。** 用户明确自报姓名时,同步更新 `users.display_name` +和 confirmed 姓名记忆;新连接从用户表恢复称呼,内部 `user_id` 不进入用户可见 +称呼。姓名疑问句(例如“知道我是谁吗”)不会再被误写为姓名。需要注意:通用的 +“记住/我喜欢/我习惯”目前仍全部写成 candidate,默认检索不会读取,而且还没有确认 +和晋升流程;所以通用长期记忆尚未闭环。 + **联网搜索已实现并部署。** DeepSeek 通过 Function Calling 请求 `web_search`,引擎 聚合完整的流式工具参数,调用回环地址上的 SearXNG,并把标题、摘要、URL 作为 `tool` 消息回灌后生成最终回答。最多连续搜索两轮,搜索失败不会拖垮对话;网页保留 @@ -207,6 +218,22 @@ P2 的本地唤醒和 20 秒连续对话窗口。 启用 Bing、360 搜索、搜狗;其中 360 与搜狗实测有结果,Bing 当前返回空结果。 容器只监听 `127.0.0.1:18080`,不向局域网或公网暴露。 +## Agent 与打断的下一阶段 + +架构已选择 **Pipecat Flows 渐进改造**,不整体迁移 OpenClaw、Agno、LangGraph 或 +LiveKit Agents。现有 `ConversationEngine` 已手写最多两轮的搜索工具循环,而 +Pipecat Flows 依赖 Pipecat `LLMService`、context aggregator 和 PipelineTask; +两者不能直接替换。实施必须先完成无传输的兼容性 spike,验证 DeepSeek 流式内容、 +工具参数聚合、取消传播和 RTVI 文本事件,再用 `AI_AGENT_RUNTIME=legacy|flows` +保留回退路径。详细任务与验收见笔记库 +`P1-Pipecat Flows Agent化与记忆改造交接.md`。 + +当前打断不是完整闭环:`SimpleVAD` 在助手推送音频时检测到高能量只记录日志; +语音话段要等 700ms 尾静音后才会取消旧轮次。收到 `InterruptionFrame` 时任务确实 +可以取消,但服务端尚未在用户开口时主动生成该信号,也没有同时清空浏览器已缓冲音频。 +下一阶段先用合成帧验证“开始说话 → 取消生成 → 清空输出”的控制链;真实 AEC、回声 +条件下的误触发率仍必须等 N5095 音频硬件到位后验收。 + ## 未做的事 - **没有常驻服务**:TTS 服务和助手都没有 systemd 开机自启(方案 §10 要求 P2 后做) @@ -214,6 +241,10 @@ P2 的本地唤醒和 20 秒连续对话窗口。 需要 HTTPS;Tailscale Serve 仍需在 tailnet 管理页启用 - **唤醒尚未实现**:当前网页连接后会持续上传麦克风并响应每段有效语音;正式方案 是在 N5095 本地做关键词检测,待机时音频不出网 +- **实时打断尚未闭环**:有能量 VAD、异步任务取消和客户端停播能力,但三者还没有 + 由可靠的 user-started-speaking 事件串起来 +- **通用长期记忆尚未闭环**:除姓名外的自动抽取均为 candidate,缺少确认、晋升、 + 修改、忘记的用户可见流程 - **设备侧代码不存在**:N5095 的外设桥(USB、WebRTC 客户端、串口)尚未开始 - **STT 没换**:仍是 2023 年的 Zipformer int8,中文识别率从未测过 - **记忆库在服务器上**,而人脸特征按方案必须留在设备端——将来做