This commit is contained in:
Team3
2026-05-27 01:00:33 +02:00
parent ad2f3e4786
commit 351f330db0
10 changed files with 1184 additions and 13 deletions

View File

@@ -1,7 +1,7 @@
import aiosqlite
from config import DB_PATH
CREATE_TABLE = """
CREATE_GUIDES = """
CREATE TABLE IF NOT EXISTS guides (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
@@ -17,6 +17,32 @@ CREATE TABLE IF NOT EXISTS guides (
)
"""
CREATE_BAUSTEINE = """
CREATE TABLE IF NOT EXISTS bausteine (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
purpose TEXT NOT NULL DEFAULT '',
example TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
CREATE_SUGGESTIONS = """
CREATE TABLE IF NOT EXISTS baustein_suggestions (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
purpose TEXT NOT NULL DEFAULT '',
example TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL
)
"""
_db: aiosqlite.Connection | None = None
@@ -30,7 +56,9 @@ async def get_db() -> aiosqlite.Connection:
async def init_db():
db = await get_db()
await db.execute(CREATE_TABLE)
await db.execute(CREATE_GUIDES)
await db.execute(CREATE_BAUSTEINE)
await db.execute(CREATE_SUGGESTIONS)
cursor = await db.execute("PRAGMA table_info(guides)")
columns = {row[1] for row in await cursor.fetchall()}
if "instructions" not in columns:
@@ -94,3 +122,102 @@ async def delete_guide(guide_id: str) -> bool:
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
await db.commit()
return cursor.rowcount > 0
# --- Bausteine ---
async def create_baustein(baustein: dict) -> dict:
db = await get_db()
await db.execute(
"""INSERT INTO bausteine (id, topic, title, description, purpose, example, created_at, updated_at)
VALUES (:id, :topic, :title, :description, :purpose, :example, :created_at, :updated_at)""",
baustein,
)
await db.commit()
return baustein
async def list_bausteine(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute(
"SELECT * FROM bausteine WHERE topic = ? ORDER BY created_at ASC", (topic,)
)
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def get_baustein(baustein_id: str) -> dict | None:
db = await get_db()
cursor = await db.execute("SELECT * FROM bausteine WHERE id = ?", (baustein_id,))
row = await cursor.fetchone()
if row is None:
return None
return _row_to_dict(row, cursor)
async def update_baustein(baustein_id: str, **fields) -> None:
sets = ", ".join(f"{k} = :{k}" for k in fields)
fields["id"] = baustein_id
db = await get_db()
await db.execute(f"UPDATE bausteine SET {sets} WHERE id = :id", fields)
await db.commit()
async def delete_baustein(baustein_id: str) -> bool:
db = await get_db()
cursor = await db.execute("DELETE FROM bausteine WHERE id = ?", (baustein_id,))
await db.commit()
return cursor.rowcount > 0
# --- Baustein Suggestions ---
async def create_suggestions(suggestions: list[dict]) -> None:
db = await get_db()
await db.executemany(
"""INSERT INTO baustein_suggestions (id, topic, title, description, purpose, example, status, created_at)
VALUES (:id, :topic, :title, :description, :purpose, :example, :status, :created_at)""",
suggestions,
)
await db.commit()
async def list_suggestions(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute(
"SELECT * FROM baustein_suggestions WHERE topic = ? ORDER BY created_at ASC", (topic,)
)
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def get_suggestion(suggestion_id: str) -> dict | None:
db = await get_db()
cursor = await db.execute("SELECT * FROM baustein_suggestions WHERE id = ?", (suggestion_id,))
row = await cursor.fetchone()
if row is None:
return None
return _row_to_dict(row, cursor)
async def update_suggestion(suggestion_id: str, **fields) -> None:
sets = ", ".join(f"{k} = :{k}" for k in fields)
fields["id"] = suggestion_id
db = await get_db()
await db.execute(f"UPDATE baustein_suggestions SET {sets} WHERE id = :id", fields)
await db.commit()
async def delete_suggestion(suggestion_id: str) -> bool:
db = await get_db()
cursor = await db.execute("DELETE FROM baustein_suggestions WHERE id = ?", (suggestion_id,))
await db.commit()
return cursor.rowcount > 0
async def delete_pending_suggestions(topic: str) -> None:
db = await get_db()
await db.execute(
"DELETE FROM baustein_suggestions WHERE topic = ? AND status = 'pending'", (topic,)
)
await db.commit()

View File

@@ -1,6 +1,9 @@
import asyncio
import json
import re
import subprocess
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -13,7 +16,14 @@ from config import (
REVIEW_TIMEOUTS,
STORAGE_DIR,
)
from database import update_guide
from database import (
update_guide,
create_baustein,
create_suggestions,
delete_pending_suggestions,
list_bausteine,
update_baustein,
)
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
_active_processes: dict[str, asyncio.subprocess.Process] = {}
@@ -35,12 +45,13 @@ async def _set_progress(guide_id: str, progress: str) -> None:
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]:
async def _run_claude(guide_id: str, prompt: str, timeout: int, tools: str | None = "Write,Bash,Read,WebSearch,WebFetch") -> tuple[int, str, str]:
cmd = [CLAUDE_CLI, "-p"]
if tools:
cmd += ["--allowedTools", tools]
cmd += ["--dangerously-skip-permissions"]
process = await asyncio.create_subprocess_exec(
CLAUDE_CLI,
"-p",
"--allowedTools", "Write,Bash,Read,WebSearch,WebFetch",
"--dangerously-skip-permissions",
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -397,3 +408,136 @@ async def rework_guide(guide_id: str, topic: str, format_name: str, instructions
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)
# --- Bausteine ---
_suggestions_generating: set[str] = set()
def is_suggestions_generating(topic: str) -> bool:
return topic in _suggestions_generating
def _parse_json(text: str):
text = text.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
return json.loads(text)
def _build_suggestions_prompt(topic: str, html_paths: list[Path], existing_titles: list[str]) -> str:
spec = (TEMPLATES_DIR / "Format" / "Baustein.md").read_text(encoding="utf-8")
reference = (TEMPLATES_DIR / "Referenz" / "Baustein.md").read_text(encoding="utf-8")
existing_list = "\n".join(f"- {t}" for t in existing_titles) if existing_titles else "(keine)"
if html_paths:
read_instructions = "\n".join(f"- Lies: {p}" for p in html_paths)
guides_section = f"""SCHRITT 1 — Guides lesen:
{read_instructions}
"""
else:
guides_section = ""
return f"""Schlage fundamentale Bausteine (Kernkonzepte) zum Thema "{topic}" vor.
{guides_section}Bereits vorhandene Bausteine (NICHT erneut vorschlagen):
{existing_list}
FORMAT-SPEZIFIKATION:
{spec}
REFERENZ-BEISPIEL:
{reference}
Schlage bis zu 20 Bausteine vor. Antworte AUSSCHLIESSLICH mit einem JSON-Array. Jedes Element hat:
- "title"
- "description"
- "purpose"
- "examples": Array mit 4 Objekten {{"label": "...", "code": "..."}}
Orientiere dich an der Spezifikation und Referenz. NUR das JSON-Array, kein weiterer Text.
"""
def _build_baustein_detail_prompt(topic: str, title: str) -> str:
spec = (TEMPLATES_DIR / "Format" / "Baustein.md").read_text(encoding="utf-8")
reference = (TEMPLATES_DIR / "Referenz" / "Baustein.md").read_text(encoding="utf-8")
return f"""Generiere Details für den Baustein "{title}" im Kontext des Themas "{topic}".
FORMAT-SPEZIFIKATION:
{spec}
REFERENZ-BEISPIEL:
{reference}
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt mit den Feldern "description", "purpose", "examples".
"examples" ist ein Array mit 4 Objekten {{"label": "...", "code": "..."}}.
Orientiere dich an der Spezifikation und Referenz. Kein weiterer Text, nur das JSON.
"""
async def generate_suggestions(topic: str, html_paths: list[Path]) -> None:
_suggestions_generating.add(topic)
try:
existing = await list_bausteine(topic)
existing_titles = [b["title"] for b in existing]
await delete_pending_suggestions(topic)
prompt = _build_suggestions_prompt(topic, html_paths, existing_titles)
tools = "Read" if html_paths else None
returncode, stdout, stderr = await _run_claude("suggestions-" + topic, prompt, 180, tools=tools)
if returncode != 0:
return
items = _parse_json(stdout)
if not isinstance(items, list):
return
now = datetime.now(timezone.utc).isoformat()
suggestions = []
for item in items[:20]:
suggestions.append({
"id": str(uuid.uuid4()),
"topic": topic,
"title": item.get("title", ""),
"description": item.get("description", ""),
"purpose": item.get("purpose", ""),
"example": json.dumps(item.get("examples", []), ensure_ascii=False),
"status": "pending",
"created_at": now,
})
if suggestions:
await create_suggestions(suggestions)
except Exception:
pass
finally:
_suggestions_generating.discard(topic)
async def generate_baustein_detail(baustein_id: str, topic: str, title: str) -> None:
try:
prompt = _build_baustein_detail_prompt(topic, title)
returncode, stdout, stderr = await _run_claude("baustein-" + baustein_id, prompt, 60, tools=None)
if returncode != 0:
return
data = _parse_json(stdout)
if not isinstance(data, dict):
return
now = datetime.now(timezone.utc).isoformat()
await update_baustein(
baustein_id,
description=data.get("description", ""),
purpose=data.get("purpose", ""),
example=json.dumps(data.get("examples", []), ensure_ascii=False),
updated_at=now,
)
except Exception:
pass

View File

@@ -32,3 +32,30 @@ class GuideResponse(BaseModel):
pdf_path: str | None = None
created_at: str
updated_at: str
class BausteinCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
title: str = Field(min_length=1, max_length=200)
class BausteinResponse(BaseModel):
id: str
topic: str
title: str
description: str
purpose: str
example: str
created_at: str
updated_at: str
class SuggestionResponse(BaseModel):
id: str
topic: str
title: str
description: str
purpose: str
example: str
status: str
created_at: str

View File

@@ -7,9 +7,16 @@ from fastapi import APIRouter, HTTPException
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, rework_guide, cancel_guide
from models import GuideCreateRequest, GuideReworkRequest, GuideResponse
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,
list_suggestions, get_suggestion, update_suggestion, delete_suggestion,
)
from generator import generate_guide, rework_guide, cancel_guide, generate_suggestions, generate_baustein_detail, is_suggestions_generating
from models import (
GuideCreateRequest, GuideReworkRequest, GuideResponse,
BausteinCreateRequest, BausteinResponse, SuggestionResponse,
)
router = APIRouter(prefix="/api")
@@ -108,3 +115,92 @@ async def remove(guide_id: str):
Path(guide["pdf_path"]).unlink(missing_ok=True)
await delete_guide(guide_id)
return {"ok": True}
# --- Bausteine ---
@router.get("/bausteine", response_model=list[BausteinResponse])
async def get_bausteine(topic: str):
return await list_bausteine(topic)
@router.post("/bausteine", response_model=BausteinResponse)
async def add_baustein(req: BausteinCreateRequest):
now = datetime.now(timezone.utc).isoformat()
baustein = {
"id": str(uuid.uuid4()),
"topic": req.topic.strip(),
"title": req.title.strip(),
"description": "",
"purpose": "",
"example": "",
"created_at": now,
"updated_at": now,
}
await db_create_baustein(baustein)
asyncio.create_task(generate_baustein_detail(baustein["id"], baustein["topic"], baustein["title"]))
return baustein
@router.delete("/bausteine/{baustein_id}")
async def remove_baustein(baustein_id: str):
b = await get_baustein(baustein_id)
if b is None:
raise HTTPException(404, "Baustein nicht gefunden")
await db_delete_baustein(baustein_id)
return {"ok": True}
# --- Baustein Suggestions ---
@router.get("/bausteine/suggestions", response_model=list[SuggestionResponse])
async def get_suggestions(topic: str):
return await list_suggestions(topic)
@router.post("/bausteine/suggestions/generate")
async def trigger_suggestions(topic: str):
if is_suggestions_generating(topic):
return {"ok": True, "status": "already_generating"}
guides = await list_guides()
html_paths = []
for g in guides:
if g["topic"] == topic and g["status"] == "done" and g["html_path"]:
html_paths.append(Path(g["html_path"]))
asyncio.create_task(generate_suggestions(topic, html_paths))
return {"ok": True}
@router.get("/bausteine/suggestions/status")
async def suggestions_status(topic: str):
return {"generating": is_suggestions_generating(topic)}
@router.post("/bausteine/suggestions/{suggestion_id}/add")
async def accept_suggestion(suggestion_id: str):
s = await get_suggestion(suggestion_id)
if s is None:
raise HTTPException(404, "Vorschlag nicht gefunden")
now = datetime.now(timezone.utc).isoformat()
baustein = {
"id": str(uuid.uuid4()),
"topic": s["topic"],
"title": s["title"],
"description": s["description"],
"purpose": s["purpose"],
"example": s["example"],
"created_at": now,
"updated_at": now,
}
await db_create_baustein(baustein)
await delete_suggestion(suggestion_id)
return baustein
@router.post("/bausteine/suggestions/{suggestion_id}/ignore")
async def ignore_suggestion(suggestion_id: str):
s = await get_suggestion(suggestion_id)
if s is None:
raise HTTPException(404, "Vorschlag nicht gefunden")
await update_suggestion(suggestion_id, status="ignored")
return {"ok": True}