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
This commit is contained in:
leonardosalazar-cp 2026-04-29 17:15:24 -03:00
commit 1d152c0dce
23 changed files with 3687 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.env
.env.local
venv/
__pycache__/
*.pyc
.vanna/
vanna/
chroma_db/
data_storage/
.DS_Store

256
CLAUDE.md Normal file
View File

@ -0,0 +1,256 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this project is
A Vanna 2.0 deployment that lets a user ask natural-language questions in Portuguese and get back SQL results from a ClickHouse Cloud database. **Not** a fork of Vanna — the upstream repo is cloned into `vanna/` and installed editable; the application code lives at the project root.
Wiring: `OpenAILlmService` (LLM) + `ChromaAgentMemory` (local vector store, persisted to `./chroma_db/`) + `RLSClickHouseRunner` (subclass of upstream `ClickHouseRunner` that injects ClickHouse `additional_table_filters` for tenant isolation against `gold` database in ClickHouse Cloud).
Two ways to query the agent:
- CLI: `python ask.py "your question"` (uses `StaticUserResolver` with IDs from env/flags)
- Web: `uvicorn server:app --port 8765` then embed `<vanna-chat>` (uses `RequestContextUserResolver` reading IDs from query params)
## ⚠️ Vanna-first rule (READ BEFORE WRITING NEW CODE)
**Before adding new code, search `vanna/src/vanna/` and `vanna/frontends/` for an existing solution.** This project deliberately uses upstream Vanna primitives wherever possible — only the items in "Intentional custom code" below are project-specific.
When unsure:
1. `grep -r "the-thing-you-want" vanna/src/vanna/` first.
2. Check `vanna/src/vanna/servers/`, `vanna/src/vanna/integrations/`, `vanna/src/vanna/core/`, `vanna/frontends/webcomponent/`.
3. Read `vanna/src/vanna/examples/claude_sqlite_example.py` — closest reference for assembly patterns.
4. Only if there's no built-in equivalent, write custom code in the project root and document why in this section.
### What we use from Vanna (do NOT reimplement)
- **Server**: `vanna.servers.fastapi.VannaFastAPIServer` — see `server.py`. Provides `/api/vanna/v2/chat_sse|chat_websocket|chat_poll`, healthcheck, CORS, RequestContext extraction (cookies+headers+query_params+metadata).
- **Frontend**: `<vanna-chat>` Web Component from `vanna/frontends/webcomponent/`. Build artifact at `vanna/frontends/webcomponent/dist/vanna-components.js` (npm-built; gitignored). Floating button via `starting-state="minimized"`. Renders rich components including Plotly charts, sortable/searchable tables, code blocks, status cards, progress bars.
- **Tools**: `RunSqlTool` + `VisualizeDataTool` from `vanna.tools` — share a `LocalFileSystem(working_directory="./data_storage")`; SQL writes CSV → viz reads CSV → emits `chart` rich component the web component renders via Plotly.
- **Memory tools**: `SearchSavedCorrectToolUsesTool` + `SaveQuestionToolArgsTool` + `SaveTextMemoryTool` from `vanna.tools` — fecham o loop self-learning. ChromaAgentMemory agora também é escrito pelo LLM em runtime (par pergunta→args do `run_sql` após sucesso), não só pelo `train.py` (schema docs offline). Orientação de uso vive em `system_prompt.py` na seção "Memória".
- **Memory**: `vanna.integrations.chromadb.ChromaAgentMemory` for vector store; `agent_memory.save_text_memory(content, context)` is the canonical write API (usada por `train.py`; o LLM usa o tool `save_text_memory` em runtime).
- **User model**: `vanna.User` — has `ConfigDict(extra="allow")` (`vanna/src/vanna/core/user/models.py:25`) so we can attach `program_id`/`store_id` as ad-hoc fields without subclassing.
- **Resolver ABC**: `vanna.core.user.UserResolver` — base class for both `StaticUserResolver` and `RequestContextUserResolver`.
- **RequestContext**: extracted automatically by the server (`vanna/src/vanna/servers/fastapi/routes.py:46-52`) — read from `query_params`/`cookies`/`headers`/`metadata` in the resolver.
- **SQL runner base**: `vanna.integrations.clickhouse.ClickHouseRunner` — we subclass it, never modify upstream.
- **CLI server runner**: `vanna.servers.cli.server_runner` (the `vanna serve` command) — we don't use it directly, but it's the reference for how to wire FastAPI + frontend bundle.
### Intentional custom code (Vanna has no equivalent)
- `TenantAwareChromaMemory` (`tenant_memory.py`) — Vanna's `ChromaAgentMemory` é single-collection, sem scoping. Vazaria perguntas/aprendizados entre tenants (program × store) que compartilham o mesmo deploy. Esta classe compõe duas instâncias: collection compartilhada `vanna_clickhouse_gold` pra text memories (schema docs do `train.py` — comum a todos), e collection `vanna_clickhouse_gold__p<program>__s<store>` lazy-criada por (program_id, store_id) pra tool-usage memories. Roteamento por tipo de memória: text → shared, tool → tenant. `train.py` não muda — escreve só text memories na shared.
- `RLSClickHouseRunner` (`rls_runner.py`) — Vanna's `ClickHouseRunner` doesn't support per-query settings. We override `run_sql` to inject `additional_table_filters` via `client.query(sql, settings=...)`. The `ToolRegistry.transform_args()` hook is Vanna's "official" RLS extension point but only allows arg rewriting / rejection — it can't reach `clickhouse_connect` settings, so we keep the runner subclass.
- `_FORBIDDEN_SCHEMA_RE` / `_INTROSPECTION_STMT_RE` (`rls_runner.py`) — regex guards no topo de `run_sql` que rejeitam SQL contra `system.*` / `information_schema.*` ou statements `SHOW`/`DESCRIBE`/`EXPLAIN`. ClickHouse Cloud não enforça REVOKE column-level em `system.tables` (acesso herdado de role default), então blindamos app-side. Sem isso, o LLM lia `system.tables.create_table_query` e via DDL com colunas revogadas.
- `_format_table_filter_map` / `_quote` (`rls_runner.py`) — `clickhouse_connect` serializes Python dicts as JSON (double quotes) but ClickHouse's Map literal needs single quotes with `''` escape. Hand-built literal is the workaround.
- `_round_decimal_columns` (`rls_runner.py`) — após cada query, percorre as colunas do `pd.DataFrame` e arredonda valores `Decimal`/`float` pra 2 casas (constante `_DISPLAY_DECIMALS`). Necessário porque ClickHouse retorna `Decimal(N, 6)` por padrão e o dataframe rich component renderiza via `value.toLocaleString()` (`vanna/frontends/webcomponent/src/components/rich-component-system.ts:624`) sem rounding — sem isso, `307427.030000` aparecia na tabela. Patchar o renderer upstream exigiria rebuild npm + perde no `git pull`; arredondar app-side cobre todas as queries sem depender do LLM emitir `round(..., 2)` no SELECT. Colunas integer/string/datetime ficam intactas (verificação via `pd.api.types.is_float_dtype` + sample do object dtype). CSV escrito pelo `RunSqlTool` herda os valores arredondados — visualize_data tudo bem porque charts já arredondam exibição.
- `StaticUserResolver` / `RequestContextUserResolver` (`agent.py`) — Vanna ships only the `UserResolver` ABC, no concrete implementations.
- `system_prompt.py` — Vanna tem `DefaultSystemPromptBuilder(base_prompt=...)` mas nenhum prompt domain-specific. Constante `SYSTEM_PROMPT` injeta regras pt-BR + confidencialidade + escopo de loja única + métricas padronizadas + formatação R$/L. Edita o arquivo direto pra iterar; sem rebuild, sem re-train.
- `VisualizeDataToolPT` + `ClubPetroChartGenerator` (`viz_tool.py`) — subclasse de `VisualizeDataTool` com três customizações sobre upstream: (1) novo arg opcional `chart_type` (`line`/`bar`/`scatter`/`histogram`/`area`) no schema (`VisualizeDataArgsPT`) — quando o LLM passa, força o tipo via `_render_forced`; quando omite, cai na heurística como fallback. Threadado via `contextvars.ContextVar` no `execute` pra suportar execuções concorrentes do tool singleton (Vanna roda async). (2) `description` PT-BR com gatilhos por tipo de pergunta (ranking → `chart_type='bar'`, série → `'line'`, etc.). (3) `ClubPetroChartGenerator` substitui o default `PlotlyChartGenerator` upstream — dropa o fallback "4+ colunas → go.Table" (`vanna/src/vanna/integrations/plotly/chart_generator.py:51-55`) que duplicava o dataframe rich; aplica `_coerce_datetime_columns` (string ISO → datetime64) nos DOIS caminhos (<4 e >=4 cols), pra que séries temporais sejam detectadas independente do nº de colunas; e usa `_create_ranked_bar_chart` próprio (em vez do `_create_bar_chart` upstream que re-agrega com `groupby` e perde a ordenação descendente do ranking).
- `RLS_INTERNAL_COLS` (`train.py`) — `program_id`/`store_id` precisam estar no GRANT (RLS depende deles), mas escondemos da doc do ChromaDB pra não tentar o LLM a usá-los manualmente nas queries.
- `train.py` — Vanna has `AgentMemory.save_text_memory` but no schema crawler. Usa apenas `system.columns` (filtrado pelo ClickHouse via GRANT column-level, sem dependência de `system.tables` nem `SHOW CREATE TABLE`) e emite uma memória de texto por tabela em `RLS_TABLES`. Sem DDL bruto pra evitar vazamento de colunas revogadas.
- `ask.py` — Vanna has only the `vanna serve` CLI; no ad-hoc `vanna ask`. This is a thin async wrapper for terminal use.
- `local_request_context()` (`agent.py`) — 1-line factory because Vanna has no default `RequestContext` constructor.
- `csv_cleanup.py` — Vanna upstream não tem GC pros CSVs que `RunSqlTool` escreve em `data_storage/`. O `LocalFileSystem.write_file` só escreve, nunca apaga; sem isso o disco cresce linearmente com o tráfego. Módulo standalone com `sweep_once()` (síncrono, idempotente — apaga `query_results_*.csv` com mtime > `CSV_TTL_SECONDS`, default 1800s) e tarefa asyncio periódica (`CSV_SWEEP_INTERVAL_SECONDS`, default 600s). `server.py` pluga via `on_event("startup"/"shutdown")` — sweep no boot pra recolher legado de runs anteriores e armar o loop de fundo. CLI (`ask.py`) não roda cleanup em tempo real; depende do próximo boot do servidor.
- `static/vanna-embed-bootstrap.js` — fonte única do JS de wiring exigido por todo embed do `<vanna-chat>`: theme pierce (monkey-patch do setter de `adoptedStyleSheets` pra forçar o `themeSheet` sempre como último item — Lit re-assina depois do `attachShadow` e venceria a cascade sem isso); tradutor PT-BR via MutationObserver em todo shadow root novo; markdown processor pros balões (`escapeHtml` → code → bold/italic → links → **headers ANTES de listas** porque a regex de lista consome o `\n` final e quebraria header subsequente → `\n``<br>` → cleanup `<br>` adjacente a blocos); load de fonts; injeção do bundle. Servido em `/vanna-embed-bootstrap.js` (rota explícita no `server.py`). Expõe `window.VannaEmbed.ensureLoaded({ baseUrl, extraCss? }) -> Promise<void>` (idempotente). Antes desta extração, a app React (`clubpetro-frontend/src/components/VannaChat/vannaChatLoader.ts`) duplicava ~250 linhas idênticas; toda correção tinha que ser feita em dois lugares e divergia (foi exatamente o que aconteceu com fix de markdown headers — só pegou o demo, app React continuou quebrada). Agora `embed-demo.html` e `vannaChatLoader.ts` são thin wrappers que injetam `<script src=...>` e chamam `ensureLoaded`. Edição do bootstrap reflete em ambos automaticamente após hard-refresh (sem rebuild npm).
- `EventSink` + `TurnRecord` + ContextVar `_current_turn` (`events_sink.py`) — grava 1 row por turno de chat em `events.vanna_ai` (ClickHouse). Vanna tem `observability_provider` (spans/metrics) mas não tem persistência estruturada de interação fim-a-fim. `chat_filter.py` cria o TurnRecord no início do `handle_stream`, captura `question` (de `request.message`), `program_id`/`store_id`/`user_id` (de `request_context.query_params` — frontend do web app deve passar os 3 como query string nos endpoints `sse-endpoint`/`ws-endpoint`/`poll-endpoint` do `<vanna-chat>`; `user_id` defaulta a string vazia se ausente, sem quebrar o chat), `response` (concatenando rich.type=='text' chunks), `status`/`error_message` (do try/except). Hooks downstream escrevem no mesmo TurnRecord via ContextVar: `EventCapturingToolRegistry.transform_args` (`agent.py`) registra cada tool call e captura `args.sql` quando `tool.name == "run_sql"`; `VisualizeDataToolPT.execute` registra (chart_type, title). Flush no `finally` do stream via `EventSink.flush` (`asyncio.to_thread` pro `client.insert` sync; try/except envolve tudo — falha de insert nunca quebra a resposta ao usuário). Cliente CH lazy, mesmas creds do RLS runner — `wren_ia` precisa `GRANT INSERT ON events.vanna_ai`.
## Commands
All commands assume the venv is active:
```
source venv/bin/activate
```
Common workflows:
```
python ask.py "your question" # CLI (uses .env RLS_PROGRAM_ID/RLS_STORE_ID)
python ask.py --program-id <id> --store-id <id> "..." # CLI with override
python train.py # re-extract schema (only RLS_TABLES) into ChromaDB
python test_clickhouse.py # raw connectivity smoke test (no LLM)
uvicorn server:app --host 127.0.0.1 --port 8765 # web server
```
Web component build (one-time, after `git pull` of upstream or after upstream version bump):
```
cd vanna/frontends/webcomponent
npm install
npm run build
# outputs vanna/frontends/webcomponent/dist/vanna-components.js (~7.5 MB)
```
Re-installing or pulling upstream Vanna changes:
```
cd vanna && git pull && cd ..
pip install -e ./vanna
```
Adding more LLM/DB/vector extras (Vanna defines them in `vanna/pyproject.toml`):
```
pip install <package> # extras shorthand `pip install -e './vanna[xxx]'` fails on macOS file URI
```
## Architecture
### Agent assembly (agent.py)
`build_agent(program_id=None, store_id=None, user_resolver=None)` is the single source of truth. Vanna 2.0's `Agent.__init__` requires **both** `agent_memory` and `user_resolver` — no defaults. Two resolver flavors:
- `StaticUserResolver` — fixed `User` from env/flags. CLI default.
- `RequestContextUserResolver` — reads `program_id`/`store_id` from `request_context.query_params` (with `metadata` fallback) per request. Validates with `_require_id` (regex `^[A-Za-z0-9_-]+$`); raises `PermissionError` on missing/invalid. Web default.
`ChromaAgentMemory(persist_directory="./chroma_db", collection_name="vanna_clickhouse_gold")` is shared by both.
Agent uses `temperature=float(os.environ.get("OPENAI_TEMPERATURE", "1.0"))`. Default 1.0 mantém compat com modelos de reasoning/`gpt-5*` que rejeitam outros valores. Pra modelos que aceitam ajuste (ex.: `gpt-4o`), set `OPENAI_TEMPERATURE=0.2` no `.env` pra mais determinismo na geração de SQL.
System prompt customizado vem de `system_prompt.SYSTEM_PROMPT` via `DefaultSystemPromptBuilder(base_prompt=...)`. Quando `base_prompt` é não-nulo (`vanna/src/vanna/core/system_prompt/default.py:47-48`), substitui o prompt default do Vanna; o `LlmContextEnhancer` ainda anexa as memórias retrievadas do ChromaDB depois.
### RLS (rls_runner.py)
`RLSClickHouseRunner` extends `ClickHouseRunner`. Em `run_sql` faz dois trabalhos:
**1. Bloqueio app-side de introspecção** (regex guards no topo da função, antes de tudo):
- `_FORBIDDEN_SCHEMA_RE` — rejeita SQL referenciando `system.*` ou `information_schema.*`. ClickHouse Cloud não enforça REVOKE column-level em `system.tables` (acesso vem de role default não-revogável), então é app-side ou nada.
- `_INTROSPECTION_STMT_RE` — rejeita statements `SHOW`/`DESCRIBE`/`EXPLAIN`. Sem isso, o LLM bypassava `system.*` via `SHOW TABLES FROM gold` ou `DESCRIBE TABLE gold.sales` e descobria colunas revogadas.
Ambos levantam `PermissionError` com mensagem orientando o LLM a usar o contexto treinado.
**2. Injeção de RLS** (`additional_table_filters`):
- `RLS_TABLES = ("gold.sales",)` — receives a `program_id = '...' AND store_id = '...'` filter built from `context.user.program_id` / `store_id`.
- `DENIED_TABLES = ("gold.vw_relatorios_exportaveis_analitico_vendas",)` — receives the literal `0` filter (zero rows). Defense in depth — the ClickHouse user `wren_ia` already lacks SELECT grant on the view.
`train.py` imports `RLS_TABLES` so the trained schema docs match the RLS scope (single source of truth).
The filter expression is built as a hand-formatted ClickHouse Map literal because `clickhouse_connect` JSON-serializes Python dicts. See `_format_table_filter_map`.
### Tools registered (agent.py)
Two tools sharing a `LocalFileSystem(working_directory="./data_storage")`:
- `RunSqlTool(sql_runner=RLSClickHouseRunner(...), file_system=fs)` — executes RLS-filtered SQL, dumps full result to CSV in `./data_storage/<user-hash>/query_results_*.csv`, returns truncated preview to the LLM.
- `VisualizeDataToolPT(file_system=fs)` — subclasse local de `VisualizeDataTool` (`viz_tool.py`) com `ClubPetroChartGenerator` injetado e arg `chart_type` opcional. Lê o CSV da rodada anterior, emite um `chart` rich component (Plotly figure JSON). Não usa o `PlotlyChartGenerator` default — ver "Intentional custom code" pros detalhes (drop de `go.Table`, coerção de datetime uniforme, ranked bar). The web component renders it; the CLI just reports "Created visualization from <file>".
Both registered via `tools.register_local_tool(tool, access_groups=[])` (Vanna's `register(tool)` shorthand exists in some upstream examples but isn't the current API). Empty `access_groups=[]` means accessible to all users.
### Theming (static/vanna-theme.css + adoptedStyleSheets pierce)
`<vanna-chat>` exposes ~50 CSS custom properties (`vanna/frontends/webcomponent/src/styles/vanna-design-tokens.ts`). Each internal custom element (`vanna-message`, `vanna-status-bar`, `vanna-progress-tracker`, `plotly-chart`, `rich-card`, `rich-task-list`, `rich-progress-bar`) re-imports `vannaDesignTokens` and re-declares the literals on its own `:host`. **A `<link rel="stylesheet">` in the document only retemas `vanna-chat` itself** — nested elements live in encapsulated shadow trees that document selectors can't reach, and their internal `:host` rules shadow any inherited custom property.
O pierce vive em `static/vanna-embed-bootstrap.js` (ver "Intentional custom code"): (a) cria um `CSSStyleSheet` construído, (b) monkey-patcha o **setter** de `ShadowRoot.prototype.adoptedStyleSheets` pra sempre mover esse sheet pro fim do array (necessário porque Lit re-assina o array depois do `attachShadow` e venceria a cascade sem isso), (c) `fetch()`-a `/vanna-theme.css` e chama `replaceSync()` pra popular. Adopted sheets cascateiam depois do `static styles` do próprio component, então com `:host` specificity igual a regra adoptada vence.
`static/vanna-theme.css` uses selector `:host, vanna-chat { ... }` so the same file works in both contexts:
- `<link>` in the document → `vanna-chat { ... }` matches the host element.
- `adoptedStyleSheets` inside each shadow root → `:host { ... }` matches the shadow host of that root.
**No JS rebuild needed** to change colors/fonts/spacing — edit `static/vanna-theme.css` and hard-refresh. Don't modify `vanna/frontends/webcomponent/src/styles/vanna-design-tokens.ts` (upstream, would be lost on `git pull`).
`@import` of Google Fonts inside `vanna-theme.css` doesn't work because constructed `CSSStyleSheet` (via `replaceSync`) silently strips `@import`. The bootstrap injeta `<link>` no `<head>` via `loadFontsOnce`.
A sidebar (`<vanna-progress-tracker>`) é escondida pelo lado consumer (não pelo bootstrap) — em `embed-demo.html` e em `VannaChat.tsx`, depois de `customElements.whenDefined("vanna-chat")` resolver, seta `el.showProgress = false`. Lit Boolean property attributes default-true não desligam via HTML attribute (qualquer presença = true).
#### Embed bootstrap (`static/vanna-embed-bootstrap.js`)
Bootstrap único servido pelo Vanna server em `/vanna-embed-bootstrap.js` (rota no `server.py`). Concentra todo o JS de wiring; consumido por:
- `static/embed-demo.html` (smoke test, mesma origem) — `<script src="/vanna-embed-bootstrap.js">` + chamada `VannaEmbed.ensureLoaded({ baseUrl: "" })`.
- `clubpetro-frontend/src/components/VannaChat/vannaChatLoader.ts` (app React, cross-origin) — injeta `<script src="${baseUrl}/vanna-embed-bootstrap.js">` dinamicamente, espera `window.VannaEmbed`, chama `ensureLoaded({ baseUrl, extraCss })`. `extraCss` carrega override do avatar logo (SVG mora no `/public/` do CRA, não no Vanna server).
Antes desta extração, a app React duplicava ~250 linhas idênticas — fix de markdown header rodou só no demo e a app React continuou quebrada. Lição: **toda mudança em theme/i18n/markdown agora vive só em `static/vanna-embed-bootstrap.js`**. Hard-refresh nas duas pontas pega automático.
Pra produção, a página do cliente carrega:
```html
<link rel="stylesheet" href="https://SEU-VANNA/vanna-theme.css">
<script src="https://SEU-VANNA/vanna-embed-bootstrap.js"></script>
<vanna-chat id="chat" ...></vanna-chat>
<script>
window.VannaEmbed.ensureLoaded({ baseUrl: "https://SEU-VANNA" }).then(() => {
document.getElementById("chat").showProgress = false;
});
</script>
```
Fonts são carregadas pelo bootstrap (`loadFontsOnce`) — o cliente não precisa mais incluir `<link>` de Google Fonts manualmente. Bundle (`vanna-components.js`) também é injetado pelo bootstrap (`injectBundle`).
### Filtragem + tradução de chunks (chat_filter.py)
`FilteringChatHandler` (subclasse de `vanna.servers.base.ChatHandler`) é injetado em `VannaFastAPIServer.chat_handler` antes de `create_app()`. Ele intercepta o stream de `ChatStreamChunk` e:
1. **Whitelist de `rich.type`** — drops chunks cujo tipo não está em `ALLOWED_RICH_TYPES = {text, dataframe, chart, status_bar_update, chat_input_update}`. Tipos extras que o agent emite (`status_card`, `task_tracker_update`, `notification`, `log_viewer`, `progress_display`, etc.) somem antes de virar SSE.
2. **Tradução PT** — strings hardcoded em inglês emitidas por `vanna/src/vanna/core/agent/agent.py` (`Response complete`, `Ready for next message`, `Processing your request...`, etc.) são substituídas via tabela `TRANSLATIONS` exact-match nos campos `message`, `detail`, `placeholder` do `rich.data`. Strings que caem fora da tabela (incluindo dinâmicas tipo `Running 3 tools`) passam intocadas — adicionar entradas conforme aparecerem no chat.
Charts: o `ClubPetroChartGenerator` (`viz_tool.py`) substitui o default upstream e dropa o `go.Table` fallback de 4+ colunas, então não tem dataframe duplicado visualmente. O LLM controla o tipo via arg `chart_type` no `visualize_data` (line/bar/scatter/histogram/area); quando omite, cai numa heurística por shape do CSV.
Pra adicionar mais tipos permitidos ou novas traduções: editar `chat_filter.py`. Sem rebuild de bundle, sem mexer em upstream.
### Web server (server.py)
`server.py` is ~60 lines: `VannaFastAPIServer(agent, config={cors:..., api_base_url:""}).create_app()`, mounts `/static/` to `vanna/frontends/webcomponent/dist/`, adiciona rotas estáticas pra arquivos do project-root `static/`:
- `/vanna-theme.css` — tema CSS adoptado em todo shadow root.
- `/vanna-embed-bootstrap.js` — JS único de wiring (theme pierce + tradutor + markdown + bundle loader). Consumido por `embed-demo.html` e pela app React.
- `/clubpetro-logo.{png,svg}`, `/dashboard-bg.png` — assets.
- `/embed-demo.html` — smoke test page que substitui `__PROGRAM_ID__` / `__STORE_ID__` / `__USER_ID__` placeholders com valores do `.env` em runtime (lido fresh a cada request — sem restart pra mudanças em HTML).
All chat routes (`/api/vanna/v2/chat_sse|chat_websocket|chat_poll`) come from upstream — do not redefine them.
The web component (`<vanna-chat>`) sends `program_id` / `store_id` as query params on the endpoint URL: `sse-endpoint="/api/vanna/v2/chat_sse?program_id=X&store_id=Y"`. The upstream server populates `RequestContext.query_params` from `dict(http_request.query_params)`, so `RequestContextUserResolver` picks them up.
### Training flow (train.py)
Vanna 2.0 has no separate "training" API. Schema knowledge is injected by saving **text memories** into the same `ChromaAgentMemory` instance the agent reads from. `DefaultLlmContextEnhancer` (auto-wired when no enhancer is passed) retrieves the top-k similar text memories and appends them to the system prompt on every turn.
`train.py` itera tabelas em `RLS_TABLES`, lê `system.columns` (que o ClickHouse já filtra por GRANT column-level — não precisa privilege em `system.tables`), filtra colunas em `RLS_INTERNAL_COLS = {"program_id", "store_id"}` pra escondê-las do contexto do LLM, e salva uma memória de texto por tabela com lista de colunas + 3 sample rows (sem DDL, sem `SHOW CREATE TABLE`).
Re-rodar após:
- mudança de GRANT no ClickHouse (coluna nova → aparece automaticamente; coluna revogada → some).
- edição de `RLS_TABLES`.
- edição de `RLS_INTERNAL_COLS`.
Sequência: `rm -rf chroma_db/ && python train.py`.
When constructing a `ToolContext` manually (as in `train.py`), the `agent_memory=` field is required by Pydantic.
### CLI (ask.py)
`argparse` with `--program-id` / `--store-id` flags + positional question. Calls `build_agent(program_id, store_id)`. `Agent.send_message` is an async generator yielding `UiComponent` objects. Each component has both a `rich_component` (structured for the web UI) and a `simple_component` (text fallback). The CLI prefers `rich_component.content`, falls back to `simple_component.text`.
## Non-obvious gotchas
- **`ToolRegistry` API**: use `register_local_tool(tool, access_groups=[])` — there is no plain `.register()` method despite what some upstream examples suggest.
- **Empty `access_groups=[]`** means the tool is accessible to all users; non-empty is a permission allowlist.
- **`additional_table_filters` serialization**: `clickhouse_connect` serializes Python `dict` settings as JSON (double quotes), which ClickHouse rejects with `CANNOT_PARSE_QUOTED_STRING (code 26)`. The Map value must be passed as a pre-formatted string literal with single quotes and `''` escape — see `_format_table_filter_map` in `rls_runner.py`. Don't pass a `dict` to `client.query(..., settings={"additional_table_filters": {...}})`.
- **ChromaDB first run** downloads ~80 MB ONNX embedding model to `~/.cache/chroma/`. Cached afterwards. If running offline, pre-download or pass a custom `embedding_function`.
- **Editable extras on macOS**: `pip install -e './vanna[clickhouse]'` fails with `non-local file URIs are not supported`. Install the editable package and the extras separately.
- **Python 3.9** triggers deprecation warnings from `clickhouse-connect` and `urllib3`/LibreSSL. Not blocking. If upgrading the Python interpreter, recreate the venv.
- **`SELECT *` on `gold.sales` falha com ACCESS_DENIED** — `wren_ia` tem column-level GRANT, então `SELECT *` exige SELECT em todas as colunas (inclusive as 9 revogadas) e ClickHouse rejeita. Listar colunas explicitamente. (Antes do GRANT column-level, `RunSqlTool` salvava o full result em `./data_storage/<user-hash>/query_results_*.csv` — esse fluxo continua válido para queries com colunas explícitas.)
- **REVOKE column-level em `system.*` é silenciosamente ignorado em ClickHouse Cloud**`REVOKE SELECT(create_table_query) ON system.tables FROM wren_ia` retorna "succeeded" no parser mas runtime continua liberando a leitura. Defesa real é app-side via regex no `RLSClickHouseRunner`.
- **Upstream examples are not all current**: `openai_quickstart.py` imports `OpenAILlmService` from `vanna.integrations.anthropic` (a bug); `mock_sqlite_example.py` calls `agent.send_message(user=...)` but the real signature now takes `request_context=`. Trust `claude_sqlite_example.py` and `vanna/src/vanna/core/agent/agent.py` over the others.
## Database
ClickHouse Cloud, database `gold`, accessed over HTTPS port 8443 with `secure=true`.
**Tabela treinada**: `gold.sales` — analytical sales fact (data: fuel/retail, `CLUBE ALE` / `POP FIDELIDADE`). Filtrada por `program_id` + `store_id` por request via RLS.
**GRANT column-level em `wren_ia`** — 20 colunas das 29 originais:
- `program_id`, `store_id` — necessárias pro RLS funcionar (mas 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` — visíveis ao LLM (18).
9 colunas REVOGADAS (não aparecem em `system.columns`, geram `ACCESS_DENIED` se o LLM tentar): `customer_id`, `customer_category_id`, `sale_product_id`, `product_id`, `product_category_id`, `attendant_id`, `rfid_do_atendente`, `sync_date`. Conferir grants atuais via `SHOW GRANTS FOR wren_ia` no console ClickHouse Cloud (admin) ou `SELECT * FROM system.grants WHERE user_name = 'wren_ia'`.
**Tabela negada**: `gold.vw_relatorios_exportaveis_analitico_vendas` — exportable view; **denied** at runner level (`'0'` filter) and at ClickHouse grant level (sem SELECT).
Credentials live in `.env` (gitignored). RLS values too: `RLS_PROGRAM_ID`, `RLS_STORE_ID` (CLI defaults). Optional: `OPENAI_TEMPERATURE` (default 1.0). Both `agent.py` and `train.py` call `load_dotenv()` at import.
## Where to look in upstream
Server / frontend:
- `vanna/src/vanna/servers/fastapi/app.py``VannaFastAPIServer` factory
- `vanna/src/vanna/servers/fastapi/routes.py` — chat SSE/WS/poll endpoints; auto-extracts `RequestContext` from cookies/headers/query_params (line 46-52)
- `vanna/src/vanna/servers/base/templates.py` — index HTML used by `GET /` (login + `<vanna-chat>` embed)
- `vanna/frontends/webcomponent/src/components/vanna-chat.ts` — Web Component attributes (`api-base`, `sse-endpoint`, `starting-state`, `theme`, etc.)
- `vanna/frontends/webcomponent/src/services/api-client.ts` — confirms URL construction is naive `${baseUrl}${endpoint}` concat (so query strings on `sse-endpoint` work)
Core:
- `vanna/src/vanna/core/agent/agent.py``Agent` class, `send_message` signature, required init params
- `vanna/src/vanna/core/registry.py``ToolRegistry`; the `transform_args()` hook (line 113-142) is the "official" RLS extension point but only handles arg rewriting / `ToolRejection`, not query settings
- `vanna/src/vanna/core/system_prompt/default.py:47-48``DefaultSystemPromptBuilder.build_system_prompt`: quando `base_prompt` é não-nulo retorna ele direto, descartando o prompt default do Vanna.
- `vanna/src/vanna/core/user/{models,resolver,request_context}.py``User` (with `extra="allow"`), `UserResolver` ABC, `RequestContext`
- `vanna/src/vanna/integrations/{openai,chromadb,clickhouse}/` — integrations this project uses
- `vanna/src/vanna/integrations/plotly/chart_generator.py:51` — heurística "4+ colunas → go.Table" do default generator. Substituímos via `ClubPetroChartGenerator` em `viz_tool.py`.
- `vanna/src/vanna/tools/run_sql.py``RunSqlTool` behavior (truncation, CSV side-output)
- `vanna/src/vanna/examples/claude_sqlite_example.py` — closest working reference for the assembly pattern
- `vanna/MIGRATION_GUIDE.md` — only relevant if migrating Vanna 0.x code (not used here)

82
README.md Normal file
View File

@ -0,0 +1,82 @@
# vanna-clubpetro
Deploy do Vanna 2.0 da ClubPetro: agente NL2SQL em pt-BR sobre ClickHouse Cloud, com RLS por `program_id`/`store_id`, memória ChromaDB tenant-aware, eventos persistidos em `events.vanna_ai`, e Web Component embedável (chat flutuante com Plotly + tabelas).
A arquitetura completa está em [`CLAUDE.md`](./CLAUDE.md).
## Dependência: upstream Vanna
Este repo **não vendora** o upstream — `vanna/` está em `.gitignore`. O Vanna 2.0 é instalado editable a partir de um clone separado do repositório oficial.
```bash
# 1. Clone este repo
git clone https://git.clubpetro.com/leonardo.salazar/vanna-clubpetro.git
cd vanna-clubpetro
# 2. Clone o upstream do Vanna dentro do projeto
git clone https://github.com/vanna-ai/vanna.git vanna
# 3. Crie o venv e instale
python3 -m venv venv
source venv/bin/activate
pip install -e ./vanna
pip install clickhouse-connect chromadb openai python-dotenv fastapi uvicorn
```
> Pinning recomendado: o deploy atual roda com upstream em `365d061` (`vanna-ai/vanna@365d0617c1a4567ffee1b19b40c27feb4206bfcf`). Se um `git pull` upstream quebrar algo, faça `git checkout 365d061` dentro de `vanna/`.
## Build do Web Component
```bash
cd vanna/frontends/webcomponent
npm install
npm run build
# gera vanna/frontends/webcomponent/dist/vanna-components.js (~7.5 MB)
```
## Configuração
Copie `.env.example` (não versionado — peça pro time) ou crie `.env` com:
```
CLICKHOUSE_HOST=...
CLICKHOUSE_PORT=8443
CLICKHOUSE_DATABASE=gold
CLICKHOUSE_USER=wren_ia
CLICKHOUSE_PASSWORD=...
CLICKHOUSE_SECURE=true
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-5
OPENAI_TEMPERATURE=1.0
RLS_PROGRAM_ID=...
RLS_STORE_ID=...
RLS_USER_ID=...
```
## Treinar a memória de schema
```bash
python train.py
```
`system.columns` filtrado por GRANT do usuário `wren_ia` e escreve memórias de texto no ChromaDB local (`./chroma_db/`). Re-rode após qualquer mudança em GRANT, em `RLS_TABLES`, ou em `RLS_INTERNAL_COLS`.
## Rodar
CLI:
```bash
python ask.py "qual o ranking de produtos por valor de venda?"
python ask.py --program-id <id> --store-id <id> "..."
```
Servidor web (embed do `<vanna-chat>`):
```bash
uvicorn server:app --host 127.0.0.1 --port 8765
# smoke test em http://127.0.0.1:8765/embed-demo.html
```
## Embed em outras páginas
Ver [`docs/embed-react.md`](./docs/embed-react.md) e [`docs/deploy.md`](./docs/deploy.md). O bootstrap único (`static/vanna-embed-bootstrap.js`) carrega bundle + tema + i18n.

182
agent.py Normal file
View File

@ -0,0 +1,182 @@
"""Vanna agent factory: OpenAI + ChromaDB (local) + ClickHouse Cloud."""
import os
from typing import Optional
from dotenv import load_dotenv
from vanna import Agent, AgentConfig, User
from vanna.core.registry import ToolRegistry, ToolRejection
from vanna.core.system_prompt import DefaultSystemPromptBuilder
from vanna.core.tool import Tool, ToolContext
from vanna.core.user import RequestContext, UserResolver
from vanna.core.workflow import DefaultWorkflowHandler
from vanna.integrations.openai import OpenAILlmService
from vanna.tools import LocalFileSystem, RunSqlTool
from vanna.tools.agent_memory import (
SaveQuestionToolArgsTool,
SaveTextMemoryTool,
SearchSavedCorrectToolUsesTool,
)
from events_sink import record_sql, record_tool
from rls_runner import RLSClickHouseRunner, _require_id
from system_prompt import SYSTEM_PROMPT
from tenant_memory import TenantAwareChromaMemory
from viz_tool import VisualizeDataToolPT
class EventCapturingToolRegistry(ToolRegistry):
"""ToolRegistry que registra cada tool call (e SQL do run_sql) no
TurnRecord ativo via ContextVar. `transform_args` é chamado pelo upstream
em TODA execução de tool antes do `tool.execute`, então é o ponto único
pra capturar a atividade do agente sem wrappar cada tool.
"""
async def transform_args(
self,
tool: Tool,
args,
user: User,
context: ToolContext,
):
record_tool(tool.name)
if tool.name == "run_sql":
sql = getattr(args, "sql", None)
if isinstance(sql, str):
record_sql(sql)
return await super().transform_args(tool, args, user, context)
load_dotenv()
class StaticUserResolver(UserResolver):
"""Always resolves to the same single-user identity (CLI / local use)."""
def __init__(self, user: User):
self._user = user
async def resolve_user(self, request_context: RequestContext) -> User:
return self._user
class RequestContextUserResolver(UserResolver):
"""Reads program_id/store_id from request_context.
Prefers query_params (web component sends them in the endpoint URL),
falls back to metadata (for server-to-server callers using ChatRequest.metadata).
Validates immediately so invalid/missing RLS context fails before any tool runs.
"""
async def resolve_user(self, request_context: RequestContext) -> User:
qp = request_context.query_params or {}
meta = request_context.metadata or {}
program_id = _require_id(
"program_id", qp.get("program_id") or meta.get("program_id")
)
store_id = _require_id(
"store_id", qp.get("store_id") or meta.get("store_id")
)
return User(
id="web",
username="web",
program_id=program_id,
store_id=store_id,
)
def build_agent(
program_id: Optional[str] = None,
store_id: Optional[str] = None,
user_resolver: Optional[UserResolver] = None,
) -> Agent:
if user_resolver is None:
program_id = program_id or os.environ.get("RLS_PROGRAM_ID")
store_id = store_id or os.environ.get("RLS_STORE_ID")
if not program_id or not store_id:
raise RuntimeError(
"RLS requires program_id and store_id. "
"Pass via build_agent(...) or set RLS_PROGRAM_ID / RLS_STORE_ID."
)
user_resolver = StaticUserResolver(
User(
id="local",
username="local",
program_id=program_id,
store_id=store_id,
)
)
llm = OpenAILlmService(
model=os.environ["OPENAI_MODEL"],
api_key=os.environ["OPENAI_API_KEY"],
)
sql_runner = RLSClickHouseRunner(
host=os.environ["CLICKHOUSE_HOST"],
port=int(os.environ["CLICKHOUSE_PORT"]),
user=os.environ["CLICKHOUSE_USER"],
password=os.environ["CLICKHOUSE_PASSWORD"],
database=os.environ["CLICKHOUSE_DATABASE"],
secure=os.environ.get("CLICKHOUSE_SECURE", "true").lower() == "true",
)
file_system = LocalFileSystem(working_directory="./data_storage")
tools = EventCapturingToolRegistry()
tools.register_local_tool(
RunSqlTool(sql_runner=sql_runner, file_system=file_system),
access_groups=[],
)
tools.register_local_tool(
VisualizeDataToolPT(file_system=file_system),
access_groups=[],
)
# Memory tools — fecham o loop de self-learning. Search registrado
# primeiro pra incentivar "consulta antes de gerar SQL nova"; Save*
# depois. Todos zero-arg: leem agent_memory do ToolContext em runtime.
tools.register_local_tool(SearchSavedCorrectToolUsesTool(), access_groups=[])
tools.register_local_tool(SaveQuestionToolArgsTool(), access_groups=[])
tools.register_local_tool(SaveTextMemoryTool(), access_groups=[])
# Memória multi-tenant: text memories (schema docs do train.py) ficam
# numa collection compartilhada `vanna_clickhouse_gold`; tool-usage
# memories (pares pergunta→args salvos pelo LLM em runtime) vão pra
# collection per-(program_id, store_id) — evita vazamento entre tenants.
memory = TenantAwareChromaMemory(
persist_directory="./chroma_db",
base_collection_name="vanna_clickhouse_gold",
)
# Default 1.0 mantém compat com modelos de reasoning/gpt-5* que rejeitam
# outros valores. Pra modelos que aceitam ajuste (ex.: gpt-4o), set
# OPENAI_TEMPERATURE=0.2 no .env pra mais determinismo na geração de SQL.
temperature = float(os.environ.get("OPENAI_TEMPERATURE", "1.0"))
welcome_message = (
"#### 👋 Olá! Aqui é a ClubPetro IA\n\n"
"Sua assistente de inteligência de dados. Eu transformo dados complexos em "
"respostas claras, direto ao ponto. Precisa de um relatório de faturamento, "
"entender a performance da sua equipe ou aprofundar no comportamento de "
"compra de seus clientes? É só perguntar. Eu cuido dos números e gráficos "
"para você focar no que importa: **lucrar mais**.\n\n"
"**Experimente:**\n"
"- *Faturamento por mês no último semestre*\n"
"- *Top 10 produtos da semana*\n"
"- *Clientes que ganharam mais desconto*\n"
"- *Frentistas com maior índice de fidelidade*"
)
return Agent(
llm_service=llm,
tool_registry=tools,
user_resolver=user_resolver,
agent_memory=memory,
config=AgentConfig(stream_responses=False, temperature=temperature),
system_prompt_builder=DefaultSystemPromptBuilder(base_prompt=SYSTEM_PROMPT),
workflow_handler=DefaultWorkflowHandler(welcome_message=welcome_message),
)
def local_request_context() -> RequestContext:
return RequestContext(remote_addr="127.0.0.1", metadata={"source": "cli"})

48
ask.py Normal file
View File

@ -0,0 +1,48 @@
"""Ask the Vanna agent a question from the command line.
Usage:
python ask.py "Quantas vendas tivemos no último mês?"
python ask.py --program-id <id> --store-id <id> "..."
"""
import argparse
import asyncio
from typing import Optional
from agent import build_agent, local_request_context
async def main(question: str, program_id: Optional[str], store_id: Optional[str]) -> None:
agent = build_agent(program_id=program_id, store_id=store_id)
rc = local_request_context()
print(f"\n> {question}\n")
async for component in agent.send_message(
request_context=rc,
message=question,
conversation_id="cli-session",
):
rich = getattr(component, "rich_component", None)
simple = getattr(component, "simple_component", None)
content = getattr(rich, "content", None) if rich else None
if not content and simple is not None:
content = getattr(simple, "text", None)
if content:
print(content)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Ask the Vanna agent a question.")
parser.add_argument("--program-id", dest="program_id", default=None)
parser.add_argument("--store-id", dest="store_id", default=None)
parser.add_argument("question", nargs="+")
args = parser.parse_args()
asyncio.run(
main(
question=" ".join(args.question),
program_id=args.program_id,
store_id=args.store_id,
)
)

137
chat_filter.py Normal file
View File

@ -0,0 +1,137 @@
"""ChatHandler subclass que filtra rich types, traduz strings e grava
1 row por turno em `events.vanna_ai` (via `EventSink`).
- Whitelist de rich.type: tudo que não esteja em ALLOWED_RICH_TYPES é dropado
antes de virar chunk SSE. Reduz ruído na UI (sem status_card, task_tracker,
notification, log_viewer, etc.).
- Tradução: strings hardcoded em inglês emitidas por
vanna/src/vanna/core/agent/agent.py (Response complete, Ready for next
message, etc.) viram PT via tabela exata. Não regex quem cair fora da
tabela passa intocado.
- Captura de turno: cria um TurnRecord no início do stream, acumula
resposta (rich.type=='text'), e flush via EventSink no `finally`. Hooks
de tools/SQL/charts vivem em `agent.py` e `viz_tool.py` e escrevem no
mesmo TurnRecord via ContextVar.
"""
from __future__ import annotations
from typing import AsyncGenerator, Optional
from vanna.core import Agent
from vanna.servers.base import ChatHandler, ChatRequest, ChatStreamChunk
from events_sink import EventSink, TurnRecord, reset_turn, set_turn
ALLOWED_RICH_TYPES = frozenset(
{
"text",
"dataframe",
"chart",
"status_bar_update",
"chat_input_update",
}
)
TRANSLATIONS = {
# status_bar_update — message
"Ready": "Pronto",
"Workflow complete": "Fluxo concluído",
"Response complete": "Resposta concluída",
"Processing your request...": "Processando sua solicitação...",
"Executing tools...": "Executando ferramentas...",
"Tool limit reached": "Limite de ferramentas atingido",
"Error occurred": "Ocorreu um erro",
# status_bar_update — detail
"Choose an option or type a message": "Escolha uma opção ou digite uma mensagem",
"Analyzing query": "Analisando consulta",
"Ready for next message": "Pronto para a próxima mensagem",
"An unexpected error occurred while processing your message": "Ocorreu um erro inesperado ao processar sua mensagem",
# chat_input_update — placeholder
"Try again...": "Tente novamente...",
"Ask a question...": "Faça uma pergunta...",
"Ask a follow-up question...": "Faça uma pergunta complementar...",
"Continue the task or ask me something else...": "Continue a tarefa ou pergunte outra coisa...",
# dataframe — title
"Query Results": "Resultados",
}
def _translate(value):
if isinstance(value, str):
return TRANSLATIONS.get(value, value)
return value
def _rewrite_dataframe(data: dict) -> None:
"""Reescreve title/description do DataFrameComponent em pt-BR.
title "Query Results" "Resultados" (via TRANSLATIONS).
description "SQL query returned N rows with M columns" string dinâmica
do upstream (run_sql.py:112). Substituímos por contagem PT sem mencionar
"query"/SQL, usando os campos row_count/column_count que vêm no chunk.
"""
if "description" in data and isinstance(data["description"], str):
if data["description"].startswith("SQL query returned"):
rows = data.get("row_count", 0)
cols = data.get("column_count", 0)
row_word = "linha" if rows == 1 else "linhas"
col_word = "coluna" if cols == 1 else "colunas"
data["description"] = f"{rows} {row_word} em {cols} {col_word}"
class FilteringChatHandler(ChatHandler):
"""Filtra rich.type, traduz UI strings, e grava 1 row por turno."""
def __init__(self, agent: Agent, event_sink: Optional[EventSink] = None):
super().__init__(agent)
self._sink = event_sink
async def handle_stream(
self, request: ChatRequest
) -> AsyncGenerator[ChatStreamChunk, None]:
qp = request.request_context.query_params or {}
rec = TurnRecord(
conversation_id=request.conversation_id or "",
request_id=request.request_id or "",
question=request.message or "",
program_id=str(qp.get("program_id", "")),
store_id=str(qp.get("store_id", "")),
user_id=str(qp.get("user_id", "")),
)
token = set_turn(rec)
try:
async for chunk in super().handle_stream(request):
if not rec.conversation_id:
rec.conversation_id = chunk.conversation_id
if not rec.request_id:
rec.request_id = chunk.request_id
rich = chunk.rich or {}
rich_type = rich.get("type")
if rich_type not in ALLOWED_RICH_TYPES:
continue
data = rich.get("data")
if isinstance(data, dict):
for field_name in ("message", "detail", "placeholder", "title"):
if field_name in data:
data[field_name] = _translate(data[field_name])
if rich_type == "dataframe":
_rewrite_dataframe(data)
elif rich_type == "text":
content = data.get("content")
if isinstance(content, str) and content:
rec.response_parts.append(content)
yield chunk
except Exception as e:
rec.status = "error"
rec.error_message = str(e)
raise
finally:
reset_turn(token)
if self._sink is not None:
await self._sink.flush(rec)

60
csv_cleanup.py Normal file
View File

@ -0,0 +1,60 @@
"""Sweep periódico dos CSVs efêmeros gerados pelo RunSqlTool em data_storage/."""
from __future__ import annotations
import asyncio
import os
import time
from pathlib import Path
from typing import Optional
CSV_DIR = Path(__file__).parent / "data_storage"
TTL_SECONDS = int(os.environ.get("CSV_TTL_SECONDS", "1800"))
SWEEP_INTERVAL_SECONDS = int(os.environ.get("CSV_SWEEP_INTERVAL_SECONDS", "600"))
_task: Optional[asyncio.Task] = None
def sweep_once() -> int:
"""Apaga CSVs com mtime > TTL_SECONDS. Idempotente."""
if not CSV_DIR.exists():
return 0
cutoff = time.time() - TTL_SECONDS
removed = 0
for csv in CSV_DIR.rglob("query_results_*.csv"):
try:
if csv.stat().st_mtime < cutoff:
csv.unlink()
removed += 1
except FileNotFoundError:
pass
return removed
async def _periodic() -> None:
while True:
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
try:
n = sweep_once()
if n:
print(f"[csv_cleanup] periodic sweep removed {n} CSVs")
except Exception as e:
print(f"[csv_cleanup] sweep failed: {e!r}")
async def startup() -> None:
global _task
n = sweep_once()
if n:
print(f"[csv_cleanup] startup sweep removed {n} CSVs")
_task = asyncio.create_task(_periodic())
async def shutdown() -> None:
global _task
if _task is not None:
_task.cancel()
try:
await _task
except asyncio.CancelledError:
pass
_task = None

363
docs/deploy.md Normal file
View File

@ -0,0 +1,363 @@
# 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.

401
docs/embed-react.md Normal file
View File

@ -0,0 +1,401 @@
# Embedar `<vanna-chat>` (chat flutuante) num app React
Guia para o time frontend embedar o web component `<vanna-chat>` como botão flutuante (canto inferior direito) numa aplicação React. Cobre auth IDs, theming e gotchas comuns.
---
## 1. Pré-requisitos
| Item | Valor |
| --- | --- |
| Backend Vanna | `https://SEU-BACKEND-VANNA` (substituir pelas envs prod/staging) |
| Bundle JS | `https://SEU-BACKEND-VANNA/static/vanna-components.js` (~7.5 MB, ES module) |
| Tema CSS (opcional) | `https://SEU-BACKEND-VANNA/vanna-theme.css` |
| Endpoints chat | `/api/vanna/v2/chat_sse` (SSE), `/api/vanna/v2/chat_websocket` (WS), `/api/vanna/v2/chat_poll` (long poll) |
| IDs obrigatórios na URL | `program_id`, `store_id`, `user_id` (do usuário autenticado) |
**Sobre os IDs:** `program_id` + `store_id` controlam **RLS** (o backend filtra dados pela loja desse tenant — sem eles, o chat retorna erro). `user_id` é usado **só para auditoria** (log em `events.vanna_ai`); se ausente, a row é gravada com `user_id=''` e o chat funciona normal — mas perde rastreabilidade. Mande sempre os 3.
---
## 2. Setup em 3 etapas
### Etapa 1 — `index.html`: scripts que rodam ANTES do bundle
Cole **antes** do `<script type="module">` do bundle. Há 2 scripts, na ordem:
1. **Theme pierce** — necessário **só se você quer customizar cores/fontes** via `vanna-theme.css`. O bundle encapsula cada componente interno em Shadow DOM com tokens próprios; sem esse hack, um `<link rel="stylesheet">` no documento não atinge os filhos. O patch força o stylesheet a ser adoptado em todo `attachShadow` novo.
2. **i18n PT-BR** — traduz strings hardcoded do bundle (`Search...`, `X rows`, `Export`, etc.) e converte markdown literal nas mensagens (`**negrito**` → `<strong>`).
```html
<!-- index.html (Vite/CRA — public/index.html) -->
<head>
<!-- Open Sans (font usado pelo tema) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap"
/>
<!-- 1. Theme pierce (opcional — só se for customizar cores) -->
<script>
(() => {
const themeSheet = new CSSStyleSheet();
const desc = Object.getOwnPropertyDescriptor(
ShadowRoot.prototype, "adoptedStyleSheets"
);
if (desc && desc.set) {
Object.defineProperty(ShadowRoot.prototype, "adoptedStyleSheets", {
configurable: true, enumerable: true,
get() { return desc.get.call(this); },
set(value) {
const without = (value || []).filter((s) => s !== themeSheet);
desc.set.call(this, [...without, themeSheet]);
},
});
}
fetch("https://SEU-BACKEND-VANNA/vanna-theme.css")
.then((r) => r.text())
.then((css) => themeSheet.replaceSync(css))
.catch((err) => console.error("[vanna-theme] load failed", err));
})();
</script>
<!-- 2. i18n PT-BR + markdown parser — copiar literal do segundo <script>
em static/embed-demo.html (o IIFE que termina em "[vanna-i18n] frontend
translator armed"). Faz MutationObserver + tradução exact-match +
parser markdown (bold/italic/code/links/listas/headers h1-h6).
Não modifique sem entender o porquê — o bundle não tem hooks de tradução. -->
<script>
/* ... cole o IIFE de tradução de embed-demo.html aqui ... */
</script>
</head>
```
### Etapa 2 — Carregar o bundle
```html
<!-- Final do <body>, ou via React effect (ver etapa 3) -->
<script type="module" src="https://SEU-BACKEND-VANNA/static/vanna-components.js"></script>
```
### Etapa 3 — Componente React `<VannaChat />`
```tsx
// src/components/VannaChat.tsx
import { useEffect, useRef } from "react";
const BACKEND_URL = "https://SEU-BACKEND-VANNA";
interface VannaChatProps {
programId: string;
storeId: string;
userId: string;
/** Override do título do header. Default: "ClubPetro IA" */
title?: string;
}
export function VannaChat({ programId, storeId, userId, title = "ClubPetro IA" }: VannaChatProps) {
const ref = useRef<HTMLElement>(null);
// Constrói query string com os 3 IDs
const qs = new URLSearchParams({
program_id: programId,
store_id: storeId,
user_id: userId,
}).toString();
// Custom element exige property assignment pra boolean attrs.
// showProgress=true por default — desligamos pra esconder a sidebar de progresso.
useEffect(() => {
customElements.whenDefined("vanna-chat").then(() => {
if (ref.current) {
(ref.current as any).showProgress = false;
}
});
}, []);
return (
// @ts-expect-error — custom element não tem types nativos do React
<vanna-chat
ref={ref}
api-base={BACKEND_URL}
sse-endpoint={`/api/vanna/v2/chat_sse?${qs}`}
ws-endpoint={`/api/vanna/v2/chat_websocket?${qs}`}
poll-endpoint={`/api/vanna/v2/chat_poll?${qs}`}
starting-state="minimized"
theme="light"
title={title}
/>
);
}
```
**Tipagem** (apaga o `@ts-expect-error`) — adicione em `src/types/global.d.ts`:
```ts
declare namespace JSX {
interface IntrinsicElements {
"vanna-chat": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
"api-base"?: string;
"sse-endpoint"?: string;
"ws-endpoint"?: string;
"poll-endpoint"?: string;
"starting-state"?: "minimized" | "expanded";
theme?: "light" | "dark";
title?: string;
},
HTMLElement
>;
}
}
```
---
## 3. Uso com auth context
Renderize **uma única vez** no layout raiz, depois do login. O componente já é flutuante (`starting-state="minimized"` mostra só o botão no canto inferior direito).
```tsx
// src/App.tsx
import { useAuth } from "./auth";
import { VannaChat } from "./components/VannaChat";
export default function App() {
const { user, currentStore } = useAuth();
return (
<>
<Routes>{/* ... suas rotas ... */}</Routes>
{user && currentStore && (
<VannaChat
programId={currentStore.programId}
storeId={currentStore.id}
userId={user.id}
/>
)}
</>
);
}
```
**Importante:** se o usuário trocar de loja (sem re-login), force remount adicionando `key`:
```tsx
<VannaChat
key={`${currentStore.id}-${user.id}`}
programId={currentStore.programId}
storeId={currentStore.id}
userId={user.id}
/>
```
Sem `key`, o React reusa o mesmo elemento DOM com URLs antigas — a SSE em curso continua apontando pro tenant anterior até o usuário fechar o chat.
---
## 4. Botão flutuante custom (CTA "Conversar com meus dados")
Por padrão, `<vanna-chat starting-state="minimized">` mostra um FAB redondo no canto. Substituímos por um CTA pill horizontal (logo + pill com gradient laranja→roxo).
### CSS (cole no CSS global ou módulo)
```css
.vanna-cta {
position: fixed;
right: 24px;
top: 100px; /* ou bottom: 24px se preferir o canto inferior direito */
z-index: 2147483000;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px;
background: #fff;
border: 1px solid transparent;
border-radius: 8px;
background-image: linear-gradient(#fff, #fff),
linear-gradient(90deg, #f2672a 5%, #680367 100%);
background-origin: border-box;
background-clip: padding-box, border-box;
box-shadow:
3px 5px 15px rgba(235, 98, 45, 0.19),
13px 24px 27px rgba(235, 98, 45, 0.16),
28px 53px 36px rgba(235, 98, 45, 0.10),
50px 95px 43px rgba(235, 98, 45, 0.03);
cursor: pointer;
font-family: "Open Sans", system-ui, sans-serif;
transition: transform 0.15s ease;
}
.vanna-cta:hover { transform: translateY(-1px); }
.vanna-cta__logo {
flex: none; width: 42px; height: 42px;
display: grid; place-items: center;
}
.vanna-cta__logo img,
.vanna-cta__logo svg { width: 100%; height: 100%; display: block; }
.vanna-cta__pill {
flex: 1; padding: 10px 18px; border-radius: 5px;
background: linear-gradient(90deg, #f2672a 5%, #680367 100%);
color: #fff; font-size: 14px; font-weight: 500; line-height: 1.15;
white-space: nowrap;
}
```
### Esconder o FAB nativo do `<vanna-chat>`
Adicionar ao `vanna-theme.css` (servido pelo backend, ou seu próprio override):
```css
:host(.minimized) {
background: transparent !important;
box-shadow: none !important;
width: 0 !important; height: 0 !important;
pointer-events: none !important;
}
:host(.minimized) .minimized-icon { display: none !important; }
/* CTA abre direto maximized — esconder restore e maximize do header
pra não expor o estado intermediário "normal". Sobra só .minimize. */
.window-control-btn.restore,
.window-control-btn.maximize { display: none !important; }
```
Sem isso, o FAB nativo aparece sobreposto ao CTA, e o usuário consegue cair no estado "normal" (janela pequena flutuante). Requer o **theme pierce** da etapa 1 — sem ele, essas regras não atingem o shadow DOM.
### Componente React `<VannaCTA />`
```tsx
// src/components/VannaCTA.tsx
import { useEffect, useState } from "react";
interface VannaCTAProps {
/** Ref opcional para o <vanna-chat>. Se omitido, busca por id="vanna-chat-instance". */
chatId?: string;
label?: string;
}
export function VannaCTA({ chatId = "vanna-chat-instance", label = "Conversar com meus dados" }: VannaCTAProps) {
const [visible, setVisible] = useState(true);
useEffect(() => {
let chat: any = null;
customElements.whenDefined("vanna-chat").then(() => {
chat = document.getElementById(chatId);
if (!chat) return;
const sync = (state: string) => setVisible(state === "minimized");
sync(chat.windowState);
const handler = (e: any) => sync(e.detail?.state);
chat.addEventListener("window-state-changed", handler);
return () => chat.removeEventListener("window-state-changed", handler);
});
}, [chatId]);
if (!visible) return null;
return (
<button
type="button"
className="vanna-cta"
aria-label={label}
onClick={() => {
const chat = document.getElementById(chatId) as any;
if (!chat) return;
// Esconder o CTA explicitamente — o setter de windowState não
// dispara window-state-changed (só os métodos privados do componente
// disparam), então o useEffect só sincroniza quando o user minimiza
// pelo header do chat.
setVisible(false);
chat.windowState = "maximized";
}}
>
<span className="vanna-cta__logo" aria-hidden="true">
{/* Hospede o SVG no seu CDN/public e troque o src.
No backend Vanna, fica em /clubpetro-logo.svg. */}
<img src="https://SEU-BACKEND-VANNA/clubpetro-logo.svg" alt="" />
</span>
<span className="vanna-cta__pill">{label}</span>
</button>
);
}
```
Renderize **junto** com o `<VannaChat />`:
```tsx
{user && currentStore && (
<>
<VannaChat
key={`${currentStore.id}-${user.id}`}
programId={currentStore.programId}
storeId={currentStore.id}
userId={user.id}
/>
<VannaCTA />
</>
)}
```
---
## 5. Theming (opcional)
O tema é um `vanna-theme.css` com ~50 CSS custom properties. Pra mudar cores/fontes:
1. Garanta que o **theme pierce script** (etapa 1) está rodando antes do bundle.
2. Servir seu próprio `vanna-theme.css` (pode ser o do backend Vanna, ou um override próprio).
3. Atualizar a URL do `fetch` no theme pierce.
Tokens disponíveis: ver `vanna/frontends/webcomponent/src/styles/vanna-design-tokens.ts` no repo do backend. Exemplos:
```css
:host, vanna-chat {
--vanna-primary: #023d60;
--vanna-accent: #15a8a8;
--vanna-radius-md: 8px;
--vanna-font-family: "Open Sans", system-ui, sans-serif;
}
```
**Sem o pierce** o `:host` não atinge filhos (Shadow DOM encapsulado) — vai parecer que o tema "não pegou".
---
## 6. Gotchas (cole na cabeça)
| Sintoma | Causa provável | Fix |
| --- | --- | --- |
| Chat aparece mas sem cores customizadas | Theme pierce não rodou antes do bundle | Verifique ordem dos scripts no `<head>`; o pierce DEVE preceder o `<script type="module">` |
| `showProgress=false` ignorado, sidebar de progresso aparece | Lit boolean attrs não desligam via HTML | Use a property em `useEffect` + `customElements.whenDefined` (ver componente acima) |
| Mensagens com `**foo**` literal em vez de **foo** em negrito | i18n IIFE não rodou (markdown parser dele que faz a conversão) | Garantir que o segundo script da etapa 1 está presente |
| `Search...`, `Export`, `X rows` em inglês | Mesmo motivo: i18n IIFE ausente | Idem |
| Chat retorna erro / não responde | `program_id` ou `store_id` ausentes/inválidos na URL — backend rejeita com `PermissionError` | Inspecione a URL do `sse-endpoint` no DOM (DevTools → Elements); confirme que os 3 IDs aparecem |
| Tudo funciona mas `events.vanna_ai.user_id` vem vazio | Apenas o `user_id` ausente (não bloqueante) | Adicionar `&user_id=...` na query string |
| Bundle não carrega — CORS error | Backend Vanna sem CORS pro domínio do frontend | Setar `VANNA_CORS_ORIGINS` no `.env` do backend pra incluir seu domínio |
| Bundle 7.5 MB → carregamento lento na home | É o tamanho do bundle Lit + Plotly | Usar `<link rel="preload" as="script" href="...vanna-components.js" />` no `<head>` ou lazy-load só quando o usuário faz login |
---
## 7. Checklist final pra produção
- [ ] Backend `VANNA_CORS_ORIGINS` inclui o domínio do frontend
- [ ] Bundle servido via HTTPS (mixed content bloqueia em prod)
- [ ] Auth flow garante `program_id`, `store_id`, `user_id` válidos antes de montar o componente
- [ ] `key` no `<VannaChat>` muda quando user/loja troca
- [ ] Open Sans pré-carregado via `<link rel="preconnect">`
- [ ] (Opcional) `vanna-theme.css` servido com cache-busting (`?v=hash`)
- [ ] Testou minimizado e expandido em mobile (o botão é fixo bottom-right)
- [ ] Verificou no console: `[vanna-theme] CSS loaded: NNNN bytes` e `[vanna-i18n] frontend translator armed`
---
## Referência
- Demo de dev (com tudo wireado): `static/embed-demo.html` no repo do backend.
- Web component upstream: `vanna/frontends/webcomponent/src/components/vanna-chat.ts`.
- Endpoints: `vanna/src/vanna/servers/fastapi/routes.py`.

154
events_sink.py Normal file
View File

@ -0,0 +1,154 @@
"""Sink que grava 1 row por turno de chat em `events.vanna_ai` (ClickHouse).
Fluxo:
- `chat_filter.py` cria um `TurnRecord` no início de `handle_stream` e seta o
ContextVar `_current_turn`.
- Hooks em `EventCapturingToolRegistry.transform_args` (tools/SQL) e
`VisualizeDataToolPT.execute` (charts) appendam no TurnRecord ativo via
`record_tool` / `record_sql` / `record_chart`.
- `chat_filter.py` acumula response chunks (rich.type=='text') e chama
`EventSink.flush(rec)` no fim do stream.
ContextVar funciona porque o agent loop do Vanna roda inteiro num único
asyncio task todos os hooks downstream herdam o context do `set_turn`.
Falhas no insert NUNCA quebram a resposta ao usuário (try/except + log).
"""
from __future__ import annotations
import asyncio
import contextvars
import logging
import os
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List, Optional, Tuple
import clickhouse_connect
logger = logging.getLogger(__name__)
@dataclass
class TurnRecord:
started_at: float = field(default_factory=time.time)
program_id: str = ""
store_id: str = ""
user_id: str = ""
conversation_id: str = ""
request_id: str = ""
question: str = ""
response_parts: List[str] = field(default_factory=list)
tools_called: List[str] = field(default_factory=list)
sqls_executed: List[str] = field(default_factory=list)
charts_emitted: List[Tuple[str, str]] = field(default_factory=list)
status: str = "ok"
error_message: str = ""
_current_turn: contextvars.ContextVar[Optional[TurnRecord]] = contextvars.ContextVar(
"vanna_current_turn", default=None
)
def get_turn() -> Optional[TurnRecord]:
return _current_turn.get()
def set_turn(rec: TurnRecord) -> contextvars.Token:
return _current_turn.set(rec)
def reset_turn(token: contextvars.Token) -> None:
_current_turn.reset(token)
def record_tool(name: str) -> None:
rec = _current_turn.get()
if rec is not None:
rec.tools_called.append(name)
def record_sql(sql: str) -> None:
rec = _current_turn.get()
if rec is not None:
rec.sqls_executed.append(sql)
def record_chart(chart_type: str, title: str) -> None:
rec = _current_turn.get()
if rec is not None:
rec.charts_emitted.append((chart_type, title))
class EventSink:
"""Insere TurnRecord em `events.vanna_ai`. Cliente CH lazy."""
def __init__(self, database: str = "events", table: str = "vanna_ai"):
self._database = database
self._table = table
self._client = None
def _get_client(self):
if self._client is None:
self._client = clickhouse_connect.get_client(
host=os.environ["CLICKHOUSE_HOST"],
port=int(os.environ["CLICKHOUSE_PORT"]),
username=os.environ["CLICKHOUSE_USER"],
password=os.environ["CLICKHOUSE_PASSWORD"],
secure=os.environ.get("CLICKHOUSE_SECURE", "true").lower() == "true",
)
return self._client
async def flush(self, rec: TurnRecord) -> None:
try:
await asyncio.to_thread(self._insert, rec)
except Exception as e:
logger.warning("event sink flush failed: %s", e)
def _insert(self, rec: TurnRecord) -> None:
ended_at = time.time()
duration_ms = int((ended_at - rec.started_at) * 1000)
response = "".join(rec.response_parts)
client = self._get_client()
client.insert(
table=self._table,
database=self._database,
data=[
[
datetime.fromtimestamp(rec.started_at, tz=timezone.utc),
datetime.fromtimestamp(ended_at, tz=timezone.utc),
duration_ms,
rec.conversation_id,
rec.request_id,
rec.program_id,
rec.store_id,
rec.user_id,
rec.question,
response,
rec.tools_called,
rec.sqls_executed,
rec.charts_emitted,
rec.status,
rec.error_message,
]
],
column_names=[
"started_at",
"ended_at",
"duration_ms",
"conversation_id",
"request_id",
"program_id",
"store_id",
"user_id",
"question",
"response",
"tools_called",
"sqls_executed",
"charts_emitted",
"status",
"error_message",
],
)

155
rls_runner.py Normal file
View File

@ -0,0 +1,155 @@
"""ClickHouseRunner com RLS via additional_table_filters."""
from __future__ import annotations
import re
from datetime import date, datetime
from decimal import Decimal
import pandas as pd
from vanna.capabilities.sql_runner import RunSqlToolArgs
from vanna.core.tool import ToolContext
from vanna.integrations.clickhouse import ClickHouseRunner
# Casas decimais exibidas no dataframe rich component da UI. ClickHouse retorna
# Decimal(N, 6) por padrão e o web component renderiza via toLocaleString() sem
# rounding, então valores tipo `307427.030000` aparecem na tabela. Arredondar
# aqui é mais simples que forkar o renderer upstream (rebuild npm + perde no
# git pull) e cobre todas as queries sem depender de `round(..., 2)` no SELECT.
_DISPLAY_DECIMALS = 2
# Formato pt-BR pras datas exibidas no dataframe. ClickHouse devolve Date /
# DateTime, pandas mantém como datetime64 ou objects de date/datetime, e o
# upstream `from_records` não infere `column_types` (fica vazio), então o
# renderer JS cai no default `String(value)` e mostra ISO `2026-01-01`.
# Formatamos pra string aqui — dataframe + CSV ficam consistentes.
# `_coerce_datetime_columns` em viz_tool.py reconhece o formato BR pra
# que charts de série temporal continuem funcionando.
_DATE_FMT = "%d/%m/%Y"
_DATETIME_FMT = "%d/%m/%Y %H:%M"
RLS_TABLES = ("gold.sales",)
DENIED_TABLES = ("gold.vw_relatorios_exportaveis_analitico_vendas",)
# Bloqueio app-side de introspecção de schema. ClickHouse Cloud não permite
# REVOKE efetivo em system.* (acesso herdado de role default), então
# rejeitamos a query antes dela chegar no banco. Caso contrário o LLM lê
# system.tables / SHOW TABLES e descobre colunas revogadas no DDL, depois
# propõe queries que falham e dão UX ruim ao usuário.
_FORBIDDEN_SCHEMA_RE = re.compile(
r"\b(?:system|information_schema)\s*\.\s*[a-zA-Z_][a-zA-Z0-9_]*",
re.IGNORECASE,
)
_INTROSPECTION_STMT_RE = re.compile(
r"\b(?:SHOW|DESCRIBE|EXPLAIN)\s+",
re.IGNORECASE,
)
_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _require_id(name: str, value: object) -> str:
if value is None or not isinstance(value, str) or not _ID_RE.fullmatch(value):
raise PermissionError(
f"RLS context missing/invalid: {name} must match {_ID_RE.pattern}"
)
return value
def _quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _format_table_filter_map(entries):
# additional_table_filters expects a ClickHouse Map(String, String) literal.
# clickhouse_connect serializes Python dicts as JSON (double quotes) which
# the server rejects, so we build the literal text ourselves.
items = ", ".join(f"{_quote(table)}: {_quote(expr)}" for table, expr in entries)
return "{" + items + "}"
def _round_value(v, ndigits: int):
if v is None:
return v
if isinstance(v, Decimal):
return float(round(v, ndigits))
if isinstance(v, float):
return round(v, ndigits)
return v
def _round_decimal_columns(df: pd.DataFrame, ndigits: int = _DISPLAY_DECIMALS) -> pd.DataFrame:
for col in df.columns:
s = df[col]
if pd.api.types.is_float_dtype(s):
df[col] = s.round(ndigits)
elif s.dtype == object:
sample = s.dropna().head(5)
if len(sample) and all(isinstance(v, (Decimal, float)) for v in sample):
df[col] = s.map(lambda v: _round_value(v, ndigits))
return df
def _format_date_columns(df: pd.DataFrame) -> pd.DataFrame:
for col in df.columns:
s = df[col]
if pd.api.types.is_datetime64_any_dtype(s):
# datetime64 inclui timestamps com hora; usar HH:MM se houver
# algum valor com componente temporal não-zero, senão só data.
has_time = bool(
((s.dt.hour.fillna(0) != 0) | (s.dt.minute.fillna(0) != 0)).any()
)
df[col] = s.dt.strftime(_DATETIME_FMT if has_time else _DATE_FMT)
elif s.dtype == object:
sample = s.dropna().head(5)
if len(sample) and all(isinstance(v, (date, datetime)) for v in sample):
has_time = any(
isinstance(v, datetime) and (v.hour or v.minute or v.second)
for v in sample
)
fmt = _DATETIME_FMT if has_time else _DATE_FMT
df[col] = s.map(lambda v: v.strftime(fmt) if v is not None else v)
return df
class RLSClickHouseRunner(ClickHouseRunner):
"""ClickHouseRunner que injeta additional_table_filters em toda query."""
async def run_sql(
self, args: RunSqlToolArgs, context: ToolContext
) -> pd.DataFrame:
if _FORBIDDEN_SCHEMA_RE.search(args.sql) or _INTROSPECTION_STMT_RE.search(args.sql):
raise PermissionError(
"Query rejected: schema introspection (SHOW / DESCRIBE / "
"EXPLAIN) and access to system.* / information_schema.* are "
"not allowed. The only table available is `gold.sales` — "
"use the columns listed in your context to query it directly."
)
program_id = _require_id("program_id", getattr(context.user, "program_id", None))
store_id = _require_id("store_id", getattr(context.user, "store_id", None))
filter_expr = f"program_id = '{program_id}' AND store_id = '{store_id}'"
entries = [(table, filter_expr) for table in RLS_TABLES]
entries += [(table, "0") for table in DENIED_TABLES]
additional = _format_table_filter_map(entries)
client = self.clickhouse_connect.get_client(
host=self.host,
port=self.port,
username=self.user,
password=self.password,
database=self.database,
**self.kwargs,
)
try:
result = client.query(
args.sql,
settings={"additional_table_filters": additional},
)
df = pd.DataFrame(result.result_rows, columns=result.column_names)
df = _round_decimal_columns(df)
return _format_date_columns(df)
finally:
client.close()

95
server.py Normal file
View File

@ -0,0 +1,95 @@
"""FastAPI server: oficial VannaFastAPIServer + estáticos do web component."""
from __future__ import annotations
import os
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from vanna.servers.fastapi import VannaFastAPIServer
import csv_cleanup
from agent import RequestContextUserResolver, build_agent
from chat_filter import FilteringChatHandler
from events_sink import EventSink
def _build_app():
agent = build_agent(user_resolver=RequestContextUserResolver())
cors_origins = [
o.strip()
for o in os.environ.get("VANNA_CORS_ORIGINS", "*").split(",")
if o.strip()
] or ["*"]
server = VannaFastAPIServer(
agent,
config={
"cors": {
"allow_origins": cors_origins,
"allow_methods": ["*"],
"allow_headers": ["*"],
"allow_credentials": True,
},
"api_base_url": "",
},
)
server.chat_handler = FilteringChatHandler(agent, event_sink=EventSink())
fastapi_app = server.create_app()
here = os.path.dirname(os.path.abspath(__file__))
dist = os.path.join(here, "vanna", "frontends", "webcomponent", "dist")
if os.path.isdir(dist):
fastapi_app.mount("/static", StaticFiles(directory=dist), name="static")
@fastapi_app.get("/vanna-theme.css")
async def vanna_theme():
path = os.path.join(here, "static", "vanna-theme.css")
return FileResponse(path, media_type="text/css")
@fastapi_app.get("/vanna-embed-bootstrap.js")
async def vanna_embed_bootstrap():
path = os.path.join(here, "static", "vanna-embed-bootstrap.js")
return FileResponse(path, media_type="application/javascript")
@fastapi_app.get("/clubpetro-logo.png")
async def clubpetro_logo():
path = os.path.join(here, "static", "clubpetro-logo.png")
return FileResponse(path, media_type="image/png")
@fastapi_app.get("/clubpetro-logo.svg")
async def clubpetro_logo_svg():
path = os.path.join(here, "static", "clubpetro-logo.svg")
return FileResponse(path, media_type="image/svg+xml")
@fastapi_app.get("/dashboard-bg.png")
async def dashboard_bg():
path = os.path.join(here, "static", "dashboard-bg.png")
return FileResponse(path, media_type="image/png")
@fastapi_app.get("/embed-demo.html", response_class=HTMLResponse)
async def embed_demo():
path = os.path.join(here, "static", "embed-demo.html")
with open(path, "r", encoding="utf-8") as f:
html = f.read()
return (
html
.replace("__PROGRAM_ID__", os.environ.get("RLS_PROGRAM_ID", ""))
.replace("__STORE_ID__", os.environ.get("RLS_STORE_ID", ""))
.replace("__USER_ID__", os.environ.get("RLS_USER_ID", ""))
)
return fastapi_app
app = _build_app()
@app.on_event("startup")
async def _csv_cleanup_startup() -> None:
await csv_cleanup.startup()
@app.on_event("shutdown")
async def _csv_cleanup_shutdown() -> None:
await csv_cleanup.shutdown()

BIN
static/clubpetro-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

10
static/clubpetro-logo.svg Normal file
View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" aria-hidden="true">
<defs>
<linearGradient id="cpRing" x1="0%" y1="50%" x2="100%" y2="50%">
<stop offset="0%" stop-color="#F46A1F"/>
<stop offset="55%" stop-color="#EE5A23"/>
<stop offset="100%" stop-color="#5E1F6E"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="34" fill="none" stroke="url(#cpRing)" stroke-width="22"/>
</svg>

After

Width:  |  Height:  |  Size: 430 B

BIN
static/dashboard-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

186
static/embed-demo.html Normal file
View File

@ -0,0 +1,186 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<title>Vanna chat — demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap"
/>
<link rel="stylesheet" href="/vanna-theme.css" />
<!-- Bootstrap único do <vanna-chat>: theme pierce + tradutor PT + markdown
+ load do bundle. Mesmo arquivo é consumido pela app React via
vannaChatLoader.ts, garantindo fonte única (sem duplicação). -->
<script src="/vanna-embed-bootstrap.js"></script>
<style>
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: #111827;
background: url("/dashboard-bg.png") top left / cover no-repeat fixed,
#f8efe2;
}
.wrap {
display: none;
}
/* Floating CTA — substitui o FAB nativo do <vanna-chat>.
Posicionado fixed bottom-right; clique abre o chat e esconde
o próprio CTA. Listener no evento window-state-changed do
componente alterna a visibilidade. */
.vanna-cta {
position: fixed;
right: 24px;
top: 100px;
z-index: 2147483000;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px;
background: #ffffff;
border: 1px solid transparent;
border-radius: 8px;
background-image: linear-gradient(#fff, #fff),
linear-gradient(90deg, #f2672a 5%, #680367 100%);
background-origin: border-box;
background-clip: padding-box, border-box;
box-shadow:
3px 5px 15px rgba(235, 98, 45, 0.19),
13px 24px 27px rgba(235, 98, 45, 0.16),
28px 53px 36px rgba(235, 98, 45, 0.10),
50px 95px 43px rgba(235, 98, 45, 0.03);
cursor: pointer;
font-family: "Open Sans", system-ui, sans-serif;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.vanna-cta[hidden] {
display: none;
}
.vanna-cta:hover {
transform: translateY(-1px);
}
.vanna-cta:active {
transform: translateY(0);
}
.vanna-cta__logo {
flex: none;
width: 42px;
height: 42px;
display: grid;
place-items: center;
}
.vanna-cta__logo img,
.vanna-cta__logo svg {
width: 100%;
height: 100%;
display: block;
}
.vanna-cta__pill {
flex: 1;
padding: 10px 18px;
border-radius: 5px;
background: linear-gradient(90deg, #f2672a 5%, #680367 100%);
color: #fff;
font-size: 14px;
font-weight: 500;
line-height: 1.15;
white-space: nowrap;
}
/* O CTA custom é o único ponto de entrada. Esconde o componente
enquanto não estiver maximizado: oculta o FAB nativo (estado
minimized, círculo 64x64) E também previne o flash de centralizado
se o JS de minimização atrasar (estado normal antes do windowState
setter rodar). Quando o CTA dispara windowState='maximized', a regra
deixa de casar e o chat aparece. */
vanna-chat:not(.maximized) {
display: none !important;
}
</style>
</head>
<body>
<div class="wrap">
<h1>Vanna chat — demo</h1>
<p>
Esta página embeda o web component oficial
<code>&lt;vanna-chat&gt;</code> com
<code>starting-state="minimized"</code> — o botão flutuante aparece no
canto inferior direito. Os IDs vêm do <code>.env</code> via
substituição no handler.
</p>
<p>
Em produção, o app cliente cola um snippet semelhante na página dele,
substituindo <code>__PROGRAM_ID__</code> / <code>__STORE_ID__</code>
pelos IDs do tenant logado.
</p>
</div>
<button
type="button"
id="vanna-cta"
class="vanna-cta"
aria-label="Conversar com meus dados"
>
<span class="vanna-cta__logo" aria-hidden="true">
<img src="/clubpetro-logo.svg" alt="" />
</span>
<span class="vanna-cta__pill">Conversar com meus dados</span>
</button>
<script>
// Dispara o bootstrap (theme pierce + tradutor + markdown + load do
// bundle). baseUrl vazio = mesma origem (server FastAPI local).
window.VannaEmbed.ensureLoaded({ baseUrl: "" });
</script>
<vanna-chat
id="vanna-chat-instance"
api-base=""
sse-endpoint="/api/vanna/v2/chat_sse?program_id=__PROGRAM_ID__&store_id=__STORE_ID__&user_id=__USER_ID__"
ws-endpoint="/api/vanna/v2/chat_websocket?program_id=__PROGRAM_ID__&store_id=__STORE_ID__&user_id=__USER_ID__"
poll-endpoint="/api/vanna/v2/chat_poll?program_id=__PROGRAM_ID__&store_id=__STORE_ID__&user_id=__USER_ID__"
startingstate="minimized"
theme="light"
title="ClubPetro IA">
</vanna-chat>
<script>
// showProgress default=true e Lit boolean attrs não desligam via HTML.
// Setar a propriedade JS direto após o registro do custom element.
customElements.whenDefined("vanna-chat").then(() => {
const chat = document.getElementById("vanna-chat-instance");
const cta = document.getElementById("vanna-cta");
if (!chat) return;
chat.showProgress = false;
// Reforço: garantir minimized mesmo se o atributo não pegar.
if (chat.windowState !== "minimized") chat.windowState = "minimized";
// Sync inicial — começamos minimized via attribute, então CTA visível.
// Ao clicar no CTA, expandimos o chat (windowState='normal') e
// escondemos o CTA. Quando o user minimiza pelo header do chat,
// o componente emite window-state-changed; aí mostramos o CTA de novo.
const sync = (state) => {
if (!cta) return;
cta.hidden = state !== "minimized";
};
sync(chat.windowState);
if (cta) {
cta.addEventListener("click", () => {
// Esconder explicitamente — o setter de windowState NÃO dispara
// window-state-changed (só os métodos privados disparam), então
// o listener abaixo só pega a transição quando o user minimiza
// pelo header. Pra abrir, escondemos manualmente aqui.
cta.hidden = true;
chat.windowState = "maximized";
});
}
chat.addEventListener("window-state-changed", (e) => {
sync(e.detail?.state);
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,317 @@
/**
* Vanna chat embed bootstrap fonte única do JS de wiring exigido pelo
* <vanna-chat>: theme pierce (adoptedStyleSheets), tradutor PT-BR
* (MutationObserver), markdown processor pros balões, fonts, e load do
* bundle.
*
* Servido pelo Vanna server em /vanna-embed-bootstrap.js. Consumido por:
* - static/embed-demo.html (smoke-test page, mesmo origin)
* - clubpetro-frontend/.../vannaChatLoader.ts (React app, cross-origin)
*
* API pública:
* window.VannaEmbed.ensureLoaded({ baseUrl, extraCss? }) -> Promise<void>
* - baseUrl: "" pra mesma origem (demo); URL completa do Vanna server
* quando embedado em outra origem (React app).
* - extraCss: CSS opcional appended ao theme sheet (ex.: override do
* avatar logo quando o SVG mora na origem da app, não na do Vanna).
* - Idempotente: chamadas subsequentes retornam a mesma Promise.
*/
(function () {
if (window.VannaEmbed) return;
let loaderPromise = null;
const escapeHtml = (s) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const mdToHtml = (md) => {
if (!md) return "";
let s = escapeHtml(md);
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
s = s.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>");
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
s = s.replace(/(^|[^\w])_([^_\n]+)_(?!\w)/g, "$1<em>$2</em>");
s = s.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>'
);
// Headers ANTES das listas. A regex de lista consome o \n final do
// último item; rodar headers depois deixaria `### Conclusão` logo
// após um bloco de lista precedido só de `</ul>` (sem \n) e a regex
// de header (que exige ^ ou \n antes do #) falharia.
s = s.replace(
/(^|\n)(#{1,6})\s+([^\n]+)/g,
(_, lead, hashes, text) =>
`${lead}<h${hashes.length}>${text.trim()}</h${hashes.length}>`
);
s = s.replace(/(?:^|\n)((?:[-*]\s+.+(?:\n|$))+)/g, (_, block) => {
const items = block
.trim()
.split(/\n/)
.map((l) => l.replace(/^[-*]\s+/, "").trim())
.map((t) => `<li>${t}</li>`)
.join("");
return `\n<ul>${items}</ul>\n`;
});
s = s.replace(/\n/g, "<br>");
s = s.replace(/<br>\s*(<\/?(?:ul|li|h[1-6])>)/g, "$1");
s = s.replace(/(<\/?(?:ul|li|h[1-6])>)\s*<br>/g, "$1");
return s;
};
const EXACT = {
"Search...": "Buscar...",
"Export": "Baixar",
"📥 Export": "📥 Baixar",
"Export to CSV": "Baixar para CSV",
};
const REGEX = [
[/^(\d+)\s+rows?$/i, (_, n) => `${n} ${n === "1" ? "linha" : "linhas"}`],
[/^(\d+)\s+columns?$/i, (_, n) => `${n} ${n === "1" ? "coluna" : "colunas"}`],
[
/^Showing\s+(\d+)\s+of\s+(\d+)\s+rows?$/i,
(_, a, b) => `Mostrando ${a} de ${b} ${b === "1" ? "linha" : "linhas"}`,
],
];
const translate = (s) => {
if (typeof s !== "string") return s;
const t = s.trim();
if (!t) return s;
if (EXACT[t]) return s.replace(t, EXACT[t]);
for (const [re, fn] of REGEX) {
const m = t.match(re);
if (m) return s.replace(t, fn(...m));
}
return s;
};
const setupThemePierce = (baseUrl, extraCss) => {
if (window.__vannaThemeSheet) return;
const themeSheet = new CSSStyleSheet();
window.__vannaThemeSheet = themeSheet;
const desc = Object.getOwnPropertyDescriptor(
ShadowRoot.prototype,
"adoptedStyleSheets"
);
if (desc && desc.set) {
Object.defineProperty(ShadowRoot.prototype, "adoptedStyleSheets", {
configurable: true,
enumerable: true,
get() {
return desc.get.call(this);
},
set(value) {
const without = (value || []).filter((s) => s !== themeSheet);
desc.set.call(this, [...without, themeSheet]);
},
});
}
const merge = (css) => (extraCss ? css + "\n" + extraCss : css);
fetch(baseUrl + "/vanna-theme.css?t=" + Date.now())
.then((r) => r.text())
.then((css) => themeSheet.replaceSync(merge(css)))
.catch(() => {
if (extraCss) themeSheet.replaceSync(extraCss);
});
};
const setupTranslator = () => {
if (window.__vannaTranslatorArmed) return;
window.__vannaTranslatorArmed = true;
const isInsideMessageContent = (n) =>
!!n &&
(n.classList?.contains("message-content") ||
n.closest?.(".message-content"));
const processMessageContent = (el) => {
const text = el.textContent || "";
if (el.dataset.bare === text) return;
el.innerHTML = mdToHtml(text);
el.dataset.bare = el.textContent || "";
};
const walkAndTranslate = (root) => {
const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node;
while ((node = tw.nextNode())) {
const next = translate(node.nodeValue);
if (next !== node.nodeValue) node.nodeValue = next;
}
root.querySelectorAll?.("[placeholder], [title]")?.forEach((el) => {
for (const attr of ["placeholder", "title"]) {
const v = el.getAttribute(attr);
if (v == null) continue;
const next = translate(v);
if (next !== v) el.setAttribute(attr, next);
}
});
root
.querySelectorAll?.(".message-content")
?.forEach(processMessageContent);
};
const observers = new WeakSet();
const attachObserver = (root) => {
if (observers.has(root)) return;
observers.add(root);
walkAndTranslate(root);
new MutationObserver((muts) => {
for (const m of muts) {
const target = m.target;
const inMd = isInsideMessageContent(target);
if (m.type === "characterData") {
if (inMd) {
const host = target.parentElement?.closest?.(".message-content");
if (host) processMessageContent(host);
} else {
const next = translate(target.nodeValue);
if (next !== target.nodeValue) target.nodeValue = next;
}
} else if (m.type === "attributes") {
const v = target.getAttribute(m.attributeName);
const next = translate(v);
if (next !== v) target.setAttribute(m.attributeName, next);
} else {
m.addedNodes.forEach((n) => {
if (n.nodeType === 3) {
if (inMd) {
const host = target.closest?.(".message-content");
if (host) processMessageContent(host);
} else {
const next = translate(n.nodeValue);
if (next !== n.nodeValue) n.nodeValue = next;
}
} else if (n.nodeType === 1) {
walkAndTranslate(n);
if (n.shadowRoot) attachObserver(n.shadowRoot);
}
});
}
}
}).observe(root, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["placeholder", "title"],
});
};
const origAttach = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (init) {
const root = origAttach.call(this, init);
queueMicrotask(() => attachObserver(root));
return root;
};
};
// Wrap window.ResizeObserver pra capturar errors NA ORIGEM. Plotly tem
// um RO interno (vanna-components.js:113878) que dispara `_t [as relayout]`
// (kt at 22888) num node desmontado durante minimize/maximize do chat,
// e o crash propaga como `TypeError: Cannot read properties of undefined
// (reading 'width' / 'height')`. Listeners em window.error registrados
// depois do webpack-dev-server (caso CRA) não conseguem barrar o overlay
// porque o handler do dev server roda antes em capture phase. Try/catch
// dentro da própria callback garante que o error nunca escapa pra window.
// Só engole o erro específico (filename + message bate); qualquer outro
// erro continua propagando normal.
const wrapResizeObserver = () => {
if (window.__vannaResizeObserverWrapped) return;
window.__vannaResizeObserverWrapped = true;
const Original = window.ResizeObserver;
if (!Original) return;
const isPlotlyCrash = (err) => {
if (!err) return false;
const msg = (err && err.message) || String(err);
const stack = (err && err.stack) || "";
const widthHeight =
msg.indexOf("reading 'width'") !== -1 ||
msg.indexOf("reading 'height'") !== -1 ||
msg.indexOf("ResizeObserver loop") !== -1;
const fromBundle = stack.indexOf("vanna-components") !== -1;
return widthHeight && fromBundle;
};
const Wrapped = function (callback) {
const safeCallback = function (entries, observer) {
try {
callback.call(this, entries, observer);
} catch (err) {
if (!isPlotlyCrash(err)) throw err;
}
};
return new Original(safeCallback);
};
Wrapped.prototype = Original.prototype;
window.ResizeObserver = Wrapped;
};
const loadFontsOnce = () => {
if (window.__vannaFontsLoaded) return;
window.__vannaFontsLoaded = true;
const links = [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossorigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap",
},
];
for (const cfg of links) {
const link = document.createElement("link");
Object.entries(cfg).forEach(([k, v]) => link.setAttribute(k, v));
document.head.appendChild(link);
}
};
const injectBundle = (baseUrl) =>
new Promise((resolve, reject) => {
const sel = 'script[data-vanna-bundle="' + baseUrl + '"]';
const existing = document.querySelector(sel);
if (existing) {
if (existing.__loaded) return resolve();
existing.addEventListener("load", () => resolve());
existing.addEventListener("error", () =>
reject(new Error("vanna bundle failed to load"))
);
return;
}
const script = document.createElement("script");
script.type = "module";
script.src = baseUrl + "/static/vanna-components.js";
script.dataset.vannaBundle = baseUrl;
script.onload = () => {
script.__loaded = true;
resolve();
};
script.onerror = () =>
reject(new Error("vanna bundle failed to load"));
document.head.appendChild(script);
});
window.VannaEmbed = {
ensureLoaded(config) {
if (loaderPromise) return loaderPromise;
const baseUrl = (config && config.baseUrl) || "";
const extraCss = config && config.extraCss;
loaderPromise = (async () => {
wrapResizeObserver();
setupThemePierce(baseUrl, extraCss);
setupTranslator();
loadFontsOnce();
await injectBundle(baseUrl);
await customElements.whenDefined("vanna-chat");
})();
return loaderPromise;
},
};
})();

392
static/vanna-theme.css Normal file
View File

@ -0,0 +1,392 @@
/* Vanna chat theme ClubPetro
* Paleta espelhada do widget de referência (test.html):
* - Brand orange: #F46A1F (primário / interativo)
* - Brand orange dark: #DB5510 (badges, hover, accent forte)
* - Purple deep: #4A1A56 (texto / estrutural)
* - Purple medium: #7B2D8E (info / divisores secundários)
* - Cream bg: #F8EFE2 (header, surfaces warm)
* - Cream border: #EADFCB (borders sobre cream)
* - Meta/muted: #8B7B6E
* - Font: Open Sans
*
* CSS custom properties pierce the Shadow DOM, so overriding them on the
* <vanna-chat> host element retemas tudo dentro do componente.
*
* Reference (todos os tokens disponíveis):
* vanna/frontends/webcomponent/src/styles/vanna-design-tokens.ts
*/
/* Estratégia dual:
* - vanna-chat: matchea o host element no documento (cascade externo).
* - :host: matchea o host de qualquer shadow root onde este sheet
* for adotado via adoptedStyleSheets (override em todo nested
* custom element como vanna-message, plotly-chart, etc., que
* eles re-declaram os tokens via :host).
*
* Google Fonts é carregado via <link> no HTML @import não funciona
* em CSSStyleSheet construída via replaceSync().
*/
:host,
vanna-chat {
/* === Brand accent (orange) === */
--vanna-accent-primary-default: #f46a1f;
--vanna-accent-primary-stronger: #db5510;
--vanna-accent-primary-strongest: #b8460e;
--vanna-accent-primary-subtle: rgba(244, 106, 31, 0.12);
--vanna-accent-primary-hover: #e55c13;
/* "Positive" também ancora no laranja pra manter coerência da marca */
--vanna-accent-positive-default: #f46a1f;
--vanna-accent-positive-stronger: #db5510;
--vanna-accent-positive-subtle: rgba(244, 106, 31, 0.12);
/* Negativo continua vermelho (semântica de erro deve diferir da marca) */
--vanna-accent-negative-default: #dc2626;
--vanna-accent-negative-stronger: #b91c1c;
--vanna-accent-negative-subtle: rgba(220, 38, 38, 0.1);
/* Warning em amber pra distinguir do laranja-primário */
--vanna-accent-warning-default: #d97706;
--vanna-accent-warning-stronger: #b45309;
--vanna-accent-warning-subtle: rgba(217, 119, 6, 0.1);
/* === Foreground (texto) — roxo deep do widget de referência === */
--vanna-foreground-default: #4a1a56;
--vanna-foreground-dimmer: #6b4673;
--vanna-foreground-dimmest: #8b7b6e;
/* === Backgrounds (cream warm pra ecoar fundo da logo + página) ===
* root/default ficam branco puro (legibilidade nas bolhas/tabelas);
* higher/subtle/lower puxam #F8EFE2 (cream do test.html) pra dar
* identidade em surfaces secundárias (sidebar, áreas de input,
* separadores).
*/
--vanna-background-root: #ffffff;
--vanna-background-default: #ffffff;
--vanna-background-higher: #f8efe2;
--vanna-background-highest: #f0e2c8;
--vanna-background-subtle: #fbf5ea;
--vanna-background-lower: #f8efe2;
/* === Outlines / borders cream-tinted pro look warm + brand orange
* pro estado de hover/focus.
*/
--vanna-outline-default: rgba(244, 106, 31, 0.25);
--vanna-outline-dimmer: #eadfcb;
--vanna-outline-dimmest: #f3e8d2;
--vanna-outline-hover: #f46a1f;
/* === Typography === */
--vanna-font-family-default: "Open Sans", system-ui, -apple-system,
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
/* === Shape — bolhas um pouco menos arredondadas pra ar mais corporativo === */
--vanna-chat-bubble-radius: 14px;
--vanna-chat-bubble-radius-sm: 8px;
}
/* ============================================================================
* Containment fixes impedem mensagem/tabela de estourar a largura do chat.
*
* Estes seletores casam elementos *dentro* dos shadow roots onde este sheet
* é adoptado (vanna-message, vanna-chat). Não dependem de :host.
* ========================================================================== */
/* Bolha de mensagem:
* 1) Texto puro mantém o limite original do upstream (~580px) preserva
* a estética de bolha de chat. adicionamos min-width:0 + overflow-wrap
* pra texto longo quebrar dentro da bolha em vez de vazar.
* 2) Quando a mensagem do assistente tem tabela/chart embutido, ampliamos
* o max-width pra 100% caso contrário a tabela empurraria a bolha
* além do limite (display:flex sem min-width:0 nos children).
*/
.message {
min-width: 0;
box-sizing: border-box;
}
.message-content {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
box-sizing: border-box;
}
/* Headers de markdown dentro do balão (### etc., gerados pelo mdToHtml
do embed-demo.html). Sem essas regras, o browser usa default que é
grande demais e quebra o ritmo do balão. */
.message-content h1,
.message-content h2,
.message-content h3,
.message-content h4,
.message-content h5,
.message-content h6 {
font-weight: 600;
line-height: 1.3;
margin: 0.7em 0 0.3em 0;
}
.message-content h1 { font-size: 1.25em; }
.message-content h2 { font-size: 1.15em; }
.message-content h3 { font-size: 1.05em; }
.message-content h4 { font-size: 1em; }
.message-content h5 { font-size: 0.95em; }
.message-content h6 { font-size: 0.9em; opacity: 0.85; }
/* Primeiro header sem margem-top — evita gap visível na borda do balão. */
.message-content > h1:first-child,
.message-content > h2:first-child,
.message-content > h3:first-child,
.message-content > h4:first-child,
.message-content > h5:first-child,
.message-content > h6:first-child {
margin-top: 0;
}
/* Esconder o FAB nativo quando minimizado substituímos por um CTA
custom (.vanna-cta) renderizado fora do shadow DOM em embed-demo.html
/ no app React. A regra :host(.minimized) é a única que vence o
posicionamento fixed do componente.
*
* overflow:hidden é cinto-de-segurança: o host fica 0x0 com overflow
* clippando o .chat-layout interno (que mantemos com dimensões naturais
* regra abaixo), evitando o crash do ResizeObserver do Plotly.
*/
:host(.minimized) {
background: transparent !important;
box-shadow: none !important;
width: 0 !important;
height: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}
:host(.minimized) .minimized-icon {
display: none !important;
}
/* Plotly ResizeObserver crash defense. Upstream (vanna-chat.ts:103-105)
* faz `:host(.minimized) .chat-layout { display: none }` quando o chat
* minimiza. Plotly tem um ResizeObserver no container do chart que dispara
* `_t [as relayout]` em cada mutação de tamanho; quando o container vai
* pra display:none, o `gd._fullLayout` interno fica undefined e o relayout
* quebra com `TypeError: Cannot read properties of undefined (reading
* 'width')` (kt at vanna-components.js:22888).
*
* Mantemos display:grid (cancela o display:none upstream) + dimensões
* naturais o host é 0x0 com overflow:hidden, então o chat-layout
* é clippado visualmente sem precisar mudar suas próprias dimensões.
* Plotly não o container colapsar pra 0x0 e o ResizeObserver fica
* quieto. pointer-events:none impede interação acidental.
*/
:host(.minimized) .chat-layout {
display: grid !important;
pointer-events: none !important;
}
/* CTA abre direto em maximized; estado intermediário "normal" não é
exposto ao usuário. Esconde os botões de restore (que voltariam de
maximized normal) e maximize. Sobra o .minimize, que dispara
windowState='minimized' e o CTA volta a aparecer. */
.window-control-btn.restore,
.window-control-btn.maximize {
display: none !important;
}
.message.assistant:has(.rich-dataframe, .rich-component, .plotly-chart) {
max-width: 100%;
width: 100%;
}
/* Tabela de resultados (rich-dataframe):
* - .dataframe-table-container tem overflow:auto, mas falta limitar
* o max-width do rich-component (wrapper) ao tamanho da bolha.
* - Forçando 100% + box-sizing, a tabela com N colunas ganha scroll
* horizontal interno em vez de empurrar a bolha.
*/
.rich-component,
.rich-dataframe,
.dataframe-table-container {
max-width: 100%;
width: 100%;
box-sizing: border-box;
}
.dataframe-table-container {
overflow-x: auto;
overflow-y: auto;
}
/* Cabeçalhos longos quebram em duas linhas em vez de forçar coluna larga;
* células de dado não quebram (preserva alinhamento numérico). */
.dataframe-table th {
white-space: normal;
overflow-wrap: anywhere;
}
.dataframe-table td {
white-space: nowrap;
}
/* Avatar do header substitui as iniciais "AV" pela logo ClubPetro.
* Upstream renderiza <div class="chat-avatar">${initials}</div> num shadow
* root; este sheet é adotado dentro via attachShadow patch (ver
* embed-demo.html), então o seletor de classe vence o :host upstream em
* specificity igual + ordem (adotado vai por último). font-size:0 esconde
* as iniciais sem mudar o layout grid 44x44 do upstream.
*/
.chat-avatar {
background-color: transparent;
background-image: url(/clubpetro-logo.svg);
background-repeat: no-repeat;
background-size: 100%;
background-position: center;
backdrop-filter: none;
border: none;
overflow: visible;
font-size: 0;
color: transparent;
}
/* Bolha de mensagem do usuário laranja brand sólido (sem gradient).
* Cor única alinhada ao #F46A1F do widget de referência. Texto branco
* mantém contraste suficiente em semibold (3.4:1 WCAG AA Large).
*
* align-self: flex-end + width: fit-content fazem a bolha encolher ao
* tamanho do conteúdo (em vez de esticar até o max-width default do
* upstream). O parent .chat-messages é flex-direction:column com
* align-items:stretch implícito, então sem o override a bolha enche
* a coluna inteira mesmo com texto curto.
*
* max-width replica o do upstream (vanna-message.ts:57). Necessário
* porque `width: fit-content` sozinho pode empurrar a bolha além do
* limite quando o conteúdo tem strings incompressíveis (URL longa,
* palavra grudada) overflow-wrap: anywhere está em .message-content
* mas o cap explícito é cinto + suspensório. min-width: 0 garante que
* flex children obedeçam ao max-width.
*/
.message.user {
background: #f46a1f;
border-color: rgba(255, 255, 255, 0.18);
align-self: flex-end;
width: fit-content;
max-width: min(80%, 500px);
min-width: 0;
box-sizing: border-box;
}
:host([theme="dark"]) .message.user {
background: #f46a1f;
}
/* ============================================================================
* Header cream bg, roxo deep no título, laranja no subtítulo.
*
* Upstream renderiza o header com gradient laranja cheio + overlay
* radial branco e texto branco. Aqui invertemos: fundo cream #F8EFE2,
* título em roxo #4A1A56 e subtítulo injetado via ::after no .header-text
* (upstream tem property `subtitle` mas nunca renderiza no template
* pseudo-element é mais simples que rebuild).
* ========================================================================== */
.chat-header {
background: #f8efe2 !important;
border-bottom: 1px solid #eadfcb !important;
color: #4a1a56 !important;
}
.chat-header::before {
display: none;
}
.chat-title {
color: #4a1a56 !important;
font-weight: 700;
}
.header-text {
gap: 2px;
}
.header-text::after {
content: "inteligência para seu posto";
color: #f46a1f;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.2;
}
/* Window control buttons (refresh / minimize / maximize / X) repintar
* com tinta roxa pra contrastar com o cream em vez do laranja original.
*
* .header-top-actions / .window-controls precisam de flex-shrink:0 porque
* upstream (vanna-chat.ts:204-209) seta `margin-left:auto` no actions,
* sem proteção de shrink. Em larguras justas (chat ocupando metade da
* viewport via :host(.maximized){left:50vw} + título/avatar consumindo o
* .header-left flex:1) o squeeze do flexbox comprime os botões a 0 e o
* minimize some. Travar shrink mantém os 32x32 sempre.
*
* .window-control-btn ganha `flex-shrink:0` pelo mesmo motivo + width
* explícito no shadow (upstream põe width:32px sem flex-basis, então em
* algumas combinações o shrink ainda toca).
*/
.header-top-actions,
.window-controls {
flex-shrink: 0 !important;
}
.window-control-btn {
background: rgba(74, 26, 86, 0.05) !important;
border-color: rgba(74, 26, 86, 0.1) !important;
color: #4a1a56 !important;
flex-shrink: 0 !important;
}
.window-control-btn:hover {
background: rgba(74, 26, 86, 0.12) !important;
}
/* Maximizado ocupa a metade direita da tela em vez da viewport inteira.
* Upstream (vanna-chat.ts:62-75) usa top/left/right/bottom = space-6 (24px),
* cobrindo full-screen com margem. Override de `left` pra 50vw mantém a
* sensação de "side panel" usuário ainda o app por trás à esquerda.
*
* overflow:hidden no host é cinto-de-segurança contra qualquer descendente
* (bolha, chart, tabela) que rompa as constraints internas nada visível
* vaza além das bordas arredondadas.
*/
:host(.maximized) {
left: 50vw !important;
overflow: hidden !important;
}
/* Containment do .chat-main é filho direto do grid .chat-layout
* (1fr quando .compact). min-width:0 + overflow:hidden impedem que um
* descendente largo (ex.: tabela com muitas colunas, chart Plotly que
* recalculou layout estranho) empurre o próprio chat-main além da
* coluna do grid, o que arrastaria o header e cortaria os botões.
*/
.chat-main {
min-width: 0 !important;
overflow: hidden !important;
}
/* Spinner do status bar repintar de teal upstream pra roxo da marca.
* Upstream (vanna-status-bar.ts:190-198) usa border teal #15A8A8 + glow
* teal via @keyframes spinnerGlow. Como o sheet é adotado em todo shadow
* root (incluindo o do <vanna-status-bar>), tanto a regra de border
* quanto o redefine do @keyframes vencem o upstream (mesma especificity,
* adotado depois).
*/
.spinner {
border-color: rgba(74, 26, 86, 0.18) !important;
border-top-color: #4a1a56 !important;
}
@keyframes spinnerGlow {
0%, 100% {
filter: drop-shadow(0 0 2px rgba(74, 26, 86, 0.45));
}
50% {
filter: drop-shadow(0 0 6px rgba(74, 26, 86, 0.75));
}
}

121
system_prompt.py Normal file
View File

@ -0,0 +1,121 @@
"""System prompt customizado para o Vanna Agent (ClubPetro / gold.sales).
Substitui o prompt default do Vanna via
`DefaultSystemPromptBuilder(base_prompt=SYSTEM_PROMPT)`.
Edição manual deste texto é o caminho normal de iteração o ChromaDB segue
sendo enriquecido via train.py separadamente.
"""
SYSTEM_PROMPT = """Você é um analista SQL de vendas de postos (ClubPetro). Responda SEMPRE em português do Brasil.
## Regra #1 — sempre rodar query antes de responder
Para QUALQUER pergunta envolvendo dados (números, métricas, vendas, faturamento, clientes, comparativos, rankings, períodos), execute `run_sql` ANTES de escrever qualquer coisa. Depois descreva o que ENCONTROU com números reais.
Nunca narre o que a análise "vai mostrar" ou "poderia mostrar". Sem query sem resposta.
Exceções (e estas): cumprimentos ("oi"), pergunta sobre suas capacidades, ou pedido fora de escopo (ranking entre lojas ver "Escopo").
## Confidencialidade
Nunca mostre SQL, nomes de tabelas/colunas, IDs internos (sale_id, store_id, program_id), file paths, ou nomes de ferramentas. Nunca mencione "RLS", "ClickHouse", "view", "guardrail". Fale linguagem de lojista: faturamento, vendas, clientes, fidelização.
A tabela com o resultado aparece separadamente na UI não repita linha a linha. Sintetize: números-chave + insight de negócio.
**Aliases obrigatórios.** O cabeçalho da tabela mostrada ao usuário é o ALIAS do SELECT. SEMPRE alias TODAS as colunas com rótulo amigável em pt-BR (`... AS "Faturamento (R$)"`). Nunca deixe `valor_total_produto`, `data_da_compra`, `nome_do_atendente` etc. virarem header.
**Erro de query.** Se qualquer query falhar (qualquer motivo: SQL inválido, coluna inexistente, permissão, timeout, regex de bloqueio), responda APENAS: "Não consegui buscar esses dados agora, pode reformular?" e ofereça reformulações em linguagem de negócio. Nunca descreva o erro técnico, nunca cite tabela/coluna/permissão.
## Escopo
Você analisa UMA loja por conversa. Não tem acesso a dados de outras lojas, ranking entre lojas, nem benchmarks reais entre postos. Se pedirem comparativo entre lojas, explique brevemente e ofereça análise INTERNA equivalente (entre produtos, atendentes, períodos da própria loja).
## Schema (única fonte: gold.sales — 1 linha = 1 item de uma venda)
ESTAS são as ÚNICAS colunas existentes. Você TEM permissão total nelas. Qualquer outro nome é alucinação mapeie pra coluna real (abaixo) ou diga que não .
Coluna de tempo (única): `data_da_compra` (DateTime). Não existe `sync_date`, `created_at`, `updated_at`, `dt_*`, `timestamp`. Todo filtro temporal usa `data_da_compra`.
Colunas disponíveis:
- `sale_id` (UUID) `countDistinct(sale_id)` = vendas distintas
- `cartao_do_cliente` (str) `countDistinct(cartao_do_cliente)` = clientes únicos
- `nome_do_cliente`, `nome_do_atendente`, `produto`, `nome_da_loja`, `nome_da_rede` (str)
- `categoria_do_produto`, `categoria_do_cliente`, `categoria_loja`, `tipo_loja` (str)
- `quantidade_total_produto` (Decimal) quantidade do item; LITROS para combustíveis
- `valor_total_produto` (Decimal) receita em R$
- `desconto` (Decimal)
- `pontuacao_produto` (Decimal) pontos de fidelidade ganhos
- `voucher_aplicado_venda` (str)
- `fidelizada` (Bool) TRUE = cliente cadastrado no programa
- `e_combustivel` (Bool)
- `data_da_compra` (DateTime)
Mapeamento de alucinações comuns coluna real:
- "data/dt da venda", "data" `data_da_compra`
- "valor/receita da venda" `valor_total_produto`
- "quantidade/qtd/litros/volume" `quantidade_total_produto`
- "id da venda" `sale_id`
- "cpf, id/email/telefone do cliente" `cartao_do_cliente` (CPF/email/telefone NÃO existem)
- "id do atendente/produto/loja" use o nome (`nome_do_atendente`, `produto`, `nome_da_loja`) não IDs acessíveis
- "fidelizado" `fidelizada`
- "pontos/pontuacao" `pontuacao_produto`
- "categoria" escolha pelo contexto (`categoria_do_produto` / `categoria_do_cliente` / `categoria_loja`)
NÃO EXISTEM (nem tente): `customer_id`, `attendant_id`, `product_id`, `sync_date`, `cpf`, `email`, `telefone`, `endereço`, `cidade`, `cep`. Se a análise depende de uma dessas, ela não é possível com esta base reformule.
## Universo de clientes — sempre `fidelizada = true`
Vendas não-fidelizadas (`fidelizada = false`) NÃO têm cliente identificado: `cartao_do_cliente`, `nome_do_cliente`, `categoria_do_cliente` ficam nulos/vazios. Toda análise centrada em CLIENTE (ranking de clientes, contagem de clientes únicos, frequência, recência, ticket por cliente, segmentação por categoria de cliente, retenção, top compradores etc.) deve INCLUIR `fidelizada = true` no `WHERE`. Sem esse filtro o resultado mistura linhas anônimas e distorce a contagem.
Quando NÃO aplicar:
- Métricas agregadas da loja (faturamento total, volume total, vendas/itens totais, ticket médio da loja, mix de produtos): NÃO filtre perderia receita não-fidelizada.
- Métrica `% Fidelização`: NÃO filtre a fórmula precisa do denominador completo.
- Pergunta sobre vendas/produtos/atendentes sem dimensão de cliente: NÃO filtre.
Regra prática: se a query agrupa por (ou filtra por, ou seleciona) `cartao_do_cliente`/`nome_do_cliente`/`categoria_do_cliente`, OU se a pergunta usa palavras como "cliente", "comprador", "fidelizado", "categoria de cliente", "frequência", "ticket por cliente" adicione `AND fidelizada = true`.
## Análise de desconto — sempre `desconto > 0`
Quando a pergunta envolver desconto (qualquer variação: "descontos aplicados", "vendas com desconto", "ranking de desconto", "total descontado", "quem mais deu desconto", "produtos com desconto" etc.), filtre `desconto > 0` no `WHERE`. Linhas com `desconto = 0` representam vendas sem desconto e poluem o resultado (inflam contagem, zeram médias, escondem o que de fato teve concessão). Métricas: `sum(desconto)` AS "Desconto (R$)", `count()` para itens com desconto, `avg(desconto)` para ticket de desconto. Não aplicar quando a pergunta é sobre faturamento/volume geral quando o foco é o desconto em si.
## Métricas padrão (use estes nomes/fórmulas)
- Faturamento (R$) = `sum(valor_total_produto)`
- Volume (L) = `sum(quantidade_total_produto)`
- Vendas = `countDistinct(sale_id)`
- Itens = `count()`
- Clientes únicos = `countDistinct(cartao_do_cliente)`
- Ticket Médio (R$) = `sum(valor_total_produto) / nullIf(countDistinct(sale_id), 0)`
- Preço (R$/L) = `sum(valor_total_produto) / nullIf(sum(quantidade_total_produto), 0)`
- % Fidelização = `countDistinctIf(sale_id, fidelizada) / nullIf(countDistinct(sale_id), 0) * 100`
- Pontos distribuídos = `sum(pontuacao_produto)`
## Janelas temporais comuns
- Hoje `toDate(data_da_compra) = today()`
- Últimos N dias `data_da_compra >= now() - INTERVAL N DAY`
- Mês atual `toStartOfMonth(data_da_compra) = toStartOfMonth(today())`
- Mês anterior `toStartOfMonth(data_da_compra) = toStartOfMonth(today() - INTERVAL 1 MONTH)`
- Comparar meses lado-a-lado `GROUP BY toStartOfMonth(data_da_compra)`
- Por hora `GROUP BY toStartOfHour(data_da_compra)`
- Dia da semana `GROUP BY toDayOfWeek(data_da_compra)` (1=seg ... 7=dom)
## Memória
Antes de gerar SQL nova pra uma pergunta de negócio, chame `search_saved_correct_tool_uses` com a pergunta atual. Se houver caso semelhante salvo, reuse o SQL como base (adaptando filtros se necessário) em vez de começar do zero.
Após uma resposta bem-sucedida (query rodou + insight relevante entregue), chame `save_question_tool_args` salvando o par (pergunta original do usuário, args do `run_sql` que funcionou). Não salve casos que falharam, retornaram vazio, ou foram bloqueados pelo guardrail.
Use `save_text_memory` somente para anotações estruturais (ex.: "esta loja chama bombas de combustível de 'pista'") nunca pra resultados numéricos ou queries.
Não exponha esses tools ao usuário são internos.
## Boas práticas SQL (ClickHouse)
- SEMPRE filtre por `data_da_compra` para evitar full scan.
- Rankings: especifique métrica de ordenação + critério de desempate; máx. 40 linhas.
- Séries temporais: ordene pelo tempo crescente.
- Divisão: proteja denominador com `nullIf(<denom>, 0)`.
- Booleano em agregação: `toUInt8(fidelizada)` ou `countDistinctIf(..., fidelizada)`.
## Apresentação
- R$ com separador de milhar + 2 casas: `R$ 12.773,86`
- Litros com 1 casa: `1.885,7 L`
- Preço R$/L com 3 casas: `R$ 6,774/L`
- Percentual com 1-2 casas: `66,0%`
- Use nomes (produto, nome_da_loja, nome_do_atendente, nome_do_cliente), nunca UUIDs.
- Aliases padrão pra header: `Data`, `Hora`, `Dia da Semana`, `Loja`, `Rede`, `Produto`, `Categoria`, `Cliente`, `Cartão`, `Atendente`, `Faturamento (R$)`, `Volume (L)`, `Quantidade`, `Desconto (R$)`, `Pontos`, `Ticket Médio (R$)`, `Volume Médio (L)`, `Preço (R$/L)`, `Vendas`, `Itens`, `Clientes Únicos`, `% Fidelização`, `Fidelizada`.
- Rankings: destaque o número e 1-2 frases de insight ("horário de pico", "produto mais rentável").
- Encerre quando útil oferecendo aprofundamento ("Quer comparar com a semana passada?", "Quer ver por atendente?").
"""

204
tenant_memory.py Normal file
View File

@ -0,0 +1,204 @@
"""Tenant-aware ChromaDB memory: schema compartilhado + tool-usage por loja.
Vanna's `ChromaAgentMemory` é single-collection — todas as memórias caem no mesmo
pool, sem scoping. No app ClubPetro isso vazaria perguntas/aprendizados entre
clientes (program × loja) que compartilham o mesmo deploy.
Esta classe compõe DUAS instâncias de `ChromaAgentMemory`:
shared (text memories) collection `<base>` schema docs do `train.py`.
Mesma collection que o app sempre usou; intacto.
per-tenant (tool usage) collection `<base>__p<program>__s<store>`,
instanciada lazy na primeira chamada de cada (program_id, store_id).
Roteamento (1 método da ABC = 1 chamada delegada):
text_memory: save / search / get_recent / delete shared
tool_usage: save / search / get_recent / delete tenant
clear_memories: tenant (preserva schema)
`context.user.program_id` / `store_id` são lidos por chamada RLS valida via
`_require_id` no resolver, então chega aqui checado. `train.py` roda com
user "trainer" sem program/store; usa text_memory, então não toca a lógica
per-tenant funciona sem mudança.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional, Tuple
from vanna.capabilities.agent_memory import (
AgentMemory,
TextMemory,
TextMemorySearchResult,
ToolMemory,
ToolMemorySearchResult,
)
from vanna.core.tool import ToolContext
from vanna.integrations.chromadb import ChromaAgentMemory
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def _slug(value: str) -> str:
"""Normaliza ID pra um fragmento válido de collection name do ChromaDB.
ChromaDB exige nomes lowercase começando/terminando em alfanumérico,
com `_`/`-`/`.` permitidos no meio. `_require_id` (rls_runner.py)
garante `^[A-Za-z0-9_-]+$`, então precisamos lowercase + colapso
de qualquer char "estranho" (defensive).
"""
s = _SLUG_RE.sub("", value.lower())
return s or "x"
class TenantAwareChromaMemory(AgentMemory):
"""Roteia memórias entre collection shared (schema) e per-tenant (tool usage).
Args:
persist_directory: Pasta do PersistentClient do ChromaDB.
base_collection_name: Nome da collection compartilhada (text memories).
Per-tenant collections usam esse nome como prefixo.
embedding_function: Embedding function passada pra todas as collections
(compartilhada + tenants). Mantém consistência semântica.
"""
def __init__(
self,
persist_directory: str,
base_collection_name: str,
embedding_function: Optional[Any] = None,
):
self._persist_directory = persist_directory
self._base = base_collection_name
self._ef = embedding_function
self._shared = ChromaAgentMemory(
persist_directory=persist_directory,
collection_name=base_collection_name,
embedding_function=embedding_function,
)
self._tenants: Dict[Tuple[str, str], ChromaAgentMemory] = {}
@staticmethod
def _tenant_ids(context: ToolContext) -> Tuple[str, str]:
user = getattr(context, "user", None)
prog = getattr(user, "program_id", None) if user else None
store = getattr(user, "store_id", None) if user else None
if not prog or not store:
raise PermissionError(
"Tool-usage memory requires User.program_id and User.store_id; "
"got program_id={!r} store_id={!r}".format(prog, store)
)
return str(prog), str(store)
def _tenant(self, context: ToolContext) -> ChromaAgentMemory:
key = self._tenant_ids(context)
cached = self._tenants.get(key)
if cached is not None:
return cached
prog, store = key
name = f"{self._base}__p{_slug(prog)}__s{_slug(store)}"
instance = ChromaAgentMemory(
persist_directory=self._persist_directory,
collection_name=name,
embedding_function=self._ef,
)
self._tenants[key] = instance
return instance
# === tool usage (per-tenant) ===
async def save_tool_usage(
self,
question: str,
tool_name: str,
args: Dict[str, Any],
context: ToolContext,
success: bool = True,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
return await self._tenant(context).save_tool_usage(
question=question,
tool_name=tool_name,
args=args,
context=context,
success=success,
metadata=metadata,
)
async def search_similar_usage(
self,
question: str,
context: ToolContext,
*,
limit: int = 10,
similarity_threshold: float = 0.7,
tool_name_filter: Optional[str] = None,
) -> List[ToolMemorySearchResult]:
return await self._tenant(context).search_similar_usage(
question=question,
context=context,
limit=limit,
similarity_threshold=similarity_threshold,
tool_name_filter=tool_name_filter,
)
async def get_recent_memories(
self, context: ToolContext, limit: int = 10
) -> List[ToolMemory]:
return await self._tenant(context).get_recent_memories(
context=context, limit=limit
)
async def delete_by_id(self, context: ToolContext, memory_id: str) -> bool:
return await self._tenant(context).delete_by_id(
context=context, memory_id=memory_id
)
# === text memory (shared / schema) ===
async def save_text_memory(
self, content: str, context: ToolContext
) -> TextMemory:
return await self._shared.save_text_memory(content=content, context=context)
async def search_text_memories(
self,
query: str,
context: ToolContext,
*,
limit: int = 10,
similarity_threshold: float = 0.7,
) -> List[TextMemorySearchResult]:
return await self._shared.search_text_memories(
query=query,
context=context,
limit=limit,
similarity_threshold=similarity_threshold,
)
async def get_recent_text_memories(
self, context: ToolContext, limit: int = 10
) -> List[TextMemory]:
return await self._shared.get_recent_text_memories(
context=context, limit=limit
)
async def delete_text_memory(self, context: ToolContext, memory_id: str) -> bool:
return await self._shared.delete_text_memory(
context=context, memory_id=memory_id
)
# === clear: só tenant (schema é gerenciado pelo train.py) ===
async def clear_memories(
self,
context: ToolContext,
tool_name: Optional[str] = None,
before_date: Optional[str] = None,
) -> int:
return await self._tenant(context).clear_memories(
context=context, tool_name=tool_name, before_date=before_date
)

27
test_clickhouse.py Normal file
View File

@ -0,0 +1,27 @@
"""Quick connectivity test for ClickHouse Cloud."""
import os
import clickhouse_connect
from dotenv import load_dotenv
load_dotenv()
client = clickhouse_connect.get_client(
host=os.environ["CLICKHOUSE_HOST"],
port=int(os.environ["CLICKHOUSE_PORT"]),
username=os.environ["CLICKHOUSE_USER"],
password=os.environ["CLICKHOUSE_PASSWORD"],
database=os.environ["CLICKHOUSE_DATABASE"],
secure=os.environ.get("CLICKHOUSE_SECURE", "true").lower() == "true",
)
print("Server version:", client.server_version)
print("Current database:", client.query("SELECT currentDatabase()").result_rows[0][0])
tables = client.query(
"SELECT name FROM system.tables WHERE database = currentDatabase() ORDER BY name"
).result_rows
print(f"\nTables in '{os.environ['CLICKHOUSE_DATABASE']}' ({len(tables)}):")
for (t,) in tables:
print(f" - {t}")
client.close()

107
train.py Normal file
View File

@ -0,0 +1,107 @@
"""Fetch ClickHouse schema and store it as text memories for the agent."""
import asyncio
import os
import clickhouse_connect
from dotenv import load_dotenv
from vanna.core.tool import ToolContext
from vanna.integrations.chromadb import ChromaAgentMemory
from vanna import User
from rls_runner import RLS_TABLES
# Colunas usadas internamente pelo RLS (additional_table_filters em rls_runner.py).
# Granted ao wren_ia para o filtro funcionar, mas escondidas do contexto do LLM.
RLS_INTERNAL_COLS = {"program_id", "store_id"}
load_dotenv()
def fetch_schema_docs() -> list[str]:
client = clickhouse_connect.get_client(
host=os.environ["CLICKHOUSE_HOST"],
port=int(os.environ["CLICKHOUSE_PORT"]),
username=os.environ["CLICKHOUSE_USER"],
password=os.environ["CLICKHOUSE_PASSWORD"],
database=os.environ["CLICKHOUSE_DATABASE"],
secure=True,
)
db = os.environ["CLICKHOUSE_DATABASE"]
allowed = sorted(
t.split(".", 1)[1] for t in RLS_TABLES if t.startswith(f"{db}.")
)
if not allowed:
raise RuntimeError(
f"No RLS_TABLES match database={db!r}; nothing to train on."
)
docs: list[str] = []
for table in allowed:
# system.columns já é filtrado pelo ClickHouse: retorna apenas colunas
# com GRANT SELECT para o user atual (wren_ia). Fonte de verdade do
# que é acessível. SELECT *, DESCRIBE TABLE e system.tables não são
# usados aqui — exigem privilégios extras que o user de runtime não tem.
cols_raw = client.query(
"SELECT name, type, comment FROM system.columns "
"WHERE database = %(db)s AND table = %(t)s ORDER BY position",
parameters={"db": db, "t": table},
).result_rows
if not cols_raw:
raise RuntimeError(
f"No accessible columns for {db}.{table} — verify the table "
f"exists and {os.environ['CLICKHOUSE_USER']!r} has SELECT "
f"grants on it."
)
visible = [n for n, _, _ in cols_raw if n not in RLS_INTERNAL_COLS]
cols = [(n, t, c) for n, t, c in cols_raw if n in visible]
col_lines = "\n".join(
f" - {n} {t}" + (f" -- {c}" if c else "") for n, t, c in cols
)
col_list = ", ".join(visible)
sample_rows = client.query(
f"SELECT {col_list} FROM {db}.{table} LIMIT 3"
)
sample_cols = ", ".join(sample_rows.column_names)
sample_preview = "\n".join(
" " + " | ".join(str(v) for v in row)
for row in sample_rows.result_rows
)
doc = (
f"Table `{db}.{table}` (ClickHouse).\n"
f"Columns:\n{col_lines}\n\n"
f"Sample rows ({sample_cols}):\n{sample_preview}"
)
docs.append(doc)
client.close()
return docs
async def main() -> None:
memory = ChromaAgentMemory(
persist_directory="./chroma_db",
collection_name="vanna_clickhouse_gold",
)
user = User(id="trainer", username="trainer")
context = ToolContext(
user=user,
conversation_id="train",
request_id="train",
agent_memory=memory,
)
docs = fetch_schema_docs()
for i, doc in enumerate(docs, 1):
await memory.save_text_memory(content=doc, context=context)
first_line = doc.splitlines()[0]
print(f"[{i}/{len(docs)}] saved: {first_line}")
print(f"\nTrained on {len(docs)} table(s).")
if __name__ == "__main__":
asyncio.run(main())

380
viz_tool.py Normal file
View File

@ -0,0 +1,380 @@
"""Override do VisualizeDataTool com `chart_type` controlável pelo LLM
+ generator que evita o fallback `go.Table` do upstream.
Três customizações sobre upstream:
1. Arg novo `chart_type` (line/bar/scatter/histogram/area) no schema. Quando
passado pelo LLM, força o tipo. Heurística automática roda como
fallback quando o LLM omite (a guidance no system_prompt + descrição do
tool empurra o LLM a sempre passar).
2. `ClubPetroChartGenerator` continua removendo a heurística "4+ colunas
go.Table" do upstream (`vanna/src/vanna/integrations/plotly/chart_generator.py:51-55`),
que duplicava visualmente o dataframe rich.
3. `_coerce_datetime_columns` foi movida pra rodar nos DOIS caminhos
(<4 e >=4 colunas). Antes rodava no 4+, então queries 2-col
`SELECT data_da_compra, faturamento` nunca eram detectadas como
time series e viravam bar. Agora a coerção uniforme torna o
comportamento previsível e o `chart_type=line` forçado funciona
consistente.
`chart_type` é threadado via `ContextVar` em vez de monkey-patch o
`VisualizeDataTool` é singleton no `ToolRegistry` e pode ter execuções
concorrentes em conversas paralelas.
"""
from __future__ import annotations
import contextvars
import json
from typing import Any, Dict, List, Literal, Optional, Type
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
from pydantic import Field
from vanna.core.tool import ToolContext, ToolResult
from vanna.integrations.plotly import PlotlyChartGenerator
from vanna.tools import VisualizeDataTool
from vanna.tools.visualize_data import VisualizeDataArgs
from events_sink import record_chart
ChartType = Literal["line", "bar", "scatter", "histogram", "area"]
_chart_type_override: contextvars.ContextVar[Optional[ChartType]] = (
contextvars.ContextVar("chart_type_override", default=None)
)
class VisualizeDataArgsPT(VisualizeDataArgs):
chart_type: Optional[ChartType] = Field(
default=None,
description=(
"Tipo do gráfico. Passe SEMPRE que souber o tipo certo: "
"'line' = série temporal (evolução, tendência ao longo do tempo); "
"'bar' = ranking, top N, comparação categórica; "
"'scatter' = correlação entre 2 métricas numéricas; "
"'histogram' = distribuição de 1 métrica; "
"'area' = acumulado temporal. "
"Omita SÓ se genuinamente em dúvida — sem hint o sistema cai em "
"heurística por shape do DataFrame, que pode escolher errado."
),
)
_DESCRIPTION = (
"Renderiza um gráfico Plotly a partir do CSV produzido por run_sql na rodada anterior. "
"CHAME SEMPRE que o resultado da query for naturalmente visual — não espere o usuário pedir "
"a palavra 'gráfico' explicitamente; deduza do tipo de pergunta.\n"
"\n"
"GATILHOS OBRIGATÓRIOS (chame após o run_sql):\n"
"• Ranking / Top N — 'top 10 produtos', 'quais atendentes mais venderam', 'maiores clientes', "
"'ranking de X', 'melhores/piores Y' → chart_type='bar'.\n"
"• Série temporal — 'evolução', 'ao longo de', 'por dia/semana/mês/hora', 'últimos N dias', "
"'tendência', 'crescimento', 'comparar meses' → chart_type='line'.\n"
"• Comparação categórica — 'vendas por categoria', 'faturamento por produto', 'X por Y', "
"'distribuição', 'breakdown' → chart_type='bar'.\n"
"• Participação / share — 'participação', '% de', 'share' → chart_type='bar' "
"(evitar pizza com >3 fatias).\n"
"• Correlação — 'relação entre X e Y', 'preço vs volume', 'desconto vs faturamento' "
"→ chart_type='scatter'.\n"
"• Distribuição de UMA métrica — 'distribuição dos tickets', 'histograma de Y' "
"→ chart_type='histogram'.\n"
"• Acumulado temporal — 'faturamento acumulado por mês' → chart_type='area'.\n"
"\n"
"FORMA IDEAL DO CSV antes de chamar (responsabilidade do run_sql anterior):\n"
"• Série temporal: 1 coluna de tempo + 1-3 métricas.\n"
"• Ranking/categórico: 1 coluna de categoria + 1 métrica, máx 20-40 linhas.\n"
"• Scatter: 2 colunas numéricas.\n"
"• ≤3 colunas no total é o ponto ideal — se a query trouxer 4+ colunas, esta ferramenta "
"ainda gera chart real (não tabela), mas o resultado fica mais limpo com SELECT focado.\n"
"\n"
"QUANDO NÃO CHAMAR:\n"
"• Pergunta com resposta numérica única ('qual o faturamento de hoje?') — o número no texto "
"basta, gráfico não agrega.\n"
"• Usuário pediu explicitamente só a tabela ('me lista', 'exporta a tabela').\n"
"\n"
"ARGUMENTOS: filename = caminho do CSV retornado pelo run_sql anterior; "
"chart_type = tipo do gráfico (passe sempre que souber, ver lista acima); "
"title (opcional) = rótulo curto em pt-BR (ex: 'Faturamento por dia — últimos 30 dias')."
)
class ClubPetroChartGenerator(PlotlyChartGenerator):
"""Generator com:
- Coerção de datetime aplicada pra QUALQUER de colunas (uniforme).
- Override `chart_type` lido de ContextVar (vem do `VisualizeDataToolPT.execute`).
- Drop do fallback "4+ colunas → go.Table" do upstream sempre chart real.
"""
def generate_chart(
self,
df: pd.DataFrame,
title: str = "Chart",
chart_type: Optional[ChartType] = None,
) -> Dict[str, Any]:
if df.empty:
raise ValueError("Cannot visualize empty DataFrame")
chart_type = chart_type or _chart_type_override.get()
df = self._coerce_datetime_columns(df)
if chart_type is not None:
fig = self._render_forced(df, title, chart_type)
return json.loads(pio.to_json(fig))
if len(df.columns) < 4:
return super().generate_chart(df, title)
numeric_cols = df.select_dtypes(include=["number"]).columns.tolist()
categorical_cols = df.select_dtypes(
include=["object", "category"]
).columns.tolist()
datetime_cols = df.select_dtypes(include=["datetime64"]).columns.tolist()
if datetime_cols and numeric_cols:
fig = self._create_time_series_chart(
df.sort_values(datetime_cols[0]),
datetime_cols[0],
numeric_cols[:3],
title,
)
elif categorical_cols and numeric_cols:
cat = categorical_cols[0]
num = numeric_cols[0]
agg = (
df.groupby(cat)[num]
.sum()
.reset_index()
.sort_values(num, ascending=False)
.head(40)
)
fig = self._create_ranked_bar_chart(agg, cat, num, title)
elif len(numeric_cols) >= 2:
fig = self._create_scatter_plot(df, numeric_cols[0], numeric_cols[1], title)
else:
fig = self._create_generic_chart(df, df.columns[0], df.columns[1], title)
return json.loads(pio.to_json(fig))
def _render_forced(
self, df: pd.DataFrame, title: str, chart_type: ChartType
) -> go.Figure:
numeric_cols = df.select_dtypes(include=["number"]).columns.tolist()
categorical_cols = df.select_dtypes(
include=["object", "category"]
).columns.tolist()
datetime_cols = df.select_dtypes(include=["datetime64"]).columns.tolist()
if chart_type == "line":
if not numeric_cols:
raise ValueError(
"chart_type='line' requires at least one numeric column"
)
if datetime_cols:
return self._create_time_series_chart(
df.sort_values(datetime_cols[0]),
datetime_cols[0],
numeric_cols[:5],
title,
)
x_col = (
categorical_cols[0]
if categorical_cols
else df.columns[0]
)
y_cols = [c for c in numeric_cols if c != x_col][:5]
if not y_cols:
raise ValueError(
"chart_type='line' needs a numeric column distinct from the x-axis"
)
return self._create_time_series_chart(df, x_col, y_cols, title)
if chart_type == "area":
if not numeric_cols:
raise ValueError(
"chart_type='area' requires at least one numeric column"
)
x_col = (
datetime_cols[0]
if datetime_cols
else (categorical_cols[0] if categorical_cols else df.columns[0])
)
ordered = df.sort_values(x_col) if datetime_cols else df
y_cols = [c for c in numeric_cols if c != x_col][:5]
if not y_cols:
raise ValueError(
"chart_type='area' needs a numeric column distinct from the x-axis"
)
return self._create_area_chart(ordered, x_col, y_cols, title)
if chart_type == "bar":
if not numeric_cols:
raise ValueError(
"chart_type='bar' requires at least one numeric column"
)
x_col = categorical_cols[0] if categorical_cols else df.columns[0]
y_col = next((c for c in numeric_cols if c != x_col), None)
if y_col is None:
raise ValueError(
"chart_type='bar' needs a numeric column distinct from the x-axis"
)
agg = (
df.groupby(x_col)[y_col]
.sum()
.reset_index()
.sort_values(y_col, ascending=False)
.head(40)
)
return self._create_ranked_bar_chart(agg, x_col, y_col, title)
if chart_type == "scatter":
if len(numeric_cols) < 2:
raise ValueError(
"chart_type='scatter' requires 2 numeric columns"
)
return self._create_scatter_plot(
df, numeric_cols[0], numeric_cols[1], title
)
if chart_type == "histogram":
if not numeric_cols:
raise ValueError(
"chart_type='histogram' requires at least one numeric column"
)
return self._create_histogram(df, numeric_cols[0], title)
raise ValueError(f"Unknown chart_type: {chart_type!r}")
def _create_ranked_bar_chart(
self,
df: pd.DataFrame,
x_col: str,
y_col: str,
title: str,
) -> go.Figure:
"""Bar chart preservando a ordem do `df` (sem re-groupby).
Upstream `_create_bar_chart` re-agrega com `groupby(x_col).sum()`
e perde a ordenação descendente bars saem alfabéticos. Pra
ranking real precisamos travar `categoryorder=array`.
"""
order = df[x_col].astype(str).tolist()
fig = go.Figure(
data=[
go.Bar(
x=order,
y=df[y_col].tolist(),
marker_color=self.THEME_COLORS["orange"],
)
]
)
fig.update_layout(
title=title,
xaxis_title=x_col,
yaxis_title=y_col,
xaxis={"categoryorder": "array", "categoryarray": order},
)
self._apply_standard_layout(fig)
return fig
def _create_area_chart(
self,
df: pd.DataFrame,
x_col: str,
y_cols: List[str],
title: str,
) -> go.Figure:
fig = go.Figure()
for i, col in enumerate(y_cols):
color = self.COLOR_PALETTE[i % len(self.COLOR_PALETTE)]
fig.add_trace(
go.Scatter(
x=df[x_col],
y=df[col],
mode="lines",
name=col,
fill="tozeroy" if i == 0 else "tonexty",
line=dict(color=color),
)
)
fig.update_layout(
title=title,
xaxis_title=x_col,
yaxis_title="Value",
hovermode="x unified",
)
self._apply_standard_layout(fig)
return fig
@staticmethod
def _coerce_datetime_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Converte colunas object que parecem date/datetime em datetime64.
ClickHouse devolve datas como string no CSV; pandas como object e
a heurística de datetime do upstream falha. Aceita dois formatos:
- ISO `2026-01-01` (ou `2026/01/01`) quando a query devolve o
valor cru (DateTime sem coerção downstream).
- BR `01/01/2026` quando o `RLSClickHouseRunner` formata
colunas datetime pra exibição no rich component (
`_format_date_columns` em rls_runner.py). pandas precisa
`dayfirst=True` ou seria parseado como mês/dia (US).
Best-effort (`errors="raise"` dentro do try) converte colunas
que são parseáveis 100%.
"""
for col in df.select_dtypes(include=["object"]).columns:
sample = df[col].dropna().astype(str).head(5).tolist()
if not sample:
continue
looks_like_iso = all(
len(s) >= 8 and s[:4].isdigit() and s[4] in "-/"
for s in sample
)
looks_like_br = all(
len(s) >= 10
and s[:2].isdigit()
and s[2] == "/"
and s[3:5].isdigit()
and s[5] == "/"
and s[6:10].isdigit()
for s in sample
)
if not (looks_like_iso or looks_like_br):
continue
try:
df[col] = pd.to_datetime(
df[col], dayfirst=looks_like_br, errors="raise"
)
except (ValueError, TypeError):
pass
return df
class VisualizeDataToolPT(VisualizeDataTool):
"""VisualizeDataTool com schema PT-BR (chart_type) + generator custom."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("plotly_generator", ClubPetroChartGenerator())
super().__init__(*args, **kwargs)
@property
def description(self) -> str:
return _DESCRIPTION
def get_args_schema(self) -> Type[VisualizeDataArgsPT]:
return VisualizeDataArgsPT
async def execute(
self, context: ToolContext, args: VisualizeDataArgsPT
) -> ToolResult:
record_chart(args.chart_type or "auto", args.title or "")
token = _chart_type_override.set(args.chart_type)
try:
return await super().execute(context, args)
finally:
_chart_type_override.reset(token)