Files
creator/backend/routes.py

389 lines
15 KiB
Python

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 bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from guide import generate_guide, guide_slot_dateien
from pipeline import cancel_guide
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)}