"""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.config import Settings # noqa: E402 from app.corpus import CorpusParseError # noqa: E402 from app.embedding import HashEmbedder # noqa: E402 from app.indexer import Indexer, IndexStatus # noqa: E402 from app.vector_store import InMemoryVectorStore # noqa: E402 USFX_HEADER = ( "\n" "\n" ) def usfx(*books: str) -> str: return USFX_HEADER + "".join(books) + "\n" def book(book_id: str, name: str, *chapters: str) -> str: return ( f"{name}" + "".join(chapters) + "" ) def chapter(number: int, verses: dict[int, str]) -> str: parts = [f""] for verse, text in verses.items(): parts.append(f"{text} ") 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( 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, embedder=None, store=None): embedder = embedder or HashEmbedder(dimensions=32) store = store if store is not None else InMemoryVectorStore() indexer = Indexer(settings=settings, embedder=embedder, store=store) return indexer, store def write_corpus(directory: Path, name: str, content: str | None = None) -> Path: directory.mkdir(parents=True, exist_ok=True) path = directory / name path.write_text(content if content is not None else usfx(GENESIS), encoding="utf-8") return path # -------------------------------------------------------------------------- # 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.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, 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() assert calls == [], "corpus inalterado não deveria ser reprocessado" 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 calls == ["almeida.usfx.xml"] def test_reindex_replaces_old_chunks_of_the_same_file(settings): 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() == 3, "não deve haver duplicação após reindexação" 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() 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_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( "Gênesis", encoding="utf-8" ) indexer, store = build_indexer(settings) status = indexer.reindex() assert status.sources_indexed == 0 assert store.count() == 0 assert status.errors assert "quebrado.usfx.xml" in status.errors[0] 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.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(" 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() assert indexer.status().verses_indexed == 0 assert indexer.status().verses_total == 0 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() class SlowEmbedder(HashEmbedder): def encode(self, texts): started.set() release.wait(timeout=5) return super().encode(texts) indexer.embedder = SlowEmbedder(dimensions=32) worker = threading.Thread(target=indexer.reindex, daemon=True) worker.start() assert started.wait(timeout=5) 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