"""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