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:
commit
1d152c0dce
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.vanna/
|
||||||
|
vanna/
|
||||||
|
chroma_db/
|
||||||
|
data_storage/
|
||||||
|
.DS_Store
|
||||||
256
CLAUDE.md
Normal file
256
CLAUDE.md
Normal 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
82
README.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Lê `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
182
agent.py
Normal 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
48
ask.py
Normal 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
137
chat_filter.py
Normal 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 já 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
60
csv_cleanup.py
Normal 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
363
docs/deploy.md
Normal 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
401
docs/embed-react.md
Normal 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
154
events_sink.py
Normal 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
155
rls_runner.py
Normal 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
95
server.py
Normal 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
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
10
static/clubpetro-logo.svg
Normal 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
BIN
static/dashboard-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 515 KiB |
186
static/embed-demo.html
Normal file
186
static/embed-demo.html
Normal 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><vanna-chat></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>
|
||||||
317
static/vanna-embed-bootstrap.js
Normal file
317
static/vanna-embed-bootstrap.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
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
392
static/vanna-theme.css
Normal 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., já 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. Só 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
|
||||||
|
* — vê 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 já é 0x0 com overflow:hidden, então o chat-layout
|
||||||
|
* é clippado visualmente sem precisar mudar suas próprias dimensões.
|
||||||
|
* Plotly não vê 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 só 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 já 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 lá 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 já 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) só 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 só 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 vê 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
121
system_prompt.py
Normal 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 só 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 já 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 dá.
|
||||||
|
|
||||||
|
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 há 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 — só 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 dê 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
204
tenant_memory.py
Normal 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 já valida via
|
||||||
|
`_require_id` no resolver, então só chega aqui já checado. `train.py` roda com
|
||||||
|
user "trainer" sem program/store; só 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) já
|
||||||
|
garante `^[A-Za-z0-9_-]+$`, então só 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
27
test_clickhouse.py
Normal 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
107
train.py
Normal 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
380
viz_tool.py
Normal 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 só 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 só 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 nº 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 lê 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 (vê
|
||||||
|
`_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) — só 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)
|
||||||
Loading…
Reference in New Issue
Block a user