feat: automação autônoma do engine (n8n) — métricas de compra, persistência e alertas

Torna o engine de automação realmente autônomo e seguro:

- métricas: lê COMPRA de verdade (actions/purchase_roas) e calcula CPA;
  novas métricas 'cpa' e 'purchases'; regras padrão afinadas p/ low-ticket
- orçamento: ações de budget agora agem no CONJUNTO (não na campanha, que
  lançava erro); usa listAdSets + updateAdSetBudget
- segurança: "substituir via IA" desativado (criava campanha com page_id/
  link inválidos) — agora só pausa e recomenda
- modo de autonomia 'mixed': pausar/notificar executa; orçamento/IA viram
  só recomendação (com cooldown que evita re-notificar)
- persistência (Supabase): regras, logs e token (app_config) — store.ts
  com fallback gracioso; SQL em docs/automation.sql
- endpoint /api/engine/cron p/ o n8n acionar (CRON_SECRET), avalia, grava
  logs e dispara webhook (N8N_WEBHOOK_URL); modo summary p/ resumo diário
- token de login persistido no callback p/ rodar sem cookie
- aba Atividade agora lê logs reais via /api/engine/logs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcio Bevervanso 2026-06-16 15:43:45 -03:00
parent 59773aa2c4
commit 19004d0cc5
9 changed files with 565 additions and 189 deletions

54
docs/automation.sql Normal file
View file

