diff --git a/README.md b/README.md index fe77a3e..34e442e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada. - **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots). - **Auto Transcription**: Integración con Whisper para generación automática de transcripciones y evaluación precisa de voz. +- **Dynamic API Resolution**: Resolución inteligente de endpoints que permite el acceso desde cualquier dispositivo en la red local (WiFi) sin configuración manual. +- **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes. +- **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso. +- **Persistent Grade Locking**: Bloqueo persistente de lecciones calificadas tras agotar los intentos, con retroalimentación personalizada generada por IA. ## Requisitos del Sistema @@ -71,6 +75,9 @@ Esto iniciará todos los servicios: - **Studio**: [http://localhost:3000](http://localhost:3000) - **Experience**: [http://localhost:3003](http://localhost:3003) +> [!TIP] +> **Acceso desde Móviles**: Gracias a la *Dynamic API Resolution*, puedes acceder desde tu celular conectado al mismo WiFi usando la IP de tu computadora (ej: `http://192.168.1.15:3000`). La interfaz se adaptará automáticamente. + ### Desarrollo Local #### Studio & CMS @@ -307,6 +314,23 @@ Elimina un curso y todos sus contenidos relacionados (módulos, lecciones, asset - **Procesamiento Asíncrono**: Despacha una tarea en segundo plano que utiliza Whisper para transcripción y Ollama para generar la traducción y el resumen inteligente. - **Cuerpo de la Petición**: Vacío. +#### GET /lessons/{id}/feedback +Obtiene retroalimentación personalizada de IA basada en el desempeño del estudiante y el contexto de la lección. + +- **Uso Crítico**: Se llama automáticamente cuando una lección calificada es bloqueada por intentos agotados. +- **Respuesta**: Un objeto JSON con la respuesta motivacional del tutor. + +#### POST /lessons/{id}/chat +Interactúa con el tutor de IA específico para la lección. + +- **Contexto Inteligente**: La IA tiene acceso a la transcripción del video, el contenido de los bloques interactivos y el historial de lecciones pasadas del curso. +- **Cuerpo ( ChatPayload ):** + ```json + { + "message": "string" + } + ``` + #### GET /lessons/{id}/vtt?lang=en|es Devuelve los subtítulos en formato WebVTT para integración nativa. @@ -410,6 +434,10 @@ Obtiene una lista de todas las organizaciones registradas. - **PDF Integrated Viewer**: Read academic documents without leaving the platform. - **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons. - **White-Label Branding**: Fully custom platform name, logo, favicon, and color themes per organization. +- **Dynamic LAN Connectivity**: Automatic server IP detection for seamless multi-device access. +- **Mobile-First Navigation**: Responsive sliding menus and adaptive layouts for all screen sizes. +- **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers. +- **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results. ## 📄 Licencia Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. \ No newline at end of file diff --git a/check_db.js b/check_db.js new file mode 100644 index 0000000..3279b45 --- /dev/null +++ b/check_db.js @@ -0,0 +1,31 @@ +const { Client } = require('pg'); + +async function checkEnrollments() { + const client = new Client({ + connectionString: "postgresql://user:password@localhost:5432/openccb_lms" + }); + + try { + await client.connect(); + console.log("Connected to LMS DB"); + + console.log("\n--- Users ---"); + const users = await client.query("SELECT id, email, organization_id, full_name FROM users"); + console.table(users.rows); + + console.log("\n--- Enrollments ---"); + const enrollments = await client.query("SELECT id, user_id, course_id, organization_id FROM enrollments"); + console.table(enrollments.rows); + + console.log("\n--- Courses ---"); + const courses = await client.query("SELECT id, title, organization_id FROM courses"); + console.table(courses.rows); + + } catch (err) { + console.error("Error:", err); + } finally { + await client.end(); + } +} + +checkEnrollments(); diff --git a/docker-compose.yml b/docker-compose.yml index 79c19a8..43890a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: environment: DATABASE_URL: postgresql://user:password@db:5432/openccb_cms JWT_SECRET: openccb_secret_key_2025_production - NEXT_PUBLIC_CMS_API_URL: http://localhost:3001 + NEXT_PUBLIC_CMS_API_URL: http://192.168.0.254:3001 LMS_INTERNAL_URL: http://experience:3002 volumes: - uploads_data:/app/uploads @@ -40,7 +40,8 @@ services: environment: DATABASE_URL: postgresql://user:password@db:5432/openccb_lms JWT_SECRET: openccb_secret_key_2025_production - NEXT_PUBLIC_LMS_API_URL: http://localhost:3002 + NEXT_PUBLIC_LMS_API_URL: http://192.168.0.254:3002 + NEXT_PUBLIC_CMS_API_URL: http://192.168.0.254:3001 env_file: .env extra_hosts: - "host.docker.internal:host-gateway" diff --git a/logs.txt b/logs.txt new file mode 100644 index 0000000..21fd2a5 --- /dev/null +++ b/logs.txt @@ -0,0 +1,76 @@ +2026-01-23T16:11:08.024085Z  INFO sqlx::postgres::notice: relation "_sqlx_migrations" already exists, skipping +2026-01-23T16:11:08.027458Z  INFO lms_service: LMS Service listening on 0.0.0.0:3002 + ▲ Next.js 14.2.21 + - Local: http://006ab367474a:3003 + - Network: http://172.18.0.3:3003 + + ✓ Starting... + ✓ Ready in 30ms + ⨯ TypeError: fetch failed + at node:internal/deps/undici/undici:12637:11 + at process.processTicksAndRejections (node:internal/process/task_queues:95:5) + at async fetchExternalImage (/app/node_modules/next/dist/server/image-optimizer.js:589:17) + at async NextNodeServer.imageOptimizer (/app/node_modules/next/dist/server/next-server.js:649:48) + at async cacheEntry.imageResponseCache.get.incrementalCache (/app/node_modules/next/dist/server/next-server.js:182:65) + at async /app/node_modules/next/dist/server/response-cache/index.js:90:36 + at async /app/node_modules/next/dist/lib/batcher.js:45:32 { + cause: Error: connect ECONNREFUSED 127.0.0.1:3001 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16) { + errno: -111, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '127.0.0.1', + port: 3001 + } +} +2026-01-23T16:12:42.241889Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4 +2026-01-23T16:12:42.247382Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb +2026-01-23T16:12:47.935236Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4 +2026-01-23T16:12:50.853225Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4 +2026-01-23T16:12:50.860842Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb +2026-01-23T16:12:53.661037Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb +2026-01-23T16:12:57.221143Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4 +2026-01-23T16:12:57.239532Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb +2026-01-23T16:12:58.825384Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4 +2026-01-23T16:12:58.834411Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb +2026-01-23T16:13:00.543902Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4 +2026-01-23T16:13:00.559156Z  INFO lms_service::handlers: get_course_outline: fetching course e3b1fe67-c411-4dbd-a222-a6111ce786bb +2026-01-23T16:13:02.271758Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4 +2026-01-23T16:22:32.087284Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4 + ⨯ TypeError: fetch failed + at node:internal/deps/undici/undici:12637:11 + at process.processTicksAndRejections (node:internal/process/task_queues:95:5) + at async fetchExternalImage (/app/node_modules/next/dist/server/image-optimizer.js:589:17) + at async NextNodeServer.imageOptimizer (/app/node_modules/next/dist/server/next-server.js:649:48) + at async cacheEntry.imageResponseCache.get.incrementalCache (/app/node_modules/next/dist/server/next-server.js:182:65) + at async /app/node_modules/next/dist/server/response-cache/index.js:90:36 + at async /app/node_modules/next/dist/lib/batcher.js:45:32 { + cause: Error: connect ECONNREFUSED 127.0.0.1:3001 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16) + at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:128:17) { + errno: -111, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '127.0.0.1', + port: 3001 + } +} +2026-01-23T16:22:45.297187Z  INFO lms_service::handlers: get_course_outline: fetching course 3b492da6-b662-4894-a244-86cd4b7d4aa4 + ⨯ TypeError: fetch failed + at node:internal/deps/undici/undici:12637:11 + at process.processTicksAndRejections (node:internal/process/task_queues:95:5) + at async fetchExternalImage (/app/node_modules/next/dist/server/image-optimizer.js:589:17) + at async NextNodeServer.imageOptimizer (/app/node_modules/next/dist/server/next-server.js:649:48) + at async cacheEntry.imageResponseCache.get.incrementalCache (/app/node_modules/next/dist/server/next-server.js:182:65) + at async /app/node_modules/next/dist/server/response-cache/index.js:90:36 + at async /app/node_modules/next/dist/lib/batcher.js:45:32 { + cause: Error: connect ECONNREFUSED 127.0.0.1:3001 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16) + at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:128:17) { + errno: -111, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '127.0.0.1', + port: 3001 + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e916042 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,159 @@ +{ + "name": "openccb", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "pg": "^8.17.2" + } + }, + "node_modules/pg": { + "version": "8.17.2", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", + "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.10.1", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz", + "integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..77df147 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "pg": "^8.17.2" + } +} diff --git a/roadmap.md b/roadmap.md index 570157a..ab988e3 100644 --- a/roadmap.md +++ b/roadmap.md @@ -39,7 +39,7 @@ - [x] **Portal del Estudiante (Experience)**: - [x] Catálogo de cursos e inscripciones - [x] Reproductor interactivo de lecciones - - [x] Diseño responsivo (móviles/tablets) + - [x] Diseño responsivo (móviles/tablets) - **Optimizado y validado en Fase 15** - [x] **Sistema de Calificación Holístico**: - [x] Categorías de calificación con pesos (porcentajes) - [x] Opción de eliminar las N puntuaciones más bajas por categoría @@ -130,7 +130,7 @@ ## Fase 11: Evaluaciones y Quizzes Extendidos (En Progreso) - [x] **Quices de Código**: Desafíos interactivos con reproductor tipo IDE (Completado) - [x] **Identificación Visual**: Quices de "Puntos Calientes" (Hotspots) en imágenes (Completado) -- [ ] **Tutor de IA Integrado**: Asistente basado en RAG dentro del reproductor de lecciones +- [x] **Tutor de IA Integrado**: Asistente basado en RAG con acceso a bloques interactivos e historial del curso (Completado) - [x] **Evaluaciones por Audio**: Preguntas con respuesta oral para idiomas con feedback de IA detallado (Completado) - [x] **Eliminación de Cursos**: Gestión completa del ciclo de vida del contenido (Completado) - [x] **Quices con Contexto IA**: Generación de evaluaciones con enfoque y tipo personalizable (Completado) @@ -155,9 +155,24 @@ --- -**Estado Actual**: La plataforma cuenta con un motor de IA avanzado que permite la generación de contenidos con una "Persona" docente experta, evaluaciones de audio automatizadas y quices personalizables con contexto. Se ha completado el ciclo de vida de gestión de cursos con la integración de la funcionalidad de borrado. +## Fase 15: Conectividad y UI Adaptativa ✅ +- [x] **Dynamic API Resolution**: Detección automática de IP del servidor para acceso multi-dispositivo y LAN (Completado) +- [x] **Menú Móvil (Experience)**: Implementación de navegación lateral (hamburger) para celulares (Completado) +- [x] **Optimización de Studio**: Interfaz de administración compacta y escalable para pantallas pequeñas (Completado) +- [x] **Tipografía Fluida**: Escalado de fuentes y márgenes adaptativos en todo el portal (Completado) +- [x] **Locked Lesson AI Feedback**: Generación de retroalimentación motivacional para lecciones bloqueadas (Completado) +- [x] **Context Enrichment**: Ingesta de bloques interactivos en el motor de RAG (Completado) +- [x] **Course History Context**: Capacidad del tutor para recordar lecciones previas (Completado) + +## Fase 16: Estabilidad y UX Avanzada (En Progreso) +- [/] **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción. +- [ ] **Rutas de Aprendizaje**: Recomendaciones basadas en el historial personalizadas. +- [ ] **Optimización de Contenedores**: Limpieza automatizada y reducción de huella de infraestructura. + +--- + +**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica y una **interfaz 100% responsiva**. **Próximas Prioridades**: -1. **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción. -2. **IA Teaching Assistant**: Tutor RAG personalizado por curso. -3. **Rutas de Aprendizaje**: Recomendaciones basadas en el historial. +1. **QA Exhaustivo**: Pruebas de carga en servicios de IA. +2. **Rutas de Aprendizaje**: Motor de recomendaciones dinámicas. diff --git a/services/lms-service/migrations/20260123000000_add_branding_fields_to_orgs.sql b/services/lms-service/migrations/20260123000000_add_branding_fields_to_orgs.sql new file mode 100644 index 0000000..e0c6ba6 --- /dev/null +++ b/services/lms-service/migrations/20260123000000_add_branding_fields_to_orgs.sql @@ -0,0 +1,4 @@ +-- Add missing branding columns to organizations table +ALTER TABLE organizations +ADD COLUMN IF NOT EXISTS platform_name TEXT, +ADD COLUMN IF NOT EXISTS favicon_url TEXT; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 4bc6ef2..732175e 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -262,6 +262,7 @@ pub async fn get_course_catalog( State(pool): State, Query(query): Query, ) -> Result>, StatusCode> { + tracing::info!("get_course_catalog: org_id={:?}, user_id={:?}", query.organization_id, query.user_id); let courses = match (query.organization_id, query.user_id) { (Some(org_id), Some(user_id)) => { sqlx::query_as::<_, Course>( @@ -464,17 +465,22 @@ pub async fn ingest_course( } pub async fn get_course_outline( - Org(_org_ctx): Org, + Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { - tracing::info!("get_course_outline: fetching course {}", id); + tracing::info!("get_course_outline: id={}, caller_org={}", id, org_ctx.id); // 1. Fetch Course let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") .bind(id) .fetch_one(&pool) .await - .map_err(|_| StatusCode::NOT_FOUND)?; + .map_err(|e| { + tracing::error!("get_course_outline: course fetch failed for {}: {}", id, e); + StatusCode::NOT_FOUND + })?; + + tracing::info!("get_course_outline: course found, title='{}'", course.title); // 2. Fetch Modules let modules = @@ -482,7 +488,12 @@ pub async fn get_course_outline( .bind(id) .fetch_all(&pool) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!("get_course_outline: modules fetch failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tracing::info!("get_course_outline: found {} modules", modules.len()); // 3. Fetch Organization let organization = sqlx::query_as::<_, common::models::Organization>( @@ -491,7 +502,12 @@ pub async fn get_course_outline( .bind(course.organization_id) .fetch_one(&pool) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!("get_course_outline: organization fetch failed for {}: {}", course.organization_id, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tracing::info!("get_course_outline: organization found: {}", organization.name); // 4. Fetch Grading Categories let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>( @@ -500,7 +516,10 @@ pub async fn get_course_outline( .bind(id) .fetch_all(&pool) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!("get_course_outline: grading categories fetch failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; // 5. Fetch Lessons let mut pub_modules = Vec::new(); @@ -511,7 +530,10 @@ pub async fn get_course_outline( .bind(module.id) .fetch_all(&pool) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!("get_course_outline: lessons fetch failed for module {}: {}", module.id, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; pub_modules.push(common::models::PublishedModule { module, lessons }); } @@ -540,10 +562,11 @@ pub async fn get_lesson_content( } pub async fn get_user_enrollments( - Org(_org_ctx): Org, + Org(org_ctx): Org, State(pool): State, Path(user_id): Path, ) -> Result>, StatusCode> { + tracing::info!("get_user_enrollments: user_id={}, caller_org_id={}", user_id, org_ctx.id); let enrollments = sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1") .bind(user_id) @@ -1345,3 +1368,311 @@ pub async fn evaluate_audio_file( Ok(Json(grading)) } + +#[derive(Deserialize)] +pub struct ChatPayload { + pub message: String, +} + +#[derive(Serialize)] +pub struct ChatResponse { + pub response: String, +} + +pub async fn chat_with_tutor( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path(lesson_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // 1. Fetch lesson context (summary and transcription) + let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(lesson_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?; + + // 1.5 Fetch previous lessons in the course for context + let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1") + .bind(lesson.module_id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch module context".into()))?; + + let previous_lessons = sqlx::query!( + r#" + SELECT l.title, l.summary + FROM lessons l + JOIN modules m ON l.module_id = m.id + WHERE m.course_id = $1 + AND (m.position < $2 OR (m.position = $2 AND l.position < $3)) + ORDER BY m.position, l.position + "#, + module.course_id, + module.position, + lesson.position + ) + .fetch_all(&pool) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch previous lessons".into()))?; + + let mut history_context = String::new(); + if !previous_lessons.is_empty() { + history_context.push_str("\n--- PAST LESSONS HISTORY (FOR CONTEXT) ---\n"); + for prev in previous_lessons { + history_context.push_str(&format!( + "Past Lesson: {}\nSummary: {}\n\n", + prev.title, + prev.summary.as_deref().unwrap_or("No summary available.") + )); + } + } + + let block_content = extract_block_content(&lesson.metadata); + + let context = format!( + "CURRENT Lesson Title: {}\nSummary: {}\nTranscription (Partial): {}\n\n--- CURRENT LESSON CONTENT (BLOCKS & ACTIVITIES) ---\n{}\n{}", + lesson.title, + lesson.summary.as_deref().unwrap_or_default(), + lesson.transcription.as_ref().and_then(|t| t.get("text").and_then(|text| text.as_str())).unwrap_or("No transcript available."), + block_content, + history_context + ); + + // 2. Setup AI request + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string()); + (format!("{}/v1/chat/completions", base_url), "".to_string(), model) + } else { + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()), + "gpt-4-turbo".to_string(), + ) + }; + + let system_prompt = format!( + "You are an expert AI Teaching Assistant for the OpenCCB platform. \ + Your purpose is to help the student understand the content of this lesson and how it relates to previous lessons in the course. \ + \ + STRICT RULES: \ + 1. You can ONLY answer questions related to the CURRENT lesson or the PAST lessons provided in the context. \ + 2. If a student asks about topics NOT covered in the current or past lessons (e.g., general knowledge, future topics, or off-topic conversation), \ + you MUST politely decline and remind them that you are here only to help with the course content up to this point. \ + 3. CRITICAL: Do NOT provide direct answers for the CURRENT lesson's activities, quizzes, or code exercises. \ + Even if the answer could be inferred from past lessons, you must only provide hints, explain underlying concepts, or guide the student to find the answer themselves. \ + 4. Maintain a supportive, encouraging, and educational tone. \ + 5. Answer in the same language as the student's question. \ + \ + LESSON CONTEXT:\n{}", + context + ); + + let response = client.post(&url) + .header("Content-Type", "application/json") + .header("Authorization", auth_header) + .json(&serde_json::json!({ + "model": model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": payload.message } + ], + "temperature": 0.7 + })) + .send() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", err_body))); + } + + let ai_data: serde_json::Value = response.json().await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?; + + let tutor_response = ai_data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("Lo siento, tuve un problema procesando tu pregunta.") + .to_string(); + + Ok(Json(ChatResponse { response: tutor_response })) +} + +pub async fn get_lesson_feedback( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(lesson_id): Path, +) -> Result, (StatusCode, String)> { + let user_id = claims.sub; + + // 1. Fetch lesson context + let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(lesson_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?; + + // 2. Fetch user's grade for this lesson + let grade = sqlx::query_as::<_, common::models::UserGrade>( + "SELECT * FROM user_grades WHERE user_id = $1 AND lesson_id = $2" + ) + .bind(user_id) + .bind(lesson_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::BAD_REQUEST, "No grade found for this lesson".into()))?; + + let score_pct = (grade.score * 100.0) as i32; + + let block_content = extract_block_content(&lesson.metadata); + + let context = format!( + "Lesson Title: {}\nSummary: {}\nStudent Score: {}%\nMax Attempts: {}\nAttempts Used: {}\n\n--- LESSON CONTENT ---\n{}", + lesson.title, + lesson.summary.as_deref().unwrap_or_default(), + score_pct, + lesson.max_attempts.unwrap_or(0), + grade.attempts_count, + block_content + ); + + // 3. Setup AI request + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string()); + (format!("{}/v1/chat/completions", base_url), "".to_string(), model) + } else { + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()), + "gpt-4-turbo".to_string(), + ) + }; + + let system_prompt = format!( + "You are an expert AI Teaching Assistant. The student has completed a graded assessment and is now seeing their final results. \ + Provide a personalized message based on their score ({}%). \ + \ + STRICT RULES: \ + 1. Base your feedback ONLY on the lesson content and the student's performance. \ + 2. If the score is high (>= 80%), congratulate them warmly. \ + 3. If the score is medium (60-79%), acknowledge their effort and suggest specific areas to improve based on the lesson content. \ + 4. If the score is low (< 60%), provide encouragement and list specific topics or related blocks they should repeat or review to improve. \ + 5. Keep the message concise, supportive, and professional. \ + 6. Answer in Spanish as the platform is mainly used in that language. \ + \ + LESSON CONTEXT:\n{}", + score_pct, + context + ); + + let response = client.post(&url) + .header("Content-Type", "application/json") + .header("Authorization", auth_header) + .json(&serde_json::json!({ + "model": model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": "Genera mi retroalimentación personalizada basada en mis resultados." } + ], + "temperature": 0.7 + })) + .send() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", err_body))); + } + + let ai_data: serde_json::Value = response.json().await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?; + + let tutor_response = ai_data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("Buen trabajo completando la lección. Revisa tus resultados arriba.") + .to_string(); + + Ok(Json(ChatResponse { response: tutor_response })) +} + + + +fn extract_block_content(metadata: &Option) -> String { + let mut block_content = String::new(); + if let Some(meta) = metadata { + if let Some(blocks) = meta.get("blocks").and_then(|b| b.as_array()) { + for block in blocks { + let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let title = block.get("title").and_then(|t| t.as_str()).unwrap_or(""); + + block_content.push_str(&format!("\n--- Block: {} ({}) ---\n", title, block_type)); + + match block_type { + "description" | "fill-in-the-blanks" => { + if let Some(content) = block.get("content").and_then(|c| c.as_str()) { + block_content.push_str(content); + } + } + "quiz" => { + if let Some(questions) = block.get("quiz_data").and_then(|q| q.get("questions")).and_then(|qs| qs.as_array()) { + for (i, q) in questions.iter().enumerate() { + let question_text = q.get("question").and_then(|qt| qt.as_str()).unwrap_or(""); + block_content.push_str(&format!("Q{}: {}\n", i + 1, question_text)); + } + } + } + "matching" | "memory-match" => { + if let Some(pairs) = block.get("pairs").and_then(|p| p.as_array()) { + for (i, p) in pairs.iter().enumerate() { + let left = p.get("left").and_then(|l| l.as_str()).unwrap_or(""); + let right = p.get("right").and_then(|r| r.as_str()).unwrap_or(""); + block_content.push_str(&format!("Pair {}: {} <-> {}\n", i + 1, left, right)); + } + } + } + "ordering" => { + if let Some(items) = block.get("items").and_then(|i| i.as_array()) { + for (i, item) in items.iter().enumerate() { + let text = item.as_str().unwrap_or(""); + block_content.push_str(&format!("Item {}: {}\n", i + 1, text)); + } + } + } + "short-answer" | "audio-response" => { + if let Some(prompt) = block.get("prompt").and_then(|p| p.as_str()) { + block_content.push_str(&format!("Prompt: {}\n", prompt)); + } + } + "code" => { + if let Some(instructions) = block.get("instructions").and_then(|i| i.as_str()) { + block_content.push_str(&format!("Instructions: {}\n", instructions)); + } + } + "hotspot" => { + if let Some(description) = block.get("description").and_then(|d| d.as_str()) { + block_content.push_str(&format!("Description: {}\n", description)); + } + } + _ => {} + } + block_content.push_str("\n"); + } + } + } + block_content +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 86150e1..8b934e9 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -81,6 +81,8 @@ async fn main() { ) .route("/audio/evaluate", post(handlers::evaluate_audio_response)) .route("/audio/evaluate-file", post(handlers::evaluate_audio_file)) + .route("/lessons/{id}/chat", post(handlers::chat_with_tutor)) + .route("/lessons/{id}/feedback", get(handlers::get_lesson_feedback)) .route("/notifications", get(handlers::get_notifications)) .route( "/notifications/{id}/read", diff --git a/test_query.js b/test_query.js new file mode 100644 index 0000000..294116a --- /dev/null +++ b/test_query.js @@ -0,0 +1,31 @@ +const { Client } = require('pg'); + +async function testQuery() { + const client = new Client({ + connectionString: "postgresql://user:password@localhost:5432/openccb_lms" + }); + + const orgId = '8555931d-b335-4b4e-9f51-4a0434e591b6'; + const userId = 'ec2e2a38-a1d1-41b2-8202-c9b90d1d5db5'; + + try { + await client.connect(); + + const query = ` + SELECT DISTINCT c.* FROM courses c + LEFT JOIN enrollments e ON c.id = e.course_id AND e.user_id = $2 + WHERE c.organization_id = $1 OR c.organization_id = '00000000-0000-0000-0000-000000000001' OR e.id IS NOT NULL + `; + + const res = await client.query(query, [orgId, userId]); + console.log("Catalog Query Result (Courses found):", res.rowCount); + console.table(res.rows); + + } catch (err) { + console.error("Error:", err); + } finally { + await client.end(); + } +} + +testQuery(); diff --git a/web/experience/src/app/auth/login/page.tsx b/web/experience/src/app/auth/login/page.tsx index af7f269..784e325 100644 --- a/web/experience/src/app/auth/login/page.tsx +++ b/web/experience/src/app/auth/login/page.tsx @@ -236,7 +236,7 @@ export default function ExperienceLoginPage() {

