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 agentemessages— histórico de conversa (role: user/model/function, parts JSONB)leads— lead único por (agent_id, remote_jid)tools— function declarations dos agentesagent_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)¶
- Busca agentes do mesmo
client_nameque o router (tenant isolation) - Query
leads.select('agent_id').eq('remote_jid', contactId).in('agent_id', tenantIds)— sticky se existir - Se sticky hit → log
router.sticky+ retorna slug - Senão →
classifyMessage(text)→ logrouter.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_ativovsperpetuo) - Bônus extras
- Nome e data de evento gratuito
Transcrição de áudio¶
Fluxo¶
- Normalizer detecta
messageType: 'audio'ouAudioMessage(camelCase Uazapi) handleAudio()emrouter.ts: a. SemediaBase64embutido → usa direto b. Seprovider === 'uazapi'→downloadMediaUazapi()viaPOST /message/downloadc. Seprovider === 'evolution'→downloadMediaWhatsApp()via/chat/getBase64FromMediaMessaged. Se tudo falhar + temmediaUrl→ fetch direto (fallback)transcribeWithFallback(buffer, mime): a. SeOPENAI_API_KEYsetada E buffer ≤ 25MB → Whisper (whisper-1,language=pt,response_format=text) b. Se Whisper falha → cai em Gemini (transcribeAudiocom o modelo do próprio agente) c. Se ambos falham → retorna[áudio não transcrito]como texto- Texto transcrito vira
msg.messageTexte 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_namedo 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_namepra FK de tabelaclientsseparada - 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:
packages/engine/src/pipeline/orchestrator.ts→ROUTER_SLUGSpackages/engine/src/pipeline/router-classifier.ts→RULES(regex por slug)- Migrations SQL em
supabase/migrations/(inserts dos agentes)
Tudo o resto é dados (banco) — editável por admin ou SQL.