- corpus/: Almeida (João Ferreira de Almeida) em USFX, domínio público, 66 livros / 1189 capítulos / 31098 versículos, versionado com SHA-256 - app/corpus.py: parser USFX (texto no tail de <v/>), nomes canônicos dos livros, aliases (abreviaturas e numerais romanos), VerseChunk sem página - indexer: descobre *.xml em CORPUS_DIR, progresso em versículos, lote configurável, manifesto incremental por SHA-256 - remove extraction.py, parallel.py, bible_parser.py, Tesseract e PyMuPDF - campo page removido de Source/SearchResult/StatusResponse e do frontend - embedder: propriedade name; status reporta o embedder em uso, não o do .env - create_app constrói o embedder ANTES do store (o default HashEmbedder do VectorStore impedia o modelo configurado de ser carregado em produção) - fallback do modelo agora aparece em /api/status.errors - torch 2.6.0 + transformers 5.22.1 pinados (transformers 5.x exige torch>=2.6)
336 lines
10 KiB
Python
336 lines
10 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)
|