136 lines
5.8 KiB
Python
136 lines
5.8 KiB
Python
"""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_levels_all, list_guides, list_progress_all
|
||
from guide import guide_slot_dateien
|
||
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,
|
||
# 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.
|
||
_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.
|
||
|
||
levels: {"absolviert"/"verstanden"/"gemeistert": {topic → normalisierte Titel}}.
|
||
"""
|
||
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()
|
||
}
|
||
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 absolviert (≥3)?"""
|
||
return ist_level(topic, fmt, guides, progress, levels["absolviert"])
|
||
|
||
|
||
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"])
|
||
|
||
|
||
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["absolviert"]))
|
||
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
|