Dois defeitos na validação de citações, ambos visíveis ao usuário:
1. _WORD_RE excluía dígitos, então o número do livro era descartado. Afeta os 18
livros numerados (1/2/3 Samuel, Reis, Crônicas, Coríntios, Tessalonicenses,
Timóteo, Pedro, João):
- extract_reference('1 João 4:8') devolvia ('João',4,8) — perguntar por
1 João 4:8 devolvia João 4:8 em silêncio pela busca determinística
- '2 Reis 5:14' e '1 Coríntios 13:4' devolviam None — caiam na busca por
similaridade em vez da exata
- validate_citations removia citações CORRETAS: '(1 João 4:8)' sustentada
pelas fontes era apagada e sobrava '(1 João )' na resposta
O regex agora casa o número junto ('1 João', e '1Sm' aglutinado via \s*).
2. A remoção de citação não sustentada usava o span só do 'Capítulo:Versículo',
deixando o nome do livro pendurado: '(Gênesis 1:1)' virava '(Gênesis )'.
O span agora cobre do início do nome do livro ao fim dos versículos, e
parênteses que ficam vazios são limpos.
466 lines
15 KiB
Python
466 lines
15 KiB
Python
"""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."
|