update
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user