vanna-clubpetro/docs/deploy.md
leonardosalazar-cp 1d152c0dce Initial commit: Vanna 2.0 deployment for ClubPetro
Wrapper application around upstream Vanna with:
- Tenant-aware ChromaDB memory (per program/store)
- ClickHouse RLS runner with introspection guards
- PT-BR system prompt and chat translations
- Custom Plotly chart generator (ranked bar, datetime coercion)
- Embed bootstrap (theme pierce + i18n + markdown) shared by demo and React app
- Event sink for chat turn observability
2026-04-29 17:22:05 -03:00

364 lines
17 KiB
Markdown

# Deploy — Vanna 2.0 (ClubPetro IA)
Guia de deploy do agente conversacional sobre `gold.sales` (ClickHouse Cloud) com chat embarcável via Web Component.
## 1. Arquitetura de runtime
```
Cliente (browser do dashboard)
│ HTTPS (SSE/WebSocket)
┌──────────────────────────────────────┐
│ Servidor app (FastAPI + uvicorn) │
│ • server.py (VannaFastAPIServer) │
│ • Agent (build_agent / agent.py) │
│ • RLSClickHouseRunner (rls_runner) │
│ • TenantAwareChromaMemory │
│ • EventSink (events.vanna_ai) │
└──────────────────────────────────────┘
│ HTTPS 8443 │ HTTPS
▼ ▼
ClickHouse Cloud OpenAI API
(db `gold`, (LLM + embeddings
user `wren_ia`) opcional)
│ INSERT
└── events.vanna_ai (telemetria de turnos)
Estado local (no servidor app):
• ./chroma_db/ — vetor store persistente
• ./data_storage/ — CSVs intermediários por usuário (run_sql → visualize_data)
• ~/.cache/chroma/ — modelo ONNX de embedding (~80 MB, baixado no 1º boot)
```
Componente único, **stateful no disco** (ChromaDB + cache do modelo). Não escala horizontalmente sem volume compartilhado ou re-treino por instância.
## 2. Pré-requisitos do alvo
| Item | Versão / Observação |
|------|---------------------|
| Python | 3.10+ recomendado (3.9 funciona mas gera warnings de LibreSSL/urllib3) |
| Node.js | 18+ (apenas para buildar o bundle do webcomponent) |
| Disco | ≥ 2 GB livres para `chroma_db/`, `data_storage/`, cache do modelo ONNX e venv |
| RAM | ≥ 2 GB (ChromaDB + ONNX runtime + uvicorn worker) |
| Egress HTTPS | OpenAI (`api.openai.com`) e ClickHouse Cloud (porta 8443) |
## 3. Build de artefatos
Dois passos, executados **antes** do deploy ou no pipeline:
```bash
# 1. Bundle do webcomponent (gera vanna/frontends/webcomponent/dist/vanna-components.js, ~7.5 MB).
# Esse arquivo é gitignored — precisa ser produzido no host de build.
cd vanna/frontends/webcomponent
npm install
npm run build
cd -
# 2. Dependências Python (instala vanna em modo editável).
python -m venv venv
source venv/bin/activate
pip install -e ./vanna
pip install -r requirements.txt # se houver; senão instalar extras manualmente
```
Ao subir versão do upstream:
```bash
cd vanna && git pull && cd ..
pip install -e ./vanna
cd vanna/frontends/webcomponent && npm run build && cd -
```
## 4. Variáveis de ambiente (`.env` ou secret manager)
Carregadas via `load_dotenv()` em `agent.py` e `train.py`. Nunca commitar.
| Variável | Obrigatória | Descrição |
|----------|-------------|-----------|
| `OPENAI_API_KEY` | ✅ | Chave da API. Use uma key dedicada por ambiente para auditoria. |
| `OPENAI_MODEL` | recomendada | Default upstream pode mudar; fixe explicitamente (ex.: `gpt-5.4-mini`). |
| `OPENAI_TEMPERATURE` | opcional | Default `1.0` (compat com `gpt-5*`). Para modelos que aceitam ajuste, use `0.2` para SQL determinístico. |
| `CLICKHOUSE_HOST` | ✅ | Hostname Cloud (ex.: `xxx.us-central1.gcp.clickhouse.cloud`). |
| `CLICKHOUSE_PORT` | ✅ | `8443`. |
| `CLICKHOUSE_DATABASE` | ✅ | `gold`. |
| `CLICKHOUSE_USER` | ✅ | `wren_ia` (com GRANTs columnares — ver §6). |
| `CLICKHOUSE_PASSWORD` | ✅ | Secret. |
| `CLICKHOUSE_SECURE` | ✅ | `true` (HTTPS). |
| `RLS_PROGRAM_ID` / `RLS_STORE_ID` / `RLS_USER_ID` | apenas CLI | Defaults para `python ask.py`. **Não são lidos pelo servidor web** — o resolver web pega de query string. |
Em produção use secret manager (AWS Secrets Manager, GCP Secret Manager, Vault, sealed-secrets) montando como env vars no container — não copie `.env` para a imagem.
## 5. Sequência de bootstrap
```bash
# Primeira vez OU sempre que mudar GRANT/RLS_TABLES/RLS_INTERNAL_COLS:
rm -rf chroma_db/
python train.py # popula chroma_db/ com schema das tabelas em RLS_TABLES
# Subir o servidor:
uvicorn server:app --host 0.0.0.0 --port 8765 --workers 1
```
> ⚠️ **Use `--workers 1`** ou um único processo por volume `chroma_db/`. Múltiplos workers escrevendo no mesmo SQLite do Chroma corrompem o index. Para escalar, ver §11.
> ⚠️ **Primeiro turno depois do boot** demora ~30s extras: ChromaDB baixa o modelo ONNX de embedding (~80 MB) para `~/.cache/chroma/`. Pré-aquecer no build se quiser remover essa latência.
## 6. ClickHouse — usuário e GRANTs
A defesa de RLS é **em duas camadas**: (a) `additional_table_filters` injetado pelo `RLSClickHouseRunner` por request, (b) GRANT no usuário.
Setup do usuário (executar como admin no ClickHouse Cloud):
```sql
-- 1. Criar usuário (ou rotacionar senha)
CREATE USER wren_ia IDENTIFIED BY '<senha-forte>';
-- 2. SELECT apenas nas colunas seguras de gold.sales
GRANT SELECT(
program_id, store_id, -- necessárias pro RLS, escondidas do LLM via RLS_INTERNAL_COLS
sale_id, cartao_do_cliente, nome_da_rede, nome_da_loja, tipo_loja,
categoria_loja, nome_do_cliente, categoria_do_cliente, nome_do_atendente,
produto, categoria_do_produto, quantidade_total_produto, valor_total_produto,
desconto, pontuacao_produto, voucher_aplicado_venda, data_da_compra,
fidelizada, e_combustivel
) ON gold.sales TO wren_ia;
-- 3. Negar a view exportável (defesa em profundidade — o runner também filtra)
-- (não conceder SELECT em gold.vw_relatorios_exportaveis_analitico_vendas)
-- 4. INSERT na tabela de telemetria de turnos
GRANT INSERT ON events.vanna_ai TO wren_ia;
```
> ⚠️ `REVOKE SELECT(col) ON system.tables` é **silenciosamente ignorado** em ClickHouse Cloud (acesso herdado de role default não-revogável). A blindagem de introspecção é **app-side**, via regex no `RLSClickHouseRunner` (`_FORBIDDEN_SCHEMA_RE`, `_INTROSPECTION_STMT_RE`). Não desabilite essas guards.
> ⚠️ `SELECT *` em `gold.sales` falha com `ACCESS_DENIED` porque exige permissão em todas as colunas. O LLM é instruído a listar colunas; manter assim.
Schema da tabela de telemetria:
```sql
CREATE TABLE events.vanna_ai (
ts DateTime64(3, 'UTC') DEFAULT now64(3),
turn_id String,
program_id String,
store_id String,
user_id String,
question String,
response String,
status Enum8('success'=1, 'error'=2, 'partial'=3),
error_message String,
tools_called Array(String),
sql_executed String,
chart_type String,
chart_title String,
duration_ms UInt32
) ENGINE = MergeTree
ORDER BY (program_id, store_id, ts);
```
> Confira o schema atual em `events_sink.py` antes de criar — manter alinhado.
## 7. Multi-tenant (`program_id` / `store_id`)
Identidades vêm na query string dos endpoints `chat_sse` / `chat_websocket` / `chat_poll`. O `RequestContextUserResolver`:
1.`program_id`, `store_id`, `user_id` de `request_context.query_params`.
2. Valida com regex `^[A-Za-z0-9_-]+$`.
3. Levanta `PermissionError` se ausente/inválido — request é rejeitada antes de qualquer SQL.
**Responsabilidade do app cliente** (dashboard que embarca o chat): preencher os 3 IDs com o tenant logado **no servidor** (não no cliente do browser, que poderia ser modificado). O snippet de embed (§9) recebe os IDs do backend do cliente e injeta no atributo `sse-endpoint`.
## 8. Reverse proxy / TLS
`<vanna-chat>` é embarcado em sites de terceiros (dashboard ClubPetro). Requisitos:
- **HTTPS obrigatório** — browsers bloqueiam mixed-content (página HTTPS chamando SSE HTTP).
- **CORS configurado** no `VannaFastAPIServer` (`server.py:config.cors`) listando o domínio do dashboard. Sem isso, o EventSource é bloqueado.
- **Cookies / autenticação**: o servidor extrai `RequestContext` de cookies, headers, query params. Se o dashboard já tem sessão própria, prefira passar IDs assinados (JWT) em vez de IDs crus na query string.
- **Buffering desabilitado** para `/api/vanna/v2/chat_sse` no proxy (nginx: `proxy_buffering off`, Cloudflare: incompatível com SSE em planos free — usar WebSocket ou TCP proxy).
- **Timeouts**: turnos longos (LLM + SQL + viz) podem levar 60-120s. Configure `proxy_read_timeout` ≥ 180s.
Exemplo nginx para SSE:
```nginx
location /api/vanna/v2/chat_sse {
proxy_pass http://localhost:8765;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 180s;
chunked_transfer_encoding on;
}
```
## 9. Embed no app cliente
Quatro peças, **na ordem**, no HTML do dashboard (snippet completo em `static/embed-demo.html`):
```html
<!-- 1. Fonte -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- 2. Tema (sobrescreve design tokens via adoptedStyleSheets) -->
<link rel="stylesheet" href="https://SEU-VANNA/vanna-theme.css">
<!-- 3. Patch de Shadow DOM + carregamento do tema (copiar bloco de embed-demo.html) -->
<script>/* monkey-patch attachShadow para adoptar /vanna-theme.css em cada shadow root */</script>
<!-- 4. Bundle e instância -->
<script type="module" src="https://SEU-VANNA/static/vanna-components.js"></script>
<vanna-chat
id="chat"
api-base="https://SEU-VANNA"
sse-endpoint="/api/vanna/v2/chat_sse?program_id=PROG&store_id=LOJA&user_id=USR"
ws-endpoint="/api/vanna/v2/chat_websocket?program_id=PROG&store_id=LOJA&user_id=USR"
poll-endpoint="/api/vanna/v2/chat_poll?program_id=PROG&store_id=LOJA&user_id=USR"
startingstate="minimized"
theme="light"
title="ClubPetro IA">
</vanna-chat>
<script>
customElements.whenDefined("vanna-chat").then(() => {
const chat = document.getElementById("chat");
chat.showProgress = false; // sidebar
if (chat.windowState !== "minimized") chat.windowState = "minimized";
});
</script>
```
> ⚠️ Atributo é **`startingstate`** (lowercase, sem hífen) — Lit converte `startingState` para lowercase, não kebab-case.
> ⚠️ `showProgress` é boolean Lit; **não desliga via HTML attribute** (qualquer presença = true). Sempre via JS após `whenDefined`.
`PROG`/`LOJA`/`USR` devem ser substituídos pelo backend do dashboard a cada request, baseando-se na sessão autenticada.
## 10. Containerização (Docker)
`Dockerfile` recomendado:
```dockerfile
# Build stage — bundle do webcomponent
FROM node:18-alpine AS frontend-build
WORKDIR /build
COPY vanna/frontends/webcomponent ./
RUN npm install && npm run build
# Runtime
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential git && rm -rf /var/lib/apt/lists/*
COPY vanna/ ./vanna/
COPY --from=frontend-build /build/dist ./vanna/frontends/webcomponent/dist
RUN pip install --no-cache-dir -e ./vanna
COPY *.py ./
COPY static/ ./static/
# Pré-baixar modelo ONNX de embedding pra evitar latência no 1º request
RUN python -c "from chromadb.utils.embedding_functions import DefaultEmbeddingFunction; DefaultEmbeddingFunction()"
EXPOSE 8765
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8765", "--workers", "1"]
```
Volumes persistentes:
- `/app/chroma_db`**obrigatório** persistir; perda implica re-rodar `train.py`.
- `/app/data_storage` — efêmero (CSVs intermediários); ok perder, mas afeta `visualize_data` se reciclar entre o `run_sql` e a visualização do mesmo turno.
Healthcheck:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8765/health"] # endpoint do upstream
interval: 30s
timeout: 5s
retries: 3
```
## 11. Escalabilidade
Single-process por design (ChromaDB SQLite). Opções para escalar:
| Estratégia | Quando | Trade-off |
|------------|--------|-----------|
| Vertical (mais CPU/RAM) | Tráfego baixo-médio | Simples; ainda single-tenant de instância |
| Múltiplas instâncias com volume read-only de `chroma_db` | Read-heavy, sem `save_text_memory` em runtime | Memórias salvas pelo LLM (tool `save_text_memory`) só persistem na instância que recebeu o turno; perde-se aprendizado |
| Migrar `ChromaAgentMemory` para Chroma server (HTTP) ou outro vetor store (Postgres+pgvector, Qdrant) | Multi-instância com aprendizado compartilhado | Mudança de integração — não é só config; ver `agent.py` |
ChromaDB **não tem locking inter-process** no modo embedded — deploy multi-worker sem coordenação corrompe o index.
## 12. Re-treino e mudanças de schema
Quando rodar `python train.py`:
- ✅ Coluna nova adicionada ao GRANT em `gold.sales`.
- ✅ Coluna revogada (some do contexto do LLM).
- ✅ Editou `RLS_TABLES` ou `RLS_INTERNAL_COLS`.
- ✅ Editou `system_prompt.py`**não precisa**, o prompt é lido a cada boot do agente.
Sequência segura:
```bash
# 1. Em janela de baixo tráfego, ou em instância de staging
rm -rf chroma_db/
python train.py
# 2. Reiniciar uvicorn (carregar memórias novas no enhancer)
```
> Em produção zero-downtime: faça em uma instância nova, troque o tráfego (blue/green), descarte a antiga.
## 13. Observabilidade
- **`events.vanna_ai`**: 1 row por turno (pergunta, resposta, SQL, tools, status, duração). Use para dashboards Grafana ou queries ad-hoc para entender uso e detectar regressão.
- **Logs uvicorn**: stdout/stderr; capture com agente do cloud (Cloud Logging, CloudWatch, Loki).
- **OpenAI usage**: dashboard da OpenAI para custo por modelo + key.
- **ClickHouse query log**: `system.query_log` filtrado por `user='wren_ia'` mostra todo SQL emitido pelo agente — útil em auditoria de RLS.
Não há `/metrics` Prometheus pronto — adicionar via `prometheus-fastapi-instrumentator` se necessário (não está no escopo atual).
## 14. Custos
Dimensionar antes de escalar acesso:
- **OpenAI**: cada turno chama LLM 1-N vezes (planejamento + tool calls + síntese). `gpt-5.4-mini` é mais barato; `gpt-5` consome significativamente mais. Cache de prompt do upstream Vanna ajuda em conversas longas — ver `claude-api` skill se trocar provider.
- **ClickHouse Cloud**: cobrado por compute-hour + storage. Queries do agente raramente escaneiam tudo (filtros RLS reduzem escopo), mas perguntas amplas ("ranking de produtos no ano") podem virar full scans.
- **Egress de bundle**: `vanna-components.js` ~7.5 MB. Servir via CDN se o dashboard tiver volume alto.
## 15. Segurança — checklist pré-prod
- [ ] `.env` fora da imagem; secrets via secret manager.
- [ ] Senha do `wren_ia` rotacionada e diferente de staging.
- [ ] GRANTs revisados (apenas as 20 colunas seguras + `INSERT events.vanna_ai`).
- [ ] CORS limitado ao(s) domínio(s) do dashboard cliente.
- [ ] HTTPS obrigatório (HSTS recomendado).
- [ ] `program_id`/`store_id` populados pelo backend do cliente, **nunca** pelo browser.
- [ ] Rate limit no proxy (ex.: 30 req/min por IP) — protege contra abuso da OpenAI key.
- [ ] Logs **não** persistem `OPENAI_API_KEY` nem senha do CH (verificar uvicorn access log + qualquer logger custom).
- [ ] `RLSClickHouseRunner` regex guards (`_FORBIDDEN_SCHEMA_RE`, `_INTROSPECTION_STMT_RE`) **ativas**.
- [ ] Smoke test pós-deploy: `python ask.py "quantos clientes únicos compraram em janeiro?"` retorna resultado filtrado por `RLS_PROGRAM_ID`/`RLS_STORE_ID`.
- [ ] Smoke test de RLS: tentar query com `program_id` de outro tenant via curl — deve retornar `PermissionError` (resolver) ou 0 rows (filtro injetado).
- [ ] Smoke test de introspecção: pedir ao agente "liste as tabelas do banco" — runner deve rejeitar com mensagem app-side.
## 16. Cuidados conhecidos
- **Bundle gitignored**: `vanna/frontends/webcomponent/dist/vanna-components.js` precisa ser buildado em todo deploy. Se o pipeline pular o `npm run build`, o servidor sobe mas o `<vanna-chat>` nunca registra (404 no `/static/vanna-components.js`).
- **`pip install -e './vanna[clickhouse]'` falha no macOS** com "non-local file URIs are not supported". Instale extras separados.
- **Primeiro request lento**: download do ONNX (~80 MB). Pré-aquecer no Dockerfile (§10).
- **`adoptedStyleSheets` strip de `@import`**: `vanna-theme.css` não pode importar Google Fonts via CSS. Use `<link>` no `<head>` da página host.
- **`startingstate` (não `starting-state`)**: `@property() startingState` no Lit gera atributo lowercase, sem kebab-case. Usar com hífen é silenciosamente ignorado.
- **Filtros RLS via `dict`** quebram com `CANNOT_PARSE_QUOTED_STRING`. `_format_table_filter_map` em `rls_runner.py` constrói o Map literal manualmente — não trocar por `client.query(..., settings={dict})`.
- **`events.vanna_ai` insert falha silenciosamente** se o GRANT estiver faltando — não derruba o chat, mas perde telemetria. Verificar contagem de rows pós-deploy.
## 17. Rollback
Estado mutável: `chroma_db/` (vetor store) e o schema de `events.vanna_ai`. Para reverter:
1. Manter snapshot do `chroma_db/` por versão (`chroma_db.v1.tar.gz`) antes de cada `train.py` em prod.
2. Tag do bundle (`vanna-components.<hash>.js`) — versionar arquivo se cliente embarca via CDN.
3. Reverter código via tag git → restaurar `chroma_db/` correspondente → reiniciar uvicorn.
Não há migração de schema na app — `events.vanna_ai` é append-only; mudança de schema requer `ALTER TABLE` no CH.