From 2b89e21cd3aa2d593fdb4c8fb630cbb38e8771e1 Mon Sep 17 00:00:00 2001 From: team3 Date: Sun, 14 Jun 2026 14:02:27 +0200 Subject: [PATCH] update --- backend/lernen.py | 102 +++++++---- backend/models.py | 9 +- backend/routes.py | 42 ++++- frontend/package-lock.json | 26 +++ frontend/package.json | 1 + frontend/src/api.js | 7 +- frontend/src/assets/markdown.css | 7 + frontend/src/components/BausteinPanel.vue | 165 +++++++++++++----- frontend/src/markdown.js | 36 ++++ templates/Format/Section.md | 39 +++-- templates/Prompt/Baustein-Bewertung-Kritik.md | 7 +- templates/Prompt/Baustein-Bewertung.md | 11 +- templates/Prompt/Baustein-Deepdive.md | 6 +- .../Prompt/Baustein-Pruefung-Diskussion.md | 27 +++ templates/Prompt/Baustein-Vertiefung.md | 6 +- templates/Prompt/Guide-Lese-Check.md | 2 +- templates/Prompt/Guide-Sections-Fix.md | 2 +- templates/Prompt/Guide-Writer.md | 2 +- 18 files changed, 378 insertions(+), 119 deletions(-) create mode 100644 templates/Prompt/Baustein-Pruefung-Diskussion.md diff --git a/backend/lernen.py b/backend/lernen.py index c2ae4f1..0913af8 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -159,16 +159,20 @@ async def _frage_mit_kritik( async def _bewertung_mit_kritik( topic: str, baustein: str, section_block: str, vertiefung_block: str, - transcript: str, gute_antworten: int, provider: str, + frage: str, transcript: str, gute_antworten: int, provider: str, ) -> dict | None: - """Letzte Antwort bewerten, vom Kritiker prüfen lassen, bei Fehlurteil neu.""" + """Antwort zur Frage bewerten, vom Kritiker prüfen lassen, bei Fehlurteil neu. + + `frage` ankert, welche Frage geprüft wird; der Dialog (transcript) liefert die + Antwort und eine etwaige Diskussion — so kann eine Re-Bewertung das Argument sehen. + """ kritik_block = "(keine)" bew = None for _ in range(KRITIK_MAX_RUNDEN): bew = await _gen_call( "Baustein-Bewertung", "judge", _bewertung_schema, provider, topic=topic, baustein=baustein, section_block=section_block, - vertiefung_block=vertiefung_block, transcript=transcript, + vertiefung_block=vertiefung_block, frage=frage, transcript=transcript, gute_antworten=gute_antworten, noetig=NOETIG, kritik_block=kritik_block, ) if bew is None: @@ -176,7 +180,7 @@ async def _bewertung_mit_kritik( probleme = await _kritik_call( "Baustein-Bewertung-Kritik", provider, topic=topic, baustein=baustein, section_block=section_block, - vertiefung_block=vertiefung_block, transcript=transcript, + vertiefung_block=vertiefung_block, frage=frage, transcript=transcript, bewertung_block=_bewertung_text(bew), ) if not probleme: @@ -185,38 +189,74 @@ async def _bewertung_mit_kritik( return bew # best-effort nach der letzten Runde -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: erst (falls Antwort vorliegt) bewerten, dann nächste Frage. +def _bloecke(section: str, vertiefung: str | None) -> tuple[str, str]: + return ( + section.strip() or "(keine Guide-Fassung übergeben)", + (vertiefung or "").strip() or "(keine)", + ) - Generator + Kritiker-Loop je Schritt. Gibt - {"feedback", "frage", "bewertung", "bestanden"} zurück · None bei Fehler. - Bewertet wird nur, wenn der Verlauf mit einer Nutzer-Antwort endet — sonst - bekäme der Evaluator keine Antwort zum Beurteilen. + +async def pruefung_frage( + topic: str, baustein: str, section: str, vertiefung: str | None, + messages: list[dict], provider: str = DEFAULT_PROVIDER, +) -> str | None: + """Aktion 'frage': nächste Frage generieren (Generator + Kritiker) · None bei Fehler.""" + try: + section_block, vertiefung_block = _bloecke(section, vertiefung) + transcript = _transcript(messages) if messages else "(leer)" + return await _frage_mit_kritik(topic, baustein, section_block, vertiefung_block, transcript, provider) + except Exception: + log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) + return None + + +async def pruefung_bewertung( + topic: str, baustein: str, section: str, vertiefung: str | None, + frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, +) -> dict | None: + """Aktion 'antwort': Antwort zur Frage bewerten (Evaluator + Kritiker). + + Gibt {"feedback", "bewertung", "bestanden"} · None bei Fehler. """ try: - section_block = section.strip() or "(keine Guide-Fassung übergeben)" - vertiefung_block = (vertiefung or "").strip() or "(keine)" + section_block, vertiefung_block = _bloecke(section, vertiefung) transcript = _transcript(messages) if messages else "(leer)" - bewerten = bool(messages) and messages[-1].get("role") == "user" - - feedback, bewertung, bestanden = None, None, False - if bewerten: - bew = await _bewertung_mit_kritik( - topic, baustein, section_block, vertiefung_block, transcript, gute_antworten, provider, - ) - if bew is None: - return None - feedback, bewertung, bestanden = bew["feedback"], bew["bewertung"], bew["bestanden"] - - frage = await _frage_mit_kritik(topic, baustein, section_block, vertiefung_block, transcript, provider) - if frage is None: - return None - return {"feedback": feedback, "frage": frage, "bewertung": bewertung, "bestanden": bestanden} + return await _bewertung_mit_kritik( + topic, baustein, section_block, vertiefung_block, + frage.strip() or "(keine Frage übergeben)", transcript, gute_antworten, provider, + ) except Exception: - log.warning("[%s] Prüfung fehlgeschlagen (%s)", topic, baustein, exc_info=True) + log.warning("[%s] Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True) + return None + + +async def baustein_diskussion( + topic: str, baustein: str, section: str, vertiefung: str | None, + frage: str, letzte_bewertung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER, +) -> str | None: + """Aktion 'diskussion': Tutor erklärt/diskutiert die Frage oder eine Bewertung. + + Kein Bewerten, kein Kritiker — hier ist der Mensch der Prüfer. None bei Fehler. + """ + try: + section_block, vertiefung_block = _bloecke(section, vertiefung) + prompt = _prompt( + "Baustein-Pruefung-Diskussion", + topic=topic, baustein=baustein, + section_block=section_block, vertiefung_block=vertiefung_block, + frage=frage.strip() or "(keine Frage übergeben)", + letzte_bewertung_block=(letzte_bewertung or "").strip() or "(noch keine)", + transcript=_transcript(messages) if messages else "(leer)", + ) + returncode, stdout, _ = await run_agent( + "pruefungdiskussion-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT, + provider=provider, role="fast", capabilities="none", lane="interactive", + ) + if returncode != 0: + return None + return stdout.strip() or None + except Exception: + log.warning("[%s] Prüfungs-Diskussion fehlgeschlagen (%s)", topic, baustein, exc_info=True) return None diff --git a/backend/models.py b/backend/models.py index 6655072..18c4337 100644 --- a/backend/models.py +++ b/backend/models.py @@ -191,13 +191,18 @@ 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 + aktion: Literal["frage", "diskussion", "antwort"] = "frage" + frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort) + letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion) + frage_schon_gut: bool = False # diese Frage wurde schon einmal "gut" bewertet → nicht doppelt zählen + messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage provider: ProviderType = "claude" class BausteinPruefungResponse(BaseModel): + frage: str | None = None + reply: str | None = None feedback: str | None = None - frage: str bewertung: Literal["gut", "schlecht"] | None = None gute_antworten: int absolviert: bool diff --git a/backend/routes.py b/backend/routes.py index 3c0eb96..c44ecb5 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 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 lernen import NOETIG, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_frage, 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 @@ -210,24 +210,48 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): (p for p in await list_baustein_progress(req.topic) if p["baustein"] == req.baustein), {"gute_antworten": 0, "absolviert": None}, ) + gute = stand["gute_antworten"] + absolviert = stand["absolviert"] is not None vertiefung = await _bester_text(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, + msgs = [m.model_dump() for m in req.messages] + + if req.aktion == "frage": + frage = await pruefung_frage(req.topic, req.baustein, req.section, vertiefung, msgs, provider=req.provider) + if frage is None: + raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen") + return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert} + + if req.aktion == "diskussion": + if not req.frage.strip(): + raise HTTPException(400, "Diskussion braucht eine laufende Frage") + reply = await baustein_diskussion( + req.topic, req.baustein, req.section, vertiefung, + req.frage, req.letzte_bewertung or None, msgs, provider=req.provider, + ) + if reply is None: + raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen") + return {"reply": reply, "gute_antworten": gute, "absolviert": absolviert} + + # aktion == "antwort" — mindestens eine Nutzer-Antwort muss im Dialog stehen + # (nach einer Diskussion endet der Dialog mit dem Tutor; Re-Bewertung bleibt erlaubt). + if not any(m.get("role") == "user" for m in msgs): + raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort") + if not req.frage.strip(): + raise HTTPException(400, "Antwort braucht eine laufende Frage") + data = await pruefung_bewertung( + req.topic, req.baustein, req.section, vertiefung, req.frage, msgs, gute, provider=req.provider, ) if data is None: - raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen") + raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") - gute = stand["gute_antworten"] - if data["bewertung"] == "gut": + if data["bewertung"] == "gut" and not req.frage_schon_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 {"feedback": data["feedback"], "frage": data["frage"], "bewertung": data["bewertung"], "gute_antworten": gute, "absolviert": absolviert} + return {"feedback": data["feedback"], "bewertung": data["bewertung"], "gute_antworten": gute, "absolviert": absolviert} # --- Guides --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 12f8960..d3f8249 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "dompurify": "^3.4.7", "highlight.js": "^11.11.1", + "katex": "^0.17.0", "marked": "^18.0.4", "marked-highlight": "^2.2.4", "vue": "^3.5.32" @@ -1187,6 +1188,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1468,6 +1478,22 @@ "node": ">=6" } }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9cf4659..8cadba0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "dependencies": { "dompurify": "^3.4.7", "highlight.js": "^11.11.1", + "katex": "^0.17.0", "marked": "^18.0.4", "marked-highlight": "^2.2.4", "vue": "^3.5.32" diff --git a/frontend/src/api.js b/frontend/src/api.js index b75dc4a..5646197 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) } diff --git a/frontend/src/assets/markdown.css b/frontend/src/assets/markdown.css index 601c8a5..3d55e75 100644 --- a/frontend/src/assets/markdown.css +++ b/frontend/src/assets/markdown.css @@ -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; } diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue index 3bf38ce..2225d51 100644 --- a/frontend/src/components/BausteinPanel.vue +++ b/frontend/src/components/BausteinPanel.vue @@ -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) +}