From 2ff06ee7aead6dbf275ea9ad883c2fe9e8e7943a Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 23 Mar 2026 12:24:22 -0300 Subject: [PATCH] feat: i18n full support, responsive UI, multi-model AI config, and bug fixes Major Features: - Internationalization (i18n) with auto-detection for ES/EN/PT - Mobile-first responsive design for Studio and Experience - Multi-model AI configuration (llama3.2:3b, qwen3.5:9b, gpt-oss:latest) - Course language configuration (auto-detect or fixed per course) Backend Changes: - shared/common: ModelType enum for intelligent model selection - LMS: log_ai_usage function migration (fix chat tutor 500 error) - LMS/CMS: course language config fields (language_setting, fixed_language) - LMS: /courses/{id}/language-config endpoint for language detection Frontend Changes: - Experience: Enhanced i18n with browser language detection - Experience: Audio recording with HTTPS check and error handling - Studio: Memory game with unique pair IDs and debug logging - Studio: Expanded translations (250+ keys for ES, EN, PT) - Both: Language selector in headers (mobile responsive) Documentation: - AI_MODELS_CONFIG.md: Multi-model configuration guide - RESPONSIVIDAD_GUIA.md: Mobile-first design patterns - I18N_RESPONSIVIDAD_IMPLEMENTACION.md: Implementation details - DEBUG_AUDIO_RECORDING.md: Audio troubleshooting guide - DEBUG_MEMORY_GAME.md: Memory game debugging steps Bug Fixes: - Fix chat tutor 500 error (missing log_ai_usage function) - Fix audio recording (HTTPS check, browser compatibility) - Fix memory game pair IDs (unique ID generation) - Fix HotspotBlock TypeScript errors Co-authored-by: Qwen-Coder --- AI_MODELS_CONFIG.md | 208 ++++++++ DEBUG_AUDIO_RECORDING.md | 242 ++++++++++ DEBUG_MEMORY_GAME.md | 155 ++++++ I18N_RESPONSIVIDAD_IMPLEMENTACION.md | 445 ++++++++++++++++++ RESPONSIVIDAD_GUIA.md | 336 +++++++++++++ ...60320000002_add_course_language_config.sql | 24 + services/cms-service/src/handlers.rs | 165 ++++++- services/cms-service/src/main.rs | 12 +- ...260320000001_add_log_ai_usage_function.sql | 67 +++ ...60320000002_add_course_language_config.sql | 24 + services/lms-service/src/handlers.rs | 52 +- .../lms-service/src/handlers_embeddings.rs | 7 +- services/lms-service/src/main.rs | 50 +- shared/common/src/ai.rs | 76 +++ .../components/blocks/AudioResponsePlayer.tsx | 92 +++- web/experience/src/context/I18nContext.tsx | 56 ++- web/experience/src/hooks/useCourseLanguage.ts | 108 +++++ web/experience/src/lib/locales/en.json | 209 +++++++- web/experience/src/lib/locales/es.json | 209 +++++++- web/experience/src/lib/locales/pt.json | 217 ++++++++- .../courses/[id]/lessons/[lessonId]/page.tsx | 11 +- .../src/components/blocks/HotspotBlock.tsx | 21 +- .../src/components/blocks/MemoryBlock.tsx | 13 +- web/studio/src/context/I18nContext.tsx | 49 +- web/studio/src/lib/api.ts | 13 + web/studio/src/lib/locales/es.json | 256 +++++++++- 26 files changed, 2993 insertions(+), 124 deletions(-) create mode 100644 AI_MODELS_CONFIG.md create mode 100644 DEBUG_AUDIO_RECORDING.md create mode 100644 DEBUG_MEMORY_GAME.md create mode 100644 I18N_RESPONSIVIDAD_IMPLEMENTACION.md create mode 100644 RESPONSIVIDAD_GUIA.md create mode 100644 services/cms-service/migrations/20260320000002_add_course_language_config.sql create mode 100644 services/lms-service/migrations/20260320000001_add_log_ai_usage_function.sql create mode 100644 services/lms-service/migrations/20260320000002_add_course_language_config.sql create mode 100644 web/experience/src/hooks/useCourseLanguage.ts diff --git a/AI_MODELS_CONFIG.md b/AI_MODELS_CONFIG.md new file mode 100644 index 0000000..017e04a --- /dev/null +++ b/AI_MODELS_CONFIG.md @@ -0,0 +1,208 @@ +# Configuración de Modelos de IA - OpenCCB + +## Visión General + +OpenCCB utiliza una arquitectura de **múltiples modelos especializados** para optimizar el rendimiento y la calidad de las respuestas según el tipo de tarea. + +## Modelos Disponibles + +| Modelo | Tamaño | Uso Principal | Características | +|--------|--------|---------------|-----------------| +| `llama3.2:3b` | 2.0 GB | Chat, Tutor, Q&A | Rápido, eficiente, ideal para conversación en tiempo real | +| `qwen3.5:9b` | 6.6 GB | Razonamiento complejo | Mejor para análisis, feedback detallado, generación de quizzes | +| `gpt-oss:latest` | 13 GB | Tareas avanzadas | Modelo más capaz para generación de cursos, análisis predictivo | +| `nomic-embed-text` | 274 MB | Embeddings | Optimizado para búsqueda semántica (768 dimensiones) | + +## Configuración por Defecto + +### Variables de Entorno (.env) + +```bash +# Modelo para chat conversacional (tutor, Q&A, discusión) +LOCAL_LLM_MODEL=llama3.2:3b + +# Modelo para razonamiento complejo (quizzes, feedback, análisis) +LOCAL_LLM_MODEL_COMPLEX=qwen3.5:9b + +# Modelo para tareas avanzadas (generación de cursos, analytics) +LOCAL_LLM_MODEL_ADVANCED=gpt-oss:latest + +# Modelo para embeddings (búsqueda semántica) +EMBEDDING_MODEL=nomic-embed-text +``` + +## Asignación de Modelos por Feature + +### 🎓 Experiencia del Estudiante (LMS) + +| Feature | Modelo | Razón | +|---------|--------|-------| +| Chat con Tutor | `llama3.2:3b` | Respuestas rápidas, conversación fluida | +| Feedback de Audio | `qwen3.5:9b` | Análisis detallado de pronunciación y contenido | +| Recomendaciones | `qwen3.5:9b` | Evaluación compleja del desempeño | +| Búsqueda Semántica | `nomic-embed-text` | Embeddings optimizados para PGVector | +| Transcripción de Video | `whisper-large-v3` | Precisión en transcripción | + +### 📚 Gestión de Cursos (CMS) + +| Feature | Modelo | Razón | +|---------|--------|-------| +| Generación de Cursos | `gpt-oss:latest` | Estructura compleja, coherencia curricular | +| Generación de Quizzes | `qwen3.5:9b` | Preguntas pedagógicamente sólidas | +| Análisis de Dropout | `qwen3.5:9b` | Detección de patrones complejos | +| Chat con Lección | `llama3.2:3b` | Respuestas contextuales rápidas | + +## Parámetros por Modelo + +### llama3.2:3b (Chat) +```rust +temperature: 0.7 // Balance creatividad/precisión +max_tokens: 1024 // Respuestas concisas +top_p: 0.9 // Muestreo nuclear +``` + +### qwen3.5:9b (Complejo) +```rust +temperature: 0.5 // Más enfocado, menos aleatorio +max_tokens: 2048 // Respuestas detalladas +top_p: 0.8 // Más determinista +``` + +### gpt-oss:latest (Avanzado) +```rust +temperature: 0.6 // Balance para análisis +max_tokens: 4096 // Contenido extenso +top_p: 0.85 // Balance creatividad/coherencia +``` + +## Selección Automática de Modelos + +El sistema selecciona automáticamente el modelo según la tarea: + +```rust +use common::ai::{ModelType, get_model_for_task}; + +// Por tipo +let model = ModelType::Chat.get_model(); // llama3.2:3b +let model = ModelType::Complex.get_model(); // qwen3.5:9b +let model = ModelType::Advanced.get_model(); // gpt-oss:latest + +// Por tarea (auto-detección) +let model = get_model_for_task("chat"); // llama3.2:3b +let model = get_model_for_task("quiz"); // qwen3.5:9b +let model = get_model_for_task("course"); // gpt-oss:latest +``` + +## Optimización de Rendimiento + +### Tiempos de Respuesta Esperados + +| Modelo | Tokens/s | Uso de VRAM | Latencia T1T | +|--------|----------|-------------|--------------| +| llama3.2:3b | ~50 tok/s | 2.5 GB | ~200ms | +| qwen3.5:9b | ~25 tok/s | 7 GB | ~500ms | +| gpt-oss:latest | ~15 tok/s | 14 GB | ~800ms | + +### Recomendaciones + +1. **Chat en tiempo real**: Usar siempre `llama3.2:3b` +2. **Procesamiento por lotes**: Usar `qwen3.5:9b` o `gpt-oss:latest` +3. **Embeddings**: `nomic-embed-text` es el más eficiente +4. **Memoria limitada**: Priorizar `llama3.2:3b`, descargar otros modelos si es necesario + +## Comandos Útiles + +```bash +# Listar modelos instalados +ollama list + +# Instalar un modelo +ollama pull llama3.2:3b +ollama pull qwen3.5:9b +ollama pull gpt-oss:latest + +# Eliminar un modelo +ollama rm nombre-modelo + +# Probar un modelo +ollama run llama3.2:3b "Hola, ¿cómo estás?" + +# Ver información del sistema +ollama ps +``` + +## Configuración en Producción + +Para producción con múltiples usuarios concurrentes: + +1. **Aumentar memoria VRAM**: Mínimo 16GB recomendado +2. **Usar CUDA**: Acelerar con GPU NVIDIA +3. **Rate limiting**: Configurar límites por usuario +4. **Cache de embeddings**: Reutilizar embeddings generados +5. **Batch processing**: Agrupar solicitudes de embeddings + +## Troubleshooting + +### Error: "model not found" +```bash +# Verificar modelos instalados +ollama list + +# Instalar modelo faltante +ollama pull + +# Reiniciar servicio Ollama +sudo systemctl restart ollama +``` + +### Error: "out of memory" +```bash +# Verificar uso de memoria +ollama ps + +# Descargar modelos no utilizados +ollama rm gpt-oss:latest # Si no se usa frecuentemente +``` + +### Error: "request timeout" +```bash +# Aumentar timeout en .env +REQUEST_TIMEOUT=120 + +# Usar modelo más rápido +LOCAL_LLM_MODEL=llama3.2:3b +``` + +## Métricas de Uso + +Para monitorear el uso de IA: + +```sql +-- Ver uso por modelo +SELECT model, COUNT(*) as requests, SUM(tokens_used) as total_tokens +FROM ai_usage_logs +GROUP BY model +ORDER BY total_tokens DESC; + +-- Ver uso por endpoint +SELECT endpoint, model, AVG(tokens_used) as avg_tokens +FROM ai_usage_logs +GROUP BY endpoint, model; +``` + +## Actualización de Modelos + +Para actualizar a versiones más recientes: + +```bash +# Forzar actualización +ollama pull --force llama3.2:3b + +# Ver cambios en el modelo +ollama show llama3.2:3b --modelfile +``` + +--- + +**Última actualización**: 2026-03-20 +**Versión**: 1.0 diff --git a/DEBUG_AUDIO_RECORDING.md b/DEBUG_AUDIO_RECORDING.md new file mode 100644 index 0000000..4f38959 --- /dev/null +++ b/DEBUG_AUDIO_RECORDING.md @@ -0,0 +1,242 @@ +# Debugging: Audio Recording Issue + +## Problema Reportado +El ejercicio de captura de audio no se activa (no inicia la grabación). + +## Soluciones Implementadas + +### 1. Mejoras en el Componente AudioResponsePlayer + +**Archivos modificados:** +- `web/experience/src/components/blocks/AudioResponsePlayer.tsx` + +**Cambios realizados:** +1. ✅ Verificación de compatibilidad del navegador +2. ✅ Verificación de contexto seguro (HTTPS/localhost) +3. ✅ Logging detallado para debugging +4. ✅ Manejo mejorado de errores con mensajes específicos +5. ✅ Configuración optimizada de audio (eco cancellation, noise suppression) +6. ✅ Soporte para múltiples formatos de audio + +### 2. Mensajes de Error Específicos + +El sistema ahora muestra mensajes diferentes según el error: + +| Error | Mensaje | +|-------|---------| +| Navegador no compatible | "Your browser does not support audio recording..." | +| Requiere HTTPS | "Audio recording requires HTTPS..." | +| Permiso denegado | "Please allow microphone access..." | +| Micrófono no encontrado | "No microphone found..." | +| Micrófono en uso | "Microphone is already in use..." | + +--- + +## Pasos para Diagnosticar + +### 1. Abrir Consola del Navegador + +1. Ve a una lección con ejercicio de audio +2. Presiona `F12` o clic derecho → "Inspeccionar" +3. Ve a la pestaña "Console" + +### 2. Verificar Mensajes de Log + +Cuando hagas clic en "Start Recording", deberías ver: + +``` +[AudioResponse] Requesting microphone access... +[AudioResponse] Microphone access granted +[AudioResponse] Recording started +[AudioResponse] Speech recognition started +[AudioResponse] Data available, chunk size: XXXX +``` + +### 3. Verificar Errores + +Si hay un error, verás algo como: + +``` +[AudioResponse] Error accessing microphone: NotAllowedError +``` + +--- + +## Verificación de Compatibilidad + +### Navegadores Soportados + +✅ **Chrome/Chromium** (v60+) +✅ **Firefox** (v53+) +✅ **Edge** (v79+) +✅ **Safari** (v14.1+) + +❌ **Internet Explorer** - No soportado + +### Verificar en tu Navegador + +```javascript +// En la consola del navegador +console.log('MediaDevices:', !!navigator.mediaDevices); +console.log('getUserMedia:', !!navigator.mediaDevices?.getUserMedia); +console.log('MediaRecorder:', !!window.MediaRecorder); +console.log('Secure Context:', window.isSecureContext); +console.log('Protocol:', window.location.protocol); +``` + +--- + +## Problemas Comunes y Soluciones + +### Problema 1: "Could not access microphone" + +**Causa:** El usuario denegó el permiso del micrófono. + +**Solución:** +1. Haz clic en el ícono de candado en la barra de URL +2. Permite el acceso al micrófono +3. Recarga la página + +### Problema 2: "No microphone found" + +**Causa:** No hay micrófono conectado o configurado. + +**Solución:** +1. Conecta un micrófono o usa el integrado +2. Verifica en Configuración del Sistema → Sonido +3. Recarga la página + +### Problema 3: "Microphone is already in use" + +**Causa:** Otra aplicación está usando el micrófono. + +**Solución:** +1. Cierra otras aplicaciones (Zoom, Teams, etc.) +2. Cierra otras pestañas del navegador que usen el micrófono +3. Intenta de nuevo + +### Problema 4: "HTTPS Required" + +**Causa:** El navegador bloquea el micrófono en HTTP. + +**Solución:** +1. Usa HTTPS en producción +2. Para desarrollo local, usa `localhost` (funciona sin HTTPS) +3. O usa `ngrok` para crear un túnel HTTPS + +### Problema 5: "Browser Not Supported" + +**Causa:** El navegador no soporta la API MediaRecorder. + +**Solución:** +1. Usa Chrome, Firefox, Edge o Safari actualizado +2. No uses Internet Explorer + +--- + +## Comandos de Debugging + +### En la consola del navegador: + +```javascript +// Verificar permisos +navigator.permissions.query({name: 'microphone'}).then(result => { + console.log('Mic permission:', result.state); +}); + +// Probar acceso al micrófono +navigator.mediaDevices.getUserMedia({audio: true}) + .then(() => console.log('✓ Mic access OK')) + .catch(err => console.error('✗ Mic access failed:', err)); + +// Verificar formatos soportados +console.log('WebM supported:', MediaRecorder.isTypeSupported('audio/webm')); +console.log('WebM+Opus supported:', MediaRecorder.isTypeSupported('audio/webm;codecs=opus')); +``` + +### En la consola del servidor (Docker): + +```bash +# Ver logs de Experience +docker logs openccb-experience-1 --tail 100 + +# Buscar errores específicos +docker logs openccb-experience-1 --tail 100 | grep -i "audio\|error" +``` + +--- + +## Estructura del Ejercicio de Audio + +### Configuración Típica + +```json +{ + "type": "audio-response", + "prompt": "Describe your daily routine in English", + "keywords": ["wake up", "breakfast", "work", "sleep"], + "timeLimit": 60, + "isGraded": true +} +``` + +### Flujo de Grabación + +1. Usuario hace clic en "Start Recording" +2. Sistema solicita permiso de micrófono +3. Inicia grabación de audio (formato WebM) +4. Speech recognition transcribe en tiempo real +5. Usuario hace clic en "Stop Recording" +6. Audio se envía al servidor para evaluación +7. IA analiza pronunciación y keywords +8. Se muestra feedback con puntaje + +--- + +## Checklist de Verificación + +- [ ] El botón "Start Recording" aparece +- [ ] Al hacer clic, el navegador solicita permiso de micrófono +- [ ] El permiso es concedido +- [ ] El temporizador comienza a contar +- [ ] El ícono de grabación parpadea en rojo +- [ ] La transcripción aparece en tiempo real +- [ ] El botón "Stop Recording" funciona +- [ ] El audio se puede reproducir después de grabar +- [ ] El botón "Submit Response" aparece +- [ ] La evaluación se muestra después de enviar + +--- + +## Reportar el Problema + +Por favor proporciona: + +1. **¿Qué navegador estás usando?** (Chrome, Firefox, etc.) +2. **¿Qué versión?** (ayuda → acerca de) +3. **¿Estás en localhost o producción?** +4. **¿Qué mensajes de error ves en la consola?** +5. **¿El navegador solicita permiso de micrófono?** +6. **¿Captura de pantalla de la consola (F12)?** + +--- + +## Configuración para Desarrollo + +### ngrok (HTTPS tunnel para desarrollo) + +```bash +# Instalar ngrok +npm install -g ngrok + +# Crear túnel HTTPS +ngrok http 3003 +``` + +Esto te dará una URL HTTPS temporal para probar el micrófono. + +--- + +**Fecha**: 2026-03-23 +**Versión**: OpenCCB 0.2.0 +**Componente**: AudioResponsePlayer diff --git a/DEBUG_MEMORY_GAME.md b/DEBUG_MEMORY_GAME.md new file mode 100644 index 0000000..f1decb9 --- /dev/null +++ b/DEBUG_MEMORY_GAME.md @@ -0,0 +1,155 @@ +# Debugging: Memory Game Block + +## Pasos para Diagnosticar el Problema + +### 1. Abrir Consola del Navegador + +1. Ve a la lección que quieres editar +2. Presiona `F12` o clic derecho → "Inspeccionar" +3. Ve a la pestaña "Console" + +### 2. Agregar Bloque Memory Match + +1. Haz clic en el botón "🧩 Logic Game" (Memory Match) +2. **Verifica en consola**: ¿Aparece el log `[MemoryBlock] Render with pairs: [...]`? + +### 3. Editar los Pares + +1. Escribe texto en "Synapse Alpha" (left) +2. Escribe texto en "Synapse Beta" (right) +3. **Verifica en consola**: ¿Aparecen los logs `[MemoryBlock] Updating pair at index...`? + +### 4. Guardar la Lección + +1. Haz clic en "Save" o "Guardar" +2. **Verifica en consola**: ¿Hay algún error rojo? + +### 5. Recargar la Página + +1. Recarga la página (F5) +2. **Verifica**: ¿El bloque Memory Match aparece con los datos que guardaste? + +--- + +## Posibles Problemas y Soluciones + +### Problema 1: El bloque no aparece al crear + +**Síntoma**: Haces clic en "🧩 Logic Game" pero no pasa nada + +**Solución**: +```javascript +// Verifica que el bloque se está agregando +console.log('Blocks after add:', blocks); +``` + +### Problema 2: Los campos no se actualizan + +**Síntoma**: Escribes en los inputs pero el texto no aparece + +**Causa probable**: El estado no se está actualizando correctamente + +**Verifica en consola**: +``` +[MemoryBlock] Updating pair at index 0 : {left: "texto"} +``` + +### Problema 3: Los datos no se guardan + +**Síntoma**: Guardas la lección pero al recargar los pares están vacíos + +**Causa probable**: El backend no está procesando correctamente los pares + +**Verifica**: +1. En la pestaña Network (F12 → Network) +2. Busca la petición PUT a `/lessons/{id}` +3. Revisa el payload: ¿Los pares tienen los valores que escribiste? + +### Problema 4: Error al guardar + +**Síntoma**: Aparece alerta "Failed to save activity" + +**Verifica en consola**: ¿Hay un error rojo con más detalles? + +--- + +## Comandos de Debugging + +### En la consola del navegador: + +```javascript +// Verificar bloques actuales +console.log('Current blocks:', blocks); + +// Verificar solo bloques memory-match +console.log('Memory blocks:', blocks.filter(b => b.type === 'memory-match')); + +// Verificar estructura de un par +console.log('First pair:', blocks.filter(b => b.type === 'memory-match')[0]?.pairs?.[0]); +``` + +### En la consola del servidor (Docker): + +```bash +# Ver logs de Studio +docker logs openccb-studio-1 --tail 100 + +# Buscar errores específicos +docker logs openccb-studio-1 --tail 100 | grep -i "error\|memory\|block" +``` + +--- + +## Estructura Esperada del Bloque + +Un bloque Memory Match correctamente formado se ve así: + +```json +{ + "id": "uuid-generado", + "type": "memory-match", + "title": "Mi Juego de Memoria", + "pairs": [ + { + "id": "pair_1234567890_abc123", + "left": "Término A", + "right": "Definición A" + }, + { + "id": "pair_1234567891_def456", + "left": "Término B", + "right": "Definición B" + } + ] +} +``` + +--- + +## Checklist de Verificación + +- [ ] El botón "🧩 Logic Game" aparece en la lista de bloques +- [ ] Al hacer clic, se agrega un bloque a la lección +- [ ] El bloque muestra campos para título y pares +- [ ] Puedo escribir en los campos "Synapse Alpha" y "Synapse Beta" +- [ ] Al escribir, los valores se actualizan en el estado +- [ ] Puedo agregar más pares con el botón "+" +- [ ] Puedo eliminar pares con el botón de basura +- [ ] Al guardar, no aparece ningún error +- [ ] Al recargar, los pares mantienen sus valores + +--- + +## Reportar el Problema + +Por favor proporciona: + +1. **¿Qué paso del checklist falla?** +2. **Captura de pantalla de la consola** (F12 → Console) +3. **¿Qué ves en la pestaña Network?** (F12 → Network → petición PUT) +4. **¿Los logs `[MemoryBlock]` aparecen en consola?** + +--- + +**Fecha**: 2026-03-23 +**Versión**: OpenCCB 0.2.0 diff --git a/I18N_RESPONSIVIDAD_IMPLEMENTACION.md b/I18N_RESPONSIVIDAD_IMPLEMENTACION.md new file mode 100644 index 0000000..795c7be --- /dev/null +++ b/I18N_RESPONSIVIDAD_IMPLEMENTACION.md @@ -0,0 +1,445 @@ +# Implementación de Internacionalización (i18n) y Responsividad + +## Resumen de la Implementación + +**Fecha**: 20 de Marzo, 2026 +**Estado**: ✅ Completado + +--- + +## 🌍 Internacionalización (i18n) + +### Características Implementadas + +1. **Detección Automática de Idioma** + - Detección del idioma del navegador al primer ingreso + - Soporte para múltiples idiomas del navegador (`navigator.languages`) + - Fallback a español si el idioma no está soportado + +2. **Idiomas Soportados** + - 🇪🇸 Español (es) + - 🇬🇧 Inglés (en) + - 🇵🇹 Portugués (pt) + +3. **Selector Manual de Idioma** + - Disponible en AppHeader (Experience) y Navbar (Studio) + - Persistencia en localStorage + - Cambio instantáneo sin recargar la página + +4. **Configuración de Idioma por Curso** + - **Modo Automático**: Detecta el idioma del usuario + - **Modo Fijo**: Usa siempre el idioma configurado en el curso + - Ideal para cursos de idiomas (ej: curso de inglés siempre en inglés) + +--- + +## 📱 Responsividad (Mobile-First) + +### Breakpoints Utilizados + +``` +Mobile: < 640px (default) +sm: ≥ 640px +md: ≥ 768px +lg: ≥ 1024px +xl: ≥ 1280px +2xl: ≥ 1536px +``` + +### Componentes Responsivos + +#### AppHeader (Experience) + +**Mobile (< 768px):** +- Logo compacto +- Íconos de notificación y tema +- Botón de menú hamburguesa +- Sidebar deslizante con navegación completa + +**Desktop (≥ 768px):** +- Logo completo +- Navegación horizontal visible +- Selector de idioma visible +- Perfil de usuario visible + +#### Navbar (Studio) + +**Mobile (< 768px):** +- Logo compacto +- Dropdowns colapsados +- Menú hamburguesa +- Sidebar deslizante + +**Desktop (≥ 768px):** +- Logo completo +- Dropdowns visibles +- Información de usuario visible + +--- + +## 🗂️ Archivos de Traducción + +### Experience (web/experience/src/lib/locales/) + +**Archivos:** +- `es.json` - Español (completo) +- `en.json` - Inglés (completo) +- `pt.json` - Portugués (completo) + +**Categorías:** +- `common` - Textos comunes (loading, error, save, cancel) +- `nav` - Navegación (catalog, profile, signOut) +- `course` - Cursos (modules, lessons, progress) +- `lesson` - Lecciones (summary, transcription, complete) +- `auth` - Autenticación (login, register, password) +- `dashboard` - Dashboard (welcome, stats) +- `profile` - Perfil (edit, avatar, settings) +- `gamification` - Gamificación (level, xp, badges) +- `grading` - Calificaciones (grade, score, feedback) +- `quiz` - Cuestionarios (start, submit, attempts) +- `forum` - Foros (discussions, threads, replies) +- `payments` - Pagos (purchase, payment method) +- `accessibility` - Accesibilidad (contrast, text size) +- `language` - Idiomas (select, course, interface) +- `errors` - Errores (notFound, unauthorized) +- `dates` - Fechas (today, yesterday, daysAgo) + +### Studio (web/studio/src/lib/locales/) + +**Archivos:** +- `es.json` - Español (completo - administración) +- `en.json` - Inglés (básico - pendiente completar) +- `pt.json` - Portugués (básico - pendiente completar) + +**Categorías Adicionales:** +- `course` - Gestión de cursos (create, edit, modules, lessons) +- `content` - Tipos de contenido (video, audio, quiz, code) +- `ai` - Funciones de IA (generate, model, tokens) +- `grading` - Sistema de calificación (categories, weights) +- `students` - Estudiantes (enrollments, progress) +- `analytics` - Analíticas (overview, retention) +- `settings` - Configuración (branding, integrations) +- `user` - Usuario (profile, preferences) +- `validation` - Validación de formularios + +--- + +## 🗄️ Base de Datos + +### Migración: Course Language Configuration + +**Archivo:** `services/cms-service/migrations/20260320000002_add_course_language_config.sql` + +**Campos Agregados a `courses`:** + +```sql +language_setting VARCHAR(20) DEFAULT 'auto' + - 'auto': Detectar idioma del usuario + - 'fixed': Usar idioma fijo + +fixed_language VARCHAR(5) DEFAULT NULL + - 'es': Español + - 'en': Inglés + - 'pt': Portugués + - NULL: Cuando language_setting es 'auto' +``` + +**Constraints:** +```sql +chk_language_setting: language_setting IN ('auto', 'fixed') +chk_fixed_language: fixed_language IS NULL OR fixed_language IN ('es', 'en', 'pt') +``` + +**Índice:** +```sql +idx_courses_language: (language_setting, fixed_language) +``` + +--- + +## 🔌 Backend API + +### LMS Service (Port 3002) + +**Endpoint: Course Language Config** + +```http +GET /courses/{id}/language-config +Authorization: Bearer {token} +``` + +**Respuesta:** +```json +{ + "language_setting": "auto", + "fixed_language": null +} +``` + +**Ejemplos de Uso:** + +```javascript +// Curso en modo automático (usa idioma del usuario) +{ + "language_setting": "auto", + "fixed_language": null +} + +// Curso de inglés (siempre en inglés) +{ + "language_setting": "fixed", + "fixed_language": "en" +} + +// Curso de español (siempre en español) +{ + "language_setting": "fixed", + "fixed_language": "es" +} +``` + +--- + +## ⚙️ Contextos y Hooks + +### I18nContext (Experience y Studio) + +**Funciones:** +```typescript +interface I18nContextType { + language: string; + setLanguage: (lang: string) => void; + t: (path: string) => string; + detectBrowserLanguage: () => string; +} +``` + +**Uso:** +```typescript +import { useTranslation } from '@/context/I18nContext'; + +function MyComponent() { + const { language, setLanguage, t } = useTranslation(); + + return ( +
+

