workspace-v1/projects/gamilit/docs/01-fase-alcance-inicial/EAI-002-actividades/especificaciones/ET-EDU-001-mecanicas-ejercicios.md
Adrian Flores Cortes 967ab360bb Initial commit: Workspace v1 with 3-layer architecture
Structure:
- control-plane/: Registries, SIMCO directives, CI/CD templates
- projects/: Gamilit, ERP-Suite, Trading-Platform, Betting-Analytics
- shared/: Libs catalog, knowledge-base

Key features:
- Centralized port, domain, database, and service registries
- 23 SIMCO directives + 6 fundamental principles
- NEXUS agent profiles with delegation rules
- Validation scripts for workspace integrity
- Dockerfiles for all services
- Path aliases for quick reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 00:35:19 -06:00

43 KiB

ET-EDU-001: Implementación de Mecánicas de Ejercicios

📋 Metadata

Campo Valor
ID ET-EDU-001
Módulo 03 - Contenido Educativo
Título Implementación de Mecánicas de Ejercicios
Prioridad Crít ica
Estado Implementado
Versión 2.0
Fecha Creación 2025-11-07
Última Actualización 2025-11-11
Autor Database Team, Backend Team
Reviewers Backend Lead, Frontend Lead, QA Lead

🔗 Referencias

Requerimiento Funcional

📘 Documento RF:

Implementación DDL

🗄️ ENUMs:

  • educational_content.exercise_type - apps/database/ddl/00-prerequisites.sql:~85-120
    • 35 implementaciones específicas GAMILIT
  • educational_content.exercise_mechanic_mapping (tabla) - Mapeo a categorías pedagógicas
    • 7 categorías: Vocabulario, Gramática, Lectura, Escritura, Audio, Pronunciación, Cultura

🗄️ Tablas:

  • educational_content.exercises - Ejercicios completos
  • educational_content.exercise_mechanic_mapping - Mapeo pedagógico (NUEVO en v2.0)

🏗️ Arquitectura

Diagrama de Capas

┌────────────────────────────────────────────────────┐
│                 FRONTEND (React)                   │
│  - ExerciseRenderer (dinámico por mecánica)       │
│  - MultipleChoiceExercise                          │
│  - FillInBlankExercise                             │
│  - ... (31 componentes específicos)                │
└─────────────────┬──────────────────────────────────┘
                  │ REST API
┌─────────────────▼──────────────────────────────────┐
│              BACKEND (NestJS)                      │
│  - ExerciseService                                 │
│    · getExercise() - sin answer_key                │
│    · submitAnswer() - valida en backend            │
│  - MechanicValidators (factory pattern)            │
│    · MultipleChoiceValidator                       │
│    · FillInBlankValidator                          │
│    · ... (31 validators)                           │
└─────────────────┬──────────────────────────────────┘
                  │ SQL Queries
┌─────────────────▼──────────────────────────────────┐
│            DATABASE (PostgreSQL)                   │
│  - exercises (JSONB content flexible)              │
│  - exercise_attempts (tracking)                    │
│  - validate_exercise_structure() (validar JSONB)   │
└────────────────────────────────────────────────────┘

💾 Implementación de Base de Datos

1. ENUM: exercise_type

Ubicación: apps/database/ddl/00-prerequisites.sql:~85-120

-- Exercise Types - Implementaciones específicas GAMILIT (35 tipos)
CREATE TYPE educational_content.exercise_type AS ENUM (
    -- Módulo 1: Comprensión Literal (5)
    'crucigrama',
    'linea_tiempo',
    'sopa_letras',
    'mapa_conceptual',
    'emparejamiento',

    -- Módulo 2: Comprensión Inferencial (5)
    'detective_textual',
    'construccion_hipotesis',
    'prediccion_narrativa',
    'puzzle_contexto',
    'rueda_inferencias',

    -- Módulo 3: Lectura Crítica (5)
    'analisis_fuentes',
    'debate_digital',
    'matriz_perspectivas',
    'podcast_argumentativo',
    'tribunal_opiniones',

    -- Módulo 4: Alfabetización Digital (5)
    'verificador_fake_news',
    'infografia_interactiva',
    'quiz_tiktok',
    'navegacion_hipertextual',
    'analisis_memes',

    -- Módulo 5: Producción y Expresión Lectora (3)
    'diario_multimedia',
    'comic_digital',
    'video_carta',

    -- Auxiliares y Futuros (12)
    'collage_prensa',
    'verdadero_falso',
    'mapa_mental',
    'call_to_action',
    'flashcard',
    'completar_espacios',
    'comprension_auditiva',
    'red_conceptos',
    'chat_literario',
    'email_formal',
    'ensayo_argumentativo',
    'resena_critica'
);

COMMENT ON TYPE educational_content.exercise_type IS '35 mecánicas específicas GAMILIT agrupadas en 5 módulos educativos + auxiliares';

2. Sistema Dual: exercise_type + Categorías Pedagógicas

Concepto

GAMILIT implementa un sistema dual que combina:

  1. exercise_type (Implementación):

    • 35 mecánicas específicas gamificadas adaptadas al contexto maya yucateco
    • Agrupadas por módulos educativos (1-5) + auxiliares
    • Implementación concreta en Frontend/Backend
  2. Categorías Pedagógicas (Clasificación):

    • 7 categorías universales: Vocabulario, Gramática, Lectura, Escritura, Audio, Pronunciación, Cultura
    • 31 subcategorías pedagógicas genéricas
    • Mapeo mediante tabla exercise_mechanic_mapping

