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>
32 KiB
32 KiB
| id | title | type | status | rf_parent | epic | version | created_date | updated_date |
|---|---|---|---|---|---|---|---|---|
| ET-EDU-005 | Quiz and Evaluation Engine | Specification | Done | RF-EDU-004 | OQI-002 | 1.0 | 2025-12-05 | 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)
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
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)
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
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
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
// 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
// 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
// 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
// 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
# 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
{
"dependencies": {
"string-similarity": "^4.0.4"
}
}
Consideraciones de Seguridad
- Nunca enviar respuestas correctas al frontend antes de submit
- Validar todas las respuestas en el backend
- Verificar ownership de attempts
- Prevenir cheating con timer
- Sandboxing para code challenges
- Rate limiting en endpoints de quiz
Testing
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