"""记忆抽象层。 设计原则(方案 §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", ]