Tabla de Mapeo

Ubicación: apps/database/ddl/schemas/educational_content/tables/21-exercise_mechanic_mapping.sql

CREATE TABLE educational_content.exercise_mechanic_mapping (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- CLASIFICACIÓN PEDAGÓGICA
    mechanic_category VARCHAR(50) NOT NULL,      -- 'vocabulario', 'lectura', 'escritura'
    mechanic_subcategory VARCHAR(50),            -- 'multiple_choice', 'word_search', 'inference'

    -- IMPLEMENTACIÓN GAMILIT
    exercise_type educational_content.exercise_type NOT NULL,

    -- CONTEXTO EDUCATIVO
    bloom_level VARCHAR(50),                     -- 'recordar', 'comprender', 'aplicar', etc.
    cefr_level educational_content.difficulty_level[],
    pedagogical_purpose TEXT,
    learning_objectives TEXT[],

    -- CARACTERÍSTICAS
    interaction_type VARCHAR(50),                -- 'drag_drop', 'text_input', 'selection'
    cognitive_load VARCHAR(20),                  -- 'bajo', 'medio', 'alto'

    -- Metadatos
    tags TEXT[],
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    UNIQUE(mechanic_subcategory, exercise_type)
);

-- Índices
CREATE INDEX idx_mechanic_mapping_category ON educational_content.exercise_mechanic_mapping(mechanic_category);
CREATE INDEX idx_mechanic_mapping_exercise_type ON educational_content.exercise_mechanic_mapping(exercise_type);
CREATE INDEX idx_mechanic_mapping_bloom ON educational_content.exercise_mechanic_mapping(bloom_level);
CREATE INDEX idx_mechanic_mapping_tags_gin ON educational_content.exercise_mechanic_mapping USING gin(tags);

COMMENT ON TABLE educational_content.exercise_mechanic_mapping IS 'Mapeo entre categorías pedagógicas universales y implementaciones específicas GAMILIT';

Ejemplos de Mapeos

Categoría Subcategoría exercise_type Propósito Pedagógico
vocabulario word_search crucigrama Reforzar vocabulario mediante juego de palabras cruzadas
vocabulario word_search sopa_letras Identificar palabras clave en contexto visual
lectura inference detective_textual Desarrollar comprensión inferencial mediante pistas
lectura reading_comprehension analisis_fuentes Análisis crítico de textos históricos/culturales
escritura free_writing ensayo_argumentativo Expresión argumentativa y pensamiento crítico
cultura cultural_context tribunal_opiniones Análisis de perspectivas culturales diversas

Beneficios del Sistema Dual

Para Profesores:

  • Buscar ejercicios por competencia pedagógica ("vocabulario", "lectura")
  • Filtrar por nivel de Bloom o CEFR
  • Asignar según objetivos de aprendizaje específicos
  • Visibilidad de progreso por área pedagógica

Para Estudiantes:

  • Recibir recomendaciones por competencia a desarrollar
  • Variedad de mecánicas para misma competencia (evita monotonía)
  • Progresión clara por área (ej: vocabulario 60% → 80%)

Para el Sistema:

  • Analytics por competencia pedagógica + por tipo específico
  • Interoperabilidad con estándares internacionales (Bloom, CEFR)
  • Extensibilidad sin modificar estructura existente
  • Facilita futuras implementaciones (13 GAPs identificados)

3. Tabla: exercises

Ubicación: apps/database/ddl/schemas/educational_content/tables/02-exercises.sql

CREATE TABLE IF NOT EXISTS educational_content.exercises (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Identificación
    code VARCHAR(100) UNIQUE,
    title VARCHAR(300) NOT NULL,

    -- Mecánica y dificultad
    exercise_type educational_content.exercise_type NOT NULL,
    difficulty educational_content.difficulty_level NOT NULL,

    -- Contenido (estructura varía por mecánica)
    content JSONB NOT NULL,
    answer_key JSONB NOT NULL,
    hints JSONB, -- Array de strings: ["hint1", "hint2", "hint3"]

    -- Multimedia
    image_url VARCHAR(500),
    audio_url VARCHAR(500),
    video_url VARCHAR(500),

    -- Metadata pedagógica
    bloom_level educational_content.bloom_taxonomy,
    estimated_time_seconds INTEGER DEFAULT 60,
    xp_reward INTEGER DEFAULT 15,
    ml_coins_reward INTEGER DEFAULT 5,

    -- Relaciones
    module_id UUID REFERENCES educational_content.modules(id),
    lesson_id UUID REFERENCES educational_content.lessons(id),

    -- Restricciones
    required_rank gamification_system.maya_rank,
    is_exam BOOLEAN DEFAULT false,

    -- Estado
    status VARCHAR(20) DEFAULT 'draft', -- draft, review, published, archived
    published_at TIMESTAMPTZ,

    -- Auditoría
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    created_by UUID REFERENCES auth.users(id)
);

-- Índices
CREATE INDEX idx_exercises_mechanic ON educational_content.exercises(mechanic);
CREATE INDEX idx_exercises_difficulty ON educational_content.exercises(difficulty);
CREATE INDEX idx_exercises_module ON educational_content.exercises(module_id);
CREATE INDEX idx_exercises_status ON educational_content.exercises(status) WHERE status = 'published';
CREATE INDEX idx_exercises_content ON educational_content.exercises USING GIN(content);

-- Constraint: validar estructura de content según mechanic
ALTER TABLE educational_content.exercises
    ADD CONSTRAINT chk_content_structure CHECK (
        educational_content.validate_exercise_structure(mechanic, content, answer_key)
    );

