cdp-edge 1.23.2 → 1.24.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.
Files changed (42) hide show
  1. package/README.md +82 -21
  2. package/bin/cdp-edge.js +10 -1
  3. package/contracts/agent-versions.json +42 -41
  4. package/contracts/types.ts +81 -0
  5. package/dist/commands/install.js +6 -1
  6. package/dist/commands/server.js +4 -4
  7. package/docs/whatsapp-ctwa.md +3 -2
  8. package/extracted-skill/tracking-events-generator/agents/database-agent.md +5 -4
  9. package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +0 -1
  10. package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +1 -1
  11. package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +4 -4
  12. package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +81 -70
  13. package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +6 -2
  14. package/extracted-skill/tracking-events-generator/cdpTrack.js +7 -0
  15. package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
  16. package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
  17. package/package.json +9 -5
  18. package/server-edge-tracker/INSTALAR.md +5 -5
  19. package/server-edge-tracker/{index.js → index.ts} +186 -72
  20. package/server-edge-tracker/modules/{db.js → db.ts} +180 -69
  21. package/server-edge-tracker/modules/dispatch/{ga4.js → ga4.ts} +12 -10
  22. package/server-edge-tracker/modules/dispatch/meta.ts +138 -0
  23. package/server-edge-tracker/modules/dispatch/{platforms.js → platforms.ts} +58 -56
  24. package/server-edge-tracker/modules/dispatch/{tiktok.js → tiktok.ts} +22 -20
  25. package/server-edge-tracker/modules/dispatch/{whatsapp.js → whatsapp.ts} +59 -25
  26. package/server-edge-tracker/modules/{intelligence.js → intelligence.ts} +175 -60
  27. package/server-edge-tracker/modules/ml/{bidding.js → bidding.ts} +37 -35
  28. package/server-edge-tracker/modules/ml/{fraud.js → fraud.ts} +49 -56
  29. package/server-edge-tracker/modules/ml/{logistic.js → logistic.ts} +44 -19
  30. package/server-edge-tracker/modules/ml/{ltv.js → ltv.ts} +179 -83
  31. package/server-edge-tracker/modules/ml/{matchquality.js → matchquality.ts} +70 -26
  32. package/server-edge-tracker/modules/ml/segmentation.ts +407 -0
  33. package/server-edge-tracker/modules/utils.ts +186 -0
  34. package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
  35. package/server-edge-tracker/types.ts +251 -0
  36. package/server-edge-tracker/wrangler.toml +24 -6
  37. package/templates/lancamento-imobiliario.md +344 -0
  38. package/docs/PixelBuilder-Documentacao-Completa (2).docx +0 -0
  39. package/server-edge-tracker/modules/dispatch/meta.js +0 -119
  40. package/server-edge-tracker/modules/ml/segmentation.js +0 -316
  41. package/server-edge-tracker/modules/utils.js +0 -89
  42. package/server-edge-tracker/worker.js +0 -4577
@@ -6,9 +6,43 @@
6
6
  */
7
7
 
8
8
  import { sendCallMeBot } from '../dispatch/whatsapp.js';
9
+ import { Env, TrackPayload } from '../../types.js';
10
+ import { D1Database } from '@cloudflare/workers-types';
11
+
12
+ // ── Tipos ───────────────────────────────────────────────────────────────────────
13
+ export interface MatchQualityThresholds {
14
+ email_rate_min: number;
15
+ fbp_rate_min: number;
16
+ composite_min: number;
17
+ min_events_alert: number;
18
+ }
19
+
20
+ export interface EnrichedPayloadResult {
21
+ payload: TrackPayload;
22
+ recovered: { email: boolean; utm: boolean };
23
+ }
24
+
25
+ export interface MatchQualityAlert {
26
+ type: string;
27
+ metric: string;
28
+ message: string;
29
+ severity?: 'critical' | 'warning';
30
+ }
31
+
32
+ export interface MatchQualityAnalysis {
33
+ total?: number;
34
+ email_rate?: number;
35
+ phone_rate?: number;
36
+ fbp_rate?: number;
37
+ fbc_rate?: number;
38
+ ext_id_rate?: number;
39
+ email_recovered_rate?: number;
40
+ composite_score?: number;
41
+ alerts: MatchQualityAlert[];
42
+ }
9
43
 
