cdp-edge 2.1.0 → 2.2.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.
@@ -25,6 +25,7 @@ import {
25
25
  initAntiBlocking,
26
26
  ANTI_BLOCKING_CONFIG
27
27
  } from './anti-blocking.js';
28
+ import { initRouteIntentCapture } from './route-intent-capture.js';
28
29
 
29
30
  // ── Guards — segurança em SSR e SDK não carregado ──
30
31
  const isBrowser = typeof window !== 'undefined';
@@ -631,6 +632,12 @@ export async function init() {
631
632
  passCheckoutParams({ platforms: CONFIG.platforms });
632
633
  }
633
634
 
635
+ // 7. Route Intent Capture — widget pós-clique de rota para imóveis
636
+ // Ativa quando CONFIG.routeIntent.whatsappNumber estiver configurado.
637
+ if (CONFIG.routeIntent?.whatsappNumber) {
638
+ initRouteIntentCapture(CONFIG.routeIntent);
639
+ }
640
+
634
641
  if (CONFIG.debug) console.log('✅ cdpTrack SDK inicializado (Quantum Tier)');
635
642
  }
636
643
 
@@ -0,0 +1,222 @@
1
+ /**
2
+ * CDP Edge — Route Intent Capture
3
+ * @version 2.0.0
4
+ *
5
+ * Fluxo:
6
+ * 1. Usuário clica em "Ver rota" → Google Maps abre normalmente
7
+ * 2. Widget aparece com um botão de WhatsApp
8
+ * 3. Usuário clica → WhatsApp abre com mensagem pré-escrita
9
+ * 4. Usuário envia → corretor recebe e responde
10
+ *
11
+ * Uso:
12
+ * initRouteIntentCapture({
13
+ * whatsappNumber: '5511999999999', // número do plantão/corretor
14
+ * propertyName: 'Reserva do Jardim',
15
+ * propertyId: 'rj-001',
16
+ * propertyLat: -23.6519,
17
+ * propertyLng: -46.5330,
18
+ * });
19
+ */
20
+
21
+ const isBrowser = typeof window !== 'undefined';
22
+
23
+ /**
24
+ * @param {object} options
25
+ * @param {string} options.whatsappNumber Número do plantão (ex: '5511999999999')
26
+ * @param {string} [options.propertyName] Nome do empreendimento
27
+ * @param {string} [options.propertyId] ID interno do imóvel
28
+ * @param {number} [options.propertyLat] Latitude
29
+ * @param {number} [options.propertyLng] Longitude
30
+ * @param {string} [options.routeSelector] CSS selector dos botões de rota (default auto)
31
+ * @param {string} [options.distanceBucket] 'very_close'|'close'|'nearby'|'moderate'|'far'
32
+ * @param {number} [options.distanceKm] Distância em km — exibe tempo estimado
33
+ * @param {string} [options.metaSignalBucket] 'hot'|'warm'|'cold'
34
+ * @param {string} [options.brokerName] Nome do corretor de plantão (ex: 'Ramon')
35
+ */
36
+ export function initRouteIntentCapture(options = {}) {
37
+ if (!isBrowser) return;
38
+
39
+ const {
40
+ whatsappNumber,
41
+ propertyName = 'o imóvel',
42
+ propertyId = null,
43
+ propertyLat = null,
44
+ propertyLng = null,
45
+ routeSelector = '[data-route-intent], a[href*="maps/dir"], a[href*="maps?q="]',
46
+ distanceBucket = null,
47
+ distanceKm = null,
48
+ metaSignalBucket = null,
49
+ brokerName = null,
50
+ } = options;
51
+
52
+ if (!whatsappNumber) {
53
+ console.warn('[RouteIntent] whatsappNumber é obrigatório.');
54
+ return;
55
+ }
56
+
57
+ _injectStyles();
58
+
59
+ document.addEventListener('click', (e) => {
60
+ const btn = e.target.closest(routeSelector);
61
+ if (!btn) return;
62
+ _showWidget(btn, { whatsappNumber, propertyName, propertyId, propertyLat, propertyLng, distanceBucket, distanceKm, metaSignalBucket, brokerName });
63
+ }, true);
64
+ }
65
+
66
+ // ── Widget ────────────────────────────────────────────────────────────────────
67
+
68
+ function _showWidget(anchorEl, opts) {
69
+ document.getElementById('cdp-ri-widget')?.remove();
70
+
71
+ const travelMinutes = _estimateTravelMinutes(opts.distanceKm);
72
+ const travelText = travelMinutes
73
+ ? `<p class="cdp-ri-travel">📍 Você está a cerca de <strong>${travelMinutes} min</strong> daqui</p>`
74
+ : '';
75
+
76
+ const brokerLine = opts.brokerName
77
+ ? `<p class="cdp-ri-broker">Ao chegar, pergunte pelo <strong>${opts.brokerName}</strong> — ele vai estar te aguardando com tudo pronto 🤝</p>`
78
+ : '';
79
+
80
+ const widget = document.createElement('div');
81
+ widget.id = 'cdp-ri-widget';
82
+ widget.innerHTML = `
83
+ <div class="cdp-ri-inner">
84
+ ${travelText}
85
+ <p class="cdp-ri-headline">Vi que você quer visitar o local! Confirme sua vinda enviando a mensagem abaixo — nossa equipe já fica de prontidão pra te receber 👇</p>
86
+ ${brokerLine}
87
+ <button id="cdp-ri-btn" type="button">
88
+ ${_waIcon()} Confirmar minha visita
89
+ </button>
90
+ <button id="cdp-ri-dismiss" type="button" class="cdp-ri-dismiss">Agora não</button>
91
+ </div>
92
+ `;
93
+
94
+ anchorEl.insertAdjacentElement('afterend', widget);
95
+ setTimeout(() => widget.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 150);
96
+
97
+ document.getElementById('cdp-ri-btn').addEventListener('click', () => _handleClick(widget, opts));
98
+ document.getElementById('cdp-ri-dismiss').addEventListener('click', () => {
99
+ window.cdpTrack?.track?.('ViewContent', {
100
+ content_name: 'rota_dispensada',
101
+ property_id: opts.propertyId,
102
+ funnel_stage: 'route_dismiss',
103
+ intent_score: 'medium',
104
+ distance_bucket: opts.distanceBucket || undefined,
105
+ meta_signal_bucket: opts.metaSignalBucket || undefined,
106
+ });
107
+ widget.remove();
108
+ });
109
+ }
110
+
111
+ // ── Click — dispara evento e abre WhatsApp ────────────────────────────────────
112
+
113
+ async function _handleClick(widget, opts) {
114
+ const btn = document.getElementById('cdp-ri-btn');
115
+ if (btn) btn.disabled = true;
116
+
117
+ // Evento Contact → Worker: LTV + hot lead trigger + CAPI
118
+ // Phone capturado depois via webhook quando a mensagem chegar
119
+ window.cdpTrack?.track?.('Contact', {
120
+ content_name: 'aviso_chegada_whatsapp',
121
+ property_id: opts.propertyId,
122
+ property_lat: opts.propertyLat,
123
+ property_lng: opts.propertyLng,
124
+ funnel_stage: 'route_click',
125
+ intent_score: 'high',
126
+ distance_bucket: opts.distanceBucket || undefined,
127
+ meta_signal_bucket: opts.metaSignalBucket || undefined,
128
+ });
129
+
130
+ // Monta mensagem — acolhedora, foco em confirmar chegada
131
+ const travelMinutes = _estimateTravelMinutes(opts.distanceKm);
132
+ const travelLine = travelMinutes ? `Estou a cerca de ${travelMinutes} minutos daí.` : '';
133
+ const brokerLine = opts.brokerName
134
+ ? `Vou procurar pelo ${opts.brokerName} ao chegar.`
135
+ : '';
136
+
137
+ const msg = [
138
+ `Oi! Estou interessado(a) em visitar o ${opts.propertyName} e gostaria de confirmar minha visita.`,
139
+ travelLine,
140
+ brokerLine || `Vocês conseguem me receber agora ou preciso marcar horário?`,
141
+ ].filter(Boolean).join(' ');
142
+
143
+ _showSuccess(widget, opts.whatsappNumber, msg);
144
+ }
145
+
146
+ // ── Tela de confirmação ───────────────────────────────────────────────────────
147
+
148
+ function _showSuccess(widget, whatsappNumber, msg) {
149
+ widget.innerHTML = `
150
+ <div class="cdp-ri-inner cdp-ri-ok">
151
+ <span class="cdp-ri-check">✅</span>
152
+ <p><strong>Abrindo WhatsApp...</strong><br><span>A equipe já foi avisada!</span></p>
153
+ </div>
154
+ `;
155
+ setTimeout(() => {
156
+ window.open(`https://wa.me/${whatsappNumber}?text=${encodeURIComponent(msg)}`, '_blank');
157
+ setTimeout(() => widget?.remove(), 3500);
158
+ }, 600);
159
+ }
160
+
161
+ // ── Helpers ───────────────────────────────────────────────────────────────────
162
+
163
+ function _estimateTravelMinutes(distanceKm) {
164
+ if (!distanceKm || distanceKm <= 0) return null;
165
+ return Math.max(5, Math.round((distanceKm * 60 / 25) / 5) * 5);
166
+ }
167
+
168
+ function _waIcon() {
169
+ return `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
170
+ <path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z"/>
171
+ <path d="M12 0C5.373 0 0 5.373 0 12c0 2.122.554 4.118 1.528 5.852L0 24l6.335-1.513A11.933 11.933 0 0012 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm0 21.818a9.818 9.818 0 01-5.002-1.368l-.359-.213-3.722.888.924-3.617-.234-.372A9.818 9.818 0 012.182 12C2.182 6.57 6.57 2.182 12 2.182S21.818 6.57 21.818 12 17.43 21.818 12 21.818z"/>
172
+ </svg>`;
173
+ }
174
+
175
+ // ── Estilos ───────────────────────────────────────────────────────────────────
176
+
177
+ function _injectStyles() {
178
+ if (document.getElementById('cdp-ri-styles')) return;
179
+ const s = document.createElement('style');
180
+ s.id = 'cdp-ri-styles';
181
+ s.textContent = `
182
+ #cdp-ri-widget {
183
+ margin-top: 12px;
184
+ padding: 16px 18px;
185
+ background: #f0fdf4;
186
+ border: 1.5px solid #22c55e;
187
+ border-radius: 12px;
188
+ font-family: Arial, sans-serif;
189
+ animation: cdp-ri-in .25s ease;
190
+ }
191
+ @keyframes cdp-ri-in {
192
+ from { opacity: 0; transform: translateY(-8px); }
193
+ to { opacity: 1; transform: translateY(0); }
194
+ }
195
+ .cdp-ri-inner { display: flex; flex-direction: column; gap: 10px; }
196
+ .cdp-ri-travel { margin: 0; font-size: 13px; color: #555; }
197
+ .cdp-ri-travel strong { color: #0f766e; }
198
+ .cdp-ri-headline { margin: 0; font-size: 14px; color: #15803d; font-weight: bold; line-height: 1.4; }
199
+ .cdp-ri-broker { margin: 0; font-size: 13px; color: #555; line-height: 1.4; }
200
+ .cdp-ri-broker strong { color: #0f766e; }
201
+ #cdp-ri-btn {
202
+ display: flex; align-items: center; justify-content: center; gap: 8px;
203
+ width: 100%; padding: 13px 16px;
204
+ background: #25D366; color: #fff;
205
+ border: none; border-radius: 10px;
206
+ font-size: 15px; font-weight: bold; cursor: pointer;
207
+ }
208
+ #cdp-ri-btn:hover { background: #1ebe5a; }
209
+ #cdp-ri-btn:disabled { background: #86efac; cursor: not-allowed; }
210
+ .cdp-ri-dismiss {
211
+ background: none; border: none;
212
+ color: #aaa; font-size: 12px;
213
+ cursor: pointer; padding: 2px 0; text-align: center;
214
+ }
215
+ .cdp-ri-dismiss:hover { color: #666; }
216
+ .cdp-ri-ok { align-items: center; text-align: center; gap: 8px; }
217
+ .cdp-ri-check { font-size: 28px; }
218
+ .cdp-ri-ok p { margin: 0; font-size: 14px; color: #15803d; line-height: 1.5; }
219
+ .cdp-ri-ok span { font-size: 13px; color: #555; }
220
+ `;
221
+ document.head.appendChild(s);
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-edge",
3
- "version": "2.1.0",
3
+ "version": "2.2.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",
@@ -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
  // ─────────────────────────────────────────────────────────────────────────────
@@ -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 = {
@@ -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,19 +132,34 @@ 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
164
  const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: prompt, max_tokens: 32 });
