cdp-edge 1.23.3 → 1.24.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.
- package/README.md +44 -2
- package/bin/cdp-edge.js +10 -1
- package/contracts/types.ts +81 -0
- package/dist/commands/install.js +6 -1
- package/docs/whatsapp-ctwa.md +3 -2
- package/package.json +7 -4
- package/server-edge-tracker/{index.js → index.ts} +91 -82
- package/server-edge-tracker/modules/{db.js → db.ts} +116 -76
- package/server-edge-tracker/modules/dispatch/{ga4.js → ga4.ts} +12 -10
- package/server-edge-tracker/modules/dispatch/{meta.js → meta.ts} +35 -28
- 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} +48 -40
- package/server-edge-tracker/modules/ml/{logistic.js → logistic.ts} +44 -19
- package/server-edge-tracker/modules/ml/{ltv.js → ltv.ts} +135 -90
- package/server-edge-tracker/modules/ml/{matchquality.js → matchquality.ts} +70 -26
- package/server-edge-tracker/modules/ml/{segmentation.js → segmentation.ts} +109 -48
- package/server-edge-tracker/modules/{utils.js → utils.ts} +41 -22
- package/server-edge-tracker/types.ts +251 -0
- package/server-edge-tracker/wrangler.toml +2 -2
- package/docs/PixelBuilder-Documentacao-Completa (2).docx +0 -0
|
@@ -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(
|
|
@@ -4,16 +4,59 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
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
|
+
}
|
|
7
50
|
|
|
8
51
|
// ── Helpers K-means vetorial ──────────────────────────────────────────────────
|
|
9
52
|
|
|
10
|
-
function _cosDist(a, b) {
|
|
53
|
+
function _cosDist(a: number[], b: number[]): number {
|
|
11
54
|
let dot = 0, na = 0, nb = 0;
|
|
12
55
|
for (let i = 0; i < a.length; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
|
|
13
56
|
return 1 - dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
|
|
14
57
|
}
|
|
15
58
|
|
|
16
|
-
function _kmeansRun(vectors, k, maxIter = 25) {
|
|
59
|
+
function _kmeansRun(vectors: number[][], k: number, maxIter = 25): KmeansResult {
|
|
17
60
|
const n = vectors.length, dim = vectors[0].length;
|
|
18
61
|
const centroids = [vectors[Math.floor(Math.random() * n)]];
|
|
19
62
|
while (centroids.length < k) {
|
|
@@ -41,7 +84,7 @@ function _kmeansRun(vectors, k, maxIter = 25) {
|
|
|
41
84
|
return { assignments, centroids };
|
|
42
85
|
}
|
|
43
86
|
|
|
44
|
-
function _silhouette(vectors, assignments, k) {
|
|
87
|
+
function _silhouette(vectors: number[][], assignments: number[], k: number): number {
|
|
45
88
|
const n = vectors.length;
|
|
46
89
|
let total = 0;
|
|
47
90
|
for (let i = 0; i < n; i++) {
|
|
@@ -59,7 +102,7 @@ function _silhouette(vectors, assignments, k) {
|
|
|
59
102
|
return Math.round((total / n) * 1000) / 1000;
|
|
60
103
|
}
|
|
61
104
|
|
|
62
|
-
function _buildLeadProfile(l) {
|
|
105
|
+
function _buildLeadProfile(l: any): string {
|
|
63
106
|
return [
|
|
64
107
|
`LTV: ${l.predicted_ltv_class || 'desconhecido'}`,
|
|
65
108
|
`engajamento: ${Math.round(l.engagement_score || 0)}`,
|
|
@@ -76,7 +119,7 @@ function _buildLeadProfile(l) {
|
|
|
76
119
|
|
|
77
120
|
// ── POST /api/segmentation/cluster ────────────────────────────────────────────
|
|
78
121
|
// Clustering real: embeddinggemma-300m → K-means vetorial → Granite para nomear
|
|
79
|
-
export async function handleSegmentationCluster(env, request, headers) {
|
|
122
|
+
export async function handleSegmentationCluster(env: Env, request: Request, headers: Headers): Promise<Response> {
|
|
80
123
|
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
81
124
|
if (!env.AI) return new Response(JSON.stringify({ error: 'Workers AI não configurado' }), { status: 503, headers });
|
|
82
125
|
|
|
@@ -98,11 +141,11 @@ export async function handleSegmentationCluster(env, request, headers) {
|
|
|
98
141
|
ORDER BY created_at DESC LIMIT 1
|
|
99
142
|
`).bind(algorithm, clientVertical).first();
|
|
100
143
|
if (existing) {
|
|
101
|
-
const ageDays = (Date.now() - new Date(existing.created_at).getTime()) / 864e5;
|
|
144
|
+
const ageDays = (Date.now() - new Date((existing as any).created_at).getTime()) / 864e5;
|
|
102
145
|
if (ageDays < 7) {
|
|
103
146
|
return new Response(JSON.stringify({
|
|
104
147
|
success: true, message: 'Cluster existente ainda válido (< 7 dias). Use ?force=true para re-clustering.',
|
|
105
|
-
cluster_id: existing.id, cluster_name: existing.cluster_name,
|
|
148
|
+
cluster_id: (existing as any).id, cluster_name: (existing as any).cluster_name,
|
|
106
149
|
age_days: Math.round(ageDays * 10) / 10, use_existing: true,
|
|
107
150
|
}), { status: 200, headers });
|
|
108
151
|
}
|
|
@@ -131,7 +174,7 @@ export async function handleSegmentationCluster(env, request, headers) {
|
|
|
131
174
|
|
|
132
175
|
// Embeddings reais via embeddinggemma-300m
|
|
133
176
|
const embRes = await env.AI.run('@cf/baai/bge-m3', { text: profiles });
|
|
134
|
-
const vectors = embRes.data;
|
|
177
|
+
const vectors = (embRes as any).data as number[][];
|
|
135
178
|
if (!vectors || vectors.length < nClusters) throw new Error(`embeddinggemma retornou ${vectors?.length ?? 0} vetores`);
|
|
136
179
|
|
|
137
180
|
// K-means vetorial real
|
|
@@ -139,40 +182,58 @@ export async function handleSegmentationCluster(env, request, headers) {
|
|
|
139
182
|
const silhouetteScore = _silhouette(vectors, assignments, nClusters);
|
|
140
183
|
|
|
141
184
|
// Agregação por cluster para nomear com Granite
|
|
142
|
-
const clusterStats = Array.from({ length: nClusters }, (_, c) => {
|
|
185
|
+
const clusterStats: (ClusterStats | null)[] = Array.from({ length: nClusters }, (_, c) => {
|
|
143
186
|
const members = sample.filter((_, i) => assignments[i] === c);
|
|
144
187
|
if (!members.length) return null;
|
|
145
|
-
const ltvMap = { High: 1, Medium: 0.5, Low: 0 };
|
|
146
|
-
const avgLtv = members.reduce((s, l) => s + (ltvMap[l.predicted_ltv_class] ?? 0), 0) / members.length;
|
|
147
|
-
const avgEng = members.reduce((s, l) => s + (l.engagement_score || 0), 0) / members.length;
|
|
148
|
-
const avgDays = members.reduce((s, l) => s + (l.days_since_lead || 0), 0) / members.length;
|
|
149
|
-
const freq = (arr) => 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;
|
|
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;
|
|
150
193
|
return {
|
|
151
194
|
c, size: members.length, pct: Math.round(members.length / sample.length * 100),
|
|
152
195
|
avgLtv, avgEng, avgDays,
|
|
153
|
-
topSource: freq(members.map(l => l.utm_source).filter(Boolean)) || 'direto',
|
|
154
|
-
topState: freq(members.map(l => l.state).filter(Boolean)) || 'BR',
|
|
155
|
-
topIntent: freq(members.map(l => l.intention_level).filter(Boolean)) || 'desconhecida',
|
|
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',
|
|
156
199
|
};
|
|
157
|
-
}).filter(Boolean);
|
|
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);
|
|
158
208
|
|
|
159
209
|
// Granite apenas para nomear segmentos
|
|
160
210
|
const namingPrompt =
|
|
161
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:
|
|
162
212
|
{"segments":[{"cluster_id":0,"name":"...","action":"..."},...]}
|
|
163
213
|
|
|
164
|
-
${
|
|
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')}`;
|
|
165
215
|
|
|
166
216
|
const nameRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: [{ role: 'user', content: namingPrompt }], max_tokens: 800 });
|
|
167
|
-
let clusterNames = {};
|
|
217
|
+
let clusterNames: Record<number, ClusterInfo> = {};
|
|
168
218
|
try {
|
|
169
|
-
const m = (nameRes?.response || '').match(/\{[\s\S]*\}/);
|
|
170
|
-
if (m)
|
|
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
|
+
}
|
|
171
232
|
} catch { /* usa nomes fallback */ }
|
|
172
233
|
|
|
173
234
|
const duration = Date.now() - startTime;
|
|
174
235
|
|
|
175
|
-
const clusters =
|
|
236
|
+
const clusters: Cluster[] = validClusterStats.map(s => ({
|
|
176
237
|
cluster_id: s.c,
|
|
177
238
|
name: clusterNames[s.c]?.name || `Segmento ${s.c + 1}`,
|
|
178
239
|
size: s.size, percentage: s.pct,
|
|
@@ -180,8 +241,8 @@ ${clusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento
|
|
|
180
241
|
characteristics: {
|
|
181
242
|
avg_ltv_class: s.avgLtv, avg_engagement_score: s.avgEng,
|
|
182
243
|
avg_intention_level: s.avgLtv, avg_days_since_lead: s.avgDays,
|
|
183
|
-
dominant_countries: ['BR'], dominant_states: [s.topState],
|
|
184
|
-
dominant_utm_sources: [s.topSource], top_features: ['ltv', 'engagement', 'intention'],
|
|
244
|
+
dominant_countries: ['BR'], dominant_states: [s.topState || 'BR'],
|
|
245
|
+
dominant_utm_sources: [s.topSource || 'direto'], top_features: ['ltv', 'engagement', 'intention'],
|
|
185
246
|
},
|
|
186
247
|
}));
|
|
187
248
|
|
|
@@ -217,7 +278,7 @@ ${clusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento
|
|
|
217
278
|
JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical, engine: 'embeddinggemma-300m+kmeans' }),
|
|
218
279
|
JSON.stringify({ clusters: clusters.length, silhouette: silhouetteScore }),
|
|
219
280
|
).run();
|
|
220
|
-
} catch (e) { console.error('[Segmentation] history log error:', e
|
|
281
|
+
} catch (e: any) { console.error('[Segmentation] history log error:', e?.message || String(e)); }
|
|
221
282
|
|
|
222
283
|
return new Response(JSON.stringify({
|
|
223
284
|
success: true, algorithm, engine: 'embeddinggemma-300m + kmeans vetorial',
|
|
@@ -227,20 +288,20 @@ ${clusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento
|
|
|
227
288
|
clusters, generated_at: now,
|
|
228
289
|
}), { status: 200, headers });
|
|
229
290
|
|
|
230
|
-
} catch (err) {
|
|
231
|
-
console.error('[Segmentation] cluster error:', err
|
|
291
|
+
} catch (err: any) {
|
|
292
|
+
console.error('[Segmentation] cluster error:', err?.message || String(err));
|
|
232
293
|
try {
|
|
233
294
|
if (env.DB) await env.DB.prepare(`
|
|
234
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)
|
|
235
296
|
VALUES (0, datetime('now'), ?, 0, 0, 0, 0, 'failed', ?, ?, '{}')
|
|
236
|
-
`).bind(algorithm, err
|
|
297
|
+
`).bind(algorithm, err?.message || String(err), JSON.stringify({ algorithm, n_clusters: nClusters })).run();
|
|
237
298
|
} catch { /* não bloquear */ }
|
|
238
|
-
return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err
|
|
299
|
+
return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err?.message || String(err) }), { status: 500, headers });
|
|
239
300
|
}
|
|
240
301
|
}
|
|
241
302
|
|
|
242
303
|
// ── GET /api/segmentation/list ────────────────────────────────────────────────
|
|
243
|
-
export async function handleSegmentationList(env, request, headers) {
|
|
304
|
+
export async function handleSegmentationList(env: Env, request: Request, headers: Headers): Promise<Response> {
|
|
244
305
|
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
245
306
|
|
|
246
307
|
const url = new URL(request.url);
|
|
@@ -248,8 +309,8 @@ export async function handleSegmentationList(env, request, headers) {
|
|
|
248
309
|
const vertical = url.searchParams.get('vertical') || null;
|
|
249
310
|
|
|
250
311
|
try {
|
|
251
|
-
const conditions = ['is_active = 1'];
|
|
252
|
-
const bindings = [];
|
|
312
|
+
const conditions: string[] = ['is_active = 1'];
|
|
313
|
+
const bindings: (string | number)[] = [];
|
|
253
314
|
if (algorithm) { conditions.push('clustering_algorithm = ?'); bindings.push(algorithm); }
|
|
254
315
|
if (vertical) { conditions.push('client_vertical = ?'); bindings.push(vertical); }
|
|
255
316
|
|
|
@@ -266,7 +327,7 @@ export async function handleSegmentationList(env, request, headers) {
|
|
|
266
327
|
LIMIT 50
|
|
267
328
|
`).bind(...bindings).all();
|
|
268
329
|
|
|
269
|
-
const segments = (result.results || []).map(s => ({
|
|
330
|
+
const segments = (result.results || []).map((s: any) => ({
|
|
270
331
|
...s,
|
|
271
332
|
dominant_countries: tryParseJson(s.dominant_countries, []),
|
|
272
333
|
dominant_states: tryParseJson(s.dominant_states, []),
|
|
@@ -278,14 +339,14 @@ export async function handleSegmentationList(env, request, headers) {
|
|
|
278
339
|
}));
|
|
279
340
|
|
|
280
341
|
return new Response(JSON.stringify({ success: true, total: segments.length, segments }), { status: 200, headers });
|
|
281
|
-
} catch (err) {
|
|
282
|
-
console.error('[Segmentation] list error:', err
|
|
283
|
-
return new Response(JSON.stringify({ error: err
|
|
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 });
|
|
284
345
|
}
|
|
285
346
|
}
|
|
286
347
|
|
|
287
348
|
// ── GET /api/segmentation/outliers ───────────────────────────────────────────
|
|
288
|
-
export async function handleSegmentationOutliers(env, request, headers) {
|
|
349
|
+
export async function handleSegmentationOutliers(env: Env, request: Request, headers: Headers): Promise<Response> {
|
|
289
350
|
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
290
351
|
|
|
291
352
|
const url = new URL(request.url);
|
|
@@ -304,17 +365,17 @@ export async function handleSegmentationOutliers(env, request, headers) {
|
|
|
304
365
|
`).bind(days, limit).all();
|
|
305
366
|
|
|
306
367
|
return new Response(JSON.stringify({ success: true, total: (result.results || []).length, period_days: days, outliers: result.results || [] }), { status: 200, headers });
|
|
307
|
-
} catch (err) {
|
|
308
|
-
console.error('[Segmentation] outliers error:', err
|
|
309
|
-
return new Response(JSON.stringify({ error: err
|
|
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 });
|
|
310
371
|
}
|
|
311
372
|
}
|
|
312
373
|
|
|
313
374
|
// ── PUT /api/segmentation/update ─────────────────────────────────────────────
|
|
314
|
-
export async function handleSegmentationUpdate(env, request, headers) {
|
|
375
|
+
export async function handleSegmentationUpdate(env: Env, request: Request, headers: Headers): Promise<Response> {
|
|
315
376
|
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
316
377
|
|
|
317
|
-
let body;
|
|
378
|
+
let body: any;
|
|
318
379
|
try { body = await request.json(); }
|
|
319
380
|
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body da requisição' }), { status: 400, headers }); }
|
|
320
381
|
|
|
@@ -324,8 +385,8 @@ export async function handleSegmentationUpdate(env, request, headers) {
|
|
|
324
385
|
}
|
|
325
386
|
|
|
326
387
|
try {
|
|
327
|
-
const sets
|
|
328
|
-
const bindings = [];
|
|
388
|
+
const sets: string[] = [];
|
|
389
|
+
const bindings: (string | number)[] = [];
|
|
329
390
|
if (action_recommendations !== undefined) { sets.push('action_recommendations = ?'); bindings.push(JSON.stringify(action_recommendations)); }
|
|
330
391
|
if (bid_recommendations !== undefined) { sets.push('bid_recommendations = ?'); bindings.push(JSON.stringify(bid_recommendations)); }
|
|
331
392
|
if (campaign_recommendations !== undefined) { sets.push('campaign_recommendations = ?'); bindings.push(JSON.stringify(campaign_recommendations)); }
|
|
@@ -339,8 +400,8 @@ export async function handleSegmentationUpdate(env, request, headers) {
|
|
|
339
400
|
|
|
340
401
|
await env.DB.prepare(`UPDATE ml_segments SET ${sets.join(', ')} WHERE id = ?`).bind(...bindings).run();
|
|
341
402
|
return new Response(JSON.stringify({ success: true, cluster_id, fields_updated: sets.length - 1 }), { status: 200, headers });
|
|
342
|
-
} catch (err) {
|
|
343
|
-
console.error('[Segmentation] update error:', err
|
|
344
|
-
return new Response(JSON.stringify({ error: err
|
|
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 });
|
|
345
406
|
}
|
|
346
407
|
}
|
|
@@ -4,8 +4,26 @@
|
|
|
4
4
|
* Importadas por todos os outros módulos.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
// ── Tipos ───────────────────────────────────────────────────────────────────────
|
|
8
|
+
export interface FunnelStageResult {
|
|
9
|
+
depth: string;
|
|
10
|
+
funnelDepth: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MetaSignalWeights {
|
|
14
|
+
intent: number;
|
|
15
|
+
ltv: number;
|
|
16
|
+
dist: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type DistanceBucket = 'very_close' | 'close' | 'nearby' | 'moderate' | 'far';
|
|
20
|
+
|
|
21
|
+
export type FunnelLevel = 'top' | 'mid' | 'bottom' | 'conversion' | 'unknown';
|
|
22
|
+
|
|
23
|
+
export type MetaSignalBucket = 'hot' | 'warm' | 'cold';
|
|
24
|
+
|
|
7
25
|
// ── CORS ──────────────────────────────────────────────────────────────────────
|
|
8
|
-
export function isAllowedOrigin(origin, siteDomain) {
|
|
26
|
+
export function isAllowedOrigin(origin: string | null, siteDomain: string | null): boolean {
|
|
9
27
|
if (!origin || !siteDomain) return false;
|
|
10
28
|
return origin === `https://${siteDomain}`
|
|
11
29
|
|| origin.endsWith(`.${siteDomain}`)
|
|
@@ -13,10 +31,10 @@ export function isAllowedOrigin(origin, siteDomain) {
|
|
|
13
31
|
|| origin === 'http://localhost:5173';
|
|
14
32
|
}
|
|
15
33
|
|
|
16
|
-
export function corsHeaders(origin, siteDomain) {
|
|
17
|
-
const allowed = isAllowedOrigin(origin, siteDomain) ? origin : `https://${siteDomain}
|
|
34
|
+
export function corsHeaders(origin: string | null, siteDomain: string | null): Record<string, string> {
|
|
35
|
+
const allowed = isAllowedOrigin(origin, siteDomain) ? origin : (siteDomain ? `https://${siteDomain}` : '*');
|
|
18
36
|
return {
|
|
19
|
-
'Access-Control-Allow-Origin': allowed,
|
|
37
|
+
'Access-Control-Allow-Origin': allowed || '*',
|
|
20
38
|
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
21
39
|
'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
|
|
22
40
|
'Access-Control-Max-Age': '86400',
|
|
@@ -24,7 +42,7 @@ export function corsHeaders(origin, siteDomain) {
|
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
// ── SHA-256 via WebCrypto (obrigatório no Cloudflare Workers) ─────────────────
|
|
27
|
-
export async function sha256(value) {
|
|
45
|
+
export async function sha256(value: string | null | undefined): Promise<string | undefined> {
|
|
28
46
|
if (!value) return undefined;
|
|
29
47
|
const clean = String(value).toLowerCase().trim();
|
|
30
48
|
if (!clean) return undefined;
|
|
@@ -38,7 +56,7 @@ export async function sha256(value) {
|
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
// ── Normalização de telefone → somente dígitos + DDI 55 ──────────────────────
|
|
41
|
-
export function normalizePhone(phone) {
|
|
59
|
+
export function normalizePhone(phone: string | null | undefined): string | undefined {
|
|
42
60
|
if (!phone) return undefined;
|
|
43
61
|
let digits = String(phone).replace(/\D/g, '');
|
|
44
62
|
if (digits.length === 11 && !digits.startsWith('55')) digits = '55' + digits;
|
|
@@ -47,7 +65,7 @@ export function normalizePhone(phone) {
|
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
// ── Normalização de cidade → lowercase sem acentos ────────────────────────────
|
|
50
|
-
export function normalizeCity(city) {
|
|
68
|
+
export function normalizeCity(city: string | null | undefined): string | undefined {
|
|
51
69
|
if (!city) return undefined;
|
|
52
70
|
return String(city)
|
|
53
71
|
.toLowerCase()
|
|
@@ -57,13 +75,13 @@ export function normalizeCity(city) {
|
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
// ── Parse seguro de JSON armazenado como TEXT no D1 ───────────────────────────
|
|
60
|
-
export function tryParseJson(str, fallback) {
|
|
78
|
+
export function tryParseJson<T = any>(str: string | null, fallback?: T): T | null {
|
|
61
79
|
if (!str) return fallback !== undefined ? fallback : null;
|
|
62
80
|
try { return JSON.parse(str); } catch { return fallback !== undefined ? fallback : null; }
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
// ── Mapa Meta → GA4 event names ───────────────────────────────────────────────
|
|
66
|
-
export const META_TO_GA4 = {
|
|
84
|
+
export const META_TO_GA4: Record<string, string> = {
|
|
67
85
|
PageView: 'page_view',
|
|
68
86
|
ViewContent: 'view_item',
|
|
69
87
|
Lead: 'generate_lead',
|
|
@@ -100,18 +118,18 @@ export const FUNNEL_TAXONOMY = {
|
|
|
100
118
|
};
|
|
101
119
|
|
|
102
120
|
// Índice invertido: funnel_stage → depth (construído uma vez, zero custo em runtime)
|
|
103
|
-
const _STAGE_TO_DEPTH = Object.entries(FUNNEL_TAXONOMY).reduce((acc, [depth, stages]) => {
|
|
104
|
-
stages.forEach(s => { acc[s] = depth; });
|
|
121
|
+
const _STAGE_TO_DEPTH: Record<string, FunnelLevel> = Object.entries(FUNNEL_TAXONOMY).reduce((acc, [depth, stages]) => {
|
|
122
|
+
stages.forEach(s => { acc[s] = depth as FunnelLevel; });
|
|
105
123
|
return acc;
|
|
106
|
-
}, {});
|
|
124
|
+
}, {} as Record<string, FunnelLevel>);
|
|
107
125
|
|
|
108
126
|
/**
|
|
109
127
|
* Resolve funnel_stage em funnelDepth semântico.
|
|
110
128
|
* bottom_intent = intenção forte (route_click, whatsapp_click)
|
|
111
129
|
* bottom_conversion = ação confirmada (schedule_confirmed, lead_form)
|
|
112
130
|
*/
|
|
113
|
-
export function resolveFunnelStage(funnel_stage) {
|
|
114
|
-
const depth = _STAGE_TO_DEPTH[funnel_stage] || 'unknown';
|
|
131
|
+
export function resolveFunnelStage(funnel_stage: string | null | undefined): FunnelStageResult {
|
|
132
|
+
const depth = _STAGE_TO_DEPTH[funnel_stage || ''] || 'unknown';
|
|
115
133
|
const funnelDepth = depth === 'conversion' ? 'bottom_conversion'
|
|
116
134
|
: depth === 'bottom' ? 'bottom_intent'
|
|
117
135
|
: depth;
|
|
@@ -120,12 +138,12 @@ export function resolveFunnelStage(funnel_stage) {
|
|
|
120
138
|
|
|
121
139
|
// ── Normalização de intent_score → 0.0–1.0 ───────────────────────────────────
|
|
122
140
|
// Aceita: string ('high'/'medium'/'low'), numérico 0-1 ou numérico 0-100
|
|
123
|
-
const _INTENT_STRING_MAP = { high: 0.92, medium: 0.65, low: 0.30 };
|
|
141
|
+
const _INTENT_STRING_MAP: Record<string, number> = { high: 0.92, medium: 0.65, low: 0.30 };
|
|
124
142
|
|
|
125
|
-
export function resolveIntentScore(value) {
|
|
143
|
+
export function resolveIntentScore(value: string | number | null | undefined): number | null {
|
|
126
144
|
if (value === null || value === undefined) return null;
|
|
127
145
|
if (typeof value === 'string') return _INTENT_STRING_MAP[value.toLowerCase()] ?? null;
|
|
128
|
-
const num = parseFloat(value);
|
|
146
|
+
const num = parseFloat(String(value));
|
|
129
147
|
if (isNaN(num)) return null;
|
|
130
148
|
const normalized = num > 1 ? num / 100 : num; // escala 0-100 → 0-1
|
|
131
149
|
return Math.min(1, Math.max(0, Math.round(normalized * 100) / 100));
|
|
@@ -135,9 +153,9 @@ export function resolveIntentScore(value) {
|
|
|
135
153
|
* Distância (distanceBucket) → peso numérico para meta_signal.
|
|
136
154
|
* very_close=1.0 ... far=0.1 ... sem dado=0.3 (neutro)
|
|
137
155
|
*/
|
|
138
|
-
export function distanceBucketWeight(bucket) {
|
|
139
|
-
const map = { very_close: 1.0, close: 0.75, nearby: 0.5, moderate: 0.25, far: 0.1 };
|
|
140
|
-
return map[bucket] ?? 0.3;
|
|
156
|
+
export function distanceBucketWeight(bucket: string | null | undefined): number {
|
|
157
|
+
const map: Record<DistanceBucket, number> = { very_close: 1.0, close: 0.75, nearby: 0.5, moderate: 0.25, far: 0.1 };
|
|
158
|
+
return map[bucket as DistanceBucket] ?? 0.3;
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
/**
|
|
@@ -146,7 +164,7 @@ export function distanceBucketWeight(bucket) {
|
|
|
146
164
|
* Topo: perfil pesa mais (ltv).
|
|
147
165
|
* Default (mid/unknown): balanceado.
|
|
148
166
|
*/
|
|
149
|
-
export function computeMetaSignalWeights(funnelLevel) {
|
|
167
|
+
export function computeMetaSignalWeights(funnelLevel: FunnelLevel | string | null | undefined): MetaSignalWeights {
|
|
150
168
|
if (funnelLevel === 'bottom' || funnelLevel === 'conversion') {
|
|
151
169
|
return { intent: 0.5, ltv: 0.2, dist: 0.3 };
|
|
152
170
|
}
|
|
@@ -160,7 +178,8 @@ export function computeMetaSignalWeights(funnelLevel) {
|
|
|
160
178
|
* Quantiza meta_signal contínuo em bucket legível.
|
|
161
179
|
* Usado em criação de públicos e leitura de BI.
|
|
162
180
|
*/
|
|
163
|
-
export function metaSignalBucket(score) {
|
|
181
|
+
export function metaSignalBucket(score: number | null | undefined): MetaSignalBucket {
|
|
182
|
+
if (!score) return 'cold';
|
|
164
183
|
if (score >= 0.8) return 'hot';
|
|
165
184
|
if (score >= 0.6) return 'warm';
|
|
166
185
|
return 'cold';
|