Files
creator/backend/lernen.py
2026-06-14 14:02:27 +02:00

281 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"
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 _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 (~75150 Wörter), "deepdive" = lang (300600 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 _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)