"""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