COMMENT ON TABLE educational_content.exercises IS 'Ejercicios con 31 mecánicas diferentes';
COMMENT ON COLUMN educational_content.exercises.content IS 'Estructura JSONB flexible según mechanic';
COMMENT ON COLUMN educational_content.exercises.answer_key IS 'Respuestas correctas (NUNCA enviar al frontend)';
COMMENT ON COLUMN educational_content.exercises.hints IS 'Array de pistas progresivas';

3. Función: validate_exercise_structure

Ubicación: apps/database/ddl/schemas/educational_content/functions/validate_exercise_structure.sql

CREATE OR REPLACE FUNCTION educational_content.validate_exercise_structure(
    p_mechanic educational_content.exercise_mechanic,
    p_content JSONB,
    p_answer_key JSONB
)
RETURNS BOOLEAN
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
    -- Validación básica: content y answer_key no pueden ser null o vacíos
    IF p_content IS NULL OR p_content = '{}'::JSONB THEN
        RETURN false;
    END IF;

    IF p_answer_key IS NULL OR p_answer_key = '{}'::JSONB THEN
        RETURN false;
    END IF;

    -- Validaciones específicas por mecánica (ejemplos)
    CASE p_mechanic
        WHEN 'multiple_choice' THEN
            -- Debe tener: question, options (array), correct_answer en answer_key
            IF NOT (
                p_content ? 'question'
                AND p_content ? 'options'
                AND jsonb_array_length(p_content->'options') >= 2
                AND p_answer_key ? 'correct_answer'
            ) THEN
                RETURN false;
            END IF;

        WHEN 'fill_in_blank' THEN
            -- Debe tener: sentence, blank_position
            IF NOT (
                p_content ? 'sentence'
                AND p_content ? 'blank_position'
                AND p_answer_key ? 'correct_answer'
            ) THEN
                RETURN false;
            END IF;

        WHEN 'matching_pairs' THEN
            -- Debe tener: pairs (array)
            IF NOT (
                p_content ? 'pairs'
                AND jsonb_array_length(p_content->'pairs') >= 2
            ) THEN
                RETURN false;
            END IF;

        -- Agregar más validaciones según necesidad
        ELSE
            -- Por defecto, aceptar si tiene content y answer_key
            RETURN true;
    END CASE;

    RETURN true;
END;
$$;

COMMENT ON FUNCTION educational_content.validate_exercise_structure IS 'Valida que estructura JSONB sea correcta según mechanic';

4. Validadores de Ejercicios (Funciones PostgreSQL)

GAMILIT implementa un sistema robusto de validación de respuestas mediante funciones PostgreSQL especializadas. Cada tipo de ejercicio tiene su validador específico que verifica la corrección de las respuestas del estudiante.

4.1. Ubicación y Estructura

Ubicación: apps/database/ddl/schemas/educational_content/functions/

Firma estándar de validadores:

CREATE OR REPLACE FUNCTION educational_content.validate_[tipo](
    p_solution JSONB,
    p_submitted_answer JSONB,
    p_max_points INTEGER,
    p_allow_partial_credit BOOLEAN DEFAULT true,
    OUT is_correct BOOLEAN,
    OUT score INTEGER,
    OUT feedback TEXT,
    OUT details JSONB
)

Todos los validadores retornan el mismo formato de respuesta:

  • is_correct: Indica si la respuesta es completamente correcta
  • score: Puntuación obtenida (0 a p_max_points)
  • feedback: Mensaje de retroalimentación para el estudiante
  • details: JSONB con detalles adicionales (conexiones correctas, errores, etc.)

4.2. Validadores Módulo 1: Comprensión Literal

# Tipo de Ejercicio Validador Archivo Formato Entrada Descripción
1 crucigrama validate_crucigrama() 03-validate_crucigrama.sql {"clues": {"h1": "sorbona", "v1": "nobel"}} Compara respuestas por ID de pista
2 linea_tiempo validate_timeline() 04-validate_timeline.sql {"events": ["e1", "e2", "e3"]} Valida orden cronológico de eventos
3 sopa_letras validate_word_search() 05-validate_word_search.sql {"words": ["MARIE", "CURIE"]} Verifica palabras encontradas
4 completar_espacios validate_fill_in_blank() 06-validate_fill_in_blank.sql {"blanks": {"b1": "varsovia"}} Compara con fuzzy matching (85%)
5 verdadero_falso validate_true_false() 07-validate_true_false.sql {"statements": {"s1": true, "s2": false}} Compara valores booleanos

4.3. Validadores Módulo 2: Comprensión Inferencial

# Tipo de Ejercicio Validador Archivo Formato Entrada Descripción
6 detective_textual validate_detective_connections() 20-validate_detective_connections.sql {"connections": [{"from": "ev1", "to": "ev2", "relationship": "..."}]} NUEVO: Valida conexiones entre evidencias con keywords
7 prediccion_narrativa validate_prediction_scenarios() 21-validate_prediction_scenarios.sql {"scenarios": {"s1": "pred_a", "s2": "pred_b"}} NUEVO: Valida predicciones por escenario
8 construccion_hipotesis validate_cause_effect_matching() 22-validate_cause_effect_matching.sql {"causes": {"c1": ["cons1", "cons2"]}} NUEVO: Valida matching causa-efecto drag & drop
9 puzzle_contexto validate_puzzle_contexto() 13-validate_puzzle_contexto.sql {"questions": {"q1": "opt_a", "q2": "opt_b"}} Múltiple choice con inferencias
10 rueda_inferencias validate_rueda_inferencias() 14-validate_rueda_inferencias.sql {"inferences": {"inf1": "conclusion1"}} Matching de inferencias

