Coverage for narrative_harm_classifier/classifier/rules/engine.py: 96%

133 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-20 13:25 +0000

1""" 

2classifier/rules/engine.py — Multi-dimensional, multi-language classification engine. 

3 

4Implements D2.4a specification: operationalizes relationships between 

5targets, identities, harm mechanisms, and decision thresholds. 

6 

7Architecture: 

8 1. Signal detection — pattern matching (per-language) + dog-whistle lexicon 

9 + Azure NLP entity/sentiment signals 

10 2. Suppression — negation / counter-speech / benign-context heuristics 

11 3. Signal scoring — weighted aggregation per taxonomy row 

12 4. Multi-signal resolution — ambiguity rules from taxonomy config 

13 5. Decision — threshold gating with rationale + provenance hash 

14""" 

15 

16import re 

17import logging 

18from pathlib import Path 

19from typing import Optional 

20 

21from narrative_harm_classifier.core.models import ( 

22 ClassifyRequest, 

23 ClassificationResult, 

24 SignalMatch, 

25 HarmCategory, 

26 TargetType, 

27 IdentityAxis, 

28) 

29from narrative_harm_classifier.classifier.taxonomy.loader import TaxonomyConfig, TaxonomyRow, CategorySpec 

30from narrative_harm_classifier.classifier.rules.azure_nlp import AzureNLPClient, AzureNLPResult 

31from narrative_harm_classifier.classifier.rules.patterns_loader import ( 

32 LanguagePatterns, 

33 load_language_patterns, 

34 DEFAULT_LANGUAGE, 

35) 

36from narrative_harm_classifier.classifier.rules.dogwhistles import DogwhistleLexicon, DogwhistleEntry 

37from narrative_harm_classifier.classifier.counter_narrative import guidance_for 

38from narrative_harm_classifier.classifier.provenance import content_hash 

39 

40logger = logging.getLogger(__name__) 

41 

42DEFAULT_PATTERNS_DIR = str(Path(__file__).parent.parent.parent / "data" / "patterns") 

43 

44# Negation cues are checked in a window immediately before a matched harm 

45# pattern ("these people are NOT vermin"), rather than across the whole 

46# text, so an unrelated un-negated claim later in the same text isn't 

47# suppressed by a negation word that has nothing to do with it. 

48_NEGATION_WINDOW_CHARS = 60 

49 

50 

51# --------------------------------------------------------------------------- 

52# Signal detection 

53# --------------------------------------------------------------------------- 

54 

55def _detect_identity_anchor(text: str, patterns: LanguagePatterns) -> Optional[str]: 

56 """Return the identity axis if a group mention is found in the text.""" 

57 text_lower = text.lower() 

58 deobf_lower = patterns.deobfuscate(text_lower) 

59 for axis, compiled_patterns in patterns.identity_anchors.items(): 

60 for compiled in compiled_patterns: 

61 if compiled.search(text_lower) or compiled.search(deobf_lower): 

62 return axis 

63 return None 

64 

65 

66def _detect_harm_signals( 

67 text: str, row: TaxonomyRow, patterns: LanguagePatterns 

68) -> tuple[bool, Optional[str], Optional[re.Match], str]: 

69 """ 

70 Check text against harm patterns for a given taxonomy row, against both 

71 the literal text and a deobfuscated ("v3rmin" -> "vermin") version. 

72 

73 Returns (matched, matched_pattern, match_object, matched_text_lower) — the 

74 match object and the text version it was found in are returned so callers 

75 can run negation-window / counter-speech checks against the same text 

76 the match actually came from. 

77 """ 

78 text_lower = text.lower() 

79 deobf_lower = patterns.deobfuscate(text_lower) 

80 compiled_patterns = patterns.harm_patterns.get(row.harm_mechanism, []) 

81 for compiled in compiled_patterns: 

82 match = compiled.search(text_lower) 

83 if match: 

84 return True, compiled.pattern, match, text_lower 

85 match = compiled.search(deobf_lower) 

86 if match: 

87 return True, compiled.pattern, match, deobf_lower 

88 return False, None, None, text_lower 

89 

90 

91def _azure_negative_amplifier(azure_result: Optional[AzureNLPResult]) -> float: 

92 """ 

93 Use Azure sentiment negativity as a signal amplifier (0.0 → 1.0). 

94 High negative sentiment boosts confidence; neutral is neutral. 

95 Falls back to 1.0 (no penalty) when Azure is unavailable. 

96 """ 

97 if azure_result is None or azure_result.is_fallback: 

