diff --git a/backend/database.py b/backend/database.py index 07fe594..efa952d 100644 --- a/backend/database.py +++ b/backend/database.py @@ -414,17 +414,22 @@ async def set_baustein_absolviert(topic: str, baustein: str) -> bool: return cursor.rowcount > 0 -async def list_baustein_absolviert_all() -> dict[str, set[str]]: - """Alle absolvierten Bausteine in einem Query: topic → Baustein-Titel.""" +async def list_baustein_levels_all() -> dict[str, dict[str, set[str]]]: + """Bausteine je Meilenstein in EINER Query: {"absolviert"/"verstanden"/"gemeistert": {topic → {Titel}}}.""" db = await get_db() cursor = await db.execute( - "SELECT topic, baustein FROM baustein_progress WHERE absolviert IS NOT NULL" + "SELECT topic, baustein, absolviert, verstanden, gemeistert FROM baustein_progress" ) rows = await cursor.fetchall() - out: dict[str, set[str]] = {} - for topic, baustein in rows: - out.setdefault(topic, set()).add(baustein) - return out + 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 async def delete_baustein_daten(topic: str) -> None: diff --git a/backend/regeln.py b/backend/regeln.py index 5e21e57..d4fcefa 100644 --- a/backend/regeln.py +++ b/backend/regeln.py @@ -12,26 +12,29 @@ Query-Schleifen mehr pro Guide. import json -from database import list_baustein_absolviert_all, list_guides, list_progress_all +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 VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"} +# Welches Niveau die Vorstufe erreichen muss, um das Format freizuschalten. +FREISCHALT_LEVEL = {"Guide": "absolviert", "FullGuide": "verstanden"} FORMATE = ("MiniGuide", "Guide", "FullGuide") -async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, set[str]]]: - """Guides + Kapitel-Fortschritt + absolvierte Bausteine in drei Queries. +async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]: + """Guides + Kapitel-Fortschritt + Bausteine je Meilenstein. - bausteine_done: topic → normalisierte Titel der Bausteine mit bestandener Prüfung. + levels: {"absolviert"/"verstanden"/"gemeistert": {topic → normalisierte Titel}}. """ - bausteine_done = { - topic: {_norm_titel(b) for b in titel} - for topic, titel in (await list_baustein_absolviert_all()).items() + 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(), bausteine_done + return await list_guides(), await list_progress_all(), levels def _content_json(topic: str, fmt: str) -> dict | None: @@ -73,31 +76,42 @@ def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]: return neueste -def _guide_absolviert(g: dict, progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> bool: +def _guide_alle(g: dict, progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool: + """Sind ALLE Bausteine (bzw. OnePager-Kapitel) des Guides auf dem geforderten Niveau?""" if g["format"] == "OnePager": titles = _kapitel_titel(g["topic"], g["format"]) return bool(titles) and titles <= progress.get(g["id"], set()) sections = _section_titel(g["topic"], g["format"]) - return bool(sections) and sections <= bausteine_done.get(g["topic"], set()) + return bool(sections) and sections <= levelset.get(g["topic"], set()) -def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> bool: - """Alle Bausteine des neuesten fertigen Guides (Thema+Format) per Prüfung absolviert?""" +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_absolviert(g, progress, bausteine_done) + return g is not None and _guide_alle(g, progress, levelset) -def formate_stats(guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> dict: +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 FullGuide, alle Bausteine gemeistert (25)?""" + return ist_level(topic, "FullGuide", 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_absolviert(g, progress, bausteine_done)) + 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]], bausteine_done: dict[str, set[str]]) -> str | None: +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, @@ -111,9 +125,12 @@ def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set 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, bausteine_done): - return f"Erst den {vorstufe} dieses Themas absolvieren (alle Bausteine prüfen)" - stat = formate_stats(guides, progress, bausteine_done).get(fmt, {"erstellt": 0, "absolviert": 0}) + if vorstufe: + stufe = FREISCHALT_LEVEL[fmt] # "absolviert" (alle 3) oder "verstanden" (alle 10) + if not ist_level(topic, vorstufe, guides, progress, levels[stufe]): + wort = "verstehen (alle Bausteine auf 10)" if stufe == "verstanden" else "absolvieren (alle Bausteine prüfen)" + return f"Erst den {vorstufe} dieses Themas {wort}" + 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)" diff --git a/backend/routes.py b/backend/routes.py index 440fd94..779e6df 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -21,7 +21,7 @@ from elements import generate_element, chat_with_guide, chat_with_element, check from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_frage, score_berechnen, vertiefung_generieren from guide import generate_guide, guide_slot_dateien from pipeline import cancel_guide -from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand +from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen from models import ( GuideCreateRequest, GuideResponse, TopicCreateRequest, @@ -59,18 +59,20 @@ async def get_topics(): @router.get("/stats") async def get_stats(): """Tracker: Themen-Anzahl + pro Format erstellt/absolviert.""" - guides, progress, bausteine_done = await lade_lernstand() + guides, progress, levels = await lade_lernstand() themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics()) if PROJECTS_DIR.is_dir(): themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()} - return {"themen": len(themen), "formate": formate_stats(guides, progress, bausteine_done)} + return {"themen": len(themen), "formate": formate_stats(guides, progress, levels)} @router.get("/topics/fortschritt") async def topic_fortschritt(topic: str): - """Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe.""" - guides, progress, bausteine_done = await lade_lernstand() - return {fmt: ist_absolviert(topic, fmt, guides, progress, bausteine_done) for fmt in FORMATE} + """Absolviert-Status pro Format + Themen-Abschluss — fürs Freischalten der nächsten Ausbaustufe.""" + guides, progress, levels = await lade_lernstand() + status = {fmt: ist_absolviert(topic, fmt, guides, progress, levels) for fmt in FORMATE} + status["abgeschlossen"] = thema_abgeschlossen(topic, guides, progress, levels) + return status @router.post("/topics") @@ -270,8 +272,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): @router.post("/guides", response_model=GuideResponse) async def create(req: GuideCreateRequest): - guides, progress, bausteine_done = await lade_lernstand() - grund = guide_lock(req.topic.strip(), req.format, guides, progress, bausteine_done) + guides, progress, levels = await lade_lernstand() + grund = guide_lock(req.topic.strip(), req.format, guides, progress, levels) if grund: raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund) await create_topic(req.topic.strip()) @@ -299,8 +301,8 @@ async def list_all(): @router.get("/guides/locks") async def guide_locks(topic: str): """Sperr-Gründe pro Format für den ▶-Button — None = erstellbar.""" - guides, progress, bausteine_done = await lade_lernstand() - return {fmt: guide_lock(topic, fmt, guides, progress, bausteine_done) for fmt in ("OnePager", *FORMATE)} + guides, progress, levels = await lade_lernstand() + return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("OnePager", *FORMATE)} @router.get("/guides/{guide_id}", response_model=GuideResponse) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6513c21..5c6ad7e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -392,6 +392,8 @@ onMounted(async () => { :dark="darkMode" :provider="provider" :elementsOpen="elementsOpen" + :doneByFormat="doneByFormat" + :themaAbgeschlossen="!!fortschritt.abgeschlossen" @progressChanged="loadStats(); loadBausteine()" />
diff --git a/frontend/src/components/TopicDetail.vue b/frontend/src/components/TopicDetail.vue index 28812da..ea41fd6 100644 --- a/frontend/src/components/TopicDetail.vue +++ b/frontend/src/components/TopicDetail.vue @@ -10,6 +10,8 @@ const props = defineProps({ dark: { type: Boolean, default: false }, provider: { type: String, default: 'claude' }, elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links + doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen) + themaAbgeschlossen: { type: Boolean, default: false }, }) const emit = defineEmits(['progressChanged']) @@ -55,16 +57,9 @@ function onBausteinStatus(baustein, status) { if (status.absolviert && !warAbsolviert) emit('progressChanged') // Locks/Stats neu laden } -// Tier 2 (Score 3→10) frei, sobald ALLE Bausteine absolviert; Tier 3 (Meisterpfad 10→25) frei, sobald ALLE verstanden. -const guideSections = computed(() => (content.value?.chapters || []).flatMap((ch) => ch.sections)) -const guideAbsolviert = computed(() => { - const secs = guideSections.value - return secs.length > 0 && secs.every((s) => lernstand.value[s.title]?.absolviert) -}) -const guideVerstanden = computed(() => { - const secs = guideSections.value - return secs.length > 0 && secs.every((s) => lernstand.value[s.title]?.verstanden) -}) +// Cap pro Baustein folgt den erstellten Formaten: Guide vorhanden → bis 10, FullGuide → bis 25. +const tier2 = computed(() => !!props.doneByFormat?.Guide) +const tier3 = computed(() => !!props.doneByFormat?.FullGuide) // --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) --- const chat = useChat((msgs) => { @@ -152,6 +147,7 @@ function extractContext() {

{{ previewGuide.topic }}

{{ previewGuide.format }} + ✓ Thema abgeschlossen
@@ -283,6 +279,16 @@ function extractContext() { font-weight: 600; } +.thema-done { + font-size: 0.8rem; + font-weight: 600; + padding: 0.15rem 0.6rem; + border-radius: 999px; + background: color-mix(in srgb, #d4af37 20%, var(--panel)); + border: 1px solid #d4af37; + color: #8a6d12; +} + .chapter { margin-bottom: 2.5rem; }