@ -0,0 +1,54 @@
-- ============================================================
-- Tabelas da Automação (rode no Supabase → SQL Editor)
-- ============================================================
-- O app acessa o Supabase com a chave anon (server-side). Mantemos RLS
-- desabilitado nestas tabelas para o engine ler/escrever, igual ao restante
-- do app (tabela agent_campaigns).
--
-- ⚠️ SEGURANÇA: app_config guarda o token da Meta. Com RLS off + chave anon,
-- quem tiver a anon key consegue ler. Para uma ferramenta privada de um único
-- dono está ok; para travar de vez, prefira definir META_SYSTEM_TOKEN por env
-- (token de System User) e NÃO depender do token salvo aqui.
-- Regras de automação ----------------------------------------
create table if not exists automation_rules (
id uuid primary key default gen_random_uuid(),
name text not null,
enabled boolean not null default true,
metric text not null,
operator text not null,
threshold numeric not null,
min_spend numeric not null default 0,
min_impressions integer not null default 0,
action text not null,
action_value numeric,
cooldown_hours integer not null default 24,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Logs de atividade do engine --------------------------------
create table if not exists automation_logs (
id uuid primary key default gen_random_uuid(),
rule_id text,
rule_name text,
campaign_id text not null,
campaign_name text,
action text,
details jsonb,
status text,
created_at timestamptz not null default now()
);
create index if not exists automation_logs_created_idx on automation_logs (created_at desc);
-- Config chave/valor (token da Meta, etc.) -------------------
create table if not exists app_config (
key text primary key,
value text,
updated_at timestamptz not null default now()
);
-- Mantém o acesso via chave anon (igual ao resto do app).
alter table automation_rules disable row level security;
alter table automation_logs disable row level security;
alter table app_config disable row level security;

View file

@ -97,6 +97,15 @@ export async function GET(request: NextRequest) {
picture: meData.picture?.data?.url || '',
});
// Persiste o token pro engine de automação rodar sem cookie (acionado pelo
// n8n). Best-effort: se o Supabase estiver fora, não bloqueia o login.
try {
const { setConfig } = await import('@/lib/engine/store');
await setConfig('meta_token', accessToken);
} catch {
// ignora — automação volta a ter token no próximo login
}
// 5. Redirect to dashboard
return NextResponse.redirect(new URL('/dashboard', baseUrl));
} catch (err) {

View file

@ -0,0 +1,156 @@
// ============================================
// Engine — Endpoint autônomo (acionado pelo n8n)
// ============================================
// O n8n agenda e bate aqui (POST). Protegido por CRON_SECRET.
// - default: avalia campanhas e age conforme o modo (auto|notify|mixed)
// - { "summary": true }: envia um resumo (gasto/compras/CPA) sem agir
// Notifica o n8n de volta via N8N_WEBHOOK_URL.
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { getAccountInsights } from '@/lib/mcp/tools';
import { evaluateCampaigns, type AutonomyMode } from '@/lib/engine/monitor';
import { loadRules, loadRecentLogs, saveLogs, getConfig } from '@/lib/engine/store';
import type { ActivityLog } from '@/lib/engine/rules';
const PURCHASE_ACTION_TYPES = [
'purchase',
'omni_purchase',
'onsite_conversion.purchase',
'offsite_conversion.fb_pixel_purchase',
];
function sumActions(actions: Array<{ action_type?: string; value?: string }> | undefined, types: string[]): number {
if (!Array.isArray(actions)) return 0;
return actions.reduce((t, a) => (a.action_type && types.includes(a.action_type) ? t + parseFloat(a.value || '0') : t), 0);
}
async function notifyN8n(payload: Record<string, unknown>): Promise<void> {
const url = process.env.N8N_WEBHOOK_URL;
if (!url) return;
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(8000),
});
} catch (e) {
console.warn('[Engine.cron] webhook n8n falhou:', e instanceof Error ? e.message : e);
}
}
export async function POST(request: NextRequest) {
// Auth via CRON_SECRET (ignora se não estiver configurado/placeholder).
const cronSecret = process.env.CRON_SECRET;
if (cronSecret && cronSecret !== 'your_random_cron_secret') {
if (request.headers.get('authorization') !== `Bearer ${cronSecret}`) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
}
const body = await request.json().catch(() => ({} as Record<string, unknown>));
const token = (body.access_token as string) || (await getConfig('meta_token')) || process.env.META_SYSTEM_TOKEN;
if (!token) {
return NextResponse.json(
{ success: false, error: 'Sem token da Meta. Faça login no app (persiste o token) ou defina META_SYSTEM_TOKEN.' },
{ status: 401 }
);
}
const mode = ((body.mode as AutonomyMode) || (process.env.AUTOMATION_MODE as AutonomyMode) || 'mixed');
let client: Awaited<ReturnType<typeof createMetaMCPClient>> | undefined;
try {
client = await createMetaMCPClient(token);
// ---- Modo resumo: só relatório, não age ----
if (body.summary === true) {
const ins = (await getAccountInsights(client, 'today')) as Record<string, unknown>;
const spend = parseFloat((ins?.spend as string) || '0');
const purchases = sumActions(ins?.actions as never, PURCHASE_ACTION_TYPES);
const roasArr = ins?.purchase_roas as Array<{ value?: string }> | undefined;
const roas = roasArr?.[0]?.value ? parseFloat(roasArr[0].value) : 0;
const cpa = purchases > 0 ? spend / purchases : 0;
const summary = {
type: 'daily_summary',
ran_at: new Date().toISOString(),
spend: Number(spend.toFixed(2)),
purchases,
cpa: Number(cpa.toFixed(2)),
roas: Number(roas.toFixed(2)),
ctr: parseFloat((ins?.ctr as string) || '0'),
cpm: parseFloat((ins?.cpm as string) || '0'),
};
await notifyN8n(summary);
return NextResponse.json({ success: true, data: summary });
}
// ---- Modo avaliação: aplica regras ----
const [rules, recentLogs] = await Promise.all([loadRules(), loadRecentLogs(72)]);
const evaluations = await evaluateCampaigns(client, rules, recentLogs, mode);
const triggered = evaluations.filter((e) => e.triggered);
const logs: ActivityLog[] = triggered.map((e) => ({
rule_id: e.rule.id ?? null,
rule_name: e.rule.name,
campaign_id: e.campaign_id,
campaign_name: e.campaign_name,
action: e.action_taken || e.recommended_action || 'notify',
details: {
metric: e.rule.metric,
value: e.metric_value,
threshold: e.rule.threshold,
text: e.details,
},
status: e.action_taken ? 'executed' : e.recommended_action ? 'pending' : 'skipped',
created_at: e.timestamp,
}));
await saveLogs(logs);
if (triggered.length > 0) {
await notifyN8n({
type: 'automation_run',
ran_at: new Date().toISOString(),
mode,
actions: triggered.map((e) => ({
campaign: e.campaign_name,
rule: e.rule.name,
executed: !!e.action_taken,
action: e.action_taken || e.recommended_action,
details: e.details,
})),
});
}
console.log(`[Engine.cron] modo=${mode} avaliadas=${evaluations.length} disparadas=${triggered.length}`);
return NextResponse.json({
success: true,
data: {
ran_at: new Date().toISOString(),
mode,
total_triggered: triggered.length,
actions: triggered.map((e) => ({
campaign: e.campaign_name,
rule: e.rule.name,
executed: !!e.action_taken,
action: e.action_taken || e.recommended_action,
details: e.details,
})),
},
});
} catch (error) {
console.error('[Engine.cron] erro:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Engine error' },
{ status: 500 }
);
} finally {
if (client) await client.close();
}
}

View file

