"""Matcher conservador de nomes de item para o InvoiceResolver. Porta o scoring determinístico do Contas original (grafia + fonética), mas não mantém LangChain/gateway próprio. O fallback conversacional é responsabilidade do agent_framework: quando mais de um candidato permanece plausível, o resultado é ambíguo e o runtime pede clarificação ao cliente. """ from __future__ import annotations import unicodedata from typing import Any try: import jellyfish # type: ignore except Exception: # pragma: no cover - fallback usado em ambientes mínimos jellyfish = None from difflib import SequenceMatcher from . import string_metrics as _fallback_metrics from .invoice_resolver import ItemMatcherError _TOP_K = 10 _MIN_TOKEN_LEN = 3 _GENERIC_TOKENS = frozenset({"app", "premium", "plus", "light", "mensal", "mes", "dados", "sva"}) def _normalize(text: str) -> str: decomposed = unicodedata.normalize("NFKD", str(text).lower()) return "".join(c for c in decomposed if not unicodedata.combining(c)) def _phrase_sim(a: str, b: str) -> float: if jellyfish is not None: return jellyfish.jaro_winkler_similarity(a, b) return _fallback_metrics.jaro_winkler_similarity(a, b) def _significant_tokens(text: str) -> list[str]: return [t for t in text.split() if len(t) >= _MIN_TOKEN_LEN and t not in _GENERIC_TOKENS] def _token_sim(mention: str, candidate: str) -> float: tokens = _significant_tokens(candidate) if not tokens: return _phrase_sim(mention, candidate) return max(_phrase_sim(mention, t) for t in tokens) def _code_sim(a: str, b: str) -> float: if not a or not b: return 0.0 if jellyfish is not None: distance = jellyfish.levenshtein_distance(a, b) return 1.0 - distance / max(len(a), len(b)) distance = _fallback_metrics.levenshtein_distance(a, b) return 1.0 - distance / max(len(a), len(b)) def _phon_sim(mention: str, candidate: str) -> float: code_m = jellyfish.metaphone(mention) if jellyfish is not None else _fallback_metrics.metaphone(mention) if not code_m: return 0.0 tokens = _significant_tokens(candidate) or [candidate] return max(_code_sim(code_m, jellyfish.metaphone(token) if jellyfish is not None else _fallback_metrics.metaphone(token)) for token in tokens) def _token_pair_sim(a: str, b: str) -> float: """Combina evidência ortográfica e fonética para um par de tokens. O pequeno bônus pela segunda evidência resolve transcrições curtas como ``apou`` -> ``apple`` sem transformar prefixos puramente gráficos em match. """ jw = _phrase_sim(a, b) code_a = jellyfish.metaphone(a) if jellyfish is not None else _fallback_metrics.metaphone(a) code_b = jellyfish.metaphone(b) if jellyfish is not None else _fallback_metrics.metaphone(b) ph = _code_sim(code_a, code_b) return min(1.0, max(jw, ph) + 0.15 * min(jw, ph)) def _token_alignment_sim(mention: str, candidate: str) -> float: mention_tokens = [t for t in mention.split() if len(t) >= 2] candidate_tokens = [t for t in candidate.split() if len(t) >= _MIN_TOKEN_LEN and t not in _GENERIC_TOKENS] if not mention_tokens or not candidate_tokens: return 0.0 # Cada token reconhecido pelo ASR precisa encontrar seu melhor correspondente. # A média impede que um token genérico perfeito (ex.: ``tim``) esconda o # discriminante errado (``miusic`` vs ``games``). return sum(max(_token_pair_sim(mt, ct) for ct in candidate_tokens) for mt in mention_tokens) / len(mention_tokens) def _grafia_sim(mention: str, candidate: str) -> float: return max(_phrase_sim(mention, candidate), _token_sim(mention, candidate)) class SimilarityItemMatcher: """Matcher síncrono compatível com ``InvoiceResolver.ItemMatcherLLM``. A implementação é deliberadamente fail-closed: só resolve automaticamente quando o melhor candidato tem score alto e margem suficiente. Empates plausíveis são devolvidos juntos para o framework pedir clarificação. """ def __init__(self, *, accept_threshold: float = 0.78, ambiguity_margin: float = 0.06, top_k: int = _TOP_K) -> None: self.accept_threshold = float(accept_threshold) self.ambiguity_margin = float(ambiguity_margin) self.top_k = int(top_k) def score(self, mention: str, candidate: str) -> float: nm, nd = _normalize(mention), _normalize(candidate) return max(_grafia_sim(nm, nd), _phon_sim(nm, nd), _token_alignment_sim(nm, nd)) def best_similarity(self, mention: str, candidates: list[str]) -> float: if not candidates: return 0.0 return max(self.score(mention, candidate) for candidate in candidates) def ranked(self, mention: str, candidates: list[str]) -> list[tuple[str, float]]: ranked = [(candidate, self.score(mention, candidate)) for candidate in candidates] ranked.sort(key=lambda pair: pair[1], reverse=True) return ranked[: self.top_k] def match(self, mention: str, candidates: list[str], *, callbacks: list[Any] | None = None) -> list[str]: del callbacks if not candidates: return [] ranked = self.ranked(mention, candidates) if not ranked or ranked[0][1] < self.accept_threshold: return [] best = ranked[0][1] plausible = [name for name, score in ranked if score >= self.accept_threshold and best - score <= self.ambiguity_margin] return plausible or [ranked[0][0]] __all__ = ["SimilarityItemMatcher"]