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.
440 lines
14 KiB
Python
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])
|