This commit is contained in:
team3
2026-06-21 15:38:10 +02:00
parent 20831e1399
commit 1910f730d2
15 changed files with 835 additions and 172 deletions

View File

@@ -78,12 +78,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 = [],
nachgefragt = false, begruendung = '', muster = '', tier2 = false, tier3 = false, messages = [], gruendlich = false,
nachgefragt = false, begruendung = '', muster = '', cap = 10, messages = [], gruendlich = false,
auswahl = [], korrekt = [], loesung = '', alternativen = [], eingabe = '',
}) {
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, nachgefragt, begruendung, muster, tier2, tier3, messages, provider, gruendlich }),
body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, muster, cap, messages, provider, gruendlich, auswahl, korrekt, loesung, alternativen, eingabe }),
})
return jsonOrThrow(res)
}

View File

@@ -7,8 +7,7 @@ const props = defineProps({
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
status: { type: Object, default: null },
tier2: { type: Boolean, default: false },
tier3: { type: Boolean, default: false },
cap: { type: Number, default: 6 },
tab: { type: String, default: 'pruefung' },
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte
hasPrev: { type: Boolean, default: false },
@@ -102,8 +101,7 @@ const standFarbe = computed(() => {
:section-kompakt="baustein.kompakt || ''"
:provider="provider"
:status="status"
:tier2="tier2"
:tier3="tier3"
:cap="cap"
:ansicht="ansicht"
@set-ansicht="$emit('setAnsicht', $event)"
@status-changed="$emit('statusChanged', $event)"

View File

@@ -2,7 +2,7 @@
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { chatBaustein, pruefeBaustein, fetchFrageMuster } from '../api.js'
import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown } from '../markdown.js'
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
import { useChat, istUnten } from '../composables/useChat.js'
const props = defineProps({
@@ -12,8 +12,7 @@ const props = defineProps({
sectionKompakt: { type: String, default: '' }, // kompakte Fassung (Merksätze) — Prüfungs-/Chat-Kontext
provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, // {gute_antworten, streak, absolviert, verstanden, gemeistert}
tier2: { type: Boolean, default: false }, // Tier 2 frei (ganzer Guide absolviert)
tier3: { type: Boolean, default: false }, // Meisterpfad frei (ganzer Guide verstanden)
cap: { type: Number, default: 6 }, // Score-Deckel = Max des höchsten Formats (6/12/18/30)
// 'trigger' (Liste): nur Tab-Leiste, Klick öffnet den Vollbild-Fokus.
// 'full' (Fokus): Tabs + Panel-Inhalt wie gehabt.
mode: { type: String, default: 'trigger' },
@@ -23,10 +22,52 @@ const props = defineProps({
const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
const NOETIG = 3 // absolviert
const MAX = 10 // verstanden
const MEISTERN = 25 // gemeistert (Maximum)
// Meilensteine (Score-Bänder): Quiz→6, Lückentext→12, Erklären→18, FullGuide-Max 30.
const ABSOLVIERT = 6
const VERSTANDEN = 12
const GEMEISTERT = 18
const VOLL = 30
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, absolviert: false, verstanden: false, gemeistert: false })
// Aktiver Modus aus Score-Band (unter 30 fix) + Schwierigkeit (ALT+E). Ab 30 frei.
// Ohne Frage-Muster (alter Baustein) gibt es nur Erklären — Quiz/Lückentext brauchen die Saat.
const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren']
const score = computed(() => st.value.gute_antworten || 0)
const schwerWahl = ref(false) // leicht/schwer unter Score 30 (ALT+E)
const freiModus = ref('quiz_leicht') // gewählter Modus ab Score 30 (ALT+E zyklisch)
const aktiverModus = computed(() => {
if (!musterModus.value) return 'erklaeren'
if (score.value >= GEMEISTERT) return freiModus.value
if (score.value >= VERSTANDEN) return 'erklaeren'
if (score.value >= ABSOLVIERT) return schwerWahl.value ? 'lueck_schwer' : 'lueck_leicht'
return schwerWahl.value ? 'quiz_schwer' : 'quiz_leicht'
})
const aktiveForm = computed(() => aktiverModus.value.startsWith('quiz') ? 'quiz' : aktiverModus.value.startsWith('lueck') ? 'lueckentext' : 'erklaeren')
const schwer = computed(() => aktiverModus.value.endsWith('_schwer'))
// Angezeigte Form: folgt der aktiven Form, friert aber ein, solange eine gezogene Frage
// sichtbar ist (sonst verschwindet das Ergebnis, wenn die Antwort eine Bandgrenze überschreitet).
const anzeigeForm = ref(null)
const zeigeForm = computed(() => anzeigeForm.value || aktiveForm.value)
// Schwierigkeit des gerade gezeigten Widgets (eingefroren) bzw. der aktiven Wahl.
const aktSchwer = computed(() => {
if (zeigeForm.value === 'quiz' && quizAktuell.value) return quizAktuell.value.schwer
if (zeigeForm.value === 'lueckentext' && lueckAktuell.value) return lueckAktuell.value.schwer
return schwer.value
})
const modusRegel = computed(() => {
if (zeigeForm.value === 'quiz') return aktSchwer.value ? 'x von 4 · +3/1' : '1 von 4 · +1/1'
if (zeigeForm.value === 'lueckentext') return aktSchwer.value ? 'frei tippen · +3/1' : 'Begriff aus 4 · +1/1'
return '+1/+2/+3 · Nachfragen max +1 · schwach 1'
})
// Ende des aktuellen Bands (nächster Meilenstein) und ob der Score am Format-Deckel klebt.
const bandZiel = computed(() => {
if (score.value >= GEMEISTERT) return VOLL
if (score.value >= VERSTANDEN) return GEMEISTERT
if (score.value >= ABSOLVIERT) return VERSTANDEN
return ABSOLVIERT
})
const amDeckel = computed(() => score.value >= props.cap && props.cap < VOLL)
const FORM_NAME = { quiz: 'Quiz', lueckentext: 'Lückentext', erklaeren: 'Erklären' }
const NAECHSTES_FORMAT = { 6: 'Guide', 12: 'ProGuide', 18: 'FullGuide' }
// --- Toggle-Bereich ---
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'pruefung'
@@ -56,6 +97,9 @@ const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion üb
const musterQuelle = slot.musterQuelle // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
const musterPool = slot.musterPool // Arbeitskopie, ohne Zurücklegen; leer → Reset
const musterGeladen = slot.musterGeladen
const poolForm = slot.poolForm // Form, für die der Pool gefüllt ist
const quizAktuell = slot.quizAktuell // laufende Quiz-Frage (Widget-State)
const lueckAktuell = slot.lueckAktuell // laufende Lückentext-Aufgabe (Widget-State)
// Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner.
const pruefInput = ref('')
const pruefLoading = ref(false)
@@ -127,25 +171,35 @@ function gestellteFragen() {
return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content)))
}
// --- Fragen-Pool: ZIEL vorformulierte, unbeantwortete Fragen je Baustein (im Slot) ---
// --- Fragen-Pool: ZIEL vorformulierte Frage-OBJEKTE der aktiven Form (im Slot) ---
// Jedes Objekt: {form, frage, optionen?|satz?|loesung?|alternativen?}. Wechselt die
// aktive Form, wird der Pool geleert und für die neue Form neu gefüllt.
const POOL_ZIEL = 5
const pool = slot.pool
const inflight = slot.inflight
const pruefFehler = ref('') // Fehlerhinweis für Quiz/Lückentext (kein Bubble-Verlauf)
let poolMiss = 0 // nur Fallback: aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife
// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback auf Live-Generierung.
// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback (nur Erklären).
const musterModus = computed(() => musterGeladen.value && musterQuelle.value.length > 0)
function istBekannt(text) {
const n = normFrage(text)
return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(f) === n)
function objText(o) { return (o && (o.frage || o.satz)) || '' }
// Lückensatz rendern: an „___" splitten, Teile inline (mit $…$-Mathe), Lücke als Span.
// So bleibt die Lücke erhalten und Markdown fasst die Unterstriche nicht an.
function lueckSatzHtml(satz) {
return String(satz || '').split('___').map((t) => renderMarkdownInline(t)).join('<span class="bp-luecke">______</span>')
}
// Schon gestellte + bereits vorgemerkte Fragen — der Generator soll sie (auch
// sinngemäß) meiden. Nur Fallback-Modus. Original-Texte, nicht normalisiert.
function istBekannt(o) {
const n = normFrage(objText(o))
return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(objText(f)) === n)
}
// Schon gestellte + vorgemerkte Fragen — nur Fallback-Modus (Erklären ohne Muster).
function vermeideListe() {
const gestellt = pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => m.content)
return [...gestellt, ...pool.value]
return [...gestellt, ...pool.value.map(objText)]
}
// Muster-Sidecar einmal je Baustein laden (Slot-cached). Leer → Fallback.
@@ -176,31 +230,55 @@ function musterNehmen() {
return musterPool.value.shift()
}
// Pool-Schlüssel = Form + Schwierigkeit (Quiz leicht/schwer ziehen verschiedene Fragen).
const poolKey = computed(() => `${aktiveForm.value}/${schwer.value}`)
// Ein Frage-Objekt des aktiven Modus generieren (aus gezogener Saat; Erklären hat Fallback).
function frageEinzeln() {
const form = aktiveForm.value
const istSchwer = schwer.value
const muster = musterNehmen()
if (muster) {
// Muster-Modus: aus gezogener Saat eine konkrete, leicht andere Frage formulieren.
return pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'frage', muster,
}).then((res) => res.frage)
const basis = {
topic: props.topic, baustein: props.baustein, section: props.section,
section_kompakt: props.sectionKompakt, provider: props.provider,
}
// Fallback (kein Muster-Sidecar): Live-Generierung mit Vermeide-Liste.
return pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe(),
}).then((res) => res.frage)
if (form === 'quiz' && muster) {
return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster, schwer: istSchwer })
.then((r) => ({ form, schwer: istSchwer, frage: r.frage, optionen: r.optionen }))
}
if (form === 'lueckentext' && muster) {
return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istSchwer })
.then((r) => istSchwer
? ({ form, schwer: true, frage: r.satz, satz: r.satz, loesung: r.loesung, alternativen: r.alternativen })
: ({ form, schwer: false, frage: r.satz, satz: r.satz, optionen: r.optionen }))
}
if (muster) { // erklaeren aus Muster
return pruefeBaustein({ ...basis, aktion: 'frage', muster }).then((r) => ({ form: 'erklaeren', frage: r.frage }))
}
// Fallback (kein Muster-Sidecar): Erklären live mit Vermeide-Liste.
return pruefeBaustein({ ...basis, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe() })
.then((r) => ({ form: 'erklaeren', frage: r.frage }))
}
// Pool PARALLEL auffüllen: pro fehlender Frage ein eigener Agent. Im Muster-Modus
// zieht jeder ein DISTINKTES Muster (ohne Zurücklegen) → keine Kollision, kein Miss-Cap.
// Pool leeren, wenn Form oder Schwierigkeit gewechselt hat (Bandgrenze oder ALT+E).
function poolFormPruefen() {
if (poolForm.value !== poolKey.value) {
pool.value = []
poolForm.value = poolKey.value
poolMiss = 0
}
}
// Pool PARALLEL auffüllen: jeder zieht ein DISTINKTES Muster → keine Kollision.
function poolFuellen() {
poolFormPruefen()
const keyBeginn = poolKey.value
const cap = musterModus.value ? Infinity : 3
while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < cap) {
inflight.value++
frageEinzeln()
.then((text) => {
if (text && !istBekannt(text)) { pool.value.push(text); poolMiss = 0 }
.then((obj) => {
if (obj && objText(obj) && poolKey.value === keyBeginn && (musterModus.value || !istBekannt(obj))) { pool.value.push(obj); poolMiss = 0 }
else poolMiss++
})
.catch(() => { poolMiss++ })
@@ -208,7 +286,7 @@ function poolFuellen() {
}
}
// Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten.
// Ein Frage-Objekt aus dem Pool nehmen; ist er leer, auf das erste eintreffende warten.
async function poolNehmen() {
if (!pool.value.length) {
const cap = musterModus.value ? Infinity : 3
@@ -223,35 +301,100 @@ async function poolNehmen() {
return pool.value.length ? pool.value.shift() : null
}
// Erste Frage (Phase idle): Muster laden, ZIEL parallel anstoßen, schnellste zuerst zeigen.
async function frageAnfordern() {
// Ein gezogenes Objekt der passenden Form anzeigen (Erklären-Bubble bzw. Quiz/Lück-Widget).
function frageObjZeigen(obj) {
pruefFehler.value = ''
anzeigeForm.value = obj.form // Anzeige an die gezogene Frage binden (Form einfrieren)
if (obj.form === 'quiz') {
quizAktuell.value = { frage: obj.frage, optionen: obj.optionen, schwer: obj.schwer, gewaehlt: [], fertig: false, punkte: null, bewertung: null, feedback: '' }
} else if (obj.form === 'lueckentext') {
lueckAktuell.value = { satz: obj.satz, schwer: obj.schwer, optionen: obj.optionen || null, loesung: obj.loesung || '', alternativen: obj.alternativen || [], eingabe: '', gewaehlt: [], fertig: false, punkte: null, bewertung: null, feedback: '' }
if (obj.schwer) nextTick(() => document.getElementById('bp-lueck-input')?.focus())
} else {
frageZeigen(obj.frage)
nextTick(() => pruefInputEl.value?.focus())
}
}
// Erste / nächste Frage: Muster laden, Pool füllen, schnellste zuerst zeigen.
async function naechsteFrageZeigen() {
if (pruefLoading.value) return
poolMiss = 0
pruefFehler.value = ''
await musterLaden()
poolFuellen()
const text = await poolNehmen()
if (text) {
frageZeigen(text)
nextTick(() => pruefInputEl.value?.focus())
} else {
const obj = await poolNehmen()
if (obj) {
frageObjZeigen(obj)
pruefScroll()
} else if (aktiveForm.value === 'erklaeren') {
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine Frage — bitte erneut.' })
} else {
pruefFehler.value = 'Keine Frage — bitte erneut.'
}
poolFuellen()
}
const frageAnfordern = naechsteFrageZeigen
// „Nächste Frage": sofort aus dem Pool, dann nachfüllen.
async function naechsteFrageZeigen() {
if (pruefLoading.value) return
await musterLaden()
const text = await poolNehmen()
if (text) {
frageZeigen(text)
pruefScroll()
nextTick(() => pruefInputEl.value?.focus())
} else {
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine neue Frage — bitte erneut.' })
// --- Quiz: leicht = Single-Select (genau 1) · schwer = Multi-Select. Deterministisch. ---
function quizToggle(i) {
const q = quizAktuell.value
if (!q || q.fertig) return
if (!q.schwer) { q.gewaehlt = q.gewaehlt.includes(i) ? [] : [i]; return }
const idx = q.gewaehlt.indexOf(i)
if (idx >= 0) q.gewaehlt.splice(idx, 1)
else q.gewaehlt.push(i)
}
async function quizAntworten() {
const q = quizAktuell.value
if (!q || q.fertig || pruefLoading.value) return
pruefLoading.value = true
pruefFehler.value = ''
try {
const korrekt = q.optionen.map((o, i) => (o.korrekt ? i : -1)).filter((i) => i >= 0)
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'quiz_antwort', frage: q.frage, cap: props.cap,
auswahl: q.gewaehlt, korrekt, schwer: q.schwer,
})
q.fertig = true; q.punkte = res.punkte; q.bewertung = res.bewertung; q.feedback = res.feedback
applyPruefung(res)
} catch {
pruefFehler.value = 'Hat nicht geklappt — bitte erneut.'
} finally {
pruefLoading.value = false
}
}
// --- Lückentext: leicht = Begriff aus 4 (Single-Select) · schwer = frei tippen ---
function lueckToggle(i) {
const l = lueckAktuell.value
if (!l || l.fertig || l.schwer) return
l.gewaehlt = l.gewaehlt.includes(i) ? [] : [i]
}
async function lueckAntworten() {
const l = lueckAktuell.value
if (!l || l.fertig || pruefLoading.value) return
if (l.schwer ? !l.eingabe.trim() : !l.gewaehlt.length) return
pruefLoading.value = true
pruefFehler.value = ''
try {
const spez = l.schwer
? { schwer: true, loesung: l.loesung, alternativen: l.alternativen, eingabe: l.eingabe }
: { schwer: false, auswahl: l.gewaehlt, korrekt: l.optionen.map((o, i) => (o.korrekt ? i : -1)).filter((i) => i >= 0) }
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'lueck_antwort', frage: l.satz, cap: props.cap, ...spez,
})
l.fertig = true; l.punkte = res.punkte; l.bewertung = res.bewertung; l.feedback = res.feedback
applyPruefung(res)
} catch {
pruefFehler.value = 'Hat nicht geklappt — bitte erneut.'
} finally {
pruefLoading.value = false
}
poolFuellen()
}
function nachfragen() {
@@ -270,7 +413,7 @@ let letzteFeedbackMsg = null // zuletzt gezeigte Bewertungs-Bubble
let pruefenRun = 0 // nur die jüngste Schnell-Bewertung darf anzeigen
function bewertungPayload() {
return { frage: aktuelleFrage.value, tier2: props.tier2, tier3: props.tier3, nachgefragt: nachgefragt.value }
return { frage: aktuelleFrage.value, cap: props.cap, nachgefragt: nachgefragt.value }
}
// Agent 1 (schnell): sofortiges Niveau + Punkte zeigen, dann Agent 2 (genau) im Hintergrund.
@@ -378,30 +521,81 @@ 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)
musterLaden() // GET (kein Agent) — setzt die aktive Form früh korrekt (Quiz/Lückentext/Erklären)
})
onUnmounted(() => {
window.removeEventListener('keydown', onWindowKey)
window.removeEventListener('keydown', onPruefKey)
})
// ALT+1 Nachfragen (nur frage_offen) · ALT+2 primärer Button je Phase (idle: Frage anfordern,
// frage_offen: Antwort abgeben, bewertet: Nächste Frage) · ALT+3 Verlauf bewerten (frage_offen).
// Gründlich prüfen läuft per Rechtsklick auf die Bewertung, nicht über ALT.
function onPruefKey(e) {
if (!e.altKey || e.repeat) return
if (props.mode !== 'full' || activeTab.value !== 'pruefung') return
if (!['1', '2', '3'].includes(e.key)) return
e.preventDefault()
if (pruefLoading.value) return
if (e.key === '1') {
if (pruefPhase.value === 'frage_offen' && pruefInput.value.trim()) nachfragen()
} else if (e.key === '2') {
if (pruefPhase.value === 'idle') frageAnfordern()
else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() }
else naechsteFrageZeigen()
} else if (e.key === '3') {
if (pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten()
// ALT+E: unter 30 leicht↔schwer der Form (Quiz/Lückentext); ab 30 zyklisch durch alle 5 Modi.
function variantWechseln() {
const vorher = aktiverModus.value
if (score.value >= GEMEISTERT) {
freiModus.value = MODI[(MODI.indexOf(freiModus.value) + 1) % MODI.length]
} else if (aktiveForm.value === 'quiz' || aktiveForm.value === 'lueckentext') {
schwerWahl.value = !schwerWahl.value
}
if (aktiverModus.value === vorher) return // nichts geändert (z.B. Erklären-Band)
// Einfrieren + altes Widget verwerfen → frische Frage der neuen Variante anfordern.
anzeigeForm.value = null
quizAktuell.value = null
lueckAktuell.value = null
}
// Quiz / Lückentext-Auswahl ist ein Wahl-Widget mit 14-Tasten; sonst Textfeld/Bubble.
const wahlWidget = computed(() => {
if (zeigeForm.value === 'quiz') return quizAktuell.value
if (zeigeForm.value === 'lueckentext' && lueckAktuell.value && !lueckAktuell.value.schwer) return lueckAktuell.value
return null
})
// Eingabefeld der aktuellen Form (Lückentext frei / Erklären) — für „Alt fokussiert".
function eingabeFeld() {
if (zeigeForm.value === 'lueckentext' && lueckAktuell.value && lueckAktuell.value.schwer && !lueckAktuell.value.fertig) {
return document.getElementById('bp-lueck-input')
}
if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen') return pruefInputEl.value
return null
}
// Primäre Aktion je Form: keine Frage → anfordern · offen → antworten · fertig → nächste.
function primaerAktion() {
if (pruefLoading.value) return
const f = zeigeForm.value
if (f === 'quiz') { if (!quizAktuell.value) frageAnfordern(); else if (!quizAktuell.value.fertig) quizAntworten(); else naechsteFrageZeigen(); return }
if (f === 'lueckentext') { if (!lueckAktuell.value) frageAnfordern(); else if (!lueckAktuell.value.fertig) lueckAntworten(); else naechsteFrageZeigen(); return }
if (pruefPhase.value === 'idle') frageAnfordern()
else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() }
else naechsteFrageZeigen()
}
// 14 (nackt): Option im Wahl-Widget toggeln. Alt allein: ins Eingabefeld springen.
// Alt+2 primär (antworten/nächste) · Alt+Q Ansicht · Alt+E Variante · Alt+1/3 Erklären.
function onPruefKey(e) {
if (e.repeat) return
if (props.mode !== 'full' || activeTab.value !== 'pruefung') return
// Alt allein → ins Eingabefeld (Lückentext frei / Erklären), wie bei Frage-Antwort.
if (e.key === 'Alt') {
const el = eingabeFeld()
if (el) { e.preventDefault(); el.focus() }
return
}
if (!e.altKey) {
// Nackte 14: Wahl-Widget toggeln — aber nicht, während ein Textfeld den Fokus hat.
if (['1', '2', '3', '4'].includes(e.key) && wahlWidget.value && !wahlWidget.value.fertig) {
const ae = document.activeElement
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return
e.preventDefault()
const i = Number(e.key) - 1
if (i < wahlWidget.value.optionen.length) (zeigeForm.value === 'quiz' ? quizToggle : lueckToggle)(i)
}
return
}
if (e.code === 'KeyQ') { e.preventDefault(); emit('setAnsicht', props.ansicht === 'kompakt' ? 'erklärend' : 'kompakt'); return }
if (e.code === 'KeyE') { e.preventDefault(); variantWechseln(); return }
if (e.key === '2') { e.preventDefault(); primaerAktion(); return }
// Erklären-Sondertasten
if (e.key === '1') { e.preventDefault(); if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen' && pruefInput.value.trim()) nachfragen(); return }
if (e.key === '3') { e.preventDefault(); if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten() }
}
</script>
@@ -418,12 +612,10 @@ function onPruefKey(e) {
</button>
<button :class="{ active: activeTab === 'pruefung' }" @click="tabKlick('pruefung')">
Prüfung
<span v-if="st.gemeistert" class="bp-chip gold" title="Gemeistert (25/25)"> Max</span>
<span v-else-if="st.verstanden && tier3" class="bp-chip lila" title="Meisterpfad"> {{ st.gute_antworten }}/{{ MEISTERN }}</span>
<span v-else-if="st.verstanden" class="bp-chip lila" title="Verstanden (10/10)"></span>
<span v-else-if="st.absolviert && tier2" class="bp-chip done"> {{ st.gute_antworten }}/{{ MAX }}</span>
<span v-else-if="st.absolviert" class="bp-chip done"></span>
<span v-else-if="st.gute_antworten" class="bp-chip">{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }}</span>
<span v-if="st.gemeistert" class="bp-chip gold" :title="`Gemeistert (${GEMEISTERT})`"> {{ score }}/{{ cap }}</span>
<span v-else-if="st.verstanden" class="bp-chip lila" :title="`Verstanden (${VERSTANDEN})`"> {{ score }}/{{ cap }}</span>
<span v-else-if="st.absolviert" class="bp-chip done" :title="`Absolviert (${ABSOLVIERT})`"> {{ score }}/{{ cap }}</span>
<span v-else-if="score" class="bp-chip">{{ score }}/{{ cap }}</span>
</button>
</div>
@@ -455,58 +647,135 @@ function onPruefKey(e) {
<!-- Prüfung: gesteuerter Dialog -->
<div v-else>
<p class="bp-hint">
<template v-if="st.gemeistert"> Gemeistert ({{ MEISTERN }}/{{ MEISTERN }}) Max. Du kannst dich weiter prüfen, ohne Punkte.</template>
<template v-else-if="st.verstanden && tier3">Meisterpfad: {{ st.gute_antworten }}/{{ MEISTERN }}. Je besser, desto mehr (+1/+2/+3), schwach = 1 (nicht unter {{ MAX }}). Bei {{ MEISTERN }} gemeistert.</template>
<template v-else-if="st.verstanden"> Verstanden. Der Meisterpfad ({{ MAX }}{{ MEISTERN }}) öffnet, sobald der ganze Guide verstanden ist.</template>
<template v-else-if="st.absolviert && tier2">Mastery: {{ st.gute_antworten }}/{{ MAX }}. Je besser, desto mehr (+1/+2/+3), schwach = 1 (nicht unter {{ NOETIG }}). Bei {{ MAX }} verstanden.</template>
<template v-else-if="st.absolviert"> Absolviert. Mehr ({{ NOETIG }}{{ MAX }}) gibt's, sobald der ganze Guide absolviert ist.</template>
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Je besser deine Antwort, desto mehr Punkte (bis +3). Nachfragen ist erlaubt dann max +1.</template>
<template v-if="amDeckel">{{ FORM_NAME[zeigeForm] }} · {{ score }}/{{ cap }} Max für dieses Format. Erstelle den {{ NAECHSTES_FORMAT[cap] }}, um weiterzukommen.</template>
<template v-else>{{ FORM_NAME[zeigeForm] }} · {{ score }}/{{ score >= GEMEISTERT ? VOLL : bandZiel }} · {{ modusRegel }} · <span class="bp-hint-key">Alt+E: {{ score >= GEMEISTERT ? 'Modus' : 'Schwierigkeit' }}</span></template>
</p>
<div v-if="pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll">
<template v-for="(m, i) in pruefMessages" :key="i">
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung" title="Klick: gründlich prüfen" @click="gruendlichOeffnen(m)">
<span v-if="m.punkte != null" class="bp-niveau">{{ punkteLabel(m.punkte) }}</span>{{ m.content }}<span v-if="!m.geprueft" class="bp-pruefend"> · wird geprüft</span>
<div v-if="gruendlichMsg === m" class="bp-gruendlich" @click.stop>
<input id="bp-gruendlich-input" v-model="gruendlichText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="gruendlichAbsenden" />
<button class="bp-action primary" @click="gruendlichAbsenden">Gründlich prüfen</button>
<button class="bp-action" @click="gruendlichMsg = null">×</button>
</div>
</div>
<div v-else-if="m.kind === 'fehler'" class="bp-error">{{ m.content }}</div>
<div v-else-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="pruefLoading" class="bp-msg assistant bp-typing"></div>
</div>
<!-- Am Format-Deckel: kein Widget, nur der Hinweis oben. -->
<template v-if="amDeckel"></template>
<!-- Phase idle: Frage anfordern -->
<div v-if="pruefPhase === 'idle'" class="bp-actions">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
</div>
<!-- Phase frage_offen: Textfeld + Aktionen · bewertet: nur Nächste Frage" -->
<template v-else>
<div v-if="pruefPhase === 'frage_offen'" class="bp-input">
<textarea
ref="pruefInputEl"
v-model="pruefInput"
rows="2"
placeholder="Antwort — oder Nachfrage bei Unklarheit…"
></textarea>
</div>
<div class="bp-actions">
<!-- Quiz: Frage + Mehrfachauswahl -->
<template v-else-if="zeigeForm === 'quiz'">
<div v-if="!quizAktuell" class="bp-actions">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<template v-else-if="pruefPhase === 'frage_offen'">
<button class="bp-action" title="Alt+1" :disabled="!pruefInput.trim()" @click="nachfragen"><span class="bp-kbd">1</span>Nachfragen</button>
<button class="bp-action primary" title="Alt+2" :disabled="!pruefInput.trim()" @click="antwortAbgeben"><span class="bp-kbd">2</span>Antwort abgeben</button>
<button v-if="hatDiskussion" class="bp-action" title="Alt+3" @click="verlaufBewerten"><span class="bp-kbd">3</span>Verlauf bewerten</button>
</template>
<template v-else>
<button class="bp-action primary" title="Alt+2" @click="naechsteFrageZeigen"><span class="bp-kbd">2</span>Nächste Frage</button>
</template>
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
</div>
<div v-else class="bp-quiz">
<p class="bp-quiz-frage markdown" v-html="renderMarkdownInline(quizAktuell.frage)"></p>
<div class="bp-quiz-grid">
<button
v-for="(o, i) in quizAktuell.optionen" :key="i"
type="button"
class="bp-quiz-opt"
:class="{ gewaehlt: quizAktuell.gewaehlt.includes(i), richtig: quizAktuell.fertig && o.korrekt, falsch: quizAktuell.fertig && !o.korrekt && quizAktuell.gewaehlt.includes(i) }"
:disabled="quizAktuell.fertig"
:title="`${i + 1}`"
@click="quizToggle(i)"
>
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
</button>
</div>
<p v-if="pruefFehler" class="bp-error">{{ pruefFehler }}</p>
<div class="bp-actions">
<button v-if="!quizAktuell.fertig" class="bp-action primary" title="Alt+2" :disabled="pruefLoading" @click="quizAntworten">Antworten</button>
<template v-else>
<span class="bp-niveau" :class="quizAktuell.bewertung">{{ punkteLabel(quizAktuell.punkte) }}</span>
<span class="bp-form-feedback">{{ quizAktuell.feedback }}</span>
<button class="bp-action primary" @click="naechsteFrageZeigen">Nächste</button>
</template>
</div>
</div>
</template>
<!-- Lückentext: Satz mit Lücke + Eingabe -->
<template v-else-if="zeigeForm === 'lueckentext'">
<div v-if="!lueckAktuell" class="bp-actions">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
</div>
<div v-else class="bp-lueck">
<p class="bp-lueck-satz markdown" v-html="lueckSatzHtml(lueckAktuell.satz)"></p>
<!-- schwer: frei tippen -->
<input
v-if="lueckAktuell.schwer"
id="bp-lueck-input"
v-model="lueckAktuell.eingabe"
:disabled="lueckAktuell.fertig"
placeholder="Begriff für die Lücke…"
@keyup.enter="lueckAntworten"
/>
<p v-if="lueckAktuell.schwer && lueckAktuell.fertig && !lueckAktuell.feedback.startsWith('Richtig')" class="bp-lueck-loesung">Lösung: <span class="markdown" v-html="renderMarkdownInline(lueckAktuell.loesung)"></span></p>
<!-- leicht: Begriff aus 4 wählen -->
<div v-if="!lueckAktuell.schwer" class="bp-quiz-grid">
<button
v-for="(o, i) in lueckAktuell.optionen" :key="i"
type="button"
class="bp-quiz-opt"
:class="{ gewaehlt: lueckAktuell.gewaehlt.includes(i), richtig: lueckAktuell.fertig && o.korrekt, falsch: lueckAktuell.fertig && !o.korrekt && lueckAktuell.gewaehlt.includes(i) }"
:disabled="lueckAktuell.fertig"
:title="`${i + 1}`"
@click="lueckToggle(i)"
>
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
</button>
</div>
<p v-if="pruefFehler" class="bp-error">{{ pruefFehler }}</p>
<div class="bp-actions">
<button v-if="!lueckAktuell.fertig" class="bp-action primary" title="Alt+2" :disabled="pruefLoading || (lueckAktuell.schwer ? !lueckAktuell.eingabe.trim() : !lueckAktuell.gewaehlt.length)" @click="lueckAntworten">Antworten</button>
<template v-else>
<span class="bp-niveau" :class="lueckAktuell.bewertung">{{ punkteLabel(lueckAktuell.punkte) }}</span>
<span class="bp-form-feedback">{{ lueckAktuell.feedback }}</span>
<button class="bp-action primary" @click="naechsteFrageZeigen">Nächste</button>
</template>
</div>
</div>
</template>
<!-- Erklären: gesteuerter Dialog (bestehend) -->
<template v-else>
<div v-if="pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll">
<template v-for="(m, i) in pruefMessages" :key="i">
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung" title="Klick: gründlich prüfen" @click="gruendlichOeffnen(m)">
<span v-if="m.punkte != null" class="bp-niveau">{{ punkteLabel(m.punkte) }}</span>{{ m.content }}<span v-if="!m.geprueft" class="bp-pruefend"> · wird geprüft</span>
<div v-if="gruendlichMsg === m" class="bp-gruendlich" @click.stop>
<input id="bp-gruendlich-input" v-model="gruendlichText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="gruendlichAbsenden" />
<button class="bp-action primary" @click="gruendlichAbsenden">Gründlich prüfen</button>
<button class="bp-action" @click="gruendlichMsg = null">×</button>
</div>
</div>
<div v-else-if="m.kind === 'fehler'" class="bp-error">{{ m.content }}</div>
<div v-else-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="pruefLoading" class="bp-msg assistant bp-typing"></div>
</div>
<div v-if="pruefPhase === 'idle'" class="bp-actions">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
</div>
<template v-else>
<div v-if="pruefPhase === 'frage_offen'" class="bp-input">
<textarea
ref="pruefInputEl"
v-model="pruefInput"
rows="2"
placeholder="Antwort — oder Nachfrage bei Unklarheit…"
></textarea>
</div>
<div class="bp-actions">
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
<template v-else-if="pruefPhase === 'frage_offen'">
<button class="bp-action" title="Alt+1" :disabled="!pruefInput.trim()" @click="nachfragen"><span class="bp-kbd">1</span>Nachfragen</button>
<button class="bp-action primary" title="Alt+2" :disabled="!pruefInput.trim()" @click="antwortAbgeben"><span class="bp-kbd">2</span>Antwort abgeben</button>
<button v-if="hatDiskussion" class="bp-action" title="Alt+3" @click="verlaufBewerten"><span class="bp-kbd">3</span>Verlauf bewerten</button>
</template>
<template v-else>
<button class="bp-action primary" title="Alt+2" @click="naechsteFrageZeigen"><span class="bp-kbd">2</span>Nächste Frage</button>
</template>
</div>
</template>
</template>
</div>
</div>
@@ -566,6 +835,7 @@ function onPruefKey(e) {
}
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
.bp-hint-key { font-size: 0.72rem; opacity: 0.7; white-space: nowrap; }
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
.bp-action {
@@ -624,6 +894,35 @@ function onPruefKey(e) {
.bp-feedback.neutral { background: var(--warning-soft); border-color: var(--warning-border); color: var(--warning); }
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
.bp-niveau { font-weight: 700; text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.03em; margin-right: 0.4rem; opacity: 0.85; }
.bp-niveau.gut { color: var(--success); }
.bp-niveau.neutral { color: var(--warning); }
.bp-niveau.schlecht { color: var(--danger); }
/* Quiz: Frage + Mehrfachauswahl */
.bp-quiz, .bp-lueck { margin-top: 0.6rem; }
.bp-quiz-frage, .bp-lueck-satz { font-size: 0.9rem; font-weight: 600; margin: 0 0 0.5rem; line-height: 1.5; }
.bp-luecke { font-weight: 700; letter-spacing: 1px; color: var(--accent); padding: 0 0.15rem; }
.bp-quiz-opt .markdown, .bp-quiz-frage.markdown, .bp-lueck-satz.markdown { display: inline; }
.bp-quiz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem; }
.bp-quiz-opt {
display: flex; gap: 0.45rem; align-items: flex-start; text-align: left;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border); border-radius: 8px;
background: var(--panel); color: var(--text); cursor: pointer; font-size: 0.85rem;
}
.bp-quiz-opt:disabled { cursor: default; }
.bp-quiz-opt.gewaehlt { border-color: var(--accent); background: var(--accent-soft, rgba(99, 102, 241, 0.12)); }
.bp-quiz-opt.richtig { background: var(--success-soft); border-color: var(--success-border); }
.bp-quiz-opt.falsch { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); }
.bp-form-feedback { font-size: 0.82rem; color: var(--text-muted); flex: 1; }
/* Lückentext: Satz + Eingabe */
.bp-lueck input {
width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; font: inherit; font-size: 0.88rem;
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text);
}
.bp-lueck input:disabled { opacity: 0.7; }
.bp-lueck-loesung { font-size: 0.82rem; color: var(--success); margin: 0.35rem 0 0; }
/* Gründlich-prüfen-Feld (per Rechtsklick) innerhalb der Bewertungs-Bubble */
.bp-gruendlich { display: flex; gap: 0.3rem; margin-top: 0.4rem; align-items: center; }
.bp-gruendlich input {

View File

@@ -87,9 +87,14 @@ const fortschritt = computed(() => {
return { total: bausteine.value.length, absolviert: a, verstanden: v, gemeistert: g }
})
// Cap pro Baustein folgt den erstellten Formaten: Guide vorhanden → bis 10, FullGuide → bis 25.
const tier2 = computed(() => !!props.doneByFormat?.Guide)
const tier3 = computed(() => !!props.doneByFormat?.FullGuide)
// Score-Deckel folgt dem höchsten erstellten Format: Mini→6, Guide→12, ProGuide→18, FullGuide→30.
const cap = computed(() => {
const d = props.doneByFormat || {}
if (d.FullGuide) return 30
if (d.ProGuide) return 18
if (d.Guide) return 12
return 6
})
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
const chat = useChat((msgs) => {
@@ -205,8 +210,7 @@ function extractContext() {
mode="trigger"
:baustein="s.title"
:status="lernstand[s.title]"
:tier2="tier2"
:tier3="tier3"
:cap="cap"
:topic="previewGuide.topic"
@open-fokus="(tab) => openFokus(s, tab)"
/>
@@ -231,8 +235,7 @@ function extractContext() {
:provider="provider"
:fortschritt="fortschritt"
:status="lernstand[fokusBaustein.title]"
:tier2="tier2"
:tier3="tier3"
:cap="cap"
:tab="fokusTab"
:ansicht="ansichtModus"
:has-prev="fokusIndex > 0"

View File

@@ -65,3 +65,9 @@ marked.use({
export function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text || ''))
}
// Inline-Variante (kein <p>-Wrapping) für kurze Texte wie Quiz-Optionen oder
// Lückentext-Satzteile — rendert $…$-Mathe und Markdown ohne Block-Umbruch.
export function renderMarkdownInline(text) {
return DOMPurify.sanitize(marked.parseInline(text || ''))
}

View File

@@ -15,11 +15,14 @@ export function usePruefSlot(key) {
phase: ref('idle'),
aktuelleFrage: ref(''),
letztesFeedback: ref(''),
pool: ref([]), // vorformulierte, unbeantwortete Fragen
pool: ref([]), // vorformulierte Frage-Objekte der AKTUELLEN Form ({form, ...})
inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert)
poolForm: ref(''), // Form, für die der Pool gefüllt ist — bei Wechsel leeren
musterQuelle: ref([]), // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
musterPool: ref([]), // Arbeitskopie: gezogene Saat ohne Zurücklegen; leer → Reset
musterGeladen: ref(false), // Muster-Sidecar schon abgerufen?
quizAktuell: ref(null), // laufende Quiz-Frage {frage, optionen, gewaehlt, fertig, ...}
lueckAktuell: ref(null), // laufende Lückentext-Aufgabe {satz, loesung, ..., fertig}
})
}
return store.get(key)