Validadores agregados en DB-117/FE-059 (2025-11-19) para resolver discrepancias entre frontend y especificaciones originales.

Nota: Los validadores antiguos (validate_detective_textual, validate_prediccion_narrativa, validate_construccion_hipotesis) aún existen en el código pero exercise_validation_config ahora apunta a los nuevos validadores.

4.4. Validadores Módulo 3: Lectura Crítica (Actualizado v6.3)

# Tipo de Ejercicio Validador Archivo Formato Entrada Descripción
11 tribunal_opiniones validate_tribunal_opiniones() 15-validate_tribunal_opiniones.sql {"answers": {"stmt-1": {"type": "hecho", "verdict": "bien-fundamentada", "justification": "..."}}} Clasificar afirmaciones (HECHO/OPINIÓN/INTERPRETACIÓN) y asignar veredictos
12 debate_digital validate_debate_digital() 16-validate_debate_digital.sql {"stance": "a_favor", "arguments": [...], "evidence": [...]} Debate sobre impacto de la fama en Marie Curie
13 analisis_fuentes validate_analisis_fuentes() 17-validate_analisis_fuentes.sql {"sources": {"s1": {"craap_scores": {...}, "classification": "..."}}} Evaluación método CRAAP
14 podcast_argumentativo validate_podcast_argumentativo() 18-validate_podcast_argumentativo.sql {"audio_url": "...", "duration": 180, "transcript": "..."} Valida duración, formato y contenido
15 matriz_perspectivas validate_matriz_perspectivas() 19-validate_matriz_perspectivas.sql {"perspectives": {"persp-1": {...}, "persp-5": {"group": "Marie Curie"}, "persp-6": {"group": "Pierre Curie"}}} 6 perspectivas incluyendo Marie y Pierre Curie

Nota v6.3: El ejercicio Tribunal de Opiniones ahora usa formato statements con 8 afirmaciones sobre Marie Curie. Cada afirmación debe clasificarse como HECHO, OPINIÓN o INTERPRETACIÓN, y asignar un veredicto (bien-fundamentada, parcialmente-fundamentada, sin-fundamento).

4.5. Configuración de Validadores

Los validadores se configuran en la tabla educational_content.exercise_validation_config:

CREATE TABLE educational_content.exercise_validation_config (
    exercise_type educational_content.exercise_type PRIMARY KEY,
    validation_function VARCHAR(100) NOT NULL,  -- Nombre de la función a llamar
    case_sensitive BOOLEAN DEFAULT false,
    allow_partial_credit BOOLEAN DEFAULT true,
    fuzzy_matching_threshold DECIMAL(3,2),      -- 0.85 = 85% similaridad
    normalize_text BOOLEAN DEFAULT true,
    special_rules JSONB,                        -- Reglas específicas por tipo
    default_max_points INTEGER DEFAULT 100,
    default_passing_score INTEGER DEFAULT 70,
    description TEXT,
    examples JSONB,                             -- Ejemplos de uso
    created_at TIMESTAMP WITH TIME ZONE DEFAULT gamilit.now_mexico(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT gamilit.now_mexico()
);

Ejemplo de configuración: Detective Textual

INSERT INTO educational_content.exercise_validation_config (
    exercise_type,
    validation_function,
    allow_partial_credit,
    special_rules,
    description
) VALUES (
    'detective_textual',
    'validate_detective_connections',  -- Función a llamar
    true,                               -- Permite crédito parcial
    '{
        "minCorrectConnections": 2,
        "allowPartialCredit": true,
        "validateKeywords": true
    }'::jsonb,
    'Validación de detective textual: conexión de evidencias en investigación tipo tablero detective'
);

4.6. Función Dispatcher: validate_and_audit()

Todos los validadores se invocan a través de validate_and_audit() que:

  1. Recupera el ejercicio y su configuración de validación
  2. Ejecuta el validador específico según validation_function en la config
  3. Crea registro de auditoría en exercise_validation_audit
  4. Retorna resultado con feedback detallado

Ubicación: apps/database/ddl/schemas/educational_content/functions/20-validate_and_audit.sql

Flujo de validación:

Frontend → Backend → validate_and_audit(exercise_id, user_id, answer)
                              ↓
                   exercise_validation_config (obtener validation_function)
                              ↓
                   CASE validation_function
                      WHEN 'validate_detective_connections' → ejecutar validador
                      WHEN 'validate_prediction_scenarios' → ejecutar validador
                      WHEN 'validate_cause_effect_matching' → ejecutar validador
                      ...
                              ↓
                   Retornar (is_correct, score, feedback, details)

Invocación desde Backend:

const result = await db.query(`
  SELECT * FROM educational_content.validate_and_audit(
    $1::UUID,  -- exercise_id
    $2::UUID,  -- user_id
    $3::JSONB  -- submitted_answer
  )
`, [exerciseId, userId, submittedAnswer]);

// result = { is_correct: true, score: 100, feedback: "...", details: {...} }

4.7. Seeds de Testing

Archivo: apps/database/seeds/dev/educational_content/10-test-nuevos-validadores-FE-059.sql

Contiene 3 ejercicios de prueba (order_index 101-103) para validar los nuevos validadores:

  1. Detective Textual (101): Investigación de descubrimientos de Marie Curie con 4 evidencias
  2. Predicción Narrativa (102): Decisiones históricas de Marie Curie en 4 escenarios
  3. Causa-Efecto (103): Impacto de los descubrimientos de Curie con 5 causas y 15 consecuencias

