This commit is contained in:
Team3
2026-05-25 18:43:17 +02:00
parent 145b3b25d5
commit 1cef392892
29 changed files with 3482 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

36
backend/config.py Normal file
View File

@@ -0,0 +1,36 @@
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DOC_DIR = PROJECT_ROOT / "doc"
STORAGE_DIR = PROJECT_ROOT / "storage"
DB_PATH = PROJECT_ROOT / "guides.db"
ALLOWED_FORMATS = [
"OnePager",
"Cheatsheet",
"MiniGuide",
"BeginnerGuide",
"IntermediateGuide",
"ExtendedGuide",
]
FORMAT_META = {
"OnePager": {"pages": "1 Seite", "time": "~5 Min"},
"Cheatsheet": {"pages": "1 Seite", "time": "~10 Min"},
"MiniGuide": {"pages": "3-4 Seiten", "time": "~15 Min"},
"BeginnerGuide": {"pages": "35-40 Seiten", "time": "~3h"},
"IntermediateGuide": {"pages": "42-50 Seiten", "time": "~4h"},
"ExtendedGuide": {"pages": "47-60 Seiten", "time": "~5h"},
}
GENERATION_TIMEOUTS = {
"OnePager": 600,
"Cheatsheet": 600,
"MiniGuide": 600,
"BeginnerGuide": 900,
"IntermediateGuide": 1200,
"ExtendedGuide": 1500,
}
MAX_CONCURRENT_GENERATIONS = 10
CLAUDE_CLI = "claude"

70
backend/database.py Normal file
View File

