cdp-edge 2.0.2 → 2.0.4

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 (27) hide show
  1. package/README.md +325 -308
  2. package/contracts/agent-versions.json +364 -0
  3. package/dist/commands/install.js +1 -1
  4. package/dist/commands/setup.js +1 -1
  5. package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +2 -2
  6. package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +14 -20
  7. package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +4 -4
  8. package/extracted-skill/tracking-events-generator/agents/server-tracking.md +13 -13
  9. package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +1 -1
  10. package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +4 -4
  11. package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +3 -3
  12. package/package.json +81 -76
  13. package/server-edge-tracker/index.js +780 -0
  14. package/server-edge-tracker/modules/db.js +531 -0
  15. package/server-edge-tracker/modules/dispatch/ga4.js +65 -0
  16. package/server-edge-tracker/modules/dispatch/meta.js +103 -0
  17. package/server-edge-tracker/modules/dispatch/platforms.js +237 -0
  18. package/server-edge-tracker/modules/dispatch/tiktok.js +100 -0
  19. package/server-edge-tracker/modules/dispatch/whatsapp.js +233 -0
  20. package/server-edge-tracker/modules/intelligence.js +204 -0
  21. package/server-edge-tracker/modules/ml/bidding.js +245 -0
  22. package/server-edge-tracker/modules/ml/fraud.js +301 -0
  23. package/server-edge-tracker/modules/ml/ltv.js +320 -0
  24. package/server-edge-tracker/modules/ml/segmentation.js +316 -0
  25. package/server-edge-tracker/modules/utils.js +89 -0
  26. package/server-edge-tracker/schema-indexes.sql +67 -0
  27. package/server-edge-tracker/wrangler.toml +2 -0
