This commit is contained in:
team3
2026-06-24 09:25:14 +02:00
parent 3b1e84e17d
commit d389584b58
10 changed files with 251 additions and 212 deletions

View File

@@ -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:

View File

@@ -21,12 +21,40 @@ from textkit import _norm_titel
log = logging.getLogger("creator.lernen")
# Meilensteine je Baustein (Score 030). Form ergibt sich aus dem Score-Band:
# 06 Quiz, 712 Lückentext, 1318 Erklären, 1930 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: 020 %→0 · 2140 %1 · 4160 %2 · 61100 %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:

View File

@@ -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):

View File

@@ -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 030). Keine Progression,
# Nur noch EIN Format „Guide" (alle relevanten Bausteine, Prüfung 0cap). 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

View File

@@ -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 ---

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
<script setup>
import BausteinPanel from './BausteinPanel.vue'
import { renderMarkdown } from '../markdown.js'
import { stufeFuer, STUFEN } from '../stufen.js'
const props = defineProps({
baustein: { type: Object, required: true }, // { title, md, num }
@@ -12,7 +13,7 @@ const props = defineProps({
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
fortschritt: { type: Object, default: () => ({ total: 0, absolviert: 0, verstanden: 0, gemeistert: 0 }) },
fortschritt: { type: Object, default: () => ({ total: 0, anfaenger: 0, fortgeschritten: 0, experte: 0, meister: 0 }) },
})
import { computed, onMounted, onUnmounted, ref } from 'vue'
@@ -58,13 +59,14 @@ onUnmounted(() => {
const pct = (n) => (100 * n / (props.fortschritt.total || 1)) + '%'
// Akzentfarbe nach Stand des aktuellen Bausteins: gemeistert=gold, verstanden=lila, absolviert=grün.
// Akzentfarbe nach Stufe des aktuellen Bausteins (grün/blau/lila/gold) oder neutral.
const standFarbe = computed(() => {
const s = props.status || {}
if (s.gemeistert) return '#d4af37'
if (s.verstanden) return '#8b5cf6'
if (s.absolviert) return 'var(--success-border)'
return 'var(--border)'
return stufeFuer(s.gute_antworten || 0, s.cap || props.cap)?.farbe || 'var(--border)'
})
const stufe = computed(() => {
const s = props.status || {}
return stufeFuer(s.gute_antworten || 0, s.cap || props.cap)
})
</script>
@@ -76,17 +78,16 @@ const standFarbe = computed(() => {
<button class="fokus-btn" :disabled="!hasPrev" title="Voriger Baustein" @click="$emit('prev')"></button>
<button class="fokus-btn" :disabled="!hasNext" title="Nächster Baustein" @click="$emit('next')"></button>
<span class="fokus-titel">{{ baustein.title }}</span>
<span v-if="status?.gemeistert" class="stand-badge gold"> Gemeistert</span>
<span v-else-if="status?.verstanden" class="stand-badge lila"> Verstanden</span>
<span v-else-if="status?.absolviert" class="stand-badge gruen"> Absolviert</span>
<span v-if="stufe" class="stand-badge" :style="{ color: stufe.farbe, borderColor: stufe.farbe }">{{ stufe.kurz }} {{ stufe.label }}</span>
<span class="fokus-spacer"></span>
<button class="fokus-btn" title="Vollansicht beenden" @click="$emit('close')"></button>
</div>
</div>
<div class="fokus-xp" :title="`${fortschritt.absolviert}/${fortschritt.total} absolviert · ${fortschritt.verstanden} verstanden · ${fortschritt.gemeistert} gemeistert`">
<div class="xp-seg gold" :style="{ width: pct(fortschritt.gemeistert) }"></div>
<div class="xp-seg lila" :style="{ width: pct(fortschritt.verstanden - fortschritt.gemeistert) }"></div>
<div class="xp-seg gruen" :style="{ width: pct(fortschritt.absolviert - fortschritt.verstanden) }"></div>
<div class="fokus-xp" :title="`${fortschritt.anfaenger}/${fortschritt.total} ab Anfänger · ${fortschritt.fortgeschritten} Fortgeschritten · ${fortschritt.experte} Experte · ${fortschritt.meister} Meister`">
<div class="xp-seg" :style="{ width: pct(fortschritt.meister), background: 'var(--stufe-meister)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.experte - fortschritt.meister), background: 'var(--stufe-experte)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.fortgeschritten - fortschritt.experte), background: 'var(--stufe-fortgeschritten)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.anfaenger - fortschritt.fortgeschritten), background: 'var(--stufe-anfaenger)' }"></div>
</div>
<div class="fokus-body">
<div ref="guideEl" class="fokus-col left">

View File

@@ -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, cap1).
// 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) {
</button>
<button :class="{ active: activeTab === 'pruefung' }" @click="tabKlick('pruefung')">
Prüfung
<span v-if="st.gemeistert" class="bp-chip gold" :title="`Gemeistert (${GEMEISTERT})`"> {{ score }}/{{ cap }}</span>
<span v-else-if="st.verstanden" class="bp-chip lila" :title="`Verstanden (${VERSTANDEN})`"> {{ score }}/{{ cap }}</span>
<span v-else-if="st.absolviert" class="bp-chip done" :title="`Absolviert (${ABSOLVIERT})`"> {{ score }}/{{ cap }}</span>
<span v-else-if="score" class="bp-chip">{{ score }}/{{ cap }}</span>
<span v-if="stufe" class="bp-chip" :style="{ borderColor: stufe.farbe, color: stufe.farbe }" :title="`${stufe.label} (${cap})`">{{ stufe.kurz }} {{ scoreAnzeige }}/{{ cap }}</span>
<span v-else-if="score" class="bp-chip">{{ scoreAnzeige }}/{{ cap }}</span>
</button>
</div>
@@ -692,9 +657,9 @@ function onPruefKey(e) {
<!-- Prüfung: gesteuerter Dialog -->
<div v-else>
<p class="bp-hint">
<template v-if="amDeckel">{{ FORM_NAME[zeigeForm] }} · {{ score }}/{{ cap }} <strong>Max</strong>.</template>
<template v-else-if="imZufall && !anzeigeForm">Zufällig · {{ score }}/{{ cap }}</template>
<template v-else-if="imZufall">{{ FORM_NAME[zeigeForm] }} · {{ score }}/{{ cap }} · {{ modusRegel }}</template>
<template v-if="amDeckel">{{ FORM_NAME[zeigeForm] }} · {{ scoreAnzeige }}/{{ cap }} <strong>Max</strong>.</template>
<template v-else-if="imZufall && !anzeigeForm">Zufällig · {{ scoreAnzeige }}/{{ cap }}</template>
<template v-else-if="imZufall">{{ FORM_NAME[zeigeForm] }} · {{ scoreAnzeige }}/{{ cap }} · {{ modusRegel }}</template>
<template v-else>{{ FORM_NAME[zeigeForm] }} · {{ score }}/{{ bandZiel }} · {{ modusRegel }}</template>
</p>
<button

View File

@@ -2,6 +2,7 @@
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
import { renderMarkdown } from '../markdown.js'
import { stufeFuer, schwelle } from '../stufen.js'
import { useChat } from '../composables/useChat.js'
import BausteinPanel from './BausteinPanel.vue'
import BausteinFokus from './BausteinFokus.vue'
@@ -83,10 +84,15 @@ async function loadContent() {
}
// --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel (lernstand oben deklariert) ---
function stufeVon(title) {
const l = lernstand.value[title]
return l ? stufeFuer(l.gute_antworten || 0, l.cap || 10) : null
}
function onBausteinStatus(baustein, status) {
const warAbsolviert = lernstand.value[baustein]?.absolviert
const alt = stufeVon(baustein)?.key || null
lernstand.value = { ...lernstand.value, [baustein]: status }
if (status.absolviert && !warAbsolviert) emit('progressChanged') // Locks/Stats neu laden
const neu = stufeFuer(status.gute_antworten || 0, status.cap || 10)?.key || null
if (neu !== alt) emit('progressChanged') // Stufenwechsel → Locks/Stats neu laden
}
// --- Vollbild-Fokus: ein Baustein groß, Guide links + Prüfung rechts ---
@@ -116,20 +122,25 @@ function fokusNext() {
for (let i = fokusIndex.value + 1; i < bausteine.value.length; i++) if (istPruefbar(bausteine.value[i])) { fokusIndex.value = i; return }
}
// Erfahrungsleiste: Lernstand über alle Bausteine des Guides (sticky, absolviert ⊇ verstanden ⊇ gemeistert).
// Erfahrungsleiste: kumulativer Stufen-Stand über alle Bausteine (gold ⊆ lila ⊆ blau ⊆ grün).
const fortschritt = computed(() => {
let a = 0, v = 0, g = 0
const z = { total: bausteine.value.length, anfaenger: 0, fortgeschritten: 0, experte: 0, meister: 0 }
for (const s of bausteine.value) {
const l = lernstand.value[s.title] || {}
if (l.absolviert) a++
if (l.verstanden) v++
if (l.gemeistert) g++
const l = lernstand.value[s.title]
if (!l) continue
const sc = l.gute_antworten || 0, cp = l.cap || 10
if (sc >= schwelle(0.2, cp)) z.anfaenger++
if (sc >= schwelle(0.4, cp)) z.fortgeschritten++
if (sc >= schwelle(0.6, cp)) z.experte++
if (sc >= schwelle(1.0, cp)) z.meister++
}
return { total: bausteine.value.length, absolviert: a, verstanden: v, gemeistert: g }
return z
})
// Prüfung läuft voll 030, sobald Guide ODER FullGuide fertig ist (gleicher Baustein-Score).
const cap = computed(() => ((props.doneByFormat?.Guide || props.doneByFormat?.FullGuide) ? 30 : 6))
// cap je Baustein = 2×relevante Subbausteine (vom Backend in lernstand[title].cap geliefert).
function capVon(title) {
return lernstand.value[title]?.cap || 10
}
// Section prüfbar? Rest nie. Sonst prüfbar, außer das Feld ist explizit false (FullGuide-Rand).
// Fehlt das Feld (alte Guides ohne `pruefbar`) → prüfbar, damit Bestands-Guides weiter funktionieren.
@@ -223,7 +234,7 @@ function extractContext() {
<header class="guide-head">
<h1>{{ previewGuide.topic }}</h1>
<span class="guide-format">{{ previewGuide.format }}</span>
<span v-if="themaAbgeschlossen" class="thema-done" title="Alle Bausteine gemeistert (18/18)"> Thema abgeschlossen</span>
<span v-if="themaAbgeschlossen" class="thema-done" title="Alle Bausteine auf Meister"> Thema abgeschlossen</span>
</header>
<section
@@ -238,14 +249,13 @@ function extractContext() {
v-for="s in ch.sections"
:key="s.num"
:data-num="s.num"
:class="['section-card', lernstand[s.title]?.gemeistert ? 'gemeistert' : (lernstand[s.title]?.verstanden ? 'verstanden' : (lernstand[s.title]?.absolviert ? 'absolviert' : ''))]"
class="section-card"
:style="stufeVon(s.title) ? { borderLeftColor: stufeVon(s.title).farbe } : {}"
>
<h3 :class="{ 'baustein-klick': istPruefbar(s) }" @click="istPruefbar(s) && openFokus(s, 'pruefung')">
{{ s.title }}
<template v-if="istPruefbar(s)">
<span v-if="lernstand[s.title]?.gemeistert" class="baustein-done gemeistert" title="Gemeistert (25/25)"> Gemeistert</span>
<span v-else-if="lernstand[s.title]?.verstanden" class="baustein-done verstanden" title="Vollständig verstanden (10/10)"> Verstanden</span>
<span v-else-if="lernstand[s.title]?.absolviert" class="baustein-done" title="Prüfung bestanden"> Absolviert</span>
<template v-if="istPruefbar(s) && stufeVon(s.title)">
<span class="baustein-done" :style="{ color: stufeVon(s.title).farbe, borderColor: stufeVon(s.title).farbe }" :title="`${stufeVon(s.title).label} (${capVon(s.title)})`">{{ stufeVon(s.title).kurz }} {{ stufeVon(s.title).label }}</span>
</template>
</h3>
<div v-if="sichtbar[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
@@ -255,7 +265,7 @@ function extractContext() {
mode="trigger"
:baustein="s.title"
:status="lernstand[s.title]"
:cap="cap"
:cap="capVon(s.title)"
:topic="previewGuide.topic"
@open-fokus="(tab) => openFokus(s, tab)"
/>
@@ -280,7 +290,7 @@ function extractContext() {
:provider="provider"
:fortschritt="fortschritt"
:status="lernstand[fokusBaustein.title]"
:cap="cap"
:cap="capVon(fokusBaustein.title)"
:tab="fokusTab"
:ansicht="ansichtModus"
:has-prev="fokusIndex > 0"

35
frontend/src/stufen.js Normal file
View File

@@ -0,0 +1,35 @@
// Lernstufen je Baustein — identisch zum Backend (lernen.py STUFEN).
// Floor in % des cap (cap = clamp(2×relevante Subbausteine, 10, 50)).
// grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%.
export const STUFEN = [
{ key: 'anfaenger', label: 'Anfänger', kurz: '✓', floor: 0.2, farbe: 'var(--stufe-anfaenger)' },
{ key: 'fortgeschritten', label: 'Fortgeschritten', kurz: '✓✓', floor: 0.4, farbe: 'var(--stufe-fortgeschritten)' },
{ key: 'experte', label: 'Experte', kurz: '✓✓✓', floor: 0.6, farbe: 'var(--stufe-experte)' },
{ key: 'meister', label: 'Meister', kurz: '★', floor: 1.0, farbe: 'var(--stufe-meister)' },
]
export function schwelle(floor, cap) {
return Math.round(floor * cap)
}
// Höchste erreichte Stufe (Objekt) oder null (unter Anfänger, <20%).
export function stufeFuer(score, cap) {
let s = null
for (const st of STUFEN) if (score >= schwelle(st.floor, cap)) s = st
return s
}
// Nächste noch nicht erreichte Stufe (für das Ziel) oder null (= Meister).
export function naechste(score, cap) {
for (const st of STUFEN) if (score < schwelle(st.floor, cap)) return st
return null
}
// Fehler-Strafe-Anzeige nach Fortschritt: 020%→0 · 2140%→1 · 4160%→2 · 61100%→3.
export function malusRegel(score, cap) {
const pct = cap ? score / cap : 0
if (pct <= 0.2) return '0'
if (pct <= 0.4) return '1'
if (pct <= 0.6) return '2'
return '3'
}