diff --git a/README.md b/README.md index 5d43b09..0cc0249 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **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). + - **Auto Transcription**: Integración con Whisper para generación automática de transcripciones y evaluación precisa de voz. ## Requisitos del Sistema diff --git a/docker-compose.yml b/docker-compose.yml index 21fa435..79c19a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,8 @@ services: JWT_SECRET: openccb_secret_key_2025_production NEXT_PUBLIC_LMS_API_URL: http://localhost:3002 env_file: .env + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: - db diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index f653cb7..e058eb9 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -576,9 +576,29 @@ pub async fn create_lesson( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Trigger auto-transcription if it's a video or audio lesson with a URL + if (content_type == "video" || content_type == "audio") && content_url.is_some() { + trigger_transcription(pool, lesson.id).await; + } + Ok(Json(lesson)) } +async fn trigger_transcription(pool: PgPool, lesson_id: Uuid) { + // Set status to queued + let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1") + .bind(lesson_id) + .execute(&pool) + .await; + + // Spawn background task + tokio::spawn(async move { + if let Err(e) = run_transcription_task(pool, lesson_id).await { + tracing::error!("Auto-transcription task failed for lesson {}: {}", lesson_id, e); + } + }); +} + pub async fn process_transcription( Org(org_ctx): Org, claims: common::auth::Claims, @@ -646,7 +666,7 @@ pub async fn process_transcription( Ok(Json(updated_lesson)) } -async fn translate_text(text: &str, target_lang: &str) -> Result { +async fn _translate_text(text: &str, target_lang: &str) -> Result { let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let client = reqwest::Client::new(); @@ -1286,6 +1306,16 @@ pub async fn update_lesson( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Trigger auto-transcription if content URL was updated and it's a video/audio lesson + if let Some(url) = content_url { + if !url.is_empty() { + let c_type = content_type.unwrap_or(lesson.content_type.as_str()); + if c_type == "video" || c_type == "audio" { + trigger_transcription(pool, lesson.id).await; + } + } + } + Ok(Json(lesson)) } diff --git a/services/lms-service/migrations/20260122000000_fix_user_grades_updated_at.sql b/services/lms-service/migrations/20260122000000_fix_user_grades_updated_at.sql new file mode 100644 index 0000000..6ced5a2 --- /dev/null +++ b/services/lms-service/migrations/20260122000000_fix_user_grades_updated_at.sql @@ -0,0 +1,4 @@ +-- Migration: Add updated_at to user_grades +-- Required by fn_upsert_user_grade matching CMS-style upserts + +ALTER TABLE user_grades ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 870bb1b..4bc6ef2 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Path, Query, State}, + extract::{Path, Query, State, Multipart}, http::StatusCode, }; use bcrypt::{DEFAULT_COST, hash, verify}; @@ -991,8 +991,9 @@ pub async fn check_deadlines_and_notify(pool: PgPool) { 'deadline', '/courses/' || c.id || '/lessons/' || l.id FROM enrollments e - JOIN lessons l ON l.course_id = e.course_id - JOIN courses c ON c.id = l.course_id + JOIN courses c ON c.id = e.course_id + JOIN modules m ON m.course_id = c.id + JOIN lessons l ON l.module_id = m.id WHERE l.due_date BETWEEN NOW() AND NOW() + INTERVAL '24 hours' AND NOT EXISTS ( SELECT 1 FROM notifications n @@ -1054,7 +1055,7 @@ pub async fn update_user( } pub async fn get_recommendations( - Org(org_ctx): Org, + Org(_org_ctx): Org, claims: Claims, State(pool): State, Path(course_id): Path, @@ -1221,3 +1222,126 @@ pub async fn evaluate_audio_response( Ok(Json(grading)) } + +pub async fn evaluate_audio_file( + Org(_org_ctx): Org, + _claims: Claims, + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + let mut prompt = String::new(); + let mut keywords_str = String::new(); + let mut audio_data = Vec::new(); + let mut filename = "audio.webm".to_string(); + + while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { + let name = field.name().unwrap_or_default().to_string(); + match name.as_str() { + "prompt" => prompt = field.text().await.unwrap_or_default(), + "keywords" => keywords_str = field.text().await.unwrap_or_default(), + "file" => { + filename = field.file_name().unwrap_or("audio.webm").to_string(); + audio_data = field.bytes().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?.to_vec(); + }, + _ => {} + } + } + + if audio_data.is_empty() { + return Err((StatusCode::BAD_REQUEST, "No audio file provided".into())); + } + + // 1. Send to Whisper + let whisper_url = env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); + let client = reqwest::Client::new(); + + let form = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(audio_data).file_name(filename)) + .text("model", "whisper-1") + .text("response_format", "json"); + + let response = client.post(format!("{}/v1/audio/transcriptions", whisper_url)) + .multipart(form) + .send() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Whisper request failed: {}", e)))?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + tracing::error!("Whisper error: {}", err_body); + return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Whisper API error: {}", err_body))); + } + + let transcription_result: serde_json::Value = response.json().await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse Whisper response: {}", e)))?; + + let transcript = transcription_result["text"].as_str().unwrap_or("").to_string(); + + if transcript.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Whisper could not detect any speech. Please speak louder or check your mic.".into())); + } + + let keywords: Vec = if keywords_str.trim().starts_with('[') { + serde_json::from_str(&keywords_str).unwrap_or_default() + } else { + keywords_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + }; + + // 2. Perform AI Grading + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let (url, auth_header, model) = if provider == "local" { + let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string()); + (format!("{}/v1/chat/completions", base_url), "".to_string(), model) + } else { + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()), + "gpt-4-turbo".to_string(), + ) + }; + + let system_prompt = "You are an expert Teacher. Evaluate the student's spoken response transcript. \ + Compare it against the prompt and expected keywords. \ + Provide a score from 0 to 100. \ + Identify which keywords were used. \ + Give constructive feedback in Spanish about their pronunciation (based on the transcript quality) and content. \ + Return ONLY a JSON object: { \"score\": number, \"found_keywords\": [string], \"feedback\": string }."; + + let user_content = format!( + "Prompt: {}\nExpected Keywords: {:?}\nStudent Transcript: {}", + prompt, keywords, transcript + ); + + let response = client.post(&url) + .header("Content-Type", "application/json") + .header("Authorization", auth_header) + .json(&serde_json::json!({ + "model": model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_content } + ], + "response_format": { "type": "json_object" } + })) + .send() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let ai_data: serde_json::Value = response.json().await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI response parse failed: {}", e)))?; + + let grading: AudioGradingResponse = serde_json::from_value( + ai_data["choices"][0]["message"]["content"] + .as_str() + .and_then(|c| serde_json::from_str(c).ok()) + .unwrap_or_else(|| { + serde_json::json!({ + "score": 50, + "found_keywords": vec![] as Vec, + "feedback": "Lo siento, tuve un problema analizando tu respuesta con Whisper. ¡Sigue practicando!" + }) + }) + ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Mapping failed: {}", e)))?; + + Ok(Json(grading)) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index e1efa88..86150e1 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -80,6 +80,7 @@ async fn main() { get(handlers::get_lesson_heatmap), ) .route("/audio/evaluate", post(handlers::evaluate_audio_response)) + .route("/audio/evaluate-file", post(handlers::evaluate_audio_file)) .route("/notifications", get(handlers::get_notifications)) .route( "/notifications/{id}/read", diff --git a/web/experience/next.config.mjs b/web/experience/next.config.mjs index 608f1be..3571f98 100644 --- a/web/experience/next.config.mjs +++ b/web/experience/next.config.mjs @@ -1,6 +1,16 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'localhost', + port: '3001', + pathname: '/assets/**', + }, + ], + }, }; export default nextConfig; diff --git a/web/experience/src/app/courses/[id]/calendar/page.tsx b/web/experience/src/app/courses/[id]/calendar/page.tsx index 21c0a68..1bc572f 100644 --- a/web/experience/src/app/courses/[id]/calendar/page.tsx +++ b/web/experience/src/app/courses/[id]/calendar/page.tsx @@ -22,12 +22,12 @@ export default function StudentCalendarPage({ params }: { params: { id: string } useEffect(() => { const loadData = async () => { try { - const courseData = await lmsApi.getCourseOutline(params.id); - setCourse(courseData); + const { course, modules } = await lmsApi.getCourseOutline(params.id); + setCourse({ ...course, modules }); // Flatten lessons from modules const allLessons: Lesson[] = []; - courseData.modules?.forEach(mod => { + modules?.forEach(mod => { mod.lessons.forEach(lesson => { allLessons.push(lesson); }); @@ -74,8 +74,8 @@ export default function StudentCalendarPage({ params }: { params: { id: string }
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 72da0ea..f94b6c7 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -34,12 +34,12 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les useEffect(() => { const fetchAll = async () => { try { - const [lessonData, courseData] = await Promise.all([ + const [lessonData, outlineData] = await Promise.all([ lmsApi.getLesson(params.lessonId), lmsApi.getCourseOutline(params.id) ]); setLesson(lessonData); - setCourse(courseData); + setCourse({ ...outlineData.course, modules: outlineData.modules }); if (user) { const grades = await lmsApi.getUserGrades(user.id, params.id); @@ -84,11 +84,25 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les [blockId]: score }; + // Calculate overall lesson score as average of block scores + const blocks = lesson.metadata?.blocks || []; + const interactiveBlocks = blocks.filter((b: any) => + !['description', 'media', 'document'].includes(b.type) + ); + + const scores = interactiveBlocks.map((b: any) => + b.id === blockId ? score : currentBlockScores[b.id] || 0 + ); + + const newOverallScore = scores.length > 0 + ? (scores.reduce((a: number, b: number) => a + b, 0) / scores.length) * 100 + : 100; + const res = await lmsApi.submitScore( user.id, params.id, params.lessonId, - userGrade?.score || 0, // Keep overall score for now, or calculate average/sum + newOverallScore, { ...userGrade?.metadata, block_scores: newBlockScores } ); setUserGrade(res); @@ -220,6 +234,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les } }} isGraded={lesson.is_graded} + hasTranscription={!!lesson.transcription} /> ); case 'document': diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index b41c17b..11f94a3 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -14,7 +14,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } useEffect(() => { lmsApi.getCourseOutline(params.id) - .then(setCourseData) + .then(data => setCourseData({ ...data.course, modules: data.modules })) .catch(console.error) .finally(() => setLoading(false)); @@ -129,8 +129,8 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
Prioridad {rec.priority}
diff --git a/web/experience/src/app/courses/[id]/progress/page.tsx b/web/experience/src/app/courses/[id]/progress/page.tsx index 10a636f..5fb8858 100644 --- a/web/experience/src/app/courses/[id]/progress/page.tsx +++ b/web/experience/src/app/courses/[id]/progress/page.tsx @@ -32,8 +32,8 @@ export default function StudentProgressPage() { const loadData = React.useCallback(async () => { try { - const courseData = await lmsApi.getCourseOutline(id); - setCourse(courseData); + const { course, modules, grading_categories } = await lmsApi.getCourseOutline(id); + setCourse({ ...course, modules, grading_categories }); if (user) { const grades = await lmsApi.getUserGrades(user.id, id); diff --git a/web/experience/src/app/layout.tsx b/web/experience/src/app/layout.tsx index 68efdf4..6e29713 100644 --- a/web/experience/src/app/layout.tsx +++ b/web/experience/src/app/layout.tsx @@ -34,7 +34,7 @@ export default function RootLayout({

- Desarrollado por OpenCCB © 2023. Codificación Agente Avanzada. + Desarrollado Por el Departamento de Informática © 2026. OpenCCB.

diff --git a/web/experience/src/app/my-learning/page.tsx b/web/experience/src/app/my-learning/page.tsx index 562d96c..1fa70c2 100644 --- a/web/experience/src/app/my-learning/page.tsx +++ b/web/experience/src/app/my-learning/page.tsx @@ -38,14 +38,14 @@ export default function MyLearningPage() { const enrichedEnrollments: EnrollmentWithProgress[] = []; for (const enrollment of enrollmentData) { try { - const outline = await lmsApi.getCourseOutline(enrollment.course_id); + const { course, modules } = await lmsApi.getCourseOutline(enrollment.course_id); // TODO: Implement actual progress tracking // For now, show 0% progress for all courses const progress = 0; enrichedEnrollments.push({ - course: outline, + course: { ...course, modules }, progress, lastAccessed: enrollment.enroled_at }); diff --git a/web/experience/src/app/page.tsx b/web/experience/src/app/page.tsx index 139ab3a..ac227c7 100644 --- a/web/experience/src/app/page.tsx +++ b/web/experience/src/app/page.tsx @@ -35,11 +35,11 @@ export default function CatalogPage() { const deadlines: { lesson: Lesson, courseTitle: string, courseId: string }[] = []; for (const enrollment of enrollmentData) { try { - const outline = await lmsApi.getCourseOutline(enrollment.course_id); - outline.modules.forEach(mod => { + const { course, modules } = await lmsApi.getCourseOutline(enrollment.course_id); + modules.forEach(mod => { mod.lessons.forEach(l => { if (l.due_date && new Date(l.due_date) >= new Date()) { - deadlines.push({ lesson: l, courseTitle: outline.title, courseId: enrollment.course_id }); + deadlines.push({ lesson: l, courseTitle: course.title, courseId: enrollment.course_id }); } }); }); diff --git a/web/experience/src/components/blocks/AudioResponsePlayer.tsx b/web/experience/src/components/blocks/AudioResponsePlayer.tsx index 23f027b..dbc50da 100644 --- a/web/experience/src/components/blocks/AudioResponsePlayer.tsx +++ b/web/experience/src/components/blocks/AudioResponsePlayer.tsx @@ -135,14 +135,14 @@ export default function AudioResponsePlayer({ }; const evaluateResponse = async () => { - if (!transcript.trim()) { - alert("No speech detected. Please try recording again."); + if (!audioBlob) { + alert("No recording found. Please try again."); return; } setIsTranscribing(true); try { - const result = await lmsApi.evaluateAudio(transcript, prompt, keywords); + const result = await lmsApi.evaluateAudioFile(audioBlob, prompt, keywords); setEvaluation({ score: result.score, foundKeywords: result.found_keywords, @@ -153,9 +153,9 @@ export default function AudioResponsePlayer({ if (onComplete) { onComplete(result.score, transcript); } - } catch (err) { + } catch (err: any) { console.error("Evaluation failed", err); - alert("Evaluation failed. Please try again."); + alert(err.message || "Evaluation failed. Please try again."); } finally { setIsTranscribing(false); } diff --git a/web/experience/src/components/blocks/HotspotPlayer.tsx b/web/experience/src/components/blocks/HotspotPlayer.tsx index 90d4b72..4a08738 100644 --- a/web/experience/src/components/blocks/HotspotPlayer.tsx +++ b/web/experience/src/components/blocks/HotspotPlayer.tsx @@ -94,6 +94,7 @@ export default function HotspotPlayer({ src={getImageUrl(imageUrl)} alt={title} fill + unoptimized className="object-cover transition-transform duration-700 group-hover:scale-[1.02]" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" /> diff --git a/web/experience/src/components/blocks/MediaPlayer.tsx b/web/experience/src/components/blocks/MediaPlayer.tsx index fc08dcf..8894fa4 100644 --- a/web/experience/src/components/blocks/MediaPlayer.tsx +++ b/web/experience/src/components/blocks/MediaPlayer.tsx @@ -12,6 +12,7 @@ interface MediaPlayerProps { media_type: 'video' | 'audio'; config?: { maxPlays?: number; + show_transcript?: boolean; markers?: { timestamp: number; question: string; @@ -176,10 +177,10 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf } }} > - {hasTranscription && vttEn && ( + {hasTranscription && config?.show_transcript !== false && vttEn && ( )} - {hasTranscription && vttEs && ( + {hasTranscription && config?.show_transcript !== false && vttEs && ( )} diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 69d65e1..53ef715 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -209,7 +209,7 @@ export const lmsApi = { return apiFetch(`/catalog${query}`); }, - async getCourseOutline(courseId: string): Promise { + async getCourseOutline(courseId: string): Promise<{ course: Course, modules: Module[], grading_categories: GradingCategory[], organization: Organization }> { return apiFetch(`/courses/${courseId}/outline`); }, @@ -321,5 +321,26 @@ export const lmsApi = { method: 'POST', body: JSON.stringify({ transcript, prompt, keywords }) }); + }, + async evaluateAudioFile(file: Blob, prompt: string, keywords: string[]): Promise { + const formData = new FormData(); + formData.append('file', file, 'recorded_audio.webm'); + formData.append('prompt', prompt); + formData.append('keywords', JSON.stringify(keywords)); + + const token = getToken(); + return fetch(`${API_BASE_URL}/audio/evaluate-file`, { + method: 'POST', + headers: { + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + }, + body: formData + }).then(async res => { + if (!res.ok) { + const err = await res.json().catch(() => ({ message: 'Audio evaluation failed' })); + throw new Error(err.message || 'Audio evaluation failed'); + } + return res.json(); + }); } }; diff --git a/web/studio/src/components/blocks/HotspotBlock.tsx b/web/studio/src/components/blocks/HotspotBlock.tsx index de33e88..5a4be6b 100644 --- a/web/studio/src/components/blocks/HotspotBlock.tsx +++ b/web/studio/src/components/blocks/HotspotBlock.tsx @@ -87,7 +87,7 @@ export default function HotspotBlock({
{imageUrl ? ( - {title + {title ) : (
No image provided.
)} @@ -140,7 +140,7 @@ export default function HotspotBlock({ ) : (
- Hotspot base + Hotspot base
@@ -164,8 +164,8 @@ export default function HotspotBlock({ onClick={handleImageClick} className="relative aspect-video rounded-2xl overflow-hidden border-2 border-white/10 cursor-crosshair shadow-2xl" > - Define Hotspots - {hotspots.map((h, idx) => ( + Define Hotspots + {hotspots.map((h) => (
-
+
); } diff --git a/web/studio/src/components/blocks/MemoryBlock.tsx b/web/studio/src/components/blocks/MemoryBlock.tsx index 7c6a8ad..70d44e2 100644 --- a/web/studio/src/components/blocks/MemoryBlock.tsx +++ b/web/studio/src/components/blocks/MemoryBlock.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState } from "react"; import { Brain, Plus, Trash2, HelpCircle } from "lucide-react"; interface MatchingPair {