cdp-edge 2.0.4 → 2.0.6
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/dist/commands/install.js +1 -1
- package/dist/commands/setup.js +326 -111
- package/extracted-skill/tracking-events-generator/INTEGRACAO-COMPLETA.md +89 -0
- package/extracted-skill/tracking-events-generator/MELHORIAS-IMPLEMENTADAS.md +101 -0
- package/extracted-skill/tracking-events-generator/agents/devops-agent.md +11 -0
- package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +27 -0
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +1 -1
- package/extracted-skill/tracking-events-generator/knowledge-base.md +172 -0
- package/package.json +1 -1
- package/server-edge-tracker/INSTALAR.md +27 -3
- package/server-edge-tracker/SEGMENTATION-DOCS.md +69 -0
- package/server-edge-tracker/index.js +11 -0
- package/server-edge-tracker/migrate-v7.sql +64 -0
- package/server-edge-tracker/modules/dispatch/meta.js +16 -0
- package/server-edge-tracker/modules/intelligence.js +120 -3
- package/server-edge-tracker/modules/ml/logistic.js +195 -0
- package/server-edge-tracker/modules/ml/ltv.js +100 -0
- package/server-edge-tracker/modules/ml/matchquality.js +176 -0
- package/server-edge-tracker/schema-indexes.sql +7 -7
- package/server-edge-tracker/worker.js +395 -4
- package/server-edge-tracker/wrangler.toml +19 -6
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import { sha256 } from './utils.js';
|
|
7
7
|
import { getHealthMetrics, generateDailyReport, logIntelligence } from './db.js';
|
|
8
8
|
import { sendCallMeBot } from './dispatch/whatsapp.js';
|
|
9
|
+
import { autoDecideAbWinner } from './ml/ltv.js';
|
|
10
|
+
import { analyzeMatchQuality, alertMatchQuality, purgeOldMatchQualityLogs } from './ml/matchquality.js';
|
|
11
|
+
import { trainLogisticRegression, extractFeatures, saveWeights, LTV_WEIGHTS_KV_KEY } from './ml/logistic.js';
|
|
9
12
|
|
|
10
13
|
// ── Versões esperadas das APIs ────────────────────────────────────────────────
|
|
11
14
|
const EXPECTED_API_VERSIONS = {
|
|
@@ -77,6 +80,62 @@ export async function auditErrorRates(env, runType) {
|
|
|
77
80
|
return alerts;
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
// ── Treinar modelo LTV (regressão logística com dados reais do D1) ────────────
|
|
84
|
+
export async function trainLtvModel(env) {
|
|
85
|
+
if (!env.DB) return { skipped: 'DB não disponível' };
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Busca leads com informação de conversão (compra confirmada)
|
|
89
|
+
const rows = await env.DB.prepare(`
|
|
90
|
+
SELECT
|
|
91
|
+
l.utm_source,
|
|
92
|
+
l.utm_medium,
|
|
93
|
+
l.engagement_score,
|
|
94
|
+
l.intention_level,
|
|
95
|
+
CAST(julianday('now') - julianday(l.created_at) AS INTEGER) AS days_since_lead,
|
|
96
|
+
CASE WHEN l.email IS NOT NULL AND l.email != '' THEN 1 ELSE 0 END AS has_email,
|
|
97
|
+
CASE WHEN l.phone IS NOT NULL AND l.phone != '' THEN 1 ELSE 0 END AS has_phone,
|
|
98
|
+
CASE WHEN (l.country = 'br' OR l.country = 'BR' OR l.country IS NULL) THEN 1 ELSE 0 END AS is_br,
|
|
99
|
+
CAST(strftime('%H', l.created_at) AS INTEGER) AS hour,
|
|
100
|
+
CASE WHEN EXISTS (
|
|
101
|
+
SELECT 1 FROM events e
|
|
102
|
+
WHERE e.user_id = l.user_id
|
|
103
|
+
AND e.event_name IN ('Purchase', 'purchase', 'PURCHASE')
|
|
104
|
+
AND e.created_at > l.created_at
|
|
105
|
+
) THEN 1 ELSE 0 END AS label
|
|
106
|
+
FROM leads l
|
|
107
|
+
WHERE l.created_at >= datetime('now', '-90 days')
|
|
108
|
+
LIMIT 5000
|
|
109
|
+
`).all();
|
|
110
|
+
|
|
111
|
+
const dataset = (rows.results || []).map(row => ({
|
|
112
|
+
features: extractFeatures(row),
|
|
113
|
+
label: row.label || 0,
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const model = trainLogisticRegression(dataset);
|
|
117
|
+
|
|
118
|
+
if (!model) {
|
|
119
|
+
console.log('[LTV Train] Dados insuficientes para treinar modelo');
|
|
120
|
+
return { skipped: 'dados insuficientes', samples: dataset.length };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await saveWeights(env.DB, model);
|
|
124
|
+
|
|
125
|
+
// Invalidar cache KV para que próximas requests carreguem o modelo novo
|
|
126
|
+
if (env.GEO_CACHE) {
|
|
127
|
+
env.GEO_CACHE.delete(LTV_WEIGHTS_KV_KEY).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`[LTV Train] Modelo treinado: ${dataset.length} samples, accuracy=${(model.accuracy * 100).toFixed(1)}%, positive_rate=${(model.positiveRate * 100).toFixed(1)}%`);
|
|
131
|
+
return { trained: true, samples: dataset.length, accuracy: model.accuracy, positiveRate: model.positiveRate };
|
|
132
|
+
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error('[LTV Train] Erro:', err.message);
|
|
135
|
+
return { error: err.message };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
80
139
|
// ── Runner principal do Intelligence Agent ────────────────────────────────────
|
|
81
140
|
export async function runIntelligenceAgent(env, runType) {
|
|
82
141
|
console.log(`[Intelligence Agent] Iniciando ${runType}`);
|
|
@@ -97,7 +156,61 @@ export async function runIntelligenceAgent(env, runType) {
|
|
|
97
156
|
console.warn(`[Intelligence Agent] ${errorAlerts.length} alertas de taxa de erro enviados`);
|
|
98
157
|
}
|
|
99
158
|
|
|
100
|
-
// 4.
|
|
159
|
+
// 4. Treinar modelo LTV (toda semana)
|
|
160
|
+
const ltvTrainResult = await trainLtvModel(env);
|
|
161
|
+
if (ltvTrainResult.trained) {
|
|
162
|
+
console.log(`[Intelligence Agent] LTV model treinado: accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`);
|
|
163
|
+
if (env.DB) {
|
|
164
|
+
await logIntelligence(env.DB, runType, 'ltv', 'model_training', 'ok',
|
|
165
|
+
`accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`, null,
|
|
166
|
+
`Modelo LTV re-treinado com ${ltvTrainResult.samples} amostras`
|
|
167
|
+
).catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
console.log(`[Intelligence Agent] LTV model: ${ltvTrainResult.skipped || ltvTrainResult.error || 'sem dados'}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 5. Auto-decisão de winner no A/B LTV Test
|
|
174
|
+
try {
|
|
175
|
+
const abResult = await autoDecideAbWinner(env);
|
|
176
|
+
if (abResult?.decided) {
|
|
177
|
+
console.log(`[Intelligence Agent] A/B LTV winner auto-decidido: test_id=${abResult.test_id}, winner=${abResult.winner_name}`);
|
|
178
|
+
|
|
179
|
+
await sendIntelligenceAlert(env, 'info',
|
|
180
|
+
`A/B LTV Test — Winner Declarado Automaticamente`,
|
|
181
|
+
`🏆 Vencedor: ${abResult.winner_name}\n📈 Melhoria: +${abResult.improvement?.toFixed(1) ?? '?'}pp vs controle\n🆔 Test ID: ${abResult.test_id}\n\n✅ Prompt vencedor ativado automaticamente`
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
if (env.DB) {
|
|
185
|
+
await logIntelligence(env.DB, runType, 'ltv', 'ab_auto_winner', 'ok',
|
|
186
|
+
abResult.winner_name, null,
|
|
187
|
+
`A/B winner auto-decidido: test ${abResult.test_id}, melhoria ${abResult.improvement?.toFixed(1)}pp`
|
|
188
|
+
).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error('[Intelligence Agent] A/B auto-decide error:', err.message);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 6. Match Quality — análise + alertas
|
|
196
|
+
try {
|
|
197
|
+
const mqAnalysis = await analyzeMatchQuality(env);
|
|
198
|
+
if (mqAnalysis) {
|
|
199
|
+
console.log(`[Intelligence Agent] Match Quality: score=${mqAnalysis.composite_score ?? 0}%, alerts=${mqAnalysis.alerts?.length ?? 0}`);
|
|
200
|
+
await alertMatchQuality(env, mqAnalysis);
|
|
201
|
+
|
|
202
|
+
if (env.DB && mqAnalysis.total > 0) {
|
|
203
|
+
await logIntelligence(env.DB, runType, 'meta', 'match_quality', mqAnalysis.alerts?.length > 0 ? 'warning' : 'ok',
|
|
204
|
+
`${mqAnalysis.composite_score ?? 0}%`, '45%',
|
|
205
|
+
`Match quality 2h: email=${mqAnalysis.email_rate ?? 0}%, fbp=${mqAnalysis.fbp_rate ?? 0}%, score=${mqAnalysis.composite_score ?? 0}%`
|
|
206
|
+
).catch(() => {});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error('[Intelligence Agent] Match quality analysis error:', err.message);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 7. Auditoria mensal adicional
|
|
101
214
|
if (runType === 'monthly_audit') {
|
|
102
215
|
if (env.DB) {
|
|
103
216
|
try {
|
|
@@ -115,14 +228,18 @@ export async function runIntelligenceAgent(env, runType) {
|
|
|
115
228
|
} catch (err) {
|
|
116
229
|
console.error('LTV audit error:', err.message);
|
|
117
230
|
}
|
|
231
|
+
|
|
232
|
+
// Purge de logs antigos de match quality (> 30 dias)
|
|
233
|
+
await purgeOldMatchQualityLogs(env.DB);
|
|
234
|
+
console.log('[Intelligence Agent] Match quality logs antigos purgados');
|
|
118
235
|
}
|
|
119
236
|
}
|
|
120
237
|
|
|
121
|
-
//
|
|
238
|
+
// 8. Customer Match sync semanal
|
|
122
239
|
const cmResult = await syncMetaCustomAudience(env);
|
|
123
240
|
console.log(`[Intelligence Agent] Customer Match Meta: sent=${cmResult?.sent ?? 0}, received=${cmResult?.num_received ?? 0}`);
|
|
124
241
|
|
|
125
|
-
console.log(`[Intelligence Agent] ${runType} concluído`);
|
|
242
|
+
console.log(`[Intelligence Agent] ${runType} concluído — LTV model, A/B auto-decide, match quality, customer match`);
|
|
126
243
|
}
|
|
127
244
|
|
|
128
245
|
// ── syncMetaCustomAudience — D1 → Meta Custom Audiences ─────────────────────
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Logistic Regression (pure JS, sem deps externas)
|
|
3
|
+
* Treina modelo de predição de conversão com dados reais do D1.
|
|
4
|
+
*
|
|
5
|
+
* Features usadas (todas normalizadas 0-1):
|
|
6
|
+
* utm_source, engagement_score, intention_level, recency,
|
|
7
|
+
* has_email, has_phone, is_br, hour_normalized
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Feature Engineering ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const UTM_SCORES = {
|
|
13
|
+
facebook: 0.90, instagram: 0.90, meta: 0.90,
|
|
14
|
+
google: 0.82, youtube: 0.82,
|
|
15
|
+
tiktok: 0.75,
|
|
16
|
+
email: 0.68, sms: 0.68,
|
|
17
|
+
organic: 0.30,
|
|
18
|
+
direct: 0.20,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const INTENTION_SCORES = {
|
|
22
|
+
comprador: 1.00, high_intent: 1.00,
|
|
23
|
+
interessado: 0.60,
|
|
24
|
+
nurture: 0.30,
|
|
25
|
+
curioso: 0.15,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function extractFeatures(row) {
|
|
29
|
+
const src = (row.utm_source || '').toLowerCase().trim();
|
|
30
|
+
const intention = (row.intention_level || '').toLowerCase().trim();
|
|
31
|
+
const daysSince = row.days_since_lead || 0;
|
|
32
|
+
|
|
33
|
+
return [
|
|
34
|
+
UTM_SCORES[src] ?? (src ? 0.10 : 0.05), // utm_score
|
|
35
|
+
Math.min((row.engagement_score || 0) / 5, 1), // engagement (0-5 → 0-1)
|
|
36
|
+
INTENTION_SCORES[intention] ?? 0, // intention
|
|
37
|
+
Math.max(0, 1 - daysSince / 90), // recency (0=90 dias, 1=hoje)
|
|
38
|
+
row.has_email ? 1 : 0, // has_email
|
|
39
|
+
row.has_phone ? 1 : 0, // has_phone
|
|
40
|
+
row.is_br ? 1 : 0, // is_br
|
|
41
|
+
((row.hour || 12) / 23), // hour normalized
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Sigmoid ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function sigmoid(z) {
|
|
48
|
+
if (z > 20) return 1;
|
|
49
|
+
if (z < -20) return 0;
|
|
50
|
+
return 1 / (1 + Math.exp(-z));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dot(weights, features) {
|
|
54
|
+
return features.reduce((sum, f, i) => sum + (weights[i] || 0) * f, 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Treinamento ───────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Treina regressão logística com gradiente descendente.
|
|
61
|
+
* @param {Array<{features: number[], label: number}>} dataset
|
|
62
|
+
* @param {{ iterations?, learningRate?, lambda? }} opts
|
|
63
|
+
* @returns {{ bias, weights, accuracy, positiveRate }}
|
|
64
|
+
*/
|
|
65
|
+
export function trainLogisticRegression(dataset, opts = {}) {
|
|
66
|
+
if (!dataset || dataset.length < 50) {
|
|
67
|
+
return null; // dados insuficientes
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const iterations = opts.iterations || 200;
|
|
71
|
+
const learningRate = opts.learningRate || 0.1;
|
|
72
|
+
const lambda = opts.lambda || 0.01; // L2 regularization
|
|
73
|
+
const nFeatures = dataset[0].features.length;
|
|
74
|
+
|
|
75
|
+
let bias = 0;
|
|
76
|
+
let weights = new Array(nFeatures).fill(0);
|
|
77
|
+
|
|
78
|
+
const positives = dataset.filter(d => d.label === 1).length;
|
|
79
|
+
const positiveRate = positives / dataset.length;
|
|
80
|
+
|
|
81
|
+
// Se menos de 5% positivos, não treina (dados de compra insuficientes)
|
|
82
|
+
if (positiveRate < 0.03) return null;
|
|
83
|
+
|
|
84
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
85
|
+
let dBias = 0;
|
|
86
|
+
const dWeights = new Array(nFeatures).fill(0);
|
|
87
|
+
|
|
88
|
+
for (const { features, label } of dataset) {
|
|
89
|
+
const z = dot(weights, features) + bias;
|
|
90
|
+
const pred = sigmoid(z);
|
|
91
|
+
const error = pred - label;
|
|
92
|
+
|
|
93
|
+
dBias += error;
|
|
94
|
+
for (let j = 0; j < nFeatures; j++) {
|
|
95
|
+
dWeights[j] += error * features[j];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const n = dataset.length;
|
|
100
|
+
bias -= learningRate * (dBias / n);
|
|
101
|
+
for (let j = 0; j < nFeatures; j++) {
|
|
102
|
+
// L2: penaliza pesos grandes para evitar overfitting
|
|
103
|
+
weights[j] -= learningRate * ((dWeights[j] / n) + lambda * weights[j]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Calcular acurácia no conjunto de treino
|
|
108
|
+
let correct = 0;
|
|
109
|
+
const threshold = positiveRate > 0.3 ? 0.5 : Math.max(0.3, positiveRate * 1.5);
|
|
110
|
+
|
|
111
|
+
for (const { features, label } of dataset) {
|
|
112
|
+
const z = dot(weights, features) + bias;
|
|
113
|
+
const pred = sigmoid(z) >= threshold ? 1 : 0;
|
|
114
|
+
if (pred === label) correct++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const accuracy = correct / dataset.length;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
bias,
|
|
121
|
+
weights,
|
|
122
|
+
accuracy,
|
|
123
|
+
positiveRate,
|
|
124
|
+
sampleSize: dataset.length,
|
|
125
|
+
threshold,
|
|
126
|
+
featureNames: ['utm_score', 'engagement', 'intention', 'recency', 'has_email', 'has_phone', 'is_br', 'hour'],
|
|
127
|
+
trainedAt: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Inferência ────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Prediz score de conversão (0-100) usando pesos treinados.
|
|
135
|
+
* @param {{ bias, weights, threshold }} model
|
|
136
|
+
* @param {number[]} features
|
|
137
|
+
* @returns {number} score 0-100
|
|
138
|
+
*/
|
|
139
|
+
export function predictWithWeights(model, features) {
|
|
140
|
+
const z = dot(model.weights, features) + model.bias;
|
|
141
|
+
const prob = sigmoid(z);
|
|
142
|
+
return Math.round(prob * 100);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Helpers de persistência ───────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
export const LTV_WEIGHTS_KV_KEY = 'ltv_weights_active';
|
|
148
|
+
|
|
149
|
+
export async function loadActiveWeights(env) {
|
|
150
|
+
// 1. Tentar KV (cache ~7 dias)
|
|
151
|
+
if (env.GEO_CACHE) {
|
|
152
|
+
try {
|
|
153
|
+
const cached = await env.GEO_CACHE.get(LTV_WEIGHTS_KV_KEY, 'json');
|
|
154
|
+
if (cached?.weights?.length) return cached;
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 2. Fallback: D1
|
|
159
|
+
if (!env.DB) return null;
|
|
160
|
+
try {
|
|
161
|
+
const row = await env.DB.prepare(
|
|
162
|
+
`SELECT weights_json FROM ltv_model_weights WHERE is_active = 1 ORDER BY trained_at DESC LIMIT 1`
|
|
163
|
+
).first();
|
|
164
|
+
if (!row?.weights_json) return null;
|
|
165
|
+
const model = JSON.parse(row.weights_json);
|
|
166
|
+
|
|
167
|
+
// Popular KV para próximas requests
|
|
168
|
+
if (env.GEO_CACHE && model?.weights?.length) {
|
|
169
|
+
env.GEO_CACHE.put(LTV_WEIGHTS_KV_KEY, JSON.stringify(model), { expirationTtl: 604800 }).catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
return model;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function saveWeights(DB, model) {
|
|
178
|
+
if (!DB || !model) return;
|
|
179
|
+
const now = new Date().toISOString();
|
|
180
|
+
|
|
181
|
+
// Desativar modelo anterior
|
|
182
|
+
await DB.prepare(`UPDATE ltv_model_weights SET is_active = 0 WHERE is_active = 1`).run();
|
|
183
|
+
|
|
184
|
+
// Inserir novo como ativo
|
|
185
|
+
await DB.prepare(`
|
|
186
|
+
INSERT INTO ltv_model_weights (trained_at, is_active, sample_size, positive_rate, accuracy, weights_json)
|
|
187
|
+
VALUES (?, 1, ?, ?, ?, ?)
|
|
188
|
+
`).bind(
|
|
189
|
+
now,
|
|
190
|
+
model.sampleSize,
|
|
191
|
+
model.positiveRate,
|
|
192
|
+
model.accuracy,
|
|
193
|
+
JSON.stringify(model),
|
|
194
|
+
).run();
|
|
195
|
+
}
|
|
@@ -3,11 +3,43 @@
|
|
|
3
3
|
* predictLtv, getLtvAbVariation, recordAbAssignment, handlers /api/ltv/*
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { extractFeatures, predictWithWeights, loadActiveWeights } from './logistic.js';
|
|
7
|
+
|
|
6
8
|
// Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
|
|
7
9
|
const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
|
|
8
10
|
|
|
9
11
|
// ── predictLtv — Heurística em 5 dimensões (0-100 pts) ───────────────────────
|
|
10
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
|
+
|
|
11
43
|
let score = 0;
|
|
12
44
|
|
|
13
45
|
// 1. Engajamento browser (0–30)
|
|
@@ -318,3 +350,71 @@ export async function handleLtvAbTestWinner(env, request, headers) {
|
|
|
318
350
|
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
319
351
|
}
|
|
320
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
|
+
}
|
|
@@ -46,13 +46,13 @@ CREATE INDEX IF NOT EXISTS idx_profiles_email_updated
|
|
|
46
46
|
|
|
47
47
|
-- ── fraud_signals: dashboard e alertas ───────────────────────────────────────
|
|
48
48
|
|
|
49
|
-
-- handleFraudAlerts: filtra por ip + período
|
|
50
|
-
CREATE INDEX IF NOT EXISTS
|
|
51
|
-
ON fraud_signals(ip_address,
|
|
49
|
+
-- handleFraudAlerts: filtra por ip + período (coluna: detected_at)
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_fraud_ip_detected
|
|
51
|
+
ON fraud_signals(ip_address, detected_at DESC);
|
|
52
52
|
|
|
53
53
|
-- handleFraudStats: fraud_score >= threshold ordenado por data
|
|
54
|
-
CREATE INDEX IF NOT EXISTS
|
|
55
|
-
ON fraud_signals(fraud_score DESC,
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_fraud_score_detected
|
|
55
|
+
ON fraud_signals(fraud_score DESC, detected_at DESC);
|
|
56
56
|
|
|
57
57
|
-- ── ltv_ab_assignments: resultados de A/B test ───────────────────────────────
|
|
58
58
|
|
|
@@ -62,6 +62,6 @@ CREATE INDEX IF NOT EXISTS idx_ab_testid_class
|
|
|
62
62
|
|
|
63
63
|
-- ── ml_segment_members: join com leads para bidding ─────────────────────────
|
|
64
64
|
|
|
65
|
-
-- handleBiddingRecommend: segment_id lookup
|
|
65
|
+
-- handleBiddingRecommend: segment_id lookup (coluna: assigned_at)
|
|
66
66
|
CREATE INDEX IF NOT EXISTS idx_seg_members_segid
|
|
67
|
-
ON ml_segment_members(
|
|
67
|
+
ON ml_segment_members(cluster_id, assigned_at DESC);
|