Pular para conteúdo

Arquitetura — Gita Agents (multi-tenant)

Visão técnica do sistema. Ler antes de mexer no engine ou admin.


Componentes

Repositório

~/projetos/gita-agents/ — monorepo com npm workspaces:

packages/
├── shared/    # Types TypeScript (AgentRow, etc.)
├── engine/    # Node.js Express — roda webhooks, chama Gemini, envia Uazapi
└── admin/     # Next.js — CRUD de agentes, leads, logs, variáveis

Deploy (Easypanel)

  • Engine: container Node.js, domain gita-engine-gita-agents.ewzc9p.easypanel.host
  • Admin: container Next.js standalone, domain separado
  • Auto-deploy: push na main → Easypanel rebuildar ambos
  • Env vars críticas (Engine): SUPABASE_URL, SUPABASE_SERVICE_KEY, REDIS_URL, CHATWOOT_URL, CHATWOOT_PLATFORM_TOKEN, OPENAI_API_KEY, GITA_SALES_WEBHOOK_SECRET

Banco (Supabase)

  • ai_agents — 1 linha por agente
  • messages — histórico de conversa (role: user/model/function, parts JSONB)
  • leads — lead único por (agent_id, remote_jid)
  • tools — function declarations dos agentes
  • agent_logs — eventos estruturados do pipeline (prefix: webhook., router., pipeline., transcribe., uazapi., route., filter., send., gemini., function.)

Pipeline de mensagem (happy path)

1. Uazapi → POST /webhook/whatsapp/<slug>
2. [Express] Valida slug + retorna 200 imediatamente
3. [orchestrator.ts processWebhook] roda em background:
   a. getAgentConfig(slug) — busca agent no Supabase
   b. log webhook.received
   c. normalize(body, 'whatsapp') — extrai contactId, messageText, tipo
   d. Se for ROUTER:
      - resolveTargetSlug(contactId, text, agent)
      - processWebhook(body, channel, targetSlug)  ← recursão com slug real
      - return
   e. Persiste credentials (api_url/token) se primeira vez
   f. applyFilters() — fromMe, horário comercial, whitelist, block_ia
   g. routeByType() — se áudio/imagem/vídeo, baixa + transcreve/upload
   h. debounce — acumula mensagens próximas (default 5-8s)
   i. getOrCreateLead(agent.id, contactId)
   j. saveMessage(role=user) + getContents() ← histórico
   k. getActiveTools(agent.id) → function declarations
   l. generateContent() (Gemini) em loop até resposta text ou max iterações
   m. saveMessage(role=model) + chunkResponse() + sendChunks() (Uazapi com delay)
   n. log pipeline.completed

Pontos críticos

Timing: entre webhook.received e pipeline.completed passam tipicamente 15-30s (áudio + Whisper + Gemini + delays do chunker).

Recursão do router: router chama processWebhook uma vez com slug real. O agente alvo vê a mensagem como se tivesse chegado direto no webhook dele — cria lead próprio, debounce próprio, histórico próprio.

Sticky routing: após primeira classificação, lead existe no agente alvo. Próximas mensagens do mesmo remote_jid via router vão direto pro mesmo alvo sem reclassificar (economiza Gemini e evita flip-flop).


Router-classifier

Arquivo: packages/engine/src/pipeline/router-classifier.ts

Função classifyMessage(text)

Array ordenado de regras {slug, regex}. Primeira que der match vence. Fallback: janaina-captacao (triagem — Isis cumprimenta e pergunta).

Por que regex e não IA? - Openers são padronizados (campanhas com CTA fixo) → regex bate em 95%+ dos casos - Zero latência, zero custo - Debuggável (grep no código) - IA seria overkill pra classificação binária

Função resolveTargetSlug(contactId, text, routerAgent)

  1. Busca agentes do mesmo client_name que o router (tenant isolation)
  2. Query leads.select('agent_id').eq('remote_jid', contactId).in('agent_id', tenantIds) — sticky se existir
  3. Se sticky hit → log router.sticky + retorna slug
  4. Senão → classifyMessage(text) → log router.classified

⚠️ Tenant isolation (bug corrigido 18/04/2026)

Sem filtrar por client_name, um remote_jid com leads em outros clientes (Moleiro, Pinzon) fazia router delegar pra agente errado. Resultado: cliente da Janaina recebeu resposta de "Dra. Ana Especialista Tributária" do Pinzon.

Fix: busca leads restrito ao tenant do router. Ver Fase 4 do plan file.

Invariante: qualquer consulta cross-agente por remote_jid no pipeline deve filtrar por tenant. Hoje só o router faz esse tipo de query — outros lugares (history, debounce) já filtram por agent_id único.


Variáveis editáveis (variables JSONB)

O que é

Coluna ai_agents.variables JSONB. Valores interpolados no prompt_text via {{chave}} pelo helper packages/engine/src/services/prompt-variables.ts.

Antes de enviar ao Gemini

Em orchestrator.ts:

systemInstruction: `${interpolate(agent.prompt_text, agent.variables)}\n\nData atual: ...`

Placeholders não encontrados ficam literais (ajuda debug — se aparecer {{chave}} na resposta, é config faltando).

Edição

  • Admin UI: aba "Variáveis" no form do agente — interface key/value visual
  • SQL direto: UPDATE com jsonb — docs em editar-variaveis-agente.md
  • PostgREST: via service_key (cuidado com merge — sempre GET → update → PATCH, nunca PATCH variables: {} porque sobrescreve tudo)

Casos de uso típicos

  • Preço e condição vigente (Janaina muda por lançamento)
  • Links (checkout, suporte, evento)
  • Modo (lancamento_ativo vs perpetuo)
  • Bônus extras
  • Nome e data de evento gratuito