120
165
  const parsed = JSON.parse(aiRes.response.trim());
@@ -87,3 +87,79 @@ export const VALID_EVENT_NAMES = new Set([
87
87
  'StartTrial','Subscribe','SubmitApplication','Search',
88
88
  'video_start','video_25','video_50','video_75','video_complete',
89
89
  ]);
90
+
91
+ // ── Taxonomia de funil (funnel_stage → profundidade semântica) ────────────────
92
+ // Fonte de verdade para interpretar funnel_stage em qualquer ponto do sistema.
93
+ export const FUNNEL_TAXONOMY = {
94
+ top: ['scroll_50', 'time_30s', 'page_view', 'gallery_view'],
95
+ mid: ['map_view', 'gallery_click', 'price_hover', 'time_3min'],
96
+ bottom: ['route_click', 'whatsapp_click', 'cta_hover'],
97
+ conversion: ['schedule_confirmed', 'lead_form', 'purchase', 'visit_booked'],
98
+ };
99
+
100
+ // Índice invertido: funnel_stage → depth (construído uma vez, zero custo em runtime)
101
+ const _STAGE_TO_DEPTH = Object.entries(FUNNEL_TAXONOMY).reduce((acc, [depth, stages]) => {
102
+ stages.forEach(s => { acc[s] = depth; });
103
+ return acc;
104
+ }, {});
105
+
106
+ /**
107
+ * Resolve funnel_stage em funnelDepth semântico.
108
+ * bottom_intent = intenção forte (route_click, whatsapp_click)
109
+ * bottom_conversion = ação confirmada (schedule_confirmed, lead_form)
110
+ */
111
+ export function resolveFunnelStage(funnel_stage) {
112
+ const depth = _STAGE_TO_DEPTH[funnel_stage] || 'unknown';
113
+ const funnelDepth = depth === 'conversion' ? 'bottom_conversion'
114
+ : depth === 'bottom' ? 'bottom_intent'
115
+ : depth;
116
+ return { depth, funnelDepth };
117
+ }
118
+
119
+ // ── Normalização de intent_score → 0.0–1.0 ───────────────────────────────────
120
+ // Aceita: string ('high'/'medium'/'low'), numérico 0-1 ou numérico 0-100
121
+ const _INTENT_STRING_MAP = { high: 0.92, medium: 0.65, low: 0.30 };
122
+
123
+ export function resolveIntentScore(value) {
124
+ if (value === null || value === undefined) return null;
125
+ if (typeof value === 'string') return _INTENT_STRING_MAP[value.toLowerCase()] ?? null;
126
+ const num = parseFloat(value);
127
+ if (isNaN(num)) return null;
128
+ const normalized = num > 1 ? num / 100 : num; // escala 0-100 → 0-1
129
+ return Math.min(1, Math.max(0, Math.round(normalized * 100) / 100));
130
+ }
131
+
132
+ /**
133
+ * Distância (distanceBucket) → peso numérico para meta_signal.
134
+ * very_close=1.0 ... far=0.1 ... sem dado=0.3 (neutro)
135
+ */
136
+ export function distanceBucketWeight(bucket) {
137
+ const map = { very_close: 1.0, close: 0.75, nearby: 0.5, moderate: 0.25, far: 0.1 };
138
+ return map[bucket] ?? 0.3;
139
+ }
140
+
141
+ /**
142
+ * Pesos dinâmicos do meta_signal por profundidade de funil.
143
+ * Fundo: comportamento pesa mais (intent + dist).
144
+ * Topo: perfil pesa mais (ltv).
145
+ * Default (mid/unknown): balanceado.
146
+ */
147
+ export function computeMetaSignalWeights(funnelLevel) {
148
+ if (funnelLevel === 'bottom' || funnelLevel === 'conversion') {
149
+ return { intent: 0.5, ltv: 0.2, dist: 0.3 };
150
+ }
151
+ if (funnelLevel === 'top') {
152
+ return { intent: 0.2, ltv: 0.6, dist: 0.2 };
153
+ }
154
+ return { intent: 0.4, ltv: 0.4, dist: 0.2 };
155
+ }
156
+
157
+ /**
158
+ * Quantiza meta_signal contínuo em bucket legível.
159
+ * Usado em criação de públicos e leitura de BI.
160
+ */
161
+ export function metaSignalBucket(score) {
162
+ if (score >= 0.8) return 'hot';
163
+ if (score >= 0.6) return 'warm';
164
+ return 'cold';
165
+ }
@@ -0,0 +1,11 @@
1
+ -- CDP Edge — Schema LTV Feedback Loop
2
+ -- Fecha o ciclo preditivo: Purchase real → corrige predicted_ltv_value
3
+ -- Execução: wrangler d1 execute cdp-edge-db --file=schema-ltv-feedback.sql --remote
4
+ --
5
+ -- Idempotência: ALTER TABLE não suporta IF NOT EXISTS no SQLite.
6
+ -- Se a coluna já existir, o comando gera erro mas não afeta dados existentes.
7
+ -- Seguro executar mais de uma vez.
8
+
9
+ ALTER TABLE user_profiles ADD COLUMN real_ltv_value REAL;
10
+ ALTER TABLE user_profiles ADD COLUMN ltv_accuracy REAL; -- 1 - |pred-real|/real (0–1, maior = melhor)
11
+ ALTER TABLE user_profiles ADD COLUMN ltv_feedback_at TEXT; -- timestamp do último feedback