Files
creator2/backend/db.py
2026-07-12 21:21:41 +02:00

205 lines
9.1 KiB
Python

"""SQLite-Schicht. Aller Pipeline-Zustand lebt HIER (Resume-Prinzip: jeder Schritt
ist idempotent, weil er nur offene Status-Werte weiterverarbeitet).
Synchronen Zugriff bewusst: DB-Arbeit ist gegen LLM-Latenz vernachlässigbar.
`on_change` (von main.py gesetzt) meldet Statuswechsel ans Live-Board."""
import json
import sqlite3
import threading
from pathlib import Path
from config import DB_PATH
on_change = None # callable(tabelle: str, row: dict) | None — nie fatal
_lock = threading.RLock()
_con: sqlite3.Connection | None = None
def connect(path: Path | str = DB_PATH) -> sqlite3.Connection:
global _con
with _lock:
if _con is not None:
return _con
Path(path).parent.mkdir(parents=True, exist_ok=True)
_con = sqlite3.connect(path, check_same_thread=False)
_con.row_factory = sqlite3.Row
_con.execute("PRAGMA journal_mode=WAL")
_con.execute("PRAGMA foreign_keys=ON")
_init_schema(_con)
return _con
def reset_for_tests(path: str = ":memory:") -> None:
"""Frische DB (Tests): bestehende Verbindung verwerfen, neue öffnen."""
global _con
with _lock:
if _con is not None:
_con.close()
_con = None
connect(path)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS topics(
name TEXT PRIMARY KEY, titel TEXT NOT NULL, art TEXT NOT NULL CHECK(art IN ('thema','uni')),
status TEXT NOT NULL DEFAULT 'neu', provider TEXT NOT NULL DEFAULT '',
auto TEXT NOT NULL DEFAULT '{}', erstellt TEXT DEFAULT (datetime('now')));
CREATE TABLE IF NOT EXISTS runs(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'running',
gestartet TEXT DEFAULT (datetime('now')), beendet TEXT, ebene TEXT DEFAULT '',
git_hash TEXT DEFAULT '', git_dirty INTEGER DEFAULT 0, budget_tokens INTEGER DEFAULT 0, grund TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS events(
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, ts TEXT DEFAULT (datetime('now')),
ebene TEXT NOT NULL, stage TEXT NOT NULL, item TEXT DEFAULT '', template TEXT DEFAULT '',
template_hash TEXT DEFAULT '', provider TEXT DEFAULT '', model TEXT DEFAULT '', role TEXT DEFAULT '',
status TEXT NOT NULL, dur_ms INTEGER DEFAULT 0, wait_ms INTEGER DEFAULT 0,
tok_in INTEGER DEFAULT 0, tok_out INTEGER DEFAULT 0, tok_cache_read INTEGER DEFAULT 0,
tok_cache_write INTEGER DEFAULT 0, meta TEXT DEFAULT '{}');
CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id);
CREATE TABLE IF NOT EXISTS quellen(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, art TEXT NOT NULL CHECK(art IN ('web','datei')),
titel TEXT NOT NULL, url TEXT DEFAULT '', snapshot TEXT NOT NULL, hash TEXT DEFAULT '',
runde INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu',
rolle TEXT NOT NULL DEFAULT 'stoff', atome_stand TEXT NOT NULL DEFAULT '');
CREATE TABLE IF NOT EXISTS soll(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]',
intro TEXT NOT NULL DEFAULT '', geprueft TEXT NOT NULL DEFAULT '[]');
CREATE TABLE IF NOT EXISTS atome(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
status TEXT NOT NULL DEFAULT 'neu', soll_id INTEGER, ziel_id INTEGER, baustein_id INTEGER,
ord INTEGER DEFAULT 0, merged_into INTEGER, braucht TEXT NOT NULL DEFAULT '[]');
CREATE INDEX IF NOT EXISTS idx_atome_topic ON atome(topic);
CREATE TABLE IF NOT EXISTS anker(
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, quelle_id INTEGER NOT NULL,
start INTEGER NOT NULL, ende INTEGER NOT NULL, zitat TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_anker_atom ON anker(atom_id);
CREATE TABLE IF NOT EXISTS kanten(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, von_atom INTEGER NOT NULL, zu_atom INTEGER NOT NULL,
art TEXT NOT NULL CHECK(art IN ('braucht','verwandt')), status TEXT NOT NULL DEFAULT 'aktiv',
UNIQUE(von_atom, zu_atom, art));
CREATE TABLE IF NOT EXISTS artefakte(
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel')),
inhalt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat');
CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id);
CREATE TABLE IF NOT EXISTS lernziele(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, text TEXT NOT NULL, soll_id INTEGER,
titel TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'neu');
CREATE TABLE IF NOT EXISTS bausteine(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER,
level TEXT NOT NULL DEFAULT 'M');
CREATE TABLE IF NOT EXISTS kapitel(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
art TEXT NOT NULL DEFAULT 'judge', level TEXT NOT NULL DEFAULT 'M');
CREATE TABLE IF NOT EXISTS sections(
baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer',
text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]',
qa_hash TEXT DEFAULT '', fix_versuche INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS leitner(
artefakt_id INTEGER PRIMARY KEY, box INTEGER NOT NULL DEFAULT 1,
faellig TEXT DEFAULT (date('now')), historie TEXT DEFAULT '[]');
CREATE TABLE IF NOT EXISTS befunde(
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, ebene TEXT NOT NULL, art TEXT NOT NULL,
item TEXT DEFAULT '', detail TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'offen');
"""
# Tabellen, deren Änderungen das Live-Board interessieren.
_LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte",
"lernziele", "bausteine", "kapitel", "sections", "befunde"}
def _init_schema(con: sqlite3.Connection) -> None:
con.executescript(_SCHEMA)
# Migration für Bestands-DBs: CREATE IF NOT EXISTS ergänzt keine neuen Spalten.
for zusatz in ("ALTER TABLE soll ADD COLUMN intro TEXT NOT NULL DEFAULT ''",
"ALTER TABLE lernziele ADD COLUMN titel TEXT NOT NULL DEFAULT ''",
"ALTER TABLE quellen ADD COLUMN rolle TEXT NOT NULL DEFAULT 'stoff'",
"ALTER TABLE sections ADD COLUMN qa_hash TEXT DEFAULT ''",
"ALTER TABLE topics ADD COLUMN auto TEXT NOT NULL DEFAULT '{}'",
"ALTER TABLE topics ADD COLUMN resets TEXT NOT NULL DEFAULT '{}'",
"ALTER TABLE bausteine ADD COLUMN kapitel_id INTEGER",
"ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'",
"ALTER TABLE bausteine ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
"ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
"ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''",
"ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0"):
try:
con.execute(zusatz)
except sqlite3.OperationalError:
pass # Spalte existiert schon
con.commit()
def _notify(tabelle: str, row: dict) -> None:
if on_change is not None and tabelle in _LIVE_TABELLEN:
try:
on_change(tabelle, row)
except Exception:
pass
def _rowdict(row: sqlite3.Row | None) -> dict | None:
return dict(row) if row is not None else None
def query(sql: str, params: tuple = ()) -> list[dict]:
con = connect()
with _lock:
return [dict(r) for r in con.execute(sql, params).fetchall()]
def one(sql: str, params: tuple = ()) -> dict | None:
con = connect()
with _lock:
return _rowdict(con.execute(sql, params).fetchone())
def execute(sql: str, params: tuple = ()) -> int:
"""→ lastrowid. Kein Notify — für Ledger/Interna."""
con = connect()
with _lock:
cur = con.execute(sql, params)
con.commit()
return cur.lastrowid
def insert(tabelle: str, **felder) -> int:
keys = ", ".join(felder)
marks = ", ".join("?" * len(felder))
rid = execute(f"INSERT INTO {tabelle}({keys}) VALUES({marks})", tuple(felder.values()))
_notify(tabelle, {"id": rid, **felder})
return rid
def update(tabelle: str, id_feld: str, id_wert, **felder) -> None:
sets = ", ".join(f"{k}=?" for k in felder)
execute(f"UPDATE {tabelle} SET {sets} WHERE {id_feld}=?", (*felder.values(), id_wert))
_notify(tabelle, {id_feld: id_wert, **felder})
def now() -> str:
"""UTC-Zeitstempel im SQLite-datetime('now')-Format (wie runs.gestartet).
Für Timestamp-Spalten — der Literal-String „jetzt" landete sonst als Müll
in runs.beendet und machte jede Dauer-Query unbrauchbar."""
import time
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
def j(obj) -> str:
return json.dumps(obj, ensure_ascii=False)
def uj(text: str | None, default=None):
if not text:
return default if default is not None else []
try:
return json.loads(text)
except ValueError:
return default if default is not None else []