"""Testes do serviço de chat: contexto, fidelidade e validação de citações.""" from __future__ import annotations import asyncio import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.corpus import VerseChunk # noqa: E402 from app.chat import ( # noqa: E402 NO_EVIDENCE_MESSAGE, ChatService, extract_reference, validate_citations, ) from app.config import Settings # noqa: E402 from app.embedding import HashEmbedder # noqa: E402 from app.vector_store import InMemoryVectorStore # noqa: E402 class RecordingProvider: """Provedor que registra as chamadas e devolve texto configurável.""" def __init__(self, response: str = "Resposta com (Gênesis 1:1)."): self.response = response self.calls: list[list[dict]] = [] self.systems: list[str] = [] async def stream(self, messages, system): self.calls.append(list(messages)) self.systems.append(system) for word in self.response.split(" "): yield word + " " async def aclose(self): return None class ExplodingProvider: """Provedor que falha se for chamado.""" async def stream(self, messages, system): raise AssertionError("o provedor não deveria ser chamado") yield "" # pragma: no cover async def aclose(self): return None def make_chunk(book="Gênesis", chapter=1, verse=1, text="No princípio criou Deus os céus e a terra."): return VerseChunk( book=book, chapter=chapter, verse=verse, file="biblia.usfx.xml", text=text, ) @pytest.fixture def settings(): return Settings( corpus_dir=Path("/tmp/corpus-test"), vector_db_dir=Path("/tmp/vector-test"), embedding_model="hash-test", top_k=6, min_similarity=0.0, ) @pytest.fixture def store(): return InMemoryVectorStore(embedder=HashEmbedder(dimensions=64)) def build_service(settings, store, provider): embedder = HashEmbedder(dimensions=64) return ChatService( settings=settings, store=store, embedder=embedder, provider=provider, ) def run(coro): return asyncio.run(coro) # --------------------------------------------------------------------------- # # Extração de referência # --------------------------------------------------------------------------- # def test_extract_reference_finds_book_chapter_verse(): assert extract_reference("o que diz João 3:16?") == ("João", 3, 16) def test_extract_reference_handles_ranges(): assert extract_reference("me mostre Salmos 23:1-3") == ("Salmos", 23, 1) def test_extract_reference_returns_none_without_reference(): assert extract_reference("estou triste hoje") is None def test_extract_reference_is_case_insensitive(): assert extract_reference("GÊNESIS 1:1") == ("Gênesis", 1, 1) # --------------------------------------------------------------------------- # # Validação de citações # --------------------------------------------------------------------------- # def test_supported_citation_is_preserved(): text = "Deus criou o mundo (Gênesis 1:1)." cleaned, citations = validate_citations(text, {"Gênesis 1:1"}) assert cleaned == text assert citations == ["Gênesis 1:1"] def test_unsupported_citation_is_removed(): text = "Isso está escrito em João 3:16 e em Gênesis 1:1." cleaned, citations = validate_citations(text, {"Gênesis 1:1"}) assert "João 3:16" not in cleaned assert "Gênesis 1:1" in cleaned assert citations == ["Gênesis 1:1"] def test_citation_outside_the_source_list_is_removed(): text = "Veja Apocalipse 22:21." cleaned, citations = validate_citations(text, {"Gênesis 1:1"}) assert "Apocalipse 22:21" not in cleaned assert citations == [] def test_validation_reports_no_citation_when_there_is_none(): cleaned, citations = validate_citations("Uma reflexão sem citação.", {"Gênesis 1:1"}) assert cleaned == "Uma reflexão sem citação." assert citations == [] # --------------------------------------------------------------------------- # # Resposta sem evidência # --------------------------------------------------------------------------- # def test_without_evidence_returns_fixed_message_without_calling_llm(settings, store): provider = ExplodingProvider() service = build_service(settings, store, provider) result = run(service.answer("Qual a melhor investment?", [])) assert result.text == NO_EVIDENCE_MESSAGE assert result.sources == [] assert result.citations == [] # --------------------------------------------------------------------------- # # Resposta com evidência # --------------------------------------------------------------------------- # def test_answer_uses_the_provider_when_evidence_exists(settings, store): chunk = make_chunk() embedder = HashEmbedder(dimensions=64) store.upsert([chunk], embedder.encode([chunk.text])) provider = RecordingProvider("A Palavra nos diz (Gênesis 1:1).") service = build_service(settings, store, provider) result = run(service.answer("Quem criou os céus e a terra?", [])) assert "Gênesis 1:1" in result.text assert len(provider.calls) == 1 assert result.sources assert result.sources[0].reference == "Gênesis 1:1" def test_context_contains_the_retrieved_versicles(settings, store): chunk = make_chunk() embedder = HashEmbedder(dimensions=64) store.upsert([chunk], embedder.encode([chunk.text])) provider = RecordingProvider() service = build_service(settings, store, provider) run(service.answer("Quem criou?", [])) sent = provider.calls[0] assert sent[0]["role"] == "user" assert "No princípio criou Deus os céus e a terra" in sent[0]["content"] assert "Gênesis 1:1" in sent[0]["content"] def test_context_marks_sources_as_data_not_instructions(settings, store): chunk = make_chunk() embedder = HashEmbedder(dimensions=64) store.upsert([chunk], embedder.encode([chunk.text])) provider = RecordingProvider() service = build_service(settings, store, provider) run(service.answer("Quem criou?", [])) content = provider.calls[0][0]["content"] assert "[FONTE 1]" in content assert "evidência" in content.lower() or "dado" in content.lower() def test_system_prompt_is_passed_to_the_provider(settings, store): chunk = make_chunk() embedder = HashEmbedder(dimensions=64) store.upsert([chunk], embedder.encode([chunk.text])) provider = RecordingProvider() service = build_service(settings, store, provider) run(service.answer("Quem criou?", [])) system = provider.systems[0] assert "Pastor Online" in system assert "única fonte de autoridade" in system def test_unsupported_citation_from_the_model_is_removed(settings, store): chunk = make_chunk() embedder = HashEmbedder(dimensions=64) store.upsert([chunk], embedder.encode([chunk.text])) provider = RecordingProvider("Veja também Efésios 2:8 e Gênesis 1:1.") service = build_service(settings, store, provider) result = run(service.answer("Fé e obras?", [])) assert "Efésios 2:8" not in result.text assert "Gênesis 1:1" in result.text assert result.citations == ["Gênesis 1:1"] def test_explicit_reference_question_uses_the_exact_verse(settings, store): """`João 3:16` deve buscar o versículo pedido, não o mais similar.""" genesis = make_chunk(text="No princípio criou Deus os céus e a terra.") john = make_chunk( book="João", chapter=3, verse=16, text="Porque Deus amou o mundo de tal maneira..." ) embedder = HashEmbedder(dimensions=64) store.upsert([genesis, john], embedder.encode([genesis.text, john.text])) provider = RecordingProvider("Está escrito em João 3:16.") service = build_service(settings, store, provider) result = run(service.answer("o que diz João 3:16?", [])) assert [source.reference for source in result.sources] == ["João 3:16"] assert "Porque Deus amou o mundo" in provider.calls[0][0]["content"] def test_history_is_included_in_the_prompt(settings, store): chunk = make_chunk() embedder = HashEmbedder(dimensions=64) store.upsert([chunk], embedder.encode([chunk.text])) provider = RecordingProvider() service = build_service(settings, store, provider) history = [ {"role": "user", "content": "Quem criou o mundo?"}, {"role": "assistant", "content": "No princípio (Gênesis 1:1)."}, ] run(service.answer("E o que isso significa?", history)) sent = provider.calls[0] assert len(sent) >= 3 assert any("Quem criou o mundo?" in message["content"] for message in sent) def test_history_is_limited(settings, store): chunk = make_chunk() embedder = HashEmbedder(dimensions=64) store.upsert([chunk], embedder.encode([chunk.text])) provider = RecordingProvider() service = build_service(settings, store, provider) history = [ {"role": "user", "content": f"mensagem {index}"} for index in range(30) ] run(service.answer("pergunta", history)) sent = provider.calls[0] assert len(sent) <= settings.max_history_turns + 1 def test_explicit_reference_bypasses_similarity_search(settings, store): """Quem digita `João 3:16` quer aquele versículo, não um parecido. A busca determinística por metadados tem precedência e não depende de limiar de similaridade nenhum. """ embedder = HashEmbedder(dimensions=64) target = make_chunk(book="João", chapter=3, verse=16, text="Porque Deus amou o mundo.") distractor = make_chunk(book="Salmos", chapter=23, verse=1, text="O Senhor é o meu pastor.") store.upsert( [target, distractor], embedder.encode([chunk.embedding_text for chunk in (target, distractor)]), ) class NeverCalled: def search(self, message): raise AssertionError("a busca por similaridade não deveria rodar") service = ChatService( settings=settings, store=store, embedder=embedder, provider=RecordingProvider(), retriever=NeverCalled(), ) results = service.retrieve("o que diz João 3:16?") assert [result.reference for result in results] == ["João 3:16"] def test_source_shows_the_cited_verse_not_the_context_window(settings): """A referência exibida tem de corresponder ao texto exibido. A busca usa uma janela de versículos vizinhos, mas o usuário vê a citação: mostrar a janela inteira rotulada como um único versículo seria mentira. """ from app.chat import Source store = InMemoryVectorStore(embedder=HashEmbedder(dimensions=64)) embedder = HashEmbedder(dimensions=64) chunk = VerseChunk( book="Salmos", chapter=23, verse=1, file="almeida.usfx.xml", text="O Senhor é o meu pastor.", context="Versículo anterior. O Senhor é o meu pastor. Versículo seguinte.", ) store.upsert([chunk], embedder.encode([chunk.embedding_text])) settings.min_similarity = -1.0 service = build_service(settings, store, RecordingProvider()) result = service.retrieve("pastor")[0] source = Source.from_result(result) assert source.reference == "Salmos 23:1" assert source.text == "O Senhor é o meu pastor." assert "Versículo anterior" in result.text, "o modelo recebe a janela completa" def test_unsupported_citation_is_removed_whole_not_just_the_numbers(): """Remover só o `1:1` deixa `(Gênesis )` pendurado na resposta do usuário. Defeito observado na UI: a validação cortava o trecho `Capítulo:Versículo` e preservava o nome do livro, que é justamente o que não tem sustentação. """ from app.chat import validate_citations cleaned, citations = validate_citations( "O Senhor é o meu pastor (Salmos 23:1) e no princípio criou Deus (Gênesis 1:1).", {"Salmos 23:1"}, ) assert citations == ["Salmos 23:1"] assert "Gênesis" not in cleaned, "o nome do livro não sustentado tem de sair junto" assert cleaned == "O Senhor é o meu pastor (Salmos 23:1) e no princípio criou Deus." def test_removal_does_not_leave_empty_parentheses(): from app.chat import validate_citations cleaned, citations = validate_citations( "Deus é amor (1 João 4:8) e o texto continua (Apocalipse 9:9).", {"1 João 4:8"}, ) assert citations == ["1 João 4:8"] assert "()" not in cleaned assert "( )" not in cleaned assert cleaned == "Deus é amor (1 João 4:8) e o texto continua." def test_removal_of_a_range_citation_takes_the_whole_range(): from app.chat import validate_citations cleaned, citations = validate_citations( "Conforme Salmos 23:1-3 e também Jó 1:2-3, há esperança.", {"Salmos 23:1-3"}, ) assert citations == ["Salmos 23:1-3"] assert "Jó" not in cleaned # -------------------------------------------------------------------------- # Livros numerados (18 dos 66: 1/2/3 Samuel, Reis, Crônicas, Coríntios, ...) # -------------------------------------------------------------------------- @pytest.mark.parametrize( "text,expected", [ ("1 João 4:8", ("1 João", 4, 8)), ("2 Reis 5:14", ("2 Reis", 5, 14)), ("1 Coríntios 13:4", ("1 Coríntios", 13, 4)), ("3 João 1:4", ("3 João", 1, 4)), ("I Samuel 17:4", ("1 Samuel", 17, 4)), ("II Crônicas 7:14", ("2 Crônicas", 7, 14)), ("1Sm 18:6", ("1 Samuel", 18, 6)), ("João 3:16", ("João", 3, 16)), ("Salmos 23:1", ("Salmos", 23, 1)), ], ) def test_extract_reference_keeps_the_book_number(text, expected): """Perder o número devolve outro livro: '1 João 4:8' viraria João 4:8.""" from app.chat import extract_reference assert extract_reference(text) == expected def test_extract_reference_inside_a_sentence_keeps_the_book_number(): from app.chat import extract_reference assert extract_reference("Leia 2 Reis 5:14 com atenção") == ("2 Reis", 5, 14) def test_reference_after_another_reference_keeps_its_own_book(): """O dígito do livro não pode ser confundido com o versículo anterior.""" from app.chat import extract_reference assert extract_reference("Salmos 23:1 e também 1 João 4:8") == ("Salmos", 23, 1) def test_supported_numbered_book_citation_is_kept(): """Uma citação correta de livro numerado não pode ser apagada da resposta.""" from app.chat import validate_citations cleaned, citations = validate_citations( "Deus é amor (1 João 4:8).", {"1 João 4:8"} ) assert citations == ["1 João 4:8"] assert cleaned == "Deus é amor (1 João 4:8)." def test_unsupported_numbered_book_citation_is_removed_whole(): from app.chat import validate_citations cleaned, citations = validate_citations( "Há esperança (2 Reis 5:14).", {"Salmos 23:1"} ) assert citations == [] assert "Reis" not in cleaned assert cleaned == "Há esperança."