This commit is contained in:
team3
2026-06-21 15:38:10 +02:00
parent 20831e1399
commit 1910f730d2
15 changed files with 835 additions and 172 deletions

View File

@@ -21,9 +21,12 @@ from textkit import _norm_titel
log = logging.getLogger("creator.lernen")
NOETIG = 3 # gute Antworten bis "absolviert" (Tier 1)
MASTERY = 10 # Score bis "verstanden" (Tier 2)
MEISTERN = 25 # Score bis "gemeistert" (Tier 3, Maximum)
# Meilensteine je Baustein (Score 030). Form ergibt sich aus dem Score-Band:
# 06 Quiz, 712 Lückentext, 1318 Erklären, 1930 frei.
NOETIG = 6 # MiniGuide-Meilenstein → „absolviert" (Quiz-Band durch)
MASTERY = 12 # Guide-Meilenstein → „verstanden" (Lückentext-Band durch)
MEISTERN = 18 # ProGuide-Meilenstein → „gemeistert" (Erklären-Band durch, Thema fertig)
MAX_VOLL = 30 # FullGuide-Maximum (freie Formwahl ab 18)
CHAT_TIMEOUT = 240
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
@@ -49,29 +52,22 @@ NIVEAUS = {
}
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.
"""
def score_berechnen(basis: int, delta: int, floor: int, cap: int) -> int:
"""Neuer Score · driftfrei aus der Basis. `delta` liefert die Route je Prüfungsform
(Quiz +1/2, Lückentext +1/1, Erklären 1…+3). Auf [floor, cap] geklemmt.
floor = höchster erreichter Meilenstein (10/20/30); cap = Max des höchsten Formats."""
return max(floor, min(cap, basis + delta))
def floor_aus_meilensteinen(absolviert: bool, verstanden: bool, gemeistert: bool) -> int:
"""Untergrenze des Scores: ein erreichter Meilenstein hält (kein Rückfall ins Band davor)."""
if gemeistert:
return MEISTERN
# 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 = (NOETIG if absolviert else 0), NOETIG
elif not tier3:
floor, cap = NOETIG, MASTERY
else:
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))
if verstanden:
return MASTERY
if absolviert:
return NOETIG
return 0
def _transcript(messages: list[dict]) -> str:
@@ -295,6 +291,149 @@ async def pruefung_frage_variante(
return None
def _optionen_schema(opts) -> list[dict] | None:
"""[{text, korrekt}]×4 → validierte Liste · sonst None."""
if not isinstance(opts, list) or len(opts) != 4:
return None
out = []
for o in opts:
if not isinstance(o, dict):
return None
text = str(o.get("text", "")).strip()
korrekt = o.get("korrekt")
if not text or not isinstance(korrekt, bool):
return None
out.append({"text": text, "korrekt": korrekt})
return out
def _quiz_schema(data, genau_einer: bool = False) -> dict | None:
"""{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None.
genau_einer (leichte Variante): exakt eine Option muss korrekt sein."""
if not isinstance(data, dict):
return None
frage = str(data.get("frage", "")).strip()
out = _optionen_schema(data.get("optionen"))
if not frage or out is None:
return None
if genau_einer and sum(o["korrekt"] for o in out) != 1:
return None
return {"frage": frage, "optionen": out}
def _lueckwahl_schema(data) -> dict | None:
"""{"satz": str (mit ___), "optionen": [{text, korrekt}]×4} → genau 1 korrekt · sonst None."""
if not isinstance(data, dict):
return None
satz = str(data.get("satz", "")).strip()
out = _optionen_schema(data.get("optionen"))
if not satz or "___" not in satz or out is None or sum(o["korrekt"] for o in out) != 1:
return None
return {"satz": satz, "optionen": out}
async def quiz_generieren(
topic: str, baustein: str, section: str, kompakt: str | None,
muster: str, schwer: bool = False, provider: str = DEFAULT_PROVIDER,
) -> dict | None:
"""Aus einem Muster eine Multiple-Choice-Frage. schwer: 04 richtig (Multi).
leicht: genau 1 richtig (Single). Starkes Modell (role guide) für korrekte Flags.
{frage, optionen:[{text,korrekt}]} · None bei Fehler."""
try:
section_block, kompakt_block = _bloecke(section, kompakt)
anzahl_block = (
"0 bis 4 Optionen dürfen richtig sein (nicht zwingend genau eine). Mische bewusst."
if schwer else
"GENAU EINE Option ist richtig, die anderen drei sind klar falsch."
)
return await _gen_call(
"Baustein-Quiz", "guide", (lambda d: _quiz_schema(d, genau_einer=not schwer)), provider,
topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, muster=muster, anzahl_block=anzahl_block,
)
except Exception:
log.warning("[%s] Quiz-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return None
async def lueckwahl_generieren(
topic: str, baustein: str, section: str, kompakt: str | None,
muster: str, provider: str = DEFAULT_PROVIDER,
) -> dict | None:
"""Lückentext mit Auswahl (leichte Variante): Satz mit ___ + 4 Begriffe, genau 1 richtig.
{satz, optionen:[{text,korrekt}]} · None bei Fehler."""
try:
section_block, kompakt_block = _bloecke(section, kompakt)
return await _gen_call(
"Baustein-Lueckwahl", "guide", _lueckwahl_schema, provider,
topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, muster=muster,
)
except Exception:
log.warning("[%s] Lückentext-Auswahl fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return None
def _lueck_schema(data) -> dict | None:
"""{"satz": str (mit ___), "loesung": str, "alternativen": [str]} → validiert · sonst None."""
if not isinstance(data, dict):
return None
satz = str(data.get("satz", "")).strip()
loesung = str(data.get("loesung", "")).strip()
alt = data.get("alternativen", [])
if not satz or "___" not in satz or not loesung:
return None
alternativen = [str(a).strip() for a in alt if isinstance(a, str) and str(a).strip()] if isinstance(alt, list) else []
return {"satz": satz, "loesung": loesung, "alternativen": alternativen}
async def lueckentext_generieren(
topic: str, baustein: str, section: str, kompakt: str | None,
muster: str, provider: str = DEFAULT_PROVIDER,
) -> dict | None:
"""Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme).
{satz, loesung, alternativen} · None bei Fehler."""
try:
section_block, kompakt_block = _bloecke(section, kompakt)
return await _gen_call(
"Baustein-Lueckentext", "fast", _lueck_schema, provider,
topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, muster=muster,
)
except Exception:
log.warning("[%s] Lückentext-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return None
def _norm_begriff(t: str) -> str:
return re.sub(r"[^\wäöüß]", "", str(t or "").lower())
def _richtig_schema(data) -> dict | None:
if not isinstance(data, dict) or not isinstance(data.get("richtig"), bool):
return None
return {"richtig": data["richtig"]}
async def lueckentext_pruefen(
topic: str, baustein: str, satz: str, loesung: str, alternativen: list[str],
eingabe: str, provider: str = DEFAULT_PROVIDER,
) -> bool:
"""Lückentext-Antwort prüfen: erst normalisierter Vergleich (Lösung + Synonyme),
sonst 1 KI-Call für Synonym-Toleranz. Fail-open zu RICHTIG nur bei exaktem Match."""
if not eingabe.strip():
return False
norm = _norm_begriff(eingabe)
if norm and norm in {_norm_begriff(loesung), *(_norm_begriff(a) for a in alternativen)}:
return True
data = await _gen_call(
"Baustein-Lueckentext-Pruefung", "fast", _richtig_schema, provider,
topic=topic, baustein=baustein, satz=satz, loesung=loesung,
alternativen=", ".join(alternativen) or "(keine)", eingabe=eingabe,
)
return bool(data and data["richtig"])
async def pruefung_bewertung_schnell(
topic: str, baustein: str, section: str, kompakt: str | None,
frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,