From f018ca5048c8fe3d84b7724142f8938c855e00b0 Mon Sep 17 00:00:00 2001 From: team3 Date: Sat, 27 Jun 2026 00:58:46 +0200 Subject: [PATCH] update --- Makefile | 17 +- backend/bausteine.py | 618 +++++++++- backend/database.py | 240 +++- backend/guide.py | 194 ++- backend/lernen.py | 228 ++-- backend/models.py | 13 +- backend/pipeline.py | 6 +- backend/regeln.py | 10 +- backend/routes.py | 167 ++- backend/textkit.py | 59 +- frontend/package-lock.json | 1069 +++++++++++++++++ frontend/package.json | 1 + frontend/src/App.vue | 31 + frontend/src/api.js | 11 +- frontend/src/components/BausteinPanel.vue | 16 +- .../src/components/BausteineUebersicht.vue | 18 +- frontend/src/components/FlashcardWidget.vue | 106 ++ frontend/src/components/GeneralExamPanel.vue | 120 ++ frontend/src/components/TopicDetail.vue | 49 +- frontend/src/components/TopicSidebar.vue | 44 +- .../src/components/WorkedExampleBlock.vue | 67 ++ frontend/src/markdown.js | 39 +- frontend/src/stufen.js | 12 +- templates/Prompt/Artefakt-Beispiel.md | 21 + templates/Prompt/Artefakt-Diagramm.md | 24 + templates/Prompt/Artefakt-Karteikarte.md | 20 + templates/Prompt/Baustein-Frage-Variante.md | 7 +- templates/Prompt/Baustein-Frage.md | 3 + templates/Prompt/Baustein-Lueckentext.md | 3 + templates/Prompt/Baustein-Lueckwahl.md | 3 + templates/Prompt/Baustein-Quiz.md | 9 +- templates/Prompt/Fakten-Check.md | 22 + templates/Prompt/Fakten-Recherche.md | 30 + templates/Prompt/Frage-Muster-Kritik.md | 14 +- templates/Prompt/Frage-Muster-Recherche.md | 21 +- templates/Prompt/Guide-Gliederung.md | 4 +- templates/Prompt/Guide-Inhalt.md | 8 +- templates/Prompt/Guide-Lese-Check.md | 9 +- templates/Prompt/Guide-Sections-Fix.md | 23 +- templates/Prompt/Guide-Writer.md | 44 +- templates/Prompt/Relevanz-Mapping.md | 4 +- templates/Prompt/Relevanz-Recherche.md | 4 +- templates/Prompt/Stufen-Mapping.md | 15 +- templates/Prompt/Stufen-Recherche.md | 26 +- 44 files changed, 3070 insertions(+), 379 deletions(-) create mode 100644 frontend/src/components/FlashcardWidget.vue create mode 100644 frontend/src/components/GeneralExamPanel.vue create mode 100644 frontend/src/components/WorkedExampleBlock.vue create mode 100644 templates/Prompt/Artefakt-Beispiel.md create mode 100644 templates/Prompt/Artefakt-Diagramm.md create mode 100644 templates/Prompt/Artefakt-Karteikarte.md create mode 100644 templates/Prompt/Fakten-Check.md create mode 100644 templates/Prompt/Fakten-Recherche.md diff --git a/Makefile b/Makefile index 31498b7..c196539 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all projects searxng ollama +.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all sync-all-reverse projects searxng ollama COMPOSE = docker compose @@ -79,4 +79,19 @@ sync-projects: sync-all: sync sync-projects @echo "Voller Sync (Themen + Projekte) abgeschlossen." +# Gegenstück zu sync-all: lokalen Stand auf den Server schieben (Local → Remote). +# Stoppt den Remote-Server (DB-Sicherheit), pusht DB + Themen + Projekte, startet ihn neu. +# --delete = exakter Spiegel: Remote-Dateien ohne lokales Pendant werden gelöscht. +sync-all-reverse: stop + @echo "ACHTUNG: überschreibt den Remote-Stand (DB, themen/, projects/) mit dem lokalen." + @[ -f storage/creator.db ] || { echo "Keine lokale DB — abgebrochen."; exit 1; } + ssh root@178.104.67.87 'cd /var/www/creator && docker compose down' + ssh root@178.104.67.87 'rm -f /var/www/creator/storage/creator.db-shm /var/www/creator/storage/creator.db-wal' + rsync -avz --progress storage/creator.db root@178.104.67.87:/var/www/creator/storage/ + @[ -f storage/creator.db-wal ] && rsync -avz --progress storage/creator.db-wal root@178.104.67.87:/var/www/creator/storage/ || true + rsync -avz --progress --delete storage/themen/ root@178.104.67.87:/var/www/creator/storage/themen/ + rsync -avz --progress --delete projects/ root@178.104.67.87:/var/www/creator/projects/ + ssh root@178.104.67.87 'cd /var/www/creator && docker compose up -d --build' + @echo "Reverse-Sync abgeschlossen — Remote läuft wieder." + projects: sync-projects diff --git a/backend/bausteine.py b/backend/bausteine.py index 59ac853..c879f9b 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -11,6 +11,7 @@ die Gesamtliste. """ import asyncio +import json import logging import math import shutil @@ -19,11 +20,10 @@ import time from pathlib import Path import database as db -from agents import kill_process, cancel_scope, clear_scope +from agents import kill_process, cancel_scope, clear_scope, run_agent from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS from fsutil import atomic_write_text, atomic_write_json from jsonio import read_json_file as _json_datei -from lernen import FRAGETYPEN from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner from crawl import crawl from pipeline import ( @@ -51,6 +51,8 @@ SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min) KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass # Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl. FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk +FAKTEN_CHUNK_SUBS = 25 # Fakten-Extraktion: kleinere Chunks (Fakten sind umfangreicher als Muster) +FAKTEN_CHECK_PANEL = 3 # Judges je Chunk im Fakten-Check (Mehrheit beanstandet) log = logging.getLogger("creator.bausteine") @@ -62,10 +64,14 @@ _bausteine_step: dict[str, int] = {} BAUSTEINE_STEPS = ( "Recherche", "Konsolidierung", "Klärung", "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", + "Fakten finden", "Fakten prüfen", "Fakten fix", "Stufen finden", "Stufen wählen", "Stufen klären", "Relevanz finden", "Relevanz wählen", "Relevanz klären", + "Gliederung", "Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen", + "Karteikarten", "Beispiele", "Diagramme", ) +ARTEFAKT_TYPEN = ("karteikarte", "beispiel", "diagramm") def lade_quelle(topic: str) -> dict: @@ -93,7 +99,8 @@ def _crawl_fertig(topic: str) -> bool: return (quelle_crawl_dir(topic) / ".done").exists() # Marker erst bei sauberem Abschluss -_STUFEN = ("einfach", "mittel", "schwer") +# Lernpfad-Stufen (anfaenger/fortgeschritten/experte); alte Schwierigkeits-Werte abwärtskompatibel. +_STUFEN = ("anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer") async def subbausteine_titel(topic: str, baustein: str) -> list[str]: @@ -115,19 +122,36 @@ async def lade_frage_muster(topic: str, baustein: str) -> list[dict]: """Vordefinierte Frage-Muster eines Bausteins — DB-first, Fallback Sidecar (leer = Live).""" rows = await db.list_frage_muster(topic, _norm_titel(baustein)) if rows: - return [{"subbaustein": r["sub_titel"], "typ": r["typ"], "frage": r["frage"]} for r in rows if r["frage"]] + return [{"subbaustein": r["sub_titel"], "frage": r["frage"]} for r in rows if r["frage"]] fm = _json_datei(frage_muster_path(topic)) if not isinstance(fm, dict): return [] return [ - {"subbaustein": str(e.get("subbaustein", "")).strip(), - "typ": str(e.get("typ", "")).strip(), - "frage": frage} + {"subbaustein": str(e.get("subbaustein", "")).strip(), "frage": frage} for e in (fm.get(baustein) or []) if isinstance(e, dict) and (frage := str(e.get("frage", "")).strip()) ] +async def subbausteine_frei(topic: str, baustein: str, max_ebene: int) -> list[str]: + """Subbaustein-Titel bis zur freigeschalteten Ebene (≤ max_ebene). Fallback ohne + Ebenen-Wissen (Altbestand/Sidecar): alle Subbaustein-Titel.""" + rows = await db.subs_mit_ebene(topic, baustein) + if not rows: + return await subbausteine_titel(topic, baustein) + return [s["titel"] for s in rows if s["ebene"] <= max_ebene and s["titel"]] + + +async def lade_frage_muster_frei(topic: str, baustein: str, max_ebene: int) -> list[dict]: + """Frage-Muster, gefiltert auf Subbausteine bis zur freigeschalteten Ebene. Ohne + Ebenen-Wissen (Altbestand/Sidecar) ungefiltert.""" + rows = await db.subs_mit_ebene(topic, baustein) + if not rows: + return await lade_frage_muster(topic, baustein) + frei = {s["norm"] for s in rows if s["ebene"] <= max_ebene} + return [m for m in await lade_frage_muster(topic, baustein) if _norm_titel(m["subbaustein"]) in frei] + + async def lade_uebersicht(topic: str) -> list[dict]: """Strukturierte Baustein-Liste für die Übersicht — DB-first (Konsens + Subs/Stufen/Relevanz), Fallback bausteine.md + Sidecar (Alt-Themen).""" @@ -140,7 +164,7 @@ async def lade_uebersicht(topic: str) -> list[dict]: "num": num, "titel": b["titel"], "beschreibung": b["beschreibung"], "subbausteine": [ {"titel": s["sub_titel"], - "stufe": s["stufe"] if s["stufe"] in _STUFEN else "mittel", + "stufe": s["stufe"] if s["stufe"] in _STUFEN else "fortgeschritten", "relevanz": s["relevanz"] if s["relevanz"] in ("relevant", "rand") else None} for s in subs if s["sub_titel"] ], @@ -157,7 +181,7 @@ async def lade_uebersicht(topic: str) -> list[dict]: subbausteine = [ { "titel": t, - "stufe": s.get("stufe") if s.get("stufe") in _STUFEN else "mittel", + "stufe": s.get("stufe") if s.get("stufe") in _STUFEN else "fortgeschritten", "relevanz": s.get("relevanz") if s.get("relevanz") in ("relevant", "rand") else None, } for s in (sidecar.get(titel) or []) @@ -177,9 +201,12 @@ def _bausteine_steps(topic: str) -> tuple: base = ("Recherche", "Konsolidierung", "Klärung") rest = ( "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", + "Fakten finden", "Fakten prüfen", "Fakten fix", "Stufen finden", "Stufen wählen", "Stufen klären", "Relevanz finden", "Relevanz wählen", "Relevanz klären", + "Gliederung", "Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen", + "Karteikarten", "Beispiele", "Diagramme", ) mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest return (("Quelle aufbereiten",) if q["type"] == "link" else ()) + mitte @@ -203,9 +230,12 @@ PHASEN = ( ("Quelle", ("Quelle aufbereiten",)), ("Inventar", ("Recherche", "Konsolidierung", "Klärung", "Ergänzung")), ("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")), + ("Fakten", ("Fakten finden", "Fakten prüfen", "Fakten fix")), ("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")), ("Relevanz", ("Relevanz finden", "Relevanz wählen", "Relevanz klären")), + ("Gliederung", ("Gliederung",)), ("Fragen", ("Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen")), + ("Artefakte", ("Karteikarten", "Beispiele", "Diagramme")), ) @@ -244,22 +274,28 @@ def _bausteine_files(topic: str) -> dict: "mapping": {n: arbeit / f"auswahl-mapping-r{n}.json" for n in runden}, "ergaenzung": arbeit / "ergaenzung.json", "sub_roh": arbeit / "subbausteine-roh.json", + "fakten": arbeit / "subbausteine-fakten.json", "sidecar": subbausteine_path(topic), "frage_muster": frage_muster_path(topic), + "gliederung": arbeit / "gliederung.json", + "gliederung_slots": [arbeit / f"gliederung-{i}.json" for i in (1, 2, 3)], + "artefakte": arbeit / "artefakte.json", } def _alle_slot_dateien(files: dict) -> list[Path]: arbeit = files["arbeit"] # Subbaustein-/Stufen-Slots sind pro Chunk dynamisch — per Glob einsammeln. - dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*")) - + list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*")) + dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("fakten-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*")) + + list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("gliederung-*")) + list(arbeit.glob("artefakt-*")) + + list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*")) + list(arbeit.glob("klaerung*"))) if arbeit.is_dir() else [] return [ *files["recherche"], files["recherche_mapping"], *(p for slots in files["auswahl"].values() for p in slots), *files["mapping"].values(), files["ergaenzung"], - files["sub_roh"], files["sidecar"], files["frage_muster"], *dyn, + files["sub_roh"], files["sidecar"], files["frage_muster"], + files["fakten"], files["gliederung"], files["artefakte"], *dyn, ] @@ -288,12 +324,21 @@ def _resume_step(topic: str) -> int: # Stufen fertig; nur noch Relevanz offen? if not _relevanz_komplett(sidecar): return _step_idx(topic, "Relevanz finden") - # Relevanz fertig; nur noch Frage-Muster offen? + # Relevanz fertig; Gliederung (Bausteine-Artefakt für den Guide) offen? + if not _gliederung_komplett(files): + return _step_idx(topic, "Gliederung") + # Gliederung fertig; Frage-Muster offen? if not _frage_muster_komplett(topic): return _step_idx(topic, "Fragen finden") + # Fragen fertig; Lern-Artefakte (Karteikarten/Beispiele/Diagramme) offen? + if not _artefakte_komplett(files): + return _step_idx(topic, "Karteikarten") return len(_bausteine_steps(topic)) if _sub_roh_schema(_json_datei(files["sub_roh"])) is None: return _step_idx(topic, "Subbausteine finden") + # Subbausteine fertig; Fakten noch offen? (Fakten kommen vor den Stufen.) + if not _fakten_komplett(files): + return _step_idx(topic, "Fakten finden") return _step_idx(topic, "Stufen finden") @@ -356,12 +401,19 @@ def _reset_ab_phase(topic: str, label: str) -> None: for p in arbeit.glob(pat): p.unlink(missing_ok=True) - if idx <= 5: # Fragen + # Phasen-Index: Quelle=0 · Inventar=1 · Subbausteine=2 · Fakten=3 · Stufen=4 · Relevanz=5 · Gliederung=6 · Fragen=7 · Artefakte=8 + if idx <= 8: # Artefakte (Karteikarten/Beispiele/Diagramme) + files["artefakte"].unlink(missing_ok=True) + glob_del("artefakt-*") + if idx <= 7: # Fragen files["frage_muster"].unlink(missing_ok=True) glob_del("frage-muster-*") - if idx <= 4: # Relevanz + if idx <= 6: # Gliederung + files["gliederung"].unlink(missing_ok=True) + glob_del("gliederung-*") + if idx <= 5: # Relevanz glob_del("relevanz-*") - if idx <= 3: # Stufen + Relevanz teilen die Sidecar → ab Stufen ganz neu + if idx <= 4: # Stufen + Relevanz teilen die Sidecar → ab Stufen ganz neu files["sidecar"].unlink(missing_ok=True) glob_del("stufe-*") else: # ab Relevanz: Stufen behalten, nur Relevanz-Felder strippen @@ -372,6 +424,9 @@ def _reset_ab_phase(topic: str, label: str) -> None: if isinstance(s, dict): s.pop("relevanz", None) atomic_write_json(files["sidecar"], sc, indent=1) + if idx <= 3: # Fakten (vor den Stufen) — Fakten-Map + Arbeitsdateien weg + files["fakten"].unlink(missing_ok=True) + glob_del("fakten-*") if idx <= 2: # Subbausteine files["sub_roh"].unlink(missing_ok=True) glob_del("subbaustein-*") @@ -474,7 +529,7 @@ def _sidecar_schema(data): if not isinstance(v, list) or not v: return None for s in v: - if not isinstance(s, dict) or not str(s.get("titel", "")).strip() or s.get("stufe") not in ("einfach", "mittel", "schwer"): + if not isinstance(s, dict) or not str(s.get("titel", "")).strip() or s.get("stufe") not in _STUFEN: return None return data @@ -493,10 +548,10 @@ def _relevanz_komplett(data) -> bool: def _frage_muster_chunk_schema(data) -> list[dict] | None: - """{"muster": [{baustein, subbaustein, typ, frage}, …]} → Liste valider Einträge · sonst None. + """{"muster": [{baustein, subbaustein, frage}, …]} → Liste valider Einträge · sonst None. - Mit `baustein` (Zuordnung im 10er-Chunk). Ungültige Einzel-Einträge werden - übersprungen (nicht die ganze Liste verworfen).""" + Ein Muster je Subbaustein (kein Typ-Kreuzprodukt — die Schwierigkeit kommt erst bei der + Prüfung aus dem Lerner-Niveau). Ungültige Einzel-Einträge werden übersprungen.""" if not isinstance(data, dict) or not isinstance(data.get("muster"), list): return None out = [] @@ -505,11 +560,10 @@ def _frage_muster_chunk_schema(data) -> list[dict] | None: continue bau = str(e.get("baustein", "")).strip() sub = str(e.get("subbaustein", "")).strip() - typ = str(e.get("typ", "")).strip().casefold() frage = str(e.get("frage", "")).strip() - if not bau or not sub or typ not in FRAGETYPEN or not frage: + if not bau or not sub or not frage: continue - out.append({"baustein": bau, "subbaustein": sub, "typ": typ, "frage": frage}) + out.append({"baustein": bau, "subbaustein": sub, "frage": frage}) return out or None @@ -801,10 +855,10 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct timeout=_timeout("stufe_check", len(strittig)), ) if status == FAILED: - _log(topic, f"Stufen-Klärung Paket {c} fehlgeschlagen — Default 'mittel'") + _log(topic, f"Stufen-Klärung Paket {c} fehlgeschlagen — Default 'fortgeschritten'") entsch = entsch if isinstance(entsch, dict) else {} - # Strittige ohne Entscheid → 'mittel'; Vote-Gewinner bleiben; Judge überschreibt. - ergebnis = {**{k: "mittel" for k in strittig}, **ergebnis, **entsch} + # Strittige ohne Entscheid → 'fortgeschritten'; Vote-Gewinner bleiben; Judge überschreibt. + ergebnis = {**{k: "fortgeschritten" for k in strittig}, **ergebnis, **entsch} return {item_idxs[k - 1] + 1: stufe for k, stufe in ergebnis.items()} parts = await _gather_fortschritt([_klaere(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _melde_p(set_p, topic, "Stufen klären")) @@ -813,12 +867,12 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct stufe_by_id: dict[int, str] = {} for c, part in enumerate(parts, 1): if not isinstance(part, dict): - # Klärung ist nicht fatal: Vote-Ergebnis + Default 'mittel' für Strittige. + # Klärung ist nicht fatal: Vote-Ergebnis + Default 'fortgeschritten' für Strittige. if isinstance(part, BaseException): _log(topic, f"Stufen-Klärung Paket {c}: {type(part).__name__}: {part}") ergebnis, strittig = vote_by_c[c] item_idxs = chunks[c - 1] - merged = {**{k: "mittel" for k in strittig}, **ergebnis} + merged = {**{k: "fortgeschritten" for k in strittig}, **ergebnis} part = {item_idxs[k - 1] + 1: s for k, s in merged.items()} stufe_by_id.update(part) @@ -829,11 +883,196 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct lst = [] for sub in subs: gid += 1 - lst.append({"titel": sub, "stufe": stufe_by_id.get(gid, "mittel")}) + lst.append({"titel": sub, "stufe": stufe_by_id.get(gid, "fortgeschritten")}) sidecar[titel] = lst return sidecar +_FAKTEN_FELDER = ("kernpunkte", "voraussetzungen", "huerden", "belegte_fakten", "beispiel_idee") + + +def _fakten_schema(data) -> list[dict] | None: + """{"fakten": [{baustein, subbaustein, …}]} → valide Liste · sonst None. + Trennt belegte_fakten (mit Quelle) hart von beispiel_idee (generativ).""" + if not isinstance(data, dict) or not isinstance(data.get("fakten"), list): + return None + out = [] + for e in data["fakten"]: + if not isinstance(e, dict): + continue + bau = str(e.get("baustein", "")).strip() + sub = str(e.get("subbaustein", "")).strip() + if not bau or not sub: + continue + bf = [{"text": t, "quelle": str(f.get("quelle", "")).strip()} + for f in (e.get("belegte_fakten") or []) if isinstance(f, dict) and (t := str(f.get("text", "")).strip())] + out.append({ + "baustein": bau, "subbaustein": sub, + "kernpunkte": [k for x in (e.get("kernpunkte") or []) if (k := str(x).strip())], + "voraussetzungen": str(e.get("voraussetzungen", "")).strip(), + "huerden": str(e.get("huerden", "")).strip(), + "belegte_fakten": bf, + "beispiel_idee": str(e.get("beispiel_idee", "")).strip(), + }) + return out or None + + +def _fakten_check_schema(data) -> list[str] | None: + """Fakten-Check → beanstandete sub_norms · {ok:true}→[] · None bei ungültig.""" + if not isinstance(data, dict): + return None + if data.get("ok") is True: + return [] + pr = data.get("probleme") + if not isinstance(pr, list): + return None + return [sn for p in pr if isinstance(p, dict) and (sn := _norm_titel(str(p.get("subbaustein", ""))))] + + +def _fakten_zeilen(fk: dict) -> str: + z = [] + if fk.get("kernpunkte"): + z.append("Kernpunkte: " + " · ".join(fk["kernpunkte"])) + if fk.get("voraussetzungen"): + z.append("Voraussetzung: " + fk["voraussetzungen"]) + if fk.get("huerden"): + z.append("Hürde: " + fk["huerden"]) + for bf in fk.get("belegte_fakten", []): + z.append(f"FAKT: {bf['text']} (Quelle: {bf.get('quelle', '?')})") + if fk.get("beispiel_idee"): + z.append("Beispiel: " + fk["beispiel_idee"]) + return "\n".join(z) + + +def _fakten_komplett(files: dict) -> bool: + """Fakten-Map existiert (Block durch)? {Baustein: {sub_norm: {...}}}.""" + d = _json_datei(files["fakten"]) + return isinstance(d, dict) and bool(d) + + +async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, instructions: str) -> dict | None: + """Block: je Sub Quell-Fakten extrahieren (finden) → verifizieren (prüfen) → korrigieren (fix). + Extract-once-Grounding: das Ergebnis nährt Stufe/Relevanz/Fragen/Guide. + → {Baustein-Titel: {sub_norm: fakten-dict}} oder None bei Abbruch/Fehler.""" + topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled + arbeit = files["arbeit"] + caps = "files" if ordner else "full" + typ = q.get("type", "thema") + source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner) if typ in _QUELLE_TEMPLATE else _prompt("Bausteine-Quelle-Thema", topic=topic) + bausteine = [(titel, [str(s).strip() for s in subs if str(s).strip()]) for titel, subs in roh.items() if subs] + if not bausteine: + return {} + chunks = _lpt_chunks([len(subs) for _, subs in bausteine], FAKTEN_CHUNK_SUBS) + + def roh_path(ci): return arbeit / f"fakten-c{ci}.json" + def chk_path(ci, j): return arbeit / f"fakten-check-c{ci}-j{j}.json" + def fix_path(ci): return arbeit / f"fakten-fix-c{ci}.json" + def ctitel(idxs): return [bausteine[i][0] for i in idxs] + def block_text(idxs): + return "\n\n".join( + f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in bausteine[i][1]) + for i in idxs) + + # Roh-Fakten eines Chunks → {Baustein: {sub_norm: {sub, …felder}}}, auf Chunk-Titel gematcht. + def roh_map(ci, path): + idxs = chunks[ci] + rel_by = {bausteine[i][0]: bausteine[i][1] for i in idxs} + ct = ctitel(idxs) + out: dict[str, dict] = {} + for e in _fakten_schema(_json_datei(path)) or []: + bt = _match_sub(e["baustein"], ct) + if bt not in rel_by: + continue + sub = _match_sub(e["subbaustein"], rel_by[bt]) + out.setdefault(bt, {})[_norm_titel(sub)] = {"sub": sub, **{k: e[k] for k in _FAKTEN_FELDER}} + return out + + # Phase „Fakten finden": 1 Generator je Chunk. + async def _finde(ci, idxs): + fp = roh_path(ci) + if _fakten_schema(_json_datei(fp)): + return True + subs_total = sum(len(bausteine[i][1]) for i in idxs) + status, _r = await run_single_slot( + ctx, f"Fakten {ci}", key=f"bausteine-{topic}-fakten-c{ci}", + prompt=_prompt("Fakten-Recherche", topic=topic, source=source, bausteine=block_text(idxs), out_path=fp, extra=_extra(instructions)), + role="guide", capabilities=caps, + payload=lambda result, p=fp: _fakten_schema(_json_datei(p)), + timeout=_timeout("inhalt", subs_total)) + return status != FAILED and _fakten_schema(_json_datei(fp)) is not None + + oks = await _gather_fortschritt([_finde(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten finden")) + if is_cancelled(): + return None + if not any(ok is True for ok in oks): + _bausteine_errors[topic] = "Fakten-Extraktion fehlgeschlagen" + return None + + # Phase „Fakten prüfen": FAKTEN_CHECK_PANEL Judges je Chunk → beanstandete sub_norms (Mehrheit). + async def _pruefe(ci, idxs): + per = roh_map(ci, roh_path(ci)) + if not per: + return ci, set() + fakten_text = "\n\n".join(f"SUBBAUSTEIN: {fk['sub']}\n{_fakten_zeilen(fk)}" for fm in per.values() for fk in fm.values()) + offen = [j for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if _fakten_check_schema(_json_datei(chk_path(ci, j))) is None] + await asyncio.gather(*[ + run_agent(f"bausteine-{topic}-fakten-check-c{ci}-j{j}", + _prompt("Fakten-Check", topic=topic, source=source, fakten=fakten_text, out_path=chk_path(ci, j), extra=_extra(instructions)), + _timeout("inhalt_check", len(per)), provider=provider, role="judge", capabilities=caps) + for j in offen], return_exceptions=True) + outs = [s for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if (s := _fakten_check_schema(_json_datei(chk_path(ci, j)))) is not None] + votes: dict[str, int] = {} + for s in outs: + for sn in set(s): + votes[sn] = votes.get(sn, 0) + 1 + schwelle = len(outs) / 2 if outs else 99 + return ci, {sn for sn, v in votes.items() if v > schwelle} + + pruef = await _gather_fortschritt([_pruefe(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten prüfen")) + if is_cancelled(): + return None + beanstandet = {ci: subs for r in pruef if isinstance(r, tuple) for ci, subs in [r]} + + # Phase „Fakten fix": je Chunk mit beanstandeten Subs → diese neu extrahieren (frische Quelle). + n_problem = sum(len(s) for s in beanstandet.values()) + if n_problem: + set_p(f"Fakten korrigieren ({n_problem})…", step=_step_idx(topic, "Fakten fix")) + async def _fix(ci): + subs_norm = beanstandet.get(ci, set()) + if not subs_norm or _fakten_schema(_json_datei(fix_path(ci))): + return + idxs = chunks[ci] + rel_by = {bausteine[i][0]: bausteine[i][1] for i in idxs} + # nur die beanstandeten Subs als Block + ziel = [] + for bt, subs in rel_by.items(): + betroffen = [s for s in subs if _norm_titel(s) in subs_norm] + if betroffen: + ziel.append(f"BAUSTEIN: {bt}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in betroffen)) + if not ziel: + return + await run_single_slot( + ctx, f"Fakten-Fix {ci}", key=f"bausteine-{topic}-fakten-fix-c{ci}", + prompt=_prompt("Fakten-Recherche", topic=topic, source=source, bausteine="\n\n".join(ziel), out_path=fix_path(ci), extra=_extra(instructions)), + role="guide", capabilities=caps, + payload=lambda result, p=fix_path(ci): _fakten_schema(_json_datei(p)), + timeout=_timeout("inhalt", len(subs_norm))) + await _gather_fortschritt([_fix(ci) for ci in beanstandet], len(beanstandet), _melde_p(set_p, topic, "Fakten fix")) + if is_cancelled(): + return None + + # Zusammensetzen: roh + Fix-Overrides für beanstandete (Vollständigkeits-Guard: fehlt der Fix, bleibt roh). + ergebnis: dict[str, dict] = {} + for ci in range(len(chunks)): + per = roh_map(ci, roh_path(ci)) + fix = roh_map(ci, fix_path(ci)) if fix_path(ci).exists() else {} + for bt, fm in per.items(): + for sn, fk in fm.items(): + gewinner = fix.get(bt, {}).get(sn, fk) if sn in beanstandet.get(ci, set()) else fk + ergebnis.setdefault(bt, {})[sn] = {k: gewinner[k] for k in _FAKTEN_FELDER} + return ergebnis + + async def _relevanz_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None: """Block D: drei Phasen mit Barriere — Finden (relevant/rand), Wählen (Vote), Klären. Items aus der Sidecar; lokale IDs 1..n pro Paket → globale gid. @@ -963,18 +1202,17 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict """Block E (10er-Chunks): Finden (1 Generator je ~10 Bausteine, parallel), Wählen (Code: je Baustein gruppieren + Dedup), Klären (1 Kritiker je Chunk), Prüfen (Nachrunde). Zuordnung je Eintrag über das `baustein`-Feld (Chunk-Datei trägt mehrere Bausteine). - → {Baustein-Titel: [{subbaustein, typ, frage}, …]} oder None bei Abbruch.""" + → {Baustein-Titel: [{subbaustein, frage}, …]} oder None bei Abbruch.""" topic, is_cancelled = ctx.topic, ctx.is_cancelled arbeit = files["arbeit"] - # Je Baustein nur RELEVANTE Subbausteine (relevanz != 'rand'; None zählt als relevant). + # ALLE Subbausteine (auch rand) bekommen ein Muster — Rand ist in der FGuide-Ebene prüfbar. bausteine = [] for titel, subs in sidecar.items(): - rel = [s["titel"] for s in subs if isinstance(s, dict) and s.get("relevanz") != "rand" and str(s.get("titel", "")).strip()] - if rel: - bausteine.append((titel, rel)) + alle = [s["titel"] for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()] + if alle: + bausteine.append((titel, alle)) if not bausteine: return {} - typen_block = "\n".join(f"- {k}: {v}" for k, v in FRAGETYPEN.items()) chunks = _lpt_chunks([len(rel) for _, rel in bausteine], FRAGE_CHUNK_SUBS) # lastbalanciert nach Sub-Zahl def roh_path(ci): @@ -1000,7 +1238,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict ctx, f"Frage-Muster {ci}", key=f"bausteine-{topic}-frage-muster-c{ci}", prompt=_prompt("Frage-Muster-Recherche", topic=topic, bausteine=block, - typen=typen_block, out_path=fp, extra=_extra(instructions)), + out_path=fp, extra=_extra(instructions)), role="fast", capabilities="files", payload=lambda result, p=fp: _frage_muster_chunk_schema(_json_datei(p)), timeout=_timeout("frage_muster", subs_total), @@ -1027,13 +1265,12 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict titel = _match_sub(e["baustein"], ctitel) if titel not in rel_by: continue # nicht zuordenbar → verwerfen - norm = _norm_frage(e["frage"]) + sub = _match_sub(e["subbaustein"], rel_by[titel]) seen = gesehen.setdefault(titel, set()) - if norm in seen: - continue - seen.add(norm) - out.setdefault(titel, []).append( - {"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "typ": e["typ"], "frage": e["frage"]}) + if sub in seen: + continue # genau ein Muster je Subbaustein + seen.add(sub) + out.setdefault(titel, []).append({"subbaustein": sub, "frage": e["frage"]}) return out def _waehle_all(ci_list): @@ -1057,7 +1294,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict eintraege = roh_by_titel.get(t) or [] if not eintraege: continue - zeilen = "\n".join(f"- [{e['typ']}] ({e['subbaustein']}) {e['frage']}" for e in eintraege) + zeilen = "\n".join(f"- ({e['subbaustein']}) {e['frage']}" for e in eintraege) bloecke.append(f"BAUSTEIN: {t}\n{zeilen}") if not bloecke: return # nichts zu klären in diesem Chunk @@ -1093,7 +1330,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict if titel not in rel_by: continue out.setdefault(titel, []).append( - {"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "typ": e["typ"], "frage": e["frage"]}) + {"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "frage": e["frage"]}) return out final_by_titel = _final_by_titel(range(len(chunks))) @@ -1415,6 +1652,226 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool: return True +# --- Gliederung (Bausteine-Artefakt: Kapitel-Struktur, vom Guide nur gelesen) --- + +def _gliederung_komplett(files: dict) -> bool: + """Gliederung steht (Kapitel-Liste vorhanden)?""" + d = _json_datei(files["gliederung"]) + return isinstance(d, dict) and isinstance(d.get("kapitel"), list) and bool(d.get("kapitel")) + + +def _gliederung_schema(data, valid: set[int]): + """{"kapitel":[{titel,nummern}]} → bereinigt (valide Nummern, je genau einmal) · + None bei <80 % Abdeckung (Agent/Judge hat zu viel weggelassen).""" + if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list): + return None + out, seen = [], set() + for ch in data["kapitel"]: + if not isinstance(ch, dict): + continue + titel = str(ch.get("titel", "")).strip() or "Kapitel" + nums = [] + for n in (ch.get("nummern") or []): + try: + n = int(n) + except (ValueError, TypeError): + continue + if n in valid and n not in seen: + seen.add(n) + nums.append(n) + if nums: + out.append({"titel": titel, "nummern": nums}) + if not out or len(seen) < 0.8 * len(valid): + return None + return {"kapitel": out} + + +async def _gliederung_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str) -> dict: + """Format-agnostische Gliederung über ALLE Bausteine — 3 Vorschläge → Judge merged. + Bricht nie ab: 0 gültige → ein Kapitel mit allem; fehlende Bausteine landen in „Weitere". + → {"kapitel":[{titel,nummern}]} (auch in files["gliederung"]).""" + topic, is_cancelled = ctx.topic, ctx.is_cancelled + valid = set(entries) + liste = "\n".join(f"{n}. {t}" for n, t in entries.items()) + step = _step_idx(topic, "Gliederung") + set_p("Gliederung — Vorschläge…", step=step) + + async def _vorschlag(i, path): + if _gliederung_schema(_json_datei(path), valid): + return True + await run_single_slot( + ctx, f"Gliederung {i}", key=f"bausteine-{topic}-gliederung-{i}", + prompt=_prompt("Guide-Gliederung", topic=topic, bausteine=liste, out_path=path, extra=_extra(instructions)), + role="guide", capabilities="files", + payload=lambda result, p=path: _gliederung_schema(_json_datei(p), valid), + timeout=_timeout("plan", len(entries))) + return _gliederung_schema(_json_datei(path), valid) is not None + + slots = files["gliederung_slots"] + await _gather_fortschritt([_vorschlag(i, p) for i, p in enumerate(slots, 1)], len(slots), _melde_p(set_p, topic, "Gliederung")) + if is_cancelled(): + return {} + vorschlaege = [v for p in slots if (v := _gliederung_schema(_json_datei(p), valid))] + + if not vorschlaege: + plan = {"kapitel": [{"titel": "Inhalte", "nummern": list(entries)}]} + elif len(vorschlaege) == 1: + plan = vorschlaege[0] + else: + set_p("Gliederung zusammenführen…", step=step) + bloecke = "\n\n".join( + f"### Vorschlag {i}\n" + "\n".join( + f"KAPITEL: {ch['titel']}\n Nummern: {', '.join(str(n) for n in ch['nummern'])}" for ch in v["kapitel"]) + for i, v in enumerate(vorschlaege, 1)) + await run_single_slot( + ctx, "Gliederung-Judge", key=f"bausteine-{topic}-gliederung-judge", + prompt=_prompt("Guide-Gliederung-Judge", topic=topic, format_name="den Guide", + zweck="alle Bausteine in einem roten Faden", n=len(vorschlaege), + bausteine=liste, gliederungen=bloecke, out_path=files["gliederung"], extra=_extra(instructions)), + role="judge", capabilities="files", + payload=lambda result: _gliederung_schema(_json_datei(files["gliederung"]), valid), + timeout=_timeout("plan_judge", len(entries))) + plan = _gliederung_schema(_json_datei(files["gliederung"]), valid) or vorschlaege[0] + + # Vollständigkeit: jeder Baustein kommt vor — fehlende in „Weitere" (gegen weglassende Agenten/Judge). + drin = {n for ch in plan["kapitel"] for n in ch["nummern"]} + fehlen = [n for n in entries if n not in drin] + if fehlen: + plan["kapitel"].append({"titel": "Weitere", "nummern": fehlen}) + atomic_write_json(files["gliederung"], plan, indent=1) + return plan + + +# --- Lern-Artefakte (Karteikarten/Beispiele/Diagramme aus den Fakten) --- + +def _karten_schema(data): + """{"karten":[{baustein,subbaustein,frage,antwort}]} → Liste (auch leer) · None bei kaputt.""" + if not isinstance(data, dict) or not isinstance(data.get("karten"), list): + return None + out = [] + for e in data["karten"]: + if isinstance(e, dict) and (f := str(e.get("frage", "")).strip()) and (a := str(e.get("antwort", "")).strip()): + out.append({"baustein": str(e.get("baustein", "")).strip(), "subbaustein": str(e.get("subbaustein", "")).strip(), + "frage": f, "antwort": a}) + return out + + +def _beispiel_schema(data): + """{"beispiele":[{baustein,subbaustein,problem,schritte,ergebnis}]} → Liste (auch leer) · None bei kaputt.""" + if not isinstance(data, dict) or not isinstance(data.get("beispiele"), list): + return None + out = [] + for e in data["beispiele"]: + if not isinstance(e, dict): + continue + problem = str(e.get("problem", "")).strip() + schritte = [s for x in (e.get("schritte") or []) if (s := str(x).strip())] + if problem and schritte: + out.append({"baustein": str(e.get("baustein", "")).strip(), "subbaustein": str(e.get("subbaustein", "")).strip(), + "problem": problem, "schritte": schritte, "ergebnis": str(e.get("ergebnis", "")).strip()}) + return out + + +def _diagramm_schema(data): + """{"diagramme":[{baustein,mermaid}]} → Liste (auch leer) · None bei kaputt.""" + if not isinstance(data, dict) or not isinstance(data.get("diagramme"), list): + return None + out = [] + for e in data["diagramme"]: + if isinstance(e, dict) and (m := str(e.get("mermaid", "")).strip()): + out.append({"baustein": str(e.get("baustein", "")).strip(), "mermaid": m}) + return out + + +_ARTEFAKT_SCHEMA = {"karteikarte": _karten_schema, "beispiel": _beispiel_schema, "diagramm": _diagramm_schema} +_ARTEFAKT_PROMPT = {"karteikarte": "Artefakt-Karteikarte", "beispiel": "Artefakt-Beispiel", "diagramm": "Artefakt-Diagramm"} +_ARTEFAKT_SCHRITT = {"karteikarte": "Karteikarten", "beispiel": "Beispiele", "diagramm": "Diagramme"} + + +def _artefakte_komplett(files: dict) -> bool: + """Artefakt-Map steht (alle Typen erzeugt)? Werte dürfen leer sein (content-aware).""" + d = _json_datei(files["artefakte"]) + return isinstance(d, dict) and all(t in d for t in ARTEFAKT_TYPEN) + + +async def _artefakte_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None: + """Lern-Artefakte je Typ aus den gespeicherten Fakten erzeugen — ein Generierungs-Durchlauf + je Typ über Chunks, keine Verifikation (Bonus-Material). → {typ: [eintraege]} (auch in files).""" + topic, is_cancelled = ctx.topic, ctx.is_cancelled + arbeit = files["arbeit"] + # Bausteine mit Subs + Fakten-Zeilen als Input-Block (extract-once aus den Fakten). + bausteine = [] + for btitel, subs in sidecar.items(): + if not isinstance(subs, list): + continue + zeilen = [] + for s in subs: + if not isinstance(s, dict) or not (st := str(s.get("titel", "")).strip()): + continue + fk = s.get("fakten") if isinstance(s.get("fakten"), dict) else {} + zeile = f"- {st}" + if fk and (fk_text := _fakten_zeilen(fk)): + zeile += "\n" + "\n".join(" " + l for l in fk_text.split("\n")) + zeilen.append(zeile) + if zeilen: + bausteine.append((btitel, zeilen)) + if not bausteine: + leer = {t: [] for t in ARTEFAKT_TYPEN} + atomic_write_json(files["artefakte"], leer, indent=1) + return leer + + chunks = _lpt_chunks([len(z) for _, z in bausteine], FAKTEN_CHUNK_SUBS) + def block_text(idxs): + return "\n\n".join(f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(bausteine[i][1]) for i in idxs) + + ergebnis: dict[str, list] = {} + for typ in ARTEFAKT_TYPEN: + schema = _ARTEFAKT_SCHEMA[typ] + def apath(ci, t=typ): return arbeit / f"artefakt-{t}-c{ci}.json" + + async def _gen(ci, idxs, t=typ, schema=schema): + p = apath(ci, t) + if schema(_json_datei(p)) is not None: + return True + await run_single_slot( + ctx, f"{_ARTEFAKT_SCHRITT[t]} {ci}", key=f"bausteine-{topic}-artefakt-{t}-c{ci}", + prompt=_prompt(_ARTEFAKT_PROMPT[t], topic=topic, bausteine=block_text(idxs), out_path=p, extra=_extra(instructions)), + role="guide", capabilities="files", + payload=lambda result, p=p, schema=schema: schema(_json_datei(p)), + timeout=_timeout("inhalt", sum(len(bausteine[i][1]) for i in idxs))) + return schema(_json_datei(p)) is not None + + await _gather_fortschritt([_gen(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, _ARTEFAKT_SCHRITT[typ])) + if is_cancelled(): + return None + eintraege: list = [] + for ci in range(len(chunks)): + eintraege += schema(_json_datei(apath(ci))) or [] + ergebnis[typ] = eintraege + atomic_write_json(files["artefakte"], ergebnis, indent=1) + return ergebnis + + +async def _mirror_artefakte_db(topic: str, sidecar: dict, artefakte: dict) -> None: + """Artefakte in die DB spiegeln. Karteikarte/Beispiel je Sub (sub_norm), Diagramm je Baustein (sub_norm='').""" + await db.delete_sub_artefakte(topic) + btitel_list = list(sidecar.keys()) + for typ in ("karteikarte", "beispiel"): + for e in artefakte.get(typ, []): + bt = _match_sub(e.get("baustein", ""), btitel_list) + bnorm, sn = _norm_titel(bt), _norm_titel(e.get("subbaustein", "")) + if not bnorm or not sn: + continue + daten = json.dumps({k: v for k, v in e.items() if k not in ("baustein", "subbaustein")}, ensure_ascii=False) + await db.put_sub_artefakt(topic, bnorm, sn, typ, daten, bt, e.get("subbaustein", "")) + for e in artefakte.get("diagramm", []): + bt = _match_sub(e.get("baustein", ""), btitel_list) + if not (bnorm := _norm_titel(bt)): + continue + daten = json.dumps({"mermaid": e.get("mermaid", "")}, ensure_ascii=False) + await db.put_sub_artefakt(topic, bnorm, "", "diagramm", daten, bt, "") + + async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None: """Sidecar {Baustein-Titel: [{titel, stufe, relevanz}]} in die DB-Tabelle subbausteine spiegeln.""" for btitel, subs in sidecar.items(): @@ -1428,12 +1885,14 @@ async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None: sn = _norm_titel(st) if not sn: continue + fakten = json.dumps(s["fakten"], ensure_ascii=False) if isinstance(s.get("fakten"), dict) else None await db.put_subbaustein(topic, bnorm, sn, btitel, st, - stufe=s.get("stufe"), relevanz=s.get("relevanz"), status="konsens") + stufe=s.get("stufe"), relevanz=s.get("relevanz"), + fakten=fakten, status="konsens") async def _mirror_frage_muster_db(topic: str, muster: dict) -> None: - """Frage-Muster {Baustein-Titel: [{subbaustein, typ, frage}]} in die DB-Tabelle frage_muster spiegeln.""" + """Frage-Muster {Baustein-Titel: [{subbaustein, frage}]} in die DB-Tabelle frage_muster spiegeln.""" await db.delete_frage_muster(topic) for btitel, eintraege in muster.items(): bnorm = _norm_titel(btitel) @@ -1444,19 +1903,22 @@ async def _mirror_frage_muster_db(topic: str, muster: dict) -> None: continue sub = str(e.get("subbaustein", "")).strip() sn = _norm_titel(sub) - typ = str(e.get("typ", "")).strip() frage = str(e.get("frage", "")).strip() - if not (sn and typ and frage): + if not (sn and frage): continue - await db.upsert_frage_muster(topic, bnorm, sn, btitel, sub, typ, frage) + await db.upsert_frage_muster(topic, bnorm, sn, btitel, sub, frage) async def _reset_db_ab_phase(topic: str, label: str) -> None: - """DB-Inhalt der Phasen ≥ `label` verwerfen (kanonische Reihenfolge Quelle…Fragen).""" + """DB-Inhalt der Phasen ≥ `label` verwerfen (kanonische Reihenfolge Quelle…Artefakte).""" idx = _phase_idx(label) - if idx <= 5: # Fragen + if idx <= 8: # Artefakte (Karteikarten/Beispiele/Diagramme) + await db.delete_sub_artefakte(topic) + if idx <= 7: # Fragen await db.delete_frage_muster(topic) - if idx <= 2: # Subbausteine (Stufen/Relevanz greifen über Sidecar→Mirror) + if idx <= 6: # Gliederung + await db.delete_gliederung(topic) + if idx <= 2: # Subbausteine (Fakten/Stufen/Relevanz greifen über Sidecar→Mirror) await db.delete_subbausteine(topic) if idx <= 1: # Inventar: Inventar + Recherche-Schritte — Sichtung bleibt await db.delete_bausteine(topic) @@ -1519,6 +1981,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = await db.delete_subbausteine(topic) await db.delete_frage_muster(topic) await db.delete_coverage(topic) + await db.delete_gliederung(topic) + await db.delete_sub_artefakte(topic) # Inventar (DB): Recherche-Loop → Konsolidierung → Klärung. if not await _recherche_batch(ctx, set_p, files, q, ordner, instructions): @@ -1590,12 +2054,30 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = if roh is None: return # Fehler ist gesetzt atomic_write_json(files["sub_roh"], roh, indent=1) + # Fakten je Sub (VOR der Stufe): Quell-Fakten extrahieren + verifizieren → fakten.json. + # Extract-once-Grounding — Stufe/Relevanz/Fragen/Guide nähren sich daraus. + if not _fakten_komplett(files): + fakten_map = await _fakten_block(ctx, set_p, files, roh, q, ordner, instructions) + if is_cancelled(): + abgebrochen() + return + if fakten_map is None: + return # Fehler ist gesetzt + atomic_write_json(files["fakten"], fakten_map, indent=1) sidecar = await _stufen_block(ctx, set_p, files, roh, instructions) if is_cancelled(): abgebrochen() return if sidecar is None: return + # Fakten in die Sidecar-Subs mergen (DB-Spiegel + Guide-Nutzung). + fakten_map = _json_datei(files["fakten"]) + if isinstance(fakten_map, dict): + for btitel, subs in sidecar.items(): + fm = fakten_map.get(btitel, {}) + for sub in subs: + if (fk := fm.get(_norm_titel(sub["titel"]))): + sub["fakten"] = fk atomic_write_json(files["sidecar"], sidecar, indent=1) # Block D: Relevanz je Subbaustein (relevant/rand) → in die Sidecar mergen. @@ -1616,6 +2098,14 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = sub["relevanz"] = relevanz_by_id.get(gid, "relevant") atomic_write_json(files["sidecar"], sidecar, indent=1) + # Block D.5: Gliederung (Bausteine-Artefakt) — Kapitel-Struktur über ALLE Bausteine, + # vom Guide nur noch gelesen. Format-agnostisch; der Guide filtert je Format. + if not _gliederung_komplett(files): + await _gliederung_block(ctx, set_p, files, entries, instructions) + if is_cancelled(): + abgebrochen() + return + # Block E: Frage-Muster je relevantem Subbaustein × Typ → eigenes Sidecar. # Zur Prüfungszeit zieht jeder Agent ein Muster ohne Zurücklegen und formuliert # daraus eine Frage — distinkte Saat verhindert die Doppelfragen der Live-Generierung. @@ -1629,6 +2119,17 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = return # Abbruch atomic_write_json(files["frage_muster"], muster, indent=1) + # Block F: Lern-Artefakte (Karteikarten/Beispiele/Diagramme) aus den Fakten — Bonus, + # vom Frontend präsentiert. Bricht den Lauf nicht ab (Artefakte sind optional). + sidecar = _json_datei(files["sidecar"]) + if _sidecar_schema(sidecar) is not None and not _artefakte_komplett(files): + artefakte = await _artefakte_block(ctx, set_p, files, sidecar, instructions) + if is_cancelled(): + abgebrochen() + return + if artefakte is None: + return # Abbruch (Fehler/Cancel) + # DB-Spiegel (Brücke): finalen Sidecar- + Frage-Muster-Stand in die DB schreiben. sidecar = _json_datei(files["sidecar"]) if _sidecar_schema(sidecar) is not None: @@ -1636,6 +2137,19 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = muster = _json_datei(files["frage_muster"]) if isinstance(muster, dict) and muster: await _mirror_frage_muster_db(topic, muster) + # Gliederung (Titel-basiert, robust gegen Nummern-Drift) → DB. + plan = _json_datei(files["gliederung"]) + if isinstance(plan, dict) and plan.get("kapitel"): + kapitel = [ + {"titel": ch.get("titel", "Kapitel"), + "bausteine": [_titel(entries[n]) for n in ch.get("nummern", []) if n in entries]} + for ch in plan["kapitel"] + ] + await db.set_gliederung(topic, json.dumps({"kapitel": kapitel}, ensure_ascii=False)) + # Artefakte → DB (Karteikarte/Beispiel je Sub, Diagramm je Baustein). + artefakte = _json_datei(files["artefakte"]) + if isinstance(artefakte, dict) and _sidecar_schema(sidecar) is not None: + await _mirror_artefakte_db(topic, sidecar, artefakte) except Exception as e: log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic) _bausteine_errors[topic] = str(e)[:2000] diff --git a/backend/database.py b/backend/database.py index ed0d81f..55a2f85 100644 --- a/backend/database.py +++ b/backend/database.py @@ -103,13 +103,15 @@ CREATE TABLE IF NOT EXISTS subbausteine ( nennungen INTEGER NOT NULL DEFAULT 1, stufe TEXT, relevanz TEXT, + fakten TEXT, status TEXT NOT NULL DEFAULT 'kandidat', updated_at TEXT NOT NULL, PRIMARY KEY (topic, baustein_norm, sub_norm) ) """ -# Ein Frage-Muster je (Baustein, Subbaustein, Typ). +# Ein Frage-Muster je (Baustein, Subbaustein). Schwierigkeit kommt erst bei der Prüfung +# aus dem Lerner-Niveau, nicht aus dem Muster — daher kein Typ-Kreuzprodukt mehr. CREATE_FRAGE_MUSTER = """ CREATE TABLE IF NOT EXISTS frage_muster ( topic TEXT NOT NULL, @@ -117,10 +119,9 @@ CREATE TABLE IF NOT EXISTS frage_muster ( sub_norm TEXT NOT NULL, baustein TEXT NOT NULL, sub_titel TEXT NOT NULL, - typ TEXT NOT NULL, frage TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY (topic, baustein_norm, sub_norm, typ) + PRIMARY KEY (topic, baustein_norm, sub_norm) ) """ @@ -170,6 +171,33 @@ CREATE TABLE IF NOT EXISTS quelle ( ) """ +# Gliederung (Kapitel → Baustein-Nummern) je Thema als JSON. In der Bausteine-Phase erzeugt, +# vom Guide nur noch gelesen + je Format gefiltert. Format-agnostisch (alle Bausteine). +CREATE_GUIDE_GLIEDERUNG = """ +CREATE TABLE IF NOT EXISTS guide_gliederung ( + topic TEXT PRIMARY KEY, + json TEXT NOT NULL, + updated_at TEXT NOT NULL +) +""" + +# Generischer Lern-Artefakt-Layer: je (Baustein, Subbaustein, Typ) ein Artefakt (JSON in `daten`). +# typ: karteikarte | beispiel | diagramm. sub_norm='' = Baustein-Ebene (z. B. Diagramm). +# In der Bausteine-Phase aus den Fakten erzeugt, vom Frontend präsentiert (Trennung Artefakt/Anzeige). +CREATE_SUB_ARTEFAKTE = """ +CREATE TABLE IF NOT EXISTS sub_artefakte ( + topic TEXT NOT NULL, + baustein_norm TEXT NOT NULL, + sub_norm TEXT NOT NULL DEFAULT '', + typ TEXT NOT NULL, + baustein TEXT NOT NULL DEFAULT '', + sub_titel TEXT NOT NULL DEFAULT '', + daten TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL, + PRIMARY KEY (topic, baustein_norm, sub_norm, typ) +) +""" + _db: aiosqlite.Connection | None = None @@ -199,6 +227,8 @@ async def init_db(): await db.execute(CREATE_PIPELINE_STATE) await db.execute(CREATE_GUIDE_CONTENT) await db.execute(CREATE_QUELLE) + await db.execute(CREATE_GUIDE_GLIEDERUNG) + await db.execute(CREATE_SUB_ARTEFAKTE) try: # Migration für Bestands-DBs ohne step-Spalte await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER") except aiosqlite.OperationalError: @@ -226,6 +256,16 @@ async def init_db(): await db.execute(f"ALTER TABLE baustein_progress ADD COLUMN {_spalte} {_typ}") except aiosqlite.OperationalError: pass + # Migration: frage_muster ohne typ-Spalte (1 Muster je Sub statt Sub×Typ-Kreuzprodukt). + # PK-Änderung → Tabelle einmalig neu bauen. Bestands-Muster gehen verloren (bewusst, kein Mapping). + cursor = await db.execute("PRAGMA table_info(frage_muster)") + if any(_r[1] == "typ" for _r in await cursor.fetchall()): + await db.execute("DROP TABLE frage_muster") + await db.execute(CREATE_FRAGE_MUSTER) + try: # Migration: subbausteine.fakten (Quell-Fakten je Sub, JSON-Blob) — Extract-once-Grounding. + await db.execute("ALTER TABLE subbausteine ADD COLUMN fakten TEXT") # nullable: COALESCE schützt Bestand + except aiosqlite.OperationalError: + pass # Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive') cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'") if await cursor.fetchone(): @@ -482,6 +522,21 @@ async def set_baustein_score(topic: str, baustein: str, score: int) -> int: return score +async def set_baustein_score_and_streak(topic: str, baustein: str, score: int, streak: int) -> tuple[int, int]: + """Setzt Score + Streak atomar (vom Aufrufer geclampt). Liefert (score, streak).""" + db = await get_db() + await db.execute( + """INSERT INTO baustein_progress (topic, baustein, gute_antworten, streak, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(topic, baustein) DO UPDATE SET + gute_antworten = excluded.gute_antworten, streak = excluded.streak, + updated_at = excluded.updated_at""", + (topic, baustein, score, streak, _now()), + ) + await db.commit() + return score, streak + + async def delete_baustein_progress(topic: str, baustein: str) -> None: """Fortschritt EINES Bausteins zurücksetzen: Zeile löschen (Score/Streak/Flags/offene Frage). Fehlt die Zeile, liefert get_baustein_progress Defaults (0) — also voller Reset.""" @@ -574,6 +629,75 @@ async def subs_count_alle() -> dict[tuple[str, str], int]: return {(t, bn): n for t, bn, n in await cursor.fetchall()} +# Sub-Ebene aus den zwei orthogonalen Spalten: rand → 4 (V), sonst Stufe (Lernpfad-Position): +# anfaenger/NULL → 1, fortgeschritten → 2, experte → 3. Alte Werte (einfach/mittel/schwer) werden +# abwärtskompatibel mitgemappt. Steuert Guide-Ansicht A/F/E/V + cap (freigeschaltete Subs × 25). +_EBENE_CASE = """CASE + WHEN relevanz = 'rand' THEN 4 + WHEN stufe IN ('fortgeschritten', 'mittel') THEN 2 + WHEN stufe IN ('experte', 'schwer') THEN 3 + ELSE 1 +END""" + + +def _leere_ebenen() -> dict[int, int]: + return {1: 0, 2: 0, 3: 0, 4: 0} + + +async def subs_je_ebene(topic: str, baustein: str) -> dict[int, int]: + """Konsens-Subbausteine eines Bausteins je Ebene 1–4. roher Baustein-Titel rein.""" + from textkit import _norm_titel + db = await get_db() + cursor = await db.execute( + f"SELECT {_EBENE_CASE} AS ebene, COUNT(*) FROM subbausteine " + "WHERE topic = ? AND baustein_norm = ? AND status = 'konsens' GROUP BY ebene", + (topic, _norm_titel(baustein)), + ) + out = _leere_ebenen() + for ebene, n in await cursor.fetchall(): + out[ebene] = n + return out + + +async def subs_je_ebene_roh(topic: str) -> dict[str, dict[int, int]]: + """Subbausteine je Ebene, gruppiert nach ROHEM Baustein-Titel (= Guide-Section-Titel).""" + db = await get_db() + cursor = await db.execute( + f"SELECT baustein, {_EBENE_CASE} AS ebene, COUNT(*) FROM subbausteine " + "WHERE topic = ? AND status = 'konsens' GROUP BY baustein, ebene", + (topic,), + ) + out: dict[str, dict[int, int]] = {} + for b, ebene, n in await cursor.fetchall(): + out.setdefault(b, _leere_ebenen())[ebene] = n + return out + + +async def subs_je_ebene_alle() -> dict[tuple[str, str], dict[int, int]]: + """Subbausteine je Ebene je (topic, baustein_norm) — für die themenweite Stufen-Ableitung.""" + db = await get_db() + cursor = await db.execute( + f"SELECT topic, baustein_norm, {_EBENE_CASE} AS ebene, COUNT(*) FROM subbausteine " + "WHERE status = 'konsens' GROUP BY topic, baustein_norm, ebene" + ) + out: dict[tuple[str, str], dict[int, int]] = {} + for t, bn, ebene, n in await cursor.fetchall(): + out.setdefault((t, bn), _leere_ebenen())[ebene] = n + return out + + +async def subs_mit_ebene(topic: str, baustein: str) -> list[dict]: + """Konsens-Subbausteine eines Bausteins mit Titel, sub_norm und Ebene 1–4.""" + from textkit import _norm_titel + db = await get_db() + cursor = await db.execute( + f"SELECT sub_titel, sub_norm, {_EBENE_CASE} AS ebene FROM subbausteine " + "WHERE topic = ? AND baustein_norm = ? AND status = 'konsens'", + (topic, _norm_titel(baustein)), + ) + return [{"titel": t, "norm": sn, "ebene": e} for t, sn, e 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() @@ -653,19 +777,21 @@ async def upsert_subbaustein(topic: str, baustein_norm: str, sub_norm: str, baus async def put_subbaustein(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str, - stufe: str | None = None, relevanz: str | None = None, status: str = "konsens") -> None: - """Insert/Update OHNE Nennungszähler (Spiegel aus dem Sidecar). stufe/relevanz nur überschreiben, - wenn ein neuer Wert übergeben wird (COALESCE schützt Bestehendes).""" + stufe: str | None = None, relevanz: str | None = None, + fakten: str | None = None, status: str = "konsens") -> None: + """Insert/Update OHNE Nennungszähler (Spiegel aus dem Sidecar). stufe/relevanz/fakten nur + überschreiben, wenn ein neuer Wert übergeben wird (COALESCE schützt Bestehendes).""" db = await get_db() await db.execute( - """INSERT INTO subbausteine (topic, baustein_norm, sub_norm, baustein, sub_titel, nennungen, stufe, relevanz, status, updated_at) - VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + """INSERT INTO subbausteine (topic, baustein_norm, sub_norm, baustein, sub_titel, nennungen, stufe, relevanz, fakten, status, updated_at) + VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) ON CONFLICT(topic, baustein_norm, sub_norm) DO UPDATE SET baustein = excluded.baustein, sub_titel = excluded.sub_titel, stufe = COALESCE(excluded.stufe, subbausteine.stufe), relevanz = COALESCE(excluded.relevanz, subbausteine.relevanz), + fakten = COALESCE(excluded.fakten, subbausteine.fakten), status = excluded.status, updated_at = excluded.updated_at""", - (topic, baustein_norm, sub_norm, baustein, sub_titel, stufe, relevanz, status, _now()), + (topic, baustein_norm, sub_norm, baustein, sub_titel, stufe, relevanz, fakten, status, _now()), ) await db.commit() @@ -700,14 +826,14 @@ async def delete_subbausteine(topic: str) -> None: await db.commit() -async def upsert_frage_muster(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str, typ: str, frage: str) -> None: +async def upsert_frage_muster(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str, frage: str) -> None: db = await get_db() await db.execute( - """INSERT INTO frage_muster (topic, baustein_norm, sub_norm, baustein, sub_titel, typ, frage, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(topic, baustein_norm, sub_norm, typ) DO UPDATE SET - frage = excluded.frage, updated_at = excluded.updated_at""", - (topic, baustein_norm, sub_norm, baustein, sub_titel, typ, frage, _now()), + """INSERT INTO frage_muster (topic, baustein_norm, sub_norm, baustein, sub_titel, frage, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(topic, baustein_norm, sub_norm) DO UPDATE SET + sub_titel = excluded.sub_titel, frage = excluded.frage, updated_at = excluded.updated_at""", + (topic, baustein_norm, sub_norm, baustein, sub_titel, frage, _now()), ) await db.commit() @@ -849,10 +975,90 @@ async def delete_guide_content(topic: str, format: str | None = None) -> None: await db.commit() +async def set_gliederung(topic: str, gliederung_json: str) -> None: + """Gliederung (Kapitel→Nummern, JSON) je Thema speichern — Bausteine-Artefakt für den Guide.""" + db = await get_db() + await db.execute( + """INSERT INTO guide_gliederung (topic, json, updated_at) VALUES (?, ?, ?) + ON CONFLICT(topic) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""", + (topic, gliederung_json, _now()), + ) + await db.commit() + + +async def get_gliederung(topic: str) -> str | None: + db = await get_db() + cursor = await db.execute("SELECT json FROM guide_gliederung WHERE topic = ?", (topic,)) + row = await cursor.fetchone() + return row[0] if row else None + + +async def delete_gliederung(topic: str) -> None: + db = await get_db() + await db.execute("DELETE FROM guide_gliederung WHERE topic = ?", (topic,)) + await db.commit() + + +async def put_sub_artefakt(topic: str, baustein_norm: str, sub_norm: str, typ: str, + daten: str, baustein: str = "", sub_titel: str = "") -> None: + """Ein Lern-Artefakt (karteikarte/beispiel/diagramm) als JSON in `daten` speichern.""" + db = await get_db() + await db.execute( + """INSERT INTO sub_artefakte (topic, baustein_norm, sub_norm, typ, baustein, sub_titel, daten, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(topic, baustein_norm, sub_norm, typ) DO UPDATE SET + baustein = excluded.baustein, sub_titel = excluded.sub_titel, + daten = excluded.daten, updated_at = excluded.updated_at""", + (topic, baustein_norm, sub_norm, typ, baustein, sub_titel, daten, _now()), + ) + await db.commit() + + +async def get_sub_artefakte(topic: str, typ: str | None = None) -> list[dict]: + db = await get_db() + if typ is None: + cursor = await db.execute("SELECT * FROM sub_artefakte WHERE topic = ? ORDER BY rowid", (topic,)) + else: + cursor = await db.execute( + "SELECT * FROM sub_artefakte WHERE topic = ? AND typ = ? ORDER BY rowid", (topic, typ) + ) + rows = await cursor.fetchall() + return [_row_to_dict(row, cursor) for row in rows] + + +async def delete_sub_artefakte(topic: str) -> None: + db = await get_db() + await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,)) + await db.commit() + + +async def get_baustein_huerden(topic: str, baustein_norm: str) -> list[str]: + """Typische Irrtümer (huerden) aller Subs eines Bausteins — als Distraktor-Pool fürs Quiz. + Liest aus subbausteine.fakten (JSON-Blob); leere/fehlende werden übersprungen.""" + db = await get_db() + cursor = await db.execute( + "SELECT fakten FROM subbausteine WHERE topic = ? AND baustein_norm = ?", (topic, baustein_norm) + ) + rows = await cursor.fetchall() + huerden = [] + for (fakten,) in rows: + if not fakten: + continue + try: + fk = json.loads(fakten) + except (ValueError, TypeError): + continue + h = (fk.get("huerden") or "").strip() if isinstance(fk, dict) else "" + if h: + huerden.append(h) + return huerden + + async def delete_topic_pipeline(topic: str) -> None: - """Bausteine-Bereich eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State). + """Bausteine-Bereich eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State/Artefakte). NICHT die Themen-Config `quelle` — die wird separat verwaltet (delete_quelle).""" db = await get_db() - for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage", "pipeline_state"): + for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage", + "pipeline_state", "guide_gliederung", "sub_artefakte"): await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,)) await db.commit() diff --git a/backend/guide.py b/backend/guide.py index 873e1df..071454b 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -24,7 +24,7 @@ from config import ( LESBARKEIT_AKTIV, TEMPLATES_DIR, ) import lesbarkeit -from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content, get_guide_content +from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content, get_guide_content, get_gliederung from fsutil import atomic_write_json, atomic_write_text from jsonio import read_json_file as _json_datei, parse_json_text as _parse_json_text from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path @@ -35,7 +35,7 @@ from pipeline import ( is_guide_cancelled, run_single_slot, ) from textkit import ( - _eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks, + _eindeutige_titel, _lade_bausteine, _norm_titel, _parse_fragment, _split_chunks, _titel, _titel_aufloesen, _titel_index, ) @@ -57,14 +57,22 @@ CHECK_PANEL = 3 LESE_RUNDEN = 1 +# Gültige Stufen-Werte: neu (Lernpfad) + alt (Schwierigkeit) abwärtskompatibel. +_STUFEN_OK = ("anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer") + + async def _load_subbausteine(topic: str) -> dict[str, list[dict]]: """Subbausteine je Baustein — DB-first ({titel, stufe, relevanz}), Fallback Sidecar-Datei. Fehlt beides → {} (Guide nimmt alles).""" out: dict[str, list[dict]] = {} for r in await list_subbausteine(topic): - if r["status"] == "konsens" and r["sub_titel"] and r["stufe"] in ("einfach", "mittel", "schwer"): + if r["status"] == "konsens" and r["sub_titel"] and r["stufe"] in _STUFEN_OK: + try: + fakten = json.loads(r["fakten"]) if r.get("fakten") else {} + except (ValueError, TypeError): + fakten = {} out.setdefault(r["baustein"], []).append( - {"titel": r["sub_titel"], "stufe": r["stufe"], "relevanz": r["relevanz"]}) + {"titel": r["sub_titel"], "stufe": r["stufe"], "relevanz": r["relevanz"], "fakten": fakten}) if out: return out data = _json_datei(subbausteine_path(topic)) @@ -74,21 +82,26 @@ async def _load_subbausteine(topic: str) -> dict[str, list[dict]]: if not isinstance(subs, list): continue gut = [s for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip() - and s.get("stufe") in ("einfach", "mittel", "schwer")] + and s.get("stufe") in _STUFEN_OK] if gut: out[titel] = gut return out +def _ebene_label(s: dict) -> str: + """Ansichts-Ebene eines Subbausteins: rand → 'rand' (Ebene 4), sonst die Stufe (1–3).""" + return "rand" if s.get("relevanz") == "rand" else (s.get("stufe") or "anfaenger") + + def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: dict[str, list[dict]]) -> str: - """Listet je Kapitel die Bausteine, darunter ihre Subbausteine mit Stufe.""" + """Listet je Kapitel die Bausteine, darunter ihre Subbausteine mit Ebenen-Label.""" lines: list[str] = [] for ch in chunk: lines.append(f"KAPITEL: {ch['title']}") for num in ch["nums"]: lines.append(f"- {entries[num]}") for s in subs_by_titel.get(_titel(entries[num]), []): - lines.append(f" [{s['stufe']}] {s['titel']}") + lines.append(f" [{_ebene_label(s)}] {s['titel']}") return "\n".join(lines) @@ -249,6 +262,68 @@ def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]: return [*plan, {"title": "Weitere", "nums": fehlen}] if fehlen else plan +def _fakten_grounding(subs_raw: dict[str, list[dict]]) -> str: + """Verifizierte Sub-Fakten (extract-once aus der Bausteine-Phase) als Grounding-Block für den + Inhalts-Agent. Leer, wenn keine Fakten gespeichert (Altbestand → Fallback auf Quelle-Hinweis).""" + bloecke = [] + for titel, subs in subs_raw.items(): + zeilen = [] + for s in subs: + fk = s.get("fakten") if isinstance(s.get("fakten"), dict) else None + if not fk: + continue + teile = [] + if fk.get("kernpunkte"): + teile.append("Kern: " + " · ".join(fk["kernpunkte"])) + for bf in fk.get("belegte_fakten", []): + teile.append(f"FAKT[{bf.get('quelle', '?')}]: {bf.get('text', '')}") + if fk.get("voraussetzungen"): + teile.append("Voraussetzung: " + fk["voraussetzungen"]) + if fk.get("huerden"): + teile.append("Hürde: " + fk["huerden"]) + if fk.get("beispiel_idee"): + teile.append("Beispiel: " + fk["beispiel_idee"]) + if teile: + zeilen.append(f"- {s['titel']}: " + " | ".join(teile)) + if zeilen: + bloecke.append(f"BAUSTEIN: {titel}\n" + "\n".join(zeilen)) + if not bloecke: + return "" + return ("VERIFIZIERTE FAKTEN je Subbaustein — verbindliche Grundlage. Belegte Fakten (FAKT[Quelle]) " + "WÖRTLICH übernehmen, nichts dazu erfinden, NICHT neu recherchieren. Beispiele als Beispiel " + "nutzen, nie als Fakt.\n\n" + "\n\n".join(bloecke)) + + +async def _gliederung_aus_db(topic: str, sel_entries: dict[int, str]) -> list[dict] | None: + """Gliederung aus dem Bausteine-Artefakt (DB) lesen und auf die gewählten Bausteine mappen. + Titel-basiert (robust gegen Nummern-Drift): Bausteine außerhalb der Auswahl werden ignoriert + (Format-Filter), fehlende ergänzt später _mit_resten. None → kein Artefakt (Altbestand).""" + raw = await get_gliederung(topic) + if not raw: + return None + try: + data = json.loads(raw) + except (ValueError, TypeError): + return None + kapitel = data.get("kapitel") if isinstance(data, dict) else None + if not isinstance(kapitel, list): + return None + norm_to_num = {_norm_titel(_titel(t)): num for num, t in sel_entries.items()} + plan, seen = [], set() + for ch in kapitel: + if not isinstance(ch, dict): + continue + nums = [] + for bt in ch.get("bausteine", []): + num = norm_to_num.get(_norm_titel(str(bt))) + if num is not None and num not in seen: + seen.add(num) + nums.append(num) + if nums: + plan.append({"title": str(ch.get("titel", "")).strip() or "Kapitel", "nums": nums}) + return plan or None + + async def _generate_sections( guide_id: str, topic: str, format_name: str, entries: dict[int, str], facts: str, instructions: str, provider: str, @@ -265,21 +340,20 @@ async def _generate_sections( # Subbausteine je Baustein (DB-first) — früh geladen: steuert Auswahl + Sub-Filter je Format. # Fehlt sie → {} (Fallback: Guide nimmt alles). subs_raw = await _load_subbausteine(topic) + # Extract-once-Grounding: gespeicherte, verifizierte Fakten ersetzen den generischen + # Quelle-Hinweis. Der Inhalts-Agent formuliert daraus, statt die Quelle neu zu lesen. + if (fakten_block := _fakten_grounding(subs_raw)): + facts = fakten_block def _hat_relevanz(num, art): return any(isinstance(s, dict) and s.get("relevanz") == art for s in subs_raw.get(_titel(entries[num]), [])) - # Auswahl (Teil von Gliederung). Deterministisch je Format: - # Guide=relevante Bausteine · FullGuide=alle · Rest=reine Rand-Bausteine (keine im Guide). - if format_name == "FullGuide": - auswahl = list(entries) - elif format_name == "Rest": + # Auswahl: EIN Voll-Dokument mit ALLEN Bausteinen (inkl. Rand). Die Ansichten E/M/S/F + # filtern später pro Subbaustein-Ebene. (FullGuide/Rest bleiben als Alt-Zweige bestehen.) + if format_name == "Rest": auswahl = [num for num in entries if not _hat_relevanz(num, "relevant")] - else: # Guide - auswahl = [num for num in entries if _hat_relevanz(num, "relevant")] - if not auswahl: - _log(topic, "Guide: keine Relevanz-Daten — alle Bausteine genommen") - auswahl = list(entries) + else: # Guide / FullGuide → alle Bausteine + auswahl = list(entries) if not auswahl: await _fail(guide_id, "Keine passenden Bausteine für dieses Format") return None @@ -289,10 +363,14 @@ async def _generate_sections( # Nummerierte Liste (ID = Baustein-Nummer aus entries) — Agenten/Judge ordnen per Nummer. sel_liste = "\n".join(f"{num}. {t}" for num, t in sel_entries.items()) - # Schritt 0: Gliederung — bis zu 3 Vorschläge (Grace), ein Judge merged sie. Bricht NIE ab: + # Schritt 0: Gliederung. Bevorzugt das Bausteine-Artefakt (DB) — der Guide präsentiert nur, + # gliedert nicht mehr selbst. Fehlt es (Altbestand) → bisherige Agenten/Judge-Logik als Fallback. # 0 gültige → Code-Fallback, 1 → direkt, ≥2 → Judge (mit Vorschlag als Rückfall). - # Gültiges gliederung.json überspringt den Schritt. - plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll) + plan = await _gliederung_aus_db(topic, sel_entries) + if plan is not None: + _log(topic, f"Gliederung aus Bausteine-Artefakt ({len(plan)} Kapitel)") + if plan is None: + plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll) if plan is None: await _set_step(guide_id, 0, "Gliederungs-Vorschläge (3 Agenten)…") files["gliederung"].unlink(missing_ok=True) @@ -366,14 +444,12 @@ async def _generate_sections( # Der Writer baut darunter pro Baustein (eigene feine Chunks, s.u.) → variable Längen. total_sections = sum(len(c["nums"]) for c in plan) chunks = _split_chunks(plan, max(1, math.ceil(total_sections / GUIDE_CHUNK))) - # Subbausteine je Baustein, je Format gefiltert: Guide=relevant · FullGuide=alle · Rest=Rand. - # Subs ohne Relevanz-Feld (Alt-Themen) zählen als „nicht Rand" (→ Guide/FullGuide). - if format_name == "FullGuide": - subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()} - elif format_name == "Rest": + # Subbausteine je Baustein: Guide/FullGuide nehmen ALLE (inkl. Rand → Ebene 4 in der Ansicht); + # nur der Alt-Zweig Rest filtert auf Rand. So trägt das eine Dokument alle Ebenen. + if format_name == "Rest": subs_by_titel = {t: [s for s in subs if s.get("relevanz") == "rand"] for t, subs in subs_raw.items()} - else: # Guide - subs_by_titel = {t: [s for s in subs if s.get("relevanz") != "rand"] for t, subs in subs_raw.items()} + else: # Guide / FullGuide + subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()} subs_by_titel = {t: subs for t, subs in subs_by_titel.items() if subs} zuteilungen = [_zuteilung_subs(chunk, entries, subs_by_titel) for chunk in chunks] chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks] @@ -617,9 +693,15 @@ async def _generate_sections( def sections_text(nums: list[int]) -> str: return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums) + def _sub_liste(num: int) -> str: + subs = subs_by_titel.get(_titel(entries[num]), []) + return "\n".join(f"- [{_ebene_label(s)}] {s['titel']}" for s in subs) or "(keine)" + def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str: return "\n\n".join( - f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}" + f"SECTION: {_titel(entries[num])}\n" + f"SUBBAUSTEINE (je einen ``-Marker setzen, Label/Reihenfolge wie hier):\n{_sub_liste(num)}\n" + f"PROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}" for num in nums ) @@ -703,9 +785,15 @@ async def _generate_sections( continue for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _titel_aufloesen(idx, sec["titel"]) - if num in probleme_by_num and sec["md"].strip(): - by_num[num] = sec - ersetzt.add(num) + if num not in probleme_by_num or not sec["md"].strip(): + continue + # Marker-Invariante: Verliert der Fix die Sub-Marker, obwohl das Original welche + # hatte, wird er verworfen — sonst stirbt der Stufen-Filter (E/M/S/F) still. + if by_num[num].get("subs") and not sec.get("subs"): + _log(topic, f"Lese-Fix für '{sec['titel']}' ohne Sub-Marker — verworfen, getaggtes Original bleibt") + continue + by_num[num] = sec + ersetzt.add(num) _log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet") if not ersetzt: break @@ -730,7 +818,9 @@ async def _generate_sections( for ch in plan: sections = [ {"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"], - "kompakt": by_num[num].get("kompakt", ""), "pruefbar": _pruefbar(num)} + "kompakt": by_num[num].get("kompakt", ""), + "anker": by_num[num].get("anker", ""), "anker_kompakt": by_num[num].get("anker_kompakt", ""), + "subs": by_num[num].get("subs", []), "pruefbar": _pruefbar(num)} for num in ch["nums"] if num in by_num ] if sections: @@ -745,6 +835,34 @@ async def _generate_sections( return chapters +_STUFE_EBENE = {"anfaenger": 1, "fortgeschritten": 2, "experte": 3, "rand": 4, + "einfach": 1, "mittel": 2, "schwer": 3} # alte Werte abwärtskompatibel + + +def _section_fuer_ebene(sec: dict, ebene: int) -> dict: + """md/kompakt einer Section auf Subbausteine bis zur Ebene rekonstruieren (Anker bleibt).""" + subs = sec.get("subs") or [] + if not subs: + return sec # keine Sub-Tags (Altbestand) → unverändert sichtbar + sichtbar = [s for s in subs if _STUFE_EBENE.get(s.get("stufe"), 1) <= ebene] + md = "\n\n".join(t for t in [sec.get("anker", ""), *(s.get("md", "") for s in sichtbar)] if t).strip() + kompakt = "\n".join(t for t in [sec.get("anker_kompakt", ""), *(s.get("kompakt", "") for s in sichtbar)] if t).strip() + return {**sec, "md": md, "kompakt": kompakt, "leer": not sichtbar} + + +def content_fuer_ebene(content: dict, ebene: int) -> dict: + """Guide-Content auf eine Ansichts-Ebene (1=A · 2=F · 3=E · 4=V) filtern. Ebene 4 = Vollfassung. + Sections ohne sichtbare Subs werden ausgeblendet, leere Kapitel entfallen.""" + if not isinstance(content, dict) or ebene >= 4: + return content + kapitel = [] + for ch in content.get("chapters", []): + secs = [s for s in (_section_fuer_ebene(x, ebene) for x in ch.get("sections", [])) if not s.get("leer")] + if secs: + kapitel.append({**ch, "sections": secs}) + return {**content, "chapters": kapitel} + + async def reconcile_guides() -> None: """DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error. @@ -894,11 +1012,21 @@ async def block_uebernehmen(topic: str, format_name: str, baustein: str, stelle: sec = _section_finden(content, baustein) if content else None if sec is None: return None - feld = "kompakt" if str(stelle).startswith("kompakt") else "md" + ist_kompakt = str(stelle).startswith("kompakt") + feld = "kompakt" if ist_kompakt else "md" aktuell = sec.get(feld, "") or "" gefunden = alt in aktuell if gefunden: sec[feld] = aktuell.replace(alt, neu, 1) + # Auch in Anker + Subs ersetzen (Quellen der gefilterten E/M/S-Ansicht), sonst zeigt + # die gestufte Ansicht weiter den alten Block. + anker_feld = "anker_kompakt" if ist_kompakt else "anker" + if alt in (sec.get(anker_feld) or ""): + sec[anker_feld] = sec[anker_feld].replace(alt, neu, 1) + for sub in sec.get("subs", []): + if alt in (sub.get(feld, "") or ""): + sub[feld] = sub[feld].replace(alt, neu, 1) + break js = json.dumps(content, ensure_ascii=False) await set_guide_content(topic, format_name, js) atomic_write_json(guide_content_path(topic, format_name), content, indent=1) diff --git a/backend/lernen.py b/backend/lernen.py index bac7a8b..509e444 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -13,7 +13,7 @@ from datetime import datetime, timezone from agents import run_agent from config import DEFAULT_PROVIDER -from database import create_element, list_elements +from database import create_element, list_elements, get_baustein_huerden from elements import generate_element from jsonio import parse_json_text as _parse_json_text from pipeline import _prompt, _probleme_schema @@ -27,34 +27,68 @@ log = logging.getLogger("creator.lernen") STUFEN = (("anfaenger", 0.2), ("fortgeschritten", 0.4), ("experte", 0.6), ("meister", 1.0)) -def cap_fuer(n_subs: int) -> int: - """Maximal-Score eines Bausteins: 4 Punkte je relevantem Subbaustein, gedeckelt [10, 50].""" - return max(10, min(50, 4 * n_subs)) +PUNKT_BASIS = 25 # Punkte je Subbaustein. Meister-cap = (alle Subs) × 25. + + +def _ebenen(n_je_ebene: dict[int, int]) -> list[int]: + return [n_je_ebene.get(k, 0) for k in (1, 2, 3, 4)] + + +def schwellen(n_je_ebene: dict[int, int]) -> list[int]: + """Kumulative Ebenen-Schwellen [S_1, S_2, S_3, S_4] = (n_1+…+n_k) × 25. + S_k ist der Score, ab dem Ebene k+1 (E→M→S→F) freigeschaltet ist; S_4 = cap_final.""" + out, akk = [], 0 + for n in _ebenen(n_je_ebene): + akk += n + out.append(akk * PUNKT_BASIS) + return out + + +def cap_final(n_je_ebene: dict[int, int]) -> int: + """Maximal-Score (Meister) = alle Subbausteine × 25.""" + return schwellen(n_je_ebene)[-1] + + +def freie_ebene(score: int, n_je_ebene: dict[int, int]) -> int: + """Höchste freigeschaltete Sub-Ebene 1–4. Ebene k+1 frei, sobald score ≥ S_k. + Leere Ebenen (n_k=0) werden automatisch übersprungen (S_k == S_{k-1}).""" + s = schwellen(n_je_ebene) + e = 1 + for k in range(3): # S_1..S_3 schalten Ebene 2..4 frei + if score >= s[k]: + e = k + 2 + return e + + +def cap_aktuell(score: int, n_je_ebene: dict[int, int]) -> int: + """Erreichbarer cap der aktuell freigeschalteten Ebene = freigeschaltete Subs × 25.""" + return schwellen(n_je_ebene)[freie_ebene(score, n_je_ebene) - 1] 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 %).""" +def stufe_aus_score(score: int, cap_final_wert: int) -> str | None: + """Höchste erreichte Lernstufe (None unter 20 %), relativ zum cap_final.""" erreicht = None for key, p in STUFEN: - if score >= _schwelle(p, cap): + if score >= _schwelle(p, cap_final_wert): erreicht = key return erreicht -def progressiver_malus(basis: int, cap: int) -> int: - """Fehler-Strafe nach Fortschritt: 0–20 %→0 · 21–40 %→−1 · 41–60 %→−2 · 61–100 %→−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 +def progressiver_malus(basis: int, cap_akt: int) -> int: + """Fehler-Strafe nach Fortschritt in der aktuellen Ebene (gegen cap_aktuell): + ≤25 %→−5 · ≤50 %→−10 · ≤75 %→−15 · >75 %→−20.""" + pct = (basis / cap_akt) if cap_akt else 0.0 + if pct <= 0.25: + return -5 + if pct <= 0.5: + return -10 + if pct <= 0.75: + return -15 + return -20 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 @@ -70,34 +104,66 @@ FRAGETYPEN = { } -# Antwort-Niveau (wie sehr der Kern getroffen ist) → Punkt-Delta. +# Antwort-Niveau → Basis-Punkte (neue 25er-Skala). „kaum" = −1 ist nur das Signal für den +# progressiven Malus (echter Wert kommt aus progressiver_malus). Positive Werte werden bei +# Streak hochmoduliert und auf [10, 40] geklemmt. NIVEAUS = { - "unbeantwortbar": 0, # Frage selbst kaputt (Randthema/Verweis auf Nichtgezeigtes) → kein Malus - "kaum": -1, # < 25 % richtig - "teilweise": 0, # 25–49 % - "solide": 1, # 50–74 % - "stark": 2, # 75–99 % - "komplett": 3, # 100 % + "unbeantwortbar": 0, # Frage selbst kaputt → keine Änderung + "kaum": -1, # < 25 % richtig → Malus + "teilweise": 0, # 25–49 % → neutral + "solide": 16, # 50–74 % + "stark": 24, # 75–99 % (Quiz/Lücke-Treffer) + "komplett": 30, # 100 % (nur freies Erklären erreichbar) } +# Reihenfolge schwach→stark (für den Nachfrage-Deckel). +_NIVEAU_RANG = ("kaum", "teilweise", "solide", "stark", "komplett") -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 = progressiver_malus). Auf - [floor, cap] geklemmt. floor = höchste erreichte Stufenschwelle (kein Rückfall); - cap = 4× relevante Subbausteine [10, 50]. Am Deckel (basis ≥ cap) eingefroren („Max").""" - if basis >= cap: + +def deckel_nachfrage(niveau: str, nachgefragt: bool) -> str: + """Mit Nachfrage (Hinweis erhalten) höchstens „solide" — kein Voll-Score erschummeln.""" + if nachgefragt and niveau in ("stark", "komplett"): + return "solide" + return niveau + + +def streak_punkte(basis_delta: int, streak_basis: int) -> int: + """Positives Basis-Delta mit Streak hochmodulieren, auf [10, 40] geklemmt.""" + faktor = min(1.33, 1 + 0.066 * min(streak_basis, 5)) + return max(10, min(40, round(basis_delta * faktor))) + + +def punkte_delta(niveau: str, streak_basis: int, basis: int, cap_akt: int) -> tuple[int, int]: + """Antwort-Niveau → (Punkt-Delta, neue Streak). Positiv: streak-moduliert, Streak +1. + Neutral (0): keine Änderung, Streak bleibt. Negativ: progressiver Malus, Streak-Reset 0.""" + basis_delta = NIVEAUS.get(niveau, 0) + if basis_delta > 0: + return streak_punkte(basis_delta, streak_basis), streak_basis + 1 + if basis_delta == 0: + return 0, streak_basis + return progressiver_malus(basis, cap_akt), 0 + + +def score_berechnen(basis: int, delta: int, floor: int, cap_akt: int, cap_fin: int) -> int: + """Neuer Score · driftfrei aus der Basis. Klemmt nach oben gegen `cap_akt` (Deckel der + aktuell freigeschalteten Ebene) und nach unten gegen `floor`. Eingefroren NUR am + absoluten Maximum (`basis ≥ cap_fin`) — sonst würde an jeder Ebenen-Schwelle blockiert.""" + if basis >= cap_fin: return basis - return max(floor, min(cap, basis + delta)) + return max(floor, min(cap_akt, basis + delta)) -def floor_aus_score(basis: int, cap: int) -> int: - """Untergrenze: höchste erreichte Stufenschwelle — kein Rückfall unter die Stufe.""" +def floor_aus_score(basis: int, cap_fin: int, s_schwellen: list[int]) -> int: + """Untergrenze (kein Rückfall): höchste erreichte Lernstufen-Schwelle (über cap_final) + UND höchste erreichte Ebenen-Freischalt-Schwelle S_k. max beider Achsen.""" floor = 0 for _, p in STUFEN: - s = _schwelle(p, cap) + s = _schwelle(p, cap_fin) if basis >= s: - floor = s + floor = max(floor, s) + for s in s_schwellen: + if basis >= s: + floor = max(floor, s) return floor @@ -199,7 +265,8 @@ def _doppelfrage_mangel(frage: str) -> str | None: async def _frage_mit_kritik( topic: str, baustein: str, section_block: str, kompakt_block: str, - transcript: str, vermeide_block: str, typ_block: str, fokus_block: str, provider: str, + transcript: str, vermeide_block: str, typ_block: str, fokus_block: str, + niveau_block: str, provider: str, ) -> str | None: """Frage generieren, vom Kritiker prüfen lassen, bei Mängeln neu (max KRITIK_MAX_RUNDEN).""" kritik_block = "(keine)" @@ -209,7 +276,7 @@ async def _frage_mit_kritik( "Baustein-Frage", "guide", _frage_schema, provider, lane="batch", topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block, - typ_block=typ_block, fokus_block=fokus_block, kritik_block=kritik_block, + typ_block=typ_block, fokus_block=fokus_block, niveau_block=niveau_block, kritik_block=kritik_block, ) if data is None: return None @@ -277,13 +344,27 @@ def _vermeide_block(vermeide: list[str] | None) -> str: return "\n".join(f"- {f}" for f in eintraege) or "(keine)" +# Lerner-Niveau (aus dem Score abgeleitet) → Adressaten-Rolle für die Frage. So entsteht die +# Schwierigkeit: nicht „extra schwer machen", sondern „für einen Anfänger/Experten fragen". +NIVEAU_ROLLE = { + "anfaenger": "Der Lerner ist ANFÄNGER. Frage nach dem Grundverständnis — das Kernkonzept, einfach und direkt.", + "fortgeschritten": "Der Lerner ist FORTGESCHRITTEN. Frage etwas tiefer — Zusammenhänge und die typische Anwendung.", + "experte": "Der Lerner ist EXPERTE. Frage anspruchsvoll — Sonderfälle, Abgrenzungen, typische Tücken.", + "meister": "Der Lerner ist auf MEISTER-Niveau. Frage sehr anspruchsvoll — Edge-Cases, präzise Begründung, Transfer auf Neues.", +} + + +def _niveau_block(niveau: str | None) -> str: + return NIVEAU_ROLLE.get(niveau or "", NIVEAU_ROLLE["anfaenger"]) + + async def pruefung_frage( topic: str, baustein: str, section: str, kompakt: str | None, messages: list[dict], subbausteine: list[str] | None = None, - vermeide: list[str] | None = None, provider: str = DEFAULT_PROVIDER, + vermeide: list[str] | None = None, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER, ) -> str | None: """Aktion 'frage': eine Frage generieren — zufälliger Typ zu einem zufälligen Subbaustein, - dann Kritiker (Generator → Kritiker sequenziell) · None bei Fehler.""" + in der Adressaten-Rolle des Niveaus, dann Kritiker (sequenziell) · None bei Fehler.""" try: section_block, kompakt_block = _bloecke(section, kompakt) transcript = _transcript(messages) if messages else "(leer)" @@ -296,7 +377,7 @@ async def pruefung_frage( ) return await _frage_mit_kritik( topic, baustein, section_block, kompakt_block, transcript, - _vermeide_block(vermeide), typ_block, fokus_block, provider, + _vermeide_block(vermeide), typ_block, fokus_block, _niveau_block(niveau), provider, ) except Exception: log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) @@ -305,17 +386,17 @@ async def pruefung_frage( async def pruefung_frage_variante( topic: str, baustein: str, section: str, kompakt: str | None, - muster: str, provider: str = DEFAULT_PROVIDER, + muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER, ) -> str | None: - """Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete, im - Wortlaut leicht andere Frage formulieren. Kein Kritiker (Muster ist build-geprüft). + """Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete Frage in der + Adressaten-Rolle des Niveaus formulieren. Kein Kritiker (Muster ist build-geprüft). Stil-Guard bleibt als billiger Schutz gegen Doppelfragen · None bei Fehler.""" try: section_block, kompakt_block = _bloecke(section, kompakt) data = await _gen_call( "Baustein-Frage-Variante", "guide", _frage_schema, provider, lane="batch", topic=topic, baustein=baustein, section_block=section_block, - kompakt_block=kompakt_block, muster=muster, + kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau), ) if data is None: return None @@ -341,17 +422,16 @@ def _optionen_schema(opts) -> list[dict] | None: return out -def _quiz_schema(data, schwer: bool = False) -> dict | None: +def _quiz_schema(data) -> dict | None: """{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None. - leicht: genau 1 richtig (Single) · schwer: mindestens 2 richtig (mehrdeutig).""" + Single-Choice: genau 1 richtig. Die Schwierigkeit steckt im Niveau, nicht in der Anzahl.""" if not isinstance(data, dict): return None frage = str(data.get("frage", "")).strip() out = _optionen_schema(data.get("optionen")) if not frage or out is None: return None - n_korrekt = sum(o["korrekt"] for o in out) - if (n_korrekt < 2 if schwer else n_korrekt != 1): + if sum(o["korrekt"] for o in out) != 1: return None return {"frage": frage, "optionen": out} @@ -367,24 +447,35 @@ def _lueckwahl_schema(data) -> dict | None: return {"satz": satz, "optionen": out} +async def huerden_distraktor_block(topic: str, baustein: str) -> str: + """Typische Irrtümer (Fakten-Hürden) des Bausteins als Distraktor-Quelle für Quiz/Lückenwahl. + Leer, wenn keine vorhanden (Altbestand) → der Prompt-Platzhalter verschwindet rückstandslos.""" + try: + huerden = await get_baustein_huerden(topic, _norm_titel(baustein)) + except Exception: + return "" + if not huerden: + return "" + zeilen = "\n".join(f"- {h}" for h in huerden[:8]) + return ("TYPISCHE IRRTÜMER zu diesem Baustein (nutze sie als Distraktoren, wenn sie zur Frage passen):\n" + + zeilen + "\n") + + async def quiz_generieren( topic: str, baustein: str, section: str, kompakt: str | None, - muster: str, schwer: bool = False, provider: str = DEFAULT_PROVIDER, + muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER, + distraktor_block: str = "", ) -> dict | None: - """Aus einem Muster eine Multiple-Choice-Frage. schwer: 0–4 richtig (Multi). - leicht: genau 1 richtig (Single). Starkes Modell (role guide) für korrekte Flags. - → {frage, optionen:[{text,korrekt}]} · None bei Fehler.""" + """Aus einem Muster eine Single-Choice-Frage (genau 1 richtig), im Anspruch des Niveaus. + Starkes Modell (role guide) für korrekte Flags. → {frage, optionen} · None bei Fehler. + distraktor_block: optionale typische Irrtümer (aus den Fakten-Hürden) als Distraktor-Quelle.""" try: section_block, kompakt_block = _bloecke(section, kompakt) - anzahl_block = ( - "MINDESTENS ZWEI Optionen sind richtig (gern auch drei) — NIEMALS nur eine. Mische bewusst, welche." - if schwer else - "GENAU EINE Option ist richtig, die anderen drei sind klar falsch." - ) return await _gen_call( - "Baustein-Quiz", "guide", (lambda d: _quiz_schema(d, schwer=schwer)), provider, lane="batch", + "Baustein-Quiz", "guide", _quiz_schema, provider, lane="batch", topic=topic, baustein=baustein, section_block=section_block, - kompakt_block=kompakt_block, muster=muster, anzahl_block=anzahl_block, + kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau), + distraktor_block=distraktor_block, ) except Exception: log.warning("[%s] Quiz-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) @@ -393,16 +484,19 @@ async def quiz_generieren( async def lueckwahl_generieren( topic: str, baustein: str, section: str, kompakt: str | None, - muster: str, provider: str = DEFAULT_PROVIDER, + muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER, + distraktor_block: str = "", ) -> dict | None: - """Lückentext mit Auswahl (leichte Variante): Satz mit ___ + 4 Begriffe, genau 1 richtig. - → {satz, optionen:[{text,korrekt}]} · None bei Fehler.""" + """Lückentext mit Auswahl: Satz mit ___ + 4 Begriffe, genau 1 richtig — im Anspruch des Niveaus. + → {satz, optionen:[{text,korrekt}]} · None bei Fehler. + distraktor_block: optionale typische Irrtümer (aus den Fakten-Hürden) als Distraktor-Quelle.""" try: section_block, kompakt_block = _bloecke(section, kompakt) return await _gen_call( "Baustein-Lueckwahl", "guide", _lueckwahl_schema, provider, lane="batch", topic=topic, baustein=baustein, section_block=section_block, - kompakt_block=kompakt_block, muster=muster, + kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau), + distraktor_block=distraktor_block, ) except Exception: log.warning("[%s] Lückentext-Auswahl fehlgeschlagen (%s)", topic, baustein, exc_info=True) @@ -424,16 +518,16 @@ def _lueck_schema(data) -> dict | None: async def lueckentext_generieren( topic: str, baustein: str, section: str, kompakt: str | None, - muster: str, provider: str = DEFAULT_PROVIDER, + muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER, ) -> dict | None: - """Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme). - → {satz, loesung, alternativen} · None bei Fehler.""" + """Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme), im Anspruch + des Niveaus. → {satz, loesung, alternativen} · None bei Fehler.""" try: section_block, kompakt_block = _bloecke(section, kompakt) return await _gen_call( "Baustein-Lueckentext", "guide", _lueck_schema, provider, lane="batch", topic=topic, baustein=baustein, section_block=section_block, - kompakt_block=kompakt_block, muster=muster, + kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau), ) except Exception: log.warning("[%s] Lückentext-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) diff --git a/backend/models.py b/backend/models.py index 1463d8c..36c559e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -71,7 +71,7 @@ class BausteineQuelleResponse(BaseModel): class SubbausteinInfo(BaseModel): titel: str - stufe: Literal["einfach", "mittel", "schwer"] + stufe: Literal["anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer"] relevanz: Literal["relevant", "rand"] | None = None @@ -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 + cap werden serverseitig geführt (Anker bzw. 4×Subs) — Client-cap nur Hinweis. - cap: int = Field(default=10, ge=1, le=50) # Score-Deckel = clamp(4×relevante Subbausteine, 10, 50) + # Basis + cap werden serverseitig geführt (Anker bzw. Subs×25) — Client-cap nur Hinweis. + cap: int = Field(default=10, ge=1, le=10000) # Score-Deckel = freigeschaltete Subbausteine × 25 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,13 +253,16 @@ class BausteinPruefungResponse(BaseModel): loesung: str | None = None # Lückentext: erwarteter Begriff alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme gute_antworten: int - cap: int = 10 # Maximal-Score dieses Bausteins (4×Subs, geklemmt) — Frontend leitet die Stufe ab + streak: int = 0 # aktuelle Serie korrekter Antworten (je Baustein) + cap: int = 10 # cap_final = alle Subs × 25 — Frontend leitet die Lernstufe ab class BausteinLernstand(BaseModel): gute_antworten: int streak: int = 0 - cap: int = 10 # 4×relevante Subbausteine, geklemmt [10, 50] + cap: int = 0 # cap_final = alle Subbausteine × 25 + cap_aktuell: int = 0 # erreichbarer cap der aktuell freigeschalteten Ebene + freie_ebene: int = 1 # 1=A · 2=F · 3=E · 4=V class BausteinLernstandResponse(BaseModel): diff --git a/backend/pipeline.py b/backend/pipeline.py index c8c8adc..f2d1366 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -133,10 +133,10 @@ def _runde_schema(data, final: bool = False): def _stufen_schema(data, ids: set[int] | None = None): - """{"stufen": {"1": "einfach", …}} → {id: stufe} · sonst None. + """{"stufen": {"1": "anfaenger", …}} → {id: stufe} · sonst None. - Stufe ∈ {einfach, mittel, schwer}. Sind `ids` gegeben, müssen mindestens - diese abgedeckt sein (Extras erlaubt); der Aufrufer filtert dann auf `ids`. + Stufe ∈ {anfaenger, fortgeschritten, experte} (alte Werte abwärtskompatibel). Sind `ids` + gegeben, müssen mindestens diese abgedeckt sein (Extras erlaubt); der Aufrufer filtert auf `ids`. """ if not isinstance(data, dict) or not isinstance(data.get("stufen"), dict) or not data["stufen"]: return None diff --git a/backend/regeln.py b/backend/regeln.py index 7fcb0b6..91c2e55 100644 --- a/backend/regeln.py +++ b/backend/regeln.py @@ -11,9 +11,9 @@ Query-Schleifen mehr pro Guide. import json -from database import list_baustein_scores_all, subs_count_alle, list_guides, list_progress_all +from database import list_baustein_scores_all, subs_je_ebene_alle, list_guides, list_progress_all from guide import guide_slot_dateien -from lernen import cap_fuer, STUFEN, _schwelle +from lernen import cap_final, STUFEN, _schwelle from paths import bausteine_path, guide_content_path from textkit import _norm_titel @@ -40,12 +40,12 @@ async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, d Stufe je Baustein wird aus Score + cap (4×relevante Subs) abgeleitet. """ scores = await list_baustein_scores_all() - caps = await subs_count_alle() + ebenen = await subs_je_ebene_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)) + cf = cap_final(ebenen.get((topic, _norm_titel(baustein)), {})) for key, p in STUFEN: - if score >= _schwelle(p, cap): + if cf and score >= _schwelle(p, cf): levels[key].setdefault(topic, set()).add(_norm_titel(baustein)) return await list_guides(), await list_progress_all(), levels diff --git a/backend/routes.py b/backend/routes.py index 6235b7c..c572505 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -1,4 +1,5 @@ import asyncio +import json import shutil import uuid from datetime import datetime, timezone @@ -14,13 +15,15 @@ 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, delete_baustein_daten, delete_baustein_progress, count_relevant_subs, subs_count_roh, + set_baustein_score, set_baustein_score_and_streak, set_baustein_absolviert, + delete_baustein_daten, delete_baustein_progress, subs_je_ebene, subs_je_ebene_roh, delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content, + get_sub_artefakte, ) -from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster +from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, subbausteine_frei, lade_frage_muster, lade_frage_muster_frei from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion -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, block_pruefen, block_uebernehmen +from lernen import 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, huerden_distraktor_block, score_berechnen, floor_aus_score, stufe_aus_score, cap_final, cap_aktuell, freie_ebene, schwellen, punkte_delta, deckel_nachfrage +from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen, content_fuer_ebene from pipeline import cancel_guide from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen from models import ( @@ -221,30 +224,59 @@ async def get_bausteine_uebersicht(topic: str): @router.get("/bausteine/frage-muster") async def get_frage_muster(topic: str, baustein: str): - """Vordefinierte Frage-Muster eines Bausteins (leer = Fallback auf Live-Generierung).""" - return {"muster": await lade_frage_muster(topic, baustein)} + """Freigeschaltete Frage-Muster eines Bausteins (bis zur aktuellen Ebene; leer = Live).""" + stand = await get_baustein_progress(topic, baustein) + fe = freie_ebene(stand["gute_antworten"], await subs_je_ebene(topic, baustein)) + return {"muster": await lade_frage_muster_frei(topic, baustein, fe)} + + +@router.get("/bausteine/artefakte") +async def get_artefakte(topic: str, typ: str | None = None): + """Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm. + Diagramm liegt auf Baustein-Ebene (sub_norm=''), Karteikarte/Beispiel je Subbaustein.""" + rows = await get_sub_artefakte(topic, typ) + out: dict[str, dict] = {} + for r in rows: + b = out.setdefault(r["baustein_norm"], {"baustein": r["baustein"], "karteikarte": [], "beispiel": [], "diagramm": None}) + if r["baustein"] and not b["baustein"]: + b["baustein"] = r["baustein"] + try: + daten = json.loads(r["daten"]) + except (ValueError, TypeError): + continue + if r["typ"] == "diagramm": + b["diagramm"] = daten + elif r["typ"] in ("karteikarte", "beispiel"): + b[r["typ"]].append({"subbaustein": r["sub_titel"], **daten}) + return {"artefakte": out} # --- Baustein-Lernen: Chat, Prüfung --- @router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse) async def baustein_lernstand(topic: str): - """Prüfungs-Stand pro Baustein (roher Titel als Key). cap = 4×relevante Subs [10,50] - — für ALLE Bausteine (auch noch nicht geprüfte), damit der cap sofort stimmt.""" + """Prüfungs-Stand pro Baustein (roher Titel als Key). cap_final = alle Subs × 25; + cap_aktuell + freie_ebene aus dem Score — für ALLE Bausteine (auch ungeprüfte).""" progress = {p["baustein"]: p for p in await list_baustein_progress(topic)} - caps = await subs_count_roh(topic) - bausteine = { - b: { - "gute_antworten": progress[b]["gute_antworten"] if b in progress else 0, - "streak": progress[b]["streak"] if b in progress else 0, - "cap": cap_fuer(n), + ebenen = await subs_je_ebene_roh(topic) + + def _stand(score: int, streak: int, n_je_ebene: dict[int, int]) -> dict: + return { + "gute_antworten": score, "streak": streak, + "cap": cap_final(n_je_ebene), + "cap_aktuell": cap_aktuell(score, n_je_ebene), + "freie_ebene": freie_ebene(score, n_je_ebene), } - for b, n in caps.items() + + bausteine = { + b: _stand(progress[b]["gute_antworten"] if b in progress else 0, + progress[b]["streak"] if b in progress else 0, n) + for b, n in ebenen.items() } - # Altbestand-Bausteine mit Prüfung, aber ohne (relevante) Subs → Default-cap. + # Altbestand-Bausteine mit Prüfung, aber ohne Subs → leere Ebenen (cap 0). for b, p in progress.items(): if b not in bausteine: - bausteine[b] = {"gute_antworten": p["gute_antworten"], "streak": p["streak"], "cap": 10} + bausteine[b] = _stand(p["gute_antworten"], p["streak"], {}) return {"bausteine": bausteine} @@ -282,54 +314,52 @@ def _farbe(punkte: int) -> str: return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht") -def _erklaeren_delta(niveau: str, nachgefragt: bool) -> int: - """Erklären-Form: Punkt-Delta aus dem Niveau (−1…+3). Mit Nachfrage höchstens +1.""" - delta = NIVEAUS.get(niveau, 0) - return min(delta, 1) if nachgefragt else delta - - -async def _cap_fuer(topic: str, baustein: str) -> int: - """Maximal-Score des Bausteins = clamp(4×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 def _buche(req, frage: str, niveau: str, n_je_ebene: dict[int, int]) -> dict: + """Score+Streak driftfrei buchen (Lock + offene_frage/offene_streak-Anker). Niveau → + Punkt-Delta (streak-moduliert) bzw. progressiver Malus bei Fehler. cap_aktuell wird aus + der Basis abgeleitet (verzögerte Freischaltung an der Ebenen-Schwelle); Element einmalig + ab Anfänger-Stufe. Re-Bewertung derselben Frage nutzt den offenen Streak-Anker → idempotent.""" async with _pruef_lock(req.topic, req.baustein): stand = await get_baustein_progress(req.topic, req.baustein) war_stufe = stand["absolviert"] is not None # Element-Guard: schon je angelegt? basis, re_bewertung = _basis(stand, frage) + streak_basis = stand["offene_streak"] if re_bewertung else stand["streak"] if not re_bewertung: - await set_offene_frage(req.topic, req.baustein, frage, basis, 0) - floor = floor_aus_score(basis, cap) - d = progressiver_malus(basis, cap) if delta < 0 else delta - score = score_berechnen(basis, d, floor, cap) + await set_offene_frage(req.topic, req.baustein, frage, basis, stand["streak"]) + s = schwellen(n_je_ebene) + cf = s[-1] + ca = cap_aktuell(basis, n_je_ebene) + floor = floor_aus_score(basis, cf, s) + d, neue_streak = punkte_delta(niveau, streak_basis, basis, ca) + score = score_berechnen(basis, d, floor, ca, cf) punkte = score - basis - gute = await set_baustein_score(req.topic, req.baustein, score) + gute, streak = await set_baustein_score_and_streak(req.topic, req.baustein, score, neue_streak) # 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 not war_stufe and stufe_aus_score(score, cf) 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)) - return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "cap": cap} + return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "streak": streak, "cap": cf} @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"] - cap = await _cap_fuer(req.topic, req.baustein) + n_je_ebene = await subs_je_ebene(req.topic, req.baustein) + cap = cap_final(n_je_ebene) + niveau = stufe_aus_score(gute, cap) or "anfaenger" # Adressaten-Rolle der Frage + fe = freie_ebene(gute, n_je_ebene) # nur freigeschaltete Subs prüfen kompakt = req.section_kompakt msgs = [m.model_dump() for m in req.messages] if req.aktion == "frage": if req.muster.strip(): - # Aus gezogenem Muster eine konkrete, leicht andere Frage formulieren (kein Dedup nötig). - frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider) + # Aus gezogenem Muster eine konkrete Frage im Niveau formulieren (kein Dedup nötig). + frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider) else: - # Fallback (kein Muster-Sidecar): Live-Generierung mit zufälligem Typ/Subbaustein. - subs = await subbausteine_titel(req.topic, req.baustein) # Fokus-Kandidaten (zufällig gewählt) - frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, provider=req.provider) + # Fallback (kein Muster-Sidecar): Live-Generierung, Fokus nur auf freigeschaltete Subs. + subs = await subbausteine_frei(req.topic, req.baustein, fe) + frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, niveau=niveau, provider=req.provider) if frage is None: raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen") return {"frage": frage, "gute_antworten": gute, "cap": cap} @@ -349,7 +379,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if req.aktion == "quiz_frage": if not req.muster.strip(): raise HTTPException(400, "Quiz braucht ein Muster") - quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, schwer=req.schwer, provider=req.provider) + distraktoren = await huerden_distraktor_block(req.topic, req.baustein) + quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren) if quiz is None: raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen") return {"frage": quiz["frage"], "optionen": quiz["optionen"], @@ -359,8 +390,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): 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 _buche(req, req.frage, delta, cap) + res = await _buche(req, req.frage, "stark" if getroffen else "kaum", n_je_ebene) res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig." return res @@ -369,12 +399,13 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if not req.muster.strip(): raise HTTPException(400, "Lückentext braucht ein Muster") if req.schwer: - lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider) + lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider) 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, "cap": cap} - lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider) + distraktoren = await huerden_distraktor_block(req.topic, req.baustein) + lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren) if lw is None: raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen") return {"satz": lw["satz"], "optionen": lw["optionen"], @@ -389,7 +420,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 _buche(req, req.frage, (3 if req.schwer else 1) if ok else -1, cap) + res = await _buche(req, req.frage, "stark" if ok else "kaum", n_je_ebene) res["feedback"] = feedback return res @@ -406,11 +437,14 @@ 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_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) + basis, re_bew = _basis(stand, req.frage) + streak_basis = stand["offene_streak"] if re_bew else stand["streak"] + s = schwellen(n_je_ebene) + ca = cap_aktuell(basis, n_je_ebene) + floor = floor_aus_score(basis, s[-1], s) + niveau = deckel_nachfrage(data["niveau"], req.nachgefragt) + d, _ = punkte_delta(niveau, streak_basis, basis, ca) + score = score_berechnen(basis, d, floor, ca, s[-1]) punkte = score - basis return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "cap": cap} @@ -424,8 +458,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): ) if data is None: raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") - roh = _erklaeren_delta(data["niveau"], req.nachgefragt) - res = await _buche(req, req.frage, roh, cap) # kurzer Lock: Basis driftfrei über offene_frage-Anker + niveau = deckel_nachfrage(data["niveau"], req.nachgefragt) + res = await _buche(req, req.frage, niveau, n_je_ebene) # kurzer Lock: Basis driftfrei über Anker res["feedback"] = data["feedback"] return res @@ -483,19 +517,26 @@ async def get_one(guide_id: str): @router.get("/guides/{guide_id}/content") -async def guide_content(guide_id: str): +async def guide_content(guide_id: str, ebene: int = 4): + """Guide-Inhalt. `ebene` (1=A · 2=F · 3=E · 4=V) filtert auf Subbausteine bis zu dieser + Ebene; 4 = Vollfassung (roh, unverändert).""" guide = await get_guide(guide_id) if guide is None: raise HTTPException(404, "Guide nicht gefunden") if guide["status"] != "done": raise HTTPException(404, "Inhalt nicht verfügbar") stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first - if stored is not None: + if stored is None: + path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen) + if not path.exists(): + raise HTTPException(404, "Datei nicht gefunden") + stored = path.read_text(encoding="utf-8") + if ebene >= 4: + return Response(content=stored, media_type="application/json") # Vollfassung roh + try: + return content_fuer_ebene(json.loads(stored), ebene) + except ValueError: return Response(content=stored, media_type="application/json") - path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen) - if not path.exists(): - raise HTTPException(404, "Datei nicht gefunden") - return FileResponse(path, media_type="application/json") @router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse) diff --git a/backend/textkit.py b/backend/textkit.py index 6ed9d13..9a2bee7 100644 --- a/backend/textkit.py +++ b/backend/textkit.py @@ -102,16 +102,18 @@ _FRAGMENT_BAUSTEIN_RE = re.compile(r"", re.IGNO # Zwei Lese-Schichten je Section: kompakt (Merksätze) + ausführlich (Erklärung). _FRAGMENT_KOMPAKT_RE = re.compile(r"", re.IGNORECASE) _FRAGMENT_AUSF_RE = re.compile(r"", re.IGNORECASE) -_STUFEN = ("einfach", "mittel", "schwer") +# Lernpfad-Stufen + Rand; alte Schwierigkeits-Werte abwärtskompatibel akzeptiert. +_STUFEN = ("anfaenger", "fortgeschritten", "experte", "rand", "einfach", "mittel", "schwer") def _parse_fragment(text: str) -> list[dict]: - """Parst eine Writer-Datei → [{"kapitel", "titel", "md", "kompakt", "subs"}] in Reihenfolge. + """Parst eine Writer-Datei → [{kapitel, titel, md, kompakt, anker, anker_kompakt, subs}]. - Zwei optionale Lese-Schichten je Section über `` / ``: - Zeilen nach `kompakt` landen in `sec["kompakt"]`, sonst in `sec["md"]` (= ausführlich, - auch der Default ohne Marker → rückwärtskompatibel). Optionale ``-Marker - sammeln sich weiter in `sec["subs"]`. + Zwei Lese-Schichten je Section über `` / ``. Innerhalb + beider markieren ``-Marker je Subbaustein einen Block; gleicher + Sub-Titel in beiden Schichten wird gemergt → `sec["subs"] = [{stufe, titel, md, kompakt}]`. + Text VOR dem ersten Sub-Marker ist der Anker (Einordnung) → `anker`/`anker_kompakt`. + `md`/`kompakt` bleiben die VOLLE Fassung (Anker + alle Subs) — rückwärtskompatibel. """ sections: list[dict] = [] kapitel = None @@ -128,7 +130,8 @@ def _parse_fragment(text: str) -> list[dict]: continue m = _FRAGMENT_SECTION_RE.match(s) if m: - current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [], "subs": []} + current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [], + "anker_md": [], "anker_kompakt": [], "_submap": {}, "_suborder": []} cur_sub = None cur_layer = "md" sections.append(current) @@ -145,24 +148,40 @@ def _parse_fragment(text: str) -> list[dict]: if m and current is not None: teil = m.group(1).split("|", 1) stufe = teil[0].strip().casefold() - cur_sub = { - "stufe": stufe if stufe in _STUFEN else "einfach", - "titel": teil[1].strip() if len(teil) == 2 else "", - "md": [], - } - current["subs"].append(cur_sub) + titel = teil[1].strip() if len(teil) == 2 else "" + key = titel.casefold() or f"_pos{len(current['_suborder'])}" + cur_sub = current["_submap"].get(key) + if cur_sub is None: + cur_sub = {"stufe": stufe if stufe in _STUFEN else "anfaenger", "titel": titel, "md": [], "kompakt": []} + current["_submap"][key] = cur_sub + current["_suborder"].append(key) + elif stufe in _STUFEN: + cur_sub["stufe"] = stufe continue if current is not None: current[cur_layer].append(line) - if cur_sub is not None and cur_layer == "md": - cur_sub["md"].append(line) + if cur_sub is not None: + cur_sub[cur_layer].append(line) + else: + current["anker_" + cur_layer].append(line) + out: list[dict] = [] for sec in sections: - sec["md"] = "\n".join(sec["md"]).strip() - sec["kompakt"] = "\n".join(sec["kompakt"]).strip() - for sub in sec["subs"]: + subs = [] + for key in sec["_suborder"]: + sub = sec["_submap"][key] sub["md"] = "\n".join(sub["md"]).strip() - sec["subs"] = [sub for sub in sec["subs"] if sub["md"]] - return sections + sub["kompakt"] = "\n".join(sub["kompakt"]).strip() + if sub["md"] or sub["kompakt"]: + subs.append(sub) + out.append({ + "kapitel": sec["kapitel"], "titel": sec["titel"], + "md": "\n".join(sec["md"]).strip(), + "kompakt": "\n".join(sec["kompakt"]).strip(), + "anker": "\n".join(sec["anker_md"]).strip(), + "anker_kompakt": "\n".join(sec["anker_kompakt"]).strip(), + "subs": subs, + }) + return out def _parse_subbausteine(text: str) -> dict[str, list[str]]: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d3f8249..b478664 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "katex": "^0.17.0", "marked": "^18.0.4", "marked-highlight": "^2.2.4", + "mermaid": "^11.16.0", "vue": "^3.5.32" }, "devDependencies": { @@ -24,6 +25,19 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -479,6 +493,18 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -513,6 +539,23 @@ "tslib": "^2.4.0" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -562,6 +605,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -873,6 +925,265 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -880,6 +1191,16 @@ "license": "MIT", "optional": true }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", @@ -1204,12 +1525,535 @@ "dev": true, "license": "MIT" }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1271,6 +2115,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1319,6 +2172,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1378,6 +2241,12 @@ "node": ">=6.9.0" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -1394,6 +2263,37 @@ "dev": true, "license": "MIT" }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1494,6 +2394,11 @@ "katex": "cli.js" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", @@ -1501,6 +2406,12 @@ "dev": true, "license": "MIT" }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1762,6 +2673,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1802,6 +2719,63 @@ "marked": ">=4 <19" } }, + "node_modules/mermaid": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -1873,6 +2847,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1906,6 +2892,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1934,6 +2936,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -1968,6 +2976,18 @@ "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -1981,6 +3001,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2015,6 +3047,21 @@ "node": ">=0.10.0" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2042,6 +3089,15 @@ "node": ">=6" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2098,6 +3154,19 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8cadba0..68ad87f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "katex": "^0.17.0", "marked": "^18.0.4", "marked-highlight": "^2.2.4", + "mermaid": "^11.16.0", "vue": "^3.5.32" }, "devDependencies": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 94210f4..1d6027a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,7 @@ import TopicDetail from './components/TopicDetail.vue' import BausteineUebersicht from './components/BausteineUebersicht.vue' import ElementsSidebar from './components/elements/ElementsSidebar.vue' import ElementsOverview from './components/ElementsOverview.vue' +import GeneralExamPanel from './components/GeneralExamPanel.vue' const guides = ref([]) const backendTopics = ref([]) @@ -27,7 +28,9 @@ const provider = ref(localStorage.getItem('provider') || 'claude') const providers = ref([]) const folders = ref({ projekt: [], uni: [] }) // Ordner für die Quellen-Auswahl const bausteineView = ref(false) // Bausteine-Übersicht im Hauptbereich +const allgemeinView = ref(false) // Allgemeine Prüfung (themenweit) im Hauptbereich const ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt +const stufeAnsicht = ref(Number(localStorage.getItem('stufe')) || 4) // 1=A · 2=F · 3=E · 4=V (Stufen-Ansicht) const stats = ref(null) const fortschritt = ref({}) const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle) @@ -192,6 +195,7 @@ function selectTopic(topic) { elementsOpen.value = false elementsView.value = false bausteineView.value = true // Thema-Klick → Baustein-Übersicht (Guide erst auf Pill-Klick) + allgemeinView.value = false elementOpenId.value = null ansichtModus.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'kompakt' localStorage.setItem('lastTopic', topic) @@ -266,10 +270,16 @@ function setAnsicht(modus) { if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus) } +function setStufe(k) { + stufeAnsicht.value = k + localStorage.setItem('stufe', String(k)) +} + function handleOpenBausteineView() { if (!selectedTopic.value) return bausteineView.value = true elementsView.value = false + allgemeinView.value = false previewGuide.value = null } @@ -296,12 +306,22 @@ function handlePreview(guide) { previewGuide.value = guide elementsView.value = false bausteineView.value = false + allgemeinView.value = false +} + +function handleGeneralExam() { + if (!selectedTopic.value) return + allgemeinView.value = true + bausteineView.value = false + elementsView.value = false + previewGuide.value = null } function handleOpenElements() { if (!selectedTopic.value) return elementsView.value = true bausteineView.value = false + allgemeinView.value = false // Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element. } @@ -380,9 +400,12 @@ onMounted(async () => { :providers="providers" :folders="folders" :ansichtModus="ansichtModus" + :stufeAnsicht="stufeAnsicht" @setProvider="setProvider" @toggleDark="toggleDark" @setAnsicht="setAnsicht" + @setStufe="setStufe" + @generalExam="handleGeneralExam" @select="selectTopic" @create="createTopic" @createThema="handleCreateThema" @@ -413,6 +436,13 @@ onMounted(async () => { :version="elementsVersion" @open="handleOpenElementDetail" /> + { :doneByFormat="doneByFormat" :themaAbgeschlossen="!!fortschritt.abgeschlossen" :ansichtModus="ansichtModus" + :stufeAnsicht="stufeAnsicht" @progressChanged="loadStats(); loadBausteine()" @setAnsicht="setAnsicht" @open-sidebar="sidebarSticky = true" diff --git a/frontend/src/api.js b/frontend/src/api.js index 99b83b5..d89c146 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -146,12 +146,19 @@ export async function deleteGuide(id, slots = false) { await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' }) } -export async function fetchGuideContent(id) { - const res = await fetch(`${BASE}/guides/${id}/content`) +export async function fetchGuideContent(id, ebene = 4) { + const res = await fetch(`${BASE}/guides/${id}/content?ebene=${ebene}`) if (!res.ok) throw new Error(`Inhalt nicht verfügbar (${res.status})`) return res.json() } +// Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm. +export async function fetchArtefakte(topic) { + const res = await fetch(`${BASE}/bausteine/artefakte?topic=${encodeURIComponent(topic)}`) + if (!res.ok) return { artefakte: {} } + return res.json() +} + // Einen Markdown-Block on-demand gegen die Guide-Regeln prüfen (Fokus, Rechtsklick). export async function pruefeBlock(id, { baustein, stelle, block, hinweis = '', provider }) { const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, { diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue index f73571a..83b63a6 100644 --- a/frontend/src/components/BausteinPanel.vue +++ b/frontend/src/components/BausteinPanel.vue @@ -25,7 +25,9 @@ const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht']) // 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 = 4×relevante Subbausteine. -const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren'] +// Vier Frageformen, zufällig. Kein leicht/schwer mehr — die Schwierigkeit setzt das Backend +// über das Lerner-Niveau (Adressaten-Rolle). lueckwahl = Begriff-Auswahl · lueckentext = frei. +const MODI = ['quiz', 'lueckwahl', 'lueckentext', 'erklaeren'] const st = computed(() => props.status || { gute_antworten: 0, streak: 0, cap: props.cap }) const score = computed(() => st.value.gute_antworten || 0) const cap = computed(() => st.value.cap || props.cap) @@ -238,20 +240,20 @@ function naechsterModus() { // Ein Frage-Objekt eines bestimmten Modus generieren (aus gezogener Saat; Erklären hat Fallback). function frageEinzeln(modus = naechsterModus()) { - const form = modus.startsWith('quiz') ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren' - const istSchwer = modus.endsWith('_schwer') + const form = modus === 'quiz' ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren' + const istLueckFrei = modus === 'lueckentext' // frei tippen vs. lueckwahl = Begriff aus 4 const muster = musterNehmen() const basis = { topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, provider: props.provider, } if (form === 'quiz' && muster) { - return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster, schwer: istSchwer }) - .then((r) => ({ form, schwer: istSchwer, frage: r.frage, optionen: r.optionen })) + return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster }) // Single-Choice, Niveau steuert + .then((r) => ({ form, schwer: false, frage: r.frage, optionen: r.optionen })) } if (form === 'lueckentext' && muster) { - return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istSchwer }) - .then((r) => istSchwer + return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istLueckFrei }) + .then((r) => istLueckFrei ? ({ form, schwer: true, frage: r.satz, satz: r.satz, loesung: r.loesung, alternativen: r.alternativen }) : ({ form, schwer: false, frage: r.satz, satz: r.satz, optionen: r.optionen })) } diff --git a/frontend/src/components/BausteineUebersicht.vue b/frontend/src/components/BausteineUebersicht.vue index d77e830..5048924 100644 --- a/frontend/src/components/BausteineUebersicht.vue +++ b/frontend/src/components/BausteineUebersicht.vue @@ -11,12 +11,14 @@ const items = ref([]) const loading = ref(true) const error = ref(null) -// Stufen-Reihenfolge + Anzeige-Label (Farbe via CSS-Klasse st-) +// Lernpfad-Stufen: Reihenfolge + Label (Farbe via CSS-Klasse st-). const STUFEN = [ - { key: 'einfach', label: 'Einfach' }, - { key: 'mittel', label: 'Mittel' }, - { key: 'schwer', label: 'Schwer' }, + { key: 'anfaenger', label: 'Anfänger' }, + { key: 'fortgeschritten', label: 'Fortgeschritten' }, + { key: 'experte', label: 'Experte' }, ] +// Alt-Themen tragen noch einfach/mittel/schwer → auf die neuen Keys mappen. +const ALT_STUFE = { einfach: 'anfaenger', mittel: 'fortgeschritten', schwer: 'experte' } watch(() => props.topic, load, { immediate: true }) @@ -43,7 +45,7 @@ function relevant(b) { // Nur nicht-leere Stufen-Gruppen je Baustein (v-if + v-for nicht auf einem Element) function gruppen(b) { return STUFEN - .map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => s.stufe === st.key) })) + .map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => (ALT_STUFE[s.stufe] || s.stufe) === st.key) })) .filter((g) => g.subs.length) } @@ -185,9 +187,9 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine border-left: 3px solid var(--st); padding-left: 0.6rem; } -.bk-stufe.st-einfach { --st: var(--success-border); } -.bk-stufe.st-mittel { --st: var(--warning-border); } -.bk-stufe.st-schwer { --st: var(--danger); } +.bk-stufe.st-anfaenger { --st: var(--stufe-anfaenger); } +.bk-stufe.st-fortgeschritten { --st: var(--stufe-fortgeschritten); } +.bk-stufe.st-experte { --st: var(--stufe-experte); } .bk-stufe-label { font-size: 0.66rem; diff --git a/frontend/src/components/FlashcardWidget.vue b/frontend/src/components/FlashcardWidget.vue new file mode 100644 index 0000000..561b43a --- /dev/null +++ b/frontend/src/components/FlashcardWidget.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/components/GeneralExamPanel.vue b/frontend/src/components/GeneralExamPanel.vue new file mode 100644 index 0000000..0435dc4 --- /dev/null +++ b/frontend/src/components/GeneralExamPanel.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/components/TopicDetail.vue b/frontend/src/components/TopicDetail.vue index 9abd837..99757cb 100644 --- a/frontend/src/components/TopicDetail.vue +++ b/frontend/src/components/TopicDetail.vue @@ -1,11 +1,22 @@ + + + + diff --git a/frontend/src/markdown.js b/frontend/src/markdown.js index 24de19b..6f9d500 100644 --- a/frontend/src/markdown.js +++ b/frontend/src/markdown.js @@ -6,6 +6,17 @@ import katex from 'katex' import 'katex/dist/katex.min.css' import DOMPurify from 'dompurify' +// Mermaid lazy laden (schwere Lib): erst beim ersten Diagramm, hält das Initial-Bundle klein. +// startOnLoad:false → wir rendern gezielt; securityLevel strict: kein HTML/JS in Graphen. +let _mermaid = null +async function getMermaid() { + if (!_mermaid) { + _mermaid = (await import('mermaid')).default + _mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', flowchart: { htmlLabels: false } }) + } + return _mermaid +} + marked.use(markedHighlight({ langPrefix: 'hljs language-', highlight(code, lang) { @@ -49,7 +60,33 @@ const inlineMath = { renderer(token) { return renderTex(token.text, false) }, } -marked.use({ extensions: [blockMath, inlineMath] }) +// ```mermaid →
(Quelltext escaped; mermaid.run liest den textContent). +const mermaidBlock = { + name: 'mermaidBlock', + level: 'block', + start(src) { const i = src.indexOf('```mermaid'); return i < 0 ? undefined : i }, + tokenizer(src) { + const m = /^```mermaid[^\n]*\n([\s\S]+?)\n```/.exec(src) + if (m) return { type: 'mermaidBlock', raw: m[0], text: m[1] } + }, + renderer(token) { + const esc = token.text.replace(/&/g, '&').replace(//g, '>') + return `
${esc}
` + }, +} + +marked.use({ extensions: [blockMath, inlineMath, mermaidBlock] }) + +// Mermaid-Knoten in einem Container rendern (nach v-html). Idempotent: bereits +// verarbeitete (data-processed) überspringt mermaid.run; defekte Graphen bleiben als Text. +export async function renderMermaid(root) { + const nodes = root?.querySelectorAll?.('pre.mermaid:not([data-processed="true"])') + if (!nodes || !nodes.length) return + try { + const m = await getMermaid() + await m.run({ nodes }) + } catch { /* defektes Diagramm → Quelltext bleibt sichtbar */ } +} // Rohes HTML im Markdown (z. B.

, ohne Backticks aus Agenten-Output) // als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt. diff --git a/frontend/src/stufen.js b/frontend/src/stufen.js index bc71e8c..6d0d733 100644 --- a/frontend/src/stufen.js +++ b/frontend/src/stufen.js @@ -1,5 +1,5 @@ // Lernstufen je Baustein — identisch zum Backend (lernen.py STUFEN). -// Floor in % des cap (cap = clamp(4×relevante Subbausteine, 10, 50)). +// Floor in % des cap_final (cap_final = alle Subbausteine × 25). // 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)' }, @@ -25,11 +25,11 @@ export function naechste(score, cap) { return null } -// Fehler-Strafe-Anzeige nach Fortschritt: 0–20%→0 · 21–40%→−1 · 41–60%→−2 · 61–100%→−3. +// Fehler-Strafe-Anzeige nach Fortschritt (gegen cap_aktuell): ≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20. 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' + if (pct <= 0.25) return '−5' + if (pct <= 0.5) return '−10' + if (pct <= 0.75) return '−15' + return '−20' } diff --git a/templates/Prompt/Artefakt-Beispiel.md b/templates/Prompt/Artefakt-Beispiel.md new file mode 100644 index 0000000..bf03dd9 --- /dev/null +++ b/templates/Prompt/Artefakt-Beispiel.md @@ -0,0 +1,21 @@ +Baue für die Subbausteine des Themas "{topic}" ein ausgearbeitetes Beispiel (Worked Example) — ein durchgearbeiteter Fall, der das Verständnis trägt. + +BAUSTEINE MIT SUBBAUSTEINEN UND IHREN FAKTEN (gehe JEDEN Subbaustein durch): +{bausteine} + +Ein gutes Worked Example: +- **problem**: eine konkrete, kleine Aufgabe/Frage zum Subbaustein (1 Satz). +- **schritte**: 2–5 nachvollziehbare Schritte vom Problem zur Lösung. Jeder Schritt ein knapper Satz, in der richtigen Reihenfolge. +- **ergebnis**: das Endergebnis / die Erkenntnis (1 Satz). +- Stütze dich auf `beispiel_idee` und die belegten Fakten des Subbausteins. Rechne sauber; erfinde keine Werte, die den Fakten widersprechen. +- **Nur wo es trägt:** Lässt sich ein Subbaustein nicht sinnvoll an einem Beispiel zeigen (reine Definition, Meta-Wissen), LASSE IHN WEG — kein erzwungenes Beispiel. +- `$…$` NUR für echte Mathematik. Code/Pfade/Bezeichner in Backticks (`` `…` ``). + +Schreibe die Beispiele als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU so: +{{"beispiele": [ + {{"baustein": "", "subbaustein": "", + "problem": "…", "schritte": ["…", "…"], "ergebnis": "…"}} +]}} + +Gib sonst keinen Text aus. +{extra} diff --git a/templates/Prompt/Artefakt-Diagramm.md b/templates/Prompt/Artefakt-Diagramm.md new file mode 100644 index 0000000..f9c62e1 --- /dev/null +++ b/templates/Prompt/Artefakt-Diagramm.md @@ -0,0 +1,24 @@ +Baue für jeden Baustein des Themas "{topic}" EIN Übersichts-Diagramm (Concept-Map) als Mermaid-Code. Es zeigt, wie die Subbausteine/Kernbegriffe des Bausteins zusammenhängen. + +BAUSTEINE MIT SUBBAUSTEINEN UND IHREN FAKTEN: +{bausteine} + +Regeln für das Diagramm: +- Genau EIN Mermaid-Graph je Baustein, Typ `flowchart TD` (oben→unten) oder `flowchart LR`. +- Knoten = die Subbausteine bzw. ihre Kernbegriffe. Kanten = echte Beziehungen (baut-auf, gehört-zu, führt-zu). Keine erfundenen Beziehungen. +- 3–8 Knoten. Lieber klar als vollständig. Stütze dich auf Voraussetzungen/Kernpunkte aus den Fakten. +- **Gültige Mermaid-Syntax, sonst rendert nichts:** + - Knoten-IDs sind kurze ASCII-Wörter ohne Leerzeichen (z. B. `dfa`, `nfa1`). + - Label in eckigen Klammern, Text schlicht: `dfa[Deterministischer Automat]`. KEINE Anführungszeichen, runden Klammern, Doppelpunkte oder `#` im Label. + - Kanten mit `-->` (optional Label: `a -->|baut auf| b`). +- Zeilen im `mermaid`-Feld mit `\n` trennen (ein JSON-String). +- Lässt sich ein Baustein nicht sinnvoll als Graph zeigen (ein einziger Begriff), LASSE IHN WEG. + +Schreibe die Diagramme als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU so: +{{"diagramme": [ + {{"baustein": "", + "mermaid": "flowchart TD\n a[Alphabet] --> b[Wort]\n b --> c[Sprache]"}} +]}} + +Gib sonst keinen Text aus. +{extra} diff --git a/templates/Prompt/Artefakt-Karteikarte.md b/templates/Prompt/Artefakt-Karteikarte.md new file mode 100644 index 0000000..9b80593 --- /dev/null +++ b/templates/Prompt/Artefakt-Karteikarte.md @@ -0,0 +1,20 @@ +Baue für jeden Subbaustein des Themas "{topic}" EINE Karteikarte (Frage→Antwort) zum aktiven Abrufen. + +BAUSTEINE MIT SUBBAUSTEINEN UND IHREN FAKTEN (bearbeite JEDEN Subbaustein): +{bausteine} + +Eine gute Karteikarte: +- **frage**: eine knappe Abruf-Frage, die genau einen Kernpunkt prüft (kein „Erkläre alles"). Eine Frage, eine Sache. +- **antwort**: die kurze, präzise Antwort — auf den belegten Fakten/Kernpunkten des Subbausteins. Nichts dazu erfinden. +- Stütze dich auf die gelieferten Fakten. Wo ein belegter Fakt existiert, muss die Antwort dazu passen. +- Knapp: Frage ≤ 15 Wörter, Antwort ≤ 25 Wörter. Keine Prosa, kein Vorspann. +- `$…$` NUR für echte Mathematik. Code/Pfade/Bezeichner in Backticks (`` `…` ``). + +Schreibe ALLE Karten als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU so: +{{"karten": [ + {{"baustein": "", "subbaustein": "", + "frage": "…", "antwort": "…"}} +]}} + +Gib sonst keinen Text aus. +{extra} diff --git a/templates/Prompt/Baustein-Frage-Variante.md b/templates/Prompt/Baustein-Frage-Variante.md index 4beedd4..3fa84ea 100644 --- a/templates/Prompt/Baustein-Frage-Variante.md +++ b/templates/Prompt/Baustein-Frage-Variante.md @@ -1,6 +1,6 @@ -Du bist Prüfer in einem Lern-Guide zum Thema "{topic}", Baustein "{baustein}". Formuliere dem Lerner EINE Verständnisfrage. Du bekommst dafür ein **Muster** — eine Beispielfrage. Stelle dieselbe Sache, aber im Wortlaut **leicht anders** (andere Formulierung, gleicher Kern und gleiche Schwierigkeit). +Du bist Prüfer in einem Lern-Guide zum Thema "{topic}", Baustein "{baustein}". Formuliere dem Lerner EINE Verständnisfrage. Du bekommst dafür ein **Muster** — eine Beispielfrage zum Kernkonzept. Stelle dieselbe Sache, aber im Anspruch passend zum Lerner-Niveau (siehe unten) und im Wortlaut anders. -MUSTER (Vorlage — gleiche Sache, neuer Wortlaut): +MUSTER (Vorlage — gleicher Kern, neuer Wortlaut und Anspruch): {muster} BAUSTEIN AUS DEM GUIDE (fachliche Referenz — die Frage darf ihr nie widersprechen): @@ -9,6 +9,9 @@ BAUSTEIN AUS DEM GUIDE (fachliche Referenz — die Frage darf ihr nie widersprec KOMPAKTE FASSUNG (Merksätze, falls vorhanden): {kompakt_block} +LERNER-NIVEAU (bestimmt den Anspruch der Frage): +{niveau_block} + ERDUNG AM BAUSTEIN (zuerst prüfen): - Das Muster wurde ohne den Guide-Text erstellt — es kann am Inhalt vorbeigehen. - Prüfe das Muster gegen den BAUSTEIN oben: Lässt sich die Sache wirklich aus dem Material beantworten? diff --git a/templates/Prompt/Baustein-Frage.md b/templates/Prompt/Baustein-Frage.md index 7c28d4a..7aaccc6 100644 --- a/templates/Prompt/Baustein-Frage.md +++ b/templates/Prompt/Baustein-Frage.md @@ -18,6 +18,9 @@ GEWÜNSCHTER FRAGETYP: FOKUS: {fokus_block} +LERNER-NIVEAU (bestimmt den Anspruch der Frage — passe Tiefe und Anforderung daran an): +{niveau_block} + HARTE REGELN FÜR DIE FRAGE — wichtiger als alles andere: - GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache. - Maximal 1–2 Sätze. Kein Szenario-Aufbau, keine Vorrede, kein "Angenommen … und außerdem …". diff --git a/templates/Prompt/Baustein-Lueckentext.md b/templates/Prompt/Baustein-Lueckentext.md index 09e41d2..6f32988 100644 --- a/templates/Prompt/Baustein-Lueckentext.md +++ b/templates/Prompt/Baustein-Lueckentext.md @@ -9,6 +9,9 @@ BAUSTEIN AUS DEM GUIDE (fachliche Referenz — der Satz darf ihr nie widersprech KOMPAKTE FASSUNG (Merksätze, falls vorhanden): {kompakt_block} +LERNER-NIVEAU (bestimmt den Anspruch von Satz und Lücke): +{niveau_block} + AUFGABE: - Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter, keine Nebensatz-Kaskade). - Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke). diff --git a/templates/Prompt/Baustein-Lueckwahl.md b/templates/Prompt/Baustein-Lueckwahl.md index c995f78..01ad61b 100644 --- a/templates/Prompt/Baustein-Lueckwahl.md +++ b/templates/Prompt/Baustein-Lueckwahl.md @@ -9,6 +9,9 @@ BAUSTEIN AUS DEM GUIDE (fachliche Referenz — der Satz darf ihr nie widersprech KOMPAKTE FASSUNG (Merksätze, falls vorhanden): {kompakt_block} +LERNER-NIVEAU (bestimmt den Anspruch von Satz und Optionen): +{niveau_block} +{distraktor_block} AUFGABE: - Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter). Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke). - Die Lücke ist ein **Fachbegriff als Wort** — KEIN mathematisches Symbol/Formelteil diff --git a/templates/Prompt/Baustein-Quiz.md b/templates/Prompt/Baustein-Quiz.md index bb2f310..1f7ac8f 100644 --- a/templates/Prompt/Baustein-Quiz.md +++ b/templates/Prompt/Baustein-Quiz.md @@ -9,10 +9,13 @@ BAUSTEIN AUS DEM GUIDE (fachliche Referenz — Optionen dürfen ihr nie widerspr KOMPAKTE FASSUNG (Merksätze, falls vorhanden): {kompakt_block} +LERNER-NIVEAU (bestimmt den Anspruch von Frage und Distraktoren): +{niveau_block} +{distraktor_block} AUFGABE: - Formuliere EINE klare Frage und GENAU VIER Antwortoptionen. -- {anzahl_block} -- Jede richtige Option ist sachlich aus dem Material belegbar. Jede falsche ist plausibel, +- GENAU EINE Option ist richtig, die anderen drei sind klar falsch. +- Die richtige Option ist sachlich aus dem Material belegbar. Jede falsche ist plausibel, aber klar falsch (typischer Irrtum, Verwechslung, Halbwahrheit) — keine Albernheiten. - **Optionen SEHR KURZ:** ein Stichwort, Begriff oder Halbsatz, höchstens ~8 Wörter. KEINE ganzen Sätze, KEIN „Weil …"-Vorspann, keine Begründungs-Prosa. Nur der Kern. @@ -25,5 +28,5 @@ Gib NUR dieses JSON aus (kein weiterer Text): {{"text": "Option A", "korrekt": true}}, {{"text": "Option B", "korrekt": false}}, {{"text": "Option C", "korrekt": false}}, - {{"text": "Option D", "korrekt": true}} + {{"text": "Option D", "korrekt": false}} ]}} diff --git a/templates/Prompt/Fakten-Check.md b/templates/Prompt/Fakten-Check.md new file mode 100644 index 0000000..87173dd --- /dev/null +++ b/templates/Prompt/Fakten-Check.md @@ -0,0 +1,22 @@ +Du bist Faktenprüfer für die Lern-Fakten des Themas "{topic}". Ein anderer Agent hat je Subbaustein Fakten extrahiert. Prüfe NUR die **belegten Fakten** auf Wahrheit — nicht die Beispiele. + +{source} + +ZU PRÜFENDE FAKTEN (je Subbaustein): +{fakten} + +Prüfe je Subbaustein: +1. **Beleg-Treue**: Steht jeder `belegte_fakten`-Eintrag so in der Quelle (sinngemäß-genau)? Stimmt die Quellenangabe? Mit Quelldatei: gegen die Datei prüfen. Ohne Quelle: ist es gesichertes Standardwissen? +2. **Sachliche Korrektheit**: Sind Formeln, Werte, Definitionen, Signaturen fachlich richtig? Eine falsche Formel/ein falscher Wert ist ein Mangel. +3. **Fakt vs. Beispiel**: Ist ein selbst gerechnetes/erfundenes Beispiel fälschlich als `belegte_fakten` deklariert? Das ist ein Mangel — es gehört in `beispiel_idee`. +4. Beispiele (`beispiel_idee`) NICHT auf Quellen-Beleg prüfen — sie sind generativ. + +Notiere nur ECHTE Mängel (falscher Fakt, falsche Quelle, Beispiel als Fakt getarnt). Geschmacksfragen nicht. + +Schreibe NUR die JSON-Datei nach: {out_path} + +Format — alles in Ordnung: +{{"ok": true}} +Sonst (Subbaustein-Titel EXAKT wie oben): +{{"probleme": [{{"subbaustein": "", "problem": "…"}}]}} +{extra} diff --git a/templates/Prompt/Fakten-Recherche.md b/templates/Prompt/Fakten-Recherche.md new file mode 100644 index 0000000..abc88b1 --- /dev/null +++ b/templates/Prompt/Fakten-Recherche.md @@ -0,0 +1,30 @@ +Extrahiere die Lern-Fakten für jeden Subbaustein des Themas "{topic}". Diese Fakten sind die verbindliche Grundlage, aus der später der Guide-Text, die Stufen und die Prüfungsfragen entstehen — sie müssen **stimmen**. + +{source} + +BAUSTEINE MIT SUBBAUSTEINEN (bearbeite JEDEN Subbaustein): +{bausteine} + +Sammle pro Subbaustein NUR das Wesentliche (überflüssiges Material schadet dem Lernen): +- **kernpunkte**: 1–3 knappe Aussagen — was muss man an diesem Subbaustein verstehen? +- **voraussetzungen**: was muss man vorher kennen (ein Halbsatz)? Leer, wenn nichts. +- **huerden**: typischer Anfänger-Irrtum (ein Halbsatz). Leer, wenn keiner. +- **belegte_fakten**: harte Fakten (Definitionen, Formeln, Werte, Namen, Signaturen) — **nur was du belegen kannst**. Jeder mit Quelle: + - Mit Quelldatei/Skript: zitiere **sinngemäß-genau** und gib die Stelle an (z. B. „Skript Def. 6.3, Z.66"). Erfinde keine Werte, rechne nichts selbst aus. + - Ohne Quelle (reines Thema): nur gesichertes Standardwissen; Unsicheres per Websuche prüfen; Quelle = „allgemein" oder die URL. +- **beispiel_idee**: EIN Beispiel, das das Verständnis trägt — frei formuliert. **Hier** gehören selbst gebildete Sätze, Mini-Szenarien, durchgerechnete Beispiele hin. Leer, wenn ein Beispiel nichts beiträgt. + +HARTE TRENNUNG — wichtig: +- `belegte_fakten` = nur Belegbares aus Quelle/gesichertem Wissen. **NIEMALS** ein selbst gerechnetes oder erfundenes Beispiel als belegten Fakt ausgeben. +- Ein durchgerechnetes Beispiel, ein erdachter Satz, ein konstruierter Fall → gehört in `beispiel_idee`, nicht in `belegte_fakten`. +- Im Zweifel: lieber weglassen als falsch behaupten. + +Schreibe ALLE Subbausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format: +{{"fakten": [ + {{"baustein": "", "subbaustein": "", + "kernpunkte": ["…"], "voraussetzungen": "…", "huerden": "…", + "belegte_fakten": [{{"text": "…", "quelle": "…"}}], "beispiel_idee": "…"}} +]}} + +Gib sonst keinen Text aus. +{extra} diff --git a/templates/Prompt/Frage-Muster-Kritik.md b/templates/Prompt/Frage-Muster-Kritik.md index c018595..4580309 100644 --- a/templates/Prompt/Frage-Muster-Kritik.md +++ b/templates/Prompt/Frage-Muster-Kritik.md @@ -1,20 +1,20 @@ -Du bist Qualitäts-Prüfer für den Fragen-Vorrat einer Lern-Prüfung zum Thema "{topic}". Ein anderer Agent hat Frage-Muster für **mehrere Bausteine** erstellt. Prüfe sie und gib eine **bereinigte** Endfassung zurück. +Du bist Qualitäts-Prüfer für den Fragen-Vorrat einer Lern-Prüfung zum Thema "{topic}". Ein anderer Agent hat Frage-Muster für **mehrere Bausteine** erstellt — genau ein Muster je Subbaustein. Prüfe sie und gib eine **bereinigte** Endfassung zurück. -ZU PRÜFENDE MUSTER (gruppiert je Baustein; Zeilen: [typ] (subbaustein) Frage): +ZU PRÜFENDE MUSTER (gruppiert je Baustein; Zeilen: (subbaustein) Frage): {tabelle} PRÜFE UND BEREINIGE GEGEN DIESE KRITERIEN (je Baustein getrennt): - **Stil:** jede Frage GENAU EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", keine Aufzählung. Maximal 1–2 Sätze. Verstöße → umformulieren. -- **Eindeutig beantwortbar:** aus dem Baustein-Wissen klar beantwortbar, keine erfundenen Annahmen. Unbeantwortbares → korrigieren oder entfernen. -- **Distinkt:** keine zwei Muster desselben Bausteins, die sinngemäß dieselbe Frage stellen (auch nicht über Typen hinweg). Dubletten → eines behalten, das andere entfernen. -- **Typ-Treue:** die Frage passt zum angegebenen Typ; sonst Typ anpassen oder Muster entfernen. -- `baustein`, `subbaustein` und `typ` der Einträge unverändert übernehmen (nur `frage` darf sich ändern); nur entfernen, nicht neu erfinden. +- **Eindeutig beantwortbar:** aus dem Baustein-Wissen klar beantwortbar, keine erfundenen Annahmen. Unbeantwortbares → korrigieren. +- **Kern-Treffer:** die Frage trifft das Kernkonzept des Subbausteins, neutral formuliert (nicht extra leicht/schwer — die Schwierigkeit setzt später das Lerner-Niveau). Verfehlt sie den Kern → umformulieren. +- **Genau eins je Subbaustein:** pro Subbaustein bleibt genau ein Muster. Doppelte → eines behalten. +- `baustein` und `subbaustein` der Einträge unverändert übernehmen (nur `frage` darf sich ändern); nicht neu erfinden. Behalte gute Muster unverändert. Ändere nur, was wirklich gegen die Kriterien verstößt. Schreibe die bereinigte Endfassung ALLER Bausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format: {{"muster": [ - {{"baustein": "", "subbaustein": "", "typ": "", "frage": ""}} + {{"baustein": "", "subbaustein": "", "frage": ""}} ]}} Gib sonst keinen Text aus. diff --git a/templates/Prompt/Frage-Muster-Recherche.md b/templates/Prompt/Frage-Muster-Recherche.md index 0c7d9a9..cc2e681 100644 --- a/templates/Prompt/Frage-Muster-Recherche.md +++ b/templates/Prompt/Frage-Muster-Recherche.md @@ -1,29 +1,26 @@ -Du baust den Fragen-Vorrat für eine Lern-Prüfung zum Thema "{topic}". Erzeuge **Frage-Muster** — je eine konkrete Beispielfrage pro Subbaustein und Fragetyp. Aus diesen Mustern werden später bei der Prüfung leicht abgewandelte Fragen formuliert. +Du baust den Fragen-Vorrat für eine Lern-Prüfung zum Thema "{topic}". Erzeuge **Frage-Muster** — **genau eine** konkrete Beispielfrage **pro Subbaustein**. Aus jedem Muster wird später bei der Prüfung eine leicht abgewandelte Frage formuliert — passend zum Niveau des Lerners (Anfänger bis Experte). Die Schwierigkeit steckt also NICHT im Muster, sondern kommt erst bei der Prüfung. Frag deshalb nach dem **Kernkonzept** des Subbausteins, neutral formuliert. -Du bearbeitest **mehrere Bausteine**. Jeder Baustein hat seine eigenen relevanten Subbausteine: +Du bearbeitest **mehrere Bausteine**. Jeder Baustein hat seine eigenen Subbausteine: {bausteine} -FRAGETYPEN (Schlüssel : Bedeutung): -{typen} - AUFGABE (für **jeden** der Bausteine oben): -- Erzeuge für **jeden** seiner Subbausteine zu **jedem** passenden Fragetyp **ein** Muster (volles Kreuzprodukt). -- Lasse eine Kombination **weg**, wenn der Typ klar nicht passt (z.B. Code-Vorhersage bei reiner Theorie ohne Code). Nichts erzwingen. -- Jedes Muster ist EINE konkrete, knappe Frage (1–2 Sätze, genau ein Fragezeichen, eine Sache) — kein Mehrteiler, keine Aufzählung. +- Erzeuge für **jeden** seiner Subbausteine **genau ein** Muster — eine konkrete, knappe Frage zum Kernkonzept dieses Subbausteins. +- Jedes Muster ist EINE konkrete Frage (1–2 Sätze, genau ein Fragezeichen, eine Sache) — kein Mehrteiler, keine Aufzählung. - Die Frage muss aus dem Baustein-Wissen beantwortbar sein. Erfinde keine Zusatzannahmen. -- WICHTIG: Du siehst nur Titel, NICHT den späteren Guide-Text. Frag deshalb nur nach dem KERNKONZEPT des Subbausteins. Setze NICHTS voraus, was evtl. nicht im Guide steht: keine „gezeigten" Code-Ausschnitte/Beispiele/Diagramme, keine konkreten Werte/Listen/Zahlen, keine Randthemen. Frag das Konzept selbst, nicht ein bestimmtes Artefakt. -- Innerhalb eines Subbausteins sollen die Muster verschiedener Typen **wirklich verschiedene** Aspekte treffen — keine Umformulierungen derselben Frage. -- Der `typ` ist exakt einer der Schlüssel oben. `baustein` ist exakt einer der Baustein-Titel oben. `subbaustein` ist exakt einer der Subbaustein-Titel des jeweiligen Bausteins. +- WICHTIG: Du siehst nur Titel, NICHT den späteren Guide-Text. Frag deshalb nur nach dem KERNKONZEPT des Subbausteins. Setze NICHTS voraus, was evtl. nicht im Guide steht: keine „gezeigten" Code-Ausschnitte/Beispiele/Diagramme, keine konkreten Werte/Listen/Zahlen. +- Die Frage trifft den **Kern** des Subbausteins — den zentralen Punkt, den man verstanden haben muss. Nicht zu eng auf ein Detail, nicht zu breit über den ganzen Baustein. +- `baustein` ist exakt einer der Baustein-Titel oben. `subbaustein` ist exakt einer der Subbaustein-Titel des jeweiligen Bausteins. HARTE STIL-REGELN JE FRAGE: - Genau EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", kein "nenne drei …". - Maximal 1–2 Sätze, keine Vorrede, kein Szenario-Aufbau. +- Neutral formuliert — nicht extra leicht, nicht extra schwer. Die Schwierigkeit setzt die Prüfung über das Lerner-Niveau. - Direkte Ansprache, klares Deutsch. {extra} Schreibe ALLE Muster aller Bausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format: {{"muster": [ - {{"baustein": "", "subbaustein": "", "typ": "", "frage": ""}} + {{"baustein": "", "subbaustein": "", "frage": ""}} ]}} Gib sonst keinen Text aus. diff --git a/templates/Prompt/Guide-Gliederung.md b/templates/Prompt/Guide-Gliederung.md index 1d45137..aa3b7d2 100644 --- a/templates/Prompt/Guide-Gliederung.md +++ b/templates/Prompt/Guide-Gliederung.md @@ -1,6 +1,6 @@ -Plane die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}). +Plane die Gliederung eines Lern-Guides zum Thema "{topic}". -GEWÄHLTE BAUSTEINE (jeder mit seiner NUMMER — die Auswahl steht fest, du ordnest nur): +BAUSTEINE (jeder mit seiner NUMMER — die Liste steht fest, du ordnest nur): {bausteine} REIHENFOLGE — vom Bekannten zum Unbekannten: diff --git a/templates/Prompt/Guide-Inhalt.md b/templates/Prompt/Guide-Inhalt.md index 81740b2..fc499cc 100644 --- a/templates/Prompt/Guide-Inhalt.md +++ b/templates/Prompt/Guide-Inhalt.md @@ -1,16 +1,18 @@ Identifiziere für jeden zugeteilten Baustein eines Lern-Guides zum Thema "{topic}", WAS ein Anfänger ohne Vorwissen verstehen muss. Du schreibst noch keinen Guide-Text — du sammelst nur die Inhalte, die später gelehrt werden. -Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jeder zugeteilte Baustein muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Stufe (`[einfach]`/`[mittel]`/`[schwer]`) — die Teilpunkte, die gelehrt werden: +Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jeder zugeteilte Baustein muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Lernpfad-Stufe (`[anfaenger]`/`[fortgeschritten]`/`[experte]`) — die Teilpunkte, die gelehrt werden: {zuteilung} {facts} +Leitprinzip: nur das **Wesentliche**. Überflüssiges Material schadet dem Lernen — sammle, was ein Anfänger WIRKLICH braucht, nicht alles, was man sagen könnte. + Sammle pro Baustein: -- Kernpunkte / Lernziele: was muss der Leser begreifen? Decke JEDEN gelisteten Subbaustein mit mindestens einem Kernpunkt ab. Hat ein Baustein keine Subbausteine, sammle 3–7 knappe Punkte. +- Kernpunkte / Lernziele: was muss der Leser begreifen? Decke JEDEN gelisteten Subbaustein mit GENAU dem einen, zentralen Kernpunkt ab — nicht mehrere überlappende. Hat ein Baustein keine Subbausteine, sammle 3–7 knappe Punkte. - Voraussetzungen: welche Begriffe/Ideen muss man vorher kennen, um das hier zu verstehen? Jede in einem Halbsatz erklärbar — das sind die Ankerpunkte für Neulinge. - Typische Hürden: wo verstehen Anfänger es erfahrungsgemäß falsch oder stolpern? - Belegte Fakten (Versionen, Namen, Werte) — nichts Unbelegtes; Unsicheres per Websuche prüfen. -- Eine konkrete Beispiel-Idee: was soll das Beispiel zeigen? +- Eine konkrete Beispiel-Idee NUR, wo ein Beispiel das Verständnis wirklich trägt — nicht pflichtmäßig bei jedem Subbaustein. - Scope: nur was DIESER Baustein hergibt. Nicht mehr, nicht weniger. Am Rand Erwähntes nicht zum Thema aufblasen. Schreibe NUR die Datei {out_path} — pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darunter die Punkte: diff --git a/templates/Prompt/Guide-Lese-Check.md b/templates/Prompt/Guide-Lese-Check.md index 4b70e66..47703f8 100644 --- a/templates/Prompt/Guide-Lese-Check.md +++ b/templates/Prompt/Guide-Lese-Check.md @@ -14,8 +14,13 @@ Prüfe jede Section: - Eine Aufzählung (Schritte/Optionen/Anforderungen) als langer Fließtext-Satz, die eine Markdown-Liste sein sollte. - Textwand: ein dichter Block ohne Absätze, der sich in mehrere teilen ließe. - Mehr als ~4 neue Fachbegriffe ohne Erklärung beim ersten Auftreten. -3. Sind die Beispiele kurz, simpel, plausibel korrekt — und im themengerechten Format laut Spezifikation (kein Codeblock um Prosa-Beispiele, kein Prosa-Pseudo-Beispiel, wo Code gefragt ist)? -4. Ist das Markdown sauber (keine abgebrochenen Code-Blöcke, keine Platzhalter, kein Fremdtext)? +3. Knappheit (überflüssiges Material schadet dem Lernen — echte Mängel notieren): + - Wiederholung: dieselbe Aussage mehrfach, nur anders formuliert. + - Breit ausgewalztes Triviales, Füllsätze, Vorreden, Meta-Kommentare ohne neuen Inhalt. + - Ein Beispiel, das nichts zum Verständnis beiträgt. + → notieren als „zu lang/redundant — kürzen ohne Info-Verlust". WICHTIG: Kürzen heißt nie, einen Subbaustein zu streichen — jeder bleibt erhalten. +4. Sind die Beispiele kurz, simpel, plausibel korrekt — und im themengerechten Format laut Spezifikation (kein Codeblock um Prosa-Beispiele, kein Prosa-Pseudo-Beispiel, wo Code gefragt ist)? +5. Ist das Markdown sauber (keine abgebrochenen Code-Blöcke, keine Platzhalter, kein Fremdtext)? Du PRÜFST nur und notierst Probleme — du änderst nichts. Nur echte Mängel notieren, keine Geschmacksfragen. diff --git a/templates/Prompt/Guide-Sections-Fix.md b/templates/Prompt/Guide-Sections-Fix.md index cb7a0eb..8e67ff5 100644 --- a/templates/Prompt/Guide-Sections-Fix.md +++ b/templates/Prompt/Guide-Sections-Fix.md @@ -5,23 +5,32 @@ SECTION-SPEZIFIKATION: {spec} -ZU ÜBERARBEITEN — je Section der aktuelle Inhalt und das notierte Problem: +ZU ÜBERARBEITEN — je Section ihre Subbausteine, das notierte Problem und der aktuelle Inhalt: {auftraege} Behebe pro Section NUR das notierte Problem; was in Ordnung ist, bleibt inhaltlich erhalten. -Jede Section hat ZWEI Fassungen mit denselben Inhalten: **kompakt** (je Subbaustein ein Merksatz als Stichpunkt) und **ausführlich** (zusammenhängender Anfänger-Text). Gib beide neu aus und halte sie deckungsgleich. +VERBINDLICHE STRUKTUR (wie der Writer) — sonst bricht die gestufte Anzeige: +- Jede Section hat ZWEI Fassungen: **kompakt** (je Subbaustein ein Merksatz) und **ausführlich** (zusammenhängender Anfänger-Text). +- In BEIDEN Fassungen steht je Subbaustein ein ``-Marker. LABEL und Titel EXAKT aus der Subbaustein-Liste oben, gleiche Reihenfolge in beiden Blöcken. Der ausführliche Text beginnt mit einem kurzen Anker (Einordnung) VOR dem ersten Marker. +- Die Marker sind unsichtbare Schnittstellen, KEINE sichtbaren Überschriften — schreibe trotzdem flüssig. -Schreibe NUR die Datei {out_path} in GENAU diesem Format — für JEDE beanstandete Section ein section-Marker (Titel EXAKT wie oben) mit `kompakt`- und `ausführlich`-Block: +KNAPP HALTEN (falls das Problem „zu lang/redundant" ist): +- Jeder Satz trägt neue Information. Streiche Wiederholungen, Füll- und Meta-Sätze, Vorreden. +- Erkläre Triviales kurz; gib Tiefe nur, wo der Stoff sie braucht. Im Zweifel weglassen, nicht hinzufügen. +- Ein Beispiel nur, wo es das Verständnis wirklich trägt — nicht pflichtmäßig bei jedem Subbaustein. +- Kürzen heißt nie: einen Subbaustein streichen. Jeder bleibt mit seinem Marker erhalten. + +Schreibe NUR die Datei {out_path} in GENAU diesem Format — für JEDE beanstandete Section ein section-Marker (Titel EXAKT wie oben): + - Merksatz je Subbaustein (knapp, ohne Erklärung) -Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION… - -### Beispiel -(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario) +Anker: kurze Einordnung des Bausteins — vor dem ersten Subbaustein. + +Anfängerfreundlicher Fließtext zu diesem Subbaustein. Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections. {extra} diff --git a/templates/Prompt/Guide-Writer.md b/templates/Prompt/Guide-Writer.md index 5272f9f..655c26d 100644 --- a/templates/Prompt/Guide-Writer.md +++ b/templates/Prompt/Guide-Writer.md @@ -1,23 +1,25 @@ Schreibe Sections für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}). -Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteilte Section muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Stufe (`[einfach]`/`[mittel]`/`[schwer]`): +Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteilte Section muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Ebenen-Label (`[anfaenger]`/`[fortgeschritten]`/`[experte]`/`[rand]`): {zuteilung} -Das Format ({format_name}) beeinflusst NICHT, wie ein Baustein geschrieben wird — nur, wie viele Bausteine das Thema abdecken. Derselbe Baustein bekommt in MiniGuide, Guide und FullGuide denselben Text. +WICHTIG — jeder Subbaustein wird mit einem Marker abgegrenzt, damit der Guide später gestuft angezeigt werden kann (Anfänger sehen nur `anfaenger`, Fortgeschrittene mehr). Das Label kommt EXAKT aus der Zuteilung. Trotz Marker schreibst du FLÜSSIG und verwoben — der Marker ist eine unsichtbare Schnittstelle, KEINE sichtbare Überschrift. -Jeder Baustein bekommt ZWEI Fassungen mit DENSELBEN Inhalten (dieselben Subbausteine): -- **kompakt**: je Subbaustein EIN Merksatz als Stichpunkt (`- …`). Zum Erinnern. Nur benennen, nicht erklären. Gleiche Reihenfolge wie ausführlich. +Der Inhalt ist UNABHÄNGIG von der Stufe: jeder Subbaustein wird **anfängerfreundlich** erklärt, egal welches Label. Das Label sagt nur, WANN im Lernpfad der Punkt drankommt (Fundament → Feinheit) — NICHT wie kompliziert er geschrieben wird. Ein `[experte]`-Punkt wird genauso simpel erklärt wie ein `[anfaenger]`-Punkt. Kein Perfektionismus, keine künstliche Tiefe: das Wesentliche klar, so dass ein Neuling es versteht. + +Jeder Baustein bekommt ZWEI Fassungen mit DENSELBEN Subbausteinen (gleiche Titel, gleiche Reihenfolge): +- **kompakt**: je Subbaustein EIN Merksatz als Stichpunkt (`- …`). Zum Erinnern. Nur benennen, nicht erklären. - **ausführlich**: der zusammenhängende Anfänger-Lerntext (siehe unten). -Beide MÜSSEN dieselben Subbausteine abdecken. Nichts, das nur in einer Fassung steht. SO SCHREIBST DU die ausführliche Fassung — EIN zusammenhängender Text für einen Junior, der das Thema NEU lernt: -- Beginne mit dem Anker (Einordnung): welches Problem/wozu, angeknüpft an etwas Bekanntes — erst dann das Neue. Pflicht, nie weglassen. -- Löse JEDEN Fachbegriff bei Erstnennung in einem Halbsatz auf. Setze nichts voraus — auch keine Begriffe aus dem Titel oder anderen Bausteinen. Nutze die „Voraussetzung"-Hinweise aus den Inhalten als Anker, die „Hürde"-Hinweise, um Missverständnisse vorweg auszuräumen. -- Die Subbausteine sind deine INHALTS-CHECKLISTE, keine Überschriften: webe sie in den Fließtext ein. Behandle jeden, aber als Teil eines Ganzen — nicht als isolierten Mini-Absatz. -- Reihenfolge: erst die `[einfach]`-Punkte (das Fundament), dann `[mittel]` (Details/Varianten), zuletzt `[schwer]` (Sonderfälle/Tücken — knapp, als fortgeschrittener Hinweis). -- Diese Fassung trägt die volle Tiefe selbst — es gibt keine zweite Ausbaustufe. „Wie"-Abläufe Schritt für Schritt zeigen, nicht nur das Ergebnis. -- KEIN festes Längen-Limit. So lang wie nötig, damit ein Neuling es OHNE Vorwissen versteht. Im Zweifel ein Satz mehr und klar. -- Beispiele großzügig — auch kleine Mini-Beispiele mitten im Text, wo sie einen Punkt sofort greifbar machen, zusätzlich zum `### Beispiel` am Ende (Format laut Spezifikation). +- Beginne mit dem Anker (Einordnung): welches Problem/wozu, angeknüpft an etwas Bekanntes — erst dann das Neue. Der Anker steht VOR dem ersten Subbaustein-Marker. Pflicht, nie weglassen. +- Löse JEDEN Fachbegriff bei Erstnennung in einem Halbsatz auf. Setze nichts voraus — auch keine Begriffe aus dem Titel oder anderen Bausteinen. Nutze die „Voraussetzung"-Hinweise als Anker, die „Hürde"-Hinweise, um Missverständnisse vorweg auszuräumen. +- Behandle jeden Subbaustein unter seinem Marker — flüssig formuliert, erklärender Fließtext, kein isolierter Stichpunkt-Block. +- Reihenfolge der Subbausteine wie in der Zuteilung: erst `[anfaenger]` (Fundament), dann `[fortgeschritten]`, dann `[experte]`, zuletzt `[rand]`. +- „Wie"-Abläufe Schritt für Schritt zeigen, nicht nur das Ergebnis. +- KNAPP: Jeder Satz trägt neue Information. Keine Wiederholung, keine Füll- oder Meta-Sätze, keine Vorrede. Überflüssiges schadet dem Lernen — im Zweifel weglassen, nicht hinzufügen. +- Länge folgt dem Inhalt: ein triviales Detail ein, zwei Sätze; ein komplexes Konzept so viel, wie es WIRKLICH braucht — nicht mehr. Kein „so lang wie möglich". +- Ein Beispiel NUR, wo es das Verständnis wirklich trägt — nicht pflichtmäßig bei jedem Subbaustein. GEPRÜFTE INHALTE je Baustein — das ist verbindlich, was gelehrt werden muss: {inhalte} @@ -27,17 +29,21 @@ Recherchiere NICHT und suche NICHT im Web. Alle nötigen Fakten stehen in diesen SECTION-SPEZIFIKATION (gilt je Baustein): {spec} -Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kapitel-Marker, pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darin ein `kompakt`- und ein `ausführlich`-Block: +Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kapitel-Marker, pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darin ein `kompakt`- und ein `ausführlich`-Block. In BEIDEN Blöcken steht je Subbaustein ein ``-Marker (LABEL und Titel EXAKT aus der Zuteilung, gleiche Reihenfolge in beiden Blöcken): -- Merksatz je Subbaustein (knapp, ohne Erklärung) + +- Merksatz zu diesem Subbaustein (knapp, ohne Erklärung) + +- Merksatz zu diesem Subbaustein -Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION… +Anker: Einordnung des ganzen Bausteins (welches Problem, wozu) — vor dem ersten Subbaustein. + +Anfängerfreundlicher Fließtext zu diesem Subbaustein, mit kleinem Beispiel. + +Anfängerfreundlicher Fließtext zu diesem Subbaustein. -### Beispiel -(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario) - -Die Marker-Zeilen exakt so schreiben (jede Section hat genau einen ``- und einen ``-Block). Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis. +Die Marker-Zeilen exakt so schreiben. Jede Section hat genau einen ``- und einen ``-Block; die Subbaustein-Titel sind in beiden identisch. Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis. {extra} diff --git a/templates/Prompt/Relevanz-Mapping.md b/templates/Prompt/Relevanz-Mapping.md index fe7c64e..b4c53ca 100644 --- a/templates/Prompt/Relevanz-Mapping.md +++ b/templates/Prompt/Relevanz-Mapping.md @@ -4,8 +4,8 @@ STRITTIGE SUBBAUSTEINE (mit den abgegebenen Stimmen): {strittig} Relevanz: -- **relevant**: Kern-/Standardwissen, ein ernsthafter Lerner braucht es. -- **rand**: Randnotiz, Spezial-/Nischenfall, „nice to know" — nur für den Komplett-Guide. +- **relevant**: Kern-/Standardwissen, das das Thema trägt — zentrale Konzepte, Definitionen, Eigenschaften. Bei theoretischen Themen auch zentrale Sätze ohne praktischen Gebrauch. +- **rand**: Randnotiz, Nischen-/Spezialfall, „nice to know", Deprecated/Exotik, rein historische Details, konkrete Zahlenbeispiele, bloße Querverweise. NICHT rand: ein zentrales Theorem/eine fundamentale Eigenschaft. Nur für den Komplett-Guide. Regeln: - Wäge die Stimmen ab, entscheide fachlich. Im Zweifel **relevant**. diff --git a/templates/Prompt/Relevanz-Recherche.md b/templates/Prompt/Relevanz-Recherche.md index f6998d3..1ceb566 100644 --- a/templates/Prompt/Relevanz-Recherche.md +++ b/templates/Prompt/Relevanz-Recherche.md @@ -4,8 +4,8 @@ SUBBAUSTEINE (Nummer in Klammern der zugehörige Baustein): {subbausteine} Relevanz: -- **relevant**: Kern- oder Standardwissen des Themas. Ein ernsthafter Lerner braucht es im normalen Gebrauch. Was man wirklich kennen sollte. -- **rand**: Randnotiz, Spezial-/Nischenfall, „nice to know", Deprecated/Exotik. Für die meisten Lerner verzichtbar — gehört nur in einen Komplett-Guide. +- **relevant**: Kern-/Standardwissen, das das Thema TRÄGT — zentrale Konzepte, Definitionen, Eigenschaften, gängige Praxis. Bei praktischen Themen: was man im Gebrauch braucht. Bei theoretischen Themen: zentrale Sätze/Eigenschaften, AUCH ohne praktischen Gebrauch. Was man wirklich kennen sollte. +- **rand**: Randnotiz, Spezial-/Nischenfall, „nice to know", Deprecated/Exotik — PLUS rein historische Details (wer/wann), konkrete Zahlenbeispiele eines allgemeinen Prinzips, bloße Querverweise. NICHT rand: ein zentrales Theorem oder eine fundamentale Eigenschaft, nur weil sie „theoretisch" wirkt. Gehört nur in einen Komplett-Guide. Regeln: - Bewerte jeden Punkt einzeln, gemessen an SEINEM Baustein und am Thema. diff --git a/templates/Prompt/Stufen-Mapping.md b/templates/Prompt/Stufen-Mapping.md index f88f5c1..e0c0d1a 100644 --- a/templates/Prompt/Stufen-Mapping.md +++ b/templates/Prompt/Stufen-Mapping.md @@ -1,19 +1,20 @@ -Mehrere Agenten haben die Subbausteine des Themas "{topic}" nach Schwierigkeit eingestuft. Bei einigen sind sie sich uneinig. Entscheide für jeden strittigen Subbaustein die finale Stufe. +Mehrere Agenten haben die Subbausteine des Themas "{topic}" nach Lernpfad-Position eingestuft. Bei einigen sind sie sich uneinig. Entscheide für jeden strittigen Subbaustein die finale Stufe. STRITTIGE SUBBAUSTEINE (mit den abgegebenen Stimmen): {strittig} -Stufe = Lern-/Verständnisaufwand (nicht Seltenheit/Obskurität): -- **einfach**: Basis-Fakt/Definition, sofort verständlich. Auch Versions-Details, Pfade, Konstanten, einzelne Fakten. -- **mittel**: braucht Erklärung — mehrere Teile, Zusammenhang, typische Anwendung. -- **schwer**: komplexes Zusammenspiel mehrerer Konzepte, tieferes Verständnis nötig. NICHT „schwer" nur weil selten/neu/Edge-Case. +Stufe = Lernpfad-Position (WANN man den Punkt braucht), NICHT Schwierigkeit: +- **anfaenger**: Fundament — was man zuerst/unbedingt braucht; Voraussetzung für den Rest. Auch wenn gedanklich anspruchsvoll. +- **fortgeschritten**: baut darauf auf; gängige Varianten, typische Anwendung. +- **experte**: Feinheiten, Sonderfälle, Nischen, seltene Details — auch wenn inhaltlich einfach (z. B. ein obskures Flag, ein Versions-Detail). Regeln: -- Wäge die Stimmen ab, entscheide fachlich nach Lernaufwand. Im Zweifel die niedrigere Stufe. +- Ein einfaches Nischen-Detail ist `experte`; ein fundamentales komplexes Konzept ist `anfaenger`. +- Wäge die Stimmen ab, entscheide fachlich nach Lernpfad-Position. - Für JEDE strittige Nummer genau eine Stufe. Schreibe NUR die JSON-Datei nach: {out_path} Format (kein weiterer Text): -{{"stufen": {{"1": "einfach", "4": "schwer"}}}} +{{"stufen": {{"1": "anfaenger", "4": "experte"}}}} {extra} diff --git a/templates/Prompt/Stufen-Recherche.md b/templates/Prompt/Stufen-Recherche.md index 1c7d392..bd6dd13 100644 --- a/templates/Prompt/Stufen-Recherche.md +++ b/templates/Prompt/Stufen-Recherche.md @@ -1,27 +1,25 @@ -Stufe jeden Subbaustein des Themas "{topic}" nach **Lern-/Verständnisaufwand** ein: einfach, mittel oder schwer. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „einfach", Fortgeschrittene alles). +Ordne jeden Subbaustein des Themas "{topic}" seiner **Lernpfad-Position** zu: anfaenger, fortgeschritten oder experte. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „anfaenger", Fortgeschrittene mehr). SUBBAUSTEINE (nach Baustein gruppiert): {subbausteine} -Was die Stufe misst — wie viel Aufwand, den Punkt zu VERSTEHEN und ANZUWENDEN: -- **einfach**: Basis-Fakt oder Definition. Sofort verständlich. Der Einstieg in den Baustein. -- **mittel**: braucht etwas Erklärung. Mehrere Teile, ein Zusammenhang, eine typische Anwendung. -- **schwer**: komplexes Zusammenspiel mehrerer Konzepte. Setzt tieferes Verständnis voraus, ist gedanklich anspruchsvoll. +ENTSCHEIDEND — die Stufe misst, WANN man den Punkt im Lernpfad braucht, NICHT wie schwer er zu verstehen ist: +- **anfaenger**: Fundament. Was man als Erstes/unbedingt braucht, um den Baustein überhaupt zu verstehen. Voraussetzung für den Rest. Auch wenn das Konzept gedanklich anspruchsvoll ist — ist es das Fundament, gehört es hierher. +- **fortgeschritten**: baut auf dem Fundament auf. Gängige Varianten, typische Anwendung, der nächste Schritt. +- **experte**: Feinheiten, Sonderfälle, Edge-Cases, Nischen, seltene Details. Was man erst braucht, wenn man die Basis beherrscht — **auch wenn es inhaltlich einfach ist** (z. B. ein obskures Flag, ein Versions-Detail). -WICHTIG — was NICHT die Stufe bestimmt: -- Seltenheit ist KEINE Schwierigkeit. Ein Nischen-Feature kann trotzdem einfach sein. -- Versions-Details (z.B. „seit 6.4.12.0"), Konstanten, Pfade, einzelne Fakten → meist **einfach**, nicht schwer. -- Edge-Cases/Gotchas, die nur eine simple Regel sind (z.B. „Name muss dem Ordner entsprechen") → **einfach/mittel**, nicht schwer. -- „schwer" NUR, wenn der Punkt selbst gedanklich schwer ist — nicht weil er obskur oder neu ist. +WICHTIG — Schwierigkeit ist NICHT das Kriterium: +- Ein **einfaches Nischen-Detail** (selten gebraucht, z. B. „seit 6.4.12.0") ist `experte`, nicht `anfaenger`. +- Ein **fundamentales, aber komplexes Konzept** (man braucht es früh) ist `anfaenger`, nicht `experte`. +- Frag: „Wann im Lernpfad braucht man das?" — nicht „Wie schwer ist es?". Regeln: -- Stufe RELATIV im jeweiligen Baustein: vergleiche die Punkte eines Bausteins miteinander. -- Die meisten Punkte sind einfach/mittel. „schwer" nur für die echten Brocken. -- Jeder Baustein hat mindestens einen „einfach"-Punkt (den Einstieg). Im Zweifel die niedrigere Stufe. +- Stufe RELATIV im jeweiligen Baustein: was ist hier Fundament, was Aufbau, was Feinheit? +- Nicht jeder Baustein braucht einen `anfaenger`-Punkt. Manche Themen setzen Vorwissen voraus und sind ganz `fortgeschritten`/`experte` — das ist gewollt. Stufe ehrlich nach Lernpfad-Position, ohne künstlichen Einstieg zu erfinden. - Urteile nur — erfinde nichts dazu, ändere keine Subbausteine. Schreibe NUR die JSON-Datei nach: {out_path} Format (für JEDE Nummer genau eine Stufe; kein weiterer Text): -{{"stufen": {{"1": "einfach", "2": "mittel", "3": "schwer"}}}} +{{"stufen": {{"1": "anfaenger", "2": "fortgeschritten", "3": "experte"}}}} {extra}