@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { loadRecentLogs } from '@/lib/engine/store';
// Logs de atividade do engine (últimos dias), pra aba Atividade.
export async function GET() {
const logs = await loadRecentLogs(24 * 14); // últimas 2 semanas
const data = logs.map((log, i) => {
const details = (log.details || {}) as Record<string, unknown>;
return {
id: (log.id as string) || `log-${i}`,
timestamp: log.created_at || new Date().toISOString(),
campaign_name: log.campaign_name,
rule_name: log.rule_name || '—',
action: log.action,
details: (details.text as string) || JSON.stringify(details),
status: log.status,
};
});
return NextResponse.json({ success: true, data });
}

View file

@ -1,30 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { DEFAULT_RULES } from '@/lib/engine/rules';
import type { AutomationRule } from '@/lib/engine/rules';
// In-memory store (replace with Supabase when configured)
let rules: AutomationRule[] = DEFAULT_RULES.map((r, i) => ({
...r,
id: `rule-${i + 1}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
import { loadRules, createRule, updateRule, deleteRule } from '@/lib/engine/store';
export async function GET() {
const rules = await loadRules();
return NextResponse.json({ success: true, data: rules });
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const newRule: AutomationRule = {
...body,
id: `rule-${Date.now()}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
rules.push(newRule);
return NextResponse.json({ success: true, data: newRule }, { status: 201 });
const rule = await createRule(body);
return NextResponse.json({ success: true, data: rule }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Invalid data' },
@ -37,12 +23,11 @@ export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { id, ...updates } = body;
const index = rules.findIndex((r) => r.id === id);
if (index === -1) {
const updated = await updateRule(id, updates);
if (!updated) {
return NextResponse.json({ success: false, error: 'Rule not found' }, { status: 404 });
}
rules[index] = { ...rules[index], ...updates, updated_at: new Date().toISOString() };
return NextResponse.json({ success: true, data: rules[index] });
return NextResponse.json({ success: true, data: updated });
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Invalid data' },
@ -53,10 +38,9 @@ export async function PATCH(request: NextRequest) {
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const id = searchParams.get('id');
const id = request.nextUrl.searchParams.get('id');
if (!id) return NextResponse.json({ success: false, error: 'ID required' }, { status: 400 });
rules = rules.filter((r) => r.id !== id);
await deleteRule(id);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(

View file

@ -1,7 +1,8 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Activity, CheckCircle, Clock, XCircle, Zap, Filter } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner';
interface LogEntry {
id: string;
@ -13,35 +14,6 @@ interface LogEntry {
status: 'executed' | 'pending' | 'failed' | 'skipped';
}
// Demo data (will be replaced with Supabase query)
const DEMO_LOGS: LogEntry[] = [
{
id: '1', timestamp: new Date(Date.now() - 3600000).toISOString(),
campaign_name: 'Campanha Conversão Q1', rule_name: 'CPL Alto — Pausar',
action: 'pause', details: 'CPL = R$67.30 (threshold: R$50.00)', status: 'executed',
},
{
id: '2', timestamp: new Date(Date.now() - 7200000).toISOString(),
campaign_name: 'Remarketing Visitantes', rule_name: 'ROAS Baixo — Pausar',
action: 'pause', details: 'ROAS = 0.32x (threshold: 0.50x)', status: 'executed',
},
{
id: '3', timestamp: new Date(Date.now() - 14400000).toISOString(),
campaign_name: 'Tráfego Blog', rule_name: 'CTR Muito Baixo — Pausar',
action: 'pause', details: 'CTR = 0.21% (threshold: 0.50%)', status: 'executed',
},
{
id: '4', timestamp: new Date(Date.now() - 28800000).toISOString(),
campaign_name: 'Leads Premium', rule_name: 'ROAS Excelente — Aumentar Budget',
action: 'increase_budget', details: 'Budget +20%: R$50.00 → R$60.00. ROAS = 4.2x', status: 'executed',
},
{
id: '5', timestamp: new Date(Date.now() - 43200000).toISOString(),
campaign_name: 'Campanha Vídeo', rule_name: 'CPC Alto — Reduzir Budget',
action: 'reduce_budget', details: 'Em cooldown (48h)', status: 'skipped',
},
];
const statusConfig = {
executed: { icon: <CheckCircle size={14} />, color: '#4ade80', bg: 'rgba(34,197,94,0.1)', label: 'Executado' },
pending: { icon: <Clock size={14} />, color: '#fbbf24', bg: 'rgba(245,158,11,0.1)', label: 'Pendente' },
@ -61,7 +33,24 @@ function timeAgo(dateStr: string): string {
export default function ActivityPage() {
const [filter, setFilter] = useState<string>('all');
const logs = filter === 'all' ? DEMO_LOGS : DEMO_LOGS.filter((l) => l.status === filter);
const [allLogs, setAllLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/engine/logs');
const data = await res.json();
if (data.success) setAllLogs(data.data || []);
} catch {
/* ignora */
} finally {
setLoading(false);
}
})();
}, []);
const logs = filter === 'all' ? allLogs : allLogs.filter((l) => l.status === filter);
return (
<div className="flex flex-col gap-6">
@ -90,10 +79,10 @@ export default function ActivityPage() {
{/* Stats */}
<div className="grid grid-cols-4 gap-4 animate-fade-in-up delay-1">
{[
{ label: 'Total', value: DEMO_LOGS.length, color: 'var(--accent-soft-fg)' },
{ label: 'Executados', value: DEMO_LOGS.filter((l) => l.status === 'executed').length, color: '#4ade80' },
{ label: 'Pendentes', value: DEMO_LOGS.filter((l) => l.status === 'pending').length, color: '#fbbf24' },
{ label: 'Pulados', value: DEMO_LOGS.filter((l) => l.status === 'skipped').length, color: '#8888a0' },
{ label: 'Total', value: allLogs.length, color: 'var(--accent-soft-fg)' },
{ label: 'Executados', value: allLogs.filter((l) => l.status === 'executed').length, color: '#4ade80' },
{ label: 'Pendentes', value: allLogs.filter((l) => l.status === 'pending').length, color: '#fbbf24' },
{ label: 'Pulados', value: allLogs.filter((l) => l.status === 'skipped').length, color: '#8888a0' },
].map((s) => (
<div key={s.label} className="glass-card-static p-4 text-center">
<p className="text-2xl font-bold" style={{ color: s.color }}>{s.value}</p>
@ -108,9 +97,13 @@ export default function ActivityPage() {
<h2 className="text-base font-semibold text-white">Timeline</h2>
</div>
<div className="divide-y" style={{ borderColor: 'var(--overlay-04)' }}>
{logs.length === 0 ? (
{loading ? (
<div className="p-12 flex justify-center">
<Spinner />
</div>
) : logs.length === 0 ? (
<div className="p-12 text-center">
<p className="text-[var(--muted-fg)]">Nenhuma atividade encontrada</p>
<p className="text-[var(--muted-fg)]">Nenhuma atividade ainda. Quando o engine agir ou recomendar algo, aparece aqui.</p>
</div>
) : (
logs.map((log) => {

View file

@ -3,30 +3,65 @@
// ============================================
import type { Client } from '@/lib/mcp/client';
import { listCampaigns, getCampaignInsights, pauseCampaign, updateCampaignBudget } from '@/lib/mcp/tools';
import { listCampaigns, getCampaignInsights, pauseCampaign, listAdSets, updateAdSetBudget } from '@/lib/mcp/tools';
import { AUTO_EXECUTE_ACTIONS } from './rules';
import type { AutomationRule, RuleEvaluation, ActivityLog } from './rules';
export type AutonomyMode = 'auto' | 'notify' | 'mixed';
// Tipos de ação (na Meta) que contam como compra e como lead.
const PURCHASE_ACTION_TYPES = [
'purchase',
'omni_purchase',
'onsite_conversion.purchase',
'offsite_conversion.fb_pixel_purchase',
];
const LEAD_ACTION_TYPES = ['lead', 'onsite_conversion.lead_grouped'];
// Soma os valores de um conjunto de action_types dentro de insights.actions.
function sumActions(insights: Record<string, unknown> | null, types: string[]): number {
const actions = (insights?.actions as Array<{ action_type?: string; value?: string }>) || [];
let total = 0;
for (const a of actions) {
if (a.action_type && types.includes(a.action_type)) {
total += parseFloat(a.value || '0');
}
}
return total;
}
function getPurchases(insights: Record<string, unknown> | null): number {
return sumActions(insights, PURCHASE_ACTION_TYPES);
}
// ---- Extract metric value from MCP insights ----
function getMetricValue(insights: Record<string, unknown> | null, metric: string): number {
if (!insights) return 0;
const spend = parseFloat((insights.spend as string) || '0');
switch (metric) {
case 'cpa': {
const purchases = getPurchases(insights);
return purchases > 0 ? spend / purchases : 0; // 0 = sem compra (use a regra 'purchases')
}
case 'purchases':
return getPurchases(insights);
case 'cpl': {
const spend = parseFloat((insights.spend as string) || '0');
const leads = parseInt((insights.leads as string) || '0', 10);
if (leads === 0) return 0;
return spend / leads;
const leads = sumActions(insights, LEAD_ACTION_TYPES);
return leads > 0 ? spend / leads : 0;
}
case 'cpc':
return parseFloat((insights.cpc as string) || '0');
case 'ctr':
return parseFloat((insights.ctr as string) || '0');
case 'roas':
return parseFloat((insights.roas as string) || '0');
case 'roas': {
const roasArr = insights.purchase_roas as Array<{ value?: string }> | undefined;
return roasArr?.[0]?.value ? parseFloat(roasArr[0].value) : 0;
}
case 'cpm':
return parseFloat((insights.cpm as string) || '0');
case 'spend':
return parseFloat((insights.spend as string) || '0');
return spend;
default:
return 0;
}
@ -63,16 +98,41 @@ function isInCooldown(
(log) =>
log.campaign_id === campaignId &&
log.rule_id === ruleId &&
log.status === 'executed' &&
// Executada OU já recomendada (pending) conta — evita re-agir e re-notificar.
(log.status === 'executed' || log.status === 'pending') &&
new Date(log.created_at || '') > cutoff
);
}
// Ajusta o orçamento de TODOS os conjuntos da campanha (o orçamento fica no
// conjunto, não na campanha). factor 0.7 = -30%, 1.2 = +20%. Piso de R$1,00.
async function adjustAdSetBudgets(client: Client, campaignId: string, factor: number): Promise<string> {
const adSets = (await listAdSets(client, campaignId)) as Array<{
id: string;
name?: string;
daily_budget?: string;
}> | null;
if (!Array.isArray(adSets) || adSets.length === 0) {
return 'nenhum conjunto encontrado';
}
const changes: string[] = [];
for (const adSet of adSets) {
const current = parseInt(adSet.daily_budget || '0', 10);
if (!current) continue; // conjunto sem orçamento próprio (campanha CBO)
const next = Math.max(100, Math.round(current * factor));
await updateAdSetBudget(client, adSet.id, next);
changes.push(`${adSet.name || adSet.id}: R$${(current / 100).toFixed(2)}→R$${(next / 100).toFixed(2)}`);
}
return changes.length ? changes.join(' · ') : 'nenhum conjunto com orçamento próprio (CBO?)';
}
// ---- Execute action on campaign via MCP ----
async function executeAction(
mcpClient: Client,
campaignId: string,
dailyBudget: number,
insights: Record<string, unknown>,
rule: AutomationRule
): Promise<{ success: boolean; details: string }> {
@ -87,92 +147,29 @@ async function executeAction(
}
case 'reduce_budget': {
const reduction = rule.action_value || 30;
const newBudget = Math.round(dailyBudget * (1 - reduction / 100));
if (newBudget < 100) return { success: false, details: 'Budget já no mínimo (R$1.00)' };
await updateCampaignBudget(mcpClient, campaignId, newBudget);
return {
success: true,
details: `Budget reduzido ${reduction}%: R$${(dailyBudget / 100).toFixed(2)} → R$${(newBudget / 100).toFixed(2)}`,
};
const details = await adjustAdSetBudgets(mcpClient, campaignId, 1 - reduction / 100);
return { success: true, details: `Budget reduzido ${reduction}% → ${details}` };
}
case 'increase_budget': {
const increase = rule.action_value || 20;
const newBgt = Math.round(dailyBudget * (1 + increase / 100));
await updateCampaignBudget(mcpClient, campaignId, newBgt);
return {
success: true,
details: `Budget aumentado ${increase}%: R$${(dailyBudget / 100).toFixed(2)} → R$${(newBgt / 100).toFixed(2)}`,
};
const details = await adjustAdSetBudgets(mcpClient, campaignId, 1 + increase / 100);
return { success: true, details: `Budget aumentado ${increase}% → ${details}` };
}
case 'notify': {
return {
success: true,
details: `Alerta: ${rule.metric.toUpperCase()} = ${getMetricValue(insights, rule.metric).toFixed(2)} (threshold: ${rule.threshold})`,
details: `Alerta: ${rule.metric.toUpperCase()} = ${getMetricValue(insights, rule.metric).toFixed(2)} (limite: ${rule.threshold})`,
};
}
case 'replace_with_ai': {
// 1. Pausar a campanha atual
// Substituição automática de criativo desativada por segurança (a versão
// anterior criava campanha com page_id/link fixos e inválidos). Aqui só
// pausamos e recomendamos recriar o criativo manualmente no app.
await pauseCampaign(mcpClient, campaignId);
// 2. Acionar a IA para gerar novos criativos
try {
// Gerar Copy via OpenAI (gpt-5.4-mini)
const { getAIClient } = await import('@/lib/ai/client');
const openai = getAIClient();
const copyPrompt = `A campanha "${insights.campaign_name || campaignId}" performou mal (CPL alto / ROAS baixo).
Você é um expert em Meta Ads. Crie uma nova Headline curta (max 40 chars) e um Primary Text (max 125 chars) muito persuasivo para substituí-la.
Retorne em JSON: {"headline": "...", "primary_text": "..."}`;
const copyResult = await openai.chat.completions.create({
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: copyPrompt }],
response_format: { type: 'json_object' },
});
const copy = JSON.parse(copyResult.choices[0]?.message?.content || '{}');
// Gerar Imagem via OpenAI (gpt-image-2)
const imageRes = await openai.images.generate({
model: 'gpt-image-2',
prompt: `Uma imagem profissional, chamativa e de alta conversão para um anúncio no Facebook/Instagram sobre: ${copy.headline || 'Produto Inovador'}. Estilo moderno, sem texto na imagem.`,
n: 1,
size: '1024x1024'
});
const image = imageRes.data?.[0];
const imageUrl = image?.url || (image?.b64_json ? `data:image/png;base64,${image.b64_json}` : undefined);
if (!imageUrl) throw new Error('Falha ao gerar imagem');
// 3. Montar e Publicar a Nova Campanha via MCP
const { createFullCampaign } = await import('@/lib/mcp/tools');
const newCampaign = await createFullCampaign(mcpClient, {
campaign_name: `[AI Gen] Substituto de ${insights.campaign_name || campaignId}`,
objective: 'OUTCOME_LEADS', // default safe objective
daily_budget: dailyBudget > 0 ? dailyBudget : 1000,
bid_strategy: 'LOWEST_COST_WITH_BID_CAP',
bid_amount: 5000, // Fixed 50 BRL bid cap for safety
targeting: { geo_locations: { countries: ['BR'] } },
page_id: '123456789', // requires a valid page_id in production
image_urls: [imageUrl],
link: 'https://seusite.com.br',
headline: copy.headline || 'Nova Oferta Irresistível',
primary_text: copy.primary_text || 'Descubra como transformar seus resultados hoje mesmo.',
description: 'Clique e saiba mais',
cta: 'LEARN_MORE',
});
return {
success: true,
details: `Campanha pausada. Nova campanha [AI Gen] criada com sucesso! (ID: ${newCampaign.campaign_id})`,
details: 'Campanha pausada. Recomendado: recriar o criativo no app (substituição via IA automática desativada por segurança).',
};
} catch (aiError: any) {
return {
success: false,
details: `Campanha pausada, mas falha ao criar IA: ${aiError.message}`,
};
}
}
default:
return { success: false, details: 'Ação desconhecida' };
@ -186,10 +183,21 @@ async function executeAction(
}
// ---- Main evaluation function ----
// Decide se a ação é executada de fato ou vira só notificação, conforme o modo:
// - 'auto' → executa qualquer ação.
// - 'notify' → nunca executa, só recomenda.
// - 'mixed' → executa só pausar/notificar; orçamento e IA viram recomendação.
function shouldExecute(mode: AutonomyMode, action: AutomationRule['action']): boolean {
if (mode === 'auto') return true;
if (mode === 'notify') return false;
return AUTO_EXECUTE_ACTIONS.includes(action);
}
export async function evaluateCampaigns(
mcpClient: Client,
rules: AutomationRule[],
recentLogs: ActivityLog[] = []
recentLogs: ActivityLog[] = [],
mode: AutonomyMode = 'mixed'
): Promise<RuleEvaluation[]> {
const enabledRules = rules.filter((r) => r.enabled);
if (enabledRules.length === 0) return [];
@ -204,7 +212,6 @@ export async function evaluateCampaigns(
for (const campaign of activeCampaigns) {
const campaignId = campaign.id as string;
const campaignName = campaign.name as string;
const dailyBudget = parseInt((campaign.daily_budget as string) || '0', 10);
let insights: Record<string, unknown> | null = null;
try {
@ -219,23 +226,14 @@ export async function evaluateCampaigns(
if (!meetsMinimums(insights, rule)) continue;
if (isInCooldown(campaignId, rule.id, recentLogs, rule.cooldown_hours)) {
evaluations.push({
rule,
campaign_id: campaignId,
campaign_name: campaignName,
metric_value: metricValue,
triggered: false,
action_taken: null,
details: `Em cooldown (${rule.cooldown_hours}h)`,
timestamp: new Date().toISOString(),
});
continue;
continue; // já agiu recentemente — silencioso pra não poluir o log
}
const triggered = evaluateCondition(metricValue, rule.operator, rule.threshold);
if (!evaluateCondition(metricValue, rule.operator, rule.threshold)) continue;
if (triggered) {
const result = await executeAction(mcpClient, campaignId, dailyBudget, insights!, rule);
// Regra disparou. Executa ou só recomenda, conforme o modo.
if (shouldExecute(mode, rule.action)) {
const result = await executeAction(mcpClient, campaignId, insights!, rule);
evaluations.push({
rule,
campaign_id: campaignId,
@ -243,9 +241,22 @@ export async function evaluateCampaigns(
metric_value: metricValue,
triggered: true,
action_taken: result.success ? rule.action : null,
recommended_action: rule.action,
details: result.details,
timestamp: new Date().toISOString(),
});
} else {
evaluations.push({
rule,
campaign_id: campaignId,
campaign_name: campaignName,
metric_value: metricValue,
triggered: true,
action_taken: null,
recommended_action: rule.action,
details: `Recomendação (modo aviso): ${rule.action}${rule.metric.toUpperCase()} = ${metricValue.toFixed(2)} (limite: ${rule.threshold})`,
timestamp: new Date().toISOString(),
});
}
}
}

View file

@ -2,7 +2,7 @@
// Automation Engine — Rule Types & Defaults
// ============================================
export type RuleMetric = 'cpl' | 'cpc' | 'ctr' | 'roas' | 'cpm' | 'spend';
export type RuleMetric = 'cpa' | 'purchases' | 'cpl' | 'cpc' | 'ctr' | 'roas' | 'cpm' | 'spend';
export type RuleOperator = '>' | '<' | '>=' | '<=';
export type RuleAction = 'pause' | 'reduce_budget' | 'increase_budget' | 'notify' | 'replace_with_ai';
@ -29,6 +29,8 @@ export interface RuleEvaluation {
metric_value: number;
triggered: boolean;
action_taken: RuleAction | null;
// Ação que a regra recomendou (mesmo quando só notificou, sem executar).
recommended_action?: RuleAction | null;
details: string;
timestamp: string;
}
@ -47,6 +49,8 @@ export interface ActivityLog {
// ---- Metric Labels (PT-BR) ----
export const METRIC_LABELS: Record<RuleMetric, string> = {
cpa: 'Custo por Compra (CPA)',
purchases: 'Nº de Compras',
cpl: 'Custo por Lead (CPL)',
cpc: 'Custo por Clique (CPC)',
ctr: 'Taxa de Clique (CTR %)',
@ -70,27 +74,29 @@ export const ACTION_LABELS: Record<RuleAction, string> = {
replace_with_ai: 'Pausar e Substituir via IA',
};
// ---- Default Rules ----
// ---- Default Rules (afinadas para produto de entrada / otimização por compra) ----
// Ajuste os thresholds ao seu funil: o CPA aceitável é função do quanto cada
// comprador rende (entrada + order bump + upsell), não dos R$9,99.
export const DEFAULT_RULES: Omit<AutomationRule, 'id' | 'created_at' | 'updated_at'>[] = [
{
name: 'CPL Alto — Pausar',
name: 'Gastou e não vendeu — Pausar',
enabled: true,
metric: 'cpl',
operator: '>',
threshold: 50,
min_spend: 30,
min_impressions: 1000,
metric: 'purchases',
operator: '<',
threshold: 1, // 0 compras
min_spend: 40, // já gastou o suficiente pra concluir que não converte
min_impressions: 1500,
action: 'pause',
cooldown_hours: 24,
},
{
name: 'ROAS Baixo — Pausar',
name: 'CPA Alto — Pausar',
enabled: true,
metric: 'roas',
operator: '<',
threshold: 0.5,
min_spend: 50,
min_impressions: 2000,
metric: 'cpa',
operator: '>',
threshold: 30, // custo por compra acima do seu lucro por comprador
min_spend: 40,
min_impressions: 1500,
action: 'pause',
cooldown_hours: 24,
},
@ -99,30 +105,30 @@ export const DEFAULT_RULES: Omit<AutomationRule, 'id' | 'created_at' | 'updated_
enabled: true,
metric: 'ctr',
operator: '<',
threshold: 0.5,
min_spend: 20,
min_impressions: 2000,
threshold: 0.6,
min_spend: 25,
min_impressions: 2500,
action: 'pause',
cooldown_hours: 24,
},
{
name: 'CPC Alto — Reduzir Budget 30%',
enabled: false,
metric: 'cpc',
operator: '>',
threshold: 5,
min_spend: 30,
min_impressions: 1000,
name: 'ROAS Baixo — Reduzir Budget 30%',
enabled: false, // em modo misto, vira só aviso
metric: 'roas',
operator: '<',
threshold: 0.8,
min_spend: 60,
min_impressions: 3000,
action: 'reduce_budget',
action_value: 30,
cooldown_hours: 48,
},
{
name: 'ROAS Excelente — Aumentar Budget 20%',
enabled: false,
enabled: false, // em modo misto, vira só aviso
metric: 'roas',
operator: '>',
threshold: 3,
threshold: 2.5,
min_spend: 100,
min_impressions: 5000,
action: 'increase_budget',
@ -130,3 +136,7 @@ export const DEFAULT_RULES: Omit<AutomationRule, 'id' | 'created_at' | 'updated_
cooldown_hours: 48,
},
];
// Em modo de autonomia "misto", só estas ações são executadas automaticamente.
// As demais (orçamento, IA) viram notificação (o engine avisa, mas não age).
export const AUTO_EXECUTE_ACTIONS: RuleAction[] = ['pause', 'notify'];

137
src/lib/engine/store.ts Normal file
View file

@ -0,0 +1,137 @@
// ============================================
// Automation Engine — Persistência (Supabase)
// ============================================
// Tabelas necessárias (rode o SQL em docs/automation.sql no Supabase):
// automation_rules, automation_logs, app_config
// Tudo com fallback gracioso: se o Supabase estiver fora, cai nas regras padrão
// e segue sem quebrar.
import { getSupabaseServerClient } from '@/lib/supabase/server';
import { DEFAULT_RULES } from './rules';
import type { AutomationRule, ActivityLog } from './rules';
const RULES_TABLE = 'automation_rules';
const LOGS_TABLE = 'automation_logs';
const CONFIG_TABLE = 'app_config';
function defaultsWithIds(): AutomationRule[] {
return DEFAULT_RULES.map((r, i) => ({ ...r, id: `default-${i}` }));
}
// ---- Regras ----
export async function loadRules(): Promise<AutomationRule[]> {
try {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from(RULES_TABLE)
.select('*')
.order('created_at', { ascending: true });
if (error) throw error;
if (Array.isArray(data) && data.length > 0) {
return data as unknown as AutomationRule[];
}
// Tabela vazia → semeia com as regras padrão.
const { data: inserted } = await supabase
.from(RULES_TABLE)
.insert(DEFAULT_RULES as never)
.select();
return (inserted as unknown as AutomationRule[]) || defaultsWithIds();
} catch (e) {
console.warn('[Engine.store] loadRules fallback:', e instanceof Error ? e.message : e);
return defaultsWithIds();
}
}
export async function createRule(
rule: Omit<AutomationRule, 'id' | 'created_at' | 'updated_at'>
): Promise<AutomationRule> {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from(RULES_TABLE)
.insert(rule as never)
.select()
.single();
if (error) throw error;
return data as unknown as AutomationRule;
}
export async function updateRule(
id: string,
updates: Partial<AutomationRule>
): Promise<AutomationRule | null> {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from(RULES_TABLE)
.update({ ...updates, updated_at: new Date().toISOString() } as never)
.eq('id', id)
.select()
.single();
if (error) throw error;
return (data as unknown as AutomationRule) || null;
}
export async function deleteRule(id: string): Promise<void> {
const supabase = getSupabaseServerClient();
const { error } = await supabase.from(RULES_TABLE).delete().eq('id', id);
if (error) throw error;
}
// ---- Logs de atividade ----
export async function loadRecentLogs(hours = 72): Promise<ActivityLog[]> {
try {
const supabase = getSupabaseServerClient();
const cutoff = new Date(Date.now() - hours * 3600 * 1000).toISOString();
const { data, error } = await supabase
.from(LOGS_TABLE)
.select('*')
.gte('created_at', cutoff)
.order('created_at', { ascending: false })
.limit(500);
if (error) throw error;
return (data as unknown as ActivityLog[]) || [];
} catch (e) {
console.warn('[Engine.store] loadRecentLogs fallback:', e instanceof Error ? e.message : e);
return [];
}
}
export async function saveLogs(logs: ActivityLog[]): Promise<void> {
if (logs.length === 0) return;
try {
const supabase = getSupabaseServerClient();
const { error } = await supabase.from(LOGS_TABLE).insert(logs as never);
if (error) throw error;
} catch (e) {
console.warn('[Engine.store] saveLogs falhou:', e instanceof Error ? e.message : e);
}
}
// ---- Config (token da Meta, etc.) ----
export async function getConfig(key: string): Promise<string | null> {
try {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from(CONFIG_TABLE)
.select('value')
.eq('key', key)
.single();
if (error) throw error;
return (data as { value?: string } | null)?.value ?? null;
} catch {
return null;
}
}
export async function setConfig(key: string, value: string): Promise<void> {
try {
const supabase = getSupabaseServerClient();
const { error } = await supabase
.from(CONFIG_TABLE)
.upsert({ key, value, updated_at: new Date().toISOString() } as never, { onConflict: 'key' });
if (error) throw error;
} catch (e) {
console.warn('[Engine.store] setConfig falhou:', e instanceof Error ? e.message : e);
}
}