update
This commit is contained in:
139
backend/agents.py
Normal file
139
backend/agents.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Provider-Schicht: führt Agent-Aufrufe über die Claude-CLI oder OpenCode (MiniMax) aus.
|
||||
|
||||
Beide Runner sind unabhängig. Fehlt ein Binary/Key, schlägt nur der
|
||||
jeweilige Provider fehl — der andere läuft unverändert weiter.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from config import PROVIDERS, DEFAULT_PROVIDER
|
||||
|
||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||
|
||||
# Capability → Claude --allowedTools
|
||||
_CLAUDE_TOOLS = {
|
||||
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
||||
"files": "Read,Bash,Write",
|
||||
"read": "Read",
|
||||
"none": None,
|
||||
}
|
||||
|
||||
# Capability → OpenCode-Agent (Tool-Rechte in dev-ops/opencode.json definiert)
|
||||
_OPENCODE_AGENTS = {
|
||||
"full": "full",
|
||||
"files": "files",
|
||||
"read": "readonly",
|
||||
"none": "text",
|
||||
}
|
||||
|
||||
|
||||
def provider_available(provider: str) -> bool:
|
||||
cfg = PROVIDERS.get(provider)
|
||||
if not cfg:
|
||||
return False
|
||||
if shutil.which(cfg["cli"]) is None:
|
||||
return False
|
||||
env_key = cfg.get("env_key")
|
||||
if env_key and not os.environ.get(env_key):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def kill_process(agent_key: str) -> None:
|
||||
process = _active_processes.get(agent_key)
|
||||
if process and process.returncode is None:
|
||||
process.kill()
|
||||
|
||||
|
||||
async def run_agent(
|
||||
agent_key: str,
|
||||
prompt: str,
|
||||
timeout: int,
|
||||
provider: str = DEFAULT_PROVIDER,
|
||||
role: str = "fast",
|
||||
capabilities: str = "none",
|
||||
) -> tuple[int, str, str]:
|
||||
if provider not in PROVIDERS:
|
||||
return 1, "", f"Unbekannter Provider: {provider}"
|
||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
|
||||
timeout = int(timeout * PROVIDERS[provider].get("timeout_factor", 1))
|
||||
if provider == "minimax":
|
||||
return await _run_opencode(agent_key, prompt, timeout, role, capabilities)
|
||||
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
||||
|
||||
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int) -> tuple[int, str, str]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_active_processes[agent_key] = process
|
||||
try:
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(input=stdin_data),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
raise
|
||||
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
_active_processes.pop(agent_key, None)
|
||||
|
||||
|
||||
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]:
|
||||
cfg = PROVIDERS["claude"]
|
||||
cmd = [cfg["cli"], "-p", "--model", cfg[role]]
|
||||
tools = _CLAUDE_TOOLS.get(capabilities)
|
||||
if tools:
|
||||
cmd += ["--allowedTools", tools]
|
||||
cmd += ["--dangerously-skip-permissions"]
|
||||
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout)
|
||||
|
||||
|
||||
async def _run_opencode(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]:
|
||||
cfg = PROVIDERS["minimax"]
|
||||
# Prompt über Tempdatei statt argv (ARG_MAX-Schutz bei großen Projekt-Prompts)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
|
||||
f.write(prompt)
|
||||
prompt_path = Path(f.name)
|
||||
# Positional-Message MUSS vor -f stehen: -f ist ein Array-Flag und
|
||||
# frisst sonst den Text als zweiten Dateinamen ("File not found").
|
||||
cmd = [
|
||||
cfg["cli"], "run",
|
||||
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
|
||||
"-m", cfg[role],
|
||||
"--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
|
||||
"--dangerously-skip-permissions",
|
||||
"-f", str(prompt_path),
|
||||
]
|
||||
try:
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout)
|
||||
return rc, _clean_opencode_output(stdout), stderr
|
||||
finally:
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _clean_opencode_output(text: str) -> str:
|
||||
"""Entfernt ANSI-Codes und den führenden Banner ("> agent · modell")."""
|
||||
text = _ANSI_RE.sub("", text)
|
||||
lines = text.splitlines()
|
||||
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):
|
||||
lines.pop(0)
|
||||
return "\n".join(lines).strip()
|
||||
@@ -1,5 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
TEMPLATES_DIR = PROJECT_ROOT / "templates"
|
||||
STORAGE_DIR = PROJECT_ROOT / "storage"
|
||||
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
|
||||
DB_PATH = PROJECT_ROOT / "creator.db"
|
||||
DB_PATH = STORAGE_DIR / "creator.db"
|
||||
PROJECTS_DIR = PROJECT_ROOT / "projects"
|
||||
|
||||
FORMAT_META = {
|
||||
"OnePager": {"pages": "1 Seite", "time": "~5 Min"},
|
||||
"MiniGuide": {"pages": "3-5 Seiten", "time": "~15-25 Min"},
|
||||
"Guide": {"pages": "10-30 Seiten", "time": "variabel"},
|
||||
}
|
||||
|
||||
AGENT_TIMEOUT = 3600
|
||||
|
||||
MAX_CONCURRENT_GENERATIONS = 10
|
||||
|
||||
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
||||
# Rollen: "guide" = große Generierung, "fast" = Baustein-Recherche/Chat.
|
||||
DEFAULT_PROVIDER = "claude"
|
||||
PROVIDERS = {
|
||||
"claude": {
|
||||
"cli": "claude",
|
||||
"guide": "claude-opus-4-8[1m]",
|
||||
"fast": "claude-sonnet-4-6",
|
||||
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
|
||||
"timeout_factor": 1,
|
||||
},
|
||||
"minimax": {
|
||||
"cli": "opencode",
|
||||
"guide": "minimax/MiniMax-M3",
|
||||
"fast": "minimax/MiniMax-M3",
|
||||
"env_key": "MINIMAX_API_KEY",
|
||||
"timeout_factor": 3, # M3 ist bei großen Dokumenten deutlich langsamer
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import aiosqlite
|
||||
|
||||
from config import DB_PATH
|
||||
|
||||
CREATE_GUIDES = """
|
||||
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,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_PROGRESS = """
|
||||
CREATE TABLE IF NOT EXISTS guide_progress (
|
||||
guide_id TEXT NOT NULL,
|
||||
chapter TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (guide_id, chapter)
|
||||
)
|
||||
"""
|
||||
|
||||
_db: aiosqlite.Connection | None = None
|
||||
|
||||
|
||||
@@ -9,12 +31,18 @@ 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():
|
||||
db = await get_db()
|
||||
# Tabellen folgen, sobald die ersten Features stehen.
|
||||
await db.execute(CREATE_GUIDES)
|
||||
await db.execute(CREATE_PROGRESS)
|
||||
await db.execute(
|
||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
||||
"WHERE status IN ('queued', 'generating')"
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -28,3 +56,77 @@ async def close_db():
|
||||
def _row_to_dict(row, cursor):
|
||||
columns = [d[0] for d in cursor.description]
|
||||
return dict(zip(columns, row))
|
||||
|
||||
|
||||
async def create_guide(guide: dict) -> dict:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO guides (id, topic, format, instructions, status, progress, created_at, updated_at)
|
||||
VALUES (:id, :topic, :format, :instructions, :status, :progress, :created_at, :updated_at)""",
|
||||
guide,
|
||||
)
|
||||
await db.commit()
|
||||
return guide
|
||||
|
||||
|
||||
async def get_guide(guide_id: str) -> dict | None:
|
||||
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]:
|
||||
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
|
||||
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:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
# --- Kapitel-Fortschritt ---
|
||||
|
||||
async def list_progress(guide_id: str) -> list[str]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT chapter FROM guide_progress WHERE guide_id = ?", (guide_id,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
async def set_progress(guide_id: str, chapter: str, done: bool) -> None:
|
||||
from datetime import datetime, timezone
|
||||
db = await get_db()
|
||||
if done:
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO guide_progress (guide_id, chapter, created_at) VALUES (?, ?, ?)",
|
||||
(guide_id, chapter, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
"DELETE FROM guide_progress WHERE guide_id = ? AND chapter = ?", (guide_id, chapter)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_progress(guide_id: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
|
||||
await db.commit()
|
||||
|
||||
178
backend/generator.py
Normal file
178
backend/generator.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from agents import run_agent, kill_process
|
||||
from config import (
|
||||
AGENT_TIMEOUT,
|
||||
DEFAULT_PROVIDER,
|
||||
TEMPLATES_DIR,
|
||||
MAX_CONCURRENT_GENERATIONS,
|
||||
)
|
||||
from database import update_guide
|
||||
from paths import final_html_path, project_dir
|
||||
|
||||
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
|
||||
_cancelled: set[str] = set()
|
||||
|
||||
|
||||
async def cancel_guide(guide_id: str) -> bool:
|
||||
_cancelled.add(guide_id)
|
||||
kill_process(guide_id)
|
||||
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:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, progress=progress, updated_at=now)
|
||||
|
||||
|
||||
# Welche Baustein-Kategorien jedes Format abdeckt.
|
||||
FORMAT_COVERAGE = {
|
||||
"OnePager": "NUR die KERN-Bausteine, maximal verdichtet",
|
||||
"MiniGuide": "NUR die KERN-Bausteine",
|
||||
"Guide": "die KERN- und WICHTIG-Bausteine",
|
||||
}
|
||||
|
||||
|
||||
def _prompt(name: str, **kwargs) -> str:
|
||||
template = (TEMPLATES_DIR / "Prompt" / f"{name}.md").read_text(encoding="utf-8")
|
||||
return template.format(**kwargs)
|
||||
|
||||
|
||||
def _extra(instructions: str) -> str:
|
||||
return f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else ""
|
||||
|
||||
|
||||
def _build_bausteine_prompt(topic: str, bausteine_path: Path, instructions: str = "", project: Path | None = None) -> str:
|
||||
if project:
|
||||
source = _prompt("Bausteine-Quelle-Projekt", project=project)
|
||||
else:
|
||||
source = _prompt("Bausteine-Quelle-Thema", topic=topic)
|
||||
return _prompt(
|
||||
"Bausteine",
|
||||
topic=topic, source=source, bausteine_path=bausteine_path, extra=_extra(instructions),
|
||||
)
|
||||
|
||||
|
||||
def _build_guide_prompt(topic: str, format_name: str, html_path: Path, bausteine: str, instructions: str = "", project: Path | 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")
|
||||
|
||||
if project:
|
||||
facts = _prompt("Guide-Fakten-Projekt", project=project)
|
||||
else:
|
||||
facts = _prompt("Guide-Fakten-Thema")
|
||||
|
||||
return _prompt(
|
||||
"Guide",
|
||||
topic=topic, format_name=format_name, html_path=html_path,
|
||||
bausteine=bausteine, coverage=FORMAT_COVERAGE[format_name],
|
||||
facts=facts, spec=spec, reference=reference, extra=_extra(instructions),
|
||||
)
|
||||
|
||||
|
||||
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||
async with _semaphore:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, status="generating", progress="Ermittle Bausteine…", updated_at=now)
|
||||
|
||||
html_path = final_html_path(topic, format_name)
|
||||
bausteine_path = html_path.with_suffix(".bausteine.md")
|
||||
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
||||
|
||||
try:
|
||||
if guide_id in _cancelled:
|
||||
return
|
||||
|
||||
# Step 1: Bausteine ermitteln (Thema: Websuche, Projekt: Dateien lesen)
|
||||
current_step = "Bausteine"
|
||||
bs_prompt = _build_bausteine_prompt(topic, bausteine_path, instructions, project)
|
||||
returncode, bs_out, bs_err = await run_agent(
|
||||
guide_id, bs_prompt, AGENT_TIMEOUT,
|
||||
provider=provider, role="fast", capabilities="files" if project else "full",
|
||||
)
|
||||
|
||||
if guide_id in _cancelled:
|
||||
return
|
||||
if returncode != 0:
|
||||
await _fail(guide_id, _claude_error("Baustein-Fehler", returncode, bs_out, bs_err))
|
||||
return
|
||||
if not bausteine_path.exists():
|
||||
await _fail(guide_id, "Baustein-Datei wurde nicht erstellt")
|
||||
return
|
||||
bausteine = bausteine_path.read_text(encoding="utf-8")
|
||||
|
||||
# Step 2: Generator-Agent erstellt HTML nach Bausteinen
|
||||
await _set_progress(guide_id, "Generiere HTML…")
|
||||
current_step = "Generierung"
|
||||
gen_prompt = _build_guide_prompt(topic, format_name, html_path, bausteine, instructions, project)
|
||||
returncode, stdout, stderr = await run_agent(guide_id, gen_prompt, AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full")
|
||||
|
||||
if guide_id in _cancelled:
|
||||
return
|
||||
if returncode != 0:
|
||||
await _fail(guide_id, _claude_error("Generator-Fehler", returncode, stdout, stderr))
|
||||
return
|
||||
|
||||
if not html_path.exists():
|
||||
await _fail(guide_id, "HTML-Datei wurde nicht erstellt")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(
|
||||
guide_id, status="done", progress=None, updated_at=now,
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await _fail(guide_id, f"Timeout bei {current_step} nach {AGENT_TIMEOUT}s")
|
||||
except Exception as e:
|
||||
await _fail(guide_id, str(e)[:2000])
|
||||
finally:
|
||||
_cancelled.discard(guide_id)
|
||||
|
||||
|
||||
def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
|
||||
stderr = (stderr or "").strip()
|
||||
if stderr:
|
||||
return f"{label}: {stderr[:1000]}"
|
||||
tail = (stdout or "").strip()[-500:]
|
||||
if tail:
|
||||
return f"{label} (exit {returncode}, stderr leer): …{tail}"
|
||||
return f"{label} (exit {returncode}, ohne Ausgabe)"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
|
||||
transcript = "\n".join(
|
||||
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
return _prompt(
|
||||
"Chat",
|
||||
topic=topic, format_name=format_name,
|
||||
outline_block=outline.strip() or "(keine)",
|
||||
section_block=section.strip() or "(kein Abschnitt erkannt)",
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
|
||||
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
|
||||
try:
|
||||
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
|
||||
returncode, stdout, stderr = await run_agent(
|
||||
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||
)
|
||||
if returncode != 0:
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
reply = stdout.strip()
|
||||
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
|
||||
except Exception:
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
@@ -3,13 +3,14 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from config import FRONTEND_DIST
|
||||
from config import FRONTEND_DIST, STORAGE_DIR
|
||||
from database import init_db, close_db
|
||||
from routes import router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
(STORAGE_DIR / "html").mkdir(parents=True, exist_ok=True)
|
||||
await init_db()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
62
backend/models.py
Normal file
62
backend/models.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
FormatType = Literal[
|
||||
"OnePager",
|
||||
"MiniGuide",
|
||||
"Guide",
|
||||
]
|
||||
|
||||
ProviderType = Literal["claude", "minimax"]
|
||||
|
||||
|
||||
class GuideCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
format: FormatType
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
id: str
|
||||
available: bool
|
||||
|
||||
|
||||
class GuideResponse(BaseModel):
|
||||
id: str
|
||||
topic: str
|
||||
format: str
|
||||
status: str
|
||||
progress: str | None = None
|
||||
error_msg: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: Literal["user", "assistant"]
|
||||
content: str = Field(min_length=1, max_length=8000)
|
||||
|
||||
|
||||
class GuideChatRequest(BaseModel):
|
||||
section: str = Field(default="", max_length=20000)
|
||||
outline: str = Field(default="", max_length=8000)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class GuideChatResponse(BaseModel):
|
||||
reply: str
|
||||
|
||||
|
||||
class ProgressUpdate(BaseModel):
|
||||
chapter: str = Field(min_length=1, max_length=100)
|
||||
done: bool
|
||||
|
||||
|
||||
class ProgressResponse(BaseModel):
|
||||
chapters: list[str]
|
||||
16
backend/paths.py
Normal file
16
backend/paths.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pathlib import Path
|
||||
|
||||
from config import STORAGE_DIR, PROJECTS_DIR
|
||||
|
||||
|
||||
def safe_basename(topic: str, format_name: str) -> str:
|
||||
clean = topic.replace("/", "_").replace("\x00", "")
|
||||
return f"{clean} - {format_name}"
|
||||
|
||||
|
||||
def final_html_path(topic: str, format_name: str) -> Path:
|
||||
return STORAGE_DIR / "html" / f"{safe_basename(topic, format_name)}.html"
|
||||
|
||||
|
||||
def project_dir(name: str) -> Path:
|
||||
return PROJECTS_DIR / name
|
||||
@@ -1,8 +1,151 @@
|
||||
from fastapi import APIRouter
|
||||
import asyncio
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from agents import provider_available
|
||||
from config import FORMAT_META, PROJECTS_DIR, PROVIDERS
|
||||
from database import (
|
||||
create_guide, delete_guide, get_guide, list_guides,
|
||||
list_progress, set_progress, delete_progress,
|
||||
)
|
||||
from generator import generate_guide, cancel_guide, chat_with_guide
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
GuideChatRequest, GuideChatResponse,
|
||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||
)
|
||||
from paths import final_html_path, project_dir
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
@router.get("/formats")
|
||||
async def get_formats():
|
||||
return FORMAT_META
|
||||
|
||||
|
||||
@router.get("/providers", response_model=list[ProviderInfo])
|
||||
async def get_providers():
|
||||
return [{"id": pid, "available": provider_available(pid)} for pid in PROVIDERS]
|
||||
|
||||
|
||||
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 []
|
||||
return [{"name": entry.name} for entry in sorted(PROJECTS_DIR.iterdir()) if entry.is_dir()]
|
||||
|
||||
|
||||
@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)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/guides", response_model=GuideResponse)
|
||||
async def create(req: GuideCreateRequest):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
guide = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"topic": req.topic.strip(),
|
||||
"format": req.format,
|
||||
"instructions": req.instructions.strip(),
|
||||
"status": "queued",
|
||||
"progress": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
await create_guide(guide)
|
||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider))
|
||||
return guide
|
||||
|
||||
|
||||
@router.get("/guides", response_model=list[GuideResponse])
|
||||
async def list_all():
|
||||
return await list_guides()
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}", response_model=GuideResponse)
|
||||
async def get_one(guide_id: str):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
return guide
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}/html")
|
||||
async def download_html(guide_id: str):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
if guide["status"] != "done":
|
||||
raise HTTPException(404, "HTML nicht verfügbar")
|
||||
html_path = final_html_path(guide["topic"], guide["format"])
|
||||
if not html_path.exists():
|
||||
raise HTTPException(404, "Datei nicht gefunden")
|
||||
return FileResponse(html_path, media_type="text/html", content_disposition_type="inline")
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse)
|
||||
async def guide_chat(guide_id: str, req: GuideChatRequest):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
reply = await chat_with_guide(
|
||||
guide["topic"], guide["format"], req.section, req.outline,
|
||||
[m.model_dump() for m in req.messages],
|
||||
provider=req.provider,
|
||||
)
|
||||
return {"reply": reply}
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/cancel")
|
||||
async def cancel(guide_id: str):
|
||||
cancelled = await cancel_guide(guide_id)
|
||||
if not cancelled:
|
||||
raise HTTPException(404, "Kein aktiver Prozess gefunden")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/guides/{guide_id}")
|
||||
async def remove(guide_id: str):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
html_path = final_html_path(guide["topic"], guide["format"])
|
||||
html_path.unlink(missing_ok=True)
|
||||
html_path.with_suffix(".bausteine.md").unlink(missing_ok=True)
|
||||
await delete_progress(guide_id)
|
||||
await delete_guide(guide_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}/progress", response_model=ProgressResponse)
|
||||
async def get_progress(guide_id: str):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
return {"chapters": await list_progress(guide_id)}
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/progress", response_model=ProgressResponse)
|
||||
async def update_progress(guide_id: str, req: ProgressUpdate):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
await set_progress(guide_id, req.chapter, req.done)
|
||||
return {"chapters": await list_progress(guide_id)}
|
||||
|
||||
Reference in New Issue
Block a user