diff --git a/Makefile b/Makefile index f8198ca..f294c00 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,8 @@ auth: @echo "Verzeichnisse angelegt und auf uid 1000 chowned." install: - pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright + pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers + pip install --break-system-packages torch --index-url https://download.pytorch.org/whl/cpu python3 -m playwright install chromium @echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen." @which pdftotext >/dev/null 2>&1 || sudo apt-get install -y poppler-utils diff --git a/backend/config.py b/backend/config.py index 6771aea..f18fdee 100644 --- a/backend/config.py +++ b/backend/config.py @@ -10,6 +10,17 @@ UNI_DIR = PROJECT_ROOT / "uni" MAX_CONCURRENT_GENERATIONS = 10 +# Lesbarkeits-Gate: deterministischer Prüfer (kleines deutsches Komplexitäts-Modell, +# Skala 1–7). Zu schwere Sections gehen in die Lese-Prüfungs-Überarbeitung. +# Fehlen transformers/torch oder das Modell → Gate stumm aus. +LESBARKEIT_AKTIV = True +LESBARKEIT_MODELL = "MiriUll/distilbert-german-text-complexity" +# Anker auf der 1–7-Skala (TextComplexityDE): Leichte Sprache ~1,2; Wikipedia-Schnitt +# ~3,22; ab MOS > 4 gilt ein Satz als „echt komplex" (Vereinfachungs-Grenze des Papers). +LESBARKEIT_MAX = 3.5 # Section zu schwer, wenn der Satz-Schnitt darüber liegt +LESBARKEIT_HART = 4.0 # Einzelsatz ab hier „hart" +LESBARKEIT_HART_ANTEIL = 0.30 # … ODER wenn dieser Anteil der Sätze hart ist + # Deckel für gleichzeitige CLI-Agenten-Prozesse (über alle Generierungen hinweg). # Eigene Spur für interaktive Aufrufe (Chat, Elemente), damit sie nicht hinter # laufenden Writern in der Warteschlange hängen. diff --git a/backend/database.py b/backend/database.py index 90c3386..7bb0d71 100644 --- a/backend/database.py +++ b/backend/database.py @@ -312,39 +312,6 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat() -async def get_vertiefung(topic: str, baustein: str, art: str) -> str | None: - db = await get_db() - cursor = await db.execute( - "SELECT md FROM baustein_texte WHERE topic = ? AND baustein = ? AND art = ?", - (topic, baustein, art), - ) - row = await cursor.fetchone() - return row[0] if row else None - - -async def set_vertiefung(topic: str, baustein: str, art: str, md: str) -> None: - db = await get_db() - now = _now() - await db.execute( - """INSERT INTO baustein_texte (topic, baustein, art, md, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(topic, baustein, art) DO UPDATE SET md = excluded.md, updated_at = excluded.updated_at""", - (topic, baustein, art, md, now, now), - ) - await db.commit() - - -async def list_vertiefungen(topic: str) -> dict[str, set[str]]: - """Baustein-Titel → vorhandene Text-Arten ('vertiefung'/'deepdive').""" - db = await get_db() - cursor = await db.execute("SELECT baustein, art FROM baustein_texte WHERE topic = ?", (topic,)) - rows = await cursor.fetchall() - out: dict[str, set[str]] = {} - for baustein, art in rows: - out.setdefault(baustein, set()).add(art) - return out - - async def list_baustein_progress(topic: str) -> list[dict]: db = await get_db() cursor = await db.execute( diff --git a/backend/guide.py b/backend/guide.py index 99c4681..709324c 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -18,8 +18,9 @@ from agents import run_agent from bausteine import _pdfs_konvertieren, quelle_ordner from config import ( DEFAULT_PROVIDER, FORMAT_ANTEIL, KONSENS_GRACE, KONSENS_MAX_RUNDEN, - TEMPLATES_DIR, + LESBARKEIT_AKTIV, TEMPLATES_DIR, ) +import lesbarkeit from database import list_guides, update_guide from fsutil import atomic_write_json from jsonio import read_json_file as _json_datei @@ -709,6 +710,17 @@ async def _generate_sections( num = _titel_aufloesen(idx, item["section"]) if num in geltung and num in by_num and num not in probleme_by_num: probleme_by_num[num] = item["problem"] + + # Deterministisches Lesbarkeits-Gate: zu schwere Sections in dieselbe + # Überarbeitung einreihen (LLM-Beanstandung hat Vorrang). Gate aus → no-op. + if LESBARKEIT_AKTIV: + md_by_num = {num: by_num[num]["md"] for nums in scope for num in nums if num in by_num} + hinweise = await asyncio.to_thread(lesbarkeit.bewerte_sections, md_by_num) + if hinweise: + _log(topic, f"Lesbarkeit: {len(hinweise)} Section(s) zu schwer") + for num, hinweis in hinweise.items(): + probleme_by_num.setdefault(num, hinweis) + if not probleme_by_num: break @@ -756,7 +768,8 @@ async def _generate_sections( chapters: list[dict] = [] for ch in plan: sections = [ - {"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"]} + {"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"], + "kompakt": by_num[num].get("kompakt", "")} for num in ch["nums"] if num in by_num ] if sections: diff --git a/backend/lernen.py b/backend/lernen.py index b8802fb..c0fa38f 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -23,7 +23,6 @@ log = logging.getLogger("creator.lernen") NOETIG = 3 # gute Antworten bis "absolviert" (Tier 1) MASTERY = 10 # Score bis "verstanden" (Tier 2) MEISTERN = 25 # Score bis "gemeistert" (Tier 3, Maximum) -VERTIEFUNG_TIMEOUT = 600 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 @@ -84,37 +83,13 @@ def _transcript(messages: list[dict]) -> str: ) or "(leer)" -async def vertiefung_generieren(topic: str, baustein: str, section: str, art: str = "vertiefung", provider: str = DEFAULT_PROVIDER) -> str | None: - """Ausführlichere Fassung des Bausteins als Markdown · None bei Fehler. - - art "vertiefung" = gleicher Stoff, nur umfangreicher; - "deepdive" = Label „Amateur": gleicher Stoff, für Einsteiger aufbereitet. - """ - try: - prompt = _prompt( - "Baustein-Deepdive" if art == "deepdive" else "Baustein-Vertiefung", - topic=topic, baustein=baustein, - section_block=section.strip() or "(keine Guide-Fassung übergeben)", - ) - returncode, stdout, _ = await run_agent( - "vertiefung-" + str(uuid.uuid4()), prompt, VERTIEFUNG_TIMEOUT, - provider=provider, role="fast", capabilities="none", lane="interactive", - ) - if returncode != 0: - return None - return stdout.strip() or None - except Exception: - log.warning("[%s] Vertiefung fehlgeschlagen (%s)", topic, baustein, exc_info=True) - return None - - -async def baustein_chat(topic: str, baustein: str, section: str, vertiefung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str: +async def baustein_chat(topic: str, baustein: str, section: str, kompakt: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str: try: prompt = _prompt( "Baustein-Chat", topic=topic, baustein=baustein, section_block=section.strip() or "(keine Guide-Fassung übergeben)", - vertiefung_block=(vertiefung or "").strip() or "(keine)", + kompakt_block=(kompakt or "").strip() or "(keine)", transcript=_transcript(messages), ) returncode, stdout, _ = await run_agent( @@ -197,7 +172,7 @@ def _doppelfrage_mangel(frage: str) -> str | None: async def _frage_mit_kritik( - topic: str, baustein: str, section_block: str, vertiefung_block: str, + topic: str, baustein: str, section_block: str, kompakt_block: str, transcript: str, provider: str, ) -> str | None: """Frage generieren, vom Kritiker prüfen lassen, bei Mängeln neu (max KRITIK_MAX_RUNDEN).""" @@ -207,7 +182,7 @@ async def _frage_mit_kritik( data = await _gen_call( "Baustein-Frage", "fast", _frage_schema, provider, topic=topic, baustein=baustein, section_block=section_block, - vertiefung_block=vertiefung_block, transcript=transcript, kritik_block=kritik_block, + kompakt_block=kompakt_block, transcript=transcript, kritik_block=kritik_block, ) if data is None: return None @@ -215,7 +190,7 @@ async def _frage_mit_kritik( probleme = await _kritik_call( "Baustein-Frage-Kritik", provider, role="guide", # starke KI prüft die Regeln topic=topic, baustein=baustein, section_block=section_block, - vertiefung_block=vertiefung_block, transcript=transcript, frage=frage, + kompakt_block=kompakt_block, transcript=transcript, frage=frage, ) hart = _doppelfrage_mangel(frage) # erzwingt Neugenerierung, auch wenn der KI-Kritiker es übersah if hart: @@ -227,7 +202,7 @@ async def _frage_mit_kritik( async def _bewertung_mit_kritik( - topic: str, baustein: str, section_block: str, vertiefung_block: str, + topic: str, baustein: str, section_block: str, kompakt_block: str, frage: str, transcript: str, gute_antworten: int, provider: str, role: str = "judge", ) -> dict | None: """Antwort bewerten (gut/schlecht), vom Kritiker prüfen lassen, bei Fehlurteil neu. @@ -243,7 +218,7 @@ async def _bewertung_mit_kritik( bew = await _gen_call( "Baustein-Bewertung", role, _bewertung_schema, provider, timeout, topic=topic, baustein=baustein, section_block=section_block, - vertiefung_block=vertiefung_block, frage=frage, transcript=transcript, + kompakt_block=kompakt_block, frage=frage, transcript=transcript, gute_antworten=gute_antworten, noetig=NOETIG, kritik_block=kritik_block, ) if bew is None: @@ -251,7 +226,7 @@ async def _bewertung_mit_kritik( probleme = await _kritik_call( "Baustein-Bewertung-Kritik", provider, role=role, timeout=timeout, topic=topic, baustein=baustein, section_block=section_block, - vertiefung_block=vertiefung_block, frage=frage, transcript=transcript, + kompakt_block=kompakt_block, frage=frage, transcript=transcript, bewertung_block=_bewertung_text(bew), ) if not probleme: @@ -260,29 +235,29 @@ async def _bewertung_mit_kritik( return bew # best-effort nach der letzten Runde -def _bloecke(section: str, vertiefung: str | None) -> tuple[str, str]: +def _bloecke(section: str, kompakt: str | None) -> tuple[str, str]: return ( section.strip() or "(keine Guide-Fassung übergeben)", - (vertiefung or "").strip() or "(keine)", + (kompakt or "").strip() or "(keine)", ) async def pruefung_frage( - topic: str, baustein: str, section: str, vertiefung: str | None, + topic: str, baustein: str, section: str, kompakt: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER, ) -> str | None: """Aktion 'frage': nächste Frage generieren (Generator + Kritiker) · None bei Fehler.""" try: - section_block, vertiefung_block = _bloecke(section, vertiefung) + section_block, kompakt_block = _bloecke(section, kompakt) transcript = _transcript(messages) if messages else "(leer)" - return await _frage_mit_kritik(topic, baustein, section_block, vertiefung_block, transcript, provider) + return await _frage_mit_kritik(topic, baustein, section_block, kompakt_block, transcript, provider) except Exception: log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) return None async def pruefung_bewertung( - topic: str, baustein: str, section: str, vertiefung: str | None, + topic: str, baustein: str, section: str, kompakt: str | None, frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, role: str = "judge", ) -> dict | None: @@ -292,10 +267,10 @@ async def pruefung_bewertung( Gibt {"feedback", "bewertung": gut|schlecht, "bestanden"} · None bei Fehler. """ try: - section_block, vertiefung_block = _bloecke(section, vertiefung) + section_block, kompakt_block = _bloecke(section, kompakt) transcript = _transcript(messages) if messages else "(leer)" return await _bewertung_mit_kritik( - topic, baustein, section_block, vertiefung_block, + topic, baustein, section_block, kompakt_block, frage.strip() or "(keine Frage übergeben)", transcript, gute_antworten, provider, role, ) except Exception: @@ -304,7 +279,7 @@ async def pruefung_bewertung( async def pruefung_bewertung_schnell( - topic: str, baustein: str, section: str, vertiefung: str | None, + topic: str, baustein: str, section: str, kompakt: str | None, frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, ) -> dict | None: """Aktion 'antwort' (Vorschau): nur Evaluator, KEIN Kritiker — sofortiges Urteil. @@ -312,12 +287,12 @@ async def pruefung_bewertung_schnell( Wird optimistisch angezeigt; 'antwort_pruefen' liefert danach das geprüfte Urteil. """ try: - section_block, vertiefung_block = _bloecke(section, vertiefung) + section_block, kompakt_block = _bloecke(section, kompakt) transcript = _transcript(messages) if messages else "(leer)" return await _gen_call( "Baustein-Bewertung", "judge", _bewertung_schema, provider, topic=topic, baustein=baustein, section_block=section_block, - vertiefung_block=vertiefung_block, frage=frage.strip() or "(keine Frage übergeben)", + kompakt_block=kompakt_block, frage=frage.strip() or "(keine Frage übergeben)", transcript=transcript, gute_antworten=gute_antworten, noetig=NOETIG, kritik_block="(keine)", ) except Exception: @@ -326,7 +301,7 @@ async def pruefung_bewertung_schnell( async def baustein_diskussion( - topic: str, baustein: str, section: str, vertiefung: str | None, + topic: str, baustein: str, section: str, kompakt: str | None, frage: str, letzte_bewertung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER, ) -> str | None: """Aktion 'diskussion': Tutor erklärt/diskutiert die Frage oder eine Bewertung. @@ -334,11 +309,11 @@ async def baustein_diskussion( Kein Bewerten, kein Kritiker — hier ist der Mensch der Prüfer. None bei Fehler. """ try: - section_block, vertiefung_block = _bloecke(section, vertiefung) + section_block, kompakt_block = _bloecke(section, kompakt) prompt = _prompt( "Baustein-Pruefung-Diskussion", topic=topic, baustein=baustein, - section_block=section_block, vertiefung_block=vertiefung_block, + section_block=section_block, kompakt_block=kompakt_block, frage=frage.strip() or "(keine Frage übergeben)", letzte_bewertung_block=(letzte_bewertung or "").strip() or "(noch keine)", transcript=_transcript(messages) if messages else "(leer)", diff --git a/backend/lesbarkeit.py b/backend/lesbarkeit.py new file mode 100644 index 0000000..b13db48 --- /dev/null +++ b/backend/lesbarkeit.py @@ -0,0 +1,115 @@ +"""Deterministisches Lesbarkeits-Gate für Guide-Sections. + +Ein kleines deutsches Komplexitäts-Modell (DistilBERT, GermEval 2022, Skala 1–7) +bewertet die Verständlichkeit der Fließtext-Prosa. guide.py meldet zu schwere +Sections in die bestehende Lese-Prüfungs-/Überarbeitungs-Schleife — kein Prompt, +kein Raten. + +Optional: fehlen `transformers`/`torch` oder lädt das Modell nicht, ist das Gate +stumm deaktiviert (das Backend läuft unverändert weiter). CPU genügt; der Aufrufer +wrappt die Bewertung in `asyncio.to_thread` (blockierende Modell-Inferenz). +""" + +import logging +import re + +from config import ( + LESBARKEIT_AKTIV, LESBARKEIT_HART, LESBARKEIT_HART_ANTEIL, LESBARKEIT_MAX, LESBARKEIT_MODELL, +) + +log = logging.getLogger("creator.lesbarkeit") + +_modell_cache = None # (tokenizer, model, torch) — Singleton +_ladeversuch = False # schon versucht zu laden? + +# Markup raus → reiner Fließtext (Code zählt nicht zur Lesbarkeit). +_CODE_FENCE = re.compile(r"```.*?```", re.DOTALL) +_COMMENT = re.compile(r"", re.DOTALL) +_INLINE_CODE = re.compile(r"`[^`]*`") +_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)") +_MD_MARK = re.compile(r"^[ \t]*([#>]+|[-*+]\s)|[*_~|]", re.MULTILINE) +_WS = re.compile(r"\s+") +_SATZ = re.compile(r"(?<=[.!?])\s+") + + +def _modell(): + """Lädt das Modell einmalig. None = Gate aus (deaktiviert oder Lade-Fehler).""" + global _modell_cache, _ladeversuch + if _ladeversuch: + return _modell_cache + _ladeversuch = True + if not LESBARKEIT_AKTIV: + return None + try: + import torch + from transformers import AutoModelForSequenceClassification, AutoTokenizer + tok = AutoTokenizer.from_pretrained(LESBARKEIT_MODELL) + model = AutoModelForSequenceClassification.from_pretrained(LESBARKEIT_MODELL) + model.eval() + _modell_cache = (tok, model, torch) + log.info("Lesbarkeits-Modell geladen: %s (num_labels=%d)", LESBARKEIT_MODELL, model.config.num_labels) + except Exception as e: + log.warning("Lesbarkeits-Gate deaktiviert (Modell nicht ladbar): %s", e) + _modell_cache = None + return _modell_cache + + +def _prosa(md: str) -> str: + """Markdown/Code strippen → reiner Fließtext für die Bewertung.""" + t = _CODE_FENCE.sub(" ", md) + t = _COMMENT.sub(" ", t) + t = _INLINE_CODE.sub(" ", t) + t = _LINK.sub(r"\1", t) + t = _MD_MARK.sub(" ", t) + return _WS.sub(" ", t).strip() + + +def _saetze(text: str) -> list[str]: + """Fließtext in Sätze splitten; sehr kurze Fragmente verwerfen.""" + return [s.strip() for s in _SATZ.split(text) if len(s.strip()) >= 15] + + +def _scores(saetze: list[str]) -> list[float]: + """Komplexität je Satz (1–7). Regression (num_labels=1) oder Erwartungswert über Klassen.""" + tok, model, torch = _modell_cache + werte: list[float] = [] + n = model.config.num_labels + for i in range(0, len(saetze), 16): + batch = saetze[i:i + 16] + enc = tok(batch, return_tensors="pt", truncation=True, max_length=256, padding=True) + with torch.no_grad(): + logits = model(**enc).logits + if n == 1: + vals = logits.reshape(-1).tolist() + else: + probs = torch.softmax(logits, dim=-1) + stufen = torch.arange(1, n + 1, dtype=probs.dtype) + vals = (probs * stufen).sum(-1).reshape(-1).tolist() + werte.extend(vals) + return werte + + +def bewerte_sections(md_by_num: dict[int, str]) -> dict[int, str]: + """{num: section_md} → {num: Hinweis} nur für zu schwere Sections. + + Leeres dict, wenn das Gate aus ist. Blockierend (CPU) — in to_thread aufrufen. + """ + if _modell() is None: + return {} + out: dict[int, str] = {} + for num, md in md_by_num.items(): + saetze = _saetze(_prosa(md or "")) + if len(saetze) < 2: # fast nur Code / zu kurz → überspringen + continue + werte = _scores(saetze) + if not werte: + continue + schnitt = sum(werte) / len(werte) + hart = sum(1 for w in werte if w > LESBARKEIT_HART) / len(werte) + # Zu schwer = hoher Schnitt ODER zu viele harte Einzelsätze (Ausreißer-Nester). + if schnitt > LESBARKEIT_MAX or hart >= LESBARKEIT_HART_ANTEIL: + out[num] = ( + f"Zu schwer lesbar (Ø {schnitt:.1f}/7, {hart * 100:.0f}% harte Sätze): " + "kürzere Sätze, einfachere Wörter, weniger Schachtelsätze, mehr Beispiele." + ) + return out diff --git a/backend/models.py b/backend/models.py index f889b8c..1899120 100644 --- a/backend/models.py +++ b/backend/models.py @@ -196,25 +196,11 @@ class ProgressResponse(BaseModel): # --- Baustein-Lernen --- -VertiefungArt = Literal["vertiefung", "deepdive"] - - -class VertiefungRequest(BaseModel): - topic: str = Field(min_length=1, max_length=100) - baustein: str = Field(min_length=1, max_length=200) - section: str = Field(default="", max_length=20000) - art: VertiefungArt = "vertiefung" - provider: ProviderType = "claude" - - -class VertiefungResponse(BaseModel): - md: str - - class BausteinChatRequest(BaseModel): topic: str = Field(min_length=1, max_length=100) baustein: str = Field(min_length=1, max_length=200) - section: str = Field(default="", max_length=20000) + section: str = Field(default="", max_length=20000) # ausführliche Fassung + section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze) messages: list[ChatMessage] = Field(min_length=1) provider: ProviderType = "claude" @@ -226,7 +212,8 @@ class BausteinChatResponse(BaseModel): class BausteinPruefungRequest(BaseModel): topic: str = Field(min_length=1, max_length=100) baustein: str = Field(min_length=1, max_length=200) - section: str = Field(default="", max_length=20000) + section: str = Field(default="", max_length=20000) # ausführliche Fassung + section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze) aktion: Literal["frage", "diskussion", "antwort", "antwort_pruefen"] = "frage" frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort) letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion) @@ -257,8 +244,6 @@ class BausteinLernstand(BaseModel): absolviert: bool verstanden: bool gemeistert: bool - vertiefung: bool - deepdive: bool class BausteinLernstandResponse(BaseModel): diff --git a/backend/requirements.txt b/backend/requirements.txt index 8d201bc..d9e4134 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,3 +2,5 @@ fastapi uvicorn[standard] aiosqlite playwright +transformers +torch diff --git a/backend/routes.py b/backend/routes.py index 0b0446e..c13af01 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -13,12 +13,11 @@ from database import ( create_topic, list_topics as db_list_topics, delete_topic, list_progress, set_progress, delete_progress, create_element, list_elements, get_element, update_element, delete_element, - get_vertiefung, set_vertiefung, list_vertiefungen, list_baustein_progress, set_baustein_score, set_baustein_streak, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten, ) from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion -from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, score_berechnen, streak_berechnen, vertiefung_generieren +from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, score_berechnen, streak_berechnen from guide import generate_guide, guide_slot_dateien from pipeline import cancel_guide from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen @@ -32,7 +31,6 @@ from models import ( ElementRefineRequest, ElementRefineResponse, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, FolderResponse, BausteineQuelleUpdate, BausteineQuelleResponse, BausteinUebersicht, - VertiefungRequest, VertiefungResponse, BausteinChatRequest, BausteinChatResponse, BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse, ) @@ -207,18 +205,12 @@ async def get_bausteine_uebersicht(topic: str): return lade_uebersicht(topic) -# --- Baustein-Lernen: Vertiefung, Chat, Prüfung --- - -async def _bester_text(topic: str, baustein: str) -> str | None: - """Kontext für Chat/Prüfung: Amateur-Fassung (art 'deepdive') bevorzugt, sonst Vertiefung.""" - return await get_vertiefung(topic, baustein, "deepdive") or await get_vertiefung(topic, baustein, "vertiefung") - +# --- Baustein-Lernen: Chat, Prüfung --- @router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse) async def baustein_lernstand(topic: str): - """Prüfungs-Stand + Vertiefungs-/Deepdive-Existenz pro Baustein (roher Titel als Key).""" + """Prüfungs-Stand pro Baustein (roher Titel als Key).""" progress = await list_baustein_progress(topic) - texte = await list_vertiefungen(topic) bausteine = { p["baustein"]: { "gute_antworten": p["gute_antworten"], @@ -226,44 +218,16 @@ async def baustein_lernstand(topic: str): "absolviert": p["absolviert"] is not None, "verstanden": p["verstanden"] is not None, "gemeistert": p["gemeistert"] is not None, - "vertiefung": "vertiefung" in texte.get(p["baustein"], set()), - "deepdive": "deepdive" in texte.get(p["baustein"], set()), } for p in progress } - for b, arten in texte.items(): - if b not in bausteine: - bausteine[b] = { - "gute_antworten": 0, "streak": 0, "absolviert": False, "verstanden": False, "gemeistert": False, - "vertiefung": "vertiefung" in arten, "deepdive": "deepdive" in arten, - } return {"bausteine": bausteine} -@router.get("/bausteine/vertiefung", response_model=VertiefungResponse) -async def get_baustein_vertiefung(topic: str, baustein: str, art: str = "vertiefung"): - if art not in ("vertiefung", "deepdive"): - raise HTTPException(400, "Unbekannte Art") - md = await get_vertiefung(topic, baustein, art) - if md is None: - raise HTTPException(404, "Kein Text vorhanden") - return {"md": md} - - -@router.post("/bausteine/vertiefung", response_model=VertiefungResponse) -async def create_baustein_vertiefung(req: VertiefungRequest): - md = await vertiefung_generieren(req.topic, req.baustein, req.section, art=req.art, provider=req.provider) - if md is None: - raise HTTPException(502, "Generierung fehlgeschlagen — bitte erneut versuchen") - await set_vertiefung(req.topic, req.baustein, req.art, md) - return {"md": md} - - @router.post("/bausteine/chat", response_model=BausteinChatResponse) async def baustein_chat_route(req: BausteinChatRequest): - vertiefung = await _bester_text(req.topic, req.baustein) reply = await baustein_chat( - req.topic, req.baustein, req.section, vertiefung, + req.topic, req.baustein, req.section, req.section_kompakt, [m.model_dump() for m in req.messages], provider=req.provider, ) return {"reply": reply} @@ -279,11 +243,11 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): absolviert = stand["absolviert"] is not None verstanden = stand["verstanden"] is not None gemeistert = stand["gemeistert"] is not None - vertiefung = await _bester_text(req.topic, req.baustein) + kompakt = req.section_kompakt msgs = [m.model_dump() for m in req.messages] if req.aktion == "frage": - frage = await pruefung_frage(req.topic, req.baustein, req.section, vertiefung, msgs, provider=req.provider) + frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, provider=req.provider) if frage is None: raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen") return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} @@ -292,7 +256,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if not req.frage.strip(): raise HTTPException(400, "Diskussion braucht eine laufende Frage") reply = await baustein_diskussion( - req.topic, req.baustein, req.section, vertiefung, + req.topic, req.baustein, req.section, kompakt, req.frage, req.letzte_bewertung or None, msgs, provider=req.provider, ) if reply is None: @@ -309,7 +273,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): if req.aktion == "antwort": # Vorschau: nur Bewerter, kein Kritiker, KEIN Persist, KEINE Meilensteine. data = await pruefung_bewertung_schnell( - req.topic, req.baustein, req.section, vertiefung, req.frage, msgs, gute, provider=req.provider, + req.topic, req.baustein, req.section, kompakt, req.frage, msgs, gute, provider=req.provider, ) if data is None: raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") @@ -319,7 +283,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest): # aktion == "antwort_pruefen": verbindlich (Bewerter + Kritiker), persistiert Score + Meilensteine. data = await pruefung_bewertung( - req.topic, req.baustein, req.section, vertiefung, req.frage, msgs, gute, provider=req.provider, + req.topic, req.baustein, req.section, kompakt, req.frage, msgs, gute, provider=req.provider, role="guide" if req.gruendlich else "judge", ) if data is None: diff --git a/backend/textkit.py b/backend/textkit.py index 4c84b8b..0f10e19 100644 --- a/backend/textkit.py +++ b/backend/textkit.py @@ -121,20 +121,25 @@ _FRAGMENT_KAPITEL_RE = re.compile(r"", re.IGNORE _FRAGMENT_SECTION_RE = re.compile(r"", re.IGNORECASE) _FRAGMENT_SUB_RE = re.compile(r"", re.IGNORECASE) _FRAGMENT_BAUSTEIN_RE = re.compile(r"", re.IGNORECASE) +# 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") def _parse_fragment(text: str) -> list[dict]: - """Parst eine Writer-Datei → [{"kapitel", "titel", "md", "subs"}] in Datei-Reihenfolge. + """Parst eine Writer-Datei → [{"kapitel", "titel", "md", "kompakt", "subs"}] in Reihenfolge. - Optionale Subbaustein-Marker `` innerhalb einer - Section sammeln sich in `sec["subs"] = [{"stufe", "titel", "md"}]`. `sec["md"]` - bleibt der vollständige Body (alle Sub-Texte zusammengesetzt) — auch ohne 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"]`. """ sections: list[dict] = [] kapitel = None current = None cur_sub = None + cur_layer = "md" # Default: alles ohne Schicht-Marker ist die ausführliche Fassung for line in text.splitlines(): s = line.strip() m = _FRAGMENT_KAPITEL_RE.match(s) @@ -145,10 +150,19 @@ def _parse_fragment(text: str) -> list[dict]: continue m = _FRAGMENT_SECTION_RE.match(s) if m: - current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "subs": []} + current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [], "subs": []} cur_sub = None + cur_layer = "md" sections.append(current) continue + if current is not None and _FRAGMENT_KOMPAKT_RE.match(s): + cur_layer = "kompakt" + cur_sub = None + continue + if current is not None and _FRAGMENT_AUSF_RE.match(s): + cur_layer = "md" + cur_sub = None + continue m = _FRAGMENT_SUB_RE.match(s) if m and current is not None: teil = m.group(1).split("|", 1) @@ -161,11 +175,12 @@ def _parse_fragment(text: str) -> list[dict]: current["subs"].append(cur_sub) continue if current is not None: - current["md"].append(line) - if cur_sub is not None: + current[cur_layer].append(line) + if cur_sub is not None and cur_layer == "md": cur_sub["md"].append(line) for sec in sections: sec["md"] = "\n".join(sec["md"]).strip() + sec["kompakt"] = "\n".join(sec["kompakt"]).strip() for sub in sec["subs"]: sub["md"] = "\n".join(sub["md"]).strip() sec["subs"] = [sub for sub in sec["subs"] if sub["md"]] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index dc287bf..9483fb8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -26,6 +26,7 @@ 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 ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt const stats = ref(null) const fortschritt = ref({}) const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle) @@ -201,6 +202,7 @@ function selectTopic(topic) { elementsView.value = false bausteineView.value = false elementOpenId.value = null + ansichtModus.value = localStorage.getItem('ansicht_' + topic) || 'kompakt' localStorage.setItem('lastTopic', topic) loadBausteine() nextTick(autoPreview) @@ -268,6 +270,15 @@ async function handleUpdateQuelle({ topic, type, ort, spec }) { await loadBausteine() // Schritt-Anzeige folgt ggf. der neuen Quelle (z.B. Link → „Quelle laden") } +function setAnsicht(modus) { + ansichtModus.value = modus + if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus) +} + +function toggleAnsicht() { + setAnsicht(ansichtModus.value === 'kompakt' ? 'erklärend' : 'kompakt') +} + function handleOpenBausteineView() { if (!selectedTopic.value) return bausteineView.value = true @@ -384,8 +395,10 @@ onMounted(async () => { :provider="provider" :providers="providers" :folders="folders" + :ansichtModus="ansichtModus" @setProvider="setProvider" @toggleDark="toggleDark" + @toggleAnsicht="toggleAnsicht" @select="selectTopic" @create="createTopic" @createThema="handleCreateThema" @@ -424,7 +437,9 @@ onMounted(async () => { :elementsOpen="elementsOpen" :doneByFormat="doneByFormat" :themaAbgeschlossen="!!fortschritt.abgeschlossen" + :ansichtModus="ansichtModus" @progressChanged="loadStats(); loadBausteine()" + @setAnsicht="setAnsicht" />

