Private
Public Access
SQLite-Ereignisse und Personenprofile vorbereiten
This commit is contained in:
1 parent
643146599d
commit
f15ab1d399
9 files changed
+637
-21
No files matched your search
@@ -0,0 +1,241 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user