--- id: "ET-EDU-005" title: "Quiz and Evaluation Engine" type: "Specification" status: "Done" rf_parent: "RF-EDU-004" epic: "OQI-002" version: "1.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-EDU-005: Motor de Evaluaciones y Quizzes **Versión:** 1.0.0 **Fecha:** 2025-12-05 **Épica:** OQI-002 - Módulo Educativo **Componente:** Backend/Frontend --- ## Descripción Define el sistema completo de evaluaciones y quizzes del módulo educativo, incluyendo tipos de preguntas, algoritmo de scoring, validación de respuestas, gestión de intentos, timer, retroalimentación y estadísticas. --- ## Arquitectura ``` ┌─────────────────────────────────────────────────────────────────┐ │ Quiz Engine Architecture │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Frontend (React) │ │ │ │ │ │ │ │ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ │ │ QuizPage │ │ QuizQuestion │ │ QuizResults │ │ │ │ │ │ - Timer │ │ - Types │ │ - Score │ │ │ │ │ │ - Progress │ │ - Validation │ │ - Review │ │ │ │ │ └─────────────┘ └──────────────┘ └──────────────┘ │ │ │ └──────────────────────┬───────────────────────────────────┘ │ │ │ │ │ v │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Backend Quiz Service │ │ │ │ │ │ │ │ ┌────────────────┐ ┌─────────────────┐ │ │ │ │ │ Quiz Manager │ │ Scoring Engine │ │ │ │ │ │ - Get quiz │ │ - Calculate │ │ │ │ │ │ - Start attempt│ │ - Validate │ │ │ │ │ │ - Submit │ │ - Grade │ │ │ │ │ └────────────────┘ └─────────────────┘ │ │ │ │ │ │ │ │ ┌────────────────┐ ┌─────────────────┐ │ │ │ │ │ Attempt Manager│ │ Analytics │ │ │ │ │ │ - Track time │ │ - Statistics │ │ │ │ │ │ - Limit checks │ │ - Insights │ │ │ │ │ └────────────────┘ └─────────────────┘ │ │ │ └──────────────────────┬───────────────────────────────────┘ │ │ │ │ │ v │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ PostgreSQL │ │ │ │ - quizzes │ │ │ │ - quiz_questions │ │ │ │ - quiz_attempts │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## Especificación Detallada ### 1. TIPOS DE PREGUNTAS #### 1.1 Multiple Choice (Opción Única) ```typescript interface MultipleChoiceQuestion { id: string; question_type: 'multiple_choice'; question_text: string; options: Array<{ id: string; text: string; isCorrect: boolean; // Solo una puede ser true }>; points: number; explanation: string; } // Ejemplo const multipleChoiceExample = { id: 'q1', question_type: 'multiple_choice', question_text: '¿Qué es un stop loss?', options: [ { id: 'opt1', text: 'Una orden para comprar acciones', isCorrect: false }, { id: 'opt2', text: 'Una orden para limitar pérdidas', isCorrect: true }, { id: 'opt3', text: 'Un indicador técnico', isCorrect: false }, { id: 'opt4', text: 'Un patrón de velas', isCorrect: false } ], points: 10, explanation: 'Un stop loss es una orden diseñada para limitar las pérdidas de un inversor en una posición.' }; ``` #### 1.2 True/False ```typescript interface TrueFalseQuestion { id: string; question_type: 'true_false'; question_text: string; options: [ { id: 'true', text: 'Verdadero', isCorrect: boolean }, { id: 'false', text: 'Falso', isCorrect: boolean } ]; points: number; explanation: string; } // Ejemplo const trueFalseExample = { id: 'q2', question_type: 'true_false', question_text: 'El RSI es un indicador de momento que oscila entre 0 y 100.', options: [ { id: 'true', text: 'Verdadero', isCorrect: true }, { id: 'false', text: 'Falso', isCorrect: false } ], points: 5, explanation: 'El RSI (Relative Strength Index) efectivamente oscila entre 0 y 100.' }; ``` #### 1.3 Multiple Select (Selección Múltiple) ```typescript interface MultipleSelectQuestion { id: string; question_type: 'multiple_select'; question_text: string; options: Array<{ id: string; text: string; isCorrect: boolean; // Múltiples pueden ser true }>; points: number; explanation: string; partial_credit: boolean; // Dar puntos parciales si algunas respuestas son correctas } // Ejemplo const multipleSelectExample = { id: 'q3', question_type: 'multiple_select', question_text: '¿Cuáles de los siguientes son indicadores de tendencia? (Selecciona todas las correctas)', options: [ { id: 'opt1', text: 'Media móvil', isCorrect: true }, { id: 'opt2', text: 'MACD', isCorrect: true }, { id: 'opt3', text: 'RSI', isCorrect: false }, { id: 'opt4', text: 'Bandas de Bollinger', isCorrect: true }, { id: 'opt5', text: 'Stochastic', isCorrect: false } ], points: 15, partial_credit: true, explanation: 'Los indicadores de tendencia incluyen medias móviles, MACD y Bandas de Bollinger. RSI y Stochastic son indicadores de momentum.' }; ``` #### 1.4 Fill in the Blank ```typescript interface FillBlankQuestion { id: string; question_type: 'fill_blank'; question_text: string; // Usar {{blank}} para indicar dónde va la respuesta correct_answer: string | string[]; // Múltiples respuestas aceptadas case_sensitive: boolean; exact_match: boolean; // Si es false, acepta respuestas similares points: number; explanation: string; } // Ejemplo const fillBlankExample = { id: 'q4', question_type: 'fill_blank', question_text: 'El patrón de velas {{blank}} indica una posible reversión alcista.', correct_answer: ['martillo', 'hammer', 'martillo invertido'], case_sensitive: false, exact_match: false, points: 10, explanation: 'Un patrón de martillo indica una posible reversión alcista cuando aparece en una tendencia bajista.' }; ``` #### 1.5 Code Challenge ```typescript interface CodeChallengeQuestion { id: string; question_type: 'code_challenge'; question_text: string; language: 'python' | 'javascript' | 'pine-script'; starter_code: string; test_cases: Array<{ input: any; expected_output: any; }>; points: number; explanation: string; } // Ejemplo const codeChallengeExample = { id: 'q5', question_type: 'code_challenge', question_text: 'Implementa una función que calcule el RSI de 14 períodos.', language: 'python', starter_code: `def calculate_rsi(prices, period=14): # Tu código aquí pass`, test_cases: [ { input: { prices: [44, 45, 46, 47, 46, 45], period: 14 }, expected_output: 50.0 } ], points: 25, explanation: 'El RSI se calcula comparando ganancias y pérdidas promedio durante un período.' }; ``` --- ### 2. SCORING ENGINE #### Algoritmo de Puntuación ```typescript // services/quiz/scoring.service.ts export class QuizScoringService { /** * Calcula la puntuación de un intento de quiz */ calculateScore( questions: QuizQuestion[], userAnswers: UserAnswer[] ): ScoreResult { let totalPoints = 0; let maxPoints = 0; const results: QuestionResult[] = []; for (const question of questions) { const userAnswer = userAnswers.find(a => a.question_id === question.id); const questionResult = this.scoreQuestion(question, userAnswer); totalPoints += questionResult.points_earned; maxPoints += question.points; results.push(questionResult); } const percentage = (totalPoints / maxPoints) * 100; return { total_points: totalPoints, max_points: maxPoints, percentage: percentage, results: results }; } /** * Puntúa una pregunta individual */ private scoreQuestion( question: QuizQuestion, userAnswer?: UserAnswer ): QuestionResult { if (!userAnswer) { return { question_id: question.id, is_correct: false, points_earned: 0, max_points: question.points, feedback: 'No se proporcionó respuesta' }; } switch (question.question_type) { case 'multiple_choice': case 'true_false': return this.scoreMultipleChoice(question, userAnswer); case 'multiple_select': return this.scoreMultipleSelect(question, userAnswer); case 'fill_blank': return this.scoreFillBlank(question, userAnswer); case 'code_challenge': return this.scoreCodeChallenge(question, userAnswer); default: throw new Error(`Unsupported question type: ${question.question_type}`); } } /** * Puntúa Multiple Choice / True False */ private scoreMultipleChoice( question: MultipleChoiceQuestion, userAnswer: UserAnswer ): QuestionResult { const correctOption = question.options.find(opt => opt.isCorrect); const isCorrect = userAnswer.answer === correctOption?.id; return { question_id: question.id, is_correct: isCorrect, points_earned: isCorrect ? question.points : 0, max_points: question.points, user_answer: userAnswer.answer, correct_answer: correctOption?.id, feedback: isCorrect ? '¡Correcto!' : question.explanation }; } /** * Puntúa Multiple Select */ private scoreMultipleSelect( question: MultipleSelectQuestion, userAnswer: UserAnswer ): QuestionResult { const correctOptions = question.options .filter(opt => opt.isCorrect) .map(opt => opt.id); const userAnswerArray = Array.isArray(userAnswer.answer) ? userAnswer.answer : [userAnswer.answer]; // Todas correctas const allCorrect = correctOptions.every(opt => userAnswerArray.includes(opt) ) && userAnswerArray.every(ans => correctOptions.includes(ans) ); if (allCorrect) { return { question_id: question.id, is_correct: true, points_earned: question.points, max_points: question.points, user_answer: userAnswerArray, correct_answer: correctOptions, feedback: '¡Correcto!' }; } // Crédito parcial if (question.partial_credit) { const correctSelected = userAnswerArray.filter(ans => correctOptions.includes(ans) ).length; const incorrectSelected = userAnswerArray.filter(ans => !correctOptions.includes(ans) ).length; const missedCorrect = correctOptions.filter(opt => !userAnswerArray.includes(opt) ).length; // Fórmula: (correctas - incorrectas) / total correctas const score = Math.max( 0, (correctSelected - incorrectSelected) / correctOptions.length ); const pointsEarned = Math.floor(question.points * score); return { question_id: question.id, is_correct: false, points_earned: pointsEarned, max_points: question.points, user_answer: userAnswerArray, correct_answer: correctOptions, feedback: `Crédito parcial: ${pointsEarned}/${question.points} puntos. ${question.explanation}` }; } // Sin crédito parcial return { question_id: question.id, is_correct: false, points_earned: 0, max_points: question.points, user_answer: userAnswerArray, correct_answer: correctOptions, feedback: question.explanation }; } /** * Puntúa Fill in the Blank */ private scoreFillBlank( question: FillBlankQuestion, userAnswer: UserAnswer ): QuestionResult { const correctAnswers = Array.isArray(question.correct_answer) ? question.correct_answer : [question.correct_answer]; let isCorrect = false; let userAnswerStr = String(userAnswer.answer); if (!question.case_sensitive) { userAnswerStr = userAnswerStr.toLowerCase(); } for (const correctAnswer of correctAnswers) { let correctStr = question.case_sensitive ? correctAnswer : correctAnswer.toLowerCase(); if (question.exact_match) { if (userAnswerStr.trim() === correctStr.trim()) { isCorrect = true; break; } } else { // Fuzzy match (permite pequeñas diferencias) const similarity = this.calculateSimilarity( userAnswerStr.trim(), correctStr.trim() ); if (similarity > 0.85) { isCorrect = true; break; } } } return { question_id: question.id, is_correct: isCorrect, points_earned: isCorrect ? question.points : 0, max_points: question.points, user_answer: userAnswer.answer, correct_answer: correctAnswers[0], feedback: isCorrect ? '¡Correcto!' : question.explanation }; } /** * Puntúa Code Challenge */ private async scoreCodeChallenge( question: CodeChallengeQuestion, userAnswer: UserAnswer ): Promise { const userCode = String(userAnswer.answer); let passedTests = 0; const testResults = []; for (const testCase of question.test_cases) { const result = await this.executeCode( userCode, testCase.input, question.language ); const passed = this.compareOutputs(result, testCase.expected_output); testResults.push({ input: testCase.input, expected: testCase.expected_output, actual: result, passed: passed }); if (passed) passedTests++; } const allPassed = passedTests === question.test_cases.length; const partialPoints = Math.floor( (passedTests / question.test_cases.length) * question.points ); return { question_id: question.id, is_correct: allPassed, points_earned: allPassed ? question.points : partialPoints, max_points: question.points, user_answer: userCode, test_results: testResults, feedback: allPassed ? '¡Todos los tests pasaron!' : `${passedTests}/${question.test_cases.length} tests pasaron. ${question.explanation}` }; } /** * Calcula similitud entre dos strings (Levenshtein distance) */ private calculateSimilarity(str1: string, str2: string): number { const maxLength = Math.max(str1.length, str2.length); if (maxLength === 0) return 1.0; const distance = this.levenshteinDistance(str1, str2); return 1 - distance / maxLength; } private levenshteinDistance(str1: string, str2: string): number { const matrix: number[][] = []; for (let i = 0; i <= str2.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= str1.length; j++) { matrix[0][j] = j; } for (let i = 1; i <= str2.length; i++) { for (let j = 1; j <= str1.length; j++) { if (str2.charAt(i - 1) === str1.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1 ); } } } return matrix[str2.length][str1.length]; } /** * Ejecuta código del usuario (sandboxed) */ private async executeCode( code: string, input: any, language: string ): Promise { // TODO: Implementar ejecución segura usando Docker o serverless functions // Por ahora, placeholder return null; } private compareOutputs(actual: any, expected: any): boolean { return JSON.stringify(actual) === JSON.stringify(expected); } } // Types interface QuizQuestion { id: string; question_type: string; points: number; [key: string]: any; } interface UserAnswer { question_id: string; answer: string | string[]; } interface QuestionResult { question_id: string; is_correct: boolean; points_earned: number; max_points: number; user_answer?: any; correct_answer?: any; feedback: string; test_results?: any[]; } interface ScoreResult { total_points: number; max_points: number; percentage: number; results: QuestionResult[]; } ``` --- ### 3. ATTEMPT MANAGEMENT #### Quiz Attempt Flow ```typescript // services/quiz/attempt.service.ts export class QuizAttemptService { /** * Inicia un nuevo intento de quiz */ async startAttempt( userId: string, quizId: string, enrollmentId: string ): Promise { // 1. Verificar que el usuario tiene acceso al quiz await this.verifyAccess(userId, quizId, enrollmentId); // 2. Obtener configuración del quiz const quiz = await this.getQuiz(quizId); // 3. Verificar límite de intentos if (quiz.max_attempts) { const previousAttempts = await this.getPreviousAttempts(userId, quizId); if (previousAttempts.length >= quiz.max_attempts) { throw new Error('NO_ATTEMPTS_REMAINING'); } } // 4. Crear nuevo intento const attempt = await db.quiz_attempts.create({ user_id: userId, quiz_id: quizId, enrollment_id: enrollmentId, started_at: new Date(), is_completed: false }); // 5. Obtener preguntas (shuffled si corresponde) const questions = await this.getQuizQuestions(quiz, attempt.id); return { id: attempt.id, quiz_id: quizId, started_at: attempt.started_at, time_limit_expires_at: quiz.time_limit_minutes ? new Date(Date.now() + quiz.time_limit_minutes * 60 * 1000) : null, questions: questions }; } /** * Envía respuestas del quiz */ async submitAttempt( attemptId: string, userId: string, answers: UserAnswer[] ): Promise { // 1. Verificar que el attempt existe y pertenece al usuario const attempt = await this.getAttempt(attemptId); if (attempt.user_id !== userId) { throw new Error('UNAUTHORIZED'); } // 2. Verificar que no ha expirado el tiempo if (attempt.time_limit_expires_at) { if (new Date() > new Date(attempt.time_limit_expires_at)) { throw new Error('QUIZ_TIME_EXPIRED'); } } // 3. Verificar que no se ha completado previamente if (attempt.is_completed) { throw new Error('ATTEMPT_ALREADY_COMPLETED'); } // 4. Obtener preguntas del quiz const quiz = await this.getQuiz(attempt.quiz_id); const questions = await this.getQuestions(quiz.id); // 5. Calcular puntuación const scoringService = new QuizScoringService(); const scoreResult = scoringService.calculateScore(questions, answers); // 6. Determinar si pasó const isPassed = scoreResult.percentage >= quiz.passing_score_percentage; // 7. Calcular XP ganado let xpEarned = 0; if (isPassed) { xpEarned = quiz.xp_reward; // Bonus por puntaje perfecto if (scoreResult.percentage === 100) { xpEarned += quiz.xp_perfect_score_bonus; } } // 8. Calcular tiempo tomado const timeTaken = Math.floor( (Date.now() - new Date(attempt.started_at).getTime()) / 1000 ); // 9. Actualizar attempt en la BD await db.quiz_attempts.update(attemptId, { is_completed: true, completed_at: new Date(), is_passed: isPassed, user_answers: answers, score_points: scoreResult.total_points, max_points: scoreResult.max_points, score_percentage: scoreResult.percentage, time_taken_seconds: timeTaken, xp_earned: xpEarned }); // 10. Actualizar progreso del usuario si pasó if (isPassed) { await this.updateUserProgress(userId, quiz, xpEarned); } // 11. Verificar si ganó achievement const achievement = await this.checkAchievements( userId, quiz, scoreResult ); return { id: attemptId, is_completed: true, is_passed: isPassed, score_points: scoreResult.total_points, max_points: scoreResult.max_points, score_percentage: scoreResult.percentage, time_taken_seconds: timeTaken, xp_earned: xpEarned, results: quiz.show_correct_answers ? scoreResult.results : undefined, achievement_earned: achievement }; } /** * Obtiene el resultado de un intento completado */ async getAttemptResult( attemptId: string, userId: string ): Promise { const attempt = await this.getAttempt(attemptId); // Verificar ownership if (attempt.user_id !== userId) { throw new Error('UNAUTHORIZED'); } // Solo mostrar resultados de intentos completados if (!attempt.is_completed) { throw new Error('ATTEMPT_NOT_COMPLETED'); } const quiz = await this.getQuiz(attempt.quiz_id); return { id: attempt.id, is_completed: attempt.is_completed, is_passed: attempt.is_passed, score_points: attempt.score_points, max_points: attempt.max_points, score_percentage: attempt.score_percentage, time_taken_seconds: attempt.time_taken_seconds, xp_earned: attempt.xp_earned, results: quiz.show_correct_answers ? this.parseResults(attempt.user_answers) : undefined }; } private async getQuizQuestions( quiz: Quiz, attemptId: string ): Promise { let questions = await db.quiz_questions.findMany({ where: { quiz_id: quiz.id }, orderBy: { display_order: 'asc' } }); // Shuffle questions si corresponde if (quiz.shuffle_questions) { questions = this.shuffleArray(questions); } // Shuffle answers si corresponde if (quiz.shuffle_answers) { questions = questions.map(q => ({ ...q, options: q.options ? this.shuffleArray(q.options) : undefined })); } // Remover respuestas correctas antes de enviar al frontend return questions.map(q => ({ ...q, options: q.options?.map(opt => ({ id: opt.id, text: opt.text // isCorrect removido })), correct_answer: undefined })); } private shuffleArray(array: T[]): T[] { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } private async checkAchievements( userId: string, quiz: Quiz, scoreResult: ScoreResult ): Promise { // Perfect score achievement if (scoreResult.percentage === 100) { const achievement = await db.user_achievements.create({ user_id: userId, achievement_type: 'quiz_perfect_score', title: 'Puntaje Perfecto', description: `Obtuviste 100% en el quiz "${quiz.title}"`, badge_icon_url: '/badges/perfect-score.svg', quiz_id: quiz.id, xp_bonus: 50, earned_at: new Date() }); return achievement; } return null; } } ``` --- ### 4. TIMER IMPLEMENTATION #### Frontend Timer Component ```typescript // components/quiz/QuizTimer.tsx import React, { useState, useEffect } from 'react'; interface QuizTimerProps { expiresAt: Date | null; onExpire: () => void; } export const QuizTimer: React.FC = ({ expiresAt, onExpire }) => { const [timeRemaining, setTimeRemaining] = useState(0); useEffect(() => { if (!expiresAt) return; const calculateTimeRemaining = () => { const now = Date.now(); const expiry = new Date(expiresAt).getTime(); const remaining = Math.max(0, Math.floor((expiry - now) / 1000)); return remaining; }; setTimeRemaining(calculateTimeRemaining()); const interval = setInterval(() => { const remaining = calculateTimeRemaining(); setTimeRemaining(remaining); if (remaining <= 0) { clearInterval(interval); onExpire(); } }, 1000); return () => clearInterval(interval); }, [expiresAt, onExpire]); if (!expiresAt) return null; const minutes = Math.floor(timeRemaining / 60); const seconds = timeRemaining % 60; const isUrgent = timeRemaining < 300; // Últimos 5 minutos return (
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
); }; ``` --- ### 5. ANALYTICS & STATISTICS #### Quiz Statistics ```typescript // services/quiz/analytics.service.ts export class QuizAnalyticsService { /** * Obtiene estadísticas de un quiz */ async getQuizStatistics(quizId: string): Promise { const attempts = await db.quiz_attempts.findMany({ where: { quiz_id: quizId, is_completed: true } }); if (attempts.length === 0) { return { total_attempts: 0, average_score: 0, pass_rate: 0, average_time: 0 }; } const totalAttempts = attempts.length; const passedAttempts = attempts.filter(a => a.is_passed).length; const totalScore = attempts.reduce((sum, a) => sum + a.score_percentage, 0); const totalTime = attempts.reduce((sum, a) => sum + (a.time_taken_seconds || 0), 0); return { total_attempts: totalAttempts, unique_users: new Set(attempts.map(a => a.user_id)).size, average_score: totalScore / totalAttempts, pass_rate: (passedAttempts / totalAttempts) * 100, average_time: totalTime / totalAttempts, score_distribution: this.calculateScoreDistribution(attempts), question_difficulty: await this.calculateQuestionDifficulty(quizId) }; } /** * Calcula distribución de puntajes */ private calculateScoreDistribution(attempts: QuizAttempt[]): ScoreDistribution { const ranges = { '0-25': 0, '26-50': 0, '51-75': 0, '76-90': 0, '91-100': 0 }; attempts.forEach(attempt => { const score = attempt.score_percentage; if (score <= 25) ranges['0-25']++; else if (score <= 50) ranges['26-50']++; else if (score <= 75) ranges['51-75']++; else if (score <= 90) ranges['76-90']++; else ranges['91-100']++; }); return ranges; } /** * Calcula dificultad de cada pregunta */ private async calculateQuestionDifficulty( quizId: string ): Promise { const questions = await db.quiz_questions.findMany({ where: { quiz_id: quizId } }); const difficulties = []; for (const question of questions) { const attempts = await db.quiz_attempts.findMany({ where: { quiz_id: quizId, is_completed: true } }); let correctCount = 0; let totalCount = 0; attempts.forEach(attempt => { const answer = attempt.user_answers?.find( (a: any) => a.question_id === question.id ); if (answer) { totalCount++; // Check if answer is correct (simplificado) if (answer.is_correct) correctCount++; } }); const successRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0; difficulties.push({ question_id: question.id, question_text: question.question_text, success_rate: successRate, total_attempts: totalCount, difficulty_label: successRate > 75 ? 'Fácil' : successRate > 50 ? 'Medio' : successRate > 25 ? 'Difícil' : 'Muy Difícil' }); } return difficulties; } } interface QuizStatistics { total_attempts: number; unique_users: number; average_score: number; pass_rate: number; average_time: number; score_distribution: ScoreDistribution; question_difficulty: QuestionDifficulty[]; } interface ScoreDistribution { '0-25': number; '26-50': number; '51-75': number; '76-90': number; '91-100': number; } interface QuestionDifficulty { question_id: string; question_text: string; success_rate: number; total_attempts: number; difficulty_label: string; } ``` --- ## Interfaces/Tipos Ver sección anterior (incluidas en código) --- ## Configuración ```bash # Quiz Settings QUIZ_DEFAULT_TIME_LIMIT=30 QUIZ_MAX_ATTEMPTS_DEFAULT=3 QUIZ_PASSING_SCORE_DEFAULT=70 QUIZ_AUTO_SUBMIT_ON_EXPIRE=true # Code Execution (para code challenges) CODE_EXECUTION_TIMEOUT=5000 CODE_EXECUTION_MEMORY_LIMIT=256 CODE_SANDBOX_DOCKER_IMAGE=python:3.11-slim ``` --- ## Dependencias ```json { "dependencies": { "string-similarity": "^4.0.4" } } ``` --- ## Consideraciones de Seguridad 1. **Nunca enviar respuestas correctas al frontend antes de submit** 2. **Validar todas las respuestas en el backend** 3. **Verificar ownership de attempts** 4. **Prevenir cheating con timer** 5. **Sandboxing para code challenges** 6. **Rate limiting en endpoints de quiz** --- ## Testing ```typescript describe('QuizScoringService', () => { it('should score multiple choice correctly', () => { // Test implementation }); it('should calculate partial credit for multiple select', () => { // Test implementation }); it('should handle fill blank with fuzzy matching', () => { // Test implementation }); }); ``` --- **Fin de Especificación ET-EDU-005**