"""Comparação semântica entre falas (opcional). A similaridade textual não cobre casos em que as palavras são diferentes mas a ideia é a mesma. Este módulo define um protocolo para providers de embeddings e uma implementação local (via Hugging Face Transformers), além de um fallback puramente lexical usado quando nenhum provider está disponível. O `ComparadorSemantico` nunca decide sozinho que há retake — apenas fornece uma métrica adicional para o classificador. """ from __future__ import annotations import math from typing import Protocol from .modelos_de_retakes import Fala class ProviderDeSimilaridadeSemantica(Protocol): """Contrato para quem calcula similaridade semântica entre dois textos.""" def similaridade(self, texto_a: str, texto_b: str) -> float: ... class ComparadorLexicalSemantico: """Fallback puramente lexical para quando não há embeddings. Reconstrói uma "similaridade semântica" a partir de palavras que compartilham a mesma raiz (stemming simples por prefixo comum) e de sinônimos frequentes em pt-PT. Destina-se a permitir executar a análise sem carregar modelos pesados. """ _PARES_SINONIMOS = ( ({"mostrar", "explicar", "demonstrar", "apresentar"},), ({"sistema", "programa", "aplicativo", "software"},), ({"configurar", "configuracao", "instalar", "ajustar"},), ) def similaridade(self, texto_a: str, texto_b: str) -> float: palavras_a = {p for p in self._palavras(texto_a)} palavras_b = {p for p in self._palavras(texto_b)} if not palavras_a or not palavras_b: return 0.0 intersecao = 0.0 for palavra_a in palavras_a: for palavra_b in palavras_b: if palavra_a == palavra_b: intersecao += 1.0 elif self._mesma_raiz(palavra_a, palavra_b): intersecao += 0.7 elif self._mesmo_sinonimo(palavra_a, palavra_b): intersecao += 0.6 tam = max(len(palavras_a), len(palavras_b)) return round(min(1.0, intersecao / tam), 4) @staticmethod def _palavras(texto: str) -> list[str]: return [p for p in texto.lower().split()] @staticmethod def _mesma_raiz(a: str, b: str) -> bool: raiz = min(len(a), len(b), 4) return raiz >= 4 and a[:raiz] == b[:raiz] @staticmethod def _mesmo_sinonimo(a: str, b: str) -> bool: return any(a in grupo and b in grupo for grupo in ComparadorLexicalSemantico._PARES_SINONIMOS) class ComparadorSemantico: """Calcula similaridade semântica usando um provider injetável. Quando o provider é ``None``, cai no ``ComparadorLexicalSemantico`` para não bloquear a análise na ausência de modelos locais. """ def __init__(self, provider: ProviderDeSimilaridadeSemantica | None = None) -> None: self.provider = provider or ComparadorLexicalSemantico() def comparar(self, fala_a: Fala, fala_b: Fala) -> float: return float(self.provider.similaridade(fala_a.texto, fala_b.texto)) class ProviderDeSimilaridadeHuggingFace: """Embeds os textos localmente com um modelo de embeddings. O modelo é carregado de forma preguiçosa para não custar nada até ser de fato necessário (zona de dúvida do classificador). """ def __init__(self, modelo: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") -> None: self.modelo = modelo self._encoder = None def similaridade(self, texto_a: str, texto_b: str) -> float: if self._encoder is None: from sentence_transformers import SentenceTransformer self._encoder = SentenceTransformer(self.modelo) embeddings = self._encoder.encode([texto_a, texto_b], normalize_embeddings=True) return float(_cosseno(embeddings[0], embeddings[1])) def _cosseno(a: list[float], b: list[float]) -> float: produto = sum(x * y for x, y in zip(a, b)) norma_a = math.sqrt(sum(x * x for x in a)) norma_b = math.sqrt(sum(y * y for y in b)) if norma_a == 0 or norma_b == 0: return 0.0 return max(0.0, min(1.0, produto / (norma_a * norma_b)))