"""FastAPI-App: REST + WebSocket-Live-Board + statisches Frontend. Der State-Snapshot liefert IMMER alle Karten (kein 20er-Cap) — das Frontend scrollt je Spalte; Deltas kommen über /ws.""" import asyncio import logging import subprocess from pathlib import Path from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel import agents import artefakte import db import guide import ledger import pipeline from config import (FRONTEND_DIST, PROJECT_ROOT, PRUEFUNG_PANEL, PRUEFUNG_SCHWELLE, topic_name_ok) from ws import hub logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") log = logging.getLogger("creator2.main") app = FastAPI(title="creator2") app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:5173"], allow_methods=["*"], allow_headers=["*"]) @app.on_event("startup") async def startup() -> None: db.connect() hub.bind_loop(asyncio.get_running_loop()) db.on_change = hub.push class TopicNeu(BaseModel): name: str titel: str = "" art: str = "thema" # thema | uni provider: str = "" class StartAuftrag(BaseModel): budget: int | None = None class UebenAntwort(BaseModel): artefakt_id: int richtig: bool class PruefungAntwort(BaseModel): ids: list[int] # die 4 Aussagen des Panels angekreuzt: list[int] # die als wahr markierten class AutoFlag(BaseModel): ebene: str an: bool def _ebenen_info(t: dict, run: dict | None) -> list[dict]: """Je Ebene: fertig/laeuft/auto + Tokens (Läufe seit letztem Entfernen) + Dauer (letzter Lauf).""" flags = db.uj(t["auto"], {}) resets = db.uj(t["resets"], {}) out = [] for name, _, marke in pipeline.EBENEN: rows = db.query( "SELECT SUM(e.tok_in+e.tok_out) AS tok," " (julianday(MAX(e.ts))-julianday(MIN(e.ts)))*86400 AS dauer" " FROM events e JOIN runs r ON e.run_id=r.id" " WHERE r.topic=? AND e.ebene=? AND e.id>? GROUP BY e.run_id ORDER BY e.run_id", (t["name"], name, resets.get(name, 0))) out.append({ "name": name, "fertig": pipeline._fertig_ab(t["status"], marke), "laeuft": bool(run and run["status"] == "running" and run["ebene"] == name), "auto": bool(flags.get(name, True)), "tokens": sum(int(r["tok"] or 0) for r in rows), "dauer_s": int(rows[-1]["dauer"] or 0) if rows else 0, }) return out @app.get("/api/topics") def topics_liste(): out = [] for t in db.query("SELECT * FROM topics ORDER BY erstellt DESC"): run = db.one("SELECT * FROM runs WHERE topic=? ORDER BY id DESC", (t["name"],)) out.append({**t, "run": run}) return out @app.post("/api/topics") def topic_anlegen(auftrag: TopicNeu): if not auftrag.name.strip(): raise HTTPException(400, "Name fehlt") # streng validieren: der Name wird Pfadkomponente (rmtree, Snapshots) und # Shell-Argument (Transfer) — „..", „/" oder „;curl…" wären sonst gefährlich if not topic_name_ok(auftrag.name): raise HTTPException(400, "Name: nur Buchstaben/Ziffern/-/_ (Anfang alphanumerisch)") if db.one("SELECT name FROM topics WHERE name=?", (auftrag.name,)): raise HTTPException(409, "Topic existiert") if auftrag.art not in ("thema", "uni"): raise HTTPException(400, "art muss thema|uni sein") from config import DEFAULT_PROVIDER db.insert("topics", name=auftrag.name, titel=auftrag.titel or auftrag.name, art=auftrag.art, provider=auftrag.provider or DEFAULT_PROVIDER) return {"ok": True} @app.post("/api/topics/{topic}/start") async def lauf_start(topic: str, auftrag: StartAuftrag): # async: lauf_starten hängt den Lauf per ensure_future an DIESEN Event-Loop if not db.one("SELECT name FROM topics WHERE name=?", (topic,)): raise HTTPException(404, "unbekanntes Topic") try: run_id = pipeline.lauf_starten(topic, auftrag.budget) except RuntimeError as e: raise HTTPException(409, str(e)) return {"run_id": run_id} @app.post("/api/topics/{topic}/stop") async def lauf_stop(topic: str): pipeline.lauf_stoppen(topic) return {"ok": True} @app.post("/api/topics/{topic}/pause") async def lauf_pause(topic: str): pipeline.lauf_pausieren(topic) return {"ok": True} def _befunde_aktuell(topic: str) -> list[dict]: """Offene Befunde des jeweils jüngsten Laufs je Ebene — in zwei billigen Pässen.""" neueste = db.query( "SELECT b.ebene, MAX(b.run_id) AS run_id FROM befunde b" " JOIN runs r ON r.id=b.run_id WHERE r.topic=? GROUP BY b.ebene", (topic,)) out: list[dict] = [] for ne in neueste: out += db.query("SELECT * FROM befunde WHERE run_id=? AND ebene=?" " AND status='offen' ORDER BY id", (ne["run_id"], ne["ebene"])) return out @app.get("/api/topics/{topic}/state") def state(topic: str): t = db.one("SELECT * FROM topics WHERE name=?", (topic,)) if not t: raise HTTPException(404, "unbekanntes Topic") run = db.one("SELECT * FROM runs WHERE topic=? ORDER BY id DESC", (topic,)) atome = db.query("SELECT * FROM atome WHERE topic=? ORDER BY id", (topic,)) # Nenner = SOLL je Atom (2 Karten + 1 Beispiel + 4 Aussagen), Zähler = # Verifizierte, je Typ aufs Ziel gekappt — so liest sich die Karte als # Fortschritt Richtung Vollständigkeit, nicht als Zeilenzählung. ziel_gesamt = sum(artefakte.ZIEL_ZEILEN.values()) fuer_atom = {} for a in db.query( "SELECT atom_id, typ, SUM(status='verifiziert') AS ok FROM artefakte" " WHERE atom_id IN (SELECT id FROM atome WHERE topic=?)" " GROUP BY atom_id, typ", (topic,)): e = fuer_atom.setdefault(a["atom_id"], {"verifiziert": 0, "gesamt": ziel_gesamt}) e["verifiziert"] += min(a["ok"], artefakte.ZIEL_ZEILEN.get(a["typ"], 0)) return { "topic": t, "run": run, "ebenen": _ebenen_info(t, run), "verbraucht": ledger.verbraucht(run["id"]) if run else 0, "quellen": db.query("SELECT id, art, titel, url, runde, status, rolle FROM quellen" " WHERE topic=? ORDER BY id", (topic,)), # Kandidaten nur zeigen, solange der Konsens noch nichts bestätigt hat "soll": db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'" " ORDER BY id", (topic,)) or db.query("SELECT * FROM soll WHERE topic=? ORDER BY id", (topic,)), "atome": [{**a, "artefakte": fuer_atom.get(a["id"], {"verifiziert": 0, "gesamt": ziel_gesamt})} for a in atome], "lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)), "bausteine": db.query( "SELECT b.*, s.stage FROM bausteine b LEFT JOIN sections s ON s.baustein_id=b.id" " WHERE b.topic=? ORDER BY b.ord", (topic,)), # Je Ebene nur der JÜNGSTE Messstand (veraltete Läufe sind Momentaufnahmen). # ENTKORRELIERT: erst MAX(run_id) je Ebene, dann gezielte Lookups — der # korrelierte Subquery brauchte 874 ms und staute hinter db._lock den # ganzen Server (state wird bei jedem Poll/WS-Push gerufen). "befunde": _befunde_aktuell(topic), "agenten": agents.aktive_agenten(), } @app.get("/api/health") def health(): return {"ok": True} # Themen-Transfer nur im lokalen Dev-Betrieb: der Server-Container (erkennbar an # /.dockerenv) hat weder ssh-Schlüssel noch das make-Gegenstück. _TRANSFER_LOKAL = not Path("/.dockerenv").exists() @app.get("/api/transfer") def transfer_info(): return {"verfuegbar": _TRANSFER_LOKAL} @app.post("/api/topics/{topic}/transfer/{richtung}") async def transfer_ausfuehren(topic: str, richtung: str): if not _TRANSFER_LOKAL: raise HTTPException(403, "Transfer nur lokal verfügbar") if richtung not in ("push", "pull"): raise HTTPException(404, "unbekannte Richtung") # Name gegen die Whitelist prüfen UND Existenz sichern — der Name fließt in # ein make-Shell-Rezept; ohne diese Schranke wäre er ein Injektionsvektor. if not topic_name_ok(topic) or not db.one("SELECT name FROM topics WHERE name=?", (topic,)): raise HTTPException(404, "unbekanntes Topic") res = await asyncio.to_thread( subprocess.run, ["make", f"server-{richtung}", f"TOPIC={topic}"], cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=600) if res.returncode: raise HTTPException(500, (res.stderr or res.stdout)[-400:]) return {"ok": True} @app.get("/api/topics/{topic}/guide") def guide_holen(topic: str): # kein markdown-Feld: das Frontend rendert aus kapitel; Roh-Markdown # verdoppelte nur die Payload (aak: +0,43 MB ungenutzt) return {"kapitel": guide.kapitel_struktur(topic)} @app.post("/api/topics/{topic}/soll-reset") async def soll_reset(topic: str): if not db.one("SELECT name FROM topics WHERE name=?", (topic,)): raise HTTPException(404, "unbekanntes Topic") pipeline.soll_reset(topic) return {"ok": True} @app.post("/api/topics/{topic}/voll-reset") async def voll_reset(topic: str): if not db.one("SELECT name FROM topics WHERE name=?", (topic,)): raise HTTPException(404, "unbekanntes Topic") pipeline.voll_reset(topic) return {"ok": True} @app.patch("/api/topics/{topic}/auto") async def auto_setzen(topic: str, flag: AutoFlag): t = db.one("SELECT auto FROM topics WHERE name=?", (topic,)) if not t: raise HTTPException(404, "unbekanntes Topic") flags = db.uj(t["auto"], {}) flags[flag.ebene] = flag.an db.update("topics", "name", topic, auto=db.j(flags)) return {"ok": True} @app.post("/api/topics/{topic}/ebene/{ebene}/entfernen") async def ebene_entfernen(topic: str, ebene: str): if not db.one("SELECT name FROM topics WHERE name=?", (topic,)): raise HTTPException(404, "unbekanntes Topic") try: pipeline.ebenen_entfernen(topic, ebene) except ValueError as e: raise HTTPException(400, str(e)) return {"ok": True} @app.post("/api/topics/{topic}/ebene/{ebene}/aktualisieren") async def ebene_aktualisieren(topic: str, ebene: str, tief: bool = False): if not db.one("SELECT name FROM topics WHERE name=?", (topic,)): raise HTTPException(404, "unbekanntes Topic") try: run_id = pipeline.aktualisierung_starten(topic, ebene, tief=tief) except ValueError as e: raise HTTPException(400, str(e)) except RuntimeError as e: raise HTTPException(409, str(e)) return {"run_id": run_id} @app.delete("/api/topics/{topic}") async def topic_loeschen(topic: str): if not db.one("SELECT name FROM topics WHERE name=?", (topic,)): raise HTTPException(404, "unbekanntes Topic") pipeline.topic_loeschen(topic) return {"ok": True} @app.get("/api/topics/{topic}/ueben") def ueben_faellig(topic: str): rows = db.query( "SELECT ar.id, ar.inhalt, a.titel, a.level, COALESCE(l.box,1) AS box," " COALESCE(l.faellig, date('now')) AS faellig" " FROM artefakte ar JOIN atome a ON ar.atom_id=a.id" " LEFT JOIN leitner l ON l.artefakt_id=ar.id" " WHERE a.topic=? AND ar.typ='flashcard' AND ar.status='verifiziert'" " AND COALESCE(l.faellig, date('now')) <= date('now') ORDER BY box, ar.id", (topic,)) return [{**r, "inhalt": db.uj(r["inhalt"], {})} for r in rows] _INTERVALLE = {1: 0, 2: 1, 3: 3, 4: 7, 5: 14} # Leitner: Tage bis zur Wiedervorlage @app.post("/api/ueben/antwort") def ueben_antwort(a: UebenAntwort): row = db.one("SELECT * FROM leitner WHERE artefakt_id=?", (a.artefakt_id,)) box = min(5, (row["box"] if row else 1) + 1) if a.richtig else 1 tage = _INTERVALLE[box] if row: db.execute("UPDATE leitner SET box=?, faellig=date('now', ?) WHERE artefakt_id=?", (box, f"+{tage} day", a.artefakt_id)) else: db.execute("INSERT INTO leitner(artefakt_id, box, faellig) VALUES(?,?,date('now', ?))", (a.artefakt_id, box, f"+{tage} day")) return {"box": box} @app.get("/api/topics/{topic}/baustein/{baustein_id}/karten") def baustein_karten(topic: str, baustein_id: int): # Flashcards genau dieses Bausteins — NICHT faellig-gefiltert (gerade gelesen → jetzt # prüfen). Beantwortet wird über /api/ueben/antwort, Leitner-Spacing bleibt intakt. rows = db.query( "SELECT ar.id, ar.inhalt, a.titel, a.level, COALESCE(l.box,1) AS box" " FROM artefakte ar JOIN atome a ON ar.atom_id=a.id" " LEFT JOIN leitner l ON l.artefakt_id=ar.id" " WHERE a.topic=? AND a.baustein_id=? AND ar.typ='flashcard'" " AND ar.status='verifiziert' ORDER BY a.ord, ar.id", (topic, baustein_id)) return [{**r, "inhalt": db.uj(r["inhalt"], {})} for r in rows] @app.get("/api/topics/{topic}/lernstand") def lernstand_holen(topic: str): return db.query("SELECT baustein_id, status, xp FROM lernstand WHERE topic=?", (topic,)) @app.post("/api/topics/{topic}/baustein/{baustein_id}/fertig") def baustein_fertig(topic: str, baustein_id: int): db.execute("INSERT INTO lernstand(topic, baustein_id, status) VALUES(?,?,'fertig')" " ON CONFLICT(topic, baustein_id) DO UPDATE SET status='fertig'", (topic, baustein_id)) return {"ok": True} @app.get("/api/topics/{topic}/baustein/{baustein_id}/pruefung") def pruefung_holen(topic: str, baustein_id: int): """4er-Panels aus verifizierten Aussagen der Baustein-Atome. OHNE Wahrheitswert (Anti-Cheat) und ohne Paar-Partner im selben Panel (verraten sich gegenseitig).""" import random rows = db.query( "SELECT ar.id, ar.inhalt FROM artefakte ar JOIN atome a ON a.id=ar.atom_id" " WHERE a.topic=? AND a.baustein_id=? AND ar.typ='aussage'" " AND ar.status='verifiziert'", (topic, baustein_id)) aussagen = [{"id": r["id"], "text": db.uj(r["inhalt"], {}).get("text", ""), "paar": db.uj(r["inhalt"], {}).get("paar", "")} for r in rows] random.shuffle(aussagen) panels: list[list[dict]] = [] for a in aussagen: # greedy: erstes Panel ohne Paar-Konflikt mit Platz for p in panels: if len(p) < PRUEFUNG_PANEL and all(x["paar"] != a["paar"] for x in p): p.append(a) break else: panels.append([a]) return [{"panel": [{"id": x["id"], "text": x["text"]} for x in p]} for p in panels if len(p) == PRUEFUNG_PANEL] @app.post("/api/topics/{topic}/pruefung/antwort") def pruefung_antwort(topic: str, a: PruefungAntwort): rows = db.query(f"SELECT ar.id, ar.inhalt, at.baustein_id FROM artefakte ar" f" JOIN atome at ON at.id=ar.atom_id" f" WHERE ar.id IN ({','.join('?' * len(a.ids))})", tuple(a.ids)) if len(rows) != len(a.ids): raise HTTPException(404, "unbekannte Aussage") wahre = {r["id"] for r in rows if db.uj(r["inhalt"], {}).get("wahr")} richtig = set(a.angekreuzt) == wahre # exakte Teilmenge, sonst falsch baustein_id = rows[0]["baustein_id"] row = db.one("SELECT xp FROM lernstand WHERE topic=? AND baustein_id=?", (topic, baustein_id)) xp = max(0, (row["xp"] if row else 0) + (1 if richtig else -1)) status = "fertig" if xp >= PRUEFUNG_SCHWELLE else "aktiv" db.execute("INSERT INTO lernstand(topic, baustein_id, status, xp) VALUES(?,?,?,?)" " ON CONFLICT(topic, baustein_id) DO UPDATE SET xp=excluded.xp," " status=excluded.status", (topic, baustein_id, status, xp)) aufloesung = [{"id": r["id"], "wahr": db.uj(r["inhalt"], {}).get("wahr", False), "erklaerung": db.uj(r["inhalt"], {}).get("erklaerung", "")} for r in rows] return {"richtig": richtig, "aufloesung": aufloesung, "xp": xp, "fertig": xp >= PRUEFUNG_SCHWELLE} @app.get("/api/runs/{run_id}/kennzahlen") def kennzahlen(run_id: int): return {"zeilen": ledger.kennzahlen(run_id), "verbraucht": ledger.verbraucht(run_id)} @app.websocket("/ws") async def websocket(ws: WebSocket): await hub.connect(ws) try: while True: await ws.receive_text() # Client sendet nichts Relevantes; hält die Verbindung except WebSocketDisconnect: hub.disconnect(ws) if FRONTEND_DIST.is_dir(): app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") @app.get("/") def index(): return FileResponse(FRONTEND_DIST / "index.html")