助手本体:会话引擎、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,185 @@
|
||||
"""记忆抽象层。
|
||||
|
||||
设计原则(方案 §8):
|
||||
|
||||
1. **接口按"可能远程"设计**。SQLite 现在跑在本地,但将来多设备共享记忆
|
||||
时要搬到服务器。所以所有操作都是 async、可能超时、可能失败——
|
||||
写成同步直连本地文件的话,搬家要重写所有调用方。
|
||||
现在付这个成本几乎为零,将来付就很贵。
|
||||
|
||||
2. **查询和写入都强制带 owner_user_id**,没有"查全部"的口子。
|
||||
未知身份只给访客临时上下文,不落库。
|
||||
|
||||
3. **模型推测不当作确认事实**。自动抽取的记忆是 candidate,默认不参与检索;
|
||||
只有用户显式"记住"或工具调用写入的才是 confirmed。
|
||||
|
||||
4. **异步任务绑定创建时的身份**。写入任务在创建时捕获 user_id/session_id,
|
||||
绝不读"当前用户"——否则用户切了身份,上一轮迟到的写入会串到新人头上。
|
||||
|
||||
5. 视觉 track_id、会话 session_id、永久 user_id **三者分开**,不互相冒充。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
class Scope(str, Enum):
|
||||
"""记忆的作用域。群体会话默认只用 shared。"""
|
||||
PRIVATE = "private"
|
||||
SHARED = "shared"
|
||||
|
||||
|
||||
class ConfirmStatus(str, Enum):
|
||||
"""确认状态。区分"用户明确说的"和"模型猜的"。"""
|
||||
CONFIRMED = "confirmed" # 用户显式记住 / 工具调用写入
|
||||
CANDIDATE = "candidate" # 回复后自动抽取,默认不参与检索
|
||||
|
||||
|
||||
class MemoryKind(str, Enum):
|
||||
PREFERENCE = "preference" # 明确偏好:结构化键值
|
||||
FACT = "fact" # 事实
|
||||
TASK = "task" # 临时事项,常带过期时间
|
||||
|
||||
|
||||
@dataclass
|
||||
class Memory:
|
||||
memory_id: int | None = None
|
||||
owner_user_id: str = ""
|
||||
scope: Scope = Scope.PRIVATE
|
||||
kind: MemoryKind = MemoryKind.FACT
|
||||
key: str = ""
|
||||
value: str = ""
|
||||
confirm_status: ConfirmStatus = ConfirmStatus.CONFIRMED
|
||||
source_session_id: str | None = None
|
||||
source_turn_id: str | None = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
expires_at: float | None = None
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""用于检索和注入的文本形式。"""
|
||||
return f"{self.key}:{self.value}" if self.key else self.value
|
||||
|
||||
def is_expired(self, now: float | None = None) -> bool:
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return (now or time.time()) > self.expires_at
|
||||
|
||||
|
||||
@dataclass
|
||||
class Turn:
|
||||
"""一轮对话。用于溯源,不默认长期保存逐字稿。"""
|
||||
turn_id: str
|
||||
session_id: str
|
||||
user_id: str
|
||||
role: str # "user" | "assistant"
|
||||
text: str
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class MemoryStore(ABC):
|
||||
"""记忆存储。
|
||||
|
||||
所有方法都可能抛异常——调用方必须按"记忆失败不阻塞对话"处理
|
||||
(方案 §8)。超时由调用方用 asyncio.wait_for 控制。
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------- 生命周期
|
||||
|
||||
@abstractmethod
|
||||
async def open(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None: ...
|
||||
|
||||
# ---------------------------------------------------------- 用户与会话
|
||||
|
||||
@abstractmethod
|
||||
async def ensure_user(self, user_id: str, display_name: str = "",
|
||||
trust_level: str = "normal") -> None:
|
||||
"""建档。已存在则不动。身份认证与信任等级分开(方案 §6)。"""
|
||||
|
||||
@abstractmethod
|
||||
async def start_session(self, session_id: str, user_id: str) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def end_session(self, session_id: str) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def add_turn(self, turn: Turn) -> None: ...
|
||||
|
||||
# ---------------------------------------------------------- 记忆读写
|
||||
|
||||
@abstractmethod
|
||||
async def remember(self, mem: Memory) -> int:
|
||||
"""写入一条记忆,返回 memory_id。
|
||||
|
||||
同 owner + 同 kind + 同 key 的 confirmed 记忆,新值替换旧值
|
||||
(方案 §8:"明确偏好…新值替换旧值")。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def forget(self, owner_user_id: str, memory_id: int) -> bool:
|
||||
"""删除。必须带 owner_user_id——不能靠 memory_id 单独删,
|
||||
否则猜到一个 id 就能删别人的记忆。"""
|
||||
|
||||
@abstractmethod
|
||||
async def retrieve(self, owner_user_id: str, query: str, *,
|
||||
limit: int = 5,
|
||||
include_candidates: bool = False,
|
||||
scopes: Sequence[Scope] | None = None) -> list[Memory]:
|
||||
"""按相关性检索。
|
||||
|
||||
强制按 owner_user_id 过滤。unknown 身份应传一个访客 id,
|
||||
而不是绕过这个方法。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def list_memories(self, owner_user_id: str, *,
|
||||
include_candidates: bool = True) -> list[Memory]:
|
||||
"""列出全部,供"查看/删除"入口使用(方案 §8)。"""
|
||||
|
||||
@abstractmethod
|
||||
async def purge_user(self, owner_user_id: str) -> int:
|
||||
"""彻底删除该用户的一切:记忆、会话、轮次。
|
||||
对应方案 §8:"删除同时覆盖会话上下文、摘要和未来索引"。"""
|
||||
|
||||
# ---------------------------------------------------------- 检索打分
|
||||
|
||||
@staticmethod
|
||||
def relevance(query: str, text: str) -> float:
|
||||
"""中文字符二元组重合度打分。
|
||||
|
||||
为什么不用 FTS5:SQLite 默认分词器对中文是按整串处理的,
|
||||
一句话会变成一个 token,检索不出来;trigram 分词器可用但要额外
|
||||
编译选项。在"记忆条数不多"这个实际规模下,在 Python 里按二元组
|
||||
打分更可控,也不依赖 SQLite 的编译配置。
|
||||
|
||||
将来真的需要向量检索时,换掉这个打分函数和 retrieve 的实现即可,
|
||||
接口不动。
|
||||
"""
|
||||
q, t = query.strip(), text.strip()
|
||||
if not q or not t:
|
||||
return 0.0
|
||||
if q in t:
|
||||
return 1.0
|
||||
qb = {q[i:i + 2] for i in range(len(q) - 1)} or {q}
|
||||
tb = {t[i:i + 2] for i in range(len(t) - 1)} or {t}
|
||||
if not qb:
|
||||
return 0.0
|
||||
hit = len(qb & tb)
|
||||
if hit == 0:
|
||||
return 0.0
|
||||
# 以查询覆盖率为准:记忆里多出来的字不该拉低分数
|
||||
return hit / len(qb)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MemoryStore", "Memory", "Turn", "Scope", "ConfirmStatus", "MemoryKind",
|
||||
]
|
||||
Reference in New Issue
Block a user