safety layer: hard daily budget, user quotas, spend report, forgetme + privacy

This commit is contained in:
Oleksandr Kozachuk
2026-07-13 13:21:45 +02:00
parent f6c3e7d8e5
commit 02c989946b
11 changed files with 465 additions and 6 deletions
+27 -2
View File
@@ -13,7 +13,7 @@ from contextlib import closing
from pathlib import Path
from typing import Any, Dict, List, Optional
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
class PersistentStore:
@@ -27,14 +27,21 @@ class PersistentStore:
return conn
def _init_db(self) -> None:
# Forward-only migrations keyed on user_version (PER-06)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
with closing(self._connect()) as conn, conn:
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
version = conn.execute("PRAGMA user_version").fetchone()[0]
if version < 1:
conn.execute(
"CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, channel TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL)"
)
conn.execute("CREATE INDEX IF NOT EXISTS history_channel ON history (channel)")
conn.execute("CREATE TABLE IF NOT EXISTS memory (channel TEXT PRIMARY KEY, content TEXT NOT NULL)")
if version < 2:
conn.execute(
"CREATE TABLE IF NOT EXISTS usage (day TEXT NOT NULL, key TEXT NOT NULL, value REAL NOT NULL, PRIMARY KEY (day, key))"
)
if version < SCHEMA_VERSION:
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
os.chmod(self.db_path, 0o600) # conversation data (PER-04)
@@ -65,6 +72,24 @@ class PersistentStore:
(channel, content),
)
def usage_add(self, day: str, key: str, amount: float) -> None:
with closing(self._connect()) as conn, conn:
conn.execute(
"INSERT INTO usage (day, key, value) VALUES (?, ?, ?) ON CONFLICT(day, key) DO UPDATE SET value = value + excluded.value",
(day, key, amount),
)
def usage_get(self, day: str, key: str) -> float:
with closing(self._connect()) as conn:
row = conn.execute("SELECT value FROM usage WHERE day = ? AND key = ?", (day, key)).fetchone()
return float(row[0]) if row else 0.0
def delete_history_of_user(self, user: str) -> int:
"""Remove persisted rows carrying this user's messages (SAF-08)."""
with closing(self._connect()) as conn, conn:
cursor = conn.execute("DELETE FROM history WHERE content LIKE ?", (f'%"user": "{user}"%',))
return cursor.rowcount
def migrate_pickles(self, channel: str, history_file: Path, memory_file: Path) -> None:
"""Import legacy pickles once; rename them *.migrated (PER-03)."""
if self.load_history(channel) or self.load_memory(channel) is not None: