# 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:** - [RF-EDU-001: Mecánicas de Ejercicios](../../01-requerimientos/03-contenido-educativo/RF-EDU-001-mecanicas-ejercicios.md) ### 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` ```sql -- 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` ```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` ```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` ```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:** ```sql 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`: ```sql 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 ```sql 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:** ```typescript 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` ```typescript 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` ```typescript 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; @Column({ type: 'jsonb', name: 'answer_key', select: false }) // NUNCA select por defecto answerKey: Record; @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` ```typescript 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` ```typescript 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, ) {} /** * Obtener ejercicio (SIN answer_key) */ async getExercise(exerciseId: string, userId: string): Promise> { 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 { 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 { // Verificar rango requerido, etc. } private async getUserRank(userId: string): Promise { // 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 { // INSERT en progress_tracking.exercise_attempts } private async updateUserStats(userId: string, xp: number, coins: number): Promise { // UPDATE gamification_system.user_stats } } ``` ### 5. Controller **Ubicación:** `apps/backend/src/educational-content/controllers/exercise.controller.ts` ```typescript 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` ```typescript 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 = ({ exercise, onSubmit }) => { // Factory pattern para renderizar componente correcto const renderExercise = () => { switch (exercise.mechanic) { case ExerciseMechanicEnum.MULTIPLE_CHOICE: return ; case ExerciseMechanicEnum.FILL_IN_BLANK: return ; case ExerciseMechanicEnum.MATCHING_PAIRS: return ; // ... otros 28 casos default: return
Mechanic not implemented: {exercise.mechanic}
; } }; return (

{exercise.title}

{exercise.difficulty}
{renderExercise()}
); }; ``` ### 2. Component: MultipleChoiceExercise **Ubicación:** `apps/frontend/src/components/exercises/mechanics/MultipleChoiceExercise.tsx` ```typescript import React, { useState } from 'react'; import { Exercise } from '../../../types/exercise.types'; interface MultipleChoiceExerciseProps { exercise: Exercise; onSubmit: (answer: any) => void; } export const MultipleChoiceExercise: React.FC = ({ exercise, onSubmit }) => { const [selectedOption, setSelectedOption] = useState(null); const handleSubmit = () => { if (selectedOption) { onSubmit({ selected: selectedOption }); } }; const { question, options } = exercise.content; return (

{question}

{options.map((option: any) => ( ))}
); }; ``` --- ## 🧪 Testing ### Test Case 1: Validar Multiple Choice ```typescript 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 ```typescript 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 ```sql CREATE INDEX idx_exercises_mechanic ON educational_content.exercises(mechanic); CREATE INDEX idx_exercises_content ON educational_content.exercises USING GIN(content); ``` ### Caching ```typescript // Redis cache para ejercicios publicados async getExercise(exerciseId: string): Promise { 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