This commit is contained in:
team3
2026-06-03 22:05:20 +02:00
parent d7e8df6876
commit 40de56c27b
5119 changed files with 552560 additions and 24 deletions

View File

@@ -5,6 +5,8 @@ TEMPLATES_DIR = PROJECT_ROOT / "templates"
STORAGE_DIR = PROJECT_ROOT / "storage"
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
DB_PATH = STORAGE_DIR / "guides.db"
PROJECTS_DIR = PROJECT_ROOT / "projects"
PROJECTS_CACHE_DIR = STORAGE_DIR / "projects"
ALLOWED_FORMATS = [
"OnePager",
@@ -27,7 +29,8 @@ AGENT_TIMEOUT = 3600
MAX_CONCURRENT_GENERATIONS = 6
CLAUDE_CLI = "claude"
MODEL_GUIDE = "claude-opus-4-8"
MODEL_GUIDE = "claude-opus-4-8[1m]"
MODEL_BAUSTEIN_GEN = "claude-sonnet-4-6"
MODEL_BAUSTEIN_REWORK = "claude-sonnet-4-6"
MODEL_CHAT = "claude-sonnet-4-6"
MODEL_PROJECT_INDEX = MODEL_BAUSTEIN_GEN

View File

@@ -17,6 +17,7 @@ from config import (
MODEL_BAUSTEIN_GEN,
MODEL_BAUSTEIN_REWORK,
MODEL_CHAT,
MODEL_PROJECT_INDEX,
STORAGE_DIR,
)
from database import (
@@ -28,7 +29,7 @@ from database import (
update_baustein,
update_baustein_sort_orders,
)
from paths import final_paths, temp_paths
from paths import final_paths, temp_paths, project_dir, project_cache_path
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
_active_processes: dict[str, asyncio.subprocess.Process] = {}
@@ -94,15 +95,48 @@ async def _render_pdf(html_path: Path, pdf_path: Path) -> tuple[bool, str]:
return True, ""
def _build_generator_prompt(topic: str, format_name: str, html_path: Path, instructions: str = "") -> str:
def _build_project_index_prompt(name: str, cache_path: Path, has_cache: bool) -> str:
src = project_dir(name)
if has_cache:
return f"""Es existiert bereits eine verdichtete Wissensdatei zum Projekt "{name}" unter {cache_path}.
Prüfe sie gegen das echte Projekt unter {src}.
SCHRITT 1: Lies die bestehende Wissensdatei {cache_path}.
SCHRITT 2: Verschaffe dir mit Bash (ls/find) und Read einen vollständigen Überblick über {src}. Lies README, Doku-Ordner und den relevanten Quellcode.
SCHRITT 3: Ergänze fehlende oder veraltete wichtige Informationen. Ein früherer Lauf war evtl. schlampig und hat Wichtiges ausgelassen.
SCHRITT 4: Schreibe die vollständige, korrigierte Wissensdatei zurück nach {cache_path}.
Die Datei muss als alleinige Faktenbasis für einen Lern-Guide ausreichen. Erfasse: Zweck, Architektur, Abläufe, wichtige Dateien, Konfiguration, Befehle, Datenstrukturen, Besonderheiten. Schreibe NUR diese eine Datei."""
return f"""Lies das Projekt "{name}" vollständig ein und erstelle daraus eine verdichtete Wissensdatei.
SCHRITT 1: Verschaffe dir mit Bash (ls/find) einen Überblick über {src}.
SCHRITT 2: Lies README, Doku-Ordner und den relevanten Quellcode mit dem Read-Tool.
SCHRITT 3: Schreibe eine vollständige Wissensdatei nach {cache_path}.
Die Datei muss als alleinige Faktenbasis für einen Lern-Guide ausreichen. Erfasse: Zweck, Architektur, Abläufe, wichtige Dateien, Konfiguration, Befehle, Datenstrukturen, Besonderheiten. Lass nichts Wichtiges aus. Schreibe NUR diese eine Datei."""
def _build_generator_prompt(topic: str, format_name: str, html_path: Path, instructions: str = "", project_content: str | None = None) -> 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 ""
if project_content:
research_line = (
f'Die folgenden PROJEKT-INHALTE sind die Quelle der Wahrheit für "{topic}". '
"Nutze sie als primäre Faktenbasis. Recherchiere per Websuche nur ergänzend, "
"um fehlende oder sich ändernde Fakten (z. B. aktuelle Versionsnummern externer Tools) zu prüfen.\n\n"
f"PROJEKT-INHALTE (Quelle der Wahrheit):\n{project_content}"
)
else:
research_line = f'Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen.'
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.
{research_line}
Schreibe die HTML-Datei nach: {html_path}
@@ -144,16 +178,29 @@ Führe KEIN weasyprint aus, erzeuge KEINE PDF.
"""
def _build_content_review_prompt(topic: str, format_name: str, html_path: Path) -> str:
def _build_content_review_prompt(topic: str, format_name: str, html_path: Path, project_content: str | None = None) -> str:
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
if project_content:
check_step = (
"SCHRITT 2 — Fakten prüfen:\n"
f'Vergleiche den Inhalt mit den folgenden PROJEKT-INHALTEN (Quelle der Wahrheit) für "{topic}". '
"Stimmen die Projekt-Fakten? Fehlt Wichtiges aus dem Projekt? "
"Externe/aktuelle Fakten (Versionsnummern fremder Tools) ergänzend per WebSearch prüfen.\n\n"
f"PROJEKT-INHALTE:\n{project_content}"
)
else:
check_step = (
"SCHRITT 2 — Fakten per Websuche prüfen:\n"
f'Recherchiere mit WebSearch, ob Versionsnummern, Jahreszahlen und zentrale Fakten zu "{topic}" aktuell und korrekt sind.'
)
return f"""Prüfe den Inhalt der HTML-Datei {html_path} für den "{format_name}" zum Thema "{topic}".
SCHRITT 1 — HTML-Datei lesen:
Öffne die Datei {html_path} mit dem Read-Tool.
SCHRITT 2 — Fakten per Websuche prüfen:
Recherchiere mit WebSearch, ob Versionsnummern, Jahreszahlen und zentrale Fakten zu "{topic}" aktuell und korrekt sind.
{check_step}
SCHRITT 3 — Vollständigkeit prüfen anhand dieser Spezifikation:
{spec}
@@ -178,7 +225,7 @@ FAIL
"""
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "") -> None:
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", reindex: bool = False) -> None:
async with _semaphore:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now)
@@ -192,9 +239,31 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
current_step = "Generierung"
current_timeout = AGENT_TIMEOUT
# Step 0: Projekt einlesen (nur wenn topic ein Projekt ist)
project_content: str | None = None
if project_dir(topic).is_dir():
cache_path = project_cache_path(topic)
if reindex or not cache_path.exists():
await _set_progress(guide_id, "Lese Projekt…")
current_step = "Projekt-Einlesen"
index_prompt = _build_project_index_prompt(topic, cache_path, cache_path.exists())
returncode, idx_out, idx_err = await _run_claude(
guide_id, index_prompt, AGENT_TIMEOUT,
tools="Read,Bash,Write", model=MODEL_PROJECT_INDEX,
)
if guide_id in _cancelled:
return
if returncode != 0:
await _fail(guide_id, _claude_error("Projekt-Einlese-Fehler", returncode, idx_out, idx_err))
return
if not cache_path.exists():
await _fail(guide_id, "Projekt-Wissensdatei wurde nicht erstellt")
return
project_content = cache_path.read_text(encoding="utf-8")
# Step 1: Generator-Agent erstellt HTML
await _set_progress(guide_id, "Generiere HTML…")
gen_prompt = _build_generator_prompt(topic, format_name, html_path, instructions)
gen_prompt = _build_generator_prompt(topic, format_name, html_path, instructions, project_content)
returncode, stdout, stderr = await _run_claude(guide_id, gen_prompt, AGENT_TIMEOUT, model=MODEL_GUIDE)
if guide_id in _cancelled:
@@ -214,7 +283,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
await _set_progress(guide_id, "Prüfe Inhalt…")
current_step = "Inhalts-Review"
current_timeout = AGENT_TIMEOUT
content_prompt = _build_content_review_prompt(topic, format_name, html_path)
content_prompt = _build_content_review_prompt(topic, format_name, html_path, project_content)
returncode, review_out, review_err = await _run_claude(guide_id, content_prompt, AGENT_TIMEOUT, model=MODEL_GUIDE)
if returncode != 0:

View File

@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from config import FRONTEND_DIST, STORAGE_DIR
from config import FRONTEND_DIST, STORAGE_DIR, PROJECTS_CACHE_DIR
from database import init_db, close_db
from routes import router
@@ -12,6 +12,7 @@ from routes import router
async def lifespan(app: FastAPI):
(STORAGE_DIR / "html").mkdir(parents=True, exist_ok=True)
(STORAGE_DIR / "pdf").mkdir(parents=True, exist_ok=True)
PROJECTS_CACHE_DIR.mkdir(parents=True, exist_ok=True)
await init_db()
yield
await close_db()

View File

@@ -14,6 +14,12 @@ class GuideCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
format: FormatType
instructions: str = Field(default="", max_length=2000)
reindex: bool = False
class ProjectResponse(BaseModel):
name: str
cached: bool
class GuideReworkRequest(BaseModel):

View File

@@ -1,6 +1,6 @@
from pathlib import Path
from config import STORAGE_DIR
from config import STORAGE_DIR, PROJECTS_DIR, PROJECTS_CACHE_DIR
def safe_basename(topic: str, format_name: str) -> str:
@@ -15,3 +15,11 @@ def final_paths(topic: str, format_name: str) -> tuple[Path, Path]:
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"
def project_dir(name: str) -> Path:
return PROJECTS_DIR / name
def project_cache_path(name: str) -> Path:
return PROJECTS_CACHE_DIR / f"{name}.md"

View File

@@ -1,11 +1,12 @@
import asyncio
import shutil
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from config import FORMAT_META
from config import FORMAT_META, PROJECTS_DIR
from database import (
create_guide, delete_guide, get_guide, list_guides,
create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein,
@@ -18,9 +19,9 @@ from models import (
BausteinCreateRequest, BausteinReworkRequest, BausteinSortRequest, BausteinResponse, SuggestionResponse,
TopicSuggestRequest, TopicSuggestion,
GuideChatRequest, GuideChatResponse,
ProgressUpdate, ProgressResponse,
ProgressUpdate, ProgressResponse, ProjectResponse,
)
from paths import final_paths
from paths import final_paths, project_dir, project_cache_path
router = APIRouter(prefix="/api")
@@ -30,6 +31,34 @@ async def get_formats():
return FORMAT_META
def _safe_project_name(name: str) -> str:
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name:
raise HTTPException(400, "Ungültiger Projektname")
return name
@router.get("/projects", response_model=list[ProjectResponse])
async def list_projects():
if not PROJECTS_DIR.is_dir():
return []
result = []
for entry in sorted(PROJECTS_DIR.iterdir()):
if entry.is_dir():
result.append({"name": entry.name, "cached": project_cache_path(entry.name).exists()})
return result
@router.delete("/projects/{name}")
async def remove_project(name: str):
_safe_project_name(name)
pdir = project_dir(name)
if not pdir.is_dir():
raise HTTPException(404, "Projekt nicht gefunden")
shutil.rmtree(pdir)
project_cache_path(name).unlink(missing_ok=True)
return {"ok": True}
@router.post("/topic-suggestions", response_model=list[TopicSuggestion])
async def topic_suggestions(req: TopicSuggestRequest):
guides = await list_guides()
@@ -51,7 +80,7 @@ async def create(req: GuideCreateRequest):
"updated_at": now,
}
await create_guide(guide)
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"]))
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.reindex))
return guide