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

@@ -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

View File

@@ -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

View File

@@ -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 ---

View File

@@ -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",

View File

@@ -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"

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({

View File

@@ -1,44 +1,51 @@
SECTION-AUFBAU
Jeder Baustein wird GENAU eine Section mit:
1. Titel — der Baustein-Titel (kommt aus dem Marker, nicht in den Body schreiben)
2. Beschreibung — was es ist und wozu: MAXIMAL 12 Sätze
3. Beispiele — KURZ und SIMPEL: das Minimalbeispiel im themengerechten Format (siehe BEISPIELFORMAT), keine Realwelt-Komplexität. Höchstens 1 knapper Satz Einordnung dazu. Ein Beispiel pro relevanter Variante: simple Bausteine eines, variantenreiche mehrere. Geordnet vom Üblichen zum Speziellen. Weglassen, wenn ohne Mehrwert.
Jeder Baustein ist ein kleiner, eigenständiger Lern-Guide: er stellt EIN Konzept vor, erklärt es von Grund auf und macht es nutzbar. Der Leser bringt KEIN Vorwissen mit — du holst ihn ab und bringst ihm die Sache bei. Eine Section ist kein Stichwort-Zettel zum Nachschlagen.
Aufbau je Baustein — drei Beats, fließend ineinander, OHNE Zwischenüberschriften:
1. Einordnung — welche Frage beantwortet der Baustein, welches Problem löst er? Ein Satz, der den Leser abholt. Bei selbsterklärenden Bausteinen weglassen.
2. Erklärung — was es ist UND wie/warum es funktioniert. Alltagssprache, von der Intuition zum Detail. Fachbegriffe beim ersten Auftreten in einem Halbsatz auflösen. Eine Analogie oder ein Bild ist erlaubt und oft besser als eine Definition.
3. Beispiel(e) — das Konzept konkret gemacht (siehe BEISPIELFORMAT).
LÄNGE — so lang wie nötig, so kurz wie möglich:
- KEIN festes Wort- oder Satzlimit. Die Länge richtet sich nach der Schwierigkeit des Konzepts: ein einfacher Baustein braucht 23 Sätze, ein kniffliger einen kurzen Absatz.
- Verständnis-Test (er entscheidet über die Länge): Versteht ein Anfänger das Konzept allein aus dieser Section? Wenn nein → eine Stufe einfacher erklären, NICHT mehr Fakten stapeln. Wenn ja und kein Satz lässt sich streichen, ohne dass Verständnis verloren geht → genau richtig.
- Kürze entsteht durch WEGLASSEN von Überflüssigem, nicht durch Verdichten von Nötigem.
- Weglassen: Füllsätze, Einleitungsfloskeln („In diesem Abschnitt…"), Wiederholungen, Fazit/Zusammenfassung. Nicht jeden Randfall nennen — das Übliche erklären; Varianten gehören in die Beispiele, mehr Tiefe in die Vertiefung.
BEISPIELFORMAT — am Thema ausrichten, nicht pauschal an Code:
- Code-/Tool-Thema (Sprache, Framework, CLI, Konfiguration): Codeblock mit Sprachangabe, wenige Zeilen, Minimalbeispiel.
- Sprach-Thema (Vokabeln, Grammatik, Formulierungen): 13 Beispielsätze oder ein Mini-Dialog, fremdsprachiger Teil *kursiv*, deutsche Übersetzung in Klammern wo nötig.
- Konzept-Thema (Psychologie, Kommunikation, Methoden, Theorie): ein Mini-Szenario in 24 Sätzen (Situation → Anwendung → Wirkung), ein Schema oder eine Formel.
- Konzept-Thema (Psychologie, Kommunikation, Methoden, Theorie, Mathe): ein Mini-Szenario in 24 Sätzen (Situation → Anwendung → Wirkung), ein Schema oder eine durchgerechnete Formel mit kleinen Zahlen.
Mischthemen: pro Beispiel das Format wählen, das den Punkt am direktesten zeigt.
Ein Beispiel ist immer KONKRET (echter Code, echte Sätze, echte Situation) — nie die Beschreibung, was ein Beispiel zeigen würde.
Jedes Beispiel benennt seine Variante: in Code als Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in Prosa als vorangestelltes fettes Label (z. B. **Höfliche Bitte:**).
Mehrere Beispiele benennen ihre Variante: in Code als Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in Prosa als vorangestelltes fettes Label (z. B. **Höfliche Bitte:**). Bei nur einem Beispiel ist kein Label nötig.
Jede Section ist ATOMAR: allein verständlich, ohne dass der Leser eine andere Section gelesen hat. Test: Ergibt der Text Sinn, wenn man NUR diese Section liest? Verweise auf andere Bausteine sind erlaubt, ihr Inhalt darf aber nie vorausgesetzt werden — benutzte Begriffe in einem Halbsatz auflösen.
Umfang: kurz. Die Länge einer Section kommt aus der ZAHL der Beispiele (Varianten), nie aus langen Texten.
Tonalität: klares, direktes Deutsch. Du erklärst, du referierst nicht. Praxisorientiert, ohne Füllsätze.
Tonalität: klares Deutsch, direkt, praxisorientiert. Fachbegriffe beim ersten Auftreten kurz erklären. Keine Füllsätze, keine Einleitungsfloskeln.
Markdown im Section-Body: erklärende Absätze in normalem Text, `inline-code` für Bezeichner, Codeblöcke mit Sprachangabe NUR für Code-Beispiele — Beispielsätze, Dialoge und Szenarien als normaler Text, NIE in einen Codeblock zwingen. **fett** sparsam für Kernaussagen und Beispiel-Labels. Keine eigenen Überschriften außer `### Beispiel` bzw. `### Beispiele` vor den Beispielen.
Markdown im Section-Body: normale Absätze, `inline-code` für Bezeichner, Codeblöcke mit Sprachangabe NUR für Code-Beispiele — Beispielsätze, Dialoge und Szenarien als normaler Text, NIE in einen Codeblock zwingen. **fett** sparsam für Kernaussagen. Keine eigenen Überschriften außer `### Beispiel` bzw. `### Beispiele` vor den Beispielen.
Mathematik IMMER als LaTeX schreiben: inline zwischen `$…$` (z. B. `$\Sigma^*$`, `$L \subseteq U$`, `$k = 3$`), abgesetzte Formeln zwischen `$$…$$`. KEINE Unicode-Sonderzeichen als Mathe-Ersatz (nicht `x₁`, `¬`, ``, `≤` — stattdessen `$x_1$`, `$\neg$`, `$\lor$`, `$\le$`) und keine nackten Formeln ohne `$`. Außerhalb von Mathe normaler Text.
Beispiel einer fertigen Section (Code-Thema, nur der Body):
Arrays speichern mehrere Werte unter einem Namen. PHP unterscheidet indizierte Arrays (`[0 => 'a']`) und assoziative Arrays (`['key' => 'wert']`) — intern sind beide geordnete Hashmaps.
Arrays lösen ein simples Problem: Du willst viele Werte unter einem Namen halten, statt für jeden eine eigene Variable. In PHP gibt es zwei Sorten. Indizierte Arrays nummerieren die Werte durch (`[0 => 'a']`). Assoziative Arrays geben jedem Wert einen eigenen Schlüssel (`['key' => 'wert']`) — praktisch, wenn die Position egal ist, der Name aber zählt. Intern sind beide dasselbe: geordnete Hashmaps.
### Beispiel
```php
$preise = ['apfel' => 1.20, 'birne' => 1.50];
$preise['kirsche'] = 3.90; // ergänzen
echo $preise['apfel']; // 1.2
$preise['kirsche'] = 3.90; // neuen Schlüssel ergänzen
echo $preise['apfel']; // 1.2 — Zugriff über den Namen
```
Assoziative Arrays sind der Arbeitsalltag: Datenbankzeilen, Konfiguration, JSON.
So sieht der Alltag aus: Datenbankzeilen, Konfiguration, JSON landen fast immer in assoziativen Arrays.
Beispiel einer fertigen Section (Konzept-Thema, nur der Body):
Paraphrasieren wiederholt die Aussage des Gegenübers in eigenen Worten, um Verständnis zu prüfen und Eskalation zu bremsen.
Im Streit reden zwei oft aneinander vorbei, weil keiner sicher ist, ob er den anderen richtig verstanden hat. Paraphrasieren setzt genau hier an: Du wiederholst die Aussage des Gegenübers in eigenen Worten und fragst nach, ob das so stimmt. Das prüft dein Verständnis und nimmt Tempo aus dem Konflikt — der andere fühlt sich gehört, statt sich verteidigen zu müssen. Wichtig: Du bestätigst nicht den Vorwurf, du spiegelst nur die Botschaft dahinter.
### Beispiel
**Vorwurf abfedern:**
A: „Nie hältst du dich an Absprachen!"
B: „Du bist sauer, weil ich den Termin gestern verschoben habe — richtig?"
Die Paraphrase bestätigt nicht den Vorwurf, sondern prüft die Botschaft dahinter.
B übernimmt nicht das Wort „nie", sondern benennt das konkrete Anliegen. Das öffnet das Gespräch, statt es zu eskalieren.

View File

@@ -1,4 +1,7 @@
Du bist Qualitäts-Prüfer für Bewertungen in einer Prüfung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Ein anderer Agent hat die letzte Antwort des Lerners bewertet. Prüfe, ob die Bewertung fair und korrekt ist.
Du bist Qualitäts-Prüfer für Bewertungen in einer Prüfung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Ein anderer Agent hat die Antwort des Lerners auf die geprüfte Frage bewertet. Prüfe, ob die Bewertung fair und korrekt ist.
GEPRÜFTE FRAGE:
{frage}
BAUSTEIN AUS DEM GUIDE:
{section_block}
@@ -6,7 +9,7 @@ BAUSTEIN AUS DEM GUIDE:
VERTIEFUNG (falls vorhanden):
{vertiefung_block}
PRÜFUNGS-VERLAUF (die letzte Nutzer-Antwort wurde bewertet):
PRÜFUNGS-VERLAUF (Antwort des Lerners und etwaige Diskussion):
{transcript}
ZU PRÜFENDE BEWERTUNG:

View File

@@ -1,4 +1,7 @@
Du bewertest die LETZTE Antwort eines Lerners in einer Prüfung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}".
Du bewertest, ob ein Lerner die geprüfte Frage verstanden beantwortet hat — Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}".
GEPRÜFTE FRAGE:
{frage}
BAUSTEIN AUS DEM GUIDE:
{section_block}
@@ -8,13 +11,15 @@ VERTIEFUNG (falls vorhanden):
STAND: {gute_antworten} von {noetig} Antworten waren bisher gut.
PRÜFUNGS-VERLAUF (die letzte Nutzer-Antwort bewertest du):
PRÜFUNGS-VERLAUF (Antwort des Lerners und etwaige Diskussion):
{transcript}
Bewerte die Antwort des Lerners auf die GEPRÜFTE FRAGE — auf Basis seiner Antwort UND der Diskussion im Verlauf.
FACHLICHE REFERENZ — WICHTIG:
- Die Guide-Fassung und die Vertiefung oben sind die fachliche Referenz. Deine Bewertung darf ihr NIE widersprechen.
- Behaupte nichts, was nicht aus dem Material folgt. Erfinde keine Zusatzannahmen.
- Widerspricht dir der Lerner mit Bezug aufs Material: Prüfe ZUERST deine eigene Annahme gegen die Referenz. Hat der Lerner recht, gib es offen zu und bewerte als "gut".
- Widerspricht dir der Lerner mit Bezug aufs Material: Prüfe ZUERST deine eigene Annahme gegen die Referenz. Hat der Lerner SACHLICH und mit Material-Bezug recht, gib es offen zu und bewerte als "gut" — aber gib NICHT aus Höflichkeit oder auf bloßes Beharren hin nach.
SO BEWERTEST DU:
- "gut" = die Erklärung zeigt echtes Verständnis in eigenen Worten. Eine knappe, richtige Antwort reicht — verlange keine Vollständigkeit über die Frage hinaus.

View File

@@ -1,10 +1,10 @@
Schreibe einen Deep Dive zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Der Leser kennt die kompakte Fassung und will tief einsteigen.
Schreibe einen Deep Dive zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Der Leser kennt die Guide-Fassung — die erklärt das Konzept schon — und will jetzt tief einsteigen.
KOMPAKTE FASSUNG AUS DEM GUIDE:
GUIDE-FASSUNG DES BAUSTEINS:
{section_block}
Inhalt des Deep Dives:
- Erkläre das Konzept gründlicher: das Warum hinter den Regeln, nicht nur das Wie.
- Geh deutlich tiefer als die Guide-Fassung: das Warum hinter den Regeln gründlich, Mechanik im Detail, nicht nur das Wie.
- Mehr und reichere Beispiele als im Guide — Varianten, Grenzfälle, ein realistischer Anwendungsfall.
- Typische Fehler und Missverständnisse, jeweils mit Korrektur.
- Abgrenzung zu verwandten Konzepten, wo Verwechslungsgefahr besteht.

View File

@@ -0,0 +1,27 @@
Du bist Tutor in einer Prüfung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Der Lerner diskutiert mit dir — über die geprüfte Frage oder über deine letzte Bewertung. Du DISKUTIERST, du bewertest NICHT.
GEPRÜFTE FRAGE:
{frage}
DEINE LETZTE BEWERTUNG (falls vorhanden):
{letzte_bewertung_block}
BAUSTEIN AUS DEM GUIDE:
{section_block}
VERTIEFUNG (falls vorhanden):
{vertiefung_block}
BISHERIGER VERLAUF:
{transcript}
Antworte als Tutor auf die letzte Nutzer-Nachricht.
WICHTIG:
- Bleib bei der geprüften Frage und beim Material. Guide-Fassung und Vertiefung sind die fachliche Referenz.
- Ist die Frage unklar: erkläre sie, ohne die Lösung zu verraten.
- Zeigt der Lerner SACHLICH und mit Material-Bezug, dass deine Frage oder eine vorige Bewertung falsch war: räume es offen ein. Schlage dann vor, die Antwort erneut bewerten zu lassen.
- Gib NICHT aus Höflichkeit oder auf bloßes Beharren hin nach. Nur ein echtes Sach-Argument zählt.
- Du vergibst KEINE Bewertung und stellst KEINE neue Prüfungsfrage.
Antwortstil: kurz und klar, 13 Sätze. Keine Einleitung, kein Markdown-Drumherum, kein Präfix wie "Assistent:". Gib NUR die Antwort aus.

View File

@@ -1,10 +1,10 @@
Schreibe eine kurze Vertiefung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Der Leser kennt die kompakte Fassung und will einen Schritt weiter nicht mehr.
Schreibe eine kurze Vertiefung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Der Leser kennt die Guide-Fassung — die erklärt das Konzept bereits — und will einen Schritt weiter, nicht mehr.
KOMPAKTE FASSUNG AUS DEM GUIDE:
GUIDE-FASSUNG DES BAUSTEINS:
{section_block}
Inhalt:
- Erkläre kurz das Warum hinter dem Konzept — den einen Punkt, der im Guide fehlt.
- Geh über die Guide-Fassung hinaus: ein tieferer Grund, eine Feinheit oder ein Aspekt, den der Guide bewusst weglässt. Wiederhole NICHT das Warum, das der Guide schon erklärt.
- 12 zusätzliche Beispiele: eine andere Variante oder ein Grenzfall, nicht die Guide-Beispiele wiederholen.
- Höchstens ein typischer Fehler, wenn er wirklich häufig ist.
- Nichts wiederholen, was die kompakte Fassung schon sagt.

View File

@@ -8,7 +8,7 @@ SECTIONS:
{sections}
Prüfe jede Section:
1. Ist die Beschreibung für Anfänger verständlich und maximal 12 Sätze?
1. Lehrt die Section das Konzept für einen Anfänger ohne Vorwissen verständlich — ordnet sie es ein, erklärt sie das Wie/Warum, macht ein Beispiel es konkret? Sie soll so lang wie nötig und so kurz wie möglich sein: kein Roman, keine Füllsätze, keine Einleitungsfloskeln — aber auch nicht so verdichtet, dass nur jemand sie versteht, der das Thema schon kennt.
2. Sind die Beispiele kurz, simpel, plausibel korrekt — und im themengerechten Format laut Spezifikation (kein Codeblock um Prosa-Beispiele, kein Prosa-Pseudo-Beispiel, wo Code gefragt ist)?
3. Ist das Markdown sauber (keine abgebrochenen Code-Blöcke, keine Platzhalter, kein Fremdtext)?

View File

@@ -13,7 +13,7 @@ Behebe pro Section NUR das notierte Problem; was in Ordnung ist, bleibt inhaltli
Schreibe NUR die Datei {out_path} in GENAU diesem Format — für JEDE beanstandete Section ein section-Marker (Titel EXAKT wie oben), darunter der vollständige neue Markdown-Body:
<!-- section: Exakter Section-Titel -->
Beschreibung
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION
### Beispiel
(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario)

View File

@@ -14,7 +14,7 @@ Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kap
<!-- kapitel: Kapiteltitel -->
<!-- section: Exakter Baustein-Titel -->
Beschreibung
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION
### Beispiel
(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario)