"""Deterministic readability gate for guide sections. A small German complexity model (DistilBERT, GermEval 2022, scale 1–7) rates the readability of the prose. guide.py feeds sections that are too hard into the existing read-exam/revision loop — no prompt, no guessing. Optional: if `transformers`/`torch` are missing or the model won't load, the gate is silently disabled (the backend keeps running unchanged). CPU is enough; the caller wraps the scoring in `asyncio.to_thread` (blocking model inference). """ import logging import re from config import ( READABILITY_ACTIVE, READABILITY_HARD, READABILITY_HARD_SHARE, READABILITY_MAX, READABILITY_MODEL, ) log = logging.getLogger("creator.readability") _model_cache = None # (tokenizer, model, torch) — singleton _load_attempt = False # already tried to load? # Strip markup → plain prose (code does not count toward readability). _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+") _SENTENCE = re.compile(r"(?<=[.!?])\s+") def _model(): """Load the model once. None = gate off (disabled or load error).""" global _model_cache, _load_attempt if _load_attempt: return _model_cache _load_attempt = True if not READABILITY_ACTIVE: return None try: import torch from transformers import AutoModelForSequenceClassification, AutoTokenizer tok = AutoTokenizer.from_pretrained(READABILITY_MODEL) model = AutoModelForSequenceClassification.from_pretrained(READABILITY_MODEL) model.eval() _model_cache = (tok, model, torch) log.info("readability model loaded: %s (num_labels=%d)", READABILITY_MODEL, model.config.num_labels) except Exception as e: log.warning("readability gate disabled (model not loadable): %s", e) _model_cache = None return _model_cache def _prose(md: str) -> str: """Strip markdown/code → plain prose for scoring.""" 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 _sentences(text: str) -> list[str]: """Split prose into sentences; discard very short fragments.""" return [s.strip() for s in _SENTENCE.split(text) if len(s.strip()) >= 15] def _scores(sentences: list[str]) -> list[float]: """Complexity per sentence (1–7). Regression (num_labels=1) or expectation over classes.""" tok, model, torch = _model_cache values: list[float] = [] n = model.config.num_labels for i in range(0, len(sentences), 16): batch = sentences[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) levels = torch.arange(1, n + 1, dtype=probs.dtype) vals = (probs * levels).sum(-1).reshape(-1).tolist() values.extend(vals) return values def rate_sections(md_by_num: dict[int, str]) -> dict[int, str]: """{num: section_md} → {num: hint} only for sections that are too hard. Empty dict if the gate is off. Blocking (CPU) — call inside to_thread. """ if _model() is None: return {} out: dict[int, str] = {} for num, md in md_by_num.items(): sentences = _sentences(_prose(md or "")) if len(sentences) < 2: # almost only code / too short → skip continue values = _scores(sentences) if not values: continue mean = sum(values) / len(values) hard = sum(1 for w in values if w > READABILITY_HARD) / len(values) # Too hard = high mean OR too many hard individual sentences (outlier nests). if mean > READABILITY_MAX or hard >= READABILITY_HARD_SHARE: # German revision hint fed to the (German-writing) writer agent — kept German on purpose. out[num] = ( f"Zu schwer lesbar (Ø {mean:.1f}/7, {hard * 100:.0f}% harte Sätze): " "kürzere Sätze, einfachere Wörter, weniger Schachtelsätze, mehr Examples." ) return out