"""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