Files
pastor-online/backend/tests/test_llm.py
T
omp 816e6be159 feat: cota lexical na fusão, recusa off-topic no prompt, docs medidas
Fusão:
- fuse_rankings() extrai a fusão RRF e aplica MAX_KEYWORD_ONLY (padrão 2):
  quantas posições podem ser ocupadas por trechos que só o BM25 achou. O que os
  dois lados confirmam nunca conta contra a cota.
- O controle é por cota de posições, não por piso de nota. Medido: a razão da
  nota BM25 não separa acerto de ruído — para 'como posso perdoar quem me
  feriu?', Deuteronômio 28:27 pontua 1,000 do topo e o correto Lucas 7:47 fica
  em 0,716 (rank 12). Um piso cortaria o certo e manteria o errado.
- Ponderar o RRF também não resolve: com peso 0,5 no BM25 os achados só-lexicais
  caem abaixo do 6º vetorial (0,5/61 < 1/66) e somem, levando junto Jó 3:13 e
  Joel 3:14, os melhores resultados medidos.

Portão off-topic:
- MIN_SIMILARITY não consegue implementar 'sem evidência'. Medido no índice
  completo: 0/6 perguntas off-topic bloqueadas em qualquer limiar de 0,30 a
  0,50 ('bolo de chocolate' bate 0,544 em Lv 7:14; 'cotação do dólar' 0,494).
  A recusa passa a ser instruída no SYSTEM_PROMPT e o limiar fica documentado
  como filtro de ruído.
2026-11-02 13:04:14 -03:00

243 lines
7.7 KiB
Python

