This commit is contained in:
team3
2026-06-19 14:04:19 +02:00
parent 26e097dc5a
commit 0868a768a2
7 changed files with 219 additions and 157 deletions

View File

@@ -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, # 2549 %
"solide": 1, # 5074 %
"stark": 2, # 7599 %
"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)

View File

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

View File

@@ -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)
if genau:
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",
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 ---

View File

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

View File

@@ -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 })
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()
}
}
</script>
@@ -372,16 +405,23 @@ function onPruefKey(e) {
<div v-else>
<p class="bp-hint">
<template v-if="st.gemeistert"> Gemeistert ({{ MEISTERN }}/{{ MEISTERN }}) Max. Du kannst dich weiter prüfen, ohne Punkte.</template>
<template v-else-if="st.verstanden && tier3">Meisterpfad: {{ st.gute_antworten }}/{{ MEISTERN }}. Richtig = +1, falsch = 2 (nicht unter {{ MAX }}). Bei {{ MEISTERN }} gemeistert.</template>
<template v-else-if="st.verstanden && tier3">Meisterpfad: {{ st.gute_antworten }}/{{ MEISTERN }}. Je besser, desto mehr (+1/+2/+3), schwach = 1 (nicht unter {{ MAX }}). Bei {{ MEISTERN }} gemeistert.</template>
<template v-else-if="st.verstanden"> Verstanden. Der Meisterpfad ({{ MAX }}{{ MEISTERN }}) öffnet, sobald der ganze Guide verstanden ist.</template>
<template v-else-if="st.absolviert && tier2">Mastery: {{ st.gute_antworten }}/{{ MAX }}. Richtig = +1, falsch = 1 (nicht unter {{ NOETIG }}). Bei {{ MAX }} verstanden.</template>
<template v-else-if="st.absolviert && tier2">Mastery: {{ st.gute_antworten }}/{{ MAX }}. Je besser, desto mehr (+1/+2/+3), schwach = 1 (nicht unter {{ NOETIG }}). Bei {{ MAX }} verstanden.</template>
<template v-else-if="st.absolviert"> Absolviert. Mehr ({{ NOETIG }}{{ MAX }}) gibt's, sobald der ganze Guide absolviert ist.</template>
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Frag nach, wenn etwas unklar ist diskutieren ist erlaubt.</template>
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Je besser deine Antwort, desto mehr Punkte (bis +3). Nachfragen ist erlaubt dann max +1.</template>
</p>
<div v-if="pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll">
<template v-for="(m, i) in pruefMessages" :key="i">
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung">{{ m.content }}<span v-if="!m.geprueft" class="bp-pruefend"> · wird geprüft</span></div>
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung" title="Rechtsklick: gründlich prüfen" @contextmenu.prevent="gruendlichOeffnen(m)">
<span v-if="m.niveau" class="bp-niveau">{{ m.niveau }}</span>{{ m.content }}<span v-if="!m.geprueft" class="bp-pruefend"> · wird geprüft</span>
<div v-if="gruendlichMsg === m" class="bp-gruendlich" @click.stop>
<input id="bp-gruendlich-input" v-model="gruendlichText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="gruendlichAbsenden" />
<button class="bp-action primary" @click="gruendlichAbsenden">Gründlich prüfen</button>
<button class="bp-action" @click="gruendlichMsg = null">×</button>
</div>
</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>
@@ -395,14 +435,14 @@ function onPruefKey(e) {
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
</div>
<!-- Phase frage_offen / bewertet: Textfeld + Aktionen -->
<!-- Phase frage_offen: Textfeld + Aktionen · bewertet: nur Nächste Frage" -->
<template v-else>
<div class="bp-input">
<div v-if="pruefPhase === 'frage_offen'" class="bp-input">
<textarea
ref="pruefInputEl"
v-model="pruefInput"
rows="2"
:placeholder="pruefPhase === 'frage_offen' ? 'Antwort — oder Nachfrage bei Unklarheit…' : 'Nachhaken oder diskutieren…'"
placeholder="Antwort — oder Nachfrage bei Unklarheit…"
></textarea>
</div>
<div class="bp-actions">
@@ -413,9 +453,7 @@ function onPruefKey(e) {
<button v-if="hatDiskussion" class="bp-action" title="Alt+3" @click="verlaufBewerten"><span class="bp-kbd">3</span>Verlauf bewerten</button>
</template>
<template v-else>
<button class="bp-action" title="Alt+1" :disabled="!pruefInput.trim()" @click="nachfragen"><span class="bp-kbd">1</span>Nachhaken</button>
<button class="bp-action primary" title="Alt+2" @click="naechsteFrageZeigen"><span class="bp-kbd">2</span>Nächste Frage</button>
<button class="bp-action" title="Alt+3" @click="gruendlichPruefen"><span class="bp-kbd">3</span>Gründlich prüfen</button>
</template>
</div>
</template>
@@ -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 {

View File

@@ -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 2549 % · solide 5074 % · stark 7599 % · 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}}

View File

@@ -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": 2549 % — ein Bruchstück stimmt, der Kern fehlt.
- "solide": 5074 % — der Kern ist richtig getroffen, Details fehlen.
- "stark": 7599 % — 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"}}