cdp-edge 1.19.0 → 1.20.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/dist/commands/setup.js +326 -111
- package/package.json +1 -1
- package/server-edge-tracker/migrate-v7.sql +64 -0
- package/server-edge-tracker/modules/dispatch/meta.js +16 -0
- package/server-edge-tracker/modules/intelligence.js +120 -3
- package/server-edge-tracker/modules/ml/logistic.js +195 -0
- package/server-edge-tracker/modules/ml/ltv.js +100 -0
- package/server-edge-tracker/modules/ml/matchquality.js +176 -0
package/dist/commands/setup.js
CHANGED
|
@@ -1,138 +1,353 @@
|
|
|
1
|
-
|
|
2
|
-
* Setup Wizard
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge Setup Wizard — Guiado e não-técnico
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Coleta os dados do projeto do cliente e gera:
|
|
5
|
+
* • Comandos wrangler secret put prontos para copiar
|
|
6
|
+
* • URLs de webhook com domínio preenchido
|
|
7
|
+
* • Checklist de próximos passos para o agente
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
import inquirer from 'inquirer';
|
|
8
11
|
import chalk from 'chalk';
|
|
9
12
|
import ora from 'ora';
|
|
13
|
+
import { writeFileSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
10
15
|
|
|
11
16
|
function printBanner() {
|
|
12
17
|
console.log('');
|
|
13
|
-
console.log(chalk.
|
|
14
|
-
console.log('');
|
|
15
|
-
console.log(chalk.cyan('
|
|
16
|
-
console.log(chalk.cyan('
|
|
17
|
-
console.log(chalk.cyan('██║ ██║ ██║██████╔╝ █████╗ ██║ ██║██║ ███╗█████╗ '));
|
|
18
|
-
console.log(chalk.cyan('██║ ██║ ██║██╔═══╝ ██╔══╝ ██║ ██║██║ ██║██╔══╝ '));
|
|
19
|
-
console.log(chalk.cyan('╚██████╗██████╔╝██║ ███████╗██████╔╝╚██████╔╝███████╗'));
|
|
20
|
-
console.log(chalk.cyan(' ╚═════╝╚═════╝ ╚═╝ ╚══════╝╚═════╝ ╚═════╝╚══════╝'));
|
|
21
|
-
console.log('');
|
|
22
|
-
console.log(chalk.gray(' Customer Data Platform on the Edge · Global Edge Tracking · v2.0.3'));
|
|
23
|
-
console.log('');
|
|
24
|
-
console.log(chalk.gray('═'.repeat(68)));
|
|
18
|
+
console.log(chalk.cyan(' ╔═══════════════════════════════════════╗'));
|
|
19
|
+
console.log(chalk.cyan(' ║') + chalk.white.bold(' CDP Edge — Setup Wizard ') + chalk.cyan('║'));
|
|
20
|
+
console.log(chalk.cyan(' ║') + chalk.gray(' Configuração Guiada de Tracking ') + chalk.cyan('║'));
|
|
21
|
+
console.log(chalk.cyan(' ╚═══════════════════════════════════════╝'));
|
|
25
22
|
console.log('');
|
|
26
23
|
}
|
|
27
24
|
|
|
25
|
+
function sep(label = '') {
|
|
26
|
+
if (label) {
|
|
27
|
+
console.log('\n' + chalk.gray('── ') + chalk.cyan.bold(label) + chalk.gray(' ' + '─'.repeat(Math.max(0, 40 - label.length))));
|
|
28
|
+
} else {
|
|
29
|
+
console.log(chalk.gray('─'.repeat(50)));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
export async function runSetupWizard(dir = '.') {
|
|
29
34
|
printBanner();
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
console.log(chalk.cyan(' Vou te fazer algumas perguntas simples.'));
|
|
37
|
+
console.log(chalk.cyan(' Não precisa saber programar — é só preencher os dados.\n'));
|
|
38
|
+
console.log(chalk.gray(' Ao final, vou gerar todos os comandos prontos para instalar.\n'));
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
console.log(chalk.gray('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
|
|
40
|
+
// ── PASSO 1: Dados do projeto ──────────────────────────────────────────────
|
|
41
|
+
sep('1. Dados do Projeto');
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
const projeto = await inquirer.prompt([
|
|
44
|
+
{
|
|
45
|
+
type: 'input',
|
|
46
|
+
name: 'domain',
|
|
47
|
+
message: 'Qual o domínio do site? (ex: meusite.com.br)',
|
|
48
|
+
validate: v => v.trim() ? true : 'Domínio é obrigatório',
|
|
49
|
+
filter: v => v.trim().replace(/^https?:\/\//, '').replace(/\/$/, ''),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
type: 'input',
|
|
53
|
+
name: 'projectName',
|
|
54
|
+
message: 'Nome do projeto (para identificação):',
|
|
55
|
+
default: answers => answers.domain.split('.')[0],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: 'list',
|
|
59
|
+
name: 'productType',
|
|
60
|
+
message: 'Que tipo de produto você vende?',
|
|
61
|
+
choices: [
|
|
62
|
+
{ name: 'Infoproduto / Curso Online', value: 'infoproduto' },
|
|
63
|
+
{ name: 'E-commerce físico', value: 'ecommerce' },
|
|
64
|
+
{ name: 'SaaS / Assinatura', value: 'saas' },
|
|
65
|
+
{ name: 'Serviço / Consultoria', value: 'servico' },
|
|
66
|
+
{ name: 'Lead Generation (captação)', value: 'leadgen' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
44
70
|
|
|
45
|
-
//
|
|
71
|
+
// ── PASSO 2: Plataforma de vendas ──────────────────────────────────────────
|
|
72
|
+
sep('2. Plataforma de Vendas / Checkout');
|
|
46
73
|
|
|
47
|
-
const
|
|
74
|
+
const { plataformas } = await inquirer.prompt([
|
|
48
75
|
{
|
|
49
|
-
type:
|
|
50
|
-
name:
|
|
51
|
-
message: '
|
|
76
|
+
type: 'checkbox',
|
|
77
|
+
name: 'plataformas',
|
|
78
|
+
message: 'Quais plataformas de checkout você usa? (espaço para marcar)',
|
|
52
79
|
choices: [
|
|
53
|
-
{ name: '
|
|
54
|
-
{ name: '
|
|
55
|
-
{ name: '
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
{ name: 'Hotmart', value: 'hotmart', checked: false },
|
|
81
|
+
{ name: 'Kiwify', value: 'kiwify', checked: false },
|
|
82
|
+
{ name: 'Ticto', value: 'ticto', checked: false },
|
|
83
|
+
{ name: 'Eduzz', value: 'eduzz', checked: false },
|
|
84
|
+
{ name: 'Stripe', value: 'stripe', checked: false },
|
|
85
|
+
{ name: 'Nenhuma (apenas formulário de captação)', value: 'none', checked: false },
|
|
86
|
+
],
|
|
87
|
+
validate: v => v.length > 0 ? true : 'Selecione pelo menos uma opção',
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
// ── PASSO 3: Plataformas de anúncios ───────────────────────────────────────
|
|
92
|
+
sep('3. Plataformas de Anúncios');
|
|
93
|
+
|
|
94
|
+
const { adsPlataformas } = await inquirer.prompt([
|
|
95
|
+
{
|
|
96
|
+
type: 'checkbox',
|
|
97
|
+
name: 'adsPlataformas',
|
|
98
|
+
message: 'Onde você faz anúncios? (espaço para marcar)',
|
|
99
|
+
choices: [
|
|
100
|
+
{ name: 'Meta Ads (Facebook / Instagram)', value: 'meta', checked: true },
|
|
101
|
+
{ name: 'Google Ads / YouTube', value: 'google', checked: false },
|
|
102
|
+
{ name: 'TikTok Ads', value: 'tiktok', checked: false },
|
|
103
|
+
{ name: 'Pinterest Ads', value: 'pinterest', checked: false },
|
|
104
|
+
{ name: 'LinkedIn Ads', value: 'linkedin', checked: false },
|
|
105
|
+
],
|
|
106
|
+
validate: v => v.length > 0 ? true : 'Selecione pelo menos uma plataforma',
|
|
107
|
+
},
|
|
58
108
|
]);
|
|
59
109
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
110
|
+
// ── PASSO 4: Credenciais Meta ──────────────────────────────────────────────
|
|
111
|
+
const creds = {};
|
|
112
|
+
|
|
113
|
+
if (adsPlataformas.includes('meta')) {
|
|
114
|
+
sep('4a. Credenciais — Meta Ads');
|
|
115
|
+
console.log(chalk.gray(' Onde encontrar: Meta Events Manager → Configurações → Pixel\n'));
|
|
116
|
+
|
|
117
|
+
const meta = await inquirer.prompt([
|
|
118
|
+
{
|
|
119
|
+
type: 'input',
|
|
120
|
+
name: 'pixelId',
|
|
121
|
+
message: 'Meta Pixel ID:',
|
|
122
|
+
validate: v => v.trim() ? true : 'Pixel ID é obrigatório',
|
|
123
|
+
filter: v => v.trim(),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
type: 'password',
|
|
127
|
+
name: 'accessToken',
|
|
128
|
+
message: 'Meta Access Token (CAPI):',
|
|
129
|
+
mask: '*',
|
|
130
|
+
validate: v => v.trim() ? true : 'Access Token é obrigatório',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
type: 'input',
|
|
134
|
+
name: 'testCode',
|
|
135
|
+
message: 'Meta Test Event Code (opcional, para homologação):',
|
|
136
|
+
default: '',
|
|
137
|
+
filter: v => v.trim(),
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
creds.meta = meta;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (adsPlataformas.includes('google')) {
|
|
144
|
+
sep('4b. Credenciais — Google Analytics 4');
|
|
145
|
+
console.log(chalk.gray(' Onde encontrar: GA4 → Admin → Data Streams → Measurement Protocol\n'));
|
|
146
|
+
|
|
147
|
+
const google = await inquirer.prompt([
|
|
148
|
+
{
|
|
149
|
+
type: 'input',
|
|
150
|
+
name: 'measurementId',
|
|
151
|
+
message: 'GA4 Measurement ID (G-XXXXXXXX):',
|
|
152
|
+
validate: v => /^G-[A-Z0-9]+$/i.test(v.trim()) ? true : 'Formato: G-XXXXXXXX',
|
|
153
|
+
filter: v => v.trim().toUpperCase(),
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
type: 'password',
|
|
157
|
+
name: 'apiSecret',
|
|
158
|
+
message: 'GA4 API Secret:',
|
|
159
|
+
mask: '*',
|
|
160
|
+
validate: v => v.trim() ? true : 'API Secret é obrigatório',
|
|
161
|
+
},
|
|
162
|
+
]);
|
|
163
|
+
creds.google = google;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (adsPlataformas.includes('tiktok')) {
|
|
167
|
+
sep('4c. Credenciais — TikTok');
|
|
168
|
+
console.log(chalk.gray(' Onde encontrar: TikTok Events Manager → Pixel → Detalhes\n'));
|
|
169
|
+
|
|
170
|
+
const tiktok = await inquirer.prompt([
|
|
171
|
+
{
|
|
172
|
+
type: 'input',
|
|
173
|
+
name: 'pixelId',
|
|
174
|
+
message: 'TikTok Pixel ID:',
|
|
175
|
+
validate: v => v.trim() ? true : 'Pixel ID é obrigatório',
|
|
176
|
+
filter: v => v.trim(),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
type: 'password',
|
|
180
|
+
name: 'accessToken',
|
|
181
|
+
message: 'TikTok Access Token:',
|
|
182
|
+
mask: '*',
|
|
183
|
+
validate: v => v.trim() ? true : 'Access Token é obrigatório',
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
creds.tiktok = tiktok;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── PASSO 5: Alertas WhatsApp ──────────────────────────────────────────────
|
|
190
|
+
sep('5. Alertas WhatsApp (opcional)');
|
|
191
|
+
console.log(chalk.gray(' Receba alertas de erros e match quality direto no WhatsApp.\n'));
|
|
192
|
+
|
|
193
|
+
const { wantsWhatsapp } = await inquirer.prompt([
|
|
194
|
+
{
|
|
195
|
+
type: 'confirm',
|
|
196
|
+
name: 'wantsWhatsapp',
|
|
197
|
+
message: 'Deseja ativar alertas via WhatsApp?',
|
|
198
|
+
default: true,
|
|
199
|
+
},
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
let whatsapp = {};
|
|
203
|
+
if (wantsWhatsapp) {
|
|
204
|
+
console.log(chalk.gray('\n Como configurar: acesse callmebot.com e adicione o bot no WhatsApp.'));
|
|
205
|
+
console.log(chalk.gray(' Envie "I allow callmebot to send me messages" para +34 644 35 78 48\n'));
|
|
206
|
+
|
|
207
|
+
whatsapp = await inquirer.prompt([
|
|
208
|
+
{
|
|
209
|
+
type: 'input',
|
|
210
|
+
name: 'phone',
|
|
211
|
+
message: 'Seu número com DDI (ex: +5511999999999):',
|
|
212
|
+
validate: v => /^\+\d{10,15}$/.test(v.trim()) ? true : 'Formato: +5511999999999',
|
|
213
|
+
filter: v => v.trim(),
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
type: 'password',
|
|
217
|
+
name: 'apiKey',
|
|
218
|
+
message: 'CallMeBot API Key:',
|
|
219
|
+
mask: '*',
|
|
220
|
+
validate: v => v.trim() ? true : 'API Key é obrigatória',
|
|
221
|
+
},
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── GERAÇÃO DOS OUTPUTS ────────────────────────────────────────────────────
|
|
226
|
+
const spinner = ora('Gerando configurações...').start();
|
|
227
|
+
await sleep(600);
|
|
228
|
+
spinner.succeed('Configurações geradas!');
|
|
229
|
+
|
|
230
|
+
const domain = projeto.domain;
|
|
231
|
+
|
|
232
|
+
// Monta lista de secrets
|
|
233
|
+
const secrets = [];
|
|
234
|
+
|
|
235
|
+
if (creds.meta) {
|
|
236
|
+
secrets.push({ key: 'META_PIXEL_ID', value: creds.meta.pixelId, platform: 'Meta' });
|
|
237
|
+
secrets.push({ key: 'META_ACCESS_TOKEN', value: creds.meta.accessToken, platform: 'Meta' });
|
|
238
|
+
if (creds.meta.testCode) {
|
|
239
|
+
secrets.push({ key: 'META_TEST_CODE', value: creds.meta.testCode, platform: 'Meta (homologação)' });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (creds.google) {
|
|
244
|
+
secrets.push({ key: 'GA4_MEASUREMENT_ID', value: creds.google.measurementId, platform: 'Google' });
|
|
245
|
+
secrets.push({ key: 'GA4_API_SECRET', value: creds.google.apiSecret, platform: 'Google' });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (creds.tiktok) {
|
|
249
|
+
secrets.push({ key: 'TIKTOK_PIXEL_ID', value: creds.tiktok.pixelId, platform: 'TikTok' });
|
|
250
|
+
secrets.push({ key: 'TIKTOK_ACCESS_TOKEN', value: creds.tiktok.accessToken, platform: 'TikTok' });
|
|
135
251
|
}
|
|
252
|
+
|
|
253
|
+
if (whatsapp.phone) {
|
|
254
|
+
secrets.push({ key: 'CALLMEBOT_PHONE', value: whatsapp.phone, platform: 'WhatsApp' });
|
|
255
|
+
secrets.push({ key: 'CALLMEBOT_API_KEY', value: whatsapp.apiKey, platform: 'WhatsApp' });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
secrets.push({ key: 'SITE_DOMAIN', value: domain, platform: 'Worker' });
|
|
259
|
+
|
|
260
|
+
// Webhooks
|
|
261
|
+
const webhookUrls = {};
|
|
262
|
+
const webhookSecrets = [];
|
|
263
|
+
if (plataformas.includes('hotmart')) {
|
|
264
|
+
webhookUrls.hotmart = `https://${domain}/webhook/hotmart`;
|
|
265
|
+
webhookSecrets.push({ key: 'WEBHOOK_SECRET_HOTMART', platform: 'Hotmart', note: 'Gere uma senha forte qualquer' });
|
|
266
|
+
}
|
|
267
|
+
if (plataformas.includes('kiwify')) {
|
|
268
|
+
webhookUrls.kiwify = `https://${domain}/webhook/kiwify`;
|
|
269
|
+
webhookSecrets.push({ key: 'WEBHOOK_SECRET_KIWIFY', platform: 'Kiwify', note: 'Gere uma senha forte qualquer' });
|
|
270
|
+
}
|
|
271
|
+
if (plataformas.includes('ticto')) {
|
|
272
|
+
webhookUrls.ticto = `https://${domain}/webhook/ticto`;
|
|
273
|
+
webhookSecrets.push({ key: 'WEBHOOK_SECRET_TICTO', platform: 'Ticto', note: 'Chave HMAC configurada no painel Ticto' });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── EXIBIR RESULTADO ───────────────────────────────────────────────────────
|
|
277
|
+
console.log('\n');
|
|
278
|
+
sep('CONFIGURAÇÃO PRONTA');
|
|
279
|
+
|
|
280
|
+
console.log(chalk.green.bold('\n Projeto: ') + chalk.white(projeto.projectName));
|
|
281
|
+
console.log(chalk.green.bold(' Domínio: ') + chalk.white(domain));
|
|
282
|
+
console.log(chalk.green.bold(' Produto: ') + chalk.white(projeto.productType));
|
|
283
|
+
console.log(chalk.green.bold(' Ads: ') + chalk.white(adsPlataformas.join(', ')));
|
|
284
|
+
|
|
285
|
+
// Comandos de secrets
|
|
286
|
+
sep('Comandos para configurar os secrets');
|
|
287
|
+
console.log(chalk.gray(' Execute dentro da pasta server-edge-tracker:\n'));
|
|
288
|
+
|
|
289
|
+
for (const s of secrets) {
|
|
290
|
+
const masked = s.value.length > 4 ? s.value.slice(0, 4) + '*'.repeat(Math.min(s.value.length - 4, 8)) : '****';
|
|
291
|
+
console.log(chalk.cyan(` wrangler secret put ${s.key}`));
|
|
292
|
+
console.log(chalk.gray(` # ${s.platform} — valor: ${masked}\n`));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (webhookSecrets.length > 0) {
|
|
296
|
+
console.log(chalk.yellow(' # Secrets de webhook (gere senhas e configure abaixo + no painel da plataforma):'));
|
|
297
|
+
for (const ws of webhookSecrets) {
|
|
298
|
+
console.log(chalk.cyan(` wrangler secret put ${ws.key}`));
|
|
299
|
+
console.log(chalk.gray(` # ${ws.platform}: ${ws.note}\n`));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// URLs de webhook
|
|
304
|
+
if (Object.keys(webhookUrls).length > 0) {
|
|
305
|
+
sep('URLs de Webhook para configurar nas plataformas');
|
|
306
|
+
for (const [plat, url] of Object.entries(webhookUrls)) {
|
|
307
|
+
console.log(chalk.green.bold(` ${plat.charAt(0).toUpperCase() + plat.slice(1)}:`));
|
|
308
|
+
console.log(chalk.white(` ${url}\n`));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Checklist
|
|
313
|
+
sep('Próximos passos');
|
|
314
|
+
const steps = [
|
|
315
|
+
'Executar todos os wrangler secret put acima',
|
|
316
|
+
'Executar as migrations D1 (schema.sql → migrate-v7.sql)',
|
|
317
|
+
'Executar: wrangler deploy',
|
|
318
|
+
...(Object.keys(webhookUrls).length > 0 ? ['Configurar as URLs de webhook nas plataformas de venda'] : []),
|
|
319
|
+
'Configurar Worker Route no Cloudflare: ' + domain + '/*',
|
|
320
|
+
'Testar o endpoint: https://' + domain + '/health',
|
|
321
|
+
...(adsPlataformas.includes('meta') && creds.meta?.testCode ? ['Testar eventos no Meta Events Manager com o Test Event Code'] : []),
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
steps.forEach((step, i) => {
|
|
325
|
+
console.log(chalk.cyan(` [${i + 1}] `) + chalk.white(step));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Salvar config (sem tokens reais)
|
|
329
|
+
const configOutput = {
|
|
330
|
+
generatedAt: new Date().toISOString(),
|
|
331
|
+
domain,
|
|
332
|
+
projectName: projeto.projectName,
|
|
333
|
+
productType: projeto.productType,
|
|
334
|
+
adsPlataformas,
|
|
335
|
+
checkoutPlataformas: plataformas,
|
|
336
|
+
webhookUrls,
|
|
337
|
+
secretKeys: secrets.map(s => s.key),
|
|
338
|
+
whatsappAlertsEnabled: !!whatsapp.phone,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const outputPath = join(dir, 'cdp-edge-setup.json');
|
|
342
|
+
try {
|
|
343
|
+
writeFileSync(outputPath, JSON.stringify(configOutput, null, 2), 'utf8');
|
|
344
|
+
console.log(chalk.gray(`\n Configuração salva em: ${outputPath} (sem tokens)\n`));
|
|
345
|
+
} catch {
|
|
346
|
+
// não crítico se não conseguir salvar
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
sep();
|
|
350
|
+
console.log(chalk.green.bold('\n Setup concluído! Passe os comandos acima para o agente instalar.\n'));
|
|
136
351
|
}
|
|
137
352
|
|
|
138
353
|
function sleep(ms) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- CDP Edge — Migration v7
|
|
2
|
+
-- Tabelas para: LTV Model (regressão logística) + Match Quality Log
|
|
3
|
+
-- Idempotente: todos os CREATE usam IF NOT EXISTS
|
|
4
|
+
|
|
5
|
+
-- ── LTV Model Weights ─────────────────────────────────────────────────────────
|
|
6
|
+
-- Armazena os pesos treinados pelo cron semanal de regressão logística.
|
|
7
|
+
-- O Worker carrega via KV (TTL 7 dias) — query D1 apenas se KV miss.
|
|
8
|
+
CREATE TABLE IF NOT EXISTS ltv_model_weights (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
trained_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
11
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
12
|
+
is_active INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
sample_size INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
positive_rate REAL, -- % de samples com label=1 (compra)
|
|
15
|
+
accuracy REAL, -- acurácia no conjunto de treino
|
|
16
|
+
weights_json TEXT NOT NULL -- JSON: {"bias": n, "features": {"utm_fb": w, ...}}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_ltv_weights_active ON ltv_model_weights(is_active);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_ltv_weights_trained ON ltv_model_weights(trained_at DESC);
|
|
21
|
+
|
|
22
|
+
-- ── Match Quality Log ─────────────────────────────────────────────────────────
|
|
23
|
+
-- Registra a qualidade dos dados enviados ao Meta CAPI por evento.
|
|
24
|
+
-- Sem PII — apenas flags booleanos.
|
|
25
|
+
CREATE TABLE IF NOT EXISTS match_quality_log (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
logged_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
28
|
+
event_name TEXT NOT NULL,
|
|
29
|
+
has_email INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
has_phone INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
has_fbp INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
has_fbc INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
has_external_id INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
was_email_recovered INTEGER NOT NULL DEFAULT 0, -- recuperado do Identity Graph
|
|
35
|
+
was_utm_restored INTEGER NOT NULL DEFAULT 0 -- via UTM Resurrection
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_mq_logged ON match_quality_log(logged_at DESC);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_mq_event ON match_quality_log(event_name, logged_at DESC);
|
|
40
|
+
|
|
41
|
+
-- ── View: Match Quality Dashboard (últimas 24h) ───────────────────────────────
|
|
42
|
+
CREATE VIEW IF NOT EXISTS v_match_quality_24h AS
|
|
43
|
+
SELECT
|
|
44
|
+
COUNT(*) AS total_events,
|
|
45
|
+
ROUND(AVG(has_email) * 100, 1) AS email_rate_pct,
|
|
46
|
+
ROUND(AVG(has_phone) * 100, 1) AS phone_rate_pct,
|
|
47
|
+
ROUND(AVG(has_fbp) * 100, 1) AS fbp_rate_pct,
|
|
48
|
+
ROUND(AVG(has_fbc) * 100, 1) AS fbc_rate_pct,
|
|
49
|
+
ROUND(AVG(has_external_id) * 100, 1) AS ext_id_rate_pct,
|
|
50
|
+
ROUND(AVG(was_email_recovered) * 100, 1) AS email_recovered_pct,
|
|
51
|
+
ROUND(AVG(was_utm_restored) * 100, 1) AS utm_restored_pct,
|
|
52
|
+
-- Score composto: email(40%) + fbp(30%) + phone(20%) + fbc(10%)
|
|
53
|
+
ROUND((AVG(has_email)*0.4 + AVG(has_fbp)*0.3 + AVG(has_phone)*0.2 + AVG(has_fbc)*0.1) * 100, 1) AS composite_score_pct
|
|
54
|
+
FROM match_quality_log
|
|
55
|
+
WHERE logged_at >= datetime('now', '-24 hours');
|
|
56
|
+
|
|
57
|
+
-- ── ALTER: colunas de auditoria em ltv_ab_tests ───────────────────────────────
|
|
58
|
+
-- Adiciona auto_decided_at se ainda não existir (SQLite não tem IF NOT EXISTS em ALTER)
|
|
59
|
+
-- Protegido por trigger-style: INSERT OR IGNORE não se aplica a ALTER, então usamos
|
|
60
|
+
-- um approach de criação de tabela auxiliar que faz nada se a coluna já existir.
|
|
61
|
+
-- A abordagem mais segura no Cloudflare D1 é tentar e ignorar o erro no app.
|
|
62
|
+
-- Aqui deixamos comentado — o worker.js já lida com campos opcionais:
|
|
63
|
+
-- ALTER TABLE ltv_ab_tests ADD COLUMN auto_decided_at TEXT;
|
|
64
|
+
-- ALTER TABLE ltv_ab_tests ADD COLUMN auto_decided_reason TEXT;
|
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
|
|
6
6
|
import { sha256, normalizePhone, normalizeCity } from '../utils.js';
|
|
7
7
|
import { logApiFailure } from '../db.js';
|
|
8
|
+
import { logMatchQuality, autoEnrichPayload } from '../ml/matchquality.js';
|
|
8
9
|
|
|
9
10
|
export async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
11
|
+
// Auto-enriquecer payload com dados do Identity Graph antes do envio
|
|
12
|
+
let recovered = { email: false, utm: false };
|
|
13
|
+
if (env.DB && payload) {
|
|
14
|
+
const enriched = await autoEnrichPayload(env, payload);
|
|
15
|
+
payload = enriched.payload;
|
|
16
|
+
recovered = enriched.recovered;
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
const {
|
|
11
20
|
email, phone, firstName, lastName,
|
|
12
21
|
city, state, country,
|
|
@@ -69,6 +78,13 @@ export async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
|
69
78
|
requestBody.test_event_code = env.META_TEST_CODE;
|
|
70
79
|
}
|
|
71
80
|
|
|
81
|
+
// Logar match quality em background (não bloqueia dispatch)
|
|
82
|
+
if (env.DB && ctx) {
|
|
83
|
+
ctx.waitUntil(logMatchQuality(env.DB, eventName, payload, recovered));
|
|
84
|
+
} else if (env.DB) {
|
|
85
|
+
logMatchQuality(env.DB, eventName, payload, recovered).catch(() => {});
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
const endpoint = `https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events`;
|
|
73
89
|
|
|
74
90
|
try {
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import { sha256 } from './utils.js';
|
|
7
7
|
import { getHealthMetrics, generateDailyReport, logIntelligence } from './db.js';
|
|
8
8
|
import { sendCallMeBot } from './dispatch/whatsapp.js';
|
|
9
|
+
import { autoDecideAbWinner } from './ml/ltv.js';
|
|
10
|
+
import { analyzeMatchQuality, alertMatchQuality, purgeOldMatchQualityLogs } from './ml/matchquality.js';
|
|
11
|
+
import { trainLogisticRegression, extractFeatures, saveWeights, LTV_WEIGHTS_KV_KEY } from './ml/logistic.js';
|
|
9
12
|
|
|
10
13
|
// ── Versões esperadas das APIs ────────────────────────────────────────────────
|
|
11
14
|
const EXPECTED_API_VERSIONS = {
|
|
@@ -77,6 +80,62 @@ export async function auditErrorRates(env, runType) {
|
|
|
77
80
|
return alerts;
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
// ── Treinar modelo LTV (regressão logística com dados reais do D1) ────────────
|
|
84
|
+
export async function trainLtvModel(env) {
|
|
85
|
+
if (!env.DB) return { skipped: 'DB não disponível' };
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Busca leads com informação de conversão (compra confirmada)
|
|
89
|
+
const rows = await env.DB.prepare(`
|
|
90
|
+
SELECT
|
|
91
|
+
l.utm_source,
|
|
92
|
+
l.utm_medium,
|
|
93
|
+
l.engagement_score,
|
|
94
|
+
l.intention_level,
|
|
95
|
+
CAST(julianday('now') - julianday(l.created_at) AS INTEGER) AS days_since_lead,
|
|
96
|
+
CASE WHEN l.email IS NOT NULL AND l.email != '' THEN 1 ELSE 0 END AS has_email,
|
|
97
|
+
CASE WHEN l.phone IS NOT NULL AND l.phone != '' THEN 1 ELSE 0 END AS has_phone,
|
|
98
|
+
CASE WHEN (l.country = 'br' OR l.country = 'BR' OR l.country IS NULL) THEN 1 ELSE 0 END AS is_br,
|
|
99
|
+
CAST(strftime('%H', l.created_at) AS INTEGER) AS hour,
|
|
100
|
+
CASE WHEN EXISTS (
|
|
101
|
+
SELECT 1 FROM events e
|
|
102
|
+
WHERE e.user_id = l.user_id
|
|
103
|
+
AND e.event_name IN ('Purchase', 'purchase', 'PURCHASE')
|
|
104
|
+
AND e.created_at > l.created_at
|
|
105
|
+
) THEN 1 ELSE 0 END AS label
|
|
106
|
+
FROM leads l
|
|
107
|
+
WHERE l.created_at >= datetime('now', '-90 days')
|
|
108
|
+
LIMIT 5000
|
|
109
|
+
`).all();
|
|
110
|
+
|
|
111
|
+
const dataset = (rows.results || []).map(row => ({
|
|
112
|
+
features: extractFeatures(row),
|
|
113
|
+
label: row.label || 0,
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const model = trainLogisticRegression(dataset);
|
|
117
|
+
|
|
118
|
+
if (!model) {
|
|
119
|
+
console.log('[LTV Train] Dados insuficientes para treinar modelo');
|
|
120
|
+
return { skipped: 'dados insuficientes', samples: dataset.length };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await saveWeights(env.DB, model);
|
|
124
|
+
|
|
125
|
+
// Invalidar cache KV para que próximas requests carreguem o modelo novo
|
|
126
|
+
if (env.GEO_CACHE) {
|
|
127
|
+
env.GEO_CACHE.delete(LTV_WEIGHTS_KV_KEY).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`[LTV Train] Modelo treinado: ${dataset.length} samples, accuracy=${(model.accuracy * 100).toFixed(1)}%, positive_rate=${(model.positiveRate * 100).toFixed(1)}%`);
|
|
131
|
+
return { trained: true, samples: dataset.length, accuracy: model.accuracy, positiveRate: model.positiveRate };
|
|
132
|
+
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error('[LTV Train] Erro:', err.message);
|
|
135
|
+
return { error: err.message };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
80
139
|
// ── Runner principal do Intelligence Agent ────────────────────────────────────
|
|
81
140
|
export async function runIntelligenceAgent(env, runType) {
|
|
82
141
|
console.log(`[Intelligence Agent] Iniciando ${runType}`);
|
|
@@ -97,7 +156,61 @@ export async function runIntelligenceAgent(env, runType) {
|
|
|
97
156
|
console.warn(`[Intelligence Agent] ${errorAlerts.length} alertas de taxa de erro enviados`);
|
|
98
157
|
}
|
|
99
158
|
|
|
100
|
-
// 4.
|
|
159
|
+
// 4. Treinar modelo LTV (toda semana)
|
|
160
|
+
const ltvTrainResult = await trainLtvModel(env);
|
|
161
|
+
if (ltvTrainResult.trained) {
|
|
162
|
+
console.log(`[Intelligence Agent] LTV model treinado: accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`);
|
|
163
|
+
if (env.DB) {
|
|
164
|
+
await logIntelligence(env.DB, runType, 'ltv', 'model_training', 'ok',
|
|
165
|
+
`accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`, null,
|
|
166
|
+
`Modelo LTV re-treinado com ${ltvTrainResult.samples} amostras`
|
|
167
|
+
).catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
console.log(`[Intelligence Agent] LTV model: ${ltvTrainResult.skipped || ltvTrainResult.error || 'sem dados'}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 5. Auto-decisão de winner no A/B LTV Test
|
|
174
|
+
try {
|
|
175
|
+
const abResult = await autoDecideAbWinner(env);
|
|
176
|
+
if (abResult?.decided) {
|
|
177
|
+
console.log(`[Intelligence Agent] A/B LTV winner auto-decidido: test_id=${abResult.test_id}, winner=${abResult.winner_name}`);
|
|
178
|
+
|
|
179
|
+
await sendIntelligenceAlert(env, 'info',
|
|
180
|
+
`A/B LTV Test — Winner Declarado Automaticamente`,
|
|
181
|
+
`🏆 Vencedor: ${abResult.winner_name}\n📈 Melhoria: +${abResult.improvement?.toFixed(1) ?? '?'}pp vs controle\n🆔 Test ID: ${abResult.test_id}\n\n✅ Prompt vencedor ativado automaticamente`
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
if (env.DB) {
|
|
185
|
+
await logIntelligence(env.DB, runType, 'ltv', 'ab_auto_winner', 'ok',
|
|
186
|
+
abResult.winner_name, null,
|
|
187
|
+
`A/B winner auto-decidido: test ${abResult.test_id}, melhoria ${abResult.improvement?.toFixed(1)}pp`
|
|
188
|
+
).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error('[Intelligence Agent] A/B auto-decide error:', err.message);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 6. Match Quality — análise + alertas
|
|
196
|
+
try {
|
|
197
|
+
const mqAnalysis = await analyzeMatchQuality(env);
|
|
198
|
+
if (mqAnalysis) {
|
|
199
|
+
console.log(`[Intelligence Agent] Match Quality: score=${mqAnalysis.composite_score ?? 0}%, alerts=${mqAnalysis.alerts?.length ?? 0}`);
|
|
200
|
+
await alertMatchQuality(env, mqAnalysis);
|
|
201
|
+
|
|
202
|
+
if (env.DB && mqAnalysis.total > 0) {
|
|
203
|
+
await logIntelligence(env.DB, runType, 'meta', 'match_quality', mqAnalysis.alerts?.length > 0 ? 'warning' : 'ok',
|
|
204
|
+
`${mqAnalysis.composite_score ?? 0}%`, '45%',
|
|
205
|
+
`Match quality 2h: email=${mqAnalysis.email_rate ?? 0}%, fbp=${mqAnalysis.fbp_rate ?? 0}%, score=${mqAnalysis.composite_score ?? 0}%`
|
|
206
|
+
).catch(() => {});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error('[Intelligence Agent] Match quality analysis error:', err.message);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 7. Auditoria mensal adicional
|
|
101
214
|
if (runType === 'monthly_audit') {
|
|
102
215
|
if (env.DB) {
|
|
103
216
|
try {
|
|
@@ -115,14 +228,18 @@ export async function runIntelligenceAgent(env, runType) {
|
|
|
115
228
|
} catch (err) {
|
|
116
229
|
console.error('LTV audit error:', err.message);
|
|
117
230
|
}
|
|
231
|
+
|
|
232
|
+
// Purge de logs antigos de match quality (> 30 dias)
|
|
233
|
+
await purgeOldMatchQualityLogs(env.DB);
|
|
234
|
+
console.log('[Intelligence Agent] Match quality logs antigos purgados');
|
|
118
235
|
}
|
|
119
236
|
}
|
|
120
237
|
|
|
121
|
-
//
|
|
238
|
+
// 8. Customer Match sync semanal
|
|
122
239
|
const cmResult = await syncMetaCustomAudience(env);
|
|
123
240
|
console.log(`[Intelligence Agent] Customer Match Meta: sent=${cmResult?.sent ?? 0}, received=${cmResult?.num_received ?? 0}`);
|
|
124
241
|
|
|
125
|
-
console.log(`[Intelligence Agent] ${runType} concluído`);
|
|
242
|
+
console.log(`[Intelligence Agent] ${runType} concluído — LTV model, A/B auto-decide, match quality, customer match`);
|
|
126
243
|
}
|
|
127
244
|
|
|
128
245
|
// ── syncMetaCustomAudience — D1 → Meta Custom Audiences ─────────────────────
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Logistic Regression (pure JS, sem deps externas)
|
|
3
|
+
* Treina modelo de predição de conversão com dados reais do D1.
|
|
4
|
+
*
|
|
5
|
+
* Features usadas (todas normalizadas 0-1):
|
|
6
|
+
* utm_source, engagement_score, intention_level, recency,
|
|
7
|
+
* has_email, has_phone, is_br, hour_normalized
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Feature Engineering ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const UTM_SCORES = {
|
|
13
|
+
facebook: 0.90, instagram: 0.90, meta: 0.90,
|
|
14
|
+
google: 0.82, youtube: 0.82,
|
|
15
|
+
tiktok: 0.75,
|
|
16
|
+
email: 0.68, sms: 0.68,
|
|
17
|
+
organic: 0.30,
|
|
18
|
+
direct: 0.20,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const INTENTION_SCORES = {
|
|
22
|
+
comprador: 1.00, high_intent: 1.00,
|
|
23
|
+
interessado: 0.60,
|
|
24
|
+
nurture: 0.30,
|
|
25
|
+
curioso: 0.15,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function extractFeatures(row) {
|
|
29
|
+
const src = (row.utm_source || '').toLowerCase().trim();
|
|
30
|
+
const intention = (row.intention_level || '').toLowerCase().trim();
|
|
31
|
+
const daysSince = row.days_since_lead || 0;
|
|
32
|
+
|
|
33
|
+
return [
|
|
34
|
+
UTM_SCORES[src] ?? (src ? 0.10 : 0.05), // utm_score
|
|
35
|
+
Math.min((row.engagement_score || 0) / 5, 1), // engagement (0-5 → 0-1)
|
|
36
|
+
INTENTION_SCORES[intention] ?? 0, // intention
|
|
37
|
+
Math.max(0, 1 - daysSince / 90), // recency (0=90 dias, 1=hoje)
|
|
38
|
+
row.has_email ? 1 : 0, // has_email
|
|
39
|
+
row.has_phone ? 1 : 0, // has_phone
|
|
40
|
+
row.is_br ? 1 : 0, // is_br
|
|
41
|
+
((row.hour || 12) / 23), // hour normalized
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Sigmoid ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function sigmoid(z) {
|
|
48
|
+
if (z > 20) return 1;
|
|
49
|
+
if (z < -20) return 0;
|
|
50
|
+
return 1 / (1 + Math.exp(-z));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dot(weights, features) {
|
|
54
|
+
return features.reduce((sum, f, i) => sum + (weights[i] || 0) * f, 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Treinamento ───────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Treina regressão logística com gradiente descendente.
|
|
61
|
+
* @param {Array<{features: number[], label: number}>} dataset
|
|
62
|
+
* @param {{ iterations?, learningRate?, lambda? }} opts
|
|
63
|
+
* @returns {{ bias, weights, accuracy, positiveRate }}
|
|
64
|
+
*/
|
|
65
|
+
export function trainLogisticRegression(dataset, opts = {}) {
|
|
66
|
+
if (!dataset || dataset.length < 50) {
|
|
67
|
+
return null; // dados insuficientes
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const iterations = opts.iterations || 200;
|
|
71
|
+
const learningRate = opts.learningRate || 0.1;
|
|
72
|
+
const lambda = opts.lambda || 0.01; // L2 regularization
|
|
73
|
+
const nFeatures = dataset[0].features.length;
|
|
74
|
+
|
|
75
|
+
let bias = 0;
|
|
76
|
+
let weights = new Array(nFeatures).fill(0);
|
|
77
|
+
|
|
78
|
+
const positives = dataset.filter(d => d.label === 1).length;
|
|
79
|
+
const positiveRate = positives / dataset.length;
|
|
80
|
+
|
|
81
|
+
// Se menos de 5% positivos, não treina (dados de compra insuficientes)
|
|
82
|
+
if (positiveRate < 0.03) return null;
|
|
83
|
+
|
|
84
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
85
|
+
let dBias = 0;
|
|
86
|
+
const dWeights = new Array(nFeatures).fill(0);
|
|
87
|
+
|
|
88
|
+
for (const { features, label } of dataset) {
|
|
89
|
+
const z = dot(weights, features) + bias;
|
|
90
|
+
const pred = sigmoid(z);
|
|
91
|
+
const error = pred - label;
|
|
92
|
+
|
|
93
|
+
dBias += error;
|
|
94
|
+
for (let j = 0; j < nFeatures; j++) {
|
|
95
|
+
dWeights[j] += error * features[j];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const n = dataset.length;
|
|
100
|
+
bias -= learningRate * (dBias / n);
|
|
101
|
+
for (let j = 0; j < nFeatures; j++) {
|
|
102
|
+
// L2: penaliza pesos grandes para evitar overfitting
|
|
103
|
+
weights[j] -= learningRate * ((dWeights[j] / n) + lambda * weights[j]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Calcular acurácia no conjunto de treino
|
|
108
|
+
let correct = 0;
|
|
109
|
+
const threshold = positiveRate > 0.3 ? 0.5 : Math.max(0.3, positiveRate * 1.5);
|
|
110
|
+
|
|
111
|
+
for (const { features, label } of dataset) {
|
|
112
|
+
const z = dot(weights, features) + bias;
|
|
113
|
+
const pred = sigmoid(z) >= threshold ? 1 : 0;
|
|
114
|
+
if (pred === label) correct++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const accuracy = correct / dataset.length;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
bias,
|
|
121
|
+
weights,
|
|
122
|
+
accuracy,
|
|
123
|
+
positiveRate,
|
|
124
|
+
sampleSize: dataset.length,
|
|
125
|
+
threshold,
|
|
126
|
+
featureNames: ['utm_score', 'engagement', 'intention', 'recency', 'has_email', 'has_phone', 'is_br', 'hour'],
|
|
127
|
+
trainedAt: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Inferência ────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Prediz score de conversão (0-100) usando pesos treinados.
|
|
135
|
+
* @param {{ bias, weights, threshold }} model
|
|
136
|
+
* @param {number[]} features
|
|
137
|
+
* @returns {number} score 0-100
|
|
138
|
+
*/
|
|
139
|
+
export function predictWithWeights(model, features) {
|
|
140
|
+
const z = dot(model.weights, features) + model.bias;
|
|
141
|
+
const prob = sigmoid(z);
|
|
142
|
+
return Math.round(prob * 100);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Helpers de persistência ───────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
export const LTV_WEIGHTS_KV_KEY = 'ltv_weights_active';
|
|
148
|
+
|
|
149
|
+
export async function loadActiveWeights(env) {
|
|
150
|
+
// 1. Tentar KV (cache ~7 dias)
|
|
151
|
+
if (env.GEO_CACHE) {
|
|
152
|
+
try {
|
|
153
|
+
const cached = await env.GEO_CACHE.get(LTV_WEIGHTS_KV_KEY, 'json');
|
|
154
|
+
if (cached?.weights?.length) return cached;
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 2. Fallback: D1
|
|
159
|
+
if (!env.DB) return null;
|
|
160
|
+
try {
|
|
161
|
+
const row = await env.DB.prepare(
|
|
162
|
+
`SELECT weights_json FROM ltv_model_weights WHERE is_active = 1 ORDER BY trained_at DESC LIMIT 1`
|
|
163
|
+
).first();
|
|
164
|
+
if (!row?.weights_json) return null;
|
|
165
|
+
const model = JSON.parse(row.weights_json);
|
|
166
|
+
|
|
167
|
+
// Popular KV para próximas requests
|
|
168
|
+
if (env.GEO_CACHE && model?.weights?.length) {
|
|
169
|
+
env.GEO_CACHE.put(LTV_WEIGHTS_KV_KEY, JSON.stringify(model), { expirationTtl: 604800 }).catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
return model;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function saveWeights(DB, model) {
|
|
178
|
+
if (!DB || !model) return;
|
|
179
|
+
const now = new Date().toISOString();
|
|
180
|
+
|
|
181
|
+
// Desativar modelo anterior
|
|
182
|
+
await DB.prepare(`UPDATE ltv_model_weights SET is_active = 0 WHERE is_active = 1`).run();
|
|
183
|
+
|
|
184
|
+
// Inserir novo como ativo
|
|
185
|
+
await DB.prepare(`
|
|
186
|
+
INSERT INTO ltv_model_weights (trained_at, is_active, sample_size, positive_rate, accuracy, weights_json)
|
|
187
|
+
VALUES (?, 1, ?, ?, ?, ?)
|
|
188
|
+
`).bind(
|
|
189
|
+
now,
|
|
190
|
+
model.sampleSize,
|
|
191
|
+
model.positiveRate,
|
|
192
|
+
model.accuracy,
|
|
193
|
+
JSON.stringify(model),
|
|
194
|
+
).run();
|
|
195
|
+
}
|
|
@@ -3,11 +3,43 @@
|
|
|
3
3
|
* predictLtv, getLtvAbVariation, recordAbAssignment, handlers /api/ltv/*
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { extractFeatures, predictWithWeights, loadActiveWeights } from './logistic.js';
|
|
7
|
+
|
|
6
8
|
// Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
|
|
7
9
|
const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
|
|
8
10
|
|
|
9
11
|
// ── predictLtv — Heurística em 5 dimensões (0-100 pts) ───────────────────────
|
|
10
12
|
export async function predictLtv(env, payload, request, customSystemPrompt = null) {
|
|
13
|
+
// ── Tentar modelo treinado (regressão logística real) ─────────────────────
|
|
14
|
+
// Se existir modelo ativo no KV/D1, usa-o em vez da heurística manual.
|
|
15
|
+
// Fallback automático para heurística se modelo não disponível.
|
|
16
|
+
try {
|
|
17
|
+
const model = await loadActiveWeights(env);
|
|
18
|
+
if (model?.weights?.length) {
|
|
19
|
+
const hour = new Date().getUTCHours();
|
|
20
|
+
const country = (payload.country || request?.cf?.country || '').toUpperCase();
|
|
21
|
+
const features = extractFeatures({
|
|
22
|
+
utm_source: payload.utmSource,
|
|
23
|
+
engagement_score: parseFloat(payload.engagementScore || 0),
|
|
24
|
+
intention_level: payload.intentionLevel,
|
|
25
|
+
days_since_lead: 0, // evento atual = recência máxima
|
|
26
|
+
has_email: !!payload.email,
|
|
27
|
+
has_phone: !!payload.phone,
|
|
28
|
+
is_br: country === 'BR',
|
|
29
|
+
hour,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const score100 = predictWithWeights(model, features);
|
|
33
|
+
const ltvClass = score100 >= 70 ? 'High' : score100 >= 40 ? 'Medium' : 'Low';
|
|
34
|
+
const ltvMultiplier = score100 >= 70 ? 3.5 : score100 >= 40 ? 1.8 : 0.8;
|
|
35
|
+
const productValue = payload.value ? parseFloat(payload.value) : 0;
|
|
36
|
+
const baseValue = productValue > 0 ? productValue : 197;
|
|
37
|
+
const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100;
|
|
38
|
+
|
|
39
|
+
return { score: score100, class: ltvClass, value: predictedValue, source: 'model' };
|
|
40
|
+
}
|
|
41
|
+
} catch { /* fallback para heurística */ }
|
|
42
|
+
|
|
11
43
|
let score = 0;
|
|
12
44
|
|
|
13
45
|
// 1. Engajamento browser (0–30)
|
|
@@ -318,3 +350,71 @@ export async function handleLtvAbTestWinner(env, request, headers) {
|
|
|
318
350
|
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
319
351
|
}
|
|
320
352
|
}
|
|
353
|
+
|
|
354
|
+
// ── autoDecideAbWinner — declara winner automaticamente via cron ──────────────
|
|
355
|
+
// Critério: todas as variações com amostra >= min_sample
|
|
356
|
+
// E diferença de accuracy_score >= 5pp entre melhor e controle
|
|
357
|
+
export async function autoDecideAbWinner(env) {
|
|
358
|
+
if (!env.DB) return { decided: false, reason: 'no_db' };
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
// Buscar teste ativo
|
|
362
|
+
const test = await env.DB.prepare(
|
|
363
|
+
`SELECT id, name, min_sample, status FROM ltv_ab_tests WHERE status = 'running' ORDER BY id DESC LIMIT 1`
|
|
364
|
+
).first();
|
|
365
|
+
|
|
366
|
+
if (!test) return { decided: false, reason: 'no_running_test' };
|
|
367
|
+
|
|
368
|
+
// Buscar performance das variações
|
|
369
|
+
const perf = await env.DB.prepare(
|
|
370
|
+
`SELECT * FROM v_ab_test_performance WHERE test_id = ?`
|
|
371
|
+
).bind(test.id).all();
|
|
372
|
+
|
|
373
|
+
const variations = perf.results || [];
|
|
374
|
+
if (variations.length < 2) return { decided: false, reason: 'insufficient_variations' };
|
|
375
|
+
|
|
376
|
+
// Verificar se todas têm amostra suficiente
|
|
377
|
+
const allReady = variations.every(v => (v.total_assigned || 0) >= test.min_sample);
|
|
378
|
+
if (!allReady) {
|
|
379
|
+
const minAssigned = Math.min(...variations.map(v => v.total_assigned || 0));
|
|
380
|
+
return { decided: false, reason: `sample_insufficient (${minAssigned}/${test.min_sample})` };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Encontrar melhor e controle
|
|
384
|
+
const best = variations.reduce((a, b) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a);
|
|
385
|
+
const control = variations.find(v => v.is_control) || variations[0];
|
|
386
|
+
|
|
387
|
+
const bestScore = parseFloat(best.accuracy_score || 0);
|
|
388
|
+
const controlScore = parseFloat(control.accuracy_score || 0);
|
|
389
|
+
const diff = bestScore - controlScore;
|
|
390
|
+
|
|
391
|
+
// Empate técnico → controle vence (determinístico)
|
|
392
|
+
if (diff < 0.05) {
|
|
393
|
+
return { decided: false, reason: `difference_too_small (${(diff * 100).toFixed(1)}pp < 5pp)` };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Declarar winner
|
|
397
|
+
await env.DB.prepare(
|
|
398
|
+
`UPDATE ltv_ab_tests SET winner_id = ?, status = 'completed', completed_at = datetime('now') WHERE id = ?`
|
|
399
|
+
).bind(best.variation_id, test.id).run();
|
|
400
|
+
|
|
401
|
+
if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
|
|
402
|
+
|
|
403
|
+
console.log(`[AB-LTV] Winner auto-declarado: teste ${test.id}, variação "${best.variation_name}" (+${(diff * 100).toFixed(1)}pp)`);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
decided: true,
|
|
407
|
+
test_id: test.id,
|
|
408
|
+
test_name: test.name,
|
|
409
|
+
winner_id: best.variation_id,
|
|
410
|
+
winner_name: best.variation_name,
|
|
411
|
+
improvement: `+${(diff * 100).toFixed(1)}pp`,
|
|
412
|
+
is_control_winner: best.variation_id === control.variation_id,
|
|
413
|
+
winning_prompt: best.system_prompt || null,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
} catch (err) {
|
|
417
|
+
console.error('[AB-LTV] autoDecide error:', err.message);
|
|
418
|
+
return { decided: false, reason: err.message };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Match Quality (Fase 5)
|
|
3
|
+
* Rastreia qualidade dos dados enviados ao Meta CAPI.
|
|
4
|
+
* Detecta degradação e alerta via CallMeBot.
|
|
5
|
+
* Tenta auto-correção onde possível.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { sendCallMeBot } from '../dispatch/whatsapp.js';
|
|
9
|
+
|
|
10
|
+
// ── Thresholds de alerta ──────────────────────────────────────────────────────
|
|
11
|
+
const THRESHOLDS = {
|
|
12
|
+
email_rate_min: 0.40, // < 40% dos eventos com email → alerta
|
|
13
|
+
fbp_rate_min: 0.30, // < 30% com fbp cookie → alerta
|
|
14
|
+
composite_min: 0.45, // < 45% score composto → alerta crítico
|
|
15
|
+
min_events_alert: 10, // mínimo de eventos nas últimas 2h para disparar alerta
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ── Log de qualidade (chamado em meta.js a cada dispatch) ─────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Registra flags de qualidade de um evento no D1 (background, não bloqueia).
|
|
22
|
+
*/
|
|
23
|
+
export async function logMatchQuality(DB, eventName, payload, recovered = {}) {
|
|
24
|
+
if (!DB) return;
|
|
25
|
+
try {
|
|
26
|
+
await DB.prepare(`
|
|
27
|
+
INSERT INTO match_quality_log (
|
|
28
|
+
event_name, has_email, has_phone, has_fbp, has_fbc, has_external_id,
|
|
29
|
+
was_email_recovered, was_utm_restored
|
|
30
|
+
) VALUES (?,?,?,?,?,?,?,?)
|
|
31
|
+
`).bind(
|
|
32
|
+
eventName,
|
|
33
|
+
payload.email ? 1 : 0,
|
|
34
|
+
payload.phone ? 1 : 0,
|
|
35
|
+
payload.fbp ? 1 : 0,
|
|
36
|
+
payload.fbc ? 1 : 0,
|
|
37
|
+
payload.userId ? 1 : 0,
|
|
38
|
+
recovered.email ? 1 : 0,
|
|
39
|
+
recovered.utm ? 1 : 0,
|
|
40
|
+
).run();
|
|
41
|
+
} catch { /* não bloquear dispatch */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Auto-correção de payload ───────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tenta enriquecer o payload com dados do Identity Graph antes do envio ao Meta.
|
|
48
|
+
* Retorna { payload enriquecido, flags de recuperação }.
|
|
49
|
+
*/
|
|
50
|
+
export async function autoEnrichPayload(env, payload) {
|
|
51
|
+
const recovered = { email: false, utm: false };
|
|
52
|
+
if (!env.DB) return { payload, recovered };
|
|
53
|
+
|
|
54
|
+
// 1. Tentar recuperar email/fbp/fbc do perfil pelo userId
|
|
55
|
+
if (!payload.email && payload.userId) {
|
|
56
|
+
try {
|
|
57
|
+
const profile = await env.DB.prepare(
|
|
58
|
+
`SELECT email, fbp, fbc, phone FROM user_profiles WHERE user_id = ? LIMIT 1`
|
|
59
|
+
).bind(payload.userId).first();
|
|
60
|
+
|
|
61
|
+
if (profile) {
|
|
62
|
+
if (profile.email && !payload.email) {
|
|
63
|
+
payload.email = profile.email;
|
|
64
|
+
recovered.email = true;
|
|
65
|
+
}
|
|
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;
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. UTM Resurrection já foi tentada no /track handler (payload.utmRestored)
|
|
74
|
+
if (payload.utmRestored) recovered.utm = true;
|
|
75
|
+
|
|
76
|
+
return { payload, recovered };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Análise de qualidade (chamada pelo cron) ─────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Analisa a qualidade das últimas 2h e retorna métricas + alertas.
|
|
83
|
+
*/
|
|
84
|
+
export async function analyzeMatchQuality(env) {
|
|
85
|
+
if (!env.DB) return null;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const row = await env.DB.prepare(`
|
|
89
|
+
SELECT
|
|
90
|
+
COUNT(*) AS total,
|
|
91
|
+
ROUND(AVG(has_email) * 100, 1) AS email_rate,
|
|
92
|
+
ROUND(AVG(has_phone) * 100, 1) AS phone_rate,
|
|
93
|
+
ROUND(AVG(has_fbp) * 100, 1) AS fbp_rate,
|
|
94
|
+
ROUND(AVG(has_fbc) * 100, 1) AS fbc_rate,
|
|
95
|
+
ROUND(AVG(has_external_id) * 100, 1) AS ext_id_rate,
|
|
96
|
+
ROUND(AVG(was_email_recovered) * 100, 1) AS email_recovered_rate,
|
|
97
|
+
ROUND((AVG(has_email)*0.4 + AVG(has_fbp)*0.3 + AVG(has_phone)*0.2 + AVG(has_fbc)*0.1) * 100, 1) AS composite_score
|
|
98
|
+
FROM match_quality_log
|
|
99
|
+
WHERE logged_at >= datetime('now', '-2 hours')
|
|
100
|
+
`).first();
|
|
101
|
+
|
|
102
|
+
if (!row || row.total < THRESHOLDS.min_events_alert) return { total: row?.total || 0, alerts: [] };
|
|
103
|
+
|
|
104
|
+
const alerts = [];
|
|
105
|
+
|
|
106
|
+
if ((row.email_rate || 0) < THRESHOLDS.email_rate_min * 100) {
|
|
107
|
+
alerts.push({
|
|
108
|
+
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}%)`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if ((row.fbp_rate || 0) < THRESHOLDS.fbp_rate_min * 100) {
|
|
115
|
+
alerts.push({
|
|
116
|
+
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`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if ((row.composite_score || 0) < THRESHOLDS.composite_min * 100) {
|
|
123
|
+
alerts.push({
|
|
124
|
+
type: 'composite_critical',
|
|
125
|
+
metric: `composite: ${row.composite_score}%`,
|
|
126
|
+
message: `Score composto de match quality crítico: ${row.composite_score}%`,
|
|
127
|
+
severity: 'critical',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { ...row, alerts };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('[MatchQuality] analyze error:', err.message);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Alerta via CallMeBot ──────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export async function alertMatchQuality(env, analysis) {
|
|
141
|
+
if (!analysis || analysis.alerts.length === 0) return;
|
|
142
|
+
|
|
143
|
+
const hasCritical = analysis.alerts.some(a => a.severity === 'critical');
|
|
144
|
+
const icon = hasCritical ? '🚨' : '⚠️';
|
|
145
|
+
|
|
146
|
+
const lines = [
|
|
147
|
+
`${icon} CDP Edge — Match Quality Alert`,
|
|
148
|
+
``,
|
|
149
|
+
`📊 Últimas 2h (${analysis.total} eventos):`,
|
|
150
|
+
` Email: ${analysis.email_rate ?? 0}% ${(analysis.email_rate ?? 0) < 40 ? '❌' : '✅'}`,
|
|
151
|
+
` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
|
|
152
|
+
` Score: ${analysis.composite_score ?? 0}%`,
|
|
153
|
+
``,
|
|
154
|
+
`🔍 Problemas:`,
|
|
155
|
+
...analysis.alerts.map(a => ` · ${a.message}`),
|
|
156
|
+
``,
|
|
157
|
+
`🛠 Ações automáticas já ativas:`,
|
|
158
|
+
` · Identity Graph recovery: ${analysis.email_recovered_rate ?? 0}% emails recuperados`,
|
|
159
|
+
` · UTM Resurrection ativa`,
|
|
160
|
+
``,
|
|
161
|
+
new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' }),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
await sendCallMeBot(env, lines.join('\n'));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Purge periódico (mensal) ──────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export async function purgeOldMatchQualityLogs(DB) {
|
|
170
|
+
if (!DB) return;
|
|
171
|
+
try {
|
|
172
|
+
await DB.prepare(
|
|
173
|
+
`DELETE FROM match_quality_log WHERE logged_at < datetime('now', '-30 days')`
|
|
174
|
+
).run();
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|