Referencia: DB-117, DB-123, FE-059


🔧 Implementación Backend (NestJS)

1. Enum TypeScript

Ubicación: apps/backend/src/educational-content/enums/exercise-mechanic.enum.ts

export enum ExerciseMechanicEnum {
  // Vocabulario
  MULTIPLE_CHOICE = 'multiple_choice',
  FILL_IN_BLANK = 'fill_in_blank',
  MATCHING_PAIRS = 'matching_pairs',
  FLASHCARD = 'flashcard',
  WORD_SEARCH = 'word_search',
  IMAGE_ASSOCIATION = 'image_association',

  // Gramática
  VERB_CONJUGATION = 'verb_conjugation',
  SENTENCE_BUILDER = 'sentence_builder',
  ERROR_DETECTION = 'error_detection',
  SENTENCE_TRANSFORMATION = 'sentence_transformation',
  PRONOUN_SELECTION = 'pronoun_selection',
  POSSESSIVE_FORMS = 'possessive_forms',
  PLURALIZATION = 'pluralization',
  ASPECT_MARKERS = 'aspect_markers',

  // Lectura
  READING_COMPREHENSION = 'reading_comprehension',
  TRUE_OR_FALSE = 'true_or_false',
  INFERENCE = 'inference',
  SEQUENCE_ORDERING = 'sequence_ordering',

  // Escritura
  FREE_WRITING = 'free_writing',
  SENTENCE_COMPLETION = 'sentence_completion',
  TRANSLATION = 'translation',
  DICTATION = 'dictation',

  // Audio
  LISTENING_COMPREHENSION = 'listening_comprehension',
  AUDIO_MATCHING = 'audio_matching',
  TONE_RECOGNITION = 'tone_recognition',

  // Pronunciación
  SPEECH_RECORDING = 'speech_recording',
  PRONUNCIATION_COMPARISON = 'pronunciation_comparison',

  // Cultura
  CULTURAL_CONTEXT = 'cultural_context',
  HISTORICAL_TIMELINE = 'historical_timeline',
  CULTURAL_ARTIFACT = 'cultural_artifact',
  TRADITIONAL_PRACTICE = 'traditional_practice',
}

2. Entity

Ubicación: apps/backend/src/educational-content/entities/exercise.entity.ts

import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { ExerciseMechanicEnum } from '../enums/exercise-mechanic.enum';
import { DifficultyLevelEnum } from '../enums/difficulty-level.enum';

@Entity({ schema: 'educational_content', name: 'exercises' })
export class Exercise {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 100, unique: true, nullable: true })
  code?: string;

  @Column({ type: 'varchar', length: 300 })
  title: string;

  @Column({ type: 'enum', enum: ExerciseMechanicEnum })
  mechanic: ExerciseMechanicEnum;

  @Column({ type: 'enum', enum: DifficultyLevelEnum })
  difficulty: DifficultyLevelEnum;

  @Column({ type: 'jsonb' })
  content: Record<string, any>;

  @Column({ type: 'jsonb', name: 'answer_key', select: false }) // NUNCA select por defecto
  answerKey: Record<string, any>;

  @Column({ type: 'jsonb', nullable: true })
  hints?: string[];

  @Column({ type: 'varchar', length: 500, nullable: true, name: 'image_url' })
  imageUrl?: string;

  @Column({ type: 'varchar', length: 500, nullable: true, name: 'audio_url' })
  audioUrl?: string;

  @Column({ type: 'varchar', length: 500, nullable: true, name: 'video_url' })
  videoUrl?: string;

  @Column({ type: 'integer', default: 60, name: 'estimated_time_seconds' })
  estimatedTimeSeconds: number;

  @Column({ type: 'integer', default: 15, name: 'xp_reward' })
  xpReward: number;

  @Column({ type: 'integer', default: 5, name: 'ml_coins_reward' })
  mlCoinsReward: number;

  @Column({ type: 'uuid', nullable: true, name: 'module_id' })
  moduleId?: string;

  @Column({ type: 'uuid', nullable: true, name: 'lesson_id' })
  lessonId?: string;

  @Column({ type: 'boolean', default: false, name: 'is_exam' })
  isExam: boolean;

  @Column({ type: 'varchar', length: 20, default: 'draft' })
  status: 'draft' | 'review' | 'published' | 'archived';

  @Column({ type: 'timestamptz', nullable: true, name: 'published_at' })
  publishedAt?: Date;

  @Column({ type: 'timestamptz', default: () => 'NOW()', name: 'created_at' })
  createdAt: Date;

  @Column({ type: 'timestamptz', default: () => 'NOW()', name: 'updated_at' })
  updatedAt: Date;

  @Column({ type: 'uuid', nullable: true, name: 'created_by' })
  createdBy?: string;
}

3. Validators (Factory Pattern)

Ubicación: apps/backend/src/educational-content/validators/exercise-validators.ts

import { ExerciseMechanicEnum } from '../enums/exercise-mechanic.enum';

export interface IExerciseValidator {
  validate(userAnswer: any, answerKey: any): ValidationResult;
}

export interface ValidationResult {
  correct: boolean;
  feedback?: string;
  score?: number; // 0-100
}

// Base Validator
abstract class BaseExerciseValidator implements IExerciseValidator {
  abstract validate(userAnswer: any, answerKey: any): ValidationResult;
}

