cdp-edge 2.1.0 → 2.2.1

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.
@@ -16,6 +16,11 @@ import {
16
16
  sha256,
17
17
  META_TO_GA4,
18
18
  VALID_EVENT_NAMES,
19
+ resolveFunnelStage,
20
+ resolveIntentScore,
21
+ distanceBucketWeight,
22
+ computeMetaSignalWeights,
23
+ metaSignalBucket,
19
24
  } from './modules/utils.js';
20
25
 
21
26
  // ── Banco de dados (D1) ───────────────────────────────────────────────────────
@@ -31,6 +36,7 @@ import {
31
36
  saveEdgeFingerprint,
32
37
  resurrectUTM,
33
38
  upsertLtvProfile,
39
+ recordLtvFeedback,
34
40
  } from './modules/db.js';
35
41
 
36
42
  // ── Dispatch — plataformas de ads ─────────────────────────────────────────────
@@ -92,6 +98,16 @@ import {
92
98
  buildGoogleCustomerMatchExport,
93
99
  } from './modules/intelligence.js';
94
100
 
101
+ // ── Haversine distance (km) — sem dependência externa ────────────────────────
102
+ function haversineKm(lat1, lon1, lat2, lon2) {
103
+ const R = 6371;
104
+ const dLat = (lat2 - lat1) * Math.PI / 180;
105
+ const dLon = (lon2 - lon1) * Math.PI / 180;
106
+ const a = Math.sin(dLat / 2) ** 2 +
107
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
108
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
109
+ }
110
+
95
111
  // ─────────────────────────────────────────────────────────────────────────────
96
112
  // HANDLER PRINCIPAL
97
113
  // ─────────────────────────────────────────────────────────────────────────────
@@ -175,7 +191,7 @@ export default {
175
191
  }
176
192
 
177
193
  try {
178
- await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
194
+ await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', {
179
195
  messages: [{ role: 'user', content: 'ping' }],
180
196
  max_tokens: 1,
181
197
  });
