bugfix: search engine
This commit is contained in:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user