Coverage for baibu/submissions/text.py: 100%
37 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"""Text normalisation and rule-based redaction.
3These rules run on every submission before any model sees it, so a
4deployment without a model still gets consistent text and basic redaction.
5"""
7import hashlib
8import re
9import unicodedata
10from dataclasses import dataclass
11from dataclasses import field
13_LINE_BREAKS = re.compile(r"\r\n?|[\u2028\u2029]")
14_SPACES = re.compile(r"[^\S\n]+")
15_BLANK_LINES = re.compile(r"\n{3,}")
16_WHITESPACE = re.compile(r"\s+")
18_EMAIL = re.compile(r"(?<![\w.+-])[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
19_URL = re.compile(r"\b(?:https?://|www\.)\S+", re.IGNORECASE)
20# Nine or more digits, optionally with a leading + and separators. Shorter
21# runs (years, dates, prices) are left alone.
22_PHONE = re.compile(r"(?<![\w+])\+?\d(?:[\s().-]{0,2}\d){8,}(?!\w)")
24REDACTIONS = (("email", _EMAIL, "[email]"), ("link", _URL, "[link]"), ("phone", _PHONE, "[phone]"))
27def normalise(text: str) -> str:
28 """NFC, Unix line breaks, no control characters, tidy spacing."""
29 text = unicodedata.normalize("NFC", text or "")
30 text = _LINE_BREAKS.sub("\n", text)
31 text = "".join(ch for ch in text if ch in "\n\t" or unicodedata.category(ch) != "Cc")
32 lines = [_SPACES.sub(" ", line).strip() for line in text.split("\n")]
33 return _BLANK_LINES.sub("\n\n", "\n".join(lines)).strip()
36def content_hash(text: str) -> str:
37 """Hash that ignores differences in spacing, for duplicate detection."""
38 collapsed = _WHITESPACE.sub(" ", normalise(text)).strip()
39 return hashlib.sha256(collapsed.encode()).hexdigest()
42def word_count(text: str) -> int:
43 return len(text.split())
46@dataclass
47class Redacted:
48 text: str
49 counts: dict[str, int] = field(default_factory=dict)
51 @property
52 def total(self) -> int:
53 return sum(self.counts.values())
56def redact(text: str) -> Redacted:
57 """Replace email addresses, links and phone numbers with placeholders."""
58 counts = {}
59 for kind, pattern, placeholder in REDACTIONS:
60 text, n = pattern.subn(placeholder, text)
61 if n:
62 counts[kind] = n
63 return Redacted(text=text, counts=counts)