From d389584b5880ecbb0021fd51e57d52b7d469f442 Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 24 Jun 2026 09:25:14 +0200 Subject: [PATCH] update --- backend/database.py | 37 +++++---- backend/lernen.py | 70 +++++++++++------ backend/models.py | 12 +-- backend/regeln.py | 42 +++++----- backend/routes.py | 92 ++++++++++------------ frontend/src/App.vue | 5 ++ frontend/src/components/BausteinFokus.vue | 27 +++---- frontend/src/components/BausteinPanel.vue | 95 +++++++---------------- frontend/src/components/TopicDetail.vue | 48 +++++++----- frontend/src/stufen.js | 35 +++++++++ 10 files changed, 251 insertions(+), 212 deletions(-) create mode 100644 frontend/src/stufen.js diff --git a/backend/database.py b/backend/database.py index 42be3ff..5f68b1e 100644 --- a/backend/database.py +++ b/backend/database.py @@ -545,22 +545,33 @@ async def set_baustein_absolviert(topic: str, baustein: str) -> bool: return cursor.rowcount > 0 -async def list_baustein_levels_all() -> dict[str, dict[str, set[str]]]: - """Bausteine je Meilenstein in EINER Query: {"absolviert"/"verstanden"/"gemeistert": {topic → {Titel}}}.""" +async def count_relevant_subs(topic: str, baustein: str) -> int: + """Anzahl relevanter Konsens-Subbausteine eines Bausteins (für den cap = 2×Subs).""" + from textkit import _norm_titel db = await get_db() cursor = await db.execute( - "SELECT topic, baustein, absolviert, verstanden, gemeistert FROM baustein_progress" + "SELECT COUNT(*) FROM subbausteine WHERE topic = ? AND baustein_norm = ? " + "AND status = 'konsens' AND relevanz != 'rand'", + (topic, _norm_titel(baustein)), ) - rows = await cursor.fetchall() - levels: dict[str, dict[str, set[str]]] = {"absolviert": {}, "verstanden": {}, "gemeistert": {}} - for topic, baustein, absolviert, verstanden, gemeistert in rows: - if absolviert is not None: - levels["absolviert"].setdefault(topic, set()).add(baustein) - if verstanden is not None: - levels["verstanden"].setdefault(topic, set()).add(baustein) - if gemeistert is not None: - levels["gemeistert"].setdefault(topic, set()).add(baustein) - return levels + return (await cursor.fetchone())[0] + + +async def subs_count_alle() -> dict[tuple[str, str], int]: + """Relevante Konsens-Subbausteine je (topic, baustein_norm) — für die Stufen-Ableitung.""" + db = await get_db() + cursor = await db.execute( + "SELECT topic, baustein_norm, COUNT(*) FROM subbausteine " + "WHERE status = 'konsens' AND relevanz != 'rand' GROUP BY topic, baustein_norm" + ) + return {(t, bn): n for t, bn, n in await cursor.fetchall()} + + +async def list_baustein_scores_all() -> list[tuple[str, str, int]]: + """(topic, baustein, gute_antworten) je Baustein — Rohdaten für die Stufen-Ableitung.""" + db = await get_db() + cursor = await db.execute("SELECT topic, baustein, gute_antworten FROM baustein_progress") + return [(t, b, n) for t, b, n in await cursor.fetchall()] async def delete_baustein_daten(topic: str) -> None: diff --git a/backend/lernen.py b/backend/lernen.py index c5325ee..df14186 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -21,12 +21,40 @@ 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) +# Lernstufen je Baustein — relativ zum cap (Floor in % des Maximal-Scores): +# grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%. +# Prüfungsform ist immer zufällig (5 Formen); der cap skaliert mit der Stoffmenge. +STUFEN = (("anfaenger", 0.2), ("fortgeschritten", 0.4), ("experte", 0.6), ("meister", 1.0)) + + +def cap_fuer(n_subs: int) -> int: + """Maximal-Score eines Bausteins: 2 Punkte je relevantem Subbaustein, gedeckelt [10, 50].""" + return max(10, min(50, 2 * n_subs)) + + +def _schwelle(p: float, cap: int) -> int: + return round(p * cap) + + +def stufe_aus_score(score: int, cap: int) -> str | None: + """Höchste erreichte Stufe (None unter 20 %).""" + erreicht = None + for key, p in STUFEN: + if score >= _schwelle(p, cap): + erreicht = key + return erreicht + + +def progressiver_malus(basis: int, cap: int) -> int: + """Fehler-Strafe nach Fortschritt: 0–20 %→0 · 21–40 %→−1 · 41–60 %→−2 · 61–100 %→−3.""" + pct = (basis / cap) if cap else 0.0 + if pct <= 0.2: + return 0 + if pct <= 0.4: + return -1 + if pct <= 0.6: + return -2 + return -3 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 @@ -54,30 +82,22 @@ NIVEAUS = { 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.""" + (Quiz +1/+3, Lückentext +1/+3, Erklären −1…+3; falsch = progressiver_malus). Auf + [floor, cap] geklemmt. floor = höchste erreichte Stufenschwelle (kein Rückfall); + cap = 2× relevante Subbausteine [10, 50]. Am Deckel (basis ≥ cap) eingefroren („Max").""" if basis >= cap: return basis return max(floor, min(cap, basis + delta)) -def _zufall_malus(basis: int, delta: int) -> int: - """Im Zufallband (Score ≥ MEISTERN=18, FullGuide) kostet jeder Fehler −3 statt −1 — - die 30 erreicht man nur mit Sicherheit. Richtige Antworten bleiben unverändert.""" - return -3 if (basis >= MEISTERN and delta < 0) else 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 floor_aus_score(basis: int, cap: int) -> int: + """Untergrenze: höchste erreichte Stufenschwelle — kein Rückfall unter die Stufe.""" + floor = 0 + for _, p in STUFEN: + s = _schwelle(p, cap) + if basis >= s: + floor = s + return floor def _transcript(messages: list[dict]) -> str: diff --git a/backend/models.py b/backend/models.py index 2a781ba..df5b88e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -230,8 +230,8 @@ class BausteinPruefungRequest(BaseModel): 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. - cap: int = Field(default=6, ge=6, le=30) # Score-Deckel = Max des höchsten erstellten Formats (6/12/18/30) + # Basis + cap werden serverseitig geführt (Anker bzw. 2×Subs) — Client-cap nur Hinweis. + cap: int = Field(default=10, ge=1, le=50) # Score-Deckel = clamp(2×relevante Subbausteine, 10, 50) messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage provider: ProviderType = "claude" gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide) @@ -253,17 +253,13 @@ class BausteinPruefungResponse(BaseModel): 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 - gemeistert: bool = False + cap: int = 10 # Maximal-Score dieses Bausteins (2×Subs, geklemmt) — Frontend leitet die Stufe ab class BausteinLernstand(BaseModel): gute_antworten: int streak: int = 0 - absolviert: bool - verstanden: bool - gemeistert: bool + cap: int = 10 # 2×relevante Subbausteine, geklemmt [10, 50] class BausteinLernstandResponse(BaseModel): diff --git a/backend/regeln.py b/backend/regeln.py index a4beea1..42111d3 100644 --- a/backend/regeln.py +++ b/backend/regeln.py @@ -11,36 +11,42 @@ Query-Schleifen mehr pro Guide. import json -from database import list_baustein_levels_all, list_guides, list_progress_all +from database import list_baustein_scores_all, subs_count_alle, list_guides, list_progress_all from guide import guide_slot_dateien +from lernen import cap_fuer, STUFEN, _schwelle from paths import bausteine_path, guide_content_path from textkit import _norm_titel MAX_OFFENE_GUIDES = 3 -# Nur noch EIN Format „Guide" (alle relevanten Bausteine, Prüfung 0–30). Keine Progression, +# Nur noch EIN Format „Guide" (alle relevanten Bausteine, Prüfung 0–cap). Keine Progression, # keine Vorstufe → der Guide ist immer freischaltbar. „Rest"/FullGuide separat. VORSTUFE: dict[str, str] = {} FREISCHALT_LEVEL: dict[str, str] = {} FORMATE = ("Guide",) -# Geforderter Score je Freischalt-Niveau — nur für die Sperr-Meldung. +# 4 Lernstufen (Floor in % des cap) — Schlüssel aus lernen.STUFEN. _LEVEL_WORT = { - "absolviert": "absolvieren (alle Bausteine auf 6)", - "verstanden": "verstehen (alle Bausteine auf 12)", - "gemeistert": "meistern (alle Bausteine auf 18)", + "anfaenger": "auf Anfänger (20 %)", + "fortgeschritten": "auf Fortgeschritten (40 %)", + "experte": "auf Experte (60 %)", + "meister": "meistern (100 %)", } async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]: - """Guides + Kapitel-Fortschritt + Bausteine je Meilenstein. + """Guides + Kapitel-Fortschritt + Bausteine je Stufe. - levels: {"absolviert"/"verstanden"/"gemeistert": {topic → normalisierte Titel}}. + levels: {"anfaenger"/"fortgeschritten"/"experte"/"meister": {topic → normalisierte Titel}}. + Stufe je Baustein wird aus Score + cap (2×relevante Subs) abgeleitet. """ - roh = await list_baustein_levels_all() - levels = { - stufe: {topic: {_norm_titel(b) for b in titel} for topic, titel in pro_topic.items()} - for stufe, pro_topic in roh.items() - } + scores = await list_baustein_scores_all() + caps = await subs_count_alle() + levels: dict[str, dict[str, set[str]]] = {key: {} for key, _ in STUFEN} + for topic, baustein, score in scores: + cap = cap_fuer(caps.get((topic, _norm_titel(baustein)), 0)) + for key, p in STUFEN: + if score >= _schwelle(p, cap): + levels[key].setdefault(topic, set()).add(_norm_titel(baustein)) return await list_guides(), await list_progress_all(), levels @@ -91,13 +97,13 @@ def ist_level(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[ def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool: - """Alle Bausteine des neuesten fertigen Guides absolviert (≥3)?""" - return ist_level(topic, fmt, guides, progress, levels["absolviert"]) + """Alle Bausteine des neuesten fertigen Guides mindestens Anfänger (≥20 %)?""" + return ist_level(topic, fmt, guides, progress, levels["anfaenger"]) def thema_abgeschlossen(topic: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool: - """Thema fertig: neuester fertiger Guide, alle Bausteine gemeistert (18)?""" - return ist_level(topic, "Guide", guides, progress, levels["gemeistert"]) + """Thema fertig: neuester fertiger Guide, alle Bausteine auf Meister (100 %)?""" + return ist_level(topic, "Guide", guides, progress, levels["meister"]) def formate_stats(guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> dict: @@ -105,7 +111,7 @@ def formate_stats(guides: list[dict], progress: dict[str, set[str]], levels: dic formate = {} for fmt in FORMATE: neueste = _neueste_done(guides, fmt) - absolviert = sum(1 for g in neueste.values() if _guide_alle(g, progress, levels["absolviert"])) + absolviert = sum(1 for g in neueste.values() if _guide_alle(g, progress, levels["anfaenger"])) formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert} return formate diff --git a/backend/routes.py b/backend/routes.py index 3fa35f6..0a36b93 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -14,12 +14,13 @@ from database import ( list_progress, set_progress, delete_progress, create_element, list_elements, get_element, update_element, delete_element, list_baustein_progress, get_baustein_progress, set_offene_frage, - set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten, + set_baustein_score, set_baustein_absolviert, delete_baustein_daten, count_relevant_subs, subs_count_alle, delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content, ) +from textkit import _norm_titel 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, 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, _zufall_malus +from lernen import NIVEAUS, cap_fuer, 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_score, progressiver_malus, stufe_aus_score from guide import generate_guide, guide_slot_dateien, guide_fertig_step from pipeline import cancel_guide from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen @@ -221,15 +222,14 @@ async def get_frage_muster(topic: str, baustein: str): @router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse) async def baustein_lernstand(topic: str): - """Prüfungs-Stand pro Baustein (roher Titel als Key).""" + """Prüfungs-Stand pro Baustein (roher Titel als Key). cap = 2×relevante Subs [10,50].""" progress = await list_baustein_progress(topic) + caps = await subs_count_alle() bausteine = { p["baustein"]: { "gute_antworten": p["gute_antworten"], "streak": p["streak"], - "absolviert": p["absolviert"] is not None, - "verstanden": p["verstanden"] is not None, - "gemeistert": p["gemeistert"] is not None, + "cap": cap_fuer(caps.get((topic, _norm_titel(p["baustein"])), 0)), } for p in progress } @@ -276,42 +276,37 @@ def _erklaeren_delta(niveau: str, nachgefragt: bool) -> int: 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 def _cap_fuer(topic: str, baustein: str) -> int: + """Maximal-Score des Bausteins = clamp(2×relevante Subbausteine, 10, 50).""" + return cap_fuer(await count_relevant_subs(topic, baustein)) + + +async def _buche(req, frage: str, delta: int, cap: int) -> dict: + """Score driftfrei buchen (Lock + offene_frage-Anker). Fehler (delta<0) → progressiver + Malus nach Fortschritt. Element einmalig anlegen, sobald Anfänger (20 %) erreicht.""" 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 + war_stufe = stand["absolviert"] is not None # Element-Guard: schon je angelegt? 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, _zufall_malus(basis, delta), floor, req.cap) + floor = floor_aus_score(basis, cap) + d = progressiver_malus(basis, cap) if delta < 0 else delta + score = score_berechnen(basis, d, floor, cap) punkte = score - basis gute = await set_baustein_score(req.topic, req.baustein, score) - if score >= NOETIG and not absolviert: - absolviert = True + # Lern-Element einmalig anlegen, sobald die erste Stufe (Anfänger) erreicht ist. + if not war_stufe and stufe_aus_score(score, cap) is not None: 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} + return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "cap": cap} @router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse) async def baustein_pruefung_route(req: BausteinPruefungRequest): stand = await get_baustein_progress(req.topic, req.baustein) gute = stand["gute_antworten"] - absolviert = stand["absolviert"] is not None - verstanden = stand["verstanden"] is not None - gemeistert = stand["gemeistert"] is not None + cap = await _cap_fuer(req.topic, req.baustein) kompakt = req.section_kompakt msgs = [m.model_dump() for m in req.messages] @@ -325,7 +320,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): 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} + return {"frage": frage, "gute_antworten": gute, "cap": cap} if req.aktion == "diskussion": if not req.frage.strip(): @@ -336,7 +331,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): ) if reply is None: raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen") - return {"reply": reply, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + return {"reply": reply, "gute_antworten": gute, "cap": cap} # --- Quiz: leicht (1 von 4) +1/−1 · schwer (x von 4) +3/−1 — deterministisch --- if req.aktion == "quiz_frage": @@ -346,14 +341,14 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): 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} + "gute_antworten": gute, "cap": cap} 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 = await _buche(req, req.frage, delta, cap) res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig." return res @@ -366,12 +361,12 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): 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} + "gute_antworten": gute, "cap": cap} 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} + "gute_antworten": gute, "cap": cap} if req.aktion == "lueck_antwort": if not req.frage.strip(): @@ -382,7 +377,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): 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 = await _buche(req, req.frage, (3 if req.schwer else 1) if ok else -1, cap) res["feedback"] = feedback return res @@ -400,19 +395,19 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if data is None: raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") basis, _ = _basis(stand, req.frage) - floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert) - score = score_berechnen(basis, _zufall_malus(basis, _erklaeren_delta(data["niveau"], req.nachgefragt)), floor, req.cap) + floor = floor_aus_score(basis, cap) + roh = _erklaeren_delta(data["niveau"], req.nachgefragt) + d = progressiver_malus(basis, cap) if roh < 0 else roh + score = score_berechnen(basis, d, floor, cap) punkte = score - basis return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte), - "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + "gute_antworten": gute, "cap": cap} # aktion "antwort_pruefen" (Agent 2 genau): verbindlich, persistiert. Serialisiert je Baustein, # Basis driftfrei über den offene_frage-Anker. NUR hier ändert sich der Score. 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 + war_stufe = stand["absolviert"] is not None # Element-Guard basis, re_bewertung = _basis(stand, req.frage) if not re_bewertung: await set_offene_frage(req.topic, req.baustein, req.frage, basis, 0) @@ -424,22 +419,17 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if data is None: raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") - floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert) - score = score_berechnen(basis, _zufall_malus(basis, _erklaeren_delta(data["niveau"], req.nachgefragt)), floor, req.cap) + floor = floor_aus_score(basis, cap) + roh = _erklaeren_delta(data["niveau"], req.nachgefragt) + d = progressiver_malus(basis, cap) if roh < 0 else roh + score = score_berechnen(basis, d, floor, cap) punkte = score - basis gute = await set_baustein_score(req.topic, req.baustein, score) - if score >= NOETIG and not absolviert: - absolviert = True + if not war_stufe and stufe_aus_score(score, cap) is not None: 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 {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte), - "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} + "gute_antworten": gute, "cap": cap} # --- Guides --- diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e08fae7..2c8ea52 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -485,6 +485,11 @@ onMounted(async () => { --success-soft: #d1fae5; --success-soft-hover: #a7f3d0; --success-border: #34d399; + /* 4 Lernstufen: grün/blau/lila/gold = Anfänger/Fortgeschritten/Experte/Meister */ + --stufe-anfaenger: #22c55e; + --stufe-fortgeschritten: #3b82f6; + --stufe-experte: #8b5cf6; + --stufe-meister: #d4af37; --warning: #92400e; --warning-soft: #fef3c7; --warning-border: #fbbf24; diff --git a/frontend/src/components/BausteinFokus.vue b/frontend/src/components/BausteinFokus.vue index 5e72d9b..ec69cce 100644 --- a/frontend/src/components/BausteinFokus.vue +++ b/frontend/src/components/BausteinFokus.vue @@ -1,6 +1,7 @@ @@ -76,17 +78,16 @@ const standFarbe = computed(() => { {{ baustein.title }} - ✓✓✓ Gemeistert - ✓✓ Verstanden - ✓ Absolviert + {{ stufe.kurz }} {{ stufe.label }} -
-
-
-
+
+
+
+
+
diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue index 517f20d..90b34e3 100644 --- a/frontend/src/components/BausteinPanel.vue +++ b/frontend/src/components/BausteinPanel.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' import { chatBaustein, pruefeBaustein, fetchFrageMuster } from '../api.js' import { usePruefSlot } from '../pruefungCache.js' import { renderMarkdown, renderMarkdownInline } from '../markdown.js' +import { stufeFuer, malusRegel } from '../stufen.js' import { useChat, istUnten } from '../composables/useChat.js' const props = defineProps({ @@ -22,74 +23,40 @@ const props = defineProps({ const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht']) -// 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 }) -// Volle Prüfung (cap 30): durchgehend zufällig aus allen 5 Formen. Kurze Prüfung (cap 6): -// Score-Band + Schwierigkeit (ALT+E). Ohne Frage-Muster (alter Baustein) nur Erklären. +// Lernstufen relativ zum cap (stufen.js): grün/blau/lila/gold @ 20/40/60/100 %. +// Prüfung IMMER zufällig aus 5 Formen (mit Muster); cap = 2×relevante Subbausteine. const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren'] +const st = computed(() => props.status || { gute_antworten: 0, streak: 0, cap: props.cap }) const score = computed(() => st.value.gute_antworten || 0) -const schwerWahl = ref(false) // leicht/schwer in den Bändern unter 19 (ALT+E) -// Band aus einem Score: Lückentext ab 6, Erklären ab 12, Zufall ab 18 (Meilenstein-inklusiv). -function bandFuer(s) { - if (s >= GEMEISTERT) return 'zufall' - if (s >= VERSTANDEN) return 'erklaeren' - if (s >= ABSOLVIERT) return 'lueckentext' - return 'quiz' -} -// Am Format-Max: Score eingefroren („Max"). Gilt auch bei cap 30 (FullGuide, endgültig). -const amDeckel = computed(() => score.value >= props.cap) -// Aktuelles Band: volle Prüfung (cap 30) ist DURCHGEHEND zufällig aus allen 5 Formen. -// Sonst score-basiert (am Deckel das Band des erreichten Formats, cap−1). -// Ohne Frage-Muster (alter Baustein) gibt es nur Erklären. -const aktuellesBand = computed(() => - !musterModus.value ? 'erklaeren' - : props.cap >= VOLL ? 'zufall' - : bandFuer(amDeckel.value ? props.cap - 1 : score.value)) -// 'zufall' = Platzhalter; das gezogene Objekt trägt die echte Form/Schwierigkeit. -const aktiverModus = computed(() => { - const b = aktuellesBand.value - if (b === 'lueckentext') return schwerWahl.value ? 'lueck_schwer' : 'lueck_leicht' - if (b === 'quiz') return schwerWahl.value ? 'quiz_schwer' : 'quiz_leicht' - return b // 'erklaeren' | 'zufall' -}) +const cap = computed(() => st.value.cap || props.cap) +const scoreAnzeige = computed(() => Math.min(score.value, cap.value)) // Altbestand kann cap übersteigen +const stufe = computed(() => stufeFuer(score.value, cap.value)) // Objekt {key,label,kurz,farbe} | null +const schwerWahl = ref(false) // ungenutzt im Zufallsmodus (immer zufällig) +const amDeckel = computed(() => score.value >= cap.value) +// Mit Frage-Muster zufällig aus 5 Formen; ohne Muster (alter Baustein) nur Erklären. +const aktuellesBand = computed(() => musterModus.value ? 'zufall' : 'erklaeren') const imZufall = computed(() => aktuellesBand.value === 'zufall') -const aktiveForm = computed(() => { - const m = aktiverModus.value - if (m === 'zufall') return 'quiz' // Platzhalter fürs idle-Widget; Objekt überschreibt - return m.startsWith('quiz') ? 'quiz' : m.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 aktiverModus = computed(() => aktuellesBand.value) // 'zufall' | 'erklaeren' +const aktiveForm = computed(() => imZufall.value ? 'quiz' : 'erklaeren') // Platzhalter; gezogenes Objekt überschreibt +const schwer = computed(() => false) +// Angezeigte Form: folgt der aktiven Form, friert ein, solange eine gezogene Frage sichtbar ist. const anzeigeForm = ref(null) const zeigeForm = computed(() => anzeigeForm.value || aktiveForm.value) -// Band, in dem die gezeigte Frage gezogen wurde. Weicht es vom aktuellen Band ab, hat die -// letzte Antwort einen Meilenstein überschritten → Button „Zum nächsten Abschnitt". const anzeigeBand = ref(null) const abschnittWechsel = computed(() => anzeigeBand.value && anzeigeBand.value !== aktuellesBand.value) -// Schwierigkeit des gerade gezeigten Widgets (eingefroren) bzw. der aktiven Wahl. +// Schwierigkeit des gerade gezeigten Widgets (vom gezogenen Objekt). 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 + return false }) const modusRegel = computed(() => { - const m = score.value >= GEMEISTERT ? '−3' : '−1' // Fehler kostet erst ab Score 18 −3 + const m = malusRegel(score.value, cap.value) // '0' | '−1' | '−2' | '−3' nach Fortschritt if (zeigeForm.value === 'quiz') return aktSchwer.value ? `x von 4 · +3/${m}` : `1 von 4 · +1/${m}` if (zeigeForm.value === 'lueckentext') return aktSchwer.value ? `frei tippen · +3/${m}` : `Begriff aus 4 · +1/${m}` - return score.value >= GEMEISTERT ? '+1/+2/+3 · Fehler −3' : '+1/+2/+3 · Nachfragen max +1 · schwach −1' -}) -// Ende des aktuellen Bands (nächster Meilenstein). -const bandZiel = computed(() => { - if (score.value >= GEMEISTERT) return VOLL - if (score.value >= VERSTANDEN) return GEMEISTERT - if (score.value >= ABSOLVIERT) return VERSTANDEN - return ABSOLVIERT + return `+1/+2/+3 · Fehler ${m}` }) +const bandZiel = computed(() => cap.value) const FORM_NAME = { quiz: 'Quiz', lueckentext: 'Lückentext', erklaeren: 'Erklären' } // --- Toggle-Bereich --- @@ -141,7 +108,7 @@ function onPruefScroll() { function applyPruefung(res) { // res.streak nur bei antwort_pruefen gesetzt (verbindlich, persistiert); sonst Streak unverändert lassen. - emit('statusChanged', { baustein: props.baustein, ...st.value, gute_antworten: res.gute_antworten, streak: res.streak ?? st.value.streak, absolviert: res.absolviert, verstanden: res.verstanden, gemeistert: res.gemeistert }) + emit('statusChanged', { baustein: props.baustein, ...st.value, gute_antworten: res.gute_antworten, streak: res.streak ?? st.value.streak, cap: res.cap ?? cap.value }) } async function pruefScroll() { @@ -310,8 +277,8 @@ function poolFormPruefen() { function poolFuellen() { poolFormPruefen() const keyBeginn = poolKey.value - const cap = musterModus.value ? Infinity : 3 - while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < cap) { + const missCap = musterModus.value ? Infinity : 3 + while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < missCap) { inflight.value++ frageEinzeln() .then((obj) => { @@ -326,10 +293,10 @@ function poolFuellen() { // 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 + const missCap = musterModus.value ? Infinity : 3 pruefLoading.value = true try { - while (!pool.value.length && (inflight.value > 0 || poolMiss < cap)) { + while (!pool.value.length && (inflight.value > 0 || poolMiss < missCap)) { if (!inflight.value) poolFuellen() await new Promise((r) => setTimeout(r, 120)) } @@ -657,10 +624,8 @@ function onPruefKey(e) {
@@ -692,9 +657,9 @@ function onPruefKey(e) {

- - - + + +