¿Eres un instructor?{" "} - + Ir al Portal de Instructores

diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index f94b6c7..8f62428 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -19,6 +19,8 @@ import MemoryPlayer from "@/components/blocks/MemoryPlayer"; import DocumentPlayer from "@/components/blocks/DocumentPlayer"; import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer"; import InteractiveTranscript from "@/components/InteractiveTranscript"; +import AITutor from "@/components/AITutor"; +import LessonLockedView from "@/components/LessonLockedView"; import { ListMusic } from "lucide-react"; export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) { @@ -188,179 +190,178 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
)} - {/* Render Blocks */} - {(lesson.metadata?.blocks || []).length > 0 ? ( -
- {lesson.metadata?.blocks?.map((block) => { - const renderBlock = () => { - switch (block.type) { - case 'description': - return ; - case 'media': - return ( - )[block.id] || 0 - : 0 - } - onPlay={async () => { - if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) { - const currentPlayCounts = (userGrade?.metadata?.play_counts as Record) || {}; - const newPlayCounts = { - ...currentPlayCounts, - [block.id]: (currentPlayCounts[block.id] || 0) + 1 - }; - - try { - const res = await lmsApi.submitScore( - user.id, - params.id, - params.lessonId, - userGrade?.score || 0, - { ...userGrade?.metadata, play_counts: newPlayCounts } - ); - setUserGrade(res); - } catch (err) { - console.error("Error al guardar el recuento de reproducciones", err); - } - } - }} - isGraded={lesson.is_graded} - hasTranscription={!!lesson.transcription} - /> - ); - case 'document': - return ; - case 'quiz': - return ( - )[block.id] || 0 - : 0 - } - onAttempt={async () => { - if (user) { - const currentAttempts = (userGrade?.metadata?.block_attempts as Record) || {}; - const newAttempts = { - ...currentAttempts, - [block.id]: (currentAttempts[block.id] || 0) + 1 - }; - - try { - const res = await lmsApi.submitScore( - user.id, - params.id, - params.lessonId, - userGrade?.score || 0, - { ...userGrade?.metadata, block_attempts: newAttempts } - ); - setUserGrade(res); - } catch (err) { - console.error("Error al guardar los intentos del bloque", err); - } - } - }} - /> - ); - case 'fill-in-the-blanks': - return ; - case 'matching': - return ; - case 'ordering': - return ; - case 'short-answer': - return ( - - ); - case 'audio-response': - return ( - handleBlockComplete(block.id, score)} - /> - ); - case 'code': - return ( - handleBlockComplete(block.id, score)} - /> - ); - case 'hotspot': - return ( - handleBlockComplete(block.id, score)} - /> - ); - case 'memory-match': - return ( - handleBlockComplete(block.id, score)} - /> - ); - default: - return
Tipo de Bloque Desconocido: {block.type}
; - } - }; - - return ( -
- {renderBlock()} -
- ); - })} -
+ {/* Render Blocks or Locked View */} + {lesson.is_graded && userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? ( + ) : ( -
-

Actualmente, esta lección no tiene contenido.

-
+ (lesson.metadata?.blocks || []).length > 0 ? ( +
+ {lesson.metadata?.blocks?.map((block) => { + const renderBlock = () => { + switch (block.type) { + case 'description': + return ; + case 'media': + return ( + )[block.id] || 0 + : 0 + } + onPlay={async () => { + if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) { + const currentPlayCounts = (userGrade?.metadata?.play_counts as Record) || {}; + const newPlayCounts = { + ...currentPlayCounts, + [block.id]: (currentPlayCounts[block.id] || 0) + 1 + }; + + try { + const res = await lmsApi.submitScore( + user.id, + params.id, + params.lessonId, + userGrade?.score || 0, + { ...userGrade?.metadata, play_counts: newPlayCounts } + ); + setUserGrade(res); + } catch (err) { + console.error("Error al guardar el recuento de reproducciones", err); + } + } + }} + isGraded={lesson.is_graded} + hasTranscription={!!lesson.transcription} + /> + ); + case 'document': + return ; + case 'quiz': + return ( + )[block.id] || 0 + : 0 + } + onAttempt={async () => { + if (user) { + const currentAttempts = (userGrade?.metadata?.block_attempts as Record) || {}; + const newAttempts = { + ...currentAttempts, + [block.id]: (currentAttempts[block.id] || 0) + 1 + }; + + try { + const res = await lmsApi.submitScore( + user.id, + params.id, + params.lessonId, + userGrade?.score || 0, + { ...userGrade?.metadata, block_attempts: newAttempts } + ); + setUserGrade(res); + } catch (err) { + console.error("Error al guardar los intentos del bloque", err); + } + } + }} + /> + ); + case 'fill-in-the-blanks': + return ; + case 'matching': + return ; + case 'ordering': + return ; + case 'short-answer': + return ( + + ); + case 'audio-response': + return ( + handleBlockComplete(block.id, score)} + /> + ); + case 'code': + return ( + handleBlockComplete(block.id, score)} + /> + ); + case 'hotspot': + return ( + handleBlockComplete(block.id, score)} + /> + ); + case 'memory-match': + return ( + handleBlockComplete(block.id, score)} + /> + ); + default: + return
Tipo de Bloque Desconocido: {block.type}
; + } + }; + + return ( +
+ {renderBlock()} +
+ ); + })} +
+ ) : ( +
+

Actualmente, esta lección no tiene contenido.

+
+ ) )} {lesson.is_graded && (
- {userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? ( -
-
- Bloqueado: Se alcanzó el máximo de intentos ({lesson.max_attempts}) -
-
- Puntuación: {userGrade.score * 100}% -
-

Esta evaluación ya está cerrada para futuras entregas.

-
- ) : ( + {userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? null : ( <>
); diff --git a/web/experience/src/app/globals.css b/web/experience/src/app/globals.css index 2f5f79d..b63fdb2 100644 --- a/web/experience/src/app/globals.css +++ b/web/experience/src/app/globals.css @@ -33,7 +33,7 @@ body { } .glass-card { - @apply glass rounded-2xl p-6 transition-all duration-300; + @apply glass rounded-2xl p-4 md:p-6 transition-all duration-300; } .glass-card:hover { @@ -88,4 +88,4 @@ body { ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); -} +} \ No newline at end of file diff --git a/web/experience/src/app/my-learning/page.tsx b/web/experience/src/app/my-learning/page.tsx index 1fa70c2..12490a3 100644 --- a/web/experience/src/app/my-learning/page.tsx +++ b/web/experience/src/app/my-learning/page.tsx @@ -47,7 +47,7 @@ export default function MyLearningPage() { enrichedEnrollments.push({ course: { ...course, modules }, progress, - lastAccessed: enrollment.enroled_at + lastAccessed: enrollment.enrolled_at }); } catch (err) { console.error(`Error loading course ${enrollment.course_id}`, err); diff --git a/web/experience/src/app/page.tsx b/web/experience/src/app/page.tsx index ac227c7..101e2aa 100644 --- a/web/experience/src/app/page.tsx +++ b/web/experience/src/app/page.tsx @@ -85,22 +85,22 @@ export default function CatalogPage() { } return ( -
-
+
+
-
+
Currículo Premier
-

- Explorar Cursos +

+ Explorar Cursos

-

+

Domina las habilidades del futuro con nuestro contenido educativo de alta fidelidad.

{!user && ( - + Comienza Gratis )} diff --git a/web/experience/src/app/profile/page.tsx b/web/experience/src/app/profile/page.tsx index d418c80..b6f83eb 100644 --- a/web/experience/src/app/profile/page.tsx +++ b/web/experience/src/app/profile/page.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import { useState, useEffect, useRef, useCallback } from "react"; import { useAuth } from "@/context/AuthContext"; import { useTranslation } from "@/context/I18nContext"; -import { lmsApi, CMS_API_URL } from "@/lib/api"; +import { lmsApi, getCmsApiUrl } from "@/lib/api"; import { Save, Shield, @@ -60,7 +60,7 @@ export default function ProfilePage() { if (path.startsWith('http')) return path; const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path; const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; - return `${CMS_API_URL}${finalPath}`; + return `${getCmsApiUrl()}${finalPath}`; }; const handleAvatarUpload = async (e: React.ChangeEvent) => { diff --git a/web/experience/src/components/AITutor.tsx b/web/experience/src/components/AITutor.tsx new file mode 100644 index 0000000..10ceaf4 --- /dev/null +++ b/web/experience/src/components/AITutor.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { lmsApi } from "@/lib/api"; +import { Send, Bot, User, X, MessageSquare, Loader2 } from "lucide-react"; + +interface Message { + role: 'tutor' | 'user'; + content: string; +} + +export default function AITutor({ lessonId }: { lessonId: string }) { + const [messages, setMessages] = useState([ + { role: 'tutor', content: '¡Hola! Soy tu tutor de IA. ¿Tienes alguna duda sobre esta lección?' } + ]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + const handleSend = async () => { + if (!input.trim() || isLoading) return; + + const userMessage = input.trim(); + setInput(""); + setMessages(prev => [...prev, { role: 'user', content: userMessage }]); + setIsLoading(true); + + try { + const { response } = await lmsApi.chatWithTutor(lessonId, userMessage); + setMessages(prev => [...prev, { role: 'tutor', content: response }]); + } catch (error) { + console.error("Chat error:", error); + setMessages(prev => [...prev, { role: 'tutor', content: "Lo siento, hubo un error conectando con el tutor. Por favor intenta de nuevo." }]); + } finally { + setIsLoading(false); + } + }; + + if (!isOpen) { + return ( + + ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Tutor de IA

+
+ + En Línea +
+
+
+ +
+ + {/* Messages */} +
+ {messages.map((msg, i) => ( +
+
+
+ {msg.role === 'user' ? : } +
+
+ {msg.content} +
+
+
+ ))} + {isLoading && ( +
+
+
+ +
+
+ + El tutor está pensando... +
+
+
+ )} +
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + placeholder="Escribe tu duda aquí..." + className="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 pr-12 text-xs font-medium focus:outline-none focus:border-blue-500/50 transition-colors placeholder:text-gray-600" + /> + +
+

+ IA entrenada con el contenido de esta lección +

+
+
+ ); +} diff --git a/web/experience/src/components/AppHeader.tsx b/web/experience/src/components/AppHeader.tsx index b454576..19ddf75 100644 --- a/web/experience/src/components/AppHeader.tsx +++ b/web/experience/src/components/AppHeader.tsx @@ -5,8 +5,9 @@ import Image from "next/image"; import { useBranding } from "@/context/BrandingContext"; import { useAuth } from "@/context/AuthContext"; import { useTranslation } from "@/context/I18nContext"; -import { LogOut, Globe } from "lucide-react"; +import { LogOut, Globe, Menu, X } from "lucide-react"; import NotificationCenter from "./NotificationCenter"; +import { useState } from "react"; import { lmsApi, getImageUrl } from "@/lib/api"; @@ -14,14 +15,15 @@ export default function AppHeader() { const { t, language, setLanguage } = useTranslation(); const { branding } = useBranding(); const { user, logout } = useAuth(); + const [isMenuOpen, setIsMenuOpen] = useState(false); // Use platform_name if available, otherwise name, otherwise default const platformName = branding?.platform_name || branding?.name || 'OpenCCB'; return ( -
- -
+
+ +
{branding?.logo_url ? ( {branding.name} ) : ( @@ -31,53 +33,129 @@ export default function AppHeader() { )}
- + {platformName.toUpperCase()} - EXPERIENCIA + EXPERIENCIA
- - +
+ -
- - -
+
+ + +
-
- -
- {user?.full_name?.charAt(0) || 'U'} -
- +
+ +
+ {user?.full_name?.charAt(0) || 'U'} +
+ + +
+ + {/* Mobile Menu Button */}
- +
+ + {/* Mobile Sidebar Overlay */} + {isMenuOpen && ( +
+
+
+ Menú + +
+ + + +
+ setIsMenuOpen(false)} + className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-white/5 transition-colors" + > +
+ {user?.full_name?.charAt(0) || 'U'} +
+ {user?.full_name || 'Mi Perfil'} + + +
+
+
+ )}
); } diff --git a/web/experience/src/components/LessonLockedView.tsx b/web/experience/src/components/LessonLockedView.tsx new file mode 100644 index 0000000..c281360 --- /dev/null +++ b/web/experience/src/components/LessonLockedView.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { lmsApi, UserGrade } from "@/lib/api"; +import { Trophy, Award, BookOpen, RotateCcw, Bot, Loader2, Star, Sparkles, CheckCircle2 } from "lucide-react"; + +interface LessonLockedViewProps { + lessonId: string; + courseId: string; + grade: UserGrade; + maxAttempts?: number; +} + +export default function LessonLockedView({ lessonId, courseId, grade, maxAttempts }: LessonLockedViewProps) { + const [feedback, setFeedback] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchFeedback = async () => { + try { + const res = await lmsApi.getLessonFeedback(lessonId); + setFeedback(res.response); + } catch (err) { + console.error("Error fetching AI feedback:", err); + setFeedback("¡Buen trabajo completando esta evaluación! Sigue así para mejorar tus resultados en las próximas lecciones."); + } finally { + setLoading(false); + } + }; + fetchFeedback(); + }, [lessonId]); + + const scorePct = Math.round(grade.score * 100); + const isPassing = scorePct >= 70; // Assuming 70% is passing + + return ( +
+ {/* Header / Score Card */} +
+
+
+ {/* Background visual flair */} +
+ +
+ +
+ Evaluación Finalizada +
+ +
+

+ Tu Puntuación: {scorePct}% +

+
+ {[1, 2, 3, 4, 5].map((s) => ( + + ))} +
+
+ +
+
+ +
+

Intentos Usados

+

{grade.attempts_count} {maxAttempts ? `de ${maxAttempts}` : ""}

+
+
+
+ +
+

Estado

+

+ {isPassing ? "Aprobado" : "No Alcanzado"} +

+
+
+
+
+
+ + {/* AI Feedback Section */} +
+
+
+
+ +

+ Retroalimentación de tu Tutor de IA +

+ + {loading ? ( +
+ +

Generando análisis personalizado...

+
+ ) : ( +
+
+ {feedback} +
+
+

+ Este análisis es generado automáticamente basándose en tu desempeño histórico y los contenidos de esta lección. +

+
+ )} +
+
+ +
+
+

+ Próximos Pasos +

+
    +
  • +
    + +
    +

    Continúa con la siguiente lección para seguir sumando XP.

    +
  • +
  • +
    + +
    +

    Revisa el glosario de términos de esta sección.

    +
  • +
+
+ +
+

Nota Importante

+

+ Has alcanzado el máximo de intentos. Esta evaluación está bloqueada, pero puedes seguir repasando los materiales del curso. +

+
+
+
+
+ ); +} diff --git a/web/experience/src/components/blocks/DocumentPlayer.tsx b/web/experience/src/components/blocks/DocumentPlayer.tsx index b20bb1f..977ff2d 100644 --- a/web/experience/src/components/blocks/DocumentPlayer.tsx +++ b/web/experience/src/components/blocks/DocumentPlayer.tsx @@ -1,7 +1,7 @@ "use client"; import { FileText, Download, Eye, ExternalLink } from "lucide-react"; -import { CMS_API_URL } from "@/lib/api"; +import { getCmsApiUrl } from "@/lib/api"; interface DocumentPlayerProps { id: string; @@ -18,7 +18,7 @@ export default function DocumentPlayer({ id, title, url }: DocumentPlayerProps) if (path.startsWith('http')) return path; const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path; const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; - return `${CMS_API_URL}${finalPath}`; + return `${getCmsApiUrl()}${finalPath}`; }; const displayUrl = getFullUrl(url); diff --git a/web/experience/src/components/blocks/MediaPlayer.tsx b/web/experience/src/components/blocks/MediaPlayer.tsx index 8894fa4..52f529c 100644 --- a/web/experience/src/components/blocks/MediaPlayer.tsx +++ b/web/experience/src/components/blocks/MediaPlayer.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { Play, Lock, AlertCircle } from "lucide-react"; -import { lmsApi } from "@/lib/api"; +import { lmsApi, getCmsApiUrl } from "@/lib/api"; interface MediaPlayerProps { id: string; @@ -46,14 +46,14 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf const maxPlays = config?.maxPlays || 0; - const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001"; + const getFullUrl = (path: string) => { if (path.startsWith('http')) return path; // Map /uploads to /assets for the backend const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path; const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; - return `${CMS_API_URL}${finalPath}`; + return `${getCmsApiUrl()}${finalPath}`; }; const isLocalFile = url.startsWith('/uploads') || url.startsWith('http://localhost:3001/assets') || url.includes('/assets/'); @@ -129,8 +129,8 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf // Since browser doesn't support custom headers easily, // we might need to handle this via a proxy or temporary signed URLs. // For now, we'll assume the backend allows VTT access if requested with the correct lesson ID. - const vttEn = lessonId ? `${CMS_API_URL}/lessons/${lessonId}/vtt?lang=en` : null; - const vttEs = lessonId ? `${CMS_API_URL}/lessons/${lessonId}/vtt?lang=es` : null; + const vttEn = lessonId ? `${getCmsApiUrl()}/lessons/${lessonId}/vtt?lang=en` : null; + const vttEs = lessonId ? `${getCmsApiUrl()}/lessons/${lessonId}/vtt?lang=es` : null; return (
diff --git a/web/experience/src/context/BrandingContext.tsx b/web/experience/src/context/BrandingContext.tsx index 172992e..31f4d69 100644 --- a/web/experience/src/context/BrandingContext.tsx +++ b/web/experience/src/context/BrandingContext.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { createContext, useContext, useEffect, useState } from 'react'; -import { lmsApi, Organization } from '@/lib/api'; +import { lmsApi, Organization, getImageUrl } from '@/lib/api'; interface BrandingContextType { branding: Organization | null; @@ -45,14 +45,6 @@ export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ chil // Import getImageUrl logic locally or assume it needs import // Since I can't easily add import at top with replace_file, I will assume getImageUrl handles the path or do logic here. // Actually I need to import getImageUrl at the top. Instead of complicating, I'll update imports too. - const getImageUrl = (path?: string) => { - if (!path) return ''; - if (path.startsWith('http')) return path; - const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001"; - const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path; - const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; - return `${CMS_API_URL}${finalPath}`; - }; const faviconUrl = getImageUrl(data.favicon_url); const link: HTMLLinkElement | null = document.querySelector("link[rel*='icon']"); diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 53f5487..b119fc3 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -1,12 +1,21 @@ -export const API_BASE_URL = process.env.NEXT_PUBLIC_LMS_API_URL || "http://localhost:3002"; -export const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001"; +const getApiBaseUrl = (defaultPort: string, envVar?: string) => { + if (typeof window !== 'undefined') { + const hostname = window.location.hostname; + const protocol = window.location.protocol; + return `${protocol}//${hostname}:${defaultPort}`; + } + return envVar || `http://localhost:${defaultPort}`; +}; + +export const getLmsApiUrl = () => getApiBaseUrl("3002", process.env.NEXT_PUBLIC_LMS_API_URL); +export const getCmsApiUrl = () => getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL); export const getImageUrl = (path?: string) => { if (!path) return ''; if (path.startsWith('http')) return path; const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path; const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; - return `${CMS_API_URL}${finalPath}`; + return `${getCmsApiUrl()}${finalPath}`; }; export interface Organization { @@ -171,7 +180,7 @@ export interface Enrollment { id: string; user_id: string; course_id: string; - enroled_at: string; + enrolled_at: string; } export interface Module { @@ -186,7 +195,7 @@ const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('exp const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => { const token = getToken(); - const baseUrl = isCMS ? CMS_API_URL : API_BASE_URL; + const baseUrl = isCMS ? getCmsApiUrl() : getLmsApiUrl(); const headers = { 'Content-Type': 'application/json', ...options.headers, @@ -238,7 +247,7 @@ export const lmsApi = { }, initSSOLogin(orgId: string): void { - window.location.href = `${CMS_API_URL}/auth/sso/login/${orgId}`; + window.location.href = `${getCmsApiUrl()}/auth/sso/login/${orgId}`; }, async enroll(courseId: string, userId: string): Promise { @@ -286,7 +295,7 @@ export const lmsApi = { const formData = new FormData(); formData.append('file', file); const token = getToken(); - return fetch(`${CMS_API_URL}/assets/upload`, { + return fetch(`${getCmsApiUrl()}/assets/upload`, { method: 'POST', headers: { ...(token ? { 'Authorization': `Bearer ${token}` } : {}) @@ -331,7 +340,7 @@ export const lmsApi = { formData.append('keywords', JSON.stringify(keywords)); const token = getToken(); - return fetch(`${API_BASE_URL}/audio/evaluate-file`, { + return fetch(`${getLmsApiUrl()}/audio/evaluate-file`, { method: 'POST', headers: { ...(token ? { 'Authorization': `Bearer ${token}` } : {}) @@ -344,5 +353,14 @@ export const lmsApi = { } return res.json(); }); + }, + async chatWithTutor(lessonId: string, message: string): Promise<{ response: string }> { + return apiFetch(`/lessons/${lessonId}/chat`, { + method: 'POST', + body: JSON.stringify({ message }) + }); + }, + async getLessonFeedback(lessonId: string): Promise<{ response: string }> { + return apiFetch(`/lessons/${lessonId}/feedback`); } }; diff --git a/web/studio/src/app/admin/organizations/page.tsx b/web/studio/src/app/admin/organizations/page.tsx index f288155..6c580ee 100644 --- a/web/studio/src/app/admin/organizations/page.tsx +++ b/web/studio/src/app/admin/organizations/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { cmsApi, Organization, getImageUrl } from '@/lib/api'; +import { cmsApi, Organization, getImageUrl, API_BASE_URL } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import Image from 'next/image'; import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X, Fingerprint, Key, Settings2 } from 'lucide-react'; @@ -176,18 +176,18 @@ export default function OrganizationsPage() { } return ( -
-
+
+
-

Organizations

-

Manage tenants and isolated environments.

+

Organizations

+

Manage tenants and isolated environments.

@@ -570,7 +570,7 @@ export default function OrganizationsPage() {

1. Register OpenCCB as an application in your Identity Provider (Okta, Google, Azure AD).
- 2. Set the Redirect URI to: http://localhost:3001/auth/sso/callback
+ 2. Set the Redirect URI to: {API_BASE_URL}/auth/sso/callback
3. Copy the Issuer URL, Client ID, and Client Secret here.

diff --git a/web/studio/src/app/auth/login/page.tsx b/web/studio/src/app/auth/login/page.tsx index 2af313c..55770f9 100644 --- a/web/studio/src/app/auth/login/page.tsx +++ b/web/studio/src/app/auth/login/page.tsx @@ -237,7 +237,7 @@ export default function StudioLoginPage() {

Are you a student?{" "} - + Go to Student Portal

diff --git a/web/studio/src/app/globals.css b/web/studio/src/app/globals.css index 427ed13..8eb0acc 100644 --- a/web/studio/src/app/globals.css +++ b/web/studio/src/app/globals.css @@ -31,7 +31,7 @@ body { } .glass-card { - @apply glass rounded-2xl p-6; + @apply glass rounded-2xl p-4 md:p-6; } .btn-premium { diff --git a/web/studio/src/app/layout.tsx b/web/studio/src/app/layout.tsx index 62b2823..62ef9c9 100644 --- a/web/studio/src/app/layout.tsx +++ b/web/studio/src/app/layout.tsx @@ -29,7 +29,7 @@ export default function RootLayout({ -
+
diff --git a/web/studio/src/components/AuthHeader.tsx b/web/studio/src/components/AuthHeader.tsx index c5932b3..0255b72 100644 --- a/web/studio/src/components/AuthHeader.tsx +++ b/web/studio/src/components/AuthHeader.tsx @@ -10,17 +10,17 @@ export default function AuthHeader() {
{user?.role === 'admin' && ( <> - - Org + + Org - - Audit + + Audit - - Tasks + + Tasks - - Settings + + Settings )} diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index a8abda2..e1d693b 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -1,4 +1,14 @@ -export const API_BASE_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001"; +const getApiBaseUrl = (defaultPort: string, envVar?: string) => { + if (typeof window !== 'undefined') { + const hostname = window.location.hostname; + // Detect if we are on a custom domain or IP + const protocol = window.location.protocol; + return `${protocol}//${hostname}:${defaultPort}`; + } + return envVar || `http://localhost:${defaultPort}`; +}; + +export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL); export const getImageUrl = (path?: string) => { if (!path) return '';