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.
322 lines
9.7 KiB
Python
322 lines
9.7 KiB
Python
"""Testes da API FastAPI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from app.config import Settings # noqa: E402
|
|
from app.embedding import HashEmbedder # noqa: E402
|
|
from app.main import create_app # noqa: E402
|
|
from app.vector_store import InMemoryVectorStore # noqa: E402
|
|
|
|
|
|
class StubProvider:
|
|
async def stream(self, messages, system):
|
|
yield "Resposta ancorada em Gênesis 1:1."
|
|
|
|
async def aclose(self):
|
|
return None
|
|
|
|
|
|
class FakeIndexer:
|
|
"""Indexador controlado para os testes de API.
|
|
|
|
`release` permite segurar a reindexação em background, de forma que o teste
|
|
do 409 seja determinístico.
|
|
"""
|
|
|
|
def __init__(self, block: bool = False):
|
|
self.calls: list[bool] = []
|
|
self.release = threading.Event()
|
|
self.release.set()
|
|
if block:
|
|
self.release.clear()
|
|
|
|
def reindex(self, force: bool = False):
|
|
self.calls.append(force)
|
|
self.release.wait(timeout=10)
|
|
from app.indexer import IndexStatus
|
|
|
|
return IndexStatus(state="ready", sources_total=1, sources_indexed=1, chunks=42)
|
|
|
|
def status(self):
|
|
from app.indexer import IndexStatus
|
|
|
|
return IndexStatus(state="ready", sources_total=1, sources_indexed=1, chunks=42)
|
|
|
|
|
|
@pytest.fixture
|
|
def settings(tmp_path):
|
|
return Settings(
|
|
corpus_dir=tmp_path / "corpus",
|
|
vector_db_dir=tmp_path / "vector_db",
|
|
embedding_model="hash-test",
|
|
llm_provider="stub",
|
|
cors_origins="http://localhost:8080",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(settings):
|
|
embedder = HashEmbedder(dimensions=64)
|
|
store = InMemoryVectorStore(embedder=embedder)
|
|
app = create_app(
|
|
settings=settings,
|
|
store=store,
|
|
embedder=embedder,
|
|
provider=StubProvider(),
|
|
indexer=FakeIndexer(),
|
|
)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_health_returns_ok(client):
|
|
response = client.get("/api/health")
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["status"] == "ok"
|
|
assert body["llm_provider"] == "stub"
|
|
|
|
|
|
def test_status_reports_indexation(client):
|
|
response = client.get("/api/status")
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["state"] == "ready"
|
|
assert body["chunks"] == 42
|
|
|
|
|
|
def test_reindex_returns_202(client):
|
|
response = client.post("/api/reindex")
|
|
|
|
assert response.status_code == 202
|
|
assert response.json()["state"] == "indexing"
|
|
|
|
|
|
def test_reindex_requires_token_when_configured(settings):
|
|
embedder = HashEmbedder(dimensions=64)
|
|
store = InMemoryVectorStore(embedder=embedder)
|
|
settings.reindex_token = "segredo"
|
|
app = create_app(settings=settings, store=store, embedder=embedder, provider=StubProvider())
|
|
client = TestClient(app)
|
|
|
|
assert client.post("/api/reindex").status_code == 401
|
|
|
|
authorized = client.post("/api/reindex", headers={"X-Reindex-Token": "segredo"})
|
|
assert authorized.status_code == 202
|
|
|
|
|
|
def test_concurrent_reindex_is_rejected_with_409(settings):
|
|
settings.auto_index = False
|
|
embedder = HashEmbedder(dimensions=64)
|
|
store = InMemoryVectorStore(embedder=embedder)
|
|
indexer = FakeIndexer(block=True)
|
|
app = create_app(
|
|
settings=settings,
|
|
store=store,
|
|
embedder=embedder,
|
|
provider=StubProvider(),
|
|
indexer=indexer,
|
|
)
|
|
|
|
# O context manager mantém um único event loop entre as requisições; sem ele,
|
|
# o TestClient fecha o loop a cada chamada e a tarefa de background seria
|
|
# cancelada, liberando o lock.
|
|
with TestClient(app) as client:
|
|
try:
|
|
first = client.post("/api/reindex")
|
|
assert first.status_code == 202
|
|
|
|
second = client.post("/api/reindex")
|
|
assert second.status_code == 409
|
|
finally:
|
|
indexer.release.set()
|
|
|
|
|
|
def test_chat_rejects_empty_message(client):
|
|
response = client.post("/api/chat", json={"message": ""})
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_chat_rejects_message_above_the_limit(client):
|
|
response = client.post("/api/chat", json={"message": "a" * 5000})
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_chat_streams_sources_token_and_done(client):
|
|
from app.corpus import VerseChunk
|
|
|
|
embedder = HashEmbedder(dimensions=64)
|
|
store = client.app.state.services.store
|
|
chunk = VerseChunk(
|
|
book="Gênesis",
|
|
chapter=1,
|
|
verse=1,
|
|
file="biblia.usfx.xml",
|
|
text="No princípio criou Deus os céus e a terra.",
|
|
)
|
|
store.upsert([chunk], embedder.encode([chunk.text]))
|
|
|
|
response = client.post(
|
|
"/api/chat",
|
|
json={"message": "Quem criou o mundo?", "history": []},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("text/event-stream")
|
|
|
|
body = response.text
|
|
assert "event: sources" in body
|
|
assert "event: token" in body
|
|
assert "event: done" in body
|
|
|
|
sources_block = body.split("event: sources")[1].split("event:")[0]
|
|
payload = json.loads(sources_block.split("data:")[1].strip())
|
|
assert payload["sources"][0]["reference"] == "Gênesis 1:1"
|
|
|
|
|
|
def test_chat_without_evidence_returns_the_fixed_message(client):
|
|
response = client.post("/api/chat", json={"message": "fale sobre ações", "history": []})
|
|
|
|
assert response.status_code == 200
|
|
assert "event: token" in response.text
|
|
assert "não aborda diretamente" in response.text
|
|
|
|
|
|
def test_cors_is_restricted_to_configured_origin(client):
|
|
response = client.get("/api/health", headers={"Origin": "http://localhost:8080"})
|
|
|
|
assert response.headers.get("access-control-allow-origin") == "http://localhost:8080"
|
|
|
|
|
|
def test_cors_rejects_unknown_origin(client):
|
|
response = client.get("/api/health", headers={"Origin": "http://malvado.example"})
|
|
|
|
assert "access-control-allow-origin" not in response.headers
|
|
|
|
|
|
def test_sources_endpoint_returns_a_list(client):
|
|
response = client.get("/api/sources")
|
|
|
|
assert response.status_code == 200
|
|
assert "sources" in response.json()
|
|
|
|
|
|
def test_create_app_builds_the_configured_embedder(monkeypatch, tmp_path):
|
|
"""Produção: o modelo configurado precisa ser construído de fato.
|
|
|
|
O `VectorStore` default para `HashEmbedder` quando nasce sem embedder. Se a
|
|
aplicação criar o store primeiro e depois herdar o embedder dele, o modelo
|
|
configurado nunca é carregado e a busca roda sem semântica nenhuma — com
|
|
`/api/status` ainda anunciando o nome do modelo.
|
|
"""
|
|
import app.main as main_module
|
|
|
|
built: list[str] = []
|
|
|
|
class FakeSentenceTransformer:
|
|
def __init__(self, model_name, batch_size=32, device="cpu", module=None):
|
|
built.append(model_name)
|
|
self.name = model_name
|
|
self.dimensions = 8
|
|
|
|
def encode(self, texts):
|
|
return [[0.0] * 8 for _ in texts]
|
|
|
|
monkeypatch.setattr(main_module, "SentenceTransformerEmbedder", FakeSentenceTransformer)
|
|
|
|
settings = Settings(
|
|
corpus_dir=tmp_path / "corpus",
|
|
vector_db_dir=tmp_path / "vector_db",
|
|
embedding_model="modelo-configurado",
|
|
llm_provider="stub",
|
|
auto_index=False,
|
|
)
|
|
|
|
app = main_module.create_app(settings=settings)
|
|
services = app.state.services
|
|
|
|
assert built == ["modelo-configurado"]
|
|
assert services.embedder.name == "modelo-configurado"
|
|
assert services.store.embedder.name == "modelo-configurado"
|
|
assert services.embedder_warning is None
|
|
|
|
|
|
def test_embedder_fallback_is_surfaced_in_status(monkeypatch, tmp_path):
|
|
"""Se o modelo não carrega, o status precisa dizer que a busca está sem semântica."""
|
|
import app.main as main_module
|
|
|
|
def explode(*args, **kwargs):
|
|
raise RuntimeError("PyTorch >= 2.6 is required but found 2.5.1")
|
|
|
|
monkeypatch.setattr(main_module, "SentenceTransformerEmbedder", explode)
|
|
|
|
settings = Settings(
|
|
corpus_dir=tmp_path / "corpus",
|
|
vector_db_dir=tmp_path / "vector_db",
|
|
embedding_model="modelo-configurado",
|
|
llm_provider="stub",
|
|
auto_index=False,
|
|
)
|
|
|
|
app = main_module.create_app(
|
|
settings=settings, indexer=FakeIndexer(), provider=StubProvider()
|
|
)
|
|
body = TestClient(app).get("/api/status").json()
|
|
|
|
assert any("semântica" in error for error in body["errors"])
|
|
assert app.state.services.embedder.name == HashEmbedder.name
|
|
|
|
|
|
def test_health_reports_the_llm_endpoint_in_use(client):
|
|
"""/api/health precisa mostrar a URL efetiva, não a que o .env parece dizer.
|
|
|
|
Variável de shell vence o `.env` na interpolação do compose: um
|
|
`OLLAMA_BASE_URL` herdado do ambiente apontando para `localhost` dentro do
|
|
container derrubava o chat sem nenhum indício do endereço tentado.
|
|
"""
|
|
body = client.get("/api/health").json()
|
|
|
|
assert body["llm_provider"] == "stub"
|
|
assert body["llm_endpoint"] is None, "o stub não tem endpoint"
|
|
|
|
|
|
def test_health_exposes_ollama_endpoint_without_credentials(tmp_path):
|
|
"""A endpoint vai para o health, mas credenciais na URL não podem vazar."""
|
|
from app.llm import OllamaProvider
|
|
|
|
settings = Settings(
|
|
corpus_dir=tmp_path / "corpus",
|
|
vector_db_dir=tmp_path / "vector_db",
|
|
embedding_model="hash-test",
|
|
llm_provider="ollama",
|
|
auto_index=False,
|
|
)
|
|
provider = OllamaProvider(
|
|
base_url="http://user:segredo@ollama:11434", model="m"
|
|
)
|
|
app = create_app(
|
|
settings=settings,
|
|
store=InMemoryVectorStore(),
|
|
embedder=HashEmbedder(dimensions=64),
|
|
provider=provider,
|
|
indexer=FakeIndexer(),
|
|
)
|
|
|
|
body = TestClient(app).get("/api/health").json()
|
|
|
|
assert "segredo" not in body["llm_endpoint"]
|
|
assert body["llm_endpoint"].endswith("ollama:11434")
|