助手本体:会话引擎、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 恢复
This commit is contained in:
ylhan
2026-09-15 11:05:09 +08:00
commit 35e107c290
46 changed files with 5976 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
"""身份抽象层。
方案 §6 的核心约束,逐条落进接口:
* "首版可靠身份入口:手动/对话确认切换到已建档的 user_id;
无法确认时使用 unknown/guest。"
* **"看到熟人不等于确认他正在说话"** —— 人脸只提供候选,
不自动切换身份。这条最容易做错:摄像头看到谁就切谁的记忆,
结果是"路人经过就把你的私人记忆念出来"。
* "显式报名字用于个性化档案选择,**不是身份认证**,不能据此解锁敏感信息"
—— 所以 trust_level 与 user_id 分开存,将来加认证时不用改偏好存储。
* "预留 IdentityProvider 接口:输入时间戳、track_id、候选身份、置信度与来源;
输出确认的 user_id 或 unknown。后续声纹模块可接入此接口,不改记忆表结构。"
* "视觉 track_id、会话 session_id、永久 user_id 分开" ——
所以 IdentityClaim 里 track_id 是线索,不是身份。
"""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Sequence
#: 无法确认身份时使用的固定 id。永远不落私人记忆。
GUEST_USER_ID = "guest"
#: 信任等级。与 user_id 分开,将来加认证不改这里。
TRUST_NORMAL = "normal"
TRUST_GUEST = "guest"
@dataclass
class IdentityClaim:
"""一条身份线索。
source 取值(首版只用 manual,其余留给后续):
"manual" 用户显式切换/确认——**唯一可单独决定身份的来源**
"face" 人脸识别候选(本地特征,需用户主动录入)
"client_login" 客户端登录态(手机 App / 浏览器)
"voiceprint" 声纹(后续阶段)
"""
source: str
candidate_user_id: str | None = None
confidence: float = 0.0
track_id: str | None = None
timestamp: float = field(default_factory=time.time)
note: str = ""
#: 只有这些来源可以单独把身份定下来。人脸不在其中——
#: 方案明确要求人脸仅作候选、经确认后才启用对应记忆。
AUTHORITATIVE_SOURCES = ("manual", "client_login")
@property
def is_authoritative(self) -> bool:
return self.source in self.AUTHORITATIVE_SOURCES
@dataclass
class IdentityDecision:
user_id: str
trust_level: str
confirmed: bool # True = 经确认;False = 默认访客
reason: str
#: 未被采纳的候选,保留下来供上层提示"是否要切换成 X"
suggestions: list[IdentityClaim] = field(default_factory=list)
@property
def is_guest(self) -> bool:
return not self.confirmed
class IdentityProvider(ABC):
"""身份裁决者。
实现要点:**宁可拒判,不要猜**。判错的代价是把 A 的私人记忆
用在 B 身上,比"这次不认识"严重得多。
"""
@abstractmethod
def current(self) -> IdentityDecision:
"""当前身份。任何时候都必须能回答。"""
@abstractmethod
def switch(self, user_id: str, *, display_name: str = "",
reason: str = "显式切换") -> IdentityDecision:
"""显式切换身份——首版唯一可靠的入口。"""
@abstractmethod
def resolve(self, claims: Sequence[IdentityClaim]) -> IdentityDecision:
"""根据一组线索裁决身份。"""
@abstractmethod
def reset(self, reason: str = "回到待机") -> IdentityDecision:
"""回到访客身份,并清掉上一人的身份状态。
方案 §8 要求"切换身份清理上一人的私有检索上下文"——
调用方在收到身份变化后必须重新构造 system 消息(含记忆),
不能沿用上一轮的。
"""
__all__ = [
"IdentityProvider", "IdentityClaim", "IdentityDecision",
"GUEST_USER_ID", "TRUST_NORMAL", "TRUST_GUEST",
]