From 1d152c0dce28e2b77f20cbeb7593f6e568215792 Mon Sep 17 00:00:00 2001 From: leonardosalazar-cp Date: Wed, 29 Apr 2026 17:15:24 -0300 Subject: [PATCH] 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 --- .gitignore | 10 + CLAUDE.md | 256 ++++++++++++++++++++ README.md | 82 +++++++ agent.py | 182 +++++++++++++++ ask.py | 48 ++++ chat_filter.py | 137 +++++++++++ csv_cleanup.py | 60 +++++ docs/deploy.md | 363 +++++++++++++++++++++++++++++ docs/embed-react.md | 401 ++++++++++++++++++++++++++++++++ events_sink.py | 154 ++++++++++++ rls_runner.py | 155 ++++++++++++ server.py | 95 ++++++++ static/clubpetro-logo.png | Bin 0 -> 1457919 bytes static/clubpetro-logo.svg | 10 + static/dashboard-bg.png | Bin 0 -> 527180 bytes static/embed-demo.html | 186 +++++++++++++++ static/vanna-embed-bootstrap.js | 317 +++++++++++++++++++++++++ static/vanna-theme.css | 392 +++++++++++++++++++++++++++++++ system_prompt.py | 121 ++++++++++ tenant_memory.py | 204 ++++++++++++++++ test_clickhouse.py | 27 +++ train.py | 107 +++++++++ viz_tool.py | 380 ++++++++++++++++++++++++++++++ 23 files changed, 3687 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 agent.py create mode 100644 ask.py create mode 100644 chat_filter.py create mode 100644 csv_cleanup.py create mode 100644 docs/deploy.md create mode 100644 docs/embed-react.md create mode 100644 events_sink.py create mode 100644 rls_runner.py create mode 100644 server.py create mode 100644 static/clubpetro-logo.png create mode 100644 static/clubpetro-logo.svg create mode 100644 static/dashboard-bg.png create mode 100644 static/embed-demo.html create mode 100644 static/vanna-embed-bootstrap.js create mode 100644 static/vanna-theme.css create mode 100644 system_prompt.py create mode 100644 tenant_memory.py create mode 100644 test_clickhouse.py create mode 100644 train.py create mode 100644 viz_tool.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4056d5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +.env.local +venv/ +__pycache__/ +*.pyc +.vanna/ +vanna/ +chroma_db/ +data_storage/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..27a219b --- /dev/null +++ b/CLAUDE.md @@ -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 `` (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**: `` 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__s` 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 ``: 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` → `
` → cleanup `
` 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` (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 ` + + + + +``` +Fonts são carregadas pelo bootstrap (`loadFontsOnce`) — o cliente não precisa mais incluir `` 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 (``) 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//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 + `` 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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e56db30 --- /dev/null +++ b/README.md @@ -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 --store-id "..." +``` + +Servidor web (embed do ``): +```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. diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..fa7d294 --- /dev/null +++ b/agent.py @@ -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"}) diff --git a/ask.py b/ask.py new file mode 100644 index 0000000..6502c57 --- /dev/null +++ b/ask.py @@ -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 --store-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, + ) + ) diff --git a/chat_filter.py b/chat_filter.py new file mode 100644 index 0000000..d9f5bcd --- /dev/null +++ b/chat_filter.py @@ -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) diff --git a/csv_cleanup.py b/csv_cleanup.py new file mode 100644 index 0000000..1e2c615 --- /dev/null +++ b/csv_cleanup.py @@ -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 diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..b2c34e0 --- /dev/null +++ b/docs/deploy.md @@ -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 ''; + +-- 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 + +`` é 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 + + + + + + + + + + + + + + + + + +``` + +> ⚠️ 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 `` 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 `` no `` 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..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. diff --git a/docs/embed-react.md b/docs/embed-react.md new file mode 100644 index 0000000..dd0c67b --- /dev/null +++ b/docs/embed-react.md @@ -0,0 +1,401 @@ +# Embedar `` (chat flutuante) num app React + +Guia para o time frontend embedar o web component `` 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 ` + + + + +``` + +### Etapa 2 — Carregar o bundle + +```html + + +``` + +### Etapa 3 — Componente React `` + +```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(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 + + ); +} +``` + +**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 & { + "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 ( + <> + {/* ... suas rotas ... */} + + {user && currentStore && ( + + )} + + ); +} +``` + +**Importante:** se o usuário trocar de loja (sem re-login), force remount adicionando `key`: + +```tsx + +``` + +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, `` 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 `` + +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 `` + +```tsx +// src/components/VannaCTA.tsx +import { useEffect, useState } from "react"; + +interface VannaCTAProps { + /** Ref opcional para o . 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 ( + + ); +} +``` + +Renderize **junto** com o ``: + +```tsx +{user && currentStore && ( + <> + + + +)} +``` + +--- + +## 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 ``; o pierce DEVE preceder o ` + + + +
+

Vanna chat — demo

+

+ Esta página embeda o web component oficial + <vanna-chat> com + starting-state="minimized" — o botão flutuante aparece no + canto inferior direito. Os IDs vêm do .env via + substituição no handler. +

+

+ Em produção, o app cliente cola um snippet semelhante na página dele, + substituindo __PROGRAM_ID__ / __STORE_ID__ + pelos IDs do tenant logado. +

+
+ + + + + + + + + + diff --git a/static/vanna-embed-bootstrap.js b/static/vanna-embed-bootstrap.js new file mode 100644 index 0000000..af18cb5 --- /dev/null +++ b/static/vanna-embed-bootstrap.js @@ -0,0 +1,317 @@ +/** + * Vanna chat embed bootstrap — fonte única do JS de wiring exigido pelo + * : 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 + * - 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, ">"); + + const mdToHtml = (md) => { + if (!md) return ""; + let s = escapeHtml(md); + s = s.replace(/`([^`]+)`/g, "$1"); + s = s.replace(/\*\*\*([^*]+)\*\*\*/g, "$1"); + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1$2"); + s = s.replace(/(^|[^\w])_([^_\n]+)_(?!\w)/g, "$1$2"); + s = s.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '$1' + ); + // 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 `` (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}${text.trim()}` + ); + s = s.replace(/(?:^|\n)((?:[-*]\s+.+(?:\n|$))+)/g, (_, block) => { + const items = block + .trim() + .split(/\n/) + .map((l) => l.replace(/^[-*]\s+/, "").trim()) + .map((t) => `
  • ${t}
  • `) + .join(""); + return `\n
      ${items}
    \n`; + }); + s = s.replace(/\n/g, "
    "); + s = s.replace(/
    \s*(<\/?(?:ul|li|h[1-6])>)/g, "$1"); + s = s.replace(/(<\/?(?:ul|li|h[1-6])>)\s*
    /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; + }, + }; +})(); diff --git a/static/vanna-theme.css b/static/vanna-theme.css new file mode 100644 index 0000000..6d6fe28 --- /dev/null +++ b/static/vanna-theme.css @@ -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 + * 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 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
    ${initials}
    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 ), 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)); + } +} diff --git a/system_prompt.py b/system_prompt.py new file mode 100644 index 0000000..e111053 --- /dev/null +++ b/system_prompt.py @@ -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(, 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?"). +""" diff --git a/tenant_memory.py b/tenant_memory.py new file mode 100644 index 0000000..ccd2e25 --- /dev/null +++ b/tenant_memory.py @@ -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 `` — schema docs do `train.py`. + Mesma collection que o app sempre usou; intacto. + • per-tenant (tool usage) — collection `__p__s`, + 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 + ) diff --git a/test_clickhouse.py b/test_clickhouse.py new file mode 100644 index 0000000..9d60d97 --- /dev/null +++ b/test_clickhouse.py @@ -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() diff --git a/train.py b/train.py new file mode 100644 index 0000000..7a265b1 --- /dev/null +++ b/train.py @@ -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()) diff --git a/viz_tool.py b/viz_tool.py new file mode 100644 index 0000000..2b44c99 --- /dev/null +++ b/viz_tool.py @@ -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)