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
364 lines
17 KiB
Markdown
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. Lê `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.
|