cdp-edge 2.2.0 → 2.2.2
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/agents/page-analyzer.md +6 -2
- package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
- package/package.json +1 -1
- package/server-edge-tracker/index.js +1 -1
- package/server-edge-tracker/modules/ml/fraud.js +1 -16
- package/server-edge-tracker/modules/ml/ltv.js +7 -1
- package/server-edge-tracker/modules/ml/segmentation.js +157 -127
- package/server-edge-tracker/modules/utils.js +5 -3
- package/server-edge-tracker/worker.js +190 -122
- package/server-edge-tracker/wrangler.toml +22 -4
- package/templates/lancamento-imobiliario.md +344 -0
|
@@ -1867,6 +1867,12 @@ async function predictLtv(env, payload, request, customSystemPrompt = null) {
|
|
|
1867
1867
|
if (payload.phone) score += 4;
|
|
1868
1868
|
if (payload.firstName) score += 2;
|
|
1869
1869
|
|
|
1870
|
+
// 5b. Tipo de evento imobiliário (0–15) — sinais de intenção de compra física
|
|
1871
|
+
const evType = (payload.eventType || '').toLowerCase();
|
|
1872
|
+
if (evType === 'customizeproduct') score += 15; // simulação de financiamento → intenção máxima
|
|
1873
|
+
else if (evType === 'findlocation') score += 10; // viu mapa/localização → visita física iminente
|
|
1874
|
+
else if (evType === 'addtowishlist') score += 8; // favoritou → interesse persistente
|
|
1875
|
+
|
|
1870
1876
|
score = Math.min(100, score);
|
|
1871
1877
|
|
|
1872
1878
|
// Classificação
|
|
@@ -1901,9 +1907,10 @@ async function predictLtv(env, payload, request, customSystemPrompt = null) {
|
|
|
1901
1907
|
country,
|
|
1902
1908
|
has_email: !!payload.email,
|
|
1903
1909
|
has_phone: !!payload.phone,
|
|
1910
|
+
event_type: payload.eventType || null,
|
|
1904
1911
|
})},
|
|
1905
1912
|
];
|
|
1906
|
-
const aiRes = await env.AI.run('@cf/
|
|
1913
|
+
const aiRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: prompt, max_tokens: 32 });
|
|
1907
1914
|
const parsed = JSON.parse(aiRes.response.trim());
|
|
1908
1915
|
if (typeof parsed.adjustment === 'number') {
|
|
1909
1916
|
aiAdjustment = Math.max(-10, Math.min(10, parsed.adjustment));
|
|
@@ -2415,8 +2422,82 @@ function tryParseJson(str, fallback) {
|
|
|
2415
2422
|
try { return JSON.parse(str); } catch { return fallback !== undefined ? fallback : null; }
|
|
2416
2423
|
}
|
|
2417
2424
|
|
|
2425
|
+
// ── Helpers K-means vetorial (usado pelo clustering com embeddings) ───────────
|
|
2426
|
+
|
|
2427
|
+
function _cosDist(a, b) {
|
|
2428
|
+
let dot = 0, na = 0, nb = 0;
|
|
2429
|
+
for (let i = 0; i < a.length; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
|
|
2430
|
+
return 1 - dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function _kmeansRun(vectors, k, maxIter = 25) {
|
|
2434
|
+
const n = vectors.length;
|
|
2435
|
+
const dim = vectors[0].length;
|
|
2436
|
+
// K-means++ init
|
|
2437
|
+
const centroids = [vectors[Math.floor(Math.random() * n)]];
|
|
2438
|
+
while (centroids.length < k) {
|
|
2439
|
+
const dists = vectors.map(v => Math.min(...centroids.map(c => _cosDist(v, c))));
|
|
2440
|
+
const sum = dists.reduce((a, b) => a + b, 0);
|
|
2441
|
+
let r = Math.random() * sum, cumul = 0;
|
|
2442
|
+
for (let i = 0; i < n; i++) { cumul += dists[i]; if (cumul >= r) { centroids.push(vectors[i]); break; } }
|
|
2443
|
+
if (centroids.length < k) centroids.push(vectors[Math.floor(Math.random() * n)]);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
let assignments = new Array(n).fill(0);
|
|
2447
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
2448
|
+
let changed = false;
|
|
2449
|
+
for (let i = 0; i < n; i++) {
|
|
2450
|
+
let best = 0, bestD = Infinity;
|
|
2451
|
+
for (let c = 0; c < k; c++) { const d = _cosDist(vectors[i], centroids[c]); if (d < bestD) { bestD = d; best = c; } }
|
|
2452
|
+
if (assignments[i] !== best) { assignments[i] = best; changed = true; }
|
|
2453
|
+
}
|
|
2454
|
+
if (!changed) break;
|
|
2455
|
+
// Recompute centroids
|
|
2456
|
+
for (let c = 0; c < k; c++) {
|
|
2457
|
+
const members = vectors.filter((_, i) => assignments[i] === c);
|
|
2458
|
+
if (members.length === 0) continue;
|
|
2459
|
+
for (let d = 0; d < dim; d++) centroids[c][d] = members.reduce((s, v) => s + v[d], 0) / members.length;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
return { assignments, centroids };
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
function _silhouette(vectors, assignments, k) {
|
|
2466
|
+
const n = vectors.length;
|
|
2467
|
+
let total = 0;
|
|
2468
|
+
for (let i = 0; i < n; i++) {
|
|
2469
|
+
const ci = assignments[i];
|
|
2470
|
+
const sameCluster = vectors.filter((_, j) => j !== i && assignments[j] === ci);
|
|
2471
|
+
const a = sameCluster.length ? sameCluster.reduce((s, v) => s + _cosDist(vectors[i], v), 0) / sameCluster.length : 0;
|
|
2472
|
+
let b = Infinity;
|
|
2473
|
+
for (let c = 0; c < k; c++) {
|
|
2474
|
+
if (c === ci) continue;
|
|
2475
|
+
const other = vectors.filter((_, j) => assignments[j] === c);
|
|
2476
|
+
if (other.length) b = Math.min(b, other.reduce((s, v) => s + _cosDist(vectors[i], v), 0) / other.length);
|
|
2477
|
+
}
|
|
2478
|
+
total += b === Infinity ? 0 : (b - a) / Math.max(a, b);
|
|
2479
|
+
}
|
|
2480
|
+
return Math.round((total / n) * 1000) / 1000;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function _buildLeadProfile(l) {
|
|
2484
|
+
return [
|
|
2485
|
+
`LTV: ${l.predicted_ltv_class || 'desconhecido'}`,
|
|
2486
|
+
`engajamento: ${Math.round(l.engagement_score || 0)}`,
|
|
2487
|
+
`intenção: ${l.intention_level || 'desconhecida'}`,
|
|
2488
|
+
`origem: ${l.utm_source || 'direto'}`,
|
|
2489
|
+
`canal: ${l.utm_medium || 'desconhecido'}`,
|
|
2490
|
+
`país: ${l.country || 'BR'}`,
|
|
2491
|
+
`estado: ${l.state || ''}`,
|
|
2492
|
+
`hora: ${l.hour_of_day || 12}h`,
|
|
2493
|
+
(l.is_weekend ? 'fim-de-semana' : 'dia-útil'),
|
|
2494
|
+
`recência: ${l.days_since_lead || 0} dias`,
|
|
2495
|
+
].filter(Boolean).join(', ');
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2418
2498
|
// ── POST /api/segmentation/cluster ───────────────────────────────────────────
|
|
2419
|
-
//
|
|
2499
|
+
// Clustering real com embeddings (embeddinggemma-300m) + K-means vetorial
|
|
2500
|
+
// Granite usado apenas para nomear segmentos
|
|
2420
2501
|
// Requer bindings: DB + AI
|
|
2421
2502
|
async function handleSegmentationCluster(env, request, headers) {
|
|
2422
2503
|
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
@@ -2424,7 +2505,7 @@ async function handleSegmentationCluster(env, request, headers) {
|
|
|
2424
2505
|
|
|
2425
2506
|
const url = new URL(request.url);
|
|
2426
2507
|
const algorithm = url.searchParams.get('algorithm') || 'kmeans';
|
|
2427
|
-
const nClusters = Math.min(10, Math.max(
|
|
2508
|
+
const nClusters = Math.min(10, Math.max(2, parseInt(url.searchParams.get('n_clusters') || '5')));
|
|
2428
2509
|
const clientVertical = url.searchParams.get('vertical') || 'general';
|
|
2429
2510
|
const forceRecluster = url.searchParams.get('force') === 'true';
|
|
2430
2511
|
|
|
@@ -2480,96 +2561,94 @@ async function handleSegmentationCluster(env, request, headers) {
|
|
|
2480
2561
|
}), { status: 400, headers });
|
|
2481
2562
|
}
|
|
2482
2563
|
|
|
2483
|
-
|
|
2484
|
-
const features = leads.map(l => ({
|
|
2485
|
-
id: l.id,
|
|
2486
|
-
ltv: l.predicted_ltv_class === 'High' ? 1 : (l.predicted_ltv_class === 'Medium' ? 0.5 : 0),
|
|
2487
|
-
engagement: Math.min((l.engagement_score || 0) / 100, 1),
|
|
2488
|
-
intention: l.intention_level === 'comprador' || l.intention_level === 'high_intent' ? 1
|
|
2489
|
-
: l.intention_level === 'interessado' ? 0.6
|
|
2490
|
-
: l.intention_level === 'curioso' ? 0.3 : 0,
|
|
2491
|
-
recency: Math.max(0, 1 - (l.days_since_lead || 0) / 180),
|
|
2492
|
-
hour: (l.hour_of_day || 12) / 23,
|
|
2493
|
-
is_weekend: l.is_weekend || 0,
|
|
2494
|
-
is_br: l.country === 'BR' ? 1 : 0,
|
|
2495
|
-
is_paid: ['facebook','google','tiktok','instagram','youtube'].includes(
|
|
2496
|
-
(l.utm_source || '').toLowerCase()) ? 1 : 0,
|
|
2497
|
-
}));
|
|
2564
|
+
const startTime = Date.now();
|
|
2498
2565
|
|
|
2499
|
-
//
|
|
2500
|
-
const
|
|
2501
|
-
const
|
|
2502
|
-
|
|
2503
|
-
const
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
- engagement: browser engagement score
|
|
2509
|
-
- intention: purchase intention (0=none, 0.3=curious, 0.6=interested, 1=buyer)
|
|
2510
|
-
- recency: lead recency (1=today, 0=6 months ago)
|
|
2511
|
-
- hour: conversion hour of day
|
|
2512
|
-
- is_weekend: converted on weekend (0/1)
|
|
2513
|
-
- is_br: lead from Brazil (0/1)
|
|
2514
|
-
- is_paid: from paid traffic channel (0/1)
|
|
2515
|
-
|
|
2516
|
-
Data (${sampleSize} customers): ${JSON.stringify(sample.slice(0, 50))}
|
|
2517
|
-
|
|
2518
|
-
Return ONLY valid JSON, zero explanation:
|
|
2519
|
-
{
|
|
2520
|
-
"clusters": [
|
|
2521
|
-
{
|
|
2522
|
-
"cluster_id": 0,
|
|
2523
|
-
"name": "[Nome Descritivo em Português]",
|
|
2524
|
-
"size": ${Math.round(sampleSize / nClusters)},
|
|
2525
|
-
"percentage": ${Math.round(100 / nClusters)},
|
|
2526
|
-
"characteristics": {
|
|
2527
|
-
"avg_ltv_class": 0.5,
|
|
2528
|
-
"avg_behavior_score": 0.5,
|
|
2529
|
-
"avg_engagement_score": 0.5,
|
|
2530
|
-
"avg_intention_level": 0.5,
|
|
2531
|
-
"avg_days_since_lead": 30,
|
|
2532
|
-
"dominant_countries": ["BR"],
|
|
2533
|
-
"dominant_states": ["SP", "RJ"],
|
|
2534
|
-
"dominant_utm_sources": ["facebook"],
|
|
2535
|
-
"top_features": ["ltv", "engagement"]
|
|
2536
|
-
},
|
|
2537
|
-
"centroid": { "ltv": 0.5, "engagement": 0.5, "intention": 0.5 },
|
|
2538
|
-
"action_recommendation": "[Recomendação de campanha específica para este segmento]"
|
|
2566
|
+
// 3. Gerar perfis textuais e embeddings via embeddinggemma-300m
|
|
2567
|
+
const sample = leads.slice(0, 100); // max 100 por batch
|
|
2568
|
+
const profiles = sample.map(_buildLeadProfile);
|
|
2569
|
+
|
|
2570
|
+
const embRes = await env.AI.run('@cf/baai/bge-m3', { text: profiles });
|
|
2571
|
+
const vectors = embRes.data; // float32[][] shape [N, 768]
|
|
2572
|
+
|
|
2573
|
+
if (!vectors || vectors.length < nClusters) {
|
|
2574
|
+
throw new Error(`embeddinggemma retornou ${vectors?.length ?? 0} vetores — insuficiente para ${nClusters} clusters`);
|
|
2539
2575
|
}
|
|
2540
|
-
],
|
|
2541
|
-
"silhouette_score": 0.65,
|
|
2542
|
-
"total_processed": ${sampleSize}
|
|
2543
|
-
}`;
|
|
2544
2576
|
|
|
2545
|
-
//
|
|
2546
|
-
const
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2577
|
+
// 4. K-means vetorial real (cosine distance)
|
|
2578
|
+
const { assignments } = _kmeansRun(vectors, nClusters);
|
|
2579
|
+
|
|
2580
|
+
// 5. Silhouette score real
|
|
2581
|
+
const silhouetteScore = _silhouette(vectors, assignments, nClusters);
|
|
2582
|
+
|
|
2583
|
+
// 6. Agregar estatísticas por cluster para nomear com Granite
|
|
2584
|
+
const clusterStats = Array.from({ length: nClusters }, (_, c) => {
|
|
2585
|
+
const members = sample.filter((_, i) => assignments[i] === c);
|
|
2586
|
+
if (members.length === 0) return null;
|
|
2587
|
+
const ltvMap = { High: 1, Medium: 0.5, Low: 0 };
|
|
2588
|
+
const avgLtv = members.reduce((s, l) => s + (ltvMap[l.predicted_ltv_class] ?? 0), 0) / members.length;
|
|
2589
|
+
const avgEng = members.reduce((s, l) => s + (l.engagement_score || 0), 0) / members.length;
|
|
2590
|
+
const avgDays = members.reduce((s, l) => s + (l.days_since_lead || 0), 0) / members.length;
|
|
2591
|
+
const sources = members.map(l => l.utm_source).filter(Boolean);
|
|
2592
|
+
const states = members.map(l => l.state).filter(Boolean);
|
|
2593
|
+
const topSource = sources.length ? [...sources.reduce((m, s) => m.set(s, (m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : 'direto';
|
|
2594
|
+
const topState = states.length ? [...states.reduce((m, s) => m.set(s, (m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : 'BR';
|
|
2595
|
+
const intentions = members.map(l => l.intention_level).filter(Boolean);
|
|
2596
|
+
const topIntent = intentions.length ? [...intentions.reduce((m, s) => m.set(s,(m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : 'desconhecida';
|
|
2597
|
+
return { c, size: members.length, pct: Math.round(members.length / sample.length * 100), avgLtv, avgEng, avgDays, topSource, topState, topIntent };
|
|
2598
|
+
}).filter(Boolean);
|
|
2599
|
+
|
|
2600
|
+
// 7. Usar Granite apenas para nomear e recomendar ação por cluster
|
|
2601
|
+
const namingPrompt =
|
|
2602
|
+
`Você é especialista em segmentação de clientes. Dê um nome descritivo em português e uma recomendação de campanha para cada segmento abaixo. Retorne SOMENTE JSON válido:
|
|
2603
|
+
{"segments":[{"cluster_id":0,"name":"...","action":"..."},...]}
|
|
2604
|
+
|
|
2605
|
+
Segmentos:
|
|
2606
|
+
${clusterStats.map(s => `Cluster ${s.c}: LTV médio=${s.avgLtv.toFixed(2)}, engajamento=${s.avgEng.toFixed(0)}, intenção dominante="${s.topIntent}", origem="${s.topSource}", estado="${s.topState}", recência=${s.avgDays.toFixed(0)} dias, tamanho=${s.size} leads`).join('\n')}`;
|
|
2607
|
+
|
|
2608
|
+
const nameRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', {
|
|
2609
|
+
messages: [{ role: 'user', content: namingPrompt }],
|
|
2610
|
+
max_tokens: 800,
|
|
2550
2611
|
});
|
|
2551
|
-
const duration = Date.now() - startTime;
|
|
2552
2612
|
|
|
2553
|
-
|
|
2613
|
+
let clusterNames = {};
|
|
2614
|
+
try {
|
|
2615
|
+
const m = (nameRes?.response || '').match(/\{[\s\S]*\}/);
|
|
2616
|
+
if (m) {
|
|
2617
|
+
const parsed = JSON.parse(m[0]);
|
|
2618
|
+
(parsed.segments || []).forEach(s => { clusterNames[s.cluster_id] = { name: s.name, action: s.action }; });
|
|
2619
|
+
}
|
|
2620
|
+
} catch { /* usa nomes fallback */ }
|
|
2554
2621
|
|
|
2555
|
-
|
|
2556
|
-
const jsonMatch = aiRes.response.trim().match(/\{[\s\S]*\}/);
|
|
2557
|
-
if (!jsonMatch) throw new Error('Resposta do Workers AI não contém JSON válido');
|
|
2558
|
-
const mlResult = JSON.parse(jsonMatch[0]);
|
|
2622
|
+
const duration = Date.now() - startTime;
|
|
2559
2623
|
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2624
|
+
// 8. Montar resultado final
|
|
2625
|
+
const clusters = clusterStats.map(s => ({
|
|
2626
|
+
cluster_id: s.c,
|
|
2627
|
+
name: clusterNames[s.c]?.name || `Segmento ${s.c + 1}`,
|
|
2628
|
+
size: s.size,
|
|
2629
|
+
percentage: s.pct,
|
|
2630
|
+
action_recommendation: clusterNames[s.c]?.action || '',
|
|
2631
|
+
characteristics: {
|
|
2632
|
+
avg_ltv_class: s.avgLtv,
|
|
2633
|
+
avg_engagement_score: s.avgEng,
|
|
2634
|
+
avg_intention_level: s.avgLtv,
|
|
2635
|
+
avg_days_since_lead: s.avgDays,
|
|
2636
|
+
dominant_countries: ['BR'],
|
|
2637
|
+
dominant_states: [s.topState],
|
|
2638
|
+
dominant_utm_sources: [s.topSource],
|
|
2639
|
+
top_features: ['ltv', 'engagement', 'intention'],
|
|
2640
|
+
},
|
|
2641
|
+
}));
|
|
2563
2642
|
|
|
2564
|
-
//
|
|
2643
|
+
// 9. Inativar clusters anteriores do mesmo algoritmo/vertical
|
|
2565
2644
|
await env.DB.prepare(
|
|
2566
2645
|
`UPDATE ml_segments SET is_active = 0 WHERE clustering_algorithm = ? AND client_vertical = ? AND is_active = 1`
|
|
2567
2646
|
).bind(algorithm, clientVertical).run();
|
|
2568
2647
|
|
|
2569
|
-
//
|
|
2648
|
+
// 10. Persistir novos clusters no D1
|
|
2570
2649
|
const now = new Date().toISOString();
|
|
2571
|
-
for (const cluster of
|
|
2572
|
-
const ch = cluster.characteristics
|
|
2650
|
+
for (const cluster of clusters) {
|
|
2651
|
+
const ch = cluster.characteristics;
|
|
2573
2652
|
await env.DB.prepare(`
|
|
2574
2653
|
INSERT INTO ml_segments (
|
|
2575
2654
|
cluster_id, cluster_name, clustering_algorithm, client_vertical,
|
|
@@ -2581,23 +2660,23 @@ Return ONLY valid JSON, zero explanation:
|
|
|
2581
2660
|
is_active, created_at, updated_at
|
|
2582
2661
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
|
|
2583
2662
|
`).bind(
|
|
2584
|
-
cluster.cluster_id
|
|
2585
|
-
cluster.name
|
|
2663
|
+
cluster.cluster_id,
|
|
2664
|
+
cluster.name,
|
|
2586
2665
|
algorithm,
|
|
2587
2666
|
clientVertical,
|
|
2588
|
-
cluster.size
|
|
2589
|
-
cluster.percentage
|
|
2590
|
-
ch.avg_ltv_class
|
|
2591
|
-
ch.
|
|
2592
|
-
ch.avg_engagement_score
|
|
2593
|
-
ch.avg_intention_level
|
|
2594
|
-
ch.avg_days_since_lead
|
|
2595
|
-
JSON.stringify(ch.dominant_countries
|
|
2596
|
-
JSON.stringify(ch.dominant_states
|
|
2597
|
-
JSON.stringify(ch.dominant_utm_sources
|
|
2598
|
-
JSON.stringify(ch.top_features
|
|
2599
|
-
|
|
2600
|
-
JSON.stringify([cluster.action_recommendation
|
|
2667
|
+
cluster.size,
|
|
2668
|
+
cluster.percentage,
|
|
2669
|
+
ch.avg_ltv_class,
|
|
2670
|
+
ch.avg_engagement_score,
|
|
2671
|
+
ch.avg_engagement_score,
|
|
2672
|
+
ch.avg_intention_level,
|
|
2673
|
+
ch.avg_days_since_lead,
|
|
2674
|
+
JSON.stringify(ch.dominant_countries),
|
|
2675
|
+
JSON.stringify(ch.dominant_states),
|
|
2676
|
+
JSON.stringify(ch.dominant_utm_sources),
|
|
2677
|
+
JSON.stringify(ch.top_features),
|
|
2678
|
+
silhouetteScore,
|
|
2679
|
+
JSON.stringify([cluster.action_recommendation]),
|
|
2601
2680
|
JSON.stringify([]),
|
|
2602
2681
|
JSON.stringify([]),
|
|
2603
2682
|
now,
|
|
@@ -2605,7 +2684,7 @@ Return ONLY valid JSON, zero explanation:
|
|
|
2605
2684
|
).run();
|
|
2606
2685
|
}
|
|
2607
2686
|
|
|
2608
|
-
//
|
|
2687
|
+
// 11. Log no histórico de clustering
|
|
2609
2688
|
try {
|
|
2610
2689
|
await env.DB.prepare(`
|
|
2611
2690
|
INSERT INTO ml_clustering_history (
|
|
@@ -2617,23 +2696,25 @@ Return ONLY valid JSON, zero explanation:
|
|
|
2617
2696
|
new Date(startTime).toISOString(),
|
|
2618
2697
|
algorithm,
|
|
2619
2698
|
leads.length,
|
|
2620
|
-
|
|
2699
|
+
clusters.length,
|
|
2621
2700
|
duration,
|
|
2622
2701
|
Math.ceil(duration * 0.01),
|
|
2623
|
-
JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical }),
|
|
2624
|
-
JSON.stringify({ clusters:
|
|
2702
|
+
JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical, engine: 'embeddinggemma-300m+kmeans' }),
|
|
2703
|
+
JSON.stringify({ clusters: clusters.length, silhouette: silhouetteScore }),
|
|
2625
2704
|
).run();
|
|
2626
2705
|
} catch (e) { console.error('[Segmentation] history log error:', e.message); }
|
|
2627
2706
|
|
|
2628
2707
|
return new Response(JSON.stringify({
|
|
2629
2708
|
success: true,
|
|
2630
2709
|
algorithm,
|
|
2631
|
-
|
|
2710
|
+
engine: 'embeddinggemma-300m + kmeans vetorial',
|
|
2711
|
+
n_clusters: clusters.length,
|
|
2632
2712
|
client_vertical: clientVertical,
|
|
2633
2713
|
leads_analyzed: leads.length,
|
|
2714
|
+
sample_embedded: sample.length,
|
|
2634
2715
|
duration_ms: duration,
|
|
2635
|
-
silhouette_score:
|
|
2636
|
-
clusters
|
|
2716
|
+
silhouette_score: silhouetteScore,
|
|
2717
|
+
clusters,
|
|
2637
2718
|
generated_at: now,
|
|
2638
2719
|
}), { status: 200, headers });
|
|
2639
2720
|
|
|
@@ -2794,14 +2875,6 @@ async function handleSegmentationUpdate(env, request, headers) {
|
|
|
2794
2875
|
// Heurístico puro (sem AI) — latência zero no /track
|
|
2795
2876
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2796
2877
|
|
|
2797
|
-
// Domínios de email descartáveis
|
|
2798
|
-
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
|
2799
|
-
'mailinator.com','guerrillamail.com','tempmail.com','throwaway.email',
|
|
2800
|
-
'yopmail.com','sharklasers.com','guerrillamailblock.com','spam4.me',
|
|
2801
|
-
'10minutemail.com','trashmail.com','maildrop.cc','fakeinbox.com',
|
|
2802
|
-
'dispostable.com','mailnull.com','tempr.email','getnada.com',
|
|
2803
|
-
]);
|
|
2804
|
-
|
|
2805
2878
|
// ASNs conhecidos de datacenters (evitar falsos negativos em ASNs legítimos)
|
|
2806
2879
|
const DATACENTER_PATTERNS = /amazon|google|microsoft|digitalocean|linode|ovh|vultr|hetzner|contabo|cloudflare|packet|rackspace|leaseweb/i;
|
|
2807
2880
|
|
|
@@ -2854,15 +2927,7 @@ async function checkFraudGate(env, request, payload) {
|
|
|
2854
2927
|
result.score += 20; result.reasons.push('no_accept_language');
|
|
2855
2928
|
}
|
|
2856
2929
|
|
|
2857
|
-
// 6.
|
|
2858
|
-
if (email) {
|
|
2859
|
-
const domain = email.split('@')[1]?.toLowerCase();
|
|
2860
|
-
if (domain && DISPOSABLE_EMAIL_DOMAINS.has(domain)) {
|
|
2861
|
-
result.score += 25; result.reasons.push('disposable_email');
|
|
2862
|
-
}
|
|
2863
|
-
}
|
|
2864
|
-
|
|
2865
|
-
// 7. Velocity check via KV
|
|
2930
|
+
// 6. Velocity check via KV
|
|
2866
2931
|
if (env.GEO_CACHE && ip) {
|
|
2867
2932
|
const velKey1h = `fraud_velocity:${ip}:h`;
|
|
2868
2933
|
const velStr = await env.GEO_CACHE.get(velKey1h);
|
|
@@ -3839,7 +3904,7 @@ export default {
|
|
|
3839
3904
|
|
|
3840
3905
|
// Workers AI — ping
|
|
3841
3906
|
try {
|
|
3842
|
-
await env.AI.run('@cf/
|
|
3907
|
+
await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', {
|
|
3843
3908
|
messages: [{ role: 'user', content: 'ping' }],
|
|
3844
3909
|
max_tokens: 1,
|
|
3845
3910
|
});
|
|
@@ -3910,7 +3975,9 @@ export default {
|
|
|
3910
3975
|
'PageView','ViewContent','Lead','Purchase','InitiateCheckout',
|
|
3911
3976
|
'AddToCart','CompleteRegistration','Contact','Schedule',
|
|
3912
3977
|
'StartTrial','Subscribe','SubmitApplication','Search',
|
|
3913
|
-
'video_start','video_25','video_50','video_75','video_complete'
|
|
3978
|
+
'video_start','video_25','video_50','video_75','video_complete',
|
|
3979
|
+
// Imóveis — intenção de visita física, financiamento e favoritar
|
|
3980
|
+
'FindLocation','CustomizeProduct','AddToWishlist',
|
|
3914
3981
|
]);
|
|
3915
3982
|
const STR_FIELDS = ['email','phone','firstName','lastName','city','state','zip','userId',
|
|
3916
3983
|
'utmSource','utmMedium','utmCampaign','utmContent','utmTerm',
|
|
@@ -4017,8 +4084,9 @@ export default {
|
|
|
4017
4084
|
// ── LTV Prediction (+ A/B Testing de Prompts) ────────────────────────────
|
|
4018
4085
|
// Lead, Contact, Schedule: sem valor monetário real → injetar LTV preditivo
|
|
4019
4086
|
// Isso treina os algoritmos de Meta/TikTok a priorizar leads de alto valor
|
|
4020
|
-
const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
|
|
4087
|
+
const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration', 'FindLocation', 'CustomizeProduct', 'AddToWishlist'];
|
|
4021
4088
|
if (LTV_EVENTS.includes(eventName) && !payload.value) {
|
|
4089
|
+
payload.eventType = eventName; // expõe ao predictLtv para scoring por tipo de evento
|
|
4022
4090
|
// A/B Testing: busca variação ativa (usa KV cache — ~0ms de latência extra)
|
|
4023
4091
|
const abVariation = await getLtvAbVariation(env);
|
|
4024
4092
|
const ltv = await predictLtv(env, payload, request, abVariation?.system_prompt || null);
|
|
@@ -4,6 +4,7 @@ name = "server-edge-tracker"
|
|
|
4
4
|
main = "index.js"
|
|
5
5
|
compatibility_date = "2025-01-01"
|
|
6
6
|
compatibility_flags = ["nodejs_compat"]
|
|
7
|
+
workers_dev = true
|
|
7
8
|
|
|
8
9
|
# ── Worker Routes — same-domain tracking (imune a bloqueios) ─────────────────
|
|
9
10
|
# Substituir SEU_DOMINIO pelo domínio do cliente antes do deploy
|
|
@@ -25,10 +26,10 @@ zone_name = "lancamentosabc.com.br"
|
|
|
25
26
|
|
|
26
27
|
# ── Variáveis públicas (não são segredos) ─────────────────────────────────────
|
|
27
28
|
[vars]
|
|
28
|
-
META_PIXEL_ID = "
|
|
29
|
-
GA4_MEASUREMENT_ID = "G-
|
|
30
|
-
TIKTOK_PIXEL_ID = "
|
|
31
|
-
SITE_DOMAIN = "
|
|
29
|
+
META_PIXEL_ID = "1583939052660159"
|
|
30
|
+
GA4_MEASUREMENT_ID = "G-G7VEN1MNH1"
|
|
31
|
+
TIKTOK_PIXEL_ID = "D71D6T3C77U56RM5VF0G"
|
|
32
|
+
SITE_DOMAIN = "lancamentosabc.com.br"
|
|
32
33
|
|
|
33
34
|
# ── Banco D1 ──────────────────────────────────────────────────────────────────
|
|
34
35
|
# Após criar o banco com "wrangler d1 create cdp-edge-db",
|
|
@@ -95,6 +96,22 @@ namespace_id = "1001"
|
|
|
95
96
|
limit = 60
|
|
96
97
|
period = 60
|
|
97
98
|
|
|
99
|
+
# ── Observabilidade — Logs + Traces persistidos no painel Cloudflare ─────────
|
|
100
|
+
[observability]
|
|
101
|
+
enabled = false
|
|
102
|
+
head_sampling_rate = 1
|
|
103
|
+
|
|
104
|
+
[observability.logs]
|
|
105
|
+
enabled = true
|
|
106
|
+
head_sampling_rate = 1
|
|
107
|
+
persist = true
|
|
108
|
+
invocation_logs = true
|
|
109
|
+
|
|
110
|
+
[observability.traces]
|
|
111
|
+
enabled = false
|
|
112
|
+
persist = true
|
|
113
|
+
head_sampling_rate = 1
|
|
114
|
+
|
|
98
115
|
# ── Secrets (NÃO ficam aqui — configurar via CLI) ─────────────────────────────
|
|
99
116
|
# wrangler secret put META_ACCESS_TOKEN ← token Meta CAPI (obrigatório)
|
|
100
117
|
# wrangler secret put GA4_API_SECRET ← secret GA4 Measurement Protocol (obrigatório)
|
|
@@ -107,6 +124,7 @@ period = 60
|
|
|
107
124
|
# wrangler secret put RESEND_API_KEY ← API Key do Resend (resend.com)
|
|
108
125
|
# wrangler secret put RESEND_FROM_EMAIL ← Remetente verificado ex: "CDP Edge <noreply@seudominio.com.br>"
|
|
109
126
|
# wrangler secret put WA_WEBHOOK_VERIFY_TOKEN ← Token de verificação do webhook WhatsApp (você define — qualquer string segura)
|
|
127
|
+
# wrangler secret put WEBHOOK_SECRET_TICTO ← HMAC-SHA256 Ticto
|
|
110
128
|
# wrangler secret put PINTEREST_ACCESS_TOKEN ← Bearer token Pinterest Conversions API
|
|
111
129
|
# wrangler secret put PINTEREST_AD_ACCOUNT_ID ← ID da conta de anúncios Pinterest (ex: 549755813XXX)
|
|
112
130
|
# wrangler secret put REDDIT_ACCESS_TOKEN ← Bearer token Reddit Conversions API
|