From 957539d20122cb820370da9455e80ee46c245874 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Thu, 22 Jan 2026 13:24:48 -0300 Subject: [PATCH] feat: se aplican varios fix a las pruebas --- README.md | 22 ++ roadmap.md | 13 +- services/cms-service/src/handlers.rs | 197 +++++++++++++- services/cms-service/src/main.rs | 5 +- web/experience/package-lock.json | 12 + .../courses/[id]/lessons/[lessonId]/page.tsx | 8 +- .../src/components/blocks/HotspotPlayer.tsx | 3 +- .../src/components/blocks/MemoryPlayer.tsx | 9 +- web/experience/src/lib/api.ts | 9 + .../courses/[id]/lessons/[lessonId]/page.tsx | 128 ++++++++- web/studio/src/app/page.tsx | 28 +- .../components/blocks/DescriptionBlock.tsx | 84 +++++- .../src/components/blocks/HotspotBlock.tsx | 251 ++++++++++++++++++ .../src/components/blocks/MemoryBlock.tsx | 138 ++++++++++ web/studio/src/lib/api.ts | 18 +- 15 files changed, 899 insertions(+), 26 deletions(-) create mode 100644 web/studio/src/components/blocks/HotspotBlock.tsx create mode 100644 web/studio/src/components/blocks/MemoryBlock.tsx diff --git a/README.md b/README.md index 874d547..5d43b09 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Smart Notifications**: Recordatorios de fechas límite y alertas in-app. - **Global i18n**: Interfaz multilingüe (EN, ES, PT) con persistencia por usuario. - **Document-Based Learning**: Soporte para actividades de lectura (PDF, DOCX, PPTX). + - **AI English Teacher**: Persona especializada para generación de contenidos y tutoría personalizada. + - **AI Audio Evaluation**: Evaluación inteligente de pronunciación y contenido con feedback en lenguaje natural. + - **Custom AI Quizzes**: Generación de quices con contexto pedagógico y tipo de pregunta personalizable (opción múltiple, V/F, etc.). + - **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada. + - **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots). + - **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots). ## Requisitos del Sistema @@ -282,6 +288,22 @@ Funcionalidades inteligentes 100% locales y gratuitas. #### POST /lessons/{id}/transcribe Inicia el proceso de transcripción y traducción para una lección de video/audio. +#### POST /audio/evaluate +Evalúa una respuesta oral del estudiante utilizando IA. + +#### POST /lessons/{id}/generate-quiz +Genera un quiz basado en el contenido de la lección. +- **Cuerpo ( QuizAIRequest ):** + ```json + { + "context": "focused on irregular verbs", + "quiz_type": "true-false" + } + ``` + +#### DELETE /courses/{id} +Elimina un curso y todos sus contenidos relacionados (módulos, lecciones, assets). + - **Procesamiento Asíncrono**: Despacha una tarea en segundo plano que utiliza Whisper para transcripción y Ollama para generar la traducción y el resumen inteligente. - **Cuerpo de la Petición**: Vacío. diff --git a/roadmap.md b/roadmap.md index 083e05d..75f1133 100644 --- a/roadmap.md +++ b/roadmap.md @@ -130,7 +130,10 @@ - [x] **Quices de Código**: Desafíos interactivos con reproductor tipo IDE (Completado) - [x] **Identificación Visual**: Quices de "Puntos Calientes" (Hotspots) en imágenes (Completado) - [ ] **Tutor de IA Integrado**: Asistente basado en RAG dentro del reproductor de lecciones -- [x] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas (Completado) +- [x] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas con feedback de IA detallado (Completado) +- [x] **Eliminación de Cursos**: Gestión completa del ciclo de vida del contenido (Completado) +- [x] **Quices con Contexto IA**: Generación de evaluaciones con enfoque y tipo personalizable (Completado) +- [x] **Actividades Gamificadas**: Nuevos bloques de Juego de Memoria e Identificación Visual (Hotspots) (Completado) - [x] **Marcadores de Video**: Preguntas que pausan el video en timestamps específicos (Completado) ## Fase 12: Generador de Cursos "Mágico" con IA ✅ @@ -151,11 +154,9 @@ --- -## Estado Actual y Próximas Prioridades +**Estado Actual**: La plataforma cuenta con un motor de IA avanzado que permite la generación de contenidos con una "Persona" docente experta, evaluaciones de audio automatizadas y quices personalizables con contexto. Se ha completado el ciclo de vida de gestión de cursos con la integración de la funcionalidad de borrado. -**Madurez de la Plataforma**: El núcleo multi-tenant es estable, escalable y está optimizado para alto rendimiento con IA Local. - -**Prioridades Inmediatas**: -1. **Marcadores de Video & Audio**: Evaluaciones integradas en contenido multimedia. +**Próximas Prioridades**: +1. **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción. 2. **IA Teaching Assistant**: Tutor RAG personalizado por curso. 3. **Rutas de Aprendizaje**: Recomendaciones basadas en el historial. diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index f735103..f653cb7 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -189,6 +189,17 @@ pub struct GradingPayload { pub drop_count: i32, } +#[derive(Deserialize)] +pub struct QuizAIRequest { + pub context: Option, + pub quiz_type: Option, +} + +#[derive(Deserialize)] +pub struct ReviewTextRequest { + pub text: String, +} + pub async fn create_course( Org(org_ctx): Org, claims: common::auth::Claims, @@ -1000,6 +1011,7 @@ pub async fn generate_quiz( claims: common::auth::Claims, State(pool): State, Path(id): Path, + Json(quiz_req): Json, ) -> Result, StatusCode> { tracing::info!("Received quiz generation request for lesson: {}", id); // 1. Fetch lesson @@ -1049,6 +1061,20 @@ pub async fn generate_quiz( ) }; + let mut system_prompt = "You are an expert English Teacher. Generate 3 questions based on the lesson content to test the student's understanding of English grammar, vocabulary, or comprehension. Instructions can be in Spanish or English. Return ONLY a JSON object with a field 'blocks' which is an array of content blocks. Each block in the array must follow this exact structure: { \"id\": \"string-uuid\", \"type\": \"quiz\", \"title\": \"Quiz: Concept Check\", \"quiz_data\": { \"questions\": [ { \"id\": \"q-string\", \"type\": \"multiple-choice\", \"question\": \"String\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\", \"Option 4\"], \"correct\": [0], \"explanation\": \"Explain why the answer is correct.\" } ] } }. Important: 'correct' MUST be an array of integers.".to_string(); + + if let Some(ctx) = &quiz_req.context { + if !ctx.is_empty() { + system_prompt.push_str(&format!(" Additional Context: {}", ctx)); + } + } + + if let Some(qtype) = &quiz_req.quiz_type { + if !qtype.is_empty() { + system_prompt.push_str(&format!(" Question Type to use: {}. If the type is 'multiple-choice', follow the structure above. If it's something else, adapt the block 'type' (e.g., 'true-false') accordingly, but keep it within the 'blocks' array.", qtype)); + } + } + let mut request = client .post(&url) .json(&json!({ @@ -1056,7 +1082,7 @@ pub async fn generate_quiz( "messages": [ { "role": "system", - "content": "You are an expert English Teacher. Generate 3 multiple-choice questions based on the lesson content to test the student's understanding of English grammar, vocabulary, or comprehension. Instructions can be in Spanish or English. Return ONLY a JSON object with a field 'blocks' which is an array of content blocks. Each block in the array must follow this exact structure: { \"id\": \"string-uuid\", \"type\": \"quiz\", \"title\": \"Quiz: Concept Check\", \"quiz_data\": { \"questions\": [ { \"id\": \"q-string\", \"type\": \"multiple-choice\", \"question\": \"String\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\", \"Option 4\"], \"correct\": [0], \"explanation\": \"Explain why the answer is correct.\" } ] } }. Important: 'correct' MUST be an array of integers." + "content": system_prompt }, { "role": "user", @@ -3282,3 +3308,172 @@ RULES: Ok(Json(course)) } + +pub async fn delete_course( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Result { + let is_super_admin = claims.role == "admin" + && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + + // 1. Check if course exists and belongs to org (or if requester is super admin) + let course = if is_super_admin { + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + } else { + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + } + .map_err(|_| StatusCode::NOT_FOUND)?; + + // 2. Additional permission check for instructors + if !is_super_admin && claims.role == "instructor" && course.instructor_id != claims.sub { + return Err(StatusCode::FORBIDDEN); + } + + // 3. Delete course using DB function + let mut conn = pool + .acquire() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut conn, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_course($1, $2)") + .bind(id) + .bind(course.organization_id) + .fetch_one(&mut *conn) + .await + .map_err(|e| { + tracing::error!("Delete course failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !success { + return Err(StatusCode::NOT_FOUND); + } + + log_action( + &pool, + org_ctx.id, + claims.sub, + "DELETE", + "Course", + id, + json!({ "title": course.title }), + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn review_text( + _: Org, + _claims: Claims, + State(_pool): State, + Json(payload): Json, +) -> Result, StatusCode> { + if payload.text.trim().is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + // Configuration + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = + env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string()); + ( + format!("{}/v1/chat/completions", base_url), + "".to_string(), + model, + ) + } else { + let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", api_key), + "gpt-4o".to_string(), + ) + }; + + let system_prompt = "You are an expert English Teacher and Editor. Analyze the following text and provide suggestions for improvement. Focus on: 1. Grammar and spelling. 2. Tone (should be professional yet encouraging). 3. Clarity and conciseness. 4. Better vocabulary choices. Return ONLY a JSON object with a field 'suggestion' containing the improved version of the text, and a field 'comments' which is a brief list of what was improved."; + + let mut request = client + .post(&url) + .json(&json!({ + "model": model, + "messages": [ + { + "role": "system", + "content": system_prompt + }, + { + "role": "user", + "content": payload.text + } + ], + "response_format": { "type": "json_object" } + })); + + if !auth_header.is_empty() { + request = request.header("Authorization", auth_header); + } + + let response = request.send().await.map_err(|e| { + tracing::error!("Text review request failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + tracing::error!("Text review API error: {}", err_body); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let review_data: serde_json::Value = response + .json() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let content_str = review_data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("{}"); + + let parsed_review: serde_json::Value = serde_json::from_str(content_str).unwrap_or(json!({ + "suggestion": payload.text, + "comments": "No suggestions available at this time." + })); + + Ok(Json(parsed_review)) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 5708d02..725b5f7 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -87,7 +87,9 @@ async fn main() { ) .route( "/courses/{id}", - get(handlers::get_course).put(handlers::update_course), + get(handlers::get_course) + .put(handlers::update_course) + .delete(handlers::delete_course), ) .route("/courses/{id}/publish", post(handlers::publish_course)) .route("/courses/{id}/outline", get(handlers::get_course_outline)) @@ -140,6 +142,7 @@ async fn main() { .route("/users", get(handlers::get_all_users)) .route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/audit-logs", get(handlers::get_audit_logs)) + .route("/api/ai/review-text", post(handlers::review_text)) .route("/api/assets/upload", post(handlers::upload_asset)) .route("/api/assets/{id}", delete(handlers::delete_asset)) .route("/courses/{id}/assets", get(handlers::get_course_assets)) diff --git a/web/experience/package-lock.json b/web/experience/package-lock.json index 30ad925..bee89bd 100644 --- a/web/experience/package-lock.json +++ b/web/experience/package-lock.json @@ -568,6 +568,7 @@ "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -630,6 +631,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz", "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -1103,6 +1105,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1513,6 +1516,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2211,6 +2215,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2373,6 +2378,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3797,6 +3803,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5138,6 +5145,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5338,6 +5346,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5349,6 +5358,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6291,6 +6301,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -6468,6 +6479,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index bc38d1b..72da0ea 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -301,9 +301,9 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les return ( handleBlockComplete(block.id, score)} /> ); @@ -311,7 +311,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les return ( handleBlockComplete(block.id, score)} /> ); diff --git a/web/experience/src/components/blocks/HotspotPlayer.tsx b/web/experience/src/components/blocks/HotspotPlayer.tsx index 7aac35f..90d4b72 100644 --- a/web/experience/src/components/blocks/HotspotPlayer.tsx +++ b/web/experience/src/components/blocks/HotspotPlayer.tsx @@ -3,6 +3,7 @@ import React, { useState, useRef } from "react"; import Image from "next/image"; import { Search, CheckCircle, XCircle, MousePointer2 } from "lucide-react"; +import { getImageUrl } from "@/lib/api"; interface Hotspot { id: string; @@ -90,7 +91,7 @@ export default function HotspotPlayer({ className="relative aspect-video rounded-3xl overflow-hidden border-4 border-white/10 bg-black cursor-crosshair group select-none shadow-2xl" > {title} void; } @@ -30,9 +30,10 @@ export default function MemoryPlayer({ const initializeGame = useCallback(() => { const gameCards: MemoryCard[] = []; initialPairs.forEach((pair, idx) => { - // Add two of each - gameCards.push({ id: idx * 2, content: pair.content, pairId: pair.id, isFlipped: false, isMatched: false }); - gameCards.push({ id: idx * 2 + 1, content: pair.content, pairId: pair.id, isFlipped: false, isMatched: false }); + const pairId = pair.id || idx.toString(); + // Add two of each (Left and Right) + gameCards.push({ id: idx * 2, content: pair.left, pairId: pairId, isFlipped: false, isMatched: false }); + gameCards.push({ id: idx * 2 + 1, content: pair.right, pairId: pairId, isFlipped: false, isMatched: false }); }); // Shuffle diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 7d0d382..69d65e1 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -74,6 +74,15 @@ export interface Block { initialCode?: string; keywords?: string[]; timeLimit?: number; + description?: string; + imageUrl?: string; + hotspots?: { + id: string; + x: number; + y: number; + radius: number; + label: string; + }[]; metadata?: any; } diff --git a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx index ae9c831..fb1aecb 100644 --- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -13,6 +13,9 @@ import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock"; import DocumentBlock from "@/components/blocks/DocumentBlock"; import VideoMarkerBlock from "@/components/blocks/VideoMarkerBlock"; import AudioResponseBlock from "@/components/blocks/AudioResponseBlock"; +import HotspotBlock from "@/components/blocks/HotspotBlock"; +import MemoryBlock from "@/components/blocks/MemoryBlock"; +import Modal from "@/components/Modal"; import { Save, X, @@ -42,6 +45,10 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI const [dueDate, setDueDate] = useState(""); const [importantDateType, setImportantDateType] = useState(""); + const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false); + const [aiQuizContext, setAiQuizContext] = useState(""); + const [aiQuizType, setAiQuizType] = useState("multiple-choice"); + const [editValue, setEditValue] = useState(""); @@ -156,7 +163,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI } }; - const addBlock = (type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response') => { + const addBlock = (type: Block['type']) => { const newBlock: Block = { id: Math.random().toString(36).substr(2, 9), type, @@ -170,6 +177,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI ...(type === 'document' && { url: "", title: "" }), ...(type === 'video_marker' && { url: "", title: "Video Interactivo", markers: [] }), ...(type === 'audio-response' && { prompt: "Ask a question for the student to record their answer...", keywords: [], timeLimit: 60 }), + ...(type === 'hotspot' && { imageUrl: "", description: "Find the following items...", hotspots: [] }), + ...(type === 'memory-match' && { pairs: [{ id: "1", left: "Term A", right: "Match A" }] }), }; setBlocks([...blocks, newBlock]); }; @@ -206,11 +215,21 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI }; const handleGenerateQuiz = async () => { + setIsAIQuizModalOpen(true); + }; + + const handleConfirmGenerateQuiz = async (e: React.FormEvent) => { + e.preventDefault(); if (!lesson) return; + setIsAIQuizModalOpen(false); setIsGeneratingQuiz(true); try { - const newBlocks = await cmsApi.generateQuiz(lesson.id); + const newBlocks = await cmsApi.generateQuiz(lesson.id, { + context: aiQuizContext, + quiz_type: aiQuizType + }); setBlocks([...blocks, ...newBlocks]); + setAiQuizContext(""); } catch { alert("Failed to generate quiz."); } finally { @@ -598,6 +617,27 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI onChange={(updates) => updateBlock(block.id, updates)} /> )} + {block.type === 'hotspot' && ( + updateBlock(block.id, updates)} + /> + )} + {block.type === 'memory-match' && ( + updateBlock(block.id, updates)} + /> + )} ))} @@ -677,6 +717,20 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI 🎤 Audio + +
@@ -693,6 +747,76 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI )} + + !isGeneratingQuiz && setIsAIQuizModalOpen(false)} + title="AI Quiz Customization" + > +
+
+

+ Tell the AI what to focus on and what type of questions you prefer. +

+
+ +
+ +