116 lines
4.4 KiB
Python
116 lines
4.4 KiB
Python
"""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
|