Files
omp 957c73b09e feat: busca híbrida (vetorial + BM25 com fusão RRF) e janela de contexto
Recuperação:
- keyword.py: tokenizador PT (acentos, stopwords, dígitos), BM25 Okapi com
  índice invertido, e reciprocal_rank_fusion com desempate determinístico
- retrieval.py: VectorRetriever e HybridRetriever; fusão por posição, não por
  nota (cosseno 0..1 e BM25 sem teto são escalas incomparáveis)
- identidade de fusão é a coordenada (arquivo,livro,capítulo,versículo): o mesmo
  versículo chega das duas buscas com distance diferente e o dataclass nunca os
  igualaria, então o RRF não premiaria o que ambas confirmam
- ChatService recebe retriever injetado; referência explícita mantém precedência

Contexto:
- VerseChunk ganha context (janela de CONTEXT_WINDOW vizinhos por lado, dentro
  do livro, atravessando capítulos) e embedding_text
- store grava a janela como documento e o versículo em verse_text; a UI mostra o
  versículo citado e o modelo recebe a janela
- snapshot() nos dois stores: o BM25 é em memória e o Chroma persiste, então o
  arranque precisa reconstruir o índice lexical ou a busca degrada em silêncio

Motivação medida em 5703 versículos reais: a busca vetorial não liga 'estou com
medo do futuro' a 'não temas' (0,19, nível de par sem relação), e e5-small tem
faixa de similaridade de só 0,02 — inutilizaria o piso de evidência.
2026-11-02 11:57:19 -03:00

440 lines
14 KiB
Python

