trading-platform/docs/02-definicion-modulos/OQI-002-education/especificaciones/ET-EDU-005-quizzes.md
rckrdmrd a7cca885f0 feat: Major platform documentation and architecture updates
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>
2026-01-07 05:33:35 -06:00

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

  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

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