Files
ylhan 20cfaa6ef7 基线:姓名档案修复、疑问句防误写、搜索与传输测试
这是进入 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/覆盖现有改动。此提交即该基线。
2026-09-16 12:48:18 +08:00

194 lines
6.9 KiB
Python
Raw Permalink 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 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",
]