Files
pastor-online/backend/tests/test_llm.py
T
omp 68861b502c fix: timeout do provedor nunca chegava ao httpx; endpoint agora visível
Causa do 'Provedor de LLM indisponível' no ambiente com Ollama local foi
externa: OLLAMA_BASE_URL exportada no shell (http://localhost:11434/v1) vence o
.env na interpolação do docker-compose, e localhost dentro do container é o
próprio container. Nada no app indicava a URL em uso.

Dois defeitos de código que tornavam isso indetectável — e um deles produziria a
mesma mensagem mesmo com a URL correta:

- _BaseProvider criava o httpx.AsyncClient com o atributo de classe (120 s) e as
  subclasses atribuíam self.timeout depois. O httpx congela o timeout na
  construção, então os 300 s pedidos pelo Ollama nunca valeram: um primeiro
  atendimento com modelo frio era cortado em 120 s com exatamente a mensagem
  'provedor indisponível'. O timeout agora é passado ao super().
- LLMProviderError de conexão não citava o endereço tentado; o log dizia só 'All
  connection attempts failed'. Passa a incluir a endpoint, com credenciais
  user:pass@ redigidas. /api/health expõe llm_endpoint para comparação direta
  com o .env.

README e .env.example documentam a precedência shell > .env, o fato de /v1 ser a
superfície OpenAI-compatível (o adaptador usa /api/chat nativo) e a latência
medida do primeiro atendimento.
2026-11-02 17:57:42 -03:00

303 lines
9.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
# --------------------------------------------------------------------------- #
# Timeout efetivo e erros acionáveis
# --------------------------------------------------------------------------- #
def close(provider):
import asyncio
asyncio.run(provider.aclose())
@pytest.mark.parametrize(
"provider_factory,esperado",
[
(lambda: OllamaProvider(base_url="http://x", model="m"), 300),
(lambda: OllamaProvider(base_url="http://x", model="m", timeout=45), 45),
(lambda: OpenAIProvider(base_url="http://x", model="m", api_key="k"), 120),
(lambda: AnthropicProvider(base_url="http://x", model="m", api_key="k"), 120),
],
)
def test_configured_timeout_reaches_the_http_client(provider_factory, esperado):
"""Atribuir `self.timeout` depois de criar o cliente não muda o cliente.
O `httpx.AsyncClient` congela o timeout na construção. Atribuir o atributo
em seguida deixava o Ollama preso aos 120 s da classe mesmo pedindo 300 —
pouco para um modelo local em CPU, e o sintoma é justamente "provedor
indisponível".
"""
provider = provider_factory()
assert provider._client.timeout.read == esperado
close(provider)
def test_connection_error_names_the_endpoint():
"""Sem a URL, o operador vê só 'falha de conexão' e não sabe o que testar.
Foi assim que um `OLLAMA_BASE_URL` herdado do shell, apontando para
`localhost` dentro do container, ficou invisível: o log dizia apenas "All
connection attempts failed".
"""
provider = OllamaProvider(base_url="http://127.0.0.1:1", model="m")
with pytest.raises(LLMProviderError) as excinfo:
collect(provider)
assert "127.0.0.1:1" in str(excinfo.value)
def test_connection_error_does_not_leak_the_api_key():
provider = OpenAIProvider(
base_url="http://127.0.0.1:1", model="m", api_key="sk-segredo-real"
)
with pytest.raises(LLMProviderError) as excinfo:
collect(provider)
assert "sk-segredo-real" not in str(excinfo.value)