Files
agent_contas/tests/migration/original_test_invoice_resolver_full.py

1145 lines
44 KiB
Python

"""Testes do InvoiceResolver: matching estruturado entre fala do cliente e
``invoice_detail``. Cobre as seções suportadas, dedupe, classificação por
seção, match bidirecional, parse monetário e os
casos de borda do shape do payload."""
from __future__ import annotations
from decimal import Decimal
from typing import Any
import pytest
from app.domain.contas.invoice_resolver import (
SECTION_DEFAULTS,
STRATEGIC_NAMES,
InvoiceResolver,
ItemMatcherError,
)
from app.domain.contas.invoice_models import (
MentionedItem,
ResolvedInvoiceItem,
)
# ----- fixtures ---------------------------------------------------------------
def _entry(desc: str, value: Any, msisdn: str = "11999999999") -> dict[str, Any]:
return {"desc": desc, "value": value, "msisdn": msisdn}
@pytest.fixture
def invoice_detail() -> dict[str, Any]:
"""``invoice_detail`` realista cobrindo as 4 seções suportadas e duas linhas
(titular + dependente). Inclui chaves de metadado para garantir que são
ignoradas pelo iterador."""
return {
"Fatura Resumo": {"total": 199.90},
"vocalized_msisdn": {"11999999999": "nove nove nove nove"},
"DANFE-COM": [],
"11999999999": {
"Plano": [_entry("TIM Black 80GB", 79.90)],
"SVA Detalhe Total": [
_entry("TIM Fashion Mensal", 10.00),
_entry("Aluguel de Filme 1", 9.90),
_entry("Aluguel de Filme 2", 9.90),
_entry("Galinha Pintadinha", 7.99),
],
"Serviços Contratados de Terceiros": [
_entry("Netflix Premium", 39.90),
],
"Serviços Bundle Inclusos": [
_entry("HBO Max", 0.00),
],
"Itens Eventuais": [
_entry("Pacote Internacional", 25.00),
],
},
"11988888888": {
"SVA Detalhe Total": [
_entry("Tamboro Mensal", 5.99, msisdn="11988888888"),
],
},
}
# ----- resolve_items (caminho feliz) -----------------------------------------
def test_resolve_avulso_em_sva_detalhe_total(invoice_detail) -> None:
items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
assert len(items) == 1
item = items[0]
assert item.canonical_name == "TIM Fashion Mensal"
assert item.tool_category == "cancelar_vas_avulso"
assert item.item_type == "avulso"
assert item.msisdn == "11999999999"
assert item.section == "SVA Detalhe Total"
assert item.value == Decimal("10.00")
def test_resolve_estrategico_via_secao_terceiros(invoice_detail) -> None:
"""Item em ``Serviços Contratados de Terceiros`` segue para VAS estratégico
pela seção da fatura."""
items = InvoiceResolver().resolve_items(["netflix"], invoice_detail)
assert len(items) == 1
item = items[0]
assert item.canonical_name == "Netflix Premium"
assert item.tool_category == "vas_estrategico"
assert item.item_type == "estrategico"
assert item.section == "Serviços Contratados de Terceiros"
def test_resolve_bundle_sempre_classifica_como_bundle(invoice_detail) -> None:
"""Bundle Inclusos sempre classifica como ``bundle`` por estar nessa seção."""
items = InvoiceResolver().resolve_items(["HBO"], invoice_detail)
assert len(items) == 1
item = items[0]
assert item.canonical_name == "HBO Max"
assert item.tool_category == "vas_estrategico"
assert item.item_type == "bundle" # NÃO "estrategico"
assert item.section == "Serviços Bundle Inclusos"
def test_resolve_avulso_em_itens_eventuais(invoice_detail) -> None:
items = InvoiceResolver().resolve_items(["Pacote Internacional"], invoice_detail)
assert len(items) == 1
assert items[0].tool_category == "cancelar_vas_avulso"
assert items[0].item_type == "avulso"
assert items[0].section == "Itens Eventuais"
# ----- múltiplos itens / múltiplos MSISDNs -----------------------------------
def test_resolve_multiplos_itens_em_categorias_diferentes(invoice_detail) -> None:
items = InvoiceResolver().resolve_items(
["tim fashion", "netflix", "HBO"], invoice_detail
)
by_name = {i.canonical_name: i for i in items}
assert set(by_name) == {"TIM Fashion Mensal", "Netflix Premium", "HBO Max"}
assert by_name["TIM Fashion Mensal"].tool_category == "cancelar_vas_avulso"
assert by_name["Netflix Premium"].tool_category == "vas_estrategico"
assert by_name["HBO Max"].tool_category == "vas_estrategico"
def test_resolve_itens_parecidos_nao_sao_deduplicados(invoice_detail) -> None:
"""'Aluguel de Filme' casa com '1', '2'. Como os ``canonical_name`` são
diferentes, ambos devem ficar — dedupe é apenas para colisão exata."""
items = InvoiceResolver().resolve_items(["Aluguel de Filme"], invoice_detail)
names = sorted(i.canonical_name for i in items)
assert names == ["Aluguel de Filme 1", "Aluguel de Filme 2"]
def test_match_exato_tem_precedencia_sobre_substring_de_outro_item() -> None:
"""Regressão CY0004: quando a fatura tem 'VOD + Canais' e 'VOD + Canais
Abertos' (um é substring do outro), o cliente que cita ambos por nome
canônico exato deve resolver cada um individualmente, sem ambiguidade.
Sem essa precedência, ambos virariam ambíguos e a fila perderia avulsos.
"""
detail = {
"11964200059": {
"SVA Detalhe Total": [
{"desc": "VOD + Canais Abertos", "value": 19.90, "msisdn": "11964200059"},
{"desc": "VOD + Canais", "value": 19.90, "msisdn": "11964200059"},
],
}
}
outcome = InvoiceResolver().resolve(
["VOD + Canais Abertos", "VOD + Canais"], detail
)
# Cada menção bate em EXATAMENTE um item, sem ambiguidade
assert [i.canonical_name for i in outcome.resolved] == [
"VOD + Canais Abertos",
"VOD + Canais",
]
assert outcome.ambiguous == []
assert outcome.not_found == []
def test_match_substring_continua_funcionando_quando_nao_ha_exato() -> None:
"""Quando o cliente cita só "VOD" (sem o nome canônico completo), o
matching cai em substring bidirecional e ambos os "VOD + Canais ..."
casam. Comportamento esperado de ambiguidade legítima — preserva o caso
"dois ayas" e similar."""
detail = {
"11964200059": {
"SVA Detalhe Total": [
{"desc": "VOD + Canais Abertos", "value": 19.90, "msisdn": "11964200059"},
{"desc": "VOD + Canais", "value": 19.90, "msisdn": "11964200059"},
],
}
}
outcome = InvoiceResolver().resolve(["VOD"], detail)
# Sem match exato, fallback substring acha os dois → ambíguo
assert outcome.resolved == []
assert len(outcome.ambiguous) == 1
assert {m.canonical_name for m in outcome.ambiguous[0].matches} == {
"VOD + Canais Abertos",
"VOD + Canais",
}
def test_match_exato_case_insensitive_e_trim() -> None:
"""Match exato ignora case e espaços nas pontas. Tolera variações
inofensivas do classifier sem inflar ambiguidade."""
detail = {
"11964200059": {
"SVA Detalhe Total": [
{"desc": "VOD + Canais Abertos", "value": 19.90, "msisdn": "11964200059"},
{"desc": "VOD + Canais", "value": 19.90, "msisdn": "11964200059"},
],
}
}
outcome = InvoiceResolver().resolve(
[" vod + canais abertos ", "VOD + CANAIS"], detail
)
assert [i.canonical_name for i in outcome.resolved] == [
"VOD + Canais Abertos",
"VOD + Canais",
]
assert outcome.ambiguous == []
def test_msisdn_vem_da_entrada_nao_da_linha_titular(invoice_detail) -> None:
"""'Tamboro' aparece só no MSISDN dependente — o item resolvido carrega o
MSISDN da entrada, NUNCA o do titular."""
items = InvoiceResolver().resolve_items(["Tamboro"], invoice_detail)
assert len(items) == 1
assert items[0].msisdn == "11988888888"
def test_msisdn_cai_no_bucket_quando_entrada_nao_traz(invoice_detail) -> None:
"""Se o ``msisdn`` da entrada vier vazio, usamos o bucket pai."""
invoice_detail["11999999999"]["SVA Detalhe Total"][0]["msisdn"] = ""
items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
assert items[0].msisdn == "11999999999"
# ----- dedupe -----------------------------------------------------------------
def test_dedupe_remove_entry_duplicada_na_mesma_secao(invoice_detail) -> None:
"""Mesmo ``desc`` + mesmo ``msisdn`` na mesma seção (duplicata real):
fica uma só."""
invoice_detail["11999999999"]["SVA Detalhe Total"].append(
_entry("TIM Fashion Mensal", 10.00)
)
items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
assert len(items) == 1
def test_dedupe_nao_colapsa_mesma_desc_em_msisdns_diferentes(invoice_detail) -> None:
"""Mesmo nome em linhas diferentes são itens distintos — não colapsar."""
invoice_detail["11988888888"]["SVA Detalhe Total"].append(
_entry("TIM Fashion Mensal", 10.00, msisdn="11988888888")
)
items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
assert len(items) == 2
assert {i.msisdn for i in items} == {"11999999999", "11988888888"}
# ----- defesa em profundidade -----------------------------------------------
def test_nome_estrategico_em_secao_avulso_permanece_avulso() -> None:
detail = {
"11999999999": {
"SVA Detalhe Total": [_entry("Netflix Standard", 25.00)],
}
}
items = InvoiceResolver().resolve_items(["netflix"], detail)
assert len(items) == 1
assert items[0].item_type == "avulso"
assert items[0].tool_category == "cancelar_vas_avulso"
def test_sva_detalhe_total_com_classe_estrategico_vai_para_vas_estrategico() -> None:
detail = {
"11999999999": {
"SVA Detalhe Total": [
{
"desc": "Netflix Standard",
"value": 25.00,
"classe": "estrategico",
"msisdn": "11999999999",
}
],
}
}
items = InvoiceResolver().resolve_items(["netflix"], detail)
assert len(items) == 1
assert items[0].item_type == "estrategico"
assert items[0].tool_category == "vas_estrategico"
assert items[0].section == "SVA Detalhe Total"
def test_youtube_em_itens_eventuais_permanece_avulso() -> None:
detail = {
"11999999999": {
"Itens Eventuais": [_entry("YouTube Premium Mensal", 24.90)],
}
}
items = InvoiceResolver().resolve_items(["youtube premium"], detail)
assert len(items) == 1
assert items[0].canonical_name == "YouTube Premium Mensal"
assert items[0].item_type == "avulso"
assert items[0].tool_category == "cancelar_vas_avulso"
def test_classify_diretamente_para_todos_os_paths() -> None:
r = InvoiceResolver()
assert r._classify("HBO Max", "Serviços Bundle Inclusos", "bundle") == "bundle"
assert r._classify("Netflix Standard", "SVA Detalhe Total", "avulso") == "avulso"
assert (
r._classify(
"Netflix Standard",
"SVA Detalhe Total",
"avulso",
{"classe": "estrategico"},
)
== "estrategico"
)
assert r._classify("Galinha Pintadinha", "SVA Detalhe Total", "avulso") == "avulso"
assert r._classify("YouTube Premium", "Itens Eventuais", "avulso") == "avulso"
assert (
r._classify(
"YouTube Premium",
"Serviços Contratados de Terceiros",
"estrategico",
)
== "estrategico"
)
# ----- _was_mentioned --------------------------------------------------------
def test_was_mentioned_bidirecional() -> None:
fn = InvoiceResolver._was_mentioned
assert fn("TIM Fashion Mensal", ["tim fashion"]) is True # desc contém mention
assert fn("Netflix", ["Netflix Premium"]) is True # mention contém desc
assert fn("Disney+", ["DISNEY+"]) is True # case-insensitive
assert fn(" TIM Fashion ", ["tim fashion"]) is True # trim
assert fn("Galinha Pintadinha", ["netflix"]) is False
assert fn("", ["x"]) is False
assert fn("desc", []) is False
assert fn("desc", ["", None]) is False # type: ignore[list-item]
# ----- _parse_money ----------------------------------------------------------
@pytest.mark.parametrize(
"raw,expected",
[
(10.00, Decimal("10.00")),
("9.99", Decimal("9.99")),
(0, Decimal("0.00")),
(Decimal("39.9"), Decimal("39.90")),
(None, None),
("", None),
("não-numérico", None),
(object(), None),
],
)
def test_parse_money(raw: Any, expected: Decimal | None) -> None:
assert InvoiceResolver._parse_money(raw) == expected
# ----- shape do payload / robustez -----------------------------------------
def test_invoice_detail_vazio() -> None:
assert InvoiceResolver().resolve_items(["netflix"], {}) == []
def test_mentioned_items_vazio(invoice_detail) -> None:
assert InvoiceResolver().resolve_items([], invoice_detail) == []
def test_invoice_detail_nao_dict() -> None:
assert InvoiceResolver().resolve_items(["x"], None) == [] # type: ignore[arg-type]
def test_secoes_denylist_sao_ignoradas() -> None:
"""Seções de ``_NON_SERVICE_SECTIONS`` (plano/desconto/consumo) não são
serviços acionáveis — entradas sem flag tratável são ignoradas."""
detail = {
"11999999999": {
"Roaming Internacional": [_entry("Pacote Roaming", 100.00)],
"Outros Valores": [_entry("MULTAS", 5.00)],
}
}
assert InvoiceResolver().resolve_items(["Pacote", "MULTAS"], detail) == []
def test_secao_de_servico_fora_das_classes_vira_out_of_scope() -> None:
"""``Cobranças de Terceiros`` (fora de SECTION_DEFAULTS, não é denylist):
item encontrado mas não tratável → bucket ``out_of_scope``, não ``not_found``."""
detail = {
"11999999999": {
"Cobranças de Terceiros": [_entry("Seguro Celular Proteção Total", 53.34)],
}
}
outcome = InvoiceResolver().resolve(["Seguro Celular Proteção Total"], detail)
assert outcome.resolved == []
assert outcome.not_found == []
assert [m.canonical_name for r in outcome.out_of_scope for m in r.matches] == [
"Seguro Celular Proteção Total"
]
oos_item = outcome.out_of_scope[0].matches[0]
assert oos_item.item_type == "out_of_scope"
assert oos_item.tool_category is None
def test_linha_de_credito_nao_e_avulso() -> None:
""""CRÉDITO: PAGAMENTO" (ajuste financeiro, valor negativo) em "Itens Eventuais"
é carimbado ``classe=avulso`` pela seção, mas NÃO é serviço → o resolver o ignora
(não vira candidato/avulso). Um avulso real na mesma seção segue normal."""
detail = {
"11999999999": {
"Itens Eventuais": [
{
"desc": "CRÉDITO: PAGAMENTO",
"value": -49.77,
"msisdn": "11999999999",
"classe": "avulso",
},
{
"desc": "Tamboro Mensal",
"value": 14.99,
"msisdn": "11999999999",
"classe": "avulso",
},
]
}
}
outcome = InvoiceResolver().resolve(
["CRÉDITO: PAGAMENTO", "Tamboro Mensal"], detail
)
resolved = [(i.canonical_name, i.item_type) for i in outcome.resolved]
assert ("Tamboro Mensal", "avulso") in resolved
assert all("CRÉDITO" not in name for name, _ in resolved) # crédito não roteado
# crédito não casa nenhum candidato → not_found (nem avulso, nem out_of_scope)
assert "CRÉDITO: PAGAMENTO" in outcome.not_found
def test_mensalidades_adicionais_e_secao_mista() -> None:
""""Mensalidades Adicionais" é seção MISTA: estratégico vem da FLAG do parser
(lista fixa por nome), não da seção. Item flageado → ``estrategico``; item sem
flag (ex.: "Plugin 5G Plus") → ``out_of_scope`` — não é estratégico só por estar
na seção (SPEC §5)."""
detail = {
"11999999999": {
"Mensalidades Adicionais": [
{
"desc": "Apple Music SVA Mes",
"value": 5.0,
"msisdn": "11999999999",
"estrategico": True,
"classe": "estrategico",
},
{"desc": "Plugin 5G Plus", "value": 5.0, "msisdn": "11999999999"},
]
}
}
resolver = InvoiceResolver()
flagged = resolver.resolve(["Apple Music SVA Mes"], detail)
assert [(i.canonical_name, i.item_type) for i in flagged.resolved] == [
("Apple Music SVA Mes", "estrategico")
]
plugin = resolver.resolve(["Plugin 5G Plus"], detail)
assert plugin.resolved == []
assert plugin.not_found == []
assert [m.item_type for r in plugin.out_of_scope for m in r.matches] == [
"out_of_scope"
]
def test_metadata_keys_nao_explodem() -> None:
"""``Fatura Resumo`` é dict, ``DANFE-COM`` é lista, ``vocalized_msisdn`` é
dict — nenhum deles deve causar erro no iterador."""
detail = {
"Fatura Resumo": {"x": 1},
"DANFE-COM": [{"y": 2}],
"vocalized_msisdn": {"z": 3},
}
assert InvoiceResolver().resolve_items(["qualquer"], detail) == []
def test_entries_nao_list_ignorada() -> None:
detail = {"11999999999": {"SVA Detalhe Total": "não é lista"}}
assert InvoiceResolver().resolve_items(["x"], detail) == []
def test_entry_sem_desc_ignorada() -> None:
detail = {"11999999999": {"SVA Detalhe Total": [{"value": 10.00}, "string"]}}
assert InvoiceResolver().resolve_items(["x"], detail) == []
def test_sections_nao_dict_ignorada() -> None:
detail = {"11999999999": "não é dict de seções"}
assert InvoiceResolver().resolve_items(["x"], detail) == []
# ----- estruturais ----------------------------------------------------------
def test_strategic_names_alinhado_com_apendice_a() -> None:
"""A constante carrega exatamente os nomes validados do Apêndice A do
orquestrador (Apple Music, Deezer, Disney, Fuze, Forge, Globoplay, HBO,
Looke, Netflix, Paramount, TIM Cloud Gaming, YouTube). Mudança aqui é
decisão de produto — exige PR explícito."""
assert STRATEGIC_NAMES == frozenset(
{
"apple music",
"deezer",
"disney",
"fuze",
"forge",
"globoplay",
"hbo",
"looke",
"netflix",
"paramount",
"tim cloud gaming",
"youtube",
"amazon prime",
}
)
def test_section_defaults_cobre_as_secoes_suportadas() -> None:
# "Mensalidades Adicionais" (formato PDF) é seção MISTA e NÃO entra aqui:
# estratégico vem da flag do parser; item sem flag → out_of_scope.
assert set(SECTION_DEFAULTS) == {
"SVA Detalhe Total",
"Itens Eventuais",
"Serviços Contratados de Terceiros",
"Serviços Bundle Inclusos",
"Serviços de valor adicionado",
"Streamings",
}
def test_resolve_formato_billing_analysis_com_streaming_e_sva_avulso() -> None:
detail = {
"currentInvoice": [
{
"desc": "Serviços de valor adicionado",
"items": [
{
"contestable": True,
"desc": "Aluguel de Filme 3",
"type": "servicos_contratados_de_parceiros",
"value": "19.9",
}
],
},
{
"desc": "Streamings",
"items": [
{
"contestable": False,
"desc": "YouTube Premium Mensal",
"type": "streaming",
"value": "24.9",
}
],
},
]
}
items = InvoiceResolver().resolve_items(
["YouTube Premium Mensal", "Aluguel de Filme 3"],
detail,
)
assert [(item.canonical_name, item.item_type, item.tool_category) for item in items] == [
("YouTube Premium Mensal", "estrategico", "vas_estrategico"),
("Aluguel de Filme 3", "avulso", "cancelar_vas_avulso"),
]
def test_resolve_eh_idempotente(invoice_detail) -> None:
"""Função pura: mesma entrada → mesma saída em chamadas repetidas."""
r = InvoiceResolver()
a = r.resolve_items(["netflix", "tim fashion"], invoice_detail)
b = r.resolve_items(["netflix", "tim fashion"], invoice_detail)
assert [(i.canonical_name, i.msisdn) for i in a] == [
(i.canonical_name, i.msisdn) for i in b
]
# ----- ordem da menção (regra do produto 2026-06) ---------------------------
def test_preserva_ordem_da_mencao_categorias_diferentes(invoice_detail) -> None:
"""Cliente menciona ``tim fashion`` (avulso) ANTES de ``netflix``
(estratégico). O output deve refletir essa ordem — o builder vai usar isso
para ordenar os steps da ActionQueue."""
items = InvoiceResolver().resolve_items(
["tim fashion", "netflix"], invoice_detail
)
names = [i.canonical_name for i in items]
assert names == ["TIM Fashion Mensal", "Netflix Premium"]
def test_inverter_a_mencao_inverte_o_output(invoice_detail) -> None:
"""Mesma combinação de itens, ordem invertida na fala → output invertido."""
items = InvoiceResolver().resolve_items(
["netflix", "tim fashion"], invoice_detail
)
names = [i.canonical_name for i in items]
assert names == ["Netflix Premium", "TIM Fashion Mensal"]
def test_mencao_invalida_eh_ignorada_sem_quebrar_ordem(invoice_detail) -> None:
"""Itens vazios/None na lista de menções não devem deslocar a ordem dos
matches válidos."""
items = InvoiceResolver().resolve_items(
["", "tim fashion", None, "netflix"], # type: ignore[list-item]
invoice_detail,
)
names = [i.canonical_name for i in items]
assert names == ["TIM Fashion Mensal", "Netflix Premium"]
# ----- resolve(): ambiguidade + matcher LLM (híbrido) ------------------------
class _FakeMatcher:
"""``ItemMatcherLLM`` fake: mapeia ``mention.lower()`` → lista de descs;
casa UMA menção por chamada (o resolver dispara várias em paralelo);
registra chamadas e pode levantar para exercitar o caminho de erro.
``calls.append`` é thread-safe sob o GIL — seguro com o ThreadPoolExecutor."""
def __init__(
self,
mapping: dict[str, list[str]] | None = None,
*,
raises: bool = False,
) -> None:
self.mapping = mapping or {}
self.raises = raises
self.calls: list[tuple[str, list[str]]] = []
def match(
self, mention: str, candidates: list[str], *, callbacks: Any = None
) -> list[str]:
self.calls.append((mention, list(candidates)))
if self.raises:
raise ItemMatcherError("boom")
return list(self.mapping.get(mention.lower().strip(), []))
def test_resolve_mencao_unica_vai_para_resolved(invoice_detail) -> None:
outcome = InvoiceResolver().resolve(["netflix"], invoice_detail)
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
assert outcome.ambiguous == []
assert outcome.not_found == []
def test_resolve_nomes_distintos_que_casam_sao_ambiguos(invoice_detail) -> None:
"""'Aluguel de Filme' casa com '1' e '2' (nomes distintos) → ambíguo: vai
para ``ambiguous``, não para ``resolved``."""
outcome = InvoiceResolver().resolve(["Aluguel de Filme"], invoice_detail)
assert outcome.resolved == []
assert len(outcome.ambiguous) == 1
res = outcome.ambiguous[0]
assert res.mention == "Aluguel de Filme"
assert res.is_ambiguous is True
assert {m.canonical_name for m in res.matches} == {
"Aluguel de Filme 1",
"Aluguel de Filme 2",
}
def test_resolve_mesmo_nome_em_linhas_diferentes_eh_ambiguo(invoice_detail) -> None:
"""Mesma desc em msisdns diferentes (os 'dois ayas') → ambíguo por linha."""
invoice_detail["11988888888"]["SVA Detalhe Total"].append(
_entry("TIM Fashion Mensal", 10.00, msisdn="11988888888")
)
outcome = InvoiceResolver().resolve(["Tim Fashion"], invoice_detail)
assert outcome.resolved == []
assert len(outcome.ambiguous) == 1
assert {m.msisdn for m in outcome.ambiguous[0].matches} == {
"11999999999",
"11988888888",
}
# ----- resolve(): menção COM msisdn (MentionedItem) desambigua a linha --------
def _tim_fashion_em_duas_linhas(invoice_detail) -> dict[str, Any]:
"""Coloca 'TIM Fashion Mensal' nas DUAS linhas (11999999999 e 11988888888) — o
mesmo cenário nome-em-2-linhas do teste anterior, reusado pelos casos de msisdn."""
invoice_detail["11988888888"]["SVA Detalhe Total"].append(
_entry("TIM Fashion Mensal", 10.00, msisdn="11988888888")
)
return invoice_detail
def test_resolve_com_msisdn_desambigua_para_uma_linha(invoice_detail) -> None:
"""Menção COM msisdn (MentionedItem) restringe à linha → 1 resolved, não ambíguo
(é o conserto do bug do trace: '8119' escolhe a linha, resolve p/ 1 item)."""
detail = _tim_fashion_em_duas_linhas(invoice_detail)
outcome = InvoiceResolver().resolve(
[MentionedItem(desc="Tim Fashion", msisdn="11988888888")], detail
)
assert outcome.ambiguous == []
assert len(outcome.resolved) == 1
assert outcome.resolved[0].msisdn == "11988888888"
def test_resolve_com_msisdn_sufixo_desambigua(invoice_detail) -> None:
"""O msisdn-dica pode vir só com os últimos dígitos ('8888') → casa por sufixo."""
detail = _tim_fashion_em_duas_linhas(invoice_detail)
outcome = InvoiceResolver().resolve(
[MentionedItem(desc="Tim Fashion", msisdn="8888")], detail
)
assert outcome.ambiguous == []
assert len(outcome.resolved) == 1
assert outcome.resolved[0].msisdn == "11988888888"
def test_resolve_msisdn_que_nao_casa_linha_nao_filtra(invoice_detail) -> None:
"""msisdn-dica inválido (não casa NENHUMA linha real) → NÃO filtra: volta ao
comportamento sem linha (ambíguo). A dica nunca zera o resultado."""
detail = _tim_fashion_em_duas_linhas(invoice_detail)
outcome = InvoiceResolver().resolve(
[MentionedItem(desc="Tim Fashion", msisdn="0000")], detail
)
assert outcome.resolved == []
assert len(outcome.ambiguous) == 1
assert {m.msisdn for m in outcome.ambiguous[0].matches} == {
"11999999999",
"11988888888",
}
def test_resolve_mentioneditem_sem_msisdn_segue_como_string(invoice_detail) -> None:
"""Back-compat: MentionedItem SEM msisdn (e a str pura) seguem ambíguos como antes."""
detail = _tim_fashion_em_duas_linhas(invoice_detail)
outcome = InvoiceResolver().resolve(
[MentionedItem(desc="Tim Fashion")], detail
)
assert outcome.resolved == []
assert len(outcome.ambiguous) == 1
# ----- resolve(): cobrança duplicada na MESMA linha (Fase 3) ------------------
def _dup_charges_detail(
v1: float = 14.99, v2: float = 19.99, p1: str = "01/11/25", p2: str = "02/11/25"
) -> dict[str, Any]:
"""Duas cobranças de "Tamboro Mensal" na MESMA linha/seção, distintas só pela
``period`` (data única). Espelha o fixture invoice_pdf_include_danfe_false."""
return {
"1199999999": {
"SVA Detalhe Total": [
{"desc": "Tamboro Mensal", "value": v1, "msisdn": "1199999999", "period": p1},
{"desc": "Tamboro Mensal", "value": v2, "msisdn": "1199999999", "period": p2},
],
},
}
def test_build_item_charge_date_apenas_para_data_unica() -> None:
"""``charge_date`` = ``period`` só quando é data única; faixa (ciclo) ou ausente
→ ``None``."""
detail = {
"1199999999": {
"SVA Detalhe Total": [
{"desc": "A", "value": 1.0, "msisdn": "1199999999", "period": "01/11/25"},
{"desc": "B", "value": 1.0, "msisdn": "1199999999", "period": "14/10 a 13/11"},
{"desc": "C", "value": 1.0, "msisdn": "1199999999"},
]
}
}
by_name = {i.canonical_name: i.charge_date for i in InvoiceResolver().build_snapshot(detail)}
assert by_name["A"] == "01/11/25"
assert by_name["B"] is None
assert by_name["C"] is None
def test_resolve_cobranca_duplicada_mesma_linha_eh_ambiguo() -> None:
outcome = InvoiceResolver().resolve(
[MentionedItem(desc="Tamboro Mensal")], _dup_charges_detail()
)
assert outcome.resolved == []
assert len(outcome.ambiguous) == 1
res = outcome.ambiguous[0]
assert res.is_ambiguous is True
assert {m.charge_date for m in res.matches} == {"01/11/25", "02/11/25"}
assert {m.msisdn for m in res.matches} == {"1199999999"}
def test_resolve_com_date_desambigua_para_uma_cobranca() -> None:
outcome = InvoiceResolver().resolve(
[MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="01/11/25")],
_dup_charges_detail(),
)
assert outcome.ambiguous == []
assert len(outcome.resolved) == 1
assert outcome.resolved[0].charge_date == "01/11/25"
def test_resolve_date_prefixo_sem_ano_desambigua() -> None:
"""A dica de data pode vir sem o ano ("01/11") → casa por prefixo."""
outcome = InvoiceResolver().resolve(
[MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="01/11")],
_dup_charges_detail(),
)
assert len(outcome.resolved) == 1
assert outcome.resolved[0].charge_date == "01/11/25"
def test_resolve_date_que_nao_casa_nao_filtra() -> None:
"""Dica de data inválida (nenhuma cobrança real) → NÃO filtra: segue ambíguo
(a dica nunca zera o resultado; espelha o msisdn-dica inválido)."""
outcome = InvoiceResolver().resolve(
[MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="31/12/25")],
_dup_charges_detail(),
)
assert outcome.resolved == []
assert len(outcome.ambiguous) == 1
def test_resolve_ambas_cobrancas_dois_objetos_mantem_duas() -> None:
""""As duas" = 2 objetos datados → 2 cobranças distintas no resolved (o dedupe
final por cobrança NÃO as colapsa)."""
outcome = InvoiceResolver().resolve(
[
MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="01/11/25"),
MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="02/11/25"),
],
_dup_charges_detail(),
)
assert outcome.ambiguous == []
assert len(outcome.resolved) == 2
assert {i.charge_date for i in outcome.resolved} == {"01/11/25", "02/11/25"}
def test_dedupe_by_charge_flag() -> None:
"""``_dedupe_exact_matches``: default colapsa 2 cobranças de mesmo
(name,msisdn,section); ``by_charge=True`` mantém as datas distintas."""
r = InvoiceResolver()
a = ResolvedInvoiceItem(
"X", "cancelar_vas_avulso", "avulso", "111", None, "SVA Detalhe Total", "01/11/25"
)
b = ResolvedInvoiceItem(
"X", "cancelar_vas_avulso", "avulso", "111", None, "SVA Detalhe Total", "02/11/25"
)
assert len(r._dedupe_exact_matches([a, b])) == 1
assert len(r._dedupe_exact_matches([a, b], by_charge=True)) == 2
def test_build_snapshot_colapsa_cobrancas_duplicadas() -> None:
"""Regressão: build_snapshot mantém a chave de 3 (name,msisdn,section) → 2
cobranças do mesmo serviço/linha colapsam em 1 (anáfora plural não infla)."""
snap = InvoiceResolver().build_snapshot(_dup_charges_detail())
tamboros = [i for i in snap if i.canonical_name == "Tamboro Mensal"]
assert len(tamboros) == 1
def test_build_snapshot_by_charge_mantem_cobrancas_duplicadas() -> None:
"""``by_charge=True``: as 2 cobranças datadas do mesmo serviço/linha SOBREVIVEM
(usado ao cancelar TODOS os avulsos — cada cobrança vira um item)."""
snap = InvoiceResolver().build_snapshot(_dup_charges_detail(), by_charge=True)
tamboros = [i for i in snap if i.canonical_name == "Tamboro Mensal"]
assert len(tamboros) == 2
assert {i.charge_date for i in tamboros} == {"01/11/25", "02/11/25"}
def test_same_charge_tolerancia_de_prefixo() -> None:
sc = InvoiceResolver._same_charge
assert sc("01/11/25", "01/11") is True
assert sc("01/11/25", "01/11/25") is True
assert sc("02/11/25", "01/11/25") is False
assert sc(None, "01/11") is False # item sem data nunca casa dica de data
assert sc("01/11/25", "") is True # sem dica → não filtra
def test_resolve_sem_matcher_mencao_nao_encontrada_vai_para_not_found(
invoice_detail,
) -> None:
outcome = InvoiceResolver().resolve(["Spotify"], invoice_detail)
assert outcome.resolved == []
assert outcome.ambiguous == []
assert outcome.not_found == ["Spotify"]
def test_resolve_matcher_nao_acionado_quando_deterministico_casa(
invoice_detail,
) -> None:
"""Happy-path: substring já casa → o matcher LLM NÃO é chamado."""
matcher = _FakeMatcher()
outcome = InvoiceResolver(matcher=matcher).resolve(["netflix"], invoice_detail)
assert matcher.calls == []
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
def test_resolve_matcher_casa_no_miss_de_grafia(invoice_detail) -> None:
"""'Netflics' não casa por substring → o matcher é acionado e resolve para
'Netflix Premium'."""
matcher = _FakeMatcher(mapping={"netflics": ["Netflix Premium"]})
outcome = InvoiceResolver(matcher=matcher).resolve(["Netflics"], invoice_detail)
assert len(matcher.calls) == 1
assert "Netflix Premium" in matcher.calls[0][1] # desc candidato presente
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
assert outcome.not_found == []
def test_resolve_multiplas_mencoes_grafia_em_paralelo(invoice_detail) -> None:
"""Várias menções com grafia errada (todas furam o substring) → uma chamada
ao matcher LLM POR menção, disparadas em paralelo por threads (cobre o trace
2253d235…). Cada uma resolve para seu item."""
matcher = _FakeMatcher(
mapping={
"netflics": ["Netflix Premium"],
"spotfy": ["Spotify Família"],
}
)
invoice_detail["11999999999"]["SVA Detalhe Total"].append(
_entry("Spotify Família", 19.90, msisdn="11999999999")
)
outcome = InvoiceResolver(matcher=matcher).resolve(
["Netflics", "Spotfy"], invoice_detail
)
assert len(matcher.calls) == 2
assert {c[0] for c in matcher.calls} == {"Netflics", "Spotfy"}
assert {i.canonical_name for i in outcome.resolved} == {
"Netflix Premium",
"Spotify Família",
}
assert outcome.not_found == []
def test_resolve_matcher_multiplos_vira_ambiguo(invoice_detail) -> None:
matcher = _FakeMatcher(
mapping={"filmes": ["Aluguel de Filme 1", "Aluguel de Filme 2"]}
)
outcome = InvoiceResolver(matcher=matcher).resolve(["filmes"], invoice_detail)
assert outcome.resolved == []
assert len(outcome.ambiguous) == 1
assert {m.canonical_name for m in outcome.ambiguous[0].matches} == {
"Aluguel de Filme 1",
"Aluguel de Filme 2",
}
def test_resolve_matcher_desc_fora_da_lista_eh_ignorado(invoice_detail) -> None:
"""Defensivo: o matcher devolve um desc que não está nos candidatos →
ignorado (não inventa item) → menção fica em not_found."""
matcher = _FakeMatcher(mapping={"xpto": ["Serviço Inexistente"]})
outcome = InvoiceResolver(matcher=matcher).resolve(["xpto"], invoice_detail)
assert outcome.resolved == []
assert outcome.not_found == ["xpto"]
def test_resolve_matcher_erro_degrada_para_not_found(invoice_detail) -> None:
matcher = _FakeMatcher(raises=True)
outcome = InvoiceResolver(matcher=matcher).resolve(["Netflics"], invoice_detail)
assert outcome.resolved == []
assert outcome.not_found == ["Netflics"]
def test_resolve_invoice_vazio_e_mencoes_vazias() -> None:
assert InvoiceResolver().resolve(["x"], {}).resolved == []
assert InvoiceResolver().resolve([], {"11999999999": {}}).not_found == []
# ----- gate de reconhecimento (best_similarity antes do matcher LLM) ----------
class _GatingMatcher(_FakeMatcher):
"""``_FakeMatcher`` + ``best_similarity`` controlável, para exercitar o gate
de reconhecimento (o ``_FakeMatcher`` base NÃO expõe ``best_similarity`` →
gate degrada para legado e o LLM é sempre chamado)."""
def __init__(self, *, similarity: float, mapping=None, raises: bool = False) -> None:
super().__init__(mapping=mapping, raises=raises)
self._similarity = similarity
def best_similarity(self, mention: str, candidates: list[str]) -> float:
return self._similarity
def test_gate_bloqueia_mencao_ruido_e_nao_chama_o_llm(invoice_detail) -> None:
"""best_similarity <= 0.6 (transcrição/ruído): o matcher LLM NÃO é chamado e
a menção fica em not_found (o runtime pede para repetir)."""
matcher = _GatingMatcher(similarity=0.5, mapping={"youtube": ["Netflix Premium"]})
outcome = InvoiceResolver(matcher=matcher).resolve(["youtube"], invoice_detail)
assert matcher.calls == [] # LLM poupado
assert outcome.resolved == []
assert outcome.not_found == ["youtube"]
def test_gate_piso_estrito_reprova_em_06(invoice_detail) -> None:
"""Estrito: exatamente 0.6 reprova (ruído de Jaro-Winkler bate 0.600)."""
matcher = _GatingMatcher(similarity=0.6, mapping={"youtube": ["Netflix Premium"]})
outcome = InvoiceResolver(matcher=matcher).resolve(["youtube"], invoice_detail)
assert matcher.calls == []
assert outcome.not_found == ["youtube"]
def test_gate_libera_mencao_reconhecida_e_chama_o_llm(invoice_detail) -> None:
"""best_similarity > 0.6 (typo/pronúncia plausível): o LLM é acionado e o
match resolve normalmente."""
matcher = _GatingMatcher(similarity=0.92, mapping={"netflics": ["Netflix Premium"]})
outcome = InvoiceResolver(matcher=matcher).resolve(["Netflics"], invoice_detail)
assert len(matcher.calls) == 1 # LLM chamado
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
def test_gate_ausente_degrada_para_legado(invoice_detail) -> None:
"""Matcher sem best_similarity (fake legado): gate não bloqueia, LLM é
chamado mesmo para menção de baixa similaridade."""
matcher = _FakeMatcher(mapping={"qualquer": ["Netflix Premium"]})
outcome = InvoiceResolver(matcher=matcher).resolve(["qualquer"], invoice_detail)
assert len(matcher.calls) == 1
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
# ----- resolve_each (motor por menção, reusado pela expansão de RAG) ----------
def test_resolve_each_devolve_uma_resolucao_por_mencao(invoice_detail) -> None:
"""resolve_each devolve UMA ItemResolution por menção válida, 1:1 e na ordem,
sem classificar/dedupar entre menções: 1 match, N matches (distintos) e 0
match coexistem na lista de saída."""
resolutions = InvoiceResolver().resolve_each(
["Tim Fashion", "Aluguel de Filme", "Spotify"], invoice_detail
)
assert [r.mention for r in resolutions] == [
"Tim Fashion",
"Aluguel de Filme",
"Spotify",
]
# 1 match → não ambíguo
assert [m.canonical_name for m in resolutions[0].matches] == ["TIM Fashion Mensal"]
assert resolutions[0].is_ambiguous is False
# N matches distintos → ambíguo
assert sorted(m.canonical_name for m in resolutions[1].matches) == [
"Aluguel de Filme 1",
"Aluguel de Filme 2",
]
assert resolutions[1].is_ambiguous is True
# 0 match
assert resolutions[2].matches == []
def test_resolve_each_pula_mencoes_vazias_e_invalidas(invoice_detail) -> None:
"""Menções em branco/não-string são descartadas; a saída fica 1:1 com as
válidas, na ordem."""
resolutions = InvoiceResolver().resolve_each(
["", " ", "netflix"], invoice_detail
)
assert [r.mention for r in resolutions] == ["netflix"]
# ----- build_snapshot (snapshot completo da fatura) --------------------------
def test_build_snapshot_enumera_todos_os_itens_classificados(invoice_detail) -> None:
"""Cobre a fixture realista: 4 avulsos no SVA Detalhe Total do titular + 1
estratégico + 1 bundle + 1 avulso em Itens Eventuais + 1 avulso no
dependente = 8 itens. O ``Plano`` é ignorado por não estar em
SECTION_DEFAULTS."""
snapshot = InvoiceResolver().build_snapshot(invoice_detail)
assert len(snapshot) == 8
by_name = {item.canonical_name: item for item in snapshot}
assert by_name["TIM Fashion Mensal"].tool_category == "cancelar_vas_avulso"
assert by_name["Netflix Premium"].item_type == "estrategico"
assert by_name["HBO Max"].item_type == "bundle"
assert by_name["Tamboro Mensal"].msisdn == "11988888888"
def test_build_snapshot_invoice_vazio_retorna_tupla_vazia() -> None:
assert InvoiceResolver().build_snapshot({}) == ()
def test_build_snapshot_payload_invalido_retorna_tupla_vazia() -> None:
"""Tipo errado entra silenciosamente como vazio (mesmo padrão de
``resolve``)."""
assert InvoiceResolver().build_snapshot(None) == () # type: ignore[arg-type]
assert InvoiceResolver().build_snapshot("string") == () # type: ignore[arg-type]
def test_build_snapshot_classificacao_canonica_cobre_familia_multi_msisdn(
invoice_detail,
) -> None:
"""A fixture tem titular + 1 dependente. O snapshot agrega ambos, sem
distinguir por bucket. O consumer (classificador) filtra por msisdn ativo
no render."""
snapshot = InvoiceResolver().build_snapshot(invoice_detail)
msisdns = {item.msisdn for item in snapshot}
assert msisdns == {"11999999999", "11988888888"}
# ----- Bug #Gamedom: recognition_score (piso do resgate de funil) ------------
def _invoice_gamedom() -> dict:
return {
"11986811082": {
"Serviços de valor adicionado": [
{"desc": "Gamedom Mensal", "value": 14.99, "msisdn": "11986811082"},
{"desc": "Aluguel de Filme 2", "value": 9.90, "msisdn": "11986811082"},
]
}
}
def test_recognition_score_pontua_fala_mal_transcrita_do_caso_real() -> None:
""""Game is done" (URA) contra "Gamedom Mensal": cruza o piso de resgate com
folga e a margem para o 2º candidato afasta ambiguidade."""
from app.domain.contas.invoice_resolver import (
_FUNNEL_RESCUE_MARGIN,
_FUNNEL_RESCUE_THRESHOLD,
)
from app.domain.contas.item_matcher import SimilarityItemMatcher
resolver = InvoiceResolver(matcher=SimilarityItemMatcher())
score = resolver.recognition_score("Game is done", _invoice_gamedom())
assert score is not None
assert score.desc == "Gamedom Mensal"
assert score.best >= _FUNNEL_RESCUE_THRESHOLD
assert score.margin >= _FUNNEL_RESCUE_MARGIN
def test_recognition_score_reprova_ruido_e_fala_curta() -> None:
from app.domain.contas.invoice_resolver import (
_FUNNEL_RESCUE_THRESHOLD,
)
from app.domain.contas.item_matcher import SimilarityItemMatcher
resolver = InvoiceResolver(matcher=SimilarityItemMatcher())
for fala in ("Deine", "blarg zorp flim flam ploft", "tem jogo hoje?"):
score = resolver.recognition_score(fala, _invoice_gamedom())
assert score is not None
assert score.best < _FUNNEL_RESCUE_THRESHOLD, fala
def test_recognition_score_fail_closed_sem_matcher_ou_sem_candidatos() -> None:
"""Assimetria deliberada vs ``_passes_recognition_gate`` (que degrada para
True): o resgate CRIA autorização, então sem sinal devolve None."""
from app.domain.contas.item_matcher import SimilarityItemMatcher
assert InvoiceResolver().recognition_score("Game is done", _invoice_gamedom()) is None
resolver = InvoiceResolver(matcher=SimilarityItemMatcher())
assert resolver.recognition_score("", _invoice_gamedom()) is None
assert resolver.recognition_score("Game is done", {}) is None