// Multiple Choice Validator
export class MultipleChoiceValidator extends BaseExerciseValidator {
  validate(userAnswer: any, answerKey: any): ValidationResult {
    const { selected } = userAnswer;
    const { correct_answer } = answerKey;

    const isCorrect = selected === correct_answer;

    return {
      correct: isCorrect,
      feedback: isCorrect
        ? '¡Correcto!'
        : `Incorrecto. La respuesta correcta es: ${correct_answer}`,
      score: isCorrect ? 100 : 0,
    };
  }
}

// Fill in Blank Validator
export class FillInBlankValidator extends BaseExerciseValidator {
  validate(userAnswer: any, answerKey: any): ValidationResult {
    const { answer } = userAnswer;
    const { correct_answer, accept_variations = [] } = answerKey;

    // Normalizar respuesta (lowercase, trim, sin acentos opcionales)
    const normalized = this.normalize(answer);

    const acceptableAnswers = [correct_answer, ...accept_variations].map(a =>
      this.normalize(a)
    );

    const isCorrect = acceptableAnswers.includes(normalized);

    return {
      correct: isCorrect,
      feedback: isCorrect ? '¡Correcto!' : `Incorrecto. Se esperaba: ${correct_answer}`,
      score: isCorrect ? 100 : 0,
    };
  }

  private normalize(text: string): string {
    return text
      .toLowerCase()
      .trim()
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, ''); // Remover acentos
  }
}

// Matching Pairs Validator
export class MatchingPairsValidator extends BaseExerciseValidator {
  validate(userAnswer: any, answerKey: any): ValidationResult {
    const { pairs: userPairs } = userAnswer; // [{ maya_id: 1, spanish_id: 1 }, ...]
    const { pairs: correctPairs } = answerKey;

    let correctCount = 0;
    let totalPairs = correctPairs.length;

    for (const correctPair of correctPairs) {
      const userPair = userPairs.find(
        (up: any) => up.maya_id === correctPair.id || up.id === correctPair.id
      );

      if (userPair && userPair.spanish_id === correctPair.id) {
        correctCount++;
      }
    }

    const score = Math.round((correctCount / totalPairs) * 100);
    const isCorrect = score === 100;

    return {
      correct: isCorrect,
      feedback: isCorrect
        ? '¡Todos los pares correctos!'
        : `${correctCount}/${totalPairs} pares correctos`,
      score,
    };
  }
}

// Verb Conjugation Validator
export class VerbConjugationValidator extends BaseExerciseValidator {
  validate(userAnswer: any, answerKey: any): ValidationResult {
    const { conjugation } = userAnswer;
    const { correct_answer, accept_variations = [] } = answerKey;

    const normalized = this.normalize(conjugation);
    const acceptableAnswers = [correct_answer, ...accept_variations].map(a =>
      this.normalize(a)
    );

    const isCorrect = acceptableAnswers.includes(normalized);

    return {
      correct: isCorrect,
      feedback: isCorrect
        ? '¡Conjugación correcta!'
        : `Incorrecto. La conjugación correcta es: ${correct_answer}`,
      score: isCorrect ? 100 : 0,
    };
  }

  private normalize(text: string): string {
    return text.toLowerCase().trim();
  }
}

// ... Implementar validators para las otras 27 mecánicas ...

// Factory
export class ExerciseValidatorFactory {
  static create(mechanic: ExerciseMechanicEnum): IExerciseValidator {
    switch (mechanic) {
      case ExerciseMechanicEnum.MULTIPLE_CHOICE:
        return new MultipleChoiceValidator();

      case ExerciseMechanicEnum.FILL_IN_BLANK:
        return new FillInBlankValidator();

      case ExerciseMechanicEnum.MATCHING_PAIRS:
        return new MatchingPairsValidator();

      case ExerciseMechanicEnum.VERB_CONJUGATION:
        return new VerbConjugationValidator();

      // ... Agregar casos para las otras 27 mecánicas ...

      default:
        throw new Error(`Validator not implemented for mechanic: ${mechanic}`);
    }
  }
}

4. ExerciseService

Ubicación: apps/backend/src/educational-content/services/exercise.service.ts

import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Exercise } from '../entities/exercise.entity';
import { ExerciseValidatorFactory } from '../validators/exercise-validators';

@Injectable()
export class ExerciseService {
  constructor(
    @InjectRepository(Exercise)
    private exerciseRepo: Repository<Exercise>,
  ) {}

  /**
   * Obtener ejercicio (SIN answer_key)
   */
  async getExercise(exerciseId: string, userId: string): Promise<Partial<Exercise>> {
    const exercise = await this.exerciseRepo.findOne({
      where: { id: exerciseId, status: 'published' },
      select: [
        'id',
        'code',
        'title',
        'mechanic',
        'difficulty',
        'content',
        'hints',
        'imageUrl',
        'audioUrl',
        'videoUrl',
        'estimatedTimeSeconds',
        'isExam',
        // ❌ NO incluir answerKey
      ],
    });

    if (!exercise) {
      throw new NotFoundException('Exercise not found or not published');
    }

    // Verificar si usuario tiene acceso (por rango, etc.)
    await this.checkAccess(userId, exercise);

    return exercise;
  }

