cdp-edge 2.0.4 → 2.0.5
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 +13 -0
|
@@ -70,8 +70,226 @@ function corsHeaders(origin, siteDomain) {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
// LOGISTIC REGRESSION — LTV Model (pure JS, sem deps externas)
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const _UTM_SCORES = {
|
|
78
|
+
facebook: 0.90, instagram: 0.90, meta: 0.90,
|
|
79
|
+
google: 0.82, youtube: 0.82, tiktok: 0.75,
|
|
80
|
+
email: 0.68, sms: 0.68, organic: 0.30, direct: 0.20,
|
|
81
|
+
};
|
|
82
|
+
const _INTENTION_SCORES = {
|
|
83
|
+
comprador: 1.00, high_intent: 1.00, interessado: 0.60, nurture: 0.30, curioso: 0.15,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function _extractFeatures(row) {
|
|
87
|
+
const src = (row.utm_source || '').toLowerCase().trim();
|
|
88
|
+
const intention = (row.intention_level || '').toLowerCase().trim();
|
|
89
|
+
const daysSince = row.days_since_lead || 0;
|
|
90
|
+
return [
|
|
91
|
+
_UTM_SCORES[src] ?? (src ? 0.10 : 0.05),
|
|
92
|
+
Math.min((row.engagement_score || 0) / 5, 1),
|
|
93
|
+
_INTENTION_SCORES[intention] ?? 0,
|
|
94
|
+
Math.max(0, 1 - daysSince / 90),
|
|
95
|
+
row.has_email ? 1 : 0,
|
|
96
|
+
row.has_phone ? 1 : 0,
|
|
97
|
+
row.is_br ? 1 : 0,
|
|
98
|
+
((row.hour || 12) / 23),
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _sigmoid(z) {
|
|
103
|
+
if (z > 20) return 1;
|
|
104
|
+
if (z < -20) return 0;
|
|
105
|
+
return 1 / (1 + Math.exp(-z));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function _dot(weights, features) {
|
|
109
|
+
return features.reduce((sum, f, i) => sum + (weights[i] || 0) * f, 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _trainLogisticRegression(dataset, opts = {}) {
|
|
113
|
+
if (!dataset || dataset.length < 50) return null;
|
|
114
|
+
const iterations = opts.iterations || 200;
|
|
115
|
+
const learningRate = opts.learningRate || 0.1;
|
|
116
|
+
const lambda = opts.lambda || 0.01;
|
|
117
|
+
const nFeatures = dataset[0].features.length;
|
|
118
|
+
let bias = 0;
|
|
119
|
+
let weights = new Array(nFeatures).fill(0);
|
|
120
|
+
const positives = dataset.filter(d => d.label === 1).length;
|
|
121
|
+
const positiveRate = positives / dataset.length;
|
|
122
|
+
if (positiveRate < 0.03) return null;
|
|
123
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
124
|
+
let dBias = 0;
|
|
125
|
+
const dWeights = new Array(nFeatures).fill(0);
|
|
126
|
+
for (const { features, label } of dataset) {
|
|
127
|
+
const error = _sigmoid(_dot(weights, features) + bias) - label;
|
|
128
|
+
dBias += error;
|
|
129
|
+
for (let j = 0; j < nFeatures; j++) dWeights[j] += error * features[j];
|
|
130
|
+
}
|
|
131
|
+
const n = dataset.length;
|
|
132
|
+
bias -= learningRate * (dBias / n);
|
|
133
|
+
for (let j = 0; j < nFeatures; j++) {
|
|
134
|
+
weights[j] -= learningRate * ((dWeights[j] / n) + lambda * weights[j]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let correct = 0;
|
|
138
|
+
const threshold = positiveRate > 0.3 ? 0.5 : Math.max(0.3, positiveRate * 1.5);
|
|
139
|
+
for (const { features, label } of dataset) {
|
|
140
|
+
if ((_sigmoid(_dot(weights, features) + bias) >= threshold ? 1 : 0) === label) correct++;
|
|
141
|
+
}
|
|
142
|
+
return { bias, weights, accuracy: correct / dataset.length, positiveRate, sampleSize: dataset.length, threshold, trainedAt: new Date().toISOString() };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _predictWithWeights(model, features) {
|
|
146
|
+
return Math.round(_sigmoid(_dot(model.weights, features) + model.bias) * 100);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const _LTV_WEIGHTS_KV_KEY = 'ltv_weights_active';
|
|
150
|
+
|
|
151
|
+
async function _loadActiveWeights(env) {
|
|
152
|
+
if (env.GEO_CACHE) {
|
|
153
|
+
try {
|
|
154
|
+
const cached = await env.GEO_CACHE.get(_LTV_WEIGHTS_KV_KEY, 'json');
|
|
155
|
+
if (cached?.weights?.length) return cached;
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
if (!env.DB) return null;
|
|
159
|
+
try {
|
|
160
|
+
const row = await env.DB.prepare(
|
|
161
|
+
`SELECT weights_json FROM ltv_model_weights WHERE is_active = 1 ORDER BY trained_at DESC LIMIT 1`
|
|
162
|
+
).first();
|
|
163
|
+
if (!row?.weights_json) return null;
|
|
164
|
+
const model = JSON.parse(row.weights_json);
|
|
165
|
+
if (env.GEO_CACHE && model?.weights?.length) {
|
|
166
|
+
env.GEO_CACHE.put(_LTV_WEIGHTS_KV_KEY, JSON.stringify(model), { expirationTtl: 604800 }).catch(() => {});
|
|
167
|
+
}
|
|
168
|
+
return model;
|
|
169
|
+
} catch { return null; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function _saveWeights(DB, model) {
|
|
173
|
+
if (!DB || !model) return;
|
|
174
|
+
await DB.prepare(`UPDATE ltv_model_weights SET is_active = 0 WHERE is_active = 1`).run();
|
|
175
|
+
await DB.prepare(`
|
|
176
|
+
INSERT INTO ltv_model_weights (trained_at, is_active, sample_size, positive_rate, accuracy, weights_json)
|
|
177
|
+
VALUES (?, 1, ?, ?, ?, ?)
|
|
178
|
+
`).bind(new Date().toISOString(), model.sampleSize, model.positiveRate, model.accuracy, JSON.stringify(model)).run();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// MATCH QUALITY — Tracking de qualidade dos dados enviados ao Meta CAPI
|
|
183
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
const _MQ_THRESHOLDS = {
|
|
186
|
+
email_rate_min: 0.40, fbp_rate_min: 0.30, composite_min: 0.45, min_events_alert: 10,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
async function _logMatchQuality(DB, eventName, payload, recovered = {}) {
|
|
190
|
+
if (!DB) return;
|
|
191
|
+
try {
|
|
192
|
+
await DB.prepare(`
|
|
193
|
+
INSERT INTO match_quality_log (
|
|
194
|
+
event_name, has_email, has_phone, has_fbp, has_fbc, has_external_id,
|
|
195
|
+
was_email_recovered, was_utm_restored
|
|
196
|
+
) VALUES (?,?,?,?,?,?,?,?)
|
|
197
|
+
`).bind(
|
|
198
|
+
eventName,
|
|
199
|
+
payload.email ? 1 : 0, payload.phone ? 1 : 0,
|
|
200
|
+
payload.fbp ? 1 : 0, payload.fbc ? 1 : 0,
|
|
201
|
+
payload.userId ? 1 : 0,
|
|
202
|
+
recovered.email ? 1 : 0, recovered.utm ? 1 : 0,
|
|
203
|
+
).run();
|
|
204
|
+
} catch { /* não bloquear dispatch */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function _autoEnrichPayload(env, payload) {
|
|
208
|
+
const recovered = { email: false, utm: false };
|
|
209
|
+
if (!env.DB) return { payload, recovered };
|
|
210
|
+
if (!payload.email && payload.userId) {
|
|
211
|
+
try {
|
|
212
|
+
const profile = await env.DB.prepare(
|
|
213
|
+
`SELECT email, fbp, fbc, phone FROM user_profiles WHERE user_id = ? LIMIT 1`
|
|
214
|
+
).bind(payload.userId).first();
|
|
215
|
+
if (profile) {
|
|
216
|
+
if (profile.email && !payload.email) { payload.email = profile.email; recovered.email = true; }
|
|
217
|
+
if (profile.fbp && !payload.fbp) payload.fbp = profile.fbp;
|
|
218
|
+
if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc;
|
|
219
|
+
if (profile.phone && !payload.phone) payload.phone = profile.phone;
|
|
220
|
+
}
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
if (payload.utmRestored) recovered.utm = true;
|
|
224
|
+
return { payload, recovered };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function _analyzeMatchQuality(env) {
|
|
228
|
+
if (!env.DB) return null;
|
|
229
|
+
try {
|
|
230
|
+
const row = await env.DB.prepare(`
|
|
231
|
+
SELECT
|
|
232
|
+
COUNT(*) AS total,
|
|
233
|
+
ROUND(AVG(has_email) * 100, 1) AS email_rate,
|
|
234
|
+
ROUND(AVG(has_phone) * 100, 1) AS phone_rate,
|
|
235
|
+
ROUND(AVG(has_fbp) * 100, 1) AS fbp_rate,
|
|
236
|
+
ROUND(AVG(has_fbc) * 100, 1) AS fbc_rate,
|
|
237
|
+
ROUND(AVG(has_external_id) * 100, 1) AS ext_id_rate,
|
|
238
|
+
ROUND(AVG(was_email_recovered) * 100, 1) AS email_recovered_rate,
|
|
239
|
+
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
|
|
240
|
+
FROM match_quality_log
|
|
241
|
+
WHERE logged_at >= datetime('now', '-2 hours')
|
|
242
|
+
`).first();
|
|
243
|
+
if (!row || row.total < _MQ_THRESHOLDS.min_events_alert) return { total: row?.total || 0, alerts: [] };
|
|
244
|
+
const alerts = [];
|
|
245
|
+
if ((row.email_rate || 0) < _MQ_THRESHOLDS.email_rate_min * 100)
|
|
246
|
+
alerts.push({ type: 'email_low', message: `Taxa de email baixa: ${row.email_rate}%` });
|
|
247
|
+
if ((row.fbp_rate || 0) < _MQ_THRESHOLDS.fbp_rate_min * 100)
|
|
248
|
+
alerts.push({ type: 'fbp_low', message: `Cookie fbp ausente em ${100 - row.fbp_rate}% dos eventos` });
|
|
249
|
+
if ((row.composite_score || 0) < _MQ_THRESHOLDS.composite_min * 100)
|
|
250
|
+
alerts.push({ type: 'composite_critical', message: `Score composto crítico: ${row.composite_score}%`, severity: 'critical' });
|
|
251
|
+
return { ...row, alerts };
|
|
252
|
+
} catch (err) { console.error('[MatchQuality] analyze error:', err.message); return null; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function _alertMatchQuality(env, analysis) {
|
|
256
|
+
if (!analysis || analysis.alerts.length === 0) return;
|
|
257
|
+
const hasCritical = analysis.alerts.some(a => a.severity === 'critical');
|
|
258
|
+
const icon = hasCritical ? '🚨' : '⚠️';
|
|
259
|
+
const lines = [
|
|
260
|
+
`${icon} CDP Edge — Match Quality Alert`,
|
|
261
|
+
``,
|
|
262
|
+
`📊 Últimas 2h (${analysis.total} eventos):`,
|
|
263
|
+
` Email: ${analysis.email_rate ?? 0}% ${(analysis.email_rate ?? 0) < 40 ? '❌' : '✅'}`,
|
|
264
|
+
` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
|
|
265
|
+
` Score: ${analysis.composite_score ?? 0}%`,
|
|
266
|
+
``,
|
|
267
|
+
`🔍 Problemas:`,
|
|
268
|
+
...analysis.alerts.map(a => ` · ${a.message}`),
|
|
269
|
+
``,
|
|
270
|
+
`🛠 Identity Graph recovery: ${analysis.email_recovered_rate ?? 0}% emails recuperados`,
|
|
271
|
+
new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' }),
|
|
272
|
+
];
|
|
273
|
+
await sendCallMeBot(env, lines.join('\n'));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function _purgeOldMatchQualityLogs(DB) {
|
|
277
|
+
if (!DB) return;
|
|
278
|
+
try {
|
|
279
|
+
await DB.prepare(`DELETE FROM match_quality_log WHERE logged_at < datetime('now', '-30 days')`).run();
|
|
280
|
+
} catch {}
|
|
281
|
+
}
|
|
282
|
+
|
|
73
283
|
// ── Meta CAPI v22.0 ───────────────────────────────────────────────────────────
|
|
74
284
|
async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
285
|
+
// Auto-enriquecer payload com dados do Identity Graph antes do envio
|
|
286
|
+
let recovered = { email: false, utm: false };
|
|
287
|
+
if (env.DB && payload) {
|
|
288
|
+
const enriched = await _autoEnrichPayload(env, payload);
|
|
289
|
+
payload = enriched.payload;
|
|
290
|
+
recovered = enriched.recovered;
|
|
291
|
+
}
|
|
292
|
+
|
|
75
293
|
const {
|
|
76
294
|
email, phone, firstName, lastName,
|
|
77
295
|
city, state, country,
|
|
@@ -137,6 +355,13 @@ async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
|
137
355
|
requestBody.test_event_code = env.META_TEST_CODE;
|
|
138
356
|
}
|
|
139
357
|
|
|
358
|
+
// Logar match quality em background (não bloqueia dispatch)
|
|
359
|
+
if (env.DB && ctx) {
|
|
360
|
+
ctx.waitUntil(_logMatchQuality(env.DB, eventName, payload, recovered));
|
|
361
|
+
} else if (env.DB) {
|
|
362
|
+
_logMatchQuality(env.DB, eventName, payload, recovered).catch(() => {});
|
|
363
|
+
}
|
|
364
|
+
|
|
140
365
|
const endpoint = `https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events`;
|
|
141
366
|
|
|
142
367
|
try {
|
|
@@ -1569,6 +1794,30 @@ async function upsertLtvProfile(env, userId, ltv) {
|
|
|
1569
1794
|
* value: valor em BRL (base × multiplicador da classe)
|
|
1570
1795
|
*/
|
|
1571
1796
|
async function predictLtv(env, payload, request, customSystemPrompt = null) {
|
|
1797
|
+
// 0. Tentar modelo treinado (regressão logística via D1/KV)
|
|
1798
|
+
try {
|
|
1799
|
+
const model = await _loadActiveWeights(env);
|
|
1800
|
+
if (model?.weights?.length) {
|
|
1801
|
+
const hour = new Date().getUTCHours();
|
|
1802
|
+
const country = (payload.country || request.cf?.country || '').toUpperCase();
|
|
1803
|
+
const features = _extractFeatures({
|
|
1804
|
+
utm_source: payload.utmSource,
|
|
1805
|
+
engagement_score: parseFloat(payload.engagementScore || 0),
|
|
1806
|
+
intention_level: payload.intentionLevel,
|
|
1807
|
+
days_since_lead: 0,
|
|
1808
|
+
has_email: !!payload.email,
|
|
1809
|
+
has_phone: !!payload.phone,
|
|
1810
|
+
is_br: country === 'BR',
|
|
1811
|
+
hour,
|
|
1812
|
+
});
|
|
1813
|
+
const score100 = _predictWithWeights(model, features);
|
|
1814
|
+
const ltvClass = score100 >= 70 ? 'High' : score100 >= 40 ? 'Medium' : 'Low';
|
|
1815
|
+
const multiplier = ltvClass === 'High' ? 3.5 : ltvClass === 'Medium' ? 1.8 : 0.8;
|
|
1816
|
+
const base = payload.value ? parseFloat(payload.value) : 197;
|
|
1817
|
+
return { score: score100, class: ltvClass, value: Math.round(base * multiplier * 100) / 100, source: 'model' };
|
|
1818
|
+
}
|
|
1819
|
+
} catch { /* fallback heurístico */ }
|
|
1820
|
+
|
|
1572
1821
|
let score = 0;
|
|
1573
1822
|
|
|
1574
1823
|
// 1. Engajamento browser (0–30)
|
|
@@ -1887,6 +2136,93 @@ async function auditErrorRates(env, runType) {
|
|
|
1887
2136
|
return alerts;
|
|
1888
2137
|
}
|
|
1889
2138
|
|
|
2139
|
+
// ── Treinar modelo LTV com dados reais do D1 ─────────────────────────────────
|
|
2140
|
+
async function _trainLtvModel(env) {
|
|
2141
|
+
if (!env.DB) return { skipped: 'DB não disponível' };
|
|
2142
|
+
try {
|
|
2143
|
+
const rows = await env.DB.prepare(`
|
|
2144
|
+
SELECT
|
|
2145
|
+
l.utm_source, l.engagement_score, l.intention_level,
|
|
2146
|
+
CAST(julianday('now') - julianday(l.created_at) AS INTEGER) AS days_since_lead,
|
|
2147
|
+
CASE WHEN l.email IS NOT NULL AND l.email != '' THEN 1 ELSE 0 END AS has_email,
|
|
2148
|
+
CASE WHEN l.phone IS NOT NULL AND l.phone != '' THEN 1 ELSE 0 END AS has_phone,
|
|
2149
|
+
CASE WHEN (l.country = 'br' OR l.country = 'BR' OR l.country IS NULL) THEN 1 ELSE 0 END AS is_br,
|
|
2150
|
+
CAST(strftime('%H', l.created_at) AS INTEGER) AS hour,
|
|
2151
|
+
CASE WHEN EXISTS (
|
|
2152
|
+
SELECT 1 FROM events e
|
|
2153
|
+
WHERE e.user_id = l.user_id
|
|
2154
|
+
AND e.event_name IN ('Purchase','purchase','PURCHASE')
|
|
2155
|
+
AND e.created_at > l.created_at
|
|
2156
|
+
) THEN 1 ELSE 0 END AS label
|
|
2157
|
+
FROM leads l
|
|
2158
|
+
WHERE l.created_at >= datetime('now', '-90 days')
|
|
2159
|
+
LIMIT 5000
|
|
2160
|
+
`).all();
|
|
2161
|
+
|
|
2162
|
+
const dataset = (rows.results || []).map(row => ({ features: _extractFeatures(row), label: row.label || 0 }));
|
|
2163
|
+
const model = _trainLogisticRegression(dataset);
|
|
2164
|
+
|
|
2165
|
+
if (!model) {
|
|
2166
|
+
console.log('[LTV Train] Dados insuficientes');
|
|
2167
|
+
return { skipped: 'dados insuficientes', samples: dataset.length };
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
await _saveWeights(env.DB, model);
|
|
2171
|
+
if (env.GEO_CACHE) env.GEO_CACHE.delete(_LTV_WEIGHTS_KV_KEY).catch(() => {});
|
|
2172
|
+
|
|
2173
|
+
console.log(`[LTV Train] Modelo treinado: ${dataset.length} samples, accuracy=${(model.accuracy * 100).toFixed(1)}%`);
|
|
2174
|
+
return { trained: true, samples: dataset.length, accuracy: model.accuracy, positiveRate: model.positiveRate };
|
|
2175
|
+
} catch (err) {
|
|
2176
|
+
console.error('[LTV Train] Erro:', err.message);
|
|
2177
|
+
return { error: err.message };
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// ── Auto-decisão de winner no A/B LTV Test ────────────────────────────────────
|
|
2182
|
+
const _AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
|
|
2183
|
+
|
|
2184
|
+
async function _autoDecideAbWinner(env) {
|
|
2185
|
+
if (!env.DB) return null;
|
|
2186
|
+
try {
|
|
2187
|
+
const test = await env.DB.prepare(`
|
|
2188
|
+
SELECT id, min_sample_size FROM ltv_ab_tests WHERE status = 'running' ORDER BY created_at DESC LIMIT 1
|
|
2189
|
+
`).first();
|
|
2190
|
+
if (!test) return { decided: false };
|
|
2191
|
+
|
|
2192
|
+
const variations = await env.DB.prepare(`
|
|
2193
|
+
SELECT id, name, is_control, sample_count, accuracy_score
|
|
2194
|
+
FROM ltv_ab_variations WHERE test_id = ?
|
|
2195
|
+
`).bind(test.id).all();
|
|
2196
|
+
|
|
2197
|
+
const vars = variations.results || [];
|
|
2198
|
+
if (vars.some(v => (v.sample_count || 0) < (test.min_sample_size || 50))) return { decided: false };
|
|
2199
|
+
|
|
2200
|
+
const control = vars.find(v => v.is_control) || vars[0];
|
|
2201
|
+
const best = vars.reduce((a, b) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a, control);
|
|
2202
|
+
|
|
2203
|
+
if (best.id === control.id) return { decided: false };
|
|
2204
|
+
|
|
2205
|
+
const improvement = (best.accuracy_score || 0) - (control.accuracy_score || 0);
|
|
2206
|
+
if (improvement < 5) return { decided: false };
|
|
2207
|
+
|
|
2208
|
+
await env.DB.prepare(`
|
|
2209
|
+
UPDATE ltv_ab_tests SET status='completed', winner_id=?, auto_decided_at=datetime('now'), auto_decided_reason=?
|
|
2210
|
+
WHERE id=?
|
|
2211
|
+
`).bind(best.id, `Auto: +${improvement.toFixed(1)}pp vs control`, test.id).run();
|
|
2212
|
+
|
|
2213
|
+
if (env.GEO_CACHE) env.GEO_CACHE.delete(_AB_LTV_CACHE_KEY).catch(() => {});
|
|
2214
|
+
|
|
2215
|
+
const winnerVar = await env.DB.prepare(
|
|
2216
|
+
`SELECT system_prompt FROM ltv_ab_variations WHERE id = ?`
|
|
2217
|
+
).bind(best.id).first();
|
|
2218
|
+
|
|
2219
|
+
return { decided: true, test_id: test.id, winner_name: best.name, improvement, winning_prompt: winnerVar?.system_prompt };
|
|
2220
|
+
} catch (err) {
|
|
2221
|
+
console.error('[AB Auto-Decide] Erro:', err.message);
|
|
2222
|
+
return null;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
1890
2226
|
// ── Runner principal do Intelligence Agent ────────────────────────────────────
|
|
1891
2227
|
async function runIntelligenceAgent(env, runType) {
|
|
1892
2228
|
console.log(`[Intelligence Agent] Iniciando ${runType}`);
|
|
@@ -1907,9 +2243,45 @@ async function runIntelligenceAgent(env, runType) {
|
|
|
1907
2243
|
console.warn(`[Intelligence Agent] ${errorAlerts.length} alertas de taxa de erro enviados`);
|
|
1908
2244
|
}
|
|
1909
2245
|
|
|
1910
|
-
// 4.
|
|
2246
|
+
// 4. Treinar modelo LTV com dados reais do D1 (toda semana)
|
|
2247
|
+
const ltvTrainResult = await _trainLtvModel(env);
|
|
2248
|
+
if (ltvTrainResult.trained) {
|
|
2249
|
+
console.log(`[Intelligence Agent] LTV model treinado: accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`);
|
|
2250
|
+
if (env.DB) {
|
|
2251
|
+
logIntelligence(env.DB, runType, 'ltv', 'model_training', 'ok',
|
|
2252
|
+
`accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`, null,
|
|
2253
|
+
`Modelo LTV re-treinado com ${ltvTrainResult.samples} amostras`
|
|
2254
|
+
).catch(() => {});
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// 5. Auto-decisão de winner no A/B LTV Test
|
|
2259
|
+
try {
|
|
2260
|
+
const abResult = await _autoDecideAbWinner(env);
|
|
2261
|
+
if (abResult?.decided) {
|
|
2262
|
+
console.log(`[Intelligence Agent] A/B winner declarado: ${abResult.winner_name}, +${abResult.improvement?.toFixed(1)}pp`);
|
|
2263
|
+
await sendIntelligenceAlert(env, 'info',
|
|
2264
|
+
`A/B LTV Test — Winner Declarado`,
|
|
2265
|
+
`🏆 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`
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2268
|
+
} catch (err) {
|
|
2269
|
+
console.error('[Intelligence Agent] A/B auto-decide error:', err.message);
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// 6. Match Quality — análise + alertas
|
|
2273
|
+
try {
|
|
2274
|
+
const mqAnalysis = await _analyzeMatchQuality(env);
|
|
2275
|
+
if (mqAnalysis) {
|
|
2276
|
+
console.log(`[Intelligence Agent] Match Quality: score=${mqAnalysis.composite_score ?? 0}%, alerts=${mqAnalysis.alerts?.length ?? 0}`);
|
|
2277
|
+
await _alertMatchQuality(env, mqAnalysis);
|
|
2278
|
+
}
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
console.error('[Intelligence Agent] Match quality error:', err.message);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// 7. Auditoria mensal adicional
|
|
1911
2284
|
if (runType === 'monthly_audit') {
|
|
1912
|
-
// Verificar LTV: quantos perfis High vs Low no último mês
|
|
1913
2285
|
if (env.DB) {
|
|
1914
2286
|
try {
|
|
1915
2287
|
const ltvStats = await env.DB.prepare(`
|
|
@@ -1927,14 +2299,18 @@ async function runIntelligenceAgent(env, runType) {
|
|
|
1927
2299
|
} catch (err) {
|
|
1928
2300
|
console.error('LTV audit error:', err.message);
|
|
1929
2301
|
}
|
|
2302
|
+
|
|
2303
|
+
// Purge de logs antigos de match quality (> 30 dias)
|
|
2304
|
+
await _purgeOldMatchQualityLogs(env.DB);
|
|
2305
|
+
console.log('[Intelligence Agent] Match quality logs antigos purgados');
|
|
1930
2306
|
}
|
|
1931
2307
|
}
|
|
1932
2308
|
|
|
1933
|
-
//
|
|
2309
|
+
// 8. Customer Match — sync semanal D1 → Meta Custom Audience
|
|
1934
2310
|
const cmResult = await syncMetaCustomAudience(env);
|
|
1935
2311
|
console.log(`[Intelligence Agent] Customer Match Meta: sent=${cmResult?.sent ?? 0}, received=${cmResult?.num_received ?? 0}`);
|
|
1936
2312
|
|
|
1937
|
-
console.log(`[Intelligence Agent] ${runType} concluído`);
|
|
2313
|
+
console.log(`[Intelligence Agent] ${runType} concluído — LTV model, A/B auto-decide, match quality, customer match`);
|
|
1938
2314
|
}
|
|
1939
2315
|
|
|
1940
2316
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -3404,6 +3780,21 @@ export default {
|
|
|
3404
3780
|
|
|
3405
3781
|
const url = new URL(request.url);
|
|
3406
3782
|
|
|
3783
|
+
// ── Rate Limiter — camada 0, antes do Fraud Gate ─────────────────────────
|
|
3784
|
+
// Bloqueia na borda por IP antes de qualquer CPU ser consumida
|
|
3785
|
+
// Silent drop (200) — atacante não sabe que foi bloqueado
|
|
3786
|
+
// Requer binding RATE_LIMITER no wrangler.toml (Workers Paid)
|
|
3787
|
+
// Fail-open: se binding não existir, deixa passar (não quebra o fluxo)
|
|
3788
|
+
if (url.pathname === '/track' && request.method === 'POST' && env.RATE_LIMITER) {
|
|
3789
|
+
const ip = request.headers.get('CF-Connecting-IP')
|
|
3790
|
+
|| request.headers.get('X-Forwarded-For')?.split(',')[0].trim()
|
|
3791
|
+
|| '0.0.0.0';
|
|
3792
|
+
const { success } = await env.RATE_LIMITER.limit({ key: ip });
|
|
3793
|
+
if (!success) {
|
|
3794
|
+
return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3407
3798
|
// ── Fraud Gate — Fase 4 (apenas em /track e /api) ────────────────────────
|
|
3408
3799
|
// Roda ANTES de qualquer processamento de evento
|
|
3409
3800
|
// Silent drop (200) — bots não sabem que foram detectados
|
|
@@ -82,6 +82,19 @@ crons = ["0 2 * * 7", "0 3 1 * *"]
|
|
|
82
82
|
[ai]
|
|
83
83
|
binding = "AI"
|
|
84
84
|
|
|
85
|
+
# ── Rate Limiting — proteção do /track contra abuso de cota ──────────────────
|
|
86
|
+
# Bloqueia na borda ANTES de qualquer processamento do Worker
|
|
87
|
+
# Limite: 60 requisições por minuto por IP (generoso para usuário real)
|
|
88
|
+
# Requer Workers Paid plan ($5/mês) — remover bloco se usar plano free
|
|
89
|
+
[[unsafe.bindings]]
|
|
90
|
+
name = "RATE_LIMITER"
|
|
91
|
+
type = "ratelimit"
|
|
92
|
+
namespace_id = "1001"
|
|
93
|
+
|
|
94
|
+
[unsafe.bindings.simple]
|
|
95
|
+
limit = 60
|
|
96
|
+
period = 60
|
|
97
|
+
|
|
85
98
|
# ── Secrets (NÃO ficam aqui — configurar via CLI) ─────────────────────────────
|
|
86
99
|
# wrangler secret put META_ACCESS_TOKEN ← token Meta CAPI (obrigatório)
|
|
87
100
|
# wrangler secret put GA4_API_SECRET ← secret GA4 Measurement Protocol (obrigatório)
|