import asyncio import json 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, 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, ) from generator import ( generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien, generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, generate_element, chat_with_element, check_element, style_element, refine_suggestion, ) from models import ( GuideCreateRequest, GuideResponse, TopicCreateRequest, BausteineCreateRequest, BausteineStatusResponse, GuideChatRequest, GuideChatResponse, ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse, ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse, ElementRefineRequest, ElementRefineResponse, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ) from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir 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)) # Lernschulden-Regeln (nur Neu-Erstellungen; Themen, Bausteine, OnePager unbegrenzt): # - JE Format (MiniGuide/Guide/FullGuide) höchstens 3 erstellte, nicht absolvierte Guides # - Progression pro Thema: Guide erst nach absolviertem MiniGuide, FullGuide erst nach absolviertem Guide MAX_OFFENE_GUIDES = 3 VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"} async def _ist_absolviert(topic: str, fmt: str) -> bool: """Alle Kapitel des neuesten fertigen Guides (Thema+Format) abgehakt?""" neueste = None for g in await list_guides(): if g["topic"] == topic and g["format"] == fmt and g["status"] == "done": if neueste is None or g["created_at"] > neueste["created_at"]: neueste = g if neueste is None: return False path = guide_content_path(topic, fmt) if not path.exists(): return False try: chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", []) except ValueError: return False titles = {c.get("title") for c in chapters} return bool(titles) and titles <= set(await list_progress(neueste["id"])) async def _formate_stats() -> dict: """Pro Format erstellt/absolviert (alle Kapitel abgehakt) — pro Thema zählt nur der neueste fertige Guide.""" guides = await list_guides() formate = {} for fmt in ("MiniGuide", "Guide", "FullGuide"): neueste: dict[str, dict] = {} for g in guides: if g["format"] == fmt and g["status"] == "done": if g["topic"] not in neueste or g["created_at"] > neueste[g["topic"]]["created_at"]: neueste[g["topic"]] = g absolviert = 0 for g in neueste.values(): path = guide_content_path(g["topic"], fmt) if not path.exists(): continue try: chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", []) except ValueError: continue titles = {c.get("title") for c in chapters} if titles and titles <= set(await list_progress(g["id"])): absolviert += 1 formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert} return formate @router.get("/stats") async def get_stats(): """Tracker: Themen-Anzahl + pro Format erstellt/absolviert.""" guides = await list_guides() 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": await _formate_stats()} @router.get("/topics/fortschritt") async def topic_fortschritt(topic: str): """Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe.""" return {fmt: await _ist_absolviert(topic, fmt) for fmt in ("MiniGuide", "Guide", "FullGuide")} @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) 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} # --- 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) asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider)) 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} # --- Guides --- @router.post("/guides", response_model=GuideResponse) async def create(req: GuideCreateRequest): if req.format != "OnePager" and not bausteine_path(req.topic.strip()).exists(): raise HTTPException(400, "Erst Bausteine erstellen") # Kein Duplikat-Start: pro Thema+Format höchstens eine laufende Generierung for g in await list_guides(): if g["topic"] == req.topic.strip() and g["format"] == req.format and g["status"] in ("queued", "generating"): raise HTTPException(409, "Generierung läuft bereits") # Lernschulden-Regeln — nur für Neu-Erstellungen; Resume (Schritt-Dateien # vorhanden) und Neu-Generieren bestehender Guides sind ausgenommen. content = guide_content_path(req.topic.strip(), req.format) if req.format != "OnePager" and not content.exists() and not guide_slot_dateien(content): vorstufe = VORSTUFE.get(req.format) if vorstufe and not await _ist_absolviert(req.topic.strip(), vorstufe): raise HTTPException(409, f"Erst den {vorstufe} dieses Themas absolvieren") stat = (await _formate_stats()).get(req.format, {"erstellt": 0, "absolviert": 0}) offen = stat["erstellt"] - stat["absolviert"] if offen >= MAX_OFFENE_GUIDES: raise HTTPException(409, f"Erst {req.format}s absolvieren — maximal {MAX_OFFENE_GUIDES} offene erlaubt ({offen} offen)") 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/{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)}