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
17 KiB
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:
# 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:
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
# 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 1ou um único processo por volumechroma_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):
-- 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 noRLSClickHouseRunner(_FORBIDDEN_SCHEMA_RE,_INTROSPECTION_STMT_RE). Não desabilite essas guards.
⚠️
SELECT *emgold.salesfalha comACCESS_DENIEDporque exige permissão em todas as colunas. O LLM é instruído a listar colunas; manter assim.
Schema da tabela de telemetria:
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.pyantes 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:
- Lê
program_id,store_id,user_idderequest_context.query_params. - Valida com regex
^[A-Za-z0-9_-]+$. - Levanta
PermissionErrorse 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
RequestContextde 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_sseno 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:
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):
<!-- 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 convertestartingStatepara lowercase, não kebab-case.
⚠️
showProgressé boolean Lit; não desliga via HTML attribute (qualquer presença = true). Sempre via JS apóswhenDefined.
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:
# 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-rodartrain.py./app/data_storage— efêmero (CSVs intermediários); ok perder, mas afetavisualize_datase reciclar entre orun_sqle a visualização do mesmo turno.
Healthcheck:
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_TABLESouRLS_INTERNAL_COLS. - ✅ Editou
system_prompt.py— não precisa, o prompt é lido a cada boot do agente.
Sequência segura:
# 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_logfiltrado poruser='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-5consome significativamente mais. Cache de prompt do upstream Vanna ajuda em conversas longas — verclaude-apiskill 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
.envfora da imagem; secrets via secret manager.- Senha do
wren_iarotacionada 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_idpopulados 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_KEYnem senha do CH (verificar uvicorn access log + qualquer logger custom). RLSClickHouseRunnerregex 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 porRLS_PROGRAM_ID/RLS_STORE_ID. - Smoke test de RLS: tentar query com
program_idde outro tenant via curl — deve retornarPermissionError(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.jsprecisa ser buildado em todo deploy. Se o pipeline pular onpm 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).
adoptedStyleSheetsstrip de@import:vanna-theme.cssnão pode importar Google Fonts via CSS. Use<link>no<head>da página host.startingstate(nãostarting-state):@property() startingStateno Lit gera atributo lowercase, sem kebab-case. Usar com hífen é silenciosamente ignorado.- Filtros RLS via
dictquebram comCANNOT_PARSE_QUOTED_STRING._format_table_filter_mapemrls_runner.pyconstrói o Map literal manualmente — não trocar porclient.query(..., settings={dict}). events.vanna_aiinsert 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:
- Manter snapshot do
chroma_db/por versão (chroma_db.v1.tar.gz) antes de cadatrain.pyem prod. - Tag do bundle (
vanna-components.<hash>.js) — versionar arquivo se cliente embarca via CDN. - 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.