Files
pastor-online/backend/tests/test_llm.py
T

227 lines
7.0 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