cdp-edge 1.18.3 → 1.20.0
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 +18 -1
- package/contracts/agent-versions.json +364 -0
- package/dist/commands/install.js +1 -1
- package/dist/commands/setup.js +326 -111
- package/extracted-skill/tracking-events-generator/agents/server-tracking.md +5 -5
- package/package.json +7 -2
- package/server-edge-tracker/index.js +780 -0
- package/server-edge-tracker/migrate-v7.sql +64 -0
- package/server-edge-tracker/modules/db.js +531 -0
- package/server-edge-tracker/modules/dispatch/ga4.js +65 -0
- package/server-edge-tracker/modules/dispatch/meta.js +119 -0
- package/server-edge-tracker/modules/dispatch/platforms.js +237 -0
- package/server-edge-tracker/modules/dispatch/tiktok.js +100 -0
- package/server-edge-tracker/modules/dispatch/whatsapp.js +233 -0
- package/server-edge-tracker/modules/intelligence.js +321 -0
- package/server-edge-tracker/modules/ml/bidding.js +245 -0
- package/server-edge-tracker/modules/ml/fraud.js +301 -0
- package/server-edge-tracker/modules/ml/logistic.js +195 -0
- package/server-edge-tracker/modules/ml/ltv.js +420 -0
- package/server-edge-tracker/modules/ml/matchquality.js +176 -0
- package/server-edge-tracker/modules/ml/segmentation.js +316 -0
- package/server-edge-tracker/modules/utils.js +89 -0
- package/server-edge-tracker/schema-indexes.sql +67 -0
- package/server-edge-tracker/wrangler.toml +2 -0
|
@@ -0,0 +1,420 @@
|
|
|
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
|
+
import { extractFeatures, predictWithWeights, loadActiveWeights } from './logistic.js';
|
|
7
|
+
|
|
8
|
+
// Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
|
|
9
|
+
const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
|
|
10
|
+
|
|
11
|
+
// ── predictLtv — Heurística em 5 dimensões (0-100 pts) ───────────────────────
|
|
12
|
+
export async function predictLtv(env, payload, request, customSystemPrompt = null) {
|
|
13
|
+
// ── Tentar modelo treinado (regressão logística real) ─────────────────────
|
|
14
|
+
// Se existir modelo ativo no KV/D1, usa-o em vez da heurística manual.
|
|
15
|
+
// Fallback automático para heurística se modelo não disponível.
|
|
16
|
+
try {
|
|
17
|
+
const model = await loadActiveWeights(env);
|
|
18
|
+
if (model?.weights?.length) {
|
|
19
|
+
const hour = new Date().getUTCHours();
|
|
20
|
+
const country = (payload.country || request?.cf?.country || '').toUpperCase();
|
|
21
|
+
const features = extractFeatures({
|
|
22
|
+
utm_source: payload.utmSource,
|
|
23
|
+
engagement_score: parseFloat(payload.engagementScore || 0),
|
|
24
|
+
intention_level: payload.intentionLevel,
|
|
25
|
+
days_since_lead: 0, // evento atual = recência máxima
|
|
26
|
+
has_email: !!payload.email,
|
|
27
|
+
has_phone: !!payload.phone,
|
|
28
|
+
is_br: country === 'BR',
|
|
29
|
+
hour,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const score100 = predictWithWeights(model, features);
|
|
33
|
+
const ltvClass = score100 >= 70 ? 'High' : score100 >= 40 ? 'Medium' : 'Low';
|
|
34
|
+
const ltvMultiplier = score100 >= 70 ? 3.5 : score100 >= 40 ? 1.8 : 0.8;
|
|
35
|
+
const productValue = payload.value ? parseFloat(payload.value) : 0;
|
|
36
|
+
const baseValue = productValue > 0 ? productValue : 197;
|
|
37
|
+
const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100;
|
|
38
|
+
|
|
39
|
+
return { score: score100, class: ltvClass, value: predictedValue, source: 'model' };
|
|
40
|
+
}
|
|
41
|
+
} catch { /* fallback para heurística */ }
|
|
42
|
+
|
|
43
|
+
let score = 0;
|
|
44
|
+
|
|
45
|
+
// 1. Engajamento browser (0–30)
|
|
46
|
+
const engScore = parseFloat(payload.engagementScore || 0);
|
|
47
|
+
const userScore = parseFloat(payload.userScore || 0);
|
|
48
|
+
score += Math.min(15, Math.round((engScore / 5) * 15));
|
|
49
|
+
score += Math.min(15, Math.round((userScore / 100) * 15));
|
|
50
|
+
|
|
51
|
+
// 2. Origem de tráfego (0–25)
|
|
52
|
+
const src = (payload.utmSource || '').toLowerCase();
|
|
53
|
+
const utm_score_map = {
|
|
54
|
+
facebook: 25, instagram: 25, meta: 25,
|
|
55
|
+
google: 22, youtube: 22, tiktok: 20,
|
|
56
|
+
email: 18, sms: 18,
|
|
57
|
+
organic: 10, direct: 5,
|
|
58
|
+
};
|
|
59
|
+
score += utm_score_map[src] ?? (src ? 8 : 3);
|
|
60
|
+
|
|
61
|
+
// 3. Contexto de rede (0–15)
|
|
62
|
+
const hour = new Date().getUTCHours();
|
|
63
|
+
const country = (payload.country || request.cf?.country || '').toUpperCase();
|
|
64
|
+
const org = String(request.cf?.asOrganization || '').toLowerCase();
|
|
65
|
+
|
|
66
|
+
const isHighConvTime = hour >= 21 || hour <= 2;
|
|
67
|
+
score += isHighConvTime ? 8 : (hour >= 12 && hour <= 20 ? 4 : 1);
|
|
68
|
+
|
|
69
|
+
const latam = ['AR', 'CL', 'CO', 'MX', 'PE', 'UY', 'PY', 'BO'];
|
|
70
|
+
score += country === 'BR' ? 5 : (latam.includes(country) ? 3 : 1);
|
|
71
|
+
|
|
72
|
+
const isCorp = /ltda|s\.a\.|corp|telecom|fibra|claro|vivo|tim|oi/.test(org);
|
|
73
|
+
score += isCorp ? 2 : 0;
|
|
74
|
+
|
|
75
|
+
// 4. Contexto do evento (0–20)
|
|
76
|
+
const intentionLevel = (payload.intentionLevel || '').toLowerCase();
|
|
77
|
+
if (intentionLevel === 'comprador' || intentionLevel === 'high_intent') score += 20;
|
|
78
|
+
else if (intentionLevel === 'interessado') score += 12;
|
|
79
|
+
else if (intentionLevel === 'nurture') score += 6;
|
|
80
|
+
|
|
81
|
+
// 5. Dados PII disponíveis (0–10)
|
|
82
|
+
if (payload.email) score += 4;
|
|
83
|
+
if (payload.phone) score += 4;
|
|
84
|
+
if (payload.firstName) score += 2;
|
|
85
|
+
|
|
86
|
+
score = Math.min(100, score);
|
|
87
|
+
|
|
88
|
+
let ltvClass, ltvMultiplier;
|
|
89
|
+
if (score >= 70) {
|
|
90
|
+
ltvClass = 'High'; ltvMultiplier = 3.5;
|
|
91
|
+
} else if (score >= 40) {
|
|
92
|
+
ltvClass = 'Medium'; ltvMultiplier = 1.8;
|
|
93
|
+
} else {
|
|
94
|
+
ltvClass = 'Low'; ltvMultiplier = 0.8;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const productValue = payload.value ? parseFloat(payload.value) : 0;
|
|
98
|
+
const baseValue = productValue > 0 ? productValue : 197;
|
|
99
|
+
const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100;
|
|
100
|
+
|
|
101
|
+
// Enriquecimento opcional via Workers AI
|
|
102
|
+
let aiAdjustment = 0;
|
|
103
|
+
if (env.AI && score >= 40) {
|
|
104
|
+
try {
|
|
105
|
+
const systemContent = customSystemPrompt ||
|
|
106
|
+
'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.';
|
|
107
|
+
const prompt = [
|
|
108
|
+
{ 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
|
+
})},
|
|
118
|
+
];
|
|
119
|
+
const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: prompt, max_tokens: 32 });
|
|
120
|
+
const parsed = JSON.parse(aiRes.response.trim());
|
|
121
|
+
if (typeof parsed.adjustment === 'number') {
|
|
122
|
+
aiAdjustment = Math.max(-10, Math.min(10, parsed.adjustment));
|
|
123
|
+
}
|
|
124
|
+
} catch { /* graceful fallback */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
score: Math.min(100, Math.max(0, score + aiAdjustment)),
|
|
129
|
+
class: ltvClass,
|
|
130
|
+
value: predictedValue,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── getLtvAbVariation — busca variação ativa do A/B test ─────────────────────
|
|
135
|
+
export async function getLtvAbVariation(env) {
|
|
136
|
+
if (!env.DB) return null;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
let testData = null;
|
|
140
|
+
if (env.GEO_CACHE) {
|
|
141
|
+
const cached = await env.GEO_CACHE.get(AB_LTV_CACHE_KEY, 'json');
|
|
142
|
+
if (cached) testData = cached;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!testData) {
|
|
146
|
+
const test = await env.DB.prepare(`
|
|
147
|
+
SELECT t.id AS test_id, v.id AS variation_id,
|
|
148
|
+
v.name, v.system_prompt, v.weight, v.is_control
|
|
149
|
+
FROM ltv_ab_tests t
|
|
150
|
+
JOIN ltv_ab_variations v ON v.test_id = t.id
|
|
151
|
+
WHERE t.status = 'running'
|
|
152
|
+
ORDER BY t.id DESC
|
|
153
|
+
`).all();
|
|
154
|
+
|
|
155
|
+
if (!test.results || test.results.length === 0) {
|
|
156
|
+
if (env.GEO_CACHE) await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(null), { expirationTtl: 300 });
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
testData = test.results;
|
|
161
|
+
if (env.GEO_CACHE) await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(testData), { expirationTtl: 300 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!testData || testData.length === 0) return null;
|
|
165
|
+
|
|
166
|
+
const totalWeight = testData.reduce((s, v) => s + (v.weight || 0.5), 0);
|
|
167
|
+
let rand = Math.random() * totalWeight;
|
|
168
|
+
for (const variation of testData) {
|
|
169
|
+
rand -= (variation.weight || 0.5);
|
|
170
|
+
if (rand <= 0) return variation;
|
|
171
|
+
}
|
|
172
|
+
return testData[testData.length - 1];
|
|
173
|
+
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error('[AB-LTV] getLtvAbVariation error:', err.message);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── recordAbAssignment — registra variação usada para um lead ─────────────────
|
|
181
|
+
export async function recordAbAssignment(env, userId, variationId, testId, predictedLtv, predictedClass, emailHash) {
|
|
182
|
+
if (!env.DB) return;
|
|
183
|
+
try {
|
|
184
|
+
await env.DB.prepare(`
|
|
185
|
+
INSERT INTO ltv_ab_assignments (test_id, variation_id, user_id, email_hash, predicted_ltv, predicted_class, assigned_at)
|
|
186
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
187
|
+
`).bind(testId, variationId, userId, emailHash || null, predictedLtv || null, predictedClass || null).run();
|
|
188
|
+
|
|
189
|
+
await env.DB.prepare(`UPDATE ltv_ab_variations SET total_assigned = total_assigned + 1 WHERE id = ?`).bind(variationId).run();
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error('[AB-LTV] recordAbAssignment error:', err.message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── POST /api/ltv/ab-test/create ─────────────────────────────────────────────
|
|
196
|
+
export async function handleLtvAbTestCreate(env, request, headers) {
|
|
197
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
198
|
+
|
|
199
|
+
let body;
|
|
200
|
+
try { body = await request.json(); }
|
|
201
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
|
|
202
|
+
|
|
203
|
+
const { name, description, min_sample = 100, variations } = body;
|
|
204
|
+
if (!name) return new Response(JSON.stringify({ error: 'name é obrigatório' }), { status: 400, headers });
|
|
205
|
+
if (!Array.isArray(variations) || variations.length < 2) {
|
|
206
|
+
return new Response(JSON.stringify({ error: 'Mínimo 2 variações são necessárias' }), { status: 400, headers });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const running = await env.DB.prepare(`SELECT id FROM ltv_ab_tests WHERE status = 'running' LIMIT 1`).first();
|
|
210
|
+
if (running) {
|
|
211
|
+
return new Response(JSON.stringify({ error: 'Já existe um teste em andamento.', running_test_id: running.id }), { status: 409, headers });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const now = new Date().toISOString();
|
|
216
|
+
const totalWeight = variations.reduce((s, v) => s + (v.weight || 0.5), 0);
|
|
217
|
+
if (Math.abs(totalWeight - 1.0) > 0.05) {
|
|
218
|
+
return new Response(JSON.stringify({ error: `A soma dos weights deve ser 1.0. Recebido: ${totalWeight.toFixed(3)}` }), { status: 400, headers });
|
|
219
|
+
}
|
|
220
|
+
if (!variations.some(v => v.is_control)) {
|
|
221
|
+
return new Response(JSON.stringify({ error: 'Pelo menos uma variação deve ter is_control: true' }), { status: 400, headers });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const testRes = await env.DB.prepare(`
|
|
225
|
+
INSERT INTO ltv_ab_tests (name, description, status, min_sample, created_at) VALUES (?, ?, 'running', ?, ?)
|
|
226
|
+
`).bind(name, description || null, min_sample, now).run();
|
|
227
|
+
|
|
228
|
+
const testId = testRes.meta?.last_row_id;
|
|
229
|
+
if (!testId) throw new Error('Falha ao criar o teste no D1');
|
|
230
|
+
|
|
231
|
+
const createdVariations = [];
|
|
232
|
+
for (const v of variations) {
|
|
233
|
+
const vRes = await env.DB.prepare(`
|
|
234
|
+
INSERT INTO ltv_ab_variations (test_id, name, system_prompt, weight, is_control, created_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
235
|
+
`).bind(testId, v.name, v.system_prompt, v.weight || 0.5, v.is_control ? 1 : 0, now).run();
|
|
236
|
+
createdVariations.push({ id: vRes.meta?.last_row_id, name: v.name, weight: v.weight, is_control: !!v.is_control });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
|
|
240
|
+
|
|
241
|
+
return new Response(JSON.stringify({ success: true, test_id: testId, name, status: 'running', min_sample, variations: createdVariations, started_at: now }), { status: 201, headers });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error('[AB-LTV] create error:', err.message);
|
|
244
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── GET /api/ltv/ab-test/list ─────────────────────────────────────────────────
|
|
249
|
+
export async function handleLtvAbTestList(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 status = url.searchParams.get('status') || null;
|
|
254
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const cond = status ? 'WHERE t.status = ?' : '';
|
|
258
|
+
const bindings = status ? [status, limit] : [limit];
|
|
259
|
+
|
|
260
|
+
const tests = await env.DB.prepare(`
|
|
261
|
+
SELECT t.id, t.name, t.description, t.status, t.winner_id,
|
|
262
|
+
t.started_at, t.completed_at, t.created_at, t.min_sample,
|
|
263
|
+
COUNT(DISTINCT v.id) AS variation_count,
|
|
264
|
+
SUM(v.total_assigned) AS total_assigned
|
|
265
|
+
FROM ltv_ab_tests t
|
|
266
|
+
LEFT JOIN ltv_ab_variations v ON v.test_id = t.id
|
|
267
|
+
${cond}
|
|
268
|
+
GROUP BY t.id
|
|
269
|
+
ORDER BY t.created_at DESC
|
|
270
|
+
LIMIT ?
|
|
271
|
+
`).bind(...bindings).all();
|
|
272
|
+
|
|
273
|
+
return new Response(JSON.stringify({ success: true, total: (tests.results || []).length, tests: tests.results || [] }), { status: 200, headers });
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error('[AB-LTV] list error:', err.message);
|
|
276
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── GET /api/ltv/ab-test/results ─────────────────────────────────────────────
|
|
281
|
+
export async function handleLtvAbTestResults(env, request, headers) {
|
|
282
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
283
|
+
|
|
284
|
+
const url = new URL(request.url);
|
|
285
|
+
const testId = url.searchParams.get('test_id') || null;
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const cond = testId ? 'WHERE test_id = ?' : 'WHERE status = \'running\'';
|
|
289
|
+
const testBind = testId ? [parseInt(testId)] : [];
|
|
290
|
+
|
|
291
|
+
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();
|
|
292
|
+
if (!testRes) return new Response(JSON.stringify({ error: 'Nenhum teste encontrado' }), { status: 404, headers });
|
|
293
|
+
|
|
294
|
+
const perf = await env.DB.prepare(`SELECT * FROM v_ab_test_performance WHERE test_id = ?`).bind(testRes.id).all();
|
|
295
|
+
const variations = perf.results || [];
|
|
296
|
+
const ready = variations.every(v => (v.total_assigned || 0) >= testRes.min_sample);
|
|
297
|
+
let recommendation = null;
|
|
298
|
+
|
|
299
|
+
if (ready && variations.length > 0) {
|
|
300
|
+
const best = variations.reduce((a, b) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a);
|
|
301
|
+
const control = variations.find(v => v.is_control);
|
|
302
|
+
const improvement = control ? ((best.accuracy_score || 0) - (control.accuracy_score || 0)) * 100 : null;
|
|
303
|
+
recommendation = {
|
|
304
|
+
winner_variation_id: best.variation_id, winner_variation_name: best.variation_name,
|
|
305
|
+
accuracy_score: best.accuracy_score, improvement_vs_control: improvement ? `+${improvement.toFixed(1)}%` : null,
|
|
306
|
+
ready_to_declare: true,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return new Response(JSON.stringify({
|
|
311
|
+
success: true,
|
|
312
|
+
test: { id: testRes.id, name: testRes.name, status: testRes.status, min_sample: testRes.min_sample, started_at: testRes.started_at, is_ready: ready },
|
|
313
|
+
variations, recommendation,
|
|
314
|
+
}), { status: 200, headers });
|
|
315
|
+
} catch (err) {
|
|
316
|
+
console.error('[AB-LTV] results error:', err.message);
|
|
317
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── POST /api/ltv/ab-test/winner ──────────────────────────────────────────────
|
|
322
|
+
export async function handleLtvAbTestWinner(env, request, headers) {
|
|
323
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
324
|
+
|
|
325
|
+
let body;
|
|
326
|
+
try { body = await request.json(); }
|
|
327
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
|
|
328
|
+
|
|
329
|
+
const { test_id, variation_id } = body;
|
|
330
|
+
if (!test_id || !variation_id) {
|
|
331
|
+
return new Response(JSON.stringify({ error: 'test_id e variation_id são obrigatórios' }), { status: 400, headers });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
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();
|
|
336
|
+
if (!variation) return new Response(JSON.stringify({ error: 'Variação não encontrada para este teste' }), { status: 404, headers });
|
|
337
|
+
|
|
338
|
+
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();
|
|
339
|
+
if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
|
|
340
|
+
|
|
341
|
+
return new Response(JSON.stringify({
|
|
342
|
+
success: true, test_id, winner_variation_id: variation_id, winner_name: variation.name,
|
|
343
|
+
is_control: variation.is_control === 1, winning_prompt: variation.system_prompt,
|
|
344
|
+
message: variation.is_control === 1
|
|
345
|
+
? 'O prompt original (controle) venceu. Nenhuma alteração necessária.'
|
|
346
|
+
: 'Novo prompt vencedor identificado. Copie o campo winning_prompt e aplique ao predictLtv() como novo default.',
|
|
347
|
+
}), { status: 200, headers });
|
|
348
|
+
} catch (err) {
|
|
349
|
+
console.error('[AB-LTV] winner error:', err.message);
|
|
350
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── autoDecideAbWinner — declara winner automaticamente via cron ──────────────
|
|
355
|
+
// Critério: todas as variações com amostra >= min_sample
|
|
356
|
+
// E diferença de accuracy_score >= 5pp entre melhor e controle
|
|
357
|
+
export async function autoDecideAbWinner(env) {
|
|
358
|
+
if (!env.DB) return { decided: false, reason: 'no_db' };
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
// Buscar teste ativo
|
|
362
|
+
const test = await env.DB.prepare(
|
|
363
|
+
`SELECT id, name, min_sample, status FROM ltv_ab_tests WHERE status = 'running' ORDER BY id DESC LIMIT 1`
|
|
364
|
+
).first();
|
|
365
|
+
|
|
366
|
+
if (!test) return { decided: false, reason: 'no_running_test' };
|
|
367
|
+
|
|
368
|
+
// Buscar performance das variações
|
|
369
|
+
const perf = await env.DB.prepare(
|
|
370
|
+
`SELECT * FROM v_ab_test_performance WHERE test_id = ?`
|
|
371
|
+
).bind(test.id).all();
|
|
372
|
+
|
|
373
|
+
const variations = perf.results || [];
|
|
374
|
+
if (variations.length < 2) return { decided: false, reason: 'insufficient_variations' };
|
|
375
|
+
|
|
376
|
+
// Verificar se todas têm amostra suficiente
|
|
377
|
+
const allReady = variations.every(v => (v.total_assigned || 0) >= test.min_sample);
|
|
378
|
+
if (!allReady) {
|
|
379
|
+
const minAssigned = Math.min(...variations.map(v => v.total_assigned || 0));
|
|
380
|
+
return { decided: false, reason: `sample_insufficient (${minAssigned}/${test.min_sample})` };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Encontrar melhor e controle
|
|
384
|
+
const best = variations.reduce((a, b) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a);
|
|
385
|
+
const control = variations.find(v => v.is_control) || variations[0];
|
|
386
|
+
|
|
387
|
+
const bestScore = parseFloat(best.accuracy_score || 0);
|
|
388
|
+
const controlScore = parseFloat(control.accuracy_score || 0);
|
|
389
|
+
const diff = bestScore - controlScore;
|
|
390
|
+
|
|
391
|
+
// Empate técnico → controle vence (determinístico)
|
|
392
|
+
if (diff < 0.05) {
|
|
393
|
+
return { decided: false, reason: `difference_too_small (${(diff * 100).toFixed(1)}pp < 5pp)` };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Declarar winner
|
|
397
|
+
await env.DB.prepare(
|
|
398
|
+
`UPDATE ltv_ab_tests SET winner_id = ?, status = 'completed', completed_at = datetime('now') WHERE id = ?`
|
|
399
|
+
).bind(best.variation_id, test.id).run();
|
|
400
|
+
|
|
401
|
+
if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
|
|
402
|
+
|
|
403
|
+
console.log(`[AB-LTV] Winner auto-declarado: teste ${test.id}, variação "${best.variation_name}" (+${(diff * 100).toFixed(1)}pp)`);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
decided: true,
|
|
407
|
+
test_id: test.id,
|
|
408
|
+
test_name: test.name,
|
|
409
|
+
winner_id: best.variation_id,
|
|
410
|
+
winner_name: best.variation_name,
|
|
411
|
+
improvement: `+${(diff * 100).toFixed(1)}pp`,
|
|
412
|
+
is_control_winner: best.variation_id === control.variation_id,
|
|
413
|
+
winning_prompt: best.system_prompt || null,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
} catch (err) {
|
|
417
|
+
console.error('[AB-LTV] autoDecide error:', err.message);
|
|
418
|
+
return { decided: false, reason: err.message };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Match Quality (Fase 5)
|
|
3
|
+
* Rastreia qualidade dos dados enviados ao Meta CAPI.
|
|
4
|
+
* Detecta degradação e alerta via CallMeBot.
|
|
5
|
+
* Tenta auto-correção onde possível.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { sendCallMeBot } from '../dispatch/whatsapp.js';
|
|
9
|
+
|
|
10
|
+
// ── Thresholds de alerta ──────────────────────────────────────────────────────
|
|
11
|
+
const THRESHOLDS = {
|
|
12
|
+
email_rate_min: 0.40, // < 40% dos eventos com email → alerta
|
|
13
|
+
fbp_rate_min: 0.30, // < 30% com fbp cookie → alerta
|
|
14
|
+
composite_min: 0.45, // < 45% score composto → alerta crítico
|
|
15
|
+
min_events_alert: 10, // mínimo de eventos nas últimas 2h para disparar alerta
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ── Log de qualidade (chamado em meta.js a cada dispatch) ─────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Registra flags de qualidade de um evento no D1 (background, não bloqueia).
|
|
22
|
+
*/
|
|
23
|
+
export async function logMatchQuality(DB, eventName, payload, recovered = {}) {
|
|
24
|
+
if (!DB) return;
|
|
25
|
+
try {
|
|
26
|
+
await DB.prepare(`
|
|
27
|
+
INSERT INTO match_quality_log (
|
|
28
|
+
event_name, has_email, has_phone, has_fbp, has_fbc, has_external_id,
|
|
29
|
+
was_email_recovered, was_utm_restored
|
|
30
|
+
) VALUES (?,?,?,?,?,?,?,?)
|
|
31
|
+
`).bind(
|
|
32
|
+
eventName,
|
|
33
|
+
payload.email ? 1 : 0,
|
|
34
|
+
payload.phone ? 1 : 0,
|
|
35
|
+
payload.fbp ? 1 : 0,
|
|
36
|
+
payload.fbc ? 1 : 0,
|
|
37
|
+
payload.userId ? 1 : 0,
|
|
38
|
+
recovered.email ? 1 : 0,
|
|
39
|
+
recovered.utm ? 1 : 0,
|
|
40
|
+
).run();
|
|
41
|
+
} catch { /* não bloquear dispatch */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Auto-correção de payload ───────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tenta enriquecer o payload com dados do Identity Graph antes do envio ao Meta.
|
|
48
|
+
* Retorna { payload enriquecido, flags de recuperação }.
|
|
49
|
+
*/
|
|
50
|
+
export async function autoEnrichPayload(env, payload) {
|
|
51
|
+
const recovered = { email: false, utm: false };
|
|
52
|
+
if (!env.DB) return { payload, recovered };
|
|
53
|
+
|
|
54
|
+
// 1. Tentar recuperar email/fbp/fbc do perfil pelo userId
|
|
55
|
+
if (!payload.email && payload.userId) {
|
|
56
|
+
try {
|
|
57
|
+
const profile = await env.DB.prepare(
|
|
58
|
+
`SELECT email, fbp, fbc, phone FROM user_profiles WHERE user_id = ? LIMIT 1`
|
|
59
|
+
).bind(payload.userId).first();
|
|
60
|
+
|
|
61
|
+
if (profile) {
|
|
62
|
+
if (profile.email && !payload.email) {
|
|
63
|
+
payload.email = profile.email;
|
|
64
|
+
recovered.email = true;
|
|
65
|
+
}
|
|
66
|
+
if (profile.fbp && !payload.fbp) payload.fbp = profile.fbp;
|
|
67
|
+
if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc;
|
|
68
|
+
if (profile.phone && !payload.phone) payload.phone = profile.phone;
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. UTM Resurrection já foi tentada no /track handler (payload.utmRestored)
|
|
74
|
+
if (payload.utmRestored) recovered.utm = true;
|
|
75
|
+
|
|
76
|
+
return { payload, recovered };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Análise de qualidade (chamada pelo cron) ─────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Analisa a qualidade das últimas 2h e retorna métricas + alertas.
|
|
83
|
+
*/
|
|
84
|
+
export async function analyzeMatchQuality(env) {
|
|
85
|
+
if (!env.DB) return null;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const row = await env.DB.prepare(`
|
|
89
|
+
SELECT
|
|
90
|
+
COUNT(*) AS total,
|
|
91
|
+
ROUND(AVG(has_email) * 100, 1) AS email_rate,
|
|
92
|
+
ROUND(AVG(has_phone) * 100, 1) AS phone_rate,
|
|
93
|
+
ROUND(AVG(has_fbp) * 100, 1) AS fbp_rate,
|
|
94
|
+
ROUND(AVG(has_fbc) * 100, 1) AS fbc_rate,
|
|
95
|
+
ROUND(AVG(has_external_id) * 100, 1) AS ext_id_rate,
|
|
96
|
+
ROUND(AVG(was_email_recovered) * 100, 1) AS email_recovered_rate,
|
|
97
|
+
ROUND((AVG(has_email)*0.4 + AVG(has_fbp)*0.3 + AVG(has_phone)*0.2 + AVG(has_fbc)*0.1) * 100, 1) AS composite_score
|
|
98
|
+
FROM match_quality_log
|
|
99
|
+
WHERE logged_at >= datetime('now', '-2 hours')
|
|
100
|
+
`).first();
|
|
101
|
+
|
|
102
|
+
if (!row || row.total < THRESHOLDS.min_events_alert) return { total: row?.total || 0, alerts: [] };
|
|
103
|
+
|
|
104
|
+
const alerts = [];
|
|
105
|
+
|
|
106
|
+
if ((row.email_rate || 0) < THRESHOLDS.email_rate_min * 100) {
|
|
107
|
+
alerts.push({
|
|
108
|
+
type: 'email_low',
|
|
109
|
+
metric: `email_rate: ${row.email_rate}%`,
|
|
110
|
+
message: `Taxa de email baixa: ${row.email_rate}% (mínimo: ${THRESHOLDS.email_rate_min * 100}%)`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if ((row.fbp_rate || 0) < THRESHOLDS.fbp_rate_min * 100) {
|
|
115
|
+
alerts.push({
|
|
116
|
+
type: 'fbp_low',
|
|
117
|
+
metric: `fbp_rate: ${row.fbp_rate}%`,
|
|
118
|
+
message: `Cookie fbp ausente em ${100 - row.fbp_rate}% dos eventos — verificar cdpTrack.js`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if ((row.composite_score || 0) < THRESHOLDS.composite_min * 100) {
|
|
123
|
+
alerts.push({
|
|
124
|
+
type: 'composite_critical',
|
|
125
|
+
metric: `composite: ${row.composite_score}%`,
|
|
126
|
+
message: `Score composto de match quality crítico: ${row.composite_score}%`,
|
|
127
|
+
severity: 'critical',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { ...row, alerts };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('[MatchQuality] analyze error:', err.message);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Alerta via CallMeBot ──────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export async function alertMatchQuality(env, analysis) {
|
|
141
|
+
if (!analysis || analysis.alerts.length === 0) return;
|
|
142
|
+
|
|
143
|
+
const hasCritical = analysis.alerts.some(a => a.severity === 'critical');
|
|
144
|
+
const icon = hasCritical ? '🚨' : '⚠️';
|
|
145
|
+
|
|
146
|
+
const lines = [
|
|
147
|
+
`${icon} CDP Edge — Match Quality Alert`,
|
|
148
|
+
``,
|
|
149
|
+
`📊 Últimas 2h (${analysis.total} eventos):`,
|
|
150
|
+
` Email: ${analysis.email_rate ?? 0}% ${(analysis.email_rate ?? 0) < 40 ? '❌' : '✅'}`,
|
|
151
|
+
` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
|
|
152
|
+
` Score: ${analysis.composite_score ?? 0}%`,
|
|
153
|
+
``,
|
|
154
|
+
`🔍 Problemas:`,
|
|
155
|
+
...analysis.alerts.map(a => ` · ${a.message}`),
|
|
156
|
+
``,
|
|
157
|
+
`🛠 Ações automáticas já ativas:`,
|
|
158
|
+
` · Identity Graph recovery: ${analysis.email_recovered_rate ?? 0}% emails recuperados`,
|
|
159
|
+
` · UTM Resurrection ativa`,
|
|
160
|
+
``,
|
|
161
|
+
new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' }),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
await sendCallMeBot(env, lines.join('\n'));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Purge periódico (mensal) ──────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export async function purgeOldMatchQualityLogs(DB) {
|
|
170
|
+
if (!DB) return;
|
|
171
|
+
try {
|
|
172
|
+
await DB.prepare(
|
|
173
|
+
`DELETE FROM match_quality_log WHERE logged_at < datetime('now', '-30 days')`
|
|
174
|
+
).run();
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|