cdp-edge 1.18.3 → 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.
@@ -0,0 +1,420 @@
1
+ /**
2
+ * CDP Edge — LTV Prediction + A/B Testing de Prompts (Fases 3 e 4)
3
+ * predictLtv, getLtvAbVariation, recordAbAssignment, handlers /api/ltv/*
4
+ */
5
+
6
+ import { extractFeatures, predictWithWeights, loadActiveWeights } from './logistic.js';
7
+
8
+ // Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
9
+ const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
10
+
11
+ // ── predictLtv — Heurística em 5 dimensões (0-100 pts) ───────────────────────
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
+
43
+ let score = 0;
44
+
45
+ // 1. Engajamento browser (0–30)
46
+ const engScore = parseFloat(payload.engagementScore || 0);
47
+ const userScore = parseFloat(payload.userScore || 0);
48
+ score += Math.min(15, Math.round((engScore / 5) * 15));
49
+ score += Math.min(15, Math.round((userScore / 100) * 15));
50
+
51
+ // 2. Origem de tráfego (0–25)
52
+ const src = (payload.utmSource || '').toLowerCase();
53
+ const utm_score_map = {
54
+ facebook: 25, instagram: 25, meta: 25,
55
+ google: 22, youtube: 22, tiktok: 20,
56
+ email: 18, sms: 18,
57
+ organic: 10, direct: 5,
58
+ };
59
+ score += utm_score_map[src] ?? (src ? 8 : 3);
60
+
61
+ // 3. Contexto de rede (0–15)
62
+ const hour = new Date().getUTCHours();
63
+ const country = (payload.country || request.cf?.country || '').toUpperCase();
64
+ const org = String(request.cf?.asOrganization || '').toLowerCase();
65
+
66
+ const isHighConvTime = hour >= 21 || hour <= 2;
67
+ score += isHighConvTime ? 8 : (hour >= 12 && hour <= 20 ? 4 : 1);
68
+
69
+ const latam = ['AR', 'CL', 'CO', 'MX', 'PE', 'UY', 'PY', 'BO'];
70
+ score += country === 'BR' ? 5 : (latam.includes(country) ? 3 : 1);
71
+
72
+ const isCorp = /ltda|s\.a\.|corp|telecom|fibra|claro|vivo|tim|oi/.test(org);
73
+ score += isCorp ? 2 : 0;
74
+
75
+ // 4. Contexto do evento (0–20)
76
+ const intentionLevel = (payload.intentionLevel || '').toLowerCase();
77
+ if (intentionLevel === 'comprador' || intentionLevel === 'high_intent') score += 20;
78
+ else if (intentionLevel === 'interessado') score += 12;
79
+ else if (intentionLevel === 'nurture') score += 6;
80
+
81
+ // 5. Dados PII disponíveis (0–10)
82
+ if (payload.email) score += 4;
83
+ if (payload.phone) score += 4;
84
+ if (payload.firstName) score += 2;
85
+
86
+ score = Math.min(100, score);
87
+
88
+ let ltvClass, ltvMultiplier;
89
+ if (score >= 70) {
90
+ ltvClass = 'High'; ltvMultiplier = 3.5;
91
+ } else if (score >= 40) {
92
+ ltvClass = 'Medium'; ltvMultiplier = 1.8;
93
+ } else {
94
+ ltvClass = 'Low'; ltvMultiplier = 0.8;
95
+ }
96
+
97
+ const productValue = payload.value ? parseFloat(payload.value) : 0;
98
+ const baseValue = productValue > 0 ? productValue : 197;
99
+ const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100;
100
+
101
+ // Enriquecimento opcional via Workers AI
102
+ let aiAdjustment = 0;
103
+ if (env.AI && score >= 40) {
104
+ try {
105
+ const systemContent = customSystemPrompt ||
106
+ 'You are a conversion rate expert. Reply ONLY with a JSON object {"adjustment": <number between -10 and 10>} based on the lead data provided. No explanation.';
107
+ const prompt = [
108
+ { role: 'system', content: systemContent },
109
+ { role: 'user', content: JSON.stringify({
110
+ utm_source: payload.utmSource,
111
+ intention: intentionLevel,
112
+ engagement: engScore,
113
+ hour_utc: hour,
114
+ country,
115
+ has_email: !!payload.email,
116
+ has_phone: !!payload.phone,
117
+ })},
118
+ ];
119
+ const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: prompt, max_tokens: 32 });
120
+ const parsed = JSON.parse(aiRes.response.trim());
121
+ if (typeof parsed.adjustment === 'number') {
122
+ aiAdjustment = Math.max(-10, Math.min(10, parsed.adjustment));
123
+ }
124
+ } catch { /* graceful fallback */ }
125
+ }
126
+
127
+ return {
128
+ score: Math.min(100, Math.max(0, score + aiAdjustment)),
129
+ class: ltvClass,
130
+ value: predictedValue,
131
+ };
132
+ }
133
+
134
+ // ── getLtvAbVariation — busca variação ativa do A/B test ─────────────────────
135
+ export async function getLtvAbVariation(env) {
136
+ if (!env.DB) return null;
137
+
138
+ try {
139
+ let testData = null;
140
+ if (env.GEO_CACHE) {
141
+ const cached = await env.GEO_CACHE.get(AB_LTV_CACHE_KEY, 'json');
142
+ if (cached) testData = cached;
143
+ }
144
+
145
+ if (!testData) {
146
+ const test = await env.DB.prepare(`
147
+ SELECT t.id AS test_id, v.id AS variation_id,
148
+ v.name, v.system_prompt, v.weight, v.is_control
149
+ FROM ltv_ab_tests t
150
+ JOIN ltv_ab_variations v ON v.test_id = t.id
151
+ WHERE t.status = 'running'
152
+ ORDER BY t.id DESC
153
+ `).all();
154
+
155
+ if (!test.results || test.results.length === 0) {
156
+ if (env.GEO_CACHE) await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(null), { expirationTtl: 300 });
157
+ return null;
158
+ }
159
+
160
+ testData = test.results;
161
+ if (env.GEO_CACHE) await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(testData), { expirationTtl: 300 });
162
+ }
163
+
164
+ if (!testData || testData.length === 0) return null;
165
+
166
+ const totalWeight = testData.reduce((s, v) => s + (v.weight || 0.5), 0);
167
+ let rand = Math.random() * totalWeight;
168
+ for (const variation of testData) {
169
+ rand -= (variation.weight || 0.5);
170
+ if (rand <= 0) return variation;
171
+ }
172
+ return testData[testData.length - 1];
173
+
174
+ } catch (err) {
175
+ console.error('[AB-LTV] getLtvAbVariation error:', err.message);
176
+ return null;
177
+ }
178
+ }
179
+
180
+ // ── recordAbAssignment — registra variação usada para um lead ─────────────────
181
+ export async function recordAbAssignment(env, userId, variationId, testId, predictedLtv, predictedClass, emailHash) {
182
+ if (!env.DB) return;
183
+ try {
184
+ await env.DB.prepare(`
185
+ INSERT INTO ltv_ab_assignments (test_id, variation_id, user_id, email_hash, predicted_ltv, predicted_class, assigned_at)
186
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
187
+ `).bind(testId, variationId, userId, emailHash || null, predictedLtv || null, predictedClass || null).run();
188
+
189
+ await env.DB.prepare(`UPDATE ltv_ab_variations SET total_assigned = total_assigned + 1 WHERE id = ?`).bind(variationId).run();
190
+ } catch (err) {
191
+ console.error('[AB-LTV] recordAbAssignment error:', err.message);
192
+ }
193
+ }
194
+
195
+ // ── POST /api/ltv/ab-test/create ─────────────────────────────────────────────
196
+ export async function handleLtvAbTestCreate(env, request, headers) {
197
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
198
+
199
+ let body;
200
+ try { body = await request.json(); }
201
+ catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
202
+
203
+ const { name, description, min_sample = 100, variations } = body;
204
+ if (!name) return new Response(JSON.stringify({ error: 'name é obrigatório' }), { status: 400, headers });
205
+ if (!Array.isArray(variations) || variations.length < 2) {
206
+ return new Response(JSON.stringify({ error: 'Mínimo 2 variações são necessárias' }), { status: 400, headers });
207
+ }
208
+
209
+ const running = await env.DB.prepare(`SELECT id FROM ltv_ab_tests WHERE status = 'running' LIMIT 1`).first();
210
+ if (running) {
211
+ return new Response(JSON.stringify({ error: 'Já existe um teste em andamento.', running_test_id: running.id }), { status: 409, headers });
212
+ }
213
+
214
+ try {
215
+ const now = new Date().toISOString();
216
+ const totalWeight = variations.reduce((s, v) => s + (v.weight || 0.5), 0);
217
+ if (Math.abs(totalWeight - 1.0) > 0.05) {
218
+ return new Response(JSON.stringify({ error: `A soma dos weights deve ser 1.0. Recebido: ${totalWeight.toFixed(3)}` }), { status: 400, headers });
219
+ }
220
+ if (!variations.some(v => v.is_control)) {
221
+ return new Response(JSON.stringify({ error: 'Pelo menos uma variação deve ter is_control: true' }), { status: 400, headers });
222
+ }
223
+
224
+ const testRes = await env.DB.prepare(`
225
+ INSERT INTO ltv_ab_tests (name, description, status, min_sample, created_at) VALUES (?, ?, 'running', ?, ?)
226
+ `).bind(name, description || null, min_sample, now).run();
227
+
228
+ const testId = testRes.meta?.last_row_id;
229
+ if (!testId) throw new Error('Falha ao criar o teste no D1');
230
+
231
+ const createdVariations = [];
232
+ for (const v of variations) {
233
+ const vRes = await env.DB.prepare(`
234
+ INSERT INTO ltv_ab_variations (test_id, name, system_prompt, weight, is_control, created_at) VALUES (?, ?, ?, ?, ?, ?)
235
+ `).bind(testId, v.name, v.system_prompt, v.weight || 0.5, v.is_control ? 1 : 0, now).run();
236
+ createdVariations.push({ id: vRes.meta?.last_row_id, name: v.name, weight: v.weight, is_control: !!v.is_control });
237
+ }
238
+
239
+ if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
240
+
241
+ return new Response(JSON.stringify({ success: true, test_id: testId, name, status: 'running', min_sample, variations: createdVariations, started_at: now }), { status: 201, headers });
242
+ } catch (err) {
243
+ console.error('[AB-LTV] create error:', err.message);
244
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
245
+ }
246
+ }
247
+
248
+ // ── GET /api/ltv/ab-test/list ─────────────────────────────────────────────────
249
+ export async function handleLtvAbTestList(env, request, headers) {
250
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
251
+
252
+ const url = new URL(request.url);
253
+ const status = url.searchParams.get('status') || null;
254
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
255
+
256
+ try {
257
+ const cond = status ? 'WHERE t.status = ?' : '';
258
+ const bindings = status ? [status, limit] : [limit];
259
+
260
+ const tests = await env.DB.prepare(`
261
+ SELECT t.id, t.name, t.description, t.status, t.winner_id,
262
+ t.started_at, t.completed_at, t.created_at, t.min_sample,
263
+ COUNT(DISTINCT v.id) AS variation_count,
264
+ SUM(v.total_assigned) AS total_assigned
265
+ FROM ltv_ab_tests t
266
+ LEFT JOIN ltv_ab_variations v ON v.test_id = t.id
267
+ ${cond}
268
+ GROUP BY t.id
269
+ ORDER BY t.created_at DESC
270
+ LIMIT ?
271
+ `).bind(...bindings).all();
272
+
273
+ return new Response(JSON.stringify({ success: true, total: (tests.results || []).length, tests: tests.results || [] }), { status: 200, headers });
274
+ } catch (err) {
275
+ console.error('[AB-LTV] list error:', err.message);
276
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
277
+ }
278
+ }
279
+
280
+ // ── GET /api/ltv/ab-test/results ─────────────────────────────────────────────
281
+ export async function handleLtvAbTestResults(env, request, headers) {
282
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
283
+
284
+ const url = new URL(request.url);
285
+ const testId = url.searchParams.get('test_id') || null;
286
+
287
+ try {
288
+ const cond = testId ? 'WHERE test_id = ?' : 'WHERE status = \'running\'';
289
+ const testBind = testId ? [parseInt(testId)] : [];
290
+
291
+ const testRes = await env.DB.prepare(`SELECT id, name, status, min_sample, winner_id, started_at FROM ltv_ab_tests ${cond} LIMIT 1`).bind(...testBind).first();
292
+ if (!testRes) return new Response(JSON.stringify({ error: 'Nenhum teste encontrado' }), { status: 404, headers });
293
+
294
+ const perf = await env.DB.prepare(`SELECT * FROM v_ab_test_performance WHERE test_id = ?`).bind(testRes.id).all();
295
+ const variations = perf.results || [];
296
+ const ready = variations.every(v => (v.total_assigned || 0) >= testRes.min_sample);
297
+ let recommendation = null;
298
+
299
+ if (ready && variations.length > 0) {
300
+ const best = variations.reduce((a, b) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a);
301
+ const control = variations.find(v => v.is_control);
302
+ const improvement = control ? ((best.accuracy_score || 0) - (control.accuracy_score || 0)) * 100 : null;
303
+ recommendation = {
304
+ winner_variation_id: best.variation_id, winner_variation_name: best.variation_name,
305
+ accuracy_score: best.accuracy_score, improvement_vs_control: improvement ? `+${improvement.toFixed(1)}%` : null,
306
+ ready_to_declare: true,
307
+ };
308
+ }
309
+
310
+ return new Response(JSON.stringify({
311
+ success: true,
312
+ test: { id: testRes.id, name: testRes.name, status: testRes.status, min_sample: testRes.min_sample, started_at: testRes.started_at, is_ready: ready },
313
+ variations, recommendation,
314
+ }), { status: 200, headers });
315
+ } catch (err) {
316
+ console.error('[AB-LTV] results error:', err.message);
317
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
318
+ }
319
+ }
320
+
321
+ // ── POST /api/ltv/ab-test/winner ──────────────────────────────────────────────
322
+ export async function handleLtvAbTestWinner(env, request, headers) {
323
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
324
+
325
+ let body;
326
+ try { body = await request.json(); }
327
+ catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
328
+
329
+ const { test_id, variation_id } = body;
330
+ if (!test_id || !variation_id) {
331
+ return new Response(JSON.stringify({ error: 'test_id e variation_id são obrigatórios' }), { status: 400, headers });
332
+ }
333
+
334
+ try {
335
+ const variation = await env.DB.prepare(`SELECT id, name, system_prompt, is_control FROM ltv_ab_variations WHERE id = ? AND test_id = ?`).bind(variation_id, test_id).first();
336
+ if (!variation) return new Response(JSON.stringify({ error: 'Variação não encontrada para este teste' }), { status: 404, headers });
337
+
338
+ await env.DB.prepare(`UPDATE ltv_ab_tests SET winner_id = ?, status = 'completed', completed_at = datetime('now') WHERE id = ?`).bind(variation_id, test_id).run();
339
+ if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
340
+
341
+ return new Response(JSON.stringify({
342
+ success: true, test_id, winner_variation_id: variation_id, winner_name: variation.name,
343
+ is_control: variation.is_control === 1, winning_prompt: variation.system_prompt,
344
+ message: variation.is_control === 1
345
+ ? 'O prompt original (controle) venceu. Nenhuma alteração necessária.'
346
+ : 'Novo prompt vencedor identificado. Copie o campo winning_prompt e aplique ao predictLtv() como novo default.',
347
+ }), { status: 200, headers });
348
+ } catch (err) {
349
+ console.error('[AB-LTV] winner error:', err.message);
350
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
351
+ }
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
+ }