cdp-edge 1.20.0 → 1.21.1

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.
@@ -113,10 +113,21 @@ wrangler d1 execute cdp-edge-db --file=schema-ab-ltv.sql --remote
113
113
 
114
114
  # Fase 4: Fraud Detection
115
115
  wrangler d1 execute cdp-edge-db --file=schema-fraud.sql --remote
116
+
117
+ # Índices compostos de performance (queries D1)
118
+ wrangler d1 execute cdp-edge-db --file=schema-indexes.sql --remote
119
+
120
+ # Fase 5: LTV Model (regressão logística) + Match Quality Log
121
+ wrangler d1 execute cdp-edge-db --file=migrate-v7.sql --remote
116
122
  ```
117
123
 
118
124
  Após cada migração: confirmar sucesso antes de prosseguir.
119
125
 
126
+ > **Fase 5 cria duas tabelas críticas:**
127
+ > - `ltv_model_weights` — pesos do modelo LTV treinado semanalmente pelo cron
128
+ > - `match_quality_log` — registra flags de qualidade de dados (has_email, has_fbp, etc.) a cada CAPI dispatch
129
+ > Sem essas tabelas: o modelo LTV não persiste e o Match Quality Alert não funciona.
130
+
120
131
  ---
121
132
 
122
133
  ## PROCEDURE `*rollback`
@@ -306,6 +306,33 @@ async function checkApiDepreciations(env) {
306
306
 
307
307
  ---
308
308
 
309
+ ### MONITORAMENTO AUTOMÁTICO DE MATCH QUALITY (CRON SEMANAL)
310
+
311
+ O Intelligence Agent monitora a qualidade dos dados enviados ao Meta CAPI a cada execução do cron.
312
+ Os dados são lidos da tabela `match_quality_log` (populada automaticamente pelo Worker a cada dispatch).
313
+
314
+ **Thresholds obrigatórios — alertar via CallMeBot se:**
315
+
316
+ | Métrica | Threshold mínimo | Ação automática |
317
+ |---|---|---|
318
+ | `email_rate` | 40% dos eventos com email | Alerta ⚠️ — verificar Identity Graph |
319
+ | `fbp_rate` | 30% dos eventos com cookie fbp | Alerta ⚠️ — verificar cdpTrack.js |
320
+ | `composite_score` | 45% (email×0.4 + fbp×0.3 + phone×0.2 + fbc×0.1) | Alerta 🚨 CRÍTICO |
321
+
322
+ **O cron semanal executa automaticamente (sem intervenção manual):**
323
+ 1. `_trainLtvModel(env)` — re-treina regressão logística com últimos 5000 leads do D1; pesos salvos em `ltv_model_weights` e cache KV invalidado
324
+ 2. `_autoDecideAbWinner(env)` — declara winner de A/B LTV se melhoria ≥ 5pp vs controle; alerta WhatsApp automático
325
+ 3. `_analyzeMatchQuality(env)` — analisa janela 2h; dispara alertas CallMeBot se abaixo dos thresholds
326
+ 4. `syncMetaCustomAudience(env)` — sincroniza leads high_intent com Meta Custom Audience
327
+
328
+ **Auto-recuperação integrada ao dispatch:**
329
+ - Antes de cada envio ao Meta CAPI, o Worker tenta enriquecer automaticamente o payload consultando o Identity Graph pelo `userId` (recupera email, fbp, fbc, phone ausentes)
330
+ - Resultado de recuperação é logado em `match_quality_log.was_email_recovered`
331
+
332
+ **Pré-requisito de infraestrutura:** `migrate-v7.sql` deve estar aplicada no D1 (tabelas `ltv_model_weights` + `match_quality_log`).
333
+
334
+ ---
335
+
309
336
  ### CHECK DE NOVOS PARÂMETROS DE EVENT MATCH QUALITY
310
337
 
311
338
  O Intelligence Agent DEVE buscar novos parâmetros que melhoram a nota de atribuição:
@@ -1285,7 +1285,7 @@ Spawnar o **Intelligence Agent** para realizar auditoria completa da stack:
1285
1285
 
1286
1286
  **2. Infraestrutura Cloudflare**
1287
1287
  - `wrangler.toml` — bindings D1, KV, Queue, AI estão todos declarados
1288
- - `schema.sql` e migrations — todas as fases (core, segmentation, bidding, ab-ltv, fraud) aplicadas
1288
+ - `schema.sql` e migrations — todas as fases aplicadas na ordem: core segmentation bidding ab-ltv fraud → schema-indexes → **migrate-v7** (LTV model + Match Quality)
1289
1289
  - Worker.js — endpoints ativos correspondem à arquitetura esperada
1290
1290
 
1291
1291
  **3. Conformidade e Qualidade de Sinal**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-edge",
3
- "version": "1.20.0",
3
+ "version": "1.21.1",
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",
@@ -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 idx_fraud_ip_created
51
- ON fraud_signals(ip_address, created_at DESC);
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 idx_fraud_score_created
55
- ON fraud_signals(fraud_score DESC, created_at 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(segment_id, joined_at DESC);
67
+ ON ml_segment_members(cluster_id, assigned_at DESC);
@@ -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
  // ─────────────────────────────────────────────────────────────────────────────