助手本体:会话引擎、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:
@@ -0,0 +1,18 @@
|
||||
"""身份层。
|
||||
|
||||
对外暴露 `IdentityProvider` 接口与首版 `ManualIdentityProvider` 实现。
|
||||
将来接人脸/声纹只需新增实现,记忆表结构与调用方都不动
|
||||
(这正是方案 §6 要求"预留 IdentityProvider 接口"的原因)。
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
GUEST_USER_ID, TRUST_GUEST, TRUST_NORMAL,
|
||||
IdentityClaim, IdentityDecision, IdentityProvider,
|
||||
)
|
||||
from .manual import SUGGEST_MIN_CONFIDENCE, ManualIdentityProvider
|
||||
|
||||
__all__ = [
|
||||
"IdentityProvider", "ManualIdentityProvider",
|
||||
"IdentityClaim", "IdentityDecision",
|
||||
"GUEST_USER_ID", "TRUST_NORMAL", "TRUST_GUEST", "SUGGEST_MIN_CONFIDENCE",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""首版身份实现:显式切换 + 访客兜底。
|
||||
|
||||
对应方案 §6:"首版可靠身份入口:手动/对话确认切换到已建档的 user_id;
|
||||
无法确认时使用 unknown/guest。"
|
||||
|
||||
行为规则:
|
||||
|
||||
1. 显式切换(`switch`)立即生效,这是唯一能单独定身份的方式。
|
||||
2. 人脸/声纹线索只生成**建议**,不自动改身份。上层可以选择性地
|
||||
问一句"要切换成 X 吗",但不能替用户决定。
|
||||
3. 没有任何线索时是访客,且访客不能落私人记忆(由记忆层用
|
||||
owner_user_id 隔离保证)。
|
||||
4. 切换身份返回一个"版本号",上层据此判断要不要丢弃上一人的上下文。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Sequence
|
||||
|
||||
from .base import (
|
||||
GUEST_USER_ID, TRUST_GUEST, TRUST_NORMAL,
|
||||
IdentityClaim, IdentityDecision, IdentityProvider,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: 人脸候选被采纳为"建议"的最低置信度。
|
||||
#: 低于这个值连建议都不给——减少无意义的打扰。
|
||||
SUGGEST_MIN_CONFIDENCE = 0.6
|
||||
|
||||
|
||||
class ManualIdentityProvider(IdentityProvider):
|
||||
def __init__(self, initial_user: str | None = None):
|
||||
self._decision = IdentityDecision(
|
||||
user_id=initial_user or GUEST_USER_ID,
|
||||
trust_level=TRUST_NORMAL if initial_user else TRUST_GUEST,
|
||||
confirmed=bool(initial_user),
|
||||
reason="初始身份" if initial_user else "初始为访客",
|
||||
)
|
||||
self._version = 0
|
||||
|
||||
# ---------------------------------------------------------- 查询
|
||||
|
||||
def current(self) -> IdentityDecision:
|
||||
return self._decision
|
||||
|
||||
@property
|
||||
def version(self) -> int:
|
||||
"""身份版本号。每次真正变更时 +1。
|
||||
|
||||
上层用它判断"上一轮的私有检索上下文是否过期"——
|
||||
方案 §8 要求切换身份时清理上一人的上下文。
|
||||
"""
|
||||
return self._version
|
||||
|
||||
# ---------------------------------------------------------- 变更
|
||||
|
||||
def switch(self, user_id: str, *, display_name: str = "",
|
||||
reason: str = "显式切换") -> IdentityDecision:
|
||||
if not user_id:
|
||||
raise ValueError("user_id 不能为空")
|
||||
prev = self._decision.user_id
|
||||
self._decision = IdentityDecision(
|
||||
user_id=user_id,
|
||||
trust_level=TRUST_NORMAL,
|
||||
confirmed=True,
|
||||
reason=reason,
|
||||
)
|
||||
if prev != user_id:
|
||||
self._version += 1
|
||||
log.info("身份切换 %s → %s(%s)", prev, user_id, reason)
|
||||
return self._decision
|
||||
|
||||
def reset(self, reason: str = "回到待机") -> IdentityDecision:
|
||||
if self._decision.user_id != GUEST_USER_ID:
|
||||
self._version += 1
|
||||
self._decision = IdentityDecision(
|
||||
user_id=GUEST_USER_ID,
|
||||
trust_level=TRUST_GUEST,
|
||||
confirmed=False,
|
||||
reason=reason,
|
||||
)
|
||||
return self._decision
|
||||
|
||||
def resolve(self, claims: Sequence[IdentityClaim]) -> IdentityDecision:
|
||||
"""裁决。
|
||||
|
||||
顺序很重要:先看有没有权威来源(显式切换 / 客户端登录态),
|
||||
有就采用;人脸之类的只作为建议附在后面。
|
||||
"""
|
||||
authoritative = [c for c in claims if c.is_authoritative and c.candidate_user_id]
|
||||
suggestions = [
|
||||
c for c in claims
|
||||
if not c.is_authoritative
|
||||
and c.candidate_user_id
|
||||
and c.confidence >= SUGGEST_MIN_CONFIDENCE
|
||||
]
|
||||
|
||||
if authoritative:
|
||||
# 同一批里有多个权威来源且互相矛盾——宁可不切,报出来让人处理
|
||||
ids = {c.candidate_user_id for c in authoritative}
|
||||
if len(ids) > 1:
|
||||
log.warning("权威身份线索互相矛盾 %s,保持当前身份", ids)
|
||||
d = self._decision
|
||||
d.reason = f"线索矛盾 {ids},保持 {d.user_id}"
|
||||
d.suggestions = suggestions
|
||||
return d
|
||||
c = max(authoritative, key=lambda x: x.confidence)
|
||||
assert c.candidate_user_id is not None
|
||||
self.switch(c.candidate_user_id, reason=f"来源 {c.source}")
|
||||
else:
|
||||
# 没有权威来源:即便人脸认出来了也不切,只给建议
|
||||
self._decision.suggestions = suggestions
|
||||
if suggestions:
|
||||
self._decision.reason = (
|
||||
f"仅有人脸候选 {suggestions[0].candidate_user_id}"
|
||||
f"(置信度 {suggestions[0].confidence:.2f}),未自动切换"
|
||||
)
|
||||
return self._decision
|
||||
|
||||
|
||||
__all__ = ["ManualIdentityProvider", "SUGGEST_MIN_CONFIDENCE"]
|
||||
Reference in New Issue
Block a user