from dataclasses import dataclass import re import unicodedata from app.domain.entities import ClassificationLabel, ClassificationResult, ModelScores HATER_PATTERNS = [ r"\bmu[eé]rete\b", r"\bmu[eé]ranse\b", r"\bmuere\b", r"\bque\s+te\s+mueras\b", r"\bojal[aá]\s+(?:que\s+)?(?:te\s+)?mueras\b", r"\bte\s+voy\s+a\s+matar\b", r"\bdeber[ií]as\s+morir\b", r"\bmaldito(?:s|as)?\b", r"\bescoria\b", ] CRITICAL_PATTERNS = [ r"\bhorrible\b", r"\basqueros[oa]s?\b", r"\bapesta\b", r"\binfumable\b", r"\bbasura\b", r"\bp[eé]sim[oa]\b", r"\bmal[ií]sim[oa]\b", r"\bmalo\s+totalmente\b", r"\bno\s+me\s+gusta\b", r"\bdecepcionante\b", r"\bdesastre\b", r"\bvergonzoso\b", ] @dataclass(frozen=True) class ThresholdConfig: hater_hs: float = 0.70 hater_ag: float = 0.80 hater_borderline_hs: float = 0.55 hater_borderline_ag: float = 0.55 critical_hs: float = 0.25 critical_ag: float = 0.25 critical_tr: float = 0.25 class HateSpeechThresholdMapper: def __init__(self, config: ThresholdConfig): self._config = config def map(self, scores: ModelScores) -> ClassificationResult: hs = self._clamp(scores.hs) tr = self._clamp(scores.tr) ag = self._clamp(scores.ag) text_match = self._match_text_rules(scores.text) hater_score = max(hs, ag) critical_score = max(hs, ag, tr) neutral_score = self._clamp(1 - critical_score) if text_match and text_match["label"] == ClassificationLabel.HATER: label = ClassificationLabel.HATER confidence = max(hater_score, 0.95) hater_score = max(hater_score, 0.95) critical_score = max(critical_score, 0.95) neutral_score = self._clamp(1 - hater_score) elif text_match and text_match["label"] == ClassificationLabel.CRITICAL: label = ClassificationLabel.CRITICAL confidence = max(critical_score, 0.85) critical_score = max(critical_score, 0.85) neutral_score = self._clamp(1 - critical_score) elif self._is_hater(hs, ag): label = ClassificationLabel.HATER confidence = hater_score elif self._is_critical(hs, tr, ag): label = ClassificationLabel.CRITICAL confidence = critical_score else: label = ClassificationLabel.NEUTRAL confidence = neutral_score return ClassificationResult( predicted_label=label, confidence=round(confidence, 4), hater_score=round(hater_score, 4), critical_score=round(critical_score, 4), neutral_score=round(neutral_score, 4), raw_response={ "model_scores": {"HS": hs, "TR": tr, "AG": ag}, "model_output": scores.raw, "thresholds": self._config.__dict__, "rule_match": self._serialize_rule_match(text_match), }, ) def _is_hater(self, hs: float, ag: float) -> bool: return ( hs >= self._config.hater_hs or ag >= self._config.hater_ag or ( hs >= self._config.hater_borderline_hs and ag >= self._config.hater_borderline_ag ) ) def _is_critical(self, hs: float, tr: float, ag: float) -> bool: return ( hs >= self._config.critical_hs or ag >= self._config.critical_ag or tr >= self._config.critical_tr ) @staticmethod def _clamp(value: float) -> float: return max(0.0, min(1.0, float(value or 0.0))) @classmethod def _match_text_rules(cls, text: str): normalized = cls._normalize_text(text) if not normalized: return None for pattern in HATER_PATTERNS: if re.search(pattern, normalized): return { "label": ClassificationLabel.HATER, "pattern": pattern, "source": "text_rule", } for pattern in CRITICAL_PATTERNS: if re.search(pattern, normalized): return { "label": ClassificationLabel.CRITICAL, "pattern": pattern, "source": "text_rule", } return None @staticmethod def _normalize_text(text: str) -> str: text = unicodedata.normalize("NFKD", text or "") text = "".join(char for char in text if not unicodedata.combining(char)) text = text.lower() return re.sub(r"\s+", " ", text).strip() @staticmethod def _serialize_rule_match(match): if not match: return None return { "label": match["label"].value, "pattern": match["pattern"], "source": match["source"], }