"""Baustein-Lernen: Vertiefung, Bausteinchat und Prüfung zu einzelnen Guide-Sections. Alle Aufrufe sind interaktiv (stdout-Antwort, lane "interactive") und stateless — der Chat-/Prüfungs-Verlauf kommt vom Frontend, persistiert wird nur der Prüfungs-Zähler (DB) und die Vertiefung (DB). """ import logging import random import re import uuid from datetime import datetime, timezone from agents import run_agent from config import DEFAULT_PROVIDER from database import create_element, list_elements from elements import generate_element from jsonio import parse_json_text as _parse_json_text from pipeline import _prompt, _probleme_schema from textkit import _norm_titel log = logging.getLogger("creator.lernen") # Lernstufen je Baustein — relativ zum cap (Floor in % des Maximal-Scores): # grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%. # Prüfungsform ist immer zufällig (5 Formen); der cap skaliert mit der Stoffmenge. STUFEN = (("anfaenger", 0.2), ("fortgeschritten", 0.4), ("experte", 0.6), ("meister", 1.0)) def cap_fuer(n_subs: int) -> int: """Maximal-Score eines Bausteins: 4 Punkte je relevantem Subbaustein, gedeckelt [10, 50].""" return max(10, min(50, 4 * n_subs)) def _schwelle(p: float, cap: int) -> int: return round(p * cap) def stufe_aus_score(score: int, cap: int) -> str | None: """Höchste erreichte Stufe (None unter 20 %).""" erreicht = None for key, p in STUFEN: if score >= _schwelle(p, cap): erreicht = key return erreicht def progressiver_malus(basis: int, cap: int) -> int: """Fehler-Strafe nach Fortschritt: 0–20 %→0 · 21–40 %→−1 · 41–60 %→−2 · 61–100 %→−3.""" pct = (basis / cap) if cap else 0.0 if pct <= 0.2: return 0 if pct <= 0.4: return -1 if pct <= 0.6: return -2 return -3 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 KRITIK_MAX_RUNDEN = 2 # Generator → Kritiker → ggf. Neu, höchstens so oft # Fragetypen für Active Recall — pro Frage einer, zufällig gewählt. Schafft Vielfalt. FRAGETYPEN = { "abruf": "Free Recall: Lass den Lerner die Kernidee frei aus dem Kopf erklären (offene Verständnisfrage).", "punkt": "Cued Recall: Frag gezielt EIN konkretes Detail oder eine Abgrenzung ab.", "warum": "Warum-Frage: Frag nach dem Grund/Mechanismus — warum funktioniert oder gilt das so?", "anwendung": "Anwendung: Lass das Konzept auf EIN kurzes, neues Beispiel/Szenario anwenden.", "pruefen": "Bei Code-/Tool-Themen: kleinen Schnipsel zeigen — Output vorhersagen ODER den Fehler finden. Kein Code-Thema → stattdessen eine Anwendungsfrage.", } # 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 % } 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/+3, Lückentext +1/+3, Erklären −1…+3; falsch = progressiver_malus). Auf [floor, cap] geklemmt. floor = höchste erreichte Stufenschwelle (kein Rückfall); cap = 4× relevante Subbausteine [10, 50]. Am Deckel (basis ≥ cap) eingefroren („Max").""" if basis >= cap: return basis return max(floor, min(cap, basis + delta)) def floor_aus_score(basis: int, cap: int) -> int: """Untergrenze: höchste erreichte Stufenschwelle — kein Rückfall unter die Stufe.""" floor = 0 for _, p in STUFEN: s = _schwelle(p, cap) if basis >= s: floor = s return floor def _transcript(messages: list[dict]) -> str: return "\n".join( f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}" for m in messages ) or "(leer)" async def baustein_chat(topic: str, baustein: str, section: str, kompakt: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str: try: prompt = _prompt( "Baustein-Chat", topic=topic, baustein=baustein, section_block=section.strip() or "(keine Guide-Fassung übergeben)", kompakt_block=(kompakt or "").strip() or "(keine)", transcript=_transcript(messages), ) returncode, stdout, _ = await run_agent( "bausteinchat-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT, provider=provider, role="fast", capabilities="none", lane="interactive", ) if returncode != 0: return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." reply = stdout.strip() return reply or "Entschuldigung, ich habe keine Antwort erhalten." except Exception: log.warning("[%s] Baustein-Chat fehlgeschlagen (%s)", topic, baustein, exc_info=True) return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." def _frage_schema(data) -> dict | None: """{"frage": str} · sonst None.""" if not isinstance(data, dict): return None frage = str(data.get("frage", "")).strip() return {"frage": frage} if frage else None def _bewertung_schema(data) -> dict | None: """{"feedback": str, "niveau": ∈ NIVEAUS} · sonst None.""" if not isinstance(data, dict): return None feedback = str(data.get("feedback", "")).strip() niveau = data.get("niveau") if not feedback or niveau not in NIVEAUS: return None return {"feedback": feedback, "niveau": niveau} 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.""" returncode, stdout, _ = await run_agent( name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout, provider=provider, role=role, capabilities="none", lane="interactive", ) return schema(_parse_json_text(stdout)) if returncode == 0 else None async def _kritik_call(name: str, provider: str, role: str = "judge", timeout: int = PRUEFUNG_TIMEOUT, **kwargs) -> list[str]: """Kritiker-Agent (Default role judge): leere Liste = in Ordnung. Fail-open: Ausfall des Kritikers darf den Turn nicht blockieren, also dann ebenfalls leere Liste.""" returncode, stdout, _ = await run_agent( name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout, provider=provider, role=role, capabilities="none", lane="interactive", ) if returncode != 0: return [] return _probleme_schema(_parse_json_text(stdout)) or [] def _kritik_block(vorversion: str, probleme: list[str]) -> str: punkte = "\n".join(f"- {p}" for p in probleme) return ( f"Deine vorige Fassung war:\n«{vorversion}»\n\n" f"Der Prüfer bemängelt:\n{punkte}\n\nBehebe diese Punkte." ) def _bewertung_text(bew: dict) -> str: return f"Niveau: {bew['niveau']}\nFeedback: {bew['feedback']}" # Deterministischer Guard gegen Doppelfragen — der KI-Kritiker übersieht „…, und welchen…". _FRAGEWORT = r"(was|welche[rsnm]?|wie|wieso|warum|wofür|wozu|wann|wo|wer|wem|wen|nenne)" _DOPPEL_RE = re.compile(r"[,;]?\s+(und|sowie|außerdem|bzw\.?)\s+" + _FRAGEWORT + r"\b", re.IGNORECASE) def _doppelfrage_mangel(frage: str) -> str | None: """Erkennt zwei verkettete Fragen. None = ok. Flaggt NUR 'und/sowie' + Fragewort.""" if frage.count("?") > 1: return "Mehr als ein Fragezeichen — stelle GENAU EINE Frage." if _DOPPEL_RE.search(frage): return "Zwei Fragen mit 'und'/'sowie' verkettet — stelle GENAU EINE Frage, eine Sache." return None async def _frage_mit_kritik( topic: str, baustein: str, section_block: str, kompakt_block: str, transcript: str, vermeide_block: str, typ_block: str, fokus_block: str, provider: str, ) -> str | None: """Frage generieren, vom Kritiker prüfen lassen, bei Mängeln neu (max KRITIK_MAX_RUNDEN).""" kritik_block = "(keine)" frage = None for _ in range(KRITIK_MAX_RUNDEN): data = await _gen_call( "Baustein-Frage", "guide", _frage_schema, provider, topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block, typ_block=typ_block, fokus_block=fokus_block, kritik_block=kritik_block, ) if data is None: return None frage = data["frage"] probleme = await _kritik_call( "Baustein-Frage-Kritik", provider, role="guide", # starke KI prüft die Regeln topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block, typ_block=typ_block, fokus_block=fokus_block, frage=frage, ) hart = _doppelfrage_mangel(frage) # erzwingt Neugenerierung, auch wenn der KI-Kritiker es übersah if hart: probleme = [hart, *(probleme or [])] if not probleme: return frage kritik_block = _kritik_block(frage, probleme) return frage # best-effort nach der letzten Runde async def _bewertung_mit_kritik( topic: str, baustein: str, section_block: str, kompakt_block: str, frage: str, transcript: str, begruendung_block: str, provider: str, role: str = "judge", ) -> dict | None: """Antwort bewerten (Niveau), vom Kritiker prüfen lassen, bei Fehlurteil neu. `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 kritik_block = "(keine)" bew = None for _ in range(KRITIK_MAX_RUNDEN): bew = await _gen_call( "Baustein-Bewertung", role, _bewertung_schema, provider, timeout, topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, frage=frage, transcript=transcript, begruendung_block=begruendung_block, kritik_block=kritik_block, ) if bew is None: return None probleme = await _kritik_call( "Baustein-Bewertung-Kritik", provider, role=role, timeout=timeout, topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, frage=frage, transcript=transcript, bewertung_block=_bewertung_text(bew), ) if not probleme: return bew kritik_block = _kritik_block(_bewertung_text(bew), probleme) return bew # best-effort nach der letzten Runde def _bloecke(section: str, kompakt: str | None) -> tuple[str, str]: return ( section.strip() or "(keine Guide-Fassung übergeben)", (kompakt or "").strip() or "(keine)", ) def _vermeide_block(vermeide: list[str] | None) -> str: eintraege = [f.strip() for f in (vermeide or []) if f and f.strip()] return "\n".join(f"- {f}" for f in eintraege) or "(keine)" async def pruefung_frage( topic: str, baustein: str, section: str, kompakt: str | None, messages: list[dict], subbausteine: list[str] | None = None, vermeide: list[str] | None = None, provider: str = DEFAULT_PROVIDER, ) -> str | None: """Aktion 'frage': eine Frage generieren — zufälliger Typ zu einem zufälligen Subbaustein, dann Kritiker (Generator → Kritiker sequenziell) · None bei Fehler.""" try: section_block, kompakt_block = _bloecke(section, kompakt) transcript = _transcript(messages) if messages else "(leer)" typ_block = FRAGETYPEN[random.choice(list(FRAGETYPEN))] subs = [s for s in (subbausteine or []) if s and s.strip()] fokus = random.choice(subs) if subs else "" fokus_block = ( f"Konzentriere die Frage auf diesen Subbaustein: „{fokus}\"" if fokus else "(ganzer Baustein — kein bestimmter Subbaustein)" ) return await _frage_mit_kritik( topic, baustein, section_block, kompakt_block, transcript, _vermeide_block(vermeide), typ_block, fokus_block, provider, ) except Exception: log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) return None async def pruefung_frage_variante( topic: str, baustein: str, section: str, kompakt: str | None, muster: str, provider: str = DEFAULT_PROVIDER, ) -> str | None: """Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete, im Wortlaut leicht andere Frage formulieren. Kein Kritiker (Muster ist build-geprüft). Stil-Guard bleibt als billiger Schutz gegen Doppelfragen · None bei Fehler.""" try: section_block, kompakt_block = _bloecke(section, kompakt) data = await _gen_call( "Baustein-Frage-Variante", "guide", _frage_schema, provider, topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, muster=muster, ) if data is None: return None return data["frage"] except Exception: log.warning("[%s] Frage-Variante fehlgeschlagen (%s)", topic, baustein, exc_info=True) 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, schwer: bool = False) -> dict | None: """{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None. leicht: genau 1 richtig (Single) · schwer: mindestens 2 richtig (mehrdeutig).""" 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 n_korrekt = sum(o["korrekt"] for o in out) if (n_korrekt < 2 if schwer else n_korrekt != 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: 0–4 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 = ( "MINDESTENS ZWEI Optionen sind richtig (gern auch drei) — NIEMALS nur eine. Mische bewusst, welche." 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, schwer=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", "guide", _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, ) -> 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], provider: str = DEFAULT_PROVIDER, role: str = "judge", begruendung: str = "", ) -> dict | None: """Aktion 'antwort_pruefen' (Agent 2, genau): Evaluator + Kritiker. → {feedback, niveau}. `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, begruendung_block, provider, role, ) except Exception: log.warning("[%s] Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True) return None async def baustein_diskussion( topic: str, baustein: str, section: str, kompakt: str | None, frage: str, letzte_bewertung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER, ) -> str | None: """Aktion 'diskussion': Tutor erklärt/diskutiert die Frage oder eine Bewertung. Kein Bewerten, kein Kritiker — hier ist der Mensch der Prüfer. None bei Fehler. """ try: section_block, kompakt_block = _bloecke(section, kompakt) prompt = _prompt( "Baustein-Pruefung-Diskussion", topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, frage=frage.strip() or "(keine Frage übergeben)", letzte_bewertung_block=(letzte_bewertung or "").strip() or "(noch keine)", transcript=_transcript(messages) if messages else "(leer)", ) returncode, stdout, _ = await run_agent( "pruefungdiskussion-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT, provider=provider, role="fast", capabilities="none", lane="interactive", ) if returncode != 0: return None return stdout.strip() or None except Exception: log.warning("[%s] Prüfungs-Diskussion fehlgeschlagen (%s)", topic, baustein, exc_info=True) return None async def baustein_element_anlegen(topic: str, baustein: str, section: str, provider: str = DEFAULT_PROVIDER) -> None: """Hintergrund-Task nach dem Absolvieren: Baustein als Element anlegen. Dedup über normalisierte Titel — existiert schon ein Element zum Baustein, passiert nichts. Darf nie eine Exception nach außen werfen. """ try: vorhanden = {_norm_titel(e["title"]) for e in await list_elements(topic)} if _norm_titel(baustein) in vorhanden: return fields = await generate_element(topic, hint=baustein, provider=provider, extra_context=section) if _norm_titel(fields["title"]) in vorhanden: return now = datetime.now(timezone.utc).isoformat() await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now}) log.info("[%s] Baustein als Element angelegt: %s", topic, fields["title"]) except Exception: log.warning("[%s] Element-Anlage nach Prüfung fehlgeschlagen (%s)", topic, baustein, exc_info=True)