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:
omp committed 2026-11-02 11:16:07 -03:00
1 parent 621679cee5
commit 2043d5ddcf
34 files changed
+34561 -1842

No files matched your search

-261
View File
@@ -1,261 +0,0 @@
"""Testes do parser bíblico.
O parser reconstrói livro, capítulo e versículo a partir do texto extraído do PDF.
O caso de referência é o PDF do projeto (Almeida Revista e Corrigida), em que o
capítulo aparece como `[N]` e os versículos são numerados no início da linha.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.bible_parser import ( # noqa: E402
BOOKS,
BibleParser,
parse_book_index,
)
from app.extraction import PageText # noqa: E402
def make_page(number: int, text: str) -> PageText:
return PageText(page=number, text=text)
# --------------------------------------------------------------------------- #
# Índice de livros
# --------------------------------------------------------------------------- #
def test_parse_book_index_reads_book_and_start_page():
text = """
INDICE
ANTIGO TESTAMENTO: 5
Gênesis: 6
Êxodo: 168
Levítico: 303
NOVO TESTAMENTO: 2542
Mateus: 2543
Apocalipse: 3314
"""
starts = parse_book_index(text)
assert starts["Gênesis"] == 6
assert starts["Êxodo"] == 168
assert starts["Apocalipse"] == 3314
assert "ANTIGO TESTAMENTO" not in starts
assert "NOVO TESTAMENTO" not in starts
def test_parse_book_index_ignores_unknown_entries():
starts = parse_book_index("Gênesis: 6\nPagina solta: 12\n")
assert starts == {"Gênesis": 6}
# --------------------------------------------------------------------------- #
# Atribuição de livro
# --------------------------------------------------------------------------- #
def test_book_is_assigned_by_page_range():
parser = BibleParser()
starts = {"Gênesis": 6, "Êxodo": 168}
assert parser.book_for_page(6, starts) == "Gênesis"
assert parser.book_for_page(167, starts) == "Gênesis"
assert parser.book_for_page(168, starts) == "Êxodo"
assert parser.book_for_page(300, starts) == "Êxodo"
def test_page_before_first_book_has_no_book():
parser = BibleParser()
assert parser.book_for_page(3, {"Gênesis": 6}) is None
def test_page_after_last_book_keeps_last_book():
parser = BibleParser()
assert parser.book_for_page(3363, {"Gênesis": 6, "Êxodo": 168}) == "Êxodo"
def test_book_falls_back_to_page_header_when_index_is_missing():
parser = BibleParser()
text = "GÊNESIS\n\n[1]\n1 No princípio criou Deus os céus e a terra."
chunks = parser.parse([make_page(6, text)], "biblia.pdf", {})
assert chunks, "o fallback por cabeçalho deveria produzir chunks"
assert chunks[0].book == "Gênesis"
assert chunks[0].chapter == 1
assert chunks[0].verse == 1
# --------------------------------------------------------------------------- #
# Capítulos e versículos
# --------------------------------------------------------------------------- #
def test_parses_genesis_1_1():
parser = BibleParser()
text = (
"GÊNESIS\n"
"\n"
"[1]\n"
"1 No princípio criou Deus os céus e a terra.\n"
"2 A terra era sem forma e vazia; e havia trevas\n"
"sobre a face do abismo, mas o Espírito de Deus\n"
"pairava sobre a face das águas.\n"
)
chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6})
assert len(chunks) == 2
first = chunks[0]
assert first.book == "Gênesis"
assert first.chapter == 1
assert first.verse == 1
assert first.page == 6
assert first.file == "biblia.pdf"
assert first.text == "No princípio criou Deus os céus e a terra."
second = chunks[1]
assert second.verse == 2
assert second.text.endswith("pairava sobre a face das águas.")
def test_chapter_marker_in_brackets_increments_chapter():
parser = BibleParser()
text = (
"GÊNESIS\n"
"[1]\n"
"1 No princípio criou Deus os céus e a terra.\n"
"\n"
"[2]\n"
"1 E disse Deus: haja luz. E houve luz.\n"
)
chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6})
assert {c.chapter for c in chunks} == {1, 2}
chapter_two = next(c for c in chunks if c.chapter == 2)
assert chapter_two.verse == 1
def test_verse_number_alone_on_a_line_joins_the_next_line():
parser = BibleParser()
text = (
"GÊNESIS\n"
"[1]\n"
"1 No princípio criou Deus os céus e a terra.\n"
"2\n"
"A terra era sem forma e vazia.\n"
)
chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6})
assert len(chunks) == 2
assert chunks[1].verse == 2
assert chunks[1].text == "A terra era sem forma e vazia."
def test_continuation_lines_are_appended_to_the_current_verse():
parser = BibleParser()
text = (
"GÊNESIS\n"
"[1]\n"
"1 No princípio criou Deus os céus e a terra.\n"
"sobre a face do abismo, mas o Espírito de Deus\n"
)
chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6})
assert len(chunks) == 1
assert chunks[0].text.endswith("sobre a face do abismo, mas o Espírito de Deus")
def test_hyphenated_word_at_line_end_is_joined():
parser = BibleParser()
text = (
"GÊNESIS\n"
"[3]\n"
"10 Respondeu-lhe o homem: Ouvi a tua voz no\n"
"jardim e tive medo, porque estava nu; e esconde-\n"
"me.\n"
)
chunks = parser.parse([make_page(12, text)], "biblia.pdf", {"Gênesis": 6})
assert len(chunks) == 1
assert "escondeme" in chunks[0].text
assert "esconde-\n" not in chunks[0].text
def test_lexical_hyphen_is_preserved():
parser = BibleParser()
text = (
"GÊNESIS\n"
"[1]\n"
"1 E disse Deus: haja uma separação entre águas e águas,\n"
"para separar as águas.\n"
)
chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6})
assert chunks
assert "para separar" in chunks[0].text
def test_pages_outside_any_book_are_discarded():
parser = BibleParser()
index_text = "Gênesis: 6\nÊxodo: 168\n"
copyright_text = "L.C.C. Publicações Eletrônicas\nProibido todo uso comercial.\n"
chunks = parser.parse(
[make_page(3, index_text), make_page(6, "GÊNESIS\n[1]\n1 No princípio.\n"), make_page(3364, copyright_text)],
"biblia.pdf",
{"Gênesis": 6, "Êxodo": 168},
)
assert all(c.book == "Gênesis" for c in chunks)
assert all("Publicações" not in c.text for c in chunks)
def test_book_names_are_recognized_in_canonical_order():
# Esta edição da Almeida não inclui Neemias nem Ester: 64 livros.
assert len(BOOKS) == 64
assert BOOKS[0] == "Gênesis"
assert BOOKS[37] == "Mateus"
assert BOOKS[-1] == "Apocalipse"
assert "Neemias" not in BOOKS
assert "Ester" not in BOOKS
def test_esdras_is_followed_directly_by_job():
"""O índice do PDF vai de Esdras (1407) direto para Jó (1440)."""
assert BOOKS.index("Esdras") + 1 == BOOKS.index("Jó")
def test_reference_is_formatted_as_book_chapter_verse():
parser = BibleParser()
text = "GÊNESIS\n[1]\n1 No princípio criou Deus os céus e a terra.\n"
chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6})
assert chunks[0].reference == "Gênesis 1:1"
@pytest.mark.parametrize(
"raw,expected",
[
("Gênesis", "Gênesis"),
("Êxodo", "Êxodo"),
("Números", "Números"),
("Cântico dos Cânticos", "Cântico dos Cânticos"),
("I Coríntios", "I Coríntios"),
("II João", "II João"),
],
)
def test_canonical_book_names_are_preserved(raw, expected):
assert raw in BOOKS
assert expected == raw
+3 -4
View File
@@ -10,7 +10,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.bible_parser import VerseChunk # noqa: E402
from app.corpus import VerseChunk # noqa: E402
from app.chat import ( # noqa: E402
NO_EVIDENCE_MESSAGE,
ChatService,
@@ -56,8 +56,7 @@ def make_chunk(book="Gênesis", chapter=1, verse=1, text="No princípio criou De
book=book,
chapter=chapter,
verse=verse,
page=6,
file="biblia.pdf",
file="biblia.usfx.xml",
text=text,
)
@@ -65,7 +64,7 @@ def make_chunk(book="Gênesis", chapter=1, verse=1, text="No princípio criou De
@pytest.fixture
def settings():
return Settings(
pdf_dir=Path("/tmp/pdfs-test"),
corpus_dir=Path("/tmp/corpus-test"),
vector_db_dir=Path("/tmp/vector-test"),
embedding_model="hash-test",
top_k=6,
+335
View File
@@ -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)
+53
View File
@@ -0,0 +1,53 @@
"""Testes da identidade real do embedder.
O bug que estes testes impedem: o modelo configurado falha ao carregar, o código
cai num embedder determinístico sem semântica, e `/api/status` continua
reportando o nome do modelo configurado. O operador vê "MiniLM" enquanto a busca
é bag-of-hashes — exatamente a falha que passou despercebida com torch 2.5.1 +
transformers 5.x.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.embedding import HashEmbedder, SentenceTransformerEmbedder # noqa: E402
def test_hash_embedder_reports_itself_as_hash():
"""O nome precisa deixar óbvio que não há semântica nenhuma."""
embedder = HashEmbedder(dimensions=32)
assert "hash" in embedder.name.lower()
def test_hash_embedder_name_is_not_a_model_name():
embedder = HashEmbedder(dimensions=32)
assert "minilm" not in embedder.name.lower()
assert "e5" not in embedder.name.lower()
def test_sentence_transformer_embedder_reports_the_model_name():
class FakeModel:
def get_sentence_embedding_dimension(self) -> int:
return 8
embedder = SentenceTransformerEmbedder(
"modelo-exemplo", module=_FakeModule(FakeModel())
)
assert embedder.name == "modelo-exemplo"
class _FakeModule:
"""Dublê do módulo sentence_transformers, para não baixar modelo em teste."""
def __init__(self, model):
self._model = model
def SentenceTransformer(self, *args, **kwargs): # noqa: N802 - imita a API
return self._model
-322
View File
@@ -1,322 +0,0 @@
"""Testes do pipeline de extração de texto de PDF.
Cobre a detecção de camada textual corrompida (o PDF real do projeto), o fallback
para OCR, o cache por página e o isolamento de falhas de OCR.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.extraction import ( # noqa: E402
OcrError,
PdfExtractor,
control_char_ratio,
legible_ratio,
)
class FakePage:
"""Página de PDF simulada."""
def __init__(self, text: str = "", pixmap=None):
self._text = text
self._pixmap = pixmap or FakePixmap()
def get_text(self, kind: str) -> str:
assert kind == "text"
return self._text
def get_pixmap(self, dpi: int = 200):
return self._pixmap
class FakeDocument:
def __init__(self, pages):
self._pages = pages
self.closed = False
def __len__(self):
return len(self._pages)
def __getitem__(self, index):
return self._pages[index]
def __iter__(self):
return iter(self._pages)
def close(self):
self.closed = True
class FakePixmap:
def __init__(self, width=10, height=10, blank=False):
self.width = width
self.height = height
self.n = 3
self.blank = blank
self.saved_to: list[Path] = []
@property
def samples(self) -> bytes:
if self.blank:
return bytes([255]) * (self.width * self.height * self.n)
return bytes(range(self.width * self.height * self.n))[: self.width * self.height * self.n]
def save(self, path):
self.saved_to.append(Path(path))
Path(path).write_bytes(b"PNG")
class FakePyMuPDF:
"""Dublê do módulo pymupdf para testes."""
def __init__(self, document):
self._document = document
self.opened: list[Path] = []
def open(self, path):
self.opened.append(Path(path))
return self._document
@pytest.fixture
def tmp_cache(tmp_path):
return tmp_path / "ocr_cache"
def test_legible_ratio_is_high_for_normal_text():
text = "No princípio criou Deus os céus e a terra. E a terra era sem forma e vazia."
assert legible_ratio(text) > 0.9
def test_legible_ratio_is_low_for_control_characters():
text = "".join(chr(i) for i in range(1, 32))
assert legible_ratio(text) < 0.35
def _corrupted_index_layer() -> str:
"""Reproduz a camada textual corrompida da página 3 do índice real.
Medido no PDF: 442 caracteres não-branco, sendo 276 controles C0 (62%) e
166 legíveis (38%) — pontuação e letras soltas. A letra é o que empurra a
razão de legibilidade para 0.376, acima do limiar de 0.35.
"""
controls = "".join(chr(index) for index in range(1, 32)) * 9 # 279 controles
legible = "!\"#$%&'()*+,-./0123:;<=>?Gênesis:6" * 8 # letras e pontuação
return f"ÍNDICE\n{controls}{legible}\n"
def test_control_char_ratio_is_high_for_corrupted_layer():
"""A camada corrompida do PDF real mistura letras avulsas e controles.
A razão de caracteres legíveis não basta para detectar o problema: no
índice do PDF, letras soltas entre os controles elevam o placar de
legibilidade acima do limiar.
"""
corrupted = _corrupted_index_layer()
assert legible_ratio(corrupted) > 0.35, "premissa do teste: legível alto"
assert control_char_ratio(corrupted) > 0.01
def test_control_char_ratio_is_low_for_clean_text():
text = "GÊNESIS\n[1]\n1 No princípio criou Deus os céus e a terra."
assert control_char_ratio(text) < 0.01
def test_control_char_ratio_of_empty_text_is_zero():
assert control_char_ratio("") == 0.0
assert control_char_ratio(" \n\t ") == 0.0
def test_legible_ratio_of_empty_text_is_zero():
assert legible_ratio("") == 0.0
assert legible_ratio(" \n\t ") == 0.0
def test_legible_text_does_not_trigger_ocr(tmp_cache, tmp_path, monkeypatch):
pdf = tmp_path / "biblia.pdf"
pdf.write_bytes(b"%PDF-1.3")
doc = FakeDocument([FakePage(text="1 No princípio criou Deus os céus e a terra.")])
extractor = PdfExtractor(cache_dir=tmp_cache, pymupdf_module=FakePyMuPDF(doc))
def fail_ocr(*args, **kwargs):
raise AssertionError("OCR não deveria ser chamado para texto legível")
monkeypatch.setattr(extractor, "_run_tesseract", fail_ocr)
pages = list(extractor.extract_document(pdf))
assert len(pages) == 1
assert "No princípio" in pages[0].text
assert pages[0].page == 1
def test_corrupted_text_layer_triggers_ocr(tmp_cache, tmp_path, monkeypatch):
corrupted = "".join(chr(i) for i in range(1, 32))
page = FakePage(text=corrupted, pixmap=FakePixmap())
doc = FakeDocument([page])
extractor = PdfExtractor(cache_dir=tmp_cache, pymupdf_module=FakePyMuPDF(doc))
calls: list[int] = []
def fake_tesseract(pixmap, language, timeout):
calls.append(pixmap.width)
return "1 No princípio criou Deus os céus e a terra."
monkeypatch.setattr(extractor, "_run_tesseract", fake_tesseract)
pdf = tmp_path / "biblia.pdf"
pdf.write_bytes(b"%PDF-1.3")
pages = list(extractor.extract_document(pdf))
assert calls == [10], "OCR deveria ter sido chamado uma vez"
assert pages[0].text.startswith("1 No princípio")
def test_partially_legible_layer_with_controls_still_triggers_ocr(
tmp_cache, tmp_path, monkeypatch
):
"""Regressão do índice do PDF real.
A página do índice tem "ÍNDICE" legível e o resto corrompido. A razão de
legibilidade fica acima de 0.35, mas os caracteres de controle revelam a
corrupção — sem esta regra a página seria indexada com lixo.
"""
corrupted = _corrupted_index_layer()
assert legible_ratio(corrupted) > 0.35
page = FakePage(text=corrupted, pixmap=FakePixmap())
extractor = PdfExtractor(
cache_dir=tmp_cache,
pymupdf_module=FakePyMuPDF(FakeDocument([page])),
)
calls: list[int] = []
def fake_tesseract(pixmap, language, timeout):
calls.append(pixmap.width)
return "ÍNDICE\nGênesis: 6"
monkeypatch.setattr(extractor, "_run_tesseract", fake_tesseract)
pdf = tmp_path / "biblia.pdf"
pdf.write_bytes(b"%PDF-1.3")
pages = list(extractor.extract_document(pdf))
assert calls == [10], "OCR deveria ter sido chamado apesar da legibilidade alta"
assert "Gênesis" in pages[0].text
def test_ocr_result_is_cached_and_reused(tmp_cache, tmp_path, monkeypatch):
corrupted = "".join(chr(i) for i in range(1, 32))
page = FakePage(text=corrupted, pixmap=FakePixmap())
extractor = PdfExtractor(
cache_dir=tmp_cache,
pymupdf_module=FakePyMuPDF(FakeDocument([page])),
)
calls = 0
def fake_tesseract(pixmap, language, timeout):
nonlocal calls
calls += 1
return "texto ocr"
monkeypatch.setattr(extractor, "_run_tesseract", fake_tesseract)
pdf = tmp_path / "biblia.pdf"
pdf.write_bytes(b"%PDF-1.3")
first = list(extractor.extract_document(pdf, file_hash="abc123"))
second = list(extractor.extract_document(pdf, file_hash="abc123"))
assert calls == 1, "segunda extração deveria usar o cache"
assert first[0].text == second[0].text == "texto ocr"
def test_cache_is_invalidated_by_different_file_hash(tmp_cache, tmp_path, monkeypatch):
corrupted = "".join(chr(i) for i in range(1, 32))
page = FakePage(text=corrupted, pixmap=FakePixmap())
extractor = PdfExtractor(
cache_dir=tmp_cache,
pymupdf_module=FakePyMuPDF(FakeDocument([page])),
)
calls = 0
def fake_tesseract(pixmap, language, timeout):
nonlocal calls
calls += 1
return "texto ocr"
monkeypatch.setattr(extractor, "_run_tesseract", fake_tesseract)
pdf = tmp_path / "biblia.pdf"
pdf.write_bytes(b"%PDF-1.3")
list(extractor.extract_document(pdf, file_hash="hash1"))
list(extractor.extract_document(pdf, file_hash="hash2"))
assert calls == 2, "hash diferente deve invalidar o cache"
def test_ocr_failure_is_isolated(tmp_cache, tmp_path, monkeypatch):
corrupted = "".join(chr(i) for i in range(1, 32))
page = FakePage(text=corrupted, pixmap=FakePixmap())
extractor = PdfExtractor(
cache_dir=tmp_cache,
pymupdf_module=FakePyMuPDF(FakeDocument([page])),
)
def failing_tesseract(pixmap, language, timeout):
raise OcrError("tesseract falhou")
monkeypatch.setattr(extractor, "_run_tesseract", failing_tesseract)
pdf = tmp_path / "biblia.pdf"
pdf.write_bytes(b"%PDF-1.3")
pages = list(extractor.extract_document(pdf))
assert len(pages) == 1
assert pages[0].text == ""
assert pages[0].failed is True
def test_extract_page_returns_empty_text_for_blank_page(tmp_cache, monkeypatch):
page = FakePage(text="", pixmap=FakePixmap(blank=True))
extractor = PdfExtractor(
cache_dir=tmp_cache,
pymupdf_module=FakePyMuPDF(FakeDocument([page])),
)
def fail_ocr(*args, **kwargs):
raise AssertionError("OCR não deveria ser chamado em página em branco")
monkeypatch.setattr(extractor, "_run_tesseract", fail_ocr)
result = extractor.extract_page(page, "abc", 1)
assert result.text == ""
assert result.failed is False
def test_real_pdf_page_is_detected_as_corrupted(tmp_path):
"""O PDF do projeto tem camada textual corrompida: precisa cair no OCR."""
import pymupdf
real_pdf = Path(__file__).resolve().parents[2] / "PDF" / "biblia.pdf"
if not real_pdf.exists():
pytest.skip("PDF real não disponível")
doc = pymupdf.open(real_pdf)
try:
page = doc[5]
text = page.get_text("text")
finally:
doc.close()
assert legible_ratio(text) < 0.35, "camada textual do PDF real deveria ser ilegível"
+354 -151
View File
@@ -1,236 +1,439 @@
"""Testes do indexador: manifesto incremental, reprocessamento e falhas parciais."""
"""Testes do indexador: descoberta de corpus, manifesto incremental e falhas.
O indexador lê arquivos USFX de `settings.corpus_dir`. O que precisa valer:
incremento por SHA-256 (não reprocessar o que não mudou), substituição dos
chunks do mesmo arquivo em vez de duplicação, falha de um arquivo registrada sem
derrubar os demais, e progresso em versículos durante a indexação.
"""
from __future__ import annotations
import json
import sys
import threading
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.bible_parser import BibleParser # noqa: E402
from app.config import Settings # noqa: E402
from app.corpus import CorpusParseError # noqa: E402
from app.embedding import HashEmbedder # noqa: E402
from app.extraction import PageText # noqa: E402
from app.indexer import Indexer, IndexStatus # noqa: E402
from app.vector_store import InMemoryVectorStore # noqa: E402
class FakeExtractor:
"""Extrator controlado: devolve as páginas pré-definidas por arquivo."""
def __init__(self, pages_by_file: dict[str, list[PageText]]):
self.pages_by_file = pages_by_file
self.calls: list[str] = []
def extract_document(self, path: Path, file_hash: str | None = None):
self.calls.append(Path(path).name)
yield from self.pages_by_file.get(Path(path).name, [])
GENESIS_PAGE = (
"GÊNESIS\n"
"[1]\n"
"1 No princípio criou Deus os céus e a terra.\n"
"2 A terra era sem forma e vazia.\n"
USFX_HEADER = (
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<usfx xmlns:xsi='http://eBible.org/usfx.xsd'>\n"
)
@pytest.fixture
def settings(tmp_path):
from app.config import Settings
def usfx(*books: str) -> str:
return USFX_HEADER + "".join(books) + "</usfx>\n"
def book(book_id: str, name: str, *chapters: str) -> str:
return (
f"<book id='{book_id}'><h>{name}</h>"
+ "".join(chapters)
+ "</book>"
)
def chapter(number: int, verses: dict[int, str]) -> str:
parts = [f"<c id='{number}'/>"]
for verse, text in verses.items():
parts.append(f"<v id='{verse}'/>{text} <ve/>")
return "".join(parts)
GENESIS = book("GEN", "Gênesis", chapter(1, {1: "No princípio criou Deus.", 2: "E a terra era vazia."}))
JOHN = book("JHN", "João", chapter(3, {16: "Porque Deus amou o mundo."}))
@pytest.fixture
def settings(tmp_path) -> Settings:
return Settings(
pdf_dir=tmp_path / "pdfs",
corpus_dir=tmp_path / "corpus",
vector_db_dir=tmp_path / "vector_db",
embedding_model="hash-test",
embedding_batch_size=2,
index_batch_size=3,
auto_index=False,
)
def build_indexer(settings, extractor, embedder=None, store=None):
def build_indexer(settings, embedder=None, store=None):
embedder = embedder or HashEmbedder(dimensions=32)
store = store or InMemoryVectorStore(embedder=embedder)
indexer = Indexer(
settings=settings,
extractor=extractor,
parser=BibleParser(),
embedder=embedder,
store=store,
)
store = store if store is not None else InMemoryVectorStore()
indexer = Indexer(settings=settings, embedder=embedder, store=store)
return indexer, store
def write_pdf(directory: Path, name: str, content: bytes = b"%PDF-1.3") -> Path:
def write_corpus(directory: Path, name: str, content: str | None = None) -> Path:
directory.mkdir(parents=True, exist_ok=True)
path = directory / name
path.write_bytes(content)
path.write_text(content if content is not None else usfx(GENESIS), encoding="utf-8")
return path
def test_reindex_indexes_pdfs_and_reports_counts(settings):
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text=GENESIS_PAGE)]})
indexer, store = build_indexer(settings, extractor)
# --------------------------------------------------------------------------
# Descoberta
# --------------------------------------------------------------------------
def test_discover_sources_lists_xml_files_sorted(settings):
write_corpus(settings.corpus_dir, "b-almeida.usfx.xml")
write_corpus(settings.corpus_dir, "a-almeida.usfx.xml")
indexer, _ = build_indexer(settings)
assert [path.name for path in indexer.discover_sources()] == [
"a-almeida.usfx.xml",
"b-almeida.usfx.xml",
]
def test_non_corpus_files_are_ignored(settings):
write_corpus(settings.corpus_dir, "biblia.usfx.xml")
write_corpus(settings.corpus_dir, "notas.txt", "texto qualquer")
write_corpus(settings.corpus_dir, "biblia.pdf", "PDF antigo")
indexer, _ = build_indexer(settings)
assert [path.name for path in indexer.discover_sources()] == ["biblia.usfx.xml"]
def test_missing_corpus_directory_yields_no_sources(settings):
indexer, _ = build_indexer(settings)
assert indexer.discover_sources() == []
def test_empty_corpus_directory_is_ready_with_zero_chunks(settings):
settings.corpus_dir.mkdir(parents=True, exist_ok=True)
indexer, _ = build_indexer(settings)
status = indexer.reindex()
assert status.state == "ready"
assert status.pdfs_total == 1
assert status.pdfs_indexed == 1
assert status.chunks == 2
assert status.chunks == 0
assert status.sources_total == 0
# --------------------------------------------------------------------------
# Indexação
# --------------------------------------------------------------------------
def test_reindex_indexes_verses_and_reports_counts(settings):
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS, JOHN))
indexer, store = build_indexer(settings)
status = indexer.reindex()
assert status.state == "ready"
assert status.sources_total == 1
assert status.sources_indexed == 1
assert status.chunks == 3
assert store.count() == 3
def test_chunks_carry_book_chapter_and_verse(settings):
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(JOHN))
indexer, store = build_indexer(settings)
indexer.reindex()
results = store.query([0.1] * 32, n_results=5, min_similarity=-1.0)
assert results[0].book == "João"
assert results[0].chapter == 3
assert results[0].verse == 16
assert results[0].reference == "João 3:16"
assert results[0].file == "almeida.usfx.xml"
def test_placeholder_verses_do_not_become_chunks(settings):
"""Versículo só com travessão é ruído e não pode ocupar slot no índice."""
content = usfx(
book("MAT", "Mateus", chapter(17, {20: "Versículo real.", 21: "—", 22: "Outro."}))
)
write_corpus(settings.corpus_dir, "almeida.usfx.xml", content)
indexer, store = build_indexer(settings)
indexer.reindex()
assert store.count() == 2
def test_reindex_is_incremental_when_hash_is_unchanged(settings):
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text=GENESIS_PAGE)]})
indexer, _ = build_indexer(settings, extractor)
indexer.reindex()
def test_reindex_is_incremental_when_hash_is_unchanged(settings, monkeypatch):
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS))
indexer, _ = build_indexer(settings)
indexer.reindex()
assert extractor.calls == ["biblia.pdf"], "o PDF não deveria ser reprocessado"
calls: list[str] = []
import app.indexer as indexer_module
real_parse = indexer_module.parse_usfx
def test_reindex_reprocesses_file_when_content_changes(settings):
pdf_path = write_pdf(settings.pdf_dir, "biblia.pdf", b"%PDF-1.3 original")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text=GENESIS_PAGE)]})
indexer, _ = build_indexer(settings, extractor)
def tracked_parse(path, file_name=None):
calls.append(Path(path).name)
return real_parse(path, file_name)
indexer.reindex()
pdf_path.write_bytes(b"%PDF-1.3 modified content")
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
indexer.reindex()
assert extractor.calls == ["biblia.pdf", "biblia.pdf"]
assert calls == [], "corpus inalterado não deveria ser reprocessado"
def test_reindex_with_force_reprocesses_everything(settings):
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text=GENESIS_PAGE)]})
indexer, _ = build_indexer(settings, extractor)
def test_reindex_reprocesses_when_content_changes(settings, monkeypatch):
path = write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS))
indexer, store = build_indexer(settings)
indexer.reindex()
calls: list[str] = []
import app.indexer as indexer_module
real_parse = indexer_module.parse_usfx
def tracked_parse(p, file_name=None):
calls.append(Path(p).name)
return real_parse(p, file_name)
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
path.write_text(usfx(GENESIS, JOHN), encoding="utf-8")
indexer.reindex()
assert calls == ["almeida.usfx.xml"]
assert store.count() == 3, "chunks antigos devem ser substituídos, não somados"
def test_force_reprocesses_everything(settings, monkeypatch):
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS))
indexer, _ = build_indexer(settings)
indexer.reindex()
calls: list[str] = []
import app.indexer as indexer_module
real_parse = indexer_module.parse_usfx
def tracked_parse(path, file_name=None):
calls.append(Path(path).name)
return real_parse(path, file_name)
monkeypatch.setattr(indexer_module, "parse_usfx", tracked_parse)
indexer.reindex(force=True)
assert extractor.calls == ["biblia.pdf", "biblia.pdf"]
def test_manifest_is_persisted(settings):
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text=GENESIS_PAGE)]})
indexer, _ = build_indexer(settings, extractor)
indexer.reindex()
manifest = json.loads(settings.manifest_path.read_text(encoding="utf-8"))
assert "biblia.pdf" in manifest
assert manifest["biblia.pdf"]["state"] == "complete"
assert manifest["biblia.pdf"]["chunks"] == 2
assert manifest["biblia.pdf"]["sha256"]
def test_partial_file_is_recorded_as_partial(settings):
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor(
{
"biblia.pdf": [
PageText(page=6, text=GENESIS_PAGE),
PageText(page=7, text="", failed=True),
]
}
)
indexer, store = build_indexer(settings, extractor)
status = indexer.reindex()
manifest = json.loads(settings.manifest_path.read_text(encoding="utf-8"))
assert manifest["biblia.pdf"]["state"] == "partial"
assert manifest["biblia.pdf"]["failed_pages"] == [7]
assert status.errors
def test_failed_file_is_recorded_as_error(settings):
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text="", failed=True)]})
indexer, store = build_indexer(settings, extractor)
indexer.reindex()
manifest = json.loads(settings.manifest_path.read_text(encoding="utf-8"))
assert manifest["biblia.pdf"]["state"] == "error"
assert store.count() == 0
assert calls == ["almeida.usfx.xml"]
def test_reindex_replaces_old_chunks_of_the_same_file(settings):
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text=GENESIS_PAGE)]})
indexer, store = build_indexer(settings, extractor)
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS, JOHN))
indexer, store = build_indexer(settings)
indexer.reindex()
indexer.reindex(force=True)
assert store.count() == 2, "não deve haver duplicação após reindexação"
assert store.count() == 3, "não deve haver duplicação após reindexação"
def test_status_reports_chunks_from_the_store(settings):
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text=GENESIS_PAGE)]})
indexer, store = build_indexer(settings, extractor)
def test_two_corpus_files_coexist_without_id_collision(settings):
write_corpus(settings.corpus_dir, "a.usfx.xml", usfx(JOHN))
write_corpus(settings.corpus_dir, "b.usfx.xml", usfx(JOHN))
indexer, store = build_indexer(settings)
status = indexer.reindex()
assert status.sources_indexed == 2
assert store.count() == 2, "mesmo versículo em arquivos distintos não colide"
# --------------------------------------------------------------------------
# Manifesto
# --------------------------------------------------------------------------
def test_manifest_is_persisted(settings):
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS))
indexer, _ = build_indexer(settings)
indexer.reindex()
status = indexer.status()
assert isinstance(status, IndexStatus)
assert status.chunks == store.count()
manifest = json.loads(settings.manifest_path.read_text(encoding="utf-8"))
assert manifest["almeida.usfx.xml"]["state"] == "complete"
assert manifest["almeida.usfx.xml"]["chunks"] == 2
assert len(manifest["almeida.usfx.xml"]["sha256"]) == 64
def test_empty_pdf_directory_is_ready_with_zero_chunks(settings):
settings.pdf_dir.mkdir(parents=True, exist_ok=True)
extractor = FakeExtractor({})
indexer, _ = build_indexer(settings, extractor)
def test_manifest_survives_a_new_indexer_instance(settings):
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS))
indexer, _ = build_indexer(settings)
indexer.reindex()
restarted, _ = build_indexer(settings)
assert restarted.status().sources_indexed == 1
# --------------------------------------------------------------------------
# Falhas
# --------------------------------------------------------------------------
def test_malformed_corpus_is_recorded_as_error(settings):
settings.corpus_dir.mkdir(parents=True, exist_ok=True)
(settings.corpus_dir / "quebrado.usfx.xml").write_text(
"<usfx><book id='GEN'><h>Gênesis</h>", encoding="utf-8"
)
indexer, store = build_indexer(settings)
status = indexer.reindex()
assert status.state == "ready"
assert status.pdfs_total == 0
assert status.chunks == 0
assert status.sources_indexed == 0
assert store.count() == 0
assert status.errors
assert "quebrado.usfx.xml" in status.errors[0]
def test_non_pdf_files_are_ignored(settings):
settings.pdf_dir.mkdir(parents=True, exist_ok=True)
(settings.pdf_dir / "notas.txt").write_text("ignorado", encoding="utf-8")
extractor = FakeExtractor({})
indexer, _ = build_indexer(settings, extractor)
def test_corpus_without_verses_is_recorded_as_error(settings):
write_corpus(settings.corpus_dir, "vazio.usfx.xml", usfx())
indexer, store = build_indexer(settings)
status = indexer.reindex()
assert status.pdfs_total == 0
assert extractor.calls == []
assert status.sources_indexed == 0
assert store.count() == 0
assert status.errors
def test_one_bad_file_does_not_block_the_others(settings):
settings.corpus_dir.mkdir(parents=True, exist_ok=True)
(settings.corpus_dir / "a-bom.usfx.xml").write_text(usfx(GENESIS), encoding="utf-8")
(settings.corpus_dir / "b-ruim.usfx.xml").write_text("<usfx><book", encoding="utf-8")
indexer, store = build_indexer(settings)
status = indexer.reindex()
assert status.sources_indexed == 1
assert store.count() == 2
assert len(status.errors) == 1
def test_failed_file_is_retried_on_the_next_reindex(settings):
path = write_corpus(settings.corpus_dir, "almeida.usfx.xml", "<usfx><book")
indexer, store = build_indexer(settings)
assert indexer.reindex().sources_indexed == 0
path.write_text(usfx(GENESIS), encoding="utf-8")
status = indexer.reindex()
assert status.sources_indexed == 1
assert store.count() == 2
def test_error_state_is_reported_when_nothing_was_indexed(settings):
write_corpus(settings.corpus_dir, "vazio.usfx.xml", usfx())
indexer, _ = build_indexer(settings)
assert indexer.reindex().state == "error"
# --------------------------------------------------------------------------
# Progresso
# --------------------------------------------------------------------------
def test_status_reports_indexing_while_work_is_in_progress(settings):
"""Durante a indexação o status deve mostrar progresso, não zero."""
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text=GENESIS_PAGE)]})
indexer, _ = build_indexer(settings, extractor)
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS))
class BlockingEmbedder(HashEmbedder):
def encode(self, texts):
captured["status"] = indexer.status()
return super().encode(texts)
captured: dict[str, IndexStatus] = {}
indexer, _ = build_indexer(settings, embedder=BlockingEmbedder(dimensions=32))
indexer.reindex()
assert captured["status"].state == "indexing"
assert captured["status"].sources_total == 1
assert captured["status"].current_file == "almeida.usfx.xml"
def test_status_counts_verses_indexed_during_indexing(settings):
content = usfx(
book("GEN", "Gênesis", chapter(1, {i: f"Versículo {i}." for i in range(1, 11)}))
)
write_corpus(settings.corpus_dir, "almeida.usfx.xml", content)
seen: list[tuple[int, int]] = []
class TrackingEmbedder(HashEmbedder):
def encode(self, texts):
result = super().encode(texts)
seen.append(
(indexer.status().verses_indexed, indexer.status().verses_total)
)
return result
indexer, _ = build_indexer(settings, embedder=TrackingEmbedder(dimensions=32))
status = indexer.reindex()
assert status.verses_total == 10
assert status.verses_indexed == 10
assert seen[0][1] == 10, "o total precisa estar disponível desde o primeiro lote"
assert len(seen) > 1, "o progresso precisa avançar em mais de um lote"
assert [done for done, _ in seen] == sorted(done for done, _ in seen)
assert seen[-1][0] > seen[0][0], "o contador deve avançar durante a indexação"
def test_verses_total_is_reset_between_runs(settings):
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS))
indexer, _ = build_indexer(settings)
indexer.reindex()
assert indexer.status().verses_indexed == 2
indexer.mark_indexing()
status = indexer.status()
assert status.state == "indexing"
assert status.pdfs_total == 1
assert indexer.status().verses_indexed == 0
assert indexer.status().verses_total == 0
def test_status_counts_pages_processed_during_indexing(settings):
write_pdf(settings.pdf_dir, "biblia.pdf")
extractor = FakeExtractor({"biblia.pdf": [PageText(page=6, text=GENESIS_PAGE)]})
indexer, _ = build_indexer(settings, extractor)
def test_reindex_lock_blocks_concurrent_runs(settings):
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS))
indexer, _ = build_indexer(settings)
started = threading.Event()
release = threading.Event()
indexer.mark_indexing()
indexer.report_progress(pdf_name="biblia.pdf", pages_done=120, pages_total=3364)
class SlowEmbedder(HashEmbedder):
def encode(self, texts):
started.set()
release.wait(timeout=5)
return super().encode(texts)
status = indexer.status()
indexer.embedder = SlowEmbedder(dimensions=32)
worker = threading.Thread(target=indexer.reindex, daemon=True)
worker.start()
assert started.wait(timeout=5)
assert status.pages_processed == 120
assert status.pages_total == 3364
assert indexer.reindex_lock.locked()
release.set()
worker.join(timeout=5)
assert not indexer.reindex_lock.locked()
def test_status_reports_the_embedder_actually_in_use(settings):
"""O nome reportado é o do embedder em uso, não o configurado no .env.
Se o modelo configurado falhar ao carregar e houver fallback, anunciar o nome
do .env esconderia que a busca está rodando sem semântica nenhuma.
"""
write_corpus(settings.corpus_dir, "almeida.usfx.xml", usfx(GENESIS))
settings.embedding_model = "modelo-que-nao-existe"
indexer, _ = build_indexer(settings, embedder=HashEmbedder(dimensions=32))
assert indexer.reindex().model == HashEmbedder.name
+71 -6
View File
@@ -45,18 +45,18 @@ class FakeIndexer:
self.release.wait(timeout=10)
from app.indexer import IndexStatus
return IndexStatus(state="ready", pdfs_total=1, pdfs_indexed=1, chunks=42)
return IndexStatus(state="ready", sources_total=1, sources_indexed=1, chunks=42)
def status(self):
from app.indexer import IndexStatus
return IndexStatus(state="ready", pdfs_total=1, pdfs_indexed=1, chunks=42)
return IndexStatus(state="ready", sources_total=1, sources_indexed=1, chunks=42)
@pytest.fixture
def settings(tmp_path):
return Settings(
pdf_dir=tmp_path / "pdfs",
corpus_dir=tmp_path / "corpus",
vector_db_dir=tmp_path / "vector_db",
embedding_model="hash-test",
llm_provider="stub",
@@ -156,7 +156,7 @@ def test_chat_rejects_message_above_the_limit(client):
def test_chat_streams_sources_token_and_done(client):
from app.bible_parser import VerseChunk
from app.corpus import VerseChunk
embedder = HashEmbedder(dimensions=64)
store = client.app.state.services.store
@@ -164,8 +164,7 @@ def test_chat_streams_sources_token_and_done(client):
book="Gênesis",
chapter=1,
verse=1,
page=6,
file="biblia.pdf",
file="biblia.usfx.xml",
text="No princípio criou Deus os céus e a terra.",
)
store.upsert([chunk], embedder.encode([chunk.text]))
@@ -213,3 +212,69 @@ def test_sources_endpoint_returns_a_list(client):
assert response.status_code == 200
assert "sources" in response.json()
def test_create_app_builds_the_configured_embedder(monkeypatch, tmp_path):
"""Produção: o modelo configurado precisa ser construído de fato.
O `VectorStore` default para `HashEmbedder` quando nasce sem embedder. Se a
aplicação criar o store primeiro e depois herdar o embedder dele, o modelo
configurado nunca é carregado e a busca roda sem semântica nenhuma — com
`/api/status` ainda anunciando o nome do modelo.
"""
import app.main as main_module
built: list[str] = []
class FakeSentenceTransformer:
def __init__(self, model_name, batch_size=32, device="cpu", module=None):
built.append(model_name)
self.name = model_name
self.dimensions = 8
def encode(self, texts):
return [[0.0] * 8 for _ in texts]
monkeypatch.setattr(main_module, "SentenceTransformerEmbedder", FakeSentenceTransformer)
settings = Settings(
corpus_dir=tmp_path / "corpus",
vector_db_dir=tmp_path / "vector_db",
embedding_model="modelo-configurado",
llm_provider="stub",
auto_index=False,
)
app = main_module.create_app(settings=settings)
services = app.state.services
assert built == ["modelo-configurado"]
assert services.embedder.name == "modelo-configurado"
assert services.store.embedder.name == "modelo-configurado"
assert services.embedder_warning is None
def test_embedder_fallback_is_surfaced_in_status(monkeypatch, tmp_path):
"""Se o modelo não carrega, o status precisa dizer que a busca está sem semântica."""
import app.main as main_module
def explode(*args, **kwargs):
raise RuntimeError("PyTorch >= 2.6 is required but found 2.5.1")
monkeypatch.setattr(main_module, "SentenceTransformerEmbedder", explode)
settings = Settings(
corpus_dir=tmp_path / "corpus",
vector_db_dir=tmp_path / "vector_db",
embedding_model="modelo-configurado",
llm_provider="stub",
auto_index=False,
)
app = main_module.create_app(
settings=settings, indexer=FakeIndexer(), provider=StubProvider()
)
body = TestClient(app).get("/api/status").json()
assert any("semântica" in error for error in body["errors"])
assert app.state.services.embedder.name == HashEmbedder.name
-76
View File
@@ -1,76 +0,0 @@
"""Testes da extração paralela de páginas.
O OCR é o gargalo da indexação: ~1,2 s por página. Para 3.364 páginas, a
extração sequencial levaria mais de uma hora. A paralelização usa subprocessos
do Tesseract, que é seguro porque cada página é um processo independente.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.extraction import PdfExtractor # noqa: E402
from app.parallel import parallel_map # noqa: E402
def test_parallel_map_preserves_order():
results = parallel_map([1, 2, 3, 4, 5], lambda value: value * 2, workers=4)
assert results == [2, 4, 6, 8, 10]
def test_parallel_map_handles_empty_input():
assert parallel_map([], lambda value: value, workers=4) == []
def test_parallel_map_is_faster_than_sequential_for_io_bound_work():
def slow(value: int) -> int:
time.sleep(0.2)
return value
start = time.monotonic()
sequential = [slow(value) for value in range(4)]
sequential_elapsed = time.monotonic() - start
start = time.monotonic()
parallel = parallel_map(list(range(4)), slow, workers=4)
parallel_elapsed = time.monotonic() - start
assert sequential == parallel
assert parallel_elapsed < sequential_elapsed
def test_parallel_map_isolates_worker_failures():
def flaky(value: int) -> int:
if value == 2:
raise ValueError("falhou")
return value
results = parallel_map([1, 2, 3], flaky, workers=2, default=None)
assert results[0] == 1
assert results[2] == 3
def test_parallel_map_uses_threads_by_default():
"""Threads bastam quando o trabalho é predominantemente subprocesso."""
import threading
barrier = threading.Barrier(2, timeout=5)
seen: list[str] = []
def worker(value: int) -> int:
seen.append(threading.current_thread().name)
# A barreira só passa se as duas tarefas rodarem em threads distintas.
barrier.wait()
return value
parallel_map([1, 2], worker, workers=2)
assert len(set(seen)) == 2, "esperava duas threads distintas"
+1 -2
View File
@@ -9,7 +9,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.bible_parser import VerseChunk # noqa: E402
from app.corpus import VerseChunk # noqa: E402
from app.embedding import HashEmbedder, normalize # noqa: E402
from app.vector_store import InMemoryVectorStore, VectorStore # noqa: E402
@@ -19,7 +19,6 @@ def make_chunk(verse: int, book: str = "Gênesis", chapter: int = 1, file: str =
book=book,
chapter=chapter,
verse=verse,
page=6,
file=file,
text=f"Versículo {verse} de {book} {chapter}",
)