389 lines
15 KiB
Python
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)}
|