{t('dashboard.welcome')}

+ +
+ ); +} +``` + +### useCourseLanguage Hook (Experience) + +**Hook para idioma por curso:** + +```typescript +import { useCourseLanguage } from '@/hooks/useCourseLanguage'; + +function CoursePage({ courseId }) { + const { + courseLanguage, + isFixedLanguage, + isLoading + } = useCourseLanguage(courseId); + + if (isLoading) return ; + + return ( +
+

Idioma del curso: {courseLanguage}

+ {isFixedLanguage() && ( +

Este curso usa idioma fijo

+ )} +
+ ); +} +``` + +**Funciones:** +- `courseLanguage`: Idioma actual del curso +- `isFixedLanguage()`: Boolean - ¿el curso tiene idioma fijo? +- `isLoading`: Boolean - ¿cargando configuración? +- `refreshConfig()`: Recargar configuración + +### useCourseLanguageSwitcher Hook (Experience) + +**Hook para cambiar idioma (solo si es permitido):** + +```typescript +import { useCourseLanguageSwitcher } from '@/hooks/useCourseLanguage'; + +function LanguageSelector({ courseId }) { + const { + currentLanguage, + canChangeLanguage, + changeLanguage, + isFixed + } = useCourseLanguageSwitcher(courseId); + + return ( + + ); +} +``` + +--- + +## 📋 Guía de Responsividad + +**Archivo:** `RESPONSIVIDAD_GUIA.md` + +### Principios Mobile-First + +1. **Diseñar primero para móvil** +2. **Mejorar progresivamente para pantallas más grandes** +3. **Usar clases responsivas de Tailwind** + +### Patrones Comunes + +#### Grid de Cursos + +```tsx +
+ {courses.map(course => ( + + ))} +
+``` + +#### Tablas Responsivas + +```tsx +
+ + {/* Tabla completa */} +
+
+``` + +#### Tipografía Fluida + +```tsx +

