630 lines
28 KiB
Python
630 lines
28 KiB
Python
import asyncio
|
||
import shutil
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import FileResponse
|
||
|
||
from agents import provider_available
|
||
from config import PROJECTS_DIR, UNI_DIR, PROVIDERS
|
||
from database import (
|
||
create_guide, delete_guide, get_guide, list_guides,
|
||
create_topic, list_topics as db_list_topics, delete_topic,
|
||
list_progress, set_progress, delete_progress,
|
||
create_element, list_elements, get_element, update_element, delete_element,
|
||
list_baustein_progress, get_baustein_progress, set_offene_frage,
|
||
set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten,
|
||
)
|
||
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster
|
||
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
||
from lernen import NOETIG, MASTERY, MEISTERN, NIVEAUS, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, score_berechnen, floor_aus_meilensteinen
|
||
from guide import generate_guide, guide_slot_dateien
|
||
from pipeline import cancel_guide
|
||
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
|
||
from models import (
|
||
GuideCreateRequest, GuideResponse,
|
||
TopicCreateRequest,
|
||
BausteineCreateRequest, BausteineStatusResponse,
|
||
GuideChatRequest, GuideChatResponse,
|
||
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
|
||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||
ElementRefineRequest, ElementRefineResponse,
|
||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||
FolderResponse, BausteineQuelleUpdate, BausteineQuelleResponse, BausteinUebersicht,
|
||
BausteinChatRequest, BausteinChatResponse,
|
||
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
|
||
)
|
||
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir, quelle_path, safe_ordner
|
||
from fsutil import atomic_write_json
|
||
|
||
router = APIRouter(prefix="/api")
|
||
|
||
|
||
@router.get("/providers", response_model=list[ProviderInfo])
|
||
async def get_providers():
|
||
return [{"id": pid, "available": provider_available(pid)} for pid in PROVIDERS]
|
||
|
||
|
||
@router.get("/topics")
|
||
async def get_topics():
|
||
db_topics = await db_list_topics()
|
||
guides = await list_guides()
|
||
derived = {g["topic"] for g in guides}
|
||
derived.update(bausteine_topics())
|
||
derived.update(job["topic"] for job in active_bausteine())
|
||
# DB ist führend (Reihenfolge: neueste zuerst); Abgeleitetes ohne DB-Eintrag hinten anhängen
|
||
return db_topics + sorted(derived - set(db_topics))
|
||
|
||
|
||
@router.get("/stats")
|
||
async def get_stats():
|
||
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert."""
|
||
guides, progress, levels = await lade_lernstand()
|
||
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
|
||
if PROJECTS_DIR.is_dir():
|
||
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||
return {"themen": len(themen), "formate": formate_stats(guides, progress, levels)}
|
||
|
||
|
||
@router.get("/topics/fortschritt")
|
||
async def topic_fortschritt(topic: str):
|
||
"""Absolviert-Status pro Format + Themen-Abschluss — fürs Freischalten der nächsten Ausbaustufe."""
|
||
guides, progress, levels = await lade_lernstand()
|
||
status = {fmt: ist_absolviert(topic, fmt, guides, progress, levels) for fmt in FORMATE}
|
||
status["abgeschlossen"] = thema_abgeschlossen(topic, guides, progress, levels)
|
||
return status
|
||
|
||
|
||
@router.post("/topics")
|
||
async def add_topic(req: TopicCreateRequest):
|
||
await create_topic(req.name.strip())
|
||
return {"ok": True}
|
||
|
||
|
||
@router.delete("/topics")
|
||
async def remove_topic(topic: str):
|
||
await delete_topic(topic)
|
||
await delete_baustein_daten(topic)
|
||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||
return {"ok": True}
|
||
|
||
|
||
def _safe_project_name(name: str) -> str:
|
||
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name:
|
||
raise HTTPException(400, "Ungültiger Projektname")
|
||
return name
|
||
|
||
|
||
@router.get("/projects", response_model=list[ProjectResponse])
|
||
async def list_projects():
|
||
if not PROJECTS_DIR.is_dir():
|
||
return []
|
||
return [{"name": entry.name} for entry in sorted(PROJECTS_DIR.iterdir()) if entry.is_dir()]
|
||
|
||
|
||
@router.delete("/projects/{name}")
|
||
async def remove_project(name: str):
|
||
_safe_project_name(name)
|
||
pdir = project_dir(name)
|
||
if not pdir.is_dir():
|
||
raise HTTPException(404, "Projekt nicht gefunden")
|
||
shutil.rmtree(pdir)
|
||
return {"ok": True}
|
||
|
||
|
||
@router.get("/folders", response_model=list[FolderResponse])
|
||
async def list_folders(kind: str):
|
||
"""Ordner für die Quellen-Auswahl: kind=projekt → projects/, kind=uni → uni/."""
|
||
base = {"projekt": (PROJECTS_DIR, "projects"), "uni": (UNI_DIR, "uni")}.get(kind)
|
||
if base is None:
|
||
raise HTTPException(400, "kind muss 'projekt' oder 'uni' sein")
|
||
root, prefix = base
|
||
if not root.is_dir():
|
||
return []
|
||
return [{"name": e.name, "ort": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()]
|
||
|
||
|
||
# --- Bausteine ---
|
||
|
||
@router.get("/bausteine/status", response_model=BausteineStatusResponse)
|
||
async def get_bausteine_status(topic: str):
|
||
return bausteine_status(topic)
|
||
|
||
|
||
@router.get("/bausteine/active")
|
||
async def get_active_bausteine():
|
||
return active_bausteine()
|
||
|
||
|
||
@router.post("/bausteine")
|
||
async def create_bausteine(req: BausteineCreateRequest):
|
||
topic = req.topic.strip()
|
||
if bausteine_status(topic)["generating"]:
|
||
return {"ok": True, "status": "already_generating"}
|
||
await create_topic(topic)
|
||
qp = quelle_path(topic)
|
||
# Quelle nur beim ERSTEN Mal festschreiben; ▶/Resume erhält die bestehende Wahl.
|
||
if not qp.exists():
|
||
typ, ort = req.source_type, req.source_ort.strip()
|
||
if typ in ("projekt", "uni"):
|
||
ordner = safe_ordner(ort)
|
||
if ordner is None or not ordner.is_dir():
|
||
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
|
||
elif typ == "link":
|
||
if not ort.lower().startswith(("http://", "https://")):
|
||
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
|
||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||
atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()})
|
||
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase))
|
||
return {"ok": True}
|
||
|
||
|
||
@router.post("/bausteine/cancel")
|
||
async def cancel_bausteine_route(topic: str):
|
||
if not cancel_bausteine(topic):
|
||
raise HTTPException(404, "Keine laufende Generierung")
|
||
return {"ok": True}
|
||
|
||
|
||
@router.delete("/bausteine")
|
||
async def remove_bausteine(topic: str):
|
||
reset_bausteine(topic)
|
||
return {"ok": True}
|
||
|
||
|
||
def _validate_quelle(typ: str, ort: str) -> None:
|
||
"""Quellen-Eingabe prüfen (gleiche Regeln wie beim Erstellen)."""
|
||
if typ in ("projekt", "uni"):
|
||
ordner = safe_ordner(ort)
|
||
if ordner is None or not ordner.is_dir():
|
||
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
|
||
elif typ == "link":
|
||
if not ort.lower().startswith(("http://", "https://")):
|
||
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
|
||
|
||
|
||
@router.get("/bausteine/quelle", response_model=BausteineQuelleResponse)
|
||
async def get_bausteine_quelle(topic: str):
|
||
return lade_quelle(topic)
|
||
|
||
|
||
@router.put("/bausteine/quelle", response_model=BausteineQuelleResponse)
|
||
async def update_bausteine_quelle(req: BausteineQuelleUpdate):
|
||
"""Nur speichern — KEINE Neugenerierung. Quellen-/Spec-Wahl überschreiben."""
|
||
topic, typ, ort = req.topic.strip(), req.type, req.ort.strip()
|
||
_validate_quelle(typ, ort)
|
||
qp = quelle_path(topic)
|
||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||
daten = {"type": typ, "ort": ort, "spec": req.spec.strip()}
|
||
atomic_write_json(qp, daten)
|
||
return daten
|
||
|
||
|
||
@router.get("/bausteine/uebersicht", response_model=list[BausteinUebersicht])
|
||
async def get_bausteine_uebersicht(topic: str):
|
||
return lade_uebersicht(topic)
|
||
|
||
|
||
@router.get("/bausteine/frage-muster")
|
||
async def get_frage_muster(topic: str, baustein: str):
|
||
"""Vordefinierte Frage-Muster eines Bausteins (leer = Fallback auf Live-Generierung)."""
|
||
return {"muster": lade_frage_muster(topic, baustein)}
|
||
|
||
|
||
# --- Baustein-Lernen: Chat, Prüfung ---
|
||
|
||
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
|
||
async def baustein_lernstand(topic: str):
|
||
"""Prüfungs-Stand pro Baustein (roher Titel als Key)."""
|
||
progress = await list_baustein_progress(topic)
|
||
bausteine = {
|
||
p["baustein"]: {
|
||
"gute_antworten": p["gute_antworten"],
|
||
"streak": p["streak"],
|
||
"absolviert": p["absolviert"] is not None,
|
||
"verstanden": p["verstanden"] is not None,
|
||
"gemeistert": p["gemeistert"] is not None,
|
||
}
|
||
for p in progress
|
||
}
|
||
return {"bausteine": bausteine}
|
||
|
||
|
||
@router.post("/bausteine/chat", response_model=BausteinChatResponse)
|
||
async def baustein_chat_route(req: BausteinChatRequest):
|
||
reply = await baustein_chat(
|
||
req.topic, req.baustein, req.section, req.section_kompakt,
|
||
[m.model_dump() for m in req.messages], provider=req.provider,
|
||
)
|
||
return {"reply": reply}
|
||
|
||
|
||
# Bewertungen je (topic, baustein) serialisieren — sonst überschreiben zwei
|
||
# gleichzeitige Bewertungen den absoluten Score mit veralteter Basis (Race).
|
||
_pruef_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||
|
||
|
||
def _pruef_lock(topic: str, baustein: str) -> asyncio.Lock:
|
||
key = (topic, baustein)
|
||
lock = _pruef_locks.get(key)
|
||
if lock is None:
|
||
lock = _pruef_locks[key] = asyncio.Lock()
|
||
return lock
|
||
|
||
|
||
def _basis(stand: dict, frage: str) -> tuple[int, bool]:
|
||
"""Score-Basis VOR der Frage. Gleiche offene Frage → Re-Bewertung auf derselben Basis
|
||
(idempotent); sonst neue Frage auf dem aktuellen Stand. → (basis, re_bewertung)."""
|
||
re_bewertung = stand["offene_frage"] == frage and stand["offene_basis"] is not None
|
||
return (stand["offene_basis"] if re_bewertung else stand["gute_antworten"]), re_bewertung
|
||
|
||
|
||
def _farbe(punkte: int) -> str:
|
||
"""Punkte-Delta → grobe Einfärbung der Bubble."""
|
||
return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht")
|
||
|
||
|
||
def _erklaeren_delta(niveau: str, nachgefragt: bool) -> int:
|
||
"""Erklären-Form: Punkt-Delta aus dem Niveau (−1…+3). Mit Nachfrage höchstens +1."""
|
||
delta = NIVEAUS.get(niveau, 0)
|
||
return min(delta, 1) if nachgefragt else delta
|
||
|
||
|
||
async def _punkte_buchen(req, frage: str, delta: int) -> dict:
|
||
"""Score driftfrei buchen (Lock + offene_frage-Anker + Meilensteine). Gemeinsam für
|
||
Quiz/Lückentext (deterministisch/schnell). → Response-Felder ohne feedback."""
|
||
async with _pruef_lock(req.topic, req.baustein):
|
||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||
absolviert = stand["absolviert"] is not None
|
||
verstanden = stand["verstanden"] is not None
|
||
gemeistert = stand["gemeistert"] is not None
|
||
basis, re_bewertung = _basis(stand, frage)
|
||
if not re_bewertung:
|
||
await set_offene_frage(req.topic, req.baustein, frage, basis, 0)
|
||
floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert)
|
||
score = score_berechnen(basis, delta, floor, req.cap)
|
||
punkte = score - basis
|
||
gute = await set_baustein_score(req.topic, req.baustein, score)
|
||
if score >= NOETIG and not absolviert:
|
||
absolviert = True
|
||
if await set_baustein_absolviert(req.topic, req.baustein):
|
||
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
|
||
if score >= MASTERY and not verstanden:
|
||
await set_baustein_verstanden(req.topic, req.baustein)
|
||
verstanden = True
|
||
if score >= MEISTERN and not gemeistert:
|
||
await set_baustein_gemeistert(req.topic, req.baustein)
|
||
gemeistert = True
|
||
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute,
|
||
"absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||
|
||
|
||
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
|
||
async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||
gute = stand["gute_antworten"]
|
||
absolviert = stand["absolviert"] is not None
|
||
verstanden = stand["verstanden"] is not None
|
||
gemeistert = stand["gemeistert"] is not None
|
||
kompakt = req.section_kompakt
|
||
msgs = [m.model_dump() for m in req.messages]
|
||
|
||
if req.aktion == "frage":
|
||
if req.muster.strip():
|
||
# Aus gezogenem Muster eine konkrete, leicht andere Frage formulieren (kein Dedup nötig).
|
||
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||
else:
|
||
# Fallback (kein Muster-Sidecar): Live-Generierung mit zufälligem Typ/Subbaustein.
|
||
subs = subbausteine_titel(req.topic, req.baustein) # Fokus-Kandidaten (zufällig gewählt)
|
||
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, provider=req.provider)
|
||
if frage is None:
|
||
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
|
||
return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||
|
||
if req.aktion == "diskussion":
|
||
if not req.frage.strip():
|
||
raise HTTPException(400, "Diskussion braucht eine laufende Frage")
|
||
reply = await baustein_diskussion(
|
||
req.topic, req.baustein, req.section, kompakt,
|
||
req.frage, req.letzte_bewertung or None, msgs, provider=req.provider,
|
||
)
|
||
if reply is None:
|
||
raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen")
|
||
return {"reply": reply, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||
|
||
# --- Quiz: leicht (1 von 4) +1/−1 · schwer (x von 4) +3/−1 — deterministisch ---
|
||
if req.aktion == "quiz_frage":
|
||
if not req.muster.strip():
|
||
raise HTTPException(400, "Quiz braucht ein Muster")
|
||
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, schwer=req.schwer, provider=req.provider)
|
||
if quiz is None:
|
||
raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen")
|
||
return {"frage": quiz["frage"], "optionen": quiz["optionen"],
|
||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||
|
||
if req.aktion == "quiz_antwort":
|
||
if not req.frage.strip():
|
||
raise HTTPException(400, "Quiz-Antwort braucht eine Frage")
|
||
getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge
|
||
delta = (3 if req.schwer else 1) if getroffen else -1
|
||
res = await _punkte_buchen(req, req.frage, delta)
|
||
res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig."
|
||
return res
|
||
|
||
# --- Lückentext: leicht (Begriff aus 4) +1/−1 · schwer (frei tippen) +3/−1 ---
|
||
if req.aktion == "lueck_frage":
|
||
if not req.muster.strip():
|
||
raise HTTPException(400, "Lückentext braucht ein Muster")
|
||
if req.schwer:
|
||
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||
if lt is None:
|
||
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||
return {"satz": lt["satz"], "loesung": lt["loesung"], "alternativen": lt["alternativen"],
|
||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||
if lw is None:
|
||
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||
return {"satz": lw["satz"], "optionen": lw["optionen"],
|
||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||
|
||
if req.aktion == "lueck_antwort":
|
||
if not req.frage.strip():
|
||
raise HTTPException(400, "Lückentext-Antwort braucht einen Satz")
|
||
if req.schwer: # frei getippt → Synonym-tolerante KI-Prüfung
|
||
ok = await lueckentext_pruefen(req.topic, req.baustein, req.frage, req.loesung, req.alternativen, req.eingabe, provider=req.provider)
|
||
feedback = "Richtig!" if ok else f"Nicht ganz — erwartet war „{req.loesung}“."
|
||
else: # Begriff aus 4 gewählt → deterministisch
|
||
ok = set(req.auswahl) == set(req.korrekt)
|
||
feedback = "Richtig!" if ok else "Nicht ganz — der markierte Begriff war richtig."
|
||
res = await _punkte_buchen(req, req.frage, (3 if req.schwer else 1) if ok else -1)
|
||
res["feedback"] = feedback
|
||
return res
|
||
|
||
# aktion "antwort" (Agent 1 schnell) / "antwort_pruefen" (Agent 2 genau).
|
||
if not any(m.get("role") == "user" for m in msgs):
|
||
raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort")
|
||
if not req.frage.strip():
|
||
raise HTTPException(400, "Antwort braucht eine laufende Frage")
|
||
|
||
if req.aktion == "antwort":
|
||
# Agent 1: nur Vorschau — Niveau + voraussichtliche Punkte, NICHTS persistieren, kein Anker.
|
||
data = await pruefung_bewertung_schnell(
|
||
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider,
|
||
)
|
||
if data is None:
|
||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||
basis, _ = _basis(stand, req.frage)
|
||
floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert)
|
||
score = score_berechnen(basis, _erklaeren_delta(data["niveau"], req.nachgefragt), floor, req.cap)
|
||
punkte = score - basis
|
||
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
|
||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||
|
||
# aktion "antwort_pruefen" (Agent 2 genau): verbindlich, persistiert. Serialisiert je Baustein,
|
||
# Basis driftfrei über den offene_frage-Anker. NUR hier ändert sich der Score.
|
||
async with _pruef_lock(req.topic, req.baustein):
|
||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||
absolviert = stand["absolviert"] is not None
|
||
verstanden = stand["verstanden"] is not None
|
||
gemeistert = stand["gemeistert"] is not None
|
||
basis, re_bewertung = _basis(stand, req.frage)
|
||
if not re_bewertung:
|
||
await set_offene_frage(req.topic, req.baustein, req.frage, basis, 0)
|
||
|
||
data = await pruefung_bewertung(
|
||
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider,
|
||
role="guide" if req.gruendlich else "judge", begruendung=req.begruendung,
|
||
)
|
||
if data is None:
|
||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||
|
||
floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert)
|
||
score = score_berechnen(basis, _erklaeren_delta(data["niveau"], req.nachgefragt), floor, req.cap)
|
||
punkte = score - basis
|
||
gute = await set_baustein_score(req.topic, req.baustein, score)
|
||
if score >= NOETIG and not absolviert:
|
||
absolviert = True
|
||
if await set_baustein_absolviert(req.topic, req.baustein):
|
||
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
|
||
if score >= MASTERY and not verstanden:
|
||
await set_baustein_verstanden(req.topic, req.baustein)
|
||
verstanden = True
|
||
if score >= MEISTERN and not gemeistert:
|
||
await set_baustein_gemeistert(req.topic, req.baustein)
|
||
gemeistert = True
|
||
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
|
||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||
|
||
|
||
# --- Guides ---
|
||
|
||
@router.post("/guides", response_model=GuideResponse)
|
||
async def create(req: GuideCreateRequest):
|
||
guides, progress, levels = await lade_lernstand()
|
||
grund = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
|
||
if grund:
|
||
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund)
|
||
await create_topic(req.topic.strip())
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
guide = {
|
||
"id": str(uuid.uuid4()),
|
||
"topic": req.topic.strip(),
|
||
"format": req.format,
|
||
"instructions": req.instructions.strip(),
|
||
"status": "queued",
|
||
"progress": None,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
await create_guide(guide)
|
||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider))
|
||
return guide
|
||
|
||
|
||
@router.get("/guides", response_model=list[GuideResponse])
|
||
async def list_all():
|
||
return await list_guides()
|
||
|
||
|
||
@router.get("/guides/locks")
|
||
async def guide_locks(topic: str):
|
||
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
|
||
guides, progress, levels = await lade_lernstand()
|
||
return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("OnePager", *FORMATE)}
|
||
|
||
|
||
@router.get("/guides/{guide_id}", response_model=GuideResponse)
|
||
async def get_one(guide_id: str):
|
||
guide = await get_guide(guide_id)
|
||
if guide is None:
|
||
raise HTTPException(404, "Guide nicht gefunden")
|
||
return guide
|
||
|
||
|
||
@router.get("/guides/{guide_id}/content")
|
||
async def guide_content(guide_id: str):
|
||
guide = await get_guide(guide_id)
|
||
if guide is None:
|
||
raise HTTPException(404, "Guide nicht gefunden")
|
||
if guide["status"] != "done":
|
||
raise HTTPException(404, "Inhalt nicht verfügbar")
|
||
path = guide_content_path(guide["topic"], guide["format"])
|
||
if not path.exists():
|
||
raise HTTPException(404, "Datei nicht gefunden")
|
||
return FileResponse(path, media_type="application/json")
|
||
|
||
|
||
@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse)
|
||
async def guide_chat(guide_id: str, req: GuideChatRequest):
|
||
guide = await get_guide(guide_id)
|
||
if guide is None:
|
||
raise HTTPException(404, "Guide nicht gefunden")
|
||
reply = await chat_with_guide(
|
||
guide["topic"], guide["format"], req.section, req.outline,
|
||
[m.model_dump() for m in req.messages],
|
||
provider=req.provider,
|
||
)
|
||
return {"reply": reply}
|
||
|
||
|
||
# --- Elemente (persönliche Zusammenfassung) ---
|
||
|
||
@router.get("/elements", response_model=list[ElementResponse])
|
||
async def get_elements(topic: str):
|
||
return await list_elements(topic)
|
||
|
||
|
||
@router.post("/elements", response_model=ElementResponse)
|
||
async def post_element(req: ElementCreateRequest):
|
||
fields = await generate_element(req.topic, req.hint, provider=req.provider)
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
element = {"id": str(uuid.uuid4()), "topic": req.topic, **fields, "created_at": now, "updated_at": now}
|
||
await create_element(element)
|
||
return element
|
||
|
||
|
||
@router.post("/elements/{element_id}/chat", response_model=ElementChatResponse)
|
||
async def element_chat(element_id: str, req: ElementChatRequest):
|
||
element = await get_element(element_id)
|
||
if element is None:
|
||
raise HTTPException(404, "Element nicht gefunden")
|
||
reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
|
||
return {"reply": reply, "changes": changes}
|
||
|
||
|
||
@router.post("/elements/{element_id}/refine", response_model=ElementRefineResponse)
|
||
async def element_refine(element_id: str, req: ElementRefineRequest):
|
||
element = await get_element(element_id)
|
||
if element is None:
|
||
raise HTTPException(404, "Element nicht gefunden")
|
||
change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
|
||
if change is None:
|
||
raise HTTPException(502, "Überarbeitung fehlgeschlagen — bitte erneut versuchen")
|
||
return {"change": change}
|
||
|
||
|
||
@router.put("/elements/{element_id}", response_model=ElementResponse)
|
||
async def put_element(element_id: str, req: ElementUpdateRequest):
|
||
if await get_element(element_id) is None:
|
||
raise HTTPException(404, "Element nicht gefunden")
|
||
fields = req.model_dump(exclude_unset=True, exclude_none=True)
|
||
if fields:
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
await update_element(element_id, **fields, updated_at=now)
|
||
return await get_element(element_id)
|
||
|
||
|
||
@router.post("/elements/{element_id}/style", response_model=ElementStyleResponse)
|
||
async def element_style(element_id: str, req: ElementCheckRequest):
|
||
element = await get_element(element_id)
|
||
if element is None:
|
||
raise HTTPException(404, "Element nicht gefunden")
|
||
changes = await style_element(element, provider=req.provider)
|
||
if changes is None:
|
||
raise HTTPException(502, "Stil-Prüfung fehlgeschlagen — bitte erneut versuchen")
|
||
return {"changes": changes}
|
||
|
||
|
||
@router.post("/elements/{element_id}/check", response_model=ElementCheckResponse)
|
||
async def element_check(element_id: str, req: ElementCheckRequest):
|
||
element = await get_element(element_id)
|
||
if element is None:
|
||
raise HTTPException(404, "Element nicht gefunden")
|
||
suggestions = await check_element(element, provider=req.provider)
|
||
if suggestions is None:
|
||
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
|
||
return {"suggestions": suggestions}
|
||
|
||
|
||
@router.delete("/elements/{element_id}")
|
||
async def remove_element(element_id: str):
|
||
if not await delete_element(element_id):
|
||
raise HTTPException(404, "Element nicht gefunden")
|
||
return {"ok": True}
|
||
|
||
|
||
@router.post("/guides/{guide_id}/cancel")
|
||
async def cancel(guide_id: str):
|
||
cancelled = await cancel_guide(guide_id)
|
||
if not cancelled:
|
||
raise HTTPException(404, "Kein aktiver Prozess gefunden")
|
||
return {"ok": True}
|
||
|
||
|
||
@router.delete("/guides/{guide_id}")
|
||
async def remove(guide_id: str, slots: bool = False):
|
||
guide = await get_guide(guide_id)
|
||
if guide is None:
|
||
raise HTTPException(404, "Guide nicht gefunden")
|
||
await delete_progress(guide_id)
|
||
await delete_guide(guide_id)
|
||
# Content-/Schritt-Dateien teilen sich alle Läufe eines Thema+Formats — erst löschen,
|
||
# wenn kein Eintrag sie mehr braucht. Teilfortschritt (Schritt-Dateien ohne fertigen
|
||
# Content) bleibt fürs Resume erhalten, außer es wird explizit verlangt (slots=1).
|
||
rest = [g for g in await list_guides() if g["topic"] == guide["topic"] and g["format"] == guide["format"]]
|
||
if not rest:
|
||
content = guide_content_path(guide["topic"], guide["format"])
|
||
if slots or content.exists():
|
||
for p in guide_slot_dateien(content):
|
||
p.unlink(missing_ok=True)
|
||
content.unlink(missing_ok=True)
|
||
return {"ok": True}
|
||
|
||
|
||
@router.get("/guides/{guide_id}/progress", response_model=ProgressResponse)
|
||
async def get_progress(guide_id: str):
|
||
guide = await get_guide(guide_id)
|
||
if guide is None:
|
||
raise HTTPException(404, "Guide nicht gefunden")
|
||
return {"chapters": await list_progress(guide_id)}
|
||
|
||
|
||
@router.post("/guides/{guide_id}/progress", response_model=ProgressResponse)
|
||
async def update_progress(guide_id: str, req: ProgressUpdate):
|
||
guide = await get_guide(guide_id)
|
||
if guide is None:
|
||
raise HTTPException(404, "Guide nicht gefunden")
|
||
await set_progress(guide_id, req.chapter, req.done)
|
||
return {"chapters": await list_progress(guide_id)}
|