"""Testes da busca por palavras-chave (BM25) e da fusão de rankings (RRF). A busca vetorial sozinha erra quando a pergunta compartilha palavras com o versículo mas não a estrutura da frase — "o que Jesus disse sobre amar os inimigos?" não achava Mateus 5:44 com nenhum dos três modelos medidos. O BM25 cobre esse caso, e a fusão RRF combina os dois rankings sem exigir que cosseno e BM25 (escalas incomparáveis) sejam normalizados um contra o outro. """ from __future__ import annotations import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.keyword import ( # noqa: E402 BM25Index, normalize_token, reciprocal_rank_fusion, stem, tokenize, ) # -------------------------------------------------------------------------- # Tokenizador # -------------------------------------------------------------------------- def test_tokenize_lowercases_and_strips_accents(): tokens = tokenize("Coração Coração") assert len(set(tokens)) == 1, "a mesma palavra não pode virar dois termos" assert all(token == token.lower() for token in tokens) assert not any(ch in token for token in tokens for ch in "ãçáéíóúÃÇÁÉÍÓÚ") def test_tokenize_splits_on_punctuation(): assert tokenize("Deus é amor; crê.") == ["deu", "amor", "cr"] def test_tokenize_drops_portuguese_stopwords(): """Stopwords em todo versículo não discriminam nada e só incham o índice.""" tokens = tokenize("O amor de Deus é grande para com o mundo") for stopword in ("o", "de", "e", "para", "com"): assert stopword not in tokens assert "amor" in tokens assert "mund" in tokens, "'mundo' deve aparecer como radical" def test_tokenize_drops_pure_numbers(): assert tokenize("Salmo 23 de Davi") == ["salm", "davi"] def test_tokenize_handles_empty_and_punctuation_only_input(): assert tokenize("") == [] assert tokenize("... — !!!") == [] # -------------------------------------------------------------------------- # BM25 # -------------------------------------------------------------------------- DOCS = [ "Eu, porém, vos digo: Amai aos vossos inimigos, e orai pelos que vos perseguem", "O Senhor é o meu pastor; nada me faltará", "Deitar-me faz em pastos verdejantes; guia-me mansamente a águas tranquilas", "Porque Deus amou o mundo de tal maneira que deu o seu Filho unigênito", "E tirará toda a gordura do novilho da oferta pelo pecado", ] @pytest.fixture def index() -> BM25Index: built = BM25Index() built.build(DOCS) return built def test_bm25_finds_the_lexical_match(index): """O caso que a busca vetorial perdeu: a palavra exata está no versículo.""" hits = index.search("amar os inimigos", k=3) assert hits assert hits[0][0] == 0 def test_bm25_ranks_by_term_specificity(index): """Termo raro pesa mais: 'pastor' aparece em 1 doc, 'Deus' em 2.""" hits = dict(index.search("pastor", k=5)) assert hits.get(1, 0) > 0 assert 4 not in hits def test_bm25_returns_empty_when_no_term_matches(index): assert index.search("astronomia quântica", k=5) == [] def test_bm25_ignores_stopwords_only_query(index): assert index.search("o de que e", k=5) == [] def test_bm25_empty_index_returns_nothing(): empty = BM25Index() empty.build([]) assert empty.search("qualquer coisa", k=5) == [] assert len(empty) == 0 def test_bm25_respects_k(index): hits = index.search("Deus amor mundo pastor", k=2) assert len(hits) <= 2 def test_bm25_scores_are_descending(index): hits = index.search("Deus amor mundo", k=5) scores = [score for _, score in hits] assert scores == sorted(scores, reverse=True) def test_bm25_rebuild_replaces_the_previous_documents(): """Reindexar não pode deixar documentos antigos pontuando.""" index = BM25Index() index.build(["texto antigo sobre gordura do novilho"]) index.build(["texto novo sobre o bom pastor"]) assert index.search("gordura novilho", k=3) == [] assert index.search("bom pastor", k=3) # -------------------------------------------------------------------------- # Fusão RRF # -------------------------------------------------------------------------- def test_rrf_prefers_items_present_in_both_rankings(): fused = reciprocal_rank_fusion([["a", "b", "c"], ["b", "c", "d"]], limit=4) assert fused[0][0] == "b", "item nos dois rankings deve liderar" def test_rrf_orders_by_rank_within_a_single_list(): fused = reciprocal_rank_fusion([["a", "b", "c"]], limit=3) assert [item for item, _ in fused] == ["a", "b", "c"] def test_rrf_first_position_beats_second_across_lists(): """Posição é o que conta: rank 1 numa lista vale o mesmo que rank 1 noutra.""" fused = reciprocal_rank_fusion([["a"], ["b", "c"]], limit=3, k=60) scores = dict(fused) assert scores["a"] == pytest.approx(1 / 61) assert scores["b"] == pytest.approx(1 / 61), "ambos são rank 1 em suas listas" assert scores["a"] > scores["c"] assert scores["c"] == pytest.approx(1 / 62) def test_rrf_respects_limit(): fused = reciprocal_rank_fusion([["a", "b", "c", "d"]], limit=2) assert len(fused) == 2 def test_rrf_handles_empty_rankings(): assert reciprocal_rank_fusion([], limit=5) == [] assert reciprocal_rank_fusion([[], []], limit=5) == [] def test_rrf_is_deterministic_on_ties(): """Empate precisa de desempate estável, senão a ordem varia entre execuções.""" first = reciprocal_rank_fusion([["a", "b"], ["b", "a"]], limit=2) second = reciprocal_rank_fusion([["a", "b"], ["b", "a"]], limit=2) assert first == second assert [item for item, _ in first] == ["a", "b"] # -------------------------------------------------------------------------- # Stemmer # -------------------------------------------------------------------------- @pytest.mark.parametrize( "words", [ ["amar", "amai", "amarás", "amado", "amam", "amo"], ["inimigo", "inimigos"], ["família", "famílias"], ["perdoar", "perdoa", "perdoai", "perdoas"], ["morreu", "morrer", "morre"], ["Senhor", "Senhora"], ], ) def test_stem_unifies_inflections(words): """Sem isso, 'amar' da pergunta nunca casa com 'amai' do versículo.""" stems = {stem(normalize_token(word)) for word in words} assert len(stems) == 1, f"{words} -> {stems}" @pytest.mark.parametrize("word", ["Deus", "mar", "dar", "ver", "pôr", "rei", "luz"]) def test_stem_does_not_collapse_short_words(word): """Stem agressivo demais destruiria palavras curtas e comuns.""" stemmed = stem(normalize_token(word)) assert len(stemmed) >= 2, f"{word} -> {stemmed!r}" @pytest.mark.parametrize( "left,right", [ ("vida", "morte"), ("amor", "temor"), ("Deus", "homem"), ("justo", "injusto"), ("fé", "lei"), ], ) def test_stem_keeps_distinct_words_distinct(left, right): """Agrupar flexões não pode fundir palavras de sentido diferente.""" assert stem(normalize_token(left)) != stem(normalize_token(right)) def test_tokenize_applies_stemming(): """Conjugações diferentes têm de produzir os mesmos termos de busca.""" shared = {"am", "inimig"} assert shared <= set(tokenize("Amai aos vossos inimigos")) assert shared <= set(tokenize("amar o inimigo")) def test_bm25_finds_the_verse_by_its_inflected_form(): """O caso real que falhou: a pergunta diz 'amar', o texto diz 'Amai'.""" index = BM25Index() index.build( [ "Eu, porém, vos digo: Amai aos vossos inimigos, e orai pelos que vos perseguem", "E Jesus, vendo que havia respondido sabiamente, disse-lhe: Não estás longe", "O Senhor é o meu pastor; nada me faltará", ] ) hits = index.search("O que Jesus disse sobre amar os inimigos?", k=3) assert hits[0][0] == 0, "Mateus 5:44 deve liderar, não o versículo com 'Jesus disse'"