diff --git a/.env.example b/.env.example index 0ed3853..af17930 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ FRONTEND_PORT=8080 LLM_PROVIDER=ollama # --- Ollama (local, requer o profile "local": docker compose --profile local up) --- +# ATENÇÃO: se você tiver OLLAMA_BASE_URL exportada no shell, ela VENCE este +# arquivo na interpolação do compose. Confira o valor efetivo com: +# docker compose --profile local config | grep OLLAMA_BASE_URL +# E o que está em uso com: GET /api/health -> llm_endpoint +# O adaptador usa a API nativa do Ollama (/api/chat): NÃO acrescente "/v1". OLLAMA_BASE_URL=http://ollama:11434 OLLAMA_MODEL=qwen2.5:1.5b diff --git a/README.md b/README.md index eb71d40..6791d89 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,32 @@ aceita `POST`. Todas as variáveis ficam no `.env`. As principais: +> ### Cuidado: variável do shell vence o `.env` +> +> O docker-compose resolve `${VAR:-padrão}` olhando **primeiro o ambiente do +> shell**, e só depois o arquivo `.env`. Uma variável exportada na sua sessão — +> por outra ferramenta, um `export` no perfil, um `direnv` — sobrescreve o `.env` +> **sem nenhum aviso**. +> +> Foi exatamente assim que o chat quebrou com "Provedor de LLM indisponível": um +> `OLLAMA_BASE_URL=http://localhost:11434/v1` herdado do shell fez o backend +> tentar conectar em `localhost` **dentro do próprio container**, onde não há +> nada na porta 11434. O `.env` dizia `http://ollama:11434` e continuou dizendo, +> sem efeito. +> +> Confira o valor que será realmente aplicado: +> +> ```bash +> docker compose --profile local config | grep -A2 'ollama:' # ou: +> docker compose --profile local config | grep OLLAMA_BASE_URL +> curl -s localhost:8030/api/health | grep llm_endpoint # o que está em uso +> ``` +> +> Para o valor do `.env` valer, rode limpo: `env -u OLLAMA_BASE_URL docker compose --profile local up -d`. +> +> Nota: `/v1` é a superfície compatível com OpenAI. O adaptador Ollama usa a API +> nativa (`/api/chat`), então a URL **não** deve terminar em `/v1`. + | Variável | Padrão | Efeito | |---|---|---| | `LLM_PROVIDER` | `ollama` | `ollama`, `openai`, `groq`, `anthropic` ou `stub` | @@ -230,6 +256,30 @@ Nenhum dos dois lados da busca encontrou nada acima do piso. Confira `/api/statu pergunta citar uma referência explícita (`João 3:16`), a busca é determinística por metadados e não depende de limiar nenhum. +**"Provedor de LLM indisponível" no chat.** +Três causas possíveis, nesta ordem de probabilidade: + +1. **A URL não é a que você acha.** Veja `curl -s localhost:8030/api/health | grep llm_endpoint` + e compare com o `.env`. Variável exportada no shell sobrescreve o `.env` em + silêncio (ver aviso na seção Configuração). `localhost` dentro do container é o + próprio container — para falar com o Ollama do Compose o nome é `ollama`. +2. **O serviço não está no ar.** Com `LLM_PROVIDER=ollama`, o Ollama só sobe com o + profile: `docker compose --profile local up -d`. Confira com + `docker compose exec -T backend curl -s http://ollama:11434/api/tags`. +3. **O modelo não está no volume.** `ollama run` no host não alimenta o container; + o volume é `ollama:/root/.ollama`. Liste os modelos de dentro do container: + `docker compose exec ollama ollama list`. + +O log do backend traz a endpoint tentada: `docker compose logs backend | grep -i provedor`. + +**A primeira resposta demora muito.** +Espera mesmo. O modelo precisa ser carregado na RAM e o prompt tem ~1.800 tokens +(system + 6 trechos com janela de contexto), tudo em CPU. Medido com `llama3.2:1b`: +~70 s com o modelo quente, bem mais no primeiro atendimento após o arranque. Como a +validação de citação exige o texto completo antes de exibir, não há saída +parcial. Modelos menores respondem mais rápido; `TOP_K` menor também reduz o +prompt. + **As respostas citam versículos que parecem fora do tema.** Verifique em `/api/status` o campo `model`. Se aparecer `hash-determinístico (sem semântica)` em vez do nome do modelo configurado, o diff --git a/backend/app/llm.py b/backend/app/llm.py index 7f2df40..ee8a07c 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -46,6 +46,14 @@ class LLMProviderError(RuntimeError): self.__cause__ = cause +def _redact_credentials(url: str) -> str: + """Remove credenciais `user:pass@` de uma URL antes de exibi-la em log.""" + scheme, _, rest = url.partition("://") + if "@" in rest: + return f"{scheme}://***@{rest.rsplit('@', 1)[-1]}" if scheme else rest + return url + + class LLMProvider(Protocol): """Interface comum dos provedores de LLM.""" @@ -61,19 +69,32 @@ class _BaseProvider: timeout: float = 120.0 - def __init__(self) -> None: + def __init__(self, timeout: float | None = None, endpoint: str | None = None): + # O `httpx.AsyncClient` congela o timeout na construção: atribuir + # `self.timeout` depois não teria efeito nenhum. + if timeout is not None: + self.timeout = timeout + self.endpoint = _redact_credentials(endpoint) if endpoint else None self._client = httpx.AsyncClient(timeout=self.timeout) async def aclose(self) -> None: await self._client.aclose() def _guard(self, exc: Exception) -> LLMProviderError: - """Converte erro de rede em erro de provedor, sem vazar segredos.""" + """Converte erro de rede em erro de provedor, sem vazar segredos. + + A endpoint vai na mensagem porque foi assim que uma configuração errada + ficou invisível: um `OLLAMA_BASE_URL` herdado do shell apontando para + `localhost` dentro do container produzia apenas "All connection attempts + failed", sem dizer o que foi tentado. + """ message = str(exc) for secret in ("sk-", "gsk-", "Bearer "): if secret in message: message = "falha de comunicação com o provedor de LLM" break + if self.endpoint: + message = f"{message} (endpoint tentada: {self.endpoint})" return LLMProviderError(message, cause=exc) @@ -81,10 +102,9 @@ class OllamaProvider(_BaseProvider): """Provedor local Ollama (endpoint `/api/chat` em NDJSON).""" def __init__(self, base_url: str, model: str, timeout: float = 300.0): - super().__init__() self.base_url = base_url.rstrip("/") self.model = model - self.timeout = timeout + super().__init__(timeout=timeout, endpoint=self.base_url) async def stream( self, messages: Sequence[dict], system: str = SYSTEM_PROMPT @@ -129,11 +149,10 @@ class OpenAIProvider(_BaseProvider): api_key: str, timeout: float = 120.0, ): - super().__init__() self.base_url = base_url.rstrip("/") self.model = model self.api_key = api_key - self.timeout = timeout + super().__init__(timeout=timeout, endpoint=self.base_url) async def stream( self, messages: Sequence[dict], system: str = SYSTEM_PROMPT @@ -185,11 +204,10 @@ class AnthropicProvider(_BaseProvider): api_key: str, timeout: float = 120.0, ): - super().__init__() self.base_url = base_url.rstrip("/") self.model = model self.api_key = api_key - self.timeout = timeout + super().__init__(timeout=timeout, endpoint=self.base_url) async def stream( self, messages: Sequence[dict], system: str = SYSTEM_PROMPT diff --git a/backend/app/main.py b/backend/app/main.py index 74f58f2..e7629c1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -189,6 +189,8 @@ def create_app( return HealthResponse( status="ok", llm_provider=settings.llm_provider, + # A URL em uso de fato, não a que o .env parece dizer. + llm_endpoint=getattr(services.provider, "endpoint", None), indexing="indexing" if state == "indexing" else "ready", ) diff --git a/backend/app/models.py b/backend/app/models.py index 0439e00..cb7ded4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -46,6 +46,10 @@ class HealthResponse(BaseModel): status: Literal["ok"] = "ok" llm_provider: str indexing: Literal["ready", "indexing", "error"] + # A URL realmente em uso. Uma `OLLAMA_BASE_URL` herdada do shell pode + # sobrescrever o `.env` silenciosamente; sem este campo o operador não tem + # como perceber que o backend está apontando para outro lugar. + llm_endpoint: str | None = None version: str = "1.0.0" diff --git a/backend/tests/test_llm.py b/backend/tests/test_llm.py index cbaabe4..fe16b4a 100644 --- a/backend/tests/test_llm.py +++ b/backend/tests/test_llm.py @@ -240,3 +240,63 @@ def test_system_prompt_declares_the_off_topic_scope(): 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) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 2eb0728..3c95317 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -278,3 +278,44 @@ def test_embedder_fallback_is_surfaced_in_status(monkeypatch, tmp_path): 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")