diff --git a/backend/config.py b/backend/config.py index e683245..52125c9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -31,4 +31,4 @@ CLAUDE_CLI = "claude" MODEL_GUIDE = "claude-opus-4-8" MODEL_BAUSTEIN_GEN = "claude-sonnet-4-6" -MODEL_BAUSTEIN_REWORK = "claude-haiku-4-5" +MODEL_BAUSTEIN_REWORK = "claude-sonnet-4-6" diff --git a/backend/database.py b/backend/database.py index 3bd6f84..8810ed4 100644 --- a/backend/database.py +++ b/backend/database.py @@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS bausteine ( description TEXT NOT NULL DEFAULT '', purpose TEXT NOT NULL DEFAULT '', example TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) @@ -62,6 +63,10 @@ async def init_db(): columns = {row[1] for row in await cursor.fetchall()} if "instructions" not in columns: await db.execute("ALTER TABLE guides ADD COLUMN instructions TEXT NOT NULL DEFAULT ''") + cursor = await db.execute("PRAGMA table_info(bausteine)") + columns = {row[1] for row in await cursor.fetchall()} + if "sort_order" not in columns: + await db.execute("ALTER TABLE bausteine ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0") await db.execute( "UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' " "WHERE status IN ('queued', 'generating')" @@ -153,12 +158,22 @@ async def create_baustein(baustein: dict) -> dict: 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,) + "SELECT * FROM bausteine WHERE topic = ? ORDER BY sort_order ASC, created_at ASC", (topic,) ) rows = await cursor.fetchall() return [_row_to_dict(row, cursor) for row in rows] +async def update_baustein_sort_orders(topic: str, order_map: dict) -> None: + db = await get_db() + for baustein_id, order in order_map.items(): + await db.execute( + "UPDATE bausteine SET sort_order = ? WHERE id = ? AND topic = ?", + (order, baustein_id, topic), + ) + await db.commit() + + async def get_baustein(baustein_id: str) -> dict | None: db = await get_db() cursor = await db.execute("SELECT * FROM bausteine WHERE id = ?", (baustein_id,)) diff --git a/backend/generator.py b/backend/generator.py index 4639601..905376c 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -25,6 +25,7 @@ from database import ( delete_pending_suggestions, list_bausteine, update_baustein, + update_baustein_sort_orders, ) from paths import final_paths, temp_paths @@ -327,12 +328,17 @@ async def _fail(guide_id: str, msg: str) -> None: # --- Bausteine --- _suggestions_generating: set[str] = set() +_sorting: set[str] = set() def is_suggestions_generating(topic: str) -> bool: return topic in _suggestions_generating +def is_sorting(topic: str) -> bool: + return topic in _sorting + + def _parse_json(text: str): text = text.strip() text = re.sub(r"^```(?:json)?\s*", "", text) @@ -484,6 +490,8 @@ async def rework_baustein(baustein_id: str, topic: str, title: str, current: dic def _build_baustein_rework_prompt(topic: str, title: str, current: dict, instructions: str) -> str: + spec = (TEMPLATES_DIR / "Format" / "Baustein.md").read_text(encoding="utf-8") + current_json = json.dumps({ "title": title, "description": current.get("description", ""), @@ -499,9 +507,53 @@ AKTUELLER STAND: ANWEISUNGEN VOM NUTZER: {instructions} +FORMAT-SPEZIFIKATION: +{spec} + Antworte AUSSCHLIESSLICH mit einem JSON-Objekt mit den Feldern "title", "description", "purpose", "examples". "examples" ist ein Array mit Objekten {{"label": "...", "code": "..."}}. -Kein weiterer Text, nur das JSON. +Orientiere dich an der Spezifikation. Kein weiterer Text, nur das JSON. """ + + +def _build_sort_prompt(topic: str, bausteine: list[dict], instructions: str) -> str: + items = "\n".join( + f"- id={b['id']} | {b['title']} | {b['description']} | {b['purpose']}" + for b in bausteine + ) + if instructions: + criterion = f"Sortiere die folgenden Bausteine zum Thema \"{topic}\" STRIKT nach diesem Kriterium:\n\n{instructions}" + else: + criterion = f"Sortiere die folgenden Bausteine zum Thema \"{topic}\" von Anfaenger zu Experte (erstes = einfachster, letztes = komplexester)." + + return f"""{criterion} + +BAUSTEINE: +{items} + +Antworte AUSSCHLIESSLICH mit einem JSON-Array der IDs in der gewuenschten Reihenfolge. +Beispiel: [\"id1\", \"id2\", \"id3\"] + +Kein weiterer Text, nur das JSON-Array. +""" + + +async def sort_bausteine(topic: str, bausteine: list[dict], instructions: str = "") -> None: + _sorting.add(topic) + try: + prompt = _build_sort_prompt(topic, bausteine, instructions) + returncode, stdout, stderr = await _run_claude("sort-" + topic, prompt, 300, tools=None, model=MODEL_BAUSTEIN_GEN) + if returncode != 0: + return + ids = _parse_json(stdout) + if not isinstance(ids, list): + return + order_map = {bid: i for i, bid in enumerate(ids) if isinstance(bid, str)} + if order_map: + await update_baustein_sort_orders(topic, order_map) + except Exception as e: + print(f"[sort] topic={topic} Exception: {type(e).__name__}: {e}") + finally: + _sorting.discard(topic) diff --git a/backend/models.py b/backend/models.py index d478412..3b2a598 100644 --- a/backend/models.py +++ b/backend/models.py @@ -49,10 +49,15 @@ class BausteinResponse(BaseModel): description: str purpose: str example: str + sort_order: int = 0 created_at: str updated_at: str +class BausteinSortRequest(BaseModel): + instructions: str = Field(default="", max_length=2000) + + class SuggestionResponse(BaseModel): id: str topic: str diff --git a/backend/routes.py b/backend/routes.py index 8322d7a..2c120b6 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -11,10 +11,10 @@ from database import ( 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, rework_baustein, is_suggestions_generating +from generator import generate_guide, rework_guide, cancel_guide, generate_suggestions, generate_baustein_detail, rework_baustein, sort_bausteine, is_suggestions_generating, is_sorting from models import ( GuideCreateRequest, GuideReworkRequest, GuideResponse, - BausteinCreateRequest, BausteinReworkRequest, BausteinResponse, SuggestionResponse, + BausteinCreateRequest, BausteinReworkRequest, BausteinSortRequest, BausteinResponse, SuggestionResponse, ) from paths import final_paths @@ -167,6 +167,22 @@ async def rework_baustein_route(baustein_id: str, req: BausteinReworkRequest): return {"ok": True} +@router.post("/bausteine/sort") +async def sort_bausteine_route(topic: str, req: BausteinSortRequest): + if is_sorting(topic): + return {"ok": True, "status": "already_sorting"} + bausteine = await list_bausteine(topic) + if not bausteine: + return {"ok": True} + asyncio.create_task(sort_bausteine(topic, bausteine, req.instructions.strip())) + return {"ok": True} + + +@router.get("/bausteine/sort/status") +async def sort_status(topic: str): + return {"sorting": is_sorting(topic)} + + # --- Baustein Suggestions --- @router.get("/bausteine/suggestions", response_model=list[SuggestionResponse]) diff --git a/frontend/src/api.js b/frontend/src/api.js index 07edf4b..1f69021 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -70,6 +70,19 @@ export async function reworkBaustein(id, instructions) { }) } +export async function sortBausteine(topic, instructions = '') { + await fetch(`${BASE}/bausteine/sort?topic=${encodeURIComponent(topic)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instructions }), + }) +} + +export async function fetchSortStatus(topic) { + const res = await fetch(`${BASE}/bausteine/sort/status?topic=${encodeURIComponent(topic)}`) + return res.json() +} + export async function fetchSuggestions(topic) { const res = await fetch(`${BASE}/bausteine/suggestions?topic=${encodeURIComponent(topic)}`) return res.json() diff --git a/frontend/src/components/BausteineView.vue b/frontend/src/components/BausteineView.vue index 00d3dd2..a396015 100644 --- a/frontend/src/components/BausteineView.vue +++ b/frontend/src/components/BausteineView.vue @@ -5,6 +5,8 @@ import { createBaustein, deleteBaustein, reworkBaustein, + sortBausteine, + fetchSortStatus, fetchSuggestions, generateSuggestions, fetchSuggestionsStatus, @@ -21,6 +23,9 @@ const suggestions = ref([]) const suggestionsLoading = ref(false) const newTitle = ref('') const newInfo = ref('') +const sortInfo = ref('') +const sortingActive = ref(false) +let sortPollTimer = null const reworkInputs = ref({}) const reworkingIds = ref(new Set()) const reworkingSnapshots = new Map() @@ -95,6 +100,26 @@ async function handleRegenerate() { startPolling() } +async function handleSort() { + sortingActive.value = true + const info = sortInfo.value.trim() + await sortBausteine(props.topic, info) + startSortPolling() +} + +function startSortPolling() { + if (sortPollTimer) return + sortPollTimer = setInterval(async () => { + const status = await fetchSortStatus(props.topic) + if (!status.sorting) { + sortingActive.value = false + clearInterval(sortPollTimer) + sortPollTimer = null + bausteine.value = await fetchBausteine(props.topic) + } + }, 3000) +} + async function handleRework(b) { const instructions = (reworkInputs.value[b.id] || '').trim() if (!instructions) return @@ -147,6 +172,10 @@ function stopPolling() { clearInterval(bausteinPollTimer) bausteinPollTimer = null } + if (sortPollTimer) { + clearInterval(sortPollTimer) + sortPollTimer = null + } } async function init() { @@ -177,12 +206,22 @@ onUnmounted(stopPolling) placeholder="Thema…" @keyup.enter="handleAdd" /> - + @keyup.enter="handleAdd" + /> + +