Files
Halloween/halloween/event_store.py
T

242 lines
10 KiB
Python

"""Lokale Ereignisse und Profile mit begrenzter Lebensdauer für Auto-Profile."""
from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path
import math
import os
import sqlite3
import struct
AUTO_PROFILE_DAYS = 30
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _timestamp(value: datetime) -> str:
if value.tzinfo is None:
raise ValueError("Zeitstempel benötigt eine Zeitzone")
return value.astimezone(timezone.utc).isoformat(timespec="seconds")
def _normalized_vector(values) -> tuple[float, ...]:
vector = tuple(float(value) for value in values)
if not 16 <= len(vector) <= 4096 or not all(math.isfinite(x) for x in vector):
raise ValueError("Ungültiger Merkmalsvektor")
magnitude = math.hypot(*vector)
if magnitude == 0 or not math.isfinite(magnitude):
raise ValueError("Leerer Merkmalsvektor")
return tuple(value / magnitude for value in vector)
class EventStore:
def __init__(self, state_dir: Path):
state_dir.mkdir(parents=True, exist_ok=True)
self.path = state_dir / "events.sqlite3"
with closing(self._connect()) as connection:
with connection:
connection.execute("""
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL CHECK(length(label) BETWEEN 1 AND 80),
created_at TEXT NOT NULL,
origin TEXT NOT NULL DEFAULT 'manual',
last_seen_at TEXT,
expires_at TEXT
)
""")
profile_columns = {
row["name"] for row in connection.execute("PRAGMA table_info(profiles)")
}
for name, definition in (
("origin", "TEXT NOT NULL DEFAULT 'manual'"),
("last_seen_at", "TEXT"),
("expires_at", "TEXT"),
):
if name not in profile_columns:
connection.execute(f"ALTER TABLE profiles ADD COLUMN {name} {definition}")
connection.execute("""
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
occurred_at TEXT NOT NULL,
kind TEXT NOT NULL,
mode TEXT NOT NULL,
profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL
)
""")
columns = {
row["name"] for row in connection.execute("PRAGMA table_info(events)")
}
if "profile_id" not in columns:
connection.execute(
"ALTER TABLE events ADD COLUMN profile_id INTEGER "
"REFERENCES profiles(id) ON DELETE SET NULL"
)
connection.execute("""
CREATE TABLE IF NOT EXISTS profile_templates (
profile_id INTEGER PRIMARY KEY
REFERENCES profiles(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
dimension INTEGER NOT NULL,
vector BLOB NOT NULL,
created_at TEXT NOT NULL
)
""")
os.chmod(self.path, 0o600)
self.cleanup_expired()
def _connect(self):
connection = sqlite3.connect(self.path, timeout=5)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA secure_delete=ON")
return connection
def record_test_trigger(self, mode: str) -> int:
occurred_at = _timestamp(_utc_now())
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)",
(occurred_at, "test_trigger", mode),
)
return cursor.lastrowid
def recent(self, limit: int = 20) -> list[dict]:
if not 1 <= limit <= 100:
raise ValueError("limit muss zwischen 1 und 100 liegen")
self.cleanup_expired()
with closing(self._connect()) as connection:
rows = connection.execute(
"SELECT e.id, e.occurred_at, e.kind, e.mode, "
"e.profile_id, p.label AS profile_label "
"FROM events e LEFT JOIN profiles p ON p.id = e.profile_id "
"ORDER BY e.id DESC LIMIT ?", (limit,),
).fetchall()
return [dict(row) for row in rows]
def create_profile(self, label: str) -> dict:
label = label.strip()
if not 1 <= len(label) <= 80:
raise ValueError("Profilname muss 1 bis 80 Zeichen lang sein")
created_at = _timestamp(_utc_now())
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"INSERT INTO profiles (label, created_at) VALUES (?, ?)",
(label, created_at),
)
profile_id = cursor.lastrowid
return {"id": profile_id, "label": label, "created_at": created_at}
def profiles(self) -> list[dict]:
self.cleanup_expired()
with closing(self._connect()) as connection:
rows = connection.execute(
"SELECT p.id, p.label, p.created_at, p.origin, p.last_seen_at, "
"p.expires_at, COUNT(e.id) AS event_count "
"FROM profiles p LEFT JOIN events e ON e.profile_id = p.id "
"GROUP BY p.id ORDER BY p.id DESC"
).fetchall()
return [dict(row) for row in rows]
def create_auto_profile(self, values, model_id: str, now: datetime | None = None) -> dict:
if not model_id or len(model_id) > 80:
raise ValueError("Ungültige Modellkennung")
vector = _normalized_vector(values)
moment = now or _utc_now()
created_at = _timestamp(moment)
expires_at = _timestamp(moment + timedelta(days=AUTO_PROFILE_DAYS))
packed = struct.pack(f"<{len(vector)}f", *vector)
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"INSERT INTO profiles (label, created_at, origin, last_seen_at, expires_at) "
"VALUES (?, ?, 'auto', ?, ?)",
("Unbekannt", created_at, created_at, expires_at),
)
profile_id = cursor.lastrowid
label = f"Unbekannt #{profile_id}"
connection.execute(
"UPDATE profiles SET label = ? WHERE id = ?", (label, profile_id)
)
connection.execute(
"INSERT INTO profile_templates "
"(profile_id, model_id, dimension, vector, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(profile_id, model_id, len(vector), packed, created_at),
)
return {"id": profile_id, "label": label, "created_at": created_at,
"origin": "auto", "last_seen_at": created_at, "expires_at": expires_at}
def best_template_match(
self, values, model_id: str, now: datetime | None = None,
) -> tuple[int | None, float]:
vector = _normalized_vector(values)
with closing(self._connect()) as connection:
rows = connection.execute(
"SELECT t.profile_id, t.dimension, t.vector FROM profile_templates t "
"JOIN profiles p ON p.id = t.profile_id "
"WHERE t.model_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)",
(model_id, _timestamp(now or _utc_now())),
).fetchall()
best_id, best_score = None, -1.0
for row in rows:
if row["dimension"] != len(vector):
continue
if len(row["vector"]) != 4 * len(vector):
continue
template = struct.unpack(f"<{len(vector)}f", row["vector"])
score = sum(a * b for a, b in zip(vector, template))
if score > best_score:
best_id, best_score = row["profile_id"], score
return best_id, best_score
def touch_auto_profile(self, profile_id: int, now: datetime | None = None) -> None:
moment = now or _utc_now()
with closing(self._connect()) as connection:
with connection:
connection.execute(
"UPDATE profiles SET last_seen_at = ?, expires_at = ? "
"WHERE id = ? AND origin = 'auto' AND expires_at > ?",
(_timestamp(moment),
_timestamp(moment + timedelta(days=AUTO_PROFILE_DAYS)),
profile_id, _timestamp(moment)),
)
def cleanup_expired(self, now: datetime | None = None) -> int:
cutoff = _timestamp(now or _utc_now())
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"DELETE FROM profiles WHERE origin = 'auto' "
"AND expires_at IS NOT NULL AND expires_at <= ?", (cutoff,)
)
return cursor.rowcount
def assign_profile(self, event_id: int, profile_id: int | None) -> None:
self.cleanup_expired()
with closing(self._connect()) as connection:
with connection:
if profile_id is not None and connection.execute(
"SELECT 1 FROM profiles WHERE id = ?", (profile_id,),
).fetchone() is None:
raise KeyError("Profil nicht gefunden")
cursor = connection.execute(
"UPDATE events SET profile_id = ? WHERE id = ?",
(profile_id, event_id),
)
if cursor.rowcount == 0:
raise KeyError("Ereignis nicht gefunden")
def delete_profile(self, profile_id: int) -> bool:
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"DELETE FROM profiles WHERE id = ?", (profile_id,),
)
return cursor.rowcount > 0