cdp-edge 2.3.8 → 2.5.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.
Files changed (29) hide show
  1. package/README.md +304 -639
  2. package/bin/cdp-edge.js +3 -2
  3. package/dist/commands/validate.js +248 -84
  4. package/dist/sdk/cdpTrack.js +2095 -0
  5. package/dist/sdk/cdpTrack.min.js +64 -0
  6. package/dist/sdk/install-snippet.html +10 -0
  7. package/extracted-skill/tracking-events-generator/agents/devops-agent.md +22 -0
  8. package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +53 -0
  9. package/extracted-skill/tracking-events-generator/agents/lead-scoring-agent.md +282 -0
  10. package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +60 -6
  11. package/extracted-skill/tracking-events-generator/agents/match-quality-agent.md +304 -0
  12. package/extracted-skill/tracking-events-generator/agents/utm-agent.md +285 -154
  13. package/extracted-skill/tracking-events-generator/anti-blocking.js +1 -1
  14. package/extracted-skill/tracking-events-generator/cdpTrack.js +10 -18
  15. package/extracted-skill/tracking-events-generator/engagement-scoring.js +2 -2
  16. package/extracted-skill/tracking-events-generator/micro-events.js +1 -1
  17. package/extracted-skill/tracking-events-generator/models/quiz-funnel.md +83 -19
  18. package/extracted-skill/tracking-events-generator/tracking.config.js +3 -3
  19. package/package.json +5 -1
  20. package/scripts/build-sdk.js +106 -0
  21. package/server-edge-tracker/index.ts +174 -6
  22. package/server-edge-tracker/modules/intelligence.ts +155 -2
  23. package/server-edge-tracker/modules/ml/quiz.ts +343 -0
  24. package/server-edge-tracker/modules/ml/roas.ts +255 -0
  25. package/server-edge-tracker/modules/nurture.ts +257 -0
  26. package/server-edge-tracker/modules/utils.ts +2 -0
  27. package/server-edge-tracker/schema-quiz.sql +52 -0
  28. package/server-edge-tracker/schema-sales-engine.sql +113 -0
  29. package/templates/quiz-funnel.md +83 -19
@@ -9,6 +9,8 @@ import { sendCallMeBot } from './dispatch/whatsapp.js';
9
9
  import { autoDecideAbWinner } from './ml/ltv.js';
10
10
  import { analyzeMatchQuality, alertMatchQuality, purgeOldMatchQualityLogs } from './ml/matchquality.js';
11
11
  import { trainLogisticRegression, extractFeatures, saveWeights, LTV_WEIGHTS_KV_KEY } from './ml/logistic.js';
12
+ import { computeRoasFeedback, sendRoasAlert } from './ml/roas.js';
13
+ import { runNurtureQueue } from './nurture.js';
12
14
  import { Env } from '../types.js';
13
15
 
14
16
  // ── Tipos ───────────────────────────────────────────────────────────────────────
@@ -57,6 +59,22 @@ export interface IntelligenceAgentResult {
57
59
  skipped?: string;
58
60
  error?: string;
59
61
  };
62
+ roasResult?: {
63
+ campaigns: number;
64
+ total_revenue: number;
65
+ best_campaign: string | null;
66
+ skipped?: string;
67
+ };
68
+ nurtureResult?: {
69
+ processed: number;
70
+ sent: number;
71
+ failed: number;
72
+ };
73
+ lookalikeResult?: {
74
+ sent: number;
75
+ seed_type: string;
76
+ skipped?: string;
77
+ };
60
78
  }
61
79
 
62
80
  export interface CustomerMatchResult {
@@ -330,11 +348,50 @@ export async function runIntelligenceAgent(
330
348
  }
331
349
  }
332
350
 
333
- // 8. Customer Match sync semanal
351
+ // 8. Customer Match sync semanal (high_intent → Meta Audience)
334
352
  const cmResult = await syncMetaCustomAudience(env);
335
353
  console.log(`[Intelligence Agent] Customer Match Meta: sent=${cmResult?.sent ?? 0}, received=${cmResult?.received ?? 0}`);
