diff --git a/backend/lernen.py b/backend/lernen.py index f01764c..7cd9a1e 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -26,7 +26,6 @@ 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 -MAX_NACHFRAGEN = 2 # mündliche Prüfung: höchstens so viele Folgefragen, dann Urteil erzwingen def score_berechnen( @@ -115,20 +114,14 @@ def _frage_schema(data) -> dict | None: def _bewertung_schema(data) -> dict | None: - """{"status": "gut"|"schlecht"|"nachfrage", "feedback": str, "frage": str, "bestanden": bool}. - - Bei status "nachfrage" muss `frage` (die Folgefrage) gefüllt sein. · sonst None. - """ + """{"feedback": str, "bewertung": "gut"|"schlecht", "bestanden": bool} · sonst None.""" if not isinstance(data, dict): return None feedback = str(data.get("feedback", "")).strip() - status = data.get("status") - frage = str(data.get("frage", "")).strip() - if not feedback or status not in ("gut", "schlecht", "nachfrage"): + bewertung = data.get("bewertung") + if not feedback or bewertung not in ("gut", "schlecht"): return None - if status == "nachfrage" and not frage: - return None - return {"status": status, "feedback": feedback, "frage": frage, "bestanden": data.get("bestanden") is True} + 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: @@ -161,7 +154,7 @@ def _kritik_block(vorversion: str, probleme: list[str]) -> str: def _bewertung_text(bew: dict) -> str: - return f"Bewertung: {bew['status']}\nFeedback: {bew['feedback']}" + return f"Bewertung: {bew['bewertung']}\nFeedback: {bew['feedback']}" async def _frage_mit_kritik( @@ -193,14 +186,12 @@ async def _frage_mit_kritik( async def _bewertung_mit_kritik( topic: str, baustein: str, section_block: str, vertiefung_block: str, - frage: str, transcript: str, gute_antworten: int, rest_nachfragen: int, provider: str, + frage: str, transcript: str, gute_antworten: int, provider: str, ) -> dict | None: - """Antwort beurteilen: gut/schlecht (mit Kritiker) ODER nachfrage (Folgefrage, ohne Kritiker). + """Antwort bewerten (gut/schlecht), vom Kritiker prüfen lassen, bei Fehlurteil neu. - `frage` ankert die geprüfte Frage; der Dialog liefert Antwort + etwaige Folgefragen. - `rest_nachfragen` = wie viele Folgefragen noch erlaubt sind (0 → muss entscheiden). - Eine „nachfrage" wird sofort zurückgegeben (kein Verdikt zu prüfen). Verdikte - durchlaufen den Kritiker-Loop wie bisher. + `frage` ankert die geprüfte Frage; der Dialog (transcript) liefert die Antwort und + eine etwaige Diskussion — so kann eine Re-Bewertung das Argument sehen. """ kritik_block = "(keine)" bew = None @@ -209,13 +200,10 @@ async def _bewertung_mit_kritik( "Baustein-Bewertung", "judge", _bewertung_schema, provider, topic=topic, baustein=baustein, section_block=section_block, vertiefung_block=vertiefung_block, frage=frage, transcript=transcript, - gute_antworten=gute_antworten, noetig=NOETIG, rest_nachfragen=rest_nachfragen, - kritik_block=kritik_block, + gute_antworten=gute_antworten, noetig=NOETIG, kritik_block=kritik_block, ) if bew is None: return None - if bew["status"] == "nachfrage": - return bew # Folgefrage → kein Kritiker, keine Wertung probleme = await _kritik_call( "Baustein-Bewertung-Kritik", provider, topic=topic, baustein=baustein, section_block=section_block, @@ -251,26 +239,19 @@ async def pruefung_frage( async def pruefung_bewertung( topic: str, baustein: str, section: str, vertiefung: str | None, - frage: str, messages: list[dict], gute_antworten: int, nachfrage_runde: int = 0, - provider: str = DEFAULT_PROVIDER, + frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, ) -> dict | None: - """Aktion 'antwort': Antwort beurteilen (Evaluator + Kritiker). + """Aktion 'antwort': Antwort bewerten (Evaluator + Kritiker). - Gibt {"status": gut|schlecht|nachfrage, "feedback", "frage", "bestanden"} · None bei Fehler. - `nachfrage_runde` = bisherige Folgefragen dieser Frage; bei erschöpftem Budget wird - ein erneutes „nachfrage" zu „schlecht" gezwungen (der Lerner konnte es nicht zeigen). + Gibt {"feedback", "bewertung": gut|schlecht, "bestanden"} · None bei Fehler. """ try: section_block, vertiefung_block = _bloecke(section, vertiefung) transcript = _transcript(messages) if messages else "(leer)" - rest = max(0, MAX_NACHFRAGEN - nachfrage_runde) - bew = await _bewertung_mit_kritik( + return await _bewertung_mit_kritik( topic, baustein, section_block, vertiefung_block, - frage.strip() or "(keine Frage übergeben)", transcript, gute_antworten, rest, provider, + frage.strip() or "(keine Frage übergeben)", transcript, gute_antworten, provider, ) - if bew and bew["status"] == "nachfrage" and rest <= 0: - return {"status": "schlecht", "feedback": bew["feedback"], "frage": "", "bestanden": False} - return bew except Exception: log.warning("[%s] Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True) return None diff --git a/backend/models.py b/backend/models.py index 7fb90d5..3c27edc 100644 --- a/backend/models.py +++ b/backend/models.py @@ -195,7 +195,6 @@ class BausteinPruefungRequest(BaseModel): 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) score_vor_frage: int = 0 # Score, als die Frage gestellt wurde → driftfreies (Re-)Bewerten - nachfrage_runde: int = 0 # bisherige Folgefragen dieser Frage (mündliche Prüfung) tier2: bool = False # ganzer Guide absolviert (alle ≥3) → −1 bei falsch, Deckel 10 tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, −2 bei falsch, Deckel 25 messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage diff --git a/backend/routes.py b/backend/routes.py index 0c5db09..440fd94 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -243,20 +243,14 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): 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, - nachfrage_runde=req.nachfrage_runde, provider=req.provider, + req.topic, req.baustein, req.section, vertiefung, req.frage, msgs, gute, provider=req.provider, ) if data is None: raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") - # Mündliche Prüfung: noch unklar → Folgefrage stellen, KEINE Wertung, kein Score. - if data["status"] == "nachfrage": - return {"frage": data["frage"], "feedback": data["feedback"], "bewertung": None, - "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} - # Score driftfrei aus dem Basis-Score rechnen (Re-Bewertung ersetzt das vorige Ergebnis). score = score_berechnen( - req.score_vor_frage, data["status"] == "gut", req.tier2, req.tier3, absolviert, gemeistert, + req.score_vor_frage, data["bewertung"] == "gut", req.tier2, req.tier3, absolviert, gemeistert, ) gute = await set_baustein_score(req.topic, req.baustein, score) if score >= NOETIG and not absolviert: @@ -269,7 +263,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if score >= MEISTERN and not gemeistert: await set_baustein_gemeistert(req.topic, req.baustein) gemeistert = True - return {"feedback": data["feedback"], "bewertung": data["status"], "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + return {"feedback": data["feedback"], "bewertung": data["bewertung"], "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} # --- Guides --- diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue index 355b714..ecc0462 100644 --- a/frontend/src/components/BausteinPanel.vue +++ b/frontend/src/components/BausteinPanel.vue @@ -80,7 +80,6 @@ const pruefLoading = ref(false) const aktuelleFrage = ref('') // ankert Bewertung/Diskussion const letztesFeedback = ref('') // Kontext für die Diskussion über eine Bewertung const scoreVorFrage = ref(0) // Score, als die aktuelle Frage gestellt wurde → driftfreies (Re-)Bewerten -const nachfrageRunde = ref(0) // mündliche Prüfung: bisherige Folgefragen dieser Frage const pruefMessagesEl = ref(null) const pruefInputEl = ref(null) const pruefStick = ref(true) // nur auto-scrollen, wenn der Nutzer (fast) unten ist @@ -134,7 +133,6 @@ function frageAnfordern() { aktuelleFrage.value = res.frage letztesFeedback.value = '' scoreVorFrage.value = res.gute_antworten // Basis für (Re-)Bewertung dieser Frage - nachfrageRunde.value = 0 // neue Frage → Folgefragen-Zähler zurücksetzen pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: res.frage }) pruefPhase.value = 'frage_offen' }) @@ -153,14 +151,6 @@ function nachfragen() { function bewerten(res) { letztesFeedback.value = res.feedback || '' - // Mündliche Prüfung: Folgefrage statt Wertung (bewertung null, frage gesetzt). - if (res.bewertung == null && res.frage) { - nachfrageRunde.value += 1 - const inhalt = (res.feedback ? res.feedback + '\n\n' : '') + res.frage - pruefMessages.value.push({ role: 'assistant', kind: 'folgefrage', content: inhalt }) - pruefPhase.value = 'frage_offen' // weiter antworten — kein Punkt - return - } pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung }) pruefPhase.value = 'bewertet' } @@ -168,7 +158,7 @@ function bewerten(res) { function antwortPayload() { return { aktion: 'antwort', frage: aktuelleFrage.value, score_vor_frage: scoreVorFrage.value, - nachfrage_runde: nachfrageRunde.value, tier2: props.tier2, tier3: props.tier3, + tier2: props.tier2, tier3: props.tier3, } } diff --git a/templates/Prompt/Baustein-Bewertung-Kritik.md b/templates/Prompt/Baustein-Bewertung-Kritik.md index 574f088..119d636 100644 --- a/templates/Prompt/Baustein-Bewertung-Kritik.md +++ b/templates/Prompt/Baustein-Bewertung-Kritik.md @@ -16,10 +16,11 @@ 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. +- 50%-Schwelle: Eine Antwort, die den Kern trifft und MINDESTENS zur Hälfte korrekt ist, MUSS "gut" sein. Nur weniger als die Hälfte oder klar falsch ist "schlecht". +- Material-Grenze: Die Bewertung darf NICHTS verlangen, was nicht aus Guide/Vertiefung folgt — kein Detail, keine Technik, kein Begriff außerhalb des Materials. Wurde der Lerner für solchen Stoff abgewertet → Fehlurteil. +- Zeigt der Lerner zu Recht, dass etwas nicht im Material steht, darf das NICHT als Fehler zählen. +- Konsistenz: Die Bewertung darf der Guide-Fassung und der Vertiefung nicht widersprechen, behauptet nichts Erfundenes. +- Hat der Lerner mit Material-Bezug recht, muss die Bewertung "gut" sein — auch gegen die Frage-Annahme. Beanstande NUR echte Fehlurteile. Ist die Bewertung fair und materialtreu, ist sie in Ordnung. diff --git a/templates/Prompt/Baustein-Bewertung.md b/templates/Prompt/Baustein-Bewertung.md index 2354540..8692bb7 100644 --- a/templates/Prompt/Baustein-Bewertung.md +++ b/templates/Prompt/Baustein-Bewertung.md @@ -1,4 +1,4 @@ -Du bist Prüfer in einer MÜNDLICHEN Prüfung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Dein Ziel: herausfinden, ob der Lerner die geprüfte Frage WIRKLICH versteht. Wie ein echter Prüfer fragst du nach, bevor du urteilst. +Du bewertest die Antwort eines Lerners auf die geprüfte Frage — Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". GEPRÜFTE FRAGE: {frage} @@ -14,27 +14,26 @@ STAND: {gute_antworten} von {noetig} Antworten waren bisher gut. PRÜFUNGS-VERLAUF (Antwort des Lerners und etwaige Diskussion): {transcript} -Beurteile den ganzen Verlauf — die Antwort(en) des Lerners auf die GEPRÜFTE FRAGE und etwaige Folgefragen/Diskussion. +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. Dein Urteil darf ihr NIE widersprechen. +SO BEWERTEST DU — die 50%-Schwelle: +- "gut" = die Antwort trifft den Kern der Frage und ist MINDESTENS zur Hälfte korrekt. Eine knappe, richtige Antwort reicht. +- "schlecht" = weniger als die Hälfte richtig, oder klar falsch. +- Die Hälfte misst sich an dem, was Guide und Vertiefung hergeben — NICHT an einer idealen Vollantwort. + +MATERIAL-GRENZE — WICHTIG: +- Guide-Fassung und Vertiefung sind die einzige fachliche Referenz. Bewerte AUSSCHLIESSLICH gegen sie. +- Verlange NICHTS, was nicht aus dem Material folgt. Kein Detail, keine Technik, kein Begriff, der dort nicht steht. +- Zeigt der Lerner zu Recht, dass etwas nicht im Material steht: das ist KEIN Fehler — nicht dafür abwerten. - 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. Hat er SACHLICH recht, gib es offen zu — aber gib NICHT aus Höflichkeit oder auf bloßes Beharren hin nach. +- Hat der Lerner mit Material-Bezug SACHLICH recht (auch gegen deine Annahme): gib es zu und werte "gut" — aber NICHT aus Höflichkeit oder auf bloßes Beharren hin. -DREI MÖGLICHE URTEILE (`status`): -- "gut" = die Antwort zeigt echtes Verständnis in eigenen Worten. Eine knappe, richtige Antwort reicht — keine Vollständigkeit über die Frage hinaus verlangen. -- "schlecht" = klar falsch, oder der Lerner zeigt (auch nach Nachfragen), dass er es nicht weiß. Beharren auf Falschem → schlecht. -- "nachfrage" = die Antwort ist UNVOLLSTÄNDIG, teilweise richtig oder unklar — du kannst noch nicht sicher sagen, ob er es versteht. Dann stelle EINE gezielte Folgefrage genau zum fehlenden/unklaren Punkt. Noch KEIN Urteil, noch keine Wertung. Gib dem Lerner so die Chance, sich zu retten. - -NACHFRAGE-BUDGET: Du hast noch {rest_nachfragen} Folgefragen übrig. Ist das 0, MUSST du dich für "gut" oder "schlecht" entscheiden — keine weitere Nachfrage. - -REGELN FÜR FELDER: -- `feedback`: max. 1 Satz, sprich den Lerner direkt an. Bei "nachfrage" kurz, was noch fehlt; bei gut/schlecht, warum. -- `frage`: NUR bei status "nachfrage" — genau EINE kurze, gezielte Folgefrage. Sonst leer. +FELDER: +- `feedback`: max. 1 Satz, sprich den Lerner direkt an. Begründe knapp, warum gut oder schlecht. KEINE neue Frage. - `bestanden`: true NUR, wenn du schon vor den {noetig} guten Antworten sicher bist, dass der Lerner den Baustein versteht. Im Zweifel false. HINWEISE DES PRÜFERS ZUR LETZTEN FASSUNG: {kritik_block} Gib NUR dieses JSON aus (kein weiterer Text): -{{"status": "gut" | "schlecht" | "nachfrage", "feedback": "ein Satz", "frage": "Folgefrage oder leer", "bestanden": false}} +{{"feedback": "ein Satz", "bewertung": "gut" | "schlecht", "bestanden": false}}