update
This commit is contained in:
BIN
backend/__pycache__/config.cpython-312.pyc
Normal file
BIN
backend/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/database.cpython-312.pyc
Normal file
BIN
backend/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/generator.cpython-312.pyc
Normal file
BIN
backend/__pycache__/generator.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/models.cpython-312.pyc
Normal file
BIN
backend/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/routes.cpython-312.pyc
Normal file
BIN
backend/__pycache__/routes.cpython-312.pyc
Normal file
Binary file not shown.
36
backend/config.py
Normal file
36
backend/config.py
Normal 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
70
backend/database.py
Normal 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
147
backend/generator.py
Normal 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
28
backend/main.py
Normal 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
29
backend/models.py
Normal 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
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
aiosqlite
|
||||
98
backend/routes.py
Normal file
98
backend/routes.py
Normal 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}
|
||||
Reference in New Issue
Block a user