这是进入 Pipecat Flows 改造前的回滚点。 提交时已核对本地与线上代码 md5 一致(engine.py 7e97bad8、 bot_server.py 1cf543ae),所以这个提交就是当前线上状态。 内容(此前一直在线上运行但未提交的改动): - 姓名档案:用户自报"我叫林"同步写 confirmed 姓名,重连后恢复 - 疑问句防误写:"谁吗"之类的疑问句不再被覆盖成姓名 - 新增 test_profile_memory.py(姓名档案专项回归) - 新增 test_profile_live.py(需要真实 LLM 的现场测试) - 搜索、身份、记忆测试的相应补充 背景:下一步要按 P1-Pipecat Flows Agent化与记忆改造交接.md 渐进引入 Pipecat Flows,交接文档明确要求先保护基线、不得 reset/checkout/覆盖现有改动。此提交即该基线。
194 lines
6.9 KiB
Python
194 lines
6.9 KiB
Python
"""记忆抽象层。
|
||
|
||
设计原则(方案 §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 set_user_display_name(self, user_id: str, display_name: str) -> None:
|
||
"""更新用户自报的称呼。它用于个性化,不提高身份信任等级。"""
|
||
|
||
@abstractmethod
|
||
async def get_user_display_name(self, user_id: str) -> str:
|
||
"""读取用户称呼;未设置或用户不存在时返回空字符串。"""
|
||
|
||
@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",
|
||
]
|