This commit is contained in:
root
2026-05-28 15:40:02 +00:00
parent 81913f3d8d
commit cc1ea166c8
5 changed files with 75 additions and 40 deletions

View File

@@ -1,5 +1,6 @@
import aiosqlite import aiosqlite
from config import DB_PATH from config import DB_PATH, STORAGE_DIR
from paths import final_paths
CREATE_GUIDES = """ CREATE_GUIDES = """
CREATE TABLE IF NOT EXISTS guides ( CREATE TABLE IF NOT EXISTS guides (
@@ -10,8 +11,6 @@ CREATE TABLE IF NOT EXISTS guides (
status TEXT NOT NULL DEFAULT 'queued', status TEXT NOT NULL DEFAULT 'queued',
progress TEXT, progress TEXT,
error_msg TEXT, error_msg TEXT,
html_path TEXT,
pdf_path TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
@@ -68,6 +67,20 @@ async def init_db():
"WHERE status IN ('queued', 'generating')" "WHERE status IN ('queued', 'generating')"
) )
await db.commit() await db.commit()
await _migrate_uuid_filenames(db)
async def _migrate_uuid_filenames(db: aiosqlite.Connection) -> None:
cursor = await db.execute("SELECT id, topic, format FROM guides WHERE status = 'done'")
rows = await cursor.fetchall()
for guide_id, topic, format_name in rows:
final_html, final_pdf = final_paths(topic, format_name)
old_html = STORAGE_DIR / "html" / f"{guide_id}.html"
old_pdf = STORAGE_DIR / "pdf" / f"{guide_id}.pdf"
if old_html.exists() and not final_html.exists():
old_html.rename(final_html)
if old_pdf.exists() and not final_pdf.exists():
old_pdf.rename(final_pdf)
async def close_db(): async def close_db():
@@ -85,8 +98,8 @@ def _row_to_dict(row, cursor):
async def create_guide(guide: dict) -> dict: async def create_guide(guide: dict) -> dict:
db = await get_db() db = await get_db()
await db.execute( await db.execute(
"""INSERT INTO guides (id, topic, format, instructions, status, progress, html_path, pdf_path, created_at, updated_at) """INSERT INTO guides (id, topic, format, instructions, status, progress, created_at, updated_at)
VALUES (:id, :topic, :format, :instructions, :status, :progress, :html_path, :pdf_path, :created_at, :updated_at)""", VALUES (:id, :topic, :format, :instructions, :status, :progress, :created_at, :updated_at)""",
guide, guide,
) )
await db.commit() await db.commit()

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import re import re
import shutil
import subprocess import subprocess
import tempfile import tempfile
import uuid import uuid
@@ -22,6 +23,7 @@ from database import (
list_bausteine, list_bausteine,
update_baustein, update_baustein,
) )
from paths import final_paths, temp_paths
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS) _semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
_active_processes: dict[str, asyncio.subprocess.Process] = {} _active_processes: dict[str, asyncio.subprocess.Process] = {}
@@ -174,8 +176,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now) await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now)
html_path = STORAGE_DIR / "html" / f"{guide_id}.html" html_path, pdf_path = final_paths(topic, format_name)
pdf_path = STORAGE_DIR / "pdf" / f"{guide_id}.pdf"
try: try:
if guide_id in _cancelled: if guide_id in _cancelled:
@@ -241,12 +242,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide( await update_guide(
guide_id, guide_id, status="done", progress=None, updated_at=now,
status="done",
progress=None,
html_path=str(html_path),
pdf_path=str(pdf_path),
updated_at=now,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -263,16 +259,23 @@ async def rework_guide(guide_id: str, topic: str, format_name: str, instructions
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Überarbeite…", updated_at=now) await update_guide(guide_id, status="generating", progress="Überarbeite…", updated_at=now)
html_path = STORAGE_DIR / "html" / f"{guide_id}.html" final_html, final_pdf = final_paths(topic, format_name)
pdf_path = STORAGE_DIR / "pdf" / f"{guide_id}.pdf" tmp_html, tmp_pdf = temp_paths(guide_id)
try: try:
if guide_id in _cancelled: if guide_id in _cancelled:
return return
if not final_html.exists():
await _fail(guide_id, "Original-HTML nicht gefunden")
return
shutil.copy2(final_html, tmp_html)
current_step = "Überarbeitung" current_step = "Überarbeitung"
current_timeout = AGENT_TIMEOUT current_timeout = AGENT_TIMEOUT
rework_prompt = _build_rework_prompt(topic, format_name, html_path, instructions) rework_prompt = _build_rework_prompt(topic, format_name, tmp_html, instructions)
returncode, stdout, stderr = await _run_claude(guide_id, rework_prompt, AGENT_TIMEOUT) returncode, stdout, stderr = await _run_claude(guide_id, rework_prompt, AGENT_TIMEOUT)
if guide_id in _cancelled: if guide_id in _cancelled:
@@ -281,20 +284,23 @@ async def rework_guide(guide_id: str, topic: str, format_name: str, instructions
await _fail(guide_id, f"Rework-Fehler: {stderr[:1000]}") await _fail(guide_id, f"Rework-Fehler: {stderr[:1000]}")
return return
if not html_path.exists(): if not tmp_html.exists():
await _fail(guide_id, "HTML-Datei wurde nicht erstellt") await _fail(guide_id, "HTML-Datei wurde nicht erstellt")
return return
await _set_progress(guide_id, "Rendere PDF…") await _set_progress(guide_id, "Rendere PDF…")
ok, err = await _render_pdf(html_path, pdf_path) ok, err = await _render_pdf(tmp_html, tmp_pdf)
if not ok: if not ok:
await _fail(guide_id, f"WeasyPrint-Fehler: {err}") await _fail(guide_id, f"WeasyPrint-Fehler: {err}")
return return
# Atomar: Temp → Final umbenennen
tmp_html.replace(final_html)
tmp_pdf.replace(final_pdf)
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide( await update_guide(
guide_id, status="done", progress=None, guide_id, status="done", progress=None, updated_at=now,
html_path=str(html_path), pdf_path=str(pdf_path), updated_at=now,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -304,6 +310,8 @@ async def rework_guide(guide_id: str, topic: str, format_name: str, instructions
finally: finally:
_active_processes.pop(guide_id, None) _active_processes.pop(guide_id, None)
_cancelled.discard(guide_id) _cancelled.discard(guide_id)
tmp_html.unlink(missing_ok=True)
tmp_pdf.unlink(missing_ok=True)
async def _fail(guide_id: str, msg: str) -> None: async def _fail(guide_id: str, msg: str) -> None:

View File

@@ -28,8 +28,6 @@ class GuideResponse(BaseModel):
status: str status: str
progress: str | None = None progress: str | None = None
error_msg: str | None = None error_msg: str | None = None
html_path: str | None = None
pdf_path: str | None = None
created_at: str created_at: str
updated_at: str updated_at: str

17
backend/paths.py Normal file
View File

@@ -0,0 +1,17 @@
from pathlib import Path
from config import STORAGE_DIR
def safe_basename(topic: str, format_name: str) -> str:
clean = topic.replace("/", "_").replace("\x00", "")
return f"{clean} - {format_name}"
def final_paths(topic: str, format_name: str) -> tuple[Path, Path]:
base = safe_basename(topic, format_name)
return STORAGE_DIR / "html" / f"{base}.html", STORAGE_DIR / "pdf" / f"{base}.pdf"
def temp_paths(guide_id: str) -> tuple[Path, Path]:
return STORAGE_DIR / "html" / f"{guide_id}.tmp.html", STORAGE_DIR / "pdf" / f"{guide_id}.tmp.pdf"

View File

@@ -1,12 +1,11 @@
import asyncio import asyncio
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from config import FORMAT_META, STORAGE_DIR from config import FORMAT_META
from database import ( from database import (
create_guide, delete_guide, get_guide, list_guides, create_guide, delete_guide, get_guide, list_guides,
create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein, create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein,
@@ -17,6 +16,7 @@ from models import (
GuideCreateRequest, GuideReworkRequest, GuideResponse, GuideCreateRequest, GuideReworkRequest, GuideResponse,
BausteinCreateRequest, BausteinResponse, SuggestionResponse, BausteinCreateRequest, BausteinResponse, SuggestionResponse,
) )
from paths import final_paths
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -36,8 +36,6 @@ async def create(req: GuideCreateRequest):
"instructions": req.instructions.strip(), "instructions": req.instructions.strip(),
"status": "queued", "status": "queued",
"progress": None, "progress": None,
"html_path": None,
"pdf_path": None,
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
} }
@@ -64,12 +62,12 @@ async def download_html(guide_id: str):
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide nicht gefunden")
if guide["status"] != "done" or not guide["html_path"]: if guide["status"] != "done":
raise HTTPException(404, "HTML nicht verfügbar") raise HTTPException(404, "HTML nicht verfügbar")
path = Path(guide["html_path"]) html_path, _ = final_paths(guide["topic"], guide["format"])
if not path.exists(): if not html_path.exists():
raise HTTPException(404, "Datei nicht gefunden") raise HTTPException(404, "Datei nicht gefunden")
return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.html", media_type="text/html") return FileResponse(html_path, filename=html_path.name, media_type="text/html")
@router.post("/guides/{guide_id}/rework") @router.post("/guides/{guide_id}/rework")
@@ -96,12 +94,12 @@ async def download_pdf(guide_id: str):
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide nicht gefunden")
if guide["status"] != "done" or not guide["pdf_path"]: if guide["status"] != "done":
raise HTTPException(404, "PDF nicht verfügbar") raise HTTPException(404, "PDF nicht verfügbar")
path = Path(guide["pdf_path"]) _, pdf_path = final_paths(guide["topic"], guide["format"])
if not path.exists(): if not pdf_path.exists():
raise HTTPException(404, "Datei nicht gefunden") raise HTTPException(404, "Datei nicht gefunden")
return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.pdf", media_type="application/pdf") return FileResponse(pdf_path, filename=pdf_path.name, media_type="application/pdf")
@router.delete("/guides/{guide_id}") @router.delete("/guides/{guide_id}")
@@ -109,10 +107,9 @@ async def remove(guide_id: str):
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide nicht gefunden")
if guide["html_path"]: html_path, pdf_path = final_paths(guide["topic"], guide["format"])
Path(guide["html_path"]).unlink(missing_ok=True) html_path.unlink(missing_ok=True)
if guide["pdf_path"]: pdf_path.unlink(missing_ok=True)
Path(guide["pdf_path"]).unlink(missing_ok=True)
await delete_guide(guide_id) await delete_guide(guide_id)
return {"ok": True} return {"ok": True}
@@ -165,8 +162,10 @@ async def trigger_suggestions(topic: str):
guides = await list_guides() guides = await list_guides()
html_paths = [] html_paths = []
for g in guides: for g in guides:
if g["topic"] == topic and g["status"] == "done" and g["html_path"]: if g["topic"] == topic and g["status"] == "done":
html_paths.append(Path(g["html_path"])) html_path, _ = final_paths(g["topic"], g["format"])
if html_path.exists():
html_paths.append(html_path)
asyncio.create_task(generate_suggestions(topic, html_paths)) asyncio.create_task(generate_suggestions(topic, html_paths))
return {"ok": True} return {"ok": True}