532 lines
23 KiB
Python
532 lines
23 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 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")
|
||
|
||
# Meilensteine je Baustein (Score 0–30). Form ergibt sich aus dem Score-Band:
|
||
# 0–6 Quiz, 7–12 Lückentext, 13–18 Erklären, 19–30 frei.
|
||
NOETIG = 6 # MiniGuide-Meilenstein → „absolviert" (Quiz-Band durch)
|
||
MASTERY = 12 # Guide-Meilenstein → „verstanden" (Lückentext-Band durch)
|
||
MEISTERN = 18 # ProGuide-Meilenstein → „gemeistert" (Erklären-Band durch, Thema fertig)
|
||
MAX_VOLL = 30 # FullGuide-Maximum (freie Formwahl ab 18)
|
||
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 −1). Auf [floor, cap] geklemmt.
|
||
floor = höchster erreichter Meilenstein (6/12/18); cap = Max des höchsten Formats.
|
||
Am Format-Deckel (basis ≥ cap) eingefroren: „Max" — weder + noch −, bis das nächste
|
||
Format freischaltet. Bei cap 30 (FullGuide) bleibt 30 endgültig."""
|
||
if basis >= cap:
|
||
return basis
|
||
return max(floor, min(cap, basis + delta))
|
||
|
||
|
||
def floor_aus_meilensteinen(absolviert: bool, verstanden: bool, gemeistert: bool) -> int:
|
||
"""Untergrenze des Scores: ein erreichter Meilenstein hält (kein Rückfall ins Band davor)."""
|
||
if gemeistert:
|
||
return MEISTERN
|
||
if verstanden:
|
||
return MASTERY
|
||
if absolviert:
|
||
return NOETIG
|
||
return 0
|
||
|
||
|
||
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", "fast", _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", "fast", _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", "fast", _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)
|