This commit is contained in:
team3
2026-06-17 11:54:45 +02:00
parent e8214598ea
commit 390ec12bbf
5 changed files with 69 additions and 25 deletions

View File

@@ -25,6 +25,7 @@ MEISTERN = 25 # Score bis "gemeistert" (Tier 3, Maximum)
VERTIEFUNG_TIMEOUT = 600 VERTIEFUNG_TIMEOUT = 600
CHAT_TIMEOUT = 240 CHAT_TIMEOUT = 240
PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt
GRUENDLICH_TIMEOUT = 600 # „Gründlich prüfen": starkes Modell (role guide) braucht länger
KRITIK_MAX_RUNDEN = 2 # Generator → Kritiker → ggf. Neu, höchstens so oft KRITIK_MAX_RUNDEN = 2 # Generator → Kritiker → ggf. Neu, höchstens so oft
@@ -137,21 +138,21 @@ def _bewertung_schema(data) -> dict | None:
return {"feedback": feedback, "bewertung": bewertung, "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: async def _gen_call(name: str, role: str, schema, provider: str, timeout: int = PRUEFUNG_TIMEOUT, **kwargs) -> dict | None:
"""Generator-Agent: Template füllen, laufen lassen, per schema parsen · None bei Fehler.""" """Generator-Agent: Template füllen, laufen lassen, per schema parsen · None bei Fehler."""
returncode, stdout, _ = await run_agent( returncode, stdout, _ = await run_agent(
name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), PRUEFUNG_TIMEOUT, name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout,
provider=provider, role=role, capabilities="none", lane="interactive", provider=provider, role=role, capabilities="none", lane="interactive",
) )
return schema(_parse_json_text(stdout)) if returncode == 0 else None return schema(_parse_json_text(stdout)) if returncode == 0 else None
async def _kritik_call(name: str, provider: str, **kwargs) -> list[str]: async def _kritik_call(name: str, provider: str, role: str = "judge", timeout: int = PRUEFUNG_TIMEOUT, **kwargs) -> list[str]:
"""Kritiker-Agent (role judge): leere Liste = in Ordnung. Fail-open: Ausfall des """Kritiker-Agent (Default role judge): leere Liste = in Ordnung. Fail-open: Ausfall des
Kritikers darf den Turn nicht blockieren, also dann ebenfalls leere Liste.""" Kritikers darf den Turn nicht blockieren, also dann ebenfalls leere Liste."""
returncode, stdout, _ = await run_agent( returncode, stdout, _ = await run_agent(
name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), PRUEFUNG_TIMEOUT, name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout,
provider=provider, role="judge", capabilities="none", lane="interactive", provider=provider, role=role, capabilities="none", lane="interactive",
) )
if returncode != 0: if returncode != 0:
return [] return []
@@ -199,18 +200,20 @@ async def _frage_mit_kritik(
async def _bewertung_mit_kritik( async def _bewertung_mit_kritik(
topic: str, baustein: str, section_block: str, vertiefung_block: str, topic: str, baustein: str, section_block: str, vertiefung_block: str,
frage: str, transcript: str, gute_antworten: int, provider: str, frage: str, transcript: str, gute_antworten: int, provider: str, role: str = "judge",
) -> dict | None: ) -> dict | None:
"""Antwort bewerten (gut/schlecht), vom Kritiker prüfen lassen, bei Fehlurteil neu. """Antwort bewerten (gut/schlecht), vom Kritiker prüfen lassen, bei Fehlurteil neu.
`frage` ankert die geprüfte Frage; der Dialog (transcript) liefert die Antwort und `frage` ankert die geprüfte Frage; der Dialog (transcript) liefert die Antwort und
eine etwaige Diskussion — so kann eine Re-Bewertung das Argument sehen. eine etwaige Diskussion — so kann eine Re-Bewertung das Argument sehen.
`role` = "judge" (schnell) oder "guide" (gründlich, starkes Modell mit Thinking).
""" """
timeout = GRUENDLICH_TIMEOUT if role == "guide" else PRUEFUNG_TIMEOUT
kritik_block = "(keine)" kritik_block = "(keine)"
bew = None bew = None
for _ in range(KRITIK_MAX_RUNDEN): for _ in range(KRITIK_MAX_RUNDEN):
bew = await _gen_call( bew = await _gen_call(
"Baustein-Bewertung", "judge", _bewertung_schema, provider, "Baustein-Bewertung", role, _bewertung_schema, provider, timeout,
topic=topic, baustein=baustein, section_block=section_block, topic=topic, baustein=baustein, section_block=section_block,
vertiefung_block=vertiefung_block, frage=frage, transcript=transcript, vertiefung_block=vertiefung_block, frage=frage, transcript=transcript,
gute_antworten=gute_antworten, noetig=NOETIG, kritik_block=kritik_block, gute_antworten=gute_antworten, noetig=NOETIG, kritik_block=kritik_block,
@@ -218,7 +221,7 @@ async def _bewertung_mit_kritik(
if bew is None: if bew is None:
return None return None
probleme = await _kritik_call( probleme = await _kritik_call(
"Baustein-Bewertung-Kritik", provider, "Baustein-Bewertung-Kritik", provider, role=role, timeout=timeout,
topic=topic, baustein=baustein, section_block=section_block, topic=topic, baustein=baustein, section_block=section_block,
vertiefung_block=vertiefung_block, frage=frage, transcript=transcript, vertiefung_block=vertiefung_block, frage=frage, transcript=transcript,
bewertung_block=_bewertung_text(bew), bewertung_block=_bewertung_text(bew),
@@ -253,9 +256,11 @@ async def pruefung_frage(
async def pruefung_bewertung( async def pruefung_bewertung(
topic: str, baustein: str, section: str, vertiefung: str | None, topic: str, baustein: str, section: str, vertiefung: str | None,
frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER,
role: str = "judge",
) -> dict | None: ) -> dict | None:
"""Aktion 'antwort_pruefen': verbindlich bewerten (Evaluator + Kritiker). """Aktion 'antwort_pruefen': verbindlich bewerten (Evaluator + Kritiker).
`role` = "guide" für „Gründlich prüfen" (starkes Modell), sonst "judge".
Gibt {"feedback", "bewertung": gut|schlecht, "bestanden"} · None bei Fehler. Gibt {"feedback", "bewertung": gut|schlecht, "bestanden"} · None bei Fehler.
""" """
try: try:
@@ -263,7 +268,7 @@ async def pruefung_bewertung(
transcript = _transcript(messages) if messages else "(leer)" transcript = _transcript(messages) if messages else "(leer)"
return await _bewertung_mit_kritik( return await _bewertung_mit_kritik(
topic, baustein, section_block, vertiefung_block, topic, baustein, section_block, vertiefung_block,
frage.strip() or "(keine Frage übergeben)", transcript, gute_antworten, provider, frage.strip() or "(keine Frage übergeben)", transcript, gute_antworten, provider, role,
) )
except Exception: except Exception:
log.warning("[%s] Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True) log.warning("[%s] Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True)

View File

@@ -200,6 +200,7 @@ class BausteinPruefungRequest(BaseModel):
tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, 2 bei falsch, Deckel 25 tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, 2 bei falsch, Deckel 25
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage
provider: ProviderType = "claude" provider: ProviderType = "claude"
gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide)
class BausteinPruefungResponse(BaseModel): class BausteinPruefungResponse(BaseModel):

View File

@@ -259,6 +259,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
# aktion == "antwort_pruefen": verbindlich (Bewerter + Kritiker), persistiert Score + Meilensteine. # aktion == "antwort_pruefen": verbindlich (Bewerter + Kritiker), persistiert Score + Meilensteine.
data = await pruefung_bewertung( data = await pruefung_bewertung(
req.topic, req.baustein, req.section, vertiefung, req.frage, msgs, gute, provider=req.provider, req.topic, req.baustein, req.section, vertiefung, req.frage, msgs, gute, provider=req.provider,
role="guide" if req.gruendlich else "judge",
) )
if data is None: if data is None:
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")

View File

@@ -94,12 +94,12 @@ export async function chatBaustein({ topic, baustein, section, messages, provide
export async function pruefeBaustein({ export async function pruefeBaustein({
topic, baustein, section, provider, topic, baustein, section, provider,
aktion = 'frage', frage = '', letzte_bewertung = '', score_vor_frage = 0, streak = 0, aktion = 'frage', frage = '', letzte_bewertung = '', score_vor_frage = 0, streak = 0,
tier2 = false, tier3 = false, messages = [], tier2 = false, tier3 = false, messages = [], gruendlich = false,
}) { }) {
const res = await fetch(`${BASE}/bausteine/pruefung`, { const res = await fetch(`${BASE}/bausteine/pruefung`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, aktion, frage, letzte_bewertung, score_vor_frage, streak, tier2, tier3, messages, provider }), body: JSON.stringify({ topic, baustein, section, aktion, frage, letzte_bewertung, score_vor_frage, streak, tier2, tier3, messages, provider, gruendlich }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { chatBaustein, createVertiefung, fetchVertiefung, pruefeBaustein } from '../api.js' import { chatBaustein, createVertiefung, fetchVertiefung, pruefeBaustein } from '../api.js'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown } from '../markdown.js'
import { useChat, istUnten } from '../composables/useChat.js' import { useChat, istUnten } from '../composables/useChat.js'
@@ -245,15 +245,17 @@ function antwortPayload() {
} }
// Hintergrund: voller Bewerter+Kritiker. Persistiert serverseitig; korrigiert die Anzeige bei Abweichung. // Hintergrund: voller Bewerter+Kritiker. Persistiert serverseitig; korrigiert die Anzeige bei Abweichung.
async function verdictPruefen() { // gruendlich=true: starkes Modell (role guide), manuell ausgelöst → sichtbarer Ladezustand.
async function verdictPruefen(gruendlich = false) {
const meine = ++pruefenRun const meine = ++pruefenRun
const ziel = letzteFeedbackMsg const ziel = letzteFeedbackMsg
const warAbsolviert = st.value.absolviert const warAbsolviert = st.value.absolviert
const warVerstanden = st.value.verstanden const warVerstanden = st.value.verstanden
if (gruendlich) pruefLoading.value = true
try { try {
const res = await pruefeBaustein({ const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, topic: props.topic, baustein: props.baustein, section: props.section,
provider: props.provider, messages: pruefDialog(), ...antwortPayload(), aktion: 'antwort_pruefen', provider: props.provider, messages: pruefDialog(), ...antwortPayload(), aktion: 'antwort_pruefen', gruendlich,
}) })
if (meine !== pruefenRun) return // eine neuere Bewertung läuft → diese verwerfen if (meine !== pruefenRun) return // eine neuere Bewertung läuft → diese verwerfen
// Streak driftfrei aus streakVorFrage neu setzen; Reset nur am Deckel der // Streak driftfrei aus streakVorFrage neu setzen; Reset nur am Deckel der
@@ -275,6 +277,8 @@ async function verdictPruefen() {
} }
} catch { } catch {
if (meine === pruefenRun && ziel) ziel.geprueft = true if (meine === pruefenRun && ziel) ziel.geprueft = true
} finally {
if (gruendlich && meine === pruefenRun) pruefLoading.value = false
} }
} }
@@ -286,9 +290,39 @@ function antwortAbgeben() {
pruefSenden(antwortPayload(), bewerten) pruefSenden(antwortPayload(), bewerten)
} }
function neuBewerten() { // „Gründlich prüfen": dieselbe Antwort, aber vom starken Modell (role guide) verbindlich neu bewerten.
function gruendlichPruefen() {
if (pruefLoading.value) return if (pruefLoading.value) return
pruefSenden(antwortPayload(), bewerten) verdictPruefen(true)
}
// Abbruch wie im Chat: Run-Counter hochzählen → laufendes Ergebnis (pruefSenden
// und verdictPruefen) wird verworfen, Buttons sofort frei. Agent läuft serverseitig aus.
function pruefAbbrechen() {
if (!pruefLoading.value) return
pruefRun++
pruefenRun++
pruefLoading.value = false
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Abgebrochen.' })
}
// ESC bricht ab — window-Listener, da nach Button-Klick der Fokus das Panel verlässt.
function onWindowKey(e) {
if (e.key === 'Escape' && pruefLoading.value) { e.preventDefault(); pruefAbbrechen() }
}
onMounted(() => window.addEventListener('keydown', onWindowKey))
onUnmounted(() => window.removeEventListener('keydown', onWindowKey))
// ALT+1 Nachfragen/Nachhaken · ALT+2 primär (Antwort abgeben / Nächste Frage) · ALT+3 Gründlich prüfen.
function onPruefKey(e) {
if (!e.altKey || e.repeat) return
if (e.key === '1') { e.preventDefault(); nachfragen() }
else if (e.key === '2') {
e.preventDefault()
pruefPhase.value === 'frage_offen' ? antwortAbgeben() : naechsteFrageZeigen()
} else if (e.key === '3' && pruefPhase.value === 'bewertet') {
e.preventDefault(); gruendlichPruefen()
}
} }
</script> </script>
@@ -357,7 +391,7 @@ function neuBewerten() {
</div> </div>
<!-- Prüfung: gesteuerter Dialog --> <!-- Prüfung: gesteuerter Dialog -->
<div v-else> <div v-else @keydown="onPruefKey">
<p class="bp-hint"> <p class="bp-hint">
<template v-if="st.gemeistert"> Gemeistert ({{ MEISTERN }}/{{ MEISTERN }}) Max. Du kannst dich weiter prüfen, ohne Punkte.</template> <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 }}. Richtig = +1, falsch = 2 (nicht unter {{ MAX }}). Bei {{ MEISTERN }} gemeistert.</template>
@@ -379,7 +413,8 @@ function neuBewerten() {
<!-- Phase idle: Frage anfordern --> <!-- Phase idle: Frage anfordern -->
<div v-if="pruefPhase === 'idle'" class="bp-actions"> <div v-if="pruefPhase === 'idle'" class="bp-actions">
<button class="bp-action primary" :disabled="pruefLoading" @click="frageAnfordern">Frage anfordern</button> <button v-if="pruefLoading" class="bp-action cancel" @click="pruefAbbrechen">Abbrechen</button>
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
</div> </div>
<!-- Phase frage_offen / bewertet: Textfeld + Aktionen --> <!-- Phase frage_offen / bewertet: Textfeld + Aktionen -->
@@ -393,14 +428,15 @@ function neuBewerten() {
></textarea> ></textarea>
</div> </div>
<div class="bp-actions"> <div class="bp-actions">
<template v-if="pruefPhase === 'frage_offen'"> <button v-if="pruefLoading" class="bp-action cancel" @click="pruefAbbrechen">Abbrechen</button>
<button class="bp-action" :disabled="pruefLoading || !pruefInput.trim()" @click="nachfragen">Nachfragen</button> <template v-else-if="pruefPhase === 'frage_offen'">
<button class="bp-action primary" :disabled="pruefLoading || !pruefInput.trim()" @click="antwortAbgeben">Antwort abgeben</button> <button class="bp-action" :disabled="!pruefInput.trim()" @click="nachfragen">Nachfragen</button>
<button class="bp-action primary" :disabled="!pruefInput.trim()" @click="antwortAbgeben">Antwort abgeben</button>
</template> </template>
<template v-else> <template v-else>
<button class="bp-action" :disabled="pruefLoading || !pruefInput.trim()" @click="nachfragen">Nachhaken</button> <button class="bp-action" :disabled="!pruefInput.trim()" @click="nachfragen">Nachhaken</button>
<button class="bp-action" :disabled="pruefLoading" @click="neuBewerten">Neu bewerten</button> <button class="bp-action" @click="gruendlichPruefen">Gründlich prüfen</button>
<button class="bp-action primary" :disabled="pruefLoading" @click="naechsteFrageZeigen">Nächste Frage</button> <button class="bp-action primary" @click="naechsteFrageZeigen">Nächste Frage</button>
</template> </template>
</div> </div>
</template> </template>
@@ -463,6 +499,7 @@ function neuBewerten() {
.bp-action:disabled { opacity: 0.5; cursor: default; } .bp-action:disabled { opacity: 0.5; cursor: default; }
.bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } .bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } .bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.bp-action.cancel { background: var(--danger); border-color: var(--danger); color: var(--on-accent); }
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; } .bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
.bp-actions .bp-action { margin-top: 0; } .bp-actions .bp-action { margin-top: 0; }