98 return 1.0 # no penalty when Azure not configured 

99 # Map: 0.0 negative → 0.2, 1.0 negative → 1.0 

100 return 0.2 + (azure_result.sentiment_score_negative * 0.8) 

101 

102 

103# --------------------------------------------------------------------------- 

104# Suppression pipeline — negation / counter-speech / benign-context 

105# --------------------------------------------------------------------------- 

106# Each rule answers "should this matched signal be discarded?" A match is 

107# suppressed if ANY rule fires. Extracted into a uniform list (rather than 

108# three separate inline `if` statements) so a new heuristic — or a new 

109# language's cues — plugs in without restructuring classify() again. 

110 

111def _rule_negation(matched_text: str, match_obj: Optional[re.Match], patterns: LanguagePatterns) -> bool: 

112 if patterns.negation_cues is None or match_obj is None: 

113 return False 

114 window = matched_text[max(0, match_obj.start() - _NEGATION_WINDOW_CHARS) : match_obj.start()] 

115 return bool(patterns.negation_cues.search(window)) 

116 

117 

118def _rule_counter_speech(matched_text: str, match_obj: Optional[re.Match], patterns: LanguagePatterns) -> bool: 

119 if patterns.reporting_cues is None or patterns.condemnation_cues is None: 

120 return False 

121 return bool(patterns.reporting_cues.search(matched_text)) and bool( 

122 patterns.condemnation_cues.search(matched_text) 

123 ) 

124 

125 

126def _rule_benign_context(matched_text: str, match_obj: Optional[re.Match], patterns: LanguagePatterns) -> bool: 

127 if patterns.benign_context_cues is None: 

128 return False 

129 return bool(patterns.benign_context_cues.search(matched_text)) 

130 

131 

132_SUPPRESSION_RULES = (_rule_counter_speech, _rule_negation, _rule_benign_context) 

133 

134 

135def _is_suppressed(matched_text: str, match_obj: Optional[re.Match], patterns: LanguagePatterns) -> bool: 

136 return any(rule(matched_text, match_obj, patterns) for rule in _SUPPRESSION_RULES) 

137 

138 

139# identity_axis -> TargetType, shared by taxonomy rows and dog-whistle entries 

140_AXIS_TO_TARGET_TYPE = { 

141 "race_ethnicity": TargetType.ETHNIC_GROUP, 

142 "religion": TargetType.RELIGIOUS_GROUP, 

143 "gender": TargetType.GENDER_GROUP, 

144 "national_origin": TargetType.NATIONAL_ORIGIN_GROUP, 

145 "political_affiliation": TargetType.POLITICAL_GROUP, 

146} 

147 

148 

149# --------------------------------------------------------------------------- 

150# Engine 

151# --------------------------------------------------------------------------- 

152 

153class ClassificationEngine: 

154 """ 

155 Multi-signal, multi-language classification engine implementing the 

156 D2.4a spec plus an optional dog-whistle lexicon. 

157 

158 Signal resolution order: 

159 1. Load the request's language vocabulary (falls back to English) 

160 2. Identity anchor check (require_target_present rule) 

161 3. Per-row harm pattern matching + dog-whistle lexicon matching 

162 4. Suppression (negation / counter-speech / benign-context) 

163 5. Azure NLP sentiment amplification 

164 6. Weighted score aggregation 

165 7. Ambiguity resolution (highest_weight_wins / conservative tie-break) 

166 8. Threshold decision + provenance hash + counter-narrative guidance 

167 """ 

168 

169 def __init__( 

170 self, 

171 taxonomy: TaxonomyConfig, 

172 azure_client: Optional[AzureNLPClient] = None, 

173 patterns_dir: Optional[str] = None, 

174 dogwhistles: Optional[DogwhistleLexicon] = None, 

175 ): 

176 self.taxonomy = taxonomy 

177 self.azure = azure_client or AzureNLPClient() # fallback mode if no creds 

178 self.patterns_dir = patterns_dir or DEFAULT_PATTERNS_DIR 

179 self.dogwhistles = dogwhistles 

180 # harm_mechanism -> category name, derived once from the taxonomy, 

181 # so dog-whistle matches (which name a harm_mechanism directly, not 

182 # a category) can be scored through the same pipeline as taxonomy rows. 

183 self._mechanism_to_category = {row.harm_mechanism: category for category, row in taxonomy.all_rows()} 

184 

185 def _load_patterns(self, requested_language: str) -> tuple[LanguagePatterns, Optional[str]]: 

