基线:姓名档案修复、疑问句防误写、搜索与传输测试
这是进入 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:
@@ -26,3 +26,6 @@ deploy/searxng/settings.yml
|
|||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# 工作前保存的基线备份(diff 与未跟踪文件)
|
||||||
|
.baseline/
|
||||||
|
|||||||
@@ -51,11 +51,12 @@ p0/ P0 阶段的验证与基准脚本(保留用于复现
|
|||||||
# 服务器上(完整)
|
# 服务器上(完整)
|
||||||
cd ~/ai-companion
|
cd ~/ai-companion
|
||||||
.venv/bin/python app/tests/test_llm.py # 27 项
|
.venv/bin/python app/tests/test_llm.py # 27 项
|
||||||
.venv/bin/python app/tests/test_memory.py # 28 项
|
.venv/bin/python app/tests/test_memory.py # 30 项
|
||||||
.venv/bin/python app/tests/test_identity.py # 17 项
|
.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_engine_e2e.py # 16 项,端到端
|
||||||
.venv/bin/python app/tests/test_transport.py # 4 项,媒体时钟/VAD/静音/RTVI文本
|
.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_search.py # 12 项,搜索 API/工具回灌/TTS 链路过滤
|
||||||
|
.venv/bin/python app/tests/test_profile_memory.py # 22 项,自报姓名/断开重连/疑问句防误写
|
||||||
```
|
```
|
||||||
|
|
||||||
端到端测试**不需要麦克风、音箱、摄像头、ESP32**——
|
端到端测试**不需要麦克风、音箱、摄像头、ESP32**——
|
||||||
@@ -85,11 +86,17 @@ DeepSeek 负责判断何时需要实时资料,`app/search.py` 提供 `web_sear
|
|||||||
`track_id`(画面里的一个目标)互不冒充。人脸认出来**不等于**确认对方在说话——
|
`track_id`(画面里的一个目标)互不冒充。人脸认出来**不等于**确认对方在说话——
|
||||||
所以人脸只产生"建议",不自动切换身份。做错的后果是路人经过就把你的私人记忆念出来。
|
所以人脸只产生"建议",不自动切换身份。做错的后果是路人经过就把你的私人记忆念出来。
|
||||||
|
|
||||||
|
**内部 ID 与用户称呼分开。** `u_ylhan` 只用于数据库隔离,绝不是姓名。用户明确说
|
||||||
|
“我叫林”时,会同步写入 `users.display_name` 和一条 confirmed 姓名记忆;新连接创建
|
||||||
|
引擎时从档案恢复称呼。自报姓名只用于个性化,不提升身份信任等级。
|
||||||
|
|
||||||
**记忆查询强制带 `owner_user_id`。** 没有"按 memory_id 单独取"的口子:
|
**记忆查询强制带 `owner_user_id`。** 没有"按 memory_id 单独取"的口子:
|
||||||
猜到 id 就能读别人的记忆,是这类系统最容易犯的错。删除同理,必须同时匹配 owner。
|
猜到 id 就能读别人的记忆,是这类系统最容易犯的错。删除同理,必须同时匹配 owner。
|
||||||
|
|
||||||
**自动抽取的记忆是"候选",默认不参与检索。** 只有用户显式"记住"或工具调用
|
**自动抽取的记忆是"候选",默认不参与检索。** 当前只有自报姓名走同步 confirmed
|
||||||
写入的才是"已确认"。对应方案 §8 的"模型推测不当作确认事实"。
|
档案路径;“记住/我喜欢/我习惯”等通用规则仍统一写成 candidate,而且尚无候选确认/
|
||||||
|
晋升入口。因此除姓名外,内容可能已经落库却不会参与后续回答。这是下一阶段必须补齐
|
||||||
|
的功能,不应把“数据库里有记录”误报为“助手已经会使用这条记忆”。
|
||||||
|
|
||||||
**异步任务绑定创建时的身份。** 回复后写记忆的任务在创建时捕获 `user_id`,
|
**异步任务绑定创建时的身份。** 回复后写记忆的任务在创建时捕获 `user_id`,
|
||||||
不读"当前用户"——否则用户在这几百毫秒内切了身份,这条记忆会写到别人名下。
|
不读"当前用户"——否则用户在这几百毫秒内切了身份,这条记忆会写到别人名下。
|
||||||
@@ -122,6 +129,25 @@ Pipecat 管传输、帧与 RTVI,自有 `ConversationEngine` 管会话语义。
|
|||||||
|
|
||||||
完整记录见代码仓库外的 `P0-Pipecat传输层实验报告.md`(在 Obsidian 笔记库里)。
|
完整记录见代码仓库外的 `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 个坑
|
### 实验挖出的 10 个坑
|
||||||
|
|
||||||
每个都表现为"什么都没发生",极难排查:
|
每个都表现为"什么都没发生",极难排查:
|
||||||
|
|||||||
@@ -214,6 +214,9 @@ class MemoryConfig:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
|
# 单用户原型的稳定内部档案 ID。它只用于数据隔离,绝不是用户可见姓名。
|
||||||
|
default_user_id: str = field(
|
||||||
|
default_factory=lambda: _env("AI_DEFAULT_USER_ID", "u_ylhan"))
|
||||||
#: 主 LLM 由 AI_LLM_PRIMARY 选择:
|
#: 主 LLM 由 AI_LLM_PRIMARY 选择:
|
||||||
#: "local" = 本机 vLLM(首 token 192ms,但占 21.5GB 显存)
|
#: "local" = 本机 vLLM(首 token 192ms,但占 21.5GB 显存)
|
||||||
#: "deepseek" = 云端(首 token 786ms,网络往返约 700ms)
|
#: "deepseek" = 云端(首 token 786ms,网络往返约 700ms)
|
||||||
|
|||||||
+105
-1
@@ -58,6 +58,29 @@ _SOURCE_SECTION_RE = re.compile(
|
|||||||
re.IGNORECASE,
|
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):
|
class EventKind(str, Enum):
|
||||||
IDENTITY = "identity" # 本轮的身份裁决结果
|
IDENTITY = "identity" # 本轮的身份裁决结果
|
||||||
@@ -143,6 +166,18 @@ class ConversationEngine:
|
|||||||
self.identity.switch(user_id, reason="会话指定")
|
self.identity.switch(user_id, reason="会话指定")
|
||||||
d = self.identity.current()
|
d = self.identity.current()
|
||||||
await self.memory.start_session(sid, d.user_id)
|
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._session_id = sid
|
||||||
self._history_identity_version = self._identity_version()
|
self._history_identity_version = self._identity_version()
|
||||||
log.info("会话开始 %s,身份 %s", sid, d.user_id)
|
log.info("会话开始 %s,身份 %s", sid, d.user_id)
|
||||||
@@ -180,6 +215,10 @@ class ConversationEngine:
|
|||||||
|
|
||||||
self._sync_identity_scope()
|
self._sync_identity_scope()
|
||||||
identity = self.identity.current()
|
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
|
metrics.identity_ms = (time.perf_counter() - t_start) * 1000
|
||||||
yield Event(kind=EventKind.IDENTITY, identity=identity)
|
yield Event(kind=EventKind.IDENTITY, identity=identity)
|
||||||
|
|
||||||
@@ -306,6 +345,43 @@ class ConversationEngine:
|
|||||||
log.warning("记忆检索失败(%s),本轮不带记忆继续", e)
|
log.warning("记忆检索失败(%s),本轮不带记忆继续", e)
|
||||||
return []
|
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],
|
async def _stream_and_speak(self, messages: list[dict],
|
||||||
metrics: TurnMetrics) -> AsyncIterator[Event]:
|
metrics: TurnMetrics) -> AsyncIterator[Event]:
|
||||||
"""执行 LLM/搜索工具闭环;最终文本仍然流式交给调用方做 TTS。"""
|
"""执行 LLM/搜索工具闭环;最终文本仍然流式交给调用方做 TTS。"""
|
||||||
@@ -476,6 +552,9 @@ def extract_memories(user_text: str, reply: str) -> list[Memory]:
|
|||||||
"""
|
"""
|
||||||
out: list[Memory] = []
|
out: list[Memory] = []
|
||||||
text = (user_text or "").strip()
|
text = (user_text or "").strip()
|
||||||
|
# 姓名由同步、已确认的档案路径处理;不要再产生一条 candidate 副本。
|
||||||
|
if extract_display_name(text):
|
||||||
|
return out
|
||||||
for trigger, kind_hint in _REMEMBER_PATTERNS:
|
for trigger, kind_hint in _REMEMBER_PATTERNS:
|
||||||
if trigger in text:
|
if trigger in text:
|
||||||
# 取触发词之后的正文
|
# 取触发词之后的正文
|
||||||
@@ -491,6 +570,31 @@ def extract_memories(user_text: str, reply: str) -> list[Memory]:
|
|||||||
return out
|
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__ = [
|
__all__ = [
|
||||||
"ConversationEngine", "Event", "EventKind", "TurnMetrics", "extract_memories",
|
"ConversationEngine", "Event", "EventKind", "TurnMetrics",
|
||||||
|
"extract_memories", "extract_display_name",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ class IdentityDecision:
|
|||||||
trust_level: str
|
trust_level: str
|
||||||
confirmed: bool # True = 经确认;False = 默认访客
|
confirmed: bool # True = 经确认;False = 默认访客
|
||||||
reason: str
|
reason: str
|
||||||
|
# 用户可见称呼,与内部稳定 user_id 分开;自报姓名不能提升信任等级。
|
||||||
|
display_name: str = ""
|
||||||
#: 未被采纳的候选,保留下来供上层提示"是否要切换成 X"
|
#: 未被采纳的候选,保留下来供上层提示"是否要切换成 X"
|
||||||
suggestions: list[IdentityClaim] = field(default_factory=list)
|
suggestions: list[IdentityClaim] = field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -61,11 +61,17 @@ class ManualIdentityProvider(IdentityProvider):
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
raise ValueError("user_id 不能为空")
|
raise ValueError("user_id 不能为空")
|
||||||
prev = self._decision.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(
|
self._decision = IdentityDecision(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
trust_level=TRUST_NORMAL,
|
trust_level=TRUST_NORMAL,
|
||||||
confirmed=True,
|
confirmed=True,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
|
display_name=visible_name,
|
||||||
)
|
)
|
||||||
if prev != user_id:
|
if prev != user_id:
|
||||||
self._version += 1
|
self._version += 1
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ class MemoryStore(ABC):
|
|||||||
trust_level: str = "normal") -> None:
|
trust_level: str = "normal") -> None:
|
||||||
"""建档。已存在则不动。身份认证与信任等级分开(方案 §6)。"""
|
"""建档。已存在则不动。身份认证与信任等级分开(方案 §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
|
@abstractmethod
|
||||||
async def start_session(self, session_id: str, user_id: str) -> None: ...
|
async def start_session(self, session_id: str, user_id: str) -> None: ...
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,25 @@ class SQLiteMemoryStore(MemoryStore):
|
|||||||
c.commit()
|
c.commit()
|
||||||
await self._run(op)
|
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:
|
async def start_session(self, session_id: str, user_id: str) -> None:
|
||||||
def op(c: sqlite3.Connection):
|
def op(c: sqlite3.Connection):
|
||||||
c.execute("INSERT INTO users(user_id, created_at) VALUES(?,?) "
|
c.execute("INSERT INTO users(user_id, created_at) VALUES(?,?) "
|
||||||
|
|||||||
+11
-1
@@ -47,7 +47,17 @@ def build_system_prompt(
|
|||||||
parts = [PERSONA_RULES]
|
parts = [PERSONA_RULES]
|
||||||
|
|
||||||
if identity.confirmed:
|
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:
|
else:
|
||||||
# 方案 §6:未知身份只用访客上下文,不做个性化
|
# 方案 §6:未知身份只用访客上下文,不做个性化
|
||||||
parts.append("\n当前用户身份未确认,按访客对待。"
|
parts.append("\n当前用户身份未确认,按访客对待。"
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ def main() -> int:
|
|||||||
d = p.switch("u_ylhan", reason="对话确认")
|
d = p.switch("u_ylhan", reason="对话确认")
|
||||||
check("切换生效", d.user_id == "u_ylhan" and d.confirmed)
|
check("切换生效", d.user_id == "u_ylhan" and d.confirmed)
|
||||||
check("信任等级为 normal", d.trust_level == "normal")
|
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))
|
check("版本号 +1", p.version == 1, str(p.version))
|
||||||
v = p.version
|
v = p.version
|
||||||
p.switch("u_ylhan") # 切到同一个人
|
p.switch("u_ylhan") # 切到同一个人
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ async def run(store: SQLiteMemoryStore) -> None:
|
|||||||
"SELECT display_name FROM users WHERE user_id=?", ("u_x",)).fetchone()["display_name"])
|
"SELECT display_name FROM users WHERE user_id=?", ("u_x",)).fetchone()["display_name"])
|
||||||
check("已存在则不改", name == "第一次", 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. 兜底检索要限量(否则就成了每轮全量注入)===")
|
print("\n=== 10. 兜底检索要限量(否则就成了每轮全量注入)===")
|
||||||
await store.ensure_user("u_big")
|
await store.ensure_user("u_big")
|
||||||
await store.remember(Memory(owner_user_id="u_big", kind=MemoryKind.FACT,
|
await store.remember(Memory(owner_user_id="u_big", kind=MemoryKind.FACT,
|
||||||
|
|||||||
@@ -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()))
|
||||||
@@ -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()))
|
||||||
@@ -62,6 +62,7 @@ class FakeSearch(SearXNGSearchClient):
|
|||||||
class FakeMemory:
|
class FakeMemory:
|
||||||
async def start_session(self, *_args): pass
|
async def start_session(self, *_args): pass
|
||||||
async def end_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 retrieve(self, *_args, **_kwargs): return []
|
||||||
async def add_turn(self, *_args): pass
|
async def add_turn(self, *_args): pass
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,8 @@ class EngineProcessor(FrameProcessor):
|
|||||||
self._engine = ConversationEngine(
|
self._engine = ConversationEngine(
|
||||||
self.cfg, self._llm, self._memory, ManualIdentityProvider(),
|
self.cfg, self._llm, self._memory, ManualIdentityProvider(),
|
||||||
create_tts(self.cfg.voice), stt=None)
|
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:
|
if require_stt and self._engine.stt is None:
|
||||||
self._engine.stt = StreamingSTT(self.cfg.voice)
|
self._engine.stt = StreamingSTT(self.cfg.voice)
|
||||||
return self._engine
|
return self._engine
|
||||||
@@ -383,7 +384,7 @@ class EngineProcessor(FrameProcessor):
|
|||||||
if ev.kind == EventKind.TRANSCRIPT and ev.text:
|
if ev.kind == EventKind.TRANSCRIPT and ev.text:
|
||||||
await self.push_frame(TranscriptionFrame(
|
await self.push_frame(TranscriptionFrame(
|
||||||
text=ev.text,
|
text=ev.text,
|
||||||
user_id="u_ylhan",
|
user_id=self.cfg.default_user_id,
|
||||||
timestamp=datetime.now(UTC).isoformat(),
|
timestamp=datetime.now(UTC).isoformat(),
|
||||||
finalized=True,
|
finalized=True,
|
||||||
))
|
))
|
||||||
@@ -391,7 +392,7 @@ class EngineProcessor(FrameProcessor):
|
|||||||
RTVI.UserTranscriptionMessage(
|
RTVI.UserTranscriptionMessage(
|
||||||
data=RTVI.UserTranscriptionMessageData(
|
data=RTVI.UserTranscriptionMessageData(
|
||||||
text=ev.text,
|
text=ev.text,
|
||||||
user_id="u_ylhan",
|
user_id=self.cfg.default_user_id,
|
||||||
timestamp=datetime.now(UTC).isoformat(),
|
timestamp=datetime.now(UTC).isoformat(),
|
||||||
final=True,
|
final=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ DoA 读取和网络桥接,不跑任何推理。
|
|||||||
| `AI_TTS_INSTRUCTIONS` | `用轻快活泼的语气说` | 风格指令 |
|
| `AI_TTS_INSTRUCTIONS` | `用轻快活泼的语气说` | 风格指令 |
|
||||||
| `AI_STT_THREADS` | `4` | **N5095 上必须改成 2**(见下) |
|
| `AI_STT_THREADS` | `4` | **N5095 上必须改成 2**(见下) |
|
||||||
| `AI_MODEL_DIR` | `/home/yonglin/ai-companion/models` | 两边模型位置不同 |
|
| `AI_MODEL_DIR` | `/home/yonglin/ai-companion/models` | 两边模型位置不同 |
|
||||||
|
| `AI_DEFAULT_USER_ID` | `u_ylhan`(默认) | 单用户原型的内部档案 ID;不能作为姓名展示 |
|
||||||
| `SEARCH_ENABLED` | `true` | 向 LLM 注册只读 `web_search` 工具 |
|
| `SEARCH_ENABLED` | `true` | 向 LLM 注册只读 `web_search` 工具 |
|
||||||
| `SEARXNG_BASE_URL` | `http://127.0.0.1:18080` | 私有 SearXNG,仅服务器回环可达 |
|
| `SEARXNG_BASE_URL` | `http://127.0.0.1:18080` | 私有 SearXNG,仅服务器回环可达 |
|
||||||
| `SEARCH_TIMEOUT_S` | `6`(默认) | 搜索总超时,失败后让模型明确降级 |
|
| `SEARCH_TIMEOUT_S` | `6`(默认) | 搜索总超时,失败后让模型明确降级 |
|
||||||
@@ -161,13 +162,17 @@ rsync -az --delete --exclude '__pycache__' --exclude '*.pyc' \
|
|||||||
生效。不加限制就变成"每轮全量注入",不再是按需检索。
|
生效。不加限制就变成"每轮全量注入",不再是按需检索。
|
||||||
9. **"被引用的 user 必须存在"由存储层自己保证**(`_ensure_user_row`),
|
9. **"被引用的 user 必须存在"由存储层自己保证**(`_ensure_user_row`),
|
||||||
不能让每个调用点各自记得——漏一处就报 `FOREIGN KEY constraint failed`。
|
不能让每个调用点各自记得——漏一处就报 `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 搞崩过。
|
hf_hub 0.36 vs 1.31)。装同一个 venv 会互相破坏,实测把 vllm-omni 搞崩过。
|
||||||
所以 TTS 跑独立进程 + HTTP。
|
所以 TTS 跑独立进程 + HTTP。
|
||||||
11. **flash-attn 装不上**:预编译 wheel 最高只支持 torch 2.9,本机是 2.14
|
12. **flash-attn 装不上**:预编译 wheel 最高只支持 torch 2.9,本机是 2.14
|
||||||
(vLLM 0.28 拉上来的);源码编译要从 GitHub 拉依赖,而这台机器下
|
(vLLM 0.28 拉上来的);源码编译要从 GitHub 拉依赖,而这台机器下
|
||||||
GitHub release 会断连。
|
GitHub release 会断连。
|
||||||
|
|
||||||
@@ -197,6 +202,12 @@ P2 的本地唤醒和 20 秒连续对话窗口。
|
|||||||
完整的 `bot-transcription` / `bot-output` 事件,不能只发送 token 级
|
完整的 `bot-transcription` / `bot-output` 事件,不能只发送 token 级
|
||||||
`bot-llm-text`。
|
`bot-llm-text`。
|
||||||
|
|
||||||
|
**姓名记忆已修复并迁移。** 用户明确自报姓名时,同步更新 `users.display_name`
|
||||||
|
和 confirmed 姓名记忆;新连接从用户表恢复称呼,内部 `user_id` 不进入用户可见
|
||||||
|
称呼。姓名疑问句(例如“知道我是谁吗”)不会再被误写为姓名。需要注意:通用的
|
||||||
|
“记住/我喜欢/我习惯”目前仍全部写成 candidate,默认检索不会读取,而且还没有确认
|
||||||
|
和晋升流程;所以通用长期记忆尚未闭环。
|
||||||
|
|
||||||
**联网搜索已实现并部署。** DeepSeek 通过 Function Calling 请求 `web_search`,引擎
|
**联网搜索已实现并部署。** DeepSeek 通过 Function Calling 请求 `web_search`,引擎
|
||||||
聚合完整的流式工具参数,调用回环地址上的 SearXNG,并把标题、摘要、URL 作为
|
聚合完整的流式工具参数,调用回环地址上的 SearXNG,并把标题、摘要、URL 作为
|
||||||
`tool` 消息回灌后生成最终回答。最多连续搜索两轮,搜索失败不会拖垮对话;网页保留
|
`tool` 消息回灌后生成最终回答。最多连续搜索两轮,搜索失败不会拖垮对话;网页保留
|
||||||
@@ -207,6 +218,22 @@ P2 的本地唤醒和 20 秒连续对话窗口。
|
|||||||
启用 Bing、360 搜索、搜狗;其中 360 与搜狗实测有结果,Bing 当前返回空结果。
|
启用 Bing、360 搜索、搜狗;其中 360 与搜狗实测有结果,Bing 当前返回空结果。
|
||||||
容器只监听 `127.0.0.1:18080`,不向局域网或公网暴露。
|
容器只监听 `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 后做)
|
- **没有常驻服务**:TTS 服务和助手都没有 systemd 开机自启(方案 §10 要求 P2 后做)
|
||||||
@@ -214,6 +241,10 @@ P2 的本地唤醒和 20 秒连续对话窗口。
|
|||||||
需要 HTTPS;Tailscale Serve 仍需在 tailnet 管理页启用
|
需要 HTTPS;Tailscale Serve 仍需在 tailnet 管理页启用
|
||||||
- **唤醒尚未实现**:当前网页连接后会持续上传麦克风并响应每段有效语音;正式方案
|
- **唤醒尚未实现**:当前网页连接后会持续上传麦克风并响应每段有效语音;正式方案
|
||||||
是在 N5095 本地做关键词检测,待机时音频不出网
|
是在 N5095 本地做关键词检测,待机时音频不出网
|
||||||
|
- **实时打断尚未闭环**:有能量 VAD、异步任务取消和客户端停播能力,但三者还没有
|
||||||
|
由可靠的 user-started-speaking 事件串起来
|
||||||
|
- **通用长期记忆尚未闭环**:除姓名外的自动抽取均为 candidate,缺少确认、晋升、
|
||||||
|
修改、忘记的用户可见流程
|
||||||
- **设备侧代码不存在**:N5095 的外设桥(USB、WebRTC 客户端、串口)尚未开始
|
- **设备侧代码不存在**:N5095 的外设桥(USB、WebRTC 客户端、串口)尚未开始
|
||||||
- **STT 没换**:仍是 2023 年的 Zipformer int8,中文识别率从未测过
|
- **STT 没换**:仍是 2023 年的 Zipformer int8,中文识别率从未测过
|
||||||
- **记忆库在服务器上**,而人脸特征按方案必须留在设备端——将来做
|
- **记忆库在服务器上**,而人脸特征按方案必须留在设备端——将来做
|
||||||
|
|||||||
Reference in New Issue
Block a user