/** * CDP Edge — Quiz Scoring Engine v2 (Fase 6) * * Análise Dimensional Automática via Workers AI (Granite 4.0 Micro): * 1. Detecta o TIPO de cada pergunta (urgency, budget, timeline, fit, etc.) * 2. Atribui peso automático por dimensão (budget/urgency > timeline > fit > awareness) * 3. Pontua a resposta (0.0–1.0) dentro dessa dimensão * 4. Calcula score ponderado final → qualification * * O front-end NÃO precisa declarar pesos — o engine infere tudo a partir * do conteúdo da pergunta e da resposta. */ import { Env, TrackPayload } from '../../types.js'; // ── Tipos públicos ───────────────────────────────────────────────────────────── export interface QuizAnswer { question: string; answer: string; step?: number; } export type QuizQualification = 'comprador' | 'interessado' | 'curioso' | 'perdido'; /** Dimensão detectada automaticamente pelo LLM para cada pergunta */ export type QuizDimension = | 'urgency' // "preciso agora", "urgente" — peso 5 | 'budget' // orçamento, financiamento, valor — peso 5 | 'timeline' // prazo, quando, em quanto tempo — peso 3 | 'fit' // perfil certo, problema real — peso 4 | 'engagement' // já pesquisou, visitou, comparou — peso 2 | 'awareness' // conhece o produto/serviço — peso 1 | 'objection' // dúvidas, barreiras, objeções — peso 3 | 'generic'; // pergunta sem dimensão clara — peso 1 /** Análise por pergunta retornada pelo LLM */ export interface QuizDimensionScore { step: number; dimension: QuizDimension; score: number; // 0.0–1.0 dentro da dimensão weight: number; // 1–5, inferido pelo LLM signal: string; // trecho ou palavra que motivou a nota } export interface QuizScoreResult { qualification: QuizQualification; intent_score: number; // 0.0–1.0 — compatível com resolveIntentScore() weighted_score: number; // score ponderado bruto antes do mapeamento confidence: number; // 0.0–1.0 reason: string; // frase curta em português para audit log dominant_dimension: QuizDimension | null; // dimensão com maior impacto no score final dimensions: QuizDimensionScore[]; // breakdown por pergunta (auditável) source: 'ai' | 'heuristic'; } // ── Prompt Granite — análise dimensional ────────────────────────────────────── // Instruções explícitas de formato para o modelo menor (Micro) respeitar o JSON. const QUIZ_DIMENSIONAL_PROMPT = `You are a lead qualification expert for the Brazilian digital marketing and sales funnel market. Your task: analyze each quiz question-answer pair and perform DIMENSIONAL SCORING. STEP 1 — For each Q&A pair, detect the DIMENSION: - "urgency": question asks about timing urgency or immediate need ("agora", "urgente", "preciso já") - "budget": question asks about money, price, investment, financing, credit approval - "timeline": question asks about purchase timeframe (days/months/year) - "fit": question checks if respondent matches the target profile or has the real problem - "engagement": question asks about prior research, visits, comparisons already done - "awareness": question asks if they know the product/service/brand - "objection": question surfaces doubts, barriers, or hesitations - "generic": question has no clear commercial dimension STEP 2 — Score the ANSWER within that dimension (0.00 to 1.00): - 1.00 = strongest possible buying signal for this dimension - 0.50 = neutral or uncertain - 0.00 = negative signal (disqualifier) STEP 3 — Assign WEIGHT by dimension (do not change these mappings): - urgency: 5, budget: 5, fit: 4, timeline: 3, objection: 3, engagement: 2, awareness: 1, generic: 1 STEP 4 — Compute weighted_score = SUM(score * weight) / SUM(weight) across all questions. STEP 5 — Map weighted_score to qualification: - 0.75–1.00 → "comprador" - 0.50–0.74 → "interessado" - 0.20–0.49 → "curioso" - 0.00–0.19 → "perdido" Reply ONLY with valid JSON. No markdown. No explanation outside the JSON: { "dimensions": [ { "step": 1, "dimension": "", "score": 0.00, "weight": 0, "signal": "" } ], "weighted_score": 0.00, "qualification": "", "confidence": 0.00, "reason": "", "dominant_dimension": "" }`; // ── Pesos canônicos por dimensão (espelho do prompt — usado no fallback) ─────── const DIMENSION_WEIGHTS: Record = { urgency: 5, budget: 5, fit: 4, timeline: 3, objection: 3, engagement: 2, awareness: 1, generic: 1, }; // ── Mapeamento de score ponderado → intent_score suavizado ──────────────────── // O weighted_score é uma média ponderada de scores por dimensão (0-1). // O intent_score preserva a mesma escala mas com limites por faixa. function _weightedToIntentScore(ws: number, qual: QuizQualification): number { // Mantém o score contínuo dentro da faixa da qualificação para não perder granularidade const ranges: Record = { comprador: [0.80, 1.00], interessado: [0.50, 0.79], curioso: [0.20, 0.49], perdido: [0.00, 0.19], }; const [lo, hi] = ranges[qual]; return _clamp(lo + (ws - lo) * ((hi - lo) / Math.max(hi - lo, 0.01))); } // ── scoreQuizAnswers — função principal ─────────────────────────────────────── export async function scoreQuizAnswers( env: Env, answers: QuizAnswer[], quizName?: string | null, ): Promise { if (!answers || answers.length === 0) { return _fallback([], 'Nenhuma resposta recebida'); } // Tenta via Workers AI if (env.AI) { try { const answersText = answers .map((a, i) => `P${a.step ?? i + 1}: "${a.question}" → "${a.answer}"`) .join('\n'); const contextMsg = quizName ? `Quiz: "${quizName}"\n\n${answersText}` : answersText; // max_tokens: ~80 por dimensão × N perguntas + ~120 para o envelope final const maxTokens = Math.min(512, 120 + answers.length * 80); const aiRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: [ { role: 'system', content: QUIZ_DIMENSIONAL_PROMPT }, { role: 'user', content: contextMsg }, ], max_tokens: maxTokens, }); const raw = (aiRes as any)?.response?.trim() ?? ''; const jsonMatch = raw.match(/\{[\s\S]*\}/); if (!jsonMatch) throw new Error('AI response has no JSON block'); const parsed = JSON.parse(jsonMatch[0]); // Valida e normaliza dimensions[] const dimensions: QuizDimensionScore[] = Array.isArray(parsed.dimensions) ? (parsed.dimensions as any[]).map((d, i) => ({ step: typeof d.step === 'number' ? d.step : i + 1, dimension: _validateDimension(d.dimension), score: _clamp(parseFloat(String(d.score ?? 0.5))), weight: typeof d.weight === 'number' ? Math.min(5, Math.max(1, d.weight)) : 1, signal: String(d.signal || '').slice(0, 100), })) : []; // Recalcula weighted_score no servidor (não confia cegamente no modelo) const weighted_score = _computeWeightedScore(dimensions); const qualification = _validateQualification(parsed.qualification); const intent_score = _weightedToIntentScore(weighted_score, qualification); const confidence = _clamp(parseFloat(String(parsed.confidence ?? 0.7))); const reason = String(parsed.reason || '').slice(0, 200); const dominant_dimension = _validateDimension(parsed.dominant_dimension) as QuizDimension | null; return { qualification, intent_score, weighted_score, confidence, reason, dominant_dimension, dimensions, source: 'ai', }; } catch (err: any) { console.warn('[QuizScoring] AI falhou, usando heurística:', err?.message || String(err)); } } return _fallback(answers, 'Workers AI indisponível'); } // ── saveQuizSession — persiste no D1 em background ─────────────────────────── export async function saveQuizSession( env: Env, userId: string | null | undefined, payload: TrackPayload, result: QuizScoreResult, ): Promise { if (!env.DB) return; try { await env.DB.prepare(` INSERT INTO quiz_sessions ( user_id, quiz_name, answers_json, qualification, intent_score, weighted_score, confidence, reason, dominant_dimension, dimensions_json, source, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) `).bind( userId || null, String(payload.quiz_name || ''), JSON.stringify(payload.quiz_answers || []), result.qualification, result.intent_score, result.weighted_score, result.confidence, result.reason, result.dominant_dimension || null, JSON.stringify(result.dimensions), result.source, ).run(); } catch (err: any) { console.error('[QuizScoring] saveQuizSession error:', err?.message || String(err)); } } // ── Helpers internos ────────────────────────────────────────────────────────── function _clamp(n: number): number { if (isNaN(n)) return 0; return Math.min(1, Math.max(0, Math.round(n * 100) / 100)); } function _validateQualification(val: unknown): QuizQualification { const valid: QuizQualification[] = ['comprador', 'interessado', 'curioso', 'perdido']; return valid.includes(val as QuizQualification) ? (val as QuizQualification) : 'curioso'; } function _validateDimension(val: unknown): QuizDimension { const valid: QuizDimension[] = ['urgency','budget','timeline','fit','engagement','awareness','objection','generic']; return valid.includes(val as QuizDimension) ? (val as QuizDimension) : 'generic'; } function _computeWeightedScore(dims: QuizDimensionScore[]): number { if (!dims.length) return 0; const sumWS = dims.reduce((acc, d) => acc + d.score * d.weight, 0); const sumW = dims.reduce((acc, d) => acc + d.weight, 0); return _clamp(sumW > 0 ? sumWS / sumW : 0); } // ── Fallback heurístico dimensional ────────────────────────────────────────── // Quando Workers AI está indisponível, classifica por padrões de texto // e gera um breakdown dimensional sintético para manter consistência de contrato. const HEURISTIC_DIMENSION_PATTERNS: Array<{ dimension: QuizDimension; patterns: string[]; }> = [ { dimension: 'urgency', patterns: ['agora','hoje','urgente','imediato','já','preciso logo','não posso esperar'] }, { dimension: 'budget', patterns: ['orçamento','budget','financiamento','aprovado','crédito','dinheiro','quanto custa','valor','posso pagar','tenho recurso'] }, { dimension: 'timeline', patterns: ['mês','meses','semana','semanas','ano','prazo','quando','breve','futuramente','em quanto tempo'] }, { dimension: 'fit', patterns: ['sim','é para mim','tenho esse problema','preciso disso','faz sentido','encaixa','me identifico'] }, { dimension: 'engagement', patterns: ['já pesquisei','comparei','visitei','já vi','pesquisando','avaliando','testei'] }, { dimension: 'awareness', patterns: ['conheço','já ouvi','sabia','já usei','familiar'] }, { dimension: 'objection', patterns: ['mas','porém','dúvida','não tenho certeza','talvez','depende','preciso pensar','não sei'] }, ]; const HEURISTIC_QUAL_SCORE: Record = { comprador: 0.87, interessado: 0.62, curioso: 0.30, perdido: 0.10, }; function _fallback(answers: QuizAnswer[], note: string): QuizScoreResult { // Gera breakdown dimensional sintético por pergunta const dimensions: QuizDimensionScore[] = answers.map((a, i) => { const text = `${a.question} ${a.answer}`.toLowerCase(); let bestDim: QuizDimension = 'generic'; let bestScore = 0.4; // neutro let bestSignal = ''; for (const { dimension, patterns } of HEURISTIC_DIMENSION_PATTERNS) { const matched = patterns.filter(p => text.includes(p)); if (matched.length > 0) { // Score positivo se resposta tem sinal de compra, negativo se objeção const baseScore = dimension === 'objection' ? 0.3 : 0.75; if (baseScore > bestScore || (bestDim === 'generic' && matched.length > 0)) { bestDim = dimension; bestScore = baseScore; bestSignal = matched[0]; } } } return { step: a.step ?? i + 1, dimension: bestDim, score: bestScore, weight: DIMENSION_WEIGHTS[bestDim], signal: bestSignal || a.answer.slice(0, 60), }; }); const weighted_score = _computeWeightedScore(dimensions.length ? dimensions : [{ step:1, dimension:'generic', score:0.3, weight:1, signal:'' }]); const qualification: QuizQualification = weighted_score >= 0.75 ? 'comprador' : weighted_score >= 0.50 ? 'interessado' : weighted_score >= 0.20 ? 'curioso' : 'perdido'; // Dimensão dominante = maior produto score × weight const dominant = dimensions.length ? dimensions.reduce((best, d) => (d.score * d.weight > best.score * best.weight ? d : best) ).dimension : null; return { qualification, intent_score: _clamp(HEURISTIC_QUAL_SCORE[qualification]), weighted_score, confidence: Math.min(0.6, 0.25 + dimensions.filter(d => d.signal).length * 0.07), reason: `${note}. Score ponderado heurístico: ${(weighted_score * 100).toFixed(0)}/100.`, dominant_dimension: dominant as QuizDimension | null, dimensions, source: 'heuristic', }; }