This commit is contained in:
team3
2026-06-14 12:24:49 +02:00
parent 8382d6f27a
commit 822f6ee3e9
10 changed files with 266 additions and 59 deletions

View File

@@ -14,7 +14,7 @@ from config import DEFAULT_PROVIDER
from database import create_element, list_elements
from elements import generate_element
from jsonio import parse_json_text as _parse_json_text
from pipeline import _prompt
from pipeline import _prompt, _probleme_schema
from textkit import _norm_titel
log = logging.getLogger("creator.lernen")
@@ -22,6 +22,8 @@ log = logging.getLogger("creator.lernen")
NOETIG = 3 # gute Antworten bis "absolviert"
VERTIEFUNG_TIMEOUT = 600
CHAT_TIMEOUT = 240
PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt
KRITIK_MAX_RUNDEN = 2 # Generator → Kritiker → ggf. Neu, höchstens so oft
def _transcript(messages: list[dict]) -> str:
@@ -76,40 +78,143 @@ async def baustein_chat(topic: str, baustein: str, section: str, vertiefung: str
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
def _pruefung_schema(data) -> dict | None:
"""{"reply": str, "bewertung": "gut"|"schlecht"|None, "bestanden": bool} · sonst None."""
def _frage_schema(data) -> dict | None:
"""{"frage": str} · sonst None."""
if not isinstance(data, dict):
return None
reply = str(data.get("reply", "")).strip()
bewertung = data.get("bewertung")
if not reply or bewertung not in ("gut", "schlecht", None):
frage = str(data.get("frage", "")).strip()
return {"frage": frage} if frage else None
def _bewertung_schema(data) -> dict | None:
"""{"feedback": str, "bewertung": "gut"|"schlecht", "bestanden": bool} · sonst None."""
if not isinstance(data, dict):
return None
return {"reply": reply, "bewertung": bewertung, "bestanden": data.get("bestanden") is True}
feedback = str(data.get("feedback", "")).strip()
bewertung = data.get("bewertung")
if not feedback or bewertung not in ("gut", "schlecht"):
return None
return {"feedback": feedback, "bewertung": bewertung, "bestanden": data.get("bestanden") is True}
async def _gen_call(name: str, role: str, schema, provider: str, **kwargs) -> dict | None:
"""Generator-Agent: Template füllen, laufen lassen, per schema parsen · None bei Fehler."""
returncode, stdout, _ = await run_agent(
name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), PRUEFUNG_TIMEOUT,
provider=provider, role=role, capabilities="none", lane="interactive",
)
return schema(_parse_json_text(stdout)) if returncode == 0 else None
async def _kritik_call(name: str, provider: str, **kwargs) -> list[str]:
"""Kritiker-Agent (role judge): leere Liste = in Ordnung. Fail-open: Ausfall des
Kritikers darf den Turn nicht blockieren, also dann ebenfalls leere Liste."""
returncode, stdout, _ = await run_agent(
name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), PRUEFUNG_TIMEOUT,
provider=provider, role="judge", capabilities="none", lane="interactive",
)
if returncode != 0:
return []
return _probleme_schema(_parse_json_text(stdout)) or []
def _kritik_block(vorversion: str, probleme: list[str]) -> str:
punkte = "\n".join(f"- {p}" for p in probleme)
return (
f"Deine vorige Fassung war:\n«{vorversion}»\n\n"
f"Der Prüfer bemängelt:\n{punkte}\n\nBehebe diese Punkte."
)
def _bewertung_text(bew: dict) -> str:
return f"Bewertung: {bew['bewertung']}\nFeedback: {bew['feedback']}"
async def _frage_mit_kritik(
topic: str, baustein: str, section_block: str, vertiefung_block: str,
transcript: str, provider: str,
) -> str | None:
"""Frage generieren, vom Kritiker prüfen lassen, bei Mängeln neu (max KRITIK_MAX_RUNDEN)."""
kritik_block = "(keine)"
frage = None
for _ in range(KRITIK_MAX_RUNDEN):
data = await _gen_call(
"Baustein-Frage", "fast", _frage_schema, provider,
topic=topic, baustein=baustein, section_block=section_block,
vertiefung_block=vertiefung_block, transcript=transcript, kritik_block=kritik_block,
)
if data is None:
return None
frage = data["frage"]
probleme = await _kritik_call(
"Baustein-Frage-Kritik", provider,
topic=topic, baustein=baustein, section_block=section_block,
vertiefung_block=vertiefung_block, transcript=transcript, frage=frage,
)
if not probleme:
return frage
kritik_block = _kritik_block(frage, probleme)
return frage # best-effort nach der letzten Runde
async def _bewertung_mit_kritik(
topic: str, baustein: str, section_block: str, vertiefung_block: str,
transcript: str, gute_antworten: int, provider: str,
) -> dict | None:
"""Letzte Antwort bewerten, vom Kritiker prüfen lassen, bei Fehlurteil neu."""
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,
gute_antworten=gute_antworten, noetig=NOETIG, kritik_block=kritik_block,
)
if bew is None:
return None
probleme = await _kritik_call(
"Baustein-Bewertung-Kritik", provider,
topic=topic, baustein=baustein, section_block=section_block,
vertiefung_block=vertiefung_block, transcript=transcript,
bewertung_block=_bewertung_text(bew),
)
if not probleme:
return bew
kritik_block = _kritik_block(_bewertung_text(bew), probleme)
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: Frage stellen bzw. letzte Antwort bewerten · None bei Fehler."""
"""Ein Prüfungs-Turn: erst (falls Antwort vorliegt) bewerten, dann nächste Frage.
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.
"""
try:
prompt = _prompt(
"Baustein-Pruefung",
topic=topic, baustein=baustein,
section_block=section.strip() or "(keine Guide-Fassung übergeben)",
vertiefung_block=(vertiefung or "").strip() or "(keine)",
transcript=_transcript(messages) if messages else "(leer)",
gute_antworten=gute_antworten, noetig=NOETIG,
section_block = section.strip() or "(keine Guide-Fassung übergeben)"
vertiefung_block = (vertiefung or "").strip() or "(keine)"
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,
)
# role "judge": Bewertungen brauchen das starke, kalte Modell —
# M2.7 hat in der Praxis gegen die eigene Referenz halluziniert.
returncode, stdout, _ = await run_agent(
"pruefung-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
provider=provider, role="judge", capabilities="none", lane="interactive",
)
if returncode != 0:
if bew is None:
return None
return _pruefung_schema(_parse_json_text(stdout))
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}
except Exception:
log.warning("[%s] Prüfung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return None

View File

@@ -196,7 +196,8 @@ class BausteinPruefungRequest(BaseModel):
class BausteinPruefungResponse(BaseModel):
reply: str
feedback: str | None = None
frage: str
bewertung: Literal["gut", "schlecht"] | None = None
gute_antworten: int
absolviert: bool

View File

@@ -227,7 +227,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
absolviert = True
if frisch:
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
return {"reply": data["reply"], "bewertung": data["bewertung"], "gute_antworten": gute, "absolviert": absolviert}
return {"feedback": data["feedback"], "frage": data["frage"], "bewertung": data["bewertung"], "gute_antworten": gute, "absolviert": absolviert}
# --- Guides ---

View File

@@ -95,7 +95,7 @@ async function startPruefung() {
topic: props.topic, baustein: props.baustein, section: props.section,
messages: [], provider: props.provider,
})
pruefung.messages.value.push({ role: 'assistant', content: res.reply })
pruefung.messages.value.push({ role: 'assistant', content: res.frage, feedback: null })
applyPruefung(res)
nextTick(() => pruefung.inputEl.value?.focus())
} catch {
@@ -175,7 +175,10 @@ async function startPruefung() {
<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">
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<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-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="pruefung.loading.value" class="bp-msg assistant bp-typing">Bewertet</div>
@@ -260,6 +263,19 @@ async function startPruefung() {
.bp-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.bp-typing { color: var(--text-faint); font-style: italic; }
/* Bewertung der letzten Antwort — getrennt über der nächsten Frage */
.bp-feedback {
align-self: flex-start;
max-width: 88%;
padding: 0.3rem 0.6rem;
border-radius: 8px;
font-size: 0.82rem;
line-height: 1.4;
border: 1px solid var(--border);
}
.bp-feedback.gut { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
.bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
.bp-input textarea {
flex: 1;

View File

@@ -52,7 +52,13 @@ export function useChat(performRequest) {
try {
const res = await performRequest(messages.value)
if (current !== run) return null
messages.value.push({ role: 'assistant', content: res.reply || '…' })
// Prüfung liefert `frage` (+ getrenntes `feedback`); andere Chats `reply`.
messages.value.push({
role: 'assistant',
content: res.frage ?? res.reply ?? '…',
feedback: res.feedback ?? null,
bewertung: res.bewertung ?? null,
})
return res
} catch {
if (current !== run) return null

View File

@@ -0,0 +1,25 @@
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.
BAUSTEIN AUS DEM GUIDE:
{section_block}
VERTIEFUNG (falls vorhanden):
{vertiefung_block}
PRÜFUNGS-VERLAUF (die letzte Nutzer-Antwort wurde bewertet):
{transcript}
ZU PRÜFENDE BEWERTUNG:
{bewertung_block}
PRÜFE GEGEN DIESE KRITERIEN:
- Konsistenz mit dem Material: Die Bewertung darf der Guide-Fassung und der Vertiefung NICHT widersprechen.
- Keine Halluzination: Das Feedback behauptet nichts, was nicht aus dem Material folgt. Es bestraft den Lerner nicht für etwas, das gar nicht gefragt war.
- Fairness: Eine knappe, fachlich richtige Antwort muss "gut" sein. Eine falsche oder abgelesene Antwort muss "schlecht" sein.
- Hat der Lerner mit Bezug aufs Material recht, muss die Bewertung "gut" sein — auch wenn sie der Frage-Annahme widerspricht.
Beanstande NUR echte Fehlurteile. Ist die Bewertung fair und materialtreu, ist sie in Ordnung.
Gib NUR JSON aus (kein weiterer Text):
- Bewertung in Ordnung: {{"ok": true}}
- Sonst: {{"probleme": ["was an der Bewertung falsch ist"]}}

View File

@@ -0,0 +1,29 @@
Du bewertest die LETZTE Antwort eines Lerners in einer Prüfung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}".
BAUSTEIN AUS DEM GUIDE:
{section_block}
VERTIEFUNG (falls vorhanden):
{vertiefung_block}
STAND: {gute_antworten} von {noetig} Antworten waren bisher gut.
PRÜFUNGS-VERLAUF (die letzte Nutzer-Antwort bewertest du):
{transcript}
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".
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.
- "schlecht" = falsch, oberflächlich, abgelesen oder bloße Wiederholung einer früheren Antwort.
- `feedback`: max. 1 Satz, sprich den Lerner direkt an. KEINE neue Frage. Begründe knapp, warum gut oder schlecht.
- `bestanden`: true NUR, wenn du schon vor den {noetig} guten Antworten sicher bist, dass der Lerner den Baustein verstanden hat. Im Zweifel false.
HINWEISE DES PRÜFERS ZUR LETZTEN FASSUNG:
{kritik_block}
Gib NUR dieses JSON aus (kein weiterer Text):
{{"feedback": "Bewertung in einem Satz", "bewertung": "gut" | "schlecht", "bestanden": false}}

View File

@@ -0,0 +1,25 @@
Du bist Qualitäts-Prüfer für Prüfungsfragen in einem Lern-Guide zum Thema "{topic}", Baustein "{baustein}". Ein anderer Agent hat eine Frage formuliert. Prüfe sie streng.
BAUSTEIN AUS DEM GUIDE:
{section_block}
VERTIEFUNG (falls vorhanden):
{vertiefung_block}
BISHERIGER PRÜFUNGS-VERLAUF (nur frühere Fragen und Antworten):
{transcript}
ZU PRÜFENDE FRAGE:
{frage}
PRÜFE GEGEN DIESE KRITERIEN:
- Stil: GENAU EINE Frage, ein einziges Fragezeichen, eine einzige Sache. Kein Mehrteiler ("und"/"sowie", "sowohl … als auch …", "nenne drei …").
- Kürze: maximal 12 Sätze, kein Szenario-Aufbau über mehrere Sätze, keine lange Vorrede.
- Keine Wiederholung einer Frage aus dem Verlauf.
- Fachlich korrekt: Die Frage muss aus dem Material oben beantwortbar sein und darf der Referenz NICHT widersprechen. Keine erfundenen Zusatzannahmen.
Beanstande NUR echte Verstöße. Ist die Frage knapp, einzeln und korrekt, ist sie in Ordnung — verlange nichts darüber hinaus.
Gib NUR JSON aus (kein weiterer Text):
- Alles in Ordnung: {{"ok": true}}
- Sonst: {{"probleme": ["kurzer Mangel 1", "kurzer Mangel 2"]}}

View File

@@ -0,0 +1,30 @@
Du bist Prüfer in einem Lern-Guide zum Thema "{topic}". Stelle dem Lerner EINE Verständnisfrage zum Baustein "{baustein}". Der Lerner sieht das Material — frage nach Verständnis und Transfer, nicht nach Abgelesenem.
BAUSTEIN AUS DEM GUIDE:
{section_block}
VERTIEFUNG (falls vorhanden):
{vertiefung_block}
BISHERIGER PRÜFUNGS-VERLAUF (nur frühere Fragen und Antworten):
{transcript}
HARTE REGELN FÜR DIE FRAGE — wichtiger als alles andere:
- GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache.
- Maximal 12 Sätze. Kein Szenario-Aufbau, keine Vorrede, kein "Angenommen … und außerdem …".
- Verboten: zwei Fragen mit "und"/"sowie" verketten, "nenne drei …", "sowohl … als auch …", Aufzähl-Forderungen.
- Frag nach EINEM Gedanken: ein Warum, eine Konsequenz, eine Abgrenzung, die Anwendung auf EIN kurzes Beispiel, einen Fehler finden.
- Passt eine Transferfrage nicht in einen Satz, wähle eine einfachere Frage.
- Wiederhole keine Frage aus dem Verlauf.
FACHLICHE REFERENZ — WICHTIG:
- Die Guide-Fassung und die Vertiefung oben sind die Referenz. Deine Frage darf ihr NIE widersprechen.
- Erfinde keine Zusatzannahmen (z. B. fehlende Eingaben, geänderte Definitionen). Frag nur, was aus dem Material folgt.
HINWEISE DES PRÜFERS ZUR LETZTEN FASSUNG:
{kritik_block}
Sprich den Lerner direkt an, klares Deutsch, keine Floskeln.
Gib NUR dieses JSON aus (kein weiterer Text):
{{"frage": "genau eine kurze Frage"}}

View File

@@ -1,30 +0,0 @@
Du prüfst das Verständnis eines Lerners zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Der Lerner sieht das Material während der Prüfung — stelle deshalb VERSTÄNDNIS- und TRANSFERFRAGEN, keine Reproduktionsfragen (nichts, was sich ablesen lässt).
BAUSTEIN AUS DEM GUIDE:
{section_block}
VERTIEFUNG (falls vorhanden):
{vertiefung_block}
STAND: {gute_antworten} von {noetig} Antworten waren bisher gut. Bei {noetig} guten Antworten ist der Baustein absolviert.
BISHERIGER PRÜFUNGS-VERLAUF:
{transcript}
FACHLICHE REFERENZ — WICHTIG:
- Die Guide-Fassung und die Vertiefung oben sind die fachliche Referenz. Deine Fragen und Bewertungen dürfen ihnen NIE widersprechen.
- Behaupte nichts, was nicht aus dem Material folgt. Erfinde keine Zusatzannahmen (z. B. fehlende Eingaben, geänderte Definitionen).
- 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 die Antwort als "gut".
Deine Aufgabe:
- Ist der Verlauf leer: Stelle die erste Frage. `bewertung` ist dann null.
- Sonst: Bewerte die LETZTE Nutzer-Antwort als "gut" oder "schlecht". Gut = die Erklärung zeigt echtes Verständnis in eigenen Worten. Schlecht = falsch, oberflächlich, abgelesen oder eine bloße Wiederholung einer früheren Antwort.
- Gib Feedback (max. 1 Satz) und stelle die nächste Frage — beides zusammen in `reply`.
- Fragen sind KURZ: 12 Sätze, EINE Sache pro Frage. Keine Mehrteiler („nenne drei…", „sowohl … als auch …"), keine Aufzähl-Forderungen, kein Szenario-Aufbau über mehrere Sätze.
- Gute Fragen: Warum-Fragen, Anwendung auf ein neues Beispiel, Abgrenzung zu Nachbarkonzepten, Fehler in einem Beispiel finden, Konsequenzen erklären.
- Eine knappe, richtige Antwort ist „gut" — verlange keine Vollständigkeit über die Frage hinaus.
- `bestanden`: true NUR, wenn du schon vor Erreichen der {noetig} guten Antworten überzeugt bist, dass der Lerner den Baustein sicher verstanden hat. Im Zweifel false.
- Sprich den Lerner direkt an, klares Deutsch, keine Floskeln.
Gib NUR dieses JSON aus (kein weiterer Text):
{{"reply": "Feedback und nächste Frage", "bewertung": "gut" | "schlecht" | null, "bestanden": false}}