  /**
   * Validar respuesta del usuario
   */
  async submitAnswer(
    userId: string,
    exerciseId: string,
    attemptId: string,
    userAnswer: any
  ): Promise<{
    correct: boolean;
    feedback: string;
    score: number;
    xp_earned: number;
    ml_coins_earned: number;
  }> {
    // 1. Obtener ejercicio CON answer_key (query separado)
    const exercise = await this.exerciseRepo
      .createQueryBuilder('e')
      .addSelect('e.answer_key') // Forzar select de answer_key
      .where('e.id = :id', { id: exerciseId })
      .getOne();

    if (!exercise) {
      throw new NotFoundException('Exercise not found');
    }

    // 2. Obtener validator según mecánica
    const validator = ExerciseValidatorFactory.create(exercise.mechanic);

    // 3. Validar respuesta
    const validationResult = validator.validate(userAnswer, exercise.answerKey);

    // 4. Calcular XP y ML Coins
    let xpEarned = 0;
    let mlCoinsEarned = 0;

    if (validationResult.correct) {
      // XP base según dificultad
      xpEarned = exercise.xpReward;
      mlCoinsEarned = exercise.mlCoinsReward;

      // Aplicar multiplicador de rango del usuario
      const userRank = await this.getUserRank(userId);
      xpEarned = Math.round(xpEarned * this.getRankMultiplier(userRank));
    }

    // 5. Registrar intento en progress_tracking.exercise_attempts
    await this.recordAttempt(userId, exerciseId, attemptId, validationResult.correct, validationResult.score);

    // 6. Si correcto, actualizar user_stats
    if (validationResult.correct) {
      await this.updateUserStats(userId, xpEarned, mlCoinsEarned);
    }

    // 7. Emitir evento para achievements
    if (validationResult.correct) {
      // eventEmitter.emit('exercise.completed', { userId, exerciseId, xpEarned });
    }

    return {
      correct: validationResult.correct,
      feedback: validationResult.feedback || '',
      score: validationResult.score || 0,
      xp_earned: xpEarned,
      ml_coins_earned: mlCoinsEarned,
    };
  }

  /**
   * Obtener hint (deduce del inventario si es necesario)
   */
  async getHint(userId: string, exerciseId: string, hintNumber: number): Promise<string> {
    const exercise = await this.exerciseRepo.findOne({
      where: { id: exerciseId },
      select: ['id', 'hints', 'isExam'],
    });

    if (!exercise) {
      throw new NotFoundException('Exercise not found');
    }

    if (exercise.isExam) {
      throw new ForbiddenException('Hints not allowed in exams');
    }

    if (!exercise.hints || exercise.hints.length < hintNumber) {
      throw new NotFoundException(`Hint ${hintNumber} not available`);
    }

    // Verificar límite de 3 pistas por ejercicio (delegado a ComodinService)
    // ...

    return exercise.hints[hintNumber - 1];
  }

  // Métodos auxiliares
  private async checkAccess(userId: string, exercise: Exercise): Promise<void> {
    // Verificar rango requerido, etc.
  }

  private async getUserRank(userId: string): Promise<string> {
    // Query a user_stats
    return 'Ajaw'; // Placeholder
  }

  private getRankMultiplier(rank: string): number {
    const multipliers = {
      Ajaw: 1.0,
      Nacom: 1.05,
      'Ah K\'in': 1.10,
      'Halach Uinic': 1.15,
      'K\'uk\'ulkan': 1.20,
    };
    return multipliers[rank] || 1.0;
  }

  private async recordAttempt(
    userId: string,
    exerciseId: string,
    attemptId: string,
    correct: boolean,
    score: number
  ): Promise<void> {
    // INSERT en progress_tracking.exercise_attempts
  }

  private async updateUserStats(userId: string, xp: number, coins: number): Promise<void> {
    // UPDATE gamification_system.user_stats
  }
}

5. Controller

Ubicación: apps/backend/src/educational-content/controllers/exercise.controller.ts

import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { ExerciseService } from '../services/exercise.service';

@Controller('exercises')
@UseGuards(JwtAuthGuard)
export class ExerciseController {
  constructor(private exerciseService: ExerciseService) {}

  /**
   * GET /exercises/:id
   * Obtener ejercicio (sin answer_key)
   */
  @Get(':id')
  async getExercise(@Req() req, @Param('id') id: string) {
    return await this.exerciseService.getExercise(id, req.user.id);
  }

  /**
   * POST /exercises/:id/submit
   * Enviar respuesta y validar
   */
  @Post(':id/submit')
  async submitAnswer(
    @Req() req,
    @Param('id') id: string,
    @Body() body: { attemptId: string; answer: any }
  ) {
    return await this.exerciseService.submitAnswer(req.user.id, id, body.attemptId, body.answer);
  }

  /**
   * GET /exercises/:id/hints/:number
   * Obtener pista específica
   */
  @Get(':id/hints/:number')
  async getHint(@Req() req, @Param('id') id: string, @Param('number') number: string) {
    return await this.exerciseService.getHint(req.user.id, id, parseInt(number));
  }
}

🎨 Implementación Frontend (React)

1. ExerciseRenderer (Component Dinámico)

Ubicación: apps/frontend/src/components/exercises/ExerciseRenderer.tsx

import React from 'react';
import { Exercise } from '../../types/exercise.types';
import { ExerciseMechanicEnum } from '../../enums/exercise-mechanic.enum';

// Importar componentes específicos
import { MultipleChoiceExercise } from './mechanics/MultipleChoiceExercise';
import { FillInBlankExercise } from './mechanics/FillInBlankExercise';
import { MatchingPairsExercise } from './mechanics/MatchingPairsExercise';
// ... importar los otros 28 componentes

interface ExerciseRendererProps {
  exercise: Exercise;
  onSubmit: (answer: any) => void;
}

