Coverage for baibu/submissions/cleaners.py: 100%
76 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Cleaners: the pluggable step that checks and cleans submitted text.
3Every submission first goes through the rules in :mod:`.text`. The cleaner
4named by ``SUBMISSION_CLEANER`` then runs on the result:
6- ``RuleCleaner`` (default): the rules only. No model, no network.
7- ``LiteLLMCleaner``: asks a language model, through LiteLLM, to redact
8 remaining personal data and rate toxicity and quality. The deployment
9 chooses the model and endpoint (``SUBMISSION_CLEANER_MODEL`` and friends).
10- ``MockCleaner``: deterministic stand-in for a model, for tests and demos.
12A cleaner returns a :class:`CleaningResult` or raises :class:`CleanerError`.
13"""
15import json
16import re
17from dataclasses import dataclass
18from dataclasses import field
19from functools import cache
21from django.conf import settings
22from django.template.loader import render_to_string
23from django.utils.module_loading import import_string
25from . import text as text_rules
28class CleanerError(Exception):
29 """The cleaner could not produce a result (for example, the model failed)."""
32@dataclass
33class CleaningResult:
34 text: str
35 cleaner: str
36 redaction_count: int = 0
37 toxicity_detected: bool | None = None
38 quality_score: int | None = None
39 detected_language: str = ""
40 flags: list[str] = field(default_factory=list)
43class Cleaner:
44 name = "cleaner"
46 def clean(self, text: str, *, language_code: str) -> CleaningResult:
47 raise NotImplementedError
50def apply_rules(raw_text: str) -> text_rules.Redacted:
51 return text_rules.redact(text_rules.normalise(raw_text))
54class RuleCleaner(Cleaner):
55 """Normalisation and pattern redaction only."""
57 name = "rules"
59 def clean(self, text: str, *, language_code: str) -> CleaningResult:
60 ruled = apply_rules(text)
61 return CleaningResult(text=ruled.text, cleaner=self.name, redaction_count=ruled.total)
64class MockCleaner(RuleCleaner):
65 """Behaves like a model cleaner without calling one.
67 Text containing a word from ``toxic_words`` is flagged as toxic; the
68 quality score grows with length; the language is echoed back.
69 """
71 name = "mock"
72 toxic_words = frozenset({"mocktoxic"})
74 def clean(self, text: str, *, language_code: str) -> CleaningResult:
75 result = super().clean(text, language_code=language_code)
76 words = {w.strip(".,!?;:").lower() for w in result.text.split()}
77 result.toxicity_detected = bool(words & self.toxic_words)
78 result.quality_score = min(100, 10 * text_rules.word_count(result.text))
79 result.detected_language = language_code
80 result.cleaner = self.name
81 return result
84_JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
87class LiteLLMCleaner(RuleCleaner):
88 """Rules first, then a language model reached through LiteLLM.
90 The instructions come from the template ``submissions/cleaner_prompt.txt``,
91 which a deployment can override. The model must answer with one JSON
92 object: ``text``, ``redactions``, ``toxic``, ``quality``, ``language``.
93 """
95 name = "litellm"
97 def clean(self, text: str, *, language_code: str) -> CleaningResult:
98 import litellm
100 ruled = super().clean(text, language_code=language_code)
101 model = settings.SUBMISSION_CLEANER_MODEL
102 if not model:
103 msg = "SUBMISSION_CLEANER_MODEL is not set."
104 raise CleanerError(msg)
105 prompt = render_to_string("submissions/cleaner_prompt.txt", {"language_code": language_code})
106 try:
107 response = litellm.completion(
108 model=model,
109 messages=[{"role": "system", "content": prompt}, {"role": "user", "content": ruled.text}],
110 api_base=settings.SUBMISSION_CLEANER_API_BASE or None,
111 api_key=settings.SUBMISSION_CLEANER_API_KEY or None,
112 timeout=settings.SUBMISSION_CLEANER_TIMEOUT,
113 )
114 content = response.choices[0].message.content or ""
115 except Exception as exc:
116 msg = f"Model call failed: {type(exc).__name__}"
117 raise CleanerError(msg) from exc
118 answer = _parse_answer(content)
119 return CleaningResult(
120 text=text_rules.normalise(answer["text"]),
121 cleaner=f"{self.name}:{getattr(response, 'model', None) or model}"[:128],
122 redaction_count=ruled.redaction_count + answer["redactions"],
123 toxicity_detected=answer["toxic"],
124 quality_score=answer["quality"],
125 detected_language=answer["language"],
126 )
129def _parse_answer(content: str) -> dict:
130 match = _JSON_BLOCK.search(content)
131 try:
132 data = json.loads(match.group(0) if match else content)
133 except json.JSONDecodeError as exc:
134 msg = "Model answer is not JSON."
135 raise CleanerError(msg) from exc
136 if not isinstance(data, dict) or not isinstance(data.get("text"), str):
137 msg = "Model answer has no text."
138 raise CleanerError(msg)
139 quality = data.get("quality")
140 redactions = data.get("redactions")
141 return {
142 "text": data["text"],
143 "redactions": redactions if isinstance(redactions, int) and redactions >= 0 else 0,
144 "toxic": data["toxic"] if isinstance(data.get("toxic"), bool) else None,
145 "quality": max(0, min(100, quality)) if isinstance(quality, int) and not isinstance(quality, bool) else None,
146 "language": str(data.get("language") or "")[:24],
147 }
150@cache
151def _cleaner_class(path: str) -> type[Cleaner]:
152 return import_string(path)
155def get_cleaner() -> Cleaner:
156 return _cleaner_class(settings.SUBMISSION_CLEANER)()