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/Diagramme) je Thema, gruppiert nach Baustein-Norm. Diagramm liegt auf Baustein-Ebene (sub_norm=''), Karteikarte/Beispiel 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": [], "diagramm": None}) if r["baustein"] and not b["baustein"]: b["baustein"] = r["baustein"] try: daten = json.loads(r["daten"]) except (ValueError, TypeError): continue if r["typ"] == "diagramm": b["diagramm"] = daten elif 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)}