cdp-edge 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,138 +1,353 @@
1
- /**
2
- * Setup Wizard - Wrapper para invocar CDP Edge Skill
1
+ /**
2
+ * CDP Edge Setup Wizard Guiado e não-técnico
3
3
  *
4
- * O CLI é apenas um disparador. A skill (Master Orchestrator) faz tudo.
4
+ * Coleta os dados do projeto do cliente e gera:
5
+ * • Comandos wrangler secret put prontos para copiar
6
+ * • URLs de webhook com domínio preenchido
7
+ * • Checklist de próximos passos para o agente
5
8
  */
6
9
 
7
10
  import inquirer from 'inquirer';
8
11
  import chalk from 'chalk';
9
12
  import ora from 'ora';
13
+ import { writeFileSync } from 'fs';
14
+ import { join } from 'path';
10
15
 
11
16
  function printBanner() {
12
17
  console.log('');
13
- console.log(chalk.white.bold(' CDP Edge Setup Wizard'));
14
- console.log('');
15
- console.log(chalk.cyan(' ██████╗██████╗ ██████╗ ███████╗██████╗ ██████╗ ███████╗'));
16
- console.log(chalk.cyan('██╔════╝██╔══██╗██╔══██╗ ██╔════╝██╔══██╗██╔════╝ ██╔════╝'));
17
- console.log(chalk.cyan('██║ ██║ ██║██████╔╝ █████╗ ██║ ██║██║ ███╗█████╗ '));
18
- console.log(chalk.cyan('██║ ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██║ ██║██╔══╝ '));
19
- console.log(chalk.cyan('╚██████╗██████╔╝██║ ███████╗██████╔╝╚██████╔╝███████╗'));
20
- console.log(chalk.cyan(' ╚═════╝╚═════╝ ╚═╝ ╚══════╝╚═════╝ ╚═════╝╚══════╝'));
21
- console.log('');
22
- console.log(chalk.gray(' Customer Data Platform on the Edge · Global Edge Tracking · v2.0.3'));
23
- console.log('');
24
- console.log(chalk.gray('═'.repeat(68)));
18
+ console.log(chalk.cyan(' ╔═══════════════════════════════════════╗'));
19
+ console.log(chalk.cyan('') + chalk.white.bold(' CDP Edge — Setup Wizard ') + chalk.cyan('║'));
20
+ console.log(chalk.cyan(' ║') + chalk.gray(' Configuração Guiada de Tracking ') + chalk.cyan('║'));
21
+ console.log(chalk.cyan(' ╚═══════════════════════════════════════╝'));
25
22
  console.log('');
26
23
  }
27
24
 
