From 1910f730d285b1cc848bec7863894b88302823aa Mon Sep 17 00:00:00 2001 From: team3 Date: Sun, 21 Jun 2026 15:38:10 +0200 Subject: [PATCH] update --- backend/database.py | 10 + backend/lernen.py | 187 +++++- backend/models.py | 25 +- backend/regeln.py | 21 +- backend/routes.py | 91 ++- frontend/src/api.js | 5 +- frontend/src/components/BausteinFokus.vue | 6 +- frontend/src/components/BausteinPanel.vue | 539 ++++++++++++++---- frontend/src/components/TopicDetail.vue | 17 +- frontend/src/markdown.js | 6 + frontend/src/pruefungCache.js | 5 +- .../Prompt/Baustein-Lueckentext-Pruefung.md | 18 + templates/Prompt/Baustein-Lueckentext.md | 23 + templates/Prompt/Baustein-Lueckwahl.md | 25 + templates/Prompt/Baustein-Quiz.md | 29 + 15 files changed, 835 insertions(+), 172 deletions(-) create mode 100644 templates/Prompt/Baustein-Lueckentext-Pruefung.md create mode 100644 templates/Prompt/Baustein-Lueckentext.md create mode 100644 templates/Prompt/Baustein-Lueckwahl.md create mode 100644 templates/Prompt/Baustein-Quiz.md diff --git a/backend/database.py b/backend/database.py index 4538a0d..061fbdb 100644 --- a/backend/database.py +++ b/backend/database.py @@ -130,6 +130,16 @@ async def init_db(): "UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' " "WHERE status IN ('queued', 'generating')" ) + # Milestone-Migration auf Score-Skala 0–30: absolviert≥6, verstanden≥12, gemeistert≥18. + # Leitet die Meilensteine aus dem aktuellen Score ab (bestehende Timestamps bleiben), + # idempotent — nach einem Lauf stabil, im Normalbetrieb deckungsgleich mit der Prüf-Route. + await db.execute("UPDATE baustein_progress SET gute_antworten = 30 WHERE gute_antworten > 30") # Alt-Scores deckeln + for _spalte, _schwelle in (("absolviert", 6), ("verstanden", 12), ("gemeistert", 18)): + await db.execute( + f"UPDATE baustein_progress SET {_spalte} = COALESCE({_spalte}, datetime('now')) " + f"WHERE gute_antworten >= {_schwelle}" + ) + await db.execute(f"UPDATE baustein_progress SET {_spalte} = NULL WHERE gute_antworten < {_schwelle}") await db.commit() diff --git a/backend/lernen.py b/backend/lernen.py index 37e004c..a119802 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -21,9 +21,12 @@ 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) +# 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 @@ -49,29 +52,22 @@ NIVEAUS = { } -def score_berechnen( - score_vor_frage: int, niveau: str, nachgefragt: bool, - tier2: bool, tier3: bool, absolviert: bool, verstanden: bool, gemeistert: bool, -) -> int: - """Neuer Score · driftfrei (immer aus dem Basis-Score). Delta aus dem Antwort-Niveau: - je besser, desto mehr (−1/0/+1/+2/+3). Bei Nachfragen ist der Gewinn auf +1 gedeckelt. - Danach auf Boden/Deckel der aktiven Stufe geklemmt (3 / 10 / 25). Kein Streak mehr. - """ +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/−2, Lückentext +1/−1, Erklären −1…+3). Auf [floor, cap] geklemmt. + floor = höchster erreichter Meilenstein (10/20/30); cap = Max des höchsten Formats.""" + 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 - # Stufe nach Baustein-Fortschritt: frisch erst Tier 1 (0→3), dann ab absolviert/verstanden. - tier2 = tier2 and absolviert - tier3 = tier3 and verstanden - if not tier2: - floor, cap = (NOETIG if absolviert else 0), NOETIG - elif not tier3: - floor, cap = NOETIG, MASTERY - else: - floor, cap = MASTERY, MEISTERN - delta = NIVEAUS.get(niveau, 0) - if nachgefragt: - delta = min(delta, 1) # mit Hilfe (Nachfrage) höchstens +1 - return max(floor, min(cap, score_vor_frage + delta)) + if verstanden: + return MASTERY + if absolviert: + return NOETIG + return 0 def _transcript(messages: list[dict]) -> str: @@ -295,6 +291,149 @@ async def pruefung_frage_variante( 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, genau_einer: bool = False) -> dict | None: + """{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None. + genau_einer (leichte Variante): exakt eine Option muss korrekt sein.""" + 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 + if genau_einer and sum(o["korrekt"] for o in out) != 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 = ( + "0 bis 4 Optionen dürfen richtig sein (nicht zwingend genau eine). Mische bewusst." + 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, genau_einer=not 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, diff --git a/backend/models.py b/backend/models.py index 2d9fd18..53e77d3 100644 --- a/backend/models.py +++ b/backend/models.py @@ -215,27 +215,44 @@ 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", "diskussion", "antwort", "antwort_pruefen"] = "frage" + aktion: Literal[ + "frage", "diskussion", "antwort", "antwort_pruefen", + "quiz_frage", "quiz_antwort", "lueck_frage", "lueck_antwort", + ] = "frage" frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort); Anker der Basis + auswahl: list[int] = [] # Quiz/Lückentext-Auswahl: vom Lerner gewählte Options-Indizes + korrekt: list[int] = [] # Quiz/Lückentext-Auswahl: korrekte Indizes (Client hält sie aus der Generierung) + loesung: str = Field(default="", max_length=500) # Lückentext frei: erwarteter Begriff + alternativen: list[str] = [] # Lückentext frei: akzeptierte Synonyme + eingabe: str = Field(default="", max_length=500) # Lückentext frei: getippter Begriff + schwer: bool = False # Variante: leicht (+1/−1) vs schwer (+3/−1) 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 nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback) # Basis wird 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 + cap: int = Field(default=6, ge=6, le=30) # Score-Deckel = Max des höchsten erstellten Formats (6/12/18/30) messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage provider: ProviderType = "claude" gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide) +class QuizOption(BaseModel): + text: str + korrekt: bool + + class BausteinPruefungResponse(BaseModel): frage: str | None = None reply: str | None = None feedback: str | None = None - punkte: int | None = None # Punkt-Delta dieser Antwort (−1 … +3); schnell = voraussichtlich + punkte: int | None = None # Punkt-Delta dieser Antwort (−2 … +3); schnell = voraussichtlich bewertung: Literal["gut", "neutral", "schlecht"] | None = None # aus Vorzeichen, fürs Einfärben + optionen: list[QuizOption] | None = None # Quiz: 4 Optionen + Korrekt-Flags + satz: str | None = None # Lückentext: Satz mit Lücke (___) + loesung: str | None = None # Lückentext: erwarteter Begriff + alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme gute_antworten: int absolviert: bool verstanden: bool = False diff --git a/backend/regeln.py b/backend/regeln.py index 95a179d..da5624e 100644 --- a/backend/regeln.py +++ b/backend/regeln.py @@ -18,12 +18,20 @@ from paths import bausteine_path, guide_content_path from textkit import _norm_titel MAX_OFFENE_GUIDES = 3 -VORSTUFE = {"Guide": "MiniGuide", "ProGuide": "Guide", "FullGuide": "Guide"} -# Welches Niveau die Vorstufe erreichen muss, um das Format freizuschalten. -# ProGuide hängt parallel zu FullGuide an „Guide absolviert" — kein Pflicht-Gate vor FullGuide. -FREISCHALT_LEVEL = {"Guide": "absolviert", "ProGuide": "absolviert", "FullGuide": "verstanden"} +# Lineare Progression: jedes Format schaltet das nächste frei, sobald ALLE Bausteine +# der Vorstufe das geforderte Niveau (Score-Meilenstein 10/20/30) erreicht haben. +VORSTUFE = {"Guide": "MiniGuide", "ProGuide": "Guide", "FullGuide": "ProGuide"} +# absolviert=6 · verstanden=12 · gemeistert=18 (siehe lernen.NOETIG/MASTERY/MEISTERN). +FREISCHALT_LEVEL = {"Guide": "absolviert", "ProGuide": "verstanden", "FullGuide": "gemeistert"} FORMATE = ("MiniGuide", "Guide", "ProGuide", "FullGuide") +# Geforderter Score je Freischalt-Niveau — nur für die Sperr-Meldung. +_LEVEL_WORT = { + "absolviert": "absolvieren (alle Bausteine auf 6)", + "verstanden": "verstehen (alle Bausteine auf 12)", + "gemeistert": "meistern (alle Bausteine auf 18)", +} + async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]: """Guides + Kapitel-Fortschritt + Bausteine je Meilenstein. @@ -127,10 +135,9 @@ def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set if fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content): vorstufe = VORSTUFE.get(fmt) if vorstufe: - stufe = FREISCHALT_LEVEL[fmt] # "absolviert" (alle 3) oder "verstanden" (alle 10) + stufe = FREISCHALT_LEVEL[fmt] # absolviert=10 · verstanden=20 · gemeistert=30 if not ist_level(topic, vorstufe, guides, progress, levels[stufe]): - wort = "verstehen (alle Bausteine auf 10)" if stufe == "verstanden" else "absolvieren (alle Bausteine prüfen)" - return f"Erst den {vorstufe} dieses Themas {wort}" + return f"Erst den {vorstufe} dieses Themas {_LEVEL_WORT[stufe]}" stat = formate_stats(guides, progress, levels).get(fmt, {"erstellt": 0, "absolviert": 0}) offen = stat["erstellt"] - stat["absolviert"] if offen >= MAX_OFFENE_GUIDES: diff --git a/backend/routes.py b/backend/routes.py index 90673a0..0f75a26 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -18,7 +18,7 @@ from database import ( ) from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster 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_frage_variante, score_berechnen +from lernen import NOETIG, MASTERY, MEISTERN, NIVEAUS, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, score_berechnen, floor_aus_meilensteinen 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 @@ -265,6 +265,41 @@ def _farbe(punkte: int) -> str: return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht") +def _erklaeren_delta(niveau: str, nachgefragt: bool) -> int: + """Erklären-Form: Punkt-Delta aus dem Niveau (−1…+3). Mit Nachfrage höchstens +1.""" + delta = NIVEAUS.get(niveau, 0) + return min(delta, 1) if nachgefragt else delta + + +async def _punkte_buchen(req, frage: str, delta: int) -> dict: + """Score driftfrei buchen (Lock + offene_frage-Anker + Meilensteine). Gemeinsam für + Quiz/Lückentext (deterministisch/schnell). → Response-Felder ohne feedback.""" + async with _pruef_lock(req.topic, req.baustein): + stand = await get_baustein_progress(req.topic, req.baustein) + absolviert = stand["absolviert"] is not None + verstanden = stand["verstanden"] is not None + gemeistert = stand["gemeistert"] is not None + basis, re_bewertung = _basis(stand, frage) + if not re_bewertung: + await set_offene_frage(req.topic, req.baustein, frage, basis, 0) + floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert) + score = score_berechnen(basis, delta, floor, req.cap) + punkte = score - basis + gute = await set_baustein_score(req.topic, req.baustein, score) + if score >= NOETIG and not absolviert: + absolviert = True + if await set_baustein_absolviert(req.topic, req.baustein): + asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider)) + if score >= MASTERY and not verstanden: + await set_baustein_verstanden(req.topic, req.baustein) + verstanden = True + if score >= MEISTERN and not gemeistert: + await set_baustein_gemeistert(req.topic, req.baustein) + gemeistert = True + return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, + "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + + @router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse) async def baustein_pruefung_route(req: BausteinPruefungRequest): stand = await get_baustein_progress(req.topic, req.baustein) @@ -298,6 +333,54 @@ 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} + # --- Quiz: leicht (1 von 4) +1/−1 · schwer (x von 4) +3/−1 — deterministisch --- + if req.aktion == "quiz_frage": + if not req.muster.strip(): + raise HTTPException(400, "Quiz braucht ein Muster") + quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, schwer=req.schwer, provider=req.provider) + if quiz is None: + raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen") + return {"frage": quiz["frage"], "optionen": quiz["optionen"], + "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + + if req.aktion == "quiz_antwort": + if not req.frage.strip(): + raise HTTPException(400, "Quiz-Antwort braucht eine Frage") + getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge + delta = (3 if req.schwer else 1) if getroffen else -1 + res = await _punkte_buchen(req, req.frage, delta) + res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig." + return res + + # --- Lückentext: leicht (Begriff aus 4) +1/−1 · schwer (frei tippen) +3/−1 --- + if req.aktion == "lueck_frage": + if not req.muster.strip(): + raise HTTPException(400, "Lückentext braucht ein Muster") + if req.schwer: + lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider) + if lt is None: + raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen") + return {"satz": lt["satz"], "loesung": lt["loesung"], "alternativen": lt["alternativen"], + "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider) + if lw is None: + raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen") + return {"satz": lw["satz"], "optionen": lw["optionen"], + "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + + if req.aktion == "lueck_antwort": + if not req.frage.strip(): + raise HTTPException(400, "Lückentext-Antwort braucht einen Satz") + if req.schwer: # frei getippt → Synonym-tolerante KI-Prüfung + ok = await lueckentext_pruefen(req.topic, req.baustein, req.frage, req.loesung, req.alternativen, req.eingabe, provider=req.provider) + feedback = "Richtig!" if ok else f"Nicht ganz — erwartet war „{req.loesung}“." + else: # Begriff aus 4 gewählt → deterministisch + ok = set(req.auswahl) == set(req.korrekt) + feedback = "Richtig!" if ok else "Nicht ganz — der markierte Begriff war richtig." + res = await _punkte_buchen(req, req.frage, (3 if req.schwer else 1) if ok else -1) + res["feedback"] = feedback + return res + # aktion "antwort" (Agent 1 schnell) / "antwort_pruefen" (Agent 2 genau). if not any(m.get("role") == "user" for m in msgs): raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort") @@ -312,7 +395,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if data is None: raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") basis, _ = _basis(stand, req.frage) - score = score_berechnen(basis, data["niveau"], req.nachgefragt, req.tier2, req.tier3, absolviert, verstanden, gemeistert) + floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert) + score = score_berechnen(basis, _erklaeren_delta(data["niveau"], req.nachgefragt), floor, req.cap) punkte = score - basis return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} @@ -335,7 +419,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if data is None: raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") - score = score_berechnen(basis, data["niveau"], req.nachgefragt, req.tier2, req.tier3, absolviert, verstanden, gemeistert) + floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert) + score = score_berechnen(basis, _erklaeren_delta(data["niveau"], req.nachgefragt), floor, req.cap) punkte = score - basis gute = await set_baustein_score(req.topic, req.baustein, score) if score >= NOETIG and not absolviert: diff --git a/frontend/src/api.js b/frontend/src/api.js index bf29af7..675b0a8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -78,12 +78,13 @@ export async function chatBaustein({ topic, baustein, section, section_kompakt = export async function pruefeBaustein({ topic, baustein, section, section_kompakt = '', provider, aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [], - nachgefragt = false, begruendung = '', muster = '', tier2 = false, tier3 = false, messages = [], gruendlich = false, + nachgefragt = false, begruendung = '', muster = '', cap = 10, messages = [], gruendlich = false, + auswahl = [], korrekt = [], loesung = '', alternativen = [], eingabe = '', }) { const res = await fetch(`${BASE}/bausteine/pruefung`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, muster, tier2, tier3, messages, provider, gruendlich }), + body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, muster, cap, messages, provider, gruendlich, auswahl, korrekt, loesung, alternativen, eingabe }), }) return jsonOrThrow(res) } diff --git a/frontend/src/components/BausteinFokus.vue b/frontend/src/components/BausteinFokus.vue index 25e2e9b..ddba301 100644 --- a/frontend/src/components/BausteinFokus.vue +++ b/frontend/src/components/BausteinFokus.vue @@ -7,8 +7,7 @@ const props = defineProps({ topic: { type: String, required: true }, provider: { type: String, default: 'claude' }, status: { type: Object, default: null }, - tier2: { type: Boolean, default: false }, - tier3: { type: Boolean, default: false }, + cap: { type: Number, default: 6 }, tab: { type: String, default: 'pruefung' }, ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte hasPrev: { type: Boolean, default: false }, @@ -102,8 +101,7 @@ const standFarbe = computed(() => { :section-kompakt="baustein.kompakt || ''" :provider="provider" :status="status" - :tier2="tier2" - :tier3="tier3" + :cap="cap" :ansicht="ansicht" @set-ansicht="$emit('setAnsicht', $event)" @status-changed="$emit('statusChanged', $event)" diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue index 0b2b6a5..828f8e3 100644 --- a/frontend/src/components/BausteinPanel.vue +++ b/frontend/src/components/BausteinPanel.vue @@ -2,7 +2,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' import { chatBaustein, pruefeBaustein, fetchFrageMuster } from '../api.js' import { usePruefSlot } from '../pruefungCache.js' -import { renderMarkdown } from '../markdown.js' +import { renderMarkdown, renderMarkdownInline } from '../markdown.js' import { useChat, istUnten } from '../composables/useChat.js' const props = defineProps({ @@ -12,8 +12,7 @@ const props = defineProps({ sectionKompakt: { type: String, default: '' }, // kompakte Fassung (Merksätze) — Prüfungs-/Chat-Kontext provider: { type: String, default: 'claude' }, status: { type: Object, default: null }, // {gute_antworten, streak, absolviert, verstanden, gemeistert} - tier2: { type: Boolean, default: false }, // Tier 2 frei (ganzer Guide absolviert) - tier3: { type: Boolean, default: false }, // Meisterpfad frei (ganzer Guide verstanden) + cap: { type: Number, default: 6 }, // Score-Deckel = Max des höchsten Formats (6/12/18/30) // 'trigger' (Liste): nur Tab-Leiste, Klick öffnet den Vollbild-Fokus. // 'full' (Fokus): Tabs + Panel-Inhalt wie gehabt. mode: { type: String, default: 'trigger' }, @@ -23,10 +22,52 @@ const props = defineProps({ const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht']) -const NOETIG = 3 // absolviert -const MAX = 10 // verstanden -const MEISTERN = 25 // gemeistert (Maximum) +// Meilensteine (Score-Bänder): Quiz→6, Lückentext→12, Erklären→18, FullGuide-Max 30. +const ABSOLVIERT = 6 +const VERSTANDEN = 12 +const GEMEISTERT = 18 +const VOLL = 30 const st = computed(() => props.status || { gute_antworten: 0, streak: 0, absolviert: false, verstanden: false, gemeistert: false }) +// Aktiver Modus aus Score-Band (unter 30 fix) + Schwierigkeit (ALT+E). Ab 30 frei. +// Ohne Frage-Muster (alter Baustein) gibt es nur Erklären — Quiz/Lückentext brauchen die Saat. +const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren'] +const score = computed(() => st.value.gute_antworten || 0) +const schwerWahl = ref(false) // leicht/schwer unter Score 30 (ALT+E) +const freiModus = ref('quiz_leicht') // gewählter Modus ab Score 30 (ALT+E zyklisch) +const aktiverModus = computed(() => { + if (!musterModus.value) return 'erklaeren' + if (score.value >= GEMEISTERT) return freiModus.value + if (score.value >= VERSTANDEN) return 'erklaeren' + if (score.value >= ABSOLVIERT) return schwerWahl.value ? 'lueck_schwer' : 'lueck_leicht' + return schwerWahl.value ? 'quiz_schwer' : 'quiz_leicht' +}) +const aktiveForm = computed(() => aktiverModus.value.startsWith('quiz') ? 'quiz' : aktiverModus.value.startsWith('lueck') ? 'lueckentext' : 'erklaeren') +const schwer = computed(() => aktiverModus.value.endsWith('_schwer')) +// Angezeigte Form: folgt der aktiven Form, friert aber ein, solange eine gezogene Frage +// sichtbar ist (sonst verschwindet das Ergebnis, wenn die Antwort eine Bandgrenze überschreitet). +const anzeigeForm = ref(null) +const zeigeForm = computed(() => anzeigeForm.value || aktiveForm.value) +// Schwierigkeit des gerade gezeigten Widgets (eingefroren) bzw. der aktiven Wahl. +const aktSchwer = computed(() => { + if (zeigeForm.value === 'quiz' && quizAktuell.value) return quizAktuell.value.schwer + if (zeigeForm.value === 'lueckentext' && lueckAktuell.value) return lueckAktuell.value.schwer + return schwer.value +}) +const modusRegel = computed(() => { + if (zeigeForm.value === 'quiz') return aktSchwer.value ? 'x von 4 · +3/−1' : '1 von 4 · +1/−1' + if (zeigeForm.value === 'lueckentext') return aktSchwer.value ? 'frei tippen · +3/−1' : 'Begriff aus 4 · +1/−1' + return '+1/+2/+3 · Nachfragen max +1 · schwach −1' +}) +// Ende des aktuellen Bands (nächster Meilenstein) und ob der Score am Format-Deckel klebt. +const bandZiel = computed(() => { + if (score.value >= GEMEISTERT) return VOLL + if (score.value >= VERSTANDEN) return GEMEISTERT + if (score.value >= ABSOLVIERT) return VERSTANDEN + return ABSOLVIERT +}) +const amDeckel = computed(() => score.value >= props.cap && props.cap < VOLL) +const FORM_NAME = { quiz: 'Quiz', lueckentext: 'Lückentext', erklaeren: 'Erklären' } +const NAECHSTES_FORMAT = { 6: 'Guide', 12: 'ProGuide', 18: 'FullGuide' } // --- Toggle-Bereich --- const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'pruefung' @@ -56,6 +97,9 @@ const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion üb const musterQuelle = slot.musterQuelle // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback) const musterPool = slot.musterPool // Arbeitskopie, ohne Zurücklegen; leer → Reset const musterGeladen = slot.musterGeladen +const poolForm = slot.poolForm // Form, für die der Pool gefüllt ist +const quizAktuell = slot.quizAktuell // laufende Quiz-Frage (Widget-State) +const lueckAktuell = slot.lueckAktuell // laufende Lückentext-Aufgabe (Widget-State) // Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner. const pruefInput = ref('') const pruefLoading = ref(false) @@ -127,25 +171,35 @@ function gestellteFragen() { return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content))) } -// --- Fragen-Pool: ZIEL vorformulierte, unbeantwortete Fragen je Baustein (im Slot) --- +// --- Fragen-Pool: ZIEL vorformulierte Frage-OBJEKTE der aktiven Form (im Slot) --- +// Jedes Objekt: {form, frage, optionen?|satz?|loesung?|alternativen?}. Wechselt die +// aktive Form, wird der Pool geleert und für die neue Form neu gefüllt. const POOL_ZIEL = 5 const pool = slot.pool const inflight = slot.inflight +const pruefFehler = ref('') // Fehlerhinweis für Quiz/Lückentext (kein Bubble-Verlauf) let poolMiss = 0 // nur Fallback: aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife -// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback auf Live-Generierung. +// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback (nur Erklären). const musterModus = computed(() => musterGeladen.value && musterQuelle.value.length > 0) -function istBekannt(text) { - const n = normFrage(text) - return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(f) === n) +function objText(o) { return (o && (o.frage || o.satz)) || '' } + +// Lückensatz rendern: an „___" splitten, Teile inline (mit $…$-Mathe), Lücke als Span. +// So bleibt die Lücke erhalten und Markdown fasst die Unterstriche nicht an. +function lueckSatzHtml(satz) { + return String(satz || '').split('___').map((t) => renderMarkdownInline(t)).join('______') } -// Schon gestellte + bereits vorgemerkte Fragen — der Generator soll sie (auch -// sinngemäß) meiden. Nur Fallback-Modus. Original-Texte, nicht normalisiert. +function istBekannt(o) { + const n = normFrage(objText(o)) + return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(objText(f)) === n) +} + +// Schon gestellte + vorgemerkte Fragen — nur Fallback-Modus (Erklären ohne Muster). function vermeideListe() { const gestellt = pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => m.content) - return [...gestellt, ...pool.value] + return [...gestellt, ...pool.value.map(objText)] } // Muster-Sidecar einmal je Baustein laden (Slot-cached). Leer → Fallback. @@ -176,31 +230,55 @@ function musterNehmen() { return musterPool.value.shift() } +// Pool-Schlüssel = Form + Schwierigkeit (Quiz leicht/schwer ziehen verschiedene Fragen). +const poolKey = computed(() => `${aktiveForm.value}/${schwer.value}`) + +// Ein Frage-Objekt des aktiven Modus generieren (aus gezogener Saat; Erklären hat Fallback). function frageEinzeln() { + const form = aktiveForm.value + const istSchwer = schwer.value const muster = musterNehmen() - if (muster) { - // Muster-Modus: aus gezogener Saat eine konkrete, leicht andere Frage formulieren. - return pruefeBaustein({ - topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, - provider: props.provider, aktion: 'frage', muster, - }).then((res) => res.frage) + const basis = { + topic: props.topic, baustein: props.baustein, section: props.section, + section_kompakt: props.sectionKompakt, provider: props.provider, } - // Fallback (kein Muster-Sidecar): Live-Generierung mit Vermeide-Liste. - return pruefeBaustein({ - topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, - provider: props.provider, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe(), - }).then((res) => res.frage) + if (form === 'quiz' && muster) { + return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster, schwer: istSchwer }) + .then((r) => ({ form, schwer: istSchwer, frage: r.frage, optionen: r.optionen })) + } + if (form === 'lueckentext' && muster) { + return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istSchwer }) + .then((r) => istSchwer + ? ({ form, schwer: true, frage: r.satz, satz: r.satz, loesung: r.loesung, alternativen: r.alternativen }) + : ({ form, schwer: false, frage: r.satz, satz: r.satz, optionen: r.optionen })) + } + if (muster) { // erklaeren aus Muster + return pruefeBaustein({ ...basis, aktion: 'frage', muster }).then((r) => ({ form: 'erklaeren', frage: r.frage })) + } + // Fallback (kein Muster-Sidecar): Erklären live mit Vermeide-Liste. + return pruefeBaustein({ ...basis, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe() }) + .then((r) => ({ form: 'erklaeren', frage: r.frage })) } -// Pool PARALLEL auffüllen: pro fehlender Frage ein eigener Agent. Im Muster-Modus -// zieht jeder ein DISTINKTES Muster (ohne Zurücklegen) → keine Kollision, kein Miss-Cap. +// Pool leeren, wenn Form oder Schwierigkeit gewechselt hat (Bandgrenze oder ALT+E). +function poolFormPruefen() { + if (poolForm.value !== poolKey.value) { + pool.value = [] + poolForm.value = poolKey.value + poolMiss = 0 + } +} + +// Pool PARALLEL auffüllen: jeder zieht ein DISTINKTES Muster → keine Kollision. function poolFuellen() { + poolFormPruefen() + const keyBeginn = poolKey.value const cap = musterModus.value ? Infinity : 3 while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < cap) { inflight.value++ frageEinzeln() - .then((text) => { - if (text && !istBekannt(text)) { pool.value.push(text); poolMiss = 0 } + .then((obj) => { + if (obj && objText(obj) && poolKey.value === keyBeginn && (musterModus.value || !istBekannt(obj))) { pool.value.push(obj); poolMiss = 0 } else poolMiss++ }) .catch(() => { poolMiss++ }) @@ -208,7 +286,7 @@ function poolFuellen() { } } -// Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten. +// Ein Frage-Objekt aus dem Pool nehmen; ist er leer, auf das erste eintreffende warten. async function poolNehmen() { if (!pool.value.length) { const cap = musterModus.value ? Infinity : 3 @@ -223,35 +301,100 @@ async function poolNehmen() { return pool.value.length ? pool.value.shift() : null } -// Erste Frage (Phase idle): Muster laden, ZIEL parallel anstoßen, schnellste zuerst zeigen. -async function frageAnfordern() { +// Ein gezogenes Objekt der passenden Form anzeigen (Erklären-Bubble bzw. Quiz/Lück-Widget). +function frageObjZeigen(obj) { + pruefFehler.value = '' + anzeigeForm.value = obj.form // Anzeige an die gezogene Frage binden (Form einfrieren) + if (obj.form === 'quiz') { + quizAktuell.value = { frage: obj.frage, optionen: obj.optionen, schwer: obj.schwer, gewaehlt: [], fertig: false, punkte: null, bewertung: null, feedback: '' } + } else if (obj.form === 'lueckentext') { + lueckAktuell.value = { satz: obj.satz, schwer: obj.schwer, optionen: obj.optionen || null, loesung: obj.loesung || '', alternativen: obj.alternativen || [], eingabe: '', gewaehlt: [], fertig: false, punkte: null, bewertung: null, feedback: '' } + if (obj.schwer) nextTick(() => document.getElementById('bp-lueck-input')?.focus()) + } else { + frageZeigen(obj.frage) + nextTick(() => pruefInputEl.value?.focus()) + } +} + +// Erste / nächste Frage: Muster laden, Pool füllen, schnellste zuerst zeigen. +async function naechsteFrageZeigen() { if (pruefLoading.value) return poolMiss = 0 + pruefFehler.value = '' await musterLaden() poolFuellen() - const text = await poolNehmen() - if (text) { - frageZeigen(text) - nextTick(() => pruefInputEl.value?.focus()) - } else { + const obj = await poolNehmen() + if (obj) { + frageObjZeigen(obj) + pruefScroll() + } else if (aktiveForm.value === 'erklaeren') { pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine Frage — bitte erneut.' }) + } else { + pruefFehler.value = 'Keine Frage — bitte erneut.' } poolFuellen() } +const frageAnfordern = naechsteFrageZeigen -// „Nächste Frage": sofort aus dem Pool, dann nachfüllen. -async function naechsteFrageZeigen() { - if (pruefLoading.value) return - await musterLaden() - const text = await poolNehmen() - if (text) { - frageZeigen(text) - pruefScroll() - nextTick(() => pruefInputEl.value?.focus()) - } else { - pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine neue Frage — bitte erneut.' }) +// --- Quiz: leicht = Single-Select (genau 1) · schwer = Multi-Select. Deterministisch. --- +function quizToggle(i) { + const q = quizAktuell.value + if (!q || q.fertig) return + if (!q.schwer) { q.gewaehlt = q.gewaehlt.includes(i) ? [] : [i]; return } + const idx = q.gewaehlt.indexOf(i) + if (idx >= 0) q.gewaehlt.splice(idx, 1) + else q.gewaehlt.push(i) +} + +async function quizAntworten() { + const q = quizAktuell.value + if (!q || q.fertig || pruefLoading.value) return + pruefLoading.value = true + pruefFehler.value = '' + try { + const korrekt = q.optionen.map((o, i) => (o.korrekt ? i : -1)).filter((i) => i >= 0) + const res = await pruefeBaustein({ + topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, + provider: props.provider, aktion: 'quiz_antwort', frage: q.frage, cap: props.cap, + auswahl: q.gewaehlt, korrekt, schwer: q.schwer, + }) + q.fertig = true; q.punkte = res.punkte; q.bewertung = res.bewertung; q.feedback = res.feedback + applyPruefung(res) + } catch { + pruefFehler.value = 'Hat nicht geklappt — bitte erneut.' + } finally { + pruefLoading.value = false + } +} + +// --- Lückentext: leicht = Begriff aus 4 (Single-Select) · schwer = frei tippen --- +function lueckToggle(i) { + const l = lueckAktuell.value + if (!l || l.fertig || l.schwer) return + l.gewaehlt = l.gewaehlt.includes(i) ? [] : [i] +} + +async function lueckAntworten() { + const l = lueckAktuell.value + if (!l || l.fertig || pruefLoading.value) return + if (l.schwer ? !l.eingabe.trim() : !l.gewaehlt.length) return + pruefLoading.value = true + pruefFehler.value = '' + try { + const spez = l.schwer + ? { schwer: true, loesung: l.loesung, alternativen: l.alternativen, eingabe: l.eingabe } + : { schwer: false, auswahl: l.gewaehlt, korrekt: l.optionen.map((o, i) => (o.korrekt ? i : -1)).filter((i) => i >= 0) } + const res = await pruefeBaustein({ + topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, + provider: props.provider, aktion: 'lueck_antwort', frage: l.satz, cap: props.cap, ...spez, + }) + l.fertig = true; l.punkte = res.punkte; l.bewertung = res.bewertung; l.feedback = res.feedback + applyPruefung(res) + } catch { + pruefFehler.value = 'Hat nicht geklappt — bitte erneut.' + } finally { + pruefLoading.value = false } - poolFuellen() } function nachfragen() { @@ -270,7 +413,7 @@ let letzteFeedbackMsg = null // zuletzt gezeigte Bewertungs-Bubble let pruefenRun = 0 // nur die jüngste Schnell-Bewertung darf anzeigen function bewertungPayload() { - return { frage: aktuelleFrage.value, tier2: props.tier2, tier3: props.tier3, nachgefragt: nachgefragt.value } + return { frage: aktuelleFrage.value, cap: props.cap, nachgefragt: nachgefragt.value } } // Agent 1 (schnell): sofortiges Niveau + Punkte zeigen, dann Agent 2 (genau) im Hintergrund. @@ -378,30 +521,81 @@ onMounted(() => { if (props.mode !== 'full') return // Trigger-Modus (Liste): nur Tab-Leiste, keine Prüfungs-/Text-Logik window.addEventListener('keydown', onWindowKey) window.addEventListener('keydown', onPruefKey) + musterLaden() // GET (kein Agent) — setzt die aktive Form früh korrekt (Quiz/Lückentext/Erklären) }) onUnmounted(() => { window.removeEventListener('keydown', onWindowKey) window.removeEventListener('keydown', onPruefKey) }) -// ALT+1 Nachfragen (nur frage_offen) · ALT+2 primärer Button je Phase (idle: Frage anfordern, -// frage_offen: Antwort abgeben, bewertet: Nächste Frage) · ALT+3 Verlauf bewerten (frage_offen). -// Gründlich prüfen läuft per Rechtsklick auf die Bewertung, nicht über ALT. -function onPruefKey(e) { - if (!e.altKey || e.repeat) return - if (props.mode !== 'full' || activeTab.value !== 'pruefung') return - if (!['1', '2', '3'].includes(e.key)) return - e.preventDefault() - if (pruefLoading.value) return - if (e.key === '1') { - if (pruefPhase.value === 'frage_offen' && pruefInput.value.trim()) nachfragen() - } else if (e.key === '2') { - if (pruefPhase.value === 'idle') frageAnfordern() - else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() } - else naechsteFrageZeigen() - } else if (e.key === '3') { - if (pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten() +// ALT+E: unter 30 leicht↔schwer der Form (Quiz/Lückentext); ab 30 zyklisch durch alle 5 Modi. +function variantWechseln() { + const vorher = aktiverModus.value + if (score.value >= GEMEISTERT) { + freiModus.value = MODI[(MODI.indexOf(freiModus.value) + 1) % MODI.length] + } else if (aktiveForm.value === 'quiz' || aktiveForm.value === 'lueckentext') { + schwerWahl.value = !schwerWahl.value } + if (aktiverModus.value === vorher) return // nichts geändert (z.B. Erklären-Band) + // Einfrieren + altes Widget verwerfen → frische Frage der neuen Variante anfordern. + anzeigeForm.value = null + quizAktuell.value = null + lueckAktuell.value = null +} + +// Quiz / Lückentext-Auswahl ist ein Wahl-Widget mit 1–4-Tasten; sonst Textfeld/Bubble. +const wahlWidget = computed(() => { + if (zeigeForm.value === 'quiz') return quizAktuell.value + if (zeigeForm.value === 'lueckentext' && lueckAktuell.value && !lueckAktuell.value.schwer) return lueckAktuell.value + return null +}) +// Eingabefeld der aktuellen Form (Lückentext frei / Erklären) — für „Alt fokussiert". +function eingabeFeld() { + if (zeigeForm.value === 'lueckentext' && lueckAktuell.value && lueckAktuell.value.schwer && !lueckAktuell.value.fertig) { + return document.getElementById('bp-lueck-input') + } + if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen') return pruefInputEl.value + return null +} +// Primäre Aktion je Form: keine Frage → anfordern · offen → antworten · fertig → nächste. +function primaerAktion() { + if (pruefLoading.value) return + const f = zeigeForm.value + if (f === 'quiz') { if (!quizAktuell.value) frageAnfordern(); else if (!quizAktuell.value.fertig) quizAntworten(); else naechsteFrageZeigen(); return } + if (f === 'lueckentext') { if (!lueckAktuell.value) frageAnfordern(); else if (!lueckAktuell.value.fertig) lueckAntworten(); else naechsteFrageZeigen(); return } + if (pruefPhase.value === 'idle') frageAnfordern() + else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() } + else naechsteFrageZeigen() +} + +// 1–4 (nackt): Option im Wahl-Widget toggeln. Alt allein: ins Eingabefeld springen. +// Alt+2 primär (antworten/nächste) · Alt+Q Ansicht · Alt+E Variante · Alt+1/3 Erklären. +function onPruefKey(e) { + if (e.repeat) return + if (props.mode !== 'full' || activeTab.value !== 'pruefung') return + // Alt allein → ins Eingabefeld (Lückentext frei / Erklären), wie bei Frage-Antwort. + if (e.key === 'Alt') { + const el = eingabeFeld() + if (el) { e.preventDefault(); el.focus() } + return + } + if (!e.altKey) { + // Nackte 1–4: Wahl-Widget toggeln — aber nicht, während ein Textfeld den Fokus hat. + if (['1', '2', '3', '4'].includes(e.key) && wahlWidget.value && !wahlWidget.value.fertig) { + const ae = document.activeElement + if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return + e.preventDefault() + const i = Number(e.key) - 1 + if (i < wahlWidget.value.optionen.length) (zeigeForm.value === 'quiz' ? quizToggle : lueckToggle)(i) + } + return + } + if (e.code === 'KeyQ') { e.preventDefault(); emit('setAnsicht', props.ansicht === 'kompakt' ? 'erklärend' : 'kompakt'); return } + if (e.code === 'KeyE') { e.preventDefault(); variantWechseln(); return } + if (e.key === '2') { e.preventDefault(); primaerAktion(); return } + // Erklären-Sondertasten + if (e.key === '1') { e.preventDefault(); if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen' && pruefInput.value.trim()) nachfragen(); return } + if (e.key === '3') { e.preventDefault(); if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten() } } @@ -418,12 +612,10 @@ function onPruefKey(e) { @@ -455,58 +647,135 @@ function onPruefKey(e) {

- - - - - - + +

-
- -
-
+ + - -
- - -
- - -