"""Testes do parser de corpus USFX.
O corpus é XML estruturado: não há OCR, nem página, nem reconstrução de
capítulo/versículo. O que pode dar errado é o mapeamento estrutural (texto no
`tail` de `<v/>`, capítulo corrente, nome do livro em `<h>`) e a normalização do
texto. Os testes usam um USFX sintético mínimo para isolar cada regra, mais uma
verificação de integridade contra o corpus real versionado.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
BACKEND = Path(__file__).resolve().parents[1]
REPO_ROOT = BACKEND.parent
sys.path.insert(0, str(BACKEND))
from app.corpus import ( # noqa: E402
BOOKS,
CorpusParseError,
VerseChunk,
canonical_book,
parse_usfx,
)
REAL_CORPUS = REPO_ROOT / "corpus" / "por-almeida.usfx.xml"
def write_usfx(tmp_path: Path, body: str) -> Path:
path = tmp_path / "corpus.usfx.xml"
path.write_text(
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<usfx xmlns:xsi='http://eBible.org/usfx.xsd'>\n"
f"{body}\n</usfx>\n",
encoding="utf-8",
)
return path
# --------------------------------------------------------------------------
# Mapeamento estrutural
# --------------------------------------------------------------------------
def test_verse_text_comes_from_the_tail_of_the_v_element(tmp_path):
"""`<v id="N"/>` é auto-fechado: o texto é o tail, não filho."""
path = write_usfx(
tmp_path,
"""
<book id="GEN">
<h>Gênesis</h>
<c id="1"/>
<v id="1"/>No princípio criou Deus os céus e a terra. <ve/>
<v id="2"/>E a terra era sem forma e vazia. <ve/>
</book>
""",
)
chunks = list(parse_usfx(path))
assert [chunk.verse for chunk in chunks] == [1, 2]
assert chunks[0].text == "No princípio criou Deus os céus e a terra."
assert chunks[1].text == "E a terra era sem forma e vazia."
def test_verse_numbers_restart_at_each_chapter(tmp_path):
"""O capítulo corrente vem do último `<c/>`; o verso reinicia em cada um."""
path = write_usfx(
tmp_path,
"""
<book id="GEN">
<h>Gênesis</h>
<c id="1"/>
<v id="1"/>Um. <ve/>
<v id="2"/>Dois. <ve/>
<c id="2"/>
<v id="1"/>Três. <ve/>
</book>
""",
)
chunks = list(parse_usfx(path))
assert [(c.chapter, c.verse, c.text) for c in chunks] == [
(1, 1, "Um."),
(1, 2, "Dois."),
(2, 1, "Três."),
]
def test_book_name_comes_from_the_h_element(tmp_path):
"""A grafia canônica é a que o corpus declara em `<h>`, não uma lista fixa."""
path = write_usfx(
tmp_path,
"""
<book id="1SA">
<h>1 Samuel</h>
<c id="1"/>
<v id="1"/>Texto. <ve/>
</book>
""",
)
chunks = list(parse_usfx(path))
assert chunks[0].book == "1 Samuel"
def test_file_name_is_recorded_for_provenance(tmp_path):
path = write_usfx(
tmp_path,
"""
<book id="GEN"><h>Gênesis</h><c id="1"/><v id="1"/>Texto. <ve/></book>
""",
)
chunks = list(parse_usfx(path))
assert chunks[0].file == "corpus.usfx.xml"
def test_explicit_file_name_overrides_the_path_basename(tmp_path):
path = write_usfx(
tmp_path,
"""
<book id="GEN"><h>Gênesis</h><c id="1"/><v id="1"/>Texto. <ve/></book>
""",
)
chunks = list(parse_usfx(path, file_name="almeida"))
assert chunks[0].file == "almeida"
# --------------------------------------------------------------------------
# Normalização de texto
# --------------------------------------------------------------------------
def test_non_breaking_space_and_runs_of_whitespace_are_normalized(tmp_path):
"""O corpus real usa \\xa0; espaços múltiplos viram um só."""
path = write_usfx(
tmp_path,
"""
<book id="GEN">
<h>Gênesis</h>
<c id="1"/>
<v id="1"/>Primeira\u00a0parte segunda parte.\u00a0\u00a0 <ve/>
</book>
""",
)
chunks = list(parse_usfx(path))
assert chunks[0].text == "Primeira parte segunda parte."
assert "\xa0" not in chunks[0].text
def test_placeholder_verses_are_skipped(tmp_path):
"""Versículo sem letra nem dígito é ruído: não pode virar chunk."""
path = write_usfx(
tmp_path,
"""
<book id="MAT">
<h>Mateus</h>
<c id="17"/>
<v id="20"/>Versículo real. <ve/>
<v id="21"/>— <ve/>
<v id="22"/>Outro versículo real. <ve/>
</book>
""",
)
chunks = list(parse_usfx(path))
assert [chunk.verse for chunk in chunks] == [20, 22]
def test_verse_without_closing_ve_still_yields_its_text(tmp_path):
"""`<ve/>` é marcador de fim; sua ausência não pode descartar o versículo."""
path = write_usfx(
tmp_path,
"""
<book id="GEN">
<h>Gênesis</h>
<c id="1"/>
<v id="1"/>Texto sem marcador de fim.
</book>
""",
)
chunks = list(parse_usfx(path))
assert len(chunks) == 1
assert chunks[0].text == "Texto sem marcador de fim."
# --------------------------------------------------------------------------
# Erros
# --------------------------------------------------------------------------
def test_malformed_xml_raises_corpus_parse_error(tmp_path):
path = tmp_path / "quebrado.usfx.xml"
path.write_text("<usfx><book id='GEN'><h>Gênesis</h>", encoding="utf-8")
with pytest.raises(CorpusParseError):
list(parse_usfx(path))
def test_missing_file_raises_corpus_parse_error(tmp_path):
with pytest.raises(CorpusParseError):
list(parse_usfx(tmp_path / "nao-existe.usfx.xml"))
def test_document_without_books_raises_corpus_parse_error(tmp_path):
"""Um XML válido mas sem nenhum livro é corpus inútil: falha explícita."""
path = write_usfx(tmp_path, "")
with pytest.raises(CorpusParseError):
list(parse_usfx(path))
# --------------------------------------------------------------------------
# Identidade do chunk
# --------------------------------------------------------------------------
def test_reference_uses_livro_capitulo_versiculo():
chunk = VerseChunk(
book="João", chapter=3, verse=16, file="almeida", text="Porque Deus amou"
)
assert chunk.reference == "João 3:16"
def test_chunk_id_is_deterministic_and_unique():
first = VerseChunk(book="João", chapter=3, verse=16, file="a", text="x")
same = VerseChunk(book="João", chapter=3, verse=16, file="a", text="x")
other_verse = VerseChunk(book="João", chapter=3, verse=17, file="a", text="x")
other_file = VerseChunk(book="João", chapter=3, verse=16, file="b", text="x")
assert first.chunk_id == same.chunk_id
assert len({first.chunk_id, other_verse.chunk_id, other_file.chunk_id}) == 3
def test_chunk_id_does_not_depend_on_the_text():
"""Reindexar com OCR/edição diferente não pode trocar a identidade do verso."""
a = VerseChunk(book="João", chapter=3, verse=16, file="a", text="versão 1")
b = VerseChunk(book="João", chapter=3, verse=16, file="a", text="versão 2")
assert a.chunk_id == b.chunk_id
# --------------------------------------------------------------------------
# Nomes canônicos e aliases
# --------------------------------------------------------------------------
@pytest.mark.parametrize(
"name,expected",
[
("João", "João"),
("1 Samuel", "1 Samuel"),
("I Samuel", "1 Samuel"),
("II Reis", "2 Reis"),
("III João", "3 João"),
("Gn", "Gênesis"),
("Sl", "Salmos"),
("1Co", "1 Coríntios"),
("Ap", "Apocalipse"),
("Cântico dos Cânticos", "Cânticos"),
("Cantares", "Cânticos"),
("Lamentações de Jeremias", "Lamentações"),
("Atos dos Apóstolos", "Atos"),
("Salmo", "Salmos"),
],
)
def test_canonical_book_resolves_variants(name, expected):
assert canonical_book(name) == expected
@pytest.mark.parametrize("name", ["joao", "JOÃO", " João ", "Isaias", "Miqueias"])
def test_canonical_book_is_case_and_accent_insensitive(name):
assert canonical_book(name) is not None
def test_canonical_book_rejects_unknown_names():
assert canonical_book("Evangelho de Tomé") is None
assert canonical_book("") is None
def test_books_covers_the_full_protestant_canon():
"""66 livros, incluindo Neemias e Ester — ausentes na edição em PDF."""
assert len(BOOKS) == 66
assert "Neemias" in BOOKS
assert "Ester" in BOOKS
assert BOOKS[0] == "Gênesis"
assert BOOKS[-1] == "Apocalipse"
# --------------------------------------------------------------------------
# Integridade contra o corpus real versionado
# --------------------------------------------------------------------------
@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado")
def test_real_corpus_parses_completely():
chunks = list(parse_usfx(REAL_CORPUS))
assert len(chunks) == 31098
assert len({chunk.book for chunk in chunks}) == 66
assert len({chunk.chunk_id for chunk in chunks}) == len(chunks), "ids duplicados"
assert all(chunk.text for chunk in chunks)
assert all(chunk.chapter >= 1 and chunk.verse >= 1 for chunk in chunks)
@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado")
def test_real_corpus_keeps_known_verses_intact():
chunks = {(c.book, c.chapter, c.verse): c.text for c in parse_usfx(REAL_CORPUS)}
assert chunks[("Gênesis", 1, 1)] == "No princípio criou Deus os céus e a terra."
assert chunks[("Salmos", 23, 1)].startswith("O Senhor é o meu pastor")
assert chunks[("João", 3, 16)].startswith("Porque Deus amou o mundo")
@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado")
def test_real_corpus_declares_every_book_as_canonical():
"""Nenhum `<h>` do corpus pode ficar de fora de BOOKS/aliases."""
books = {chunk.book for chunk in parse_usfx(REAL_CORPUS)}
assert books <= set(BOOKS)
# --------------------------------------------------------------------------
# Janela de contexto
# --------------------------------------------------------------------------
CONTEXT_BOOK = book_xml = """
<book id="PSA">
<h>Salmos</h>
<c id="23"/>
<v id="1"/>O Senhor é o meu pastor; nada me faltará. <ve/>
<v id="2"/>Deitar-me faz em verdes pastos. <ve/>
<v id="3"/>Guia-me mansamente a águas tranquilas. <ve/>
<v id="4"/>Refrigera a minha alma. <ve/>
<c id="24"/>
<v id="1"/>Do Senhor é a terra e a sua plenitude. <ve/>
</book>
"""
def test_text_stays_the_single_verse_and_context_holds_the_window(tmp_path):
"""A citação continua apontando para um versículo; o contexto é só para busca."""
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunks = list(parse_usfx(path, context_window=1))
first = chunks[0]
assert first.text == "O Senhor é o meu pastor; nada me faltará."
assert first.reference == "Salmos 23:1"
assert first.context.startswith("O Senhor é o meu pastor")
assert "Deitar-me faz em verdes pastos." in first.context
def test_context_is_clipped_at_the_start_of_the_book(tmp_path):
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunks = list(parse_usfx(path, context_window=2))
assert chunks[0].context.startswith("O Senhor é o meu pastor")
def test_context_does_not_cross_books(tmp_path):
"""Contexto de outro livro misturaria evidência de obras diferentes."""
path = write_usfx(
tmp_path,
"""
<book id="PSA">
<h>Salmos</h>
<c id="1"/>
<v id="1"/>Bem-aventurado o varão. <ve/>
<v id="2"/>Antes tem o seu prazer na lei. <ve/>
</book>
<book id="PRO">
<h>Provérbios</h>
<c id="1"/>
<v id="1"/>Provérbios de Salomão. <ve/>
<v id="2"/>Para se conhecer a sabedoria. <ve/>
</book>
""",
)
chunks = list(parse_usfx(path, context_window=2))
salmos = [chunk for chunk in chunks if chunk.book == "Salmos"]
assert salmos, "esperava chunks de Salmos"
assert all("Provérbios de Salomão" not in chunk.context for chunk in salmos)
def test_context_window_zero_falls_back_to_the_verse(tmp_path):
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunks = list(parse_usfx(path, context_window=0))
assert chunks[0].context == chunks[0].text
def test_embedding_text_prefers_the_context_window(tmp_path):
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunk = parse_usfx(path, context_window=1).__next__()
assert chunk.embedding_text == chunk.context
assert chunk.embedding_text != chunk.text
def test_context_window_crosses_chapters_inside_the_same_book(tmp_path):
"""A narrativa continua entre capítulos; cortar ali perderia sentido."""
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunks = {chunk.reference: chunk for chunk in parse_usfx(path, context_window=1)}
assert "Do Senhor é a terra" in chunks["Salmos 23:4"].context
assert "Refrigera a minha alma" in chunks["Salmos 24:1"].context
@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado")
def test_real_corpus_chunks_keep_verse_and_context_consistent():
chunks = list(parse_usfx(REAL_CORPUS, context_window=2))
assert len(chunks) == 31098
sample = next(c for c in chunks if c.reference == "Salmos 23:1")
assert sample.text == "O Senhor é o meu pastor; nada me faltará."
assert "Deitar-me faz em pastos verdejantes" in sample.context
assert all(chunk.text in chunk.context for chunk in chunks[:500])