diff --git a/backend/lernen.py b/backend/lernen.py index d5eaad3..918c4fb 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -5,6 +5,7 @@ der Chat-/Prüfungs-Verlauf kommt vom Frontend, persistiert wird nur der Prüfungs-Zähler (DB) und die Vertiefung (DB). """ +import asyncio import logging import re import uuid @@ -113,6 +114,14 @@ def _frage_schema(data) -> dict | None: return {"frage": frage} if frage else None +def _fragen_schema(data) -> list[str] | None: + """{"fragen": [str, …]} → Liste nicht-leerer Fragen · sonst None.""" + if not isinstance(data, dict) or not isinstance(data.get("fragen"), list): + return None + fragen = [str(f).strip() for f in data["fragen"] if str(f).strip()] + return fragen or None + + def _bewertung_schema(data) -> dict | None: """{"feedback": str, "bewertung": "gut"|"schlecht", "bestanden": bool} · sonst None.""" if not isinstance(data, dict): @@ -264,6 +273,56 @@ async def pruefung_frage( return None +async def _frage_ok(topic, baustein, section_block, kompakt_block, transcript, vermeide_block, frage, provider) -> bool: + """Eine geplante Frage prüfen: Doppelfrage-Guard + Kritiker. True = in Ordnung.""" + if _doppelfrage_mangel(frage): + return False + probleme = await _kritik_call( + "Baustein-Frage-Kritik", provider, role="guide", + topic=topic, baustein=baustein, section_block=section_block, + kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block, frage=frage, + ) + return not probleme + + +async def pruefung_fragen( + topic: str, baustein: str, section: str, kompakt: str | None, + messages: list[dict], vermeide: list[str] | None = None, anzahl: int = 5, + provider: str = DEFAULT_PROVIDER, +) -> list[str]: + """Aktion 'fragen': EIN Planer entwirft `anzahl` verschiedene Fragen, parallel geprüft. + Diversität kommt vom Planer; der Kritiker prüft Qualität + Ähnlichkeit zu Gestelltem. + → Liste gültiger Fragen (kann < anzahl sein).""" + try: + section_block, kompakt_block = _bloecke(section, kompakt) + transcript = _transcript(messages) if messages else "(leer)" + vermeide = [f for f in (vermeide or []) if f and f.strip()] + gesammelt: list[str] = [] + for _ in range(2): # Plan + eine Nachrunde für Abgelehnte + brauche = anzahl - len(gesammelt) + if brauche <= 0: + break + vblock = _vermeide_block(vermeide + gesammelt) + kandidaten = await _gen_call( + "Baustein-Fragen-Plan", "fast", _fragen_schema, provider, CHAT_TIMEOUT, + topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, + transcript=transcript, vermeide_block=vblock, anzahl=brauche, + ) + if not kandidaten: + break + oks = await asyncio.gather( + *[_frage_ok(topic, baustein, section_block, kompakt_block, transcript, vblock, f, provider) for f in kandidaten], + return_exceptions=True, + ) + for f, ok in zip(kandidaten, oks): + if ok is True and f not in gesammelt: + gesammelt.append(f) + return gesammelt[:anzahl] + except Exception: + log.warning("[%s] Fragen-Plan fehlgeschlagen (%s)", topic, baustein, exc_info=True) + return [] + + async def pruefung_bewertung( topic: str, baustein: str, section: str, kompakt: str | None, frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, diff --git a/backend/models.py b/backend/models.py index 4cc7209..a670398 100644 --- a/backend/models.py +++ b/backend/models.py @@ -218,6 +218,7 @@ class BausteinPruefungRequest(BaseModel): frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort); Anker der Basis letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion) vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen + anzahl: int = 5 # aktion 'fragen': wie viele neue Fragen der Planer entwerfen soll # Basis/Streak werden serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client. tier2: bool = False # ganzer Guide absolviert (alle ≥3) → −1 bei falsch, Deckel 10 tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, −2 bei falsch, Deckel 25 @@ -228,6 +229,7 @@ class BausteinPruefungRequest(BaseModel): class BausteinPruefungResponse(BaseModel): frage: str | None = None + fragen: list[str] | None = None # aktion 'fragen': geplante, geprüfte Fragen reply: str | None = None feedback: str | None = None bewertung: Literal["gut", "schlecht"] | None = None diff --git a/backend/routes.py b/backend/routes.py index 263a1fe..736f2ad 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -18,7 +18,7 @@ from database import ( ) from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion -from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, score_berechnen, streak_berechnen +from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_fragen, score_berechnen, streak_berechnen 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, thema_abgeschlossen @@ -273,6 +273,10 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen") return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + if req.aktion == "fragen": + fragen = await pruefung_fragen(req.topic, req.baustein, req.section, kompakt, msgs, vermeide=req.vermeide, anzahl=req.anzahl, provider=req.provider) + return {"fragen": fragen, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + if req.aktion == "diskussion": if not req.frage.strip(): raise HTTPException(400, "Diskussion braucht eine laufende Frage") diff --git a/frontend/src/api.js b/frontend/src/api.js index 9b355a5..fbe3915 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -77,13 +77,13 @@ export async function chatBaustein({ topic, baustein, section, section_kompakt = export async function pruefeBaustein({ topic, baustein, section, section_kompakt = '', provider, - aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [], + aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [], anzahl = 5, tier2 = false, tier3 = false, messages = [], gruendlich = false, }) { const res = await fetch(`${BASE}/bausteine/pruefung`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, tier2, tier3, messages, provider, gruendlich }), + body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, anzahl, tier2, tier3, messages, provider, gruendlich }), }) return jsonOrThrow(res) } diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue index b07f80d..83696c4 100644 --- a/frontend/src/components/BausteinPanel.vue +++ b/frontend/src/components/BausteinPanel.vue @@ -137,27 +137,24 @@ function vermeideListe() { return [...gestellt, ...pool.value] } -async function frageEinzeln() { - const res = await pruefeBaustein({ - topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, - provider: props.provider, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe(), - }) - return res.frage -} - -// Pool SEQUENZIELL auffüllen — je eine Generierung; jede neue sieht die vorigen -// (Verlauf + Pool) in der Vermeide-Liste und divergiert. Verhindert ähnliche Dubletten. +// Pool als BATCH auffüllen: EIN Planer entwirft die fehlenden Fragen auf einmal +// (verschieden by design), parallel geprüft → schnell und ohne ähnliche Dubletten. function poolFuellen() { if (inflight.value > 0) return - if (pool.value.length >= POOL_ZIEL || poolMiss >= 6) return + const brauche = POOL_ZIEL - pool.value.length + if (brauche <= 0 || poolMiss >= 3) return inflight.value++ - frageEinzeln() - .then((text) => { - if (text && !istBekannt(text)) { pool.value.push(text); poolMiss = 0 } + pruefeBaustein({ + topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, + provider: props.provider, aktion: 'fragen', anzahl: brauche, messages: pruefDialog(), vermeide: vermeideListe(), + }) + .then((res) => { + const neu = (res.fragen || []).filter((f) => f && !istBekannt(f)) + if (neu.length) { pool.value.push(...neu); poolMiss = 0 } else poolMiss++ }) .catch(() => { poolMiss++ }) - .finally(() => { inflight.value--; poolFuellen() }) + .finally(() => { inflight.value--; if (pool.value.length < POOL_ZIEL) poolFuellen() }) } // Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten. @@ -165,7 +162,7 @@ async function poolNehmen() { if (!pool.value.length) { pruefLoading.value = true try { - while (!pool.value.length && (inflight.value > 0 || poolMiss < 6)) { + while (!pool.value.length && (inflight.value > 0 || poolMiss < 3)) { if (!inflight.value) poolFuellen() await new Promise((r) => setTimeout(r, 120)) } @@ -299,6 +296,7 @@ onMounted(() => { if (props.mode !== 'full') return // Trigger-Modus (Liste): nur Tab-Leiste, keine Prüfungs-/Text-Logik window.addEventListener('keydown', onWindowKey) window.addEventListener('keydown', onPruefKey) + poolFuellen() // Pool schon beim Öffnen vorfüllen — keine Wartezeit bei der ersten Frage }) onUnmounted(() => { window.removeEventListener('keydown', onWindowKey) diff --git a/templates/Prompt/Baustein-Fragen-Plan.md b/templates/Prompt/Baustein-Fragen-Plan.md new file mode 100644 index 0000000..20aab22 --- /dev/null +++ b/templates/Prompt/Baustein-Fragen-Plan.md @@ -0,0 +1,31 @@ +Du bist Prüfer in einem Lern-Guide zum Thema "{topic}". Plane {anzahl} Verständnisfragen zum Baustein "{baustein}" — auf einmal, damit sie sich klar UNTERSCHEIDEN. Der Lerner sieht das Material; frage nach Verständnis und Transfer, nicht nach Abgelesenem. + +BAUSTEIN AUS DEM GUIDE: +{section_block} + +KOMPAKTE FASSUNG (Merksätze, falls vorhanden): +{kompakt_block} + +BISHERIGER PRÜFUNGS-VERLAUF (nur frühere Fragen und Antworten): +{transcript} + +BEREITS GESTELLT / SCHON VORGEMERKT — auch SINNGEMÄSS NICHT wiederholen: +{vermeide_block} + +WICHTIG — VIELFALT: +- Die {anzahl} Fragen müssen verschiedene Aspekte/Subbausteine treffen. KEINE zwei Fragen zur gleichen Kernsache, auch nicht umformuliert. +- Decke möglichst unterschiedliche Subbausteine ab (einfach → mittel → schwer gemischt). + +HARTE REGELN JE FRAGE — wichtiger als alles andere: +- GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache. +- Maximal 1–2 Sätze. Kein Szenario-Aufbau, keine Vorrede, kein "Angenommen … und außerdem …". +- Verboten: zwei Fragen mit "und"/"sowie" verketten, "nenne drei …", "sowohl … als auch …", Aufzähl-Forderungen. +- Frag nach EINEM Gedanken: ein Warum, eine Konsequenz, eine Abgrenzung, die Anwendung auf EIN kurzes Beispiel, einen Fehler finden. +- KEIN Faktenabruf ("welche Daten…", "wie viele…", "was enthält…"). +- Frag nur zu dem, was der Abschnitt wirklich erklärt — nicht zu am Rand Erwähntem. +- Fachlich korrekt: aus dem Material beantwortbar, der Referenz NIE widersprechen, keine erfundenen Zusatzannahmen. + +Sprich den Lerner direkt an, klares Deutsch, keine Floskeln. + +Gib NUR dieses JSON aus (kein weiterer Text), genau {anzahl} verschiedene Fragen: +{{"fragen": ["genau eine kurze Frage", "…"]}}