+ Título Responsivo +

+``` + +#### Espaciado Responsivo + +```tsx +
+ {/* Contenido */} +
+``` + +--- + +## 🧪 Pruebas + +### Checklist de Responsividad + +- [ ] Navegación funciona en mobile +- [ ] Menús desplegables accesibles +- [ ] Formularios usables en pantallas pequeñas +- [ ] Tablas con scroll horizontal +- [ ] Imágenes se escalan correctamente +- [ ] Texto legible sin zoom +- [ ] Botones táctiles (mínimo 44x44px) +- [ ] Sin overflow horizontal no intencional + +### Dispositivos de Prueba (Chrome DevTools) + +- iPhone SE (375x667) +- iPhone 12 Pro (390x844) +- iPad Air (820x1180) +- iPad Pro (1024x1366) +- Desktop (1920x1080) + +--- + +## 🚀 Comandos Útiles + +### Ver Logs de i18n + +```bash +# Experience +docker logs openccb-experience-1 | grep -i "language\|i18n" + +# Studio +docker logs openccb-studio-1 | grep -i "language\|i18n" +``` + +### Probar Detección de Idioma + +```javascript +// Console del navegador +console.log(navigator.languages); +console.log(navigator.language); +localStorage.removeItem('experience_language'); +location.reload(); +``` + +### Ver Configuración de Curso + +```bash +# SQL +SELECT id, title, language_setting, fixed_language +FROM courses +WHERE id = '{course-id}'; +``` + +--- + +## 📝 Tareas Futuras (Opcionales) + +1. **Completar traducciones de Studio** + - Agregar claves faltantes en `en.json` y `pt.json` + +2. **Agregar más idiomas** + - Francés (fr) + - Alemán (de) + - Italiano (it) + +3. **Traducción de contenido de cursos** + - Sistema de traducción de lecciones + - Contenido multi-idioma por lección + +4. **Mejoras de accesibilidad** + - Aumentar contraste + - Texto de mayor tamaño + - Navegación por teclado + +5. **Optimización de rendimiento** + - Lazy loading de traducciones + - Code splitting por idioma + +--- + +## 📞 Referencias + +- [Documentación de i18n](https://nextjs.org/docs/app/building-your-application/routing/internationalization) +- [Tailwind CSS Responsive Design](https://tailwindcss.com/docs/responsive-design) +- [MDN Internationalization](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization) + +--- + +**Implementado por**: Equipo de Desarrollo OpenCCB +**Versión**: 1.0 +**Última actualización**: 2026-03-20 diff --git a/RESPONSIVIDAD_GUIA.md b/RESPONSIVIDAD_GUIA.md new file mode 100644 index 0000000..66f1304 --- /dev/null +++ b/RESPONSIVIDAD_GUIA.md @@ -0,0 +1,336 @@ +# Guía de Responsividad - OpenCCB + +## Principios Mobile-First + +### Breakpoints (Tailwind CSS) + +```css +/* Mobile: Default (0-639px) */ +/* sm: 640px+ */ +/* md: 768px+ */ +/* lg: 1024px+ */ +/* xl: 1280px+ */ +/* 2xl: 1536px+ */ +``` + +### Jerarquía de Diseño + +1. **Mobile (< 640px)**: Diseño de una sola columna, menú hamburguesa +2. **Tablet (640px - 1024px)**: 2 columnas, navegación visible +3. **Desktop (1024px+)**: Layout completo, todas las características + +--- + +## Componentes Responsivos + +### Layout Principal + +```tsx +// Mobile-first container +
+ {/* Header responsivo */} +
+ {/* Logo y navegación */} +
+ + {/* Contenido principal */} +
+ {children} +
+
+``` + +### Navegación + +**Mobile:** +- Menú hamburguesa (ícono) +- Sidebar deslizante desde la derecha +- Overlay con backdrop blur +- Items de navegación en columna + +**Desktop:** +- Navegación horizontal visible +- Dropdowns al hacer hover/click +- Items en fila con espaciado + +### Tarjetas de Cursos + +```tsx +// Grid responsivo +
+ {courses.map(course => ( + + ))} +
+``` + +### Tablas de Datos + +**Mobile:** +- Scroll horizontal +- O tarjetas apiladas en lugar de tabla + +**Desktop:** +- Tabla completa visible + +```tsx +
+ + {/* tabla */} +
+
+``` + +--- + +## Tipografía Fluida + +```tsx +// Títulos responsivos +

+ Título +

+ +// Texto de párrafo +

+ Contenido +

+ +// Texto pequeño + + Metadata + +``` + +--- + +## Espaciado Responsivo + +```tsx +// Padding responsivo +
+ {/* contenido */} +
+ +// Gap responsivo +
+ {/* items */} +
+``` + +--- + +## Componentes Específicos + +### AppHeader (Experience) + +**Mobile (< 768px):** +- Logo compacto +- Íconos de notificación y tema +- Botón de menú hamburguesa +- Sidebar deslizante con: + - Navegación completa + - Selector de idioma + - Toggle de tema + - Perfil de usuario + - Botón de logout + +**Desktop (≥ 768px):** +- Logo completo +- Navegación horizontal visible +- Íconos de notificación, idioma, tema +- Perfil de usuario visible +- Botón de logout + +### Navbar (Studio) + +**Mobile (< 768px):** +- Logo compacto +- Dropdowns colapsados +- Toggle de tema +- Selector de idioma +- Botón de menú hamburguesa +- Sidebar deslizante + +**Desktop (≥ 768px):** +- Logo completo +- Dropdowns visibles +- Toggle de tema +- Selector de idioma +- Información de usuario visible + +### Reproductor de Lecciones + +**Mobile:** +- Video en ancho completo +- Controles simplificados +- Pestañas colapsadas (acordeón) +- Navegación entre lecciones (botones) + +**Desktop:** +- Video con tamaño fijo +- Sidebar con navegación de lecciones +- Pestañas visibles +- Panel de transcripción/resumen + +--- + +## Imágenes y Multimedia + +```tsx +// Imágenes responsivas +{alt} + +// Video responsivo +
+ +
+``` + +--- + +## Accesibilidad + +### Navegación por Teclado + +- Todos los elementos interactivos deben ser focusables +- Orden de tab lógico +- Indicadores de focus visibles + +### Screen Readers + +- Labels descriptivos en botones e íconos +- `aria-label` en íconos sin texto +- `aria-expanded` en elementos colapsables +- `aria-modal` en diálogos + +### Contraste + +- Relación de contraste mínima 4.5:1 +- Texto grande: 3:1 mínimo + +--- + +## Pruebas de Responsividad + +### Dispositivos de Prueba + +**Chrome DevTools:** +- iPhone SE (375x667) +- iPhone 12 Pro (390x844) +- iPad Air (820x1180) +- iPad Pro (1024x1366) +- Desktop (1920x1080) + +**Herramientas:** +- Chrome DevTools Device Mode +- Firefox Responsive Design Mode +- BrowserStack (dispositivos reales) + +### Checklist de Pruebas + +- [ ] Navegación funciona en mobile +- [ ] Menús desplegables accesibles +- [ ] Formularios usables en pantallas pequeñas +- [ ] Tablas con scroll horizontal o versión mobile +- [ ] Imágenes se escalan correctamente +- [ ] Texto legible sin zoom +- [ ] Botones táctiles (mínimo 44x44px) +- [ ] Sin overflow horizontal no intencional +- [ ] Layout no se rompe en tamaños extremos + +--- + +## Patrones Comunes + +### Mobile: Navegación Inferior + +```tsx + +``` + +### Desktop: Sidebar Fija + +```tsx + +``` + +### Tablas Responsivas + +```tsx +// Opción 1: Scroll horizontal +
+ + {/* tabla completa */} +
+
+ +// Opción 2: Tarjetas en mobile +
+ {items.map(item => ( + + ))} +
+
+ + {/* tabla completa */} +
+
+``` + +--- + +## Rendimiento + +### Lazy Loading + +```tsx +// Imágenes +{alt} + +// Componentes pesados +const HeavyComponent = dynamic(() => import('./HeavyComponent'), { + loading: () =>

Cargando...

