Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
371 lines
14 KiB
Python
371 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
# memory_store.py — 结构化长期记忆存储(SQLite + FTS5,零外部依赖)
|
||
#
|
||
# 事实库: facts(uid, content, importance, keywords, created_at, archived, source)
|
||
# 召回: FTS5 trigram(≥3 字子串)+ LIKE 兜底(短查询/中文双字)
|
||
# 迁移: 首次接触某 uid 时把 memory/<uid>.txt 的旧文本记忆导入 facts(meta 表幂等)
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import threading
|
||
import time
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
DB_PATH = os.environ.get("CHAT_MEMORY_DB", os.path.join(BASE_DIR, "memory.db"))
|
||
LEGACY_DIR = os.environ.get("CHAT_MEMORY_LEGACY_DIR", os.path.join(BASE_DIR, "memory"))
|
||
|
||
_lock = threading.RLock()
|
||
|
||
|
||
def _connect():
|
||
con = sqlite3.connect(DB_PATH, timeout=30)
|
||
con.execute("PRAGMA journal_mode=WAL")
|
||
return con
|
||
|
||
|
||
def init_db():
|
||
with _lock, _connect() as con:
|
||
con.executescript("""
|
||
CREATE TABLE IF NOT EXISTS facts(
|
||
id INTEGER PRIMARY KEY,
|
||
uid INTEGER NOT NULL,
|
||
content TEXT NOT NULL,
|
||
importance INTEGER NOT NULL DEFAULT 3,
|
||
keywords TEXT NOT NULL DEFAULT '',
|
||
created_at REAL NOT NULL,
|
||
archived INTEGER NOT NULL DEFAULT 0,
|
||
source TEXT NOT NULL DEFAULT ''
|
||
);
|
||
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
|
||
content, keywords, content='facts', content_rowid='id', tokenize='trigram'
|
||
);
|
||
CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
|
||
INSERT INTO facts_fts(rowid, content, keywords) VALUES (new.id, new.content, new.keywords);
|
||
END;
|
||
CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
|
||
INSERT INTO facts_fts(facts_fts, rowid, content, keywords)
|
||
VALUES ('delete', old.id, old.content, old.keywords);
|
||
END;
|
||
CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
|
||
INSERT INTO facts_fts(facts_fts, rowid, content, keywords)
|
||
VALUES ('delete', old.id, old.content, old.keywords);
|
||
INSERT INTO facts_fts(rowid, content, keywords) VALUES (new.id, new.content, new.keywords);
|
||
END;
|
||
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
|
||
CREATE TABLE IF NOT EXISTS triples(
|
||
id INTEGER PRIMARY KEY,
|
||
uid INTEGER NOT NULL,
|
||
subject TEXT NOT NULL,
|
||
predicate TEXT NOT NULL,
|
||
object TEXT NOT NULL,
|
||
importance INTEGER NOT NULL DEFAULT 3,
|
||
valid_from REAL NOT NULL,
|
||
valid_until REAL,
|
||
created_at REAL NOT NULL,
|
||
source TEXT NOT NULL DEFAULT 'model'
|
||
);
|
||
CREATE INDEX IF NOT EXISTS triples_uid ON triples(uid);
|
||
CREATE INDEX IF NOT EXISTS triples_subj ON triples(uid, subject);
|
||
CREATE TABLE IF NOT EXISTS relations(
|
||
uid INTEGER PRIMARY KEY,
|
||
first_met REAL,
|
||
msg_count INTEGER NOT NULL DEFAULT 0,
|
||
affinity INTEGER NOT NULL DEFAULT 50,
|
||
last_seen REAL,
|
||
notes TEXT NOT NULL DEFAULT '[]'
|
||
);
|
||
""")
|
||
|
||
|
||
def _now():
|
||
return time.time()
|
||
|
||
|
||
def _norm_content(s):
|
||
return re.sub(r"\s+", " ", str(s or "")).strip()
|
||
|
||
|
||
def _norm_keywords(kw):
|
||
if isinstance(kw, str):
|
||
kw = [k.strip() for k in re.split(r"[,,、/ ]+", kw) if k.strip()]
|
||
elif isinstance(kw, (list, tuple)):
|
||
kw = [str(k).strip() for k in kw if str(k).strip()]
|
||
else:
|
||
kw = []
|
||
return ",".join(kw[:8])
|
||
|
||
|
||
def add_facts(uid, facts, source="model"):
|
||
"""写入若干条事实;同 uid 完全相同(content 一致)的活跃条目去重跳过。
|
||
返回新增的 id 列表。"""
|
||
init_db()
|
||
uid = int(uid)
|
||
added = []
|
||
with _lock, _connect() as con:
|
||
for f in facts or []:
|
||
if isinstance(f, str):
|
||
f = {"content": f}
|
||
if not isinstance(f, dict):
|
||
continue
|
||
content = _norm_content(f.get("content"))
|
||
if not content or len(content) < 2:
|
||
continue
|
||
importance = f.get("importance", 3)
|
||
try:
|
||
importance = int(importance)
|
||
except (TypeError, ValueError):
|
||
importance = 3
|
||
importance = max(1, min(5, importance))
|
||
keywords = _norm_keywords(f.get("keywords"))
|
||
if con.execute(
|
||
"SELECT 1 FROM facts WHERE uid=? AND content=? AND archived=0",
|
||
(uid, content)).fetchone():
|
||
continue
|
||
cur = con.execute(
|
||
"INSERT INTO facts(uid, content, importance, keywords, created_at, archived, source)"
|
||
" VALUES (?,?,?,?,?,0,?)",
|
||
(uid, content, importance, keywords, _now(), source))
|
||
added.append(cur.lastrowid)
|
||
return added
|
||
|
||
|
||
def list_facts(uid, include_archived=False, limit=50):
|
||
init_db()
|
||
q = ("SELECT id, content, importance, keywords, created_at, archived "
|
||
"FROM facts WHERE uid=?")
|
||
if not include_archived:
|
||
q += " AND archived=0"
|
||
q += " ORDER BY created_at DESC LIMIT ?"
|
||
with _lock, _connect() as con:
|
||
return con.execute(q, (int(uid), limit)).fetchall()
|
||
|
||
|
||
def forget_fact(uid, fact_id):
|
||
"""归档一条事实(不物理删除,可审计)。返回是否命中。"""
|
||
init_db()
|
||
with _lock, _connect() as con:
|
||
cur = con.execute(
|
||
"UPDATE facts SET archived=1 WHERE uid=? AND id=? AND archived=0",
|
||
(int(uid), int(fact_id)))
|
||
return cur.rowcount > 0
|
||
|
||
|
||
def count_facts(uid, active_only=True):
|
||
init_db()
|
||
q = "SELECT COUNT(*) FROM facts WHERE uid=?"
|
||
if active_only:
|
||
q += " AND archived=0"
|
||
with _lock, _connect() as con:
|
||
return con.execute(q, (int(uid),)).fetchone()[0]
|
||
|
||
|
||
def search_facts(uid, query, limit=5):
|
||
"""FTS5 trigram 召回(≥3 字);短查询/无命中时 LIKE 兜底。"""
|
||
init_db()
|
||
uid = int(uid)
|
||
query = _norm_content(query)
|
||
if not query:
|
||
return []
|
||
rows = []
|
||
with _lock, _connect() as con:
|
||
if len(query) >= 3:
|
||
try:
|
||
rows = con.execute(
|
||
"SELECT f.id, f.content, f.importance, f.created_at "
|
||
"FROM facts_fts JOIN facts f ON f.id = facts_fts.rowid "
|
||
"WHERE facts_fts MATCH ? AND f.uid=? AND f.archived=0 "
|
||
"ORDER BY rank, f.created_at DESC LIMIT ?",
|
||
(query, uid, limit)).fetchall()
|
||
except sqlite3.OperationalError:
|
||
rows = []
|
||
if not rows:
|
||
like = f"%{query}%"
|
||
rows = con.execute(
|
||
"SELECT id, content, importance, created_at FROM facts "
|
||
"WHERE uid=? AND archived=0 AND (content LIKE ? OR keywords LIKE ?) "
|
||
"ORDER BY created_at DESC LIMIT ?",
|
||
(uid, like, like, limit)).fetchall()
|
||
return rows
|
||
|
||
|
||
def touch_relation(uid):
|
||
"""每轮对话调用:自动登记/更新关系统计(相识时间、对话次数、最近活跃)。
|
||
每 50 轮顺手做一次程序式整理(归档低重要度老记忆,不花 token)。"""
|
||
init_db()
|
||
uid = int(uid)
|
||
now = _now()
|
||
with _lock, _connect() as con:
|
||
row = con.execute("SELECT msg_count FROM relations WHERE uid=?", (uid,)).fetchone()
|
||
if row:
|
||
new_count = row[0] + 1
|
||
con.execute("UPDATE relations SET msg_count=?, last_seen=? WHERE uid=?",
|
||
(new_count, now, uid))
|
||
else:
|
||
new_count = 1
|
||
con.execute(
|
||
"INSERT INTO relations(uid, first_met, msg_count, affinity, last_seen, notes)"
|
||
" VALUES (?,?,1,50,?,'[]')", (uid, now, now))
|
||
if new_count % 50 == 0:
|
||
maybe_archive_stale(uid)
|
||
|
||
|
||
def get_relation(uid):
|
||
init_db()
|
||
with _lock, _connect() as con:
|
||
row = con.execute(
|
||
"SELECT first_met, msg_count, affinity, last_seen, notes FROM relations WHERE uid=?",
|
||
(int(uid),)).fetchone()
|
||
if not row:
|
||
return None
|
||
try:
|
||
notes = json.loads(row[4] or "[]")
|
||
except Exception:
|
||
notes = []
|
||
return {"first_met": row[0], "msg_count": row[1], "affinity": row[2],
|
||
"last_seen": row[3], "notes": notes}
|
||
|
||
|
||
def add_relation_note(uid, note):
|
||
"""模型写的「关系上的重要变化/约定/里程碑」(一句话)。notes 保留最近 5 条。"""
|
||
init_db()
|
||
uid = int(uid)
|
||
note = _norm_content(note)
|
||
if not note:
|
||
return
|
||
with _lock, _connect() as con:
|
||
row = con.execute("SELECT notes FROM relations WHERE uid=?", (uid,)).fetchone()
|
||
if not row:
|
||
touch_relation(uid)
|
||
row = ("[]",)
|
||
try:
|
||
notes = json.loads(row[0] or "[]")
|
||
except Exception:
|
||
notes = []
|
||
notes.append({"ts": _now(), "text": note[:200]})
|
||
notes = notes[-5:]
|
||
con.execute("UPDATE relations SET notes=? WHERE uid=?",
|
||
(json.dumps(notes, ensure_ascii=False), uid))
|
||
|
||
|
||
def maybe_archive_stale(uid, older_than_days=30, max_importance=2):
|
||
"""轻量整理:归档「老且不重要」的记忆(程序式,不花 token)。返回归档条数。"""
|
||
init_db()
|
||
cutoff = _now() - older_than_days * 86400
|
||
with _lock, _connect() as con:
|
||
cur = con.execute(
|
||
"UPDATE facts SET archived=1 WHERE uid=? AND archived=0 AND importance<=? AND created_at<?",
|
||
(int(uid), max_importance, cutoff))
|
||
return cur.rowcount
|
||
|
||
|
||
def add_links(uid, links, source="model"):
|
||
"""写入 SPO 三元组。同 (uid, subject, predicate) 的旧边若 object 不同,
|
||
置 valid_until(时效窗:旧事实保留但不再生效)。返回新增 id 列表。"""
|
||
init_db()
|
||
uid = int(uid)
|
||
now = _now()
|
||
added = []
|
||
with _lock, _connect() as con:
|
||
for lk in links or []:
|
||
if not isinstance(lk, dict):
|
||
continue
|
||
s = _norm_content(lk.get("subject"))
|
||
pr = _norm_content(lk.get("predicate"))
|
||
o = _norm_content(lk.get("object"))
|
||
if not s or not pr or not o:
|
||
continue
|
||
try:
|
||
importance = max(1, min(5, int(lk.get("importance", 3))))
|
||
except (TypeError, ValueError):
|
||
importance = 3
|
||
# 同主同谓的旧边:值相同则跳过;值不同则置时效窗
|
||
old = con.execute(
|
||
"SELECT id, object FROM triples WHERE uid=? AND subject=? AND predicate=?"
|
||
" AND valid_until IS NULL", (uid, s, pr)).fetchall()
|
||
if any(row[1] == o for row in old):
|
||
continue
|
||
for row in old:
|
||
con.execute("UPDATE triples SET valid_until=? WHERE id=?", (now, row[0]))
|
||
cur = con.execute(
|
||
"INSERT INTO triples(uid, subject, predicate, object, importance,"
|
||
" valid_from, valid_until, created_at, source) VALUES (?,?,?,?,?,?,NULL,?,?)",
|
||
(uid, s, pr, o, importance, now, now, source))
|
||
added.append(cur.lastrowid)
|
||
return added
|
||
|
||
|
||
def entity_card(uid, name, limit=10):
|
||
"""某实体的卡片:以它为主语或宾语的当前有效三元组。"""
|
||
init_db()
|
||
uid = int(uid)
|
||
name = _norm_content(name)
|
||
if not name:
|
||
return []
|
||
with _lock, _connect() as con:
|
||
return con.execute(
|
||
"SELECT subject, predicate, object, importance FROM triples "
|
||
"WHERE uid=? AND valid_until IS NULL AND (subject=? OR object=?) "
|
||
"ORDER BY importance DESC, created_at DESC LIMIT ?",
|
||
(uid, name, name, limit)).fetchall()
|
||
|
||
|
||
def list_entities(uid, limit=50):
|
||
"""全部实体(主语/宾语出现过的名字)及各自有效边数。"""
|
||
init_db()
|
||
with _lock, _connect() as con:
|
||
rows = con.execute(
|
||
"SELECT name, COUNT(*) FROM ("
|
||
" SELECT subject AS name FROM triples WHERE uid=? AND valid_until IS NULL"
|
||
" UNION ALL SELECT object FROM triples WHERE uid=? AND valid_until IS NULL"
|
||
") GROUP BY name ORDER BY COUNT(*) DESC LIMIT ?",
|
||
(int(uid), int(uid), limit)).fetchall()
|
||
return rows
|
||
|
||
|
||
def recall_facts(uid, query, top_k=5, pinned_k=3):
|
||
"""召回式读取:高重要度置顶 pinned_k 条 + 按当前输入 FTS5 召回 top_k 条,按 id 去重。
|
||
返回 [(id, content, importance), ...]"""
|
||
init_db()
|
||
uid = int(uid)
|
||
with _lock, _connect() as con:
|
||
pinned = con.execute(
|
||
"SELECT id, content, importance FROM facts WHERE uid=? AND archived=0 "
|
||
"ORDER BY importance DESC, created_at DESC LIMIT ?",
|
||
(uid, pinned_k)).fetchall()
|
||
seen = {r[0] for r in pinned}
|
||
out = list(pinned)
|
||
for r in search_facts(uid, query, limit=top_k):
|
||
if r[0] not in seen:
|
||
out.append((r[0], r[1], r[2]))
|
||
seen.add(r[0])
|
||
return out
|
||
|
||
|
||
def migrate_legacy(uid, legacy_dir=None):
|
||
"""首次接触某 uid 时把 memory/<uid>.txt 的旧文本记忆导入 facts(meta 幂等)。"""
|
||
init_db()
|
||
uid = int(uid)
|
||
legacy_dir = legacy_dir or LEGACY_DIR
|
||
marker = f"imported:{uid}"
|
||
with _lock, _connect() as con:
|
||
if con.execute("SELECT 1 FROM meta WHERE key=?", (marker,)).fetchone():
|
||
return 0
|
||
path = os.path.join(legacy_dir, f"{uid}.txt")
|
||
if not os.path.exists(path):
|
||
return 0
|
||
facts = []
|
||
with open(path, encoding="utf-8") as f:
|
||
for ln in f:
|
||
ln = ln.strip()
|
||
if not ln:
|
||
continue
|
||
m = re.match(r"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\s*(.*)$", ln)
|
||
facts.append({"content": m.group(1) if m else ln,
|
||
"importance": 3, "keywords": []})
|
||
added = len(add_facts(uid, facts, source="legacy"))
|
||
with _lock, _connect() as con:
|
||
con.execute("INSERT OR REPLACE INTO meta(key, value) VALUES (?,?)",
|
||
(marker, str(_now())))
|
||
return added
|