diff --git a/backend/database.py b/backend/database.py index 5893f6e..2c89416 100644 --- a/backend/database.py +++ b/backend/database.py @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS guides ( id TEXT PRIMARY KEY, topic TEXT NOT NULL, format TEXT NOT NULL, + instructions TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'queued', progress TEXT, error_msg TEXT, @@ -16,11 +17,36 @@ CREATE TABLE IF NOT EXISTS guides ( ) """ +_db: aiosqlite.Connection | None = None + + +async def get_db() -> aiosqlite.Connection: + global _db + if _db is None: + _db = await aiosqlite.connect(DB_PATH) + _db.row_factory = None + return _db + async def init_db(): - async with aiosqlite.connect(DB_PATH) as db: - await db.execute(CREATE_TABLE) - await db.commit() + db = await get_db() + await db.execute(CREATE_TABLE) + cursor = await db.execute("PRAGMA table_info(guides)") + columns = {row[1] for row in await cursor.fetchall()} + if "instructions" not in columns: + await db.execute("ALTER TABLE guides ADD COLUMN instructions TEXT NOT NULL DEFAULT ''") + await db.execute( + "UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' " + "WHERE status IN ('queued', 'generating')" + ) + await db.commit() + + +async def close_db(): + global _db + if _db is not None: + await _db.close() + _db = None def _row_to_dict(row, cursor): @@ -29,42 +55,42 @@ def _row_to_dict(row, cursor): 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() + db = await get_db() + await db.execute( + """INSERT INTO guides (id, topic, format, instructions, status, progress, html_path, pdf_path, created_at, updated_at) + VALUES (:id, :topic, :format, :instructions, :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) + db = await get_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] + db = await get_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() + db = await get_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 + db = await get_db() + cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,)) + await db.commit() + return cursor.rowcount > 0 diff --git a/backend/generator.py b/backend/generator.py index afced1b..c9132fd 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -16,16 +16,17 @@ from database import update_guide _semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS) _active_processes: dict[str, asyncio.subprocess.Process] = {} +_cancelled: set[str] = set() async def cancel_guide(guide_id: str) -> bool: + _cancelled.add(guide_id) 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 + now = datetime.now(timezone.utc).isoformat() + await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now) + return True async def _set_progress(guide_id: str, progress: str) -> None: @@ -70,7 +71,7 @@ async def _render_pngs(pdf_path: Path, preview_dir: Path) -> list[Path]: preview_dir.mkdir(parents=True, exist_ok=True) proc = await asyncio.create_subprocess_exec( "python3", "-c", - f"from pdf2image import convert_from_path; pages = convert_from_path('{pdf_path}', dpi=120); [p.save('{preview_dir}/page_{{i}}.png') for i, p in enumerate(pages)]; print(len(pages))", + f"from pdf2image import convert_from_path; pages = convert_from_path('{pdf_path}', dpi=120); [p.save('{preview_dir}/page_' + str(i) + '.png') for i, p in enumerate(pages)]; print(len(pages))", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) @@ -79,10 +80,12 @@ async def _render_pngs(pdf_path: Path, preview_dir: Path) -> list[Path]: return pngs -def _build_generator_prompt(topic: str, format_name: str, html_path: Path) -> str: +def _build_generator_prompt(topic: str, format_name: str, html_path: Path, instructions: str = "") -> str: spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8") reference = (TEMPLATES_DIR / "Referenz" / f"{format_name}.md").read_text(encoding="utf-8") + extra = f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else "" + 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. @@ -96,6 +99,23 @@ FORMAT-SPEZIFIKATION: REFERENZ-IMPLEMENTIERUNG (Stil-Vorlage, adaptiere für "{topic}"): {reference} +{extra}""" + + +def _build_rework_prompt(topic: str, format_name: str, html_path: Path, instructions: str) -> str: + spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8") + return f"""Überarbeite die bestehende HTML-Datei {html_path} für den "{format_name}" zum Thema "{topic}". + +Lies zuerst die aktuelle HTML-Datei mit dem Read-Tool. + +ANWEISUNGEN VOM NUTZER: +{instructions} + +FORMAT-SPEZIFIKATION (muss weiterhin eingehalten werden): +{spec} + +Schreibe die überarbeitete Version in dieselbe Datei: {html_path} +Führe KEIN weasyprint aus, erzeuge KEINE PDF. """ @@ -113,24 +133,28 @@ Führe KEIN weasyprint aus, erzeuge KEINE PDF. def _build_review_prompt(format_name: str, png_paths: list[Path], page_count: int) -> str: spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8") - png_list = "\n".join(str(p) for p in png_paths) + read_instructions = "\n".join( + f"- Öffne mit dem Read-Tool: {p}" for p in png_paths + ) - return f"""Prüfe die folgenden Preview-Bilder eines generierten "{format_name}" Guides. + return f"""Prüfe visuell einen generierten "{format_name}" Guide. -Das PDF hat {page_count} Seite(n). Lies die Preview-Bilder und prüfe sie: -{png_list} +SCHRITT 1 — Bilder laden: +Das PDF hat {page_count} Seite(n), gerendert als PNG-Screenshots. +Nutze das Read-Tool, um JEDE der folgenden Dateien zu öffnen und visuell zu inspizieren: +{read_instructions} -FORMAT-SPEZIFIKATION (Prüfkriterien): +SCHRITT 2 — Visuell prüfen anhand dieser Spezifikation: {spec} -Prüfe anhand der Spezifikation: +Prüfkriterien (basierend auf dem, was du in den Bildern SIEHST): - Stimmt die Seitenanzahl? (OnePager/Cheatsheet = exakt 1 Seite) -- Sind Elemente abgeschnitten oder überlappend? +- Ist Text abgeschnitten, überlappt oder läuft aus dem sichtbaren Bereich? - Fehlen Pflicht-Elemente (Cover, TOC, Recall-Boxen, Callouts, etc.)? - Sind Code-Blöcke über Seitenumbrüche zerrissen? - Ist das Layout korrekt (Spalten, Grid, Footer)? -Antworte mit GENAU EINEM der folgenden Formate: +SCHRITT 3 — Antworte mit GENAU EINEM der folgenden Formate: Bei Bestehen: PASS @@ -143,7 +167,7 @@ FAIL """ -async def generate_guide(guide_id: str, topic: str, format_name: str) -> None: +async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "") -> None: async with _semaphore: now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now) @@ -155,11 +179,16 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None: max_iter = MAX_ITERATIONS.get(format_name, 3) try: + if guide_id in _cancelled: + return + # Step 1: Generator-Agent erstellt HTML await _set_progress(guide_id, "Generiere HTML…") - gen_prompt = _build_generator_prompt(topic, format_name, html_path) + gen_prompt = _build_generator_prompt(topic, format_name, html_path, instructions) returncode, stdout, stderr = await _run_claude(guide_id, gen_prompt, timeout) + if guide_id in _cancelled: + return if returncode != 0: await _fail(guide_id, f"Generator-Fehler: {stderr[:1000]}") return @@ -170,6 +199,9 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None: # Step 2-N: Render → Review → Fix Loop for iteration in range(1, max_iter + 1): + if guide_id in _cancelled: + return + await _set_progress(guide_id, f"Rendere PDF… (Iteration {iteration})") ok, err = await _render_pdf(html_path, pdf_path) if not ok: @@ -222,6 +254,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None: await _fail(guide_id, str(e)[:2000]) finally: _active_processes.pop(guide_id, None) + _cancelled.discard(guide_id) # Preview-PNGs aufräumen if preview_dir.exists(): for f in preview_dir.glob("*"): @@ -229,6 +262,88 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None: preview_dir.rmdir() +async def rework_guide(guide_id: str, topic: str, format_name: str, instructions: str) -> None: + async with _semaphore: + now = datetime.now(timezone.utc).isoformat() + await update_guide(guide_id, status="generating", progress="Überarbeite…", updated_at=now) + + html_path = STORAGE_DIR / "html" / f"{guide_id}.html" + pdf_path = STORAGE_DIR / "pdf" / f"{guide_id}.pdf" + preview_dir = STORAGE_DIR / "preview" / guide_id + timeout = GENERATION_TIMEOUTS.get(format_name, 600) + max_iter = MAX_ITERATIONS.get(format_name, 3) + + try: + if guide_id in _cancelled: + return + + rework_prompt = _build_rework_prompt(topic, format_name, html_path, instructions) + returncode, stdout, stderr = await _run_claude(guide_id, rework_prompt, timeout) + + if guide_id in _cancelled: + return + if returncode != 0: + await _fail(guide_id, f"Rework-Fehler: {stderr[:1000]}") + return + + if not html_path.exists(): + await _fail(guide_id, "HTML-Datei wurde nicht erstellt") + return + + for iteration in range(1, max_iter + 1): + if guide_id in _cancelled: + return + + await _set_progress(guide_id, f"Rendere PDF… (Iteration {iteration})") + ok, err = await _render_pdf(html_path, pdf_path) + if not ok: + await _fail(guide_id, f"WeasyPrint-Fehler: {err}") + return + + await _set_progress(guide_id, f"Prüfe… (Iteration {iteration})") + pngs = await _render_pngs(pdf_path, preview_dir) + page_count = len(pngs) + + review_prompt = _build_review_prompt(format_name, pngs, page_count) + returncode, review_out, review_err = await _run_claude(guide_id, review_prompt, 120) + + if returncode != 0: + await _fail(guide_id, f"Review-Fehler: {review_err[:1000]}") + return + + review_text = review_out.strip() + if review_text.startswith("PASS"): + break + if iteration == max_iter: + break + + feedback = review_text.replace("FAIL", "").strip() + await _set_progress(guide_id, f"Korrigiere… (Iteration {iteration})") + fix_prompt = _build_fix_prompt(topic, format_name, html_path, feedback) + returncode, _, fix_err = await _run_claude(guide_id, fix_prompt, timeout) + if returncode != 0: + await _fail(guide_id, f"Fix-Fehler: {fix_err[:1000]}") + return + + now = datetime.now(timezone.utc).isoformat() + 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: + await _fail(guide_id, f"Timeout nach {timeout}s") + except Exception as e: + await _fail(guide_id, str(e)[:2000]) + finally: + _active_processes.pop(guide_id, None) + _cancelled.discard(guide_id) + if preview_dir.exists(): + for f in preview_dir.glob("*"): + f.unlink() + preview_dir.rmdir() + + async def _fail(guide_id: str, msg: str) -> None: now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now) diff --git a/backend/main.py b/backend/main.py index b0ff769..13f51c9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from config import STORAGE_DIR -from database import init_db +from database import init_db, close_db from routes import router @@ -15,6 +15,7 @@ async def lifespan(app: FastAPI): (STORAGE_DIR / "preview").mkdir(parents=True, exist_ok=True) await init_db() yield + await close_db() app = FastAPI(title="Guides Generator", lifespan=lifespan) diff --git a/backend/models.py b/backend/models.py index dd74b44..5bc9d76 100644 --- a/backend/models.py +++ b/backend/models.py @@ -14,6 +14,11 @@ FormatType = Literal[ class GuideCreateRequest(BaseModel): topic: str = Field(min_length=1, max_length=100) format: FormatType + instructions: str = Field(default="", max_length=2000) + + +class GuideReworkRequest(BaseModel): + instructions: str = Field(min_length=1, max_length=2000) class GuideResponse(BaseModel): diff --git a/backend/routes.py b/backend/routes.py index 65bd19b..9f75c34 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -8,8 +8,8 @@ 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 +from generator import generate_guide, rework_guide, cancel_guide +from models import GuideCreateRequest, GuideReworkRequest, GuideResponse router = APIRouter(prefix="/api") @@ -26,6 +26,7 @@ async def create(req: GuideCreateRequest): "id": str(uuid.uuid4()), "topic": req.topic.strip(), "format": req.format, + "instructions": req.instructions.strip(), "status": "queued", "progress": None, "html_path": None, @@ -34,7 +35,7 @@ async def create(req: GuideCreateRequest): "updated_at": now, } await create_guide(guide) - asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"])) + asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"])) return guide @@ -64,6 +65,17 @@ async def download_html(guide_id: str): return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.html", media_type="text/html") +@router.post("/guides/{guide_id}/rework") +async def rework(guide_id: str, req: GuideReworkRequest): + guide = await get_guide(guide_id) + if guide is None: + raise HTTPException(404, "Guide nicht gefunden") + if guide["status"] != "done": + raise HTTPException(400, "Guide muss fertig sein") + asyncio.create_task(rework_guide(guide_id, guide["topic"], guide["format"], req.instructions.strip())) + return {"ok": True} + + @router.post("/guides/{guide_id}/cancel") async def cancel(guide_id: str): cancelled = await cancel_guide(guide_id) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 761cb5f..fdf2b0b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,6 @@