update
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user