diff --git a/backend/agents.py b/backend/agents.py index e70bd09..16281d7 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -26,6 +26,12 @@ _active_processes: dict[str, asyncio.subprocess.Process] = {} _batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS) _interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE) +# OpenCode-Starts serialisieren: gleichzeitig startende Prozesse kollidieren an +# der internen Session-DB ("database is locked", Exit nach <1s). Der kurze +# Versatz entzerrt die Starts; danach laufen die Prozesse normal parallel. +_opencode_start_lock = asyncio.Lock() +_OPENCODE_START_DELAY = 1.0 + # Capability → Claude --allowedTools _CLAUDE_TOOLS = { "full": "Write,Bash,Read,WebSearch,WebFetch", @@ -92,14 +98,23 @@ async def run_agent( 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]: +async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False) -> tuple[int, str, str]: start = time.monotonic() - 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, - ) + + async def spawn(): + return 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, + ) + + if stagger: + async with _opencode_start_lock: + process = await spawn() + await asyncio.sleep(_OPENCODE_START_DELAY) + else: + process = await spawn() _active_processes[agent_key] = process try: try: @@ -154,7 +169,7 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str "-f", str(prompt_path), ] try: - rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout) + rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True) return rc, _clean_opencode_output(stdout), stderr finally: prompt_path.unlink(missing_ok=True) diff --git a/backend/bausteine.py b/backend/bausteine.py index 7945128..d70809e 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -1,4 +1,14 @@ -"""Bausteine-Pipeline: 4x Recherche (3 nötig) → 2x Auswahl (1) → Prüfung — reines Inventar, unsortiert.""" +"""Bausteine-Pipeline: Recherche-Konsens + Klärungs-Loop — reines Inventar, unsortiert. + +5x Recherche (min. 3, Grace) → Mapping (Konsens/Rest) → Klärungs-Loop (max. +KONSENS_MAX_RUNDEN Runden): 3 Auswahl-Agenten (min. 2, Grace) entscheiden +über den strittigen Rest, ein Mapping-Agent sortiert in aufnehmen/verwerfen/ +weiter strittig. Leerer Rest beendet den Loop; die letzte Runde muss alles +entscheiden. Races nutzen ein Grace-Fenster statt „erste N gewinnen": Nach dem +ersten gültigen Ergebnis dürfen die übrigen Agenten KONSENS_GRACE Sekunden +fertig werden. Der Konsens wird im Code akkumuliert — kein Agent re-emittiert +die Gesamtliste. +""" import asyncio import logging @@ -7,15 +17,15 @@ import subprocess from pathlib import Path from agents import kill_process -from config import DEFAULT_PROVIDER +from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER from fsutil import atomic_write_text from jsonio import read_json_file as _json_datei from paths import arbeit_dir, bausteine_path, project_dir from pipeline import ( - CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, - _semaphore, _timeout, run_single_slot, + CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _rest_schema, + _runde_schema, _semaphore, _str_liste, _timeout, run_single_slot, ) -from textkit import _eindeutige_titel, _parse_auswahl, _titel, _titel_aufloesen, _titel_index +from textkit import _eindeutige_titel, _parse_auswahl, _titel_aufloesen, _titel_index, _vormerge log = logging.getLogger("creator.bausteine") @@ -24,11 +34,11 @@ _bausteine_errors: dict[str, str] = {} _bausteine_cancelled: set[str] = set() _bausteine_step: dict[str, int] = {} -BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung") +BAUSTEINE_STEPS = ("Recherche", "Konsolidierung", "Klärung") def _bausteine_steps(topic: str) -> tuple: - """Projekte haben einen 4. Schritt: Themenfeld-Ergänzung per Web-Recherche.""" + """Projekte haben einen zusätzlichen Schritt: Themenfeld-Ergänzung per Web-Recherche.""" if project_dir(topic).is_dir(): return BAUSTEINE_STEPS + ("Ergänzung",) return BAUSTEINE_STEPS @@ -36,18 +46,24 @@ def _bausteine_steps(topic: str) -> tuple: def _bausteine_files(topic: str) -> dict: arbeit = arbeit_dir(topic) + runden = range(1, KONSENS_MAX_RUNDEN + 1) return { "final": bausteine_path(topic), "arbeit": arbeit, - "recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4)], - "auswahl": [arbeit / f"auswahl-{i}.md" for i in (1, 2)], - "auswahl_check": arbeit / "auswahl-check.json", + "recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4, 5)], + "recherche_mapping": arbeit / "recherche-mapping.json", + "auswahl": {n: [arbeit / f"auswahl-r{n}-{i}.json" for i in (1, 2, 3)] for n in runden}, + "mapping": {n: arbeit / f"auswahl-mapping-r{n}.json" for n in runden}, "ergaenzung": arbeit / "ergaenzung.json", } def _alle_slot_dateien(files: dict) -> list[Path]: - return [*files["recherche"], *files["auswahl"], files["auswahl_check"], files["ergaenzung"]] + return [ + *files["recherche"], files["recherche_mapping"], + *(p for slots in files["auswahl"].values() for p in slots), + *files["mapping"].values(), files["ergaenzung"], + ] def cancel_bausteine(topic: str) -> bool: @@ -63,9 +79,14 @@ def _resume_step(topic: str) -> int: files = _bausteine_files(topic) if sum(p.exists() for p in files["recherche"]) < 3: return 0 - if not any(p.exists() for p in files["auswahl"]): + if not files["recherche_mapping"].exists(): return 1 - if not files["auswahl_check"].exists(): + mapping = _mapping_schema(_json_datei(files["recherche_mapping"])) + geklaert = mapping is not None and ( + not mapping[1] # kein strittiger Rest + or any((r := _runde_schema(_json_datei(p))) is not None and not r[1] for p in files["mapping"].values()) + ) + if not geklaert: return 2 if project_dir(topic).is_dir() and not files["ergaenzung"].exists(): return 3 @@ -168,25 +189,15 @@ def _file_payload(path: Path): return text if _parse_auswahl(text) else None -def _auswahl_payload(path: Path): - if not path.exists(): - return None - text = path.read_text(encoding="utf-8") - entries = _parse_auswahl(text) - return (text, entries) if entries else None - - -def _auswahl_check_schema(data): - """{"nachtraege": [...], "streichen": [...]} — None bei Schema-Verstoß.""" +def _mapping_schema(data): + """{"bausteine": [str, ≥1], "rest": [str]} → (bausteine, rest) · sonst None.""" if not isinstance(data, dict): return None - nach = data.get("nachtraege", []) - streich = data.get("streichen", []) - if not isinstance(nach, list) or not isinstance(streich, list): + bausteine = _str_liste(data.get("bausteine")) + rest = _str_liste(data.get("rest")) + if not bausteine or rest is None: return None - if not all(isinstance(x, str) for x in [*nach, *streich]): - return None - return {"nachtraege": nach, "streichen": streich} + return bausteine, rest async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None: @@ -223,17 +234,18 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = for p_alt in _alle_slot_dateien(files): p_alt.unlink(missing_ok=True) - # Schritt 1: 4 Recherche-Agenten, 3 gültige nötig — vorhandene Slot-Dateien zählen + # Schritt 1: 5 Recherche-Agenten, min. 3 mit Grace-Fenster — alle gültigen + # Slot-Dateien fließen ins Mapping (kein Kappen mehr bei 3) recherchen: list[str] = [] offen = [] for i, path in enumerate(files["recherche"], 1): text = _file_payload(path) - if text is not None and len(recherchen) < 3: + if text is not None: recherchen.append(text) else: offen.append((i, path)) vorhanden = len(recherchen) - set_p(f"Recherche läuft ({vorhanden}/3 gültig)…", step=0) + set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=0) if vorhanden < 3: caps = "files" if project else "full" slots = [ @@ -247,80 +259,136 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = ] neue = await _race( topic, "Recherche", slots, 3 - vorhanden, _timeout("recherche"), provider, - on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c}/3 gültig)…"), - cancelled=is_cancelled, + on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c} gültig, min. 3)…"), + cancelled=is_cancelled, grace=KONSENS_GRACE, ) if is_cancelled(): abgebrochen() return if neue is None: - _bausteine_errors[topic] = "Recherche fehlgeschlagen (Quorum nicht erreicht)" + _bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)" return recherchen += neue - # Schritt 2: 2 Auswahl-Agenten, der erste gewinnt — vorhandene gültige Datei wird übernommen - n_est = max(len(_parse_auswahl(t)) for t in recherchen) - bestehende = next((res for p in files["auswahl"] if (res := _auswahl_payload(p)) is not None), None) - if bestehende is not None: - flat, entries = bestehende - else: + # Schritt 2: Recherche-Mapping — Code-Vormerge (exakte Titel) + 1 Agent + # für semantische Dubletten und Konsens/Rest-Teilung (fatal) + mapping = _mapping_schema(_json_datei(files["recherche_mapping"])) + if mapping is None: set_p("Konsolidiere Recherche…", step=1) - results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1)) - slots = [ - { - "key": f"bausteine-{topic}-auswahl-{i}", - "prompt": _prompt("Bausteine-Auswahl", topic=topic, results=results_block, out_path=path), - "role": "fast", "capabilities": "files", - "payload": (lambda result, p=path: _auswahl_payload(p)), - } - for i, path in enumerate(files["auswahl"], 1) - ] - auswahl = await _race(topic, "Auswahl", slots, 1, _timeout("auswahl", n_est), provider, cancelled=is_cancelled) - if is_cancelled(): - abgebrochen() - return - if auswahl is None: - _bausteine_errors[topic] = "Auswahl fehlgeschlagen (kein gültiges Ergebnis)" - return - flat, entries = auswahl[0] - - # Schritt 2b: Auswahl-Prüfung gegen die Recherche-Titel (JSON, nicht fatal) - set_p("Prüfe Auswahl…", step=2) - check_path = files["auswahl_check"] - patch = _auswahl_check_schema(_json_datei(check_path)) - if patch is None: - check_path.unlink(missing_ok=True) - titel_listen = "\n\n".join( - f"### Recherche {i}\n" + "\n".join(f"- {_titel(t)}" for t in _parse_auswahl(text).values()) - for i, text in enumerate(recherchen, 1) - ) - status, check = await run_single_slot( - ctx, "Auswahl-Check", - key=f"bausteine-{topic}-auswahlcheck-1", - prompt=_prompt("Bausteine-Auswahl-Check", topic=topic, results=titel_listen, auswahl=flat, out_path=check_path), - role="fast", capabilities="files", - payload=lambda result: _auswahl_check_schema(_json_datei(check_path)), - timeout=_timeout("auswahl_check", len(entries)), + files["recherche_mapping"].unlink(missing_ok=True) + gemergt = _vormerge([_parse_auswahl(t) for t in recherchen]) + eintraege = "\n".join(f"{i}. {text} ({n}× genannt)" for i, (text, n) in enumerate(gemergt, 1)) + status, mapping = await run_single_slot( + ctx, "Recherche-Mapping", + key=f"bausteine-{topic}-recherche-mapping", + prompt=_prompt( + "Bausteine-Recherche-Mapping", + topic=topic, n=len(recherchen), eintraege=eintraege, + out_path=files["recherche_mapping"], + ), + role="judge", capabilities="files", + payload=lambda result: _mapping_schema(_json_datei(files["recherche_mapping"])), + timeout=_timeout("recherche_mapping", len(gemergt)), ) if status == CANCELLED: abgebrochen() return if status == FAILED: - _log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort") - else: - patch = check - if patch is not None and (patch["streichen"] or patch["nachtraege"]): - idx = _titel_index(entries) - weg = {num for t in patch["streichen"] if (num := _titel_aufloesen(idx, t)) is not None} - if weg: - _log(topic, f"Auswahl-Check streicht Duplikate: {sorted(weg)}") - entries = {n: t for n, t in entries.items() if n not in weg} - if patch["nachtraege"]: - _log(topic, f"Auswahl-Check ergänzt {len(patch['nachtraege'])} Bausteine") - texts = [t for _, t in sorted(entries.items())] + list(patch["nachtraege"]) - entries = {i: t for i, t in enumerate(texts, 1)} + _bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen" + return + konsens, rest = mapping - # Schritt 4 (nur Projekte): Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt, + # Klärungs-Loop: 3 Auswahl-Agenten entscheiden über den Rest, ein + # Mapping-Agent sortiert in aufnehmen/verwerfen/weiter strittig. + # Leerer Rest beendet den Loop; Runde KONSENS_MAX_RUNDEN muss + # alles entscheiden. Der Konsens wächst nur hier im Code. + runde = 0 + while rest and runde < KONSENS_MAX_RUNDEN: + runde += 1 + final_runde = runde == KONSENS_MAX_RUNDEN + set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=2) + mapping_path = files["mapping"][runde] + + # Resume: fertiges Runden-Mapping wird direkt übernommen + ergebnis = _runde_schema(_json_datei(mapping_path), final=final_runde) + if ergebnis is None: + mapping_path.unlink(missing_ok=True) + konsens_block = "\n".join(f"- {t}" for t in konsens) + rest_block = "\n".join(f"- {t}" for t in rest) + + # 3 Auswahl-Agenten, min. 2 mit Grace-Fenster + entscheidungen = [] + offen = [] + for i, path in enumerate(files["auswahl"][runde], 1): + res = _rest_schema(_json_datei(path)) + if res is not None: + entscheidungen.append(res) + else: + offen.append((i, path)) + if len(entscheidungen) < 2: + slots = [ + { + "key": f"bausteine-{topic}-auswahl-r{runde}-{i}", + "prompt": _prompt( + "Bausteine-Auswahl", + topic=topic, konsens=konsens_block, rest=rest_block, out_path=path, + ), + "role": "fast", "capabilities": "files", + "payload": (lambda result, p=path: _rest_schema(_json_datei(p))), + } + for i, path in offen + ] + neue = await _race( + topic, f"Auswahl r{runde}", slots, 2 - len(entscheidungen), + _timeout("auswahl", len(rest)), provider, + cancelled=is_cancelled, grace=KONSENS_GRACE, + ) + if is_cancelled(): + abgebrochen() + return + if neue is None: + _bausteine_errors[topic] = f"Auswahl fehlgeschlagen (Runde {runde}, Minimum nicht erreicht)" + return + entscheidungen += neue + + # Votum pro Rest-Eintrag deterministisch zählen + indizes = [_titel_index(dict(enumerate(e, 1))) for e in entscheidungen] + voten = "\n".join( + f"{i}. {text} (von {sum(1 for idx in indizes if _titel_aufloesen(idx, text) is not None)}" + f"/{len(entscheidungen)} Agenten übernommen)" + for i, text in enumerate(rest, 1) + ) + final_zusatz = ( + "\n- LETZTE RUNDE: Es gibt keine weitere Runde. `rest` MUSS leer sein" + " — entscheide JEDEN Eintrag selbst: aufnehmen oder verwerfen." + if final_runde else "" + ) + status, ergebnis = await run_single_slot( + ctx, f"Auswahl-Mapping r{runde}", + key=f"bausteine-{topic}-auswahl-mapping-r{runde}", + prompt=_prompt( + "Bausteine-Auswahl-Mapping", + topic=topic, n=len(entscheidungen), konsens=konsens_block, + rest=voten, final=final_zusatz, out_path=mapping_path, + ), + role="judge", capabilities="files", + payload=lambda result, p=mapping_path, f=final_runde: _runde_schema(_json_datei(p), final=f), + timeout=_timeout("auswahl_mapping", len(rest)), + ) + if status == CANCELLED: + abgebrochen() + return + if status == FAILED: + _bausteine_errors[topic] = f"Auswahl-Mapping fehlgeschlagen (Runde {runde})" + return + + aufnehmen, rest = ergebnis + _log(topic, f"Klärung Runde {runde}: {len(aufnehmen)} aufgenommen, {len(rest)} weiter strittig") + konsens = konsens + aufnehmen + + entries = {i: t for i, t in enumerate(konsens, 1)} + + # Nur Projekte: Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt, # ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung]. if project: set_p("Ergänze Themenfeld…", step=3) diff --git a/backend/config.py b/backend/config.py index 42184d3..8ace1af 100644 --- a/backend/config.py +++ b/backend/config.py @@ -15,20 +15,33 @@ MAX_CONCURRENT_GENERATIONS = 10 MAX_CONCURRENT_AGENTS = 12 MAX_CONCURRENT_INTERACTIVE = 4 +# Grace-Fenster der Konsens-Races (Bausteine, Guide, OnePager): Nach dem ersten +# gültigen Ergebnis dürfen die übrigen Agenten noch so viele Sekunden fertig +# werden (Kill nur, wenn das Minimum schon steht). +KONSENS_GRACE = 300 + +# Cap der Klärungs- und Prüf-Loops: maximale Runden, bis alles entschieden sein +# muss. In der letzten Runde MUSS der Mapping-Agent jeden Eintrag entscheiden; +# Prüf-Loops lassen Rest-Beanstandungen danach stehen. +KONSENS_MAX_RUNDEN = 3 + # Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section). # Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt. TIMEOUTS = { "recherche": (1800, 0), # fix 30 min - "auswahl": (600, 10), - "auswahl_check": (300, 2), + "recherche_mapping": (600, 3), # n = vorgemergte Einträge + "auswahl": (300, 2), # Rest-Prüfung im Klärungs-Loop, n = Rest-Einträge + "auswahl_mapping": (600, 2), # n = Rest-Einträge "ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche) "guide_auswahl": (300, 5), # pro Baustein im Inventar - "guide_check": (300, 2), # Auswahl-/Gliederungs-Prüfung (nur Titellisten) "plan": (300, 5), + "plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections "writer": (600, 120), # pro Section im Chunk "lese_check": (300, 10), # pro Section im Paket "onepager_recherche": (900, 0), + "onepager_mapping": (600, 0), # Konsolidierung der Recherchen "onepager_bauen": (300, 0), + "onepager_judge": (600, 0), # Judge über die Karten-Sätze "onepager_verify": (300, 0), } @@ -41,31 +54,27 @@ FORMAT_ANTEIL = { # Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden. # Rollen: "quick" = Massenarbeit (Recherche, Einordnung), -# "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat), -# "guide" = große Generierung (Plan, Writer). +# "fast" = Interaktion + Voten (Chat, Prüfung, Klärung, Elemente), +# "judge" = Mapping-/Judge-/Prüf-Agenten — kalt (niedrige Temperature, +# ohne Thinking) für stabile Urteile; Claude/Lokal mappen auf "fast", +# "guide" = große Generierung (Vorschläge, Writer). DEFAULT_PROVIDER = "claude" PROVIDERS = { "claude": { "cli": "claude", "guide": "claude-opus-4-8[1m]", "fast": "claude-sonnet-4-6", + "judge": "claude-sonnet-4-6", # CLI kennt keine Temperature "quick": "claude-sonnet-4-6", "env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude }, + # "minimax-kalt/…" ist KEIN eigener Stack, nur ein opencode-Provider-Eintrag + # (dev-ops/opencode.json) mit niedriger Temperature; M3 dort ohne Thinking. "minimax": { "cli": "opencode", "guide": "minimax/MiniMax-M3", - "fast": "minimax/MiniMax-M2.7-highspeed", - "quick": "minimax/MiniMax-M2.7-highspeed", - "env_key": "MINIMAX_API_KEY", - }, - # Wie "minimax", aber Chat/Elemente (Rolle "fast") laufen auf M3 OHNE Thinking. - # M2.x kann Thinking nicht abschalten — nur M3 respektiert thinking:disabled. - # guide/quick bleiben identisch zur Thinking-Variante. - "minimax-direkt": { - "cli": "opencode", - "guide": "minimax/MiniMax-M3", - "fast": "minimax-direkt/MiniMax-M3", + "fast": "minimax-kalt/MiniMax-M2.7-highspeed", + "judge": "minimax-kalt/MiniMax-M3", "quick": "minimax/MiniMax-M2.7-highspeed", "env_key": "MINIMAX_API_KEY", }, @@ -73,6 +82,7 @@ PROVIDERS = { "cli": "opencode", "guide": "ollama/qwen3.6:27b", "fast": "ollama/qwen3.5:9b", + "judge": "ollama/qwen3.5:9b", "quick": "ollama/qwen3.5:9b", "env_key": None, "check_url": "http://localhost:11434/api/tags", # Ollama erreichbar? diff --git a/backend/database.py b/backend/database.py index b923984..83190e0 100644 --- a/backend/database.py +++ b/backend/database.py @@ -47,6 +47,28 @@ CREATE TABLE IF NOT EXISTS elements ( ) """ +CREATE_VERTIEFUNGEN = """ +CREATE TABLE IF NOT EXISTS vertiefungen ( + topic TEXT NOT NULL, + baustein TEXT NOT NULL, + md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (topic, baustein) +) +""" + +CREATE_BAUSTEIN_PROGRESS = """ +CREATE TABLE IF NOT EXISTS baustein_progress ( + topic TEXT NOT NULL, + baustein TEXT NOT NULL, + gute_antworten INTEGER NOT NULL DEFAULT 0, + absolviert TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (topic, baustein) +) +""" + _db: aiosqlite.Connection | None = None @@ -67,6 +89,8 @@ async def init_db(): await db.execute(CREATE_PROGRESS) await db.execute(CREATE_TOPICS) await db.execute(CREATE_ELEMENTS) + await db.execute(CREATE_VERTIEFUNGEN) + await db.execute(CREATE_BAUSTEIN_PROGRESS) try: # Migration für Bestands-DBs ohne step-Spalte await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER") except aiosqlite.OperationalError: @@ -255,3 +279,104 @@ 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() + + +# --- Baustein-Lernen: Vertiefungen + Prüfungs-Fortschritt --- + +def _now() -> str: + from datetime import datetime, timezone + return datetime.now(timezone.utc).isoformat() + + +async def get_vertiefung(topic: str, baustein: str) -> str | None: + db = await get_db() + cursor = await db.execute( + "SELECT md FROM vertiefungen WHERE topic = ? AND baustein = ?", (topic, baustein) + ) + row = await cursor.fetchone() + return row[0] if row else None + + +async def set_vertiefung(topic: str, baustein: str, md: str) -> None: + db = await get_db() + now = _now() + await db.execute( + """INSERT INTO vertiefungen (topic, baustein, md, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(topic, baustein) DO UPDATE SET md = excluded.md, updated_at = excluded.updated_at""", + (topic, baustein, md, now, now), + ) + await db.commit() + + +async def list_vertiefungen(topic: str) -> set[str]: + """Baustein-Titel, zu denen eine Vertiefung existiert.""" + db = await get_db() + cursor = await db.execute("SELECT baustein FROM vertiefungen WHERE topic = ?", (topic,)) + rows = await cursor.fetchall() + return {row[0] for row in rows} + + +async def list_baustein_progress(topic: str) -> list[dict]: + db = await get_db() + cursor = await db.execute( + "SELECT baustein, gute_antworten, absolviert FROM baustein_progress WHERE topic = ?", (topic,) + ) + rows = await cursor.fetchall() + return [{"baustein": b, "gute_antworten": n, "absolviert": a} for b, n, a in rows] + + +async def add_gute_antwort(topic: str, baustein: str) -> int: + """Zählt eine gut bewertete Antwort und liefert den neuen Stand.""" + db = await get_db() + await db.execute( + """INSERT INTO baustein_progress (topic, baustein, gute_antworten, updated_at) + VALUES (?, ?, 1, ?) + ON CONFLICT(topic, baustein) DO UPDATE SET + gute_antworten = gute_antworten + 1, updated_at = excluded.updated_at""", + (topic, baustein, _now()), + ) + await db.commit() + cursor = await db.execute( + "SELECT gute_antworten FROM baustein_progress WHERE topic = ? AND baustein = ?", + (topic, baustein), + ) + row = await cursor.fetchone() + return row[0] if row else 0 + + +async def set_baustein_absolviert(topic: str, baustein: str) -> bool: + """Markiert absolviert; True nur beim ersten Mal (steuert den Element-Task).""" + db = await get_db() + now = _now() + await db.execute( + "INSERT OR IGNORE INTO baustein_progress (topic, baustein, gute_antworten, updated_at) VALUES (?, ?, 0, ?)", + (topic, baustein, now), + ) + cursor = await db.execute( + "UPDATE baustein_progress SET absolviert = ?, updated_at = ? " + "WHERE topic = ? AND baustein = ? AND absolviert IS NULL", + (now, now, topic, baustein), + ) + await db.commit() + return cursor.rowcount > 0 + + +async def list_baustein_absolviert_all() -> dict[str, set[str]]: + """Alle absolvierten Bausteine in einem Query: topic → Baustein-Titel.""" + db = await get_db() + cursor = await db.execute( + "SELECT topic, baustein FROM baustein_progress WHERE absolviert IS NOT NULL" + ) + rows = await cursor.fetchall() + out: dict[str, set[str]] = {} + for topic, baustein in rows: + out.setdefault(topic, set()).add(baustein) + return out + + +async def delete_baustein_daten(topic: str) -> None: + db = await get_db() + await db.execute("DELETE FROM vertiefungen WHERE topic = ?", (topic,)) + await db.execute("DELETE FROM baustein_progress WHERE topic = ?", (topic,)) + await db.commit() diff --git a/backend/elements.py b/backend/elements.py index a408fb3..9369c79 100644 --- a/backend/elements.py +++ b/backend/elements.py @@ -82,14 +82,17 @@ def _topic_context(topic: str, limit: int = 12000) -> str: return text[:limit] if text else "(kein Material vorhanden)" -async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER) -> dict: +async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER, extra_context: str = "") -> dict: """Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort.""" fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []} try: + context = _topic_context(topic) + if extra_context.strip(): + context = (extra_context.strip() + "\n\n" + context)[:12000] prompt = _prompt( "Element-Create", topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)", - context=_topic_context(topic), + context=context, ) returncode, stdout, _ = await run_agent( "element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive" diff --git a/backend/guide.py b/backend/guide.py index 2d42d80..42a734a 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -1,11 +1,14 @@ -"""Guide-Generierung: 6 Schritte mit Prüfung nach jeder Phase (OnePager hat einen eigenen Weg). +"""Guide-Generierung als Konsens-Pipeline (OnePager hat einen eigenen Weg). -Prüf-Agenten notieren nur Probleme; das Anpassen übernimmt der jeweilige Erzeuger-Typ. +Auswahl: 5 Agenten (min. 3, Grace) → Code-Voting (Mehrheit = Konsens) → +Mapping-Agent sortiert Strittiges → Klärungs-Loop (max. KONSENS_MAX_RUNDEN). +Gliederung: 5 Vorschläge (min. 3, Grace) → ein Judge wählt und kombiniert. +Schreiben: Writer pro Chunk. Lese-Prüfung: Check→Fix-Loop (max. Runden-Cap), +Folgerunden prüfen nur ersetzte Sections; danach bleiben Beanstandungen stehen. Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort. """ import asyncio -import json import logging import math from datetime import datetime, timezone @@ -13,17 +16,20 @@ from pathlib import Path from agents import run_agent from bausteine import _pdfs_konvertieren -from config import DEFAULT_PROVIDER, FORMAT_ANTEIL, TEMPLATES_DIR +from config import ( + DEFAULT_PROVIDER, FORMAT_ANTEIL, KONSENS_GRACE, KONSENS_MAX_RUNDEN, + TEMPLATES_DIR, +) from database import list_guides, update_guide -from fsutil import atomic_write_json, atomic_write_text +from fsutil import atomic_write_json from jsonio import read_json_file as _json_datei from onepager import _generate_onepager from paths import bausteine_path, guide_content_path, project_dir from pipeline import ( - CANCELLED, FAILED, GenContext, _check_then_fix, _claude_error, _extra, - _fail, _gather_error, _log, _prompt, _race, _semaphore, _set_progress, - _set_step, _timeout, clear_guide_cancelled, is_guide_cancelled, - run_single_slot, + CANCELLED, FAILED, GenContext, _claude_error, _extra, + _fail, _gather_error, _log, _prompt, _race, _rest_schema, _runde_schema, + _semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled, + is_guide_cancelled, run_single_slot, ) from textkit import ( _eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks, @@ -32,7 +38,7 @@ from textkit import ( log = logging.getLogger("creator.guide") -GUIDE_STEPS = ("Auswahl", "Auswahl-Prüfung", "Gliederung", "Gliederungs-Prüfung", "Schreiben", "Lese-Prüfung") +GUIDE_STEPS = ("Auswahl", "Gliederung", "Schreiben", "Lese-Prüfung") # Writer skalieren mit der Section-Zahl: 1 Writer je ~30 Sections (gedeckelt). # Kleine Pakete vermeiden Lazy-Output bei langen Listen und begrenzen den Schaden @@ -43,12 +49,18 @@ WRITER_MAX = 20 def _guide_files(content_path: Path) -> dict: d, stem = content_path.parent, content_path.stem + runden = range(1, KONSENS_MAX_RUNDEN + 1) return { - "auswahl": d / f"{stem}.auswahl.json", - "auswahl_check": d / f"{stem}.auswahl-check.json", - "gliederung": d / f"{stem}.gliederung.json", - "gliederung_check": d / f"{stem}.gliederung-check.json", - # chunk-/lese-check-/fix-Dateien sind dynamisch: {stem}.chunk-i.md usw. + # Runde 1: 5 volle Auswahl-Vorschläge; Runden 2+: 3 Klärungs-Voten + "auswahl_slots": { + n: [d / f"{stem}.auswahl-r{n}-{i}.json" for i in range(1, (5 if n == 1 else 3) + 1)] + for n in runden + }, + "auswahl_mapping": {n: d / f"{stem}.auswahl-mapping-r{n}.json" for n in runden}, + "gliederung_slots": [d / f"{stem}.gliederung-{i}.json" for i in (1, 2, 3, 4, 5)], + "gliederung": d / f"{stem}.gliederung.json", # Judge-Ausgabe + # chunk-/lese-check-/fix-Dateien sind dynamisch: + # {stem}.chunk-i.md, {stem}.lese-check-r{n}-{i}.json, {stem}.fix-r{n}-{i}.md } @@ -131,6 +143,221 @@ def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: return chapters +def _voting(stimmen: list[list[int]]) -> tuple[list[int], dict[int, int]]: + """Mehrheit (> Hälfte der Stimmen) → Konsens; ≥1 Stimme → Rest mit Votenzahl.""" + zaehler: dict[int, int] = {} + for stimme in stimmen: + for num in stimme: + zaehler[num] = zaehler.get(num, 0) + 1 + konsens = sorted(num for num, v in zaehler.items() if v > len(stimmen) / 2) + rest = {num: v for num, v in sorted(zaehler.items()) if v <= len(stimmen) / 2} + return konsens, rest + + +def _resolve_uebernehmen(data, entries: dict[int, str]) -> list[int] | None: + """{"uebernehmen": [Titel]} → Nummern; leer gültig; >15 % unauflösbar → None.""" + titel = _rest_schema(data) + if titel is None: + return None + if not titel: + return [] + idx = _titel_index(entries) + nums: list[int] = [] + seen: set[int] = set() + unknown = 0 + for t in titel: + num = _titel_aufloesen(idx, t) + if num is None: + unknown += 1 + elif num not in seen: + seen.add(num) + nums.append(num) + if unknown / len(titel) > 0.15: + return None + return nums + + +def _resolve_runde(data, entries: dict[int, str], konsens: list[int], k_min: int, k_max: int, final: bool) -> tuple[list[int], list[int]] | None: + """Auswahl-Mapping-Runde auflösen — erzwingt die Zielgrößen-Grenzen schema-seitig. + + Immer: Konsens + Aufnehmen + Rest muss 0.9*k_min erreichen können (sonst + wäre die Mindestgröße in späteren Runden unerreichbar). Aufnehmen über + 1.1*k_max hinaus ist ungültig; final erzwingt zusätzlich leeren Rest und + die Mindestgröße. Ein bereits zu großer Konsens allein ist kein Fehler — + der Agent kann dann nichts mehr aufnehmen. + """ + res = _runde_schema(data, final=final) + if res is None: + return None + idx = _titel_index(entries) + bekannt = set(konsens) + listen: list[list[int]] = [] + for titel_liste in res: + nums: list[int] = [] + unknown = 0 + for t in titel_liste: + num = _titel_aufloesen(idx, t) + if num is None: + unknown += 1 + elif num not in bekannt: + bekannt.add(num) + nums.append(num) + if titel_liste and unknown / len(titel_liste) > 0.15: + return None + listen.append(nums) + aufnehmen, rest = listen + gesamt = len(konsens) + len(aufnehmen) + if aufnehmen and gesamt > 1.1 * k_max: + return None + if gesamt + len(rest) < 0.9 * k_min: + return None + if (final or not rest) and gesamt < 0.9 * k_min: + return None + return aufnehmen, rest + + +async def _konsens_auswahl( + ctx: GenContext, files: dict, entries: dict[int, str], + k_min: int, k_max: int, auswahl_auftrag: str, format_name: str, + bausteine_liste: str, instructions: str, +) -> list[int] | None: + """Schritt 0: 5 Auswahl-Agenten → Code-Voting → Mapping → Klärungs-Loop. + + Rückgabe: finale Baustein-Nummern; None = Fehler/Abbruch (bereits gemeldet). + """ + guide_id, topic, provider = ctx.guide_id, ctx.topic, ctx.provider + is_cancelled = ctx.is_cancelled + n = len(entries) + + def titel_liste(nums) -> str: + return "\n".join(f"- {_titel(entries[num])}" for num in nums) + + konsens: list[int] = [] + rest: list[int] = [] + runde = 0 + while True: + runde += 1 + final_runde = runde == KONSENS_MAX_RUNDEN + + # Voten der Runde einsammeln — Slot-Dateien zuerst (Resume), Rest per Race + if runde == 1: + await _set_step(guide_id, 0, "Wähle Bausteine (5 Vorschläge)…") + stimmen: list[list[int]] = [] + offen = [] + for i, path in enumerate(files["auswahl_slots"][1], 1): + res = _resolve_auswahl(_json_datei(path), entries, k_min, k_max) + if res is not None: + stimmen.append(res) + else: + offen.append((i, path)) + if len(stimmen) < 3: + slots = [ + { + "key": f"{guide_id}-auswahl-r1-{i}", + "prompt": _prompt( + "Guide-Auswahl", + topic=topic, format_name=format_name, bausteine=bausteine_liste, + auswahl_auftrag=auswahl_auftrag, out_path=path, extra=_extra(instructions), + ), + "role": "guide", "capabilities": "files", + "payload": (lambda result, p=path: _resolve_auswahl(_json_datei(p), entries, k_min, k_max)), + } + for i, path in offen + ] + neue = await _race( + topic, "Guide-Auswahl", slots, 3 - len(stimmen), _timeout("guide_auswahl", n), + provider, cancelled=is_cancelled, grace=KONSENS_GRACE, + ) + if is_cancelled(): + return None + if neue is None: + await _fail(guide_id, "Auswahl fehlgeschlagen (Minimum nicht erreicht)") + return None + stimmen += neue + konsens, voten = _voting(stimmen) + rest = list(voten) + stimmen_n = len(stimmen) + else: + await _set_step(guide_id, 0, f"Kläre strittige Bausteine (Runde {runde}/{KONSENS_MAX_RUNDEN})…") + entscheidungen: list[list[int]] = [] + offen = [] + for i, path in enumerate(files["auswahl_slots"][runde], 1): + res = _resolve_uebernehmen(_json_datei(path), entries) + if res is not None: + entscheidungen.append(res) + else: + offen.append((i, path)) + if len(entscheidungen) < 2: + slots = [ + { + "key": f"{guide_id}-auswahl-r{runde}-{i}", + "prompt": _prompt( + "Guide-Klaerung", + topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag, + konsens=titel_liste(konsens) or "- (leer)", rest=titel_liste(rest), + out_path=path, extra=_extra(instructions), + ), + "role": "fast", "capabilities": "files", + "payload": (lambda result, p=path: _resolve_uebernehmen(_json_datei(p), entries)), + } + for i, path in offen + ] + neue = await _race( + topic, f"Guide-Klärung r{runde}", slots, 2 - len(entscheidungen), + _timeout("auswahl", len(rest)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE, + ) + if is_cancelled(): + return None + if neue is None: + await _fail(guide_id, f"Auswahl fehlgeschlagen (Runde {runde}, Minimum nicht erreicht)") + return None + entscheidungen += neue + voten = {num: sum(1 for e in entscheidungen if num in e) for num in rest} + stimmen_n = len(entscheidungen) + + # Mapping-Agent sortiert die strittigen Voten — gültige Datei = Resume + mapping_path = files["auswahl_mapping"][runde] + ergebnis = _resolve_runde(_json_datei(mapping_path), entries, konsens, k_min, k_max, final_runde) + if ergebnis is None: + mapping_path.unlink(missing_ok=True) + voten_block = "\n".join( + f"{i}. {_titel(entries[num])} (von {voten[num]}/{stimmen_n} Agenten gewählt)" + for i, num in enumerate(rest, 1) + ) or "- (keine)" + final_zusatz = ( + "\n- LETZTE RUNDE: Es gibt keine weitere Runde. `rest` MUSS leer sein" + " — entscheide JEDEN Eintrag selbst: aufnehmen oder verwerfen." + if final_runde else "" + ) + status, ergebnis = await run_single_slot( + ctx, f"Auswahl-Mapping r{runde}", + key=f"{guide_id}-auswahl-mapping-r{runde}", + prompt=_prompt( + "Guide-Auswahl-Mapping", + topic=topic, format_name=format_name, n=stimmen_n, + auswahl_auftrag=auswahl_auftrag, konsens_n=len(konsens), + k_min=k_min, k_max=k_max, + konsens=titel_liste(konsens) or "- (leer)", rest=voten_block, + final=final_zusatz, out_path=mapping_path, + ), + role="judge", capabilities="files", + payload=lambda result, p=mapping_path, k=tuple(konsens), f=final_runde: + _resolve_runde(_json_datei(p), entries, list(k), k_min, k_max, f), + timeout=_timeout("auswahl_mapping", len(konsens) + len(rest)), + ) + if status == CANCELLED: + return None + if status == FAILED: + await _fail(guide_id, f"Auswahl-Mapping fehlgeschlagen (Runde {runde})") + return None + + aufnehmen, rest = ergebnis + konsens = konsens + aufnehmen + _log(topic, f"Auswahl Runde {runde}: {len(aufnehmen)} aufgenommen, {len(rest)} strittig, Konsens {len(konsens)}") + if not rest or final_runde: + return konsens + + async def _generate_sections( guide_id: str, topic: str, format_name: str, entries: dict[int, str], facts: str, instructions: str, provider: str, @@ -152,134 +379,83 @@ async def _generate_sections( "Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist." ) - # Schritt 1: Auswahl — vorhandene gültige Datei wird übernommen (Resume) - auswahl = _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max) - if auswahl is None: - await _set_step(guide_id, 0, "Wähle Bausteine…") - files["auswahl"].unlink(missing_ok=True) - status, auswahl = await run_single_slot( - ctx, "Guide-Auswahl", - key=f"{guide_id}-auswahl", - prompt=_prompt( - "Guide-Auswahl", - topic=topic, format_name=format_name, bausteine=bausteine_liste, - auswahl_auftrag=auswahl_auftrag, out_path=files["auswahl"], extra=_extra(instructions), - ), - role="guide", capabilities="files", - payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max), - timeout=_timeout("guide_auswahl", n), - ) - if status == CANCELLED: - return None - if status == FAILED: - await _fail(guide_id, "Auswahl fehlgeschlagen") - return None - - def auswahl_titel() -> str: - return "\n".join(f"- {_titel(entries[num])}" for num in auswahl) - - def auswahl_json() -> str: - return json.dumps({"bausteine": [_titel(entries[num]) for num in auswahl]}, ensure_ascii=False) - - # Schritt 2: Auswahl-Prüfung — notiert Probleme; Anpassung macht ein Auswahl-Agent - status, fixed = await _check_then_fix( - ctx, name="Auswahl", step=1, - check_key=f"{guide_id}-auswahl-check", - check_prompt=_prompt( - "Guide-Auswahl-Check", - topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag, - bausteine=bausteine_liste, auswahl=auswahl_titel(), - out_path=files["auswahl_check"], extra=_extra(instructions), - ), - check_path=files["auswahl_check"], check_timeout=_timeout("guide_check", len(auswahl)), - fix_key=f"{guide_id}-auswahl-fix", - build_fix_prompt=lambda probleme: _prompt( - "Guide-Auswahl-Fix", - topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag, - bausteine=bausteine_liste, auswahl=auswahl_titel(), - probleme="\n".join(f"- {p}" for p in probleme), - out_path=files["auswahl"], extra=_extra(instructions), - ), - fix_payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max), - fix_timeout=_timeout("guide_auswahl", n), fix_role="guide", - on_fix_invalid=lambda: atomic_write_text(files["auswahl"], auswahl_json()), + # Schritt 0: Auswahl-Konsens (5 Agenten → Voting → Mapping → Klärungs-Loop) + auswahl = await _konsens_auswahl( + ctx, files, entries, k_min, k_max, auswahl_auftrag, format_name, + bausteine_liste, instructions, ) - if status == CANCELLED: + if auswahl is None: return None - if status == FAILED: - await _fail(guide_id, "Auswahl-Prüfung fehlgeschlagen") - return None - if fixed is not None: - auswahl = fixed sel_entries = {num: entries[num] for num in auswahl} soll = len(sel_entries) sel_liste = "\n".join(f"- {t}" for t in sel_entries.values()) - # Schritt 3: Gliederung der festen Auswahl + # Schritt 1: Gliederung — 5 Vorschläge (min. 3, Grace), ein Judge wählt. + # Gültiges gliederung.json (auch aus Altläufen) überspringt den Schritt. plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll) if plan is None: - await _set_step(guide_id, 2, "Plane Gliederung…") + await _set_step(guide_id, 1, "Gliederungs-Vorschläge (5 Agenten)…") files["gliederung"].unlink(missing_ok=True) + vorschlaege: list[list[dict]] = [] + offen = [] + for i, path in enumerate(files["gliederung_slots"], 1): + res = _resolve_gliederung(_json_datei(path), sel_entries, soll, soll) + if res is not None: + vorschlaege.append(res) + else: + offen.append((i, path)) + if len(vorschlaege) < 3: + slots = [ + { + "key": f"{guide_id}-gliederung-{i}", + "prompt": _prompt( + "Guide-Gliederung", + topic=topic, format_name=format_name, bausteine=sel_liste, + out_path=path, extra=_extra(instructions), + ), + "role": "guide", "capabilities": "files", + "payload": (lambda result, p=path: _resolve_gliederung(_json_datei(p), sel_entries, soll, soll)), + } + for i, path in offen + ] + neue = await _race( + topic, "Gliederung", slots, 3 - len(vorschlaege), _timeout("plan", soll), + provider, cancelled=is_cancelled, grace=KONSENS_GRACE, + ) + if is_cancelled(): + return None + if neue is None: + await _fail(guide_id, "Gliederung fehlgeschlagen (Minimum nicht erreicht)") + return None + vorschlaege += neue + + await _set_step(guide_id, 1, "Wähle beste Gliederung…") + bloecke = "\n\n".join( + f"### Vorschlag {i}\n" + + "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in v) + for i, v in enumerate(vorschlaege, 1) + ) status, plan = await run_single_slot( - ctx, "Gliederung", - key=f"{guide_id}-gliederung", + ctx, "Gliederungs-Judge", + key=f"{guide_id}-gliederung-judge", prompt=_prompt( - "Guide-Gliederung", - topic=topic, format_name=format_name, bausteine=sel_liste, + "Guide-Gliederung-Judge", + topic=topic, format_name=format_name, zweck=zweck, n=len(vorschlaege), + bausteine=sel_liste, gliederungen=bloecke, out_path=files["gliederung"], extra=_extra(instructions), ), - role="guide", capabilities="files", + role="judge", capabilities="files", payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll), - timeout=_timeout("plan", soll), + timeout=_timeout("plan_judge", soll), ) if status == CANCELLED: return None if status == FAILED: - await _fail(guide_id, "Gliederung fehlgeschlagen") + await _fail(guide_id, "Gliederung fehlgeschlagen (Judge ohne gültiges Ergebnis)") return None - def gliederung_text() -> str: - return "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in plan) - - def gliederung_json() -> str: - return json.dumps( - {"kapitel": [{"titel": ch["title"], "bausteine": [_titel(entries[num]) for num in ch["nums"]]} for ch in plan]}, - ensure_ascii=False, - ) - - # Schritt 4: Gliederungs-Prüfung - status, fixed = await _check_then_fix( - ctx, name="Gliederung", step=3, - check_key=f"{guide_id}-gliederung-check", - check_prompt=_prompt( - "Guide-Gliederung-Check", - topic=topic, format_name=format_name, zweck=zweck, - auswahl=auswahl_titel(), gliederung=gliederung_text(), - out_path=files["gliederung_check"], extra=_extra(instructions), - ), - check_path=files["gliederung_check"], check_timeout=_timeout("guide_check", soll), - fix_key=f"{guide_id}-gliederung-fix", - build_fix_prompt=lambda probleme: _prompt( - "Guide-Gliederung-Fix", - topic=topic, format_name=format_name, - auswahl=auswahl_titel(), gliederung=gliederung_text(), - probleme="\n".join(f"- {p}" for p in probleme), - out_path=files["gliederung"], extra=_extra(instructions), - ), - fix_payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll), - fix_timeout=_timeout("plan", soll), fix_role="guide", - on_fix_invalid=lambda: atomic_write_text(files["gliederung"], gliederung_json()), - ) - if status == CANCELLED: - return None - if status == FAILED: - await _fail(guide_id, "Gliederungs-Prüfung fehlgeschlagen") - return None - if fixed is not None: - plan = fixed - - # Schritt 5: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume) + # Schritt 2: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume) total_sections = sum(len(c["nums"]) for c in plan) chunks = _split_chunks(plan, min(WRITER_MAX, max(1, math.ceil(total_sections / WRITER_SECTIONS)))) zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks] @@ -288,7 +464,7 @@ async def _generate_sections( paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, writer_count + 1)] offen = [i for i, p in enumerate(paths) if not p.exists()] if offen: - await _set_step(guide_id, 4, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…") + await _set_step(guide_id, 2, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…") results = await asyncio.gather(*[ run_agent( f"{guide_id}-w{i + 1}", @@ -329,61 +505,69 @@ async def _generate_sections( await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden") return None - # Schritt 6: Lese-Prüfung pro Writer-Paket — Fix beauftragt Writer nur mit beanstandeten Sections + # Schritt 3: Lese-Prüfungs-Loop — Check pro Writer-Paket, Fix nur für + # beanstandete Sections; Folgerunden prüfen NUR die ersetzten Sections. + # Nach dem Runden-Cap bleiben offene Beanstandungen stehen. chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks] - check_paths = [content_path.parent / f"{content_path.stem}.lese-check-{i}.json" for i in range(1, writer_count + 1)] - offen_checks = [i for i, p in enumerate(check_paths) if _lese_probleme_schema(_json_datei(p)) is None and chunk_nums[i]] - if offen_checks: - await _set_step(guide_id, 5, f"Prüfe Lesbarkeit ({len(offen_checks)} Prüfer)…" if len(offen_checks) > 1 else "Prüfe Lesbarkeit…") - def sections_text(nums: list[int]) -> str: - return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums) + def sections_text(nums: list[int]) -> str: + return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums) - slots = [{ - "key": f"{guide_id}-lese-check-{i + 1}", - "prompt": _prompt( - "Guide-Lese-Check", - topic=topic, format_name=format_name, spec=spec, - sections=sections_text(chunk_nums[i]), - out_path=check_paths[i], extra=_extra(instructions), - ), - "role": "fast", "capabilities": "files", - "payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))), - } for i in offen_checks] - res = await _race(topic, "Lese-Prüfung", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled) - if is_cancelled(): - return None - if res is None: - await _fail(guide_id, "Lese-Prüfung fehlgeschlagen") - return None + def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str: + return "\n\n".join( + f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}" + for num in nums + ) - probleme_by_num: dict[int, str] = {} - for p in check_paths: - for item in (_lese_probleme_schema(_json_datei(p)) or []): - num = _titel_aufloesen(idx, item["section"]) - if num in by_num and num not in probleme_by_num: - probleme_by_num[num] = item["problem"] + scope = chunk_nums + for runde in range(1, KONSENS_MAX_RUNDEN + 1): + check_paths = [content_path.parent / f"{content_path.stem}.lese-check-r{runde}-{i}.json" for i in range(1, writer_count + 1)] + offen_checks = [i for i, p in enumerate(check_paths) if scope[i] and _lese_probleme_schema(_json_datei(p)) is None] + if offen_checks: + await _set_step(guide_id, 3, f"Prüfe Lesbarkeit (Runde {runde}/{KONSENS_MAX_RUNDEN})…") + slots = [{ + "key": f"{guide_id}-lese-check-r{runde}-{i + 1}", + "prompt": _prompt( + "Guide-Lese-Check", + topic=topic, format_name=format_name, spec=spec, + sections=sections_text(scope[i]), + out_path=check_paths[i], extra=_extra(instructions), + ), + "role": "judge", "capabilities": "files", + "payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))), + } for i in offen_checks] + res = await _race(topic, f"Lese-Prüfung r{runde}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled) + if is_cancelled(): + return None + if res is None: + if runde == 1: + await _fail(guide_id, "Lese-Prüfung fehlgeschlagen") + return None + _log(topic, f"Lese-Prüfung Runde {runde} fehlgeschlagen — Stand bleibt") + break - if probleme_by_num: - _log(topic, f"Lese-Prüfung: {len(probleme_by_num)} Section(s) beanstandet") - await _set_step(guide_id, 5, f"Überarbeite {len(probleme_by_num)} Section(s)…") + probleme_by_num: dict[int, str] = {} + for i, p in enumerate(check_paths): + geltung = set(scope[i]) + for item in (_lese_probleme_schema(_json_datei(p)) or []): + num = _titel_aufloesen(idx, item["section"]) + if num in geltung and num in by_num and num not in probleme_by_num: + probleme_by_num[num] = item["problem"] + if not probleme_by_num: + break + + _log(topic, f"Lese-Prüfung Runde {runde}: {len(probleme_by_num)} Section(s) beanstandet") + await _set_step(guide_id, 3, f"Überarbeite {len(probleme_by_num)} Section(s) (Runde {runde})…") fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in chunk_nums] - fix_offen = [i for i, nums in enumerate(fix_chunks) if nums] - fix_paths = [content_path.parent / f"{content_path.stem}.fix-{i + 1}.md" for i in range(writer_count)] - - def auftraege_text(nums: list[int]) -> str: - return "\n\n".join( - f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}" - for num in nums - ) - + fix_paths = [content_path.parent / f"{content_path.stem}.fix-r{runde}-{i + 1}.md" for i in range(writer_count)] + fix_offen = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()] results = await asyncio.gather(*[ run_agent( - f"{guide_id}-fix-w{i + 1}", + f"{guide_id}-fix-r{runde}-w{i + 1}", _prompt( "Guide-Sections-Fix", topic=topic, format_name=format_name, facts=facts, spec=spec, - auftraege=auftraege_text(fix_chunks[i]), + auftraege=auftraege_text(fix_chunks[i], probleme_by_num), out_path=fix_paths[i], extra=_extra(instructions), ), _timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full", @@ -394,17 +578,23 @@ async def _generate_sections( return None for i, r in zip(fix_offen, results): if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0): - _log(topic, f"Sections-Fix {i + 1} fehlgeschlagen — Original bleibt") - ersetzt = 0 - for i in fix_offen: - if not fix_paths[i].exists(): + _log(topic, f"Sections-Fix {i + 1} (Runde {runde}) fehlgeschlagen — Original bleibt") + ersetzt: set[int] = set() + for p in fix_paths: + if not p.exists(): continue - for sec in _parse_fragment(fix_paths[i].read_text(encoding="utf-8")): + for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _titel_aufloesen(idx, sec["titel"]) if num in probleme_by_num and sec["md"].strip(): by_num[num] = sec - ersetzt += 1 - _log(topic, f"Lese-Prüfung: {ersetzt} Section(s) überarbeitet") + ersetzt.add(num) + _log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet") + if not ersetzt: + break + if runde == KONSENS_MAX_RUNDEN: + _log(topic, f"Lese-Prüfung: Cap erreicht — letzte Überarbeitung bleibt ungeprüft") + break + scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums] await _set_progress(guide_id, "Setze zusammen…") chapters: list[dict] = [] diff --git a/backend/lernen.py b/backend/lernen.py new file mode 100644 index 0000000..722888a --- /dev/null +++ b/backend/lernen.py @@ -0,0 +1,132 @@ +"""Baustein-Lernen: Vertiefung, Bausteinchat und Prüfung zu einzelnen Guide-Sections. + +Alle Aufrufe sind interaktiv (stdout-Antwort, lane "interactive") und stateless — +der Chat-/Prüfungs-Verlauf kommt vom Frontend, persistiert wird nur der +Prüfungs-Zähler (DB) und die Vertiefung (DB). +""" + +import logging +import uuid +from datetime import datetime, timezone + +from agents import run_agent +from config import DEFAULT_PROVIDER +from database import create_element, list_elements +from elements import generate_element +from jsonio import parse_json_text as _parse_json_text +from pipeline import _prompt +from textkit import _norm_titel + +log = logging.getLogger("creator.lernen") + +NOETIG = 3 # gute Antworten bis "absolviert" +VERTIEFUNG_TIMEOUT = 600 +CHAT_TIMEOUT = 240 + + +def _transcript(messages: list[dict]) -> str: + return "\n".join( + f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}" + for m in messages + ) or "(leer)" + + +async def vertiefung_generieren(topic: str, baustein: str, section: str, provider: str = DEFAULT_PROVIDER) -> str | None: + """Ausführliche Fassung des Bausteins als Markdown · None bei Fehler.""" + try: + prompt = _prompt( + "Baustein-Vertiefung", + topic=topic, baustein=baustein, + section_block=section.strip() or "(keine Guide-Fassung übergeben)", + ) + returncode, stdout, _ = await run_agent( + "vertiefung-" + str(uuid.uuid4()), prompt, VERTIEFUNG_TIMEOUT, + provider=provider, role="fast", capabilities="none", lane="interactive", + ) + if returncode != 0: + return None + return stdout.strip() or None + except Exception: + log.warning("[%s] Vertiefung fehlgeschlagen (%s)", topic, baustein, exc_info=True) + return None + + +async def baustein_chat(topic: str, baustein: str, section: str, vertiefung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str: + try: + prompt = _prompt( + "Baustein-Chat", + topic=topic, baustein=baustein, + section_block=section.strip() or "(keine Guide-Fassung übergeben)", + vertiefung_block=(vertiefung or "").strip() or "(keine)", + transcript=_transcript(messages), + ) + returncode, stdout, _ = await run_agent( + "bausteinchat-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT, + provider=provider, role="fast", capabilities="none", lane="interactive", + ) + 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: + log.warning("[%s] Baustein-Chat fehlgeschlagen (%s)", topic, baustein, exc_info=True) + return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." + + +def _pruefung_schema(data) -> dict | None: + """{"reply": str, "bewertung": "gut"|"schlecht"|None, "bestanden": bool} · sonst None.""" + if not isinstance(data, dict): + return None + reply = str(data.get("reply", "")).strip() + bewertung = data.get("bewertung") + if not reply or bewertung not in ("gut", "schlecht", None): + return None + return {"reply": reply, "bewertung": bewertung, "bestanden": data.get("bestanden") is True} + + +async def baustein_pruefung( + topic: str, baustein: str, section: str, vertiefung: str | None, + messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, +) -> dict | None: + """Ein Prüfungs-Turn: Frage stellen bzw. letzte Antwort bewerten · None bei Fehler.""" + try: + prompt = _prompt( + "Baustein-Pruefung", + topic=topic, baustein=baustein, + section_block=section.strip() or "(keine Guide-Fassung übergeben)", + vertiefung_block=(vertiefung or "").strip() or "(keine)", + transcript=_transcript(messages) if messages else "(leer)", + gute_antworten=gute_antworten, noetig=NOETIG, + ) + # role "judge": Bewertungen brauchen das starke, kalte Modell — + # M2.7 hat in der Praxis gegen die eigene Referenz halluziniert. + returncode, stdout, _ = await run_agent( + "pruefung-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT, + provider=provider, role="judge", capabilities="none", lane="interactive", + ) + if returncode != 0: + return None + return _pruefung_schema(_parse_json_text(stdout)) + except Exception: + log.warning("[%s] Prüfung fehlgeschlagen (%s)", topic, baustein, exc_info=True) + return None + + +async def baustein_element_anlegen(topic: str, baustein: str, section: str, provider: str = DEFAULT_PROVIDER) -> None: + """Hintergrund-Task nach dem Absolvieren: Baustein als Element anlegen. + + Dedup über normalisierte Titel — existiert schon ein Element zum Baustein, + passiert nichts. Darf nie eine Exception nach außen werfen. + """ + try: + vorhanden = {_norm_titel(e["title"]) for e in await list_elements(topic)} + if _norm_titel(baustein) in vorhanden: + return + fields = await generate_element(topic, hint=baustein, provider=provider, extra_context=section) + if _norm_titel(fields["title"]) in vorhanden: + return + now = datetime.now(timezone.utc).isoformat() + await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now}) + log.info("[%s] Baustein als Element angelegt: %s", topic, fields["title"]) + except Exception: + log.warning("[%s] Element-Anlage nach Prüfung fehlgeschlagen (%s)", topic, baustein, exc_info=True) diff --git a/backend/models.py b/backend/models.py index 7d0b4c1..eefe6dc 100644 --- a/backend/models.py +++ b/backend/models.py @@ -8,7 +8,7 @@ FormatType = Literal[ "FullGuide", ] -ProviderType = Literal["claude", "minimax", "minimax-direkt", "lokal"] +ProviderType = Literal["claude", "minimax", "lokal"] class GuideCreateRequest(BaseModel): @@ -156,3 +156,53 @@ class ProgressUpdate(BaseModel): class ProgressResponse(BaseModel): chapters: list[str] + + +# --- Baustein-Lernen --- + +class VertiefungRequest(BaseModel): + topic: str = Field(min_length=1, max_length=100) + baustein: str = Field(min_length=1, max_length=200) + section: str = Field(default="", max_length=20000) + provider: ProviderType = "claude" + + +class VertiefungResponse(BaseModel): + md: str + + +class BausteinChatRequest(BaseModel): + topic: str = Field(min_length=1, max_length=100) + baustein: str = Field(min_length=1, max_length=200) + section: str = Field(default="", max_length=20000) + messages: list[ChatMessage] = Field(min_length=1) + provider: ProviderType = "claude" + + +class BausteinChatResponse(BaseModel): + reply: str + + +class BausteinPruefungRequest(BaseModel): + topic: str = Field(min_length=1, max_length=100) + baustein: str = Field(min_length=1, max_length=200) + section: str = Field(default="", max_length=20000) + messages: list[ChatMessage] = [] # leer = KI stellt die erste Frage + provider: ProviderType = "claude" + + +class BausteinPruefungResponse(BaseModel): + reply: str + bewertung: Literal["gut", "schlecht"] | None = None + gute_antworten: int + absolviert: bool + + +class BausteinLernstand(BaseModel): + gute_antworten: int + absolviert: bool + vertiefung: bool + + +class BausteinLernstandResponse(BaseModel): + bausteine: dict[str, BausteinLernstand] diff --git a/backend/onepager.py b/backend/onepager.py index bcd82f8..06754e8 100644 --- a/backend/onepager.py +++ b/backend/onepager.py @@ -1,20 +1,30 @@ -"""OnePager-Pipeline: Recherche → Recherche-Prüfung → Bauen → Prüfung (7 Karten im 3×3-Raster).""" +"""OnePager-Pipeline als Konsens-Kette (7 Karten im 3×3-Raster). + +Recherche: 3 Agenten (min. 2, Grace) → Mapping konsolidiert zu EINER Faktenbasis. +Bauen: 3 Agenten bauen je einen Karten-Satz → ein Judge wählt und kombiniert. +Prüfung: Verify→Fix-Loop (max. KONSENS_MAX_RUNDEN); Runde 1 ist fatal, danach +bleibt bei Fehlern die letzte gültige Version. Schritt-Dateien bleiben liegen → +Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort. +""" from pathlib import Path -from fsutil import atomic_write_json +from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN from jsonio import read_json_file as _json_datei from pipeline import ( - CANCELLED, FAILED, GenContext, _check_then_fix, _extra, _fail, - _prompt, _set_step, _timeout, is_guide_cancelled, run_single_slot, + CANCELLED, FAILED, GenContext, _extra, _fail, _log, _probleme_schema, + _prompt, _race, _set_step, _timeout, is_guide_cancelled, run_single_slot, ) +ONEPAGER_STEPS = ("Recherche", "Bauen", "Prüfung") + async def _generate_onepager( guide_id: str, topic: str, instructions: str, provider: str, project: Path | None, content_path: Path, ) -> list[dict] | None: - ctx = GenContext(topic=topic, provider=provider, is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id) + is_cancelled = lambda: is_guide_cancelled(guide_id) + ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id) # 3×3-Raster: 7 Karten mit festen Schlüsseln (Reihenfolge = Lesereihenfolge mobil) KARTEN_KEYS = ("info", "eigenschaften", "beispiel", "zusammenhaenge", "voraussetzungen", "modern", "veraltet") @@ -38,116 +48,193 @@ async def _generate_onepager( return out d, stem = content_path.parent, content_path.stem - recherche_path = d / f"{stem}.recherche.md" - recherche_check_path = d / f"{stem}.recherche-check.json" - karten_path = d / f"{stem}.karten.json" - check_path = d / f"{stem}.onepager-check.json" + recherche_slots = [d / f"{stem}.recherche-{i}.md" for i in (1, 2, 3)] + recherche_path = d / f"{stem}.recherche.md" # konsolidierte Faktenbasis + karten_slots = [d / f"{stem}.karten-{i}.json" for i in (1, 2, 3)] + karten_path = d / f"{stem}.karten.json" # Judge-Ausgabe + verify_paths = {n: d / f"{stem}.verify-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)} + fix_paths = {n: d / f"{stem}.karten-fix-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)} # Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen # (Version, Lizenz, Alternativen) laufen dort ins Leere. if project: source = _prompt("OnePager-Quelle-Projekt", project=project) recherche_template = "OnePager-Recherche-Projekt" - recherche_check_template = "OnePager-Recherche-Check-Projekt" else: source = _prompt("OnePager-Quelle-Thema", topic=topic) recherche_template = "OnePager-Recherche" - recherche_check_template = "OnePager-Recherche-Check" - def recherche_payload(result=None): - if not recherche_path.exists(): + def text_payload(path: Path): + if not path.exists(): return None - text = recherche_path.read_text(encoding="utf-8").strip() + text = path.read_text(encoding="utf-8").strip() return text or None - # Schritt 1: Recherche — vorhandene Datei wird übernommen (Resume) - recherche = recherche_payload() + # Schritt 0: Recherche — 3 Agenten (min. 2, Grace), Mapping konsolidiert. + # Eine gültige recherche.md (auch aus Altläufen) überspringt den Schritt. + recherche = text_payload(recherche_path) if recherche is None: - await _set_step(guide_id, 0, "Recherchiere…") + await _set_step(guide_id, 0, "Recherchiere (3 Agenten)…") + recherchen = [] + offen = [] + for i, path in enumerate(recherche_slots, 1): + text = text_payload(path) + if text is not None: + recherchen.append(text) + else: + offen.append((i, path)) + if len(recherchen) < 2: + slots = [ + { + "key": f"{guide_id}-recherche-{i}", + "prompt": _prompt(recherche_template, topic=topic, source=source, out_path=path, extra=_extra(instructions)), + "role": "quick", "capabilities": "files" if project else "full", + "payload": (lambda result, p=path: text_payload(p)), + } + for i, path in offen + ] + neue = await _race( + topic, "OnePager-Recherche", slots, 2 - len(recherchen), + _timeout("onepager_recherche"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE, + ) + if is_cancelled(): + return None + if neue is None: + await _fail(guide_id, "OnePager-Recherche fehlgeschlagen (Minimum nicht erreicht)") + return None + recherchen += neue + + await _set_step(guide_id, 0, "Konsolidiere Recherche…") + recherchen_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1)) status, recherche = await run_single_slot( - ctx, "OnePager-Recherche", - key=f"{guide_id}-recherche", - prompt=_prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)), - role="quick", capabilities="files" if project else "full", - payload=recherche_payload, timeout=_timeout("onepager_recherche"), + ctx, "Recherche-Mapping", + key=f"{guide_id}-recherche-mapping", + prompt=_prompt( + "OnePager-Recherche-Mapping", + topic=topic, n=len(recherchen), recherchen=recherchen_block, out_path=recherche_path, + ), + role="judge", capabilities="files", + payload=lambda result: text_payload(recherche_path), + timeout=_timeout("onepager_mapping"), ) if status == CANCELLED: return None if status == FAILED: - await _fail(guide_id, "OnePager-Recherche fehlgeschlagen") + await _fail(guide_id, "Recherche-Konsolidierung fehlgeschlagen") return None - # Schritt 2: Recherche-Prüfung — notiert Probleme; Anpassung macht ein Recherche-Agent - status, fixed = await _check_then_fix( - ctx, name="Recherche", step=1, - check_key=f"{guide_id}-recherche-check", - check_prompt=_prompt(recherche_check_template, topic=topic, recherche=recherche, out_path=recherche_check_path), - check_path=recherche_check_path, check_timeout=_timeout("onepager_verify"), - fix_key=f"{guide_id}-recherche-fix", - build_fix_prompt=lambda probleme: _prompt( - "OnePager-Recherche-Fix", - topic=topic, source=source, recherche=recherche, - probleme="\n".join(f"- {p}" for p in probleme), - out_path=recherche_path, extra=_extra(instructions), - ), - fix_payload=recherche_payload, fix_timeout=_timeout("onepager_recherche"), - fix_role="quick", fix_caps="files" if project else "full", - ) - if status == CANCELLED: - return None - if status == FAILED: - await _fail(guide_id, "Recherche-Prüfung fehlgeschlagen") - return None - if fixed is not None: - recherche = fixed - - # Schritt 3: Bauen — Karten nur aus der Faktenbasis (Resume: gültige Datei wird übernommen) + # Schritt 1: Bauen — 3 Entwürfe (min. 2, Grace), ein Judge kombiniert. + # Gültiges karten.json (auch aus Altläufen) überspringt den Schritt. karten = karten_schema(_json_datei(karten_path)) if karten is None: - await _set_step(guide_id, 2, "Baue OnePager…") - karten_path.unlink(missing_ok=True) + await _set_step(guide_id, 1, "Baue OnePager (3 Entwürfe)…") + entwuerfe = [] + offen = [] + for i, path in enumerate(karten_slots, 1): + res = karten_schema(_json_datei(path)) + if res is not None: + entwuerfe.append(res) + else: + offen.append((i, path)) + if len(entwuerfe) < 2: + slots = [ + { + "key": f"{guide_id}-bauen-{i}", + "prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=path, extra=_extra(instructions)), + "role": "fast", "capabilities": "files", + "payload": (lambda result, p=path: karten_schema(_json_datei(p))), + } + for i, path in offen + ] + neue = await _race( + topic, "OnePager-Bauen", slots, 2 - len(entwuerfe), + _timeout("onepager_bauen"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE, + ) + if is_cancelled(): + return None + if neue is None: + await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Minimum nicht erreicht)") + return None + entwuerfe += neue + + await _set_step(guide_id, 1, "Wähle besten Entwurf…") + saetze_block = "\n\n".join( + f"## Entwurf {i}\n\n" + "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in satz) + for i, satz in enumerate(entwuerfe, 1) + ) status, karten = await run_single_slot( - ctx, "OnePager-Bauen", - key=f"{guide_id}-bauen", - prompt=_prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)), - role="fast", capabilities="files", + ctx, "Bauen-Judge", + key=f"{guide_id}-bauen-judge", + prompt=_prompt( + "OnePager-Bauen-Judge", + topic=topic, n=len(entwuerfe), recherche=recherche, kartensaetze=saetze_block, + out_path=karten_path, extra=_extra(instructions), + ), + role="judge", capabilities="files", payload=lambda result: karten_schema(_json_datei(karten_path)), - timeout=_timeout("onepager_bauen"), + timeout=_timeout("onepager_judge"), ) if status == CANCELLED: return None if status == FAILED: - await _fail(guide_id, "OnePager-Bau fehlgeschlagen") + await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Judge ohne gültiges Ergebnis)") return None def karten_block() -> str: return "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in karten) - # Schritt 4: Prüfung — notiert Probleme; Anpassung macht ein Bauen-Agent - status, fixed = await _check_then_fix( - ctx, name="OnePager", step=3, - check_key=f"{guide_id}-verify", - check_prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=check_path), - check_path=check_path, check_timeout=_timeout("onepager_verify"), - fix_key=f"{guide_id}-karten-fix", - build_fix_prompt=lambda probleme: _prompt( - "OnePager-Fix", - topic=topic, recherche=recherche, karten=karten_block(), - probleme="\n".join(f"- {p}" for p in probleme), - out_path=karten_path, extra=_extra(instructions), - ), - fix_payload=lambda result: karten_schema(_json_datei(karten_path)), - fix_timeout=_timeout("onepager_bauen"), - on_fix_invalid=lambda: atomic_write_json( - karten_path, {"karten": {k["key"]: {"titel": k["titel"], "md": k["md"]} for k in karten}}, - ), - ) - if status == CANCELLED: - return None - if status == FAILED: - await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen") - return None - if fixed is not None: + # Schritt 2: Prüf-Loop — Verify notiert Probleme, Fix behebt; max. Runden-Cap. + # Runde 1 ist fatal (wie früher der Einzel-Check), danach bleibt bei Fehlern + # die letzte gültige Version stehen. + for runde in range(1, KONSENS_MAX_RUNDEN + 1): + probleme = _probleme_schema(_json_datei(verify_paths[runde])) + if probleme is None: + await _set_step(guide_id, 2, f"Prüfe OnePager (Runde {runde}/{KONSENS_MAX_RUNDEN})…") + verify_paths[runde].unlink(missing_ok=True) + status, probleme = await run_single_slot( + ctx, f"OnePager-Prüfung r{runde}", + key=f"{guide_id}-verify-r{runde}", + prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=verify_paths[runde]), + role="judge", capabilities="files", + payload=lambda result, p=verify_paths[runde]: _probleme_schema(_json_datei(p)), + timeout=_timeout("onepager_verify"), + ) + if status == CANCELLED: + return None + if status == FAILED: + if runde == 1: + await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen") + return None + _log(topic, f"OnePager-Prüfung Runde {runde} fehlgeschlagen — letzte gültige Version bleibt") + break + if not probleme: + break + if runde == KONSENS_MAX_RUNDEN: + _log(topic, f"OnePager-Prüfung: {len(probleme)} Problem(e) bleiben nach Runde {runde} stehen") + break + + _log(topic, f"OnePager-Prüfung Runde {runde}: {len(probleme)} Problem(e) notiert") + fixed = karten_schema(_json_datei(fix_paths[runde])) # Resume + if fixed is None: + await _set_step(guide_id, 2, f"Überarbeite OnePager (Runde {runde})…") + status, fixed = await run_single_slot( + ctx, f"OnePager-Fix r{runde}", + key=f"{guide_id}-karten-fix-r{runde}", + prompt=_prompt( + "OnePager-Fix", + topic=topic, recherche=recherche, karten=karten_block(), + probleme="\n".join(f"- {p}" for p in probleme), + out_path=fix_paths[runde], extra=_extra(instructions), + ), + role="fast", capabilities="files", + payload=lambda result, p=fix_paths[runde]: karten_schema(_json_datei(p)), + timeout=_timeout("onepager_bauen"), + ) + if status == CANCELLED: + return None + if status == FAILED: + _log(topic, f"OnePager-Fix Runde {runde} ungültig — letzte gültige Version bleibt") + break karten = fixed sections = [ diff --git a/backend/pipeline.py b/backend/pipeline.py index 120cf92..88cb05a 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -1,4 +1,4 @@ -"""Pipeline-Grundbausteine: Agent-Races, Single-Slot, Check→Fix, Prompts, Guide-Status. +"""Pipeline-Grundbausteine: Agent-Races (mit Grace), Single-Slot, Schemata, Prompts, Guide-Status. Hält den mutablen Pipeline-Zustand (Generierungs-Semaphore, Cancel-Set). Zugriff auf das Cancel-Set NUR über die Funktionen hier — kopierte Referenzen @@ -105,10 +105,39 @@ def _probleme_schema(data): return out or None +def _str_liste(val) -> list[str] | None: + """Liste nicht-leerer Strings → gestrippte Liste (leer erlaubt) · sonst None.""" + if not isinstance(val, list) or not all(isinstance(x, str) for x in val): + return None + out = [x.strip() for x in val] + return None if any(not x for x in out) else out + + +def _rest_schema(data): + """{"uebernehmen": [str]} → Liste (leer erlaubt) · sonst None.""" + if not isinstance(data, dict): + return None + return _str_liste(data.get("uebernehmen")) + + +def _runde_schema(data, final: bool = False): + """{"aufnehmen": [str], "rest": [str]} → (aufnehmen, rest) · sonst None. + + final=True: letzte Klärungs-Runde — ein nicht-leerer Rest ist ungültig. + """ + if not isinstance(data, dict): + return None + aufnehmen = _str_liste(data.get("aufnehmen")) + rest = _str_liste(data.get("rest")) + if aufnehmen is None or rest is None or (final and rest): + return None + return aufnehmen, rest + + _MAX_RESTARTS = 2 -async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None) -> list | None: +async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None) -> list | None: """Startet alle Slots parallel und sammelt `quorum` gültige Ergebnisse. Slot-Spec: {key, prompt, role, capabilities, payload}. `payload(result)` @@ -116,9 +145,16 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: Fehler/Timeout/ungültig → Slot-Neustart (max. _MAX_RESTARTS). Sobald das Quorum steht, werden die übrigen Agenten gekillt. None = Quorum verfehlt. `cancelled()` → True bricht ab (keine Restarts, Rückgabe None). + + Mit `grace` wird `quorum` zum Minimum: Das erste gültige Ergebnis startet + einen Timer von `grace` Sekunden. Nach dessen Ablauf werden laufende + Agenten nur gekillt, wenn das Minimum steht — sonst läuft das Race samt + Restarts weiter, bis es steht. Rückgabe: `quorum` bis `len(slots)` Ergebnisse. """ attempts = {i: 0 for i in range(len(slots))} tasks: dict[asyncio.Task, int] = {} + loop = asyncio.get_running_loop() + deadline: float | None = None def spawn(i: int) -> None: slot = slots[i] @@ -136,7 +172,15 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: while tasks: if cancelled and cancelled(): return None - done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED) + if deadline is not None and len(results) >= quorum and loop.time() >= deadline: + return results + # Grace gesetzt und Minimum erreicht → nur bis zum Deadline-Rest warten + wait_timeout = None + if deadline is not None and len(results) >= quorum: + wait_timeout = max(0.0, deadline - loop.time()) + done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout) + if not done: + continue for task in done: i = tasks.pop(task) payload, err = None, None @@ -155,16 +199,24 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: if payload is not None: results.append(payload) + if grace is not None and deadline is None: + deadline = loop.time() + grace + _log(topic, f"{label}: erstes Ergebnis — Grace {grace}s läuft") if on_update: on_update(len(results)) - if len(results) >= quorum: + if len(results) >= quorum and (grace is None or loop.time() >= deadline): return results continue _log(topic, f"{label} {i + 1} (Versuch {attempts[i] + 1}): {err}") attempts[i] += 1 - if attempts[i] <= _MAX_RESTARTS and not (cancelled and cancelled()): + # Steht das Minimum schon, sind Restarts sinnlos — der Neustart + # würde am Grace-Ende ohnehin gekillt. + satt = grace is not None and len(results) >= quorum + if attempts[i] <= _MAX_RESTARTS and not satt and not (cancelled and cancelled()): spawn(i) + if len(results) >= quorum: # alle Slots durch, Minimum steht (nur mit grace erreichbar) + return results _log(topic, f"{label}: Quorum {quorum} nicht erreicht ({len(results)} gültig)") return None finally: @@ -184,7 +236,7 @@ class GenContext: guide_id: str | None = None -# Ergebnis-Status von run_single_slot/_check_then_fix +# Ergebnis-Status von run_single_slot OK, CANCELLED, FAILED = "ok", "cancelled", "failed" @@ -205,47 +257,3 @@ async def run_single_slot( return OK, res[0] -async def _check_then_fix( - ctx: GenContext, *, name: str, step: int, - check_key: str, check_prompt: str, check_path: Path, check_timeout: int, - fix_key: str, build_fix_prompt, fix_payload, fix_timeout: int, - fix_role: str = "fast", fix_caps: str = "files", - on_fix_invalid=None, -) -> tuple[str, object]: - """Check→Fix-Muster: Prüf-Agent notiert Probleme (JSON), Fix-Agent behebt sie. - - Resume: existierende Check-Datei überspringt den ganzen Schritt. - Check ist fatal (FAILED), Fix nicht — Original bleibt; on_fix_invalid kann - das kanonische Original zurückschreiben, falls der Fix-Agent die - Artefakt-Datei zerschrieben hat. - Lese-Check (Multi-Slot, Section-genau) und Bausteine-Auswahl-Check - (Patch-Semantik) passen bewusst NICHT in dieses Muster. - → (OK, neues_artefakt | None=unverändert) | (CANCELLED, None) | (FAILED, None) - """ - if check_path.exists(): - return OK, None - await _set_step(ctx.guide_id, step, f"Prüfe {name}…") - status, probleme = await run_single_slot( - ctx, f"{name}-Prüfung", key=check_key, prompt=check_prompt, - role="fast", capabilities="files", - payload=lambda result: _probleme_schema(_json_datei(check_path)), - timeout=check_timeout, - ) - if status != OK: - return status, None - if not probleme: - return OK, None - _log(ctx.topic, f"{name}-Prüfung: {len(probleme)} Problem(e) notiert") - await _set_step(ctx.guide_id, step, f"Passe {name} an…") - status, fixed = await run_single_slot( - ctx, f"{name}-Fix", key=fix_key, prompt=build_fix_prompt(probleme), - role=fix_role, capabilities=fix_caps, payload=fix_payload, timeout=fix_timeout, - ) - if status == CANCELLED: - return CANCELLED, None - if status == FAILED: - _log(ctx.topic, f"{name}-Fix ungültig — Original bleibt") - if on_fix_invalid: - on_fix_invalid() - return OK, None - return OK, fixed diff --git a/backend/regeln.py b/backend/regeln.py index bc341b9..5e21e57 100644 --- a/backend/regeln.py +++ b/backend/regeln.py @@ -3,35 +3,64 @@ Regeln (nur Neu-Erstellungen; Themen, Bausteine, OnePager unbegrenzt): - JE Format (MiniGuide/Guide/FullGuide) höchstens 3 erstellte, nicht absolvierte Guides - Progression pro Thema: Guide erst nach absolviertem MiniGuide, FullGuide erst nach absolviertem Guide +- Absolviert (Mini/Guide/FullGuide): ALLE Bausteine (Section-Titel) des neuesten + fertigen Guides haben eine bestandene Prüfung (baustein_progress). Das + Kapitel-Häkchen ist nur noch eine Lese-Markierung. OnePager: Kapitel-Häkchen. Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine Query-Schleifen mehr pro Guide. """ import json -from database import list_guides, list_progress_all +from database import list_baustein_absolviert_all, list_guides, list_progress_all from guide import guide_slot_dateien from paths import bausteine_path, guide_content_path +from textkit import _norm_titel MAX_OFFENE_GUIDES = 3 VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"} FORMATE = ("MiniGuide", "Guide", "FullGuide") -async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]]]: - """Guides + kompletter Kapitel-Fortschritt in zwei Queries.""" - return await list_guides(), await list_progress_all() +async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, set[str]]]: + """Guides + Kapitel-Fortschritt + absolvierte Bausteine in drei Queries. + + bausteine_done: topic → normalisierte Titel der Bausteine mit bestandener Prüfung. + """ + bausteine_done = { + topic: {_norm_titel(b) for b in titel} + for topic, titel in (await list_baustein_absolviert_all()).items() + } + return await list_guides(), await list_progress_all(), bausteine_done -def _kapitel_titel(topic: str, fmt: str) -> set[str] | None: +def _content_json(topic: str, fmt: str) -> dict | None: path = guide_content_path(topic, fmt) if not path.exists(): return None try: - chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", []) + return json.loads(path.read_text(encoding="utf-8")) except ValueError: return None - return {c.get("title") for c in chapters} + + +def _kapitel_titel(topic: str, fmt: str) -> set[str] | None: + content = _content_json(topic, fmt) + if content is None: + return None + return {c.get("title") for c in content.get("chapters", [])} + + +def _section_titel(topic: str, fmt: str) -> set[str] | None: + """Normalisierte Baustein-Titel (Sections) aus dem Guide-Content.""" + content = _content_json(topic, fmt) + if content is None: + return None + return { + _norm_titel(s.get("title", "")) + for ch in content.get("chapters", []) + for s in ch.get("sections", []) + } def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]: @@ -44,28 +73,31 @@ def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]: return neueste -def _guide_absolviert(g: dict, progress: dict[str, set[str]]) -> bool: - titles = _kapitel_titel(g["topic"], g["format"]) - return bool(titles) and titles <= progress.get(g["id"], set()) +def _guide_absolviert(g: dict, progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> bool: + if g["format"] == "OnePager": + titles = _kapitel_titel(g["topic"], g["format"]) + return bool(titles) and titles <= progress.get(g["id"], set()) + sections = _section_titel(g["topic"], g["format"]) + return bool(sections) and sections <= bausteine_done.get(g["topic"], set()) -def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]]) -> bool: - """Alle Kapitel des neuesten fertigen Guides (Thema+Format) abgehakt?""" +def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> bool: + """Alle Bausteine des neuesten fertigen Guides (Thema+Format) per Prüfung absolviert?""" g = _neueste_done(guides, fmt).get(topic) - return g is not None and _guide_absolviert(g, progress) + return g is not None and _guide_absolviert(g, progress, bausteine_done) -def formate_stats(guides: list[dict], progress: dict[str, set[str]]) -> dict: +def formate_stats(guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> dict: """Pro Format erstellt/absolviert — pro Thema zählt nur der neueste fertige Guide.""" formate = {} for fmt in FORMATE: neueste = _neueste_done(guides, fmt) - absolviert = sum(1 for g in neueste.values() if _guide_absolviert(g, progress)) + absolviert = sum(1 for g in neueste.values() if _guide_absolviert(g, progress, bausteine_done)) formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert} return formate -def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]]) -> str | None: +def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> str | None: """Grund, warum ein Neu-Start für Thema+Format gesperrt ist — None = erlaubt. Exakt die Regeln aus POST /guides: Bausteine nötig, kein Duplikat-Start, @@ -79,9 +111,9 @@ def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set content = guide_content_path(topic, fmt) if fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content): vorstufe = VORSTUFE.get(fmt) - if vorstufe and not ist_absolviert(topic, vorstufe, guides, progress): - return f"Erst den {vorstufe} dieses Themas absolvieren" - stat = formate_stats(guides, progress).get(fmt, {"erstellt": 0, "absolviert": 0}) + if vorstufe and not ist_absolviert(topic, vorstufe, guides, progress, bausteine_done): + return f"Erst den {vorstufe} dieses Themas absolvieren (alle Bausteine prüfen)" + stat = formate_stats(guides, progress, bausteine_done).get(fmt, {"erstellt": 0, "absolviert": 0}) offen = stat["erstellt"] - stat["absolviert"] if offen >= MAX_OFFENE_GUIDES: return f"Erst {fmt}s absolvieren — maximal {MAX_OFFENE_GUIDES} offene erlaubt ({offen} offen)" diff --git a/backend/routes.py b/backend/routes.py index 25edd10..5811f33 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -13,9 +13,12 @@ from database import ( create_topic, list_topics as db_list_topics, delete_topic, list_progress, set_progress, delete_progress, create_element, list_elements, get_element, update_element, delete_element, + get_vertiefung, set_vertiefung, list_vertiefungen, + list_baustein_progress, add_gute_antwort, set_baustein_absolviert, delete_baustein_daten, ) from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion +from lernen import NOETIG, baustein_chat, baustein_element_anlegen, baustein_pruefung, vertiefung_generieren from guide import generate_guide, guide_slot_dateien from pipeline import cancel_guide from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand @@ -28,6 +31,9 @@ from models import ( ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse, ElementRefineRequest, ElementRefineResponse, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, + VertiefungRequest, VertiefungResponse, + BausteinChatRequest, BausteinChatResponse, + BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse, ) from paths import bausteine_topics, guide_content_path, project_dir, topic_dir @@ -53,18 +59,18 @@ async def get_topics(): @router.get("/stats") async def get_stats(): """Tracker: Themen-Anzahl + pro Format erstellt/absolviert.""" - guides, progress = await lade_lernstand() + guides, progress, bausteine_done = await lade_lernstand() themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics()) if PROJECTS_DIR.is_dir(): themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()} - return {"themen": len(themen), "formate": formate_stats(guides, progress)} + return {"themen": len(themen), "formate": formate_stats(guides, progress, bausteine_done)} @router.get("/topics/fortschritt") async def topic_fortschritt(topic: str): """Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe.""" - guides, progress = await lade_lernstand() - return {fmt: ist_absolviert(topic, fmt, guides, progress) for fmt in FORMATE} + guides, progress, bausteine_done = await lade_lernstand() + return {fmt: ist_absolviert(topic, fmt, guides, progress, bausteine_done) for fmt in FORMATE} @router.post("/topics") @@ -76,6 +82,7 @@ async def add_topic(req: TopicCreateRequest): @router.delete("/topics") async def remove_topic(topic: str): await delete_topic(topic) + await delete_baustein_daten(topic) shutil.rmtree(topic_dir(topic), ignore_errors=True) return {"ok": True} @@ -138,12 +145,85 @@ async def remove_bausteine(topic: str): return {"ok": True} +# --- Baustein-Lernen: Vertiefung, Chat, Prüfung --- + +@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse) +async def baustein_lernstand(topic: str): + """Prüfungs-Stand + Vertiefungs-Existenz pro Baustein (roher Titel als Key).""" + progress = await list_baustein_progress(topic) + mit_vertiefung = await list_vertiefungen(topic) + bausteine = { + p["baustein"]: { + "gute_antworten": p["gute_antworten"], + "absolviert": p["absolviert"] is not None, + "vertiefung": p["baustein"] in mit_vertiefung, + } + for p in progress + } + for b in mit_vertiefung - set(bausteine): + bausteine[b] = {"gute_antworten": 0, "absolviert": False, "vertiefung": True} + return {"bausteine": bausteine} + + +@router.get("/bausteine/vertiefung", response_model=VertiefungResponse) +async def get_baustein_vertiefung(topic: str, baustein: str): + md = await get_vertiefung(topic, baustein) + if md is None: + raise HTTPException(404, "Keine Vertiefung vorhanden") + return {"md": md} + + +@router.post("/bausteine/vertiefung", response_model=VertiefungResponse) +async def create_baustein_vertiefung(req: VertiefungRequest): + md = await vertiefung_generieren(req.topic, req.baustein, req.section, provider=req.provider) + if md is None: + raise HTTPException(502, "Vertiefung fehlgeschlagen — bitte erneut versuchen") + await set_vertiefung(req.topic, req.baustein, md) + return {"md": md} + + +@router.post("/bausteine/chat", response_model=BausteinChatResponse) +async def baustein_chat_route(req: BausteinChatRequest): + vertiefung = await get_vertiefung(req.topic, req.baustein) + reply = await baustein_chat( + req.topic, req.baustein, req.section, vertiefung, + [m.model_dump() for m in req.messages], provider=req.provider, + ) + return {"reply": reply} + + +@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse) +async def baustein_pruefung_route(req: BausteinPruefungRequest): + stand = next( + (p for p in await list_baustein_progress(req.topic) if p["baustein"] == req.baustein), + {"gute_antworten": 0, "absolviert": None}, + ) + vertiefung = await get_vertiefung(req.topic, req.baustein) + data = await baustein_pruefung( + req.topic, req.baustein, req.section, vertiefung, + [m.model_dump() for m in req.messages], stand["gute_antworten"], provider=req.provider, + ) + if data is None: + raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen") + + gute = stand["gute_antworten"] + if data["bewertung"] == "gut": + gute = await add_gute_antwort(req.topic, req.baustein) + absolviert = stand["absolviert"] is not None + if gute >= NOETIG or data["bestanden"]: + frisch = await set_baustein_absolviert(req.topic, req.baustein) + absolviert = True + if frisch: + asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider)) + return {"reply": data["reply"], "bewertung": data["bewertung"], "gute_antworten": gute, "absolviert": absolviert} + + # --- Guides --- @router.post("/guides", response_model=GuideResponse) async def create(req: GuideCreateRequest): - guides, progress = await lade_lernstand() - grund = guide_lock(req.topic.strip(), req.format, guides, progress) + guides, progress, bausteine_done = await lade_lernstand() + grund = guide_lock(req.topic.strip(), req.format, guides, progress, bausteine_done) if grund: raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund) await create_topic(req.topic.strip()) @@ -171,8 +251,8 @@ async def list_all(): @router.get("/guides/locks") async def guide_locks(topic: str): """Sperr-Gründe pro Format für den ▶-Button — None = erstellbar.""" - guides, progress = await lade_lernstand() - return {fmt: guide_lock(topic, fmt, guides, progress) for fmt in ("OnePager", *FORMATE)} + guides, progress, bausteine_done = await lade_lernstand() + return {fmt: guide_lock(topic, fmt, guides, progress, bausteine_done) for fmt in ("OnePager", *FORMATE)} @router.get("/guides/{guide_id}", response_model=GuideResponse) diff --git a/backend/textkit.py b/backend/textkit.py index 94d2f0a..8943b74 100644 --- a/backend/textkit.py +++ b/backend/textkit.py @@ -42,6 +42,30 @@ def _eindeutige_titel(entries: dict[int, str]) -> dict[int, str]: return out +def _vormerge(listen: list[dict[int, str]]) -> list[tuple[str, int]]: + """Vereinigt Baustein-Listen: exakte Titel-Dubletten (per _norm_titel) zusammenführen. + + → [("Titel — Beschreibung", nennungen)] in Erstnennungs-Reihenfolge. + nennungen = Anzahl der Listen, die den Titel nennen (Dublette innerhalb + einer Liste zählt nicht doppelt). Repräsentant ist die Erstnennung; ein + drittes " — "-Segment (Quelle) wird verworfen. + """ + merged: dict[str, tuple[str, int]] = {} + for liste in listen: + gesehen: set[str] = set() + for text in liste.values(): + key = _norm_titel(_titel(text)) + if key in gesehen: + continue + gesehen.add(key) + if key in merged: + repr_text, n = merged[key] + merged[key] = (repr_text, n + 1) + else: + merged[key] = (" — ".join(text.split(" — ")[:2]), 1) + return list(merged.values()) + + def _titel_index(entries: dict[int, str]) -> dict[str, int]: return {_norm_titel(_titel(text)): num for num, text in entries.items()} diff --git a/dev-ops/opencode.json b/dev-ops/opencode.json index 7dba695..e1d5c55 100644 --- a/dev-ops/opencode.json +++ b/dev-ops/opencode.json @@ -11,19 +11,26 @@ } } }, - "minimax-direkt": { + "minimax-kalt": { "npm": "@ai-sdk/anthropic", - "name": "MiniMax (ohne Thinking)", + "name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)", "options": { "baseURL": "https://api.minimax.io/anthropic/v1", "apiKey": "{env:MINIMAX_API_KEY}" }, "models": { "MiniMax-M3": { - "name": "MiniMax M3 (ohne Thinking)", + "name": "MiniMax M3 (kalt)", "options": { + "temperature": 0.2, "thinking": { "type": "disabled" } } + }, + "MiniMax-M2.7-highspeed": { + "name": "MiniMax M2.7 highspeed (kalt)", + "options": { + "temperature": 0.3 + } } } }, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 34b51a1..6513c21 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -393,7 +393,6 @@ onMounted(async () => { :provider="provider" :elementsOpen="elementsOpen" @progressChanged="loadStats(); loadBausteine()" - @openElements="elementsOpen = true" />
Thema in der Sidebar anlegen oder auswählen.
diff --git a/frontend/src/api.js b/frontend/src/api.js index bd05e41..8378ef5 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -59,6 +59,47 @@ export async function deleteBausteine(topic) { await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' }) } +// --- Baustein-Lernen: Vertiefung, Chat, Prüfung --- + +export async function fetchBausteinLernstand(topic) { + const res = await fetch(`${BASE}/bausteine/lernstand?topic=${encodeURIComponent(topic)}`) + return jsonOrThrow(res) +} + +export async function fetchVertiefung(topic, baustein) { + const res = await fetch( + `${BASE}/bausteine/vertiefung?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}` + ) + return jsonOrThrow(res) +} + +export async function createVertiefung({ topic, baustein, section, provider }) { + const res = await fetch(`${BASE}/bausteine/vertiefung`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic, baustein, section, provider }), + }) + return jsonOrThrow(res) +} + +export async function chatBaustein({ topic, baustein, section, messages, provider }) { + const res = await fetch(`${BASE}/bausteine/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic, baustein, section, messages, provider }), + }) + return jsonOrThrow(res) +} + +export async function pruefeBaustein({ topic, baustein, section, messages, provider }) { + const res = await fetch(`${BASE}/bausteine/pruefung`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic, baustein, section, messages, provider }), + }) + return jsonOrThrow(res) +} + export async function fetchTopicFortschritt(topic) { const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`) return res.json() @@ -114,20 +155,6 @@ export async function deleteTopic(name) { await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' }) } -export async function fetchProgress(id) { - const res = await fetch(`${BASE}/guides/${id}/progress`) - return res.json() -} - -export async function setProgress(id, chapter, done) { - const res = await fetch(`${BASE}/guides/${id}/progress`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chapter, done }), - }) - return res.json() -} - export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) { const res = await fetch(`${BASE}/guides/${id}/chat`, { method: 'POST', diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue new file mode 100644 index 0000000..e9f12fb --- /dev/null +++ b/frontend/src/components/BausteinPanel.vue @@ -0,0 +1,277 @@ + + + +{{ vert === null ? 'Generiere Vertiefung…' : 'Lade…' }}
+ + + + + +Noch keine Vertiefung zu diesem Baustein.
+ + +{{ vertError }}
++ ✓ Absolviert — du kannst dich weiter prüfen lassen. + {{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Erkläre in eigenen Worten — das Material darfst du nutzen. +
+ +