bugfix: search engine

This commit is contained in:
T3782834
2026-08-22 09:45:02 -03:00
parent dea818b845
commit 05373deff2
5 changed files with 392 additions and 49 deletions

View File

@@ -296,7 +296,7 @@ class InvoiceResolver:
for mention in mentioned_items:
if not isinstance(mention, str) or not mention.strip():
continue
resolved.extend(self._resolve_one_mention(mention, invoice_detail))
resolved.extend(self._resolve_one_mention(mention, invoice_detail, include_identity_oos=False))
return self._dedupe_exact_matches(resolved)
def resolve_each(
@@ -344,7 +344,7 @@ class InvoiceResolver:
ordered.append((idx, name))
lines[idx] = msisdn
dates[idx] = date
matches = self._resolve_one_mention(name, invoice_detail)
matches = self._resolve_one_mention(name, invoice_detail, include_identity_oos=True)
if matches:
deterministic[idx] = matches
elif (
@@ -537,6 +537,8 @@ class InvoiceResolver:
self,
mention: str,
invoice_detail: dict[str, Any],
*,
include_identity_oos: bool = False,
) -> list[ResolvedInvoiceItem]:
"""Match determinístico de UMA menção contra todas as combinações
msisdn × seção × entry, com precedência de match exato sobre substring.
@@ -564,20 +566,33 @@ class InvoiceResolver:
casava por substring o prefixo ``"VOD + Canais Abertos"`` — item errado;
a normalização a torna exata ao item ``+Fechados``.)"""
mention_normalized = self._normalize_match_text(mention)
# Primeiro procure igualdade EXATA em todo o catálogo da fatura, inclusive
# seções não acionáveis (Plano, descontos etc.). Isso é um guard de identidade:
# se o cliente nomeou precisamente um item conhecido, esse item precisa vencer
# antes de qualquer fuzzy matching. Sem isso, um plano explicitamente citado
# pode ser removido do universo tratável e o matcher acabar autorizando outro
# VAS apenas por similaridade (ex.: TIM CTRL Redes Sociais -> TIM Fashion).
if include_identity_oos:
identity_candidates = list(self._iter_identity_candidates(invoice_detail))
exact_identity = [
cand
for cand in identity_candidates
if mention_normalized
and self._normalize_match_text(cand.desc) == mention_normalized
]
if exact_identity:
return [self._build_item(cand) for cand in exact_identity]
# Fuzzy/substring continua restrito ao universo de serviços acionáveis +
# out-of-scope de seções de serviço. Seções explicitamente não acionáveis
# jamais entram no matcher aproximado; elas só podem bloquear por igualdade
# exata acima. Isso mantém o comportamento conservador do fluxo.
candidates = list(self._iter_candidates(invoice_detail))
exact_cands: list[_Candidate] = []
substring_cands: list[_Candidate] = []
for cand in candidates:
desc_normalized = self._normalize_match_text(cand.desc)
if mention_normalized and desc_normalized == mention_normalized:
exact_cands.append(cand)
elif self._was_mentioned(cand.desc, [mention]):
if self._was_mentioned(cand.desc, [mention]):
substring_cands.append(cand)
# Match exato (chave normalizada) tem precedência absoluta. ``canonical_name``
# e o payload seguem sempre o ``desc`` CRU da fatura — a normalização é só
# chave de comparação.
if exact_cands:
return [self._build_item(cand) for cand in exact_cands]
guarded = self._apply_prefix_guard(mention, substring_cands, candidates)
return [self._build_item(cand) for cand in guarded]
@@ -828,6 +843,45 @@ class InvoiceResolver:
# ----- construção de candidatos e itens --------------------------------
def _iter_identity_candidates(
self, invoice_detail: dict[str, Any]
) -> Iterable[_Candidate]:
"""Itera itens nomeáveis de TODAS as seções da fatura para match exato.
Diferente de :meth:`_iter_candidates`, este catálogo inclui também seções
não acionáveis como ``Plano``/``Planos``. Esses itens são construídos como
``out_of_scope`` e servem somente para preservar a identidade explicitamente
citada pelo cliente. Eles nunca participam do fuzzy matcher.
"""
for parent_msisdn, sections in self._iter_msisdn_buckets(invoice_detail):
if not isinstance(sections, dict):
continue
for section, entries in sections.items():
if not isinstance(entries, list):
continue
section_tool, section_type = SECTION_DEFAULTS.get(
section, (None, _OUT_OF_SCOPE_TYPE)
)
for entry in entries:
if not isinstance(entry, dict) or self._is_non_service_item(entry):
continue
desc = str(entry.get("desc") or "").strip()
if not desc:
continue
default_tool, default_type = section_tool, section_type
# Se a seção é explicitamente não acionável e a entry não foi
# carimbada como tratável pelo parser, force out_of_scope.
if section in _NON_SERVICE_SECTIONS and not self._has_treatable_flag(entry):
default_tool, default_type = None, _OUT_OF_SCOPE_TYPE
yield _Candidate(
desc=desc,
section=section,
default_tool=default_tool,
default_type=default_type,
parent_msisdn=parent_msisdn,
entry=entry,
)
def _iter_candidates(
self, invoice_detail: dict[str, Any]
) -> Iterable[_Candidate]: