330 lines
14 KiB
Python
330 lines
14 KiB
Python
"""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 bewerten (gut/schlecht), vom Kritiker prüfen lassen, bei Fehlurteil neu.
|
||
|
||
`frage` ankert die geprüfte Frage; 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_pruefen': verbindlich bewerten (Evaluator + Kritiker).
|
||
|
||
Gibt {"feedback", "bewertung": gut|schlecht, "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 pruefung_bewertung_schnell(
|
||
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' (Vorschau): nur Evaluator, KEIN Kritiker — sofortiges Urteil.
|
||
|
||
Wird optimistisch angezeigt; 'antwort_pruefen' liefert danach das geprüfte Urteil.
|
||
"""
|
||
try:
|
||
section_block, vertiefung_block = _bloecke(section, vertiefung)
|
||
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,
|
||
vertiefung_block=vertiefung_block, frage=frage.strip() or "(keine Frage übergeben)",
|
||
transcript=transcript, gute_antworten=gute_antworten, noetig=NOETIG, kritik_block="(keine)",
|
||
)
|
||
except Exception:
|
||
log.warning("[%s] Schnell-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)
|