This commit is contained in:
team3
2026-06-19 12:59:13 +02:00
parent 3168ec9094
commit 26e097dc5a
9 changed files with 107 additions and 199 deletions

View File

@@ -82,6 +82,17 @@ def _crawl_fertig(topic: str) -> bool:
_STUFEN = ("einfach", "mittel", "schwer")
def subbausteine_titel(topic: str, baustein: str) -> list[str]:
"""Subbaustein-Titel eines Bausteins aus der Sidecar (leer, wenn keine)."""
sc = _json_datei(subbausteine_path(topic))
if not isinstance(sc, dict):
return []
return [
t for s in (sc.get(baustein) or [])
if isinstance(s, dict) and (t := str(s.get("titel", "")).strip())
]
def lade_uebersicht(topic: str) -> list[dict]:
"""Strukturierte Baustein-Liste für die Übersicht: Titel + Beschreibung + Subbausteine/Stufen.

View File

@@ -5,8 +5,8 @@ der Chat-/Prüfungs-Verlauf kommt vom Frontend, persistiert wird nur der
Prüfungs-Zähler (DB) und die Vertiefung (DB).
"""
import asyncio
import logging
import random
import re
import uuid
from datetime import datetime, timezone
@@ -29,6 +29,15 @@ PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfu
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.",
}
def score_berechnen(
score_vor_frage: int, gut: bool, streak: int, tier2: bool, tier3: bool,
@@ -114,14 +123,6 @@ def _frage_schema(data) -> dict | None:
return {"frage": frage} if frage else None
def _fragen_schema(data) -> list[str] | None:
"""{"fragen": [str, …]} → Liste nicht-leerer Fragen · sonst None."""
if not isinstance(data, dict) or not isinstance(data.get("fragen"), list):
return None
fragen = [str(f).strip() for f in data["fragen"] if str(f).strip()]
return fragen or None
def _bewertung_schema(data) -> dict | None:
"""{"feedback": str, "bewertung": "gut"|"schlecht", "bestanden": bool} · sonst None."""
if not isinstance(data, dict):
@@ -182,7 +183,7 @@ def _doppelfrage_mangel(frage: str) -> str | None:
async def _frage_mit_kritik(
topic: str, baustein: str, section_block: str, kompakt_block: str,
transcript: str, vermeide_block: str, provider: 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)"
@@ -192,7 +193,7 @@ async def _frage_mit_kritik(
"Baustein-Frage", "fast", _frage_schema, provider,
topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block,
kritik_block=kritik_block,
typ_block=typ_block, fokus_block=fokus_block, kritik_block=kritik_block,
)
if data is None:
return None
@@ -200,7 +201,8 @@ async def _frage_mit_kritik(
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, frage=frage,
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:
@@ -259,70 +261,30 @@ def _vermeide_block(vermeide: list[str] | None) -> str:
async def pruefung_frage(
topic: str, baustein: str, section: str, kompakt: str | None,
messages: list[dict], vermeide: list[str] | None = None, provider: str = DEFAULT_PROVIDER,
messages: list[dict], subbausteine: list[str] | None = None,
vermeide: list[str] | None = None, provider: str = DEFAULT_PROVIDER,
) -> str | None:
"""Aktion 'frage': nächste Frage generieren (Generator + Kritiker) · None bei Fehler."""
"""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), provider,
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 _frage_ok(topic, baustein, section_block, kompakt_block, transcript, vermeide_block, frage, provider) -> bool:
"""Eine geplante Frage prüfen: Doppelfrage-Guard + Kritiker. True = in Ordnung."""
if _doppelfrage_mangel(frage):
return False
probleme = await _kritik_call(
"Baustein-Frage-Kritik", provider, role="guide",
topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block, frage=frage,
)
return not probleme
async def pruefung_fragen(
topic: str, baustein: str, section: str, kompakt: str | None,
messages: list[dict], vermeide: list[str] | None = None, anzahl: int = 5,
provider: str = DEFAULT_PROVIDER,
) -> list[str]:
"""Aktion 'fragen': EIN Planer entwirft `anzahl` verschiedene Fragen, parallel geprüft.
Diversität kommt vom Planer; der Kritiker prüft Qualität + Ähnlichkeit zu Gestelltem.
→ Liste gültiger Fragen (kann < anzahl sein)."""
try:
section_block, kompakt_block = _bloecke(section, kompakt)
transcript = _transcript(messages) if messages else "(leer)"
vermeide = [f for f in (vermeide or []) if f and f.strip()]
gesammelt: list[str] = []
for _ in range(2): # Plan + eine Nachrunde für Abgelehnte
brauche = anzahl - len(gesammelt)
if brauche <= 0:
break
vblock = _vermeide_block(vermeide + gesammelt)
kandidaten = await _gen_call(
"Baustein-Fragen-Plan", "fast", _fragen_schema, provider, CHAT_TIMEOUT,
topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block,
transcript=transcript, vermeide_block=vblock, anzahl=brauche,
)
if not kandidaten:
break
oks = await asyncio.gather(
*[_frage_ok(topic, baustein, section_block, kompakt_block, transcript, vblock, f, provider) for f in kandidaten],
return_exceptions=True,
)
for f, ok in zip(kandidaten, oks):
if ok is True and f not in gesammelt:
gesammelt.append(f)
return gesammelt[:anzahl]
except Exception:
log.warning("[%s] Fragen-Plan fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return []
async def pruefung_bewertung(
topic: str, baustein: str, section: str, kompakt: str | None,
frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER,
@@ -345,28 +307,6 @@ async def pruefung_bewertung(
return None
async def pruefung_bewertung_schnell(
topic: str, baustein: str, section: str, kompakt: 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, 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, 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, kompakt: str | None,
frage: str, letzte_bewertung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER,

View File

@@ -214,11 +214,10 @@ class BausteinPruefungRequest(BaseModel):
baustein: str = Field(min_length=1, max_length=200)
section: str = Field(default="", max_length=20000) # ausführliche Fassung
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze)
aktion: Literal["frage", "fragen", "diskussion", "antwort", "antwort_pruefen"] = "frage"
aktion: Literal["frage", "diskussion", "antwort_pruefen"] = "frage"
frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort); Anker der Basis
letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion)
vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen
anzahl: int = 5 # aktion 'fragen': wie viele neue Fragen der Planer entwerfen soll
# Basis/Streak werden serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client.
tier2: bool = False # ganzer Guide absolviert (alle ≥3) → 1 bei falsch, Deckel 10
tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, 2 bei falsch, Deckel 25
@@ -229,7 +228,6 @@ class BausteinPruefungRequest(BaseModel):
class BausteinPruefungResponse(BaseModel):
frage: str | None = None
fragen: list[str] | None = None # aktion 'fragen': geplante, geprüfte Fragen
reply: str | None = None
feedback: str | None = None
bewertung: Literal["gut", "schlecht"] | None = None

View File

@@ -16,9 +16,9 @@ from database import (
list_baustein_progress, get_baustein_progress, set_offene_frage,
set_baustein_score, set_baustein_streak, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten,
)
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_fragen, score_berechnen, streak_berechnen
from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_frage, score_berechnen, streak_berechnen
from guide import generate_guide, guide_slot_dateien
from pipeline import cancel_guide
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
@@ -268,15 +268,12 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
msgs = [m.model_dump() for m in req.messages]
if req.aktion == "frage":
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, vermeide=req.vermeide, provider=req.provider)
subs = subbausteine_titel(req.topic, req.baustein) # Fokus-Kandidaten (zufällig gewählt)
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, provider=req.provider)
if frage is None:
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
if req.aktion == "fragen":
fragen = await pruefung_fragen(req.topic, req.baustein, req.section, kompakt, msgs, vermeide=req.vermeide, anzahl=req.anzahl, provider=req.provider)
return {"fragen": fragen, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
if req.aktion == "diskussion":
if not req.frage.strip():
raise HTTPException(400, "Diskussion braucht eine laufende Frage")
@@ -288,27 +285,15 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen")
return {"reply": reply, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
# aktion "antwort"/"antwort_pruefen" — mindestens eine Nutzer-Antwort muss im Dialog stehen
# aktion == "antwort_pruefen" — mindestens eine Nutzer-Antwort muss im Dialog stehen
# (nach einer Diskussion endet der Dialog mit dem Tutor; Re-Bewertung bleibt erlaubt).
if not any(m.get("role") == "user" for m in msgs):
raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort")
if not req.frage.strip():
raise HTTPException(400, "Antwort braucht eine laufende Frage")
if req.aktion == "antwort":
# Vorschau: nur Bewerter, kein Kritiker, KEIN Persist. Basis serverseitig (best effort).
data = await pruefung_bewertung_schnell(
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, gute, provider=req.provider,
)
if data is None:
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
basis, streak_basis, _ = _basis(stand, req.frage)
score = score_berechnen(basis, data["bewertung"] == "gut", streak_basis, req.tier2, req.tier3, absolviert, verstanden, gemeistert)
return {"feedback": data["feedback"], "bewertung": data["bewertung"], "gute_antworten": score,
"absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
# aktion == "antwort_pruefen": verbindlich, persistiert. Serialisiert je Baustein, damit
# die Basis immer den geprüften Vorgänger-Stand sieht (kein Stale-Overwrite).
# Verbindlich, persistiert. Serialisiert je Baustein, damit die Basis immer den
# geprüften Vorgänger-Stand sieht (kein Stale-Overwrite). Punkte kommen NUR hier.
async with _pruef_lock(req.topic, req.baustein):
stand = await get_baustein_progress(req.topic, req.baustein) # nach evtl. Vorgänger-Persist neu lesen
absolviert = stand["absolviert"] is not None