cdp-edge 1.27.0 → 1.28.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 +179 -459
- package/contracts/api-versions.json +6 -6
- package/dist/sdk/cdpTrack.js +2095 -0
- package/dist/sdk/cdpTrack.min.js +64 -0
- package/dist/sdk/install-snippet.html +10 -0
- package/extracted-skill/tracking-events-generator/agents/devops-agent.md +22 -0
- package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +53 -0
- package/extracted-skill/tracking-events-generator/agents/lead-scoring-agent.md +282 -0
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +60 -6
- package/extracted-skill/tracking-events-generator/agents/match-quality-agent.md +304 -0
- package/extracted-skill/tracking-events-generator/agents/utm-agent.md +285 -154
- package/extracted-skill/tracking-events-generator/anti-blocking.js +1 -1
- package/extracted-skill/tracking-events-generator/cdpTrack.js +10 -18
- package/extracted-skill/tracking-events-generator/contracts/api-versions.json +6 -6
- package/extracted-skill/tracking-events-generator/engagement-scoring.js +2 -2
- package/extracted-skill/tracking-events-generator/micro-events.js +1 -1
- package/extracted-skill/tracking-events-generator/models/quiz-funnel.md +83 -19
- package/extracted-skill/tracking-events-generator/tracking.config.js +3 -3
- package/package.json +5 -1
- package/scripts/build-sdk.js +106 -0
- package/server-edge-tracker/index.ts +81 -6
- package/server-edge-tracker/modules/intelligence.ts +155 -2
- package/server-edge-tracker/modules/ml/quiz.ts +343 -0
- package/server-edge-tracker/modules/ml/roas.ts +255 -0
- package/server-edge-tracker/modules/nurture.ts +257 -0
- package/server-edge-tracker/modules/utils.ts +2 -0
- package/server-edge-tracker/schema-quiz.sql +52 -0
- package/server-edge-tracker/schema-sales-engine.sql +113 -0
- package/templates/quiz-funnel.md +83 -19
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Quiz Scoring Engine v2 (Fase 6)
|
|
3
|
+
*
|
|
4
|
+
* Análise Dimensional Automática via Workers AI (Granite 4.0 Micro):
|
|
5
|
+
* 1. Detecta o TIPO de cada pergunta (urgency, budget, timeline, fit, etc.)
|
|
6
|
+
* 2. Atribui peso automático por dimensão (budget/urgency > timeline > fit > awareness)
|
|
7
|
+
* 3. Pontua a resposta (0.0–1.0) dentro dessa dimensão
|
|
8
|
+
* 4. Calcula score ponderado final → qualification
|
|
9
|
+
*
|
|
10
|
+
* O front-end NÃO precisa declarar pesos — o engine infere tudo a partir
|
|
11
|
+
* do conteúdo da pergunta e da resposta.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Env, TrackPayload } from '../../types.js';
|
|
15
|
+
|
|
16
|
+
// ── Tipos públicos ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface QuizAnswer {
|
|
19
|
+
question: string;
|
|
20
|
+
answer: string;
|
|
21
|
+
step?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type QuizQualification = 'comprador' | 'interessado' | 'curioso' | 'perdido';
|
|
25
|
+
|
|
26
|
+
/** Dimensão detectada automaticamente pelo LLM para cada pergunta */
|
|
27
|
+
export type QuizDimension =
|
|
28
|
+
| 'urgency' // "preciso agora", "urgente" — peso 5
|
|
29
|
+
| 'budget' // orçamento, financiamento, valor — peso 5
|
|
30
|
+
| 'timeline' // prazo, quando, em quanto tempo — peso 3
|
|
31
|
+
| 'fit' // perfil certo, problema real — peso 4
|
|
32
|
+
| 'engagement' // já pesquisou, visitou, comparou — peso 2
|
|
33
|
+
| 'awareness' // conhece o produto/serviço — peso 1
|
|
34
|
+
| 'objection' // dúvidas, barreiras, objeções — peso 3
|
|
35
|
+
| 'generic'; // pergunta sem dimensão clara — peso 1
|
|
36
|
+
|
|
37
|
+
/** Análise por pergunta retornada pelo LLM */
|
|
38
|
+
export interface QuizDimensionScore {
|
|
39
|
+
step: number;
|
|
40
|
+
dimension: QuizDimension;
|
|
41
|
+
score: number; // 0.0–1.0 dentro da dimensão
|
|
42
|
+
weight: number; // 1–5, inferido pelo LLM
|
|
43
|
+
signal: string; // trecho ou palavra que motivou a nota
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface QuizScoreResult {
|
|
47
|
+
qualification: QuizQualification;
|
|
48
|
+
intent_score: number; // 0.0–1.0 — compatível com resolveIntentScore()
|
|
49
|
+
weighted_score: number; // score ponderado bruto antes do mapeamento
|
|
50
|
+
confidence: number; // 0.0–1.0
|
|
51
|
+
reason: string; // frase curta em português para audit log
|
|
52
|
+
dominant_dimension: QuizDimension | null; // dimensão com maior impacto no score final
|
|
53
|
+
dimensions: QuizDimensionScore[]; // breakdown por pergunta (auditável)
|
|
54
|
+
source: 'ai' | 'heuristic';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Prompt Granite — análise dimensional ──────────────────────────────────────
|
|
58
|
+
// Instruções explícitas de formato para o modelo menor (Micro) respeitar o JSON.
|
|
59
|
+
|
|
60
|
+
const QUIZ_DIMENSIONAL_PROMPT = `You are a lead qualification expert for the Brazilian digital marketing and sales funnel market.
|
|
61
|
+
|
|
62
|
+
Your task: analyze each quiz question-answer pair and perform DIMENSIONAL SCORING.
|
|
63
|
+
|
|
64
|
+
STEP 1 — For each Q&A pair, detect the DIMENSION:
|
|
65
|
+
- "urgency": question asks about timing urgency or immediate need ("agora", "urgente", "preciso já")
|
|
66
|
+
- "budget": question asks about money, price, investment, financing, credit approval
|
|
67
|
+
- "timeline": question asks about purchase timeframe (days/months/year)
|
|
68
|
+
- "fit": question checks if respondent matches the target profile or has the real problem
|
|
69
|
+
- "engagement": question asks about prior research, visits, comparisons already done
|
|
70
|
+
- "awareness": question asks if they know the product/service/brand
|
|
71
|
+
- "objection": question surfaces doubts, barriers, or hesitations
|
|
72
|
+
- "generic": question has no clear commercial dimension
|
|
73
|
+
|
|
74
|
+
STEP 2 — Score the ANSWER within that dimension (0.00 to 1.00):
|
|
75
|
+
- 1.00 = strongest possible buying signal for this dimension
|
|
76
|
+
- 0.50 = neutral or uncertain
|
|
77
|
+
- 0.00 = negative signal (disqualifier)
|
|
78
|
+
|
|
79
|
+
STEP 3 — Assign WEIGHT by dimension (do not change these mappings):
|
|
80
|
+
- urgency: 5, budget: 5, fit: 4, timeline: 3, objection: 3, engagement: 2, awareness: 1, generic: 1
|
|
81
|
+
|
|
82
|
+
STEP 4 — Compute weighted_score = SUM(score * weight) / SUM(weight) across all questions.
|
|
83
|
+
|
|
84
|
+
STEP 5 — Map weighted_score to qualification:
|
|
85
|
+
- 0.75–1.00 → "comprador"
|
|
86
|
+
- 0.50–0.74 → "interessado"
|
|
87
|
+
- 0.20–0.49 → "curioso"
|
|
88
|
+
- 0.00–0.19 → "perdido"
|
|
89
|
+
|
|
90
|
+
Reply ONLY with valid JSON. No markdown. No explanation outside the JSON:
|
|
91
|
+
{
|
|
92
|
+
"dimensions": [
|
|
93
|
+
{ "step": 1, "dimension": "<type>", "score": 0.00, "weight": 0, "signal": "<word or phrase>" }
|
|
94
|
+
],
|
|
95
|
+
"weighted_score": 0.00,
|
|
96
|
+
"qualification": "<comprador|interessado|curioso|perdido>",
|
|
97
|
+
"confidence": 0.00,
|
|
98
|
+
"reason": "<one sentence in Portuguese>",
|
|
99
|
+
"dominant_dimension": "<dimension with highest score*weight product>"
|
|
100
|
+
}`;
|
|
101
|
+
|
|
102
|
+
// ── Pesos canônicos por dimensão (espelho do prompt — usado no fallback) ───────
|
|
103
|
+
|
|
104
|
+
const DIMENSION_WEIGHTS: Record<QuizDimension, number> = {
|
|
105
|
+
urgency: 5,
|
|
106
|
+
budget: 5,
|
|
107
|
+
fit: 4,
|
|
108
|
+
timeline: 3,
|
|
109
|
+
objection: 3,
|
|
110
|
+
engagement: 2,
|
|
111
|
+
awareness: 1,
|
|
112
|
+
generic: 1,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ── Mapeamento de score ponderado → intent_score suavizado ────────────────────
|
|
116
|
+
// O weighted_score é uma média ponderada de scores por dimensão (0-1).
|
|
117
|
+
// O intent_score preserva a mesma escala mas com limites por faixa.
|
|
118
|
+
|
|
119
|
+
function _weightedToIntentScore(ws: number, qual: QuizQualification): number {
|
|
120
|
+
// Mantém o score contínuo dentro da faixa da qualificação para não perder granularidade
|
|
121
|
+
const ranges: Record<QuizQualification, [number, number]> = {
|
|
122
|
+
comprador: [0.80, 1.00],
|
|
123
|
+
interessado: [0.50, 0.79],
|
|
124
|
+
curioso: [0.20, 0.49],
|
|
125
|
+
perdido: [0.00, 0.19],
|
|
126
|
+
};
|
|
127
|
+
const [lo, hi] = ranges[qual];
|
|
128
|
+
return _clamp(lo + (ws - lo) * ((hi - lo) / Math.max(hi - lo, 0.01)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── scoreQuizAnswers — função principal ───────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export async function scoreQuizAnswers(
|
|
134
|
+
env: Env,
|
|
135
|
+
answers: QuizAnswer[],
|
|
136
|
+
quizName?: string | null,
|
|
137
|
+
): Promise<QuizScoreResult> {
|
|
138
|
+
if (!answers || answers.length === 0) {
|
|
139
|
+
return _fallback([], 'Nenhuma resposta recebida');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Tenta via Workers AI
|
|
143
|
+
if (env.AI) {
|
|
144
|
+
try {
|
|
145
|
+
const answersText = answers
|
|
146
|
+
.map((a, i) => `P${a.step ?? i + 1}: "${a.question}" → "${a.answer}"`)
|
|
147
|
+
.join('\n');
|
|
148
|
+
|
|
149
|
+
const contextMsg = quizName
|
|
150
|
+
? `Quiz: "${quizName}"\n\n${answersText}`
|
|
151
|
+
: answersText;
|
|
152
|
+
|
|
153
|
+
// max_tokens: ~80 por dimensão × N perguntas + ~120 para o envelope final
|
|
154
|
+
const maxTokens = Math.min(512, 120 + answers.length * 80);
|
|
155
|
+
|
|
156
|
+
const aiRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', {
|
|
157
|
+
messages: [
|
|
158
|
+
{ role: 'system', content: QUIZ_DIMENSIONAL_PROMPT },
|
|
159
|
+
{ role: 'user', content: contextMsg },
|
|
160
|
+
],
|
|
161
|
+
max_tokens: maxTokens,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const raw = (aiRes as any)?.response?.trim() ?? '';
|
|
165
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
166
|
+
if (!jsonMatch) throw new Error('AI response has no JSON block');
|
|
167
|
+
|
|
168
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
169
|
+
|
|
170
|
+
// Valida e normaliza dimensions[]
|
|
171
|
+
const dimensions: QuizDimensionScore[] = Array.isArray(parsed.dimensions)
|
|
172
|
+
? (parsed.dimensions as any[]).map((d, i) => ({
|
|
173
|
+
step: typeof d.step === 'number' ? d.step : i + 1,
|
|
174
|
+
dimension: _validateDimension(d.dimension),
|
|
175
|
+
score: _clamp(parseFloat(String(d.score ?? 0.5))),
|
|
176
|
+
weight: typeof d.weight === 'number' ? Math.min(5, Math.max(1, d.weight)) : 1,
|
|
177
|
+
signal: String(d.signal || '').slice(0, 100),
|
|
178
|
+
}))
|
|
179
|
+
: [];
|
|
180
|
+
|
|
181
|
+
// Recalcula weighted_score no servidor (não confia cegamente no modelo)
|
|
182
|
+
const weighted_score = _computeWeightedScore(dimensions);
|
|
183
|
+
|
|
184
|
+
const qualification = _validateQualification(parsed.qualification);
|
|
185
|
+
const intent_score = _weightedToIntentScore(weighted_score, qualification);
|
|
186
|
+
const confidence = _clamp(parseFloat(String(parsed.confidence ?? 0.7)));
|
|
187
|
+
const reason = String(parsed.reason || '').slice(0, 200);
|
|
188
|
+
const dominant_dimension = _validateDimension(parsed.dominant_dimension) as QuizDimension | null;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
qualification,
|
|
192
|
+
intent_score,
|
|
193
|
+
weighted_score,
|
|
194
|
+
confidence,
|
|
195
|
+
reason,
|
|
196
|
+
dominant_dimension,
|
|
197
|
+
dimensions,
|
|
198
|
+
source: 'ai',
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
} catch (err: any) {
|
|
202
|
+
console.warn('[QuizScoring] AI falhou, usando heurística:', err?.message || String(err));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return _fallback(answers, 'Workers AI indisponível');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── saveQuizSession — persiste no D1 em background ───────────────────────────
|
|
210
|
+
|
|
211
|
+
export async function saveQuizSession(
|
|
212
|
+
env: Env,
|
|
213
|
+
userId: string | null | undefined,
|
|
214
|
+
payload: TrackPayload,
|
|
215
|
+
result: QuizScoreResult,
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
if (!env.DB) return;
|
|
218
|
+
try {
|
|
219
|
+
await env.DB.prepare(`
|
|
220
|
+
INSERT INTO quiz_sessions (
|
|
221
|
+
user_id, quiz_name, answers_json,
|
|
222
|
+
qualification, intent_score, weighted_score, confidence,
|
|
223
|
+
reason, dominant_dimension, dimensions_json, source, created_at
|
|
224
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
225
|
+
`).bind(
|
|
226
|
+
userId || null,
|
|
227
|
+
String(payload.quiz_name || ''),
|
|
228
|
+
JSON.stringify(payload.quiz_answers || []),
|
|
229
|
+
result.qualification,
|
|
230
|
+
result.intent_score,
|
|
231
|
+
result.weighted_score,
|
|
232
|
+
result.confidence,
|
|
233
|
+
result.reason,
|
|
234
|
+
result.dominant_dimension || null,
|
|
235
|
+
JSON.stringify(result.dimensions),
|
|
236
|
+
result.source,
|
|
237
|
+
).run();
|
|
238
|
+
} catch (err: any) {
|
|
239
|
+
console.error('[QuizScoring] saveQuizSession error:', err?.message || String(err));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Helpers internos ──────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
function _clamp(n: number): number {
|
|
246
|
+
if (isNaN(n)) return 0;
|
|
247
|
+
return Math.min(1, Math.max(0, Math.round(n * 100) / 100));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function _validateQualification(val: unknown): QuizQualification {
|
|
251
|
+
const valid: QuizQualification[] = ['comprador', 'interessado', 'curioso', 'perdido'];
|
|
252
|
+
return valid.includes(val as QuizQualification) ? (val as QuizQualification) : 'curioso';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _validateDimension(val: unknown): QuizDimension {
|
|
256
|
+
const valid: QuizDimension[] = ['urgency','budget','timeline','fit','engagement','awareness','objection','generic'];
|
|
257
|
+
return valid.includes(val as QuizDimension) ? (val as QuizDimension) : 'generic';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function _computeWeightedScore(dims: QuizDimensionScore[]): number {
|
|
261
|
+
if (!dims.length) return 0;
|
|
262
|
+
const sumWS = dims.reduce((acc, d) => acc + d.score * d.weight, 0);
|
|
263
|
+
const sumW = dims.reduce((acc, d) => acc + d.weight, 0);
|
|
264
|
+
return _clamp(sumW > 0 ? sumWS / sumW : 0);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Fallback heurístico dimensional ──────────────────────────────────────────
|
|
268
|
+
// Quando Workers AI está indisponível, classifica por padrões de texto
|
|
269
|
+
// e gera um breakdown dimensional sintético para manter consistência de contrato.
|
|
270
|
+
|
|
271
|
+
const HEURISTIC_DIMENSION_PATTERNS: Array<{
|
|
272
|
+
dimension: QuizDimension;
|
|
273
|
+
patterns: string[];
|
|
274
|
+
}> = [
|
|
275
|
+
{ dimension: 'urgency', patterns: ['agora','hoje','urgente','imediato','já','preciso logo','não posso esperar'] },
|
|
276
|
+
{ dimension: 'budget', patterns: ['orçamento','budget','financiamento','aprovado','crédito','dinheiro','quanto custa','valor','posso pagar','tenho recurso'] },
|
|
277
|
+
{ dimension: 'timeline', patterns: ['mês','meses','semana','semanas','ano','prazo','quando','breve','futuramente','em quanto tempo'] },
|
|
278
|
+
{ dimension: 'fit', patterns: ['sim','é para mim','tenho esse problema','preciso disso','faz sentido','encaixa','me identifico'] },
|
|
279
|
+
{ dimension: 'engagement', patterns: ['já pesquisei','comparei','visitei','já vi','pesquisando','avaliando','testei'] },
|
|
280
|
+
{ dimension: 'awareness', patterns: ['conheço','já ouvi','sabia','já usei','familiar'] },
|
|
281
|
+
{ dimension: 'objection', patterns: ['mas','porém','dúvida','não tenho certeza','talvez','depende','preciso pensar','não sei'] },
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
const HEURISTIC_QUAL_SCORE: Record<QuizQualification, number> = {
|
|
285
|
+
comprador: 0.87, interessado: 0.62, curioso: 0.30, perdido: 0.10,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
function _fallback(answers: QuizAnswer[], note: string): QuizScoreResult {
|
|
289
|
+
// Gera breakdown dimensional sintético por pergunta
|
|
290
|
+
const dimensions: QuizDimensionScore[] = answers.map((a, i) => {
|
|
291
|
+
const text = `${a.question} ${a.answer}`.toLowerCase();
|
|
292
|
+
|
|
293
|
+
let bestDim: QuizDimension = 'generic';
|
|
294
|
+
let bestScore = 0.4; // neutro
|
|
295
|
+
let bestSignal = '';
|
|
296
|
+
|
|
297
|
+
for (const { dimension, patterns } of HEURISTIC_DIMENSION_PATTERNS) {
|
|
298
|
+
const matched = patterns.filter(p => text.includes(p));
|
|
299
|
+
if (matched.length > 0) {
|
|
300
|
+
// Score positivo se resposta tem sinal de compra, negativo se objeção
|
|
301
|
+
const baseScore = dimension === 'objection' ? 0.3 : 0.75;
|
|
302
|
+
if (baseScore > bestScore || (bestDim === 'generic' && matched.length > 0)) {
|
|
303
|
+
bestDim = dimension;
|
|
304
|
+
bestScore = baseScore;
|
|
305
|
+
bestSignal = matched[0];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
step: a.step ?? i + 1,
|
|
312
|
+
dimension: bestDim,
|
|
313
|
+
score: bestScore,
|
|
314
|
+
weight: DIMENSION_WEIGHTS[bestDim],
|
|
315
|
+
signal: bestSignal || a.answer.slice(0, 60),
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const weighted_score = _computeWeightedScore(dimensions.length ? dimensions : [{ step:1, dimension:'generic', score:0.3, weight:1, signal:'' }]);
|
|
320
|
+
|
|
321
|
+
const qualification: QuizQualification =
|
|
322
|
+
weighted_score >= 0.75 ? 'comprador' :
|
|
323
|
+
weighted_score >= 0.50 ? 'interessado' :
|
|
324
|
+
weighted_score >= 0.20 ? 'curioso' : 'perdido';
|
|
325
|
+
|
|
326
|
+
// Dimensão dominante = maior produto score × weight
|
|
327
|
+
const dominant = dimensions.length
|
|
328
|
+
? dimensions.reduce((best, d) =>
|
|
329
|
+
(d.score * d.weight > best.score * best.weight ? d : best)
|
|
330
|
+
).dimension
|
|
331
|
+
: null;
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
qualification,
|
|
335
|
+
intent_score: _clamp(HEURISTIC_QUAL_SCORE[qualification]),
|
|
336
|
+
weighted_score,
|
|
337
|
+
confidence: Math.min(0.6, 0.25 + dimensions.filter(d => d.signal).length * 0.07),
|
|
338
|
+
reason: `${note}. Score ponderado heurístico: ${(weighted_score * 100).toFixed(0)}/100.`,
|
|
339
|
+
dominant_dimension: dominant as QuizDimension | null,
|
|
340
|
+
dimensions,
|
|
341
|
+
source: 'heuristic',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — ROAS Feedback Loop (Fase 7)
|
|
3
|
+
*
|
|
4
|
+
* Cruza dados de leads (UTM) com compras confirmadas (Purchase events) no D1
|
|
5
|
+
* para calcular qualidade real de cada campanha:
|
|
6
|
+
* → revenue_per_lead, conversion_rate, ltv_accuracy
|
|
7
|
+
* → alimenta bidding.ts com dados reais de ROAS
|
|
8
|
+
* → relatório semanal via CallMeBot
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Env } from '../../types.js';
|
|
12
|
+
import { sendIntelligenceAlert } from '../intelligence.js';
|
|
13
|
+
|
|
14
|
+
// ── Tipos ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface CampaignRoas {
|
|
17
|
+
utm_source: string;
|
|
18
|
+
utm_campaign: string;
|
|
19
|
+
utm_content: string; // origem do lead: quiz_*, video_*, landing_*, ctwa_*
|
|
20
|
+
total_leads: number;
|
|
21
|
+
confirmed_buyers: number;
|
|
22
|
+
conversion_rate: number; // 0.0–1.0
|
|
23
|
+
total_revenue: number; // soma de value dos Purchase events
|
|
24
|
+
revenue_per_lead: number; // total_revenue / total_leads
|
|
25
|
+
avg_ltv_score: number; // média do ltvScore predito (valida accuracy do modelo)
|
|
26
|
+
ltv_accuracy: number; // % de leads com ltvClass=High que realmente compraram
|
|
27
|
+
top_qualification: string; // qualificação quiz mais frequente nessa campanha
|
|
28
|
+
bid_recommendation: 'increase' | 'maintain' | 'decrease' | 'pause';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RoasReport {
|
|
32
|
+
generated_at: string;
|
|
33
|
+
period_days: number;
|
|
34
|
+
campaigns: CampaignRoas[];
|
|
35
|
+
total_revenue: number;
|
|
36
|
+
total_leads: number;
|
|
37
|
+
best_campaign: string | null;
|
|
38
|
+
worst_campaign: string | null;
|
|
39
|
+
model_accuracy: number; // % geral do modelo LTV
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── computeRoasFeedback — calcula ROAS por campanha ───────────────────────────
|
|
43
|
+
|
|
44
|
+
export async function computeRoasFeedback(
|
|
45
|
+
env: Env,
|
|
46
|
+
periodDays = 30,
|
|
47
|
+
): Promise<RoasReport | null> {
|
|
48
|
+
if (!env.DB) return null;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Leads com UTM no período + seu LTV predito
|
|
52
|
+
const leadsRows = await env.DB.prepare(`
|
|
53
|
+
SELECT
|
|
54
|
+
COALESCE(l.utm_source, 'direct') AS utm_source,
|
|
55
|
+
COALESCE(l.utm_campaign, 'unknown') AS utm_campaign,
|
|
56
|
+
COALESCE(l.utm_content, 'unknown') AS utm_content,
|
|
57
|
+
l.user_id,
|
|
58
|
+
l.value AS predicted_value,
|
|
59
|
+
l.intention_level,
|
|
60
|
+
up.predicted_ltv_class,
|
|
61
|
+
qs.qualification AS quiz_qualification
|
|
62
|
+
FROM leads l
|
|
63
|
+
LEFT JOIN user_profiles up ON up.user_id = l.user_id
|
|
64
|
+
LEFT JOIN quiz_sessions qs ON qs.user_id = l.user_id
|
|
65
|
+
WHERE l.created_at >= datetime('now', '-' || ? || ' days')
|
|
66
|
+
AND l.event_name IN ('Lead','Contact','QuizComplete','CompleteRegistration')
|
|
67
|
+
ORDER BY l.created_at DESC
|
|
68
|
+
`).bind(periodDays).all();
|
|
69
|
+
|
|
70
|
+
// Compras confirmadas no mesmo período — JOIN por user_id
|
|
71
|
+
const purchaseRows = await env.DB.prepare(`
|
|
72
|
+
SELECT
|
|
73
|
+
l.utm_source,
|
|
74
|
+
l.utm_campaign,
|
|
75
|
+
l.utm_content,
|
|
76
|
+
l.user_id,
|
|
77
|
+
e.value AS purchase_value
|
|
78
|
+
FROM events e
|
|
79
|
+
JOIN leads l ON l.user_id = e.user_id
|
|
80
|
+
WHERE e.event_name IN ('Purchase','purchase')
|
|
81
|
+
AND e.created_at >= datetime('now', '-' || ? || ' days')
|
|
82
|
+
AND l.event_name IN ('Lead','Contact','QuizComplete','CompleteRegistration')
|
|
83
|
+
`).bind(periodDays).all();
|
|
84
|
+
|
|
85
|
+
const leads = (leadsRows.results || []) as any[];
|
|
86
|
+
const purchases = (purchaseRows.results || []) as any[];
|
|
87
|
+
|
|
88
|
+
if (leads.length === 0) return null;
|
|
89
|
+
|
|
90
|
+
// Indexa compras por user_id para lookup O(1)
|
|
91
|
+
const buyerMap = new Map<string, number>(); // user_id → purchase_value
|
|
92
|
+
for (const p of purchases) {
|
|
93
|
+
const existing = buyerMap.get(p.user_id) || 0;
|
|
94
|
+
buyerMap.set(p.user_id, existing + (parseFloat(String(p.purchase_value || 0))));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Agrupa por campanha
|
|
98
|
+
const campaignMap = new Map<string, {
|
|
99
|
+
leads: any[];
|
|
100
|
+
buyers: Set<string>;
|
|
101
|
+
revenue: number;
|
|
102
|
+
highLtvLeads: number;
|
|
103
|
+
highLtvBuyers: number;
|
|
104
|
+
qualifications: Record<string, number>;
|
|
105
|
+
}>();
|
|
106
|
+
|
|
107
|
+
for (const lead of leads) {
|
|
108
|
+
const key = `${lead.utm_source}|||${lead.utm_campaign}|||${lead.utm_content || 'unknown'}`;
|
|
109
|
+
if (!campaignMap.has(key)) {
|
|
110
|
+
campaignMap.set(key, {
|
|
111
|
+
leads: [], buyers: new Set(), revenue: 0,
|
|
112
|
+
highLtvLeads: 0, highLtvBuyers: 0,
|
|
113
|
+
qualifications: {},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const c = campaignMap.get(key)!;
|
|
117
|
+
c.leads.push(lead);
|
|
118
|
+
|
|
119
|
+
if (lead.predicted_ltv_class === 'High') c.highLtvLeads++;
|
|
120
|
+
|
|
121
|
+
const purchaseValue = buyerMap.get(lead.user_id);
|
|
122
|
+
if (purchaseValue !== undefined) {
|
|
123
|
+
c.buyers.add(lead.user_id);
|
|
124
|
+
c.revenue += purchaseValue;
|
|
125
|
+
if (lead.predicted_ltv_class === 'High') c.highLtvBuyers++;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const qual = lead.quiz_qualification || lead.intention_level || 'unknown';
|
|
129
|
+
c.qualifications[qual] = (c.qualifications[qual] || 0) + 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Monta relatório por campanha
|
|
133
|
+
const campaigns: CampaignRoas[] = [];
|
|
134
|
+
for (const [key, c] of campaignMap.entries()) {
|
|
135
|
+
const [utm_source, utm_campaign, utm_content] = key.split('|||');
|
|
136
|
+
const total_leads = c.leads.length;
|
|
137
|
+
const confirmed_buyers = c.buyers.size;
|
|
138
|
+
const conversion_rate = total_leads > 0 ? confirmed_buyers / total_leads : 0;
|
|
139
|
+
const revenue_per_lead = total_leads > 0 ? c.revenue / total_leads : 0;
|
|
140
|
+
const avg_ltv_score = c.leads.reduce((s: number, l: any) => s + parseFloat(String(l.predicted_value || 0)), 0) / total_leads;
|
|
141
|
+
const ltv_accuracy = c.highLtvLeads > 0 ? c.highLtvBuyers / c.highLtvLeads : 0;
|
|
142
|
+
|
|
143
|
+
const top_qualification = Object.entries(c.qualifications)
|
|
144
|
+
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown';
|
|
145
|
+
|
|
146
|
+
// Recomendação de bid baseada em conversão + accuracy
|
|
147
|
+
let bid_recommendation: CampaignRoas['bid_recommendation'];
|
|
148
|
+
if (conversion_rate >= 0.15 && ltv_accuracy >= 0.6) bid_recommendation = 'increase';
|
|
149
|
+
else if (conversion_rate >= 0.05 && ltv_accuracy >= 0.3) bid_recommendation = 'maintain';
|
|
150
|
+
else if (conversion_rate > 0 && confirmed_buyers > 0) bid_recommendation = 'decrease';
|
|
151
|
+
else bid_recommendation = 'pause';
|
|
152
|
+
|
|
153
|
+
campaigns.push({
|
|
154
|
+
utm_source, utm_campaign, utm_content,
|
|
155
|
+
total_leads, confirmed_buyers,
|
|
156
|
+
conversion_rate: Math.round(conversion_rate * 10000) / 10000,
|
|
157
|
+
total_revenue: Math.round(c.revenue * 100) / 100,
|
|
158
|
+
revenue_per_lead: Math.round(revenue_per_lead * 100) / 100,
|
|
159
|
+
avg_ltv_score: Math.round(avg_ltv_score * 100) / 100,
|
|
160
|
+
ltv_accuracy: Math.round(ltv_accuracy * 10000) / 10000,
|
|
161
|
+
top_qualification,
|
|
162
|
+
bid_recommendation,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Ordena por revenue total desc
|
|
167
|
+
campaigns.sort((a, b) => b.total_revenue - a.total_revenue);
|
|
168
|
+
|
|
169
|
+
const total_revenue = campaigns.reduce((s, c) => s + c.total_revenue, 0);
|
|
170
|
+
const total_leads = campaigns.reduce((s, c) => s + c.total_leads, 0);
|
|
171
|
+
// model_accuracy: média ponderada de ltv_accuracy por campanha (High LTV que realmente compraram)
|
|
172
|
+
const model_accuracy = campaigns.length > 0
|
|
173
|
+
? campaigns.reduce((s, c) => s + c.ltv_accuracy * c.total_leads, 0) / Math.max(total_leads, 1)
|
|
174
|
+
: 0;
|
|
175
|
+
|
|
176
|
+
const best_campaign = campaigns.find(c => c.bid_recommendation === 'increase')
|
|
177
|
+
? `${campaigns[0].utm_source} / ${campaigns[0].utm_campaign}`
|
|
178
|
+
: null;
|
|
179
|
+
const worst_campaign = campaigns.find(c => c.bid_recommendation === 'pause')
|
|
180
|
+
? (() => { const w = campaigns.find(c => c.bid_recommendation === 'pause')!; return `${w.utm_source} / ${w.utm_campaign}`; })()
|
|
181
|
+
: null;
|
|
182
|
+
|
|
183
|
+
// Persiste no D1 para histórico
|
|
184
|
+
await _persistRoasReport(env, campaigns, periodDays);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
generated_at: new Date().toISOString(),
|
|
188
|
+
period_days: periodDays,
|
|
189
|
+
campaigns,
|
|
190
|
+
total_revenue: Math.round(total_revenue * 100) / 100,
|
|
191
|
+
total_leads,
|
|
192
|
+
best_campaign,
|
|
193
|
+
worst_campaign,
|
|
194
|
+
model_accuracy: Math.round(model_accuracy * 10000) / 10000,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
} catch (err: any) {
|
|
198
|
+
console.error('[ROAS] computeRoasFeedback error:', err?.message || String(err));
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── sendRoasAlert — relatório via CallMeBot ───────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export async function sendRoasAlert(env: Env, report: RoasReport): Promise<void> {
|
|
206
|
+
const top3 = report.campaigns.slice(0, 3);
|
|
207
|
+
const lines = top3.map(c =>
|
|
208
|
+
`• ${c.utm_source}/${c.utm_campaign}/${c.utm_content}: ${c.confirmed_buyers} compradores, R$${c.total_revenue.toLocaleString('pt-BR')} (${(c.conversion_rate * 100).toFixed(1)}% conv) → ${c.bid_recommendation.toUpperCase()}`
|
|
209
|
+
).join('\n');
|
|
210
|
+
|
|
211
|
+
const pauseCount = report.campaigns.filter(c => c.bid_recommendation === 'pause').length;
|
|
212
|
+
const increaseCount = report.campaigns.filter(c => c.bid_recommendation === 'increase').length;
|
|
213
|
+
|
|
214
|
+
const details = [
|
|
215
|
+
`📅 Período: últimos ${report.period_days} dias`,
|
|
216
|
+
`💰 Receita total: R$${report.total_revenue.toLocaleString('pt-BR')}`,
|
|
217
|
+
`👥 Total leads: ${report.total_leads}`,
|
|
218
|
+
`📈 Top campanhas por receita:\n${lines}`,
|
|
219
|
+
increaseCount > 0 ? `✅ ${increaseCount} campanha(s) recomendada(s) para AUMENTAR bid` : '',
|
|
220
|
+
pauseCount > 0 ? `⛔ ${pauseCount} campanha(s) recomendada(s) para PAUSAR` : '',
|
|
221
|
+
report.best_campaign ? `🏆 Melhor: ${report.best_campaign}` : '',
|
|
222
|
+
report.worst_campaign ? `🔻 Pior: ${report.worst_campaign}` : '',
|
|
223
|
+
].filter(Boolean).join('\n');
|
|
224
|
+
|
|
225
|
+
await sendIntelligenceAlert(env, 'info', 'ROAS Feedback Semanal', details);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── _persistRoasReport — salva snapshot no D1 ────────────────────────────────
|
|
229
|
+
|
|
230
|
+
async function _persistRoasReport(
|
|
231
|
+
env: Env,
|
|
232
|
+
campaigns: CampaignRoas[],
|
|
233
|
+
periodDays: number,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
if (!env.DB) return;
|
|
236
|
+
try {
|
|
237
|
+
for (const c of campaigns) {
|
|
238
|
+
await env.DB.prepare(`
|
|
239
|
+
INSERT INTO roas_reports (
|
|
240
|
+
utm_source, utm_campaign, utm_content, period_days,
|
|
241
|
+
total_leads, confirmed_buyers, conversion_rate,
|
|
242
|
+
total_revenue, revenue_per_lead, ltv_accuracy,
|
|
243
|
+
top_qualification, bid_recommendation, created_at
|
|
244
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
245
|
+
`).bind(
|
|
246
|
+
c.utm_source, c.utm_campaign, c.utm_content, periodDays,
|
|
247
|
+
c.total_leads, c.confirmed_buyers, c.conversion_rate,
|
|
248
|
+
c.total_revenue, c.revenue_per_lead, c.ltv_accuracy,
|
|
249
|
+
c.top_qualification, c.bid_recommendation,
|
|
250
|
+
).run();
|
|
251
|
+
}
|
|
252
|
+
} catch (err: any) {
|
|
253
|
+
console.error('[ROAS] persist error:', err?.message || String(err));
|
|
254
|
+
}
|
|
255
|
+
}
|