This commit is contained in:
team3
2026-05-25 22:59:37 +02:00
parent e964c807d9
commit 619bac34cb
8 changed files with 339 additions and 88 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)