336
354
 
337
- console.log(`[Intelligence Agent] ${runType} concluídoLTV model, A/B auto-decide, match quality, customer match`);
355
+ // 9. ROAS Feedback Loopcruza leads com compras reais por campanha
356
+ let roasResult: IntelligenceAgentResult['roasResult'] = undefined;
357
+ try {
358
+ const report = await computeRoasFeedback(env, 30);
359
+ if (report) {
360
+ roasResult = {
361
+ campaigns: report.campaigns.length,
362
+ total_revenue: report.total_revenue,
363
+ best_campaign: report.best_campaign,
364
+ };
365
+ await sendRoasAlert(env, report);
366
+ console.log(`[Intelligence Agent] ROAS: ${report.campaigns.length} campanhas, R$${report.total_revenue} receita`);
367
+ } else {
368
+ roasResult = { campaigns: 0, total_revenue: 0, best_campaign: null, skipped: 'sem dados suficientes' };
369
+ }
370
+ } catch (err: any) {
371
+ console.error('[Intelligence Agent] ROAS error:', err?.message || String(err));
372
+ }
373
+
374
+ // 10. Nurture Queue — processa mensagens agendadas (D+1, D+3, D+7)
375
+ let nurtureResult: IntelligenceAgentResult['nurtureResult'] = undefined;
376
+ try {
377
+ const nr = await runNurtureQueue(env);
378
+ nurtureResult = { processed: nr.processed, sent: nr.sent, failed: nr.failed };
379
+ console.log(`[Intelligence Agent] Nurture: ${nr.sent}/${nr.processed} mensagens enviadas`);
380
+ } catch (err: any) {
381
+ console.error('[Intelligence Agent] Nurture error:', err?.message || String(err));
382
+ }
383
+
384
+ // 11. Lookalike Dinâmico — compradores confirmados → Meta Audience seed
385
+ let lookalikeResult: IntelligenceAgentResult['lookalikeResult'] = undefined;
386
+ try {
387
+ const lr = await syncMetaLookalikeSeed(env);
388
+ lookalikeResult = lr;
389
+ console.log(`[Intelligence Agent] Lookalike seed: sent=${lr.sent}, type=${lr.seed_type}`);
390
+ } catch (err: any) {
391
+ console.error('[Intelligence Agent] Lookalike error:', err?.message || String(err));
392
+ }
393
+
394
+ console.log(`[Intelligence Agent] ${runType} concluído — LTV, A/B, match quality, customer match, ROAS, nurture, lookalike`);
338
395
 
339
396
  return {
340
397
  versionResults,
@@ -343,6 +400,9 @@ export async function runIntelligenceAgent(
343
400
  abResult,
344
401
  mqAnalysis,
345
402
  cmResult,
403
+ roasResult,
404
+ nurtureResult,
405
+ lookalikeResult,
346
406
  };
347
407
  }
348
408
 
@@ -400,6 +460,99 @@ export async function syncMetaCustomAudience(env: Env): Promise<CustomerMatchRes
400
460
  }
401
461
  }
402
462
 
