This commit is contained in:
Team3
2026-05-25 19:28:25 +02:00
parent 1cef392892
commit 66a48759b3
11 changed files with 169 additions and 67 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
storage/
guides.db
node_modules/
frontend/dist/
__pycache__/
*.pyc

View File

@@ -33,4 +33,12 @@ GENERATION_TIMEOUTS = {
} }
MAX_CONCURRENT_GENERATIONS = 10 MAX_CONCURRENT_GENERATIONS = 10
MAX_ITERATIONS = {
"OnePager": 3,
"Cheatsheet": 3,
"MiniGuide": 3,
"BeginnerGuide": 5,
"IntermediateGuide": 5,
"ExtendedGuide": 5,
}
CLAUDE_CLI = "claude" CLAUDE_CLI = "claude"

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import subprocess
import tempfile import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -8,6 +9,7 @@ from config import (
DOC_DIR, DOC_DIR,
GENERATION_TIMEOUTS, GENERATION_TIMEOUTS,
MAX_CONCURRENT_GENERATIONS, MAX_CONCURRENT_GENERATIONS,
MAX_ITERATIONS,
STORAGE_DIR, STORAGE_DIR,
) )
from database import update_guide from database import update_guide
@@ -26,7 +28,58 @@ async def cancel_guide(guide_id: str) -> bool:
return False return False
def _build_prompt(topic: str, format_name: str, html_path: Path, pdf_path: Path) -> str: 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 _run_claude(guide_id: str, prompt: str, timeout: int) -> tuple[int, str, str]:
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
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=prompt.encode("utf-8")),
timeout=timeout,
)
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
finally:
_active_processes.pop(guide_id, None)
async def _render_pdf(html_path: Path, pdf_path: Path) -> tuple[bool, str]:
proc = await asyncio.create_subprocess_exec(
"weasyprint", str(html_path), str(pdf_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
if proc.returncode != 0:
return False, stderr.decode("utf-8", errors="replace")[:1000]
return True, ""
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))",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=60)
pngs = sorted(preview_dir.glob("page_*.png"))
return pngs
def _build_generator_prompt(topic: str, format_name: str, html_path: Path) -> str:
spec = (DOC_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8") 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") reference = (DOC_DIR / "Referenz" / f"{format_name}.md").read_text(encoding="utf-8")
@@ -35,7 +88,8 @@ def _build_prompt(topic: str, format_name: str, html_path: Path, pdf_path: Path)
Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen. Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen.
Schreibe die HTML-Datei nach: {html_path} Schreibe die HTML-Datei nach: {html_path}
Erstelle die PDF-Datei nach: {pdf_path}
Schreibe NUR die HTML-Datei. Führe KEIN weasyprint aus, erzeuge KEINE PDF. Das übernimmt ein anderer Prozess.
FORMAT-SPEZIFIKATION: FORMAT-SPEZIFIKATION:
{spec} {spec}
@@ -45,84 +99,114 @@ REFERENZ-IMPLEMENTIERUNG (Stil-Vorlage, adaptiere für "{topic}"):
""" """
async def _set_progress(guide_id: str, progress: str) -> None: def _build_fix_prompt(topic: str, format_name: str, html_path: Path, feedback: str) -> str:
now = datetime.now(timezone.utc).isoformat() return f"""Die HTML-Datei {html_path} für den "{format_name}" zum Thema "{topic}" hat Probleme.
await update_guide(guide_id, progress=progress, updated_at=now)
FEEDBACK VOM PRÜFER:
{feedback}
Behebe die Probleme in der HTML-Datei {html_path}. Schreibe die korrigierte Version in dieselbe Datei.
Führe KEIN weasyprint aus, erzeuge KEINE PDF.
"""
async def _watch_files(guide_id: str, html_path: Path, pdf_path: Path, stop_event: asyncio.Event) -> None: def _build_review_prompt(format_name: str, png_paths: list[Path], page_count: int) -> str:
html_seen = False spec = (DOC_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
pdf_mtime = 0.0
iteration = 0
while not stop_event.is_set(): png_list = "\n".join(str(p) for p in png_paths)
await asyncio.sleep(2)
if not html_seen and html_path.exists(): return f"""Prüfe die folgenden Preview-Bilder eines generierten "{format_name}" Guides.
html_seen = True
await _set_progress(guide_id, "HTML generiert…")
if pdf_path.exists(): Das PDF hat {page_count} Seite(n). Lies die Preview-Bilder und prüfe sie:
current_mtime = pdf_path.stat().st_mtime {png_list}
if current_mtime > pdf_mtime:
pdf_mtime = current_mtime FORMAT-SPEZIFIKATION (Prüfkriterien):
iteration += 1 {spec}
await _set_progress(guide_id, f"Iteration {iteration}")
Prüfe anhand der Spezifikation:
- Stimmt die Seitenanzahl? (OnePager/Cheatsheet = exakt 1 Seite)
- Sind Elemente abgeschnitten oder überlappend?
- 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:
Bei Bestehen:
PASS
Bei Nicht-Bestehen:
FAIL
- Problem 1
- Problem 2
- ...
"""
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) -> None:
async with _semaphore: async with _semaphore:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Lesen", 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 = STORAGE_DIR / "html" / f"{guide_id}.html"
pdf_path = STORAGE_DIR / "pdf" / f"{guide_id}.pdf" pdf_path = STORAGE_DIR / "pdf" / f"{guide_id}.pdf"
preview_dir = STORAGE_DIR / "preview" / guide_id
prompt = _build_prompt(topic, format_name, html_path, pdf_path) timeout = GENERATION_TIMEOUTS.get(format_name, 600)
await _set_progress(guide_id, "Generiere HTML…") max_iter = MAX_ITERATIONS.get(format_name, 3)
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: try:
timeout = GENERATION_TIMEOUTS.get(format_name, 600) # Step 1: Generator-Agent erstellt HTML
process = await asyncio.create_subprocess_exec( await _set_progress(guide_id, "Generiere HTML…")
CLAUDE_CLI, gen_prompt = _build_generator_prompt(topic, format_name, html_path)
"-p", returncode, stdout, stderr = await _run_claude(guide_id, gen_prompt, timeout)
"--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() if returncode != 0:
await watcher await _fail(guide_id, f"Generator-Fehler: {stderr[:1000]}")
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 return
if not html_path.exists(): if not html_path.exists():
await update_guide(guide_id, status="error", progress=None, error_msg="HTML-Datei wurde nicht erstellt", updated_at=now) await _fail(guide_id, "HTML-Datei wurde nicht erstellt")
return return
if not pdf_path.exists(): # Step 2-N: Render → Review → Fix Loop
await update_guide(guide_id, status="error", progress=None, error_msg="PDF-Datei wurde nicht erstellt", updated_at=now) for iteration in range(1, max_iter + 1):
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
# Fix-Agent
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
# Final: PDF existiert bereits vom letzten Render
now = datetime.now(timezone.utc).isoformat()
await update_guide( await update_guide(
guide_id, guide_id,
status="done", status="done",
@@ -133,15 +217,18 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
stop_event.set() await _fail(guide_id, f"Timeout nach {timeout}s")
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: except Exception as e:
stop_event.set() await _fail(guide_id, str(e)[:2000])
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: finally:
_active_processes.pop(guide_id, None) _active_processes.pop(guide_id, None)
Path(prompt_file).unlink(missing_ok=True) # Preview-PNGs aufräumen
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

@@ -12,6 +12,7 @@ from routes import router
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
(STORAGE_DIR / "html").mkdir(parents=True, exist_ok=True) (STORAGE_DIR / "html").mkdir(parents=True, exist_ok=True)
(STORAGE_DIR / "pdf").mkdir(parents=True, exist_ok=True) (STORAGE_DIR / "pdf").mkdir(parents=True, exist_ok=True)
(STORAGE_DIR / "preview").mkdir(parents=True, exist_ok=True)
await init_db() await init_db()
yield yield

BIN
guides.db

Binary file not shown.