"""Testes dos adaptadores de LLM.
Cada adaptador converte o formato de streaming do próprio provedor em deltas de
texto. Os testes usam transportes falsos: verificam a conversão, não a rede.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import httpx
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.config import Settings # noqa: E402
from app.llm import ( # noqa: E402
SYSTEM_PROMPT,
AnthropicProvider,
LLMProviderError,
OllamaProvider,
OpenAIProvider,
StubProvider,
build_provider,
)
class FakeStreamResponse:
"""Dublê do contexto assíncrono devolvido por `httpx.AsyncClient.stream`."""
status_code = 200
def __init__(self, chunks):
self._chunks = chunks
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def aiter_lines(self):
for chunk in self._chunks:
text = chunk.decode() if isinstance(chunk, bytes) else chunk
for line in text.splitlines():
yield line
async def aread(self):
return b"erro"
class FakeStreamClient:
"""Dublê do `httpx.AsyncClient` que devolve um stream fixo."""
def __init__(self, chunks):
self._chunks = chunks
def stream(self, *args, **kwargs):
return FakeStreamResponse(self._chunks)
def collect(provider, messages=None, system=SYSTEM_PROMPT):
import asyncio
async def run():
chunks = []
async for delta in provider.stream(messages or [{"role": "user", "content": "olá"}], system):
chunks.append(delta)
return "".join(chunks)
return asyncio.run(run())
# --------------------------------------------------------------------------- #
# Seleção de provedor
# --------------------------------------------------------------------------- #
def test_build_provider_selects_ollama():
provider = build_provider(Settings(llm_provider="ollama"))
assert isinstance(provider, OllamaProvider)
def test_build_provider_selects_openai():
provider = build_provider(Settings(llm_provider="openai", openai_api_key="sk-x"))
assert isinstance(provider, OpenAIProvider)
def test_build_provider_selects_groq_as_openai_compatible():
provider = build_provider(Settings(llm_provider="groq", groq_api_key="gsk-x"))
assert isinstance(provider, OpenAIProvider)
assert provider.base_url.startswith("https://api.groq.com")
def test_build_provider_selects_anthropic():
provider = build_provider(Settings(llm_provider="anthropic", anthropic_api_key="sk-x"))
assert isinstance(provider, AnthropicProvider)
def test_build_provider_selects_stub():
provider = build_provider(Settings(llm_provider="stub"))
assert isinstance(provider, StubProvider)
def test_build_provider_rejects_unknown_provider():
with pytest.raises(ValueError, match="não suportado"):
build_provider(Settings(llm_provider="desconhecido"))
def test_openai_provider_requires_api_key():
with pytest.raises(ValueError, match="OPENAI_API_KEY"):
build_provider(Settings(llm_provider="openai", openai_api_key=""))
# --------------------------------------------------------------------------- #
# System prompt
# --------------------------------------------------------------------------- #
def test_system_prompt_contains_the_spec_directives():
assert "conselheiro espiritual e Pastor Online" in SYSTEM_PROMPT
assert "única fonte de autoridade" in SYSTEM_PROMPT
assert "livro, capítulo e versículo" in SYSTEM_PROMPT
assert "tom acolhedor" in SYSTEM_PROMPT
assert "Não crie doutrina própria" in SYSTEM_PROMPT
def test_system_prompt_contains_the_crisis_scope():
assert "emergência" in SYSTEM_PROMPT.lower()
assert "diagnóstico" in SYSTEM_PROMPT.lower() or "diagnost" in SYSTEM_PROMPT.lower()
# --------------------------------------------------------------------------- #
# Conversão de streaming
# --------------------------------------------------------------------------- #
def test_ollama_provider_converts_ndjson_stream(monkeypatch):
provider = OllamaProvider(base_url="http://ollama:11434", model="qwen2.5:1.5b")
lines = [
{"message": {"content": "Olá "}, "done": False},
{"message": {"content": "mundo"}, "done": False},
{"message": {"content": ""}, "done": True},
]
async def fake_stream(*args, **kwargs):
for line in lines:
yield f"{json.dumps(line)}\n".encode()
monkeypatch.setattr(
provider, "_client", FakeStreamClient([f"{json.dumps(line)}\n" for line in lines])
)
assert collect(provider) == "Olá mundo"
def test_openai_provider_converts_sse_stream(monkeypatch):
provider = OpenAIProvider(
base_url="https://api.openai.com/v1", model="gpt-4o-mini", api_key="sk-test"
)
chunks = [
'data: {"choices":[{"delta":{"content":"Olá "}}]}\n\n'.encode(),
'data: {"choices":[{"delta":{"content":"mundo"}}]}\n\n'.encode(),
b"data: [DONE]\n\n",
]
async def fake_stream(*args, **kwargs):
for chunk in chunks:
yield chunk
monkeypatch.setattr(provider, "_client", FakeStreamClient(chunks))
assert collect(provider) == "Olá mundo"
def test_anthropic_provider_converts_event_stream(monkeypatch):
provider = AnthropicProvider(
base_url="https://api.anthropic.com", model="claude-sonnet-4-5", api_key="sk-test"
)
events = [
'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Olá "}}\n\n'.encode(),
'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"mundo"}}\n\n'.encode(),
]
async def fake_stream(*args, **kwargs):
for event in events:
yield event
monkeypatch.setattr(provider, "_client", FakeStreamClient(events))
assert collect(provider) == "Olá mundo"
def test_stub_provider_returns_deterministic_text():
provider = StubProvider()
text = collect(provider, [{"role": "user", "content": "Quem criou o mundo?"}])
assert "Gênesis 1:1" in text
# --------------------------------------------------------------------------- #
# Erros
# --------------------------------------------------------------------------- #
def test_provider_error_does_not_leak_the_api_key():
provider = OpenAIProvider(
base_url="https://api.openai.com/v1", model="gpt-4o-mini", api_key="sk-super-secreta"
)
def raise_error(*args, **kwargs):
raise httpx.ConnectError("connection refused")
monkeypatched = provider._client
try:
provider._client = type(
"FailingClient",
(),
{"stream": staticmethod(raise_error)},
)()
with pytest.raises(LLMProviderError) as exc:
collect(provider)
assert "sk-super-secreta" not in str(exc.value)
assert "sk-super-secreta" not in str(exc.value.__cause__ or "")
finally:
provider._client = monkeypatched
def test_system_prompt_declares_the_off_topic_scope():
"""O portão por similaridade não funciona: precisa estar no prompt.
Medido no corpus real, nenhum valor de MIN_SIMILARITY entre 0,30 e 0,50
bloqueou uma única pergunta fora do tema — "como fazer um bolo de chocolate"
bate 0,544 em Levítico 7:14 e "cotação do dólar" bate 0,494. Num corpus de
31 mil versículos sempre existe algum acima de qualquer limiar utilizável,
então a recusa tem de ser instruída ao modelo, não filtrada por nota.
"""
lowered = SYSTEM_PROMPT.lower()
assert "receita" in lowered
assert "apenas do texto bíblico" in lowered
assert "não use os trechos recuperados" in lowered