Files
creator/backend/database.py
2026-06-21 15:38:10 +02:00

474 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import aiosqlite
from config import DB_PATH
CREATE_GUIDES = """
CREATE TABLE IF NOT EXISTS guides (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
format TEXT NOT NULL,
instructions TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'queued',
progress TEXT,
step INTEGER,
error_msg TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
CREATE_PROGRESS = """
CREATE TABLE IF NOT EXISTS guide_progress (
guide_id TEXT NOT NULL,
chapter TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (guide_id, chapter)
)
"""
CREATE_TOPICS = """
CREATE TABLE IF NOT EXISTS topics (
name TEXT PRIMARY KEY,
created_at TEXT NOT NULL
)
"""
CREATE_ELEMENTS = """
CREATE TABLE IF NOT EXISTS elements (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
examples TEXT NOT NULL DEFAULT '[]',
hints TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
CREATE_BAUSTEIN_TEXTE = """
CREATE TABLE IF NOT EXISTS baustein_texte (
topic TEXT NOT NULL,
baustein TEXT NOT NULL,
art TEXT NOT NULL,
md TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, baustein, art)
)
"""
CREATE_BAUSTEIN_PROGRESS = """
CREATE TABLE IF NOT EXISTS baustein_progress (
topic TEXT NOT NULL,
baustein TEXT NOT NULL,
gute_antworten INTEGER NOT NULL DEFAULT 0,
streak INTEGER NOT NULL DEFAULT 0,
absolviert TEXT,
verstanden TEXT,
gemeistert TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, baustein)
)
"""
_db: aiosqlite.Connection | None = None
async def get_db() -> aiosqlite.Connection:
global _db
if _db is None:
_db = await aiosqlite.connect(DB_PATH)
_db.row_factory = None
return _db
async def init_db():
db = await get_db()
# WAL übersteht Crashes deutlich besser; busy_timeout fängt kurze Locks ab.
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout=5000")
await db.execute(CREATE_GUIDES)
await db.execute(CREATE_PROGRESS)
await db.execute(CREATE_TOPICS)
await db.execute(CREATE_ELEMENTS)
await db.execute(CREATE_BAUSTEIN_TEXTE)
await db.execute(CREATE_BAUSTEIN_PROGRESS)
try: # Migration für Bestands-DBs ohne step-Spalte
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
except aiosqlite.OperationalError:
pass
try: # Migration für Bestands-DBs ohne verstanden-Spalte (Mastery-Stufe)
await db.execute("ALTER TABLE baustein_progress ADD COLUMN verstanden TEXT")
except aiosqlite.OperationalError:
pass
try: # Migration für Bestands-DBs ohne gemeistert-Spalte (Meisterpfad 25)
await db.execute("ALTER TABLE baustein_progress ADD COLUMN gemeistert TEXT")
except aiosqlite.OperationalError:
pass
try: # Migration für Bestands-DBs ohne streak-Spalte (persistente Streak-Bonus-Folge)
await db.execute("ALTER TABLE baustein_progress ADD COLUMN streak INTEGER NOT NULL DEFAULT 0")
except aiosqlite.OperationalError:
pass
# Offene-Frage-Anker: Basis/Streak VOR der aktuell offenen Frage — macht die Bewertung
# serverseitig idempotent (Re-Bewertung) und driftfrei über Fragen hinweg.
for _spalte, _typ in (("offene_frage", "TEXT"), ("offene_basis", "INTEGER"), ("offene_streak", "INTEGER")):
try:
await db.execute(f"ALTER TABLE baustein_progress ADD COLUMN {_spalte} {_typ}")
except aiosqlite.OperationalError:
pass
# Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive')
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'")
if await cursor.fetchone():
await db.execute(
"INSERT OR IGNORE INTO baustein_texte (topic, baustein, art, md, created_at, updated_at) "
"SELECT topic, baustein, 'deepdive', md, created_at, updated_at FROM vertiefungen"
)
await db.execute("DROP TABLE vertiefungen")
await db.execute(
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
"WHERE status IN ('queued', 'generating')"
)
# Milestone-Migration auf Score-Skala 030: absolviert≥6, verstanden≥12, gemeistert≥18.
# Leitet die Meilensteine aus dem aktuellen Score ab (bestehende Timestamps bleiben),
# idempotent — nach einem Lauf stabil, im Normalbetrieb deckungsgleich mit der Prüf-Route.
await db.execute("UPDATE baustein_progress SET gute_antworten = 30 WHERE gute_antworten > 30") # Alt-Scores deckeln
for _spalte, _schwelle in (("absolviert", 6), ("verstanden", 12), ("gemeistert", 18)):
await db.execute(
f"UPDATE baustein_progress SET {_spalte} = COALESCE({_spalte}, datetime('now')) "
f"WHERE gute_antworten >= {_schwelle}"
)
await db.execute(f"UPDATE baustein_progress SET {_spalte} = NULL WHERE gute_antworten < {_schwelle}")
await db.commit()
async def close_db():
global _db
if _db is not None:
await _db.close()
_db = None
def _row_to_dict(row, cursor):
columns = [d[0] for d in cursor.description]
return dict(zip(columns, row))
async def create_guide(guide: dict) -> dict:
db = await get_db()
await db.execute(
"""INSERT INTO guides (id, topic, format, instructions, status, progress, created_at, updated_at)
VALUES (:id, :topic, :format, :instructions, :status, :progress, :created_at, :updated_at)""",
guide,
)
await db.commit()
return guide
async def get_guide(guide_id: str) -> dict | None:
db = await get_db()
cursor = await db.execute("SELECT * FROM guides WHERE id = ?", (guide_id,))
row = await cursor.fetchone()
if row is None:
return None
return _row_to_dict(row, cursor)
async def list_guides() -> list[dict]:
db = await get_db()
cursor = await db.execute("SELECT * FROM guides ORDER BY created_at DESC")
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def update_guide(guide_id: str, **fields) -> None:
sets = ", ".join(f"{k} = :{k}" for k in fields)
fields["id"] = guide_id
db = await get_db()
await db.execute(f"UPDATE guides SET {sets} WHERE id = :id", fields)
await db.commit()
async def delete_guide(guide_id: str) -> bool:
db = await get_db()
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
await db.commit()
return cursor.rowcount > 0
# --- Themen ---
async def create_topic(name: str) -> None:
from datetime import datetime, timezone
db = await get_db()
await db.execute(
"INSERT OR IGNORE INTO topics (name, created_at) VALUES (?, ?)",
(name, datetime.now(timezone.utc).isoformat()),
)
await db.commit()
async def list_topics() -> list[str]:
db = await get_db()
cursor = await db.execute("SELECT name FROM topics ORDER BY created_at DESC")
rows = await cursor.fetchall()
return [row[0] for row in rows]
async def delete_topic(name: str) -> None:
db = await get_db()
await db.execute("DELETE FROM topics WHERE name = ?", (name,))
await db.commit()
# --- Elemente ---
def _element_row(row, cursor) -> dict:
el = _row_to_dict(row, cursor)
el["examples"] = json.loads(el["examples"] or "[]")
el["hints"] = json.loads(el["hints"] or "[]")
return el
async def create_element(element: dict) -> dict:
db = await get_db()
await db.execute(
"""INSERT INTO elements (id, topic, title, description, examples, hints, created_at, updated_at)
VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""",
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
"hints": json.dumps(element["hints"], ensure_ascii=False)},
)
await db.commit()
return element
async def list_elements(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute(
"SELECT * FROM elements WHERE topic = ? ORDER BY updated_at DESC", (topic,)
)
rows = await cursor.fetchall()
return [_element_row(row, cursor) for row in rows]
async def get_element(element_id: str) -> dict | None:
db = await get_db()
cursor = await db.execute("SELECT * FROM elements WHERE id = ?", (element_id,))
row = await cursor.fetchone()
if row is None:
return None
return _element_row(row, cursor)
async def update_element(element_id: str, **fields) -> None:
for key in ("examples", "hints"):
if key in fields:
fields[key] = json.dumps(fields[key], ensure_ascii=False)
sets = ", ".join(f"{k} = :{k}" for k in fields)
fields["id"] = element_id
db = await get_db()
await db.execute(f"UPDATE elements SET {sets} WHERE id = :id", fields)
await db.commit()
async def delete_element(element_id: str) -> bool:
db = await get_db()
cursor = await db.execute("DELETE FROM elements WHERE id = ?", (element_id,))
await db.commit()
return cursor.rowcount > 0
# --- Kapitel-Fortschritt ---
async def list_progress_all() -> dict[str, set[str]]:
"""Kompletter Kapitel-Fortschritt in einem Query: guide_id → Kapitel-Titel."""
db = await get_db()
cursor = await db.execute("SELECT guide_id, chapter FROM guide_progress")
rows = await cursor.fetchall()
out: dict[str, set[str]] = {}
for guide_id, chapter in rows:
out.setdefault(guide_id, set()).add(chapter)
return out
async def list_progress(guide_id: str) -> list[str]:
db = await get_db()
cursor = await db.execute(
"SELECT chapter FROM guide_progress WHERE guide_id = ?", (guide_id,)
)
rows = await cursor.fetchall()
return [row[0] for row in rows]
async def set_progress(guide_id: str, chapter: str, done: bool) -> None:
from datetime import datetime, timezone
db = await get_db()
if done:
await db.execute(
"INSERT OR IGNORE INTO guide_progress (guide_id, chapter, created_at) VALUES (?, ?, ?)",
(guide_id, chapter, datetime.now(timezone.utc).isoformat()),
)
else:
await db.execute(
"DELETE FROM guide_progress WHERE guide_id = ? AND chapter = ?", (guide_id, chapter)
)
await db.commit()
async def delete_progress(guide_id: str) -> None:
db = await get_db()
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
await db.commit()
# --- Baustein-Lernen: Vertiefungen + Prüfungs-Fortschritt ---
def _now() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
async def list_baustein_progress(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute(
"SELECT baustein, gute_antworten, streak, absolviert, verstanden, gemeistert FROM baustein_progress WHERE topic = ?", (topic,)
)
rows = await cursor.fetchall()
return [{"baustein": b, "gute_antworten": n, "streak": s, "absolviert": a, "verstanden": v, "gemeistert": m} for b, n, s, a, v, m in rows]
async def get_baustein_progress(topic: str, baustein: str) -> dict:
"""Eine Baustein-Zeile inkl. Offene-Frage-Anker. Defaults, falls noch keine existiert."""
db = await get_db()
cursor = await db.execute(
"SELECT gute_antworten, streak, absolviert, verstanden, gemeistert, "
"offene_frage, offene_basis, offene_streak FROM baustein_progress "
"WHERE topic = ? AND baustein = ?",
(topic, baustein),
)
row = await cursor.fetchone()
if row is None:
return {"gute_antworten": 0, "streak": 0, "absolviert": None, "verstanden": None,
"gemeistert": None, "offene_frage": None, "offene_basis": None, "offene_streak": None}
return {"gute_antworten": row[0], "streak": row[1], "absolviert": row[2], "verstanden": row[3],
"gemeistert": row[4], "offene_frage": row[5], "offene_basis": row[6], "offene_streak": row[7]}
async def set_offene_frage(topic: str, baustein: str, frage: str, basis: int, streak: int) -> None:
"""Friert Basis + Streak VOR der jetzt offenen Frage ein (Anker für idempotente Re-Bewertung)."""
db = await get_db()
now = _now()
await db.execute(
"""INSERT INTO baustein_progress (topic, baustein, offene_frage, offene_basis, offene_streak, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(topic, baustein) DO UPDATE SET
offene_frage = excluded.offene_frage, offene_basis = excluded.offene_basis,
offene_streak = excluded.offene_streak, updated_at = excluded.updated_at""",
(topic, baustein, frage, basis, streak, now),
)
await db.commit()
async def set_baustein_score(topic: str, baustein: str, score: int) -> int:
"""Setzt den Score absolut (vom Aufrufer geclampt) und liefert ihn zurück."""
db = await get_db()
await db.execute(
"""INSERT INTO baustein_progress (topic, baustein, gute_antworten, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(topic, baustein) DO UPDATE SET
gute_antworten = excluded.gute_antworten, updated_at = excluded.updated_at""",
(topic, baustein, score, _now()),
)
await db.commit()
return score
async def set_baustein_streak(topic: str, baustein: str, streak: int) -> None:
"""Persistiert die Streak-Folge (Bonus) absolut — überlebt Reload/Session-Wechsel."""
db = await get_db()
await db.execute(
"""INSERT INTO baustein_progress (topic, baustein, streak, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(topic, baustein) DO UPDATE SET
streak = excluded.streak, updated_at = excluded.updated_at""",
(topic, baustein, streak, _now()),
)
await db.commit()
async def set_baustein_verstanden(topic: str, baustein: str) -> bool:
"""Markiert verstanden (Mastery); True nur beim ersten Mal. Sticky wie absolviert."""
db = await get_db()
now = _now()
await db.execute(
"INSERT OR IGNORE INTO baustein_progress (topic, baustein, gute_antworten, updated_at) VALUES (?, ?, 0, ?)",
(topic, baustein, now),
)
cursor = await db.execute(
"UPDATE baustein_progress SET verstanden = ?, updated_at = ? "
"WHERE topic = ? AND baustein = ? AND verstanden IS NULL",
(now, now, topic, baustein),
)
await db.commit()
return cursor.rowcount > 0
async def set_baustein_gemeistert(topic: str, baustein: str) -> bool:
"""Markiert gemeistert (Meisterpfad, Score 25); True nur beim ersten Mal. Sticky."""
db = await get_db()
now = _now()
await db.execute(
"INSERT OR IGNORE INTO baustein_progress (topic, baustein, gute_antworten, updated_at) VALUES (?, ?, 0, ?)",
(topic, baustein, now),
)
cursor = await db.execute(
"UPDATE baustein_progress SET gemeistert = ?, updated_at = ? "
"WHERE topic = ? AND baustein = ? AND gemeistert IS NULL",
(now, now, topic, baustein),
)
await db.commit()
return cursor.rowcount > 0
async def set_baustein_absolviert(topic: str, baustein: str) -> bool:
"""Markiert absolviert; True nur beim ersten Mal (steuert den Element-Task)."""
db = await get_db()
now = _now()
await db.execute(
"INSERT OR IGNORE INTO baustein_progress (topic, baustein, gute_antworten, updated_at) VALUES (?, ?, 0, ?)",
(topic, baustein, now),
)
cursor = await db.execute(
"UPDATE baustein_progress SET absolviert = ?, updated_at = ? "
"WHERE topic = ? AND baustein = ? AND absolviert IS NULL",
(now, now, topic, baustein),
)
await db.commit()
return cursor.rowcount > 0
async def list_baustein_levels_all() -> dict[str, dict[str, set[str]]]:
"""Bausteine je Meilenstein in EINER Query: {"absolviert"/"verstanden"/"gemeistert": {topic → {Titel}}}."""
db = await get_db()
cursor = await db.execute(
"SELECT topic, baustein, absolviert, verstanden, gemeistert FROM baustein_progress"
)
rows = await cursor.fetchall()
levels: dict[str, dict[str, set[str]]] = {"absolviert": {}, "verstanden": {}, "gemeistert": {}}
for topic, baustein, absolviert, verstanden, gemeistert in rows:
if absolviert is not None:
levels["absolviert"].setdefault(topic, set()).add(baustein)
if verstanden is not None:
levels["verstanden"].setdefault(topic, set()).add(baustein)
if gemeistert is not None:
levels["gemeistert"].setdefault(topic, set()).add(baustein)
return levels
async def delete_baustein_daten(topic: str) -> None:
db = await get_db()
await db.execute("DELETE FROM baustein_texte WHERE topic = ?", (topic,))
await db.execute("DELETE FROM baustein_progress WHERE topic = ?", (topic,))
await db.commit()