cdp-edge 1.20.0 → 1.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-edge",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "CDP Edge - Quantum Tracking - Sistema multi-agente para tracking digital Cloudflare Native (Workers + D1)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -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. Auditoria mensal adicional
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
- // 5. Customer Match — sync semanal D1 → Meta Custom Audience
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
  // ─────────────────────────────────────────────────────────────────────────────