Files
ai-companion/app/memory/base.py
T
ylhan 35e107c290 助手本体:会话引擎、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 恢复
2026-09-15 11:05:09 +08:00

186 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""记忆抽象层。
设计原则(方案 §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",
]