diff --git a/backend/lernen.py b/backend/lernen.py index f525e73..b8802fb 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -213,7 +213,7 @@ async def _frage_mit_kritik( return None frage = data["frage"] probleme = await _kritik_call( - "Baustein-Frage-Kritik", provider, + "Baustein-Frage-Kritik", provider, role="guide", # starke KI prüft die Regeln topic=topic, baustein=baustein, section_block=section_block, vertiefung_block=vertiefung_block, transcript=transcript, frage=frage, ) diff --git a/frontend/src/components/BausteinFokus.vue b/frontend/src/components/BausteinFokus.vue index 51279e2..5f15edb 100644 --- a/frontend/src/components/BausteinFokus.vue +++ b/frontend/src/components/BausteinFokus.vue @@ -19,20 +19,41 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' const emit = defineEmits(['close', 'prev', 'next', 'statusChanged']) -const guideEl = ref(null) // linke Guide-Spalte (Scroll-Ziel für ↑/↓) +const guideEl = ref(null) // linke Guide-Spalte (Scroll-Ziel für ALT+↑/↓) +const rightEl = ref(null) // rechte Spalte (Prüfungs-Panel mit Eingabefeld) -// Die Fokus-Ansicht besitzt die Pfeiltasten — auch bei fokussiertem Eingabefeld. -// ←/→ blättern Baustein, ↑/↓ scrollen den Guide; Browser-Default wird unterdrückt. -// ALT/Strg/Meta ignorieren (ALT+1/2/3 gehören dem Panel). -function onKey(e) { - if (e.altKey || e.ctrlKey || e.metaKey) return - if (e.key === 'ArrowLeft') { e.preventDefault(); if (props.hasPrev) emit('prev') } - else if (e.key === 'ArrowRight') { e.preventDefault(); if (props.hasNext) emit('next') } +// ALT ist der Modifier der Fokus-Ansicht. Plain Pfeile bleiben Browser-Standard. +// ALT+←/→ blättert Baustein, ALT+↑/↓ scrollt den Guide. Ein reiner ALT-Tipp +// (drücken+loslassen ohne andere Taste) toggelt den Fokus aufs Eingabefeld. +let altAlone = false +function onKeyDown(e) { + if (e.key === 'Alt') { altAlone = true; e.preventDefault(); return } // unterdrückt Firefox-Menüleiste + if (e.ctrlKey || e.metaKey || !e.altKey) return // plain/andere → normal + altAlone = false // ALT+irgendwas = kein Lone-Tap + if (e.key === 'ArrowLeft') { e.preventDefault(); if (!e.repeat && props.hasPrev) emit('prev') } + else if (e.key === 'ArrowRight') { e.preventDefault(); if (!e.repeat && props.hasNext) emit('next') } else if (e.key === 'ArrowUp') { e.preventDefault(); guideEl.value?.scrollBy(0, -120) } else if (e.key === 'ArrowDown') { e.preventDefault(); guideEl.value?.scrollBy(0, 120) } } -onMounted(() => window.addEventListener('keydown', onKey)) -onUnmounted(() => window.removeEventListener('keydown', onKey)) +function onKeyUp(e) { + if (e.key === 'Alt') { e.preventDefault(); if (altAlone) { altAlone = false; toggleInput() } } +} +function resetAlt() { altAlone = false } // Fenster-Blur (ALT+Tab) → kein falscher Tipp +function toggleInput() { + const el = rightEl.value?.querySelector('textarea') + if (!el) return + document.activeElement === el ? el.blur() : el.focus() +} +onMounted(() => { + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', resetAlt) +}) +onUnmounted(() => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', resetAlt) +}) const pct = (n) => (100 * n / (props.fortschritt.total || 1)) + '%' @@ -69,7 +90,7 @@ const standFarbe = computed(() => {
-
+
m.kind === 'frage').map((m) => normFrage(m.content))) } -// Holt eine Frage; bis zu 3 Versuche, eine NICHT wiederholte zu bekommen. Sonst best-effort die letzte. -async function frageHolen() { - const gestellt = gestellteFragen() - let letzte = null - for (let i = 0; i < 3; i++) { - const res = await pruefeBaustein({ - topic: props.topic, baustein: props.baustein, section: props.section, - provider: props.provider, aktion: 'frage', messages: pruefDialog(), - }) - letzte = res.frage - if (!gestellt.has(normFrage(res.frage))) return res.frage - } - return letzte +// --- Fragen-Pool: ZIEL vorgenerierte, geprüfte, unbeantwortete Fragen je Baustein (im Slot) --- +const POOL_ZIEL = 5 +const pool = slot.pool +const inflight = slot.inflight +let poolMiss = 0 // aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife (kleine Bausteine) + +function istBekannt(text) { + const n = normFrage(text) + return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(f) === n) } -// Nächste Frage im Hintergrund vorbereiten (überbrückt die Wartezeit). Ohne pruefLoading/pruefRun. -function prefetchFrage() { - if (naechsteFrage.value || prefetchPromise) return - prefetchPromise = frageHolen() - .then((text) => { naechsteFrage.value = text }) - .catch(() => {}) - .finally(() => { prefetchPromise = null }) -} - -// Erste Frage (Phase idle): frisch generieren, dann die nächste vorbereiten. -function frageAnfordern() { - if (pruefLoading.value) return - pruefSenden({ aktion: 'frage' }, (res) => { - frageZeigen(res.frage) - prefetchFrage() +async function frageEinzeln() { + const res = await pruefeBaustein({ + topic: props.topic, baustein: props.baustein, section: props.section, + provider: props.provider, aktion: 'frage', messages: pruefDialog(), }) + return res.frage } -// „Nächste Frage": vorgemerkte sofort zeigen, sonst auf den Prefetch warten, sonst frisch holen. +// Pool parallel auf ZIEL auffüllen; jede Auflösung dedupt + füllt nach. +function poolFuellen() { + while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < 6) { + inflight.value++ + frageEinzeln() + .then((text) => { + if (text && !istBekannt(text)) { pool.value.push(text); poolMiss = 0 } + else poolMiss++ + }) + .catch(() => { poolMiss++ }) + .finally(() => { inflight.value--; poolFuellen() }) + } +} + +// Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten. +async function poolNehmen() { + if (!pool.value.length) { + pruefLoading.value = true + try { + while (!pool.value.length && (inflight.value > 0 || poolMiss < 6)) { + if (!inflight.value) poolFuellen() + await new Promise((r) => setTimeout(r, 120)) + } + } finally { pruefLoading.value = false } + } + return pool.value.length ? pool.value.shift() : null +} + +// Erste Frage (Phase idle): ZIEL parallel anstoßen, schnellste zuerst zeigen. +async function frageAnfordern() { + if (pruefLoading.value) return + poolMiss = 0 + poolFuellen() + const text = await poolNehmen() + if (text) { + frageZeigen(text) + nextTick(() => pruefInputEl.value?.focus()) + } else { + pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine Frage — bitte erneut.' }) + } + poolFuellen() +} + +// „Nächste Frage": sofort aus dem Pool, dann nachfüllen. async function naechsteFrageZeigen() { if (pruefLoading.value) return - if (!naechsteFrage.value && prefetchPromise) { - pruefLoading.value = true - try { await prefetchPromise } finally { pruefLoading.value = false } - } - if (naechsteFrage.value) { - const text = naechsteFrage.value - naechsteFrage.value = null + const text = await poolNehmen() + if (text) { frageZeigen(text) pruefScroll() nextTick(() => pruefInputEl.value?.focus()) - prefetchFrage() - return - } - // Fallback: kein Cache → frisch holen (mit Dedup). - pruefLoading.value = true - try { - const text = await frageHolen() - if (text) { - frageZeigen(text) - pruefScroll() - nextTick(() => pruefInputEl.value?.focus()) - prefetchFrage() - } else { - pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine neue Frage — bitte erneut.' }) - } - } catch { - pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' }) - } finally { - pruefLoading.value = false + } else { + pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine neue Frage — bitte erneut.' }) } + poolFuellen() } function nachfragen() { diff --git a/frontend/src/pruefungCache.js b/frontend/src/pruefungCache.js index 965b072..8f63028 100644 --- a/frontend/src/pruefungCache.js +++ b/frontend/src/pruefungCache.js @@ -6,7 +6,7 @@ import { ref } from 'vue' -const store = new Map() // key "topic::baustein" -> { messages, phase, aktuelleFrage, letztesFeedback, scoreVorFrage, streakVorFrage } +const store = new Map() // key "topic::baustein" -> Refs (Verlauf + Fragen-Pool) export function usePruefSlot(key) { if (!store.has(key)) { @@ -17,6 +17,8 @@ export function usePruefSlot(key) { letztesFeedback: ref(''), scoreVorFrage: ref(0), streakVorFrage: ref(0), + pool: ref([]), // vorgenerierte, geprüfte, unbeantwortete Fragen + inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert) }) } return store.get(key)