diff --git a/backend/lernen.py b/backend/lernen.py
index 6f49438..c2ae4f1 100644
--- a/backend/lernen.py
+++ b/backend/lernen.py
@@ -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,
- )
- # 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:
+ 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,
+ )
+ 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 _pruefung_schema(_parse_json_text(stdout))
+ 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
diff --git a/backend/models.py b/backend/models.py
index 3e7c942..6655072 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -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
diff --git a/backend/routes.py b/backend/routes.py
index 363ccc7..3c0eb96 100644
--- a/backend/routes.py
+++ b/backend/routes.py
@@ -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 ---
diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue
index a2539cd..3bf38ce 100644
--- a/frontend/src/components/BausteinPanel.vue
+++ b/frontend/src/components/BausteinPanel.vue
@@ -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() {
Erste Frage kommt…
-
+
+ {{ m.feedback }}
+
+
{{ m.content }}
Bewertet…
@@ -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;
diff --git a/frontend/src/composables/useChat.js b/frontend/src/composables/useChat.js
index 6b22541..85a23be 100644
--- a/frontend/src/composables/useChat.js
+++ b/frontend/src/composables/useChat.js
@@ -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
diff --git a/templates/Prompt/Baustein-Bewertung-Kritik.md b/templates/Prompt/Baustein-Bewertung-Kritik.md
new file mode 100644
index 0000000..e4a00fc
--- /dev/null
+++ b/templates/Prompt/Baustein-Bewertung-Kritik.md
@@ -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"]}}
diff --git a/templates/Prompt/Baustein-Bewertung.md b/templates/Prompt/Baustein-Bewertung.md
new file mode 100644
index 0000000..23230ae
--- /dev/null
+++ b/templates/Prompt/Baustein-Bewertung.md
@@ -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}}
diff --git a/templates/Prompt/Baustein-Frage-Kritik.md b/templates/Prompt/Baustein-Frage-Kritik.md
new file mode 100644
index 0000000..a0a75aa
--- /dev/null
+++ b/templates/Prompt/Baustein-Frage-Kritik.md
@@ -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 1–2 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"]}}
diff --git a/templates/Prompt/Baustein-Frage.md b/templates/Prompt/Baustein-Frage.md
new file mode 100644
index 0000000..829dc39
--- /dev/null
+++ b/templates/Prompt/Baustein-Frage.md
@@ -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 1–2 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"}}
diff --git a/templates/Prompt/Baustein-Pruefung.md b/templates/Prompt/Baustein-Pruefung.md
deleted file mode 100644
index d5ebd66..0000000
--- a/templates/Prompt/Baustein-Pruefung.md
+++ /dev/null
@@ -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: 1–2 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}}