413 lines
13 KiB
Python
413 lines
13 KiB
Python
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,
|
|
absolviert TEXT,
|
|
verstanden 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
|
|
# 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')"
|
|
)
|
|
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 get_vertiefung(topic: str, baustein: str, art: str) -> str | None:
|
|
db = await get_db()
|
|
cursor = await db.execute(
|
|
"SELECT md FROM baustein_texte WHERE topic = ? AND baustein = ? AND art = ?",
|
|
(topic, baustein, art),
|
|
)
|
|
row = await cursor.fetchone()
|
|
return row[0] if row else None
|
|
|
|
|
|
async def set_vertiefung(topic: str, baustein: str, art: str, md: str) -> None:
|
|
db = await get_db()
|
|
now = _now()
|
|
await db.execute(
|
|
"""INSERT INTO baustein_texte (topic, baustein, art, md, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(topic, baustein, art) DO UPDATE SET md = excluded.md, updated_at = excluded.updated_at""",
|
|
(topic, baustein, art, md, now, now),
|
|
)
|
|
await db.commit()
|
|
|
|
|
|
async def list_vertiefungen(topic: str) -> dict[str, set[str]]:
|
|
"""Baustein-Titel → vorhandene Text-Arten ('vertiefung'/'deepdive')."""
|
|
db = await get_db()
|
|
cursor = await db.execute("SELECT baustein, art FROM baustein_texte WHERE topic = ?", (topic,))
|
|
rows = await cursor.fetchall()
|
|
out: dict[str, set[str]] = {}
|
|
for baustein, art in rows:
|
|
out.setdefault(baustein, set()).add(art)
|
|
return out
|
|
|
|
|
|
async def list_baustein_progress(topic: str) -> list[dict]:
|
|
db = await get_db()
|
|
cursor = await db.execute(
|
|
"SELECT baustein, gute_antworten, absolviert, verstanden FROM baustein_progress WHERE topic = ?", (topic,)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [{"baustein": b, "gute_antworten": n, "absolviert": a, "verstanden": v} for b, n, a, v in rows]
|
|
|
|
|
|
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_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_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_absolviert_all() -> dict[str, set[str]]:
|
|
"""Alle absolvierten Bausteine in einem Query: topic → Baustein-Titel."""
|
|
db = await get_db()
|
|
cursor = await db.execute(
|
|
"SELECT topic, baustein FROM baustein_progress WHERE absolviert IS NOT NULL"
|
|
)
|
|
rows = await cursor.fetchall()
|
|
out: dict[str, set[str]] = {}
|
|
for topic, baustein in rows:
|
|
out.setdefault(topic, set()).add(baustein)
|
|
return out
|
|
|
|
|
|
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()
|