10
44
  // ── Thresholds de alerta ──────────────────────────────────────────────────────
11
- const THRESHOLDS = {
45
+ const THRESHOLDS: MatchQualityThresholds = {
12
46
  email_rate_min: 0.40, // < 40% dos eventos com email → alerta
13
47
  fbp_rate_min: 0.30, // < 30% com fbp cookie → alerta
14
48
  composite_min: 0.45, // < 45% score composto → alerta crítico
@@ -20,7 +54,7 @@ const THRESHOLDS = {
20
54
  /**
21
55
  * Registra flags de qualidade de um evento no D1 (background, não bloqueia).
22
56
  */
23
- export async function logMatchQuality(DB, eventName, payload, recovered = {}) {
57
+ export async function logMatchQuality(DB: D1Database, eventName: string, payload: TrackPayload, recovered: { email: boolean; utm: boolean } = { email: false, utm: false }): Promise<void> {
24
58
  if (!DB) return;
25
59
  try {
26
60
  await DB.prepare(`
@@ -47,7 +81,7 @@ export async function logMatchQuality(DB, eventName, payload, recovered = {}) {
47
81
  * Tenta enriquecer o payload com dados do Identity Graph antes do envio ao Meta.
48
82
  * Retorna { payload enriquecido, flags de recuperação }.
49
83
  */
50
- export async function autoEnrichPayload(env, payload) {
84
+ export async function autoEnrichPayload(env: Env, payload: TrackPayload): Promise<EnrichedPayloadResult> {
51
85
  const recovered = { email: false, utm: false };
52
86
  if (!env.DB) return { payload, recovered };
53
87
 
@@ -60,12 +94,12 @@ export async function autoEnrichPayload(env, payload) {
60
94
 
61
95
  if (profile) {
62
96
  if (profile.email && !payload.email) {
63
- payload.email = profile.email;
97
+ payload.email = profile.email as string;
64
98
  recovered.email = true;
65
99
  }
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;
100
+ if (profile.fbp && !payload.fbp) payload.fbp = profile.fbp as string;
101
+ if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc as string;
102
+ if (profile.phone && !payload.phone) payload.phone = profile.phone as string;
69
103
  }
70
104
  } catch {}
71
105
  }
@@ -81,7 +115,7 @@ export async function autoEnrichPayload(env, payload) {
81
115
  /**
82
116
  * Analisa a qualidade das últimas 2h e retorna métricas + alertas.
83
117
  */
84
- export async function analyzeMatchQuality(env) {
118
+ export async function analyzeMatchQuality(env: Env): Promise<MatchQualityAnalysis | null> {
85
119
  if (!env.DB) return null;
86
120
 
87
121
  try {
@@ -99,45 +133,55 @@ export async function analyzeMatchQuality(env) {
99
133
  WHERE logged_at >= datetime('now', '-2 hours')
100
134
  `).first();
101
135
 
102
- if (!row || row.total < THRESHOLDS.min_events_alert) return { total: row?.total || 0, alerts: [] };
136
+ if (!row || Number((row as any).total) < THRESHOLDS.min_events_alert) return { total: Number((row as any)?.total || 0), alerts: [] };
103
137
 
104
- const alerts = [];
138
+ const alerts: MatchQualityAlert[] = [];
105
139
 
106
- if ((row.email_rate || 0) < THRESHOLDS.email_rate_min * 100) {
140
+ if (Number((row as any).email_rate || 0) < THRESHOLDS.email_rate_min * 100) {
107
141
  alerts.push({
108
142
  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}%)`,
143
+ metric: `email_rate: ${(row as any).email_rate}%`,
144
+ message: `Taxa de email baixa: ${(row as any).email_rate}% (mínimo: ${THRESHOLDS.email_rate_min * 100}%)`,
111
145
  });
112
146
  }
113
147
 
114
- if ((row.fbp_rate || 0) < THRESHOLDS.fbp_rate_min * 100) {
148
+ if (Number((row as any).fbp_rate || 0) < THRESHOLDS.fbp_rate_min * 100) {
115
149
  alerts.push({
116
150
  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`,
151
+ metric: `fbp_rate: ${(row as any).fbp_rate}%`,
152
+ message: `Cookie fbp ausente em ${100 - Number((row as any).fbp_rate)}% dos eventos — verificar cdpTrack.js`,
119
153
  });
120
154
  }
121
155
 
122
- if ((row.composite_score || 0) < THRESHOLDS.composite_min * 100) {
156
+ if (Number((row as any).composite_score || 0) < THRESHOLDS.composite_min * 100) {
123
157
  alerts.push({
124
158
  type: 'composite_critical',
125
- metric: `composite: ${row.composite_score}%`,
126
- message: `Score composto de match quality crítico: ${row.composite_score}%`,
159
+ metric: `composite: ${(row as any).composite_score}%`,
160
+ message: `Score composto de match quality crítico: ${(row as any).composite_score}%`,
127
161
  severity: 'critical',
128
162
  });
129
163
  }
130
164
 
131
- return { ...row, alerts };
132
- } catch (err) {
133
- console.error('[MatchQuality] analyze error:', err.message);
165
+ return {
166
+ total: Number((row as any).total),
167
+ email_rate: Number((row as any).email_rate),
168
+ phone_rate: Number((row as any).phone_rate),
169
+ fbp_rate: Number((row as any).fbp_rate),
170
+ fbc_rate: Number((row as any).fbc_rate),
171
+ ext_id_rate: Number((row as any).ext_id_rate),
172
+ email_recovered_rate: Number((row as any).email_recovered_rate),
173
+ composite_score: Number((row as any).composite_score),
174
+ alerts,
175
+ };
176
+ } catch (err: any) {
177
+ console.error('[MatchQuality] analyze error:', err?.message || String(err));
134
178
  return null;
135
179
  }
136
180
  }
137
181
 
138
182
  // ── Alerta via CallMeBot ──────────────────────────────────────────────────────
139
183
 
140
- export async function alertMatchQuality(env, analysis) {
184
+ export async function alertMatchQuality(env: Env, analysis: MatchQualityAnalysis): Promise<void> {
141
185
  if (!analysis || analysis.alerts.length === 0) return;
142
186
 
143
187
  const hasCritical = analysis.alerts.some(a => a.severity === 'critical');
@@ -146,9 +190,9 @@ export async function alertMatchQuality(env, analysis) {
146
190
  const lines = [
147
191
  `${icon} CDP Edge — Match Quality Alert`,
148
192
  ``,
149
- `📊 Últimas 2h (${analysis.total} eventos):`,
193
+ `📊 Últimas 2h (${analysis.total || 0} eventos):`,
150
194
  ` Email: ${analysis.email_rate ?? 0}% ${(analysis.email_rate ?? 0) < 40 ? '❌' : '✅'}`,
151
- ` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
195
+ ` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
152
196
  ` Score: ${analysis.composite_score ?? 0}%`,
153
197
  ``,
154
198
  `🔍 Problemas:`,
@@ -166,7 +210,7 @@ export async function alertMatchQuality(env, analysis) {
166
210
 
167
211
  // ── Purge periódico (mensal) ──────────────────────────────────────────────────
168
212
 
169
- export async function purgeOldMatchQualityLogs(DB) {
213
+ export async function purgeOldMatchQualityLogs(DB: D1Database): Promise<void> {
170
214
  if (!DB) return;
171
215
  try {
172
216
  await DB.prepare(
@@ -0,0 +1,407 @@
1
+ /**
2
+ * CDP Edge — ML Clustering (Fase 1)
3
+ * Handlers das rotas /api/segmentation/*
4
+ */
5
+
6
+ import { tryParseJson } from '../utils.js';
7
+ import { Env } from '../../types.js';
8
+
9
+ // ── Tipos ───────────────────────────────────────────────────────────────────────
10
+ interface KmeansResult {
11
+ assignments: number[];
12
+ centroids: number[][];
13
+ }
14
+
15
+ interface ClusterStats {
16
+ c: number;
17
+ size: number;
18
+ pct: number;
19
+ avgLtv: number;
20
+ avgEng: number;
21
+ avgDays: number;
22
+ topSource: string;
23
+ topState: string;
24
+ topIntent: string;
25
+ }
26
+
27
+ interface Cluster {
28
+ cluster_id: number;
29
+ name: string;
30
+ size: number;
31
+ percentage: number;
32
+ action_recommendation: string;
33
+ characteristics: {
34
+ avg_ltv_class: number;
35
+ avg_engagement_score: number;
36
+ avg_intention_level: number;
37
+ avg_days_since_lead: number;
38
+ dominant_countries: string[];
39
+ dominant_states: string[];
40
+ dominant_utm_sources: string[];
41
+ top_features: string[];
42
+ };
43
+ }
44
+
45
+ interface ClusterInfo {
46
+ cluster_id: number;
47
+ name: string;
48
+ action: string;
49
+ }
50
+
51
+ // ── Helpers K-means vetorial ──────────────────────────────────────────────────
52
+
53
+ function _cosDist(a: number[], b: number[]): number {
54
+ let dot = 0, na = 0, nb = 0;
55
+ for (let i = 0; i < a.length; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
56
+ return 1 - dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
57
+ }
58
+
59
+ function _kmeansRun(vectors: number[][], k: number, maxIter = 25): KmeansResult {
60
+ const n = vectors.length, dim = vectors[0].length;
61
+ const centroids = [vectors[Math.floor(Math.random() * n)]];
62
+ while (centroids.length < k) {
63
+ const dists = vectors.map(v => Math.min(...centroids.map(c => _cosDist(v, c))));
64
+ const sum = dists.reduce((a, b) => a + b, 0);
65
+ let r = Math.random() * sum, cumul = 0;
66
+ for (let i = 0; i < n; i++) { cumul += dists[i]; if (cumul >= r) { centroids.push(vectors[i]); break; } }
67
+ if (centroids.length < k) centroids.push(vectors[Math.floor(Math.random() * n)]);
68
+ }
69
+ let assignments = new Array(n).fill(0);
70
+ for (let iter = 0; iter < maxIter; iter++) {
71
+ let changed = false;
72
+ for (let i = 0; i < n; i++) {
73
+ let best = 0, bestD = Infinity;
74
+ for (let c = 0; c < k; c++) { const d = _cosDist(vectors[i], centroids[c]); if (d < bestD) { bestD = d; best = c; } }
75
+ if (assignments[i] !== best) { assignments[i] = best; changed = true; }
76
+ }
77
+ if (!changed) break;
78
+ for (let c = 0; c < k; c++) {
79
+ const members = vectors.filter((_, i) => assignments[i] === c);
80
+ if (!members.length) continue;
81
+ for (let d = 0; d < dim; d++) centroids[c][d] = members.reduce((s, v) => s + v[d], 0) / members.length;
82
+ }
83
+ }
84
+ return { assignments, centroids };
85
+ }
86
+
87
+ function _silhouette(vectors: number[][], assignments: number[], k: number): number {
88
+ const n = vectors.length;
89
+ let total = 0;
90
+ for (let i = 0; i < n; i++) {
91
+ const ci = assignments[i];
92
+ const same = vectors.filter((_, j) => j !== i && assignments[j] === ci);
93
+ const a = same.length ? same.reduce((s, v) => s + _cosDist(vectors[i], v), 0) / same.length : 0;
94
+ let b = Infinity;
95
+ for (let c = 0; c < k; c++) {
96
+ if (c === ci) continue;
97
+ const other = vectors.filter((_, j) => assignments[j] === c);
98
+ if (other.length) b = Math.min(b, other.reduce((s, v) => s + _cosDist(vectors[i], v), 0) / other.length);
99
+ }
100
+ total += b === Infinity ? 0 : (b - a) / Math.max(a, b);
101
+ }
102
+ return Math.round((total / n) * 1000) / 1000;
103
+ }
104
+
105
+ function _buildLeadProfile(l: any): string {
106
+ return [
107
+ `LTV: ${l.predicted_ltv_class || 'desconhecido'}`,
108
+ `engajamento: ${Math.round(l.engagement_score || 0)}`,
109
+ `intenção: ${l.intention_level || 'desconhecida'}`,
110
+ `origem: ${l.utm_source || 'direto'}`,
111
+ `canal: ${l.utm_medium || 'desconhecido'}`,
112
+ `país: ${l.country || 'BR'}`,
113
+ `estado: ${l.state || ''}`,
114
+ `hora: ${l.hour_of_day || 12}h`,
115
+ (l.is_weekend ? 'fim-de-semana' : 'dia-útil'),
116
+ `recência: ${l.days_since_lead || 0} dias`,
117
+ ].filter(Boolean).join(', ');
118
+ }
119
+
120
+ // ── POST /api/segmentation/cluster ────────────────────────────────────────────
121
+ // Clustering real: embeddinggemma-300m → K-means vetorial → Granite para nomear
122
+ export async function handleSegmentationCluster(env: Env, request: Request, headers: Headers): Promise<Response> {
123
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
124
+ if (!env.AI) return new Response(JSON.stringify({ error: 'Workers AI não configurado' }), { status: 503, headers });
125
+
126
+ const url = new URL(request.url);
127
+ const algorithm = url.searchParams.get('algorithm') || 'kmeans';
128
+ const nClusters = Math.min(10, Math.max(2, parseInt(url.searchParams.get('n_clusters') || '5')));
129
+ const clientVertical = url.searchParams.get('vertical') || 'general';
130
+ const forceRecluster = url.searchParams.get('force') === 'true';
131
+
132
+ if (!['kmeans', 'dbscan', 'hierarchical'].includes(algorithm)) {
133
+ return new Response(JSON.stringify({ error: 'algorithm deve ser: kmeans, dbscan ou hierarchical', received: algorithm }), { status: 400, headers });
134
+ }
135
+
136
+ try {
137
+ if (!forceRecluster) {
138
+ const existing = await env.DB.prepare(`
139
+ SELECT id, created_at, cluster_name FROM ml_segments
140
+ WHERE clustering_algorithm = ? AND is_active = 1 AND client_vertical = ?
141
+ ORDER BY created_at DESC LIMIT 1
142
+ `).bind(algorithm, clientVertical).first();
143
+ if (existing) {
144
+ const ageDays = (Date.now() - new Date((existing as any).created_at).getTime()) / 864e5;
145
+ if (ageDays < 7) {
146
+ return new Response(JSON.stringify({
147
+ success: true, message: 'Cluster existente ainda válido (< 7 dias). Use ?force=true para re-clustering.',
148
+ cluster_id: (existing as any).id, cluster_name: (existing as any).cluster_name,
149
+ age_days: Math.round(ageDays * 10) / 10, use_existing: true,
150
+ }), { status: 200, headers });
151
+ }
152
+ }
153
+ }
154
+
155
+ const leadsRes = await env.DB.prepare(`
156
+ SELECT id, predicted_ltv_class, engagement_score, intention_level,
157
+ country, state, utm_source, utm_medium, bot_score,
158
+ CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,
159
+ CAST(julianday('now') - julianday(created_at) AS INTEGER) AS days_since_lead,
160
+ CASE WHEN strftime('%w', created_at) IN ('0','6') THEN 1 ELSE 0 END AS is_weekend
161
+ FROM leads
162
+ WHERE created_at >= datetime('now', '-6 months') AND (bot_score IS NULL OR bot_score < 2)
163
+ ORDER BY RANDOM() LIMIT 2000
164
+ `).all();
165
+
166
+ const leads = leadsRes.results || [];
167
+ if (leads.length < 50) {
168
+ return new Response(JSON.stringify({ error: 'Dados insuficientes para clustering. Mínimo: 50 leads.', leads_found: leads.length, required: 50 }), { status: 400, headers });
169
+ }
170
+
171
+ const startTime = Date.now();
172
+ const sample = leads.slice(0, 100);
173
+ const profiles = sample.map(_buildLeadProfile);
174
+
175
+ // Embeddings reais via embeddinggemma-300m
176
+ const embRes = await env.AI.run('@cf/baai/bge-m3', { text: profiles });
177
+ const vectors = (embRes as any).data as number[][];
178
+ if (!vectors || vectors.length < nClusters) throw new Error(`embeddinggemma retornou ${vectors?.length ?? 0} vetores`);
179
+
180
+ // K-means vetorial real
181
+ const { assignments } = _kmeansRun(vectors, nClusters);
182
+ const silhouetteScore = _silhouette(vectors, assignments, nClusters);
183
+
184
+ // Agregação por cluster para nomear com Granite
185
+ const clusterStats: (ClusterStats | null)[] = Array.from({ length: nClusters }, (_, c) => {
186
+ const members = sample.filter((_, i) => assignments[i] === c);
187
+ if (!members.length) return null;
188
+ const ltvMap: Record<string, number> = { High: 1, Medium: 0.5, Low: 0 };
189
+ const avgLtv = members.reduce((s: number, l: any) => s + (ltvMap[l.predicted_ltv_class] ?? 0), 0) / members.length;
190
+ const avgEng = members.reduce((s: number, l: any) => s + (l.engagement_score || 0), 0) / members.length;
191
+ const avgDays = members.reduce((s: number, l: any) => s + (l.days_since_lead || 0), 0) / members.length;
192
+ const freq = (arr: string[]) => arr.length ? [...arr.reduce((m,s) => m.set(s,(m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : null;
193
+ return {
194
+ c, size: members.length, pct: Math.round(members.length / sample.length * 100),
195
+ avgLtv, avgEng, avgDays,
196
+ topSource: freq(members.map((l: any) => l.utm_source).filter(Boolean)) || 'direto',
197
+ topState: freq(members.map((l: any) => l.state).filter(Boolean)) || 'BR',
198
+ topIntent: freq(members.map((l: any) => l.intention_level).filter(Boolean)) || 'desconhecida',
199
+ };
200
+ }).filter(Boolean) as ClusterStats[];
201
+
202
+ // Type guard function to filter null values
203
+ function isNotNull<T>(value: T | null): value is T {
204
+ return value !== null;
205
+ }
206
+
207
+ const validClusterStats = clusterStats.filter(isNotNull);
208
+
209
+ // Granite apenas para nomear segmentos
210
+ const namingPrompt =
211
+ `Você é especialista em segmentação de clientes. Dê um nome descritivo em português e uma recomendação de campanha para cada segmento. Retorne SOMENTE JSON válido:
212
+ {"segments":[{"cluster_id":0,"name":"...","action":"..."},...]}
213
+
214
+ ${validClusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento=${s.avgEng.toFixed(0)}, intenção="${s.topIntent}", origem="${s.topSource}", estado="${s.topState}", recência=${s.avgDays.toFixed(0)} dias, tamanho=${s.size}`).join('\n')}`;
215
+
216
+ const nameRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: [{ role: 'user', content: namingPrompt }], max_tokens: 800 });
217
+ let clusterNames: Record<number, ClusterInfo> = {};
218
+ try {
219
+ const m = ((nameRes as any)?.response || '').match(/\{[\s\S]*\}/);
220
+ if (m) {
221
+ const parsed = JSON.parse(m[0]);
222
+ (parsed.segments || []).forEach((s: any) => {
223
+ if (typeof s.cluster_id === 'number') {
224
+ clusterNames[s.cluster_id] = {
225
+ cluster_id: s.cluster_id,
226
+ name: s.name || `Segmento ${s.cluster_id + 1}`,
227
+ action: s.action || '',
228
+ };
229
+ }
230
+ });
231
+ }
232
+ } catch { /* usa nomes fallback */ }
233
+
234
+ const duration = Date.now() - startTime;
235
+
236
+ const clusters: Cluster[] = validClusterStats.map(s => ({
237
+ cluster_id: s.c,
238
+ name: clusterNames[s.c]?.name || `Segmento ${s.c + 1}`,
239
+ size: s.size, percentage: s.pct,
240
+ action_recommendation: clusterNames[s.c]?.action || '',
241
+ characteristics: {
242
+ avg_ltv_class: s.avgLtv, avg_engagement_score: s.avgEng,
243
+ avg_intention_level: s.avgLtv, avg_days_since_lead: s.avgDays,
244
+ dominant_countries: ['BR'], dominant_states: [s.topState || 'BR'],
245
+ dominant_utm_sources: [s.topSource || 'direto'], top_features: ['ltv', 'engagement', 'intention'],
246
+ },
247
+ }));
248
+
249
+ await env.DB.prepare(`UPDATE ml_segments SET is_active = 0 WHERE clustering_algorithm = ? AND client_vertical = ? AND is_active = 1`).bind(algorithm, clientVertical).run();
250
+
251
+ const now = new Date().toISOString();
252
+ for (const cluster of clusters) {
253
+ const ch = cluster.characteristics;
254
+ await env.DB.prepare(`
255
+ INSERT INTO ml_segments (
256
+ cluster_id, cluster_name, clustering_algorithm, client_vertical, size, percentage,
257
+ avg_ltv_class, avg_behavior_score, avg_engagement_score, avg_intention_level, avg_days_since_lead,
258
+ dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
259
+ silhouette_score, action_recommendations, bid_recommendations, campaign_recommendations,
260
+ is_active, created_at, updated_at
261
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
262
+ `).bind(
263
+ cluster.cluster_id, cluster.name, algorithm, clientVertical, cluster.size, cluster.percentage,
264
+ ch.avg_ltv_class, ch.avg_engagement_score, ch.avg_engagement_score, ch.avg_intention_level, ch.avg_days_since_lead,
265
+ JSON.stringify(ch.dominant_countries), JSON.stringify(ch.dominant_states),
266
+ JSON.stringify(ch.dominant_utm_sources), JSON.stringify(ch.top_features),
267
+ silhouetteScore,
268
+ JSON.stringify([cluster.action_recommendation]), JSON.stringify([]), JSON.stringify([]),
269
+ now, now,
270
+ ).run();
271
+ }
272
+
273
+ try {
274
+ await env.DB.prepare(`
275
+ INSERT INTO ml_clustering_history (clustering_id, started_at, completed_at, algorithm, n_leads_processed, n_clusters_created, total_duration_ms, workers_ai_neurons_used, status, parameters, results_summary)
276
+ VALUES (0, ?, datetime('now'), ?, ?, ?, ?, ?, 'completed', ?, ?)
277
+ `).bind(new Date(startTime).toISOString(), algorithm, leads.length, clusters.length, duration, Math.ceil(duration * 0.01),
278
+ JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical, engine: 'embeddinggemma-300m+kmeans' }),
279
+ JSON.stringify({ clusters: clusters.length, silhouette: silhouetteScore }),
280
+ ).run();
281
+ } catch (e: any) { console.error('[Segmentation] history log error:', e?.message || String(e)); }
282
+
283
+ return new Response(JSON.stringify({
284
+ success: true, algorithm, engine: 'embeddinggemma-300m + kmeans vetorial',
285
+ n_clusters: clusters.length, client_vertical: clientVertical,
286
+ leads_analyzed: leads.length, sample_embedded: sample.length,
287
+ duration_ms: duration, silhouette_score: silhouetteScore,
288
+ clusters, generated_at: now,
289
+ }), { status: 200, headers });
290
+
291
+ } catch (err: any) {
292
+ console.error('[Segmentation] cluster error:', err?.message || String(err));
293
+ try {
294
+ if (env.DB) await env.DB.prepare(`
295
+ INSERT INTO ml_clustering_history (clustering_id, started_at, algorithm, n_leads_processed, n_clusters_created, total_duration_ms, workers_ai_neurons_used, status, error_message, parameters, results_summary)
296
+ VALUES (0, datetime('now'), ?, 0, 0, 0, 0, 'failed', ?, ?, '{}')
297
+ `).bind(algorithm, err?.message || String(err), JSON.stringify({ algorithm, n_clusters: nClusters })).run();
298
+ } catch { /* não bloquear */ }
299
+ return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err?.message || String(err) }), { status: 500, headers });
300
+ }
301
+ }
302
+
303
+ // ── GET /api/segmentation/list ────────────────────────────────────────────────
304
+ export async function handleSegmentationList(env: Env, request: Request, headers: Headers): Promise<Response> {
305
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
306
+
307
+ const url = new URL(request.url);
308
+ const algorithm = url.searchParams.get('algorithm') || null;
309
+ const vertical = url.searchParams.get('vertical') || null;
310
+
311
+ try {
312
+ const conditions: string[] = ['is_active = 1'];
313
+ const bindings: (string | number)[] = [];
314
+ if (algorithm) { conditions.push('clustering_algorithm = ?'); bindings.push(algorithm); }
315
+ if (vertical) { conditions.push('client_vertical = ?'); bindings.push(vertical); }
316
+
317
+ const result = await env.DB.prepare(`
318
+ SELECT id, cluster_id, cluster_name, clustering_algorithm, client_vertical,
319
+ size, percentage, avg_ltv_class, avg_behavior_score, avg_engagement_score,
320
+ avg_intention_level, avg_days_since_lead, silhouette_score,
321
+ dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
322
+ action_recommendations, bid_recommendations, campaign_recommendations,
323
+ is_active, created_at, updated_at
324
+ FROM ml_segments
325
+ WHERE ${conditions.join(' AND ')}
326
+ ORDER BY created_at DESC
327
+ LIMIT 50
328
+ `).bind(...bindings).all();
329
+
330
+ const segments = (result.results || []).map((s: any) => ({
331
+ ...s,
332
+ dominant_countries: tryParseJson(s.dominant_countries, []),
333
+ dominant_states: tryParseJson(s.dominant_states, []),
334
+ dominant_utm_sources: tryParseJson(s.dominant_utm_sources, []),
335
+ dominant_features: tryParseJson(s.dominant_features, []),
336
+ action_recommendations: tryParseJson(s.action_recommendations, []),
337
+ bid_recommendations: tryParseJson(s.bid_recommendations, []),
338
+ campaign_recommendations: tryParseJson(s.campaign_recommendations, []),
339
+ }));
340
+
341
+ return new Response(JSON.stringify({ success: true, total: segments.length, segments }), { status: 200, headers });
342
+ } catch (err: any) {
343
+ console.error('[Segmentation] list error:', err?.message || String(err));
344
+ return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers });
345
+ }
346
+ }
347
+
348
+ // ── GET /api/segmentation/outliers ───────────────────────────────────────────
349
+ export async function handleSegmentationOutliers(env: Env, request: Request, headers: Headers): Promise<Response> {
350
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
351
+
352
+ const url = new URL(request.url);
353
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
354
+ const days = parseInt(url.searchParams.get('days') || '30');
355
+
356
+ try {
357
+ const result = await env.DB.prepare(`
358
+ SELECT msm.lead_id, msm.cluster_id, msm.confidence, msm.is_outlier, msm.outlier_reason, msm.assigned_at,
359
+ l.email, l.phone, l.country, l.state, l.city, l.utm_source, l.bot_score, l.engagement_score, l.intention_level, l.created_at AS lead_created_at
360
+ FROM ml_segment_members msm
361
+ LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
362
+ WHERE msm.is_outlier = 1 AND msm.assigned_at >= datetime('now', '-' || ? || ' days')
363
+ ORDER BY msm.assigned_at DESC
364
+ LIMIT ?
365
+ `).bind(days, limit).all();
366
+
367
+ return new Response(JSON.stringify({ success: true, total: (result.results || []).length, period_days: days, outliers: result.results || [] }), { status: 200, headers });
368
+ } catch (err: any) {
369
+ console.error('[Segmentation] outliers error:', err?.message || String(err));
370
+ return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers });
371
+ }
372
+ }
373
+
374
+ // ── PUT /api/segmentation/update ─────────────────────────────────────────────
375
+ export async function handleSegmentationUpdate(env: Env, request: Request, headers: Headers): Promise<Response> {
376
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
377
+
378
+ let body: any;
379
+ try { body = await request.json(); }
380
+ catch { return new Response(JSON.stringify({ error: 'JSON inválido no body da requisição' }), { status: 400, headers }); }
381
+
382
+ const { cluster_id, action_recommendations, bid_recommendations, campaign_recommendations } = body;
383
+ if (cluster_id === undefined || cluster_id === null) {
384
+ return new Response(JSON.stringify({ error: 'cluster_id é obrigatório' }), { status: 400, headers });
385
+ }
386
+
387
+ try {
388
+ const sets: string[] = [];
389
+ const bindings: (string | number)[] = [];
390
+ if (action_recommendations !== undefined) { sets.push('action_recommendations = ?'); bindings.push(JSON.stringify(action_recommendations)); }
391
+ if (bid_recommendations !== undefined) { sets.push('bid_recommendations = ?'); bindings.push(JSON.stringify(bid_recommendations)); }
392
+ if (campaign_recommendations !== undefined) { sets.push('campaign_recommendations = ?'); bindings.push(JSON.stringify(campaign_recommendations)); }
393
+
394
+ if (sets.length === 0) {
395
+ return new Response(JSON.stringify({ error: 'Nenhum campo válido para atualizar (action_recommendations, bid_recommendations, campaign_recommendations)' }), { status: 400, headers });
396
+ }
397
+
398
+ sets.push("updated_at = datetime('now')");
399
+ bindings.push(cluster_id);
400
+
401
+ await env.DB.prepare(`UPDATE ml_segments SET ${sets.join(', ')} WHERE id = ?`).bind(...bindings).run();
402
+ return new Response(JSON.stringify({ success: true, cluster_id, fields_updated: sets.length - 1 }), { status: 200, headers });
403
+ } catch (err: any) {
404
+ console.error('[Segmentation] update error:', err?.message || String(err));
405
+ return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers });
406
+ }
407
+ }