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.
- package/README.md +82 -21
- package/bin/cdp-edge.js +10 -1
- package/contracts/agent-versions.json +42 -41
- package/contracts/types.ts +81 -0
- package/dist/commands/install.js +6 -1
- package/dist/commands/server.js +4 -4
- package/docs/whatsapp-ctwa.md +3 -2
- package/extracted-skill/tracking-events-generator/agents/database-agent.md +5 -4
- package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +0 -1
- package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +1 -1
- package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +4 -4
- package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +81 -70
- package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +6 -2
- package/extracted-skill/tracking-events-generator/cdpTrack.js +7 -0
- package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
- package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
- package/package.json +9 -5
- package/server-edge-tracker/INSTALAR.md +5 -5
- package/server-edge-tracker/{index.js → index.ts} +186 -72
- package/server-edge-tracker/modules/{db.js → db.ts} +180 -69
- package/server-edge-tracker/modules/dispatch/{ga4.js → ga4.ts} +12 -10
- package/server-edge-tracker/modules/dispatch/meta.ts +138 -0
- package/server-edge-tracker/modules/dispatch/{platforms.js → platforms.ts} +58 -56
- package/server-edge-tracker/modules/dispatch/{tiktok.js → tiktok.ts} +22 -20
- package/server-edge-tracker/modules/dispatch/{whatsapp.js → whatsapp.ts} +59 -25
- package/server-edge-tracker/modules/{intelligence.js → intelligence.ts} +175 -60
- package/server-edge-tracker/modules/ml/{bidding.js → bidding.ts} +37 -35
- package/server-edge-tracker/modules/ml/{fraud.js → fraud.ts} +49 -56
- package/server-edge-tracker/modules/ml/{logistic.js → logistic.ts} +44 -19
- package/server-edge-tracker/modules/ml/{ltv.js → ltv.ts} +179 -83
- package/server-edge-tracker/modules/ml/{matchquality.js → matchquality.ts} +70 -26
- package/server-edge-tracker/modules/ml/segmentation.ts +407 -0
- package/server-edge-tracker/modules/utils.ts +186 -0
- package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
- package/server-edge-tracker/types.ts +251 -0
- package/server-edge-tracker/wrangler.toml +24 -6
- package/templates/lancamento-imobiliario.md +344 -0
- package/docs/PixelBuilder-Documentacao-Completa (2).docx +0 -0
- package/server-edge-tracker/modules/dispatch/meta.js +0 -119
- package/server-edge-tracker/modules/ml/segmentation.js +0 -316
- package/server-edge-tracker/modules/utils.js +0 -89
- 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 {
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
+
}
|