Files
creator/backend/routes.py
2026-06-27 09:50:50 +02:00

697 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import json
import shutil
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, Response
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_score_and_streak, set_baustein_absolviert,
delete_baustein_daten, delete_baustein_progress, subs_je_ebene, subs_je_ebene_roh,
delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content,
get_sub_artefakte,
)
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, subbausteine_frei, lade_frage_muster, lade_frage_muster_frei
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from lernen import 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, huerden_distraktor_block, score_berechnen, floor_aus_score, stufe_aus_score, cap_final, cap_aktuell, freie_ebene, schwellen, punkte_delta, deckel_nachfrage
from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen, content_fuer_ebene
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,
BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse,
)
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)
await delete_topic_pipeline(topic)
await delete_quelle(topic) # Themen-Config (DB) — beim Thema-Löschen mit weg
await delete_guide_content(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) # Dateien: Crawl + Sichtung + Inventar…Fragen weg; quelle.json bleibt
await delete_topic_pipeline(topic) # DB: Bausteine-Bereich weg; Themen-Config (quelle) bleibt
return {"ok": True}
@router.delete("/bausteine/fortschritt")
async def reset_baustein_fortschritt(topic: str, baustein: str):
"""Lern-Fortschritt EINES Bausteins auf null (Score/Streak/Flags/offene Frage)."""
await delete_baustein_progress(topic, baustein)
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 await lade_uebersicht(topic)
@router.get("/bausteine/frage-muster")
async def get_frage_muster(topic: str, baustein: str):
"""Freigeschaltete Frage-Muster eines Bausteins (bis zur aktuellen Ebene; leer = Live)."""
stand = await get_baustein_progress(topic, baustein)
fe = freie_ebene(stand["gute_antworten"], await subs_je_ebene(topic, baustein))
return {"muster": await lade_frage_muster_frei(topic, baustein, fe)}
@router.get("/bausteine/artefakte")
async def get_artefakte(topic: str, typ: str | None = None):
"""Lern-Artefakte (Karteikarten/Beispiele) je Thema, gruppiert nach Baustein-Norm — je Subbaustein."""
rows = await get_sub_artefakte(topic, typ)
out: dict[str, dict] = {}
for r in rows:
b = out.setdefault(r["baustein_norm"], {"baustein": r["baustein"], "karteikarte": [], "beispiel": []})
if r["baustein"] and not b["baustein"]:
b["baustein"] = r["baustein"]
try:
daten = json.loads(r["daten"])
except (ValueError, TypeError):
continue
if r["typ"] in ("karteikarte", "beispiel"):
b[r["typ"]].append({"subbaustein": r["sub_titel"], **daten})
return {"artefakte": out}
# --- 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). cap_final = alle Subs × 25;
cap_aktuell + freie_ebene aus dem Score — für ALLE Bausteine (auch ungeprüfte)."""
progress = {p["baustein"]: p for p in await list_baustein_progress(topic)}
ebenen = await subs_je_ebene_roh(topic)
def _stand(score: int, streak: int, n_je_ebene: dict[int, int]) -> dict:
return {
"gute_antworten": score, "streak": streak,
"cap": cap_final(n_je_ebene),
"cap_aktuell": cap_aktuell(score, n_je_ebene),
"freie_ebene": freie_ebene(score, n_je_ebene),
}
bausteine = {
b: _stand(progress[b]["gute_antworten"] if b in progress else 0,
progress[b]["streak"] if b in progress else 0, n)
for b, n in ebenen.items()
}
# Altbestand-Bausteine mit Prüfung, aber ohne Subs → leere Ebenen (cap 0).
for b, p in progress.items():
if b not in bausteine:
bausteine[b] = _stand(p["gute_antworten"], p["streak"], {})
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")
async def _buche(req, frage: str, niveau: str, n_je_ebene: dict[int, int]) -> dict:
"""Score+Streak driftfrei buchen (Lock + offene_frage/offene_streak-Anker). Niveau →
Punkt-Delta (streak-moduliert) bzw. progressiver Malus bei Fehler. cap_aktuell wird aus
der Basis abgeleitet (verzögerte Freischaltung an der Ebenen-Schwelle); Element einmalig
ab Anfänger-Stufe. Re-Bewertung derselben Frage nutzt den offenen Streak-Anker → idempotent."""
async with _pruef_lock(req.topic, req.baustein):
stand = await get_baustein_progress(req.topic, req.baustein)
war_stufe = stand["absolviert"] is not None # Element-Guard: schon je angelegt?
basis, re_bewertung = _basis(stand, frage)
streak_basis = stand["offene_streak"] if re_bewertung else stand["streak"]
if not re_bewertung:
await set_offene_frage(req.topic, req.baustein, frage, basis, stand["streak"])
s = schwellen(n_je_ebene)
cf = s[-1]
ca = cap_aktuell(basis, n_je_ebene)
floor = floor_aus_score(basis, cf, s)
d, neue_streak = punkte_delta(niveau, streak_basis, basis, ca)
score = score_berechnen(basis, d, floor, ca, cf)
punkte = score - basis
gute, streak = await set_baustein_score_and_streak(req.topic, req.baustein, score, neue_streak)
# Lern-Element einmalig anlegen, sobald die erste Stufe (Anfänger) erreicht ist.
if not war_stufe and stufe_aus_score(score, cf) is not None:
if await set_baustein_absolviert(req.topic, req.baustein):
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "streak": streak, "cap": cf}
@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"]
n_je_ebene = await subs_je_ebene(req.topic, req.baustein)
cap = cap_final(n_je_ebene)
niveau = stufe_aus_score(gute, cap) or "anfaenger" # Adressaten-Rolle der Frage
fe = freie_ebene(gute, n_je_ebene) # nur freigeschaltete Subs prüfen
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 Frage im Niveau formulieren (kein Dedup nötig).
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider)
else:
# Fallback (kein Muster-Sidecar): Live-Generierung, Fokus nur auf freigeschaltete Subs.
subs = await subbausteine_frei(req.topic, req.baustein, fe)
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, niveau=niveau, provider=req.provider)
if frage is None:
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": frage, "gute_antworten": gute, "cap": cap}
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, "cap": cap}
# --- 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")
distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
if quiz is None:
raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": quiz["frage"], "optionen": quiz["optionen"],
"gute_antworten": gute, "cap": cap}
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
res = await _buche(req, req.frage, "stark" if getroffen else "kaum", n_je_ebene)
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, niveau=niveau, 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, "cap": cap}
distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
if lw is None:
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
return {"satz": lw["satz"], "optionen": lw["optionen"],
"gute_antworten": gute, "cap": cap}
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 _buche(req, req.frage, "stark" if ok else "kaum", n_je_ebene)
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, re_bew = _basis(stand, req.frage)
streak_basis = stand["offene_streak"] if re_bew else stand["streak"]
s = schwellen(n_je_ebene)
ca = cap_aktuell(basis, n_je_ebene)
floor = floor_aus_score(basis, s[-1], s)
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
d, _ = punkte_delta(niveau, streak_basis, basis, ca)
score = score_berechnen(basis, d, floor, ca, s[-1])
punkte = score - basis
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
"gute_antworten": gute, "cap": cap}
# aktion "antwort_pruefen" (Agent 2 genau): verbindlich, persistiert. NUR hier ändert sich der Score.
# LLM läuft OHNE Lock; gebucht wird kurz über _buche (Anker + Score), wie bei Quiz/Lück.
# So blockiert die lange KI-Bewertung keine folgende (deterministische) Antwort desselben Bausteins.
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")
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
res = await _buche(req, req.frage, niveau, n_je_ebene) # kurzer Lock: Basis driftfrei über Anker
res["feedback"] = data["feedback"]
return res
# --- 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, ab_step=req.ab_step))
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 ("FullGuide", "Rest", *FORMATE)}
@router.get("/guides/steps")
async def guide_steps(topic: str):
"""Höchster voll abgeschlossener Schritt-Index je Format (artefakt-basiert, -1 = keiner).
Treibt die klickbaren Schritt-Kugeln (wie die Bausteine-Phasen)."""
return {fmt: guide_fertig_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")}
@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, ebene: int = 4):
"""Guide-Inhalt. `ebene` (1=A · 2=F · 3=E · 4=V) filtert auf Subbausteine bis zu dieser
Ebene; 4 = Vollfassung (roh, unverändert)."""
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")
stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first
if stored is None:
path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen)
if not path.exists():
raise HTTPException(404, "Datei nicht gefunden")
stored = path.read_text(encoding="utf-8")
if ebene >= 4:
return Response(content=stored, media_type="application/json") # Vollfassung roh
try:
return content_fuer_ebene(json.loads(stored), ebene)
except ValueError:
return Response(content=stored, 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}
async def _guide_tf(guide_id: str) -> tuple[str, str]:
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
return guide["topic"], guide["format"]
@router.post("/guides/{guide_id}/block/pruefen", response_model=BlockPruefenResponse)
async def block_pruefen_route(guide_id: str, req: BlockPruefenRequest):
topic, fmt = await _guide_tf(guide_id)
neu = await block_pruefen(topic, fmt, req.baustein, req.stelle, req.block, req.hinweis, provider=req.provider)
if neu is None:
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
return {"neu": neu}
@router.post("/guides/{guide_id}/block/uebernehmen", response_model=BlockUebernehmenResponse)
async def block_uebernehmen_route(guide_id: str, req: BlockUebernehmenRequest):
topic, fmt = await _guide_tf(guide_id)
res = await block_uebernehmen(topic, fmt, req.baustein, req.stelle, req.alt, req.neu)
if res is None:
raise HTTPException(404, "Section nicht gefunden")
return res
# --- 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:
await delete_guide_content(guide["topic"], guide["format"])
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)}