186 try: 

187 return load_language_patterns(self.patterns_dir, requested_language), None 

188 except FileNotFoundError: 

189 fallback = load_language_patterns(self.patterns_dir, DEFAULT_LANGUAGE) 

190 return fallback, f"Language '{requested_language}' not available — fell back to '{DEFAULT_LANGUAGE}'." 

191 

192 def classify(self, request: ClassifyRequest) -> ClassificationResult: 

193 text = request.text 

194 full_text = f"{request.context or ''} {text}".strip() 

195 patterns, language_fallback_note = self._load_patterns(request.language or DEFAULT_LANGUAGE) 

196 hash_value = content_hash(text, request.context, self.taxonomy.version) 

197 

198 # Step 1: Run Azure NLP (non-blocking — fallback if unavailable) 

199 azure_result = self.azure.analyze(full_text) 

200 

201 # Step 2: Check for identity anchor (target group presence) 

202 identity_axis_detected = _detect_identity_anchor(full_text, patterns) 

203 

204 rules = self.taxonomy.ambiguity_rules 

205 if rules.require_target_present and not identity_axis_detected: 

206 rationale = "No target group identity detected — require_target_present=True" 

207 if language_fallback_note: 

208 rationale += f" {language_fallback_note}" 

209 return self._no_harm_result(request, rationale, patterns, hash_value) 

210 

211 # Step 3: Score each taxonomy row + dog-whistle lexicon entries. 

212 # Each signal carries its own decision_threshold directly (rather 

213 # than being re-derived later via a row_id lookup) so a dog-whistle 

214 # match — which has no corresponding taxonomy row — can't fall 

215 # through to an unrelated config value as a threshold. 

216 # Each entry: (score, SignalMatch, CategorySpec, dogwhistle_term_or_None, decision_threshold) 

217 signal_matches: list[tuple[float, SignalMatch, CategorySpec, Optional[str], float]] = [] 

218 azure_amp = _azure_negative_amplifier(azure_result) 

219 

220 for category, row in self.taxonomy.all_rows(): 

221 harm_matched, pattern, match_obj, matched_text = _detect_harm_signals(full_text, row, patterns) 

222 if not harm_matched: 

223 continue 

224 if _is_suppressed(matched_text, match_obj, patterns): 

225 continue 

226 

227 cat_spec = self.taxonomy.get_category(category) 

228 score = row.signal_weight * azure_amp 

229 match = SignalMatch( 

230 row_id=row.row_id, 

231 harm_mechanism=row.harm_mechanism, 

232 target_type=TargetType(row.target_type) if row.target_type in TargetType._value2member_map_ else TargetType.UNKNOWN, 

233 identity_axis=IdentityAxis(row.identity_axis) if row.identity_axis in IdentityAxis._value2member_map_ else IdentityAxis.UNKNOWN, 

234 signal_weight=row.signal_weight, 

235 matched_pattern=pattern, 

236 azure_sentiment_score=azure_result.sentiment_score_negative if not azure_result.is_fallback else None, 

237 azure_entity_detected=None, 

238 ) 

239 signal_matches.append((score, match, cat_spec, None, row.decision_threshold)) 

240 

241 if self.dogwhistles is not None: 

242 for entry in self.dogwhistles.detect(full_text): 

243 category = self._mechanism_to_category.get(entry.harm_mechanism) 

244 cat_spec = self.taxonomy.get_category(category) if category else None 

245 if cat_spec is None: 

246 continue # dog-whistle maps to a mechanism this taxonomy doesn't define 

247 score = entry.signal_weight * azure_amp 

248 match = SignalMatch( 

249 row_id=f"DOGWHISTLE:{entry.term}", 

250 harm_mechanism=entry.harm_mechanism, 

251 target_type=_AXIS_TO_TARGET_TYPE.get(entry.identity_axis, TargetType.UNKNOWN), 

252 identity_axis=IdentityAxis(entry.identity_axis) if entry.identity_axis in IdentityAxis._value2member_map_ else IdentityAxis.UNKNOWN, 

253 signal_weight=entry.signal_weight, 

254 matched_pattern=entry.term, 

255 azure_sentiment_score=azure_result.sentiment_score_negative if not azure_result.is_fallback else None, 

256 azure_entity_detected=None, 

257 ) 

258 signal_matches.append((score, match, cat_spec, entry.term, entry.decision_threshold)) 

259 

260 # Step 4: Check min_signal_count 

261 if len(signal_matches) < rules.min_signal_count: 

