refactor: troca PDF+OCR por corpus USFX em domínio público
- 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)
This commit is contained in:
1 parent
621679cee5
commit
2043d5ddcf
34 files changed
+34561
-1842
No files matched your search
@@ -0,0 +1,335 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user