cdp-edge 1.23.2 → 1.23.3

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.
Files changed (26) hide show
  1. package/README.md +44 -20
  2. package/contracts/agent-versions.json +42 -41
  3. package/dist/commands/install.js +1 -1
  4. package/dist/commands/server.js +4 -4
  5. package/extracted-skill/tracking-events-generator/agents/database-agent.md +5 -4
  6. package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +0 -1
  7. package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +1 -1
  8. package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +4 -4
  9. package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +81 -70
  10. package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +6 -2
  11. package/extracted-skill/tracking-events-generator/cdpTrack.js +7 -0
  12. package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
  13. package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
  14. package/package.json +3 -2
  15. package/server-edge-tracker/INSTALAR.md +5 -5
  16. package/server-edge-tracker/index.js +109 -4
  17. package/server-edge-tracker/modules/db.js +71 -0
  18. package/server-edge-tracker/modules/dispatch/meta.js +12 -0
  19. package/server-edge-tracker/modules/ml/fraud.js +1 -16
  20. package/server-edge-tracker/modules/ml/ltv.js +62 -11
  21. package/server-edge-tracker/modules/ml/segmentation.js +157 -127
  22. package/server-edge-tracker/modules/utils.js +78 -0
  23. package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
  24. package/server-edge-tracker/wrangler.toml +26 -8
  25. package/templates/lancamento-imobiliario.md +344 -0
  26. package/server-edge-tracker/worker.js +0 -4577
@@ -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": "1.23.2",
3
+ "version": "1.23.3",
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",
@@ -23,11 +23,12 @@
23
23
  "build": "node build.js",
24
24
  "dev": "node build.js --watch",
25
25
  "test": "node test.js",
26
- "test:unit": "node tests/unit/normalization.test.js && node tests/unit/hashing.test.js && node tests/unit/deduplication.test.js && node tests/unit/payload-validation.test.js && node tests/unit/new-features.test.js",
26
+ "test:unit": "node tests/unit/normalization.test.js && node tests/unit/hashing.test.js && node tests/unit/deduplication.test.js && node tests/unit/payload-validation.test.js && node tests/unit/new-features.test.js && node tests/unit/utils.test.js",
27
27
  "test:unit:normalize": "node tests/unit/normalization.test.js",
28
28
  "test:unit:hash": "node tests/unit/hashing.test.js",
29
29
  "test:unit:dedup": "node tests/unit/deduplication.test.js",
30
30
  "test:unit:payload": "node tests/unit/payload-validation.test.js",
31
+ "test:unit:utils": "node tests/unit/utils.test.js",
31
32
  "test:all": "npm run test:unit",
32
33
  "test:integration": "cd tests/integration && npx vitest run",
33
34
  "agents:check": "node scripts/validate-agents.js",
@@ -298,7 +298,7 @@ wrangler d1 execute cdp-edge-db --remote --command="SELECT event_name, email, ci
298
298
  ```
299
299
  ERROR: Can't deploy routes that are assigned to another worker.
300
300
  "server-edge-tracker" is already assigned to routes:
301
- - lancamentosabc.com.br/track*
301
+ - SEU_DOMINIO/track*
302
302
  ```
303
303
 
304
304
  ### SOLUÇÃO 1 — Via Painel Cloudflare (RECOMENDADO):
@@ -306,7 +306,7 @@ ERROR: Can't deploy routes that are assigned to another worker.
306
306
  1. Acesse: https://dash.cloudflare.com/[ID_DA_CONTA]/workers/overview
307
307
  2. Clique no worker que está usando as rotas do seu domínio
308
308
  3. Vá em Settings → Triggers → Routes
309
- 4. Clique "Delete" nas rotas do domínio `lancamentosabc.com.br`
309
+ 4. Clique "Delete" nas rotas do domínio `SEU_DOMINIO`
310
310
  5. Repita o `wrangler deploy`
311
311
 
312
312
  ### SOLUÇÃO 2 — Via Wrangler CLI:
@@ -325,11 +325,11 @@ Se não quiser remover rotas existentes, use sufixo:
325
325
 
326
326
  ```toml
327
327
  [[routes]]
328
- pattern = "lancamentosabc.com.br/track-worker-novo*"
329
- zone_name = "lancamentosabc.com.br"
328
+ pattern = "SEU_DOMINIO/track-worker-novo*"
329
+ zone_name = "SEU_DOMINIO"
330
330
  ```
331
331
 
332
- **URL do tracking:** `https://lancamentosabc.com.br/track-worker-novo`
332
+ **URL do tracking:** `https://SEU_DOMINIO/track-worker-novo`
333
333
 
334
334
  ---
335
335
 
@@ -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);