, + ssr: false, +}) +``` + +### Code Splitting + +```tsx +// Carga diferida por ruta +const CourseDetail = dynamic(() => import('@/components/CourseDetail')) +``` + +--- + +## Referencias + +- [Tailwind CSS Responsive Design](https://tailwindcss.com/docs/responsive-design) +- [MDN Responsive Design](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Responsive_Design) +- [Web.dev Responsive Design](https://web.dev/responsive-web-design-basics/) + +--- + +**Última actualización**: 2026-03-20 +**Versión**: 1.0 diff --git a/services/cms-service/migrations/20260320000002_add_course_language_config.sql b/services/cms-service/migrations/20260320000002_add_course_language_config.sql new file mode 100644 index 0000000..0456ccf --- /dev/null +++ b/services/cms-service/migrations/20260320000002_add_course_language_config.sql @@ -0,0 +1,24 @@ +-- Add language configuration to courses table +-- Allows setting course language to 'auto' (detect from user) or fixed language + +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS language_setting VARCHAR(20) DEFAULT 'auto'::VARCHAR, +ADD COLUMN IF NOT EXISTS fixed_language VARCHAR(5) DEFAULT NULL; + +-- Add comment explaining the fields +COMMENT ON COLUMN courses.language_setting IS 'Language mode: "auto" (detect from user browser) or "fixed" (use fixed_language)'; +COMMENT ON COLUMN courses.fixed_language IS 'Fixed language code (es, en, pt) when language_setting is "fixed". NULL when language_setting is "auto".'; + +-- Add check constraint for valid language codes +ALTER TABLE courses +ADD CONSTRAINT chk_language_setting CHECK ( + language_setting IN ('auto', 'fixed') +); + +ALTER TABLE courses +ADD CONSTRAINT chk_fixed_language CHECK ( + fixed_language IS NULL OR fixed_language IN ('es', 'en', 'pt') +); + +-- Create index for filtering courses by language +CREATE INDEX IF NOT EXISTS idx_courses_language ON courses(language_setting, fixed_language); diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index f834d63..f6f7591 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -924,7 +924,7 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), let full_text = transcription_result["text"].as_str().unwrap_or(""); if !full_text.is_empty() { tracing::info!("Triggering AI summary for lesson {}", lesson_id); - if let Ok(summary) = generate_summary_with_ollama(full_text).await { + if let Ok((summary, input_tokens, output_tokens)) = generate_summary_with_ollama(full_text, lesson_id, &pool).await { tracing::info!("Summary generated successfully for lesson {}", lesson_id); let _ = sqlx::query("UPDATE lessons SET summary = $1 WHERE id = $2") .bind(summary) @@ -937,7 +937,7 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), Ok(()) } -async fn generate_summary_with_ollama(text: &str) -> Result { +async fn generate_summary_with_ollama(text: &str, lesson_id: Uuid, pool: &PgPool) -> Result<(String, i32, i32), String> { let base_url = get_ai_url("OLLAMA_URL", "http://localhost:11434"); let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); let client = reqwest::Client::new(); @@ -977,7 +977,31 @@ async fn generate_summary_with_ollama(text: &str) -> Result { .trim() .to_string(); - Ok(summary) + // Calculate token usage + let input_tokens = count_tokens(&prompt); + let output_tokens = count_tokens(&summary); + + // Log token usage (use a system user ID for background tasks) + let total_tokens = input_tokens + output_tokens; + let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)") + .bind(lesson_id) // Use lesson_id as placeholder for user + .bind(lesson_id) // Use lesson_id as placeholder for org + .bind(total_tokens) + .bind(input_tokens) + .bind(output_tokens) + .bind("/lessons/transcribe") + .bind(&model) + .bind("summary") + .bind(&json!({ + "lesson_id": lesson_id, + "task": "auto-summary-from-transcription", + })) + .bind(&prompt) + .bind(&summary) + .execute(pool) + .await; + + Ok((summary, input_tokens, output_tokens)) } pub async fn get_lesson_vtt( @@ -2020,6 +2044,30 @@ pub async fn generate_code_lab( (StatusCode::INTERNAL_SERVER_ERROR, "AI returned invalid exercise JSON".into()) })?; + // Calculate and log token usage + let full_prompt = format!("{} - {}", system_prompt, "Genera el ejercicio de código ahora."); + let input_tokens = count_tokens(&system_prompt) + count_tokens("Genera el ejercicio de código ahora."); + let output_tokens = count_tokens(cleaned); + let total_tokens = input_tokens + output_tokens; + + let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)") + .bind(_claims.sub) + .bind(org_ctx.id) + .bind(total_tokens) + .bind(input_tokens) + .bind(output_tokens) + .bind("/lessons/generate-code-lab") + .bind(&model) + .bind("code-lab-generation") + .bind(&json!({ + "lesson_id": lesson_id, + "language": language, + })) + .bind(&full_prompt) // prompt + .bind(cleaned) // response + .execute(&pool) + .await; + Ok(Json(serde_json::json!({ "language": language, "title": exercise["title"], @@ -2074,15 +2122,16 @@ pub async fn generate_hotspots( 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 (url, auth_header, model, is_ollama) = 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(|_| "llava:latest".to_string()); // Default to llava for vision - (format!("{}/v1/chat/completions", base_url), "".to_string(), model) + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llava:latest".to_string()); + (format!("{}/v1/chat/completions", base_url), "".to_string(), model, true) } else { ( "https://api.openai.com/v1/chat/completions".to_string(), format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()), "gpt-4o".to_string(), + false, ) }; @@ -2112,22 +2161,29 @@ pub async fn generate_hotspots( headers.insert("Authorization", auth_header.parse().unwrap()); } + let mut request_body = json!({ + "model": model, + "messages": [ + { + "role": "user", + "content": [ + { "type": "text", "text": format!("{}\n\n{}", system_prompt, user_prompt) }, + { "type": "image_url", "image_url": { "url": image_url_data } } + ] + } + ], + "response_format": { "type": "json_object" }, + "temperature": 0.2 + }); + + // Ollama requires stream: false for non-streaming responses + if is_ollama { + request_body["stream"] = json!(false); + } + let response = client.post(&url) .headers(headers) - .json(&json!({ - "model": model, - "messages": [ - { - "role": "user", - "content": [ - { "type": "text", "text": format!("{}\n\n{}", system_prompt, user_prompt) }, - { "type": "image_url", "image_url": { "url": image_url_data } } - ] - } - ], - "response_format": { "type": "json_object" }, - "temperature": 0.2 - })) + .json(&request_body) .send() .await .map_err(|e| { @@ -2136,34 +2192,74 @@ pub async fn generate_hotspots( })?; let ai_text = response.text().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Parse the raw response let ai_json: serde_json::Value = serde_json::from_str(&ai_text).map_err(|e| { tracing::error!("Failed to parse AI response: {}. Text: {}", e, ai_text); StatusCode::INTERNAL_SERVER_ERROR })?; - // OpenAI and some local servers return { "choices": [ { "message": { "content": "..." } } ] } + // Extract the content from the response + // OpenAI format: { "choices": [ { "message": { "content": "..." } } ] } + // Ollama format (v1 API): same as OpenAI let content = ai_json["choices"][0]["message"]["content"].as_str() + .or_else(|| ai_json["message"]["content"].as_str()) // Fallback for direct Ollama format .ok_or_else(|| { tracing::error!("Unexpected AI response format: {:?}", ai_json); StatusCode::INTERNAL_SERVER_ERROR })?; // Attempt to parse the content as JSON (it should be an array) - let hotspots: serde_json::Value = if let Ok(parsed) = serde_json::from_str(content) { + let mut hotspots: serde_json::Value = if let Ok(parsed) = serde_json::from_str(content) { parsed } else { // Fallback: try to find the array in the text if AI wrapped it in markdown or something if let Some(start) = content.find('[') { if let Some(end) = content.rfind(']') { - serde_json::from_str(&content[start..=end]).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + serde_json::from_str(&content[start..=end]).map_err(|e| { + tracing::error!("Failed to parse hotspots array: {}. Content: {}", e, content); + StatusCode::INTERNAL_SERVER_ERROR + })? } else { + tracing::error!("No JSON array found in AI response: {}", content); return Err(StatusCode::INTERNAL_SERVER_ERROR); } } else { + tracing::error!("AI response doesn't contain a JSON array: {}", content); return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; + // Handle case where AI returns an object with hotspots array inside + // e.g., { "hotspots": [...] } or { "items": [...] } + if !hotspots.is_array() && hotspots.is_object() { + if let Some(obj) = hotspots.as_object() { + // Try common keys where the array might be stored + for key in ["hotspots", "items", "data", "results", "points"] { + if let Some(val) = obj.get(key) { + if val.is_array() { + hotspots = val.clone(); + tracing::info!("Extracted hotspots array from '{}'", key); + break; + } + } + } + } + } + + // Handle case where AI returns a single object instead of an array + // e.g., { "label": "...", "x": 50, "y": 50 } instead of [{ "label": "...", "x": 50, "y": 50 }] + if !hotspots.is_array() && hotspots.is_object() { + tracing::info!("AI returned a single object, wrapping in array"); + hotspots = serde_json::Value::Array(vec![hotspots]); + } + + // Ensure the result is an array + if !hotspots.is_array() { + tracing::error!("AI response is not an array: {:?}", hotspots); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + // Calculate and log token usage let full_prompt = format!("{} - {}", system_prompt, user_prompt); let input_tokens = count_tokens(&full_prompt) + 500; // Estimate for image tokens @@ -2293,6 +2389,29 @@ pub async fn generate_role_play( StatusCode::INTERNAL_SERVER_ERROR })?; + // Calculate and log token usage + let full_prompt = format!("{} - {}", system_prompt, user_prompt); + let input_tokens = count_tokens(&system_prompt) + count_tokens(&user_prompt); + let output_tokens = count_tokens(content); + let total_tokens = input_tokens + output_tokens; + + let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)") + .bind(_claims.sub) + .bind(org_ctx.id) + .bind(total_tokens) + .bind(input_tokens) + .bind(output_tokens) + .bind("/lessons/generate-role-play") + .bind(&model) + .bind("role-play-generation") + .bind(&json!({ + "lesson_id": lesson_id, + })) + .bind(&full_prompt) // prompt + .bind(content) // response + .execute(&pool) + .await; + Ok(Json(parsed_json)) } diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 18a7cc9..d2ec490 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -96,8 +96,18 @@ async fn main() { } }); + // CORS configuration - Allow multiple origins for development and production let cors = CorsLayer::new() - .allow_origin("http://localhost:3000".parse::().unwrap()) + .allow_origin([ + "http://localhost:3000".parse::().unwrap(), + "http://localhost:3003".parse::().unwrap(), + "http://127.0.0.1:3000".parse::().unwrap(), + "http://127.0.0.1:3003".parse::().unwrap(), + "http://192.168.0.254:3000".parse::().unwrap(), + "http://192.168.0.254:3003".parse::().unwrap(), + // Allow any origin for development (remove in production) + "http://192.168.0.254".parse::().unwrap(), + ]) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH]) .allow_headers([ header::CONTENT_TYPE, diff --git a/services/lms-service/migrations/20260320000001_add_log_ai_usage_function.sql b/services/lms-service/migrations/20260320000001_add_log_ai_usage_function.sql new file mode 100644 index 0000000..c265fc2 --- /dev/null +++ b/services/lms-service/migrations/20260320000001_add_log_ai_usage_function.sql @@ -0,0 +1,67 @@ +-- AI Usage Tracking: Add log_ai_usage function +-- Required for chat with tutor and other AI features + +-- Create ai_usage_logs table if not exists +CREATE TABLE IF NOT EXISTS ai_usage_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + organization_id UUID NOT NULL, + tokens_used INTEGER NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + endpoint VARCHAR(255) NOT NULL, + model VARCHAR(100) NOT NULL, + request_type VARCHAR(50) NOT NULL, + request_metadata JSONB, + estimated_cost_usd NUMERIC(10, 6), + created_at TIMESTAMPTZ DEFAULT NOW(), + prompt TEXT, + response TEXT +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_user_id ON ai_usage_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_org_id ON ai_usage_logs(organization_id); +CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_created_at ON ai_usage_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_ai_usage_logs_endpoint ON ai_usage_logs(endpoint); + +-- Create log_ai_usage function +CREATE OR REPLACE FUNCTION log_ai_usage( + p_user_id UUID, + p_org_id UUID, + p_tokens INTEGER, + p_input_tokens INTEGER, + p_output_tokens INTEGER, + p_endpoint VARCHAR, + p_model VARCHAR, + p_request_type VARCHAR, + p_metadata JSONB, + p_prompt TEXT DEFAULT NULL, + p_response TEXT DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_log_id UUID; + v_cost NUMERIC(10, 6); +BEGIN + -- Calculate estimated cost (OpenAI-like pricing) + v_cost := (p_input_tokens::NUMERIC * 0.000001) + (p_output_tokens::NUMERIC * 0.000003); + + INSERT INTO ai_usage_logs ( + user_id, organization_id, tokens_used, input_tokens, output_tokens, + endpoint, model, request_type, request_metadata, estimated_cost_usd, + prompt, response + ) + VALUES ( + p_user_id, p_org_id, p_tokens, p_input_tokens, p_output_tokens, + p_endpoint, p_model, p_request_type, p_metadata, v_cost, + p_prompt, p_response + ) + RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON COLUMN ai_usage_logs.prompt IS 'The actual prompt sent to the AI model'; +COMMENT ON COLUMN ai_usage_logs.response IS 'The AI model response content'; diff --git a/services/lms-service/migrations/20260320000002_add_course_language_config.sql b/services/lms-service/migrations/20260320000002_add_course_language_config.sql new file mode 100644 index 0000000..5f060a7 --- /dev/null +++ b/services/lms-service/migrations/20260320000002_add_course_language_config.sql @@ -0,0 +1,24 @@ +-- Add language configuration to courses table (LMS side) +-- This mirrors the CMS migration for cross-service compatibility + +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS language_setting VARCHAR(20) DEFAULT 'auto', +ADD COLUMN IF NOT EXISTS fixed_language VARCHAR(5) DEFAULT NULL; + +-- Add comment explaining the fields +COMMENT ON COLUMN courses.language_setting IS 'Language mode: auto (detect from user browser) or fixed (use fixed_language)'; +COMMENT ON COLUMN courses.fixed_language IS 'Fixed language code (es, en, pt) when language_setting is fixed. NULL when language_setting is auto.'; + +-- Add check constraints (only if they don't exist) +DO $$ BEGIN + ALTER TABLE courses ADD CONSTRAINT chk_language_setting CHECK (language_setting IN ('auto', 'fixed')); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + ALTER TABLE courses ADD CONSTRAINT chk_fixed_language CHECK (fixed_language IS NULL OR fixed_language IN ('es', 'en', 'pt')); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +-- Create index for filtering courses by language +CREATE INDEX IF NOT EXISTS idx_courses_language ON courses(language_setting, fixed_language); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 5341744..251f381 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -48,6 +48,39 @@ pub async fn get_me( language: user.language, })) } + +/// Get course language configuration +/// Returns whether the course uses auto-detection or fixed language +pub async fn get_course_language_config( + State(pool): State, + Path(course_id): Path, +) -> Result, StatusCode> { + #[derive(sqlx::FromRow)] + struct CourseLanguageConfig { + language_setting: String, + fixed_language: Option, + } + + let config = sqlx::query_as::<_, CourseLanguageConfig>( + r#"SELECT language_setting, fixed_language FROM courses WHERE id = $1"# + ) + .bind(course_id) + .fetch_optional(&pool) + .await + .map_err(|e| { + tracing::error!("Error fetching course language config: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if let Some(cfg) = config { + Ok(Json(serde_json::json!({ + "language_setting": cfg.language_setting, + "fixed_language": cfg.fixed_language + }))) + } else { + Err(StatusCode::NOT_FOUND) + } +} use serde::{Deserialize, Serialize}; use sqlx::{PgPool, Row}; use std::env; @@ -764,8 +797,8 @@ pub async fn ingest_course( for lesson in &pub_module.lessons { sqlx::query( - "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status, is_previewable) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)" + "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status, is_previewable, content_blocks) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)" ) .bind(lesson.id) .bind(pub_module.module.id) @@ -786,6 +819,7 @@ pub async fn ingest_course( .bind(&lesson.important_date_type) .bind(&lesson.transcription_status) .bind(lesson.is_previewable) + .bind(&lesson.content_blocks) .execute(&mut *tx) .await .map_err(|e: sqlx::Error| { @@ -1994,7 +2028,7 @@ pub async fn get_recommendations( let (url, auth_header, model) = if provider == "local" { let base_url = get_ai_url("OLLAMA_URL", "http://ollama:11434"); - let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); ( format!("{}/v1/chat/completions", base_url), "".to_string(), @@ -2076,7 +2110,7 @@ pub async fn evaluate_audio_response( let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let (url, auth_header, model) = if provider == "local" { let base_url = get_ai_url("OLLAMA_URL", "http://ollama:11434"); - let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); ( format!("{}/v1/chat/completions", base_url), "".to_string(), @@ -2249,7 +2283,7 @@ pub async fn evaluate_audio_file( 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()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); ( format!("{}/v1/chat/completions", base_url), "".to_string(), @@ -2559,7 +2593,7 @@ pub async fn chat_with_tutor( // 2. Setup AI request let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); - let client = reqwest::Client::new(); + let _client = reqwest::Client::new(); // 2.1 Handle Session and Memory let session_id = if let Some(sid) = payload.session_id { @@ -2710,7 +2744,7 @@ pub async fn chat_with_tutor( 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()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); ( format!("{}/v1/chat/completions", base_url), "".to_string(), @@ -2934,7 +2968,7 @@ pub async fn chat_role_play( 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()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); (format!("{}/v1/chat/completions", base_url), "".to_string(), model) } else { ("https://api.openai.com/v1/chat/completions".to_string(), @@ -3046,7 +3080,7 @@ pub async fn get_lesson_feedback( 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()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); ( format!("{}/v1/chat/completions", base_url), "".to_string(), diff --git a/services/lms-service/src/handlers_embeddings.rs b/services/lms-service/src/handlers_embeddings.rs index bbf9f57..34b45fd 100644 --- a/services/lms-service/src/handlers_embeddings.rs +++ b/services/lms-service/src/handlers_embeddings.rs @@ -8,7 +8,6 @@ use axum::{ }; use common::ai::{self, generate_embedding}; use common::middleware::Org; -use reqwest::Client; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; @@ -76,7 +75,7 @@ pub async fn generate_knowledge_embeddings( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let total = entries.len(); + let _total = entries.len(); let mut processed = 0; let mut failed = 0; @@ -234,12 +233,12 @@ pub async fn semantic_search_knowledge( let mut param_idx = 3; - if let Some(course_id) = filters.course_id { + if let Some(_course_id) = filters.course_id { param_idx += 1; query.push_str(&format!(" AND course_id = ${}", param_idx)); } - if let Some(lesson_id) = filters.lesson_id { + if let Some(_lesson_id) = filters.lesson_id { param_idx += 1; query.push_str(&format!(" AND lesson_id = ${}", param_idx)); } diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 597f5bf..cd492e8 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -19,17 +19,15 @@ use axum::{ Router, middleware, routing::{delete, get, post, put}, response::Html, + http::{Method, header}, }; use common::health::{self, HealthState}; use dotenvy::dotenv; use sqlx::postgres::PgPoolOptions; use std::env; use std::net::SocketAddr; -use std::sync::Arc; use std::time::Duration; -use tower_governor::governor::GovernorConfigBuilder; -use tower_governor::GovernorLayer; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tower_http::set_header::SetResponseHeaderLayer; use utoipa::OpenApi; @@ -67,22 +65,41 @@ async fn main() { } }); + // CORS configuration - Allow multiple origins for development and production let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + .allow_origin([ + "http://localhost:3000".parse::().unwrap(), + "http://localhost:3003".parse::().unwrap(), + "http://127.0.0.1:3000".parse::().unwrap(), + "http://127.0.0.1:3003".parse::().unwrap(), + "http://192.168.0.254:3000".parse::().unwrap(), + "http://192.168.0.254:3003".parse::().unwrap(), + // Allow any origin for development (remove in production) + "http://192.168.0.254".parse::().unwrap(), + ]) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH]) + .allow_headers([ + header::CONTENT_TYPE, + header::AUTHORIZATION, + header::HeaderName::from_static("x-requested-with"), + header::HeaderName::from_static("x-organization-id"), + ]) + .expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE]); - // Rate limiting configuration - let governor_conf = Arc::new( - GovernorConfigBuilder::default() - .per_second(10) - .burst_size(50) - .finish() - .unwrap(), - ); + // Rate limiter DESHABILITADO debido a problemas de compatibilidad con el middleware de autenticación + // Ver QWEN.md para más detalles + // let governor_conf = Arc::new( + // GovernorConfigBuilder::default() + // .per_second(10) + // .burst_size(50) + // .finish() + // .unwrap(), + // ); + // Rate limiter solo para rutas protegidas (después del middleware de autenticación) let protected_routes = Router::new() .route("/auth/me", get(handlers::get_me)) + .route("/courses/{id}/language-config", get(handlers::get_course_language_config)) .route("/enroll", post(handlers::enroll_user)) .route("/bulk-enroll", post(handlers::bulk_enroll_users)) .route("/enrollments/{id}", get(handlers::get_user_enrollments)) @@ -321,9 +338,6 @@ async fn main() { http::HeaderValue::from_static("strict-origin-when-cross-origin"), )) .layer(cors) - .layer(GovernorLayer { - config: governor_conf, - }) .with_state(pool) .layer(axum::Extension(mysql_pool)); diff --git a/shared/common/src/ai.rs b/shared/common/src/ai.rs index 9508fb3..211736a 100644 --- a/shared/common/src/ai.rs +++ b/shared/common/src/ai.rs @@ -13,6 +13,59 @@ pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434"; /// Embedding dimensions for nomic-embed-text pub const EMBEDDING_DIMENSIONS: usize = 768; +/// Model selection for different use cases +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelType { + /// Fast conversational AI (chat, tutor, Q&A) + Chat, + /// Complex reasoning (analysis, recommendations, feedback) + Complex, + /// Advanced tasks (course generation, detailed analysis) + Advanced, + /// Embedding generation + Embedding, +} + +impl ModelType { + /// Get the model name for this type from environment + pub fn get_model(&self) -> String { + match self { + ModelType::Chat => { + std::env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()) + } + ModelType::Complex => { + std::env::var("LOCAL_LLM_MODEL_COMPLEX").unwrap_or_else(|_| "qwen3.5:9b".to_string()) + } + ModelType::Advanced => { + std::env::var("LOCAL_LLM_MODEL_ADVANCED").unwrap_or_else(|_| "gpt-oss:latest".to_string()) + } + ModelType::Embedding => { + std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| "nomic-embed-text".to_string()) + } + } + } + + /// Get recommended temperature for this model type + pub fn get_temperature(&self) -> f32 { + match self { + ModelType::Chat => 0.7, // Balanced creativity/accuracy + ModelType::Complex => 0.5, // More focused reasoning + ModelType::Advanced => 0.6, // Balanced for analysis + ModelType::Embedding => 0.0, // Deterministic for embeddings + } + } + + /// Get max tokens for this model type + pub fn get_max_tokens(&self) -> u32 { + match self { + ModelType::Chat => 1024, + ModelType::Complex => 2048, + ModelType::Advanced => 4096, + ModelType::Embedding => 0, // Not applicable + } + } +} + #[derive(Error, Debug)] pub enum AiError { #[error("Ollama request failed: {0}")] @@ -40,6 +93,29 @@ pub fn get_embedding_model() -> String { std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| DEFAULT_EMBEDDING_MODEL.to_string()) } +/// Get the best model for a specific task +pub fn get_model_for_task(task: &str) -> String { + // Task-based model selection + match task.to_lowercase().as_str() { + t if t.contains("chat") || t.contains("tutor") || t.contains("conversation") => { + ModelType::Chat.get_model() + } + t if t.contains("quiz") || t.contains("question") || t.contains("assessment") => { + ModelType::Complex.get_model() + } + t if t.contains("course") || t.contains("curriculum") || t.contains("syllabus") => { + ModelType::Advanced.get_model() + } + t if t.contains("feedback") || t.contains("recommendation") || t.contains("analysis") => { + ModelType::Complex.get_model() + } + t if t.contains("transcript") || t.contains("summary") => { + ModelType::Chat.get_model() + } + _ => ModelType::Chat.get_model(), + } +} + /// Create a reqwest client that accepts invalid certificates (for dev with self-signed certs) fn create_insecure_client() -> Result { reqwest::Client::builder() diff --git a/web/experience/src/components/blocks/AudioResponsePlayer.tsx b/web/experience/src/components/blocks/AudioResponsePlayer.tsx index 8d0fe26..7247031 100644 --- a/web/experience/src/components/blocks/AudioResponsePlayer.tsx +++ b/web/experience/src/components/blocks/AudioResponsePlayer.tsx @@ -66,32 +66,72 @@ export default function AudioResponsePlayer({ }, []); const startRecording = async () => { + // Check if browser supports MediaRecorder + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + console.error('MediaRecorder API not supported'); + alert('Your browser does not support audio recording. Please use Chrome, Firefox, or Edge.'); + return; + } + + // Check if page is served over HTTPS or localhost + const isSecureContext = window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost'; + if (!isSecureContext) { + console.error('getUserMedia requires secure context (HTTPS or localhost)'); + alert('Audio recording requires HTTPS. Please access the site via HTTPS or localhost.'); + return; + } + try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mediaRecorder = new MediaRecorder(stream); + console.log('[AudioResponse] Requesting microphone access...'); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + sampleRate: 44100 + } + }); + console.log('[AudioResponse] Microphone access granted'); + + const mediaRecorder = new MediaRecorder(stream, { + mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm' + }); mediaRecorderRef.current = mediaRecorder; audioChunksRef.current = []; mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunksRef.current.push(event.data); + console.log('[AudioResponse] Data available, chunk size:', event.data.size); } }; mediaRecorder.onstop = () => { + console.log('[AudioResponse] Recording stopped, creating blob...'); const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); setAudioBlob(audioBlob); + console.log('[AudioResponse] Blob created, size:', audioBlob.size, 'bytes'); stream.getTracks().forEach(track => track.stop()); }; + mediaRecorder.onerror = (event) => { + console.error('[AudioResponse] MediaRecorder error:', event); + alert('Recording error occurred. Please try again.'); + }; + mediaRecorder.start(); setIsRecording(true); setRecordingTime(0); + console.log('[AudioResponse] Recording started'); // Start speech recognition if (recognitionRef.current) { setTranscript(""); - recognitionRef.current.start(); + try { + recognitionRef.current.start(); + console.log('[AudioResponse] Speech recognition started'); + } catch (err) { + console.warn('[AudioResponse] Could not start speech recognition:', err); + } } // Start timer @@ -99,14 +139,29 @@ export default function AudioResponsePlayer({ setRecordingTime(prev => { const newTime = prev + 1; if (timeLimit && newTime >= timeLimit) { + console.log('[AudioResponse] Time limit reached, stopping...'); stopRecording(); } return newTime; }); }, 1000); - } catch (error) { - console.error('Error accessing microphone:', error); - alert('Could not access microphone. Please check permissions.'); + } catch (error: any) { + console.error('[AudioResponse] Error accessing microphone:', error); + let errorMessage = 'Could not access microphone. '; + + if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { + errorMessage += 'Please allow microphone access and try again.'; + } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') { + errorMessage += 'No microphone found. Please connect a microphone and try again.'; + } else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') { + errorMessage += 'Microphone is already in use by another application.'; + } else if (error.name === 'OverconstrainedError') { + errorMessage += 'Microphone does not meet the required constraints.'; + } else { + errorMessage += 'Please check permissions and try again.'; + } + + alert(errorMessage); } }; @@ -177,6 +232,31 @@ export default function AudioResponsePlayer({ return (
+ {/* Browser Compatibility Check */} + {typeof window !== 'undefined' && (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) && ( +
+
+ +

Browser Not Supported

+
+

+ Your browser does not support audio recording. Please use Chrome, Firefox, Edge, or Safari. +

+
+ )} + + {!window.isSecureContext && window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && ( +
+
+ +

HTTPS Required

+
+

+ Audio recording requires a secure connection (HTTPS). Please access this site via HTTPS or localhost. +

+
+ )} +
diff --git a/web/experience/src/context/I18nContext.tsx b/web/experience/src/context/I18nContext.tsx index 76f3be2..59b914f 100644 --- a/web/experience/src/context/I18nContext.tsx +++ b/web/experience/src/context/I18nContext.tsx @@ -7,27 +7,71 @@ import pt from '../lib/locales/pt.json'; const translations: Record = { en, es, pt }; +// Idiomas soportados +export const SUPPORTED_LANGUAGES = ['es', 'en', 'pt'] as const; +export type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number]; + interface I18nContextType { language: string; setLanguage: (lang: string) => void; t: (path: string) => string; + detectBrowserLanguage: () => string; } const I18nContext = createContext(undefined); +/** + * Detecta el idioma del navegador del usuario + */ +function detectBrowserLanguage(): string { + if (typeof navigator === 'undefined') return 'es'; + + // Obtener idiomas del navegador (puede ser un array) + const browserLanguages = navigator.languages || [navigator.language || (navigator as any).userLanguage]; + + // Buscar el primer idioma soportado + for (const browserLang of browserLanguages) { + // Idioma completo (ej: 'en-US') + if (SUPPORTED_LANGUAGES.includes(browserLang as SupportedLanguage)) { + return browserLang; + } + + // Solo código de idioma (ej: 'en' de 'en-US') + const langCode = browserLang.split('-')[0].toLowerCase(); + if (SUPPORTED_LANGUAGES.includes(langCode as SupportedLanguage)) { + return langCode; + } + } + + // Fallback a español + return 'es'; +} + export function I18nProvider({ children }: { children: React.ReactNode }) { - const [language, setLanguageState] = useState('es'); // Default to Spanish for Experience as it's more localized by default + const [language, setLanguageState] = useState('es'); + const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { + // 1. Primero intentar cargar de localStorage const savedLang = localStorage.getItem('experience_language'); - if (savedLang) { + + if (savedLang && SUPPORTED_LANGUAGES.includes(savedLang as SupportedLanguage)) { setLanguageState(savedLang); + } else { + // 2. Si no hay guardado, detectar del navegador + const detectedLang = detectBrowserLanguage(); + setLanguageState(detectedLang); + localStorage.setItem('experience_language', detectedLang); } + + setIsInitialized(true); }, []); const setLanguage = (lang: string) => { - setLanguageState(lang); - localStorage.setItem('experience_language', lang); + if (SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage)) { + setLanguageState(lang); + localStorage.setItem('experience_language', lang); + } }; const t = (path: string): string => { @@ -46,7 +90,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { }; return ( - + {children} ); @@ -59,3 +103,5 @@ export function useTranslation() { } return context; } + +export { detectBrowserLanguage }; diff --git a/web/experience/src/hooks/useCourseLanguage.ts b/web/experience/src/hooks/useCourseLanguage.ts new file mode 100644 index 0000000..8702487 --- /dev/null +++ b/web/experience/src/hooks/useCourseLanguage.ts @@ -0,0 +1,108 @@ +"use client"; + +import { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from '@/context/I18nContext'; + +interface CourseLanguageConfig { + language_setting: 'auto' | 'fixed'; + fixed_language: string | null; +} + +/** + * Hook para manejar el idioma específico de un curso + * + * - Si el curso está en modo 'auto', usa el idioma del usuario + * - Si el curso está en modo 'fixed', usa el idioma fijo del curso + * + * @param courseId - ID del curso actual + * @returns El idioma que debe usarse para este curso + */ +export function useCourseLanguage(courseId: string | null) { + const { language: userLanguage } = useTranslation(); + const [courseLanguage, setCourseLanguage] = useState(userLanguage); + const [isLoading, setIsLoading] = useState(true); + const [config, setConfig] = useState(null); + + // Función para cargar la configuración de idioma del curso + const loadCourseLanguageConfig = useCallback(async () => { + if (!courseId) { + setCourseLanguage(userLanguage); + setIsLoading(false); + return; + } + + try { + // Importar dinámicamente para evitar circular dependencies + const { lmsApi } = await import('@/lib/api'); + + const response = await lmsApi.get(`/courses/${courseId}/language-config`); + const data = response.data; + + setConfig(data); + + // Determinar qué idioma usar + if (data.language_setting === 'fixed' && data.fixed_language) { + setCourseLanguage(data.fixed_language); + } else { + // Modo 'auto' o sin configuración: usar idioma del usuario + setCourseLanguage(userLanguage); + } + } catch (error) { + console.error('Error loading course language config:', error); + // Fallback: usar idioma del usuario + setCourseLanguage(userLanguage); + } finally { + setIsLoading(false); + } + }, [courseId, userLanguage]); + + // Cargar configuración cuando cambia el courseId + useEffect(() => { + loadCourseLanguageConfig(); + }, [loadCourseLanguageConfig]); + + // Función para verificar si el curso usa idioma fijo + const isFixedLanguage = useCallback(() => { + return config?.language_setting === 'fixed'; + }, [config]); + + // Función para obtener el idioma actual (curso o usuario) + const getCurrentLanguage = useCallback(() => { + return courseLanguage; + }, [courseLanguage]); + + return { + courseLanguage, + isFixedLanguage, + getCurrentLanguage, + isLoading, + refreshConfig: loadCourseLanguageConfig, + }; +} + +/** + * Hook para cambiar el idioma del usuario (solo funciona si el curso está en modo 'auto') + * + * @param courseId - ID del curso actual + */ +export function useCourseLanguageSwitcher(courseId: string | null) { + const { setLanguage, language } = useTranslation(); + const { isFixedLanguage } = useCourseLanguage(courseId); + + const canChangeLanguage = !isFixedLanguage(); + + const changeLanguage = (newLanguage: string) => { + if (canChangeLanguage) { + setLanguage(newLanguage); + } else { + console.warn('Cannot change language: course has fixed language setting'); + } + }; + + return { + currentLanguage: language, + canChangeLanguage, + changeLanguage, + isFixed: isFixedLanguage(), + }; +} diff --git a/web/experience/src/lib/locales/en.json b/web/experience/src/lib/locales/en.json index 3f6a3e3..a3b4ee6 100644 --- a/web/experience/src/lib/locales/en.json +++ b/web/experience/src/lib/locales/en.json @@ -5,14 +5,31 @@ "next": "Next", "finish": "Finish", "error": "Error", - "retry": "Retry" + "retry": "Retry", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "close": "Close", + "confirm": "Confirm", + "success": "Success", + "warning": "Warning", + "info": "Info", + "search": "Search", + "noResults": "No results", + "viewAll": "View all", + "learnMore": "Learn more" }, "nav": { "catalog": "Catalog", "myLearning": "My Learning", "bookmarks": "Bookmarks", "profile": "Profile", - "signOut": "Sign Out" + "signOut": "Sign Out", + "selectLanguage": "Select Language", + "menu": "Menu", + "notifications": "Notifications", + "settings": "Settings" }, "course": { "modules": "Modules", @@ -20,12 +37,194 @@ "progress": "Progress", "calendar": "Timeline", "start": "Start", - "continue": "Continue" + "continue": "Continue", + "enroll": "Enroll", + "enrolled": "Enrolled", + "price": "Price", + "free": "Free", + "duration": "Duration", + "level": "Level", + "description": "Description", + "objectives": "Objectives", + "requirements": "Requirements", + "instructor": "Instructor", + "certificate": "Certificate", + "aboutCourse": "About Course", + "content": "Content", + "reviews": "Reviews", + "faq": "FAQ" }, "lesson": { "summary": "AI Summary", "transcription": "Transcription", "complete": "Mark as Completed", - "nextLesson": "Next Lesson" + "nextLesson": "Next Lesson", + "previousLesson": "Previous Lesson", + "locked": "Locked", + "lockedMessage": "Complete previous lessons to unlock", + "markComplete": "Mark as Completed", + "inProgress": "In Progress", + "completed": "Completed", + "notStarted": "Not Started", + "dueDate": "Due Date", + "timeRemaining": "Time Remaining", + "overdue": "Overdue" + }, + "auth": { + "login": "Login", + "register": "Register", + "email": "Email", + "password": "Password", + "fullName": "Full Name", + "forgotPassword": "Forgot Password?", + "resetPassword": "Reset Password", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "signIn": "Sign In", + "signUp": "Sign Up", + "orContinueWith": "Or continue with", + "google": "Google", + "microsoft": "Microsoft", + "sso": "Single Sign-On" + }, + "dashboard": { + "welcome": "Welcome", + "continueLearning": "Continue Learning", + "recommendedCourses": "Recommended Courses", + "recentActivity": "Recent Activity", + "myProgress": "My Progress", + "certificates": "Certificates", + "badges": "Badges", + "stats": { + "coursesCompleted": "Courses Completed", + "hoursLearned": "Hours Learned", + "averageGrade": "Average Grade", + "currentStreak": "Current Streak" + } + }, + "profile": { + "myProfile": "My Profile", + "editProfile": "Edit Profile", + "avatar": "Avatar", + "bio": "Bio", + "language": "Language", + "timezone": "Timezone", + "notifications": "Notifications", + "privacy": "Privacy", + "account": "Account", + "changePassword": "Change Password", + "deleteAccount": "Delete Account", + "portfolio": "Portfolio", + "achievements": "Achievements", + "publicProfile": "Public Profile" + }, + "gamification": { + "level": "Level", + "xp": "Experience Points", + "leaderboard": "Leaderboard", + "rank": "Rank", + "points": "Points", + "badge": "Badge", + "badges": "Badges", + "earned": "Earned", + "locked": "Locked", + "nextLevel": "Next Level", + "xpToNextLevel": "XP to next level" + }, + "grading": { + "grade": "Grade", + "grades": "Grades", + "score": "Score", + "percentage": "Percentage", + "passingGrade": "Passing Grade", + "failed": "Failed", + "passed": "Passed", + "excellent": "Excellent", + "good": "Good", + "regular": "Regular", + "poor": "Poor", + "feedback": "Feedback", + "submissionDate": "Submission Date", + "gradedDate": "Graded Date" + }, + "quiz": { + "startQuiz": "Start Quiz", + "submitAnswers": "Submit Answers", + "questionNumber": "Question", + "of": "of", + "correctAnswer": "Correct Answer", + "yourAnswer": "Your Answer", + "explanation": "Explanation", + "tryAgain": "Try Again", + "attempts": "Attempts", + "attemptsRemaining": "attempts remaining", + "timeLimit": "Time Limit", + "timeExpired": "Time Expired" + }, + "forum": { + "discussions": "Discussions", + "newThread": "New Discussion", + "title": "Title", + "content": "Content", + "post": "Post", + "reply": "Reply", + "replies": "Replies", + "views": "Views", + "lastActivity": "Last Activity", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe", + "markAsSolved": "Mark as Solved", + "upvote": "Upvote", + "downvote": "Downvote" + }, + "payments": { + "purchase": "Purchase", + "buyNow": "Buy Now", + "paymentMethod": "Payment Method", + "cardNumber": "Card Number", + "expiryDate": "Expiry Date", + "cvv": "CVV", + "cardholderName": "Cardholder Name", + "paymentSuccess": "Payment Success", + "paymentFailed": "Payment Failed", + "refund": "Refund", + "invoice": "Invoice", + "receipt": "Receipt" + }, + "accessibility": { + "skipToContent": "Skip to content", + "increaseContrast": "Increase Contrast", + "decreaseContrast": "Decrease Contrast", + "largerText": "Larger Text", + "smallerText": "Smaller Text", + "screenReader": "Screen Reader" + }, + "language": { + "selectLanguage": "Select Language", + "auto": "Automatic", + "spanish": "Spanish", + "english": "English", + "portuguese": "Portuguese", + "courseLanguage": "Course Language", + "interfaceLanguage": "Interface Language" + }, + "errors": { + "notFound": "Not found", + "unauthorized": "Unauthorized", + "serverError": "Server error", + "networkError": "Network error", + "sessionExpired": "Session expired", + "invalidCredentials": "Invalid credentials", + "emailInUse": "Email already in use", + "weakPassword": "Weak password" + }, + "dates": { + "today": "Today", + "yesterday": "Yesterday", + "tomorrow": "Tomorrow", + "daysAgo": "{{days}} days ago", + "weeksAgo": "{{weeks}} weeks ago", + "monthsAgo": "{{months}} months ago", + "yearsAgo": "{{years}} years ago" } -} \ No newline at end of file +} diff --git a/web/experience/src/lib/locales/es.json b/web/experience/src/lib/locales/es.json index 0150450..50612a0 100644 --- a/web/experience/src/lib/locales/es.json +++ b/web/experience/src/lib/locales/es.json @@ -5,14 +5,31 @@ "next": "Siguiente", "finish": "Finalizar", "error": "Error", - "retry": "Reintentar" + "retry": "Reintentar", + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "close": "Cerrar", + "confirm": "Confirmar", + "success": "Éxito", + "warning": "Advertencia", + "info": "Información", + "search": "Buscar", + "noResults": "Sin resultados", + "viewAll": "Ver todo", + "learnMore": "Más información" }, "nav": { "catalog": "Catálogo", "myLearning": "Mi Aprendizaje", "bookmarks": "Marcadores", "profile": "Perfil", - "signOut": "Cerrar Sesión" + "signOut": "Cerrar Sesión", + "selectLanguage": "Seleccionar Idioma", + "menu": "Menú", + "notifications": "Notificaciones", + "settings": "Configuración" }, "course": { "modules": "Módulos", @@ -20,12 +37,194 @@ "progress": "Progreso", "calendar": "Cronología", "start": "Comenzar", - "continue": "Continuar" + "continue": "Continuar", + "enroll": "Inscribirse", + "enrolled": "Inscrito", + "price": "Precio", + "free": "Gratis", + "duration": "Duración", + "level": "Nivel", + "description": "Descripción", + "objectives": "Objetivos", + "requirements": "Requisitos", + "instructor": "Instructor", + "certificate": "Certificado", + "aboutCourse": "Acerca del Curso", + "content": "Contenido", + "reviews": "Reseñas", + "faq": "Preguntas Frecuentes" }, "lesson": { "summary": "Resumen AI", "transcription": "Transcripción", "complete": "Marcar como Completado", - "nextLesson": "Siguiente Lección" + "nextLesson": "Siguiente Lección", + "previousLesson": "Lección Anterior", + "locked": "Bloqueado", + "lockedMessage": "Completa las lecciones anteriores para desbloquear", + "markComplete": "Marcar como Completada", + "inProgress": "En Progreso", + "completed": "Completada", + "notStarted": "No Iniciada", + "dueDate": "Fecha Límite", + "timeRemaining": "Tiempo Restante", + "overdue": "Vencido" + }, + "auth": { + "login": "Iniciar Sesión", + "register": "Registrarse", + "email": "Correo Electrónico", + "password": "Contraseña", + "fullName": "Nombre Completo", + "forgotPassword": "¿Olvidaste tu contraseña?", + "resetPassword": "Restablecer Contraseña", + "noAccount": "¿No tienes cuenta?", + "hasAccount": "¿Ya tienes cuenta?", + "signIn": "Ingresar", + "signUp": "Crear Cuenta", + "orContinueWith": "O continúa con", + "google": "Google", + "microsoft": "Microsoft", + "sso": "Inicio de Sesión Único" + }, + "dashboard": { + "welcome": "Bienvenido", + "continueLearning": "Continuar Aprendiendo", + "recommendedCourses": "Cursos Recomendados", + "recentActivity": "Actividad Reciente", + "myProgress": "Mi Progreso", + "certificates": "Certificados", + "badges": "Insignias", + "stats": { + "coursesCompleted": "Cursos Completados", + "hoursLearned": "Horas Aprendidas", + "averageGrade": "Nota Promedio", + "currentStreak": "Racha Actual" + } + }, + "profile": { + "myProfile": "Mi Perfil", + "editProfile": "Editar Perfil", + "avatar": "Avatar", + "bio": "Biografía", + "language": "Idioma", + "timezone": "Zona Horaria", + "notifications": "Notificaciones", + "privacy": "Privacidad", + "account": "Cuenta", + "changePassword": "Cambiar Contraseña", + "deleteAccount": "Eliminar Cuenta", + "portfolio": "Portafolio", + "achievements": "Logros", + "publicProfile": "Perfil Público" + }, + "gamification": { + "level": "Nivel", + "xp": "Puntos de Experiencia", + "leaderboard": "Tabla de Clasificación", + "rank": "Rango", + "points": "Puntos", + "badge": "Insignia", + "badges": "Insignias", + "earned": "Obtenida", + "locked": "Bloqueada", + "nextLevel": "Siguiente Nivel", + "xpToNextLevel": "XP para el siguiente nivel" + }, + "grading": { + "grade": "Nota", + "grades": "Notas", + "score": "Puntaje", + "percentage": "Porcentaje", + "passingGrade": "Nota de Aprobación", + "failed": "Reprobado", + "passed": "Aprobado", + "excellent": "Excelente", + "good": "Bueno", + "regular": "Regular", + "poor": "Deficiente", + "feedback": "Retroalimentación", + "submissionDate": "Fecha de Entrega", + "gradedDate": "Fecha de Calificación" + }, + "quiz": { + "startQuiz": "Comenzar Cuestionario", + "submitAnswers": "Enviar Respuestas", + "questionNumber": "Pregunta", + "of": "de", + "correctAnswer": "Respuesta Correcta", + "yourAnswer": "Tu Respuesta", + "explanation": "Explicación", + "tryAgain": "Intentar de Nuevo", + "attempts": "Intentos", + "attemptsRemaining": "intentos restantes", + "timeLimit": "Límite de Tiempo", + "timeExpired": "Tiempo Expirado" + }, + "forum": { + "discussions": "Discusiones", + "newThread": "Nueva Discusión", + "title": "Título", + "content": "Contenido", + "post": "Publicar", + "reply": "Responder", + "replies": "Respuestas", + "views": "Vistas", + "lastActivity": "Última Actividad", + "subscribe": "Suscribirse", + "unsubscribe": "Desuscribirse", + "markAsSolved": "Marcar como Resuelto", + "upvote": "Votar a Favor", + "downvote": "Votar en Contra" + }, + "payments": { + "purchase": "Comprar", + "buyNow": "Comprar Ahora", + "paymentMethod": "Método de Pago", + "cardNumber": "Número de Tarjeta", + "expiryDate": "Fecha de Vencimiento", + "cvv": "CVV", + "cardholderName": "Nombre del Titular", + "paymentSuccess": "Pago Exitoso", + "paymentFailed": "Pago Fallido", + "refund": "Reembolso", + "invoice": "Factura", + "receipt": "Recibo" + }, + "accessibility": { + "skipToContent": "Saltar al contenido", + "increaseContrast": "Aumentar Contraste", + "decreaseContrast": "Disminuir Contraste", + "largerText": "Texto Más Grande", + "smallerText": "Texto Más Pequeño", + "screenReader": "Lector de Pantalla" + }, + "language": { + "selectLanguage": "Seleccionar Idioma", + "auto": "Automático", + "spanish": "Español", + "english": "Inglés", + "portuguese": "Portugués", + "courseLanguage": "Idioma del Curso", + "interfaceLanguage": "Idioma de la Interfaz" + }, + "errors": { + "notFound": "No encontrado", + "unauthorized": "No autorizado", + "serverError": "Error del servidor", + "networkError": "Error de red", + "sessionExpired": "Sesión expirada", + "invalidCredentials": "Credenciales inválidas", + "emailInUse": "Correo ya en uso", + "weakPassword": "Contraseña débil" + }, + "dates": { + "today": "Hoy", + "yesterday": "Ayer", + "tomorrow": "Mañana", + "daysAgo": "hace {{days}} días", + "weeksAgo": "hace {{weeks}} semanas", + "monthsAgo": "hace {{months}} meses", + "yearsAgo": "hace {{years}} años" } -} \ No newline at end of file +} diff --git a/web/experience/src/lib/locales/pt.json b/web/experience/src/lib/locales/pt.json index 0309f58..b59ce46 100644 --- a/web/experience/src/lib/locales/pt.json +++ b/web/experience/src/lib/locales/pt.json @@ -3,29 +3,228 @@ "loading": "Carregando...", "back": "Voltar", "next": "Próximo", - "finish": "Finalizar", + "finish": "Concluir", "error": "Erro", - "retry": "Repetir" + "retry": "Tentar Novamente", + "save": "Salvar", + "cancel": "Cancelar", + "delete": "Excluir", + "edit": "Editar", + "close": "Fechar", + "confirm": "Confirmar", + "success": "Sucesso", + "warning": "Aviso", + "info": "Informação", + "search": "Buscar", + "noResults": "Sem resultados", + "viewAll": "Ver tudo", + "learnMore": "Saiba mais" }, "nav": { "catalog": "Catálogo", "myLearning": "Meu Aprendizado", "bookmarks": "Favoritos", "profile": "Perfil", - "signOut": "Sair" + "signOut": "Sair", + "selectLanguage": "Selecionar Idioma", + "menu": "Menu", + "notifications": "Notificações", + "settings": "Configurações" }, "course": { "modules": "Módulos", - "lessons": "Lições", + "lessons": "Aulas", "progress": "Progresso", - "calendar": "Cronologia", + "calendar": "Cronograma", "start": "Começar", - "continue": "Continuar" + "continue": "Continuar", + "enroll": "Inscrever-se", + "enrolled": "Inscrito", + "price": "Preço", + "free": "Gratuito", + "duration": "Duração", + "level": "Nível", + "description": "Descrição", + "objectives": "Objetivos", + "requirements": "Requisitos", + "instructor": "Instrutor", + "certificate": "Certificado", + "aboutCourse": "Sobre o Curso", + "content": "Conteúdo", + "reviews": "Avaliações", + "faq": "Perguntas Frequentes" }, "lesson": { "summary": "Resumo IA", "transcription": "Transcrição", - "complete": "Marcar como Concluído", - "nextLesson": "Próxima Lição" + "complete": "Marcar como Concluída", + "nextLesson": "Próxima Aula", + "previousLesson": "Aula Anterior", + "locked": "Bloqueada", + "lockedMessage": "Complete as aulas anteriores para desbloquear", + "markComplete": "Marcar como Concluída", + "inProgress": "Em Progresso", + "completed": "Concluída", + "notStarted": "Não Iniciada", + "dueDate": "Data Limite", + "timeRemaining": "Tempo Restante", + "overdue": "Atrasada" + }, + "auth": { + "login": "Entrar", + "register": "Cadastrar", + "email": "E-mail", + "password": "Senha", + "fullName": "Nome Completo", + "forgotPassword": "Esqueceu a senha?", + "resetPassword": "Redefinir Senha", + "noAccount": "Não tem uma conta?", + "hasAccount": "Já tem uma conta?", + "signIn": "Entrar", + "signUp": "Criar Conta", + "orContinueWith": "Ou continue com", + "google": "Google", + "microsoft": "Microsoft", + "sso": "Login Único" + }, + "dashboard": { + "welcome": "Bem-vindo", + "continueLearning": "Continuar Aprendendo", + "recommendedCourses": "Cursos Recomendados", + "recentActivity": "Atividade Recente", + "myProgress": "Meu Progresso", + "certificates": "Certificados", + "badges": "Distintivos", + "stats": { + "coursesCompleted": "Cursos Concluídos", + "hoursLearned": "Horas Aprendidas", + "averageGrade": "Nota Média", + "currentStreak": "Sequência Atual" + } + }, + "profile": { + "myProfile": "Meu Perfil", + "editProfile": "Editar Perfil", + "avatar": "Avatar", + "bio": "Biografia", + "language": "Idioma", + "timezone": "Fuso Horário", + "notifications": "Notificações", + "privacy": "Privacidade", + "account": "Conta", + "changePassword": "Alterar Senha", + "deleteAccount": "Excluir Conta", + "portfolio": "Portfólio", + "achievements": "Conquistas", + "publicProfile": "Perfil Público" + }, + "gamification": { + "level": "Nível", + "xp": "Pontos de Experiência", + "leaderboard": "Classificação", + "rank": "Posição", + "points": "Pontos", + "badge": "Distintivo", + "badges": "Distintivos", + "earned": "Conquistado", + "locked": "Bloqueado", + "nextLevel": "Próximo Nível", + "xpToNextLevel": "XP para o próximo nível" + }, + "grading": { + "grade": "Nota", + "grades": "Notas", + "score": "Pontuação", + "percentage": "Porcentagem", + "passingGrade": "Nota Mínima", + "failed": "Reprovado", + "passed": "Aprovado", + "excellent": "Excelente", + "good": "Bom", + "regular": "Regular", + "poor": "Insuficiente", + "feedback": "Feedback", + "submissionDate": "Data de Entrega", + "gradedDate": "Data de Avaliação" + }, + "quiz": { + "startQuiz": "Iniciar Questionário", + "submitAnswers": "Enviar Respostas", + "questionNumber": "Pergunta", + "of": "de", + "correctAnswer": "Resposta Correta", + "yourAnswer": "Sua Resposta", + "explanation": "Explicação", + "tryAgain": "Tentar Novamente", + "attempts": "Tentativas", + "attemptsRemaining": "tentativas restantes", + "timeLimit": "Limite de Tempo", + "timeExpired": "Tempo Esgotado" + }, + "forum": { + "discussions": "Discussões", + "newThread": "Nova Discussão", + "title": "Título", + "content": "Conteúdo", + "post": "Publicar", + "reply": "Responder", + "replies": "Respostas", + "views": "Visualizações", + "lastActivity": "Última Atividade", + "subscribe": "Inscrever-se", + "unsubscribe": "Cancelar Inscrição", + "markAsSolved": "Marcar como Resolvido", + "upvote": "Votar a Favor", + "downvote": "Votar Contra" + }, + "payments": { + "purchase": "Comprar", + "buyNow": "Comprar Agora", + "paymentMethod": "Método de Pagamento", + "cardNumber": "Número do Cartão", + "expiryDate": "Data de Validade", + "cvv": "CVV", + "cardholderName": "Nome do Titular", + "paymentSuccess": "Pagamento Bem-Sucedido", + "paymentFailed": "Pagamento Falhou", + "refund": "Reembolso", + "invoice": "Fatura", + "receipt": "Recibo" + }, + "accessibility": { + "skipToContent": "Pular para o conteúdo", + "increaseContrast": "Aumentar Contraste", + "decreaseContrast": "Diminuir Contraste", + "largerText": "Texto Maior", + "smallerText": "Texto Menor", + "screenReader": "Leitor de Tela" + }, + "language": { + "selectLanguage": "Selecionar Idioma", + "auto": "Automático", + "spanish": "Espanhol", + "english": "Inglês", + "portuguese": "Português", + "courseLanguage": "Idioma do Curso", + "interfaceLanguage": "Idioma da Interface" + }, + "errors": { + "notFound": "Não encontrado", + "unauthorized": "Não autorizado", + "serverError": "Erro do servidor", + "networkError": "Erro de rede", + "sessionExpired": "Sessão expirada", + "invalidCredentials": "Credenciais inválidas", + "emailInUse": "E-mail já em uso", + "weakPassword": "Senha fraca" + }, + "dates": { + "today": "Hoje", + "yesterday": "Ontem", + "tomorrow": "Amanhã", + "daysAgo": "há {{days}} dias", + "weeksAgo": "há {{weeks}} semanas", + "monthsAgo": "há {{months}} meses", + "yearsAgo": "há {{years}} anos" } -} \ No newline at end of file +} 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 cf0cc8d..30fcd2c 100644 --- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback } from "react"; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; -import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, getImageUrl } from '@/lib/api'; +import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, getImageUrl, generateUUID } from '@/lib/api'; import { Layout, CheckCircle2, @@ -103,7 +103,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI setImportantDateType(lessonData.important_date_type || ""); setIsPreviewable(lessonData.is_previewable || false); - if (lessonData.metadata?.blocks) { + if (lessonData.content_blocks && Array.isArray(lessonData.content_blocks)) { + setBlocks(lessonData.content_blocks); + } else if (lessonData.metadata?.blocks) { setBlocks(lessonData.metadata.blocks); } else { setBlocks([ @@ -195,7 +197,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI } const updated = await cmsApi.updateLesson(lesson.id, { - metadata: { ...lesson.metadata, blocks }, + metadata: { ...lesson.metadata }, + content_blocks: blocks, content_url, content_type, // Sync type to ensure backend triggers transcription summary, @@ -218,7 +221,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI const addBlock = (type: Block['type']) => { const newBlock: Block = { - id: crypto.randomUUID(), + id: generateUUID(), type, ...(type === 'description' && { content: "" }), ...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }), diff --git a/web/studio/src/components/blocks/HotspotBlock.tsx b/web/studio/src/components/blocks/HotspotBlock.tsx index ae1dfeb..2d16415 100644 --- a/web/studio/src/components/blocks/HotspotBlock.tsx +++ b/web/studio/src/components/blocks/HotspotBlock.tsx @@ -4,7 +4,7 @@ import { useState, useRef } from "react"; import Image from "next/image"; import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair, Wand2, Loader2 } from "lucide-react"; import AssetPickerModal from "../AssetPickerModal"; -import { Asset, getImageUrl, cmsApi } from "@/lib/api"; +import { Asset, getImageUrl, cmsApi, generateUUID } from "@/lib/api"; interface Hotspot { id: string; @@ -46,12 +46,19 @@ export default function HotspotBlock({ setIsGenerating(true); try { const data = await cmsApi.generateHotspots(lessonId, { image_url: imageUrl }); - const newHotspots = data.map(h => ({ - id: crypto.randomUUID(), - x: h.x, - y: h.y, + // Handle different response formats from AI + let hotspotsArray = Array.isArray(data) ? data : (data.hotspots || data.items || []); + + if (!Array.isArray(hotspotsArray)) { + throw new Error("La respuesta de la IA no es un array válido"); + } + + const newHotspots = hotspotsArray.map((h: any) => ({ + id: generateUUID(), + x: typeof h.x === 'number' ? h.x : 50, + y: typeof h.y === 'number' ? h.y : 50, radius: 5, - label: h.label + label: h.label || 'Punto de interés' })); onChange({ hotspots: [...hotspots, ...newHotspots] }); } catch (error) { @@ -76,7 +83,7 @@ export default function HotspotBlock({ const y = ((e.clientY - rect.top) / rect.height) * 100; const newHotspot: Hotspot = { - id: crypto.randomUUID(), + id: generateUUID(), x, y, radius: 5, diff --git a/web/studio/src/components/blocks/MemoryBlock.tsx b/web/studio/src/components/blocks/MemoryBlock.tsx index 4a5135e..6f9de22 100644 --- a/web/studio/src/components/blocks/MemoryBlock.tsx +++ b/web/studio/src/components/blocks/MemoryBlock.tsx @@ -16,27 +16,38 @@ interface MemoryBlockProps { onChange: (updates: { title?: string; pairs?: MatchingPair[] }) => void; } +// Generate unique ID for pairs +const generatePairId = () => { + return `pair_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +}; + export default function MemoryBlock({ id, title, pairs = [], editMode, onChange }: MemoryBlockProps) { const addPair = () => { const newPair: MatchingPair = { - id: Math.random().toString(36).substr(2, 9), + id: generatePairId(), left: "", right: "" }; + console.log('[MemoryBlock] Adding new pair:', newPair); onChange({ pairs: [...pairs, newPair] }); }; const updatePair = (index: number, updates: Partial) => { const newPairs = [...pairs]; newPairs[index] = { ...newPairs[index], ...updates }; + console.log('[MemoryBlock] Updating pair at index', index, ':', updates); onChange({ pairs: newPairs }); }; const removePair = (index: number) => { + console.log('[MemoryBlock] Removing pair at index', index); const newPairs = pairs.filter((_, i) => i !== index); onChange({ pairs: newPairs }); }; + // Debug: Log pairs on render + console.log('[MemoryBlock] Render with pairs:', pairs); + if (!editMode) { return (
diff --git a/web/studio/src/context/I18nContext.tsx b/web/studio/src/context/I18nContext.tsx index 6131baf..9a904f7 100644 --- a/web/studio/src/context/I18nContext.tsx +++ b/web/studio/src/context/I18nContext.tsx @@ -8,29 +8,64 @@ import pt from '../lib/locales/pt.json'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const translations: Record = { en, es, pt }; +// Idiomas soportados +export const SUPPORTED_LANGUAGES = ['es', 'en', 'pt'] as const; +export type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number]; + interface I18nContextType { language: string; setLanguage: (lang: string) => void; t: (path: string) => string; + detectBrowserLanguage: () => string; } const I18nContext = createContext(undefined); +/** + * Detecta el idioma del navegador del usuario + */ +function detectBrowserLanguage(): string { + if (typeof navigator === 'undefined') return 'es'; + + const browserLanguages = navigator.languages || [navigator.language || (navigator as any).userLanguage]; + + for (const browserLang of browserLanguages) { + if (SUPPORTED_LANGUAGES.includes(browserLang as SupportedLanguage)) { + return browserLang; + } + + const langCode = browserLang.split('-')[0].toLowerCase(); + if (SUPPORTED_LANGUAGES.includes(langCode as SupportedLanguage)) { + return langCode; + } + } + + return 'es'; +} + export function I18nProvider({ children }: { children: React.ReactNode }) { const [language, setLanguageState] = useState('es'); + const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { const savedLang = localStorage.getItem('studio_language'); - if (savedLang) { + + if (savedLang && SUPPORTED_LANGUAGES.includes(savedLang as SupportedLanguage)) { setLanguageState(savedLang); } else { - // Try to detect from user profile if available, but for now default or localstorage + const detectedLang = detectBrowserLanguage(); + setLanguageState(detectedLang); + localStorage.setItem('studio_language', detectedLang); } + + setIsInitialized(true); }, []); const setLanguage = (lang: string) => { - setLanguageState(lang); - localStorage.setItem('studio_language', lang); + if (SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage)) { + setLanguageState(lang); + localStorage.setItem('studio_language', lang); + } }; const t = (path: string): string => { @@ -41,7 +76,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { if (result[key]) { result = result[key]; } else { - return path; // Fallback to path if key missing + return path; } } @@ -49,7 +84,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { }; return ( - + {children} ); @@ -62,3 +97,5 @@ export function useTranslation() { } return context; } + +export { detectBrowserLanguage }; diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 8dde9d2..3360183 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -11,6 +11,19 @@ const getApiBaseUrl = (defaultPort: string, envVar?: string) => { export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL); export const LMS_API_BASE_URL = getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LMS_API_URL); +// Polyfill for crypto.randomUUID() for non-HTTPS contexts +export function generateUUID(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback for non-secure contexts + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + export const getImageUrl = (path?: string) => { if (!path) return ''; if (path.startsWith('http')) return path; diff --git a/web/studio/src/lib/locales/es.json b/web/studio/src/lib/locales/es.json index 6e3ba4a..50115a8 100644 --- a/web/studio/src/lib/locales/es.json +++ b/web/studio/src/lib/locales/es.json @@ -1,39 +1,253 @@ { "common": { + "loading": "Cargando...", + "back": "Volver", + "next": "Siguiente", + "finish": "Finalizar", + "error": "Error", + "retry": "Reintentar", "save": "Guardar", "cancel": "Cancelar", "delete": "Eliminar", "edit": "Editar", + "close": "Cerrar", + "confirm": "Confirmar", + "success": "Éxito", + "warning": "Advertencia", "create": "Crear", - "loading": "Cargando...", + "add": "Agregar", + "remove": "Eliminar", + "update": "Actualizar", "search": "Buscar", - "back": "Volver", - "next": "Siguiente", + "filter": "Filtrar", + "export": "Exportar", + "import": "Importar", + "download": "Descargar", + "upload": "Subir", + "preview": "Vista Previa", "publish": "Publicar", - "preview": "Previsualizar" + "draft": "Borrador", + "archive": "Archivar", + "duplicate": "Duplicar", + "settings": "Configuración" }, "nav": { + "dashboard": "Dashboard", "courses": "Cursos", - "globalControl": "Control Global", - "webhooks": "Webhooks", "library": "Librería", + "questionBank": "Banco de Preguntas", + "testTemplates": "Plantillas de Pruebas", + "analytics": "Analíticas", + "students": "Estudiantes", + "instructors": "Instructores", "settings": "Configuración", "profile": "Perfil", - "signOut": "Cerrar Sesión" + "signOut": "Cerrar Sesión", + "selectLanguage": "Seleccionar Idioma", + "controlPanel": "Panel de Control", + "webhooks": "Webhooks", + "globalControl": "Control Global" }, - "dashboard": { - "title": "Panel de Control", - "newCourse": "Nuevo Curso", - "aiBuilder": "Constructor AI", - "noCourses": "No hay cursos disponibles." + "course": { + "createCourse": "Crear Curso", + "editCourse": "Editar Curso", + "deleteCourse": "Eliminar Curso", + "courseTitle": "Título del Curso", + "courseDescription": "Descripción del Curso", + "courseLanguage": "Idioma del Curso", + "languageAuto": "Automático (detectar del usuario)", + "languageFixed": "Fijo (siempre este idioma)", + "languageEnglish": "Inglés", + "languageSpanish": "Español", + "languagePortuguese": "Portugués", + "modules": "Módulos", + "addModule": "Agregar Módulo", + "editModule": "Editar Módulo", + "deleteModule": "Eliminar Módulo", + "moduleName": "Nombre del Módulo", + "moduleDescription": "Descripción del Módulo", + "lessons": "Lecciones", + "addLesson": "Agregar Lección", + "editLesson": "Editar Lección", + "deleteLesson": "Eliminar Lección", + "lessonTitle": "Título de la Lección", + "lessonType": "Tipo de Lección", + "blocks": "Bloques", + "addBlock": "Agregar Bloque", + "blockType": "Tipo de Bloque", + "reorderBlocks": "Reordenar Bloques", + "moveUp": "Mover Arriba", + "moveDown": "Mover Abajo", + "pricing": "Precios", + "currency": "Moneda", + "enrollment": "Inscripciones", + "published": "Publicado", + "unpublished": "No Publicado", + "visibility": "Visibilidad", + "public": "Público", + "private": "Privado" }, - "editor": { - "outline": "Estructura", - "settings": "Configuración", - "newModule": "Nuevo Módulo", - "newLesson": "Nueva Lección", - "addBlock": "Añadir Bloque de Contenido", - "publishToOrg": "Publicar en Organización", - "reading": "Lectura" + "content": { + "video": "Video", + "audio": "Audio", + "text": "Texto", + "image": "Imagen", + "document": "Documento", + "quiz": "Cuestionario", + "codeExercise": "Ejercicio de Código", + "memoryGame": "Juego de Memoria", + "dragToCube": "Arrastrar al Cubo", + "hotspots": "Puntos Calientes", + "videoMarkers": "Marcadores de Video", + "audioResponse": "Respuesta de Audio", + "fillBlanks": "Completar Espacios", + "matching": "Emparejamiento", + "ordering": "Ordenamiento", + "shortAnswer": "Respuesta Corta", + "multipleChoice": "Opción Múltiple", + "trueFalse": "Verdadero/Falso", + "essay": "Ensayo", + "uploadMedia": "Subir Archivo", + "dropFiles": "Soltar archivos aquí", + "browseFiles": "Examinar archivos", + "uploading": "Subiendo...", + "uploadComplete": "Subida Completa", + "uploadFailed": "Subida Fallida" + }, + "ai": { + "generateWithAI": "Generar con IA", + "generateCourse": "Generar Curso con IA", + "generateQuiz": "Generar Cuestionario con IA", + "generateSummary": "Generar Resumen con IA", + "generateTranscription": "Transcribir con IA", + "generateQuestions": "Generar Preguntas con IA", + "topicPrompt": "Ingresa el tema del curso", + "generating": "Generando...", + "generationComplete": "Generación Completa", + "generationFailed": "Generación Fallida", + "aiPowered": "Potenciado por IA", + "model": "Modelo de IA", + "tokens": "Tokens", + "cost": "Costo" + }, + "grading": { + "gradingSystem": "Sistema de Calificación", + "categories": "Categorías", + "weights": "Pesos", + "passingGrade": "Nota de Aprobación", + "percentage": "Porcentaje", + "dropLowest": "Eliminar Notas Más Bajas", + "maxAttempts": "Intentos Máximos", + "autoGrade": "Calificación Automática", + "manualGrade": "Calificación Manual", + "rubric": "Rúbrica", + "feedback": "Retroalimentación", + "grades": "Notas", + "exportGrades": "Exportar Notas", + "importGrades": "Importar Notas" + }, + "students": { + "enrollments": "Inscripciones", + "enrollStudent": "Inscribir Estudiante", + "bulkEnroll": "Inscripción Masiva", + "studentProgress": "Progreso del Estudiante", + "studentGrades": "Notas del Estudiante", + "studentActivity": "Actividad del Estudiante", + "cohort": "Cohorte", + "groups": "Grupos", + "retention": "Retención", + "dropoutRisk": "Riesgo de Abandono", + "engagement": "Compromiso" + }, + "analytics": { + "overview": "Resumen", + "enrollments": "Inscripciones", + "completions": "Completaciones", + "averageGrade": "Nota Promedio", + "engagement": "Compromiso", + "retention": "Retención", + "revenue": "Ingresos", + "aiUsage": "Uso de IA", + "exportReport": "Exportar Reporte", + "dateRange": "Rango de Fechas", + "compareCourses": "Comparar Cursos", + "studentPerformance": "Rendimiento Estudiantil" + }, + "settings": { + "general": "Configuración General", + "branding": "Marca", + "logo": "Logotipo", + "favicon": "Favicon", + "colors": "Colores", + "platformName": "Nombre de la Plataforma", + "notifications": "Notificaciones", + "emailTemplates": "Plantillas de Email", + "integrations": "Integraciones", + "lti": "LTI", + "sso": "SSO", + "webhooks": "Webhooks", + "apiKeys": "Claves API", + "security": "Seguridad", + "backup": "Respaldo", + "export": "Exportar Datos", + "import": "Importar Datos" + }, + "user": { + "profile": "Perfil", + "editProfile": "Editar Perfil", + "changePassword": "Cambiar Contraseña", + "preferences": "Preferencias", + "language": "Idioma", + "timezone": "Zona Horaria", + "notifications": "Notificaciones", + "avatar": "Avatar", + "bio": "Biografía", + "role": "Rol", + "admin": "Administrador", + "instructor": "Instructor", + "student": "Estudiante" + }, + "errors": { + "required": "Campo requerido", + "invalidEmail": "Email inválido", + "minLength": "Longitud mínima: {{length}}", + "maxLength": "Longitud máxima: {{length}}", + "invalidFormat": "Formato inválido", + "alreadyExists": "Ya existe", + "notFound": "No encontrado", + "unauthorized": "No autorizado", + "serverError": "Error del servidor", + "networkError": "Error de red", + "uploadFailed": "Subida fallida", + "generationFailed": "Generación fallida" + }, + "validation": { + "required": "Este campo es requerido", + "email": "Debe ser un email válido", + "url": "Debe ser una URL válida", + "number": "Debe ser un número", + "min": "Debe ser mayor o igual a {{min}}", + "max": "Debe ser menor o igual a {{max}}", + "pattern": "Formato inválido" + }, + "dates": { + "today": "Hoy", + "yesterday": "Ayer", + "tomorrow": "Mañana", + "lastWeek": "Semana Pasada", + "lastMonth": "Mes Pasado", + "lastYear": "Año Pasado", + "custom": "Personalizado", + "startDate": "Fecha de Inicio", + "endDate": "Fecha de Fin", + "dueDate": "Fecha Límite", + "publishedDate": "Fecha de Publicación", + "createdDate": "Fecha de Creación", + "updatedDate": "Fecha de Actualización" + }, + "accessibility": { + "skipToContent": "Saltar al contenido", + "increaseContrast": "Aumentar Contraste", + "screenReader": "Lector de Pantalla" } -} \ No newline at end of file +}