423 lines
17 KiB
Python
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")
|