@@ -270,6 +286,9 @@ export default {
270
286
  payload.engagementScore = behavioral_data.engagement_score ?? behavioral_data.totalScore ?? null;
271
287
  payload.intentionLevel = behavioral_data.intention_level ?? null;
272
288
  payload.userScore = behavioral_data.user_score ?? null;
289
+ // Sinais de engajamento profundo — usados no anti-falso-positivo e no LTV
290
+ payload.scrollScore = behavioral_data.scroll_score ?? null;
291
+ payload.timeLevel = behavioral_data.time_level ?? null;
273
292
  payload.email = payload.email || behavioral_data.email || null;
274
293
  payload.phone = payload.phone || behavioral_data.phone || null;
275
294
  payload.firstName = payload.firstName || behavioral_data.first_name || behavioral_data.firstName || null;
@@ -280,6 +299,35 @@ export default {
280
299
  payload.dob = payload.dob || behavioral_data.dob || null;
281
300
  }
282
301
 
302
+ // ── Normalização de intent_score → 0.0–1.0 ──────────────────────────
303
+ // Aceita string ('high'/'medium'/'low'), 0-1 ou 0-100 enviados pelo front.
304
+ // intent_bucket mantém a label legível para D1 e logs.
305
+ const intentScoreNum = resolveIntentScore(payload.intent_score);
306
+ if (intentScoreNum !== null) {
307
+ payload.intent_score = intentScoreNum;
308
+ payload.intentScoreNum = intentScoreNum;
309
+ payload.intent_bucket = intentScoreNum >= 0.8 ? 'high'
310
+ : intentScoreNum >= 0.5 ? 'medium' : 'low';
311
+ } else {
312
+ payload.intentScoreNum = null;
313
+ }
314
+
315
+ // ── Anti-falso-positivo ───────────────────────────────────────────────
316
+ // Penaliza intent se engajamento insuficiente: scroll raso E tempo curto.
317
+ // scroll_score < 2.0 ≈ não passou de 50% da página.
318
+ // time_level 'curioso' = menos de 60 segundos na página.
319
+ if (payload.intentScoreNum !== null) {
320
+ const isShallowScroll = payload.scrollScore !== null && payload.scrollScore < 2.0;
321
+ const isShallowTime = payload.timeLevel === 'curioso';
322
+ if (isShallowScroll && isShallowTime) {
323
+ const penalized = Math.round(payload.intentScoreNum * 0.7 * 100) / 100;
324
+ payload.intentScoreNum = penalized;
325
+ payload.intent_score = penalized;
326
+ payload.intent_bucket = penalized >= 0.8 ? 'high' : penalized >= 0.5 ? 'medium' : 'low';
327
+ payload.intent_penalized = true; // flag auditável — visível no D1 e logs
328
+ }
329
+ }
330
+
283
331
  // ── Edge Fingerprint + UTM Resurrection ──────────────────────────────
284
332
  const fingerprint = await generateEdgeFingerprint(request);
285
333
  payload.utmRestored = false;
@@ -324,6 +372,34 @@ export default {
324
372
 
325
373
  const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase();
326
374
 
375
+ // ── Dual-layer semantics ─────────────────────────────────────────────
376
+ // Meta sempre recebe o nome canônico (Schedule, Lead, etc.).
377
+ // Internamente o CDP usa semântica de funil precisa via FUNNEL_TAXONOMY.
378
+ if (payload.funnel_stage) {
379
+ const { depth, funnelDepth } = resolveFunnelStage(payload.funnel_stage);
380
+ payload.funnelDepth = funnelDepth; // ex: 'bottom_intent', 'bottom_conversion'
381
+ payload.funnelLevel = depth; // ex: 'bottom', 'conversion', 'mid'
382
+ }
383
+ if (eventName === 'Schedule' && payload.funnel_stage === 'route_click') {
384
+ payload.internalEvent = 'IntentToVisit';
385
+ }
386
+
387
+ // ── Real Estate Distance Enrichment ──────────────────────────────────
388
+ // Calcula distância entre o IP do usuário (via Cloudflare) e o imóvel.
389
+ // Ativa quando o evento carrega property_lat + property_lng (ex: Schedule de rota).
390
+ const propLat = parseFloat(payload.property_lat ?? payload.propertyLat);
391
+ const propLng = parseFloat(payload.property_lng ?? payload.propertyLng);
392
+ const userLat = parseFloat(request.cf?.latitude);
393
+ const userLng = parseFloat(request.cf?.longitude);
394
+ if (!isNaN(propLat) && !isNaN(propLng) && !isNaN(userLat) && !isNaN(userLng)) {
395
+ const distKm = haversineKm(userLat, userLng, propLat, propLng);
396
+ payload.distanceKm = Math.round(distKm * 10) / 10;
397
+ payload.distanceBucket = distKm < 5 ? 'very_close' :
398
+ distKm < 15 ? 'close' :
399
+ distKm < 30 ? 'nearby' :
400
+ distKm < 60 ? 'moderate' : 'far';
401
+ }
402
+
327
403
  // ── LTV Prediction (+ A/B Testing de Prompts) ─────────────────────────
328
404
  const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
329
405
  if (LTV_EVENTS.includes(eventName) && !payload.value) {
@@ -352,6 +428,35 @@ export default {
352
428
  }
353
429
  }
354
430
 
431
+ // ── LTV Feedback Loop — fecha o ciclo preditivo ──────────────────────
432
+ // Quando uma compra real acontece, registra o valor real e recalcula accuracy.
433
+ // Alimenta ltv_ab_variations.accuracy_score → autoDecideAbWinner usa isso.
434
+ if (eventName === 'Purchase' && payload.value > 0) {
435
+ ctx.waitUntil(recordLtvFeedback(env, payload.userId, payload.value));
436
+ }
437
+
438
+ // ── Meta Signal Score (composite, pesos dinâmicos) ───────────────────
439
+ // Pesos variam por profundidade de funil: fundo = comportamento pesa mais.
440
+ {
441
+ const w = computeMetaSignalWeights(payload.funnelLevel);
442
+ const iW = payload.intentScoreNum ?? 0.5;
443
+ const lW = payload.ltvScore ? payload.ltvScore / 100 : 0.5;
444
+ const dW = distanceBucketWeight(payload.distanceBucket);
445
+ payload.metaSignal = Math.round(((iW * w.intent) + (lW * w.ltv) + (dW * w.dist)) * 100) / 100;
446
+ payload.metaSignalBucket = metaSignalBucket(payload.metaSignal); // 'hot'/'warm'/'cold'
447
+ }
448
+
449
+ // ── Hot Lead Trigger (timing + sinal) ────────────────────────────────
450
+ // Reage em tempo real: WhatsApp apenas quando comportamento + sinal justificam.
451
+ // Critérios: rota + muito próximo + (intent alto OU meta_signal alto)
452
+ // + (janela de decisão BRT 18–22h OU sinal excepcional ≥ 0.9)
453
+ const hourBRT = (new Date().getUTCHours() - 3 + 24) % 24;
454
+ const inWindow = hourBRT >= 18 && hourBRT <= 22;
455
+ const isHotLead = payload.funnel_stage === 'route_click'
456
+ && payload.distanceBucket === 'very_close'
457
+ && ((payload.intentScoreNum ?? 0) >= 0.8 || payload.metaSignal >= 0.85)
458
+ && (inWindow || payload.metaSignal >= 0.9);
459
+
355
460
  // Cross-Device Graph — background
356
461
  if (env.DB && payload.userId && (payload.email || payload.phone)) {
357
462
  ctx.waitUntil(resolveDeviceGraph(env.DB, payload.userId, payload.email, payload.phone));
@@ -366,10 +471,10 @@ export default {
366
471
  sendMetaCapi(env, eventName, payload, request, ctx),
367
472
  sendGA4Mp(env, ga4Name, payload, ctx),
368
473
  sendTikTokApi(env, eventName, payload, request, ctx),
369
- saveLead(env, eventName, payload, request, 'website'),
474
+ saveLead(env, payload.internalEvent || eventName, payload, request, 'website'),
370
475
  upsertProfile(env, eventName, payload, request),
371
- ...(WHATSAPP_NOTIFY_EVENTS.includes(eventName)
372
- ? [sendWhatsApp(env, eventName, payload)]
476
+ ...(WHATSAPP_NOTIFY_EVENTS.includes(eventName) || isHotLead
477
+ ? [sendWhatsApp(env, isHotLead ? 'hot_lead_intent_to_visit' : eventName, payload)]
373
478
  : []),
374
479
  ]);
375
480
 
@@ -443,6 +443,77 @@ export async function upsertLtvProfile(env, userId, ltv) {
443
443
  }
444
444
  }
445
445
 
446
+ // ── recordLtvFeedback — fecha o ciclo preditivo com valor real de compra ─────
447
+ // Chamado em background quando um Purchase chega com payload.value > 0.
448
+ // Atualiza user_profiles + ltv_ab_assignments + ltv_ab_variations em cascata.
449
+ export async function recordLtvFeedback(env, userId, realValue) {
450
+ if (!env.DB || !userId || !realValue || realValue <= 0) return;
451
+
452
+ try {
453
+ // 1. Busca predicted_ltv_value atual do perfil
454
+ const profile = await env.DB.prepare(`
455
+ SELECT predicted_ltv_value FROM user_profiles WHERE user_id = ?
456
+ `).bind(userId).first();
457
+
458
+ // accuracy = 1 - |pred-real|/real (0–1, mesmo padrão do A/B test accuracy_score)
459
+ const predictedValue = profile?.predicted_ltv_value;
460
+ const ltv_accuracy = (predictedValue !== null && predictedValue !== undefined)
461
+ ? Math.max(0, Math.round((1 - Math.abs(predictedValue - realValue) / Math.max(realValue, 1)) * 100) / 100)
462
+ : null;
463
+
464
+ // 2. Grava valor real + accuracy no perfil
465
+ await env.DB.prepare(`
466
+ UPDATE user_profiles
467
+ SET real_ltv_value = ?,
468
+ ltv_accuracy = ?,
469
+ ltv_feedback_at = datetime('now'),
470
+ updated_at = datetime('now')
471
+ WHERE user_id = ?
472
+ `).bind(realValue, ltv_accuracy, userId).run();
473
+
474
+ // 3. Fecha assignment do A/B test mais recente não convertido (janela 60 dias)
475
+ const assignment = await env.DB.prepare(`
476
+ SELECT id, variation_id, predicted_ltv
477
+ FROM ltv_ab_assignments
478
+ WHERE user_id = ?
479
+ AND converted = 0
480
+ AND assigned_at > datetime('now', '-60 days')
481
+ ORDER BY assigned_at DESC
482
+ LIMIT 1
483
+ `).bind(userId).first();
484
+
485
+ if (!assignment) return;
486
+
487
+ // 3a. Marca assignment como convertido
488
+ await env.DB.prepare(`
489
+ UPDATE ltv_ab_assignments
490
+ SET converted = 1,
491
+ real_revenue = ?,
492
+ converted_at = datetime('now')
493
+ WHERE id = ?
494
+ `).bind(realValue, assignment.id).run();
495
+
496
+ // 3b. Atualiza métricas acumuladas da variação (running average — safe para concorrência D1)
497
+ const predLtv = assignment.predicted_ltv || 0;
498
+ const indivAcc = Math.max(0, 1 - Math.abs(predLtv - realValue) / Math.max(realValue, 1));
499
+
500
+ await env.DB.prepare(`
501
+ UPDATE ltv_ab_variations
502
+ SET total_purchases = total_purchases + 1,
503
+ sum_real_revenue = sum_real_revenue + ?,
504
+ avg_real_revenue = (sum_real_revenue + ?) / (total_purchases + 1),
505
+ accuracy_score = ROUND(
506
+ (COALESCE(accuracy_score, 0) * total_purchases + ?) / (total_purchases + 1),
507
+ 4
508
+ )
509
+ WHERE id = ?
510
+ `).bind(realValue, realValue, indivAcc, assignment.variation_id).run();
511
+
512
+ } catch (err) {
513
+ console.error('[LTV-Feedback] recordLtvFeedback error:', err.message);
514
+ }
515
+ }
516
+
446
517
  // ── Feedback Loop — Log de falhas e métricas de saúde ────────────────────────
447
518
 
448
519
  export async function logApiFailure(DB, platform, eventName, errorCode, errorMessage, eventId, rawPayload) {
@@ -24,6 +24,9 @@ export async function sendMetaCapi(env, eventName, payload, request, ctx) {
24
24
  eventId, pageUrl,
25
25
  value, currency,
26
26
  contentIds, contentName, contentType, numItems,
27
+ // Dual-layer context — funil avançado + imóveis
28
+ funnel_stage, distance_bucket, intent_score, intent_bucket,
29
+ ltvScore, ltvClass, metaSignal, metaSignalBucket: metaSignalBucketVal,
27
30
  } = payload;
28
31
 
29
32
  const phoneNorm = normalizePhone(phone);
@@ -57,6 +60,15 @@ export async function sendMetaCapi(env, eventName, payload, request, ctx) {
57
60
  ...(contentName && { content_name: contentName }),
58
61
  ...(contentType && { content_type: contentType }),
59
62
  ...(numItems && { num_items: parseInt(numItems) }),
63
+ // Contexto de funil e proximidade — enriquece matching e otimização Meta
64
+ ...(funnel_stage && { funnel_stage }),
65
+ ...(distance_bucket && { distance_bucket }),
66
+ ...(intent_score && { intent_score }),
67
+ ...(ltvScore !== undefined && ltvScore !== null && { ltv_score: ltvScore }),
68
+ ...(ltvClass && { ltv_class: ltvClass }),
69
+ ...(metaSignal !== undefined && metaSignal !== null && { meta_signal: metaSignal }),
70
+ ...(metaSignalBucketVal && { meta_signal_bucket: metaSignalBucketVal }),
71
+ ...(intent_bucket && { intent_bucket }),
60
72
  };
61
73
 
62
74
  const eventPayload = {
@@ -6,13 +6,6 @@
6
6
  import { sha256, tryParseJson } from '../utils.js';
7
7
 
8
8
  // ── Listas de detecção ────────────────────────────────────────────────────────
9
- export const DISPOSABLE_EMAIL_DOMAINS = new Set([
10
- 'mailinator.com','guerrillamail.com','tempmail.com','throwaway.email',
11
- 'yopmail.com','sharklasers.com','guerrillamailblock.com','spam4.me',
12
- '10minutemail.com','trashmail.com','maildrop.cc','fakeinbox.com',
13
- 'dispostable.com','getairmail.com','mailnull.com',
14
- ]);
15
-
16
9
  export const DATACENTER_PATTERNS = /amazon|google|microsoft|digitalocean|linode|ovh|vultr|hetzner|contabo|cloudflare|packet|rackspace|leaseweb/i;
17
10
 
18
11
  // ── checkFraudGate — roda ANTES de qualquer processamento de evento ────────────
@@ -64,15 +57,7 @@ export async function checkFraudGate(env, request, payload) {
64
57
  result.score += 20; result.reasons.push('no_accept_language');
65
58
  }
66
59
 
67
- // 6. Email descartável
68
- if (email) {
69
- const domain = email.split('@')[1]?.toLowerCase();
70
- if (domain && DISPOSABLE_EMAIL_DOMAINS.has(domain)) {
71
- result.score += 25; result.reasons.push('disposable_email');
72
- }
73
- }
74
-
75
- // 7. Velocity check via KV
60
+ // 6. Velocity check via KV
76
61
  if (env.GEO_CACHE && ip) {
77
62
  const velKey1h = `fraud_velocity:${ip}:h`;
78
63
  const velStr = await env.GEO_CACHE.get(velKey1h);
@@ -8,6 +8,23 @@ import { extractFeatures, predictWithWeights, loadActiveWeights } from './logist
8
8
  // Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
9
9
  const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
10
10
 
11
+ // ── Prompt especializado para imóveis ────────────────────────────────────────
12
+ // Ativado automaticamente quando property_lat/lng estão presentes no payload.
13
+ // Override por A/B test tem prioridade sobre este prompt.
14
+ const REAL_ESTATE_PROMPT = `You are a real estate lead scoring expert for the Brazilian market.
15
+ Reply ONLY with a JSON object {"adjustment": <integer between -15 and 15>} based on the lead data.
16
+ Scoring rules (apply additively):
17
+ - distance_km < 5: +12 (lives nearby, buys fast)
18
+ - distance_km 5-15: +8
19
+ - distance_km 15-30: +3
20
+ - distance_km > 30: 0
21
+ - distance_km unknown: +3 (gave intent signal without geo)
22
+ - event = Schedule or route click: +5 (physical visit intent)
23
+ - scroll_score >= 3 AND time_level = comprador: +4 (deep engagement)
24
+ - hour_brt between 18-22 (weekday): +3 (active decision window)
25
+ - has_phone = true: +2 (reachable for follow-up)
26
+ No explanation. JSON only.`;
27
+
11
28
  // ── predictLtv — Heurística em 5 dimensões (0-100 pts) ───────────────────────
12
29
  export async function predictLtv(env, payload, request, customSystemPrompt = null) {
13
30
  // ── Tentar modelo treinado (regressão logística real) ─────────────────────
@@ -83,6 +100,19 @@ export async function predictLtv(env, payload, request, customSystemPrompt = nul
83
100
  if (payload.phone) score += 4;
84
101
  if (payload.firstName) score += 2;
85
102
 
103
+ // 6. Proximidade ao imóvel físico (0–15) — apenas quando distância calculada
104
+ const distKm = parseFloat(payload.distanceKm ?? payload.user_distance_km ?? -1);
105
+ if (distKm >= 0) {
106
+ if (distKm < 5) score += 15;
107
+ else if (distKm < 15) score += 10;
108
+ else if (distKm < 30) score += 6;
109
+ else if (distKm < 60) score += 3;
110
+ // > 60km: sem bônus — lead distante precisa de argumento diferente
111
+ } else if (payload.property_lat || payload.propertyLat) {
112
+ // Coords no payload mas distância não resolvida: pequeno bônus por intenção de rota
113
+ score += 3;
114
+ }
115
+
86
116
  score = Math.min(100, score);
87
117
 
88
118
  let ltvClass, ltvMultiplier;
@@ -102,21 +132,36 @@ export async function predictLtv(env, payload, request, customSystemPrompt = nul
102
132
  let aiAdjustment = 0;
103
133
  if (env.AI && score >= 40) {
104
134
  try {
135
+ const isRealEstate = !!(payload.property_lat || payload.propertyLat);
105
136
  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.';
137
+ (isRealEstate
138
+ ? REAL_ESTATE_PROMPT
139
+ : '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.');
140
+
141
+ const userContext = {
142
+ utm_source: payload.utmSource,
143
+ intention: intentionLevel,
144
+ engagement: engScore,
145
+ hour_utc: hour,
146
+ country,
147
+ has_email: !!payload.email,
148
+ has_phone: !!payload.phone,
149
+ };
150
+ if (isRealEstate) {
151
+ userContext.event_type = 'real_estate_schedule';
152
+ userContext.distance_km = payload.distanceKm ?? payload.user_distance_km ?? 'unknown';
153
+ userContext.distance_bucket = payload.distanceBucket ?? 'unknown';
154
+ userContext.scroll_score = payload.scrollScore || payload.scroll_score || 0;
155
+ userContext.time_level = payload.timeLevel || payload.time_level || 'unknown';
156
+ userContext.intent_score = payload.intent_score || 'high';
157
+ userContext.hour_brt = (hour - 3 + 24) % 24; // UTC-3 aproximado
158
+ }
159
+
107
160
  const prompt = [
108
161
  { 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
- })},
162
+ { role: 'user', content: JSON.stringify(userContext) },
118
163
  ];
119
- const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: prompt, max_tokens: 32 });
164
+ const aiRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: prompt, max_tokens: 32 });
120
165
  const parsed = JSON.parse(aiRes.response.trim());
121
166
  if (typeof parsed.adjustment === 'number') {
122
167
  aiAdjustment = Math.max(-10, Math.min(10, parsed.adjustment));