Changes include: - Updated architecture documentation - Enhanced module definitions (OQI-001 to OQI-008) - ML integration documentation updates - Trading strategies documentation - Orchestration and inventory updates - Docker configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1158 lines
32 KiB
Markdown
1158 lines
32 KiB
Markdown
---
|
|
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<QuestionResult> {
|
|
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<any> {
|
|
// 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<QuizAttempt> {
|
|
// 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<AttemptResult> {
|
|
// 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<AttemptResult> {
|
|
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<QuizQuestion[]> {
|
|
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<T>(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<Achievement | null> {
|
|
// 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<QuizTimerProps> = ({
|
|
expiresAt,
|
|
onExpire
|
|
}) => {
|
|
const [timeRemaining, setTimeRemaining] = useState<number>(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 (
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-2 px-4 py-2 rounded-lg font-mono text-lg',
|
|
isUrgent
|
|
? 'bg-red-100 text-red-700 animate-pulse'
|
|
: 'bg-gray-100 text-gray-700'
|
|
)}
|
|
>
|
|
<ClockIcon className="w-5 h-5" />
|
|
<span>
|
|
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### 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<QuizStatistics> {
|
|
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<QuestionDifficulty[]> {
|
|
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**
|