25
+ function sep(label = '') {
26
+ if (label) {
27
+ console.log('\n' + chalk.gray('── ') + chalk.cyan.bold(label) + chalk.gray(' ' + '─'.repeat(Math.max(0, 40 - label.length))));
28
+ } else {
29
+ console.log(chalk.gray('─'.repeat(50)));
30
+ }
31
+ }
32
+
28
33
  export async function runSetupWizard(dir = '.') {
29
34
  printBanner();
30
35
 
31
- // === MENSAGEM INICIAL ===
36
+ console.log(chalk.cyan(' Vou te fazer algumas perguntas simples.'));
37
+ console.log(chalk.cyan(' Não precisa saber programar — é só preencher os dados.\n'));
38
+ console.log(chalk.gray(' Ao final, vou gerar todos os comandos prontos para instalar.\n'));
32
39
 
33
- console.log(chalk.gray('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
34
- console.log(chalk.green.bold(' ✅ CDP Edge INSTALADO COM SUCESSO!'));
35
- console.log(chalk.gray('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
40
+ // ── PASSO 1: Dados do projeto ──────────────────────────────────────────────
41
+ sep('1. Dados do Projeto');
36
42
 
37
- console.log(chalk.cyan('🎯 O Master Orchestrator controla todo o processo de criação.'));
38
- console.log(chalk.cyan(' Ele vai:\n'));
39
- console.log(chalk.cyan(' • Fazer as perguntas necessárias'));
40
- console.log(chalk.cyan(' • Analisar suas páginas automaticamente'));
41
- console.log(chalk.cyan(' Chamar os agentes especialistas'));
42
- console.log(chalk.cyan(' • Gerar todos os arquivos de tracking'));
43
- console.log(chalk.cyan(' Validar o código gerado\n'));
43
+ const projeto = await inquirer.prompt([
44
+ {
45
+ type: 'input',
46
+ name: 'domain',
47
+ message: 'Qual o domínio do site? (ex: meusite.com.br)',
48
+ validate: v => v.trim() ? true : 'Domínio é obrigatório',
49
+ filter: v => v.trim().replace(/^https?:\/\//, '').replace(/\/$/, ''),
50
+ },
51
+ {
52
+ type: 'input',
53
+ name: 'projectName',
54
+ message: 'Nome do projeto (para identificação):',
55
+ default: answers => answers.domain.split('.')[0],
56
+ },
57
+ {
58
+ type: 'list',
59
+ name: 'productType',
60
+ message: 'Que tipo de produto você vende?',
61
+ choices: [
62
+ { name: 'Infoproduto / Curso Online', value: 'infoproduto' },
63
+ { name: 'E-commerce físico', value: 'ecommerce' },
64
+ { name: 'SaaS / Assinatura', value: 'saas' },
65
+ { name: 'Serviço / Consultoria', value: 'servico' },
66
+ { name: 'Lead Generation (captação)', value: 'leadgen' },
67
+ ],
68
+ },
69
+ ]);
44
70
 
45
- // === MENU DE OPÇÃO ===
71
+ // ── PASSO 2: Plataforma de vendas ──────────────────────────────────────────
72
+ sep('2. Plataforma de Vendas / Checkout');
46
73
 
47
- const menu = await inquirer.prompt([
74
+ const { plataformas } = await inquirer.prompt([
48
75
  {
49
- type: 'list',
50
- name: 'action',
51
- message: 'O que você deseja fazer?',
76
+ type: 'checkbox',
77
+ name: 'plataformas',
78
+ message: 'Quais plataformas de checkout você usa? (espaço para marcar)',
52
79
  choices: [
53
- { name: '🚀 Iniciar Master Orchestrator (Configuração Completa)', value: 'start' },
54
- { name: '📖 Ver documentação', value: 'docs' },
55
- { name: '❌ Sair', value: 'exit' }
56
- ]
57
- }
80
+ { name: 'Hotmart', value: 'hotmart', checked: false },
81
+ { name: 'Kiwify', value: 'kiwify', checked: false },
82
+ { name: 'Ticto', value: 'ticto', checked: false },
83
+ { name: 'Eduzz', value: 'eduzz', checked: false },
84
+ { name: 'Stripe', value: 'stripe', checked: false },
85
+ { name: 'Nenhuma (apenas formulário de captação)', value: 'none', checked: false },
86
+ ],
87
+ validate: v => v.length > 0 ? true : 'Selecione pelo menos uma opção',
88
+ },
89
+ ]);
90
+
91
+ // ── PASSO 3: Plataformas de anúncios ───────────────────────────────────────
92
+ sep('3. Plataformas de Anúncios');
93
+
94
+ const { adsPlataformas } = await inquirer.prompt([
95
+ {
96
+ type: 'checkbox',
97
+ name: 'adsPlataformas',
98
+ message: 'Onde você faz anúncios? (espaço para marcar)',
99
+ choices: [
100
+ { name: 'Meta Ads (Facebook / Instagram)', value: 'meta', checked: true },
101
+ { name: 'Google Ads / YouTube', value: 'google', checked: false },
102
+ { name: 'TikTok Ads', value: 'tiktok', checked: false },
103
+ { name: 'Pinterest Ads', value: 'pinterest', checked: false },
104
+ { name: 'LinkedIn Ads', value: 'linkedin', checked: false },
105
+ ],
106
+ validate: v => v.length > 0 ? true : 'Selecione pelo menos uma plataforma',
107
+ },
58
108
  ]);
59
109
 
60
- if (menu.action === 'exit') {
61
- console.log(chalk.yellow('\n👋 Até logo!\n'));
62
- process.exit(0);
63
- }
64
-
65
- if (menu.action === 'docs') {
66
- console.log(chalk.cyan('\n📚 Documentação:'));
67
- console.log(' Acesse: ' + chalk.underline('docs/guia-cloudflare-iniciante.md'));
68
- console.log(' Ou visite: ' + chalk.underline('github.com/ricardosoli777/CDP-Edge-Premium'));
69
- return;
70
- }
71
-
72
- if (menu.action === 'start') {
73
- const spinner = ora('Iniciando Master Orchestrator...').start();
74
- await sleep(800);
75
- spinner.succeed(chalk.green('Master Orchestrator iniciado!'));
76
-
77
- console.log(chalk.gray('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
78
- console.log(chalk.cyan.bold(' ' + chalk.bold('🧠 MASTER ORCHESTRATOR ATIVO')));
79
- console.log(chalk.cyan(' Controlando todo o fluxo de criação...'));
80
- console.log(chalk.gray('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
81
-
82
- // === AQUI INVOCARIA A SKILL ===
83
- //
84
- // Em produção, aqui seria:
85
- //
86
- // import { spawnMasterOrchestrator } from '../skill-wrapper';
87
- // await spawnMasterOrchestrator({ dir, platforms: [] });
88
- //
89
- // A skill faria:
90
- // - Perguntar modo (guiado/livre)
91
- // - Perguntar plataformas
92
- // - Acessar projeto
93
- // - Spawnar Page Analyzer Agent
94
- // - Spawnar Browser Agent
95
- // - Spawnar Meta Agent, Google Agent, TikTok Agent
96
- // - Spawnar Server Agent
97
- // - Gerar todos os arquivos
98
- // - Validar
99
-
100
- // === DEMONSTRAÇÃO DO QUE ACONTECE ===
101
-
102
- console.log(chalk.yellow.bold('\n🔄 MODO DEMONSTRAÇÃO\n'));
103
- console.log(chalk.gray(' Nesta versão, veja o fluxo que a skill executaria:\n'));
104
-
105
- const demoFlow = [
106
- ' [1] Perguntar: Como prefere configurar?',
107
- ' → Guiado ou Livre',
108
- ' [2] Perguntar: Quais plataformas de ads usa?',
109
- ' → Meta, Google, TikTok, LinkedIn, Spotify...',
110
- ' [3] Acessar: Seu projeto (GitHub ou local)',
111
- ' [4] Page Analyzer: Analisar páginas',
112
- ' → Detecta tipo de produto, nicho, formulários',
113
- ' [5] Browser Agent: Gerar cdpTrack.js',
114
- ' → Tracking SDK + micro-events',
115
- ' [6] Meta Agent: Gerar Pixel + CAPI',
116
- ' [7] Google Agent: Gerar GA4 + Google Ads',
117
- ' [8] TikTok Agent: Gerar Pixel + Events API',
118
- ' [9] Server Agent: Gerar Worker + D1',
119
- ' [10] Validator: Auditar código gerado',
120
- ' [11] Entregar: Arquivos no seu projeto',
121
- ' → tracking.config.js, cdpTrack.js, worker.js, schema.sql'
122
- ];
123
-
124
- demoFlow.forEach(line => console.log(chalk.cyan(line)));
125
-
126
- console.log(chalk.gray('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
127
- console.log(chalk.green.bold(' ✅ SETUP CONCLUÍDO!'));
128
- console.log(chalk.gray('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
129
-
130
- console.log(chalk.cyan('📁 Arquivos gerados: ' + chalk.underline(dir || '.')));
131
- console.log(chalk.gray('\nPróximos passos:'));
132
- console.log(' 1. Configure seus API tokens no Wrangler');
133
- console.log(' 2. Faça o deploy: ' + chalk.bold('wrangler deploy'));
134
- console.log(' 3. Configure o domínio no Cloudflare Dashboard');
110
+ // ── PASSO 4: Credenciais Meta ──────────────────────────────────────────────
111
+ const creds = {};
112
+
113
+ if (adsPlataformas.includes('meta')) {
114
+ sep('4a. Credenciais — Meta Ads');
115
+ console.log(chalk.gray(' Onde encontrar: Meta Events Manager → Configurações → Pixel\n'));
116
+
117
+ const meta = await inquirer.prompt([
118
+ {
119
+ type: 'input',
120
+ name: 'pixelId',
121
+ message: 'Meta Pixel ID:',
122
+ validate: v => v.trim() ? true : 'Pixel ID é obrigatório',
123
+ filter: v => v.trim(),
124
+ },
125
+ {
126
+ type: 'password',
127
+ name: 'accessToken',
128
+ message: 'Meta Access Token (CAPI):',
129
+ mask: '*',
130
+ validate: v => v.trim() ? true : 'Access Token é obrigatório',
131
+ },
132
+ {
133
+ type: 'input',
134
+ name: 'testCode',
135
+ message: 'Meta Test Event Code (opcional, para homologação):',
136
+ default: '',
137
+ filter: v => v.trim(),
138
+ },
139
+ ]);
140
+ creds.meta = meta;
141
+ }
142
+
143
+ if (adsPlataformas.includes('google')) {
144
+ sep('4b. Credenciais Google Analytics 4');
145
+ console.log(chalk.gray(' Onde encontrar: GA4 Admin Data Streams → Measurement Protocol\n'));
146
+
147
+ const google = await inquirer.prompt([
148
+ {
149
+ type: 'input',
150
+ name: 'measurementId',
151
+ message: 'GA4 Measurement ID (G-XXXXXXXX):',
152
+ validate: v => /^G-[A-Z0-9]+$/i.test(v.trim()) ? true : 'Formato: G-XXXXXXXX',
153
+ filter: v => v.trim().toUpperCase(),
154
+ },
155
+ {
156
+ type: 'password',
157
+ name: 'apiSecret',
158
+ message: 'GA4 API Secret:',
159
+ mask: '*',
160
+ validate: v => v.trim() ? true : 'API Secret é obrigatório',
161
+ },
162
+ ]);
163
+ creds.google = google;
164
+ }
165
+
166
+ if (adsPlataformas.includes('tiktok')) {
167
+ sep('4c. Credenciais TikTok');
168
+ console.log(chalk.gray(' Onde encontrar: TikTok Events Manager → Pixel → Detalhes\n'));
169
+
170
+ const tiktok = await inquirer.prompt([
171
+ {
172
+ type: 'input',
173
+ name: 'pixelId',
174
+ message: 'TikTok Pixel ID:',
175
+ validate: v => v.trim() ? true : 'Pixel ID é obrigatório',
176
+ filter: v => v.trim(),
177
+ },
178
+ {
179
+ type: 'password',
180
+ name: 'accessToken',
181
+ message: 'TikTok Access Token:',
182
+ mask: '*',
183
+ validate: v => v.trim() ? true : 'Access Token é obrigatório',
184
+ },
185
+ ]);
186
+ creds.tiktok = tiktok;
187
+ }
188
+
189
+ // ── PASSO 5: Alertas WhatsApp ──────────────────────────────────────────────
190
+ sep('5. Alertas WhatsApp (opcional)');
191
+ console.log(chalk.gray(' Receba alertas de erros e match quality direto no WhatsApp.\n'));
192
+
193
+ const { wantsWhatsapp } = await inquirer.prompt([
194
+ {
195
+ type: 'confirm',
196
+ name: 'wantsWhatsapp',
197
+ message: 'Deseja ativar alertas via WhatsApp?',
198
+ default: true,
199
+ },
200
+ ]);
201
+
202
+ let whatsapp = {};
203
+ if (wantsWhatsapp) {
204
+ console.log(chalk.gray('\n Como configurar: acesse callmebot.com e adicione o bot no WhatsApp.'));
205
+ console.log(chalk.gray(' Envie "I allow callmebot to send me messages" para +34 644 35 78 48\n'));
206
+
207
+ whatsapp = await inquirer.prompt([
208
+ {
209
+ type: 'input',
210
+ name: 'phone',
211
+ message: 'Seu número com DDI (ex: +5511999999999):',
212
+ validate: v => /^\+\d{10,15}$/.test(v.trim()) ? true : 'Formato: +5511999999999',
213
+ filter: v => v.trim(),
214
+ },
215
+ {
216
+ type: 'password',
217
+ name: 'apiKey',
218
+ message: 'CallMeBot API Key:',
219
+ mask: '*',
220
+ validate: v => v.trim() ? true : 'API Key é obrigatória',
221
+ },
222
+ ]);
223
+ }
224
+
225
+ // ── GERAÇÃO DOS OUTPUTS ────────────────────────────────────────────────────
226
+ const spinner = ora('Gerando configurações...').start();
227
+ await sleep(600);
228
+ spinner.succeed('Configurações geradas!');
229
+
230
+ const domain = projeto.domain;
231
+
232
+ // Monta lista de secrets
233
+ const secrets = [];
234
+
235
+ if (creds.meta) {
236
+ secrets.push({ key: 'META_PIXEL_ID', value: creds.meta.pixelId, platform: 'Meta' });
237
+ secrets.push({ key: 'META_ACCESS_TOKEN', value: creds.meta.accessToken, platform: 'Meta' });
238
+ if (creds.meta.testCode) {
239
+ secrets.push({ key: 'META_TEST_CODE', value: creds.meta.testCode, platform: 'Meta (homologação)' });
240
+ }
241
+ }
242
+
243
+ if (creds.google) {
244
+ secrets.push({ key: 'GA4_MEASUREMENT_ID', value: creds.google.measurementId, platform: 'Google' });
245
+ secrets.push({ key: 'GA4_API_SECRET', value: creds.google.apiSecret, platform: 'Google' });
246
+ }
247
+
248
+ if (creds.tiktok) {
249
+ secrets.push({ key: 'TIKTOK_PIXEL_ID', value: creds.tiktok.pixelId, platform: 'TikTok' });
250
+ secrets.push({ key: 'TIKTOK_ACCESS_TOKEN', value: creds.tiktok.accessToken, platform: 'TikTok' });
135
251
  }
252
+
253
+ if (whatsapp.phone) {
254
+ secrets.push({ key: 'CALLMEBOT_PHONE', value: whatsapp.phone, platform: 'WhatsApp' });
255
+ secrets.push({ key: 'CALLMEBOT_API_KEY', value: whatsapp.apiKey, platform: 'WhatsApp' });
256
+ }
257
+
258
+ secrets.push({ key: 'SITE_DOMAIN', value: domain, platform: 'Worker' });
259
+
260
+ // Webhooks
261
+ const webhookUrls = {};
262
+ const webhookSecrets = [];
263
+ if (plataformas.includes('hotmart')) {
264
+ webhookUrls.hotmart = `https://${domain}/webhook/hotmart`;
265
+ webhookSecrets.push({ key: 'WEBHOOK_SECRET_HOTMART', platform: 'Hotmart', note: 'Gere uma senha forte qualquer' });
266
+ }
267
+ if (plataformas.includes('kiwify')) {
268
+ webhookUrls.kiwify = `https://${domain}/webhook/kiwify`;
269
+ webhookSecrets.push({ key: 'WEBHOOK_SECRET_KIWIFY', platform: 'Kiwify', note: 'Gere uma senha forte qualquer' });
270
+ }
271
+ if (plataformas.includes('ticto')) {
272
+ webhookUrls.ticto = `https://${domain}/webhook/ticto`;
273
+ webhookSecrets.push({ key: 'WEBHOOK_SECRET_TICTO', platform: 'Ticto', note: 'Chave HMAC configurada no painel Ticto' });
274
+ }
275
+
276
+ // ── EXIBIR RESULTADO ───────────────────────────────────────────────────────
277
+ console.log('\n');
278
+ sep('CONFIGURAÇÃO PRONTA');
279
+
280
+ console.log(chalk.green.bold('\n Projeto: ') + chalk.white(projeto.projectName));
281
+ console.log(chalk.green.bold(' Domínio: ') + chalk.white(domain));
282
+ console.log(chalk.green.bold(' Produto: ') + chalk.white(projeto.productType));
283
+ console.log(chalk.green.bold(' Ads: ') + chalk.white(adsPlataformas.join(', ')));
284
+
285
+ // Comandos de secrets
286
+ sep('Comandos para configurar os secrets');
287
+ console.log(chalk.gray(' Execute dentro da pasta server-edge-tracker:\n'));
288
+
289
+ for (const s of secrets) {
290
+ const masked = s.value.length > 4 ? s.value.slice(0, 4) + '*'.repeat(Math.min(s.value.length - 4, 8)) : '****';
291
+ console.log(chalk.cyan(` wrangler secret put ${s.key}`));
292
+ console.log(chalk.gray(` # ${s.platform} — valor: ${masked}\n`));
293
+ }
294
+
295
+ if (webhookSecrets.length > 0) {
296
+ console.log(chalk.yellow(' # Secrets de webhook (gere senhas e configure abaixo + no painel da plataforma):'));
297
+ for (const ws of webhookSecrets) {
298
+ console.log(chalk.cyan(` wrangler secret put ${ws.key}`));
299
+ console.log(chalk.gray(` # ${ws.platform}: ${ws.note}\n`));
300
+ }
301
+ }
302
+
303
+ // URLs de webhook
304
+ if (Object.keys(webhookUrls).length > 0) {
305
+ sep('URLs de Webhook para configurar nas plataformas');
306
+ for (const [plat, url] of Object.entries(webhookUrls)) {
307
+ console.log(chalk.green.bold(` ${plat.charAt(0).toUpperCase() + plat.slice(1)}:`));
308
+ console.log(chalk.white(` ${url}\n`));
309
+ }
310
+ }
311
+
312
+ // Checklist
313
+ sep('Próximos passos');
314
+ const steps = [
315
+ 'Executar todos os wrangler secret put acima',
316
+ 'Executar as migrations D1 (schema.sql → migrate-v7.sql)',
317
+ 'Executar: wrangler deploy',
318
+ ...(Object.keys(webhookUrls).length > 0 ? ['Configurar as URLs de webhook nas plataformas de venda'] : []),
319
+ 'Configurar Worker Route no Cloudflare: ' + domain + '/*',
320
+ 'Testar o endpoint: https://' + domain + '/health',
321
+ ...(adsPlataformas.includes('meta') && creds.meta?.testCode ? ['Testar eventos no Meta Events Manager com o Test Event Code'] : []),
322
+ ];
323
+
324
+ steps.forEach((step, i) => {
325
+ console.log(chalk.cyan(` [${i + 1}] `) + chalk.white(step));
326
+ });
327
+
328
+ // Salvar config (sem tokens reais)
329
+ const configOutput = {
330
+ generatedAt: new Date().toISOString(),
331
+ domain,
332
+ projectName: projeto.projectName,
333
+ productType: projeto.productType,
334
+ adsPlataformas,
335
+ checkoutPlataformas: plataformas,
336
+ webhookUrls,
337
+ secretKeys: secrets.map(s => s.key),
338
+ whatsappAlertsEnabled: !!whatsapp.phone,
339
+ };
340
+
341
+ const outputPath = join(dir, 'cdp-edge-setup.json');
342
+ try {
343
+ writeFileSync(outputPath, JSON.stringify(configOutput, null, 2), 'utf8');
344
+ console.log(chalk.gray(`\n Configuração salva em: ${outputPath} (sem tokens)\n`));
345
+ } catch {
346
+ // não crítico se não conseguir salvar
347
+ }
348
+
349
+ sep();
350
+ console.log(chalk.green.bold('\n Setup concluído! Passe os comandos acima para o agente instalar.\n'));
136
351
  }
137
352
 
138
353
  function sleep(ms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-edge",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "CDP Edge - Quantum Tracking - Sistema multi-agente para tracking digital Cloudflare Native (Workers + D1)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -0,0 +1,64 @@
1
+ -- CDP Edge — Migration v7
2
+ -- Tabelas para: LTV Model (regressão logística) + Match Quality Log
3
+ -- Idempotente: todos os CREATE usam IF NOT EXISTS
4
+
5
+ -- ── LTV Model Weights ─────────────────────────────────────────────────────────
6
+ -- Armazena os pesos treinados pelo cron semanal de regressão logística.
7
+ -- O Worker carrega via KV (TTL 7 dias) — query D1 apenas se KV miss.
8
+ CREATE TABLE IF NOT EXISTS ltv_model_weights (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ trained_at TEXT NOT NULL DEFAULT (datetime('now')),
11
+ version INTEGER NOT NULL DEFAULT 1,
12
+ is_active INTEGER NOT NULL DEFAULT 0,
13
+ sample_size INTEGER NOT NULL DEFAULT 0,
14
+ positive_rate REAL, -- % de samples com label=1 (compra)
15
+ accuracy REAL, -- acurácia no conjunto de treino
16
+ weights_json TEXT NOT NULL -- JSON: {"bias": n, "features": {"utm_fb": w, ...}}
17
+ );
18
+
19
+ CREATE INDEX IF NOT EXISTS idx_ltv_weights_active ON ltv_model_weights(is_active);
20
+ CREATE INDEX IF NOT EXISTS idx_ltv_weights_trained ON ltv_model_weights(trained_at DESC);
21
+
22
+ -- ── Match Quality Log ─────────────────────────────────────────────────────────
23
+ -- Registra a qualidade dos dados enviados ao Meta CAPI por evento.
24
+ -- Sem PII — apenas flags booleanos.
25
+ CREATE TABLE IF NOT EXISTS match_quality_log (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ logged_at TEXT NOT NULL DEFAULT (datetime('now')),
28
+ event_name TEXT NOT NULL,
29
+ has_email INTEGER NOT NULL DEFAULT 0,
30
+ has_phone INTEGER NOT NULL DEFAULT 0,
31
+ has_fbp INTEGER NOT NULL DEFAULT 0,
32
+ has_fbc INTEGER NOT NULL DEFAULT 0,
33
+ has_external_id INTEGER NOT NULL DEFAULT 0,
34
+ was_email_recovered INTEGER NOT NULL DEFAULT 0, -- recuperado do Identity Graph
35
+ was_utm_restored INTEGER NOT NULL DEFAULT 0 -- via UTM Resurrection
36
+ );
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_mq_logged ON match_quality_log(logged_at DESC);
39
+ CREATE INDEX IF NOT EXISTS idx_mq_event ON match_quality_log(event_name, logged_at DESC);
40
+
41
+ -- ── View: Match Quality Dashboard (últimas 24h) ───────────────────────────────
42
+ CREATE VIEW IF NOT EXISTS v_match_quality_24h AS
43
+ SELECT
44
+ COUNT(*) AS total_events,
45
+ ROUND(AVG(has_email) * 100, 1) AS email_rate_pct,
46
+ ROUND(AVG(has_phone) * 100, 1) AS phone_rate_pct,
47
+ ROUND(AVG(has_fbp) * 100, 1) AS fbp_rate_pct,
48
+ ROUND(AVG(has_fbc) * 100, 1) AS fbc_rate_pct,
49
+ ROUND(AVG(has_external_id) * 100, 1) AS ext_id_rate_pct,
50
+ ROUND(AVG(was_email_recovered) * 100, 1) AS email_recovered_pct,
51
+ ROUND(AVG(was_utm_restored) * 100, 1) AS utm_restored_pct,
52
+ -- Score composto: email(40%) + fbp(30%) + phone(20%) + fbc(10%)
53
+ ROUND((AVG(has_email)*0.4 + AVG(has_fbp)*0.3 + AVG(has_phone)*0.2 + AVG(has_fbc)*0.1) * 100, 1) AS composite_score_pct
54
+ FROM match_quality_log
55
+ WHERE logged_at >= datetime('now', '-24 hours');
56
+
57
+ -- ── ALTER: colunas de auditoria em ltv_ab_tests ───────────────────────────────
58
+ -- Adiciona auto_decided_at se ainda não existir (SQLite não tem IF NOT EXISTS em ALTER)
59
+ -- Protegido por trigger-style: INSERT OR IGNORE não se aplica a ALTER, então usamos
60
+ -- um approach de criação de tabela auxiliar que faz nada se a coluna já existir.
61
+ -- A abordagem mais segura no Cloudflare D1 é tentar e ignorar o erro no app.
62
+ -- Aqui deixamos comentado — o worker.js já lida com campos opcionais:
63
+ -- ALTER TABLE ltv_ab_tests ADD COLUMN auto_decided_at TEXT;
64
+ -- ALTER TABLE ltv_ab_tests ADD COLUMN auto_decided_reason TEXT;
@@ -5,8 +5,17 @@
5
5
 
6
6
  import { sha256, normalizePhone, normalizeCity } from '../utils.js';
7
7
  import { logApiFailure } from '../db.js';
8
+ import { logMatchQuality, autoEnrichPayload } from '../ml/matchquality.js';
8
9
 
9
10
  export async function sendMetaCapi(env, eventName, payload, request, ctx) {
11
+ // Auto-enriquecer payload com dados do Identity Graph antes do envio
12
+ let recovered = { email: false, utm: false };
13
+ if (env.DB && payload) {
14
+ const enriched = await autoEnrichPayload(env, payload);
15
+ payload = enriched.payload;
16
+ recovered = enriched.recovered;
17
+ }
18
+
10
19
  const {
11
20
  email, phone, firstName, lastName,
12
21
  city, state, country,
@@ -69,6 +78,13 @@ export async function sendMetaCapi(env, eventName, payload, request, ctx) {
69
78
  requestBody.test_event_code = env.META_TEST_CODE;
70
79
  }
71
80
 
81
+ // Logar match quality em background (não bloqueia dispatch)
82
+ if (env.DB && ctx) {
83
+ ctx.waitUntil(logMatchQuality(env.DB, eventName, payload, recovered));
84
+ } else if (env.DB) {
85
+ logMatchQuality(env.DB, eventName, payload, recovered).catch(() => {});
86
+ }
87
+
72
88
  const endpoint = `https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events`;
73
89
 
74
90
  try {
@@ -6,6 +6,9 @@
6
6
  import { sha256 } from './utils.js';
7
7
  import { getHealthMetrics, generateDailyReport, logIntelligence } from './db.js';
8
8
  import { sendCallMeBot } from './dispatch/whatsapp.js';
9
+ import { autoDecideAbWinner } from './ml/ltv.js';
10
+ import { analyzeMatchQuality, alertMatchQuality, purgeOldMatchQualityLogs } from './ml/matchquality.js';
11
+ import { trainLogisticRegression, extractFeatures, saveWeights, LTV_WEIGHTS_KV_KEY } from './ml/logistic.js';
9
12
 
10
13
  // ── Versões esperadas das APIs ────────────────────────────────────────────────
11
14
  const EXPECTED_API_VERSIONS = {
@@ -77,6 +80,62 @@ export async function auditErrorRates(env, runType) {
77
80
  return alerts;
78
81
  }
79
82
 
83
+ // ── Treinar modelo LTV (regressão logística com dados reais do D1) ────────────
84
+ export async function trainLtvModel(env) {
85
+ if (!env.DB) return { skipped: 'DB não disponível' };
86
+
87
+ try {
88
+ // Busca leads com informação de conversão (compra confirmada)
89
+ const rows = await env.DB.prepare(`
90
+ SELECT
91
+ l.utm_source,
92
+ l.utm_medium,
93
+ l.engagement_score,
94
+ l.intention_level,
95
+ CAST(julianday('now') - julianday(l.created_at) AS INTEGER) AS days_since_lead,
96
+ CASE WHEN l.email IS NOT NULL AND l.email != '' THEN 1 ELSE 0 END AS has_email,
97
+ CASE WHEN l.phone IS NOT NULL AND l.phone != '' THEN 1 ELSE 0 END AS has_phone,
98
+ CASE WHEN (l.country = 'br' OR l.country = 'BR' OR l.country IS NULL) THEN 1 ELSE 0 END AS is_br,
99
+ CAST(strftime('%H', l.created_at) AS INTEGER) AS hour,
100
+ CASE WHEN EXISTS (
101
+ SELECT 1 FROM events e
102
+ WHERE e.user_id = l.user_id
103
+ AND e.event_name IN ('Purchase', 'purchase', 'PURCHASE')
104
+ AND e.created_at > l.created_at
105
+ ) THEN 1 ELSE 0 END AS label
106
+ FROM leads l
107
+ WHERE l.created_at >= datetime('now', '-90 days')
108
+ LIMIT 5000
109
+ `).all();
110
+
111
+ const dataset = (rows.results || []).map(row => ({
112
+ features: extractFeatures(row),
113
+ label: row.label || 0,
114
+ }));
115
+
116
+ const model = trainLogisticRegression(dataset);
117
+
118
+ if (!model) {
119
+ console.log('[LTV Train] Dados insuficientes para treinar modelo');
120
+ return { skipped: 'dados insuficientes', samples: dataset.length };
121
+ }
122
+
123
+ await saveWeights(env.DB, model);
124
+
125
+ // Invalidar cache KV para que próximas requests carreguem o modelo novo
126
+ if (env.GEO_CACHE) {
127
+ env.GEO_CACHE.delete(LTV_WEIGHTS_KV_KEY).catch(() => {});
128
+ }
129
+
130
+ console.log(`[LTV Train] Modelo treinado: ${dataset.length} samples, accuracy=${(model.accuracy * 100).toFixed(1)}%, positive_rate=${(model.positiveRate * 100).toFixed(1)}%`);
131
+ return { trained: true, samples: dataset.length, accuracy: model.accuracy, positiveRate: model.positiveRate };
132
+
133
+ } catch (err) {
134
+ console.error('[LTV Train] Erro:', err.message);
135
+ return { error: err.message };
136
+ }
137
+ }
138
+
80
139
  // ── Runner principal do Intelligence Agent ────────────────────────────────────
81
140
  export async function runIntelligenceAgent(env, runType) {
82
141
  console.log(`[Intelligence Agent] Iniciando ${runType}`);
@@ -97,7 +156,61 @@ export async function runIntelligenceAgent(env, runType) {
97
156
  console.warn(`[Intelligence Agent] ${errorAlerts.length} alertas de taxa de erro enviados`);
98
157
  }
99
158
 
100
- // 4. Auditoria mensal adicional
159
+ // 4. Treinar modelo LTV (toda semana)
160
+ const ltvTrainResult = await trainLtvModel(env);
161
+ if (ltvTrainResult.trained) {
162
+ console.log(`[Intelligence Agent] LTV model treinado: accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`);
163
+ if (env.DB) {
164
+ await logIntelligence(env.DB, runType, 'ltv', 'model_training', 'ok',
165
+ `accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`, null,
166
+ `Modelo LTV re-treinado com ${ltvTrainResult.samples} amostras`
167
+ ).catch(() => {});
168
+ }
169
+ } else {
170
+ console.log(`[Intelligence Agent] LTV model: ${ltvTrainResult.skipped || ltvTrainResult.error || 'sem dados'}`);
171
+ }
172
+
173
+ // 5. Auto-decisão de winner no A/B LTV Test
174
+ try {
175
+ const abResult = await autoDecideAbWinner(env);
176
+ if (abResult?.decided) {
177
+ console.log(`[Intelligence Agent] A/B LTV winner auto-decidido: test_id=${abResult.test_id}, winner=${abResult.winner_name}`);
178
+
179
+ await sendIntelligenceAlert(env, 'info',
180
+ `A/B LTV Test — Winner Declarado Automaticamente`,
181
+ `🏆 Vencedor: ${abResult.winner_name}\n📈 Melhoria: +${abResult.improvement?.toFixed(1) ?? '?'}pp vs controle\n🆔 Test ID: ${abResult.test_id}\n\n✅ Prompt vencedor ativado automaticamente`
182
+ );
183
+
184
+ if (env.DB) {
185
+ await logIntelligence(env.DB, runType, 'ltv', 'ab_auto_winner', 'ok',
186
+ abResult.winner_name, null,
187
+ `A/B winner auto-decidido: test ${abResult.test_id}, melhoria ${abResult.improvement?.toFixed(1)}pp`
188
+ ).catch(() => {});
189
+ }
190
+ }
191
+ } catch (err) {
192
+ console.error('[Intelligence Agent] A/B auto-decide error:', err.message);
193
+ }
194
+
195
+ // 6. Match Quality — análise + alertas
196
+ try {
197
+ const mqAnalysis = await analyzeMatchQuality(env);
198
+ if (mqAnalysis) {
199
+ console.log(`[Intelligence Agent] Match Quality: score=${mqAnalysis.composite_score ?? 0}%, alerts=${mqAnalysis.alerts?.length ?? 0}`);
200
+ await alertMatchQuality(env, mqAnalysis);
201
+
202
+ if (env.DB && mqAnalysis.total > 0) {
203
+ await logIntelligence(env.DB, runType, 'meta', 'match_quality', mqAnalysis.alerts?.length > 0 ? 'warning' : 'ok',
204
+ `${mqAnalysis.composite_score ?? 0}%`, '45%',
205
+ `Match quality 2h: email=${mqAnalysis.email_rate ?? 0}%, fbp=${mqAnalysis.fbp_rate ?? 0}%, score=${mqAnalysis.composite_score ?? 0}%`
206
+ ).catch(() => {});
207
+ }
208
+ }
209
+ } catch (err) {
210
+ console.error('[Intelligence Agent] Match quality analysis error:', err.message);
211
+ }
212
+
213
+ // 7. Auditoria mensal adicional
101
214
  if (runType === 'monthly_audit') {
102
215
  if (env.DB) {
103
216
  try {
@@ -115,14 +228,18 @@ export async function runIntelligenceAgent(env, runType) {
115
228
  } catch (err) {
116
229
  console.error('LTV audit error:', err.message);
117
230
  }
231
+
232
+ // Purge de logs antigos de match quality (> 30 dias)
233
+ await purgeOldMatchQualityLogs(env.DB);
234
+ console.log('[Intelligence Agent] Match quality logs antigos purgados');
118
235
  }
119
236
  }
120
237
 
121
- // 5. Customer Match sync semanal
238
+ // 8. Customer Match sync semanal
122
239
  const cmResult = await syncMetaCustomAudience(env);
123
240
  console.log(`[Intelligence Agent] Customer Match Meta: sent=${cmResult?.sent ?? 0}, received=${cmResult?.num_received ?? 0}`);
124
241
 
125
- console.log(`[Intelligence Agent] ${runType} concluído`);
242
+ console.log(`[Intelligence Agent] ${runType} concluído — LTV model, A/B auto-decide, match quality, customer match`);
126
243
  }
127
244
 
128
245
  // ── syncMetaCustomAudience — D1 → Meta Custom Audiences ─────────────────────
@@ -0,0 +1,195 @@
1
+ /**
2
+ * CDP Edge — Logistic Regression (pure JS, sem deps externas)
3
+ * Treina modelo de predição de conversão com dados reais do D1.
4
+ *
5
+ * Features usadas (todas normalizadas 0-1):
6
+ * utm_source, engagement_score, intention_level, recency,
7
+ * has_email, has_phone, is_br, hour_normalized
8
+ */
9
+
10
+ // ── Feature Engineering ───────────────────────────────────────────────────────
11
+
12
+ const UTM_SCORES = {
13
+ facebook: 0.90, instagram: 0.90, meta: 0.90,
14
+ google: 0.82, youtube: 0.82,
15
+ tiktok: 0.75,
16
+ email: 0.68, sms: 0.68,
17
+ organic: 0.30,
18
+ direct: 0.20,
19
+ };
20
+
21
+ const INTENTION_SCORES = {
22
+ comprador: 1.00, high_intent: 1.00,
23
+ interessado: 0.60,
24
+ nurture: 0.30,
25
+ curioso: 0.15,
26
+ };
27
+
28
+ export function extractFeatures(row) {
29
+ const src = (row.utm_source || '').toLowerCase().trim();
30
+ const intention = (row.intention_level || '').toLowerCase().trim();
31
+ const daysSince = row.days_since_lead || 0;
32
+
33
+ return [
34
+ UTM_SCORES[src] ?? (src ? 0.10 : 0.05), // utm_score
35
+ Math.min((row.engagement_score || 0) / 5, 1), // engagement (0-5 → 0-1)
36
+ INTENTION_SCORES[intention] ?? 0, // intention
37
+ Math.max(0, 1 - daysSince / 90), // recency (0=90 dias, 1=hoje)
38
+ row.has_email ? 1 : 0, // has_email
39
+ row.has_phone ? 1 : 0, // has_phone
40
+ row.is_br ? 1 : 0, // is_br
41
+ ((row.hour || 12) / 23), // hour normalized
42
+ ];
43
+ }
44
+
45
+ // ── Sigmoid ───────────────────────────────────────────────────────────────────
46
+
47
+ function sigmoid(z) {
48
+ if (z > 20) return 1;
49
+ if (z < -20) return 0;
50
+ return 1 / (1 + Math.exp(-z));
51
+ }
52
+
53
+ function dot(weights, features) {
54
+ return features.reduce((sum, f, i) => sum + (weights[i] || 0) * f, 0);
55
+ }
56
+
57
+ // ── Treinamento ───────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Treina regressão logística com gradiente descendente.
61
+ * @param {Array<{features: number[], label: number}>} dataset
62
+ * @param {{ iterations?, learningRate?, lambda? }} opts
63
+ * @returns {{ bias, weights, accuracy, positiveRate }}
64
+ */
65
+ export function trainLogisticRegression(dataset, opts = {}) {
66
+ if (!dataset || dataset.length < 50) {
67
+ return null; // dados insuficientes
68
+ }
69
+
70
+ const iterations = opts.iterations || 200;
71
+ const learningRate = opts.learningRate || 0.1;
72
+ const lambda = opts.lambda || 0.01; // L2 regularization
73
+ const nFeatures = dataset[0].features.length;
74
+
75
+ let bias = 0;
76
+ let weights = new Array(nFeatures).fill(0);
77
+
78
+ const positives = dataset.filter(d => d.label === 1).length;
79
+ const positiveRate = positives / dataset.length;
80
+
81
+ // Se menos de 5% positivos, não treina (dados de compra insuficientes)
82
+ if (positiveRate < 0.03) return null;
83
+
84
+ for (let iter = 0; iter < iterations; iter++) {
85
+ let dBias = 0;
86
+ const dWeights = new Array(nFeatures).fill(0);
87
+
88
+ for (const { features, label } of dataset) {
89
+ const z = dot(weights, features) + bias;
90
+ const pred = sigmoid(z);
91
+ const error = pred - label;
92
+
93
+ dBias += error;
94
+ for (let j = 0; j < nFeatures; j++) {
95
+ dWeights[j] += error * features[j];
96
+ }
97
+ }
98
+
99
+ const n = dataset.length;
100
+ bias -= learningRate * (dBias / n);
101
+ for (let j = 0; j < nFeatures; j++) {
102
+ // L2: penaliza pesos grandes para evitar overfitting
103
+ weights[j] -= learningRate * ((dWeights[j] / n) + lambda * weights[j]);
104
+ }
105
+ }
106
+
107
+ // Calcular acurácia no conjunto de treino
108
+ let correct = 0;
109
+ const threshold = positiveRate > 0.3 ? 0.5 : Math.max(0.3, positiveRate * 1.5);
110
+
111
+ for (const { features, label } of dataset) {
112
+ const z = dot(weights, features) + bias;
113
+ const pred = sigmoid(z) >= threshold ? 1 : 0;
114
+ if (pred === label) correct++;
115
+ }
116
+
117
+ const accuracy = correct / dataset.length;
118
+
119
+ return {
120
+ bias,
121
+ weights,
122
+ accuracy,
123
+ positiveRate,
124
+ sampleSize: dataset.length,
125
+ threshold,
126
+ featureNames: ['utm_score', 'engagement', 'intention', 'recency', 'has_email', 'has_phone', 'is_br', 'hour'],
127
+ trainedAt: new Date().toISOString(),
128
+ };
129
+ }
130
+
131
+ // ── Inferência ────────────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Prediz score de conversão (0-100) usando pesos treinados.
135
+ * @param {{ bias, weights, threshold }} model
136
+ * @param {number[]} features
137
+ * @returns {number} score 0-100
138
+ */
139
+ export function predictWithWeights(model, features) {
140
+ const z = dot(model.weights, features) + model.bias;
141
+ const prob = sigmoid(z);
142
+ return Math.round(prob * 100);
143
+ }
144
+
145
+ // ── Helpers de persistência ───────────────────────────────────────────────────
146
+
147
+ export const LTV_WEIGHTS_KV_KEY = 'ltv_weights_active';
148
+
149
+ export async function loadActiveWeights(env) {
150
+ // 1. Tentar KV (cache ~7 dias)
151
+ if (env.GEO_CACHE) {
152
+ try {
153
+ const cached = await env.GEO_CACHE.get(LTV_WEIGHTS_KV_KEY, 'json');
154
+ if (cached?.weights?.length) return cached;
155
+ } catch {}
156
+ }
157
+
158
+ // 2. Fallback: D1
159
+ if (!env.DB) return null;
160
+ try {
161
+ const row = await env.DB.prepare(
162
+ `SELECT weights_json FROM ltv_model_weights WHERE is_active = 1 ORDER BY trained_at DESC LIMIT 1`
163
+ ).first();
164
+ if (!row?.weights_json) return null;
165
+ const model = JSON.parse(row.weights_json);
166
+
167
+ // Popular KV para próximas requests
168
+ if (env.GEO_CACHE && model?.weights?.length) {
169
+ env.GEO_CACHE.put(LTV_WEIGHTS_KV_KEY, JSON.stringify(model), { expirationTtl: 604800 }).catch(() => {});
170
+ }
171
+ return model;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ export async function saveWeights(DB, model) {
178
+ if (!DB || !model) return;
179
+ const now = new Date().toISOString();
180
+
181
+ // Desativar modelo anterior
182
+ await DB.prepare(`UPDATE ltv_model_weights SET is_active = 0 WHERE is_active = 1`).run();
183
+
184
+ // Inserir novo como ativo
185
+ await DB.prepare(`
186
+ INSERT INTO ltv_model_weights (trained_at, is_active, sample_size, positive_rate, accuracy, weights_json)
187
+ VALUES (?, 1, ?, ?, ?, ?)
188
+ `).bind(
189
+ now,
190
+ model.sampleSize,
191
+ model.positiveRate,
192
+ model.accuracy,
193
+ JSON.stringify(model),
194
+ ).run();
195
+ }
@@ -3,11 +3,43 @@
3
3
  * predictLtv, getLtvAbVariation, recordAbAssignment, handlers /api/ltv/*
4
4
  */
5
5
 
6
+ import { extractFeatures, predictWithWeights, loadActiveWeights } from './logistic.js';
7
+
6
8
  // Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
7
9
  const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
8
10
 
9
11
  // ── predictLtv — Heurística em 5 dimensões (0-100 pts) ───────────────────────
10
12
  export async function predictLtv(env, payload, request, customSystemPrompt = null) {
13
+ // ── Tentar modelo treinado (regressão logística real) ─────────────────────
14
+ // Se existir modelo ativo no KV/D1, usa-o em vez da heurística manual.
15
+ // Fallback automático para heurística se modelo não disponível.
16
+ try {
17
+ const model = await loadActiveWeights(env);
18
+ if (model?.weights?.length) {
19
+ const hour = new Date().getUTCHours();
20
+ const country = (payload.country || request?.cf?.country || '').toUpperCase();
21
+ const features = extractFeatures({
22
+ utm_source: payload.utmSource,
23
+ engagement_score: parseFloat(payload.engagementScore || 0),
24
+ intention_level: payload.intentionLevel,
25
+ days_since_lead: 0, // evento atual = recência máxima
26
+ has_email: !!payload.email,
27
+ has_phone: !!payload.phone,
28
+ is_br: country === 'BR',
29
+ hour,
30
+ });
31
+
32
+ const score100 = predictWithWeights(model, features);
33
+ const ltvClass = score100 >= 70 ? 'High' : score100 >= 40 ? 'Medium' : 'Low';
34
+ const ltvMultiplier = score100 >= 70 ? 3.5 : score100 >= 40 ? 1.8 : 0.8;
35
+ const productValue = payload.value ? parseFloat(payload.value) : 0;
36
+ const baseValue = productValue > 0 ? productValue : 197;
37
+ const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100;
38
+
39
+ return { score: score100, class: ltvClass, value: predictedValue, source: 'model' };
40
+ }
41
+ } catch { /* fallback para heurística */ }
42
+
11
43
  let score = 0;
12
44
 
13
45
  // 1. Engajamento browser (0–30)
@@ -318,3 +350,71 @@ export async function handleLtvAbTestWinner(env, request, headers) {
318
350
  return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
319
351
  }
320
352
  }
353
+
354
+ // ── autoDecideAbWinner — declara winner automaticamente via cron ──────────────
355
+ // Critério: todas as variações com amostra >= min_sample
356
+ // E diferença de accuracy_score >= 5pp entre melhor e controle
357
+ export async function autoDecideAbWinner(env) {
358
+ if (!env.DB) return { decided: false, reason: 'no_db' };
359
+
360
+ try {
361
+ // Buscar teste ativo
362
+ const test = await env.DB.prepare(
363
+ `SELECT id, name, min_sample, status FROM ltv_ab_tests WHERE status = 'running' ORDER BY id DESC LIMIT 1`
364
+ ).first();
365
+
366
+ if (!test) return { decided: false, reason: 'no_running_test' };
367
+
368
+ // Buscar performance das variações
369
+ const perf = await env.DB.prepare(
370
+ `SELECT * FROM v_ab_test_performance WHERE test_id = ?`
371
+ ).bind(test.id).all();
372
+
373
+ const variations = perf.results || [];
374
+ if (variations.length < 2) return { decided: false, reason: 'insufficient_variations' };
375
+
376
+ // Verificar se todas têm amostra suficiente
377
+ const allReady = variations.every(v => (v.total_assigned || 0) >= test.min_sample);
378
+ if (!allReady) {
379
+ const minAssigned = Math.min(...variations.map(v => v.total_assigned || 0));
380
+ return { decided: false, reason: `sample_insufficient (${minAssigned}/${test.min_sample})` };
381
+ }
382
+
383
+ // Encontrar melhor e controle
384
+ const best = variations.reduce((a, b) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a);
385
+ const control = variations.find(v => v.is_control) || variations[0];
386
+
387
+ const bestScore = parseFloat(best.accuracy_score || 0);
388
+ const controlScore = parseFloat(control.accuracy_score || 0);
389
+ const diff = bestScore - controlScore;
390
+
391
+ // Empate técnico → controle vence (determinístico)
392
+ if (diff < 0.05) {
393
+ return { decided: false, reason: `difference_too_small (${(diff * 100).toFixed(1)}pp < 5pp)` };
394
+ }
395
+
396
+ // Declarar winner
397
+ await env.DB.prepare(
398
+ `UPDATE ltv_ab_tests SET winner_id = ?, status = 'completed', completed_at = datetime('now') WHERE id = ?`
399
+ ).bind(best.variation_id, test.id).run();
400
+
401
+ if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
402
+
403
+ console.log(`[AB-LTV] Winner auto-declarado: teste ${test.id}, variação "${best.variation_name}" (+${(diff * 100).toFixed(1)}pp)`);
404
+
405
+ return {
406
+ decided: true,
407
+ test_id: test.id,
408
+ test_name: test.name,
409
+ winner_id: best.variation_id,
410
+ winner_name: best.variation_name,
411
+ improvement: `+${(diff * 100).toFixed(1)}pp`,
412
+ is_control_winner: best.variation_id === control.variation_id,
413
+ winning_prompt: best.system_prompt || null,
414
+ };
415
+
416
+ } catch (err) {
417
+ console.error('[AB-LTV] autoDecide error:', err.message);
418
+ return { decided: false, reason: err.message };
419
+ }
420
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * CDP Edge — Match Quality (Fase 5)
3
+ * Rastreia qualidade dos dados enviados ao Meta CAPI.
4
+ * Detecta degradação e alerta via CallMeBot.
5
+ * Tenta auto-correção onde possível.
6
+ */
7
+
8
+ import { sendCallMeBot } from '../dispatch/whatsapp.js';
9
+
10
+ // ── Thresholds de alerta ──────────────────────────────────────────────────────
11
+ const THRESHOLDS = {
12
+ email_rate_min: 0.40, // < 40% dos eventos com email → alerta
13
+ fbp_rate_min: 0.30, // < 30% com fbp cookie → alerta
14
+ composite_min: 0.45, // < 45% score composto → alerta crítico
15
+ min_events_alert: 10, // mínimo de eventos nas últimas 2h para disparar alerta
16
+ };
17
+
18
+ // ── Log de qualidade (chamado em meta.js a cada dispatch) ─────────────────────
19
+
20
+ /**
21
+ * Registra flags de qualidade de um evento no D1 (background, não bloqueia).
22
+ */
23
+ export async function logMatchQuality(DB, eventName, payload, recovered = {}) {
24
+ if (!DB) return;
25
+ try {
26
+ await DB.prepare(`
27
+ INSERT INTO match_quality_log (
28
+ event_name, has_email, has_phone, has_fbp, has_fbc, has_external_id,
29
+ was_email_recovered, was_utm_restored
30
+ ) VALUES (?,?,?,?,?,?,?,?)
31
+ `).bind(
32
+ eventName,
33
+ payload.email ? 1 : 0,
34
+ payload.phone ? 1 : 0,
35
+ payload.fbp ? 1 : 0,
36
+ payload.fbc ? 1 : 0,
37
+ payload.userId ? 1 : 0,
38
+ recovered.email ? 1 : 0,
39
+ recovered.utm ? 1 : 0,
40
+ ).run();
41
+ } catch { /* não bloquear dispatch */ }
42
+ }
43
+
44
+ // ── Auto-correção de payload ───────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Tenta enriquecer o payload com dados do Identity Graph antes do envio ao Meta.
48
+ * Retorna { payload enriquecido, flags de recuperação }.
49
+ */
50
+ export async function autoEnrichPayload(env, payload) {
51
+ const recovered = { email: false, utm: false };
52
+ if (!env.DB) return { payload, recovered };
53
+
54
+ // 1. Tentar recuperar email/fbp/fbc do perfil pelo userId
55
+ if (!payload.email && payload.userId) {
56
+ try {
57
+ const profile = await env.DB.prepare(
58
+ `SELECT email, fbp, fbc, phone FROM user_profiles WHERE user_id = ? LIMIT 1`
59
+ ).bind(payload.userId).first();
60
+
61
+ if (profile) {
62
+ if (profile.email && !payload.email) {
63
+ payload.email = profile.email;
64
+ recovered.email = true;
65
+ }
66
+ if (profile.fbp && !payload.fbp) payload.fbp = profile.fbp;
67
+ if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc;
68
+ if (profile.phone && !payload.phone) payload.phone = profile.phone;
69
+ }
70
+ } catch {}
71
+ }
72
+
73
+ // 2. UTM Resurrection já foi tentada no /track handler (payload.utmRestored)
74
+ if (payload.utmRestored) recovered.utm = true;
75
+
76
+ return { payload, recovered };
77
+ }
78
+
79
+ // ── Análise de qualidade (chamada pelo cron) ─────────────────────────────────
80
+
81
+ /**
82
+ * Analisa a qualidade das últimas 2h e retorna métricas + alertas.
83
+ */
84
+ export async function analyzeMatchQuality(env) {
85
+ if (!env.DB) return null;
86
+
87
+ try {
88
+ const row = await env.DB.prepare(`
89
+ SELECT
90
+ COUNT(*) AS total,
91
+ ROUND(AVG(has_email) * 100, 1) AS email_rate,
92
+ ROUND(AVG(has_phone) * 100, 1) AS phone_rate,
93
+ ROUND(AVG(has_fbp) * 100, 1) AS fbp_rate,
94
+ ROUND(AVG(has_fbc) * 100, 1) AS fbc_rate,
95
+ ROUND(AVG(has_external_id) * 100, 1) AS ext_id_rate,
96
+ ROUND(AVG(was_email_recovered) * 100, 1) AS email_recovered_rate,
97
+ ROUND((AVG(has_email)*0.4 + AVG(has_fbp)*0.3 + AVG(has_phone)*0.2 + AVG(has_fbc)*0.1) * 100, 1) AS composite_score
98
+ FROM match_quality_log
99
+ WHERE logged_at >= datetime('now', '-2 hours')
100
+ `).first();
101
+
102
+ if (!row || row.total < THRESHOLDS.min_events_alert) return { total: row?.total || 0, alerts: [] };
103
+
104
+ const alerts = [];
105
+
106
+ if ((row.email_rate || 0) < THRESHOLDS.email_rate_min * 100) {
107
+ alerts.push({
108
+ type: 'email_low',
109
+ metric: `email_rate: ${row.email_rate}%`,
110
+ message: `Taxa de email baixa: ${row.email_rate}% (mínimo: ${THRESHOLDS.email_rate_min * 100}%)`,
111
+ });
112
+ }
113
+
114
+ if ((row.fbp_rate || 0) < THRESHOLDS.fbp_rate_min * 100) {
115
+ alerts.push({
116
+ type: 'fbp_low',
117
+ metric: `fbp_rate: ${row.fbp_rate}%`,
118
+ message: `Cookie fbp ausente em ${100 - row.fbp_rate}% dos eventos — verificar cdpTrack.js`,
119
+ });
120
+ }
121
+
122
+ if ((row.composite_score || 0) < THRESHOLDS.composite_min * 100) {
123
+ alerts.push({
124
+ type: 'composite_critical',
125
+ metric: `composite: ${row.composite_score}%`,
126
+ message: `Score composto de match quality crítico: ${row.composite_score}%`,
127
+ severity: 'critical',
128
+ });
129
+ }
130
+
131
+ return { ...row, alerts };
132
+ } catch (err) {
133
+ console.error('[MatchQuality] analyze error:', err.message);
134
+ return null;
135
+ }
136
+ }
137
+
138
+ // ── Alerta via CallMeBot ──────────────────────────────────────────────────────
139
+
140
+ export async function alertMatchQuality(env, analysis) {
141
+ if (!analysis || analysis.alerts.length === 0) return;
142
+
143
+ const hasCritical = analysis.alerts.some(a => a.severity === 'critical');
144
+ const icon = hasCritical ? '🚨' : '⚠️';
145
+
146
+ const lines = [
147
+ `${icon} CDP Edge — Match Quality Alert`,
148
+ ``,
149
+ `📊 Últimas 2h (${analysis.total} eventos):`,
150
+ ` Email: ${analysis.email_rate ?? 0}% ${(analysis.email_rate ?? 0) < 40 ? '❌' : '✅'}`,
151
+ ` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
152
+ ` Score: ${analysis.composite_score ?? 0}%`,
153
+ ``,
154
+ `🔍 Problemas:`,
155
+ ...analysis.alerts.map(a => ` · ${a.message}`),
156
+ ``,
157
+ `🛠 Ações automáticas já ativas:`,
158
+ ` · Identity Graph recovery: ${analysis.email_recovered_rate ?? 0}% emails recuperados`,
159
+ ` · UTM Resurrection ativa`,
160
+ ``,
161
+ new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' }),
162
+ ];
163
+
164
+ await sendCallMeBot(env, lines.join('\n'));
165
+ }
166
+
167
+ // ── Purge periódico (mensal) ──────────────────────────────────────────────────
168
+
169
+ export async function purgeOldMatchQualityLogs(DB) {
170
+ if (!DB) return;
171
+ try {
172
+ await DB.prepare(
173
+ `DELETE FROM match_quality_log WHERE logged_at < datetime('now', '-30 days')`
174
+ ).run();
175
+ } catch {}
176
+ }