Files
creator/backend/lesbarkeit.py
2026-06-18 16:45:33 +02:00

116 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Deterministisches Lesbarkeits-Gate für Guide-Sections.
Ein kleines deutsches Komplexitäts-Modell (DistilBERT, GermEval 2022, Skala 17)
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 (17). 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