aegis-ml / app /classifiers /text_preprocessor.py
billybitcoin's picture
Deploy Aegis-ML to HF Spaces
5c76335 verified
Raw
History Blame Contribute Delete
4.48 kB
"""
app/classifiers/text_preprocessor.py
======================================
Unicode-aware text preprocessor that runs before any classifier.
Responsibilities:
1. Strip invisible / control Unicode characters and flag their presence
(zero-width spaces, RTL overrides, soft hyphens, etc.)
2. Apply NFKC Unicode normalization to collapse compatibility variants and
homoglyph lookalikes (e.g. Cyrillic 'а' → Latin 'a')
3. Return cleaned text + metadata dict for downstream bias adjustments
The preprocessor is a pure-Python, ML-free module with no heavy dependencies.
It is called before every classifier (not just hf2) so that even the fast
sklearn path benefits from Unicode normalization.
Metadata flags drive the invisible-char probability bias in input_guard.py:
- had_invisible_chars: True if zero-width or other invisible chars were found
- had_rtl_override: True if a bidirectional control override was found
- had_homoglyphs: True if characters that NFKC normalized significantly differ
- unicode_normalized: True if NFKC changed the text at all
"""
from __future__ import annotations
import unicodedata
class TextPreprocessor:
"""
Stateless text preprocessor for adversarial Unicode normalization.
Usage:
preprocessor = TextPreprocessor()
cleaned, meta = preprocessor.preprocess(raw_text)
# Use cleaned for classifier, meta for bias decisions
"""
# Characters that are invisible / zero-width in most rendering contexts
# and have no semantic value in legitimate user input.
INVISIBLE_CHARS: frozenset[str] = frozenset([
'\u200B', # zero-width space
'\u200C', # zero-width non-joiner
'\u200D', # zero-width joiner
'\u2060', # word joiner
'\u00AD', # soft hyphen
'\uFEFF', # BOM / zero-width no-break space
'\u200E', # left-to-right mark
'\u200F', # right-to-left mark
'\u202A', # LTR embedding
'\u202B', # RTL embedding
'\u202C', # pop directional formatting
'\u202D', # LTR override
'\u202E', # RTL override (most dangerous — reverses displayed text)
'\u2066', # LTR isolate
'\u2067', # RTL isolate
'\u2068', # first strong isolate
'\u2069', # pop directional isolate
])
# RTL override chars are a strong signal on their own
RTL_OVERRIDE_CHARS: frozenset[str] = frozenset([
'\u202E', # RTL override
'\u202B', # RTL embedding
'\u2067', # RTL isolate
])
def preprocess(self, text: str) -> tuple[str, dict[str, bool]]:
"""
Normalize *text* for safe classifier input.
Returns:
(cleaned_text, metadata)
metadata keys:
had_invisible_chars: bool — invisible/zero-width chars were found
had_rtl_override: bool — RTL control chars were found (strong signal)
unicode_normalized: bool — NFKC changed the text (covers homoglyphs)
"""
if not text:
return text, {
"had_invisible_chars": False,
"had_rtl_override": False,
"unicode_normalized": False,
}
had_invisible = False
had_rtl = False
# ── Step 1: detect and strip invisible/control chars ──────────────────
stripped_chars: list[str] = []
for ch in text:
if ch in self.INVISIBLE_CHARS:
had_invisible = True
if ch in self.RTL_OVERRIDE_CHARS:
had_rtl = True
# Strip — do not add to output
continue
stripped_chars.append(ch)
stripped = "".join(stripped_chars)
# ── Step 2: NFKC normalization ─────────────────────────────────────────
# NFKC collapses compatibility variants and maps many Unicode lookalikes
# (Cyrillic, Greek, fullwidth, superscript digits) to their ASCII
# equivalents. This handles the 'homoglyph' attack class automatically.
normalized = unicodedata.normalize("NFKC", stripped)
unicode_normalized = normalized != text
return normalized, {
"had_invisible_chars": had_invisible,
"had_rtl_override": had_rtl,
"unicode_normalized": unicode_normalized,
}