/** * CDP Edge — LTV Prediction + A/B Testing de Prompts (Fases 3 e 4) * predictLtv, getLtvAbVariation, recordAbAssignment, handlers /api/ltv/* */ import { extractFeatures, predictWithWeights, loadActiveWeights } from './logistic.js'; import { Env, TrackPayload } from '../../types.js'; // ── Tipos ─────────────────────────────────────────────────────────────────────── export interface LtvResult { score: number; class: string; value: number; source?: string; } export interface AbVariation { id: number; test_id: number; name: string; system_prompt: string; weight: number; is_control: number; total_assigned?: number; accuracy_score?: number; } export interface AbTestCreate { name: string; description?: string; min_sample?: number; variations: Array<{ name: string; system_prompt?: string; weight?: number; is_control?: boolean; }>; } export interface AutoDecideResult { decided: boolean; reason?: string; test_id?: number; test_name?: string; winner_id?: number; winner_name?: string; improvement?: string; is_control_winner?: boolean; winning_prompt?: string; } // Cache key para o teste ativo (KV — evita hit no D1 a cada request /track) const AB_LTV_CACHE_KEY = 'ab_ltv_active_test'; // ── Prompt especializado para imóveis ─────────────────────────────────────── // Ativado automaticamente quando property_lat/lng estão presentes no payload. // Override por A/B test tem prioridade sobre este prompt. const REAL_ESTATE_PROMPT = `You are a real estate lead scoring expert for the Brazilian market. Reply ONLY with a JSON object {"adjustment": } based on the lead data. Scoring rules (apply additively): - distance_km < 5: +12 (lives nearby, buys fast) - distance_km 5-15: +8 - distance_km 15-30: +3 - distance_km > 30: 0 - distance_km unknown: +3 (gave intent signal without geo) - event = Schedule or route click: +5 (physical visit intent) - scroll_score >= 3 AND time_level = comprador: +4 (deep engagement) - hour_brt between 18-22 (weekday): +3 (active decision window) - has_phone = true: +2 (reachable for follow-up) No explanation. JSON only.`; // ── predictLtv — Heurística em 5 dimensões (0-100 pts) ─────────────────────── export async function predictLtv(env: Env, payload: TrackPayload, request: Request | null, customSystemPrompt: string | null = null): Promise { // ── Tentar modelo treinado (regressão logística real) ───────────────────── // Se existir modelo ativo no KV/D1, usa-o em vez da heurística manual. // Fallback automático para heurística se modelo não disponível. try { const model = await loadActiveWeights(env); if (model?.weights?.length) { const hour = new Date().getUTCHours(); const country = (payload.country || (request as any)?.cf?.country || '').toUpperCase(); const features = extractFeatures({ utm_source: payload.utmSource, engagement_score: parseFloat(String(payload.engagementScore || '0')), intention_level: payload.intentionLevel, days_since_lead: 0, // evento atual = recência máxima has_email: !!payload.email, has_phone: !!payload.phone, is_br: country === 'BR', hour, }); const score100 = predictWithWeights(model, features); const ltvClass = score100 >= 70 ? 'High' : score100 >= 40 ? 'Medium' : 'Low'; const ltvMultiplier = score100 >= 70 ? 3.5 : score100 >= 40 ? 1.8 : 0.8; const productValue = payload.value ? parseFloat(String(payload.value)) : 0; const baseValue = productValue > 0 ? productValue : 197; const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100; return { score: score100, class: ltvClass, value: predictedValue, source: 'model' }; } } catch { /* fallback para heurística */ } let score = 0; // 1. Engajamento browser (0–30) const engScore = parseFloat(String(payload.engagementScore || '0')); const userScore = parseFloat(String((payload as any).userScore || '0')); score += Math.min(15, Math.round((engScore / 5) * 15)); score += Math.min(15, Math.round((userScore / 100) * 15)); // 2. Origem de tráfego (0–25) const src = (payload.utmSource || '').toLowerCase(); const utm_score_map: Record = { facebook: 25, instagram: 25, meta: 25, google: 22, youtube: 22, tiktok: 20, email: 18, sms: 18, organic: 10, direct: 5, }; score += utm_score_map[src] ?? (src ? 8 : 3); // 3. Contexto de rede (0–15) const hour = new Date().getUTCHours(); const country = (payload.country || (request as any)?.cf?.country || '').toUpperCase(); const org = String((request as any).cf?.asOrganization || '').toLowerCase(); const isHighConvTime = hour >= 21 || hour <= 2; score += isHighConvTime ? 8 : (hour >= 12 && hour <= 20 ? 4 : 1); const latam = ['AR', 'CL', 'CO', 'MX', 'PE', 'UY', 'PY', 'BO']; score += country === 'BR' ? 5 : (latam.includes(country) ? 3 : 1); const isCorp = /ltda|s\.a\.|corp|telecom|fibra|claro|vivo|tim|oi/.test(org); score += isCorp ? 2 : 0; // 4. Contexto do evento (0–20) const intentionLevel = (payload.intentionLevel || '').toLowerCase(); if (intentionLevel === 'comprador' || intentionLevel === 'high_intent') score += 20; else if (intentionLevel === 'interessado') score += 12; else if (intentionLevel === 'nurture') score += 6; // 5. Dados PII disponíveis (0–10) if (payload.email) score += 4; if (payload.phone) score += 4; if (payload.firstName) score += 2; // 5b. Tipo de evento imobiliário (0–15) — sinais de intenção de compra física const evType = ((payload as any).eventType || '').toLowerCase(); if (evType === 'customizeproduct') score += 15; // simulação de financiamento → intenção máxima else if (evType === 'findlocation') score += 10; // viu mapa/localização → visita física iminente else if (evType === 'addtowishlist') score += 8; // favoritou → interesse persistente // 6. Proximidade ao imóvel físico (0–15) — apenas quando distância calculada const distKm = parseFloat(String(payload.distanceKm || (payload as any).user_distance_km || '-1')); if (distKm >= 0) { if (distKm < 5) score += 15; else if (distKm < 15) score += 10; else if (distKm < 30) score += 6; else if (distKm < 60) score += 3; // > 60km: sem bônus — lead distante precisa de argumento diferente } else if (payload.property_lat || (payload as any).propertyLat) { // Coords no payload mas distância não resolvida: pequeno bônus por intenção de rota score += 3; } score = Math.min(100, score); let ltvClass: string; let ltvMultiplier: number; if (score >= 70) { ltvClass = 'High'; ltvMultiplier = 3.5; } else if (score >= 40) { ltvClass = 'Medium'; ltvMultiplier = 1.8; } else { ltvClass = 'Low'; ltvMultiplier = 0.8; } const productValue = payload.value ? parseFloat(String(payload.value)) : 0; const baseValue = productValue > 0 ? productValue : 197; const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100; // Enriquecimento opcional via Workers AI let aiAdjustment = 0; if (env.AI && score >= 40) { try { const isRealEstate = !!(payload.property_lat || (payload as any).propertyLat); const systemContent = customSystemPrompt || (isRealEstate ? REAL_ESTATE_PROMPT : 'You are a conversion rate expert. Reply ONLY with a JSON object {"adjustment": } based on the lead data provided. No explanation.'); const userContext: Record = { utm_source: payload.utmSource, intention: intentionLevel, engagement: engScore, hour_utc: hour, country, has_email: !!payload.email, has_phone: !!payload.phone, }; if (isRealEstate) { userContext.event_type = 'real_estate_schedule'; userContext.distance_km = payload.distanceKm || (payload as any).user_distance_km || 'unknown'; userContext.distance_bucket = payload.distanceBucket || 'unknown'; userContext.scroll_score = payload.scrollScore || (payload as any).scroll_score || 0; userContext.time_level = payload.timeLevel || (payload as any).timeLevel || 'unknown'; userContext.intent_score = payload.intentScoreNum || payload.intent_score || 'high'; userContext.hour_brt = (hour - 3 + 24) % 24; // UTC-3 aproximado } const prompt = [ { role: 'system', content: systemContent }, { role: 'user', content: JSON.stringify(userContext) }, ]; const aiRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: prompt, max_tokens: 32 }); const parsed = JSON.parse((aiRes as any).response.trim()); if (typeof parsed.adjustment === 'number') { aiAdjustment = Math.max(-10, Math.min(10, parsed.adjustment)); } } catch { /* graceful fallback */ } } return { score: Math.min(100, Math.max(0, score + aiAdjustment)), class: ltvClass, value: predictedValue, }; } // ── getLtvAbVariation — busca variação ativa do A/B test ───────────────────── export async function getLtvAbVariation(env: Env): Promise { if (!env.DB) return null; try { let testData: any = null; if (env.GEO_CACHE) { const cached = await env.GEO_CACHE.get(AB_LTV_CACHE_KEY, 'json') as any; if (cached) testData = cached; } if (!testData) { const test = await env.DB.prepare(` SELECT t.id AS test_id, v.id AS variation_id, v.name, v.system_prompt, v.weight, v.is_control FROM ltv_ab_tests t JOIN ltv_ab_variations v ON v.test_id = t.id WHERE t.status = 'running' ORDER BY t.id DESC `).all(); if (!test.results || test.results.length === 0) { if (env.GEO_CACHE) await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(null), { expirationTtl: 300 }); return null; } testData = test.results; if (env.GEO_CACHE) await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(testData), { expirationTtl: 300 }); } if (!testData || testData.length === 0) return null; const totalWeight = testData.reduce((s: number, v: AbVariation) => s + (v.weight || 0.5), 0); let rand = Math.random() * totalWeight; for (const variation of testData) { rand -= (variation.weight || 0.5); if (rand <= 0) return variation; } return testData[testData.length - 1]; } catch (err: any) { console.error('[AB-LTV] getLtvAbVariation error:', err?.message || String(err)); return null; } } // ── recordAbAssignment — registra variação usada para um lead ───────────────── export async function recordAbAssignment(env: Env, userId: string, variationId: number, testId: number, predictedLtv: number | null, predictedClass: string | null, emailHash: string | null): Promise { if (!env.DB) return; try { await env.DB.prepare(` INSERT INTO ltv_ab_assignments (test_id, variation_id, user_id, email_hash, predicted_ltv, predicted_class, assigned_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now')) `).bind(testId, variationId, userId, emailHash || null, predictedLtv || null, predictedClass || null).run(); await env.DB.prepare(`UPDATE ltv_ab_variations SET total_assigned = total_assigned + 1 WHERE id = ?`).bind(variationId).run(); } catch (err: any) { console.error('[AB-LTV] recordAbAssignment error:', err?.message || String(err)); } } // ── POST /api/ltv/ab-test/create ───────────────────────────────────────────── export async function handleLtvAbTestCreate(env: Env, request: Request, headers: Headers): Promise { if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers }); let body: AbTestCreate; try { body = await request.json() as AbTestCreate; } catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); } const { name, description, min_sample = 100, variations } = body; if (!name) return new Response(JSON.stringify({ error: 'name é obrigatório' }), { status: 400, headers }); if (!Array.isArray(variations) || variations.length < 2) { return new Response(JSON.stringify({ error: 'Mínimo 2 variações são necessárias' }), { status: 400, headers }); } const running = await env.DB.prepare(`SELECT id FROM ltv_ab_tests WHERE status = 'running' LIMIT 1`).first(); if (running) { return new Response(JSON.stringify({ error: 'Já existe um teste em andamento.', running_test_id: (running as any).id }), { status: 409, headers }); } try { const now = new Date().toISOString(); const totalWeight = variations.reduce((s, v) => s + (v.weight || 0.5), 0); if (Math.abs(totalWeight - 1.0) > 0.05) { return new Response(JSON.stringify({ error: `A soma dos weights deve ser 1.0. Recebido: ${totalWeight.toFixed(3)}` }), { status: 400, headers }); } if (!variations.some(v => v.is_control)) { return new Response(JSON.stringify({ error: 'Pelo menos uma variação deve ter is_control: true' }), { status: 400, headers }); } const testRes = await env.DB.prepare(` INSERT INTO ltv_ab_tests (name, description, status, min_sample, created_at) VALUES (?, ?, 'running', ?, ?) `).bind(name, description || null, min_sample, now).run(); const testId = (testRes as any).meta?.last_row_id; if (!testId) throw new Error('Falha ao criar o teste no D1'); const createdVariations: Array<{ id: number; name: string; weight: number; is_control: boolean }> = []; for (const v of variations) { const vRes = await env.DB.prepare(` INSERT INTO ltv_ab_variations (test_id, name, system_prompt, weight, is_control, created_at) VALUES (?, ?, ?, ?, ?, ?) `).bind(testId, v.name, v.system_prompt, v.weight || 0.5, v.is_control ? 1 : 0, now).run(); createdVariations.push({ id: (vRes as any).meta?.last_row_id, name: v.name, weight: v.weight || 0.5, is_control: !!v.is_control }); } if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY); return new Response(JSON.stringify({ success: true, test_id: testId, name, status: 'running', min_sample, variations: createdVariations, started_at: now }), { status: 201, headers }); } catch (err: any) { console.error('[AB-LTV] create error:', err?.message || String(err)); return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers }); } } // ── GET /api/ltv/ab-test/list ───────────────────────────────────────────────── export async function handleLtvAbTestList(env: Env, request: Request, headers: Headers): Promise { if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers }); const url = new URL(request.url); const status = url.searchParams.get('status') || null; const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50); try { const cond = status ? 'WHERE t.status = ?' : ''; const bindings: (string | number)[] = status ? [status, limit] : [limit]; const tests = await env.DB.prepare(` SELECT t.id, t.name, t.description, t.status, t.winner_id, t.started_at, t.completed_at, t.created_at, t.min_sample, COUNT(DISTINCT v.id) AS variation_count, SUM(v.total_assigned) AS total_assigned FROM ltv_ab_tests t LEFT JOIN ltv_ab_variations v ON v.test_id = t.id ${cond} GROUP BY t.id ORDER BY t.created_at DESC LIMIT ? `).bind(...bindings).all(); return new Response(JSON.stringify({ success: true, total: (tests.results || []).length, tests: tests.results || [] }), { status: 200, headers }); } catch (err: any) { console.error('[AB-LTV] list error:', err?.message || String(err)); return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers }); } } // ── GET /api/ltv/ab-test/results ───────────────────────────────────────────── export async function handleLtvAbTestResults(env: Env, request: Request, headers: Headers): Promise { if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers }); const url = new URL(request.url); const testId = url.searchParams.get('test_id'); try { let testRes: any; if (testId) { // Query específica para teste por ID testRes = await env.DB.prepare(` SELECT id, name, status, min_sample, winner_id, started_at FROM ltv_ab_tests WHERE test_id = ? LIMIT 1 `).bind(parseInt(testId)).first(); } else { // Query padrão: busca teste ativo em execução testRes = await env.DB.prepare(` SELECT id, name, status, min_sample, winner_id, started_at FROM ltv_ab_tests WHERE status = 'running' LIMIT 1 `).first(); } if (!testRes) return new Response(JSON.stringify({ error: 'Nenhum teste encontrado' }), { status: 404, headers }); const perf = await env.DB.prepare(`SELECT * FROM v_ab_test_performance WHERE test_id = ?`).bind((testRes as any).id).all(); const variations = perf.results || []; const ready = variations.every((v: any) => (v.total_assigned || 0) >= (testRes as any).min_sample); let recommendation: any = null; if (ready && variations.length > 0) { const best = variations.reduce((a: any, b: any) => (Number(b.accuracy_score) || 0) > (Number(a.accuracy_score) || 0) ? b : a); const control = variations.find((v: any) => v.is_control); const improvement = control ? ((Number(best.accuracy_score) || 0) - (Number(control.accuracy_score) || 0)) * 100 : null; recommendation = { winner_variation_id: best.variation_id, winner_variation_name: best.variation_name, accuracy_score: best.accuracy_score, improvement_vs_control: improvement ? `+${improvement.toFixed(1)}%` : null, ready_to_declare: true, }; } return new Response(JSON.stringify({ success: true, test: { id: (testRes as any).id, name: (testRes as any).name, status: (testRes as any).status, min_sample: (testRes as any).min_sample, started_at: (testRes as any).started_at, is_ready: ready }, variations, recommendation, }), { status: 200, headers }); } catch (err: any) { console.error('[AB-LTV] results error:', err?.message || String(err)); return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers }); } } // ── POST /api/ltv/ab-test/winner ────────────────────────────────────────────── export async function handleLtvAbTestWinner(env: Env, request: Request, headers: Headers): Promise { if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers }); let body: any; try { body = await request.json(); } catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); } const { test_id, variation_id } = body; if (!test_id || !variation_id) { return new Response(JSON.stringify({ error: 'test_id e variation_id são obrigatórios' }), { status: 400, headers }); } try { 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(); if (!variation) return new Response(JSON.stringify({ error: 'Variação não encontrada para este teste' }), { status: 404, headers }); 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(); if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY); return new Response(JSON.stringify({ success: true, test_id, winner_variation_id: variation_id, winner_name: (variation as any).name, is_control: (variation as any).is_control === 1, winning_prompt: (variation as any).system_prompt, message: (variation as any).is_control === 1 ? 'O prompt original (controle) venceu. Nenhuma alteração necessária.' : 'Novo prompt vencedor identificado. Copie o campo winning_prompt e aplique ao predictLtv() como novo default.', }), { status: 200, headers }); } catch (err: any) { console.error('[AB-LTV] winner error:', err?.message || String(err)); return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers }); } } // ── autoDecideAbWinner — declara winner automaticamente via cron ────────────── // Critério: todas as variações com amostra >= min_sample // E diferença de accuracy_score >= 5pp entre melhor e controle export async function autoDecideAbWinner(env: Env): Promise { if (!env.DB) return { decided: false, reason: 'no_db' }; try { // Buscar teste ativo const test = await env.DB.prepare( `SELECT id, name, min_sample, status FROM ltv_ab_tests WHERE status = 'running' ORDER BY id DESC LIMIT 1` ).first(); if (!test) return { decided: false, reason: 'no_running_test' }; // Buscar performance das variações const perf = await env.DB.prepare( `SELECT * FROM v_ab_test_performance WHERE test_id = ?` ).bind((test as any).id).all(); const variations = perf.results || []; if (variations.length < 2) return { decided: false, reason: 'insufficient_variations' }; // Verificar se todas têm amostra suficiente const allReady = variations.every((v: any) => (v.total_assigned || 0) >= (test as any).min_sample); if (!allReady) { const minAssigned = Math.min(...variations.map((v: any) => v.total_assigned || 0)); return { decided: false, reason: `sample_insufficient (${minAssigned}/${(test as any).min_sample})` }; } // Encontrar melhor e controle const best = variations.reduce((a: any, b: any) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a); const control = variations.find((v: any) => v.is_control) || variations[0]; const bestScore = parseFloat(String(best.accuracy_score) || '0'); const controlScore = parseFloat(String(control.accuracy_score) || '0'); const diff = bestScore - controlScore; // Empate técnico → controle vence (determinístico) if (diff < 0.05) { return { decided: false, reason: `difference_too_small (${(diff * 100).toFixed(1)}pp < 5pp)` }; } // Declarar winner await env.DB.prepare( `UPDATE ltv_ab_tests SET winner_id = ?, status = 'completed', completed_at = datetime('now') WHERE id = ?` ).bind(best.variation_id, test.id).run(); if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY); console.log(`[AB-LTV] Winner auto-declarado: teste ${test.id}, variação "${best.variation_name}" (+${(diff * 100).toFixed(1)}pp)`); return { decided: true, test_id: (test as any).id, test_name: (test as any).name, winner_id: typeof best.variation_id === 'number' ? best.variation_id : undefined, winner_name: typeof best.variation_name === 'string' ? best.variation_name : undefined, improvement: `+${(diff * 100).toFixed(1)}pp`, is_control_winner: best.variation_id === control.variation_id, winning_prompt: String(best.system_prompt || ''), }; } catch (err: any) { console.error('[AB-LTV] autoDecide error:', err?.message || String(err)); return { decided: false, reason: err?.message || String(err) }; } }