From 0868a768a2cfec5458a57d5d89ea41216885eff8 Mon Sep 17 00:00:00 2001 From: team3 Date: Fri, 19 Jun 2026 14:04:19 +0200 Subject: [PATCH] update --- backend/lernen.py | 110 +++++++++------- backend/models.py | 10 +- backend/routes.py | 62 +++++---- frontend/src/api.js | 4 +- frontend/src/components/BausteinPanel.vue | 124 ++++++++++++------ templates/Prompt/Baustein-Bewertung-Kritik.md | 23 ++-- templates/Prompt/Baustein-Bewertung.md | 43 +++--- 7 files changed, 219 insertions(+), 157 deletions(-) diff --git a/backend/lernen.py b/backend/lernen.py index 9aa546a..ea2cb71 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -39,53 +39,41 @@ FRAGETYPEN = { } -def score_berechnen( - score_vor_frage: int, gut: bool, streak: int, tier2: bool, tier3: bool, - absolviert: bool, verstanden: bool, gemeistert: bool, -) -> int: - """Neuer Score nach einer Antwort · driftfrei (immer aus dem Basis-Score gerechnet). +# Antwort-Niveau (wie sehr der Kern getroffen ist) → Punkt-Delta. +NIVEAUS = { + "kaum": -1, # < 25 % richtig + "teilweise": 0, # 25–49 % + "solide": 1, # 50–74 % + "stark": 2, # 75–99 % + "komplett": 3, # 100 % +} - Drei Stufen, freigeschaltet über Guide-Flags: - - Tier 1 (tier2=False): +1 bei richtig, KEINE Strafe, Deckel NOETIG (3). - - Tier 2 (tier2, nicht tier3): +1 / −1, Boden 3, Deckel MASTERY (10). - - Tier 3 (tier3, Meisterpfad): +1 / −2, Boden 10, Deckel MEISTERN (25). - Streak-Bonus bei richtig: ab 3 in Folge +2 (Tier 2/3), ab 5 in Folge +3 (Tier 3). - `streak` = bisherige Folge VOR dieser Antwort; mit dieser Antwort zählt streak+1. - Boden vor dem Absolvieren ist 0; gemeistert friert bei MEISTERN ein. - Re-Bewertung nutzt denselben Basis-Score + dieselbe Streak (kein Doppelzählen). + +def score_berechnen( + score_vor_frage: int, niveau: str, nachgefragt: bool, + tier2: bool, tier3: bool, absolviert: bool, verstanden: bool, gemeistert: bool, +) -> int: + """Neuer Score · driftfrei (immer aus dem Basis-Score). Delta aus dem Antwort-Niveau: + je besser, desto mehr (−1/0/+1/+2/+3). Bei Nachfragen ist der Gewinn auf +1 gedeckelt. + Danach auf Boden/Deckel der aktiven Stufe geklemmt (3 / 10 / 25). Kein Streak mehr. """ if gemeistert: return MEISTERN - # Stufe richtet sich nach dem Baustein-Fortschritt, nicht nur nach den - # Guide-Flags: ein frischer Baustein durchläuft erst Tier 1 (0→3), auch - # wenn der Guide schon tier2/tier3 freigibt. Tier 2 erst ab absolviert, - # Tier 3 erst ab verstanden. + # Stufe nach Baustein-Fortschritt: frisch erst Tier 1 (0→3), dann ab absolviert/verstanden. tier2 = tier2 and absolviert tier3 = tier3 and verstanden if not tier2: - floor, cap, strafe = (NOETIG if absolviert else 0), NOETIG, 0 + floor, cap = (NOETIG if absolviert else 0), NOETIG elif not tier3: - floor, cap, strafe = NOETIG, MASTERY, -1 + floor, cap = NOETIG, MASTERY else: - floor, cap, strafe = MASTERY, MEISTERN, -2 - if gut: - s = streak + 1 - delta = 3 if (tier3 and s >= 5) else (2 if (tier2 and s >= 3) else 1) - else: - delta = strafe + floor, cap = MASTERY, MEISTERN + delta = NIVEAUS.get(niveau, 0) + if nachgefragt: + delta = min(delta, 1) # mit Hilfe (Nachfrage) höchstens +1 return max(floor, min(cap, score_vor_frage + delta)) -def streak_berechnen(streak_vor: int, gut: bool, tier2: bool, tier3: bool, neu_absolviert: bool, neu_verstanden: bool) -> int: - """Neue Streak nach einer Antwort · persistiert. Reset NUR am Deckel der aktiven Stufe: - Tier 1 → bei neu-absolviert (3), Tier 2 → bei neu-verstanden (10). Sonst läuft sie durch.""" - if not gut: - return 0 - if (not tier2 and neu_absolviert) or (tier2 and not tier3 and neu_verstanden): - return 0 - return streak_vor + 1 - - def _transcript(messages: list[dict]) -> str: return "\n".join( f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}" @@ -124,14 +112,14 @@ def _frage_schema(data) -> dict | None: def _bewertung_schema(data) -> dict | None: - """{"feedback": str, "bewertung": "gut"|"schlecht", "bestanden": bool} · sonst None.""" + """{"feedback": str, "niveau": ∈ NIVEAUS} · sonst None.""" if not isinstance(data, dict): return None feedback = str(data.get("feedback", "")).strip() - bewertung = data.get("bewertung") - if not feedback or bewertung not in ("gut", "schlecht"): + niveau = data.get("niveau") + if not feedback or niveau not in NIVEAUS: return None - return {"feedback": feedback, "bewertung": bewertung, "bestanden": data.get("bestanden") is True} + return {"feedback": feedback, "niveau": niveau} async def _gen_call(name: str, role: str, schema, provider: str, timeout: int = PRUEFUNG_TIMEOUT, **kwargs) -> dict | None: @@ -164,7 +152,7 @@ def _kritik_block(vorversion: str, probleme: list[str]) -> str: def _bewertung_text(bew: dict) -> str: - return f"Bewertung: {bew['bewertung']}\nFeedback: {bew['feedback']}" + return f"Niveau: {bew['niveau']}\nFeedback: {bew['feedback']}" # Deterministischer Guard gegen Doppelfragen — der KI-Kritiker übersieht „…, und welchen…". @@ -215,12 +203,12 @@ async def _frage_mit_kritik( async def _bewertung_mit_kritik( topic: str, baustein: str, section_block: str, kompakt_block: str, - frage: str, transcript: str, gute_antworten: int, provider: str, role: str = "judge", + frage: str, transcript: str, begruendung_block: str, provider: str, role: str = "judge", ) -> dict | None: - """Antwort bewerten (gut/schlecht), vom Kritiker prüfen lassen, bei Fehlurteil neu. + """Antwort bewerten (Niveau), vom Kritiker prüfen lassen, bei Fehlurteil neu. - `frage` ankert die geprüfte Frage; der Dialog (transcript) liefert die Antwort und - eine etwaige Diskussion — so kann eine Re-Bewertung das Argument sehen. + `frage` ankert die geprüfte Frage; der Dialog (transcript) liefert Antwort + Diskussion. + `begruendung_block` = optionale Unzufriedenheit des Lerners (nur bei „Gründlich prüfen"). `role` = "judge" (schnell) oder "guide" (gründlich, starkes Modell mit Thinking). """ timeout = GRUENDLICH_TIMEOUT if role == "guide" else PRUEFUNG_TIMEOUT @@ -231,7 +219,7 @@ async def _bewertung_mit_kritik( "Baustein-Bewertung", role, _bewertung_schema, provider, timeout, topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, frage=frage, transcript=transcript, - gute_antworten=gute_antworten, noetig=NOETIG, kritik_block=kritik_block, + begruendung_block=begruendung_block, kritik_block=kritik_block, ) if bew is None: return None @@ -285,22 +273,42 @@ async def pruefung_frage( return None +async def pruefung_bewertung_schnell( + topic: str, baustein: str, section: str, kompakt: str | None, + frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER, +) -> dict | None: + """Aktion 'antwort' (Agent 1, schnell): nur Evaluator, kein Kritiker. → {feedback, niveau}.""" + try: + section_block, kompakt_block = _bloecke(section, kompakt) + transcript = _transcript(messages) if messages else "(leer)" + return await _gen_call( + "Baustein-Bewertung", "judge", _bewertung_schema, provider, + topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, + frage=frage.strip() or "(keine Frage übergeben)", transcript=transcript, + begruendung_block="(keine)", kritik_block="(keine)", + ) + except Exception: + log.warning("[%s] Schnell-Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True) + return None + + async def pruefung_bewertung( topic: str, baustein: str, section: str, kompakt: str | None, - frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, - role: str = "judge", + frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER, + role: str = "judge", begruendung: str = "", ) -> dict | None: - """Aktion 'antwort_pruefen': verbindlich bewerten (Evaluator + Kritiker). + """Aktion 'antwort_pruefen' (Agent 2, genau): Evaluator + Kritiker. → {feedback, niveau}. - `role` = "guide" für „Gründlich prüfen" (starkes Modell), sonst "judge". - Gibt {"feedback", "bewertung": gut|schlecht, "bestanden"} · None bei Fehler. + `role` = "guide" für „Gründlich prüfen" (starkes Modell). `begruendung` = optionale + Unzufriedenheit des Lerners mit einer früheren Bewertung. """ try: section_block, kompakt_block = _bloecke(section, kompakt) transcript = _transcript(messages) if messages else "(leer)" + begruendung_block = begruendung.strip() or "(keine)" return await _bewertung_mit_kritik( topic, baustein, section_block, kompakt_block, - frage.strip() or "(keine Frage übergeben)", transcript, gute_antworten, provider, role, + frage.strip() or "(keine Frage übergeben)", transcript, begruendung_block, provider, role, ) except Exception: log.warning("[%s] Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True) diff --git a/backend/models.py b/backend/models.py index dadad3c..70f9882 100644 --- a/backend/models.py +++ b/backend/models.py @@ -214,11 +214,13 @@ class BausteinPruefungRequest(BaseModel): baustein: str = Field(min_length=1, max_length=200) section: str = Field(default="", max_length=20000) # ausführliche Fassung section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze) - aktion: Literal["frage", "diskussion", "antwort_pruefen"] = "frage" + aktion: Literal["frage", "diskussion", "antwort", "antwort_pruefen"] = "frage" frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort); Anker der Basis letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion) vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen - # Basis/Streak werden serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client. + nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt + begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden + # Basis wird serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client. 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 @@ -230,9 +232,9 @@ class BausteinPruefungResponse(BaseModel): frage: str | None = None reply: str | None = None feedback: str | None = None - bewertung: Literal["gut", "schlecht"] | None = None + niveau: str | None = None # kaum|teilweise|solide|stark|komplett + bewertung: Literal["gut", "neutral", "schlecht"] | None = None # abgeleitet, nur fürs Einfärben gute_antworten: int - streak: int | None = None # nur antwort_pruefen setzt die persistierte Streak absolviert: bool verstanden: bool = False gemeistert: bool = False diff --git a/backend/routes.py b/backend/routes.py index 9054b12..451bf73 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -14,11 +14,11 @@ from database import ( list_progress, set_progress, delete_progress, create_element, list_elements, get_element, update_element, delete_element, list_baustein_progress, get_baustein_progress, set_offene_frage, - set_baustein_score, set_baustein_streak, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten, + set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten, ) from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion -from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_frage, score_berechnen, streak_berechnen +from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, score_berechnen 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, thema_abgeschlossen @@ -247,14 +247,16 @@ def _pruef_lock(topic: str, baustein: str) -> asyncio.Lock: return lock -def _basis(stand: dict, frage: str) -> tuple[int, int, bool]: - """Score-/Streak-Basis VOR der Frage. Gleiche offene Frage → Re-Bewertung auf derselben - Basis (idempotent); sonst neue Frage auf dem aktuellen Stand. → (basis, streak_basis, re_bewertung).""" +def _basis(stand: dict, frage: str) -> tuple[int, bool]: + """Score-Basis VOR der Frage. Gleiche offene Frage → Re-Bewertung auf derselben Basis + (idempotent); sonst neue Frage auf dem aktuellen Stand. → (basis, re_bewertung).""" re_bewertung = stand["offene_frage"] == frage and stand["offene_basis"] is not None - if re_bewertung: - streak_basis = stand["offene_streak"] if stand["offene_streak"] is not None else stand["streak"] - return stand["offene_basis"], streak_basis, True - return stand["gute_antworten"], stand["streak"], False + return (stand["offene_basis"] if re_bewertung else stand["gute_antworten"]), re_bewertung + + +def _farbe(niveau: str) -> str: + """Niveau → grobe Einfärbung der Bubble.""" + return "gut" if niveau in ("solide", "stark", "komplett") else ("neutral" if niveau == "teilweise" else "schlecht") @router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse) @@ -285,36 +287,38 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen") return {"reply": reply, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} - # aktion == "antwort_pruefen" — mindestens eine Nutzer-Antwort muss im Dialog stehen - # (nach einer Diskussion endet der Dialog mit dem Tutor; Re-Bewertung bleibt erlaubt). + # aktion "antwort" (Agent 1 schnell) / "antwort_pruefen" (Agent 2 genau). 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") + genau = req.aktion == "antwort_pruefen" - # Verbindlich, persistiert. Serialisiert je Baustein, damit die Basis immer den - # geprüften Vorgänger-Stand sieht (kein Stale-Overwrite). Punkte kommen NUR hier. + # Persist serialisiert je Baustein, Basis driftfrei über den offene_frage-Anker + # (schnell setzt den Anker, genau re-bewertet dieselbe Frage). Beide vergeben Punkte. async with _pruef_lock(req.topic, req.baustein): - stand = await get_baustein_progress(req.topic, req.baustein) # nach evtl. Vorgänger-Persist neu lesen + stand = await get_baustein_progress(req.topic, req.baustein) absolviert = stand["absolviert"] is not None verstanden = stand["verstanden"] is not None gemeistert = stand["gemeistert"] is not None - basis, streak_basis, re_bewertung = _basis(stand, req.frage) + basis, re_bewertung = _basis(stand, req.frage) if not re_bewertung: - # Neue Frage: Basis/Streak einfrieren (Anker für spätere Re-Bewertung dieser Frage). - await set_offene_frage(req.topic, req.baustein, req.frage, basis, streak_basis) + await set_offene_frage(req.topic, req.baustein, req.frage, basis, 0) - data = await pruefung_bewertung( - req.topic, req.baustein, req.section, kompakt, req.frage, msgs, basis, provider=req.provider, - role="guide" if req.gruendlich else "judge", - ) + if genau: + data = await pruefung_bewertung( + req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider, + role="guide" if req.gruendlich else "judge", begruendung=req.begruendung, + ) + else: + data = await pruefung_bewertung_schnell( + req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider, + ) if data is None: raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") - score = score_berechnen( - basis, data["bewertung"] == "gut", streak_basis, req.tier2, req.tier3, absolviert, verstanden, gemeistert, - ) - war_absolviert, war_verstanden = absolviert, verstanden + niveau = data["niveau"] + score = score_berechnen(basis, niveau, req.nachgefragt, req.tier2, req.tier3, absolviert, verstanden, gemeistert) gute = await set_baustein_score(req.topic, req.baustein, score) if score >= NOETIG and not absolviert: absolviert = True @@ -326,13 +330,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if score >= MEISTERN and not gemeistert: await set_baustein_gemeistert(req.topic, req.baustein) gemeistert = True - # Streak persistieren — Reset nur am Deckel der gerade aktiven Stufe. - streak = streak_berechnen( - streak_basis, data["bewertung"] == "gut", req.tier2, req.tier3, - absolviert and not war_absolviert, verstanden and not war_verstanden, - ) - await set_baustein_streak(req.topic, req.baustein, streak) - return {"feedback": data["feedback"], "bewertung": data["bewertung"], "gute_antworten": gute, "streak": streak, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + return {"feedback": data["feedback"], "niveau": niveau, "bewertung": _farbe(niveau), "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} # --- Guides --- diff --git a/frontend/src/api.js b/frontend/src/api.js index 9b355a5..de5d02a 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -78,12 +78,12 @@ export async function chatBaustein({ topic, baustein, section, section_kompakt = export async function pruefeBaustein({ topic, baustein, section, section_kompakt = '', provider, aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [], - tier2 = false, tier3 = false, messages = [], gruendlich = false, + nachgefragt = false, begruendung = '', tier2 = false, tier3 = false, messages = [], gruendlich = false, }) { const res = await fetch(`${BASE}/bausteine/pruefung`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, tier2, tier3, messages, provider, gruendlich }), + body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, tier2, tier3, messages, provider, gruendlich }), }) return jsonOrThrow(res) } diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue index 0638281..94468b1 100644 --- a/frontend/src/components/BausteinPanel.vue +++ b/frontend/src/components/BausteinPanel.vue @@ -56,6 +56,9 @@ const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion üb // Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner. const pruefInput = ref('') const pruefLoading = ref(false) +const nachgefragt = ref(false) // für die laufende Frage wurde nachgefragt → Gewinn max +1 +const gruendlichMsg = ref(null) // Feedback-Bubble, für die das Gründlich-Feld offen ist +const gruendlichText = ref('') // optionale Begründung für „Gründlich prüfen" const pruefMessagesEl = ref(null) const pruefInputEl = ref(null) @@ -109,6 +112,8 @@ async function pruefSenden(payload, onOk) { function frageZeigen(text) { aktuelleFrage.value = text letztesFeedback.value = '' + nachgefragt.value = false + gruendlichMsg.value = null pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: text }) pruefPhase.value = 'frage_offen' } @@ -206,6 +211,7 @@ async function naechsteFrageZeigen() { function nachfragen() { const text = pruefInput.value.trim() if (!text || pruefLoading.value) return + nachgefragt.value = true // Hilfe genutzt → Gewinn für diese Frage max +1 pruefMessages.value.push({ role: 'user', kind: 'nachfrage', content: text }) pruefInput.value = '' pruefSenden( @@ -214,13 +220,15 @@ function nachfragen() { ) } -let letzteFeedbackMsg = null // Referenz auf die zuletzt gezeigte Bewertungs-Bubble -let pruefenRun = 0 // nur die jüngste Bewertung darf die Anzeige setzen +let letzteFeedbackMsg = null // zuletzt gezeigte Bewertungs-Bubble +let pruefenRun = 0 // nur die jüngste Schnell-Bewertung darf anzeigen -// Antwort verbindlich prüfen — der Prüfer (Bewerter + Kritiker) vergibt die Punkte. -// Während des Wartens zeigt das „…"-Bubble den Geprüft-Zustand. gruendlich=true: -// starkes Modell, re-bewertet dieselbe Frage und aktualisiert die bestehende Bubble. -async function bewertenLassen(gruendlich = false) { +function bewertungPayload() { + return { frage: aktuelleFrage.value, tier2: props.tier2, tier3: props.tier3, nachgefragt: nachgefragt.value } +} + +// Agent 1 (schnell): sofortiges Niveau + Punkte zeigen, dann Agent 2 (genau) im Hintergrund. +async function schnellBewerten() { const meine = ++pruefenRun pruefStick.value = true pruefLoading.value = true @@ -228,22 +236,17 @@ async function bewertenLassen(gruendlich = false) { try { const res = await pruefeBaustein({ topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, - provider: props.provider, messages: pruefDialog(), - aktion: 'antwort_pruefen', frage: aktuelleFrage.value, tier2: props.tier2, tier3: props.tier3, gruendlich, + provider: props.provider, messages: pruefDialog(), aktion: 'antwort', ...bewertungPayload(), }) if (meine !== pruefenRun) return applyPruefung(res) letztesFeedback.value = res.feedback || '' - if (gruendlich && letzteFeedbackMsg) { - letzteFeedbackMsg.content = res.feedback || letzteFeedbackMsg.content - letzteFeedbackMsg.bewertung = res.bewertung - } else { - pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung, geprueft: true }) - letzteFeedbackMsg = pruefMessages.value[pruefMessages.value.length - 1] - pruefPhase.value = 'bewertet' - } + pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung, niveau: res.niveau, geprueft: false }) + letzteFeedbackMsg = pruefMessages.value[pruefMessages.value.length - 1] + pruefPhase.value = 'bewertet' pruefScroll() nextTick(() => pruefInputEl.value?.focus()) + genauBewerten() // Agent 2 korrigiert im Hintergrund } catch { if (meine === pruefenRun) pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' }) } finally { @@ -251,12 +254,36 @@ async function bewertenLassen(gruendlich = false) { } } +// Agent 2 (genau): Bewerter + Kritiker. Korrigiert Bubble + Score (Server-Wahrheit, immer übernehmen). +// gruendlich=true: starkes Modell, optional mit Begründung des Lerners. +async function genauBewerten(gruendlich = false, begruendung = '') { + const ziel = letzteFeedbackMsg + if (gruendlich) pruefLoading.value = true + try { + const res = await pruefeBaustein({ + topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, + provider: props.provider, messages: pruefDialog(), aktion: 'antwort_pruefen', ...bewertungPayload(), gruendlich, begruendung, + }) + applyPruefung(res) + if (ziel) { + ziel.content = res.feedback || ziel.content + ziel.bewertung = res.bewertung + ziel.niveau = res.niveau + ziel.geprueft = true + } + } catch { + if (ziel) ziel.geprueft = true + } finally { + if (gruendlich) pruefLoading.value = false + } +} + function antwortAbgeben() { const text = pruefInput.value.trim() if (!text || pruefLoading.value) return pruefMessages.value.push({ role: 'user', kind: 'antwort', content: text }) pruefInput.value = '' - bewertenLassen(false) + schnellBewerten() } // Lief schon eine Diskussion? Dann darf der Verlauf direkt bewertet werden. @@ -265,17 +292,25 @@ const hatDiskussion = computed(() => pruefMessages.value.some((m) => m.kind === // „Verlauf bewerten": den bisherigen Dialog als Antwort werten (Feld wird ignoriert). function verlaufBewerten() { if (pruefLoading.value) return - bewertenLassen(false) + schnellBewerten() } -// „Gründlich prüfen": dieselbe Antwort vom starken Modell (role guide) neu bewerten. -function gruendlichPruefen() { +// Rechtsklick auf eine Bewertung → Gründlich-Feld öffnen; Absenden re-bewertet (mit Begründung). +function gruendlichOeffnen(msg) { + gruendlichText.value = '' + gruendlichMsg.value = msg + nextTick(() => document.getElementById('bp-gruendlich-input')?.focus()) +} +function gruendlichAbsenden() { if (pruefLoading.value) return - bewertenLassen(true) + letzteFeedbackMsg = gruendlichMsg.value || letzteFeedbackMsg + const text = gruendlichText.value.trim() + gruendlichMsg.value = null + genauBewerten(true, text) } // Abbruch wie im Chat: Run-Counter hochzählen → laufendes Ergebnis (pruefSenden -// und bewertenLassen) wird verworfen, Buttons sofort frei. Agent läuft serverseitig aus. +// und schnellBewerten) wird verworfen, Buttons sofort frei. Agent läuft serverseitig aus. function pruefAbbrechen() { if (!pruefLoading.value) return pruefRun++ @@ -298,10 +333,9 @@ onUnmounted(() => { window.removeEventListener('keydown', onPruefKey) }) -// ALT+1 Nachfragen/Nachhaken · ALT+2 primärer Button je Phase (idle: Frage anfordern, -// frage_offen: Antwort abgeben, bewertet: Nächste Frage) · ALT+3 Verlauf bewerten / Gründlich. -// window-Listener (nicht div), damit es auch ohne Fokus im Feld greift + den Browser-Default -// (Tab-Wechsel) unterdrückt. Nur im Prüfungs-Tab. +// ALT+1 Nachfragen (nur frage_offen) · ALT+2 primärer Button je Phase (idle: Frage anfordern, +// frage_offen: Antwort abgeben, bewertet: Nächste Frage) · ALT+3 Verlauf bewerten (frage_offen). +// Gründlich prüfen läuft per Rechtsklick auf die Bewertung, nicht über ALT. function onPruefKey(e) { if (!e.altKey || e.repeat) return if (props.mode !== 'full' || activeTab.value !== 'pruefung') return @@ -309,14 +343,13 @@ function onPruefKey(e) { e.preventDefault() if (pruefLoading.value) return if (e.key === '1') { - if (pruefPhase.value !== 'idle' && pruefInput.value.trim()) nachfragen() + if (pruefPhase.value === 'frage_offen' && pruefInput.value.trim()) nachfragen() } else if (e.key === '2') { if (pruefPhase.value === 'idle') frageAnfordern() else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() } else naechsteFrageZeigen() } else if (e.key === '3') { - if (pruefPhase.value === 'bewertet') gruendlichPruefen() - else if (pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten() + if (pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten() } } @@ -372,16 +405,23 @@ function onPruefKey(e) {

- + - + - +

@@ -530,8 +568,18 @@ function onPruefKey(e) { border: 1px solid var(--border); } .bp-pruefend { font-style: italic; opacity: 0.7; font-size: 0.92em; } +.bp-feedback { cursor: context-menu; } .bp-feedback.gut { background: var(--success-soft); border-color: var(--success-border); color: var(--success); } +.bp-feedback.neutral { background: var(--warning-soft); border-color: var(--warning-border); color: var(--warning); } .bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); } +.bp-niveau { font-weight: 700; text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.03em; margin-right: 0.4rem; opacity: 0.85; } +/* Gründlich-prüfen-Feld (per Rechtsklick) innerhalb der Bewertungs-Bubble */ +.bp-gruendlich { display: flex; gap: 0.3rem; margin-top: 0.4rem; align-items: center; } +.bp-gruendlich input { + flex: 1; min-width: 0; padding: 0.3rem 0.5rem; font: inherit; font-size: 0.8rem; + border: 1px solid var(--border-strong); border-radius: 6px; background: var(--panel); color: var(--text); +} +.bp-gruendlich .bp-action { margin-top: 0; } .bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; } .bp-input textarea { diff --git a/templates/Prompt/Baustein-Bewertung-Kritik.md b/templates/Prompt/Baustein-Bewertung-Kritik.md index 346ace6..e5df238 100644 --- a/templates/Prompt/Baustein-Bewertung-Kritik.md +++ b/templates/Prompt/Baustein-Bewertung-Kritik.md @@ -12,19 +12,22 @@ KOMPAKTE FASSUNG (Merksätze, falls vorhanden): PRÜFUNGS-VERLAUF (Antwort des Lerners und etwaige Diskussion): {transcript} -ZU PRÜFENDE BEWERTUNG: +ZU PRÜFENDE BEWERTUNG (enthält das vergebene Niveau): {bewertung_block} -PRÜFE GEGEN DIESE KRITERIEN: -- 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". -- Kein Fordern über das Material hinaus: Die Bewertung darf NICHTS verlangen, was nicht aus Guide/Vertiefung folgt. Wurde der Lerner für fehlenden guide-externen Stoff abgewertet → Fehlurteil. „Nicht im Material" darf nie zu seinen Lasten gehen. -- Kein Ablese-Test: Wurde eine sachlich RICHTIGE Antwort als "schlecht" gewertet, nur weil sie nicht den Guide-Begriff nutzt oder über den Guide hinausgeht → Fehlurteil. Korrekte Synonyme und gleichbedeutende Begriffe müssen "gut" sein. -- Asymmetrie: Weltwissen darf nur zum ANERKENNEN richtiger Antworten dienen, nie zum strengeren Fordern. -- "schlecht" ist nur fair, wenn die Antwort sachlich falsch ist oder dem Material widerspricht. -- KEIN Durchwinken: Wurde eine sachlich FALSCHE oder dem Material widersprechende Antwort als "gut" gewertet → Fehlurteil. Prüfe die Antwort SELBST auf Korrektheit (gegen Material UND Logik), nicht nur die Fairness — in BEIDE Richtungen. -- Widerspruchs-Check: Widerspricht sich das Feedback selbst (bejaht die Antwort UND nennt zugleich die Gegen-Lösung) oder passt es nicht zur Bewertung → Fehlurteil. +NIVEAU-SKALA (Anteil des getroffenen Kerns): +- kaum < 25 % · teilweise 25–49 % · solide 50–74 % · stark 75–99 % · komplett 100 %. -Beanstande NUR echte Fehlurteile. Ist die Bewertung fair, korrekt und materialtreu, ist sie in Ordnung. +PRÜFE GEGEN DIESE KRITERIEN: +- Passt das Niveau zum tatsächlichen Anteil des Kerns? Zu STRENG (richtige/vollständige Antwort zu niedrig) ODER zu MILDE (falsche/dünne Antwort zu hoch) → Fehlurteil. Beide Richtungen prüfen. +- Kern sachlich FALSCH (Gegenteil) → muss "kaum" sein, egal wie selbstsicher. +- Kein Fordern über das Material hinaus: „Nicht im Material" darf nie zu Lasten gehen. +- Kein Ablese-Test: sachlich RICHTIG mit anderen Worten/Synonymen ist voll zu werten, nicht abzuwerten. +- Asymmetrie: Weltwissen nur zum ANERKENNEN richtiger Antworten, nie zum strengeren Fordern. +- Widerspruchs-Check: Feedback passt zum Niveau und widerspricht sich nicht. +- Prüfe die Antwort SELBST auf Korrektheit (Material UND Logik), nicht nur die Fairness. + +Beanstande NUR echte Fehlurteile. Ist das Niveau fair, korrekt und materialtreu, ist es in Ordnung. Gib NUR JSON aus (kein weiterer Text): - Bewertung in Ordnung: {{"ok": true}} diff --git a/templates/Prompt/Baustein-Bewertung.md b/templates/Prompt/Baustein-Bewertung.md index 79b07ff..22b49b0 100644 --- a/templates/Prompt/Baustein-Bewertung.md +++ b/templates/Prompt/Baustein-Bewertung.md @@ -9,38 +9,41 @@ BAUSTEIN AUS DEM GUIDE: KOMPAKTE FASSUNG (Merksätze, falls vorhanden): {kompakt_block} -STAND: {gute_antworten} von {noetig} Antworten waren bisher gut. - 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. +UNZUFRIEDENHEIT DES LERNERS MIT EINER FRÜHEREN BEWERTUNG (falls vorhanden — ernst nehmen, aber nur nachgeben, wenn er sachlich recht hat): +{begruendung_block} + +Bewerte die Antwort auf die GEPRÜFTE FRAGE — auf Basis der Antwort UND der Diskussion im Verlauf. FINALER STAND ZÄHLT — nicht die erste Aussage: - Bewerte den Erkenntnisstand, den der Lerner am ENDE des Verlaufs SELBST erreicht hat. -- Eine zuerst falsche, dann im Dialog selbst korrigierte und richtig formulierte Einsicht ist "gut". Die Diskussion ist ein erlaubter Lernweg. -- ABER: Hat der Tutor die Lösung vorgesagt und der Lerner sie nur bestätigt oder nachgesprochen ("ja", "genau", bloße Wiederholung), ist das "schlecht". Der tragende Schluss muss erkennbar vom Lerner kommen. +- Zuerst falsch, dann im Dialog selbst korrigiert = zählt (erlaubter Lernweg). +- ABER: Hat der Tutor die Lösung vorgesagt und der Lerner sie nur nachgesprochen ("ja", "genau", bloße Wiederholung), zählt sie NICHT. Der tragende Schluss muss vom Lerner kommen. -SO BEWERTEST DU — die 50%-Schwelle: -- "gut" = der Kern der Antwort ist sachlich RICHTIG und mindestens zur Hälfte korrekt. Eine knappe, richtige Antwort reicht. -- "schlecht" = weniger als die Hälfte richtig, oder klar falsch. -- Ist der Kern sachlich FALSCH (z. B. das Gegenteil der richtigen Aussage), ist das "schlecht" — egal wie selbstsicher es formuliert ist. Die Lockerheit unten gilt NUR für richtige Inhalte, nie um Falsches zu retten. -- Die Hälfte misst sich an dem, was die FRAGE verlangt — in JEDER korrekten Formulierung, nicht am Wortlaut des Guides. KEINE ideale Vollantwort verlangen. -- MASSSTAB = die ursprünglich GEPRÜFTE FRAGE, NICHT tiefere Folge-Fragen aus der Diskussion. Hat der Lerner die gestellte Frage korrekt beantwortet, ist es "gut" — auch wenn der Tutor danach weiter gebohrt hat und ein noch genaueres Detail offenblieb. Tieferes Bohren erhöht die Messlatte NICHT. +NIVEAU — wie viel vom KERN der Frage ist richtig getroffen? Wähle GENAU EINS: +- "kaum": unter 25 % — fast nichts richtig, oder klar falsch / das Gegenteil. +- "teilweise": 25–49 % — ein Bruchstück stimmt, der Kern fehlt. +- "solide": 50–74 % — der Kern ist richtig getroffen, Details fehlen. +- "stark": 75–99 % — größtenteils vollständig und richtig, nur eine kleine Lücke. +- "komplett": 100 % — der Kern ist vollständig und korrekt beantwortet. + +MASSSTAB: +- Gemessen an dem, was die FRAGE verlangt — in JEDER korrekten Formulierung, nicht am Wortlaut des Guides. Für "komplett" muss die Frage erfüllt sein, mehr nicht — verlange keine ideale Vollantwort. +- Ist der Kern sachlich FALSCH (Gegenteil), ist es "kaum" — egal wie selbstsicher formuliert. +- Maßstab ist die ursprünglich GEPRÜFTE FRAGE, nicht tiefere Folge-Fragen. Tieferes Bohren erhöht die Messlatte NICHT. DU PRÜFST VERSTÄNDNIS, NICHT ABLESEN — asymmetrische Material-Grenze: -- FORDERN: Verlange NIE mehr, als Frage und Material hergeben. Kein Detail, keine Technik, kein Begriff, der über das Material hinausgeht, darf gefordert werden. Zeigt der Lerner zu Recht, dass etwas nicht im Material steht → KEIN Fehler. -- AKZEPTIEREN: Eine sachlich RICHTIGE Antwort ist "gut" — auch mit anderen Worten als der Guide oder mit korrektem Wissen ÜBER den Guide hinaus. Korrekte Synonyme und gleichbedeutende Begriffe zählen voll. Mehr-richtig ist ein Plus, NIE ein Minus. -- "schlecht" NUR, wenn die Antwort sachlich FALSCH ist oder dem Material WIDERSPRICHT — NIEMALS, weil sie nicht den Guide-Begriff nutzt. -- WELTWISSEN: Nutze es, um eine richtige Antwort als "gut" ANZUERKENNEN. Nutze es NIE, um mehr zu fordern oder strenger zu prüfen. -- Behaupte selbst nichts Erfundenes. Gib nach, wenn der Lerner sachlich recht hat — aber NICHT aus Höflichkeit oder auf bloßes Beharren hin. +- FORDERN: Verlange NIE mehr, als Frage und Material hergeben. „Nicht im Material" geht nie zu Lasten des Lerners. +- AKZEPTIEREN: Sachlich RICHTIG zählt hoch — auch mit anderen Worten oder korrektem Wissen über den Guide hinaus. Synonyme zählen voll. +- WELTWISSEN: nur zum ANERKENNEN richtiger Antworten, nie zum strengeren Fordern. +- Behaupte nichts Erfundenes. Gib nach, wenn der Lerner sachlich recht hat — nicht aus Höflichkeit oder auf bloßes Beharren. -FELDER: -- `feedback`: max. 1 Satz, sprich den Lerner direkt an. Begründe knapp, warum gut oder schlecht. KEINE neue Frage. Kein Widerspruch — bejahe nicht die Antwort und nenne zugleich die Gegen-Lösung; entscheide dich. -- `bestanden`: true NUR, wenn du schon vor den {noetig} guten Antworten sicher bist, dass der Lerner den Baustein versteht. Im Zweifel false. +FELD `feedback`: max. 1 Satz, sprich den Lerner direkt an. Begründe knapp das Niveau. KEINE neue Frage. Kein Widerspruch — bejahe nicht die Antwort und nenne zugleich die Gegen-Lösung. HINWEISE DES PRÜFERS ZUR LETZTEN FASSUNG: {kritik_block} Gib NUR dieses JSON aus (kein weiterer Text): -{{"feedback": "ein Satz", "bewertung": "gut" | "schlecht", "bestanden": false}} +{{"feedback": "ein Satz", "niveau": "kaum" | "teilweise" | "solide" | "stark" | "komplett"}}