# Triagem de E-mail com LLM Local (n8n + Ollama) Classifica os e-mails do Gmail em 8 categorias semânticas com um LLM rodando localmente (Ollama, `llama3.1`), orquestrado por **n8n** self-hosted em Docker Compose. Nenhum conteúdo de e-mail vai para API de terceiro: o modelo roda no próprio host, sem custo por chamada. ![Arquitetura: n8n no container chama o Ollama no host; o watchdog, fora do container, lê o banco de execuções](docs/arquitetura.png) ## Como funciona 1. Um fluxo agendado do n8n (02:00) busca os e-mails que ainda não têm label. 2. O nó `Classificar` monta o prompt com as descrições das 8 categorias do `config.yaml` e chama o Ollama pedindo JSON: `label`, `needs_action`, `confidence` e `reason`. 3. Com confiança de 0,6 ou mais e label dentro da lista, o e-mail recebe o label da categoria (e `Ação necessária`, quando for o caso). Label fora da lista, confiança baixa ou falha de rede → `_Revisar`. A fila nunca trava. 4. Cada classificação vira uma linha JSON em `~/.n8n/triagem-log.jsonl`, para auditoria. 5. No host, um watchdog confere uma vez por dia se o fluxo continua processando. ## Início rápido Pré-requisitos: Docker, o Ollama com o modelo `llama3.1` escutando na bridge do Docker ([setup, passos 1 e 2](docs/setup.md#1-ollama--o-modelo-local-que-classifica)) e uma credencial OAuth2 do Gmail no n8n ([passo 3](docs/setup.md#3-credencial-gmail-oauth2-no-n8n)). ```bash git clone https://github.com/ClaudineiAlves/triagem-email-llm.git && cd triagem-email-llm cp config.example.yaml config.yaml # troque os IDs dos labels pelos seus (passo 4) mkdir -p ~/.n8n docker compose up -d # n8n em http://localhost:5678 docker exec n8n wget -qO- http://host.docker.internal:11434/api/tags # deve listar o llama3.1 ``` Depois importe o `workflow.json` no n8n, selecione a credencial do Gmail e ative o fluxo ([passo 5](docs/setup.md#5-importar-e-ativar-o-fluxo)). O setup foi escrito para Arch Linux. Em Debian ou Ubuntu, instale o Docker e o Ollama como na [Fase 3 do deploy na Oracle](docs/deploy-oracle.md#fase-3--docker--ollama-na-vm), que usa os instaladores genéricos. ## Documentação | Documento | Conteúdo | |---|---| | [Setup local](docs/setup.md) | Ollama, Docker e n8n, credencial do Gmail, labels, importação do fluxo e watchdog | | [Deploy na Oracle Cloud](docs/deploy-oracle.md) | A mesma stack 24/7 numa VM Always Free, sem reautorizar o Gmail | | [Operação](docs/operacao.md) | Ajustar a classificação, backfill, auditoria e troubleshooting | ## Decisões de projeto - **Configuração fora do fluxo.** Prompt, descrições das categorias, limiar e modelo ficam no `config.yaml`, montado read-only no container. Trocar categoria ou ajustar o prompt não exige mexer no workflow. - **Limiar com rota de revisão.** Abaixo de 0,6 o e-mail vai para `_Revisar` em vez de receber um rótulo chutado. O prompt pede que o modelo use a escala de confiança inteira, para que os casos ambíguos caiam na revisão, e não num palpite. - **Saída do modelo validada.** A resposta passa por `JSON.parse` com try/catch e o label tem de estar na lista de categorias; qualquer outra coisa vai para revisão. - **Watchdog fora do container.** O aviso precisa sobreviver justamente ao que costuma quebrar (container parado, OAuth do Gmail revogado). Por isso roda no host, disparado por um timer do systemd, e alerta por `notify-send`, não por e-mail. Nasceu depois que uma credencial expirou em silêncio e a triagem ficou **38 dias parada sem ninguém notar**. ## Diagnóstico: labels inventados, sem erro nenhum Com `ollama_num_ctx: 1024` e um prompt de ~1.410 tokens, o modelo perdia a lista de categorias e passava a inventar labels (`marketing`, `Oportunidade`, `spam`), acertando 1 de 16 casos — sem lançar erro nenhum. A correção foi dimensionar a janela de contexto pelo tamanho do prompt (hoje `3072`, com folga para um prompt de ~1.760 tokens) e medir de novo sempre que as descrições das categorias mudam. A regra de bolso está no `config.yaml`: `chars / 3.3 ≈ tokens`. ## Stack Python · n8n · Ollama (`llama3.1`) · Docker Compose · SQLite · systemd · YAML · JavaScript (Code nodes do n8n) --- ## Arquivos | Arquivo | O quê | |---|---| | `docker-compose.yml` | Sobe o n8n self-hosted com as envs necessárias | | `config.example.yaml` | Modelo do `config.yaml`: categorias, prompt, limiar de confiança e mapa de IDs dos labels | | `config.yaml` | Sua cópia local do modelo, com os IDs dos seus labels (fora do git) | | `workflow.json` | Fluxo n8n importável da triagem ao vivo (4 nós) | | `backfill.json` | Fluxo n8n importável para classificar emails **antigos** em lote (ver [Backfill](docs/operacao.md#backfill--classificar-emails-antigos)) | | `nodes/classificar.js` | Código do Code node de classificação (cópia editável) | | `nodes/log.js` | Código do Code node de log estruturado | | `watchdog.py` | Watchdog que alerta quando a triagem para (roda no host, fora do container) | | `systemd/` | Units `triagem-watchdog.service` e `.timer` (checagem às 03:30) | | `dns-watch.sh` | Renova o DNS do container n8n a cada troca de rede | > O fluxo tem 4 nós. O `IF (confiança)` da especificação foi consolidado **dentro** > do nó `Classificar` (comparar float em expressão do n8n é frágil; em código é > determinístico). O comportamento é idêntico: `confidence < confidence_threshold` > → `_Revisar`. Se preferir o IF visível, é trivial reintroduzir. ## Fluxo `Gmail Trigger` → `Classificar` (Ollama + validação + resolve labelIds) → `Aplicar label` → `Log` - **Gmail Trigger**: DESATIVADO desde 13/08/2026. O poll de 2 min nunca deixava passar os 5 min de `keep_alive` do Ollama, então o modelo (~5,6 GB) ficava residente na RAM 24h por dia. O backfill diário cobre exatamente o mesmo conjunto de emails, porque `-has:userlabels` contém `is:unread -has:userlabels`. - **Classificar**: lê `config.yaml`, chama o Ollama (`/api/chat` com `format: json`), faz `JSON.parse` com try/catch. Label fora da lista, baixa confiança ou falha de rede → `_Revisar`. Nunca trava a fila. - **Aplicar label**: `messages.modify` por ID de label (retry 3x, backoff 2s). - **Log**: anexa linha JSON em `~/.n8n/triagem-log.jsonl` (auditoria). ## Licença [MIT](LICENSE).