"""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 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") NOETIG = 3 # gute Antworten bis "absolviert" (Tier 1) MASTERY = 10 # Score bis "verstanden" (Tier 2) MEISTERN = 25 # Score bis "gemeistert" (Tier 3, Maximum) VERTIEFUNG_TIMEOUT = 600 CHAT_TIMEOUT = 240 PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt KRITIK_MAX_RUNDEN = 2 # Generator → Kritiker → ggf. Neu, höchstens so oft def score_berechnen( score_vor_frage: int, gut: bool, tier2: bool, tier3: bool, absolviert: bool, gemeistert: bool, ) -> int: """Neuer Score nach einer Antwort · driftfrei (immer aus dem Basis-Score gerechnet). 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). Boden vor dem Absolvieren ist 0 (sonst NOETIG — absolviert bleibt erhalten). Ist der Baustein gemeistert, friert der Score bei MEISTERN ein (keine Punkte mehr). Re-Bewertung nutzt denselben Basis-Score und ersetzt das vorige Ergebnis. """ if gemeistert: return MEISTERN if not tier2: delta, floor, cap = (1 if gut else 0), (NOETIG if absolviert else 0), NOETIG elif not tier3: delta, floor, cap = (1 if gut else -1), NOETIG, MASTERY else: delta, floor, cap = (1 if gut else -2), MASTERY, MEISTERN return max(floor, min(cap, score_vor_frage + delta)) 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 vertiefung_generieren(topic: str, baustein: str, section: str, art: str = "vertiefung", provider: str = DEFAULT_PROVIDER) -> str | None: """Ausführlichere Fassung des Bausteins als Markdown · None bei Fehler. art "vertiefung" = gleicher Stoff, nur umfangreicher; "deepdive" = Label „Amateur": gleicher Stoff, für Einsteiger aufbereitet. """ try: prompt = _prompt( "Baustein-Deepdive" if art == "deepdive" else "Baustein-Vertiefung", topic=topic, baustein=baustein, section_block=section.strip() or "(keine Guide-Fassung übergeben)", ) returncode, stdout, _ = await run_agent( "vertiefung-" + str(uuid.uuid4()), prompt, VERTIEFUNG_TIMEOUT, provider=provider, role="fast", capabilities="none", lane="interactive", ) if returncode != 0: return None return stdout.strip() or None except Exception: log.warning("[%s] Vertiefung fehlgeschlagen (%s)", topic, baustein, exc_info=True) return None async def baustein_chat(topic: str, baustein: str, section: str, vertiefung: 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)", vertiefung_block=(vertiefung 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, "bewertung": "gut"|"schlecht", "bestanden": bool} · 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"): return None 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: """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), PRUEFUNG_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, **kwargs) -> list[str]: """Kritiker-Agent (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), PRUEFUNG_TIMEOUT, provider=provider, role="judge", 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"Bewertung: {bew['bewertung']}\nFeedback: {bew['feedback']}" async def _frage_mit_kritik( topic: str, baustein: str, section_block: str, vertiefung_block: str, transcript: 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", "fast", _frage_schema, provider, topic=topic, baustein=baustein, section_block=section_block, vertiefung_block=vertiefung_block, transcript=transcript, kritik_block=kritik_block, ) if data is None: return None frage = data["frage"] probleme = await _kritik_call( "Baustein-Frage-Kritik", provider, topic=topic, baustein=baustein, section_block=section_block, vertiefung_block=vertiefung_block, transcript=transcript, frage=frage, ) 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, vertiefung_block: str, frage: str, transcript: str, gute_antworten: int, provider: str, ) -> dict | None: """Antwort zur Frage bewerten, vom Kritiker prüfen lassen, bei Fehlurteil neu. `frage` ankert, welche Frage geprüft wird; der Dialog (transcript) liefert die Antwort und eine etwaige Diskussion — so kann eine Re-Bewertung das Argument sehen. """ kritik_block = "(keine)" bew = None for _ in range(KRITIK_MAX_RUNDEN): bew = await _gen_call( "Baustein-Bewertung", "judge", _bewertung_schema, provider, topic=topic, baustein=baustein, section_block=section_block, vertiefung_block=vertiefung_block, frage=frage, transcript=transcript, gute_antworten=gute_antworten, noetig=NOETIG, kritik_block=kritik_block, ) if bew is None: return None probleme = await _kritik_call( "Baustein-Bewertung-Kritik", provider, topic=topic, baustein=baustein, section_block=section_block, vertiefung_block=vertiefung_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, vertiefung: str | None) -> tuple[str, str]: return ( section.strip() or "(keine Guide-Fassung übergeben)", (vertiefung or "").strip() or "(keine)", ) async def pruefung_frage( topic: str, baustein: str, section: str, vertiefung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER, ) -> str | None: """Aktion 'frage': nächste Frage generieren (Generator + Kritiker) · None bei Fehler.""" try: section_block, vertiefung_block = _bloecke(section, vertiefung) transcript = _transcript(messages) if messages else "(leer)" return await _frage_mit_kritik(topic, baustein, section_block, vertiefung_block, transcript, provider) except Exception: log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) return None async def pruefung_bewertung( topic: str, baustein: str, section: str, vertiefung: str | None, frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, ) -> dict | None: """Aktion 'antwort': Antwort zur Frage bewerten (Evaluator + Kritiker). Gibt {"feedback", "bewertung", "bestanden"} · None bei Fehler. """ try: section_block, vertiefung_block = _bloecke(section, vertiefung) transcript = _transcript(messages) if messages else "(leer)" return await _bewertung_mit_kritik( topic, baustein, section_block, vertiefung_block, frage.strip() or "(keine Frage übergeben)", transcript, gute_antworten, provider, ) 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, vertiefung: 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, vertiefung_block = _bloecke(section, vertiefung) prompt = _prompt( "Baustein-Pruefung-Diskussion", topic=topic, baustein=baustein, section_block=section_block, vertiefung_block=vertiefung_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)