export const ExerciseRenderer: React.FC<ExerciseRendererProps> = ({ exercise, onSubmit }) => {
  // Factory pattern para renderizar componente correcto
  const renderExercise = () => {
    switch (exercise.mechanic) {
      case ExerciseMechanicEnum.MULTIPLE_CHOICE:
        return <MultipleChoiceExercise exercise={exercise} onSubmit={onSubmit} />;

      case ExerciseMechanicEnum.FILL_IN_BLANK:
        return <FillInBlankExercise exercise={exercise} onSubmit={onSubmit} />;

      case ExerciseMechanicEnum.MATCHING_PAIRS:
        return <MatchingPairsExercise exercise={exercise} onSubmit={onSubmit} />;

      // ... otros 28 casos

      default:
        return <div>Mechanic not implemented: {exercise.mechanic}</div>;
    }
  };

  return (
    <div className="exercise-container">
      <div className="exercise-header">
        <h2>{exercise.title}</h2>
        <span className="difficulty-badge">{exercise.difficulty}</span>
      </div>

      <div className="exercise-content">{renderExercise()}</div>
    </div>
  );
};

2. Component: MultipleChoiceExercise

Ubicación: apps/frontend/src/components/exercises/mechanics/MultipleChoiceExercise.tsx

import React, { useState } from 'react';
import { Exercise } from '../../../types/exercise.types';

interface MultipleChoiceExerciseProps {
  exercise: Exercise;
  onSubmit: (answer: any) => void;
}

export const MultipleChoiceExercise: React.FC<MultipleChoiceExerciseProps> = ({ exercise, onSubmit }) => {
  const [selectedOption, setSelectedOption] = useState<string | null>(null);

  const handleSubmit = () => {
    if (selectedOption) {
      onSubmit({ selected: selectedOption });
    }
  };

  const { question, options } = exercise.content;

  return (
    <div className="multiple-choice-exercise">
      <p className="question text-lg font-semibold mb-4">{question}</p>

      <div className="options space-y-3">
        {options.map((option: any) => (
          <button
            key={option.id}
            onClick={() => setSelectedOption(option.id)}
            className={`option-button w-full p-4 text-left rounded-lg border-2 transition-all ${
              selectedOption === option.id ? 'border-blue-600 bg-blue-50' : 'border-gray-300 hover:border-gray-400'
            }`}
          >
            <span className="font-bold mr-2">{option.id.toUpperCase()})</span>
            {option.text}
          </button>
        ))}
      </div>

      <button
        onClick={handleSubmit}
        disabled={!selectedOption}
        className="mt-6 w-full py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
      >
        Verificar Respuesta
      </button>
    </div>
  );
};

🧪 Testing

Test Case 1: Validar Multiple Choice

test('Multiple choice validator validates correct answer', () => {
  const validator = new MultipleChoiceValidator();

  const userAnswer = { selected: 'a' };
  const answerKey = { correct_answer: 'a' };

  const result = validator.validate(userAnswer, answerKey);

  expect(result.correct).toBe(true);
  expect(result.score).toBe(100);
});

Test Case 2: Fill in Blank con Variaciones

test('Fill in blank accepts variations', () => {
  const validator = new FillInBlankValidator();

  const answerKey = {
    correct_answer: 'paal',
    accept_variations: ['paal', 'páal'],
  };

  // Con acento
  const result1 = validator.validate({ answer: 'páal' }, answerKey);
  expect(result1.correct).toBe(true);

  // Sin acento
  const result2 = validator.validate({ answer: 'paal' }, answerKey);
  expect(result2.correct).toBe(true);
});

📊 Performance

Índices Críticos

CREATE INDEX idx_exercises_mechanic ON educational_content.exercises(mechanic);
CREATE INDEX idx_exercises_content ON educational_content.exercises USING GIN(content);

Caching

// Redis cache para ejercicios publicados
async getExercise(exerciseId: string): Promise<Exercise> {
  const cacheKey = `exercise:${exerciseId}`;
  const cached = await this.redis.get(cacheKey);

  if (cached) {
    return JSON.parse(cached);
  }

  const exercise = await this.exerciseRepo.findOne(exerciseId);

  await this.redis.set(cacheKey, JSON.stringify(exercise), 'EX', 3600); // 1 hora

  return exercise;
}

📅 Historial de Cambios

Versión Fecha Autor Cambios
1.0 2025-11-07 Database Team Creación del documento
2.0 2025-11-11 Database Team Reconciliación: Actualizado de exercise_mechanic a exercise_type. Sistema dual implementado con tabla de mapeo pedagógico. Sincronizado con DDL real. 13 GAPs pedagógicos identificados.
2.1 2025-11-21 Database Team Alineación v6.3: Actualización validadores Módulo 3. tribunal_opiniones: formato statements. debate_digital: tema fama Marie Curie. matriz_perspectivas: perspectivas Marie y Pierre Curie. Ref: DB-127

Nota v2.0: Este documento fue actualizado para reflejar la implementación real del sistema (DB-110, DB-111, DB-112). Se mantiene la clasificación pedagógica mediante tabla de mapeo exercise_mechanic_mapping, reconciliando el valor pedagógico original con las 35 implementaciones específicas GAMILIT existentes. La decisión arquitectónica está documentada en ADR-008.


Documento: docs/01-fase-alcance-inicial/EAI-002-actividades/especificaciones/ET-EDU-001-mecanicas-ejercicios.md Propósito: Especificación técnica completa de implementación de las 35 mecánicas GAMILIT + sistema de mapeo pedagógico Audiencia: Backend Developers, Frontend Developers, QA Team