Backend: regeln.py (Lernregeln zentral), Stats O(n), GET /guides/locks, _norm_titel gehärtet
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
88
backend/regeln.py
Normal file
88
backend/regeln.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Lernschulden-Regeln: Progression und Deckel für offene Guides — die EINZIGE Quelle.
|
||||
|
||||
Regeln (nur Neu-Erstellungen; Themen, Bausteine, OnePager unbegrenzt):
|
||||
- JE Format (MiniGuide/Guide/FullGuide) höchstens 3 erstellte, nicht absolvierte Guides
|
||||
- Progression pro Thema: Guide erst nach absolviertem MiniGuide, FullGuide erst nach absolviertem Guide
|
||||
Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine
|
||||
Query-Schleifen mehr pro Guide.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from database import list_guides, list_progress_all
|
||||
from guide import guide_slot_dateien
|
||||
from paths import bausteine_path, guide_content_path
|
||||
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"}
|
||||
FORMATE = ("MiniGuide", "Guide", "FullGuide")
|
||||
|
||||
|
||||
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]]]:
|
||||
"""Guides + kompletter Kapitel-Fortschritt in zwei Queries."""
|
||||
return await list_guides(), await list_progress_all()
|
||||
|
||||
|
||||
def _kapitel_titel(topic: str, fmt: str) -> set[str] | None:
|
||||
path = guide_content_path(topic, fmt)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", [])
|
||||
except ValueError:
|
||||
return None
|
||||
return {c.get("title") for c in chapters}
|
||||
|
||||
|
||||
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_absolviert(g: dict, progress: dict[str, set[str]]) -> bool:
|
||||
titles = _kapitel_titel(g["topic"], g["format"])
|
||||
return bool(titles) and titles <= progress.get(g["id"], set())
|
||||
|
||||
|
||||
def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]]) -> bool:
|
||||
"""Alle Kapitel des neuesten fertigen Guides (Thema+Format) abgehakt?"""
|
||||
g = _neueste_done(guides, fmt).get(topic)
|
||||
return g is not None and _guide_absolviert(g, progress)
|
||||
|
||||
|
||||
def formate_stats(guides: list[dict], progress: 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_absolviert(g, progress))
|
||||
formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert}
|
||||
return formate
|
||||
|
||||
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], progress: 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 fmt != "OnePager" and 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 fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content):
|
||||
vorstufe = VORSTUFE.get(fmt)
|
||||
if vorstufe and not ist_absolviert(topic, vorstufe, guides, progress):
|
||||
return f"Erst den {vorstufe} dieses Themas absolvieren"
|
||||
stat = formate_stats(guides, progress).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
|
||||
Reference in New Issue
Block a user