216 lines
9.8 KiB
Python
216 lines
9.8 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');
|
|
CREATE TABLE IF NOT EXISTS lernstand(
|
|
topic TEXT NOT NULL, baustein_id INTEGER NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'aktiv', xp INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY(topic, baustein_id));
|
|
"""
|
|
|
|
# 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
|
|
# Status-Marker entfernter Ebenen (diagramme/fehlersuche) zurückrollen — sonst
|
|
# kennt _ORDNUNG den Status nicht und der Resume fiele fälschlich auf korpus zurück.
|
|
con.execute("UPDATE topics SET status='struktur_fertig'"
|
|
" WHERE status IN ('diagramme_fertig', 'fehlersuche_fertig')")
|
|
# Befunde entfernter Ebenen schließen — sie würden sonst ewig als „offen" im Board stehen
|
|
con.execute("UPDATE befunde SET status='veraltet'"
|
|
" WHERE status='offen' AND ebene IN ('diagramme', 'fehlersuche')")
|
|
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 []
|