cdp-edge 2.0.4 → 2.0.5

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.
@@ -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
+ }
@@ -46,13 +46,13 @@ CREATE INDEX IF NOT EXISTS idx_profiles_email_updated
46
46
 
47
47
  -- ── fraud_signals: dashboard e alertas ───────────────────────────────────────
48
48
 
49
- -- handleFraudAlerts: filtra por ip + período
50
- CREATE INDEX IF NOT EXISTS idx_fraud_ip_created
51
- ON fraud_signals(ip_address, created_at DESC);
49
+ -- handleFraudAlerts: filtra por ip + período (coluna: detected_at)
50
+ CREATE INDEX IF NOT EXISTS idx_fraud_ip_detected
51
+ ON fraud_signals(ip_address, detected_at DESC);
52
52
 
53
53
  -- handleFraudStats: fraud_score >= threshold ordenado por data
54
- CREATE INDEX IF NOT EXISTS idx_fraud_score_created
55
- ON fraud_signals(fraud_score DESC, created_at DESC);
54
+ CREATE INDEX IF NOT EXISTS idx_fraud_score_detected
55
+ ON fraud_signals(fraud_score DESC, detected_at DESC);
56
56
 
57
57
  -- ── ltv_ab_assignments: resultados de A/B test ───────────────────────────────
58
58
 
@@ -62,6 +62,6 @@ CREATE INDEX IF NOT EXISTS idx_ab_testid_class
62
62
 
63
63
  -- ── ml_segment_members: join com leads para bidding ─────────────────────────
64
64
 
65
- -- handleBiddingRecommend: segment_id lookup
65
+ -- handleBiddingRecommend: segment_id lookup (coluna: assigned_at)
66
66
  CREATE INDEX IF NOT EXISTS idx_seg_members_segid
67
- ON ml_segment_members(segment_id, joined_at DESC);
67
+ ON ml_segment_members(cluster_id, assigned_at DESC);