Este documento descreve a arquitetura necessaria para escalar o sistema de agentes do i9-agents-bot de um unico agente virtual para N agentes independentes, cada um com suas proprias bases de conhecimento (Vaults), personalidade, configuracoes de LLM e canais de atendimento.
Alem disso, introduz a camada de Data Sources — conexoes diretas com bancos MySQL dos clientes, onde cada cliente possui seu proprio schema. Atraves de Materialized Views, os agentes podem responder perguntas baseadas nos dados reais e atualizados de cada cliente (pedidos, estoque, financeiro, etc.), combinando RAG semantico com consulta estruturada.
- Arquitetura Atual
- Arquitetura Proposta para N Agentes
- Modelagem de Dados
- Fontes de Conhecimento (Knowledge Sources)
- Data Sources: Conexao com MySQL dos Clientes
- Materialized Views para Agentes
- Pipeline Hibrido: RAG + SQL Estruturado
- Fluxo de Conversa com Multiplos Agentes
- Pipeline RAG por Agente
- Migracoes Necessarias
- Exemplos de Configuracao
- Seguranca e Isolamento de Dados
- Consideracoes de Performance
Hoje o sistema opera com um unico agente virtual implicito. Todos os componentes compartilham o mesmo contexto:
Webhook → MessageProcessor → RAG Pipeline → LLM → Resposta
↓
Todos os Documentos
Todos os Vaults
Todos os Prompts
| Componente | Limitacao |
|---|---|
| Vaults/Documentos | Todos os documentos sao buscados globalmente — nao ha separacao por agente |
| Prompts | Prompts sao globais (por key) — todos usam a mesma personalidade |
| Conversas | Nao ha vinculo entre conversa e agente virtual especifico |
| LLM Config | Modelo e parametros sao fixos no codigo (gpt-4o-mini) |
| Dados do Cliente | Nenhuma conexao com o banco de dados real do cliente (MySQL) |
Cada Agente Virtual (Bot) e uma entidade independente com:
- Identidade propria: nome, avatar, personalidade
- Bases de conhecimento dedicadas: vaults e documentos vinculados
- Data Sources: conexoes com schemas MySQL do cliente para dados em tempo real
- Materialized Views: views pre-computadas para consultas frequentes
- Configuracao de LLM: modelo, temperatura, tokens maximos
- Prompts personalizados: personalidade, regras, fallbacks
- Canais de atendimento: numeros de WhatsApp, webhooks
┌─────────────────────────────┐
│ Webhook Recebido │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ Identificar Agente Virtual │
│ (por telefone/canal/rota) │
└──────────────┬──────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌────────▼───────┐ ┌────────▼───────┐ ┌─────────▼──────┐
│ Agente "Loja" │ │ Agente "Suporte"│ │ Agente "Vendas"│
│ │ │ │ │ │
│ Vault: Produtos │ │ Vault: FAQ │ │ Vault: Precos │
│ Vault: Entregas │ │ Vault: Manuais │ │ Vault: Propostas│
│ │ │ │ │ │
│ MySQL: loja_abc │ │ MySQL: suporte │ │ MySQL: vendas │
│ → mv_pedidos │ │ → mv_tickets │ │ → mv_pipeline │
│ → mv_estoque │ │ → mv_sla │ │ → mv_comissoes│
│ │ │ │ │ │
│ LLM: gpt-4o │ │ LLM: gpt-4o-mini│ │ LLM: claude │
└────────┬────────┘ └────────┬────────┘ └────────┬───────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌──────────────▼──────────────┐
│ Pipeline Hibrido │
│ RAG (Vaults) + SQL (MySQL) │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ Resposta ao Usuario │
└─────────────────────────────┘
┌──────────────────┐ ┌──────────────────────┐
│ virtual_agents │ │ virtual_agent_vaults │
├──────────────────┤ ├──────────────────────┤
│ id (PK) │──┐ │ id (PK) │
│ name │ │ │ virtual_agent_id (FK)│──→ virtual_agents.id
│ slug │ │ │ vault_id (FK) │──→ vaults.id
│ description │ └───→│ priority │
│ avatar_url │ │ inserted_at │
│ personality │ └──────────────────────┘
│ llm_provider │
│ llm_model │ ┌──────────────────────┐
│ llm_config │ │ virtual_agent_prompts│
│ is_active │ ├──────────────────────┤
│ metadata │ │ id (PK) │
│ inserted_at │ ┌───→│ virtual_agent_id (FK)│
│ updated_at │──┘ │ prompt_id (FK) │──→ prompts.id
└──────────────────┘ │ inserted_at │
│ └──────────────────────┘
│
│ ┌──────────────────────────┐
│ │ data_sources │
│ ├──────────────────────────┤
└─→│ id (PK) │
│ virtual_agent_id (FK) │──→ virtual_agents.id
│ name │
│ db_type (mysql/pg) │
│ host │
│ port │
│ database_name │
│ schema_name │ ← schema do cliente no MySQL
│ credentials (encrypted) │
│ is_active │
│ sync_config │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ materialized_views │
├─────────────────────────┤
│ id (PK) │
│ data_source_id (FK) │──→ data_sources.id
│ virtual_agent_id (FK) │──→ virtual_agents.id
│ view_name │
│ source_query │ ← SELECT original no MySQL
│ description │ ← descricao para o LLM entender
│ column_descriptions │ ← JSONB com descricao de cada coluna
│ refresh_strategy │ ← cron, on_demand, event_driven
│ refresh_interval_minutes│
│ last_refreshed_at │
│ row_count │
│ status │
└─────────────────────────┘
┌──────────────────┐ ┌──────────────────────┐
│ vaults │ │ documents │
├──────────────────┤ ├──────────────────────┤
│ id (PK) │──────→│ vault_id (FK) │
│ name │ │ id (PK) │
│ description │ │ title │
│ status │ │ content │
│ metadata │ │ embedding (vector) │
└──────────────────┘ │ metadata │
└──────────────────────┘
┌──────────────────┐
│ conversations │
├──────────────────┤
│ id (PK) │
│ virtual_agent_id │──→ virtual_agents.id (NOVO)
│ phone_number │
│ session_id │
│ status │
│ assigned_agent_id│──→ support_agents.id
│ ... │
└──────────────────┘
┌──────────────────────────┐
│ virtual_agent_channels │
├──────────────────────────┤
│ id (PK) │
│ virtual_agent_id (FK) │──→ virtual_agents.id
│ channel_type │
│ channel_identifier │
│ is_active │
│ metadata │
└──────────────────────────┘
Tabela central que define cada agente virtual com sua configuracao completa.
CREATE TABLE virtual_agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
avatar_url VARCHAR(500),
-- Personalidade e Comportamento
personality TEXT,
welcome_message TEXT,
fallback_message TEXT,
-- Configuracao do LLM
llm_provider VARCHAR(50) NOT NULL DEFAULT 'openai',
llm_model VARCHAR(100) NOT NULL DEFAULT 'gpt-4o-mini',
llm_config JSONB DEFAULT '{}',
-- Configuracao do RAG
rag_config JSONB DEFAULT '{
"retrieval_limit": 10,
"rerank_limit": 5,
"similarity_threshold": 0.7,
"cache_ttl": 3600
}',
-- Configuracao de Embeddings
embedding_provider VARCHAR(50) DEFAULT 'openai',
embedding_model VARCHAR(100) DEFAULT 'text-embedding-3-small',
embedding_dimensions INTEGER DEFAULT 1536,
-- Estado
is_active BOOLEAN DEFAULT true,
metadata JSONB DEFAULT '{}',
inserted_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX virtual_agents_slug_index ON virtual_agents(slug);
CREATE INDEX virtual_agents_is_active_index ON virtual_agents(is_active);CREATE TABLE virtual_agent_vaults (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
virtual_agent_id UUID NOT NULL REFERENCES virtual_agents(id) ON DELETE CASCADE,
vault_id UUID NOT NULL REFERENCES vaults(id) ON DELETE CASCADE,
priority INTEGER DEFAULT 0,
inserted_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(virtual_agent_id, vault_id)
);
CREATE INDEX virtual_agent_vaults_agent_index ON virtual_agent_vaults(virtual_agent_id);CREATE TABLE virtual_agent_prompts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
virtual_agent_id UUID NOT NULL REFERENCES virtual_agents(id) ON DELETE CASCADE,
prompt_id UUID NOT NULL REFERENCES prompts(id) ON DELETE CASCADE,
inserted_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(virtual_agent_id, prompt_id)
);CREATE TABLE virtual_agent_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
virtual_agent_id UUID NOT NULL REFERENCES virtual_agents(id) ON DELETE CASCADE,
channel_type VARCHAR(50) NOT NULL,
channel_identifier VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
metadata JSONB DEFAULT '{}',
inserted_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(channel_type, channel_identifier)
);
CREATE INDEX virtual_agent_channels_lookup_index ON virtual_agent_channels(channel_type, channel_identifier);Armazena as credenciais e configuracoes de conexao com o MySQL de cada cliente. Cada cliente tem seu proprio schema no MySQL, e cada agente pode acessar um ou mais data sources.
CREATE TABLE data_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
virtual_agent_id UUID NOT NULL REFERENCES virtual_agents(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, -- "Banco Loja ABC", "ERP Cliente X"
description TEXT,
-- Conexao
db_type VARCHAR(20) NOT NULL DEFAULT 'mysql', -- mysql, postgres, mssql
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL DEFAULT 3306,
database_name VARCHAR(255) NOT NULL,
schema_name VARCHAR(255), -- schema especifico do cliente no MySQL
credentials BYTEA NOT NULL, -- credenciais criptografadas (AES-256-GCM)
-- Configuracao de Sincronizacao
sync_config JSONB DEFAULT '{
"pool_size": 2,
"timeout_ms": 15000,
"ssl_enabled": true,
"read_only": true
}',
-- Pool de Conexao Dinamico
pool_id VARCHAR(100), -- identificador do pool no DynamicSupervisor
-- Estado
is_active BOOLEAN DEFAULT true,
last_connected_at TIMESTAMP,
last_error TEXT,
metadata JSONB DEFAULT '{}',
inserted_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX data_sources_agent_index ON data_sources(virtual_agent_id);
CREATE INDEX data_sources_active_index ON data_sources(is_active);Cada materialized view e uma "foto" pre-processada dos dados do MySQL do cliente, armazenada localmente no PostgreSQL para consulta rapida pelo agente. O LLM usa as descricoes das colunas para entender o que pode consultar.
CREATE TABLE materialized_views (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
data_source_id UUID NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
virtual_agent_id UUID NOT NULL REFERENCES virtual_agents(id) ON DELETE CASCADE,
-- Definicao da View
view_name VARCHAR(255) NOT NULL, -- "mv_pedidos_recentes"
display_name VARCHAR(255), -- "Pedidos Recentes" (para o LLM)
description TEXT NOT NULL, -- "Pedidos dos ultimos 90 dias com status, valor e cliente"
source_query TEXT NOT NULL, -- SELECT no MySQL de origem
local_table_name VARCHAR(255) NOT NULL, -- tabela PostgreSQL onde os dados ficam
-- Metadados para o LLM (Text-to-SQL)
column_descriptions JSONB NOT NULL DEFAULT '{}',
-- Exemplo:
-- {
-- "pedido_id": "Identificador unico do pedido",
-- "cliente_nome": "Nome completo do cliente",
-- "valor_total": "Valor total do pedido em reais (BRL)",
-- "status": "Status do pedido: pendente, aprovado, enviado, entregue, cancelado",
-- "data_criacao": "Data em que o pedido foi realizado"
-- }
-- Queries permitidas (seguranca)
allowed_query_patterns JSONB DEFAULT '[]',
-- Exemplo: ["SELECT * WHERE status = ?", "SELECT COUNT(*) GROUP BY ?"]
-- Exemplos de perguntas naturais que esta view responde
example_questions JSONB DEFAULT '[]',
-- Exemplo: [
-- "Quantos pedidos foram feitos hoje?",
-- "Qual o valor total de vendas do mes?",
-- "Quais pedidos estao pendentes?"
-- ]
-- Refresh
refresh_strategy VARCHAR(50) DEFAULT 'scheduled', -- scheduled, on_demand, event_driven, realtime
refresh_cron VARCHAR(50) DEFAULT '*/30 * * * *', -- a cada 30 min
refresh_interval_minutes INTEGER DEFAULT 30,
last_refreshed_at TIMESTAMP,
next_refresh_at TIMESTAMP,
refresh_duration_ms INTEGER, -- quanto tempo levou o ultimo refresh
-- Estatisticas
row_count INTEGER DEFAULT 0,
data_size_bytes BIGINT DEFAULT 0,
-- Estado
status VARCHAR(50) DEFAULT 'pending', -- pending, syncing, active, error, stale
last_error TEXT,
is_active BOOLEAN DEFAULT true,
inserted_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(virtual_agent_id, view_name)
);
CREATE INDEX materialized_views_agent_index ON materialized_views(virtual_agent_id);
CREATE INDEX materialized_views_source_index ON materialized_views(data_source_id);
CREATE INDEX materialized_views_status_index ON materialized_views(status);CREATE TABLE materialized_view_refresh_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
materialized_view_id UUID NOT NULL REFERENCES materialized_views(id) ON DELETE CASCADE,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
duration_ms INTEGER,
rows_synced INTEGER DEFAULT 0,
status VARCHAR(50) NOT NULL, -- success, error, timeout, partial
error_message TEXT,
metadata JSONB DEFAULT '{}', -- bytes_transferred, query_time, etc.
inserted_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX mv_refresh_log_view_index ON materialized_view_refresh_log(materialized_view_id);
CREATE INDEX mv_refresh_log_status_index ON materialized_view_refresh_log(status);Registra todas as queries SQL que o agente executou contra os dados do cliente, para auditoria e seguranca.
CREATE TABLE query_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
virtual_agent_id UUID NOT NULL REFERENCES virtual_agents(id),
materialized_view_id UUID REFERENCES materialized_views(id),
conversation_id UUID REFERENCES conversations(id),
-- Query
user_question TEXT NOT NULL, -- Pergunta original do usuario
generated_sql TEXT NOT NULL, -- SQL gerado pelo LLM
sanitized_sql TEXT, -- SQL apos sanitizacao
was_executed BOOLEAN DEFAULT false,
execution_time_ms INTEGER,
rows_returned INTEGER,
-- Seguranca
risk_score FLOAT DEFAULT 0.0, -- 0.0 = seguro, 1.0 = bloqueado
blocked BOOLEAN DEFAULT false,
block_reason TEXT,
inserted_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX query_audit_agent_index ON query_audit_log(virtual_agent_id);
CREATE INDEX query_audit_conversation_index ON query_audit_log(conversation_id);ALTER TABLE conversations
ADD COLUMN virtual_agent_id UUID REFERENCES virtual_agents(id);
CREATE INDEX conversations_virtual_agent_index ON conversations(virtual_agent_id);O sistema agora tem dois tipos de fontes de conhecimento que trabalham em conjunto:
Os Vaults sao colecoes de documentos para busca semantica via embeddings:
- Conteudo textual (
content) - Embedding vetorial (
embedding— pgvector 1536 dimensoes) - Metadados (
metadata— JSON com tags, fonte, etc.)
Ideal para: FAQs, manuais, politicas, tutoriais, documentacao.
Os Data Sources conectam ao MySQL do cliente e as Materialized Views trazem snapshots dos dados para consulta local:
- Dados tabulares reais do cliente (pedidos, estoque, clientes, financeiro)
- Atualizados periodicamente via cron ou sob demanda
- Consultados via Text-to-SQL — o LLM gera queries SQL a partir de perguntas naturais
Ideal para: "Quantos pedidos temos hoje?", "Qual o estoque do produto X?", "Quanto faturamos no mes?"
Pergunta do Usuario
│
▼
┌──────────────────────────────┐
│ Classificador de Intencao │
│ (LLM decide o tipo) │
└──────┬───────────┬───────────┘
│ │
Semantica Estruturada
│ │
▼ ▼
┌────────────┐ ┌────────────────┐
│ RAG Search │ │ Text-to-SQL │
│ (Vaults) │ │ (Mat. Views) │
│ │ │ │
│ Documentos │ │ Dados reais │
│ similares │ │ do cliente │
└──────┬─────┘ └───────┬────────┘
│ │
└───────┬───────┘
│
┌───────▼───────┐
│ Contexto │
│ Combinado │
│ (docs + dados)│
└───────┬───────┘
│
┌───────▼───────┐
│ LLM gera │
│ resposta final │
└───────────────┘
Cenario 1: Vaults Exclusivos + Data Source Proprio
──────────────────────────────────────────────────
Agente A → [Vault Produtos] + [MySQL: schema_cliente_a → mv_pedidos, mv_estoque]
Agente B → [Vault Suporte] + [MySQL: schema_cliente_b → mv_tickets]
Cenario 2: Vaults Compartilhados + Data Sources Isolados
─────────────────────────────────────────────────────────
Agente A → [Vault FAQ Global] ← Agente B
Agente A → [MySQL: schema_a] (NUNCA compartilhado)
Agente B → [MySQL: schema_b] (NUNCA compartilhado)
Cenario 3: Multiplos Data Sources por Agente
────────────────────────────────────────────
Agente A → [MySQL: erp_producao → mv_pedidos, mv_clientes]
Agente A → [MySQL: erp_financeiro → mv_faturamento, mv_inadimplencia]
Agente A → [Vault: Manual de Operacoes]
Cada cliente tem seu proprio schema no MySQL. O sistema se conecta de forma isolada e read-only.
┌─────────────────────────────────────────────────┐
│ MySQL Server do Cliente │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────┐│
│ │ schema_loja_a│ │ schema_loja_b│ │schema_c ││
│ │ │ │ │ │ ││
│ │ pedidos │ │ pedidos │ │ vendas ││
│ │ produtos │ │ produtos │ │ clientes ││
│ │ clientes │ │ clientes │ │ estoque ││
│ │ estoque │ │ financeiro │ │ ││
│ └──────┬──────┘ └──────┬──────┘ └────┬─────┘│
│ │ │ │ │
└─────────┼────────────────┼───────────────┼──────┘
│ │ │
┌────▼────┐ ┌───▼────┐ ┌───▼────┐
│Agente A │ │Agente B│ │Agente C│
│(read) │ │(read) │ │(read) │
└─────────┘ └────────┘ └────────┘
Cada data source ativo inicia um pool de conexoes dinamico via DynamicSupervisor:
defmodule AgentsBot.DataSources.ConnectionManager do
@moduledoc """
Gerencia pools de conexao MySQL dinamicos para cada data source ativo.
Cada pool e supervisionado independentemente — se um cliente
ficar offline, os outros agentes nao sao afetados.
"""
use GenServer
def start_pool(data_source) do
config = decrypt_credentials(data_source)
child_spec = {
MyXQL,
hostname: config.host,
port: config.port,
database: config.database_name,
username: config.username,
password: config.password,
pool_size: data_source.sync_config["pool_size"] || 2,
ssl: data_source.sync_config["ssl_enabled"] || true,
# CRITICO: conexao somente leitura
after_connect: &set_read_only/1,
name: pool_name(data_source.id)
}
DynamicSupervisor.start_child(
AgentsBot.DataSources.PoolSupervisor,
child_spec
)
end
def query(data_source_id, sql, params \\ []) do
pool = pool_name(data_source_id)
MyXQL.query(pool, sql, params, timeout: 15_000)
end
defp set_read_only(conn) do
MyXQL.query!(conn, "SET SESSION TRANSACTION READ ONLY")
end
defp pool_name(data_source_id) do
:"data_source_pool_#{data_source_id}"
end
enddefmodule AgentsBot.DataSources.PoolSupervisor do
use DynamicSupervisor
def start_link(init_arg) do
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
def init(_init_arg) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end
# No Application.start/2, apos o Repo:
defmodule AgentsBot.Application do
def start(_type, _args) do
children = [
AgentsBot.Repo,
AgentsBot.DataSources.PoolSupervisor,
# ... outros supervisores
]
# Apos startup, iniciar pools para data sources ativos
Task.async(fn ->
AgentsBot.DataSources.list_active()
|> Enum.each(&AgentsBot.DataSources.ConnectionManager.start_pool/1)
end)
end
endMaterialized Views neste contexto sao tabelas PostgreSQL locais que contem uma copia processada dos dados do MySQL do cliente. Diferente de views SQL tradicionais, elas armazenam os dados fisicamente para consulta rapida.
| Motivo | Explicacao |
|---|---|
| Latencia | Consultar o MySQL do cliente a cada pergunta do usuario adiciona 100-500ms |
| Carga no Cliente | Queries do agente nao devem impactar o banco de producao do cliente |
| Disponibilidade | Se o MySQL do cliente cair, o agente continua respondendo com dados recentes |
| Seguranca | Dados pre-processados permitem remover colunas sensiveis (CPF, senha, etc.) |
| Transformacao | Podemos agregar, filtrar e normalizar dados antes de disponibilizar |
1. DEFINICAO
Admin configura:
- source_query: "SELECT id, nome, valor, status, created_at FROM schema_a.pedidos WHERE created_at > NOW() - INTERVAL 90 DAY"
- column_descriptions: { "id": "ID do pedido", "valor": "Valor em R$", ... }
- refresh_cron: "*/30 * * * *"
2. SYNC (Refresh)
Worker Oban/GenServer executa periodicamente:
- Conecta ao MySQL via pool do data_source
- Executa source_query
- Trunca tabela local no PostgreSQL
- Insere resultados em batch
- Atualiza last_refreshed_at e row_count
3. CONSULTA
Quando usuario pergunta algo:
- LLM identifica qual materialized view responde
- LLM gera SQL seguro contra a tabela local
- Query executada no PostgreSQL (rapido, local)
- Resultado formatado e incluido na resposta
4. MONITORAMENTO
- Refresh log registra cada sincronizacao
- Alertas se uma view fica "stale" (sem refresh)
- Metricas de tempo de sync e rows transferidos
defmodule AgentsBot.DataSources.SyncWorker do
@moduledoc """
Worker que sincroniza materialized views do MySQL para o PostgreSQL local.
Pode ser executado via Oban (cron) ou sob demanda.
"""
def sync_view(materialized_view) do
data_source = get_data_source(materialized_view.data_source_id)
started_at = DateTime.utc_now()
with {:ok, result} <- fetch_from_mysql(data_source, materialized_view.source_query),
{:ok, count} <- write_to_local(materialized_view.local_table_name, result.rows, result.columns) do
update_view_status(materialized_view, %{
status: "active",
last_refreshed_at: started_at,
row_count: count,
refresh_duration_ms: DateTime.diff(DateTime.utc_now(), started_at, :millisecond)
})
log_refresh(materialized_view, :success, count, started_at)
else
{:error, reason} ->
update_view_status(materialized_view, %{status: "error", last_error: inspect(reason)})
log_refresh(materialized_view, :error, 0, started_at, inspect(reason))
end
end
defp fetch_from_mysql(data_source, query) do
ConnectionManager.query(data_source.id, query)
end
defp write_to_local(table_name, rows, columns) do
Repo.transaction(fn ->
# Trunca tabela local
Repo.query!("TRUNCATE TABLE #{table_name}")
# Insere em batches de 1000
rows
|> Enum.chunk_every(1000)
|> Enum.each(fn batch ->
placeholders = build_placeholders(batch, columns)
values = List.flatten(batch)
Repo.query!(
"INSERT INTO #{table_name} (#{Enum.join(columns, ", ")}) VALUES #{placeholders}",
values
)
end)
length(rows)
end)
end
endQuando uma materialized view e configurada, o sistema cria automaticamente a tabela PostgreSQL correspondente:
defmodule AgentsBot.DataSources.TableBuilder do
@moduledoc """
Cria tabelas PostgreSQL locais a partir do schema da query MySQL.
Mapeia tipos MySQL → PostgreSQL automaticamente.
"""
@type_mapping %{
"int" => "INTEGER",
"bigint" => "BIGINT",
"varchar" => "TEXT",
"text" => "TEXT",
"decimal" => "NUMERIC",
"datetime" => "TIMESTAMP",
"date" => "DATE",
"tinyint" => "BOOLEAN",
"float" => "DOUBLE PRECISION",
"double" => "DOUBLE PRECISION",
"json" => "JSONB"
}
def create_local_table(materialized_view) do
data_source = get_data_source(materialized_view.data_source_id)
# Descobre colunas da query original
{:ok, columns} = introspect_query(data_source, materialized_view.source_query)
# Gera DDL para PostgreSQL
ddl = """
CREATE TABLE IF NOT EXISTS #{materialized_view.local_table_name} (
_sync_id BIGSERIAL PRIMARY KEY,
_synced_at TIMESTAMP DEFAULT NOW(),
#{columns |> Enum.map(&column_ddl/1) |> Enum.join(",\n ")}
)
"""
Repo.query!(ddl)
# Cria indices para colunas frequentemente filtradas
create_smart_indices(materialized_view.local_table_name, columns)
end
defp column_ddl(%{name: name, mysql_type: type}) do
pg_type = Map.get(@type_mapping, type, "TEXT")
"#{name} #{pg_type}"
end
endO LLM primeiro classifica se a pergunta do usuario requer:
- Busca semantica (RAG em vaults) — perguntas sobre politicas, processos, como fazer
- Consulta estruturada (SQL em materialized views) — perguntas sobre dados, numeros, status
- Ambos — perguntas que combinam contexto e dados
defmodule AgentsBot.AI.IntentClassifier do
@moduledoc """
Classifica a intencao do usuario para rotear entre RAG e SQL.
"""
@classify_prompt """
Voce e um classificador de intencao. Dada a pergunta do usuario e as fontes disponiveis,
classifique a intencao em uma das categorias:
FONTES SEMANTICAS (Vaults):
{{vault_descriptions}}
FONTES ESTRUTURADAS (Views de Dados):
{{view_descriptions}}
Pergunta: {{question}}
Responda APENAS com JSON:
{"intent": "semantic" | "structured" | "hybrid", "sources": ["nome_da_fonte1", "nome_da_fonte2"]}
"""
def classify(question, virtual_agent) do
vaults = get_vault_descriptions(virtual_agent.id)
views = get_view_descriptions(virtual_agent.id)
prompt = @classify_prompt
|> String.replace("{{vault_descriptions}}", format_vaults(vaults))
|> String.replace("{{view_descriptions}}", format_views(views))
|> String.replace("{{question}}", question)
{:ok, response} = LLM.generate(prompt, virtual_agent.llm_config)
Jason.decode!(response)
end
endQuando a intencao e structured ou hybrid, o LLM gera SQL contra as materialized views locais:
defmodule AgentsBot.AI.TextToSQL do
@moduledoc """
Converte perguntas em linguagem natural para SQL seguro.
Opera APENAS contra materialized views locais (PostgreSQL),
nunca diretamente contra o MySQL do cliente.
"""
@text_to_sql_prompt """
Voce e um gerador de SQL. Gere uma query SELECT read-only para responder a pergunta.
TABELA: {{table_name}}
COLUNAS:
{{column_descriptions}}
REGRAS OBRIGATORIAS:
- Apenas SELECT (nunca INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE)
- Apenas a tabela especificada (nunca joins com outras tabelas)
- LIMIT maximo de 100 linhas
- Nao usar subqueries
- Nao usar funcoes de sistema (pg_*, information_schema, etc.)
- Para datas, usar formato ISO: '2026-01-15'
PERGUNTA: {{question}}
Responda APENAS com o SQL, sem explicacao.
"""
def generate_sql(question, materialized_view) do
prompt = build_prompt(question, materialized_view)
{:ok, raw_sql} = LLM.generate(prompt, %{"temperature" => 0.0})
# Validacao de seguranca em multiplas camadas
with {:ok, sql} <- SQLSanitizer.validate(raw_sql),
{:ok, sql} <- SQLSanitizer.ensure_read_only(sql),
{:ok, sql} <- SQLSanitizer.enforce_limit(sql, 100),
{:ok, sql} <- SQLSanitizer.check_table_access(sql, materialized_view.local_table_name) do
{:ok, sql}
else
{:error, reason} -> {:blocked, reason}
end
end
enddefmodule AgentsBot.Security.SQLSanitizer do
@moduledoc """
Valida e sanitiza queries SQL geradas pelo LLM.
Garante que apenas operacoes read-only sejam executadas.
"""
@forbidden_keywords ~w(
INSERT UPDATE DELETE DROP ALTER TRUNCATE CREATE GRANT REVOKE
EXECUTE CALL INTO OUTFILE DUMPFILE LOAD_FILE pg_read_file
pg_ls_dir information_schema pg_catalog
)
def validate(sql) do
normalized = String.upcase(String.trim(sql))
cond do
not String.starts_with?(normalized, "SELECT") ->
{:error, "Query deve comecar com SELECT"}
has_forbidden_keyword?(normalized) ->
{:error, "Query contem operacao proibida"}
has_comment_injection?(sql) ->
{:error, "Query contem padrao de injecao SQL"}
String.contains?(normalized, ";") ->
{:error, "Multiplas statements nao sao permitidas"}
true ->
{:ok, sql}
end
end
def ensure_read_only(sql) do
# Parse adicional com regex para detectar subqueries perigosas
if Regex.match?(~r/\b(INTO|OUTFILE|DUMPFILE)\b/i, sql) do
{:error, "Clausula de escrita detectada"}
else
{:ok, sql}
end
end
def enforce_limit(sql, max_limit) do
if Regex.match?(~r/\bLIMIT\s+\d+/i, sql) do
# Substituir limit se for maior que o maximo
sql = Regex.replace(~r/LIMIT\s+(\d+)/i, sql, fn _, n ->
limit = min(String.to_integer(n), max_limit)
"LIMIT #{limit}"
end)
{:ok, sql}
else
{:ok, sql <> " LIMIT #{max_limit}"}
end
end
def check_table_access(sql, allowed_table) do
# Verifica que a query so acessa a tabela permitida
tables = extract_table_names(sql)
if Enum.all?(tables, &(&1 == allowed_table)) do
{:ok, sql}
else
{:error, "Acesso a tabelas nao permitidas: #{inspect(tables)}"}
end
end
defp has_forbidden_keyword?(normalized_sql) do
Enum.any?(@forbidden_keywords, fn kw ->
Regex.match?(~r/\b#{kw}\b/, normalized_sql)
end)
end
defp has_comment_injection?(sql) do
Regex.match?(~r/(--|\/\*|\*\/|#)/, sql)
end
enddefmodule AgentsBot.AI.HybridPipeline do
@moduledoc """
Pipeline hibrido que combina RAG semantico com consulta estruturada.
"""
def process(question, %{virtual_agent: agent} = context) do
# 1. Classificar intencao
intent = IntentClassifier.classify(question, agent)
# 2. Buscar informacoes de acordo com a intencao
{docs_context, data_context} = case intent["intent"] do
"semantic" ->
docs = search_vaults(question, context)
{format_docs(docs), nil}
"structured" ->
data = query_materialized_views(question, intent["sources"], context)
{nil, format_data(data)}
"hybrid" ->
docs = search_vaults(question, context)
data = query_materialized_views(question, intent["sources"], context)
{format_docs(docs), format_data(data)}
end
# 3. Gerar resposta final com todo o contexto
generate_response(question, docs_context, data_context, context)
end
defp search_vaults(question, context) do
# Pipeline RAG existente — busca nos vaults do agente
vault_ids = context.vault_ids
embedding = Embeddings.generate(question)
Documents.find_similar(embedding, vault_ids, context.virtual_agent.rag_config)
end
defp query_materialized_views(question, source_names, context) do
views = MaterializedViews.get_by_names(context.virtual_agent.id, source_names)
Enum.map(views, fn view ->
case TextToSQL.generate_sql(question, view) do
{:ok, sql} ->
# Auditar a query
audit_query(context, view, question, sql)
# Executar contra PostgreSQL local
{:ok, result} = Repo.query(sql)
%{view: view.display_name, columns: result.columns, rows: result.rows}
{:blocked, reason} ->
audit_blocked_query(context, view, question, reason)
%{view: view.display_name, error: "Consulta bloqueada: #{reason}"}
end
end)
end
defp generate_response(question, docs_context, data_context, context) do
prompt = """
#{context.prompts.personality}
#{if docs_context, do: "DOCUMENTACAO RELEVANTE:\n#{docs_context}\n"}
#{if data_context, do: "DADOS DO SISTEMA:\n#{data_context}\n"}
CONVERSA ANTERIOR:
#{format_history(context.conversation)}
PERGUNTA: #{question}
Responda de forma natural, citando os dados quando relevante.
Se os dados mostram numeros, inclua-os na resposta.
"""
LLM.generate(prompt, context.virtual_agent.llm_config)
end
endMensagem Recebida (WhatsApp +5511999999999)
│
▼
Consultar virtual_agent_channels
WHERE channel_type = 'whatsapp'
AND channel_identifier = '+5511999999999'
│
▼
Encontrou agente? ──→ Sim: Usar agente encontrado
│ │
▼ ▼
Nao: Usar agente Criar/Retomar conversa
padrao com virtual_agent_id
defmodule AgentsBot.Webhooks.MessageProcessor do
def process_message(message, channel_info) do
# 1. Identificar o agente virtual pelo canal
virtual_agent = Agents.get_agent_by_channel(
channel_info.type,
channel_info.identifier
)
# 2. Obter ou criar conversa vinculada ao agente
conversation = Conversations.get_or_create(
message.phone_number,
virtual_agent_id: virtual_agent.id
)
# 3. Carregar prompts e fontes do agente
prompts = Prompts.get_for_agent(virtual_agent.id)
# 4. Executar Pipeline Hibrido (RAG + SQL)
response = HybridPipeline.process(message.content, %{
virtual_agent: virtual_agent,
conversation: conversation,
prompts: prompts,
vault_ids: Agents.get_vault_ids(virtual_agent.id),
materialized_views: MaterializedViews.list_active(virtual_agent.id)
})
# 5. Enviar resposta pelo canal correto
Messaging.send(channel_info, response)
end
endCREATE TABLE virtual_agent_support_agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
virtual_agent_id UUID NOT NULL REFERENCES virtual_agents(id) ON DELETE CASCADE,
support_agent_id UUID NOT NULL REFERENCES support_agents(id) ON DELETE CASCADE,
inserted_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(virtual_agent_id, support_agent_id)
);{
"retrieval_limit": 10,
"rerank_limit": 5,
"similarity_threshold": 0.7,
"cache_ttl": 3600,
"handoff_enabled": true,
"handoff_patterns": ["falar com humano", "atendente"],
"max_conversation_history": 10,
"enable_data_sources": true,
"data_source_timeout_ms": 5000
}{
"provider": "openai",
"model": "gpt-4o",
"temperature": 0.3,
"max_tokens": 1000,
"top_p": 0.9,
"frequency_penalty": 0.0,
"presence_penalty": 0.0
}| Provider | Modelos | Uso Recomendado |
|---|---|---|
| OpenAI | gpt-4o, gpt-4o-mini | Uso geral, melhor custo-beneficio |
| Anthropic | claude-3.5-sonnet, claude-3-haiku | Respostas longas, analise complexa |
| gemini-1.5-pro, gemini-1.5-flash | Multimodal, documentos longos |
# 1. Tabela de agentes virtuais
mix ecto.gen.migration create_virtual_agents
# 2. Vinculo agente-vault
mix ecto.gen.migration create_virtual_agent_vaults
# 3. Prompts por agente
mix ecto.gen.migration create_virtual_agent_prompts
# 4. Canais por agente
mix ecto.gen.migration create_virtual_agent_channels
# 5. Data sources (conexoes MySQL)
mix ecto.gen.migration create_data_sources
# 6. Materialized views
mix ecto.gen.migration create_materialized_views
# 7. Log de refresh
mix ecto.gen.migration create_materialized_view_refresh_log
# 8. Auditoria de queries
mix ecto.gen.migration create_query_audit_log
# 9. Adicionar virtual_agent_id em conversations
mix ecto.gen.migration add_virtual_agent_to_conversations
# 10. (Opcional) Support agents por agente virtual
mix ecto.gen.migration create_virtual_agent_support_agentsdefmodule AgentsBot.Repo.Migrations.CreateVirtualAgents do
use Ecto.Migration
def change do
create table(:virtual_agents, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :slug, :string, null: false
add :description, :text
add :avatar_url, :string
add :personality, :text
add :welcome_message, :text
add :fallback_message, :text
add :llm_provider, :string, null: false, default: "openai"
add :llm_model, :string, null: false, default: "gpt-4o-mini"
add :llm_config, :map, default: %{}
add :rag_config, :map, default: %{
"retrieval_limit" => 10,
"rerank_limit" => 5,
"similarity_threshold" => 0.7,
"cache_ttl" => 3600
}
add :embedding_provider, :string, default: "openai"
add :embedding_model, :string, default: "text-embedding-3-small"
add :embedding_dimensions, :integer, default: 1536
add :is_active, :boolean, default: true
add :metadata, :map, default: %{}
timestamps()
end
create unique_index(:virtual_agents, [:slug])
create index(:virtual_agents, [:is_active])
end
end# Agente Virtual
%VirtualAgent{
name: "LojaBot",
slug: "lojabot",
personality: "Assistente de loja online, ajuda com pedidos e produtos",
llm_provider: "openai",
llm_model: "gpt-4o",
llm_config: %{"temperature" => 0.2, "max_tokens" => 1500}
}
# Data Source — MySQL do ERP
%DataSource{
name: "ERP Loja ABC",
db_type: "mysql",
host: "erp.lojaabc.com.br",
port: 3306,
database_name: "erp",
schema_name: "loja_abc",
sync_config: %{"pool_size" => 2, "ssl_enabled" => true, "read_only" => true}
}
# Materialized View — Pedidos Recentes
%MaterializedView{
view_name: "mv_pedidos_recentes",
display_name: "Pedidos Recentes",
description: "Pedidos dos ultimos 90 dias com status, valor e cliente",
source_query: """
SELECT
p.id AS pedido_id,
c.nome AS cliente_nome,
p.valor_total,
p.status,
p.forma_pagamento,
p.data_criacao,
p.data_atualizacao
FROM loja_abc.pedidos p
JOIN loja_abc.clientes c ON c.id = p.cliente_id
WHERE p.data_criacao > NOW() - INTERVAL 90 DAY
""",
local_table_name: "mv_lojabot_pedidos_recentes",
column_descriptions: %{
"pedido_id" => "Numero do pedido",
"cliente_nome" => "Nome do cliente que fez o pedido",
"valor_total" => "Valor total do pedido em reais (BRL)",
"status" => "Status: pendente, aprovado, separando, enviado, entregue, cancelado",
"forma_pagamento" => "Forma de pagamento: pix, cartao, boleto",
"data_criacao" => "Data em que o pedido foi realizado",
"data_atualizacao" => "Data da ultima atualizacao do status"
},
example_questions: [
"Quantos pedidos temos hoje?",
"Qual o valor total de vendas do mes?",
"Quais pedidos estao pendentes?",
"Quantos pedidos por pix no ultimo mes?"
],
refresh_cron: "*/15 * * * *",
refresh_interval_minutes: 15
}
# Materialized View — Estoque
%MaterializedView{
view_name: "mv_estoque_atual",
display_name: "Estoque Atual",
description: "Estoque atual de todos os produtos com quantidade e preco",
source_query: """
SELECT
p.sku,
p.nome AS produto_nome,
p.categoria,
e.quantidade_disponivel,
e.quantidade_reservada,
p.preco_venda,
e.atualizado_em
FROM loja_abc.produtos p
JOIN loja_abc.estoque e ON e.produto_id = p.id
WHERE p.ativo = 1
""",
local_table_name: "mv_lojabot_estoque_atual",
column_descriptions: %{
"sku" => "Codigo SKU do produto",
"produto_nome" => "Nome do produto",
"categoria" => "Categoria do produto",
"quantidade_disponivel" => "Unidades disponiveis para venda",
"quantidade_reservada" => "Unidades reservadas em pedidos pendentes",
"preco_venda" => "Preco de venda em reais",
"atualizado_em" => "Data da ultima atualizacao do estoque"
},
example_questions: [
"Tem estoque do produto X?",
"Quais produtos estao com estoque baixo?",
"Qual o preco do produto Y?"
],
refresh_cron: "*/10 * * * *",
refresh_interval_minutes: 10
}Conversa de exemplo:
Usuario: "Quantos pedidos tivemos hoje?"
→ Intent: structured → mv_pedidos_recentes
→ SQL: SELECT COUNT(*) as total FROM mv_lojabot_pedidos_recentes WHERE data_criacao::date = CURRENT_DATE
→ Resultado: 47
→ Resposta: "Hoje tivemos 47 pedidos ate agora! Posso te dar mais detalhes?"
Usuario: "E qual o valor total?"
→ Intent: structured → mv_pedidos_recentes
→ SQL: SELECT SUM(valor_total) as total FROM mv_lojabot_pedidos_recentes WHERE data_criacao::date = CURRENT_DATE
→ Resultado: 15847.90
→ Resposta: "O valor total de vendas de hoje e R$ 15.847,90. Quer ver separado por forma de pagamento?"
Usuario: "Qual a politica de devolucao?"
→ Intent: semantic → Vault "Politicas da Loja"
→ RAG: busca documentos sobre devolucao
→ Resposta: "De acordo com nossa politica, voce tem ate 7 dias para solicitar devolucao..."
%VirtualAgent{
name: "SuporteBot",
slug: "suportebot",
personality: "Assistente de suporte tecnico, objetivo e empático",
llm_provider: "openai",
llm_model: "gpt-4o-mini",
llm_config: %{"temperature" => 0.1, "max_tokens" => 2000}
}
# Data Source
%DataSource{
name: "Helpdesk MySQL",
schema_name: "helpdesk_cliente_x"
}
# View — Tickets Abertos
%MaterializedView{
view_name: "mv_tickets_abertos",
display_name: "Tickets em Aberto",
description: "Tickets de suporte abertos com prioridade, categoria e SLA",
source_query: """
SELECT t.id, t.titulo, t.categoria, t.prioridade,
t.status, t.sla_vencimento, t.criado_em,
c.nome AS cliente_nome, c.email AS cliente_email
FROM helpdesk_cliente_x.tickets t
JOIN helpdesk_cliente_x.clientes c ON c.id = t.cliente_id
WHERE t.status IN ('aberto', 'em_andamento', 'aguardando_cliente')
""",
local_table_name: "mv_suportebot_tickets_abertos",
refresh_cron: "*/5 * * * *" -- A cada 5 min (dados criticos)
}%VirtualAgent{
name: "FinBot",
slug: "finbot",
personality: "Assistente financeiro, preciso com numeros e datas",
llm_provider: "anthropic",
llm_model: "claude-3.5-sonnet",
llm_config: %{"temperature" => 0.0, "max_tokens" => 2000}
}
# View — Faturamento Mensal
%MaterializedView{
view_name: "mv_faturamento_mensal",
display_name: "Faturamento Mensal",
description: "Faturamento agregado por mes, categoria e forma de pagamento",
source_query: """
SELECT
DATE_FORMAT(data_venda, '%Y-%m') AS mes,
categoria_produto,
forma_pagamento,
COUNT(*) AS total_vendas,
SUM(valor_liquido) AS faturamento_liquido,
SUM(valor_bruto) AS faturamento_bruto,
AVG(valor_liquido) AS ticket_medio
FROM financeiro_cliente.vendas
WHERE data_venda > DATE_SUB(NOW(), INTERVAL 12 MONTH)
GROUP BY mes, categoria_produto, forma_pagamento
""",
local_table_name: "mv_finbot_faturamento_mensal",
column_descriptions: %{
"mes" => "Mes no formato YYYY-MM",
"categoria_produto" => "Categoria do produto vendido",
"forma_pagamento" => "Forma de pagamento utilizada",
"total_vendas" => "Quantidade de vendas no periodo",
"faturamento_liquido" => "Faturamento liquido em reais (descontadas taxas)",
"faturamento_bruto" => "Faturamento bruto em reais",
"ticket_medio" => "Valor medio por venda"
},
refresh_cron: "0 */6 * * *" -- A cada 6 horas (dados consolidados)
}┌─────────────────────────────────────────────────────────────┐
│ CAMADAS DE SEGURANCA │
│ │
│ 1. CONEXAO → read-only, SSL, credenciais cifradas │
│ 2. MATERIALIZATION → dados copiados, sem acesso direto │
│ 3. SQL SANITIZER → valida toda query gerada pelo LLM │
│ 4. TABLE ACCESS → agente so acessa suas proprias views │
│ 5. AUDIT LOG → toda query registrada para auditoria │
│ 6. RATE LIMITING → limite de queries por agente/minuto │
└─────────────────────────────────────────────────────────────┘
REGRA: Um agente NUNCA pode acessar dados de outro agente.
Agente A → mv_agentea_pedidos ✅
Agente A → mv_agenteb_tickets ❌ BLOQUEADO
Implementacao:
- Tabelas locais prefixadas: mv_{agent_slug}_{view_name}
- Query sanitizer verifica prefixo antes de executar
- PostgreSQL RLS (Row Level Security) como camada adicional
defmodule AgentsBot.DataSources.Credentials do
@moduledoc """
Gerencia criptografia de credenciais de conexao.
Usa AES-256-GCM com chave derivada do application secret.
"""
@aad "data_source_credentials"
def encrypt(%{username: user, password: pass}) do
key = derive_key()
iv = :crypto.strong_rand_bytes(16)
plaintext = Jason.encode!(%{username: user, password: pass})
{ciphertext, tag} = :crypto.crypto_one_time_aead(
:aes_256_gcm, key, iv, plaintext, @aad, true
)
iv <> tag <> ciphertext
end
def decrypt(encrypted) do
key = derive_key()
<<iv::binary-16, tag::binary-16, ciphertext::binary>> = encrypted
plaintext = :crypto.crypto_one_time_aead(
:aes_256_gcm, key, iv, ciphertext, @aad, tag, false
)
Jason.decode!(plaintext)
end
defp derive_key do
secret = Application.get_env(:agents_bot, :secret_key_base)
:crypto.hash(:sha256, secret)
end
endA source_query deve excluir dados sensiveis na origem:
-- ❌ ERRADO: traz dados sensiveis
SELECT * FROM clientes
-- ✅ CORRETO: exclui PII desnecessario
SELECT
id,
CONCAT(LEFT(nome, 1), '***') AS nome_parcial,
cidade,
estado,
-- CPF, email, telefone NAO incluidos
data_cadastro
FROM clientescache_key = "#{virtual_agent_id}:#{:crypto.hash(:sha256, query)}"Nem todas as views precisam do mesmo intervalo:
| Tipo de Dado | Intervalo Sugerido | Motivo |
|---|---|---|
| Estoque | 5-10 min | Muda frequentemente |
| Pedidos do dia | 10-15 min | Volume medio de mudancas |
| Faturamento mensal | 6h | Dados consolidados |
| Catalogo de produtos | 1h | Muda pouco |
| Dados historicos | 24h | Nao mudam |
# Telemetria para monitorar saude das materialized views
:telemetry.execute(
[:agents_bot, :materialized_view, :refresh],
%{duration_ms: duration, rows: row_count},
%{view_name: view.view_name, agent_id: view.virtual_agent_id, status: :success}
)
:telemetry.execute(
[:agents_bot, :text_to_sql, :query],
%{execution_time_ms: time, rows_returned: count},
%{agent_id: agent_id, view_name: view_name, blocked: false}
)CircuitBreaker.create(:openai_chat, failure_threshold: 5)
CircuitBreaker.create(:anthropic_chat, failure_threshold: 5)
CircuitBreaker.create(:google_chat, failure_threshold: 5)RateLimiter.check("agent:#{virtual_agent_id}", max_requests: 100, window: :timer.minutes(1))
RateLimiter.check("agent:#{virtual_agent_id}:sql", max_requests: 30, window: :timer.minutes(1))| Tabela | Descricao | Relacoes |
|---|---|---|
virtual_agents |
Definicao de cada agente virtual | Central |
virtual_agent_vaults |
N:N entre agentes e bases de conhecimento | virtual_agents ↔ vaults |
virtual_agent_prompts |
Prompts personalizados por agente | virtual_agents ↔ prompts |
virtual_agent_channels |
Canais de atendimento por agente | virtual_agents → canais |
virtual_agent_support_agents |
Atendentes humanos por agente | virtual_agents ↔ support_agents |
data_sources |
Conexoes com bancos MySQL dos clientes | virtual_agents → MySQL schemas |
materialized_views |
Views pre-computadas dos dados do cliente | data_sources → tabelas locais PG |
materialized_view_refresh_log |
Historico de sincronizacoes | materialized_views → log |
query_audit_log |
Auditoria de queries SQL do agente | virtual_agents → auditoria |
conversations (alterada) |
Adicionado virtual_agent_id |
conversations → virtual_agents |
vaults (existente) |
Bases de conhecimento | Ja existe |
documents (existente) |
Documentos com embeddings | Ja existe |
prompts (existente) |
Templates de prompts | Ja existe |
support_agents (existente) |
Atendentes humanos | Ja existe |
- Criar migracoes para as novas tabelas (incluindo
data_sourcesematerialized_views) - Implementar schemas Ecto para
VirtualAgent,DataSource,MaterializedView - Criar contexto
Agentscom funcoes CRUD e consultas - Implementar
ConnectionManagercom pools dinamicos (MyXQL + DynamicSupervisor) - Criar
SyncWorkerpara refresh periodico das materialized views - Implementar
TextToSQLcom sanitizacao em multiplas camadas - Criar
HybridPipelinecombinando RAG + SQL - Modificar
MessageProcessorpara identificar agente pelo canal - Implementar
SQLSanitizereQueryAuditLog - Criar interface de administracao para gerenciar agentes, data sources e views
- Implementar testes para isolamento entre agentes e seguranca SQL
- Adicionar metricas por agente no dashboard de analytics
- Configurar alertas para views stale e erros de sincronizacao
Documento gerado para o projeto i9-agents-bot — Abril 2026