Files
creator2/backend/main.py
2026-07-13 20:04:30 +02:00

423 lines
17 KiB
Python

"""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, alle: bool = False):
# Absolvierte Karten (richtig beantwortet → Leitner-faellig in der Zukunft)
# bleiben draußen — der Abruf startet nicht bei jedem Besuch von vorn.
# alle=True zeigt den vollen Stapel (Button „Alle erneut üben").
sql = ("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'")
if not alle:
sql += " AND COALESCE(l.faellig, date('now')) <= date('now')"
rows = db.query(sql + " 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")