"""Lernschulden-Regeln: Progression und Deckel für offene Guides — die EINZIGE Quelle. Regeln (nur Neu-Erstellungen; Themen + Bausteine unbegrenzt): - Format „Guide": höchstens 3 erstellte, nicht absolvierte Guides - Keine Progression/Vorstufe mehr (nur ein Guide-Format). - Absolviert: ALLE Bausteine (Section-Titel) des neuesten fertigen Guides haben eine bestandene Prüfung. Rest ist read-only (kein Fortschritt, keine Prüfung). Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine Query-Schleifen mehr pro Guide. """ import json 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–cap). Keine Progression, # keine Vorstufe → der Guide ist immer freischaltbar. „Rest"/FullGuide separat. VORSTUFE: dict[str, str] = {} FREISCHALT_LEVEL: dict[str, str] = {} FORMATE = ("Guide",) # 4 Lernstufen (Floor in % des cap) — Schlüssel aus lernen.STUFEN. _LEVEL_WORT = { "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 Stufe. levels: {"anfaenger"/"fortgeschritten"/"experte"/"meister": {topic → normalisierte Titel}}. Stufe je Baustein wird aus Score + cap (2×relevante Subs) abgeleitet. """ 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 def _content_json(topic: str, fmt: str) -> dict | None: path = guide_content_path(topic, fmt) if not path.exists(): return None try: return json.loads(path.read_text(encoding="utf-8")) except ValueError: return None def _section_titel(topic: str, fmt: str) -> set[str] | None: """Normalisierte Baustein-Titel (Sections) aus dem Guide-Content.""" content = _content_json(topic, fmt) if content is None: return None return { _norm_titel(s.get("title", "")) for ch in content.get("chapters", []) for s in ch.get("sections", []) } def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]: """Pro Thema der neueste fertige Guide dieses Formats.""" neueste: dict[str, dict] = {} for g in guides: if g["format"] == fmt and g["status"] == "done": if g["topic"] not in neueste or g["created_at"] > neueste[g["topic"]]["created_at"]: neueste[g["topic"]] = g return neueste def _guide_alle(g: dict, progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool: """Sind ALLE Bausteine des Guides auf dem geforderten Niveau?""" sections = _section_titel(g["topic"], g["format"]) return bool(sections) and sections <= levelset.get(g["topic"], set()) def ist_level(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool: """Neuester fertiger Guide (Thema+Format): alle Bausteine auf dem Niveau von levelset?""" g = _neueste_done(guides, fmt).get(topic) return g is not None and _guide_alle(g, progress, levelset) 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 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 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: """Pro Format erstellt/absolviert — pro Thema zählt nur der neueste fertige Guide.""" formate = {} for fmt in FORMATE: neueste = _neueste_done(guides, fmt) absolviert = sum(1 for g in neueste.values() if _guide_alle(g, progress, levels["anfaenger"])) formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert} return formate def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> str | None: """Grund, warum ein Neu-Start für Thema+Format gesperrt ist — None = erlaubt. Exakt die Regeln aus POST /guides: Bausteine nötig, kein Duplikat-Start, Lernschulden nur für echte Neu-Erstellungen (Resume/Regenerieren frei). """ if not bausteine_path(topic).exists(): return "Erst Bausteine erstellen" for g in guides: if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"): return "Generierung läuft bereits" content = guide_content_path(topic, fmt) if not content.exists() and not guide_slot_dateien(content): vorstufe = VORSTUFE.get(fmt) if vorstufe: stufe = FREISCHALT_LEVEL[fmt] # absolviert=10 · verstanden=20 · gemeistert=30 if not ist_level(topic, vorstufe, guides, progress, levels[stufe]): 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: return f"Erst {fmt}s absolvieren — maximal {MAX_OFFENE_GUIDES} offene erlaubt ({offen} offen)" return None