This commit is contained in:
team3
2026-06-14 14:02:27 +02:00
parent 822f6ee3e9
commit 2b89e21cd3
18 changed files with 378 additions and 119 deletions

View File

@@ -91,11 +91,14 @@ export async function chatBaustein({ topic, baustein, section, messages, provide
return jsonOrThrow(res)
}
export async function pruefeBaustein({ topic, baustein, section, messages, provider }) {
export async function pruefeBaustein({
topic, baustein, section, provider,
aktion = 'frage', frage = '', letzte_bewertung = '', frage_schon_gut = false, messages = [],
}) {
const res = await fetch(`${BASE}/bausteine/pruefung`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, messages, provider }),
body: JSON.stringify({ topic, baustein, section, aktion, frage, letzte_bewertung, frage_schon_gut, messages, provider }),
})
return jsonOrThrow(res)
}

View File

@@ -7,6 +7,13 @@
margin: 0 0 0.5em;
}
/* KaTeX: lange Block-Formeln scrollen statt das Layout zu sprengen */
.markdown .katex-display {
overflow-x: auto;
overflow-y: hidden;
padding: 0.2em 0;
}
.markdown p:last-child {
margin-bottom: 0;
}

View File

@@ -23,7 +23,6 @@ const activeTab = ref(null) // null | 'vertiefung' | 'deepdive' | 'chat' | 'prue
function toggle(tab) {
activeTab.value = activeTab.value === tab ? null : tab
if (activeTab.value === 'vertiefung' || activeTab.value === 'deepdive') openText(activeTab.value)
if (activeTab.value === 'pruefung') startPruefung()
}
// --- Vertiefung (kurz) + Deep Dive (lang), beide persistiert ---
@@ -68,42 +67,97 @@ const chat = useChat((msgs) => chatBaustein({
messages: msgs, provider: props.provider,
}))
// --- Prüfung (Verlauf flüchtig, Zähler serverseitig) ---
const pruefung = useChat(async (msgs) => {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
messages: msgs, provider: props.provider,
})
applyPruefung(res)
return res
})
const startLoading = ref(false)
// --- Prüfung: gesteuerter Dialog (Verlauf flüchtig, Zähler serverseitig) ---
// Phasen: 'idle' (Frage anfordern) | 'frage_offen' (antworten/nachfragen) | 'bewertet' (diskutieren/neu bewerten/weiter)
const pruefMessages = ref([]) // {role, kind: 'frage'|'nachfrage'|'antwort'|'feedback'|'diskussion'|'fehler', content, bewertung?}
const pruefInput = ref('')
const pruefPhase = ref('idle')
const pruefLoading = ref(false)
const aktuelleFrage = ref('') // ankert Bewertung/Diskussion
const letztesFeedback = ref('') // Kontext für die Diskussion über eine Bewertung
const frageSchonGut = ref(false) // diese Frage schon "gut" → nicht doppelt zählen
const pruefMessagesEl = ref(null)
const pruefInputEl = ref(null)
let pruefRun = 0
function applyPruefung(res) {
emit('statusChanged', {
...st.value,
gute_antworten: res.gute_antworten,
absolviert: res.absolviert,
})
emit('statusChanged', { ...st.value, gute_antworten: res.gute_antworten, absolviert: res.absolviert })
}
async function startPruefung() {
if (pruefung.messages.value.length || startLoading.value) return
startLoading.value = true
async function pruefScroll() {
await nextTick()
if (pruefMessagesEl.value) pruefMessagesEl.value.scrollTop = pruefMessagesEl.value.scrollHeight
}
// Nur echte Gesprächs-Turns ans Backend; Feedback bleibt reines UI-Artefakt.
function pruefDialog() {
return pruefMessages.value
.filter((m) => m.kind !== 'feedback' && m.kind !== 'fehler')
.map((m) => ({ role: m.role, content: m.content }))
}
async function pruefSenden(payload, onOk) {
const run = ++pruefRun
pruefLoading.value = true
pruefScroll()
try {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
messages: [], provider: props.provider,
provider: props.provider, messages: pruefDialog(), ...payload,
})
pruefung.messages.value.push({ role: 'assistant', content: res.frage, feedback: null })
if (run !== pruefRun) return
onOk(res)
applyPruefung(res)
nextTick(() => pruefung.inputEl.value?.focus())
pruefScroll()
nextTick(() => pruefInputEl.value?.focus())
} catch {
pruefung.messages.value.push({ role: 'assistant', content: 'Fehler beim Start der Prüfung — Tab erneut öffnen.' })
if (run === pruefRun) pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' })
} finally {
startLoading.value = false
if (run === pruefRun) pruefLoading.value = false
}
}
function frageAnfordern() {
if (pruefLoading.value) return
pruefSenden({ aktion: 'frage' }, (res) => {
aktuelleFrage.value = res.frage
letztesFeedback.value = ''
frageSchonGut.value = false
pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: res.frage })
pruefPhase.value = 'frage_offen'
})
}
function nachfragen() {
const text = pruefInput.value.trim()
if (!text || pruefLoading.value) return
pruefMessages.value.push({ role: 'user', kind: 'nachfrage', content: text })
pruefInput.value = ''
pruefSenden(
{ aktion: 'diskussion', frage: aktuelleFrage.value, letzte_bewertung: letztesFeedback.value },
(res) => pruefMessages.value.push({ role: 'assistant', kind: 'diskussion', content: res.reply }),
)
}
function bewerten(res) {
letztesFeedback.value = res.feedback || ''
if (res.bewertung === 'gut') frageSchonGut.value = true
pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung })
pruefPhase.value = 'bewertet'
}
function antwortAbgeben() {
const text = pruefInput.value.trim()
if (!text || pruefLoading.value) return
pruefMessages.value.push({ role: 'user', kind: 'antwort', content: text })
pruefInput.value = ''
pruefSenden({ aktion: 'antwort', frage: aktuelleFrage.value, frage_schon_gut: frageSchonGut.value }, bewerten)
}
function neuBewerten() {
if (pruefLoading.value) return
pruefSenden({ aktion: 'antwort', frage: aktuelleFrage.value, frage_schon_gut: frageSchonGut.value }, bewerten)
}
</script>
<template>
@@ -166,35 +220,50 @@ async function startPruefung() {
</div>
</div>
<!-- Prüfung -->
<!-- Prüfung: gesteuerter Dialog -->
<div v-else>
<p class="bp-hint">
<template v-if="st.absolviert"> Absolviert du kannst dich weiter prüfen lassen.</template>
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Erkläre in eigenen Worten das Material darfst du nutzen.</template>
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Frag nach, wenn etwas unklar ist diskutieren ist erlaubt.</template>
</p>
<div :ref="pruefung.messagesEl" class="bp-messages">
<div v-if="startLoading" class="bp-msg assistant bp-typing">Erste Frage kommt</div>
<template v-for="(m, i) in pruefung.messages.value" :key="i">
<template v-if="m.role === 'assistant'">
<div v-if="m.feedback" class="bp-feedback" :class="m.bewertung">{{ m.feedback }}</div>
<div class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
</template>
<div v-if="pruefMessages.length" :ref="pruefMessagesEl" class="bp-messages">
<template v-for="(m, i) in pruefMessages" :key="i">
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung">{{ m.content }}</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="pruefung.loading.value" class="bp-msg assistant bp-typing">Bewertet</div>
<div v-if="pruefLoading" class="bp-msg assistant bp-typing"></div>
</div>
<div class="bp-input">
<textarea
:ref="pruefung.inputEl"
v-model="pruefung.input.value"
rows="2"
placeholder="Deine Erklärung…"
@keydown.enter.exact.prevent="pruefung.send"
></textarea>
<button :disabled="!pruefung.input.value.trim() && !pruefung.loading.value" :class="{ cancel: pruefung.loading.value }" @click="pruefung.send">
{{ pruefung.loading.value ? '' : '' }}
</button>
<!-- Phase idle: Frage anfordern -->
<div v-if="pruefPhase === 'idle'" class="bp-actions">
<button class="bp-action primary" :disabled="pruefLoading" @click="frageAnfordern">Frage anfordern</button>
</div>
<!-- Phase frage_offen / bewertet: Textfeld + Aktionen -->
<template v-else>
<div class="bp-input">
<textarea
:ref="pruefInputEl"
v-model="pruefInput"
rows="2"
:placeholder="pruefPhase === 'frage_offen' ? 'Antwort — oder Nachfrage bei Unklarheit…' : 'Nachhaken oder diskutieren…'"
></textarea>
</div>
<div class="bp-actions">
<template v-if="pruefPhase === 'frage_offen'">
<button class="bp-action" :disabled="pruefLoading || !pruefInput.trim()" @click="nachfragen">Nachfragen</button>
<button class="bp-action primary" :disabled="pruefLoading || !pruefInput.trim()" @click="antwortAbgeben">Antwort abgeben</button>
</template>
<template v-else>
<button class="bp-action" :disabled="pruefLoading || !pruefInput.trim()" @click="nachfragen">Nachhaken</button>
<button class="bp-action" :disabled="pruefLoading" @click="neuBewerten">Neu bewerten</button>
<button class="bp-action primary" :disabled="pruefLoading" @click="frageAnfordern">Nächste Frage</button>
</template>
</div>
</template>
</div>
</div>
</div>
@@ -249,6 +318,12 @@ async function startPruefung() {
cursor: pointer;
}
.bp-action:hover { border-color: var(--accent); }
.bp-action:disabled { opacity: 0.5; cursor: default; }
.bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
.bp-actions .bp-action { margin-top: 0; }
.bp-messages { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; }
.bp-msg {

View File

@@ -2,6 +2,8 @@ import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import DOMPurify from 'dompurify'
marked.use(markedHighlight({
@@ -15,6 +17,40 @@ marked.use(markedHighlight({
}))
marked.setOptions({ breaks: true, gfm: true })
// LaTeX-Mathe via KaTeX. Eigene marked-Extensions (statt marked-katex-extension,
// die marked v18 hinterherhinkt). marked tokenisiert Code zuerst → $…$ in Code-
// Blöcken wird NICHT als Mathe erkannt. throwOnError:false zeigt defektes TeX rot.
function renderTex(tex, displayMode) {
return katex.renderToString(tex, { displayMode, throwOnError: false, output: 'html' })
}
const blockMath = {
name: 'blockMath',
level: 'block',
start(src) { const i = src.indexOf('$$'); return i < 0 ? undefined : i },
tokenizer(src) {
const m = /^\$\$([\s\S]+?)\$\$/.exec(src)
if (m) return { type: 'blockMath', raw: m[0], text: m[1].trim() }
},
renderer(token) { return renderTex(token.text, true) },
}
const inlineMath = {
name: 'inlineMath',
level: 'inline',
start(src) { const i = src.indexOf('$'); return i < 0 ? undefined : i },
tokenizer(src) {
// $…$: kein $$, kein Leerzeichen direkt hinter dem öffnenden $ und vor dem
// schließenden $ (pandoc-Stil) → mindert Kollisionen mit Fließtext-Dollarzeichen.
const m = /^\$(?![\s$])((?:\\\$|[^$])+?)\$/.exec(src)
if (!m || /\s$/.test(m[1])) return
return { type: 'inlineMath', raw: m[0], text: m[1].trim() }
},
renderer(token) { return renderTex(token.text, false) },
}
marked.use({ extensions: [blockMath, inlineMath] })
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
marked.use({