463
+ // ── syncMetaLookalikeSeed — compradores confirmados → Meta Audience (Fase 7) ──
464
+ // Seed de Lookalike mais preciso: usa quem REALMENTE comprou (Purchase event)
465
+ // em vez de quem só teve intenção (cohort_label = high_intent).
466
+ // Separado do syncMetaCustomAudience para não misturar seeds de qualidade diferente.
467
+
468
+ export async function syncMetaLookalikeSeed(env: Env): Promise<{
469
+ sent: number;
470
+ seed_type: string;
471
+ skipped?: string;
472
+ }> {
473
+ if (!env.META_ACCESS_TOKEN || !env.META_AUDIENCE_ID) {
474
+ return { sent: 0, seed_type: 'buyer_confirmed', skipped: 'META secrets não configurados' };
475
+ }
476
+ if (!env.DB) return { sent: 0, seed_type: 'buyer_confirmed', skipped: 'DB não disponível' };
477
+
478
+ try {
479
+ // Busca perfis de compradores confirmados (Purchase event nos últimos 60 dias)
480
+ const confirmed = await env.DB.prepare(`
481
+ SELECT DISTINCT up.email, up.phone, up.first_name, up.last_name
482
+ FROM user_profiles up
483
+ JOIN leads l ON l.user_id = up.user_id
484
+ WHERE l.event_name IN ('Purchase','purchase')
485
+ AND l.created_at >= datetime('now', '-60 days')
486
+ AND up.email IS NOT NULL
487
+ UNION
488
+ SELECT DISTINCT up.email, up.phone, up.first_name, up.last_name
489
+ FROM user_profiles up
490
+ JOIN quiz_sessions qs ON qs.user_id = up.user_id
491
+ WHERE qs.qualification = 'comprador'
492
+ AND qs.created_at >= datetime('now', '-30 days')
493
+ AND up.email IS NOT NULL
494
+ LIMIT 10000
495
+ `).all();
496
+
497
+ if (!confirmed.results?.length) {
498
+ return { sent: 0, seed_type: 'buyer_confirmed', skipped: 'nenhum comprador confirmado no período' };
499
+ }
500
+
501
+ const data = await Promise.all(
502
+ confirmed.results.map(async (p: any) => [
503
+ p.email ? await sha256(p.email) : '',
504
+ p.phone ? await sha256(p.phone) : '',
505
+ ])
506
+ );
507
+
508
+ const body = { payload: { schema: ['EMAIL_SHA256', 'PHONE_SHA256'], data } };
509
+ const endpoint = `https://graph.facebook.com/v22.0/${env.META_AUDIENCE_ID}/users`;
510
+
511
+ const res = await fetch(endpoint, {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/json' },
514
+ body: JSON.stringify({ ...body, access_token: env.META_ACCESS_TOKEN }),
515
+ });
516
+
517
+ const result = await res.json() as any;
518
+
519
+ // Persiste histórico do seed
520
+ if (env.DB) {
521
+ await env.DB.prepare(`
522
+ INSERT INTO lookalike_seeds (audience_id, seed_type, profiles_sent, profiles_received, period_days)
523
+ VALUES (?, 'buyer_confirmed', ?, ?, 60)
524
+ `).bind(
525
+ env.META_AUDIENCE_ID,
526
+ confirmed.results.length,
527
+ result.num_received ?? null,
528
+ ).run().catch(() => {});
529
+ }
530
+
531
+ if (!res.ok) {
532
+ console.error('[Lookalike] Meta erro:', result.error?.message);
533
+ return { sent: 0, seed_type: 'buyer_confirmed', skipped: result.error?.message };
534
+ }
535
+
536
+ // Atualiza cohort_label dos compradores para buyer_confirmed
537
+ await env.DB.prepare(`
538
+ UPDATE user_profiles
539
+ SET cohort_label = 'buyer_confirmed', updated_at = datetime('now')
540
+ WHERE user_id IN (
541
+ SELECT DISTINCT user_id FROM leads
542
+ WHERE event_name IN ('Purchase','purchase')
543
+ AND created_at >= datetime('now', '-60 days')
544
+ )
545
+ `).run().catch(() => {});
546
+
547
+ console.log(`[Lookalike] ${confirmed.results.length} compradores confirmados enviados ao Meta`);
548
+ return { sent: confirmed.results.length, seed_type: 'buyer_confirmed' };
549
+
550
+ } catch (err: any) {
551
+ console.error('[Lookalike] syncMetaLookalikeSeed error:', err?.message || String(err));
552
+ return { sent: 0, seed_type: 'buyer_confirmed', skipped: err?.message };
553
+ }
554
+ }
555
+
403
556
  // ── buildGoogleCustomerMatchExport — gera JSON para Google Ads Customer Match ─
404
557
  export async function buildGoogleCustomerMatchExport(env: Env): Promise<GoogleCustomerMatchExport[]> {
405
558
  if (!env.DB) return [];
@@ -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
+ }