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.
- package/README.md +25 -8
- package/extracted-skill/tracking-events-generator/agents/database-agent.md +5 -4
- package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +0 -1
- package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +1 -1
- package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +4 -4
- package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +81 -70
- package/extracted-skill/tracking-events-generator/cdpTrack.js +7 -0
- package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
- package/package.json +1 -1
- package/server-edge-tracker/index.js +109 -4
- package/server-edge-tracker/modules/db.js +71 -0
- package/server-edge-tracker/modules/dispatch/meta.js +12 -0
- package/server-edge-tracker/modules/ml/fraud.js +1 -16
- package/server-edge-tracker/modules/ml/ltv.js +56 -11
- package/server-edge-tracker/modules/ml/segmentation.js +157 -127
- package/server-edge-tracker/modules/utils.js +76 -0
- package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
- package/server-edge-tracker/worker.js +178 -120
- package/server-edge-tracker/wrangler.toml +21 -4
|
@@ -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/
|
|
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.
|
|
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
|
-
|
|
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/
|
|
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));
|