@@ -0,0 +1,301 @@
1
+ /**
2
+ * CDP Edge — Fraud Detection (Fase 4)
3
+ * checkFraudGate, logFraudSignal, handlers das rotas /api/fraud/*
4
+ */
5
+
6
+ import { sha256, tryParseJson } from '../utils.js';
7
+
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
+ export const DATACENTER_PATTERNS = /amazon|google|microsoft|digitalocean|linode|ovh|vultr|hetzner|contabo|cloudflare|packet|rackspace|leaseweb/i;
17
+
18
+ // ── checkFraudGate — roda ANTES de qualquer processamento de evento ────────────
19
+ // Retorna { allowed, score, reasons, action }
20
+ // Falhas no gate = fail-safe (deixa passar)
21
+ export async function checkFraudGate(env, request, payload) {
22
+ const result = { allowed: true, score: 0, reasons: [], action: 'allowed' };
23
+
24
+ try {
25
+ const ip = request.headers.get('CF-Connecting-IP') || '';
26
+ const ua = request.headers.get('User-Agent') || '';
27
+ const fingerprint = payload.fingerprint || '';
28
+ const email = payload.email || '';
29
+ const botScore = parseInt(payload.botScore || payload.bot_score || 0);
30
+ const asn = String(request.cf?.asOrganization || '').toLowerCase();
31
+ const country = (request.cf?.country || '').toUpperCase();
32
+ const acceptLang = request.headers.get('Accept-Language');
33
+
34
+ // 1. KV blocklist check — instantâneo (~0ms)
35
+ if (env.GEO_CACHE && ip) {
36
+ const ipBlocked = await env.GEO_CACHE.get(`fraud_block:ip:${ip}`);
37
+ if (ipBlocked) {
38
+ return { allowed: false, score: 100, reasons: ['ip_blocklisted'], action: 'dropped' };
39
+ }
40
+ }
41
+ if (env.GEO_CACHE && fingerprint) {
42
+ const fpBlocked = await env.GEO_CACHE.get(`fraud_block:fp:${fingerprint}`);
43
+ if (fpBlocked) {
44
+ return { allowed: false, score: 100, reasons: ['fingerprint_blocklisted'], action: 'dropped' };
45
+ }
46
+ }
47
+
48
+ // 2. Bot score
49
+ if (botScore >= 3) { result.score += 60; result.reasons.push('bot_score_high'); }
50
+ else if (botScore === 2) { result.score += 30; result.reasons.push('bot_score_medium'); }
51
+
52
+ // 3. User-Agent suspeito
53
+ if (/headless|phantomjs|selenium|webdriver|curl|python|scrapy|bot|crawler|spider/i.test(ua)) {
54
+ result.score += 40; result.reasons.push('suspicious_user_agent');
55
+ }
56
+
57
+ // 4. Datacenter IP
58
+ if (ip && DATACENTER_PATTERNS.test(asn)) {
59
+ result.score += 35; result.reasons.push('datacenter_ip');
60
+ }
61
+
62
+ // 5. Sem Accept-Language
63
+ if (!acceptLang) {
64
+ result.score += 20; result.reasons.push('no_accept_language');
65
+ }
66
+
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
76
+ if (env.GEO_CACHE && ip) {
77
+ const velKey1h = `fraud_velocity:${ip}:h`;
78
+ const velStr = await env.GEO_CACHE.get(velKey1h);
79
+ const vel1h = parseInt(velStr || '0') + 1;
80
+
81
+ await env.GEO_CACHE.put(velKey1h, String(vel1h), { expirationTtl: 3600 });
82
+
83
+ if (vel1h > 20) { result.score += 50; result.reasons.push('ip_velocity_very_high'); }
84
+ else if (vel1h > 10) { result.score += 25; result.reasons.push('ip_velocity_high'); }
85
+ }
86
+
87
+ result.score = Math.min(100, result.score);
88
+
89
+ // 8. Decisão final
90
+ if (result.score >= 80) {
91
+ result.allowed = false;
92
+ result.action = 'dropped';
93
+ } else if (result.score >= 40) {
94
+ result.action = 'flagged';
95
+ }
96
+
97
+ return result;
98
+
99
+ } catch (err) {
100
+ console.error('[Fraud] checkFraudGate error:', err.message);
101
+ return { allowed: true, score: 0, reasons: ['gate_error_fallback'], action: 'allowed' };
102
+ }
103
+ }
104
+
105
+ // ── logFraudSignal — persiste no D1 em background ────────────────────────────
106
+ export async function logFraudSignal(env, request, payload, fraudResult) {
107
+ if (!env.DB || fraudResult.action === 'allowed') return;
108
+ try {
109
+ const ip = request.headers.get('CF-Connecting-IP') || '';
110
+ const ua = request.headers.get('User-Agent') || '';
111
+ const fingerprint = payload.fingerprint || '';
112
+ const botScore = parseInt(payload.botScore || payload.bot_score || 0);
113
+ const asn = String(request.cf?.asOrganization || '');
114
+ const country = (request.cf?.country || '');
115
+ const velKey1h = `fraud_velocity:${ip}:h`;
116
+ const vel1h = env.GEO_CACHE ? parseInt(await env.GEO_CACHE.get(velKey1h) || '0') : 0;
117
+
118
+ let emailHash = null;
119
+ if (payload.email) {
120
+ try { emailHash = await sha256(payload.email.trim().toLowerCase()); } catch {}
121
+ }
122
+
123
+ await env.DB.prepare(`
124
+ INSERT INTO fraud_signals (
125
+ ip_address, fingerprint, user_id, email_hash, event_name, event_id,
126
+ fraud_score, action_taken, reasons,
127
+ ip_country, ip_asn, user_agent, bot_score, velocity_1h, detected_at
128
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
129
+ `).bind(
130
+ ip, fingerprint || null, payload.userId || null, emailHash,
131
+ payload.eventName || null, payload.eventId || null,
132
+ fraudResult.score, fraudResult.action, JSON.stringify(fraudResult.reasons),
133
+ country, asn, ua.substring(0, 255), botScore, vel1h,
134
+ ).run();
135
+
136
+ if (fraudResult.action === 'dropped' && ip) {
137
+ await env.DB.prepare(`
138
+ INSERT INTO fraud_alerts (alert_type, entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, top_reasons)
139
+ VALUES ('ip_attack', 'ip', ?, 1, 1, ?, datetime('now'), datetime('now'), ?)
140
+ ON CONFLICT(entity_type, entity_value) DO UPDATE SET
141
+ events_total = events_total + 1,
142
+ events_dropped = events_dropped + 1,
143
+ peak_score = MAX(peak_score, excluded.peak_score),
144
+ last_seen = datetime('now'),
145
+ updated_at = datetime('now')
146
+ `).bind(ip, fraudResult.score, JSON.stringify(fraudResult.reasons)).run().catch(() => {});
147
+ }
148
+ } catch (err) {
149
+ console.error('[Fraud] logFraudSignal error:', err.message);
150
+ }
151
+ }
152
+
153
+ // ── GET /api/fraud/alerts ─────────────────────────────────────────────────────
154
+ export async function handleFraudAlerts(env, request, headers) {
155
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
156
+
157
+ const url = new URL(request.url);
158
+ const action = url.searchParams.get('action') || null;
159
+ const hours = parseInt(url.searchParams.get('hours') || '24');
160
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
161
+
162
+ try {
163
+ const cond = action ? 'AND action_taken = ?' : '';
164
+ const bindings = action ? [hours, action, limit] : [hours, limit];
165
+
166
+ const result = await env.DB.prepare(`
167
+ SELECT ip_address, fingerprint, event_name, fraud_score, action_taken,
168
+ reasons, ip_country, ip_asn, bot_score, velocity_1h, detected_at
169
+ FROM fraud_signals
170
+ WHERE detected_at >= datetime('now', '-' || ? || ' hours')
171
+ ${cond}
172
+ ORDER BY fraud_score DESC, detected_at DESC
173
+ LIMIT ?
174
+ `).bind(...bindings).all();
175
+
176
+ const signals = (result.results || []).map(s => ({ ...s, reasons: tryParseJson(s.reasons, []) }));
177
+ const stats = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first().catch(() => null);
178
+
179
+ return new Response(JSON.stringify({ success: true, period_hours: hours, total: signals.length, stats, alerts: signals }), { status: 200, headers });
180
+ } catch (err) {
181
+ console.error('[Fraud] alerts error:', err.message);
182
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
183
+ }
184
+ }
185
+
186
+ // ── GET /api/fraud/blocklist ──────────────────────────────────────────────────
187
+ export async function handleFraudBlocklist(env, request, headers) {
188
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
189
+
190
+ try {
191
+ const result = await env.DB.prepare(`
192
+ SELECT entity_type, entity_value, events_total, events_dropped,
193
+ peak_score, first_seen, last_seen, blocked_at, block_expires, top_reasons
194
+ FROM fraud_alerts WHERE is_blocked = 1 ORDER BY events_dropped DESC LIMIT 100
195
+ `).all();
196
+
197
+ const blocklist = (result.results || []).map(r => ({ ...r, top_reasons: tryParseJson(r.top_reasons, []) }));
198
+ return new Response(JSON.stringify({ success: true, total: blocklist.length, blocklist }), { status: 200, headers });
199
+ } catch (err) {
200
+ console.error('[Fraud] blocklist error:', err.message);
201
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
202
+ }
203
+ }
204
+
205
+ // ── POST /api/fraud/blocklist/add ─────────────────────────────────────────────
206
+ export async function handleFraudBlocklistAdd(env, request, headers) {
207
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
208
+
209
+ let body;
210
+ try { body = await request.json(); }
211
+ catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
212
+
213
+ const { entity_type, entity_value, ttl_hours = 24, reason = 'manual_block' } = body;
214
+ if (!entity_type || !entity_value) {
215
+ return new Response(JSON.stringify({ error: 'entity_type (ip|fingerprint) e entity_value são obrigatórios' }), { status: 400, headers });
216
+ }
217
+ if (!['ip', 'fingerprint'].includes(entity_type)) {
218
+ return new Response(JSON.stringify({ error: 'entity_type deve ser: ip ou fingerprint' }), { status: 400, headers });
219
+ }
220
+
221
+ try {
222
+ const kvKey = `fraud_block:${entity_type}:${entity_value}`;
223
+ const ttlSec = Math.min(ttl_hours * 3600, 7 * 24 * 3600);
224
+ const expiresAt = new Date(Date.now() + ttlSec * 1000).toISOString();
225
+
226
+ if (env.GEO_CACHE) {
227
+ await env.GEO_CACHE.put(kvKey, JSON.stringify({ reason, blocked_at: new Date().toISOString() }), { expirationTtl: ttlSec });
228
+ }
229
+
230
+ await env.DB.prepare(`
231
+ INSERT INTO fraud_alerts (alert_type, entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, is_blocked, blocked_at, block_expires, top_reasons)
232
+ VALUES ('manual', ?, ?, 0, 0, 100, datetime('now'), datetime('now'), 1, datetime('now'), ?, ?)
233
+ ON CONFLICT DO UPDATE SET is_blocked = 1, blocked_at = datetime('now'), block_expires = excluded.block_expires, updated_at = datetime('now')
234
+ `).bind(entity_type, entity_value, expiresAt, JSON.stringify([reason])).run().catch(() => {});
235
+
236
+ return new Response(JSON.stringify({
237
+ success: true, entity_type, entity_value, kv_key: kvKey, ttl_hours, expires_at: expiresAt,
238
+ message: `${entity_type} '${entity_value}' bloqueado por ${ttl_hours}h. Efeito imediato via KV.`,
239
+ }), { status: 200, headers });
240
+ } catch (err) {
241
+ console.error('[Fraud] blocklist add error:', err.message);
242
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
243
+ }
244
+ }
245
+
246
+ // ── DELETE /api/fraud/blocklist/remove ───────────────────────────────────────
247
+ export async function handleFraudBlocklistRemove(env, request, headers) {
248
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
249
+
250
+ let body;
251
+ try { body = await request.json(); }
252
+ catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
253
+
254
+ const { entity_type, entity_value } = body;
255
+ if (!entity_type || !entity_value) {
256
+ return new Response(JSON.stringify({ error: 'entity_type e entity_value são obrigatórios' }), { status: 400, headers });
257
+ }
258
+
259
+ try {
260
+ const kvKey = `fraud_block:${entity_type}:${entity_value}`;
261
+ if (env.GEO_CACHE) await env.GEO_CACHE.delete(kvKey);
262
+ await env.DB.prepare(`UPDATE fraud_alerts SET is_blocked = 0, resolved_at = datetime('now'), resolved_by = 'manual' WHERE entity_type = ? AND entity_value = ?`).bind(entity_type, entity_value).run();
263
+
264
+ return new Response(JSON.stringify({
265
+ success: true, entity_type, entity_value,
266
+ message: `${entity_type} '${entity_value}' removido do blocklist. Efeito imediato via KV.`,
267
+ }), { status: 200, headers });
268
+ } catch (err) {
269
+ console.error('[Fraud] blocklist remove error:', err.message);
270
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
271
+ }
272
+ }
273
+
274
+ // ── GET /api/fraud/stats ──────────────────────────────────────────────────────
275
+ export async function handleFraudStats(env, request, headers) {
276
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
277
+
278
+ try {
279
+ const dashboard = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first();
280
+ const topIps = await env.DB.prepare(`
281
+ SELECT ip_address, COUNT(*) as events, MAX(fraud_score) as peak_score
282
+ FROM fraud_signals
283
+ WHERE detected_at >= datetime('now', '-24 hours') AND action_taken = 'dropped'
284
+ GROUP BY ip_address ORDER BY events DESC LIMIT 10
285
+ `).all();
286
+ const topReasons = await env.DB.prepare(`
287
+ SELECT action_taken, COUNT(*) as count FROM fraud_signals
288
+ WHERE detected_at >= datetime('now', '-24 hours')
289
+ GROUP BY action_taken
290
+ `).all();
291
+
292
+ return new Response(JSON.stringify({
293
+ success: true, period: '24h', dashboard,
294
+ top_attacking_ips: topIps.results || [],
295
+ by_action: topReasons.results || [],
296
+ }), { status: 200, headers });
297
+ } catch (err) {
298
+ console.error('[Fraud] stats error:', err.message);
299
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
300
+ }
301
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * CDP Edge — LTV Prediction + A/B Testing de Prompts (Fases 3 e 4)
3
+ * predictLtv, getLtvAbVariation, recordAbAssignment, handlers /api/ltv/*
4
+ */
5
+
6
+ // Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
7
+ const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
8
+
9
+ // ── predictLtv — Heurística em 5 dimensões (0-100 pts) ───────────────────────
10
+ export async function predictLtv(env, payload, request, customSystemPrompt = null) {
11
+ let score = 0;
12
+
13
+ // 1. Engajamento browser (0–30)
14
+ const engScore = parseFloat(payload.engagementScore || 0);
15
+ const userScore = parseFloat(payload.userScore || 0);
16
+ score += Math.min(15, Math.round((engScore / 5) * 15));
17
+ score += Math.min(15, Math.round((userScore / 100) * 15));
18
+
19
+ // 2. Origem de tráfego (0–25)
20
+ const src = (payload.utmSource || '').toLowerCase();
21
+ const utm_score_map = {
22
+ facebook: 25, instagram: 25, meta: 25,
23
+ google: 22, youtube: 22, tiktok: 20,
24
+ email: 18, sms: 18,
25
+ organic: 10, direct: 5,
26
+ };
27
+ score += utm_score_map[src] ?? (src ? 8 : 3);
28
+
29
+ // 3. Contexto de rede (0–15)
30
+ const hour = new Date().getUTCHours();
31
+ const country = (payload.country || request.cf?.country || '').toUpperCase();
32
+ const org = String(request.cf?.asOrganization || '').toLowerCase();
33
+
34
+ const isHighConvTime = hour >= 21 || hour <= 2;
35
+ score += isHighConvTime ? 8 : (hour >= 12 && hour <= 20 ? 4 : 1);
36
+
37
+ const latam = ['AR', 'CL', 'CO', 'MX', 'PE', 'UY', 'PY', 'BO'];
38
+ score += country === 'BR' ? 5 : (latam.includes(country) ? 3 : 1);
39
+
40
+ const isCorp = /ltda|s\.a\.|corp|telecom|fibra|claro|vivo|tim|oi/.test(org);
41
+ score += isCorp ? 2 : 0;
42
+
43
+ // 4. Contexto do evento (0–20)
44
+ const intentionLevel = (payload.intentionLevel || '').toLowerCase();
45
+ if (intentionLevel === 'comprador' || intentionLevel === 'high_intent') score += 20;
46
+ else if (intentionLevel === 'interessado') score += 12;
47
+ else if (intentionLevel === 'nurture') score += 6;
48
+
49
+ // 5. Dados PII disponíveis (0–10)
50
+ if (payload.email) score += 4;
51
+ if (payload.phone) score += 4;
52
+ if (payload.firstName) score += 2;
53
+
54
+ score = Math.min(100, score);
55
+
56
+ let ltvClass, ltvMultiplier;
57
+ if (score >= 70) {
58
+ ltvClass = 'High'; ltvMultiplier = 3.5;
59
+ } else if (score >= 40) {
60
+ ltvClass = 'Medium'; ltvMultiplier = 1.8;
61
+ } else {
62
+ ltvClass = 'Low'; ltvMultiplier = 0.8;
63
+ }
64
+
65
+ const productValue = payload.value ? parseFloat(payload.value) : 0;
66
+ const baseValue = productValue > 0 ? productValue : 197;
67
+ const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100;
68
+
69
+ // Enriquecimento opcional via Workers AI
70
+ let aiAdjustment = 0;
71
+ if (env.AI && score >= 40) {
72
+ try {
73
+ const systemContent = customSystemPrompt ||
74
+ '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.';
75
+ const prompt = [
76
+ { role: 'system', content: systemContent },
77
+ { role: 'user', content: JSON.stringify({
78
+ utm_source: payload.utmSource,
79
+ intention: intentionLevel,
80
+ engagement: engScore,
81
+ hour_utc: hour,
82
+ country,
83
+ has_email: !!payload.email,
84
+ has_phone: !!payload.phone,
85
+ })},
86
+ ];
87
+ const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: prompt, max_tokens: 32 });
88
+ const parsed = JSON.parse(aiRes.response.trim());
89
+ if (typeof parsed.adjustment === 'number') {
90
+ aiAdjustment = Math.max(-10, Math.min(10, parsed.adjustment));
91
+ }
92
+ } catch { /* graceful fallback */ }
93
+ }
94
+
95
+ return {
96
+ score: Math.min(100, Math.max(0, score + aiAdjustment)),
97
+ class: ltvClass,
98
+ value: predictedValue,
99
+ };
100
+ }
101
+
102
+ // ── getLtvAbVariation — busca variação ativa do A/B test ─────────────────────
103
+ export async function getLtvAbVariation(env) {
104
+ if (!env.DB) return null;
105
+
106
+ try {
107
+ let testData = null;
108
+ if (env.GEO_CACHE) {
109
+ const cached = await env.GEO_CACHE.get(AB_LTV_CACHE_KEY, 'json');
110
+ if (cached) testData = cached;
111
+ }
112
+
113
+ if (!testData) {
114
+ const test = await env.DB.prepare(`
115
+ SELECT t.id AS test_id, v.id AS variation_id,
116
+ v.name, v.system_prompt, v.weight, v.is_control
117
+ FROM ltv_ab_tests t
118
+ JOIN ltv_ab_variations v ON v.test_id = t.id
119
+ WHERE t.status = 'running'
120
+ ORDER BY t.id DESC
121
+ `).all();
122
+
123
+ if (!test.results || test.results.length === 0) {
124
+ if (env.GEO_CACHE) await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(null), { expirationTtl: 300 });
125
+ return null;
126
+ }
127
+
128
+ testData = test.results;
129
+ if (env.GEO_CACHE) await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(testData), { expirationTtl: 300 });
130
+ }
131
+
132
+ if (!testData || testData.length === 0) return null;
133
+
134
+ const totalWeight = testData.reduce((s, v) => s + (v.weight || 0.5), 0);
135
+ let rand = Math.random() * totalWeight;
136
+ for (const variation of testData) {
137
+ rand -= (variation.weight || 0.5);
138
+ if (rand <= 0) return variation;
139
+ }
140
+ return testData[testData.length - 1];
141
+
142
+ } catch (err) {
143
+ console.error('[AB-LTV] getLtvAbVariation error:', err.message);
144
+ return null;
145
+ }
146
+ }
147
+
148
+ // ── recordAbAssignment — registra variação usada para um lead ─────────────────
149
+ export async function recordAbAssignment(env, userId, variationId, testId, predictedLtv, predictedClass, emailHash) {
150
+ if (!env.DB) return;
151
+ try {
152
+ await env.DB.prepare(`
153
+ INSERT INTO ltv_ab_assignments (test_id, variation_id, user_id, email_hash, predicted_ltv, predicted_class, assigned_at)
154
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
155
+ `).bind(testId, variationId, userId, emailHash || null, predictedLtv || null, predictedClass || null).run();
156
+
157
+ await env.DB.prepare(`UPDATE ltv_ab_variations SET total_assigned = total_assigned + 1 WHERE id = ?`).bind(variationId).run();
158
+ } catch (err) {
159
+ console.error('[AB-LTV] recordAbAssignment error:', err.message);
160
+ }
161
+ }
162
+
163
+ // ── POST /api/ltv/ab-test/create ─────────────────────────────────────────────
164
+ export async function handleLtvAbTestCreate(env, request, headers) {
165
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
166
+
167
+ let body;
168
+ try { body = await request.json(); }
169
+ catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
170
+
171
+ const { name, description, min_sample = 100, variations } = body;
172
+ if (!name) return new Response(JSON.stringify({ error: 'name é obrigatório' }), { status: 400, headers });
173
+ if (!Array.isArray(variations) || variations.length < 2) {
174
+ return new Response(JSON.stringify({ error: 'Mínimo 2 variações são necessárias' }), { status: 400, headers });
175
+ }
176
+
177
+ const running = await env.DB.prepare(`SELECT id FROM ltv_ab_tests WHERE status = 'running' LIMIT 1`).first();
178
+ if (running) {
179
+ return new Response(JSON.stringify({ error: 'Já existe um teste em andamento.', running_test_id: running.id }), { status: 409, headers });
180
+ }
181
+
182
+ try {
183
+ const now = new Date().toISOString();
184
+ const totalWeight = variations.reduce((s, v) => s + (v.weight || 0.5), 0);
185
+ if (Math.abs(totalWeight - 1.0) > 0.05) {
186
+ return new Response(JSON.stringify({ error: `A soma dos weights deve ser 1.0. Recebido: ${totalWeight.toFixed(3)}` }), { status: 400, headers });
187
+ }
188
+ if (!variations.some(v => v.is_control)) {
189
+ return new Response(JSON.stringify({ error: 'Pelo menos uma variação deve ter is_control: true' }), { status: 400, headers });
190
+ }
191
+
192
+ const testRes = await env.DB.prepare(`
193
+ INSERT INTO ltv_ab_tests (name, description, status, min_sample, created_at) VALUES (?, ?, 'running', ?, ?)
194
+ `).bind(name, description || null, min_sample, now).run();
195
+
196
+ const testId = testRes.meta?.last_row_id;
197
+ if (!testId) throw new Error('Falha ao criar o teste no D1');
198
+
199
+ const createdVariations = [];
200
+ for (const v of variations) {
201
+ const vRes = await env.DB.prepare(`
202
+ INSERT INTO ltv_ab_variations (test_id, name, system_prompt, weight, is_control, created_at) VALUES (?, ?, ?, ?, ?, ?)
203
+ `).bind(testId, v.name, v.system_prompt, v.weight || 0.5, v.is_control ? 1 : 0, now).run();
204
+ createdVariations.push({ id: vRes.meta?.last_row_id, name: v.name, weight: v.weight, is_control: !!v.is_control });
205
+ }
206
+
207
+ if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
208
+
209
+ return new Response(JSON.stringify({ success: true, test_id: testId, name, status: 'running', min_sample, variations: createdVariations, started_at: now }), { status: 201, headers });
210
+ } catch (err) {
211
+ console.error('[AB-LTV] create error:', err.message);
212
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
213
+ }
214
+ }
215
+
216
+ // ── GET /api/ltv/ab-test/list ─────────────────────────────────────────────────
217
+ export async function handleLtvAbTestList(env, request, headers) {
218
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
219
+
220
+ const url = new URL(request.url);
221
+ const status = url.searchParams.get('status') || null;
222
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
223
+
224
+ try {
225
+ const cond = status ? 'WHERE t.status = ?' : '';
226
+ const bindings = status ? [status, limit] : [limit];
227
+
228
+ const tests = await env.DB.prepare(`
229
+ SELECT t.id, t.name, t.description, t.status, t.winner_id,
230
+ t.started_at, t.completed_at, t.created_at, t.min_sample,
231
+ COUNT(DISTINCT v.id) AS variation_count,
232
+ SUM(v.total_assigned) AS total_assigned
233
+ FROM ltv_ab_tests t
234
+ LEFT JOIN ltv_ab_variations v ON v.test_id = t.id
235
+ ${cond}
236
+ GROUP BY t.id
237
+ ORDER BY t.created_at DESC
238
+ LIMIT ?
239
+ `).bind(...bindings).all();
240
+
241
+ return new Response(JSON.stringify({ success: true, total: (tests.results || []).length, tests: tests.results || [] }), { status: 200, headers });
242
+ } catch (err) {
243
+ console.error('[AB-LTV] list error:', err.message);
244
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
245
+ }
246
+ }
247
+
248
+ // ── GET /api/ltv/ab-test/results ─────────────────────────────────────────────
249
+ export async function handleLtvAbTestResults(env, request, headers) {
250
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
251
+
252
+ const url = new URL(request.url);
253
+ const testId = url.searchParams.get('test_id') || null;
254
+
255
+ try {
256
+ const cond = testId ? 'WHERE test_id = ?' : 'WHERE status = \'running\'';
257
+ const testBind = testId ? [parseInt(testId)] : [];
258
+
259
+ const testRes = await env.DB.prepare(`SELECT id, name, status, min_sample, winner_id, started_at FROM ltv_ab_tests ${cond} LIMIT 1`).bind(...testBind).first();
260
+ if (!testRes) return new Response(JSON.stringify({ error: 'Nenhum teste encontrado' }), { status: 404, headers });
261
+
262
+ const perf = await env.DB.prepare(`SELECT * FROM v_ab_test_performance WHERE test_id = ?`).bind(testRes.id).all();
263
+ const variations = perf.results || [];
264
+ const ready = variations.every(v => (v.total_assigned || 0) >= testRes.min_sample);
265
+ let recommendation = null;
266
+
267
+ if (ready && variations.length > 0) {
268
+ const best = variations.reduce((a, b) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a);
269
+ const control = variations.find(v => v.is_control);
270
+ const improvement = control ? ((best.accuracy_score || 0) - (control.accuracy_score || 0)) * 100 : null;
271
+ recommendation = {
272
+ winner_variation_id: best.variation_id, winner_variation_name: best.variation_name,
273
+ accuracy_score: best.accuracy_score, improvement_vs_control: improvement ? `+${improvement.toFixed(1)}%` : null,
274
+ ready_to_declare: true,
275
+ };
276
+ }
277
+
278
+ return new Response(JSON.stringify({
279
+ success: true,
280
+ test: { id: testRes.id, name: testRes.name, status: testRes.status, min_sample: testRes.min_sample, started_at: testRes.started_at, is_ready: ready },
281
+ variations, recommendation,
282
+ }), { status: 200, headers });
283
+ } catch (err) {
284
+ console.error('[AB-LTV] results error:', err.message);
285
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
286
+ }
287
+ }
288
+
289
+ // ── POST /api/ltv/ab-test/winner ──────────────────────────────────────────────
290
+ export async function handleLtvAbTestWinner(env, request, headers) {
291
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
292
+
293
+ let body;
294
+ try { body = await request.json(); }
295
+ catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
296
+
297
+ const { test_id, variation_id } = body;
298
+ if (!test_id || !variation_id) {
299
+ return new Response(JSON.stringify({ error: 'test_id e variation_id são obrigatórios' }), { status: 400, headers });
300
+ }
301
+
302
+ try {
303
+ const variation = await env.DB.prepare(`SELECT id, name, system_prompt, is_control FROM ltv_ab_variations WHERE id = ? AND test_id = ?`).bind(variation_id, test_id).first();
304
+ if (!variation) return new Response(JSON.stringify({ error: 'Variação não encontrada para este teste' }), { status: 404, headers });
305
+
306
+ await env.DB.prepare(`UPDATE ltv_ab_tests SET winner_id = ?, status = 'completed', completed_at = datetime('now') WHERE id = ?`).bind(variation_id, test_id).run();
307
+ if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
308
+
309
+ return new Response(JSON.stringify({
310
+ success: true, test_id, winner_variation_id: variation_id, winner_name: variation.name,
311
+ is_control: variation.is_control === 1, winning_prompt: variation.system_prompt,
312
+ message: variation.is_control === 1
313
+ ? 'O prompt original (controle) venceu. Nenhuma alteração necessária.'
314
+ : 'Novo prompt vencedor identificado. Copie o campo winning_prompt e aplique ao predictLtv() como novo default.',
315
+ }), { status: 200, headers });
316
+ } catch (err) {
317
+ console.error('[AB-LTV] winner error:', err.message);
318
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
319
+ }
320
+ }