Coverage for narrative_harm_classifier/classifier/rules/patterns_loader.py: 97%
38 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 13:25 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 13:25 +0000
1"""
2classifier/rules/patterns_loader.py — Per-language detection vocabulary.
4Loads data/patterns/<lang>.yaml files into a LanguagePatterns object with
5precompiled regexes (explicit precompilation rather than relying on Python's
6internal re.compile cache — deliberate now that pattern volume grows with
7each added language and the dog-whistle lexicon).
9Every file declares a `confidence` of "verified" or "experimental" — see
10README's Limitations section for what that distinction means in practice.
11Not every language has negation/counter-speech/benign-context cues defined;
12missing ones are treated as "no suppression available in this language yet"
13rather than silently reused from English.
14"""
16import re
17from dataclasses import dataclass, field
18from functools import lru_cache
19from pathlib import Path
20from typing import Optional
22from narrative_harm_classifier.core.yaml_loader import load_yaml_file
24DEFAULT_LANGUAGE = "en"
27def _compile_all(patterns: list[str]) -> list[re.Pattern]:
28 return [re.compile(p, re.IGNORECASE) for p in patterns]
31def _compile_dict(d: dict[str, list[str]]) -> dict[str, list[re.Pattern]]:
32 return {key: _compile_all(patterns) for key, patterns in d.items()}
35def _combine_and_compile(cue_list: Optional[list[str]]) -> Optional[re.Pattern]:
36 """Join a YAML list of literal cue phrases into a single compiled alternation."""
37 if not cue_list:
38 return None
39 alternation = "|".join(re.escape(cue) for cue in cue_list)
40 return re.compile(rf"\b({alternation})\b", re.IGNORECASE)
43@dataclass
44class LanguagePatterns:
45 language: str
46 confidence: str # "verified" | "experimental"
47 identity_anchors: dict[str, list[re.Pattern]] = field(default_factory=dict)
48 harm_patterns: dict[str, list[re.Pattern]] = field(default_factory=dict)
49 negation_cues: Optional[re.Pattern] = None
50 reporting_cues: Optional[re.Pattern] = None
51 condemnation_cues: Optional[re.Pattern] = None
52 benign_context_cues: Optional[re.Pattern] = None
53 obfuscation_map: dict[str, str] = field(default_factory=dict)
55 def deobfuscate(self, text_lower: str) -> str:
56 if not self.obfuscation_map:
57 return text_lower
58 return text_lower.translate(str.maketrans(self.obfuscation_map))
61@lru_cache(maxsize=32)
62def load_language_patterns(patterns_dir: str, language: str) -> LanguagePatterns:
63 """
64 Load and precompile a language's detection vocabulary.
66 Raises FileNotFoundError if the language isn't available — callers should
67 catch this and fall back to DEFAULT_LANGUAGE rather than let a typo
68 silently produce empty (always-no-harm) patterns.
69 """
70 path = Path(patterns_dir) / f"{language}.yaml"
71 raw = load_yaml_file(path)
73 return LanguagePatterns(
74 language=raw["language"],
75 confidence=raw["confidence"],
76 identity_anchors=_compile_dict(raw.get("identity_anchors", {})),
77 harm_patterns=_compile_dict(raw.get("harm_patterns", {})),
78 negation_cues=_combine_and_compile(raw.get("negation_cues")),
79 reporting_cues=_combine_and_compile(raw.get("reporting_cues")),
80 condemnation_cues=_combine_and_compile(raw.get("condemnation_cues")),
81 benign_context_cues=_combine_and_compile(raw.get("benign_context_cues")),
82 obfuscation_map=raw.get("obfuscation_map") or {},
83 )
86def available_languages(patterns_dir: str) -> list[str]:
87 return sorted(p.stem for p in Path(patterns_dir).glob("*.yaml"))