262 rationale = f"Signal count {len(signal_matches)} below minimum {rules.min_signal_count}" 

263 return self._no_harm_result(request, rationale, patterns, hash_value) 

264 

265 # Step 5: Ambiguity resolution — highest_weight_wins 

266 if rules.multi_signal_conflict == "highest_weight_wins": 

267 signal_matches.sort(key=lambda x: x[0], reverse=True) 

268 

269 best_score, best_match, best_cat, best_dogwhistle_term, threshold = signal_matches[0] 

270 

271 # Step 6: Threshold decision 

272 # Conservative tie-break 

273 if best_score == threshold and rules.threshold_tie == "conservative": 

274 is_harmful = True 

275 else: 

276 is_harmful = best_score >= threshold 

277 

278 all_matches = [m for _, m, _, _, _ in signal_matches] 

279 

280 rationale = self._build_rationale( 

281 is_harmful, best_score, threshold, best_match, azure_result, identity_axis_detected 

282 ) 

283 if language_fallback_note: 

284 rationale += f" {language_fallback_note}" 

285 

286 category_map = { 

287 "dehumanization": HarmCategory.DEHUMANIZATION, 

288 "incitement": HarmCategory.INCITEMENT, 

289 "narrative_distortion": HarmCategory.NARRATIVE_DISTORTION, 

290 } 

291 

292 return ClassificationResult( 

293 request_id=request.request_id, 

294 text_excerpt=text[:200], 

295 is_harmful=is_harmful, 

296 harm_category=category_map.get(best_cat.name, HarmCategory.NONE) if is_harmful else HarmCategory.NONE, 

297 confidence=round(min(best_score, 1.0), 4), 

298 target_type=best_match.target_type if is_harmful else None, 

299 identity_axis=best_match.identity_axis if is_harmful else None, 

300 harm_mechanism=best_match.harm_mechanism if is_harmful else None, 

301 signals_matched=all_matches, 

302 decision_rationale=rationale, 

303 taxonomy_version=self.taxonomy.version, 

304 language=patterns.language, 

305 language_confidence=patterns.confidence, 

306 dogwhistle_matched=best_dogwhistle_term if is_harmful else None, 

307 counter_narrative_guidance=guidance_for(best_match.harm_mechanism) if is_harmful else None, 

308 content_hash=hash_value, 

309 ) 

310 

311 def _no_harm_result( 

312 self, request: ClassifyRequest, rationale: str, patterns: LanguagePatterns, hash_value: str 

313 ) -> ClassificationResult: 

314 return ClassificationResult( 

315 request_id=request.request_id, 

316 text_excerpt=request.text[:200], 

317 is_harmful=False, 

318 harm_category=HarmCategory.NONE, 

319 confidence=0.0, 

320 signals_matched=[], 

321 decision_rationale=rationale, 

322 taxonomy_version=self.taxonomy.version, 

323 language=patterns.language, 

324 language_confidence=patterns.confidence, 

325 content_hash=hash_value, 

326 ) 

327 

328 def _build_rationale( 

329 self, 

330 is_harmful: bool, 

331 score: float, 

332 threshold: float, 

333 match: SignalMatch, 

334 azure: AzureNLPResult, 

335 identity_axis: Optional[str], 

336 ) -> str: 

337 parts = [] 

338 if is_harmful: 

339 # Use the axis actually detected in the text rather than the 

340 # matched row's static tag when both are available — the two 

341 # can differ (a row tagged race_ethnicity can still fire on text 

342 # whose only identity anchor is national_origin), and reporting 

343 # both a static "targeting X" and a detected "Identity axis: Y" 

344 # in the same rationale read as self-contradictory. 

345 axis_for_rationale = identity_axis or match.identity_axis.value 

346 parts.append(f"HARM DETECTED: {match.harm_mechanism} targeting {axis_for_rationale}.") 

347 parts.append(f"Confidence {score:.3f} ≥ threshold {threshold:.3f}.") 

348 parts.append(f"Matched row {match.row_id}.") 

349 else: 

350 parts.append(f"NO HARM: score {score:.3f} below threshold {threshold:.3f}.") 

351 

352 if not azure.is_fallback: 

353 parts.append(f"Azure sentiment: {azure.sentiment} (neg={azure.sentiment_score_negative:.2f}).") 

354 else: 

355 parts.append("Azure NLP: fallback mode (no credentials).") 

356 

357 if identity_axis: 

358 parts.append(f"Identity axis detected: {identity_axis}.") 

359 

360 return " ".join(parts)