"""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 from textkit import _norm_titel log = logging.getLogger("creator.lernen") NOETIG = 3 # gute Antworten bis "absolviert" VERTIEFUNG_TIMEOUT = 600 CHAT_TIMEOUT = 240 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" = kurz (~75–150 Wörter), "deepdive" = lang (300–600 Wörter). """ 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 _pruefung_schema(data) -> dict | None: """{"reply": str, "bewertung": "gut"|"schlecht"|None, "bestanden": bool} · sonst None.""" if not isinstance(data, dict): return None reply = str(data.get("reply", "")).strip() bewertung = data.get("bewertung") if not reply or bewertung not in ("gut", "schlecht", None): return None return {"reply": reply, "bewertung": bewertung, "bestanden": data.get("bestanden") is True} async def baustein_pruefung( topic: str, baustein: str, section: str, vertiefung: str | None, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, ) -> dict | None: """Ein Prüfungs-Turn: Frage stellen bzw. letzte Antwort bewerten · None bei Fehler.""" try: prompt = _prompt( "Baustein-Pruefung", topic=topic, baustein=baustein, section_block=section.strip() or "(keine Guide-Fassung übergeben)", vertiefung_block=(vertiefung or "").strip() or "(keine)", transcript=_transcript(messages) if messages else "(leer)", gute_antworten=gute_antworten, noetig=NOETIG, ) # role "judge": Bewertungen brauchen das starke, kalte Modell — # M2.7 hat in der Praxis gegen die eigene Referenz halluziniert. returncode, stdout, _ = await run_agent( "pruefung-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT, provider=provider, role="judge", capabilities="none", lane="interactive", ) if returncode != 0: return None return _pruefung_schema(_parse_json_text(stdout)) except Exception: log.warning("[%s] Prüfung 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)