@@ -0,0 +1,70 @@
import aiosqlite
from config import DB_PATH
CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS guides (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
format TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
progress TEXT,
error_msg TEXT,
html_path TEXT,
pdf_path TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
async def init_db():
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(CREATE_TABLE)
await db.commit()
def _row_to_dict(row, cursor):
columns = [d[0] for d in cursor.description]
return dict(zip(columns, row))
async def create_guide(guide: dict) -> dict:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""INSERT INTO guides (id, topic, format, status, progress, html_path, pdf_path, created_at, updated_at)
VALUES (:id, :topic, :format, :status, :progress, :html_path, :pdf_path, :created_at, :updated_at)""",
guide,
)
await db.commit()
return guide
async def get_guide(guide_id: str) -> dict | None:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT * FROM guides WHERE id = ?", (guide_id,))
row = await cursor.fetchone()
if row is None:
return None
return _row_to_dict(row, cursor)
async def list_guides() -> list[dict]:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT * FROM guides ORDER BY created_at DESC")
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def update_guide(guide_id: str, **fields) -> None:
sets = ", ".join(f"{k} = :{k}" for k in fields)
fields["id"] = guide_id
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(f"UPDATE guides SET {sets} WHERE id = :id", fields)
await db.commit()
async def delete_guide(guide_id: str) -> bool:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
await db.commit()
return cursor.rowcount > 0

147
backend/generator.py Normal file
View File

@@ -0,0 +1,147 @@
import asyncio
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from config import (
CLAUDE_CLI,
DOC_DIR,
GENERATION_TIMEOUTS,
MAX_CONCURRENT_GENERATIONS,
STORAGE_DIR,
)
from database import update_guide
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
_active_processes: dict[str, asyncio.subprocess.Process] = {}
async def cancel_guide(guide_id: str) -> bool:
process = _active_processes.get(guide_id)
if process and process.returncode is None:
process.kill()
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now)
return True
return False
def _build_prompt(topic: str, format_name: str, html_path: Path, pdf_path: Path) -> str:
spec = (DOC_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
reference = (DOC_DIR / "Referenz" / f"{format_name}.md").read_text(encoding="utf-8")
return f"""Erstelle einen Lern-Guide zum Thema "{topic}" im Format "{format_name}".
Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen.
Schreibe die HTML-Datei nach: {html_path}
Erstelle die PDF-Datei nach: {pdf_path}
FORMAT-SPEZIFIKATION:
{spec}
REFERENZ-IMPLEMENTIERUNG (Stil-Vorlage, adaptiere für "{topic}"):
{reference}
"""
async def _set_progress(guide_id: str, progress: str) -> None:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, progress=progress, updated_at=now)
async def _watch_files(guide_id: str, html_path: Path, pdf_path: Path, stop_event: asyncio.Event) -> None:
html_seen = False
pdf_mtime = 0.0
iteration = 0
while not stop_event.is_set():
await asyncio.sleep(2)
if not html_seen and html_path.exists():
html_seen = True
await _set_progress(guide_id, "HTML generiert…")
if pdf_path.exists():
current_mtime = pdf_path.stat().st_mtime
if current_mtime > pdf_mtime:
pdf_mtime = current_mtime
iteration += 1
await _set_progress(guide_id, f"Iteration {iteration}")
async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
async with _semaphore:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Lesen…", updated_at=now)
html_path = STORAGE_DIR / "html" / f"{guide_id}.html"
pdf_path = STORAGE_DIR / "pdf" / f"{guide_id}.pdf"
prompt = _build_prompt(topic, format_name, html_path, pdf_path)
await _set_progress(guide_id, "Generiere HTML…")
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write(prompt)
prompt_file = f.name
stop_event = asyncio.Event()
watcher = asyncio.create_task(_watch_files(guide_id, html_path, pdf_path, stop_event))
try:
timeout = GENERATION_TIMEOUTS.get(format_name, 600)
process = await asyncio.create_subprocess_exec(
CLAUDE_CLI,
"-p",
"--allowedTools", "Write,Bash,Read,WebSearch,WebFetch",
"--dangerously-skip-permissions",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_active_processes[guide_id] = process
stdout, stderr = await asyncio.wait_for(
process.communicate(input=prompt.encode("utf-8")),
timeout=timeout,
)
stop_event.set()
await watcher
now = datetime.now(timezone.utc).isoformat()
if process.returncode != 0:
error = stderr.decode("utf-8", errors="replace")[:2000]
await update_guide(guide_id, status="error", progress=None, error_msg=error, updated_at=now)
return
if not html_path.exists():
await update_guide(guide_id, status="error", progress=None, error_msg="HTML-Datei wurde nicht erstellt", updated_at=now)
return
if not pdf_path.exists():
await update_guide(guide_id, status="error", progress=None, error_msg="PDF-Datei wurde nicht erstellt", updated_at=now)
return
await update_guide(
guide_id,
status="done",
progress=None,
html_path=str(html_path),
pdf_path=str(pdf_path),
updated_at=now,
)
except asyncio.TimeoutError:
stop_event.set()
await watcher
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg=f"Timeout nach {timeout}s", updated_at=now)
except Exception as e:
stop_event.set()
await watcher
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg=str(e)[:2000], updated_at=now)
finally:
_active_processes.pop(guide_id, None)
Path(prompt_file).unlink(missing_ok=True)

28
backend/main.py Normal file
View File

@@ -0,0 +1,28 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import STORAGE_DIR
from database import init_db
from routes import router
@asynccontextmanager
async def lifespan(app: FastAPI):
(STORAGE_DIR / "html").mkdir(parents=True, exist_ok=True)
(STORAGE_DIR / "pdf").mkdir(parents=True, exist_ok=True)
await init_db()
yield
app = FastAPI(title="Guides Generator", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)

29
backend/models.py Normal file
View File

@@ -0,0 +1,29 @@
from pydantic import BaseModel, Field
from typing import Literal
FormatType = Literal[
"OnePager",
"Cheatsheet",
"MiniGuide",
"BeginnerGuide",
"IntermediateGuide",
"ExtendedGuide",
]
class GuideCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
format: FormatType
class GuideResponse(BaseModel):
id: str
topic: str
format: str
status: str
progress: str | None = None
error_msg: str | None = None
html_path: str | None = None
pdf_path: str | None = None
created_at: str
updated_at: str

3
backend/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi
uvicorn[standard]
aiosqlite

98
backend/routes.py Normal file
View File

@@ -0,0 +1,98 @@
import asyncio
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from config import FORMAT_META, STORAGE_DIR
from database import create_guide, delete_guide, get_guide, list_guides
from generator import generate_guide, cancel_guide
from models import GuideCreateRequest, GuideResponse
router = APIRouter(prefix="/api")
@router.get("/formats")
async def get_formats():
return FORMAT_META
@router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest):
now = datetime.now(timezone.utc).isoformat()
guide = {
"id": str(uuid.uuid4()),
"topic": req.topic.strip(),
"format": req.format,
"status": "queued",
"progress": None,
"html_path": None,
"pdf_path": None,
"created_at": now,
"updated_at": now,
}
await create_guide(guide)
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"]))
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}/html")
async def download_html(guide_id: str):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
if guide["status"] != "done" or not guide["html_path"]:
raise HTTPException(404, "HTML nicht verfügbar")
path = Path(guide["html_path"])
if not path.exists():
raise HTTPException(404, "Datei nicht gefunden")
return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.html", media_type="text/html")
@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.get("/guides/{guide_id}/pdf")
async def download_pdf(guide_id: str):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
if guide["status"] != "done" or not guide["pdf_path"]:
raise HTTPException(404, "PDF nicht verfügbar")
path = Path(guide["pdf_path"])
if not path.exists():
raise HTTPException(404, "Datei nicht gefunden")
return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.pdf", media_type="application/pdf")
@router.delete("/guides/{guide_id}")
async def remove(guide_id: str):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
if guide["html_path"]:
Path(guide["html_path"]).unlink(missing_ok=True)
if guide["pdf_path"]:
Path(guide["pdf_path"]).unlink(missing_ok=True)
await delete_guide(guide_id)
return {"ok": True}