Thema in der Sidebar anlegen oder auswählen.

diff --git a/frontend/src/api.js b/frontend/src/api.js index 6a308fa..df92325 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -59,47 +59,31 @@ export async function deleteBausteine(topic) { await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' }) } -// --- Baustein-Lernen: Vertiefung, Chat, Prüfung --- +// --- Baustein-Lernen: Chat, Prüfung --- export async function fetchBausteinLernstand(topic) { const res = await fetch(`${BASE}/bausteine/lernstand?topic=${encodeURIComponent(topic)}`) return jsonOrThrow(res) } -export async function fetchVertiefung(topic, baustein, art = 'vertiefung') { - const res = await fetch( - `${BASE}/bausteine/vertiefung?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}&art=${art}` - ) - return jsonOrThrow(res) -} - -export async function createVertiefung({ topic, baustein, section, art = 'vertiefung', provider }) { - const res = await fetch(`${BASE}/bausteine/vertiefung`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, baustein, section, art, provider }), - }) - return jsonOrThrow(res) -} - -export async function chatBaustein({ topic, baustein, section, messages, provider }) { +export async function chatBaustein({ topic, baustein, section, section_kompakt = '', messages, provider }) { const res = await fetch(`${BASE}/bausteine/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, baustein, section, messages, provider }), + body: JSON.stringify({ topic, baustein, section, section_kompakt, messages, provider }), }) return jsonOrThrow(res) } export async function pruefeBaustein({ - topic, baustein, section, provider, + topic, baustein, section, section_kompakt = '', provider, aktion = 'frage', frage = '', letzte_bewertung = '', score_vor_frage = 0, streak = 0, tier2 = false, tier3 = false, messages = [], gruendlich = false, }) { const res = await fetch(`${BASE}/bausteine/pruefung`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, baustein, section, aktion, frage, letzte_bewertung, score_vor_frage, streak, tier2, tier3, messages, provider, gruendlich }), + body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, score_vor_frage, streak, tier2, tier3, messages, provider, gruendlich }), }) return jsonOrThrow(res) } diff --git a/frontend/src/components/BausteinFokus.vue b/frontend/src/components/BausteinFokus.vue index 5f15edb..25e2e9b 100644 --- a/frontend/src/components/BausteinFokus.vue +++ b/frontend/src/components/BausteinFokus.vue @@ -10,6 +10,7 @@ const props = defineProps({ tier2: { type: Boolean, default: false }, tier3: { type: Boolean, default: false }, tab: { type: String, default: 'pruefung' }, + ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte hasPrev: { type: Boolean, default: false }, hasNext: { type: Boolean, default: false }, fortschritt: { type: Object, default: () => ({ total: 0, absolviert: 0, verstanden: 0, gemeistert: 0 }) }, @@ -17,7 +18,7 @@ const props = defineProps({ import { computed, onMounted, onUnmounted, ref } from 'vue' -const emit = defineEmits(['close', 'prev', 'next', 'statusChanged']) +const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht']) const guideEl = ref(null) // linke Guide-Spalte (Scroll-Ziel für ALT+↑/↓) const rightEl = ref(null) // rechte Spalte (Prüfungs-Panel mit Eingabefeld) @@ -88,7 +89,7 @@ const standFarbe = computed(() => {
-
+
{ :topic="topic" :baustein="baustein.title" :section="baustein.md" + :section-kompakt="baustein.kompakt || ''" :provider="provider" :status="status" :tier2="tier2" :tier3="tier3" + :ansicht="ansicht" + @set-ansicht="$emit('setAnsicht', $event)" @status-changed="$emit('statusChanged', $event)" />
diff --git a/frontend/src/components/BausteinPanel.vue b/frontend/src/components/BausteinPanel.vue index 436e9ff..8e6da2c 100644 --- a/frontend/src/components/BausteinPanel.vue +++ b/frontend/src/components/BausteinPanel.vue @@ -1,6 +1,6 @@