Transcrição de áudio

Fluxo

  1. Normalizer detecta messageType: 'audio' ou AudioMessage (camelCase Uazapi)
  2. handleAudio() em router.ts: a. Se mediaBase64 embutido → usa direto b. Se provider === 'uazapi'downloadMediaUazapi() via POST /message/download c. Se provider === 'evolution'downloadMediaWhatsApp() via /chat/getBase64FromMediaMessage d. Se tudo falhar + tem mediaUrl → fetch direto (fallback)
  3. transcribeWithFallback(buffer, mime): a. Se OPENAI_API_KEY setada E buffer ≤ 25MB → Whisper (whisper-1, language=pt, response_format=text) b. Se Whisper falha → cai em Gemini (transcribeAudio com o modelo do próprio agente) c. Se ambos falham → retorna [áudio não transcrito] como texto
  4. Texto transcrito vira msg.messageText e segue pipeline normal

Download Uazapi (crítico)

URL em content.URL (objeto, não string) não é baixável direto. Precisa:

POST https://grupogita.uazapi.com/message/download
Headers: token: <uuid da instância>
Body: { id: <message.messageid>, return_base64: true, generate_mp3: false }
Resposta: { base64Data, mimetype, fileURL }

Implementação: packages/engine/src/integrations/whatsapp/uazapi.ts

Normalizer Uazapi — tipos de mensagem

Uazapi manda messageType em camelCase: AudioMessage, ImageMessage, VideoMessage. Normalizer faz toLowerCase() e bate em audiomessage, etc.

content é objeto com {URL, mimetype, mediaKey, fileSHA256, seconds, PTT, ...} — não string.


Multi-tenant (como hoje)

Identificação de tenant

Campo ai_agents.client_name (string livre). Todos os agentes do mesmo cliente têm client_name idêntico.

Isolamento

  • Sticky routing: filtra leads pelo client_name do router
  • Histórico: já filtra por agent_id (único), não vaza
  • Debounce: chave Redis inclui agent_id + remote_jid (único)
  • Tools: sempre por agent_id

Limitações atuais

  • client_name é string livre — pode ter inconsistências ("Janaina Ortiga" vs "Janaina" = tenants diferentes)
  • Sem RLS no Supabase — toda query depende do código respeitar isolamento
  • Admin não tem visualização "por cliente"

Evolução futura (backlog)

  • Migrar client_name pra FK de tabela clients separada
  • RLS com JWT por tenant no Supabase
  • Admin com aba "Clientes" agrupando agentes
  • Template de criação de cliente com prompts baseline

Integrações externas

Sistema Propósito Arquivo
Gemini (Google) LLM principal integrations/gemini/client.ts
OpenAI Whisper Transcrição áudio integrations/openai/transcribe.ts
Uazapi Canal WhatsApp + download mídia integrations/whatsapp/uazapi.ts, pipeline/sender.ts
Evolution Canal WhatsApp alternativo integrations/whatsapp/evolution.ts
Instagram Graph API Canal Instagram pipeline/sender.ts
Unipile Canal LinkedIn pipeline/sender.ts
Chatwoot CRM (leitura de labels, pausa IA) integrations/crm/chatwoot.ts
Kommo CRM alternativo integrations/crm/kommo.ts
Google Calendar Agendamento via tools integrations/google/calendar.ts
Redis Debounce + cache integrations/redis.ts

Eventos em agent_logs (referência rápida)

Evento Nível Quando
webhook.received debug Webhook recebido pelo Express
webhook.agent_not_found warn Slug não existe no banco
webhook.no_contact warn Payload sem contactId
router.sticky info Router resolveu via lead existente
router.classified info Router rodou regex + fallback
router.self_loop_blocked warn Router classificaria pra si mesmo (bug)
filter.blocked info Mensagem filtrada (fromMe, whitelist, horário)
route.audio_transcribed info Áudio virou texto com sucesso
route.audio_no_source error Não conseguiu obter buffer do áudio
route.audio_download_error warn Fetch direto da URL falhou
route.audio_transcribe_failed error Whisper + Gemini falharam
transcribe.openai.success info Whisper OK
transcribe.openai.error error Whisper retornou erro HTTP
transcribe.openai.fallback warn Caiu no Gemini por erro Whisper
transcribe.success info Gemini transcribe OK
uazapi.download_success info /message/download OK
uazapi.download_failed warn Uazapi retornou erro
uazapi.download_missing_fields warn Faltou token/api_url/messageid
gemini.response info Gemini respondeu (text ou functionCall)
function.executed info Tool executada com sucesso
function.error error Tool falhou
send.success info Uazapi recebeu mensagem enviada
pipeline.completed info Ciclo completo
pipeline.error error Exception no pipeline

Queries úteis:

-- Últimos 10 min, sem ruído
SELECT created_at, event, data FROM agent_logs
WHERE created_at > NOW() - INTERVAL '10 minutes'
  AND event NOT IN ('webhook.no_contact')
ORDER BY created_at DESC;

-- Só erros
SELECT * FROM agent_logs WHERE level = 'error'
ORDER BY created_at DESC LIMIT 20;

-- Conversa específica
SELECT role, parts->0->>'text' FROM messages
WHERE remote_jid = '[email protected]'
ORDER BY created_at ASC;

Onde hardcodes moram hoje

Lugares que exigem edição de código pra novo cliente:

  1. packages/engine/src/pipeline/orchestrator.tsROUTER_SLUGS
  2. packages/engine/src/pipeline/router-classifier.tsRULES (regex por slug)
  3. Migrations SQL em supabase/migrations/ (inserts dos agentes)

Tudo o resto é dados (banco) — editável por admin ou SQL.