diff --git a/.env.example b/.env.example index e5b22df..d0a7dd6 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,32 @@ -# Database URLs for local development (outside Docker) -# Change 'db' to 'localhost' if running the services natively -# NOTE: If port 5432 is occupied, use 5433 instead -CMS_DATABASE_URL=postgresql://user:password@localhost:5433/openccb_cms -LMS_DATABASE_URL=postgresql://user:password@localhost:5433/openccb_lms +# ======================================== +# OpenCCB Environment Configuration +# ======================================== +# NOTA: Este archivo es solo un ejemplo. +# El script deploy-ssl.sh generará valores seguros automáticamente. +# ======================================== -# General fallback -DATABASE_URL=postgresql://user:password@localhost:5433/openccb_cms +# ---------------------------------------- +# Database Configuration +# PRODUCCIÓN (Docker): Usar db:5432 +# DESARROLLO (Local): Usar localhost:5434 +# El script deploy-ssl.sh generará un valor seguro si no existe +# ---------------------------------------- +DB_PASSWORD=CHANGE_ME_GENERATE_SECURE_PASSWORD +JWT_SECRET=CHANGE_ME_GENERATE_SECURE_SECRET -# JWT Secret (generate with ./generate_jwt_secret.sh) -JWT_SECRET=supersecret +# Database URLs (producción: db:5432, desarrollo: localhost:5434) +CMS_DATABASE_URL=postgresql://user:DB_PASSWORD@db:5432/openccb_cms +LMS_DATABASE_URL=postgresql://user:DB_PASSWORD@db:5432/openccb_lms +DATABASE_URL=postgresql://user:DB_PASSWORD@db:5432/openccb_cms # Logging RUST_LOG=info +# Let's Encrypt Configuration +# true = Staging (certificados de prueba, sin rate limits) +# false = Production (certificados reales, con rate limits) +LETSENCRYPT_STAGING=true + # AI Configuration # Providers: 'openai' or 'local' AI_PROVIDER=local @@ -25,7 +39,7 @@ LOCAL_LLM_MODEL=llama3.2:3b # Embedding Model for semantic search (pgvector) EMBEDDING_MODEL=nomic-embed-text - + # Mercado Pago Configuration MP_ACCESS_TOKEN= MP_PUBLIC_KEY= diff --git a/.gitignore b/.gitignore index 29516d6..6889f72 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ yarn-debug.log* yarn-error.log* # --- Project-specific Development Keys --- services/lms-service/dev_keys/ + +# --- SSH Keys --- +*.pem +ubuntu.pem diff --git a/.qwen/settings.json b/.qwen/settings.json new file mode 100644 index 0000000..be3bc4e --- /dev/null +++ b/.qwen/settings.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(rm *)", + "Bash(sed *)", + "Bash(ls *)", + "Bash(true)", + "Bash(cargo check *)", + "Bash(cargo build *)", + "Bash(chmod *)", + "Bash(mkdir *)", + "Bash(ssh *)", + "Bash(git fetch *)", + "Bash(git add *)" + ] + }, + "$version": 3 +} \ No newline at end of file diff --git a/.qwen/settings.json.orig b/.qwen/settings.json.orig new file mode 100644 index 0000000..71a9513 --- /dev/null +++ b/.qwen/settings.json.orig @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(rm *)", + "Bash(sed *)", + "Bash(ls *)", + "Bash(true)", + "Bash(cargo check *)", + "Bash(cargo build *)", + "Bash(chmod *)", + "Bash(mkdir *)" + ] + } +} \ No newline at end of file diff --git a/AI_MODELS_CONFIG.md b/AI_MODELS_CONFIG.md deleted file mode 100644 index 017e04a..0000000 --- a/AI_MODELS_CONFIG.md +++ /dev/null @@ -1,208 +0,0 @@ -# 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/CHANGELOG_2026_03_18.md b/CHANGELOG_2026_03_18.md deleted file mode 100644 index 5fc4420..0000000 --- a/CHANGELOG_2026_03_18.md +++ /dev/null @@ -1,597 +0,0 @@ -# 📝 Changelog - 18 de Marzo, 2026 - -## Resumen del Día - -**Tema Principal:** Búsqueda Semántica con PGVector + Integración MySQL Completa - -**Archivos Nuevos:** 9 archivos -**Archivos Modificados:** 16 archivos -**Líneas Agregadas:** ~976 líneas -**Líneas Eliminadas:** ~156 líneas - ---- - -## 🎯 Características Principales - -### 1. **Búsqueda Semántica con PGVector** ⭐ - -#### Backend - CMS (Question Bank) - -**Migración:** `20260319000000_pgvector_embeddings.sql` - -**Características:** -- ✅ Embeddings de 768 dimensiones (nomic-embed-text) -- ✅ Búsqueda por similitud de coseno -- ✅ Detección de preguntas duplicadas -- ✅ Búsqueda semántica (no solo keywords) -- ✅ Funciones SQL para diversidad (MMR) - -**Funciones SQL Creadas:** -```sql --- Calcular similitud entre dos preguntas -question_similarity(q1_id, q2_id) → REAL - --- Encontrar preguntas similares (detección de duplicados) -find_similar_questions(question_id, threshold, limit) → TABLE - --- Búsqueda semántica con threshold -search_questions_semantic(org_id, embedding, limit, threshold) → TABLE - --- Obtener preguntas diversas (Maximal Marginal Relevance) -get_diverse_questions(org_id, embedding, limit, lambda) → TABLE -``` - -**Índices de Rendimiento:** -- IVFFlat con `lists = 100` (optimizado para >10k filas) -- Índice en `embedding_updated_at` para tracking - -#### Backend - LMS (Knowledge Base) - -**Migración:** `20260319000000_pgvector_knowledge_embeddings.sql` - -**Características:** -- ✅ Búsqueda semántica en base de conocimiento -- ✅ RAG mejorado para tutor IA -- ✅ Contexto de lecciones con prioridad -- ✅ Búsqueda global (todos los cursos) - -**Funciones SQL Creadas:** -```sql --- Búsqueda semántica dentro de un curso -search_knowledge_semantic(course_id, embedding, limit, threshold) → TABLE - --- Búsqueda global (admin) -search_knowledge_global(embedding, limit, threshold) → TABLE - --- Contexto de lección específica -get_lesson_context(lesson_id, embedding, limit) → TABLE -``` - -#### Handlers de Embeddings - -**CMS - `handlers_embeddings.rs` (NUEVO):** -```rust -POST /question-bank/embeddings/generate // Generar embeddings faltantes -POST /question-bank/{id}/embedding/regenerate // Regenerar embedding -GET /question-bank/semantic-search?query=... // Búsqueda semántica -GET /question-bank/similar/{id} // Preguntas similares -``` - -**LMS - `handlers_embeddings.rs` (NUEVO):** -```rust -POST /knowledge-base/embeddings/generate // Generar embeddings KB -POST /knowledge-base/{id}/embedding/regenerate // Regenerar embedding -GET /knowledge-base/semantic-search?query=... // Búsqueda semántica -``` - -#### Módulo AI Compartido - -**`shared/common/src/ai.rs` (NUEVO):** -```rust -// Constantes -DEFAULT_EMBEDDING_MODEL = "nomic-embed-text" -DEFAULT_OLLAMA_URL = "http://localhost:11434" -EMBEDDING_DIMENSIONS = 768 - -// Funciones -generate_embedding(client, url, model, text) → EmbeddingResponse -generate_embeddings_batch(...) → Vec -embedding_to_pgvector(embedding) → String // "[0.1,0.2,...]" -pgvector_to_embedding(pgvector) → Vec -``` - -**Configuración Docker:** -```yaml -# docker-compose.yml -db: - image: pgvector/pgvector:pg16 # Antes: postgres:16-alpine -``` - -**Variables de Entorno (.env.example):** -```bash -LOCAL_OLLAMA_URL=http://localhost:11434 -EMBEDDING_MODEL=nomic-embed-text -``` - ---- - -### 2. **Integración MySQL Mejorada** 🔄 - -#### Study Plans & Courses - -**Migración:** `20260318000000_mysql_courses_integration.sql` - -**Tablas Creadas:** - -**`mysql_study_plans`:** -```sql -- id (serial PK) -- mysql_id (int, unique) -- ID original en MySQL -- organization_id (uuid) -- name (varchar) -- course_type (varchar) -- regular/intensive -- is_active (bool) -- created_at, updated_at -``` - -**`mysql_courses`:** -```sql -- id (serial PK) -- mysql_id (int, unique) -- ID original en MySQL -- organization_id (uuid) -- study_plan_id (int, FK) -- name (varchar) -- level (int) -- duracion (int) -- duración en horas -- course_type (varchar) -- level_calculated (varchar) -- básico/intermedio/avanzado -- is_active (bool) -- created_at, updated_at -``` - -**Funciones de Importación:** - -**`handlers_question_bank.rs`:** -```rust -// Guardar planes y cursos desde MySQL -save_mysql_courses_and_plans(pool, org_id, plans, courses) → Result - -// Calcular course_type desde nombre del plan -calculate_course_type(plan_name) → String - -// Calcular nivel desde duración -calculate_course_level(level) → String -``` - -**Lógica de Clasificación:** -```rust -// Course Type -40h → "regular" -80h → "intensive" -120h → "advanced" - -// Level -1 → "básico" -2 → "intermedio" -3 → "avanzado" -4 → "experto" -``` - -#### Test Templates con MySQL Course ID - -**Cambios en `handlers_test_templates.rs`:** - -**Nuevo campo:** -```rust -pub struct TestTemplateFilters { - mysql_course_id: Option, // NUEVO: Filtrar por curso MySQL - level: Option, - course_type: Option, - // ... -} -``` - -**SQL Actualizado:** -```sql --- CREATE/INSERT -INSERT INTO test_templates ( - organization_id, created_by, name, description, mysql_course_id, - level, course_type, test_type, ... -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ...) - --- UPDATE -UPDATE test_templates -SET mysql_course_id = COALESCE($5, mysql_course_id), - level = COALESCE($6, level), - course_type = COALESCE($7, course_type), - ... -``` - -**Filtros Dinámicos:** -```rust -// Filtrar por mysql_course_id -if filters.mysql_course_id.is_some() { - query.push_str(&format!(" AND mysql_course_id = ${}", param_count)); -} -``` - ---- - -### 3. **Mejoras en Question Bank** 📚 - -#### Generación de Preguntas con RAG Mejorado - -**`handlers_question_bank.rs` - Funciones Agregadas:** - -```rust -// Generar pregunta individual con RAG + skills -generate_question_with_rag( - pool, claims, payload, ollama_client -) → Result - -// Buscar contexto relevante -find_relevant_context(pool, topic, organization_id) → Vec - -// Verificar 4 habilidades -verify_four_skills(question) → Result<(Reading, Listening, Speaking, Writing)> -``` - -**Flujo de Generación:** -1. Usuario ingresa tópico/contexto -2. Sistema busca contexto en question bank existente (semántico) -3. IA genera pregunta enfocada en 1 skill al azar -4. Verifica que cubra las 4 habilidades -5. Guarda con tags: `[skill, 'ai-generated', ...]` - -**Ejemplo de Respuesta:** -```json -{ - "question_text": "Read: 'Yesterday, John went to the store.' What did John do?", - "skill_assessed": "reading", - "tags": ["reading", "ai-generated", "past-tense", "grammar"], - "explanation": "The passage uses past tense to describe... 📊 Skill assessed: READING", - "question_type": "multiple-choice" -} -``` - ---- - -### 4. **Frontend - Test Templates** 🎨 - -#### Componentes Actualizados - -**`TestTemplateForm.tsx`:** -```typescript -// Nuevo campo -mysql_course_id?: number; - -// Filtros mejorados -interface TestTemplateFilters { - mysql_course_id?: number; - level?: CourseLevel; - course_type?: CourseType; - test_type?: TestType; - // ... -} -``` - -**`TestTemplateManager.tsx`:** -```typescript -// Filtrar por curso MySQL -const filteredTemplates = templates.filter(t => - !selectedCourse || t.mysql_course_id === selectedCourse -); -``` - -**`page.tsx`:** -```typescript -// Ruta actualizada -/app/test-templates/page.tsx -``` - -**`api.ts`:** -```typescript -// Nuevos endpoints -async function generateQuestionWithRAG(payload) → QuestionBank -async function getSemanticSearch(query, filters) → Questions[] -async function getSimilarQuestions(id, threshold) → Questions[] -async function generateEmbeddings() → Result -``` - ---- - -## 📊 Endpoints Nuevos - -### CMS (Port 3001) - -| Método | Endpoint | Descripción | -|--------|----------|-------------| -| POST | `/question-bank/embeddings/generate` | Generar embeddings para todas las preguntas | -| POST | `/question-bank/{id}/embedding/regenerate` | Regenerar embedding de pregunta específica | -| GET | `/question-bank/semantic-search` | Búsqueda semántica con query string | -| GET | `/question-bank/similar/{id}` | Encontrar preguntas similares (duplicados) | -| POST | `/question-bank/generate-with-rag` | Generar pregunta con RAG + 4 skills | -| GET | `/question-bank/mysql-courses` | Listar cursos importados desde MySQL | -| POST | `/question-bank/import-mysql-all` | Importar todos los cursos/preguntas desde MySQL | - -### LMS (Port 3002) - -| Método | Endpoint | Descripción | -|--------|----------|-------------| -| POST | `/knowledge-base/embeddings/generate` | Generar embeddings para knowledge base | -| POST | `/knowledge-base/{id}/embedding/regenerate` | Regenerar embedding específico | -| GET | `/knowledge-base/semantic-search` | Búsqueda semántica en knowledge base | - ---- - -## 🔧 Cambios Técnicos - -### Base de Datos - -**Extensiones:** -```sql -CREATE EXTENSION IF NOT EXISTS vector; -- PGVector -``` - -**Tipos de Columnas:** -```sql -embedding vector(768) -- 768 dimensiones para nomic-embed-text -``` - -**Índices:** -```sql --- IVFFlat para búsqueda rápida -CREATE INDEX idx_question_embeddings -ON question_bank USING ivfflat (embedding vector_cosine_ops) -WITH (lists = 100); -``` - -### Rust - Dependencias - -**`shared/common/Cargo.toml`:** -```toml -[dependencies] -reqwest = { version = "0.12", features = ["json"] } -serde = "1.0" -serde_json = "1.0" -thiserror = "2.0" -``` - -**`services/cms-service/Cargo.toml`:** -```toml -[dependencies] -common = { path = "../../shared/common" } # Para ai.rs -``` - -### Docker - -**`docker-compose.yml`:** -```yaml -db: - image: pgvector/pgvector:pg16 # CAMBIO: Ahora con pgvector - ports: - - "5433:5432" - environment: - - POSTGRES_USER=user - - POSTGRES_DB=openccb_cms -``` - ---- - -## 📈 Rendimiento - -### Búsqueda Semántica - -| Operación | Sin Índice | Con IVFFlat | Mejora | -|-----------|------------|-------------|--------| -| Similarity (10k rows) | ~500ms | ~20ms | 25x | -| Similarity (100k rows) | ~5s | ~50ms | 100x | - -### Generación de Embeddings - -- **Velocidad:** ~50ms por embedding (Ollama local) -- **Batch 100 preguntas:** ~5 segundos -- **Recomendación:** Generar en background (off-peak) - ---- - -## 🎯 Casos de Uso - -### 1. Detección de Preguntas Duplicadas - -```bash -curl -G "http://localhost:3001/question-bank/similar/{id}" \ - -d "threshold=0.95" \ - -H "Authorization: Bearer TOKEN" -``` - -**Respuesta:** -```json -[ - { - "id": "uuid-1", - "question_text": "What is the past tense of 'go'?", - "similarity": 0.97, - "question_type": "multiple-choice" - } -] -``` - -### 2. Búsqueda Semántica - -```bash -curl -G "http://localhost:3001/question-bank/semantic-search" \ - -d "query=preguntas sobre pasado simple en inglés" \ - -d "limit=10" \ - -d "threshold=0.6" \ - -H "Authorization: Bearer TOKEN" -``` - -**Respuesta:** -```json -[ - { - "id": "uuid-1", - "question_text": "Choose the correct past form: 'Yesterday I ___ to the store'", - "similarity": 0.87, - "tags": ["past-tense", "grammar"], - "difficulty": "medium" - } -] -``` - -### 3. RAG Mejorado para Generación - -```bash -curl -X POST "http://localhost:3001/question-bank/generate-with-rag" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer TOKEN" \ - -d '{ - "topic": "present perfect tense", - "context": "English grammar for Spanish speakers" - }' -``` - -**Proceso:** -1. Busca preguntas existentes sobre "present perfect" (semántico) -2. Extrae contexto relevante -3. IA genera nueva pregunta con ese contexto -4. Verifica 4 skills -5. Guarda con embedding automático - ---- - -## ✅ Checklist de Implementación - -### Backend -- [x] Migración PGVector CMS -- [x] Migración PGVector LMS -- [x] Migración MySQL courses integration -- [x] Handlers de embeddings (CMS) -- [x] Handlers de embeddings (LMS) -- [x] Módulo AI compartido (ai.rs) -- [x] Modelos actualizados (models.rs) -- [x] Rutas registradas en main.rs -- [x] Funciones SQL de similitud -- [x] Índices de rendimiento - -### Frontend -- [x] API client actualizado (api.ts) -- [x] TestTemplateForm con mysql_course_id -- [x] TestTemplateManager con filtros -- [x] Endpoints de semantic search -- [x] Generación de embeddings UI - -### Infraestructura -- [x] Docker image pgvector/pgvector:pg16 -- [x] Variables de entorno (.env.example) -- [x] Dependencias Rust (reqwest, serde) -- [x] Migraciones SQLx - ---- - -## 🚀 Comandos de Uso - -### Generar Embeddings - -```bash -# CMS - Question Bank -curl -X POST "http://localhost:3001/question-bank/embeddings/generate" \ - -H "Authorization: Bearer YOUR_TOKEN" - -# LMS - Knowledge Base -curl -X POST "http://localhost:3002/knowledge-base/embeddings/generate" \ - -H "Authorization: Bearer YOUR_TOKEN" -``` - -### Búsqueda Semántica - -```bash -# Question Bank -curl -G "http://localhost:3001/question-bank/semantic-search" \ - -d "query=verbs in past tense" \ - -d "limit=10" \ - -d "threshold=0.6" \ - -H "Authorization: Bearer TOKEN" -``` - -### Detección de Duplicados - -```bash -curl -G "http://localhost:3001/question-bank/similar/{question-id}" \ - -d "threshold=0.90" \ - -H "Authorization: Bearer TOKEN" -``` - ---- - -## 📝 Archivos Modificados - -### Nuevos (9 archivos) -``` -PGVECTOR_EMBEDDINGS.md -services/cms-service/migrations/20260318000000_mysql_courses_integration.sql -services/cms-service/migrations/20260319000000_pgvector_embeddings.sql -services/lms-service/migrations/20260319000000_pgvector_knowledge_embeddings.sql -services/cms-service/src/handlers_embeddings.rs -services/lms-service/src/handlers_embeddings.rs -shared/common/src/ai.rs -``` - -### Modificados (16 archivos) -``` -.env.example -Cargo.lock -docker-compose.yml -services/cms-service/Cargo.toml -services/cms-service/src/handlers_question_bank.rs -services/cms-service/src/handlers_test_templates.rs -services/cms-service/src/main.rs -services/lms-service/src/handlers.rs -services/lms-service/src/main.rs -shared/common/Cargo.toml -shared/common/src/lib.rs -shared/common/src/models.rs -web/studio/src/app/test-templates/page.tsx -web/studio/src/components/TestTemplates/TestTemplateForm.tsx -web/studio/src/components/TestTemplates/TestTemplateManager.tsx -web/studio/src/lib/api.ts -``` - ---- - -## 🎓 Próximos Pasos (Opcionales) - -1. **Optimización de Índices** - - Ajustar `lists` parameter según volumen de datos - - Monitorear rendimiento con EXPLAIN ANALYZE - -2. **Modelos de Embedding Alternativos** - - Probar `mxbai-embed-large` (1024 dims, mejor calidad) - - Probar `all-minilm` (384 dims, más rápido) - -3. **Caching de Embeddings** - - Cache de queries frecuentes - - Pre-generar embeddings para topics comunes - -4. **Analytics de Búsqueda** - - Trackear queries más populares - - Medir precisión de resultados - -5. **Multi-idioma** - - Embeddings cross-lingual (ES/EN/PT) - - Query rewriting automático - ---- - -## 📞 Referencias - -- **Documentación PGVector:** `PGVECTOR_EMBEDDINGS.md` -- **API Endpoints:** `README.md` -- **Guía de Optimización:** `OPTIMIZATIONS.md` - ---- - -**Fecha:** 18 de Marzo, 2026 -**Autor:** Equipo de Desarrollo OpenCCB -**Versión:** OpenCCB 0.2.0 diff --git a/CHAT_RESUMEN_2026-03-26.md b/CHAT_RESUMEN_2026-03-26.md new file mode 100644 index 0000000..5fb8033 --- /dev/null +++ b/CHAT_RESUMEN_2026-03-26.md @@ -0,0 +1,319 @@ +# OpenCCB - Resumen de Configuración y Despliegue +## Fecha: 26 de Marzo de 2026 + +--- + +## 📋 Resumen del Proyecto + +**OpenCCB** es una plataforma LMS/CMS de código abierto desplegada en **AWS EC2** con nginx-proxy para SSL automático. + +**Servidor AWS:** +- **Host**: `ec2-18-224-137-67.us-east-2.compute.amazonaws.com` +- **Usuario**: `ubuntu` +- **SSH Key**: `ubuntu.pem` +- **Región**: us-east-2 (Ohio) + +--- + +## 🔧 Configuración Actual + +### Dominios +- `studio.norteamericano.com` - CMS/Admin +- `learning.norteamericano.com` - LMS/Estudiantes + +### Arquitectura +``` +┌─────────────────────────────────────────────────────┐ +│ AWS EC2 │ +│ │ +│ ┌─────────────┐ ┌──────────────────────────┐ │ +│ │ nginx │───▶│ Studio + CMS │ │ +│ │ proxy │ │ (Next.js + Rust) │ │ +│ │ :80, :443 │ │ :3000, :3001 │ │ +│ └──────┬──────┘ └──────────┬───────────────┘ │ +│ │ │ │ +│ │ ┌─────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌──────────────────────────┐ │ +│ │ acme │ │ Experience + LMS │ │ +│ │ companion │ │ (Next.js + Rust) │ │ +│ │ (Let's │ │ :3003, :3002 │ │ +│ │ Encrypt) │ └──────────┬───────────────┘ │ +│ └─────────────┘ │ │ +│ │ │ +│ ┌──────────▼───────────────┐ │ +│ │ PostgreSQL + PGVector │ │ +│ │ :5432 │ │ +│ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 🚀 Comandos de Despliegue + +### Desde tu máquina local: + +```bash +# Ejecutar deploy +./deploy.sh +``` + +El script preguntará: +1. Nombre del administrador +2. Email del administrador +3. Contraseña (oculta) +4. Nombre de la organización +5. ¿Usar SSL? [y/N] +6. ¿Usar STAGING? [y/N] (solo si elegiste SSL) + +### Conexión al servidor: + +```bash +ssh -i "ubuntu.pem" ubuntu@ec2-18-224-137-67.us-east-2.compute.amazonaws.com +cd /var/www/openccb +``` + +--- + +## ⚠️ Problemas Conocidos (Para Resolver) + +### 1. Variables NEXT_PUBLIC en Studio + +**Problema**: El contenedor `openccb-studio` no está recibiendo ambas variables `NEXT_PUBLIC`: +- ✅ `NEXT_PUBLIC_LMS_API_URL=http://learning.norteamericano.com` +- ❌ `NEXT_PUBLIC_CMS_API_URL` - **FALTA** + +**Solución Aplicada**: +1. Actualizado `web/studio/Dockerfile` para aceptar ambos ARG +2. Actualizado `docker-compose.yml` para pasar ambos argumentos +3. Actualizado `deploy.sh` para verificar y reconstruir con `--no-cache` + +**Comandos para Verificar**: +```bash +# En el servidor +sudo docker exec openccb-studio env | grep NEXT_PUBLIC + +# Debería mostrar: +# NEXT_PUBLIC_CMS_API_URL=http://studio.norteamericano.com +# NEXT_PUBLIC_LMS_API_URL=http://learning.norteamericano.com +``` + +**Si persiste el problema**: +```bash +# Reconstruir manualmente +cd /var/www/openccb +sudo docker compose down +sudo docker rmi openccb-studio 2>/dev/null || true +sudo docker builder prune -f +sudo docker compose build --no-cache studio +sudo docker compose up -d studio +sudo docker exec openccb-studio env | grep NEXT_PUBLIC +``` + +### 2. Rate Limit de Let's Encrypt + +**Problema**: Se alcanzó el límite de 5 certificados por semana. + +**Solución Temporal**: +- Usar HTTP en lugar de HTTPS +- O usar Let's Encrypt Staging (certificados de prueba) + +**Fecha de Reinicio**: 2026-03-27 04:21:42 UTC + +--- + +## 📁 Archivos Modificados + +### 1. `deploy.sh` +- ✅ Pregunta datos del administrador +- ✅ Pregunta sobre SSL y Staging +- ✅ Actualiza docker-compose.yml según elección +- ✅ Reconstruye contenedores con `--no-cache` +- ✅ Verifica variables de entorno + +### 2. `docker-compose.yml` +- ✅ URLs en HTTP por defecto +- ✅ Ambos argumentos de build para studio +- ✅ Variables de entorno correctas + +### 3. `web/studio/Dockerfile` +- ✅ Agrega `ARG NEXT_PUBLIC_LMS_API_URL` +- ✅ Agrega `ENV NEXT_PUBLIC_LMS_API_URL` + +### 4. `web/studio/src/lib/api.ts` +- ✅ Corrige función `getApiBaseUrl` para priorizar variable de entorno + +--- + +## 🔐 Credenciales (Ejemplo) + +**Usuario Administrador**: +- Email: `admin@norteamericano.com` +- Contraseña: `Admin123!` (o la que se haya configurado) + +**Base de Datos** (en `/var/www/openccb/.env`): +``` +DB_PASSWORD= +JWT_SECRET= +``` + +--- + +## 📊 Comandos Útiles + +### Ver estado de servicios +```bash +sudo docker compose ps +``` + +### Ver logs +```bash +# Todos los servicios +sudo docker compose logs -f + +# Servicio específico +docker logs openccb-studio --tail 50 +docker logs openccb-experience --tail 50 +docker logs acme-companion --tail 50 +``` + +### Verificar variables de entorno +```bash +# Studio +sudo docker exec openccb-studio env | grep NEXT_PUBLIC + +# Experience +sudo docker exec openccb-experience env | grep NEXT_PUBLIC +``` + +### Reconstruir contenedores +```bash +# Todo +sudo docker compose build --no-cache +sudo docker compose up -d + +# Solo studio +sudo docker compose build --no-cache studio +sudo docker compose up -d studio +``` + +### Verificar certificados SSL +```bash +docker logs acme-companion --tail 50 +``` + +--- + +## 🎯 Próximos Pasos (Para Continuar Mañana) + +### 1. Verificar Variables NEXT_PUBLIC +```bash +ssh -i "ubuntu.pem" ubuntu@ec2-18-224-137-67.us-east-2.compute.amazonaws.com +cd /var/www/openccb + +# Verificar +sudo docker exec openccb-studio env | grep NEXT_PUBLIC + +# Si falta CMS_API_URL, reconstruir: +sudo docker compose down +sudo docker rmi openccb-studio 2>/dev/null || true +sudo docker builder prune -f +sudo docker compose build --no-cache studio +sudo docker compose up -d studio +``` + +### 2. Probar Login +- Acceder a `http://studio.norteamericano.com` +- Intentar loguearse con las credenciales del admin +- Verificar que no haya errores de conexión + +### 3. Cambiar a HTTPS (Después del Rate Limit) +```bash +# Después del 2026-03-27 +./deploy.sh +# Responder "y" a "¿Usar SSL?" +# Responder "n" a "¿Usar STAGING?" +``` + +### 4. Verificar Funcionalidades +- [ ] Login de administrador +- [ ] Creación de cursos +- [ ] Subida de archivos +- [ ] Integración con LMS +- [ ] Certificados SSL generados + +--- + +## 📝 Notas Importantes + +1. **HTTP vs HTTPS**: Actualmente se usa HTTP porque los certificados de staging no son válidos para las llamadas API entre dominios. + +2. **Rate Limit**: Let's Encrypt permite 5 certificados por semana por dominio. El límite se reinicia el 2026-03-27. + +3. **Variables de Entorno**: Es crítico que ambos `NEXT_PUBLIC_*` estén presentes en el contenedor de Studio para que las llamadas API funcionen correctamente. + +4. **Reconstrucción**: Siempre usar `--no-cache` al reconstruir para asegurar que los cambios en las variables de entorno se apliquen. + +--- + +## 🔧 Solución de Problemas Comunes + +### Error 502 Bad Gateway +```bash +# Verificar que los servicios están corriendo +sudo docker compose ps + +# Ver logs +docker logs openccb-studio --tail 50 +docker logs nginx-proxy --tail 50 + +# Reiniciar +sudo docker compose restart +``` + +### Variables NEXT_PUBLIC faltantes +```bash +# Ver docker-compose.yml +cat docker-compose.yml | grep -A 10 "studio:" + +# Verificar argumentos +sudo docker compose config | grep NEXT_PUBLIC + +# Reconstruir +sudo docker compose build --no-cache studio +``` + +### Certificados SSL no se generan +```bash +# Ver logs +docker logs acme-companion --tail 100 + +# Verificar DNS +dig studio.norteamericano.com +dig learning.norteamericano.com + +# Verificar puertos +sudo netstat -tlnp | grep :80 +``` + +--- + +## 📞 Contacto y Soporte + +**Documentación**: +- `DESPLIEGUE.md` - Instrucciones de despliegue +- `README.md` - Documentación general +- `docker-compose.yml` - Configuración de servicios + +**Archivos de Configuración**: +- `/var/www/openccb/.env` - Variables de entorno +- `/var/www/openccb/docker-compose.yml` - Servicios Docker +- `web/studio/Dockerfile` - Build de Studio +- `deploy.sh` - Script de despliegue + +--- + +**Última Actualización**: 26 de Marzo de 2026 +**Estado**: Pendiente verificar variables NEXT_PUBLIC en Studio diff --git a/DEBUG_AUDIO_RECORDING.md b/DEBUG_AUDIO_RECORDING.md deleted file mode 100644 index 4f38959..0000000 --- a/DEBUG_AUDIO_RECORDING.md +++ /dev/null @@ -1,242 +0,0 @@ -# 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 deleted file mode 100644 index f1decb9..0000000 --- a/DEBUG_MEMORY_GAME.md +++ /dev/null @@ -1,155 +0,0 @@ -# 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/DESPLIEGUE.md b/DESPLIEGUE.md new file mode 100644 index 0000000..63b88a5 --- /dev/null +++ b/DESPLIEGUE.md @@ -0,0 +1,345 @@ +# OpenCCB - Instrucciones de Despliegue (AWS EC2) + +## Resumen + +OpenCCB utiliza **nginx-proxy + acme-companion** para SSL automático con Let's Encrypt. + +| Componente | Función | +|------------|---------| +| **nginx-proxy** | Reverse proxy que maneja el tráfico HTTP/HTTPS | +| **acme-companion** | Gestiona certificados SSL de Let's Encrypt automáticamente | +| **Studio + CMS** | Administración de cursos (puerto 3000/3001) | +| **Experience + LMS** | Experiencia del estudiante (puerto 3003/3002) | +| **PostgreSQL + PGVector** | Base de datos con búsqueda semántica | + +--- + +## Despliegue Rápido + +### Paso 1: Desde tu máquina local + +```bash +# Ejecutar el script de despliegue +./deploy.sh +``` + +Este script hace **TODO** automáticamente: +1. ✅ Copia todos los archivos al servidor +2. ✅ Instala Docker y Docker Compose +3. ✅ Genera credenciales seguras (DB_PASSWORD, JWT_SECRET) +4. ✅ Configura las variables de entorno correctas +5. ✅ Inicia nginx-proxy y acme-companion +6. ✅ Crea las bases de datos +7. ✅ Inicia todos los servicios +8. ✅ Obtiene certificados SSL automáticamente + +--- + +### Paso 2: Verificar estado (opcional) + +```bash +# Conectarse al servidor +ssh -i "ubuntu.pem" ubuntu@ec2-18-224-137-67.us-east-2.compute.amazonaws.com +cd /var/www/openccb + +# Ver estado de contenedores +sudo docker compose ps + +# Ver logs de SSL +docker logs acme-companion --tail 50 +``` + +--- + +## URLs de Acceso + +Después del despliegue (esperar 2-5 minutos para SSL): + +``` +https://studio.norteamericano.com (CMS/Admin) +https://learning.norteamericano.com (LMS/Estudiantes) +``` + +--- + +## Requisitos Previos + +### 1. Configurar DNS + +Antes de ejecutar `deploy.sh`, configura los registros DNS en tu proveedor de dominio: + +| Tipo | Nombre | Valor | TTL | +|------|--------|-------|-----| +| A | @ | 18.224.137.67 | 300 | +| A | studio | 18.224.137.67 | 300 | +| A | learning | 18.224.137.67 | 300 | + +**Verificación:** +```bash +dig studio.norteamericano.com +dig learning.norteamericano.com +``` + +### 2. Configurar Security Group de AWS + +En la consola de AWS EC2, edita el Security Group de tu instancia: + +| Tipo | Protocolo | Puerto | Fuente | +|------|-----------|--------|--------| +| SSH | TCP | 22 | Tu IP | +| HTTP | TCP | 80 | 0.0.0.0/0 | +| HTTPS | TCP | 443 | 0.0.0.0/0 | + +### 3. Archivo de llave SSH + +Asegúrate de tener `ubuntu.pem` en el directorio del proyecto: +```bash +chmod 400 ubuntu.pem +``` + +--- + +## Comandos Útiles + +### Ver estado de servicios +```bash +sudo docker compose ps +``` + +### Ver logs en tiempo real +```bash +sudo docker compose logs -f +``` + +### Ver logs de un servicio específico +```bash +# Studio (CMS) +docker logs openccb-studio --tail 20 + +# Experience (LMS) +docker logs openccb-experience --tail 20 + +# Base de datos +docker logs openccb-db --tail 20 + +# nginx-proxy +docker logs nginx-proxy --tail 20 + +# SSL (acme-companion) +docker logs acme-companion --tail 50 +``` + +### Reiniciar servicios +```bash +# Reiniciar todo +sudo docker compose restart + +# Reiniciar un servicio específico +sudo docker compose restart studio +``` + +### Detener servicios +```bash +sudo docker compose down +``` + +### Reconstruir desde cero +```bash +# ⚠️ Esto elimina todos los datos +sudo docker compose down -v +sudo docker compose up -d --build +``` + +--- + +## Solución de Problemas + +### Error 502 Bad Gateway + +**Causa:** nginx-proxy no puede conectar con los servicios backend. + +**Solución:** +```bash +# 1. Verificar que los servicios están corriendo +sudo docker compose ps + +# 2. Ver logs de Studio +docker logs openccb-studio --tail 20 + +# 3. Ver logs de nginx +docker logs nginx-proxy --tail 20 + +# 4. Reiniciar servicios +sudo docker compose restart +``` + +### Certificados SSL no se generan + +**Síntomas:** +- Error de conexión SSL en el navegador +- `acme-companion` muestra errores de rate limit + +**Solución:** +```bash +# 1. Verificar DNS +dig studio.norteamericano.com +dig learning.norteamericano.com + +# 2. Verificar que nginx-proxy está corriendo +sudo docker compose ps nginx-proxy + +# 3. Ver logs de acme-companion +docker logs acme-companion --tail 100 + +# 4. Verificar puertos +sudo netstat -tlnp | grep -E ':80|:443' + +# 5. Esperar 1 hora (rate limit de Let's Encrypt) +``` + +### Base de datos no está disponible + +```bash +# 1. Verificar estado de la DB +sudo docker compose ps db + +# 2. Ver logs de la DB +docker logs openccb-db --tail 20 + +# 3. Reiniciar DB +sudo docker compose restart db + +# 4. Verificar bases de datos +sudo docker exec openccb-db psql -U user -d postgres -c "\l" +``` + +### Error de conexión SSH + +```bash +# Verificar permisos del archivo .pem +chmod 400 ubuntu.pem + +# Probar conexión +ssh -i "ubuntu.pem" ubuntu@ec2-18-224-137-67.us-east-2.compute.amazonaws.com +``` + +--- + +## Actualización de la Plataforma + +### Método recomendado + +```bash +# Desde tu máquina local +./deploy.sh +``` + +El script sincronizará los cambios y reconstruirá los contenedores automáticamente. + +--- + +## Backup y Restauración + +### Backup de base de datos + +```bash +# Conectarse al servidor +ssh -i "ubuntu.pem" ubuntu@ec2-18-224-137-67.us-east-2.compute.amazonaws.com +cd /var/www/openccb + +# Crear backup +sudo docker exec openccb-db pg_dump -U user openccb_cms > backup_cms_$(date +%Y%m%d).sql +sudo docker exec openccb-db pg_dump -U user openccb_lms > backup_lms_$(date +%Y%m%d).sql + +# Descargar backup a tu máquina local +scp -i "ubuntu.pem" ubuntu@ec2-18-224-137-67.us-east-2.compute.amazonaws.com:/var/www/openccb/backup_*.sql . +``` + +### Restaurar base de datos + +```bash +# Subir backup al servidor +scp -i "ubuntu.pem" backup_cms_20250101.sql ubuntu@ec2-18-224-137-67.us-east-2.compute.amazonaws.com:/var/www/openccb/ + +# Conectarse al servidor +ssh -i "ubuntu.pem" ubuntu@ec2-18-224-137-67.us-east-2.compute.amazonaws.com +cd /var/www/openccb + +# Restaurar +sudo docker exec -i openccb-db psql -U user -d openccb_cms < backup_cms_20250101.sql +sudo docker exec -i openccb-db psql -U user -d openccb_lms < backup_lms_20250101.sql +``` + +--- + +## Seguridad + +### Credenciales por Defecto + +Después de ejecutar `deploy.sh`, las credenciales se generan automáticamente: + +- **DB_PASSWORD**: Contraseña de base de datos (generada aleatoriamente) +- **JWT_SECRET**: Secreto JWT (generado aleatoriamente) + +Estas credenciales se guardan en el archivo `.env`. **Guárdalas en un lugar seguro**. + +### Usuario Admin Inicial + +El primer usuario admin se crea durante la instalación inicial con: +- **Email**: admin@norteamericano.com +- **Contraseña**: La que definas durante la instalación + +--- + +## Arquitectura del Sistema + +``` +┌─────────────────────────────────────────────────────┐ +│ AWS EC2 │ +│ │ +│ ┌─────────────┐ ┌──────────────────────────┐ │ +│ │ nginx │───▶│ Studio + CMS │ │ +│ │ proxy │ │ (Next.js + Rust) │ │ +│ │ :80, :443 │ │ :3000, :3001 │ │ +│ └──────┬──────┘ └──────────┬───────────────┘ │ +│ │ │ │ +│ │ ┌─────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌──────────────────────────┐ │ +│ │ acme │ │ Experience + LMS │ │ +│ │ companion │ │ (Next.js + Rust) │ │ +│ │ (Let's │ │ :3003, :3002 │ │ +│ │ Encrypt) │ └──────────┬───────────────┘ │ +│ └─────────────┘ │ │ +│ │ │ +│ ┌──────────▼───────────────┐ │ +│ │ PostgreSQL + PGVector │ │ +│ │ :5432 │ │ +│ └──────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## Notas Importantes + +1. **Certificados automáticos**: Se renuevan automáticamente con Let's Encrypt +2. **Puertos usados**: + - 80: HTTP (redirección a HTTPS, validación Let's Encrypt) + - 443: HTTPS (tráfico web) + - 5432: PostgreSQL (interno de Docker) + - 3000-3003: Internos de los servicios +3. **Volúmenes persistentes**: + - `postgres_data`: Datos de la base de datos + - `uploads_data`: Archivos subidos por usuarios + - `certs`: Certificados SSL + - `vhost`: Configuración virtual de nginx + +--- + +**Fecha**: Marzo 2026 +**Versión**: OpenCCB 0.3.0 +**Servidor**: AWS EC2 us-east-2 +**Instancia**: ec2-18-224-137-67.us-east-2.compute.amazonaws.com diff --git a/FINAL_UPDATES.md b/FINAL_UPDATES.md deleted file mode 100644 index 3ee2bb2..0000000 --- a/FINAL_UPDATES.md +++ /dev/null @@ -1,295 +0,0 @@ -# 🔄 Actualizaciones Finales - Question Bank & Admin - -## ✅ Cambios Realizados - -### 1. **Navbar Simplificado** ✅ -- ❌ Removido: Question Bank link -- ❌ Removido: Webhooks link separado -- ❌ Removido: Profile link separado -- ✅ Movido: Todo a Settings -- ✅ Admin ve: Dashboard + Settings -- ✅ Global Admin ve: Control Global + Settings - -**Nuevo navbar:** -``` -Courses | Library | [Admin: Global Control] | Settings -``` - -### 2. **Importación MySQL Inteligente** ✅ - -**Campos agregados:** -```sql -imported_mysql_id INTEGER -- ID original para evitar duplicados -imported_mysql_course_id INTEGER -- Curso original de MySQL -``` - -**Lógica de importación:** -```rust -// Verifica si ya existe antes de importar -SELECT EXISTS( - SELECT 1 FROM question_bank - WHERE imported_mysql_id = $1 AND organization_id = $2 -) - -// Si existe → Skip (no reimportar) -// Si no existe → Import y marca con IDs -``` - -**Resultado:** -- ✅ No se duplican preguntas al reimportar -- ✅ Tracking de origen MySQL -- ✅ Mensaje: "Imported X questions (skipped Y already imported)" - -### 3. **Token Usage Tracking** ✅ - -**Tabla: `ai_usage_logs`** -```sql -- user_id, organization_id -- tokens_used, input_tokens, output_tokens -- endpoint, model, request_type -- estimated_cost_usd -- created_at -``` - -**Función: `log_ai_usage()`** -```sql --- Usar en cada llamada a IA -SELECT log_ai_usage( - user_id, org_id, - tokens, input, output, - endpoint, model, type, - metadata -) -``` - -**Endpoint: `/admin/token-usage`** -```json -{ - "usage": [ - { - "user_id": "...", - "email": "user@example.com", - "role": "student", - "total_tokens": 125000, - "input_tokens": 75000, - "output_tokens": 50000, - "ai_requests": 45, - "estimated_cost_usd": 0.225 - } - ], - "stats": { - "total_tokens": 5000000, - "total_input": 3000000, - "total_output": 2000000, - "total_requests": 1250, - "total_cost_usd": 9.00, - "top_user_tokens": 500000, - "avg_tokens_per_user": 125000 - } -} -``` - -### 4. **Admin Dashboard - Token Tracking** ✅ - -**Página: `/admin/token-usage`** - -**Features:** -- 📊 Estadísticas en tiempo real -- 💰 Costos estimados (pricing OpenAI-like) -- 👥 Filter por rol (student/instructor/admin) -- 📈 Ordenar por tokens/requests/costo -- ⚠️ Alertas de alto consumo (>1M tokens) -- 📋 Tabla detallada por usuario - -**Alertas automáticas:** -``` -⚠️ Usuarios con alto consumo detectado -5 usuario(s) han superado 1M de tokens. -Considere implementar límites de uso. -``` - -**Colores por consumo:** -- 🟢 Normal: < 500K tokens -- 🟡 Moderado: 500K - 1M tokens -- 🔴 Alto: > 1M tokens - ---- - -## 📊 Pricing Estimado - -**Usado en cálculos:** -``` -Input tokens: $0.000001 por token ($1 por 1M) -Output tokens: $0.000003 por token ($3 por 1M) -``` - -**Ejemplos:** -| Escenario | Tokens | Costo/mes | -|-----------|--------|-----------| -| Estudiante ligero | 50K | $0.10 | -| Estudiante activo | 200K | $0.40 | -| Instructor | 500K | $1.00 | -| Power user | 2M | $4.00 | -| Sistema completo (1000 users) | 500M | $1,000 | - ---- - -## 🎯 Límites Sugeridos - -Basado en los datos de uso: - -### Estudiantes -```json -{ - "daily_tokens": 50000, - "monthly_tokens": 1000000, - "max_requests_per_day": 100 -} -``` - -### Instructores -```json -{ - "daily_tokens": 200000, - "monthly_tokens": 5000000, - "max_requests_per_day": 500 -} -``` - -### Admin -```json -{ - "daily_tokens": 1000000, - "monthly_tokens": 20000000, - "max_requests_per_day": 2000 -} -``` - ---- - -## 📁 Archivos Modificados - -### Backend -``` -services/cms-service/migrations/20260316000001_question_bank.sql - + imported_mysql_id - + imported_mysql_course_id - -services/cms-service/migrations/20260316000002_ai_usage_tracking.sql (NUEVO) - + ai_usage_logs table - + log_ai_usage() function - + ai_usage_daily view - -services/cms-service/src/handlers_question_bank.rs - + Skip ya importadas - + Bind imported_mysql_id - -services/cms-service/src/handlers_admin.rs (NUEVO) - + get_token_usage endpoint - -services/cms-service/src/main.rs - + handlers_admin module - + /admin/token-usage route - -shared/common/src/models.rs - + imported_mysql_id fields -``` - -### Frontend -``` -web/studio/src/components/Navbar.tsx - - Question Bank link - - Webhooks link - - Profile link - + Simplified nav - -web/studio/src/app/admin/token-usage/page.tsx (NUEVO) - + Token tracking UI - + Stats cards - + Usage table - + Filters & alerts -``` - ---- - -## 🚀 Cómo Funciona el Tracking - -### 1. **Cada llamada a IA registra uso** - -```rust -// Ejemplo: generate_quiz -let tokens = input_len + output_len; -sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9)") - .bind(user_id) - .bind(org_id) - .bind(tokens) - .bind(input_len) - .bind(output_len) - .bind("/lessons/generate-quiz") - .bind(model) - .bind("quiz-generation") - .bind(&metadata) - .execute(&pool) - .await?; -``` - -### 2. **Admin ve estadísticas en tiempo real** - -``` -GET /admin/token-usage -→ Lista todos los usuarios con su consumo -→ Calcula costos estimados -→ Muestra alertas de alto uso -``` - -### 3. **Se pueden implementar límites** - -```rust -// Ejemplo: Check before AI call -let user_usage = sqlx::query_scalar( - "SELECT SUM(tokens_used) FROM ai_usage_logs - WHERE user_id = $1 AND DATE(created_at) = CURRENT_DATE" -) -.bind(user_id) -.fetch_one(&pool) -.await?; - -if user_usage > DAILY_LIMIT { - return Err("Daily token limit exceeded".into()); -} -``` - ---- - -## ✅ Estado Final - -| Feature | Estado | Notas | -|---------|--------|-------| -| Navbar simplificado | ✅ | Menos clutter | -| Import sin duplicados | ✅ | Skip automáticos | -| Token tracking DB | ✅ | Tabla + función | -| Admin token dashboard | ✅ | UI completa | -| Costos estimados | ✅ | Pricing OpenAI-like | -| Alertas alto consumo | ✅ | >1M tokens | -| Límites (opcional) | ⏸️ | Listo para implementar | - ---- - -## 📝 Próximos Pasos (Opcionales) - -1. **Implementar límites de tokens** - - Daily/monthly limits por rol - - Soft limits (warning) vs hard limits (block) - -2. **Notificaciones de uso** - - Email al alcanzar 80% del límite - - Notificación a admin si usuario excede - -3. **Reportes avanzados** - - Exportar CSV de usage - - Gráficos de tendencia - - Comparativa mes a mes - ---- - -**Implementación: 100% Completa** 🎉 diff --git a/I18N_RESPONSIVIDAD_IMPLEMENTACION.md b/I18N_RESPONSIVIDAD_IMPLEMENTACION.md deleted file mode 100644 index 795c7be..0000000 --- a/I18N_RESPONSIVIDAD_IMPLEMENTACION.md +++ /dev/null @@ -1,445 +0,0 @@ -# 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/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 01a9584..0000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,330 +0,0 @@ -# 🚀 Resumen de Implementación - Question Bank - -## ✅ Estado de la Implementación - -### Backend (Rust) - COMPLETO -- ✅ Migración de base de datos con `skill_assessed` -- ✅ Endpoints CRUD para Question Bank -- ✅ Importación desde MySQL -- ✅ RAG con verificación de 4 habilidades -- ✅ Compilación exitosa - -### Frontend (TypeScript/React) - COMPLETO -- ✅ Página `/question-bank` con dashboard -- ✅ Componente QuestionBankCard con badge de skills -- ✅ QuestionBankEditor con generación IA de skills -- ✅ MySQLImportModal -- ✅ Navegación actualizada con link -- ✅ TypeScript: 3 errores menores (admin, no críticos) - -### Infraestructura - LISTO -- ✅ install.sh actualizado con detección dev/prod -- ✅ Documentación completa - ---- - -## 📋 Archivos Creados/Modificados - -### Backend -``` -services/cms-service/migrations/20260316000001_question_bank.sql -services/cms-service/src/handlers_question_bank.rs (NUEVO) -services/cms-service/src/handlers_test_templates.rs (actualizado) -services/cms-service/src/main.rs (rutas agregadas) -shared/common/src/models.rs (modelos QuestionBank) -``` - -### Frontend -``` -web/studio/src/app/question-bank/page.tsx (NUEVO) -web/studio/src/components/QuestionBank/QuestionBankCard.tsx (NUEVO) -web/studio/src/components/QuestionBank/QuestionBankEditor.tsx (NUEVO) -web/studio/src/components/QuestionBank/MySQLImportModal.tsx (NUEVO) -web/studio/src/components/Navbar.tsx (link agregado) -web/studio/src/lib/api.ts (API client) -``` - -### Scripts & Docs -``` -docs/QUESTION_BANK_UI.md (NUEVO) -docs/EXCEL_IMPORT_TEMPLATE.md -install.sh (actualizado) -``` - ---- - -## 🎯 Características de 4 Habilidades - -### Implementación -- ✅ **Reading**: Comprensión lectora, vocabulario en contexto -- ✅ **Listening**: Comprensión auditiva, diálogos -- ✅ **Speaking**: Producción oral, conversación -- ✅ **Writing**: Producción escrita, gramática - -### Flujo IA -1. Usuario ingresa contexto -2. Sistema selecciona skill al azar -3. IA genera pregunta enfocada en ese skill -4. Se guarda `skill_assessed` en BD -5. Se agregan tags: `[skill, 'ai-generated']` -6. Badge 📊 visible en UI - -### Ejemplo -```json -{ - "question_text": "Read: 'Yesterday, John went to the store.' What did John do?", - "skill_assessed": "reading", - "tags": ["reading", "ai-generated", "past-tense"], - "explanation": "The passage uses past tense... 📊 Skill assessed: READING" -} -``` - ---- - -## 🌍 Configuración Dev vs Prod - -### install.sh detecta automáticamente: - -**Desarrollo:** -```bash -OLLAMA_URL=http://t-800:11434 -WHISPER_URL=http://t-800:9000 -``` - -**Producción:** -```bash -OLLAMA_URL=http://t-800.norteamericano.cl:11434 -WHISPER_URL=http://t-800.norteamericano.cl:9000 -``` - ---- - -## 📊 Endpoints Disponibles - -### Question Bank -``` -GET /question-bank # Listar con filtros -POST /question-bank # Crear pregunta -GET /question-bank/{id} # Obtener pregunta -PUT /question-bank/{id} # Actualizar pregunta -DELETE /question-bank/{id} # Eliminar pregunta -POST /question-bank/import-mysql # Importar desde MySQL -GET /question-bank/mysql-courses # Listar cursos MySQL -POST /question-bank/import-mysql-all # Importar todo desde MySQL -``` - -### Test Templates (actualizado) -``` -POST /test-templates/generate-with-rag # Generar con RAG + skills -POST /test-templates/{id}/apply # Aplicar a lección -``` - ---- - -## ✅ Pruebas de Verificación - -### 1. Backend -```bash -cd /home/juan/dev/openccb -cargo build -p cms-service -# ✅ Compilación exitosa -``` - -### 2. Frontend -```bash -cd /home/juan/dev/openccb/web/studio -npm run type-check -# ⚠️ 3 errores menores en admin (no afectan Question Bank) -``` - ---- - -## 🎨 UI Features - -### Dashboard -- Estadísticas en tiempo real -- Filtros por skill, tipo, dificultad -- Búsqueda de texto -- Grid responsive - -### Tarjetas -- Badges: Tipo, Dificultad, 📊 Skill -- Preview de opciones -- Estado de audio -- Acciones rápidas - -### Editor -- 10 tipos de preguntas -- Generación IA con skills -- Tags automáticos - ---- - -## 📝 Próximos Pasos (Opcionales) - -1. **Filtrar errores de admin** (no críticos) - - `getOrganizations` no existe - - `BrandingContext` type error - -2. **Integración con Test Templates** - - Selector de preguntas desde banco - - Bulk selection - -3. **Analytics de Skills** - - Dashboard de distribución de skills - - Reportes por habilidad - ---- - -## 🎯 Estado General - -| Componente | Estado | Notas | -|------------|--------|-------| -| Backend Question Bank | ✅ 100% | Compila exitosamente | -| Frontend Question Bank | ✅ 95% | UI completa, 3 errores admin menores | -| install.sh | ✅ 100% | Detecta dev/prod automáticamente | -| Skills Verification | ✅ 100% | Implementado en IA y BD | -| Documentación | ✅ 100% | Archivos docs completos | - -**Progreso Total: 98%** 🎉 - ---- - -## 🆕 Última Actualización: PGVector & Búsqueda Semántica (Marzo 18, 2026) - -### ✅ Características Implementadas - -#### 1. **Búsqueda Semántica con PGVector** - -**Backend:** -- ✅ Migración PGVector CMS (question_bank embeddings) -- ✅ Migración PGVector LMS (knowledge_base embeddings) -- ✅ Handlers de embeddings (CMS + LMS) -- ✅ Módulo AI compartido (`shared/common/src/ai.rs`) -- ✅ Funciones SQL de similitud y diversidad (MMR) -- ✅ Índices IVFFlat para rendimiento (25-100x más rápido) - -**Endpoints:** -``` -POST /question-bank/embeddings/generate -POST /question-bank/{id}/embedding/regenerate -GET /question-bank/semantic-search?query=... -GET /question-bank/similar/{id} -POST /knowledge-base/embeddings/generate -GET /knowledge-base/semantic-search?query=... -``` - -**Rendimiento:** -| Operación | Sin Índice | Con IVFFlat | Mejora | -|-----------|------------|-------------|--------| -| 10k rows | ~500ms | ~20ms | 25x | -| 100k rows | ~5s | ~50ms | 100x | - -#### 2. **Integración MySQL Completa** - -**Tablas:** -- ✅ `mysql_study_plans` (planes de estudio) -- ✅ `mysql_courses` (cursos con duración y nivel) - -**Características:** -- ✅ Importación automática desde MySQL -- ✅ Clasificación por duración (regular/intensive) -- ✅ Cálculo de nivel (básico/intermedio/avanzado/experto) -- ✅ Tracking de IDs originales (no duplicar) -- ✅ Filtros por mysql_course_id en test templates - -#### 3. **RAG Mejorado para Generación de Preguntas** - -**Mejoras:** -- ✅ Búsqueda semántica de contexto (no solo keywords) -- ✅ Verificación automática de 4 habilidades -- ✅ Generación diversa con MMR -- ✅ Embeddings automáticos al generar - -**Flujo:** -1. Usuario ingresa tópico -2. Búsqueda semántica de preguntas relacionadas -3. IA genera pregunta con contexto enriquecido -4. Verifica Reading, Listening, Speaking, Writing -5. Guarda con embedding y tags automáticos - -### 📊 Estado de Implementación - -| Componente | Estado | Notas | -|------------|--------|-------| -| PGVector CMS | ✅ 100% | Embeddings + búsqueda semántica | -| PGVector LMS | ✅ 100% | Knowledge base + RAG | -| MySQL Integration | ✅ 100% | Study plans + courses | -| AI Module | ✅ 100% | shared/common/src/ai.rs | -| Test Templates | ✅ 95% | Filtros por mysql_course_id | -| Frontend API | ✅ 95% | Endpoints semánticos | - -### 📁 Archivos Nuevos (9) - -``` -PGVECTOR_EMBEDDINGS.md -services/cms-service/migrations/20260318000000_mysql_courses_integration.sql -services/cms-service/migrations/20260319000000_pgvector_embeddings.sql -services/lms-service/migrations/20260319000000_pgvector_knowledge_embeddings.sql -services/cms-service/src/handlers_embeddings.rs -services/lms-service/src/handlers_embeddings.rs -shared/common/src/ai.rs -CHANGELOG_2026_03_18.md -``` - -### 📁 Archivos Modificados (16) - -``` -.env.example -Cargo.lock -docker-compose.yml (pgvector/pgvector:pg16) -services/cms-service/Cargo.toml -services/cms-service/src/handlers_question_bank.rs -services/cms-service/src/handlers_test_templates.rs -services/cms-service/src/main.rs -services/lms-service/src/handlers.rs -services/lms-service/src/main.rs -shared/common/Cargo.toml -shared/common/src/lib.rs -shared/common/src/models.rs -web/studio/src/app/test-templates/page.tsx -web/studio/src/components/TestTemplates/TestTemplateForm.tsx -web/studio/src/components/TestTemplates/TestTemplateManager.tsx -web/studio/src/lib/api.ts -``` - -### 🚀 Comandos de Uso - -```bash -# Generar embeddings para questions existentes -curl -X POST "http://localhost:3001/question-bank/embeddings/generate" \ - -H "Authorization: TOKEN" - -# Búsqueda semántica -curl -G "http://localhost:3001/question-bank/semantic-search" \ - -d "query=past tense verbs" \ - -d "threshold=0.6" \ - -H "Authorization: TOKEN" - -# Detectar duplicados -curl -G "http://localhost:3001/question-bank/similar/{id}" \ - -d "threshold=0.95" \ - -H "Authorization: TOKEN" -``` - -### 📚 Documentación - -- **PGVector Guide:** `PGVECTOR_EMBEDDINGS.md` -- **Changelog:** `CHANGELOG_2026_03_18.md` -- **Optimizations:** `OPTIMIZATIONS.md` -- **Roadmap:** `roadmap.md` (Fase 21 completada) - ---- - -**Fecha:** 18 de Marzo, 2026 -**Versión:** OpenCCB 0.2.0 - -## 📞 Soporte - -- **UI Usage**: `docs/QUESTION_BANK_UI.md` -- **General**: `README.md` del proyecto diff --git a/IMPORTACION_CURSOS_MYSQL.md b/IMPORTACION_CURSOS_MYSQL.md deleted file mode 100644 index 7f339a5..0000000 --- a/IMPORTACION_CURSOS_MYSQL.md +++ /dev/null @@ -1,296 +0,0 @@ -# Importación de Cursos desde MySQL - -## Descripción - -OpenCCB ahora soporta la importación completa de cursos desde una base de datos MySQL externa. Esta funcionalidad permite: - -1. **Importar metadatos** de cursos y planes de estudio -2. **Importar preguntas** del banco de preguntas MySQL -3. **Crear cursos completos** con estructura básica (módulos y lecciones) - -## Endpoints Implementados - -### 1. Importar Metadatos (Cursos y Planes) - -```bash -POST /question-bank/import-mysql-all -Content-Type: application/json -Authorization: Bearer - -{ - "import_metadata_only": true -} -``` - -**Respuesta:** -```json -{ - "success": true, - "message": "Metadatos importados exitosamente", - "metadata": { - "study_plans_imported": 5, - "courses_imported": 20, - "courses": [ - { - "id_cursos": 1, - "nombre_curso": "Inglés Básico", - "nombre_plan": "Plan Regular", - "duracion": 40, - "nivel_curso": 2 - } - ] - } -} -``` - -### 2. Ver Cursos Disponibles - -```bash -GET /question-bank/mysql-courses -Authorization: Bearer -``` - -**Respuesta:** -```json -[ - { - "id_cursos": 1, - "nombre_curso": "Inglés Básico", - "nombre_plan": "Plan Regular", - "duracion": 40, - "nivel_curso": 2 - }, - { - "id_cursos": 2, - "nombre_curso": "Inglés Intermedio", - "nombre_plan": "Plan Intensivo", - "duracion": 80, - "nivel_curso": 6 - } -] -``` - -### 3. Importar Curso Completo con Estructura - -```bash -POST /question-bank/import-course-mysql -Content-Type: application/json -Authorization: Bearer - -{ - "mysql_course_id": 1, - "title": "Opcional: Título personalizado", - "description": "Opcional: Descripción del curso", - "pacing_mode": "self_paced" -} -``` - -**Respuesta:** -```json -{ - "course_id": "uuid-del-curso", - "course_title": "Inglés Básico (Plan Regular)", - "mysql_course_id": 1, - "modules_created": 4, - "lessons_created": 20, - "message": "Curso 'Inglés Básico (Plan Regular)' importado exitosamente con 4 módulos y 20 lecciones" -} -``` - -## Estructura del Curso Importado - -Al importar un curso desde MySQL, se crea automáticamente una estructura básica basada en la duración del curso: - -### Cursos Regulares (40 horas) -- **4 módulos** con 5 lecciones cada uno (20 lecciones total) - - Módulo 1: Introducción y Fundamentos - - Módulo 2: Gramática Básica - - Módulo 3: Vocabulario Esencial - - Módulo 4: Práctica Integradora - -### Cursos Intensivos (80 horas) -- **8 módulos** con 4-6 lecciones cada uno (46 lecciones total) - - Módulo 1-2: Fundamentos Básicos y Gramática Esencial - - Módulo 3-4: Vocabulario Intermedio y Comprensión Auditiva - - Módulo 5-6: Expresión Oral y Lectura/Escritura - - Módulo 7-8: Práctica Avanzada y Proyecto Final - -### Tipos de Lecciones -Las lecciones se crean rotando entre diferentes tipos de contenido: -1. **video** - Contenido de video -2. **document** - Documentos de lectura (PDF, DOCX) -3. **interactive** - Actividades interactivas -4. **quiz** - Evaluaciones (cada 4ta lección es graded) - -## Scripts de Importación - -### Script Completo (Recomendado) - -```bash -./scripts/import_courses_mysql.sh -``` - -Este script: -1. Obtiene el token de autenticación -2. Importa todos los metadatos de cursos y planes -3. Muestra la lista de cursos disponibles -4. Permite importar un curso específico o todos - -### Script Manual (Paso a Paso) - -```bash -# 1. Obtener token -TOKEN=$(curl -s -X POST "http://localhost:3001/auth/login" \ - -H "Content-Type: application/json" \ - -d '{"email":"admin@norteamericano.cl","password":"Admin123!"}' \ - | jq -r '.token') - -# 2. Importar metadatos -curl -X POST "http://localhost:3001/question-bank/import-mysql-all" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"import_metadata_only": true}' - -# 3. Ver cursos disponibles -curl -X GET "http://localhost:3001/question-bank/mysql-courses" \ - -H "Authorization: Bearer $TOKEN" | jq . - -# 4. Importar un curso específico -curl -X POST "http://localhost:3001/question-bank/import-course-mysql" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"mysql_course_id": 1}' -``` - -## Configuración Requerida - -### Variables de Entorno - -Asegúrate de configurar las siguientes variables en tu `.env`: - -```bash -# Conexión a MySQL externa -MYSQL_DATABASE_URL=mysql://usuario:contraseña@host:3306/base_de_datos - -# Credenciales de admin para importación -EMAIL="admin@norteamericano.cl" -PASSWORD="Admin123!" -``` - -### Estructura de la Base de Datos MySQL - -La base de datos MySQL debe tener las siguientes tablas: - -```sql --- Planes de estudio -plandeestudios ( - idPlanDeEstudios INT PRIMARY KEY, - Nombre VARCHAR(255), - Activo BOOLEAN -) - --- Cursos -curso ( - idCursos INT PRIMARY KEY, - idPlanDeEstudios INT, - NombreCurso VARCHAR(255), - NivelCurso INT, - Duracion INT, - Activo BOOLEAN, - FOREIGN KEY (idPlanDeEstudios) REFERENCES plandeestudios(idPlanDeEstudios) -) - --- Banco de preguntas (opcional) -bancopreguntas ( - idPregunta INT PRIMARY KEY, - idCursos INT, - idPlanDeEstudios INT, - idTipoPregunta INT, - descripcion TEXT, - activo BOOLEAN, - FOREIGN KEY (idCursos) REFERENCES curso(idCursos) -) - --- Respuestas (opcional) -bancorespuestas ( - idRespuesta INT PRIMARY KEY, - idPregunta INT, - descripcion TEXT, - resultado INT, - activo BOOLEAN, - FOREIGN KEY (idPregunta) REFERENCES bancopreguntas(idPregunta) -) -``` - -## Flujo de Importación Recomendado - -1. **Primera vez:** - ```bash - ./scripts/import_courses_mysql.sh - ``` - - Selecciona "all" para importar todos los cursos - -2. **Actualizaciones posteriores:** - ```bash - # Solo importar nuevos cursos - ./scripts/import_courses_mysql.sh - # Selecciona los cursos específicos que faltan - ``` - -3. **Importar solo preguntas:** - ```bash - ./scripts/import_mysql.sh - ``` - -## Solución de Problemas - -### Error: "MYSQL_DATABASE_URL not configured" -**Solución:** Agrega la variable `MYSQL_DATABASE_URL` en tu `.env` - -### Error: "Course with ID X not found in MySQL" -**Solución:** Verifica que el curso exista en la base de datos MySQL y que `Activo = 1` - -### Error: "Failed to connect to MySQL" -**Solución:** -- Verifica que la conexión a MySQL esté disponible -- Comprueba las credenciales en `MYSQL_DATABASE_URL` -- Asegúrate de que el firewall permita la conexión - -### Los cursos importados no tienen contenido -**Solución:** Esto es esperado. Los cursos se importan con una estructura básica (módulos y lecciones vacías). Debes completar el contenido de cada lección desde el Studio. - -## Notas Importantes - -1. **Los cursos importados no sobrescriben existentes** - Cada importación crea un nuevo curso -2. **El campo `imported_mysql_course_id`** se usa para rastrear el origen del curso -3. **Las preguntas del banco de preguntas** se importan por separado con `import_mysql.sh` -4. **La estructura generada es básica** - Debes personalizar el contenido de las lecciones - -## Ejemplo de Uso con curl - -```bash -# Login -RESPONSE=$(curl -s -X POST "http://localhost:3001/auth/login" \ - -H "Content-Type: application/json" \ - -d '{"email":"admin@norteamericano.cl","password":"Admin123!"}') - -TOKEN=$(echo $RESPONSE | jq -r '.token') - -# Importar curso ID 5 -curl -X POST "http://localhost:3001/question-bank/import-course-mysql" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{ - "mysql_course_id": 5, - "title": "Curso Personalizado", - "description": "Descripción personalizada del curso", - "pacing_mode": "instructor_led" - }' | jq . -``` - -## Próximas Mejoras - -- [ ] Importación de contenido específico desde MySQL (si existe) -- [ ] Mapeo de prerrequisitos entre cursos -- [ ] Importación de usuarios inscritos -- [ ] Sincronización automática de cambios en MySQL diff --git a/ManualDeConfiguracion.md b/ManualDeConfiguracion.md new file mode 100644 index 0000000..bdb319a --- /dev/null +++ b/ManualDeConfiguracion.md @@ -0,0 +1,669 @@ +# Manual de Configuración de OpenCCB + +## Tabla de Contenidos + +1. [Requisitos del Sistema](#requisitos-del-sistema) +2. [Instalación](#instalación) +3. [Configuración de Entorno](#configuración-de-entorno) +4. [Configuración de IA](#configuración-de-ia) +5. [Base de Datos](#base-de-datos) +6. [Despliegue en Producción](#despliegue-en-producción) +7. [Solución de Problemas](#solución-de-problemas) + +--- + +## Requisitos del Sistema + +OpenCCB es altamente escalable. A continuación se detallan los requisitos recomendados según la carga de usuarios concurrentes: + +| Componente | **Pequeño (100 u.)** | **Mediano (500 u. concurrentes)** | **Grande (1000+ u.)** | +| :--- | :--- | :--- | :--- | +| **CPU** | 4 vCPUs | 8-12 vCPUs (AVX2/AVX-512) | 16-32+ vCPUs | +| **RAM** | 8 GB | 16-32 GB (Recomendado 24GB+) | 64 GB+ | +| **Almacenamiento** | 50 GB SSD | 250 GB+ NVMe (RAID-1) | 1 TB+ NVMe (S3 Backup) | +| **AI (Opcional)** | N/A (Solo CPU) | NVIDIA RTX 3060+ (12GB VRAM) | Multi-GPU (A100/H100) | +| **OS** | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS / Debian | Cloud Native (K8s / Terraform) | + +> [!NOTE] +> Los requisitos de AI son específicos para la función de transcripción local (Whisper). Si se utiliza una API externa, el requisito de GPU desaparece. + +--- + +## Instalación + +### Instalación Automática (Recomendado) + +El script `install.sh` automatiza todo el proceso: + +```bash +# Instalación estándar +./install.sh + +# Modo rápido (omite comprobaciones de dependencias) +./install.sh --fast + +# Modo despliegue (sincroniza con servidor remoto) +./install.sh --deploy +``` + +### Pasos del Script + +1. **Detección de entorno**: Dev o Prod +2. **Instalación de dependencias**: Rust, Node.js, Docker, sqlx-cli +3. **Configuración de .env**: Variables automáticas según entorno +4. **Inicialización de DB**: Crea bases de datos y ejecuta migraciones +5. **Configuración de IA**: URLs de Ollama y Whisper +6. **Creación de admin**: Usuario administrador inicial +7. **Inicio de servicios**: Docker Compose + +### Instalación Manual + +```bash +# 1. Clonar repositorio +git clone +cd openccb + +# 2. Copiar .env.example +cp .env.example .env + +# 3. Configurar variables de entorno +nano .env + +# 4. Si PostgreSQL NO está corriendo, iniciar con Docker +docker-compose up -d db + +# 5. Esperar a que DB esté lista +sleep 10 + +# 6. Crear bases de datos (puerto 5433) +docker exec openccb-db-1 psql -U user -d postgres -c "CREATE DATABASE openccb_cms;" || true +docker exec openccb-db-1 psql -U user -d postgres -c "CREATE DATABASE openccb_lms;" || true + +# Si PostgreSQL ya existe (producción), usar psql directo: +# psql -h localhost -p 5433 -U user -d postgres -c "CREATE DATABASE openccb_cms;" +# psql -h localhost -p 5433 -U user -d postgres -c "CREATE DATABASE openccb_lms;" + +# 7. Ejecutar migraciones (puerto 5433) +DATABASE_URL=postgresql://user:password@localhost:5433/openccb_cms \ + sqlx migrate run --source services/cms-service/migrations + +DATABASE_URL=postgresql://user:password@localhost:5433/openccb_lms \ + sqlx migrate run --source services/lms-service/migrations + +# 8. Iniciar servicios (sin DB si ya existe) +docker-compose up -d +``` + +--- + +## Configuración de Entorno + +### Variables Principales (.env) + +```bash +# Entorno (dev o prod) +ENVIRONMENT=dev + +# Base de Datos (Puerto 5434 - 5432 y 5433 pueden estar en uso) +DATABASE_URL=postgresql://user:password@localhost:5434/openccb?sslmode=disable +CMS_DATABASE_URL=postgresql://user:password@localhost:5434/openccb_cms?sslmode=disable +LMS_DATABASE_URL=postgresql://user:password@localhost:5434/openccb_lms?sslmode=disable + +# JWT Secret (generar con ./generate_jwt_secret.sh) +JWT_SECRET=tu_secreto_seguro_aqui + +# URLs de Frontend +NEXT_PUBLIC_CMS_API_URL=http://localhost:3001 +NEXT_PUBLIC_LMS_API_URL=http://localhost:3002 + +# Branding +DEFAULT_ORG_NAME=Norteamericano +DEFAULT_PLATFORM_NAME=OpenCCB Learning +DEFAULT_PRIMARY_COLOR=#3B82F6 +DEFAULT_SECONDARY_COLOR=#8B5CF6 + +# SAM Integration (Opcional - para gestión académica) +SAM_DATABASE_URL=postgresql://user:password@sige_sam_v3_host:5432/sige_sam_v3 +``` + +### Configuración por Entorno + +**Desarrollo:** +```bash +ENVIRONMENT=dev +LOCAL_OLLAMA_URL=http://t-800:11434 +LOCAL_WHISPER_URL=http://t-800:9000 +``` + +**Producción:** +```bash +ENVIRONMENT=prod +LOCAL_OLLAMA_URL=http://t-800.norteamericano.cl:11434 +LOCAL_WHISPER_URL=http://t-800.norteamericano.cl:9000 +``` + +--- + +## Integración SAM (Sistema de Administración Académica) + +### ¿Qué es SAM? + +SAM es un sistema externo de gestión académica que contiene: +- **Alumnos**: `sige_sam_v3.alumnos` +- **Cursos**: `sige_sam_v3.detalle_contrato` + +OpenCCB se sincroniza con SAM para: +1. Importar alumnos automáticamente +2. Asignar cursos a cada alumno +3. Restringir acceso solo a cursos asignados + +### Configuración + +1. **Agregar variable de entorno** en `.env`: + ```bash + SAM_DATABASE_URL=postgresql://user:password@host:5432/sige_sam_v3 + ``` + +2. **Ejecutar migración**: + ```bash + DATABASE_URL=postgresql://user:password@localhost:5433/openccb_cms \ + sqlx migrate run --source services/cms-service/migrations + ``` + +3. **Sincronizar datos**: + ```bash + # Obtener token de admin + TOKEN=$(curl -s -X POST "http://localhost:3001/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@norteamericano.cl","password":"Admin123!"}' \ + | jq -r '.token') + + # Sincronizar alumnos y cursos + curl -X POST "http://localhost:3001/sam/sync-all" \ + -H "Authorization: Bearer $TOKEN" + ``` + +### Endpoints SAM + +| Método | Endpoint | Descripción | +|--------|----------|-------------| +| POST | `/sam/sync-all` | Sincronización completa (alumnos + cursos) | +| POST | `/sam/sync-students` | Solo sincroniza alumnos | +| POST | `/sam/sync-assignments` | Solo sincroniza asignaciones de cursos | +| GET | `/sam/students` | Lista alumnos SAM con filtros | +| GET | `/sam/students/{student_id}/courses` | Cursos asignados a un alumno | + +### Comportamiento por Rol + +| Rol | Acceso a Cursos | +|-----|-----------------| +| **Super Admin** | Todos los cursos (todas las organizaciones) | +| **Admin** | Todos los cursos de su organización | +| **Instructor** | Todos los cursos de su organización | +| **Alumno SAM** | Solo cursos asignados vía SAM | +| **Alumno NO-SAM** | ❌ Ningún curso (lista vacía) | + +### Flujo de Sincronización + +``` +┌─────────────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ sige_sam_v3.alumnos │────▶│ Sync SAM │────▶│ users │ +│ (id_alumno, email) │ │ POST /sam │ │ (sam_student_id)│ +└─────────────────────┘ └──────────────┘ └─────────────────┘ + +┌──────────────────────────┐ ┌──────────────┐ ┌──────────────┐ +│ sige_sam_v3.detalle_ │────▶│ Sync SAM │────▶│ sam_course_ │ +│ contrato (id_alumno, │ │ POST /sam │ │ assignments │ +│ id_curso_abierto) │ │ │ │ │ +└──────────────────────────┘ └──────────────┘ └──────────────┘ +``` + +### Solución de Problemas + +**Error: "SAM_DATABASE_URL not configured"** +```bash +# Agregar en .env +SAM_DATABASE_URL=postgresql://user:pass@host:5432/sige_sam_v3 + +# Reiniciar servicio +docker-compose restart cms +``` + +**Error: "Failed to fetch SAM students"** +- Verificar conexión a la base de datos SAM +- Confirmar que las tablas `sige_sam_v3.alumnos` existen +- Verificar permisos de usuario en SAM + +**Alumnos SAM no ven cursos** +1. Verificar que `is_sam_student = TRUE` en tabla `users` +2. Verificar que existen registros en `sam_course_assignments` +3. Ejecutar sincronización nuevamente: `POST /sam/sync-all` + +--- + +## Configuración de IA + +### Proveedores Soportados + +1. **Local (Ollama + Whisper)**: Recomendado para privacidad y costo cero +2. **Remoto**: API externa (t-800 o similar) + +### Configuración Local + +```bash +AI_PROVIDER=local +LOCAL_OLLAMA_URL=http://localhost:11434 +LOCAL_WHISPER_URL=http://localhost:9000 +LOCAL_LLM_MODEL=llama3.2:3b +EMBEDDING_MODEL=nomic-embed-text +``` + +### Pull de Modelos + +```bash +# Ollama +docker exec -it ollama ollama pull llama3.2:3b +docker exec -it ollama ollama pull nomic-embed-text + +# Whisper (ya viene pre-configurado en el contenedor) +``` + +### Generación de Embeddings + +```bash +# Generar para preguntas existentes +curl -X POST http://localhost:3001/question-bank/embeddings/generate \ + -H "Authorization: Bearer TOKEN" + +# Generar para base de conocimiento +curl -X POST http://localhost:3002/knowledge-base/embeddings/generate \ + -H "Authorization: Bearer TOKEN" +``` + +--- + +## Base de Datos + +### Estructura + +- **openccb_cms**: Gestión de cursos, usuarios, organizaciones +- **openccb_lms**: Progreso estudiantil, calificaciones, foros + +### Migraciones + +```bash +# CMS +DATABASE_URL=postgresql://user:password@localhost:5433/openccb_cms \ + sqlx migrate run --source services/cms-service/migrations + +# LMS +DATABASE_URL=postgresql://user:password@localhost:5433/openccb_lms \ + sqlx migrate run --source services/lms-service/migrations + +# Revertir última migración +DATABASE_URL=postgresql://user:password@localhost:5433/openccb_cms \ + sqlx migrate revert --source services/cms-service/migrations +``` + +### Backup + +```bash +# Backup completo +docker exec openccb-db-1 pg_dump -U user openccb_cms > backup_cms.sql +docker exec openccb-db-1 pg_dump -U user openccb_lms > backup_lms.sql + +# Restaurar +docker exec -i openccb-db-1 psql -U user openccb_cms < backup_cms.sql +docker exec -i openccb-db-1 psql -U user openccb_lms < backup_lms.sql +``` + +### PGVector (Búsqueda Semántica) + +La extensión pgvector está habilitada para búsqueda semántica: + +```sql +-- Verificar extensión +SELECT * FROM pg_extension WHERE extname = 'vector'; + +-- Búsqueda semántica de preguntas +SELECT * FROM question_bank +ORDER BY embedding <=> '[...]'::vector +LIMIT 10; +``` + +--- + +## Despliegue en Producción + +### Scripts de Despliegue + +El proyecto cuenta con tres scripts separados: + +| Script | Propósito | +|--------|-----------| +| **install.sh** | Instalación local y configuración inicial | +| **deploy.sh** | Despliegue remoto automático | +| **setup-nginx-ssl.sh** | Configuración de nginx + SSL (servidor con nginx instalado) | + +### SSL con nginx Existente (Tu Caso) + +Si nginx ya está instalado en el servidor remoto: + +**1. Copiar archivos al servidor:** +```bash +# Desde tu máquina local +./deploy.sh +``` + +**2. Conectarse al servidor:** +```bash +ssh -i "ubuntu.pem" ubuntu@ec2-18-222-198-24.us-east-2.compute.amazonaws.com +cd /var/www/openccb +``` + +**3. Ejecutar configuración de nginx:** +```bash +sudo ./setup-nginx-ssl.sh +``` + +**4. Instalar certificados SSL:** +```bash +sudo /usr/local/bin/install-ssl-certs.sh +``` + +**5. Iniciar OpenCCB:** +```bash +docker-compose up -d +``` + +**Configuración creada:** +- ✅ `/etc/nginx/sites-available/studio.norteamericano.com` +- ✅ `/etc/nginx/sites-available/learning.norteamericano.com` +- ✅ `/usr/local/bin/install-ssl-certs.sh` (instalación de certificados) +- ✅ `/etc/cron.daily/certbot-renewal` (renovación automática) + +**Dominios configurados:** +- `https://studio.norteamericano.com` → Puerto 3000 (Studio) +- `https://learning.norteamericano.com` → Puerto 3003 (Experience) + +### Despliegue con Docker Compose SSL + +Si prefieres usar docker-compose con nginx-proxy: + +```bash +# Ejecutar despliegue con SSL +docker compose -f docker-compose.ssl.yml up -d +``` + +**¿Qué hace?** +1. **Inicia nginx-proxy** → Proxy inverso +2. **Inicia acme-companion** → SSL automático con Let's Encrypt +3. **Inicia servicios** → Studio, Experience, DB +4. **Obtiene certificados** → Automáticamente + +### Comandos de Despliegue + +```bash +# Despliegue remoto (copia archivos) +./deploy.sh + +# Configuración de nginx + SSL (en el servidor) +sudo ./setup-nginx-ssl.sh + +# Instalación de certificados (en el servidor) +sudo /usr/local/bin/install-ssl-certs.sh + +# Instalación local (desarrollo) +./install.sh +``` + +### Flujo de Despliegue + +``` +┌─────────────────┐ +│ deploy.sh │ (local) +│ │ +│ 1. Lee config │───┐ +│ 2. Prepara │ │ +│ 3. Sube │ ▼ +└─────────────────┐ ┌──────────────────┐ + │ │ Servidor Remoto │ + │ │ │ + │ │ 4. Verifica │ + │ │ 5. Gestiona │ + │ │ 6. Verifica │ + │ └──────────────────┘ +``` + +### Archivos Subidos al Servidor + +El deploy.sh copia los siguientes archivos al servidor remoto: + +``` +/var/www/openccb/ +├── docker-compose.yml +├── .env (o .env.example) +├── install.sh ← Para instalaciones futuras +├── deploy.sh ← Para actualizaciones futuras +├── Cargo.toml +├── Cargo.lock +├── services/ +├── shared/ +└── web/ +``` + +### Actualizaciones Futuras + +Una vez desplegado, puedes actualizar el servidor remoto de dos formas: + +**Opción A: Desde tu máquina local** +```bash +./deploy.sh +``` + +**Opción B: Desde el servidor remoto** +```bash +ssh -i ubuntu.pem ubuntu@REMOTE_HOST +cd /var/www/openccb +./deploy.sh +``` + +### Pasos Manuales (Alternativo) + +Si prefieres control manual del despliegue: + +1. **Sincronizar archivos:** + ```bash + rsync -avz -e "ssh -i ubuntu.pem" \ + --exclude 'node_modules' \ + --exclude 'target' \ + --exclude '.next' \ + --exclude '.git' \ + --exclude '*.md' \ + ./ ubuntu@remote-host:/var/www/openccb/ + ``` + +2. **Conectarse al servidor:** + ```bash + ssh -i ubuntu.pem ubuntu@remote-host + cd /var/www/openccb + ``` + +3. **Gestionar contenedores:** + ```bash + # Ver estado + docker-compose ps + + # Ver logs + docker-compose logs -f + + # Reiniciar servicios + docker-compose restart + + # Reconstruir desde cero + docker-compose down + docker-compose up -d --build + + # Actualizar desde git + git pull + docker-compose up -d --build + ``` + +### Configuración de Producción + +```bash +# .env en producción +ENVIRONMENT=prod +JWT_SECRET= +DATABASE_URL=postgresql://user:secure_password@db-host:5432/openccb?sslmode=require +LOCAL_OLLAMA_URL=http://t-800.norteamericano.cl:11434 +LOCAL_WHISPER_URL=http://t-800.norteamericano.cl:9000 +``` + +### Seguridad en Producción + +1. **HTTPS obligatorio**: Usar reverse proxy (Nginx/Traefik) con Let's Encrypt +2. **Firewall**: Solo puertos 80, 443, 22 abiertos +3. **DB password**: Usar contraseña segura (20+ caracteres) +4. **JWT_SECRET**: Generar con `./generate_jwt_secret.sh` +5. **Backups automáticos**: Configurar cron job diario + +--- + +## Solución de Problemas + +### Error: "extension 'vector' does not exist" + +```bash +# Verificar imagen de Docker +docker-compose pull db +docker-compose down +docker-compose up -d db + +# Verificar extensión +docker exec openccb-db-1 psql -U user -d openccb_cms \ + -c "SELECT * FROM pg_extension WHERE extname = 'vector';" +``` + +### Error: "Ollama no responde" + +```bash +# Verificar contenedor +docker ps | grep ollama + +# Probar conexión +curl http://localhost:11434/api/tags + +# Reiniciar +docker-compose restart ollama +``` + +### Error: "Puerto 5432 en uso" + +El script usa automáticamente el puerto 5433 si el 5432 está ocupado. + +```bash +# Verificar qué usa el puerto +lsof -i :5432 + +# Cambiar a puerto 5433 en .env +DATABASE_URL=postgresql://user:password@localhost:5433/openccb +``` + +### Error: "CORS en login/registro" + +Verificar que el rate limiter NO esté aplicado a rutas de autenticación: + +```rust +// services/cms-service/src/main.rs +// CORRECTO: +.protected_routes + .route_layer(middleware::from_fn(org_extractor_middleware)) +// NO agregar GovernorLayer aquí +``` + +### Error: "Audio recording no funciona" + +1. **Verificar HTTPS**: El audio requiere contexto seguro + ```bash + # Localhost está OK + # Producción requiere HTTPS + ``` + +2. **Verificar permisos del navegador**: + - Chrome: `chrome://settings/content/microphone` + - Firefox: `about:preferences#privacy` → Permisos + +3. **Verificar logs**: + ``` + [AudioResponse] Requesting microphone access... + [AudioResponse] Microphone access granted + ``` + +### Limpieza Completa + +```bash +# Detener servicios +docker-compose down -v + +# Eliminar volúmenes +docker volume rm openccb_db_data + +# Reinstalar +./install.sh +``` + +### Logs y Debugging + +```bash +# Ver logs de servicios +docker-compose logs -f cms +docker-compose logs -f lms +docker-compose logs -f studio +docker-compose logs -f experience + +# Logs con filtro +docker-compose logs -f cms | grep -i error + +# Acceder a DB +docker exec -it openccb-db-1 psql -U user -d openccb_cms +``` + +### Comandos Útiles + +```bash +# Health checks +curl http://localhost:3001/health +curl http://localhost:3002/health + +# Generar JWT_SECRET +./generate_jwt_secret.sh + +# Reset de sesión +./clear_session.sh + +# Diagnóstico de auth +./diagnose_auth.sh + +# Ver usuarios +docker exec openccb-db-1 psql -U user -d openccb_cms \ + -c "SELECT email, role FROM users;" + +# Ver organizaciones +docker exec openccb-db-1 psql -U user -d openccb_cms \ + -c "SELECT name, api_key FROM organizations;" +``` + +--- + +## Recursos Adicionales + +- **README.md**: Características y arquitectura +- **roadmap.md**: Hoja de ruta de desarrollo +- **OPTIMIZATIONS.md**: Guía de optimizaciones implementadas +- **PGVECTOR_EMBEDDINGS.md**: Guía de búsqueda semántica + +--- + +**Última actualización**: Marzo 2026 +**Versión**: OpenCCB 0.2.3 diff --git a/OPTIMIZATIONS.md b/OPTIMIZATIONS.md deleted file mode 100644 index a38ca3b..0000000 --- a/OPTIMIZATIONS.md +++ /dev/null @@ -1,322 +0,0 @@ -# OpenCCB - Guía de Optimizaciones - -Este documento resume las optimizaciones implementadas en el proyecto OpenCCB. - -## 🚀 Optimizaciones Implementadas - -### 1. Docker Build Cache (40-60% más rápido) - -**Archivos modificados:** -- `web/studio/Dockerfile` -- `web/experience/Dockerfile` - -**Cambios:** -- Separación de la construcción de dependencias Rust del código fuente -- Uso de dummy files para construir dependencias primero -- Cacheo eficiente de layers de Docker - -**Beneficio:** Los builds subsequentes solo recompilan cuando cambia el código fuente, no las dependencias. - ---- - -### 2. Optimizaciones de Rust (Release más rápido y binarios más pequeños) - -**Archivo modificado:** `Cargo.toml` (workspace) - -```toml -[profile.release] -lto = "thin" # Link-Time Optimization -codegen-units = 1 # Mejor optimización a costa de más tiempo de compile -panic = "abort" # Binarios más pequeños -``` - -**Beneficio:** -- Binarios ~10-20% más pequeños -- Mejor rendimiento en runtime -- Menor uso de memoria - ---- - -### 3. Rate Limiting (Protección contra abuso) - -**Librería agregada:** `tower-governor = "0.7"` - -**Configuración:** -- 10 requests por segundo -- Burst de 50 requests -- Aplicado a ambos servicios (CMS y LMS) - -**Endpoints afectados:** Todos los endpoints ahora tienen protección contra DDoS y brute-force. - ---- - -### 4. Security Headers (Mejora de seguridad) - -Headers agregados a todas las respuestas: - -``` -Strict-Transport-Security: max-age=31536000; includeSubDomains -X-Content-Type-Options: nosniff -X-Frame-Options: SAMEORIGIN -X-XSS-Protection: 1; mode=block -Referrer-Policy: strict-origin-when-cross-origin -``` - -**Beneficio:** Protección contra XSS, clickjacking, MIME sniffing. - ---- - -### 5. Health Check Endpoints (Observabilidad) - -**Nuevos endpoints en ambos servicios:** - -| Endpoint | Descripción | -|----------|-------------| -| `GET /health` | Health check básico | -| `GET /health/live` | Liveness check con uptime | -| `GET /health/ready` | Readiness check con estado de DB | - -**Ejemplo de uso:** -```bash -curl http://localhost:3001/health -curl http://localhost:3002/health/ready -``` - -**Beneficio:** Monitoreo, Kubernetes readiness probes, load balancer health checks. - ---- - -### 6. Connection Pooling Optimizado - -**Cambios en `main.rs`:** -```rust -let pool = PgPoolOptions::new() - .max_connections(10) // Antes: 5 - .min_connections(2) // Nuevo: mantiene conexiones mínimas - .acquire_timeout(Duration::from_secs(30)) // Nuevo: timeout configurable -``` - -**Beneficio:** Mejor manejo de carga, menos latencia en conexiones. - ---- - -### 7. Frontend: Turbopack (Desarrollo más rápido) - -**Archivos modificados:** -- `web/studio/package.json` -- `web/experience/package.json` - -**Cambios:** -```json -"dev": "next dev --turbo" -``` - -**Beneficio:** Hot reload más rápido en desarrollo. - ---- - -### 8. Frontend: Code Quality Tools - -**Nuevos scripts:** -```json -"lint:fix": "next lint --fix", -"type-check": "tsc --noEmit", -"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", -"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"" -``` - -**Dependencias agregadas:** -- `prettier` ^3.2.0 -- `prettier-plugin-tailwindcss` ^0.5.0 - -**Beneficio:** Código consistente, menos bugs, mejor mantenibilidad. - ---- - -### 9. JWT_SECRET Generator - -**Nuevo script:** `generate_jwt_secret.sh` - -**Uso:** -```bash -./generate_jwt_secret.sh -``` - -**Beneficio:** Genera claves criptográficamente seguras automáticamente. - ---- - -### 10. .dockerignore Mejorado - -**Nuevas exclusiones:** -- Archivos de testing (coverage, *.gcda) -- Logs de desarrollo -- Config de IDEs (.idea, .vscode) -- Archivos temporales - -**Beneficio:** Imágenes Docker más pequeñas, builds más rápidos. - ---- - -## 📊 Impacto Esperado - -| Métrica | Antes | Después | Mejora | -|---------|-------|---------|--------| -| Docker Build Time | ~5 min | ~2-3 min | 40-60% | -| Binario Rust | ~25 MB | ~20 MB | 20% | -| Requests/segundo | Sin límite | 10/s + burst 50 | Seguridad | -| Hot Reload (Next.js) | ~2s | ~500ms | 75% | -| Búsqueda (10k rows) | ~500ms | ~20ms | 25x | -| Búsqueda (100k rows) | ~5s | ~50ms | 100x | - ---- - -## 🔧 Comandos Útiles - -### Desarrollo -```bash -# Frontend con Turbopack -cd web/studio && npm run dev -cd web/experience && npm run dev - -# Backend con logs detallados -RUST_LOG=debug cargo run -p cms-service -RUST_LOG=debug cargo run -p lms-service -``` - -### Code Quality -```bash -# Linting -npm run lint:fix - -# Type checking -npm run type-check - -# Formatting -npm run format -``` - -### Health Checks -```bash -# CMS Service -curl http://localhost:3001/health -curl http://localhost:3001/health/live -curl http://localhost:3001/health/ready - -# LMS Service -curl http://localhost:3002/health -curl http://localhost:3002/health/live -curl http://localhost:3002/health/ready -``` - -### Seguridad -```bash -# Generar nueva JWT_SECRET -./generate_jwt_secret.sh -``` - ---- - -## 📝 Próximas Optimizaciones Sugeridas - -1. **Lazy Loading en Frontend**: Cargar componentes pesados (Mermaid, Recharts) dinámicamente -2. **SQLx Offline Mode**: Usar queries pre-compiladas para CI/CD más rápido -3. **Prometheus Metrics**: Agregar métricas de rendimiento -4. **Redis Cache**: Para sesiones y datos frecuentemente accedidos -5. **CDN para Assets**: Usar S3 + CloudFront para archivos estáticos - ---- - -## 🆕 Nuevas Optimizaciones (Marzo 2026) - -### 11. **Búsqueda Semántica con PGVector** ⭐ - -**Librería agregada:** `pgvector` (extensión de PostgreSQL) - -**Configuración:** -- Embeddings de 768 dimensiones (nomic-embed-text) -- Índices IVFFlat optimizados para >10k filas -- Búsqueda por similitud de coseno - -**Beneficio:** -- Búsqueda 25-100x más rápida que texto completo -- Resultados más precisos (semántica vs keywords) -- Detección automática de duplicados - -**Archivos modificados:** -- `docker-compose.yml` (imagen pgvector/pgvector:pg16) -- `shared/common/src/ai.rs` (módulo nuevo) -- `services/cms-service/src/handlers_embeddings.rs` (nuevo) -- `services/lms-service/src/handlers_embeddings.rs` (nuevo) -- Migraciones SQLx con funciones de similitud - -**Endpoints nuevos:** -``` -POST /question-bank/embeddings/generate -GET /question-bank/semantic-search?query=... -GET /question-bank/similar/{id} -POST /knowledge-base/embeddings/generate -GET /knowledge-base/semantic-search?query=... -``` - -**Ejemplo de uso:** -```bash -# Búsqueda semántica -curl -G "http://localhost:3001/question-bank/semantic-search" \ - -d "query=preguntas sobre pasado simple" \ - -d "threshold=0.6" \ - -H "Authorization: TOKEN" -``` - -**Rendimiento:** -| Operación | Sin Índice | Con IVFFlat | Mejora | -|-----------|------------|-------------|--------| -| 10k rows | ~500ms | ~20ms | 25x | -| 100k rows | ~5s | ~50ms | 100x | - ---- - -### 12. **Integración MySQL Mejorada** 🔄 - -**Características:** -- Importación de study plans y courses desde MySQL -- Clasificación automática (regular/intensive, básico/intermedio/avanzado) -- Tracking de IDs originales para evitar duplicados -- Filtros por mysql_course_id en test templates - -**Tablas nuevas:** -- `mysql_study_plans` (planes de estudio) -- `mysql_courses` (cursos con duración y nivel) - -**Beneficio:** -- Migración sin dolor desde sistema legacy -- No duplicar datos al reimportar -- Filtros precisos por curso original - ---- - -### 13. **RAG Mejorado para Generación de Preguntas** 🧠 - -**Mejoras:** -- Búsqueda semántica de contexto (no solo keywords) -- Verificación automática de 4 habilidades (Reading, Listening, Speaking, Writing) -- Generación diversa con MMR (Maximal Marginal Relevance) -- Embeddings automáticos al generar - -**Beneficio:** -- Preguntas más relevantes y variadas -- Coverage completo de skills -- Menos duplicación accidental - ---- - -## 🚨 Breaking Changes - -- **JWT_SECRET**: Si actualizas la JWT_SECRET, todos los tokens existentes serán inválidos -- **Rate Limiting**: Algunas integraciones pueden necesitar ajustar sus límites -- **Health Endpoints**: Actualizar health checks de Kubernetes/load balancer si existen - ---- - -**Fecha de implementación:** Marzo 2026 -**Versión:** OpenCCB 0.2.0 (con PGVector y Búsqueda Semántica) diff --git a/PGVECTOR_EMBEDDINGS.md b/PGVECTOR_EMBEDDINGS.md deleted file mode 100644 index c4f660d..0000000 --- a/PGVECTOR_EMBEDDINGS.md +++ /dev/null @@ -1,286 +0,0 @@ -# PGVector Embeddings Implementation Guide - -## Overview - -OpenCCB now includes **semantic search capabilities** using PostgreSQL's `pgvector` extension and Ollama's embedding models. This enables: - -1. **Semantic question search** - Find similar questions in the question bank -2. **Improved RAG for question generation** - Generate questions based on semantic similarity -3. **Enhanced AI tutor chat** - Better context retrieval from knowledge base - -## Architecture - -``` -┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ -│ User Query │────▶│ Ollama │────▶│ Embedding │ -│ (text) │ │ (embeddings)│ │ Vector (384) │ -└─────────────────┘ └──────────────┘ └────────┬────────┘ - │ - ▼ -┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ -│ Search Results │◀────│ PostgreSQL │◀────│ pgvector │ -│ (similar items)│ │ + pgvector │ │ cosine search │ -└─────────────────┘ └──────────────┘ └─────────────────┘ -``` - -## Installation - -### 1. Update Docker Compose - -Change the database image to include pgvector: - -```yaml -# docker-compose.yml -services: - db: - image: pgvector/pgvector:pg16 # Was: postgres:16-alpine -``` - -### 2. Pull Embedding Model - -```bash -docker pull ollama/ollama:latest -docker exec -it ollama ollama pull nomic-embed-text -``` - -### 3. Run Migrations - -```bash -# CMS migrations (question_bank embeddings) -DATABASE_URL=postgresql://user:password@localhost:5433/openccb_cms \ - sqlx migrate run --source services/cms-service/migrations - -# LMS migrations (knowledge_base embeddings) -DATABASE_URL=postgresql://user:password@localhost:5433/openccb_lms \ - sqlx migrate run --source services/lms-service/migrations -``` - -### 4. Generate Embeddings - -After migration, generate embeddings for existing data: - -```bash -# Generate question embeddings -curl -X POST http://localhost:3001/question-bank/embeddings/generate \ - -H "Authorization: Bearer YOUR_TOKEN" - -# Generate knowledge base embeddings -curl -X POST http://localhost:3002/knowledge-base/embeddings/generate \ - -H "Authorization: Bearer YOUR_TOKEN" -``` - -## API Endpoints - -### CMS (Port 3001) - -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/question-bank/embeddings/generate` | Generate embeddings for all questions without them | -| POST | `/question-bank/{id}/embedding/regenerate` | Regenerate embedding for a specific question | -| GET | `/question-bank/semantic-search?query=...` | Search questions by semantic similarity | -| GET | `/question-bank/similar/{id}?threshold=0.85` | Find questions similar to a given question | - -### LMS (Port 3002) - -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/knowledge-base/embeddings/generate` | Generate embeddings for knowledge base entries | -| POST | `/knowledge-base/{id}/embedding/regenerate` | Regenerate embedding for a specific entry | -| GET | `/knowledge-base/semantic-search?query=...` | Search knowledge base semantically | - -## Configuration - -### Environment Variables - -```bash -# .env -LOCAL_OLLAMA_URL=http://localhost:11434 -EMBEDDING_MODEL=nomic-embed-text -``` - -### Supported Embedding Models - -| Model | Dimensions | Speed | Quality | Recommended | -|-------|------------|-------|---------|-------------| -| `nomic-embed-text` | 768 | Fast | Good | ✅ Default | -| `mxbai-embed-large` | 1024 | Medium | Better | For higher accuracy | -| `all-minilm` | 384 | Very Fast | Good | For resource-constrained | - -Pull models with: -```bash -ollama pull nomic-embed-text -ollama pull mxbai-embed-large -ollama pull all-minilm -``` - -## Usage Examples - -### 1. Semantic Question Search - -```bash -curl -G "http://localhost:3001/question-bank/semantic-search" \ - -d "query=questions about past tense verbs" \ - -d "limit=10" \ - -d "threshold=0.6" \ - -H "Authorization: Bearer YOUR_TOKEN" -``` - -Response: -```json -[ - { - "id": "uuid-here", - "question_text": "Choose the correct past tense of 'to go'", - "question_type": "multiple-choice", - "similarity": 0.87, - "tags": ["grammar", "past-tense"], - "difficulty": "medium", - "points": 1 - } -] -``` - -### 2. Find Duplicate Questions - -```bash -curl -G "http://localhost:3001/question-bank/similar/{question-id}" \ - -d "threshold=0.95" \ - -H "Authorization: Bearer YOUR_TOKEN" -``` - -### 3. RAG Question Generation (Enhanced) - -```bash -curl -X POST "http://localhost:3001/test-templates/generate-with-rag" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -d '{ - "topic": "present perfect tense", - "num_questions": 5 - }' -``` - -This now uses **semantic search** to find relevant questions from the bank, not just keyword matching. - -## Performance Considerations - -### Index Tuning - -The migrations create IVFFlat indexes optimized for >10k rows. For larger datasets: - -```sql --- For 100k+ rows, increase lists parameter -DROP INDEX IF EXISTS idx_question_embeddings; -CREATE INDEX idx_question_embeddings -ON question_bank -USING ivfflat (embedding vector_cosine_ops) -WITH (lists = 1000); -- Default: 100 -``` - -### Embedding Generation Speed - -- ~50ms per embedding with Ollama (local) -- Batch generation: 100 questions ≈ 5 seconds -- Recommended: Generate embeddings in background during off-peak hours - -### Query Performance - -| Operation | Without Index | With IVFFlat | -|-----------|---------------|--------------| -| Similarity search (10k rows) | ~500ms | ~20ms | -| Similarity search (100k rows) | ~5s | ~50ms | - -## Hybrid Search Strategy - -The implementation uses a **hybrid approach**: - -1. **First**: Try semantic search with embeddings (most accurate) -2. **Fallback**: Full-text search with tsvector (if embeddings unavailable) - -This ensures the system works even if: -- Ollama is temporarily unavailable -- Embeddings haven't been generated yet -- You want to minimize latency for simple queries - -## Database Schema - -### Question Bank (CMS) - -```sql -ALTER TABLE question_bank -ADD COLUMN embedding vector(384), -ADD COLUMN embedding_updated_at TIMESTAMPTZ; - -CREATE INDEX idx_question_embeddings -ON question_bank -USING ivfflat (embedding vector_cosine_ops); -``` - -### Knowledge Base (LMS) - -```sql -ALTER TABLE knowledge_base -ADD COLUMN embedding vector(384), -ADD COLUMN embedding_updated_at TIMESTAMPTZ; - -CREATE INDEX idx_knowledge_base_embeddings -ON knowledge_base -USING ivfflat (embedding vector_cosine_ops); -``` - -## Troubleshooting - -### "extension 'vector' does not exist" - -Make sure you're using the pgvector Docker image: -```bash -docker-compose pull db -docker-compose down -docker-compose up -d db -``` - -### Slow semantic search - -1. Check if index exists: -```sql -SELECT indexname FROM pg_indexes WHERE tablename = 'question_bank'; -``` - -2. Verify index is being used: -```sql -EXPLAIN ANALYZE SELECT * FROM question_bank -ORDER BY embedding <=> '[...]'::vector LIMIT 10; -``` - -### Embeddings not generating - -1. Check Ollama is running: -```bash -curl http://localhost:11434/api/tags -``` - -2. Verify model is available: -```bash -ollama list | grep nomic-embed -``` - -3. Check logs for errors: -```bash -docker logs openccb-studio-1 | grep -i embedding -``` - -## Future Enhancements - -Potential improvements: - -1. **Multi-vector search** - Combine title, question, and explanation embeddings -2. **Cross-lingual embeddings** - Support Spanish/English/Portuguese semantic search -3. **Query rewriting** - Use LLM to improve search queries before embedding -4. **Caching** - Cache common query embeddings for faster response -5. **Analytics** - Track which questions are most similar/related - -## References - -- [pgvector GitHub](https://github.com/pgvector/pgvector) -- [Ollama Embeddings API](https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings) -- [Nomic Embed Text Model](https://ollama.com/library/nomic-embed-text) diff --git a/README.md b/README.md index 2a5281f..76fe2a0 100644 --- a/README.md +++ b/README.md @@ -619,6 +619,59 @@ OpenCCB está diseñado como un módulo premium single-tenant. Todas las operaci - **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo. - **Premium Course Summaries**: Presentación de cursos con diseño de alta fidelidad y desgloses de objetivos de aprendizaje. + +--- + +## 📚 Documentación + +### Guías Principales + +| Archivo | Descripción | +|---------|-------------| +| **README.md** | Este archivo - Visión general y características | +| **roadmap.md** | Hoja de ruta completa del proyecto (Fases 1-21) | +| **ManualDeConfiguracion.md** | Guía completa de instalación, configuración y troubleshooting | + +### Comandos Rápidos + +```bash +# Instalación estándar (detecta dev/prod automáticamente) +./install.sh + +# Instalación rápida (omite chequeos) +./install.sh --fast + +# Despliegue a producción (sincroniza con servidor remoto) +./install.sh --deploy + +# Iniciar servicios +docker-compose up -d + +# Ver logs +docker-compose logs -f + +# Health checks +curl http://localhost:3001/health +curl http://localhost:3002/health +``` + +### URLs de Acceso + +| Servicio | Puerto | URL | +|----------|--------|-----| +| **Studio (CMS)** | 3000 | http://localhost:3000 | +| **Experience (LMS)** | 3003 | http://localhost:3003 | +| **CMS API** | 3001 | http://localhost:3001 | +| **LMS API** | 3002 | http://localhost:3002 | + +### Credenciales por Defecto + +Después de ejecutar `./install.sh`: + +- **Email**: `admin@norteamericano.cl` +- **Contraseña**: `Admin123!` + +--- ## �️ Próximos Pasos (Roadmap 2024-2025) OpenCCB evoluciona constantemente. Estos son los pilares de nuestro desarrollo futuro: diff --git a/RESPONSIVIDAD_GUIA.md b/RESPONSIVIDAD_GUIA.md deleted file mode 100644 index 66f1304..0000000 --- a/RESPONSIVIDAD_GUIA.md +++ /dev/null @@ -1,336 +0,0 @@ -# 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/TEST_TEMPLATES_IMPLEMENTATION.md b/TEST_TEMPLATES_IMPLEMENTATION.md deleted file mode 100644 index ab57d71..0000000 --- a/TEST_TEMPLATES_IMPLEMENTATION.md +++ /dev/null @@ -1,267 +0,0 @@ -# Test Templates System - Implementation Summary - -## Overview - -This document describes the implementation of a comprehensive **Test Template System** for OpenCCB, allowing instructors to create and reuse test/quiz templates based on: - -1. **Course Level**: `beginner`, `beginner_1`, `beginner_2`, `intermediate`, `advanced`, etc. -2. **Course Type**: `intensive` or `regular` -3. **Assessment Type**: `CA`, `MWT`, `MOT`, `FOT`, `FWT` - -## Database Changes - -### New Enums - -```sql --- Course levels -CREATE TYPE course_level AS ENUM ( - 'beginner', 'beginner_1', 'beginner_2', - 'intermediate', 'intermediate_1', 'intermediate_2', - 'advanced', 'advanced_1', 'advanced_2' -); - --- Course types -CREATE TYPE course_type AS ENUM ('intensive', 'regular'); - --- Test types (assessment types) -CREATE TYPE test_type AS ENUM ('CA', 'MWT', 'MOT', 'FOT', 'FWT'); -``` - -### New Tables - -#### `test_templates` -Main table for storing test templates with metadata: -- `id`: UUID primary key -- `organization_id`: Multi-tenancy support -- `name`, `description`: Template identification -- `level`, `course_type`, `test_type`: Classification enums -- `duration_minutes`, `passing_score`, `total_points`: Configuration -- `instructions`: General test instructions -- `template_data`: JSONB with complete test structure -- `tags`: Text array for categorization -- `usage_count`: Tracks template popularity -- `is_active`: Soft delete support - -#### `test_template_sections` -Optional sections within a test (e.g., "Reading", "Grammar", "Listening"): -- `template_id`: Foreign key to `test_templates` -- `section_order`: Ordering within template -- `points`, `instructions`: Section-specific config -- `section_data`: JSONB for advanced configuration - -#### `test_template_questions` -Individual questions for each template: -- `template_id`, `section_id`: Foreign keys -- `question_order`: Ordering -- `question_type`: "multiple-choice", "true-false", "short-answer", "essay", "matching", "ordering" -- `question_text`, `options`, `correct_answer`, `explanation`: Question content -- `points`, `metadata`: Scoring and additional data - -### Database Functions - -- `get_test_templates_by_filters()`: Filter templates by level, type, test type, and search -- `increment_template_usage()`: Track template usage statistics - -### Course Model Updates - -Added optional fields to `courses` table: -- `level`: Course level enum -- `course_type`: Course type enum - -## Backend Implementation - -### Models (`shared/common/src/models.rs`) - -New Rust structs: -- `CourseLevel`, `CourseType`, `TestType`: Enum types -- `TestTemplate`, `TestTemplateSection`, `TestTemplateQuestion`: Main models -- `CreateTestTemplatePayload`, `UpdateTestTemplatePayload`: Request/Response DTOs -- `TestTemplateWithQuestions`: Composite response type - -### API Handlers (`services/cms-service/src/handlers_test_templates.rs`) - -RESTful endpoints: - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/test-templates` | List templates with filters | -| POST | `/test-templates` | Create new template | -| GET | `/test-templates/{id}` | Get template with questions | -| PUT | `/test-templates/{id}` | Update template | -| DELETE | `/test-templates/{id}` | Delete template | -| POST | `/test-templates/{id}/questions` | Add question | -| DELETE | `/test-templates/{id}/questions/{qid}` | Delete question | -| POST | `/test-templates/{id}/sections` | Add section | -| DELETE | `/test-templates/{id}/sections/{sid}` | Delete section | -| POST | `/test-templates/{id}/apply` | Apply template to lesson | - -### Routes (`services/cms-service/src/main.rs`) - -All routes are protected and require organization context. - -## Frontend Implementation - -### TypeScript Types (`web/studio/src/lib/api.ts`) - -```typescript -type CourseLevel = 'beginner' | 'beginner_1' | ... | 'advanced_2'; -type CourseType = 'intensive' | 'regular'; -type TestType = 'CA' | 'MWT' | 'MOT' | 'FOT' | 'FWT'; -type QuestionType = 'multiple-choice' | 'true-false' | ...; - -interface TestTemplate { ... } -interface TestTemplateSection { ... } -interface TestTemplateQuestion { ... } -interface CreateTestTemplatePayload { ... } -``` - -### API Functions (`cmsApi` object) - -- `listTestTemplates(filters)` -- `getTestTemplate(templateId)` -- `createTestTemplate(payload)` -- `updateTestTemplate(templateId, payload)` -- `deleteTestTemplate(templateId)` -- `createTemplateQuestion(templateId, payload)` -- `deleteTemplateQuestion(templateId, questionId)` -- `createTemplateSection(templateId, payload)` -- `deleteTemplateSection(templateId, sectionId)` -- `applyTemplateToLesson(templateId, lessonId, gradingCategoryId)` - -### UI Components - -#### `TestTemplateManager` (`web/studio/src/components/TestTemplates/TestTemplateManager.tsx`) - -Main management interface with: -- Grid view of templates -- Search functionality -- Advanced filters (level, course type, test type) -- Template cards showing: - - Name, description - - Level, type badges with color coding - - Duration, passing score, total points - - Tags - - Usage statistics - - Action buttons (view, edit, delete, apply) - -#### `TestTemplateForm` (`web/studio/src/components/TestTemplates/TestTemplateForm.tsx`) - -Modal form for creating/editing templates with: -- Basic info (name, description) -- Classification (level, course type, test type) -- Configuration (duration, passing score, total points) -- Instructions -- Tag management - -#### Page (`web/studio/src/app/test-templates/page.tsx`) - -Dedicated page at `/test-templates` route. - -## Usage Examples - -### Creating a Template via API - -```bash -curl -X POST http://localhost:3001/test-templates \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ - -H "X-Organization-Id: YOUR_ORG_ID" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Final Exam - Beginner 1", - "description": "Comprehensive final exam for beginner level", - "level": "beginner_1", - "course_type": "regular", - "test_type": "FWT", - "duration_minutes": 90, - "passing_score": 70, - "total_points": 100, - "instructions": "Answer all questions. You have 90 minutes.", - "tags": ["final", "beginner", "written"] - }' -``` - -### Filtering Templates - -```bash -# Get all FOT templates for beginner intensive courses -curl "http://localhost:3001/test-templates?level=beginner_1&course_type=intensive&test_type=FOT" \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" -``` - -### Applying Template to a Lesson - -```bash -curl -X POST http://localhost:3001/test-templates/TEMPLATE_ID/apply \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ - -H "X-Organization-Id: YOUR_ORG_ID" \ - -H "Content-Type: application/json" \ - -d '{ - "lesson_id": "LESSON_ID", - "grading_category_id": "CATEGORY_ID" - }' -``` - -## Migration - -Run the migration to set up the database schema: - -```bash -cd services/cms-service -sqlx migrate run --source migrations -``` - -The migration file is: `20260316000000_test_templates.sql` - -## Future Enhancements - -1. **Template Cloning**: Allow duplicating existing templates -2. **Question Bank**: Centralized repository of questions that can be mixed and matched -3. **Template Sharing**: Share templates across organizations -4. **Analytics**: Track template effectiveness and student performance -5. **AI Generation**: Auto-generate templates based on course content -6. **Version Control**: Track template revisions -7. **Bulk Operations**: Apply templates to multiple lessons at once -8. **Preview Mode**: Preview template before applying - -## File Structure - -``` -openccb/ -├── shared/common/src/models.rs # Rust models -├── services/cms-service/ -│ ├── migrations/20260316000000_test_templates.sql -│ ├── src/handlers_test_templates.rs # API handlers -│ └── src/main.rs # Routes -└── web/studio/ - ├── src/lib/api.ts # TypeScript types & API functions - ├── src/components/TestTemplates/ - │ ├── TestTemplateManager.tsx - │ ├── TestTemplateForm.tsx - │ └── index.ts - └── src/app/test-templates/ - └── page.tsx -``` - -## Testing - -### Backend Tests - -```bash -cargo test -p common -cargo test -p cms-service -``` - -### Frontend Type Check - -```bash -cd web/studio -npm run type-check -``` - -## Notes - -- All templates are organization-scoped for multi-tenancy -- Soft delete via `is_active` flag preserves historical data -- Usage count helps identify popular templates -- JSONB fields provide flexibility for evolving question types -- The system integrates with existing grading categories via `tipo_nota` catalog diff --git a/TOKEN_LIMITS_COMPLETE_SUMMARY.md b/TOKEN_LIMITS_COMPLETE_SUMMARY.md deleted file mode 100644 index f3b7bf3..0000000 --- a/TOKEN_LIMITS_COMPLETE_SUMMARY.md +++ /dev/null @@ -1,297 +0,0 @@ -# Token Limits - Implementación 100% Completa - -## ✅ ESTADO: COMPLETADO (100%) - 2026-03-23 - -**Versión**: OpenCCB 0.2.4 -**Commits**: 5 commits en el día - ---- - -## 📊 Implementación Completa - -### ✅ Phase 1: Database + API -- [x] Migración de base de datos -- [x] Funciones SQL (check_token_limit, get_user_usage_stats) -- [x] API Endpoints para gestión de límites -- [x] Módulo common::token_limits - -### ✅ Phase 2: UI Dashboard + User Management -- [x] Token Usage Dashboard mejorado -- [x] User Management con columna de límites -- [x] Admin Dashboard con card de AI Token Usage -- [x] Edición de límites en tiempo real -- [x] Alertas visuales de uso - -### ✅ Phase 3: Sistema de Alertas Automáticas -- [x] Trigger SQL en ai_usage_logs -- [x] Notificaciones al 80%, 90%, 100% -- [x] Tabla token_limit_alerts para historial -- [x] Función send_token_limit_notification() - -### ✅ Phase 4: Enforce Automático en Handlers -- [x] generate_quiz (2000 tokens) -- [x] generate_course (5000 tokens) -- [x] generate_hotspots (2000 tokens) -- [x] generate_role_play (2500 tokens) -- [x] summarize_lesson (1500 tokens) -- [x] chat_with_tutor (1000 tokens) - LMS -- [x] evaluate_audio_response (1500 tokens) - LMS - ---- - -## 🎯 Dónde Encontrar Cada Feature - -### 1. Admin Dashboard -**URL**: `http://localhost:3000/admin` - -**Qué verás**: -- Card "AI Token Usage" (4ta card) -- Total tokens (formato compacto) -- Total requests • Costo USD -- Link a Token Usage Dashboard - -### 2. Token Usage Dashboard -**URL**: `http://localhost:3000/admin/token-usage` - -**Features**: -- Tabla editable de usuarios -- Columna "Límite Mensual" con editor ✏️ -- Columna "% Usado" con barra de progreso -- Alertas amarillas (>80%) y rojas (>100%) -- Ordenar por % usado - -### 3. User Management -**URL**: `http://localhost:3000/admin/users` - -**Features**: -- Columna "Token Limit" -- Badge de % usado con color -- Mini barra de progreso - -### 4. Notificaciones de Alerta -**Dónde**: Icono de campana en el header - -**Mensajes**: -- 80%: "📊 80% de Tokens IA Utilizados" -- 90%: "⚡ 90% de Tokens IA Utilizados" -- 100%: "⚠️ Límite de Tokens IA Excedido" - ---- - -## 🚀 Cómo Configurar - -### Configuración Inicial (SQL) - -```sql --- Ver configuración actual -SELECT email, role, monthly_token_limit, token_limit_reset_day -FROM users ORDER BY role, email; - --- Configurar por rol -UPDATE users SET monthly_token_limit = 50000 WHERE role = 'student'; -UPDATE users SET monthly_token_limit = 200000 WHERE role = 'instructor'; -UPDATE users SET monthly_token_limit = 0 WHERE role = 'admin'; -``` - -### Editar Límite Individual (UI) - -1. Navegar a `/admin/token-usage` -2. Buscar usuario en la tabla -3. Clic en ✏️ junto al límite -4. Ingresar nuevo valor (0 = ilimitado) -5. Clic en ✓ para guardar - ---- - -## 🔔 Sistema de Alertas - -### Cómo Funciona - -1. **Trigger** en `ai_usage_logs` INSERT -2. **Calcula** uso mensual del usuario -3. **Compara** con su límite mensual -4. **Envía notificación** si alcanza 80%, 90%, o 100% -5. **Registra** alerta para no repetir en el mismo mes - -### Niveles - -| % | Mensaje | Acción | -|---|---------|--------| -| 80% | 📊 Warning | Monitorear uso | -| 90% | ⚡ Critical | Reducir uso de IA | -| 100% | ⚠️ Error | Contacto admin | - ---- - -## 🚫 Enforce Automático - -### Qué Pasa Cuando Se Excede el Límite - -1. Usuario intenta usar función de IA -2. Sistema verifica: `check_ai_token_limit()` -3. Si excede límite → **HTTP 429 Too Many Requests** -4. Mensaje: "Monthly AI token limit exceeded" -5. Usuario debe contactar admin - -### Funciones Protegidas - -**CMS (5)**: -- `generate_quiz` → 2000 tokens -- `generate_course` → 5000 tokens -- `generate_hotspots` → 2000 tokens -- `generate_role_play` → 2500 tokens -- `summarize_lesson` → 1500 tokens - -**LMS (2)**: -- `chat_with_tutor` → 1000 tokens -- `evaluate_audio_response` → 1500 tokens - ---- - -## 📊 Monitoreo - -### Queries Útiles - -```sql --- Top usuarios por uso -SELECT - u.email, - u.role, - u.monthly_token_limit, - SUM(au.tokens_used) as used_tokens, - ROUND((SUM(au.tokens_used)::NUMERIC / - CASE WHEN u.monthly_token_limit = 0 THEN 1 - ELSE u.monthly_token_limit END * 100), 2) as percentage -FROM users u -LEFT JOIN ai_usage_logs au ON u.id = au.user_id - AND au.created_at >= DATE_TRUNC('month', NOW()) -GROUP BY u.id -ORDER BY percentage DESC; - --- Uso por día -SELECT - DATE(created_at) as date, - COUNT(*) as requests, - SUM(tokens_used) as total_tokens -FROM ai_usage_logs -WHERE created_at >= NOW() - INTERVAL '30 days' -GROUP BY DATE(created_at) -ORDER BY date DESC; - --- Alertas enviadas -SELECT - u.email, - n.title, - n.created_at -FROM notifications n -JOIN users u ON n.user_id = u.id -WHERE n.notification_type = 'token_limit_alert' -ORDER BY n.created_at DESC; -``` - ---- - -## 📁 Archivos Modificados - -### Backend (8 archivos) -- `shared/common/src/token_limits.rs` ✅ -- `shared/common/src/lib.rs` ✅ -- `services/cms-service/src/handlers_admin.rs` ✅ -- `services/cms-service/src/handlers.rs` ✅ -- `services/cms-service/src/main.rs` ✅ -- `services/lms-service/src/handlers.rs` ✅ -- `services/cms-service/migrations/20260323000000_monthly_token_limits.sql` ✅ -- `services/cms-service/migrations/20260323000001_token_limit_alerts.sql` ✅ - -### Frontend (3 archivos) -- `web/studio/src/app/admin/token-usage/page.tsx` ✅ -- `web/studio/src/app/admin/users/page.tsx` ✅ -- `web/studio/src/app/admin/page.tsx` ✅ - -### Documentación (5 archivos) -- `TOKEN_LIMITS_GUIDE.md` ✅ -- `TOKEN_LIMITS_STATUS.md` ✅ -- `TOKEN_LIMITS_UI_COMPLETE.md` ✅ -- `TOKEN_LIMITS_FINAL.md` ✅ -- `TOKEN_LIMITS_COMPLETE_SUMMARY.md` ✅ (este archivo) - ---- - -## 🧪 Testing Checklist - -- [ ] Navegar a `/admin` -- [ ] Ver card "AI Token Usage" -- [ ] Hacer clic en "View Details" -- [ ] Navegar a `/admin/token-usage` -- [ ] Ver tabla con usuarios y límites -- [ ] Ordenar por "% Usado" -- [ ] Editar límite de un usuario (✏️) -- [ ] Ver alerta amarilla (>80%) -- [ ] Ver alerta roja (>100%) -- [ ] Navegar a `/admin/users` -- [ ] Ver columna "Token Limit" -- [ ] Configurar límites por rol (SQL) -- [ ] Probar función de IA (quiz/course) -- [ ] Verificar enforce automático (429 error) -- [ ] Ver notificaciones de alerta - ---- - -## 🎓 Ejemplo de Uso Completo - -### 1. Configuración Inicial - -```sql --- Configurar límites -UPDATE users SET monthly_token_limit = 50000 WHERE role = 'student'; -UPDATE users SET monthly_token_limit = 200000 WHERE role = 'instructor'; -UPDATE users SET monthly_token_limit = 0 WHERE role = 'admin'; -``` - -### 2. Monitoreo Semanal - -1. Ir a `/admin/token-usage` -2. Ordenar por "% Usado" -3. Identificar usuarios con >80% -4. Ajustar límites si es necesario - -### 3. Alertas Automáticas - -El sistema automáticamente: -- Detecta cuando usuario alcanza 80%, 90%, 100% -- Envía notificación en plataforma -- Registra alerta (una vez por umbral/mes) - -### 4. Enforce Automático - -Cuando usuario excede límite: -- Intenta generar quiz → Error 429 -- Intenta chatear con tutor → Error 429 -- Mensaje: "Monthly AI token limit exceeded" -- Debe contactar admin para aumento - ---- - -## 📞 Soporte - -**Documentación Completa**: `TOKEN_LIMITS_GUIDE.md` -**UI Guide**: `TOKEN_LIMITS_UI_COMPLETE.md` -**Implementation**: `TOKEN_LIMITS_FINAL.md` -**Issues**: Reportar en GitHub - ---- - -## 🏆 Logros del Día - -✅ **5 Commits** realizados -✅ **4 Phases** completadas -✅ **7 Handlers** protegidos -✅ **3 Alertas** automáticas -✅ **100% Feature Complete** - ---- - -**Implementado por**: Equipo de Desarrollo OpenCCB -**Fecha**: 2026-03-23 -**Versión**: 0.2.4 -**Estado**: ✅ **Production Ready** diff --git a/TOKEN_LIMITS_FINAL.md b/TOKEN_LIMITS_FINAL.md deleted file mode 100644 index a065d8b..0000000 --- a/TOKEN_LIMITS_FINAL.md +++ /dev/null @@ -1,291 +0,0 @@ -# Token Limits - Implementación FINAL - -## ✅ ESTADO: COMPLETADO (100%) - -**Fecha**: 2026-03-23 -**Versión**: OpenCCB 0.2.3 - ---- - -## 📊 Resumen Ejecutivo - -Se ha implementado un **sistema completo de límites de tokens mensuales** con: - -1. ✅ **Database + API** (Phase 1) -2. ✅ **UI Dashboard + User Management** (Phase 2) -3. ✅ **Sistema de Alertas Automáticas** (Phase 3) -4. ⏳ **Enforce Automático en Handlers** (Pendiente - ver nota abajo) - ---- - -## 🎯 ¿Dónde Están las Opciones en el Control Global? - -### 1. Dashboard Principal -**URL**: `http://localhost:3000/admin` - -**Qué verás**: -- Card "AI Token Usage" (4ta card, ícono Gauge) -- Total tokens (formato compacto: 1.2M) -- Total requests • Costo USD -- Link "View Details" → Token Usage Dashboard - -**Si NO ves la card**: -- Limpia cache del navegador: `Ctrl + Shift + R` -- O espera a que el build de Docker termine (~5-10 min) - ---- - -### 2. Token Usage Dashboard -**URL**: `http://localhost:3000/admin/token-usage` - -**Qué verás**: -- Tabla con todos los usuarios -- Columnas: Usuario, Rol, Límite Mensual, % Usado, Total Tokens, Requests, Costo USD -- Botón ✏️ para editar límites -- Barras de progreso de color -- Alertas amarillas (>80%) y rojas (>100%) - -**Características**: -- Ordenar por % usado (nuevo) -- Filtrar por rol -- Editar en línea (clic en ✏️, ingresa valor, clic en ✓) - ---- - -### 3. User Management -**URL**: `http://localhost:3000/admin/users` - -**Qué verás**: -- Tabla de usuarios -- Columna nueva: "Token Limit" (después de Organization) -- Badge de % usado con color -- Mini barra de progreso - ---- - -## 🔔 Sistema de Alertas Automáticas (NUEVO) - -### ¿Cómo Funciona? - -1. **Trigger SQL** en `ai_usage_logs` se activa con cada INSERT -2. **Calcula** el uso mensual del usuario -3. **Compara** con su límite mensual -4. **Envía notificación** si alcanza 80%, 90%, o 100% -5. **Registra** la alerta para no repetir en el mismo mes - -### Niveles de Alerta - -| Porcentaje | Mensaje | Tipo | -|------------|---------|------| -| 80% | "📊 80% de Tokens IA Utilizados" | Warning | -| 90% | "⚡ 90% de Tokens IA Utilizados" | Critical | -| 100% | "⚠️ Límite de Tokens IA Excedido" | Error | - -### ¿Dónde Ver las Alertas? - -**En la plataforma**: -- Icono de campana (notificaciones) en el header -- Notificación con título y mensaje detallado -- Link a `/admin/token-usage` - -**En la base de datos**: -```sql --- Ver alertas enviadas -SELECT * FROM notifications -WHERE notification_type = 'token_limit_alert' -ORDER BY created_at DESC; - --- Ver historial de alertas por usuario -SELECT * FROM token_limit_alerts -WHERE user_id = 'user-uuid' -ORDER BY sent_at DESC; -``` - ---- - -## 🚀 Cómo Configurar Límites - -### Opción A: SQL Directo (Recomendado para configuración inicial) - -```sql --- Ver configuración actual -SELECT - email, - role, - monthly_token_limit, - token_limit_reset_day -FROM users -ORDER BY role, email; - --- Configurar por rol -UPDATE users SET monthly_token_limit = 50000, token_limit_reset_day = 1 -WHERE role = 'student'; - -UPDATE users SET monthly_token_limit = 200000, token_limit_reset_day = 1 -WHERE role = 'instructor'; - -UPDATE users SET monthly_token_limit = 0 -WHERE role = 'admin'; -- 0 = ilimitado -``` - -### Opción B: UI Dashboard - -1. Navega a `/admin/token-usage` -2. Busca el usuario en la tabla -3. Haz clic en ✏️ junto al límite -4. Ingresa nuevo valor (0 = ilimitado) -5. Haz clic en ✓ para guardar - ---- - -## 📊 Monitoreo - -### Queries Útiles - -```sql --- Top 10 usuarios por uso -SELECT - u.email, - u.role, - u.monthly_token_limit, - SUM(au.tokens_used) as used_tokens, - ROUND((SUM(au.tokens_used)::NUMERIC / - CASE WHEN u.monthly_token_limit = 0 THEN 1 - ELSE u.monthly_token_limit END * 100), 2) as percentage -FROM users u -LEFT JOIN ai_usage_logs au ON u.id = au.user_id - AND au.created_at >= DATE_TRUNC('month', NOW()) -GROUP BY u.id -ORDER BY percentage DESC -LIMIT 10; - --- Uso por día (últimos 30 días) -SELECT - DATE(created_at) as date, - COUNT(*) as requests, - SUM(tokens_used) as total_tokens, - SUM(estimated_cost_usd) as cost_usd -FROM ai_usage_logs -WHERE created_at >= NOW() - INTERVAL '30 days' -GROUP BY DATE(created_at) -ORDER BY date DESC; - --- Ver alertas enviadas este mes -SELECT - u.email, - n.title, - n.created_at -FROM notifications n -JOIN users u ON n.user_id = u.id -WHERE n.notification_type = 'token_limit_alert' - AND n.created_at >= DATE_TRUNC('month', NOW()) -ORDER BY n.created_at DESC; -``` - ---- - -## ⚠️ Nota Sobre Enforce Automático - -El **enforce automático** (bloquear solicitudes de IA cuando se excede el límite) requiere modificar 5 funciones en `handlers.rs`: - -- `generate_quiz` (2000 tokens) -- `generate_course` (5000 tokens) -- `generate_hotspots` (2000 tokens) -- `generate_role_play` (2500 tokens) -- `summarize_lesson` (1500 tokens) - -**Código a agregar en cada función**: -```rust -// Check token limit before proceeding (estimate X tokens) -if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 2000).await { - return Err(StatusCode::TOO_MANY_REQUESTS); -} -``` - -**Estado**: Pendiente de implementación manual (requiere edits cuidadosos en Rust) - -**Workaround actual**: El sistema de alertas notifica a los usuarios, pero no bloquea automáticamente. Los admins pueden contactar manualmente a usuarios que excedan el límite. - ---- - -## 📁 Archivos Modificados - -### Backend -- `shared/common/src/token_limits.rs` ✅ -- `shared/common/src/lib.rs` ✅ -- `services/cms-service/src/handlers_admin.rs` ✅ -- `services/cms-service/src/main.rs` ✅ -- `services/cms-service/migrations/20260323000000_monthly_token_limits.sql` ✅ -- `services/cms-service/migrations/20260323000001_token_limit_alerts.sql` ✅ - -### Frontend -- `web/studio/src/app/admin/token-usage/page.tsx` ✅ -- `web/studio/src/app/admin/users/page.tsx` ✅ -- `web/studio/src/app/admin/page.tsx` ✅ - -### Documentación -- `TOKEN_LIMITS_GUIDE.md` ✅ -- `TOKEN_LIMITS_STATUS.md` ✅ -- `TOKEN_LIMITS_UI_COMPLETE.md` ✅ -- `TOKEN_LIMITS_FINAL.md` ✅ (este archivo) - ---- - -## 🧪 Testing Checklist - -- [ ] Navegar a `/admin` -- [ ] Ver card "AI Token Usage" -- [ ] Hacer clic en "View Details" -- [ ] Navegar a `/admin/token-usage` -- [ ] Ver tabla con usuarios y límites -- [ ] Ordenar por "% Usado" -- [ ] Editar límite de un usuario (✏️) -- [ ] Ver alerta amarilla (>80%) -- [ ] Ver alerta roja (>100%) -- [ ] Navegar a `/admin/users` -- [ ] Ver columna "Token Limit" -- [ ] Configurar límites por rol (SQL) -- [ ] Ver notificaciones de alerta - ---- - -## 🎓 Ejemplo de Uso Completo - -### 1. Configuración Inicial - -```sql --- Configurar límites por rol -UPDATE users SET monthly_token_limit = 50000 WHERE role = 'student'; -UPDATE users SET monthly_token_limit = 200000 WHERE role = 'instructor'; -UPDATE users SET monthly_token_limit = 0 WHERE role = 'admin'; -``` - -### 2. Monitoreo Semanal - -1. Navegar a `/admin/token-usage` -2. Ordenar por "% Usado" -3. Identificar usuarios con >80% -4. Ajustar límites si es necesario - -### 3. Alertas Automáticas - -El sistema automáticamente: -- Detecta cuando un usuario alcanza 80%, 90%, o 100% -- Envía notificación en la plataforma -- Registra la alerta para no repetir -- Notifica solo una vez por umbral por mes - ---- - -## 📞 Soporte - -**Documentación**: `TOKEN_LIMITS_GUIDE.md` -**UI Guide**: `TOKEN_LIMITS_UI_COMPLETE.md` -**Issues**: Reportar en GitHub - ---- - -**Implementado por**: Equipo de Desarrollo OpenCCB -**Fecha**: 2026-03-23 -**Versión**: 0.2.3 -**Estado**: ✅ Production Ready diff --git a/TOKEN_LIMITS_GUIDE.md b/TOKEN_LIMITS_GUIDE.md deleted file mode 100644 index b47fc19..0000000 --- a/TOKEN_LIMITS_GUIDE.md +++ /dev/null @@ -1,358 +0,0 @@ -# Límites de Tokens Mensuales por Usuario - -## Visión General - -OpenCCB ahora soporta límites de tokens de IA mensuales configurables por usuario, permitiendo controlar el consumo de IA y prevenir usos excesivos. - ---- - -## Características - -### Configuración por Usuario - -Cada usuario puede tener: -- **Límite mensual personalizado**: Número máximo de tokens que puede consumir por mes -- **Día de reset**: Día del mes cuando se reinicia el contador (1-28) -- **Ilimitado**: Configurando límite en 0 - -### Valores por Defecto - -- **Límite**: 100,000 tokens/mes -- **Día de reset**: 1 (primero del mes) -- **Unlimited**: `monthly_token_limit = 0` - ---- - -## Esquema de Base de Datos - -### Tabla `users` (Nuevas Columnas) - -```sql -monthly_token_limit INTEGER DEFAULT 100000 - -- Límite mensual de tokens (0 = ilimitado) - -token_limit_reset_day INTEGER DEFAULT 1 - -- Día del mes para resetear (1-28) -``` - -### Vista `ai_usage_monthly` - -Muestra el uso actual del mes por usuario: - -```sql -SELECT * FROM ai_usage_monthly -WHERE user_id = '{user-uuid}'; -``` - -**Columnas:** -- `user_id`: UUID del usuario -- `organization_id`: UUID de la organización -- `usage_month`: Mes de uso -- `total_tokens`: Total de tokens consumidos -- `input_tokens`: Tokens de entrada (prompts) -- `output_tokens`: Tokens de salida (respuestas) -- `total_requests`: Número de peticiones a IA -- `total_cost_usd`: Costo estimado en USD - -### Funciones SQL - -#### `check_token_limit(user_id, additional_tokens)` - -Verifica si un usuario tiene tokens disponibles. - -```sql -SELECT * FROM check_token_limit( - 'user-uuid'::UUID, - 1000 -- Tokens adicionales a verificar -); -``` - -**Retorna:** -- `has_available_tokens`: BOOLEAN - ¿Tiene tokens disponibles? -- `monthly_limit`: INTEGER - Límite mensual configurado -- `used_tokens`: BIGINT - Tokens ya usados este mes -- `remaining_tokens`: BIGINT - Tokens restantes -- `reset_date`: TIMESTAMPTZ - Fecha del próximo reset - -#### `get_user_usage_stats(user_id, months)` - -Obtiene estadísticas históricas de uso. - -```sql -SELECT * FROM get_user_usage_stats( - 'user-uuid'::UUID, - 3 -- Últimos 3 meses -); -``` - -**Retorna:** -- `month_start`: DATE - Inicio del mes -- `total_tokens`: BIGINT - Tokens totales -- `input_tokens`: BIGINT - Tokens de entrada -- `output_tokens`: BIGINT - Tokens de salida -- `total_requests`: BIGINT - Número de peticiones -- `total_cost_usd`: NUMERIC - Costo estimado -- `monthly_limit`: INTEGER - Límite del mes -- `percentage_used`: NUMERIC - Porcentaje usado - ---- - -## API Endpoints - -### 1. Establecer Límite Mensual - -```http -PUT /api/admin/users/{user_id}/token-limit -Authorization: Bearer {admin_token} -Content-Type: application/json - -{ - "monthly_token_limit": 50000, - "token_limit_reset_day": 15 -} -``` - -**Parámetros:** -- `monthly_token_limit` (integer): Límite mensual (0 = ilimitado) -- `token_limit_reset_day` (integer, opcional): Día de reset (1-28, default: 1) - -**Respuesta:** `200 OK` - ---- - -### 2. Ver Uso de Tokens de Usuario - -```http -GET /api/admin/users/{user_id}/token-usage -Authorization: Bearer {admin_token} -``` - -**Respuesta:** -```json -{ - "user_id": "uuid", - "email": "user@example.com", - "full_name": "Nombre Usuario", - "monthly_token_limit": 100000, - "token_limit_reset_day": 1, - "used_tokens": 45678, - "total_requests": 234, - "total_cost_usd": 0.05, - "last_used": "2026-03-23T10:30:00Z" -} -``` - ---- - -### 3. Verificar Límite Disponible - -```http -GET /api/admin/users/{user_id}/token-limit/check -Authorization: Bearer {admin_token} -``` - -**Respuesta:** -```json -{ - "has_available_tokens": true, - "monthly_limit": 100000, - "used_tokens": 45678, - "remaining_tokens": 54322, - "reset_date": "2026-04-01T00:00:00Z" -} -``` - ---- - -## Ejemplos de Uso - -### Ejemplo 1: Establecer Límite de 50K Tokens - -```bash -curl -X PUT "http://localhost:3001/api/admin/users/{user-id}/token-limit" \ - -H "Authorization: Bearer {admin-token}" \ - -H "Content-Type: application/json" \ - -d '{ - "monthly_token_limit": 50000, - "token_limit_reset_day": 1 - }' -``` - -### Ejemplo 2: Verificar Tokens Disponibles - -```bash -curl "http://localhost:3001/api/admin/users/{user-id}/token-limit/check" \ - -H "Authorization: Bearer {admin-token}" -``` - -### Ejemplo 3: Obtener Uso del Mes - -```bash -curl "http://localhost:3001/api/admin/users/{user-id}/token-usage" \ - -H "Authorization: Bearer {admin-token}" -``` - -### Ejemplo 4: SQL Directo - -```sql --- Ver uso actual de todos los usuarios -SELECT - u.email, - u.full_name, - u.monthly_token_limit, - COALESCE(SUM(au.tokens_used), 0) as used_tokens, - u.monthly_token_limit - COALESCE(SUM(au.tokens_used), 0) as remaining_tokens, - CASE - WHEN u.monthly_token_limit = 0 THEN 'Unlimited' - ELSE ROUND((COALESCE(SUM(au.tokens_used), 0)::NUMERIC / u.monthly_token_limit * 100)::NUMERIC, 2) || '%' - END as usage_percentage -FROM users u -LEFT JOIN ai_usage_logs au ON u.id = au.user_id - AND au.created_at >= DATE_TRUNC('month', NOW()) -GROUP BY u.id, u.email, u.full_name, u.monthly_token_limit -ORDER BY used_tokens DESC; -``` - ---- - -## Estrategias de Límites - -### Por Rol de Usuario - -```sql --- Estudiantes: 50K tokens/mes -UPDATE users SET monthly_token_limit = 50000 WHERE role = 'student'; - --- Instructores: 200K tokens/mes -UPDATE users SET monthly_token_limit = 200000 WHERE role = 'instructor'; - --- Admins: Ilimitado -UPDATE users SET monthly_token_limit = 0 WHERE role = 'admin'; -``` - -### Por Tipo de Plan - -```sql --- Plan Básico: 25K tokens -UPDATE users SET monthly_token_limit = 25000 WHERE plan = 'basic'; - --- Plan Pro: 100K tokens -UPDATE users SET monthly_token_limit = 100000 WHERE plan = 'pro'; - --- Plan Enterprise: Ilimitado -UPDATE users SET monthly_token_limit = 0 WHERE plan = 'enterprise'; -``` - -### Reset en Días Diferentes - -```sql --- Reset el día 15 (mitad de mes) -UPDATE users SET token_limit_reset_day = 15 WHERE organization_id = 'org-uuid'; -``` - ---- - -## Implementación de Enforce - -Para hacer cumplir los límites a nivel de API, verifica antes de cada solicitud de IA: - -```rust -// Ejemplo en handler de IA -pub async fn chat_with_tutor(...) -> Result<...> { - // 1. Verificar límite de tokens - let limit_check: TokenLimitCheck = sqlx::query_as( - "SELECT * FROM check_token_limit($1, 1000)" // 1000 tokens estimados - ) - .bind(claims.sub) - .fetch_one(&pool) - .await?; - - if !limit_check.has_available_tokens { - return Err(( - StatusCode::TOO_MANY_REQUESTS, - format!( - "Monthly token limit exceeded. Reset date: {}", - limit_check.reset_date - ) - )); - } - - // 2. Proceder con solicitud de IA - // ... - - // 3. Loguear uso (ya implementado) - sqlx::query("SELECT log_ai_usage(...)").execute(&pool).await?; - - Ok(response) -} -``` - ---- - -## Monitoreo y Alertas - -### Dashboard de Uso - -```sql --- Usuarios que han usado > 80% de su límite -SELECT - u.email, - u.full_name, - u.monthly_token_limit, - SUM(au.tokens_used) as used_tokens, - ROUND((SUM(au.tokens_used)::NUMERIC / NULLIF(u.monthly_token_limit, 0) * 100)::NUMERIC, 2) as percentage_used -FROM users u -JOIN ai_usage_logs au ON u.id = au.user_id -WHERE au.created_at >= DATE_TRUNC('month', NOW()) - AND u.monthly_token_limit > 0 -GROUP BY u.id, u.email, u.full_name, u.monthly_token_limit -HAVING SUM(au.tokens_used)::NUMERIC / NULLIF(u.monthly_token_limit, 0) > 0.8 -ORDER BY percentage_used DESC; -``` - -### Notificación de Límite Alcanzado - -Configura webhooks o emails cuando un usuario alcance el 80%, 90% y 100% de su límite. - ---- - -## Troubleshooting - -### Usuario Reporta que No Puede Usar IA - -```sql --- Verificar límite y uso -SELECT * FROM check_token_limit('user-uuid'::UUID, 0); - --- Ver historial de uso -SELECT * FROM get_user_usage_stats('user-uuid'::UUID, 1); - --- Ver logs de errores -SELECT * FROM ai_usage_logs -WHERE user_id = 'user-uuid' -ORDER BY created_at DESC -LIMIT 10; -``` - -### Resetear Contador de Usuario - -```sql --- Establecer límite ilimitado temporalmente -UPDATE users SET monthly_token_limit = 0 WHERE id = 'user-uuid'; - --- O aumentar límite -UPDATE users SET monthly_token_limit = 200000 WHERE id = 'user-uuid'; -``` - ---- - -## Referencias - -- **Migración:** `services/cms-service/migrations/20260323000000_monthly_token_limits.sql` -- **Handlers:** `services/cms-service/src/handlers_admin.rs` -- **Endpoints:** `services/cms-service/src/main.rs` - ---- - -**Fecha:** 2026-03-23 -**Versión:** OpenCCB 0.2.1 diff --git a/TOKEN_LIMITS_STATUS.md b/TOKEN_LIMITS_STATUS.md deleted file mode 100644 index ca6b09e..0000000 --- a/TOKEN_LIMITS_STATUS.md +++ /dev/null @@ -1,155 +0,0 @@ -# Token Limits - Estado de Implementación - -## ✅ Phase 1: Completado (Database + API) - -### Base de Datos -- [x] Columnas `monthly_token_limit` y `token_limit_reset_day` en users -- [x] Vista `ai_usage_monthly` -- [x] Función `check_token_limit()` -- [x] Función `get_user_usage_stats()` - -### API Endpoints -- [x] `PUT /admin/users/{user_id}/token-limit` -- [x] `GET /admin/users/{user_id}/token-usage` -- [x] `GET /admin/users/{user_id}/token-limit/check` - -### Common Library -- [x] Módulo `token_limits` -- [x] Función `check_ai_token_limit()` - ---- - -## 🔄 Phase 2: En Progreso (Enforce + Dashboard + Alertas) - -### 1. Enforce Automático en Handlers de IA -- [ ] `generate_quiz` - 2000 tokens estimados -- [ ] `generate_course` - 5000 tokens estimados -- [ ] `generate_hotspots` - 2000 tokens estimados -- [ ] `generate_role_play` - 2500 tokens estimados -- [ ] `summarize_lesson` - 1500 tokens estimados -- [ ] `chat_with_tutor` (LMS) - 1000 tokens estimados -- [ ] `evaluate_audio` (LMS) - 1500 tokens estimados - -**Código a agregar en cada handler:** -```rust -// Check token limit before proceeding (estimate X tokens) -if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 2000).await { - return Err(StatusCode::TOO_MANY_REQUESTS); -} -``` - -### 2. Dashboard UI para Admins -- [ ] Página `/admin/token-usage` en Studio -- [ ] Tabla de usuarios con uso de tokens -- [ ] Gráfico de uso por día/semana/mes -- [ ] Input para editar límites por usuario -- [ ] Alertas visuales (>80%, >90%, 100%) - -**Componentes necesarios:** -- `TokenUsageDashboard.tsx` - Página principal -- `UserTokenRow.tsx` - Fila de tabla de usuario -- `TokenLimitEditor.tsx` - Editor de límites -- `UsageChart.tsx` - Gráfico de uso - -### 3. Sistema de Alertas -- [ ] Trigger en `ai_usage_logs` INSERT -- [ ] Notificaciones al alcanzar 80%, 90%, 100% -- [ ] Email opcional al usuario -- [ ] Registro en tabla `notifications` - -**Trigger SQL:** -```sql -CREATE OR REPLACE FUNCTION check_token_limit_alert() -RETURNS TRIGGER AS $$ -DECLARE - v_limit INTEGER; - v_used BIGINT; - v_percentage NUMERIC; -BEGIN - -- Get user limit - SELECT monthly_token_limit INTO v_limit - FROM users WHERE id = NEW.user_id; - - IF v_limit > 0 THEN - -- Calculate current usage - SELECT SUM(tokens_used) INTO v_used - FROM ai_usage_logs - WHERE user_id = NEW.user_id - AND created_at >= DATE_TRUNC('month', NOW()); - - v_percentage := (v_used::NUMERIC / v_limit * 100); - - -- Send notification at thresholds - IF v_percentage >= 80 AND v_percentage < 90 THEN - -- Send 80% alert - ELSIF v_percentage >= 90 AND v_percentage < 100 THEN - -- Send 90% alert - ELSIF v_percentage >= 100 THEN - -- Send 100% alert - END IF; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; -``` - ---- - -## 📊 Métricas de Uso - -### Queries Útiles - -```sql --- Usuarios que excedieron 80% de su límite -SELECT - u.email, - u.monthly_token_limit, - SUM(au.tokens_used) as used, - ROUND((SUM(au.tokens_used)::NUMERIC / u.monthly_token_limit * 100), 2) as percentage -FROM users u -JOIN ai_usage_logs au ON u.id = au.user_id -WHERE au.created_at >= DATE_TRUNC('month', NOW()) - AND u.monthly_token_limit > 0 -GROUP BY u.id -HAVING SUM(au.tokens_used)::NUMERIC / u.monthly_token_limit > 0.8 -ORDER BY percentage DESC; - --- Uso por endpoint -SELECT - endpoint, - COUNT(*) as requests, - SUM(tokens_used) as total_tokens, - AVG(tokens_used) as avg_tokens -FROM ai_usage_logs -WHERE created_at >= DATE_TRUNC('month', NOW()) -GROUP BY endpoint -ORDER BY total_tokens DESC; - --- Top 10 usuarios por uso -SELECT - u.email, - SUM(au.tokens_used) as total_tokens, - COUNT(*) as requests -FROM users u -JOIN ai_usage_logs au ON u.id = au.user_id -WHERE au.created_at >= DATE_TRUNC('month', NOW()) -GROUP BY u.id -ORDER BY total_tokens DESC -LIMIT 10; -``` - ---- - -## 🎯 Próximos Pasos - -1. **Enforce Automático**: Agregar check en handlers de IA (CMS + LMS) -2. **Dashboard UI**: Crear componentes React para admin -3. **Alertas**: Implementar trigger y notificaciones -4. **Testing**: Verificar que los límites se respetan -5. **Documentación**: Actualizar guías de uso - ---- - -**Fecha**: 2026-03-23 -**Estado**: Phase 1 ✅ Completado, Phase 2 🔄 En Progreso diff --git a/TOKEN_LIMITS_UI_COMPLETE.md b/TOKEN_LIMITS_UI_COMPLETE.md deleted file mode 100644 index bb54d38..0000000 --- a/TOKEN_LIMITS_UI_COMPLETE.md +++ /dev/null @@ -1,294 +0,0 @@ -# Token Limits - Implementación Completa - -## ✅ Estado: COMPLETADO - -**Fecha**: 2026-03-23 -**Versión**: OpenCCB 0.2.2 - ---- - -## 📊 Resumen de la Implementación - -### Phase 1: Database + API ✅ -- [x] Migración de base de datos -- [x] Funciones SQL (check_token_limit, get_user_usage_stats) -- [x] API Endpoints para gestión de límites -- [x] Módulo common::token_limits - -### Phase 2: UI Dashboard + User Management ✅ -- [x] Token Usage Dashboard mejorado -- [x] User Management con columna de límites -- [x] Admin Dashboard con card de AI Token Usage -- [x] Edición de límites en tiempo real -- [x] Alertas visuales de uso - -### Phase 3: Enforce Automático ⏳ (Pendiente) -- [ ] Agregar check en handlers de IA (CMS + LMS) -- [ ] Sistema de alertas por email/notificación -- [ ] Trigger SQL para notificaciones automáticas - ---- - -## 🎯 Dónde Encontrar Cada Feature - -### 1. Token Usage Dashboard -**URL**: `http://localhost:3000/admin/token-usage` - -**Features**: -- ✅ Ver uso de tokens por usuario -- ✅ Ver límite mensual configurado -- ✅ Ver porcentaje usado con barra de progreso -- ✅ Editar límites (clic en ícono ✏️) -- ✅ Alertas de usuarios >80% y >100% -- ✅ Ordenar por % usado, tokens, requests, costo -- ✅ Filtrar por rol - -**Columnas**: -| Usuario | Rol | Límite Mensual | % Usado | Total Tokens | Requests | Costo USD | -|---------|-----|----------------|---------|--------------|----------|-----------| - ---- - -### 2. User Management -**URL**: `http://localhost:3000/admin/users` - -**Features**: -- ✅ Ver límite de tokens por usuario -- ✅ Ver porcentaje usado con badge de color -- ✅ Barra de progreso miniatura - -**Columnas Nuevas**: -- Token Limit: Muestra % usado y límite mensual - ---- - -### 3. Admin Dashboard -**URL**: `http://localhost:3000/admin` - -**Features**: -- ✅ Card "AI Token Usage" con resumen -- ✅ Total tokens (formato compacto: 1.2M) -- ✅ Total requests -- ✅ Costo estimado en USD -- ✅ Link a Token Usage Dashboard - ---- - -## 🎨 UI/UX Features - -### Sistema de Colores - -| Porcentaje | Color | Badge | Barra | -|------------|-------|-------|-------| -| 0-49% | Verde | bg-green-50 | bg-green-600 | -| 50-79% | Azul | bg-blue-50 | bg-blue-600 | -| 80-89% | Amarillo | bg-yellow-50 | bg-yellow-600 | -| 90-99% | Naranja | bg-orange-50 | bg-orange-600 | -| 100%+ | Rojo | bg-red-50 | bg-red-600 | - -### Alertas - -**Amarilla**: Usuarios con ≥80% de uso -``` -⚠️ Usuarios cerca del límite -X usuario(s) han usado ≥80% de su límite mensual. -``` - -**Roja**: Usuarios con ≥100% de uso -``` -⚠️ Límite excedido -X usuario(s) han excedido su límite mensual. -``` - ---- - -## 🔧 Cómo Usar - -### 1. Ver Uso de Tokens - -1. Navega a `/admin/token-usage` -2. Verás tabla con todos los usuarios -3. Ordena por "% Usado" para ver quienes están cerca del límite - -### 2. Editar Límite de Usuario - -1. En `/admin/token-usage` o `/admin/users` -2. Busca el usuario en la tabla -3. Haz clic en el ícono ✏️ junto al límite -4. Ingresa nuevo límite (0 = ilimitado) -5. Haz clic en ✓ para guardar - -### 3. Configuración Masiva (SQL) - -```sql --- Estudiantes: 50K tokens/mes -UPDATE users -SET monthly_token_limit = 50000, token_limit_reset_day = 1 -WHERE role = 'student'; - --- Instructores: 200K tokens/mes -UPDATE users -SET monthly_token_limit = 200000, token_limit_reset_day = 1 -WHERE role = 'instructor'; - --- Admins: Ilimitado -UPDATE users -SET monthly_token_limit = 0 -WHERE role = 'admin'; -``` - ---- - -## 📊 Métricas y Queries Útiles - -### Ver Usuarios por Porcentaje de Uso - -```sql -SELECT - u.email, - u.role, - u.monthly_token_limit, - SUM(au.tokens_used) as used_tokens, - ROUND((SUM(au.tokens_used)::NUMERIC / - CASE WHEN u.monthly_token_limit = 0 THEN 1 - ELSE u.monthly_token_limit END * 100), 2) as percentage -FROM users u -LEFT JOIN ai_usage_logs au ON u.id = au.user_id - AND au.created_at >= DATE_TRUNC('month', NOW()) -WHERE u.monthly_token_limit IS NOT NULL -GROUP BY u.id -ORDER BY percentage DESC; -``` - -### Ver Uso por Día - -```sql -SELECT - DATE(created_at) as date, - COUNT(*) as requests, - SUM(tokens_used) as total_tokens, - SUM(estimated_cost_usd) as cost_usd -FROM ai_usage_logs -WHERE created_at >= NOW() - INTERVAL '30 days' -GROUP BY DATE(created_at) -ORDER BY date DESC; -``` - ---- - -## 🚀 Próximos Pasos (Opcional) - -### 1. Enforce Automático en Handlers de IA - -Agregar en cada handler que usa IA: - -```rust -// Check token limit before proceeding (estimate X tokens) -if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 2000).await { - return Err(StatusCode::TOO_MANY_REQUESTS); -} -``` - -**Handlers a actualizar**: -- `generate_quiz` (2000 tokens) -- `generate_course` (5000 tokens) -- `generate_hotspots` (2000 tokens) -- `generate_role_play` (2500 tokens) -- `summarize_lesson` (1500 tokens) -- `chat_with_tutor` (LMS, 1000 tokens) -- `evaluate_audio` (LMS, 1500 tokens) - -### 2. Sistema de Alertas Automáticas - -```sql -CREATE TRIGGER token_limit_alert -AFTER INSERT ON ai_usage_logs -FOR EACH ROW -EXECUTE FUNCTION check_token_limit_alert(); -``` - -### 3. Notificaciones por Email - -- Email al alcanzar 80% -- Email al alcanzar 90% -- Email al alcanzar 100% - ---- - -## 📁 Archivos Modificados - -### Backend -- `shared/common/src/token_limits.rs` (NUEVO) -- `shared/common/src/lib.rs` -- `services/cms-service/src/handlers_admin.rs` -- `services/cms-service/src/main.rs` -- `services/cms-service/migrations/20260323000000_monthly_token_limits.sql` (NUEVO) - -### Frontend -- `web/studio/src/app/admin/token-usage/page.tsx` -- `web/studio/src/app/admin/users/page.tsx` -- `web/studio/src/app/admin/page.tsx` - -### Documentación -- `TOKEN_LIMITS_GUIDE.md` (NUEVO) -- `TOKEN_LIMITS_STATUS.md` (NUEVO) -- `TOKEN_LIMITS_UI_COMPLETE.md` (ESTE ARCHIVO) - ---- - -## 🎓 Ejemplo de Uso Real - -### Escenario: Universidad con 1000 estudiantes - -**Configuración Inicial**: -```sql --- Límites por rol -UPDATE users SET monthly_token_limit = 50000 WHERE role = 'student'; -- 50K -UPDATE users SET monthly_token_limit = 200000 WHERE role = 'instructor'; -- 200K -UPDATE users SET monthly_token_limit = 0 WHERE role = 'admin'; -- Unlimited -``` - -**Monitoreo Semanal**: -1. Revisar `/admin/token-usage` -2. Ordenar por "% Usado" -3. Identificar estudiantes con >80% -4. Ajustar límites si es necesario - -**Ajuste de Límites**: -```sql --- Aumentar límite para estudiantes avanzados -UPDATE users -SET monthly_token_limit = 100000 -WHERE role = 'student' AND gpa > 3.5; -``` - ---- - -## ✅ Checklist de Testing - -- [ ] Navegar a `/admin/token-usage` -- [ ] Ver tabla con usuarios y límites -- [ ] Editar límite de un usuario (✏️) -- [ ] Ver alerta amarilla (>80%) -- [ ] Ver alerta roja (>100%) -- [ ] Navegar a `/admin/users` -- [ ] Ver columna Token Limit -- [ ] Navegar a `/admin` -- [ ] Ver card AI Token Usage -- [ ] Ver link a Token Usage Dashboard -- [ ] Probar en mobile (responsive) -- [ ] Probar en dark mode - ---- - -## 📞 Soporte - -**Documentación Completa**: `TOKEN_LIMITS_GUIDE.md` -**Estado**: `TOKEN_LIMITS_STATUS.md` -**Issues**: Reportar en GitHub - ---- - -**Implementado por**: Equipo de Desarrollo OpenCCB -**Fecha**: 2026-03-23 -**Versión**: 0.2.2 diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..fdd3d46 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,656 @@ +#!/bin/bash + +# OpenCCB Unified Deployment Script +# Despliegue automático en AWS EC2 con SSL (Let's Encrypt) +# Servidor: ec2-18-224-137-67.us-east-2.compute.amazonaws.com +# Dominios: studio.norteamericano.com, learning.norteamericano.com + +set -e + +echo "====================================================" +echo " 🚀 OpenCCB Deployment Tool" +echo "====================================================" +echo "" + +# ============================================================================ +# CONFIGURACIÓN +# ============================================================================ +PEM_PATH="ubuntu.pem" +REMOTE_USER="ubuntu" +REMOTE_HOST="ec2-18-224-137-67.us-east-2.compute.amazonaws.com" +REMOTE_PATH="/var/www/openccb" +# Cambiar a "false" para usar Let's Encrypt production (solo después de rate limits) +LETSENCRYPT_STAGING="true" +# ============================================================================ + +# Si PEM_PATH es relativo, convertirlo a absoluto +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ "$PEM_PATH" != /* ]]; then + PEM_PATH="$SCRIPT_DIR/$PEM_PATH" +fi + +# Verificar que existe el archivo PEM +if [ ! -f "$PEM_PATH" ]; then + echo "❌ ERROR: No se encontró el archivo $PEM_PATH" + echo "" + echo "Verifica la ruta de la llave SSH" + exit 1 +fi + +echo "✅ Configuración cargada exitosamente" +echo "" +echo "📋 Configuración de despliegue:" +echo " 👤 Usuario: $REMOTE_USER" +echo " 🖥️ Host: $REMOTE_HOST" +echo " 📁 Destino: $REMOTE_PATH" +echo " 🔑 SSH Key: $PEM_PATH" +echo "" + +# Preguntar si continuar +read -p "¿Desea continuar con el despliegue? [y/N]: " CONFIRM +if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + echo "❌ Despliegue cancelado" + exit 0 +fi + +echo "" +echo "📦 Preparando archivos para producción..." + +# Crear directorio temporal +PROD_DIR="./.deploy-temp-$$" +mkdir -p "$PROD_DIR" + +# Asegurar limpieza al finalizar +cleanup() { + rm -rf "$PROD_DIR" +} +trap cleanup EXIT + +# Copiar archivos esenciales +echo " 📋 Copiando archivos esenciales..." +cp -r docker-compose.yml "$PROD_DIR/" 2>/dev/null || echo " ⚠️ docker-compose.yml no existe" +# NO copiar .env local - tiene configuraciones incorrectas para producción +echo " ℹ️ .env local NO se copia - se generará uno correcto en el servidor" +cp -r .env.example "$PROD_DIR/" 2>/dev/null || true + +# NO copiar ubuntu.pem - solo se usa localmente para SSH +echo " ℹ️ ubuntu.pem NO se copia - solo para SSH local" + +# Copiar servicios excluyendo target/ y node_modules/ +echo " - Copiando services/..." +mkdir -p "$PROD_DIR/services" +find services -type f \ + ! -path "*/target/*" \ + ! -path "*/node_modules/*" \ + -exec cp --parents {} "$PROD_DIR/" \; 2>/dev/null || true + +# Copiar shared excluyendo target/ y node_modules/ +echo " - Copiando shared/..." +mkdir -p "$PROD_DIR/shared" +find shared -type f \ + ! -path "*/target/*" \ + ! -path "*/node_modules/*" \ + -exec cp --parents {} "$PROD_DIR/" \; 2>/dev/null || true + +# Copiar web excluyendo .next/ y node_modules/ +echo " - Copiando web/..." +mkdir -p "$PROD_DIR/web" +find web -type f \ + ! -path "*/.next/*" \ + ! -path "*/node_modules/*" \ + -exec cp --parents {} "$PROD_DIR/" \; 2>/dev/null || true + +# Copiar archivos root +cp -r Cargo.toml "$PROD_DIR/" 2>/dev/null || true +cp -r Cargo.lock "$PROD_DIR/" 2>/dev/null || true + +# Copiar configuración de nginx +mkdir -p "$PROD_DIR/nginx" +if [ -f "nginx/proxy.conf" ]; then + cp nginx/proxy.conf "$PROD_DIR/nginx/" +fi + +echo " ✅ Archivos esenciales copiados" + +# Contar archivos +FILE_COUNT=$(find "$PROD_DIR" -type f | wc -l) +TOTAL_SIZE=$(du -sh "$PROD_DIR" | cut -f1) +echo " ✅ $FILE_COUNT archivos listos ($TOTAL_SIZE)" +echo "" + +# Verificar rsync +RSYNC_PATH=$(which rsync 2>/dev/null || echo "") +if [ -z "$RSYNC_PATH" ]; then + echo "📦 Instalando rsync..." + if command -v apt-get &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y rsync + else + echo "❌ Instala rsync manualmente: sudo apt-get install rsync" + exit 1 + fi + RSYNC_PATH=$(which rsync 2>/dev/null || echo "") +fi +echo "✅ rsync encontrado en: $RSYNC_PATH" +echo "" + +# Sincronizar con servidor remoto +echo "🌐 Sincronizando con servidor remoto..." + +# Verificar conectividad SSH +if ! ssh -i "$PEM_PATH" -o ConnectTimeout=10 -o BatchMode=yes "$REMOTE_USER@$REMOTE_HOST" "echo 'SSH OK'" &> /dev/null; then + echo " ❌ ERROR: No se pudo conectar vía SSH" + echo " Verifica:" + echo " 1. Que el archivo $PEM_PATH existe" + echo " 2. Que los permisos son correctos - chmod 400 $PEM_PATH" + echo " 3. Que el host $REMOTE_HOST es accesible" + exit 1 +fi +echo " ✅ SSH conectado exitosamente" +echo "" + +# Crear directorio remoto +echo " 📁 Creando directorio remoto..." +ssh -i "$PEM_PATH" "$REMOTE_USER@$REMOTE_HOST" "sudo mkdir -p $REMOTE_PATH && sudo chown $REMOTE_USER:$REMOTE_USER $REMOTE_PATH" + +# Sincronizar archivos +echo " 📤 Subiendo archivos con rsync..." +rsync -avz -e "ssh -i $PEM_PATH" \ + --progress \ + --rsync-path="sudo rsync" \ + --exclude 'node_modules' \ + --exclude 'target' \ + --exclude '.next' \ + --exclude '.git' \ + --exclude '.qwen' \ + "$PROD_DIR/" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH" + +if [ $? -ne 0 ]; then + echo " ❌ ERROR: rsync falló" + exit 1 +fi + +rm -rf "$PROD_DIR" +echo "" +echo "✅ Archivos sincronizados exitosamente!" +echo "" + +# ============================================================================ +# SCRIPT REMOTO PARA GESTIÓN DE CONTENEDORES +# ============================================================================ +echo "🔧 Ejecutando gestión de contenedores en remoto..." +echo "" + +# ============================================================================ +# PREGUNTAR DATOS DEL ADMINISTRADOR (LOCAL) +# ============================================================================ +echo "" +echo "========================================" +echo " Configuración del Administrador" +echo "========================================" +echo "" + +# Preguntar datos del administrador +read -p "Nombre completo del administrador [Administrador]: " ADMIN_NAME +ADMIN_NAME=${ADMIN_NAME:-Administrador} + +read -p "Email del administrador [admin@norteamericano.com]: " ADMIN_EMAIL +ADMIN_EMAIL=${ADMIN_EMAIL:-admin@norteamericano.com} + +read -sp "Contraseña del administrador [Admin123!]: " ADMIN_PASS +echo "" +ADMIN_PASS=${ADMIN_PASS:-Admin123!} + +read -p "Nombre de la organización [Norteamericano]: " ORG_NAME +ORG_NAME=${ORG_NAME:-Norteamericano} + +echo "" +echo "----------------------------------------" +echo "Configuración SSL" +echo "----------------------------------------" +echo "" +echo "¿Deseas usar SSL con Let's Encrypt?" +echo " - SI: Usará HTTPS (recomendado para producción)" +echo " - NO: Usará HTTP (recomendado para pruebas o si hay rate limits)" +echo "" +read -p "¿Usar SSL? [y/N]: " USE_SSL +USE_SSL=${USE_SSL:-N} + +if [[ "$USE_SSL" =~ ^[Yy]$ ]]; then + echo "" + echo "----------------------------------------" + echo "Configuración de Let's Encrypt" + echo "----------------------------------------" + echo "" + echo "¿Usar servidor de STAGING (certificados de prueba)?" + echo " - SI: Sin rate limits, pero el navegador muestra advertencias" + echo " - NO: Certificados reales, pero con rate limits (5 por semana)" + echo "" + read -p "¿Usar STAGING? [y/N]: " USE_STAGING + USE_STAGING=${USE_STAGING:-Y} + + if [[ "$USE_STAGING" =~ ^[Yy]$ ]]; then + LETSENCRYPT_STAGING="true" + PROTOCOL="http" + echo "" + echo "✅ Configuración: STAGING (HTTP por ahora)" + echo " Los certificados de staging no son válidos para producción" + echo " Las llamadas API usarán HTTP para evitar errores de SSL" + else + LETSENCRYPT_STAGING="false" + PROTOCOL="https" + echo "" + echo "✅ Configuración: PRODUCTION (HTTPS)" + echo " Se usarán certificados reales de Let's Encrypt" + fi +else + LETSENCRYPT_STAGING="false" + PROTOCOL="http" + echo "" + echo "✅ Configuración: HTTP (sin SSL)" +fi + +echo "" +echo "========================================" +echo " Resumen de Configuración" +echo "========================================" +echo "" +echo " Nombre: $ADMIN_NAME" +echo " Email: $ADMIN_EMAIL" +echo " Organización: $ORG_NAME" +echo " Protocolo: $PROTOCOL" +echo " SSL Staging: $LETSENCRYPT_STAGING" +echo "" + +# Crear script remoto en un archivo temporal +cat > /tmp/remote-deploy.sh << 'REMOTE_SCRIPT_CONTENT' +set -e + +cd /var/www/openccb + +echo "========================================" +echo " OpenCCB Remote Deployment" +echo "========================================" +echo "" + +# ======================================== +# GENERAR .ENV CORRECTO PARA PRODUCCION +# ======================================== +echo "Generando configuracion .env para produccion..." + +if [ ! -f ".env" ]; then + echo " Creando .env desde .env.example..." + if [ -f ".env.example" ]; then + cp .env.example .env + else + touch .env + fi +fi + +# Generar DB_PASSWORD seguro +if ! grep -q "^DB_PASSWORD=" .env || grep -q "CHANGE_ME" .env || grep -q "^DB_PASSWORD=password$" .env; then + echo " Generando DB_PASSWORD segura..." + DB_PASS=$(openssl rand -base64 32 | tr -dc "a-zA-Z0-9" | head -c 32) + if grep -q "^DB_PASSWORD=" .env; then + sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=$DB_PASS/" .env + else + echo "DB_PASSWORD=$DB_PASS" >> .env + fi +fi + +# Generar JWT_SECRET seguro +if ! grep -q "^JWT_SECRET=" .env || grep -q "CHANGE_ME" .env || grep -q "secret.*2025" .env || grep -q "^JWT_SECRET=supersecret" .env; then + echo " Generando JWT_SECRET seguro..." + JWT_SEC=$(openssl rand -base64 48 | tr -dc "a-zA-Z0-9" | head -c 64) + if grep -q "^JWT_SECRET=" .env; then + sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$JWT_SEC/" .env + else + echo "JWT_SECRET=$JWT_SEC" >> .env + fi +fi + +# CORREGIR DATABASE_URL para produccion - db:5432 +echo " Configurando DATABASE_URL para Docker..." +DB_PASS=$(grep "^DB_PASSWORD=" .env | cut -d"=" -f2) + +sed -i "/^CMS_DATABASE_URL=/d" .env 2>/dev/null || true +sed -i "/^LMS_DATABASE_URL=/d" .env 2>/dev/null || true +sed -i "/^DATABASE_URL=/d" .env 2>/dev/null || true + +echo "CMS_DATABASE_URL=postgresql://user:${DB_PASS}@db:5432/openccb_cms" >> .env +echo "LMS_DATABASE_URL=postgresql://user:${DB_PASS}@db:5432/openccb_lms" >> .env +echo "DATABASE_URL=postgresql://user:${DB_PASS}@db:5432/openccb_cms" >> .env + +# Configurar Let's Encrypt - staging o production +echo " Configurando Let's Encrypt..." +if [ "$LETSENCRYPT_STAGING" = "true" ]; then + echo "LETSENCRYPT_STAGING=true" >> .env + echo " Usando STAGING - certificados de prueba" +else + echo "LETSENCRYPT_STAGING=false" >> .env + echo " Usando PRODUCTION - certificados reales" +fi +echo "" +REMOTE_SCRIPT_CONTENT + +# Ahora agregamos la sección de Docker con las variables correctas +cat >> /tmp/remote-deploy.sh << REMOTE_SCRIPT_CONTENT + +# ======================================== +# ACTUALIZAR DOCKER-COMPOSE.YML SEGUN SSL +# ======================================== +echo "Configurando docker-compose.yml para $PROTOCOL..." + +# Reemplazar las URLs en docker-compose.yml +if [ "$PROTOCOL" = "https" ]; then + sed -i 's|NEXT_PUBLIC_CMS_API_URL: http://|NEXT_PUBLIC_CMS_API_URL: https://|g' docker-compose.yml + sed -i 's|NEXT_PUBLIC_LMS_API_URL: http://|NEXT_PUBLIC_LMS_API_URL: https://|g' docker-compose.yml + echo " ✅ URLs actualizadas a HTTPS" +else + sed -i 's|NEXT_PUBLIC_CMS_API_URL: https://|NEXT_PUBLIC_CMS_API_URL: http://|g' docker-compose.yml + sed -i 's|NEXT_PUBLIC_LMS_API_URL: https://|NEXT_PUBLIC_LMS_API_URL: http://|g' docker-compose.yml + echo " ✅ URLs actualizadas a HTTP" +fi + +# Verificar configuración +echo "" +echo "Configuración de URLs en docker-compose.yml:" +grep "NEXT_PUBLIC_" docker-compose.yml | head -10 +echo "" + +# Verificar que los argumentos de build estén presentes +echo "Verificando argumentos de build..." +if grep -q "NEXT_PUBLIC_CMS_API_URL:" docker-compose.yml && grep -q "NEXT_PUBLIC_LMS_API_URL:" docker-compose.yml; then + echo " ✅ Ambos argumentos de build están presentes" +else + echo " ⚠️ Faltan argumentos de build, agregando..." + # Agregar argumentos si faltan + if ! grep -q "NEXT_PUBLIC_LMS_API_URL:" docker-compose.yml; then + sed -i '/NEXT_PUBLIC_CMS_API_URL:/a\ NEXT_PUBLIC_LMS_API_URL: http://learning.norteamericano.com' docker-compose.yml + fi +fi +echo "" + +REMOTE_SCRIPT_CONTENT + +# Ahora agregamos la sección de Docker con las variables correctas +cat >> /tmp/remote-deploy.sh << 'REMOTE_SCRIPT_CONTENT' + +# ======================================== +# VERIFICAR E INSTALAR DOCKER +# ======================================== +echo "Verificando requerimientos del sistema..." + +command_exists() { + command -v "$1" &> /dev/null +} + +# Docker +echo "Verificando Docker..." +if ! command_exists docker; then + echo " Docker no esta instalado, instalando..." + curl -fsSL https://get.docker.com | sudo sh + echo " Docker instalado" +fi + +# Verificar permisos de Docker +if docker ps &> /dev/null 2>&1; then + DOCKER_CMD="docker" +elif sudo docker ps &> /dev/null 2>&1; then + DOCKER_CMD="sudo docker" + sudo usermod -aG docker $(whoami) 2>/dev/null || true +else + echo " ERROR: No se puede acceder a Docker" + exit 1 +fi + +echo " Usando: $DOCKER_CMD" + +# Docker Compose +if ! $DOCKER_CMD compose version &> /dev/null 2>&1; then + echo " Instalando Docker Compose..." + sudo curl -L "https://github.com/docker/compose/releases/download/v2.24.0/docker-compose-$(uname -s)-$(uname -m)" \ + -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + sudo mkdir -p /usr/lib/docker/cli-plugins + sudo ln -sf /usr/local/bin/docker-compose /usr/lib/docker/cli-plugins/docker-compose 2>/dev/null || true + echo " Docker Compose instalado" +fi + +echo "" + +# Funcion para ejecutar docker compose +run_docker_compose() { + $DOCKER_CMD compose -f docker-compose.yml "$@" +} + +# ======================================== +# INICIAR SERVICIOS +# ======================================== +echo "Iniciando servicios OpenCCB..." + +# Detener contenedores existentes +echo "Deteniendo contenedores existentes..." +run_docker_compose down || true + +# Eliminar contenedores antiguos para forzar reconstrucción +echo "Eliminando contenedores antiguos..." +$DOCKER_CMD rm openccb-studio 2>/dev/null || true +$DOCKER_CMD rm openccb-experience 2>/dev/null || true + +# Limpiar caché de builder +echo "Limpiando caché de Docker builder..." +$DOCKER_CMD builder prune -f 2>/dev/null || true + +# Reconstruir con las URLs correctas (sin cache para asegurar que tome los cambios) +echo "Reconstruyendo contenedores con las URLs configuradas..." +run_docker_compose build --no-cache studio experience + +# Iniciar nginx-proxy y acme-companion primero +echo "Iniciando nginx-proxy y acme-companion - SSL..." +run_docker_compose up -d nginx-proxy acme-companion +echo "Esperando a que nginx-proxy este listo..." +sleep 10 + +# Iniciar base de datos +echo "Iniciando base de datos..." +run_docker_compose up -d db +echo "Esperando a que la base de datos este lista..." +sleep 10 + +# Crear bases de datos +echo "Creando bases de datos..." +$DOCKER_CMD exec openccb-db psql -U user -d postgres -c "CREATE DATABASE openccb_cms;" 2>/dev/null || echo " openccb_cms ya existe" +$DOCKER_CMD exec openccb-db psql -U user -d postgres -c "CREATE DATABASE openccb_lms;" 2>/dev/null || echo " openccb_lms ya existe" + +# Iniciar servicios +echo "Iniciando servicios OpenCCB..." +run_docker_compose up -d studio experience + +echo "" +echo "Esperando a que los servicios esten listos..." +sleep 15 + +# ======================================== +# VERIFICAR VARIABLES DE ENTORNO +# ======================================== +echo "" +echo "Verificando variables de entorno en los contenedores..." +echo "" +echo "Studio:" +$DOCKER_CMD exec openccb-studio env | grep NEXT_PUBLIC || echo " No se pudo verificar" +echo "" +echo "Experience:" +$DOCKER_CMD exec openccb-experience env | grep NEXT_PUBLIC || echo " No se pudo verificar" +echo "" + +# ======================================== +# VERIFICAR ESTADO +# ======================================== +echo "" +echo "Estado de contenedores:" +run_docker_compose ps + +echo "" +echo "Verificando logs de errores..." +CMS_ERRORS=$(run_docker_compose logs studio 2>&1 | grep -i "error" | tail -5 || true) +LMS_ERRORS=$(run_docker_compose logs experience 2>&1 | grep -i "error" | tail -5 || true) + +if [ -n "$CMS_ERRORS" ]; then + echo "Errores en Studio:" + echo "$CMS_ERRORS" +fi + +if [ -n "$LMS_ERRORS" ]; then + echo "Errores en Experience:" + echo "$LMS_ERRORS" +fi + +if [ -z "$CMS_ERRORS" ] && [ -z "$LMS_ERRORS" ]; then + echo "No se detectaron errores criticos" +fi + +echo "" + +# ======================================== +# CREAR USUARIO ADMINISTRADOR +# ======================================== +echo "========================================" +echo " Creando Usuario Administrador" +echo "========================================" +echo "" + +echo "Esperando a que el API CMS este listo..." +sleep 10 + +# Intentar crear el usuario via API +echo "Creando usuario administrador..." + +CMS_INTERNAL_URL="http://openccb-studio:3001" + +# Crear payload JSON +cat > /tmp/admin_payload.json << EOF +{ + "email": "$ADMIN_EMAIL", + "password": "$ADMIN_PASS", + "full_name": "$ADMIN_NAME", + "organization_name": "$ORG_NAME", + "role": "admin" +} +EOF + +# Copiar payload al servidor +scp -i "$PEM_PATH" /tmp/admin_payload.json "$REMOTE_USER@$REMOTE_HOST:/tmp/admin_payload.json" 2>/dev/null || true + +# Intentar crear usuario via SSH +ssh -i "$PEM_PATH" "$REMOTE_USER@$REMOTE_HOST" " + CMS_URL='$PROTOCOL://openccb-studio:3001' + if [ -f /tmp/admin_payload.json ]; then + RESPONSE=\$(curl -s -X POST \"\$CMS_URL/auth/register\" -H 'Content-Type: application/json' -d @/tmp/admin_payload.json 2>/dev/null || echo 'error') + if echo \"\$RESPONSE\" | grep -qi 'token\\|user\\|success'; then + echo 'Usuario creado via API' + else + echo 'Intentando via base de datos...' + DOCKER_CMD=\$(docker ps &>/dev/null && echo 'docker' || echo 'sudo docker') + \$DOCKER_CMD exec openccb-db psql -U user -d openccb_cms -c \" + CREATE EXTENSION IF NOT EXISTS pgcrypto; + SELECT * FROM fn_register_user( + '$ADMIN_EMAIL', + crypt('$ADMIN_PASS', gen_salt('bf', 12)), + '$ADMIN_NAME', + 'admin', + '$ORG_NAME' + ); + \" 2>/dev/null && echo 'Usuario creado' || echo 'No se pudo crear' + fi + rm -f /tmp/admin_payload.json + fi +" 2>/dev/null || echo "No se pudo crear el usuario administrador" + +rm -f /tmp/admin_payload.json + +echo "" +echo "========================================" +echo " CREDENCIALES DE ACCESO" +echo "========================================" +echo "" +echo "URLs de acceso:" +echo " Studio - CMS: $PROTOCOL://studio.norteamericano.com" +echo " Experience - LMS: $PROTOCOL://learning.norteamericano.com" +echo "" +echo "Usuario Administrador:" +echo " Email: $ADMIN_EMAIL" +echo " Contraseña: $ADMIN_PASS" +echo "" +echo "Organizacion: $ORG_NAME" +echo "" +if [ "$LETSENCRYPT_STAGING" = "true" ]; then + echo "⚠️ Usando Let's Encrypt STAGING" + echo " Los certificados son de prueba - el navegador mostrara advertencias" + echo " Las APIs usan HTTP para evitar errores de SSL" +else + echo "✅ Usando HTTP (produccion o pruebas)" +fi +echo "" +echo "Credenciales de Base de Datos - GUARDAR EN LUGAR SEGURO:" +echo " DB_PASSWORD: $(grep "^DB_PASSWORD=" .env | cut -d"=" -f2)" +echo " JWT_SECRET: $(grep "^JWT_SECRET=" .env | cut -d"=" -f2)" +echo "" +echo "Comandos utiles:" +echo " sudo docker compose ps" +echo " docker logs acme-companion --tail 50" +echo " docker logs openccb-studio --tail 20" +echo " sudo docker compose restart" +echo "" +if [ "$LETSENCRYPT_STAGING" = "true" ]; then + echo "Certificados SSL se generaran en 1 hora - STAGING" +else + echo "Certificados SSL se generaran en 2-5 minutos - PRODUCTION" +fi +echo "" +REMOTE_SCRIPT_CONTENT + +# Copiar script al servidor +scp -i "$PEM_PATH" /tmp/remote-deploy.sh "$REMOTE_USER@$REMOTE_HOST:/tmp/openccb-remote.sh" + +# Ejecutar script remoto +ssh -i "$PEM_PATH" "$REMOTE_USER@$REMOTE_HOST" "bash /tmp/openccb-remote.sh" +SCRIPT_EXIT=$? + +# Limpiar archivo temporal +rm -f /tmp/remote-deploy.sh +ssh -i "$PEM_PATH" "$REMOTE_USER@$REMOTE_HOST" "rm -f /tmp/openccb-remote.sh" + +echo "" + +if [ $SCRIPT_EXIT -eq 0 ]; then + echo "====================================================" + echo " Despliegue Completado Exitosamente" + echo "====================================================" + echo "" + echo "Accede a tu plataforma:" + echo " Studio - CMS: $PROTOCOL://studio.norteamericano.com" + echo " Experience - LMS: $PROTOCOL://learning.norteamericano.com" + echo "" + echo "Conectate para administrar:" + echo " ssh -i \"$PEM_PATH\" $REMOTE_USER@$REMOTE_HOST" + echo " cd $REMOTE_PATH" + echo "" + echo "Para actualizar en el futuro:" + echo " Ejecuta: ./deploy.sh" + echo "" +else + echo "====================================================" + echo " Despliegue Completado con Errores" + echo "====================================================" + echo "" + echo "Error al ejecutar script remoto - codigo: $SCRIPT_EXIT" + echo "" + echo "Verifica manualmente:" + echo " ssh -i \"$PEM_PATH\" $REMOTE_USER@$REMOTE_HOST" + echo " cd $REMOTE_PATH" + echo " sudo docker compose ps" + echo " sudo docker compose logs" + echo "" +fi + +exit $SCRIPT_EXIT diff --git a/docker-compose.yml b/docker-compose.yml index 8158303..b6ef600 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,33 +1,85 @@ +# OpenCCB Docker Compose - Producción con SSL +# Servidor: AWS EC2 us-east-2 +# Dominios: studio.norteamericano.com y learning.norteamericano.com +# Usa nginx-proxy + acme-companion para SSL automático con Let's Encrypt + services: + # ======================================== + # NGINX Proxy + SSL (Let's Encrypt) + # ======================================== + nginx-proxy: + image: nginxproxy/nginx-proxy:1.4 + container_name: nginx-proxy + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - certs:/etc/nginx/certs:ro + - vhost:/etc/nginx/vhost.d + - html:/usr/share/nginx/html + - ./nginx/proxy.conf:/etc/nginx/conf.d/proxy.conf:ro + restart: always + networks: + - openccb-network + + acme-companion: + image: nginxproxy/acme-companion:2.2 + container_name: acme-companion + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - certs:/etc/nginx/certs:rw + - vhost:/etc/nginx/vhost.d + - html:/usr/share/nginx/html + - ./nginx/certs-data:/etc/acme.sh:rw + environment: + - DEFAULT_EMAIL=admin@norteamericano.com + - NGINX_PROXY_CONTAINER=nginx-proxy + - LETSENCRYPT_STAGING=${LETSENCRYPT_STAGING:-true} + depends_on: + - nginx-proxy + restart: always + networks: + - openccb-network + + # ======================================== + # Base de Datos + # ======================================== db: image: pgvector/pgvector:pg16 + container_name: openccb-db environment: POSTGRES_USER: user - POSTGRES_PASSWORD: password + POSTGRES_PASSWORD: ${DB_PASSWORD:-password} POSTGRES_DB: openccb - ports: - - "5433:5432" volumes: - postgres_data:/var/lib/postgresql/data + networks: + - openccb-network + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user"] + interval: 10s + timeout: 5s + retries: 5 + # ======================================== + # Studio + CMS (HTTPS) + # ======================================== studio: build: context: . dockerfile: web/studio/Dockerfile - network: host args: - NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001} - dns: - - 8.8.8.8 - ports: - - "3000:3000" - - "3001:3001" + NEXT_PUBLIC_CMS_API_URL: http://studio.norteamericano.com + NEXT_PUBLIC_LMS_API_URL: http://learning.norteamericano.com + container_name: openccb-studio environment: - DATABASE_URL: postgresql://user:password@db:5432/openccb_cms - JWT_SECRET: ${JWT_SECRET:-openccb_secret_key_2025_production} - NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001} - RUST_LOG: debug - LMS_INTERNAL_URL: http://experience:3002 + - VIRTUAL_HOST=studio.norteamericano.com + - VIRTUAL_PORT=3000 + - LETSENCRYPT_HOST=studio.norteamericano.com + - LMS_INTERNAL_URL=http://experience:3002 + - NEXT_PUBLIC_LMS_API_URL=http://learning.norteamericano.com volumes: - uploads_data:/app/uploads env_file: .env @@ -35,48 +87,49 @@ services: - "host.docker.internal:host-gateway" - "t-800:192.168.0.5" depends_on: - - db + db: + condition: service_healthy + networks: + - openccb-network + restart: always + # ======================================== + # Experience + LMS + # ======================================== experience: build: context: . dockerfile: web/experience/Dockerfile - network: host args: - NEXT_PUBLIC_LMS_API_URL: ${NEXT_PUBLIC_LMS_API_URL:-http://localhost:3002} - NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001} - dns: - - 8.8.8.8 - ports: - - "3003:3003" - - "3002:3002" + NEXT_PUBLIC_LMS_API_URL: http://learning.norteamericano.com + NEXT_PUBLIC_CMS_API_URL: http://studio.norteamericano.com + container_name: openccb-experience environment: - DATABASE_URL: postgresql://user:password@db:5432/openccb_lms - JWT_SECRET: ${JWT_SECRET:-openccb_secret_key_2025_production} - RUST_LOG: debug - NEXT_PUBLIC_LMS_API_URL: ${NEXT_PUBLIC_LMS_API_URL:-http://localhost:3002} - NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001} + - VIRTUAL_HOST=learning.norteamericano.com + - VIRTUAL_PORT=3003 + - LETSENCRYPT_HOST=learning.norteamericano.com + - NEXT_PUBLIC_CMS_API_URL=http://studio.norteamericano.com env_file: .env extra_hosts: - "host.docker.internal:host-gateway" - "t-800:192.168.0.5" depends_on: - - db - - e2e: - build: - context: ./e2e - environment: - - STUDIO_URL=http://studio:3000 - - EXPERIENCE_URL=http://experience:3003 - depends_on: - - studio - - experience - volumes: - - ./e2e/tests:/e2e/tests - - ./e2e/playwright-report:/e2e/playwright-report - profiles: [ "test" ] + db: + condition: service_healthy + networks: + - openccb-network + restart: always +# ======================================== +# Volúmenes y Redes +# ======================================== volumes: postgres_data: uploads_data: + certs: + vhost: + html: + +networks: + openccb-network: + driver: bridge diff --git a/install.sh b/install.sh index 007123d..18af669 100755 --- a/install.sh +++ b/install.sh @@ -4,24 +4,43 @@ # This script automates the setup of OpenCCB: # 1. Prerequisite checks (Rust, Node.js, Docker, sqlx-cli) # 2. Hardware detection (NVIDIA GPU vs CPU) -# 3. Environment configuration (.env) +# 3. Environment configuration (.env) - Dev/Prod support # 4. Database creation and migrations (CMS, LMS, AI Bridge) # 5. System initialization (Admin account and Organization) -# Version: 2.0 - PGVector & Semantic Search Support +# 6. Optional: Production deployment with SSH sync +# Version: 3.0 - Dev/Prod + Deployment Support set -e +# ============================================================================ +# CONFIGURACIÓN DE PRODUCCIÓN +# ============================================================================ +PEM_PATH="ubuntu.pem" +REMOTE_USER="ubuntu" +REMOTE_HOST="ec2-18-224-137-67.us-east-2.compute.amazonaws.com" +REMOTE_PATH="/var/www/openccb" +# ============================================================================ +# CONFIGURACIÓN SAM (Sistema de Administración Académica) +# ============================================================================ +# URL de conexión a la base de datos SAM externa +# Formato: postgresql://usuario:contraseña@host:puerto/sige_sam_v3 +SAM_DATABASE_URL="" +# ============================================================================ + echo "====================================================" -echo " 🚀 Bienvenido al Instalador de OpenCCB v2.0" -echo " (Con Búsqueda Semántica PGVector)" +echo " 🚀 Bienvenido al Instalador de OpenCCB v3.0" +echo " (Con Búsqueda Semántica PGVector + Deploy)" echo "====================================================" echo "" # Parse arguments FAST_MODE="false" +DEPLOY_MODE="false" for arg in "$@"; do if [ "$arg" == "--fast" ]; then FAST_MODE="true" + elif [ "$arg" == "--deploy" ]; then + DEPLOY_MODE="true" fi done @@ -113,67 +132,70 @@ ENV_CHOICE=$(echo "$ENV_CHOICE" | tr '[:upper:]' '[:lower:]') ENV_CHOICE=${ENV_CHOICE:-prod} update_env "ENVIRONMENT" "$ENV_CHOICE" -# 6. Configuración de IA Remota +# 6. Configuración de IA Remota (Automática según entorno) echo "" echo "🔍 Configurando Servicios de IA Remota ($ENV_CHOICE)..." +# Configuración automática según entorno if [ "$ENV_CHOICE" == "dev" ]; then DEFAULT_OLLAMA="http://t-800:11434" DEFAULT_WHISPER="http://t-800:9000" + DEFAULT_IMAGE="http://t-800:8080" + echo " ✅ Entorno de DESARROLLO detectado" else DEFAULT_OLLAMA="http://t-800.norteamericano.cl:11434" DEFAULT_WHISPER="http://t-800.norteamericano.cl:9000" + DEFAULT_IMAGE="http://t-800.norteamericano.cl:8080" + echo " ✅ Entorno de PRODUCCIÓN detectado" fi -read -p "Ingrese la URL de Ollama Remoto [$DEFAULT_OLLAMA]: " REMOTE_OLLAMA_URL -REMOTE_OLLAMA_URL=${REMOTE_OLLAMA_URL:-$DEFAULT_OLLAMA} -read -p "Ingrese la URL de Whisper Remoto [$DEFAULT_WHISPER]: " REMOTE_WHISPER_URL -REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-$DEFAULT_WHISPER} -read -p "Ingrese la URL del Image Bridge Remoto [http://t-800:8080]: " REMOTE_IMAGE_URL -REMOTE_IMAGE_URL=${REMOTE_IMAGE_URL:-"http://t-800:8080"} -read -p "Ingrese el nombre del Modelo (en el servidor remoto) [llama3.2:3b]: " LLM_MODEL -LLM_MODEL=${LLM_MODEL:-llama3.2:3b} -read -p "Ingrese el nombre del Modelo de Embeddings [nomic-embed-text]: " EMBEDDING_MODEL -EMBEDDING_MODEL=${EMBEDDING_MODEL:-nomic-embed-text} +# Configurar con valores por defecto (sin preguntar) +REMOTE_OLLAMA_URL="$DEFAULT_OLLAMA" +REMOTE_WHISPER_URL="$DEFAULT_WHISPER" +REMOTE_IMAGE_URL="$DEFAULT_IMAGE" +LLM_MODEL="llama3.2:3b" +EMBEDDING_MODEL="nomic-embed-text" + +echo " 🤖 Ollama: $REMOTE_OLLAMA_URL" +echo " 🎤 Whisper: $REMOTE_WHISPER_URL" +echo " 🖼️ Image Bridge: $REMOTE_IMAGE_URL" +echo " 🧠 Modelo LLM: $LLM_MODEL" +echo " 📊 Embeddings: $EMBEDDING_MODEL" update_env "AI_PROVIDER" "local" update_env "LOCAL_LLM_MODEL" "$LLM_MODEL" update_env "LOCAL_VIDEO_BRIDGE_URL" "$REMOTE_IMAGE_URL" -update_env "EMBEDDING_MODEL" "nomic-embed-text" +update_env "EMBEDDING_MODEL" "$EMBEDDING_MODEL" +update_env "DEV_OLLAMA_URL" "$REMOTE_OLLAMA_URL" +update_env "DEV_WHISPER_URL" "$REMOTE_WHISPER_URL" +update_env "PROD_OLLAMA_URL" "$REMOTE_OLLAMA_URL" +update_env "PROD_WHISPER_URL" "$REMOTE_WHISPER_URL" +update_env "LOCAL_OLLAMA_URL" "$REMOTE_OLLAMA_URL" +update_env "LOCAL_WHISPER_URL" "$REMOTE_WHISPER_URL" -if [ "$ENV_CHOICE" == "dev" ]; then - update_env "DEV_OLLAMA_URL" "$REMOTE_OLLAMA_URL" - update_env "DEV_WHISPER_URL" "$REMOTE_WHISPER_URL" - # Portavilidad: set base URLs too - update_env "LOCAL_OLLAMA_URL" "$REMOTE_OLLAMA_URL" - update_env "LOCAL_WHISPER_URL" "$REMOTE_WHISPER_URL" -else - update_env "PROD_OLLAMA_URL" "$REMOTE_OLLAMA_URL" - update_env "PROD_WHISPER_URL" "$REMOTE_WHISPER_URL" - # Portavilidad: set base URLs too - update_env "LOCAL_OLLAMA_URL" "$REMOTE_OLLAMA_URL" - update_env "LOCAL_WHISPER_URL" "$REMOTE_WHISPER_URL" -fi - -# 6.5 Configuración de Base de Datos Externa (para bridges remotos) +# Configuración SAM (opcional) echo "" -echo "🔌 Configuración de Base de Datos para Bridge Remoto" -echo "Si el equipo t-800 necesita reportar progreso, debe conocer la IP de este servidor." -SERVER_IP=$(ip -4 -o addr show | awk '{print $4}' | cut -d/ -f1 | grep -v '127.0.0.1' | head -n 1) -DEFAULT_BRIDGE_DB="postgresql://user:${DB_PASS:-password}@${SERVER_IP}:5432/openccb_cms?sslmode=disable" -read -p "Ingrese la URL de la DB que verá el Bridge Remoto [$DEFAULT_BRIDGE_DB]: " BRIDGE_DB_URL -BRIDGE_DB_URL=${BRIDGE_DB_URL:-$DEFAULT_BRIDGE_DB} -update_env "BRIDGE_DATABASE_URL" "$BRIDGE_DB_URL" - -# AI setup is now purely remote. Skipping local container configuration. +echo "🔌 Configuración de Integración SAM" +read -p "¿Desea configurar la conexión a la base de datos SAM? [y/N]: " CONFIGURE_SAM +if [[ "$CONFIGURE_SAM" =~ ^[Yy]$ ]]; then + read -p "Ingrese SAM_DATABASE_URL []: " SAM_DB_URL + SAM_DB_URL=${SAM_DB_URL:-""} + if [ -n "$SAM_DB_URL" ]; then + update_env "SAM_DATABASE_URL" "$SAM_DB_URL" + echo " ✅ SAM_DATABASE_URL configurada" + fi +else + echo " ℹ️ SAM no configurado. Puede configurarlo luego en .env" +fi # Solicitar credenciales de DB si no están configuradas if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' -f2) == "" ]]; then read -p "Ingrese la Contraseña de la Base de Datos [password]: " DB_PASS DB_PASS=${DB_PASS:-password} - update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5433/openccb?sslmode=disable" - update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5433/openccb_cms?sslmode=disable" - update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5433/openccb_lms?sslmode=disable" + # Usar puerto 5434 (5432 y 5433 ya están en uso) + update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5434/openccb?sslmode=disable" + update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5434/openccb_cms?sslmode=disable" + update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5434/openccb_lms?sslmode=disable" update_env "JWT_SECRET" "supersecretsecret" update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001" update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3003" @@ -190,44 +212,58 @@ echo "🌐 Usando servicios de IA remotos en $REMOTE_OLLAMA_URL y $REMOTE_WHISPE # 6. Inicialización de la Base de Datos echo "" -read -p "¿Desea una instalación LIMPIA? (Esto ELIMINARÁ todos los datos existentes) [y/N]: " CLEAN_INSTALL -if [[ "$CLEAN_INSTALL" =~ ^[Yy]$ ]]; then - echo "🐘 Reseteando la base de datos para una instalación limpia..." - docker compose down -v || true -fi - -echo "🐘 Iniciando base de datos con Docker..." -docker compose up -d db - -echo "⏳ Esperando a que la base de datos esté lista (contenedor)..." -RETRIES=30 -until docker exec openccb-db-1 pg_isready -U user &> /dev/null || [ $RETRIES -eq 0 ]; do - echo -n "." - sleep 1 - RETRIES=$((RETRIES-1)) -done +echo "🔌 Configuración de Base de Datos" +echo " Puerto: 5433 (PostgreSQL existente)" echo "" -echo "⏳ Esperando al puerto de la base de datos (host)..." -RETRIES=10 -until curl -s localhost:5432 &> /dev/null || [ $RETRIES -eq 0 ]; do - echo -n "+" - sleep 1 - RETRIES=$((RETRIES-1)) -done -echo "" +# Preguntar si PostgreSQL ya está corriendo (producción) +read -p "¿PostgreSQL ya está corriendo en un contenedor? [y/N]: " DB_EXISTS +DB_EXISTS=${DB_EXISTS:-N} -if [ $RETRIES -eq 0 ]; then - echo "⚠️ Tiempo de espera agotado para el puerto del host, pero continuando..." +if [[ ! "$DB_EXISTS" =~ ^[Yy]$ ]]; then + read -p "¿Desea una instalación LIMPIA? (Esto ELIMINARÁ todos los datos existentes) [y/N]: " CLEAN_INSTALL + if [[ "$CLEAN_INSTALL" =~ ^[Yy]$ ]]; then + echo "🐘 Reseteando la base de datos para una instalación limpia..." + docker compose down -v || true + fi + + echo "🐘 Iniciando base de datos con Docker..." + docker compose up -d db + + echo "⏳ Esperando a que la base de datos esté lista (contenedor)..." + RETRIES=30 + until docker exec openccb-db-1 pg_isready -U user &> /dev/null || [ $RETRIES -eq 0 ]; do + echo -n "." + sleep 1 + RETRIES=$((RETRIES-1)) + done + echo "" + + echo "⏳ Esperando al puerto de la base de datos (host)..." + RETRIES=10 + until curl -s localhost:5433 &> /dev/null || [ $RETRIES -eq 0 ]; do + echo -n "+" + sleep 1 + RETRIES=$((RETRIES-1)) + done + echo "" + + if [ $RETRIES -eq 0 ]; then + echo "⚠️ Tiempo de espera agotado para el puerto del host, pero continuando..." + fi + + # Extra buffer for PostgreSQL initialization + sleep 2 + + echo "🏗️ Creando bases de datos CMS y LMS..." + docker exec openccb-db-1 psql -U user -d postgres -c "CREATE DATABASE openccb_cms;" || true + docker exec openccb-db-1 psql -U user -d postgres -c "CREATE DATABASE openccb_lms;" || true +else + echo "✅ PostgreSQL ya está corriendo. Saltando inicio del contenedor." + echo " Verificando conexión al puerto 5433..." + sleep 2 fi -# Extra buffer for PostgreSQL initialization -sleep 2 - -echo "🏗️ Creando bases de datos CMS y LMS..." -docker exec openccb-db-1 psql -U user -d openccb -c "CREATE DATABASE openccb_cms;" || true -docker exec openccb-db-1 psql -U user -d openccb -c "CREATE DATABASE openccb_lms;" || true - CMS_URL=$(grep "CMS_DATABASE_URL=" .env | cut -d'=' -f2-) LMS_URL=$(grep "LMS_DATABASE_URL=" .env | cut -d'=' -f2-) @@ -388,3 +424,29 @@ echo "" echo " # Detectar preguntas duplicadas" echo " curl -G \"http://localhost:3001/question-bank/similar/{id}?threshold=0.95\"" echo "====================================================" + +# ============================================================================ +# MODO DESPLIEGUE (PRODUCCIÓN) +# ============================================================================ +if [ "$DEPLOY_MODE" == "true" ]; then + echo "" + echo "====================================================" + echo " 🚀 Modo de Despliegue Activado" + echo "====================================================" + echo "" + echo "ℹ️ El despliegue remoto se maneja mediante deploy.sh" + echo "" + echo "📋 Ejecutando: ./deploy.sh" + echo "" + + # Verificar que existe deploy.sh + if [ ! -f "$SCRIPT_DIR/deploy.sh" ]; then + echo "❌ ERROR: No se encontró deploy.sh" + echo "" + echo "Asegúrate de que deploy.sh esté en el mismo directorio que install.sh" + exit 1 + fi + + # Ejecutar deploy.sh + exec "$SCRIPT_DIR/deploy.sh" +fi diff --git a/nginx/proxy.conf b/nginx/proxy.conf new file mode 100644 index 0000000..b70a7d8 --- /dev/null +++ b/nginx/proxy.conf @@ -0,0 +1,5 @@ +# Proxy configuration for nginx +map $http_x_forwarded_proto $origin_proto { + default $http_x_forwarded_proto; + "" $scheme; +} diff --git a/remote-setup.sh b/remote-setup.sh new file mode 100755 index 0000000..3b534ad --- /dev/null +++ b/remote-setup.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +# OpenCCB Remote Setup Script +# Ejecutar en el servidor remoto después de deploy.sh + +set -e + +echo "====================================================" +echo " 🔧 OpenCCB Remote Setup" +echo "====================================================" +echo "" + +# ======================================== +# 1. Obtener Certificados SSL +# ======================================== +echo "📜 Obteniendo certificados SSL..." + +# Asegurar que nginx está corriendo en puerto 80 +sudo systemctl start nginx || true + +# Obtener certificados +sudo certbot certonly --webroot \ + -w /var/www/certbot \ + -d studio.norteamericano.com \ + -d learning.norteamericano.com \ + --email admin@norteamericano.com \ + --agree-tos \ + --non-interactive \ + --force-renewal || { + echo "⚠️ Error obteniendo certificados. Continuando..." +} + +echo " ✅ Certificados obtenidos" +echo "" + +# ======================================== +# 2. Configurar HAProxy para OpenCCB +# ======================================== +echo "⚙️ Configurando HAProxy para OpenCCB..." + +# Backup de configuración actual +sudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.backup.$(date +%Y%m%d_%H%M%S) + +# Agregar configuraciones de OpenCCB antes de frontend nginx_or_turn +sudo tee /etc/haproxy/openccb.cfg > /dev/null << 'EOF' +# ======================================== +# OpenCCB Frontends (SSL Termination) +# ======================================== + +# Studio +frontend studio-https + bind *:443 ssl crt /etc/letsencrypt/live/studio.norteamericano.com/fullchain.pem,/etc/letsencrypt/live/studio.norteamericano.com/privkey.pem ssl-min-ver TLSv1.2 alpn h2,http/1.1 + mode tcp + option tcplog + + acl is_studio hdr(host) -i studio.norteamericano.com + use_backend studio-http2 if { ssl_fc_alpn h2 } is_studio + use_backend studio-http if { ssl_fc_alpn http/1.1 } is_studio + +backend studio-http2 + mode tcp + server studio 127.0.0.1:3000 send-proxy check + +backend studio-http + mode tcp + server studio 127.0.0.1:3000 send-proxy check + +# Learning +frontend learning-https + bind *:443 ssl crt /etc/letsencrypt/live/learning.norteamericano.com/fullchain.pem,/etc/letsencrypt/live/learning.norteamericano.com/privkey.pem ssl-min-ver TLSv1.2 alpn h2,http/1.1 + mode tcp + option tcplog + + acl is_learning hdr(host) -i learning.norteamericano.com + use_backend learning-http2 if { ssl_fc_alpn h2 } is_learning + use_backend learning-http if { ssl_fc_alpn http/1.1 } is_learning + +backend learning-http2 + mode tcp + server learning 127.0.0.1:3003 send-proxy check + +backend learning-http + mode tcp + server learning 127.0.0.1:3003 send-proxy check + +EOF + +# Insertar configuración de OpenCCB antes de frontend nginx_or_turn +if ! grep -q "OpenCCB Frontends" /etc/haproxy/haproxy.cfg; then + sudo sed -i '/^frontend nginx_or_turn/r /etc/haproxy/openccb.cfg' /etc/haproxy/haproxy.cfg + echo " ✅ Configuración de OpenCCB agregada a HAProxy" +else + echo " ℹ️ OpenCCB ya está configurado en HAProxy" +fi + +# Verificar configuración +if sudo haproxy -c -f /etc/haproxy/haproxy.cfg; then + echo " ✅ Configuración de HAProxy válida" + sudo systemctl reload haproxy + echo " ✅ HAProxy recargado" +else + echo " ❌ Error en configuración de HAProxy" + echo " Restaurando backup..." + sudo cp /etc/haproxy/haproxy.cfg.backup.* /etc/haproxy/haproxy.cfg 2>/dev/null || true + exit 1 +fi +echo "" + +# ======================================== +# 3. Iniciar OpenCCB con Docker +# ======================================== +echo "🐳 Iniciando OpenCCB..." + +cd /var/www/openccb + +# Verificar docker-compose.yml +if [ ! -f "docker-compose.yml" ]; then + echo " ❌ docker-compose.yml no encontrado" + exit 1 +fi + +# Iniciar servicios +sudo docker compose up -d + +# Esperar a que los servicios estén listos +echo " ⏳ Esperando servicios..." +sleep 10 + +# Verificar estado +sudo docker compose ps +echo "" + +# ======================================== +# 4. Verificación Final +# ======================================== +echo "====================================================" +echo " ✅ Configuración Completada" +echo "====================================================" +echo "" +echo "🌐 URLs de acceso:" +echo " https://studio.norteamericano.com" +echo " https://learning.norteamericano.com" +echo "" +echo "📋 Verificación:" +echo " # Ver certificados:" +echo " sudo certbot certificates" +echo "" +echo " # Ver HAProxy:" +echo " sudo systemctl status haproxy" +echo "" +echo " # Ver OpenCCB:" +echo " sudo docker compose ps" +echo "" +echo " # Ver logs:" +echo " sudo docker compose logs -f" +echo "" +echo "⚠️ Si algo no funciona:" +echo " 1. Verifica que los DNS estén propagados" +echo " 2. Revisa logs: sudo tail -f /var/log/haproxy.log" +echo " 3. Verifica puertos: sudo netstat -tlnp | grep :443" +echo "" diff --git a/services/cms-service/migrations/20260319000001_ai_usage_add_prompt_response.sql b/services/cms-service/migrations/20260319000001_ai_usage_add_prompt_response.sql index c43b212..53109a2 100644 --- a/services/cms-service/migrations/20260319000001_ai_usage_add_prompt_response.sql +++ b/services/cms-service/migrations/20260319000001_ai_usage_add_prompt_response.sql @@ -1,7 +1,29 @@ --- AI Usage Logs: Update log_ai_usage function to include prompt and response --- Note: prompt and response columns already exist from previous migration +-- AI Usage Logs: Add prompt and response columns +-- First, add columns if they don't exist --- Update log_ai_usage function to accept prompt and response +-- Add prompt column +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'ai_usage_logs' AND column_name = 'prompt' + ) THEN + ALTER TABLE ai_usage_logs ADD COLUMN prompt TEXT; + END IF; +END $$; + +-- Add response column +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'ai_usage_logs' AND column_name = 'response' + ) THEN + ALTER TABLE ai_usage_logs ADD COLUMN response TEXT; + END IF; +END $$; + +-- Update log_ai_usage function to include prompt and response CREATE OR REPLACE FUNCTION log_ai_usage( p_user_id UUID, p_org_id UUID, diff --git a/services/cms-service/migrations/20260325000000_sam_integration.sql b/services/cms-service/migrations/20260325000000_sam_integration.sql new file mode 100644 index 0000000..6e1f666 --- /dev/null +++ b/services/cms-service/migrations/20260325000000_sam_integration.sql @@ -0,0 +1,49 @@ +-- Add SAM integration fields to users table +-- This allows tracking students imported from the SAM system + +-- Add SAM student ID column (nullable for non-SAM users) +ALTER TABLE users +ADD COLUMN sam_student_id VARCHAR(50), +ADD COLUMN is_sam_student BOOLEAN DEFAULT FALSE, +ADD COLUMN sam_verified_at TIMESTAMPTZ; + +-- Create index for faster SAM lookups +CREATE INDEX IF NOT EXISTS idx_users_sam_student_id ON users(sam_student_id); +CREATE INDEX IF NOT EXISTS idx_users_is_sam_student ON users(is_sam_student); + +-- Create table to track SAM course assignments (cached from sige_sam_v3.detalle_contrato) +CREATE TABLE IF NOT EXISTS sam_course_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + sam_student_id VARCHAR(50) NOT NULL, + sam_contrato_id VARCHAR(50), + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + is_active BOOLEAN DEFAULT TRUE, + synced_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(sam_student_id, course_id) +); + +-- Indexes for fast lookups +CREATE INDEX IF NOT EXISTS idx_sam_assignments_student ON sam_course_assignments(sam_student_id); +CREATE INDEX IF NOT EXISTS idx_sam_assignments_course ON sam_course_assignments(course_id); +CREATE INDEX IF NOT EXISTS idx_sam_assignments_active ON sam_course_assignments(is_active); + +-- Trigger to update updated_at +CREATE OR REPLACE FUNCTION update_sam_assignment_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_sam_assignment_updated_at + BEFORE UPDATE ON sam_course_assignments + FOR EACH ROW + EXECUTE FUNCTION update_sam_assignment_updated_at(); + +-- Comment describing the integration +COMMENT ON TABLE sam_course_assignments IS 'Cache of course assignments from sige_sam_v3.detalle_contrato. Links SAM students to OpenCCB courses.'; +COMMENT ON COLUMN users.sam_student_id IS 'Student ID from sige_sam_v3.alumnos (nombre column)'; +COMMENT ON COLUMN users.is_sam_student IS 'True if this user was imported/managed by SAM system'; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 9ea78a8..a86541d 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -339,15 +339,42 @@ pub async fn get_courses( let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); - let courses = if is_super_admin { + // Check if user is a SAM student + let is_sam_student: bool = sqlx::query_scalar( + "SELECT COALESCE(is_sam_student, false) FROM users WHERE id = $1" + ) + .bind(claims.sub) + .fetch_one(&pool) + .await + .unwrap_or(false); + + let courses: Vec = if is_super_admin { + // Super admin sees all courses sqlx::query_as::<_, Course>("SELECT * FROM courses") .fetch_all(&pool) .await - } else { + } else if claims.role == "admin" || claims.role == "instructor" { + // Admins and instructors see all courses in their organization sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1") .bind(org_ctx.id) .fetch_all(&pool) .await + } else if is_sam_student { + // SAM students only see courses they are enrolled in via SAM + sqlx::query_as::<_, Course>( + "SELECT c.* FROM courses c + INNER JOIN sam_course_assignments sca ON c.id = sca.course_id + WHERE sca.sam_student_id = (SELECT sam_student_id FROM users WHERE id = $1) + AND sca.is_active = TRUE + AND c.organization_id = $2" + ) + .bind(claims.sub) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + } else { + // Non-SAM students see NO courses (empty list) + return Ok(Json(Vec::new())); } .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/services/cms-service/src/handlers_sam.rs b/services/cms-service/src/handlers_sam.rs new file mode 100644 index 0000000..00d731e --- /dev/null +++ b/services/cms-service/src/handlers_sam.rs @@ -0,0 +1,522 @@ +/// SAM (Sistema de Administración Académica) Integration Handlers +/// +/// This module handles synchronization between OpenCCB and the external SAM system. +/// SAM tables: sige_sam_v3.alumnos, sige_sam_v3.detalle_contrato + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use crate::handlers::Claims; +use crate::handlers::Org; + +/// SAM Student info from external database +#[derive(Debug, Serialize, Deserialize)] +pub struct SamStudentInfo { + pub id_alumno: String, + pub nombre: String, + pub email: String, + pub telefono: Option, + pub activo: bool, +} + +/// SAM Course Assignment from detalle_contrato +#[derive(Debug, Serialize, Deserialize)] +pub struct SamAssignmentInfo { + pub id_contrato: String, + pub id_alumno: String, + pub id_curso_abierto: i32, + pub estado: String, +} + +/// Response for sync operation +#[derive(Debug, Serialize)] +pub struct SamSyncResponse { + pub students_synced: usize, + pub assignments_synced: usize, + pub errors: Vec, +} + +/// Filters for SAM queries +#[derive(Debug, Deserialize)] +pub struct SamStudentFilters { + pub email: Option, + pub nombre: Option, +} + +/// ==================== SAM Sync Handlers ==================== + +/// POST /api/sam/sync-students +/// Sync students from sige_sam_v3.alumnos to OpenCCB users table +pub async fn sync_sam_students( + _org: Org, + _claims: Claims, + State(pool): State, +) -> Result, (StatusCode, String)> { + // Connect to external SAM database + let sam_url = std::env::var("SAM_DATABASE_URL") + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?; + + let sam_pool = sqlx::PgPool::connect(&sam_url) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?; + + let mut errors = Vec::new(); + let mut students_synced = 0; + + // Fetch active students from SAM using generic query + let rows = sqlx::query( + r#" + SELECT + id_alumno, + nombre, + email, + telefono, + activo + FROM sige_sam_v3.alumnos + WHERE activo = true AND email IS NOT NULL + "# + ) + .fetch_all(&sam_pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?; + + // Convert to SamStudentInfo + let sam_students: Vec = rows.iter().map(|row| { + SamStudentInfo { + id_alumno: row.get("id_alumno"), + nombre: row.get("nombre"), + email: row.get("email"), + telefono: row.get::, _>("telefono"), + activo: row.get("activo"), + } + }).collect(); + + // Sync each student to OpenCCB + for sam_student in sam_students { + // Check if user exists by email + let existing_user: Option<(Uuid, Option)> = sqlx::query_as( + "SELECT id, sam_student_id FROM users WHERE email = $1" + ) + .bind(&sam_student.email) + .fetch_optional(&pool) + .await + .map_err(|e| { + errors.push(format!("Error checking user {}: {}", sam_student.email, e)); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + }) + .ok() + .flatten(); + + match existing_user { + Some((user_id, existing_sam_id)) => { + // Update existing user with SAM info + let update_result = sqlx::query( + r#" + UPDATE users + SET sam_student_id = $1, + is_sam_student = TRUE, + sam_verified_at = NOW(), + full_name = COALESCE($2, full_name) + WHERE id = $3 + "# + ) + .bind(&sam_student.id_alumno) + .bind(&sam_student.nombre) + .bind(user_id) + .execute(&pool) + .await; + + if update_result.is_ok() { + students_synced += 1; + } else { + errors.push(format!("Failed to update user {}", sam_student.email)); + } + } + None => { + // Create new user for SAM student + let insert_result = sqlx::query( + r#" + INSERT INTO users ( + email, + password_hash, + full_name, + role, + organization_id, + sam_student_id, + is_sam_student, + sam_verified_at + ) VALUES ($1, $2, $3, $4, $5, $6, TRUE, NOW()) + RETURNING id + "# + ) + .bind(&sam_student.email) + .bind(format!("sam_managed_{}", sam_student.id_alumno)) // Placeholder password + .bind(&sam_student.nombre) + .bind("student") + .bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) // Default org + .bind(&sam_student.id_alumno) + .fetch_optional(&pool) + .await; + + match insert_result { + Ok(Some(_)) => students_synced += 1, + Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)), + Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)), + } + } + } + } + + sam_pool.close().await; + + Ok(Json(SamSyncResponse { + students_synced, + assignments_synced: 0, + errors, + })) +} + +/// POST /api/sam/sync-assignments +/// Sync course assignments from sige_sam_v3.detalle_contrato +pub async fn sync_sam_assignments( + _org: Org, + _claims: Claims, + State(pool): State, +) -> Result, (StatusCode, String)> { + // Connect to external SAM database + let sam_url = std::env::var("SAM_DATABASE_URL") + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?; + + let sam_pool = sqlx::PgPool::connect(&sam_url) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?; + + let mut errors = Vec::new(); + let mut assignments_synced = 0; + + // Fetch active course assignments from SAM + let rows = sqlx::query( + r#" + SELECT + id_contrato, + id_alumno, + id_curso_abierto, + estado + FROM sige_sam_v3.detalle_contrato + WHERE estado = 'activo' OR estado = 'vigente' + "# + ) + .fetch_all(&sam_pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?; + + // Convert to SamAssignmentInfo + let sam_assignments: Vec = rows.iter().map(|row| { + SamAssignmentInfo { + id_contrato: row.get("id_contrato"), + id_alumno: row.get("id_alumno"), + id_curso_abierto: row.get("id_curso_abierto"), + estado: row.get("estado"), + } + }).collect(); + + // Sync each assignment + for assignment in sam_assignments { + // Get the OpenCCB course ID from SAM course ID + // This assumes you have a mapping table or the SAM course ID matches OpenCCB course external_id + let course_result = sqlx::query_as::<_, (Uuid,)>( + "SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2" + ) + .bind(assignment.id_curso_abierto as i64) + .bind(assignment.id_curso_abierto) + .fetch_optional(&pool) + .await; + + let course_id = match course_result { + Ok(Some((id,))) => Some(id), + Ok(None) => None, + Err(e) => { + errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e)); + continue; + } + }; + + if let Some(course_id) = course_id { + // Upsert assignment + let upsert_result = sqlx::query( + r#" + INSERT INTO sam_course_assignments (sam_student_id, sam_contrato_id, course_id, is_active, synced_at) + VALUES ($1, $2, $3, TRUE, NOW()) + ON CONFLICT (sam_student_id, course_id) + DO UPDATE SET + is_active = TRUE, + sam_contrato_id = EXCLUDED.sam_contrato_id, + synced_at = NOW() + "# + ) + .bind(&assignment.id_alumno) + .bind(&assignment.id_contrato) + .bind(course_id) + .execute(&pool) + .await; + + if upsert_result.is_ok() { + assignments_synced += 1; + } else { + errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno)); + } + } + } + + sam_pool.close().await; + + Ok(Json(SamSyncResponse { + students_synced: 0, + assignments_synced, + errors, + })) +} + +/// GET /api/sam/students +/// List SAM students with optional filters +pub async fn list_sam_students( + _org: Org, + _claims: Claims, + State(pool): State, + Query(filters): Query, +) -> Result>, (StatusCode, String)> { + let mut query = r#" + SELECT + u.id, + u.email, + u.full_name, + u.sam_student_id, + u.is_sam_student, + u.sam_verified_at, + u.created_at + FROM users u + WHERE u.is_sam_student = TRUE + "#.to_string(); + + if let Some(email) = filters.email { + query.push_str(&format!(" AND u.email ILIKE '%{}%'", email)); + } + + if let Some(nombre) = filters.nombre { + query.push_str(&format!(" AND u.full_name ILIKE '%{}%'", nombre)); + } + + query.push_str(" ORDER BY u.full_name"); + + let rows = sqlx::query(&query) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch students: {}", e)))?; + + let students: Vec = rows.iter().map(|row| { + serde_json::json!({ + "id": row.get::("id"), + "email": row.get::("email"), + "full_name": row.get::("full_name"), + "sam_student_id": row.get::("sam_student_id"), + "is_sam_student": row.get::("is_sam_student"), + "sam_verified_at": row.get::>, _>("sam_verified_at"), + "created_at": row.get::, _>("created_at"), + }) + }).collect(); + + Ok(Json(students)) +} + +/// GET /api/sam/students/{student_id}/courses +/// Get courses assigned to a specific SAM student +pub async fn get_sam_student_courses( + _org: Org, + _claims: Claims, + State(pool): State, + Path(sam_student_id): Path, +) -> Result>, (StatusCode, String)> { + let rows = sqlx::query( + r#" + SELECT c.*, sca.is_active, sca.synced_at + FROM courses c + INNER JOIN sam_course_assignments sca ON c.id = sca.course_id + WHERE sca.sam_student_id = $1 AND sca.is_active = TRUE + ORDER BY c.title + "# + ) + .bind(&sam_student_id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?; + + let courses: Vec = rows.iter().map(|row| { + serde_json::json!({ + "id": row.get::("id"), + "title": row.get::("title"), + "description": row.get::, _>("description"), + "is_active": row.get::("is_active"), + "synced_at": row.get::>, _>("synced_at"), + }) + }).collect(); + + Ok(Json(courses)) +} + +/// POST /api/sam/sync-all +/// Full synchronization: students + assignments +pub async fn sync_all_sam( + org: Org, + claims: Claims, + State(pool): State, +) -> Result, (StatusCode, String)> { + let mut errors = Vec::new(); + let mut students_synced = 0; + let mut assignments_synced = 0; + + // Connect to external SAM database + let sam_url = std::env::var("SAM_DATABASE_URL") + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?; + + let sam_pool = sqlx::PgPool::connect(&sam_url) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?; + + // Sync students first + { + // Fetch active students from SAM + let rows = sqlx::query( + r#" + SELECT id_alumno, nombre, email, telefono, activo + FROM sige_sam_v3.alumnos + WHERE activo = true AND email IS NOT NULL + "# + ) + .fetch_all(&sam_pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?; + + let sam_students: Vec = rows.iter().map(|row| { + SamStudentInfo { + id_alumno: row.get("id_alumno"), + nombre: row.get("nombre"), + email: row.get("email"), + telefono: row.get::, _>("telefono"), + activo: row.get("activo"), + } + }).collect(); + + // Sync each student + for sam_student in sam_students { + let existing_user: Option<(Uuid, Option)> = sqlx::query_as( + "SELECT id, sam_student_id FROM users WHERE email = $1" + ) + .bind(&sam_student.email) + .fetch_optional(&pool) + .await + .map_err(|e| { + errors.push(format!("Error checking user {}: {}", sam_student.email, e)); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + }) + .ok() + .flatten(); + + match existing_user { + Some((user_id, _existing_sam_id)) => { + let update_result = sqlx::query( + "UPDATE users SET sam_student_id = $1, is_sam_student = TRUE, sam_verified_at = NOW(), full_name = COALESCE($2, full_name) WHERE id = $3" + ) + .bind(&sam_student.id_alumno) + .bind(&sam_student.nombre) + .bind(user_id) + .execute(&pool) + .await; + + if update_result.is_ok() { students_synced += 1; } else { errors.push(format!("Failed to update user {}", sam_student.email)); } + } + None => { + let insert_result = sqlx::query( + "INSERT INTO users (email, password_hash, full_name, role, organization_id, sam_student_id, is_sam_student, sam_verified_at) VALUES ($1, $2, $3, $4, $5, $6, TRUE, NOW()) RETURNING id" + ) + .bind(&sam_student.email) + .bind(format!("sam_managed_{}", sam_student.id_alumno)) + .bind(&sam_student.nombre) + .bind("student") + .bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) + .bind(&sam_student.id_alumno) + .fetch_optional(&pool) + .await; + + match insert_result { + Ok(Some(_)) => students_synced += 1, + Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)), + Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)), + } + } + } + } + } + + // Sync assignments + { + let rows = sqlx::query( + "SELECT id_contrato, id_alumno, id_curso_abierto, estado FROM sige_sam_v3.detalle_contrato WHERE estado = 'activo' OR estado = 'vigente'" + ) + .fetch_all(&sam_pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?; + + let sam_assignments: Vec = rows.iter().map(|row| { + SamAssignmentInfo { + id_contrato: row.get("id_contrato"), + id_alumno: row.get("id_alumno"), + id_curso_abierto: row.get("id_curso_abierto"), + estado: row.get("estado"), + } + }).collect(); + + for assignment in sam_assignments { + let course_result = sqlx::query_as::<_, (Uuid,)>( + "SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2" + ) + .bind(assignment.id_curso_abierto as i64) + .bind(assignment.id_curso_abierto) + .fetch_optional(&pool) + .await; + + let course_id = match course_result { + Ok(Some((id,))) => Some(id), + Ok(None) => continue, + Err(e) => { errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e)); continue; } + }; + + if let Some(course_id) = course_id { + let upsert_result = sqlx::query( + "INSERT INTO sam_course_assignments (sam_student_id, sam_contrato_id, course_id, is_active, synced_at) VALUES ($1, $2, $3, TRUE, NOW()) ON CONFLICT (sam_student_id, course_id) DO UPDATE SET is_active = TRUE, sam_contrato_id = EXCLUDED.sam_contrato_id, synced_at = NOW()" + ) + .bind(&assignment.id_alumno) + .bind(&assignment.id_contrato) + .bind(course_id) + .execute(&pool) + .await; + + if upsert_result.is_ok() { assignments_synced += 1; } else { errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno)); } + } + } + } + + sam_pool.close().await; + + Ok(Json(SamSyncResponse { + students_synced, + assignments_synced, + errors, + })) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 40922f7..ab9d9f3 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -11,6 +11,7 @@ mod handlers_test_templates; mod handlers_question_bank; mod handlers_admin; mod handlers_embeddings; +mod handlers_sam; mod webhooks; use axum::{ @@ -97,17 +98,48 @@ async fn main() { }); // CORS configuration - Allow multiple origins for development and production + // Using a predicate closure to support wildcard subdomains for norteamericano.cl + use tower_http::cors::AllowOrigin; + let cors = CorsLayer::new() - .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_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool { + let origin_str = origin.to_str().unwrap_or(""); + + // Development origins + let allowed_origins = [ + "http://localhost:3000", + "http://localhost:3003", + "http://127.0.0.1:3000", + "http://127.0.0.1:3003", + "http://192.168.0.254:3000", + "http://192.168.0.254:3003", + "http://192.168.0.254", + // Production - Norteamericano domains (HTTPS) + "https://studio.norteamericano.cl", + "https://learning.norteamericano.cl", + ]; + + // Check exact matches + if allowed_origins.contains(&origin_str) { + return true; + } + + // Check wildcard for subdomains: https://*.norteamericano.cl + if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") { + let subdomain = origin_str + .strip_prefix("https://") + .unwrap_or("") + .strip_suffix(".norteamericano.cl") + .unwrap_or(""); + + // Allow any subdomain (e.g., api., cdn., admin., etc.) + if !subdomain.is_empty() && !subdomain.contains('/') { + return true; + } + } + + false + })) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH, Method::HEAD]) .allow_headers([ header::CONTENT_TYPE, @@ -392,6 +424,27 @@ async fn main() { "/question-bank/{id}/embedding/regenerate", post(handlers_embeddings::regenerate_question_embedding), ) + // SAM Integration routes + .route( + "/sam/sync-all", + post(handlers_sam::sync_all_sam), + ) + .route( + "/sam/sync-students", + post(handlers_sam::sync_sam_students), + ) + .route( + "/sam/sync-assignments", + post(handlers_sam::sync_sam_assignments), + ) + .route( + "/sam/students", + get(handlers_sam::list_sam_students), + ) + .route( + "/sam/students/{student_id}/courses", + get(handlers_sam::get_sam_student_courses), + ) // Admin routes .route( "/admin/token-usage", diff --git a/services/lms-service/migrations/20260324000001_add_audio_responses.sql b/services/lms-service/migrations/20260324000001_add_audio_responses.sql new file mode 100644 index 0000000..cce6cb2 --- /dev/null +++ b/services/lms-service/migrations/20260324000001_add_audio_responses.sql @@ -0,0 +1,123 @@ +-- Migration: Add audio responses table for speaking practice +-- Allows students to submit audio recordings and teachers to evaluate them + +-- Table to store student audio responses +CREATE TABLE IF NOT EXISTS audio_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + user_id UUID NOT NULL, + course_id UUID NOT NULL, + lesson_id UUID NOT NULL, + block_id UUID NOT NULL, -- ID del bloque audio-response dentro de la lección + prompt TEXT NOT NULL, -- La pregunta/instrucción original + transcript TEXT, -- Transcripción del audio (generada por Whisper) + audio_url TEXT, -- URL o path del archivo de audio almacenado + audio_data BYTEA, -- Audio almacenado en base64 (opcional, para archivos pequeños) + + -- Evaluación de IA + ai_score INTEGER, -- Puntaje de IA (0-100) + ai_found_keywords TEXT[], -- Keywords encontradas por la IA + ai_feedback TEXT, -- Feedback de la IA + ai_evaluated_at TIMESTAMPTZ, -- Cuándo fue evaluado por IA + + -- Evaluación del profesor + teacher_score INTEGER, -- Puntaje del profesor (0-100) + teacher_feedback TEXT, -- Feedback del profesor + teacher_evaluated_at TIMESTAMPTZ, -- Cuándo fue evaluado por el profesor + teacher_evaluated_by UUID, -- ID del profesor que evaluó + + -- Estado y metadatos + status VARCHAR(50) NOT NULL DEFAULT 'pending', -- 'pending', 'ai_evaluated', 'teacher_evaluated', 'both_evaluated' + attempt_number INTEGER NOT NULL DEFAULT 1, -- Número de intento (permite reintentos) + duration_seconds INTEGER, -- Duración de la grabación en segundos + metadata JSONB, -- Metadatos adicionales (calidad de audio, etc.) + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign keys + CONSTRAINT fk_audio_response_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + CONSTRAINT fk_audio_response_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_audio_response_course FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + CONSTRAINT fk_audio_response_lesson FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE, + CONSTRAINT fk_audio_response_teacher FOREIGN KEY (teacher_evaluated_by) REFERENCES users(id) ON DELETE SET NULL, + + -- Constraints + CONSTRAINT chk_ai_score CHECK (ai_score IS NULL OR (ai_score >= 0 AND ai_score <= 100)), + CONSTRAINT chk_teacher_score CHECK (teacher_score IS NULL OR (teacher_score >= 0 AND teacher_score <= 100)), + CONSTRAINT chk_attempt_number CHECK (attempt_number >= 1) +); + +-- Indexes for performance +CREATE INDEX idx_audio_responses_user_id ON audio_responses(user_id); +CREATE INDEX idx_audio_responses_course_id ON audio_responses(course_id); +CREATE INDEX idx_audio_responses_lesson_id ON audio_responses(lesson_id); +CREATE INDEX idx_audio_responses_block_id ON audio_responses(block_id); +CREATE INDEX idx_audio_responses_organization_id ON audio_responses(organization_id); +CREATE INDEX idx_audio_responses_status ON audio_responses(status); +CREATE INDEX idx_audio_responses_created_at ON audio_responses(created_at DESC); +CREATE INDEX idx_audio_responses_teacher_evaluated ON audio_responses(teacher_evaluated_at) WHERE teacher_evaluated_at IS NOT NULL; + +-- Composite index for common queries +CREATE INDEX idx_audio_responses_user_lesson_block ON audio_responses(user_id, lesson_id, block_id); + +-- Add comment +COMMENT ON TABLE audio_responses IS 'Almacena las respuestas de audio de los estudiantes para ejercicios de speaking practice'; +COMMENT ON COLUMN audio_responses.status IS 'pending: sin evaluar, ai_evaluated: solo IA, teacher_evaluated: solo profesor, both_evaluated: ambos'; + +-- Add updated_at trigger +CREATE OR REPLACE FUNCTION update_audio_responses_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_audio_responses_updated_at + BEFORE UPDATE ON audio_responses + FOR EACH ROW + EXECUTE FUNCTION update_audio_responses_updated_at(); + +-- View para profesores: respuestas pendientes de evaluación +CREATE VIEW v_pending_audio_responses AS +SELECT + ar.id, + ar.user_id, + u.full_name AS student_name, + u.email AS student_email, + ar.course_id, + c.title AS course_title, + ar.lesson_id, + l.title AS lesson_title, + ar.block_id, + ar.prompt, + ar.transcript, + ar.ai_score, + ar.ai_feedback, + ar.status, + ar.created_at, + ar.attempt_number +FROM audio_responses ar +JOIN users u ON ar.user_id = u.id +JOIN courses c ON ar.course_id = c.id +JOIN lessons l ON ar.lesson_id = l.id +WHERE ar.teacher_evaluated_at IS NULL +ORDER BY ar.created_at DESC; + +-- View para estadísticas de evaluación +CREATE VIEW v_audio_response_stats AS +SELECT + organization_id, + course_id, + lesson_id, + COUNT(*) AS total_responses, + COUNT(*) FILTER (WHERE ai_score IS NOT NULL) AS ai_evaluated, + COUNT(*) FILTER (WHERE teacher_score IS NOT NULL) AS teacher_evaluated, + COUNT(*) FILTER (WHERE status = 'both_evaluated') AS fully_evaluated, + COUNT(*) FILTER (WHERE status = 'pending') AS pending, + AVG(ai_score) FILTER (WHERE ai_score IS NOT NULL) AS avg_ai_score, + AVG(teacher_score) FILTER (WHERE teacher_score IS NOT NULL) AS avg_teacher_score +FROM audio_responses +GROUP BY organization_id, course_id, lesson_id; diff --git a/services/lms-service/migrations/20260324000002_add_audio_status_enum.sql b/services/lms-service/migrations/20260324000002_add_audio_status_enum.sql new file mode 100644 index 0000000..b80cba7 --- /dev/null +++ b/services/lms-service/migrations/20260324000002_add_audio_status_enum.sql @@ -0,0 +1,8 @@ +-- Migration: Add audio_response_status ENUM type +-- This migration creates the ENUM type for audio response status + +DO $$ BEGIN + CREATE TYPE audio_response_status AS ENUM ('pending', 'ai_evaluated', 'teacher_evaluated', 'both_evaluated'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index cd1a035..cd06b81 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -6,6 +6,7 @@ use axum::{ Extension, }; use bcrypt::{DEFAULT_COST, hash, verify}; +use chrono::{DateTime, Utc}; use common::auth::{Claims, create_jwt}; use common::middleware::Org; use common::models::{ @@ -15,6 +16,7 @@ use common::models::{ }; use crate::external_db::MySqlPool; use serde_json::json; +use base64::Engine; // Simple token counter (approximate: 1 token ≈ 4 characters in English, ~3-5 in Spanish) fn count_tokens(text: &str) -> i32 { @@ -2103,6 +2105,7 @@ pub async fn get_recommendations( pub async fn evaluate_audio_response( Org(_org_ctx): Org, claims: Claims, + State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { // Check token limit before proceeding (estimate 1500 tokens for audio evaluation) @@ -2182,14 +2185,20 @@ pub async fn evaluate_audio_response( } pub async fn evaluate_audio_file( - Org(_org_ctx): Org, - _claims: Claims, + Org(org_ctx): Org, + claims: Claims, + State(pool): State, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { + let mut lesson_id_str = String::new(); + let mut block_id_str = String::new(); let mut prompt = String::new(); let mut keywords_str = String::new(); let mut audio_data = Vec::new(); let mut filename = "audio.webm".to_string(); + let mut duration_seconds: Option = None; + + tracing::info!("Received audio evaluation request from user: {}", claims.sub); while let Some(field) = multipart .next_field() @@ -2198,8 +2207,24 @@ pub async fn evaluate_audio_file( { let name = field.name().unwrap_or_default().to_string(); match name.as_str() { - "prompt" => prompt = field.text().await.unwrap_or_default(), + "lesson_id" => { + lesson_id_str = field.text().await.unwrap_or_default(); + tracing::info!("Received lesson_id: {}", lesson_id_str); + } + "block_id" => { + block_id_str = field.text().await.unwrap_or_default(); + tracing::info!("Received block_id: {}", block_id_str); + } + "prompt" => { + prompt = field.text().await.unwrap_or_default(); + tracing::info!("Received prompt: {}", prompt); + } "keywords" => keywords_str = field.text().await.unwrap_or_default(), + "duration" => { + if let Ok(d) = field.text().await.unwrap_or_default().parse() { + duration_seconds = Some(d); + } + } "file" => { filename = field.file_name().unwrap_or("audio.webm").to_string(); audio_data = field @@ -2207,18 +2232,35 @@ pub async fn evaluate_audio_file( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .to_vec(); + tracing::info!("Received audio file: {} bytes", audio_data.len()); } _ => {} } } if audio_data.is_empty() { + tracing::error!("No audio data received"); return Err(( StatusCode::BAD_REQUEST, "No se proporcionó ningún archivo de audio".into(), )); } + // Parse lesson_id and block_id + let lesson_id = Uuid::parse_str(&lesson_id_str) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid lesson_id".into()))?; + let block_id = Uuid::parse_str(&block_id_str) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid block_id".into()))?; + + // Get course_id from lesson (lessons has module_id, modules has course_id) + let course_id: Uuid = sqlx::query_scalar( + "SELECT m.course_id FROM lessons l JOIN modules m ON l.module_id = m.id WHERE l.id = $1" + ) + .bind(lesson_id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?; + // 1. Send to Whisper let whisper_url = env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); @@ -2227,7 +2269,7 @@ pub async fn evaluate_audio_file( let form = reqwest::multipart::Form::new() .part( "file", - reqwest::multipart::Part::bytes(audio_data).file_name(filename), + reqwest::multipart::Part::bytes(audio_data.clone()).file_name(filename), ) .text("model", "whisper-1") .text("response_format", "json"); @@ -2355,9 +2397,372 @@ pub async fn evaluate_audio_file( }) ).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Mapping failed: {}", e)))?; + // 3. Save audio response to database + // Determine status based on evaluation + let status = "ai_evaluated"; + + // Get attempt number (check if there's a previous response for this block) + let attempt_number: i32 = sqlx::query_scalar( + "SELECT COALESCE(MAX(attempt_number), 0) + 1 FROM audio_responses WHERE user_id = $1 AND lesson_id = $2 AND block_id = $3" + ) + .bind(claims.sub) + .bind(lesson_id) + .bind(block_id) + .fetch_one(&pool) + .await + .unwrap_or(1); + + // Store audio as base64 for now (can be moved to object storage later) + let audio_base64 = base64::engine::general_purpose::STANDARD.encode(&audio_data); + + let _ = sqlx::query( + r#"INSERT INTO audio_responses + (organization_id, user_id, course_id, lesson_id, block_id, prompt, transcript, audio_data, + ai_score, ai_found_keywords, ai_feedback, ai_evaluated_at, + status, attempt_number, duration_seconds) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), $12, $13, $14)"# + ) + .bind(org_ctx.id) + .bind(claims.sub) + .bind(course_id) + .bind(lesson_id) + .bind(block_id) + .bind(&prompt) + .bind(&transcript) + .bind(&audio_base64) + .bind(grading.score) + .bind(&grading.found_keywords) + .bind(&grading.feedback) + .bind(status) + .bind(attempt_number) + .bind(duration_seconds) + .execute(&pool) + .await; + Ok(Json(grading)) } +// ==================== AUDIO RESPONSE TEACHER ENDPOINTS ==================== + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct AudioResponseListItem { + pub id: Uuid, + pub user_id: Uuid, + pub student_name: String, + pub student_email: String, + pub course_id: Uuid, + pub course_title: String, + pub lesson_id: Uuid, + pub lesson_title: String, + pub block_id: Uuid, + pub prompt: String, + pub transcript: Option, + pub ai_score: Option, + pub ai_found_keywords: Option>, + pub ai_feedback: Option, + pub teacher_score: Option, + pub teacher_feedback: Option, + pub status: String, + pub created_at: DateTime, + pub attempt_number: i32, +} + +#[derive(Debug, Deserialize)] +pub struct AudioResponseFilters { + pub course_id: Option, + pub lesson_id: Option, + pub status: Option, + pub user_id: Option, +} + +/// Get all audio responses for teachers +/// Filters: course_id, lesson_id, status (pending, ai_evaluated, teacher_evaluated, both_evaluated), user_id +pub async fn get_audio_responses( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Query(filters): Query, +) -> Result>, StatusCode> { + // Only instructors and admins can access + if claims.role != "admin" && claims.role != "instructor" { + return Err(StatusCode::FORBIDDEN); + } + + // Use static query with optional filters + let responses = sqlx::query_as::<_, AudioResponseListItem>( + r#" + SELECT + ar.id, + ar.user_id, + u.full_name as student_name, + u.email as student_email, + ar.course_id, + c.title as course_title, + ar.lesson_id, + l.title as lesson_title, + ar.block_id, + ar.prompt, + ar.transcript, + ar.ai_score, + ar.ai_found_keywords, + ar.ai_feedback, + ar.teacher_score, + ar.teacher_feedback, + ar.status::text, + ar.created_at, + ar.attempt_number + FROM audio_responses ar + JOIN users u ON ar.user_id = u.id + JOIN courses c ON ar.course_id = c.id + JOIN lessons l ON ar.lesson_id = l.id + WHERE ar.organization_id = $1 + AND ($2::uuid IS NULL OR ar.course_id = $2) + AND ($3::uuid IS NULL OR ar.lesson_id = $3) + AND ($4::text IS NULL OR ar.status::text = $4) + AND ($5::uuid IS NULL OR ar.user_id = $5) + ORDER BY ar.created_at DESC + "# + ) + .bind(org_ctx.id) + .bind(filters.course_id) + .bind(filters.lesson_id) + .bind(filters.status) + .bind(filters.user_id) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Error fetching audio responses: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(responses)) +} + +/// Get single audio response with full details including audio data +pub async fn get_audio_response_detail( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(response_id): Path, +) -> Result, StatusCode> { + // Only instructors and admins can access + if claims.role != "admin" && claims.role != "instructor" { + return Err(StatusCode::FORBIDDEN); + } + + let response = sqlx::query_as::<_, AudioResponseListItem>( + r#" + SELECT + ar.id, + ar.user_id, + u.full_name as student_name, + u.email as student_email, + ar.course_id, + c.title as course_title, + ar.lesson_id, + l.title as lesson_title, + ar.block_id, + ar.prompt, + ar.transcript, + ar.ai_score, + ar.ai_found_keywords, + ar.ai_feedback, + ar.teacher_score, + ar.teacher_feedback, + ar.status::text, + ar.created_at, + ar.attempt_number + FROM audio_responses ar + JOIN users u ON ar.user_id = u.id + JOIN courses c ON ar.course_id = c.id + JOIN lessons l ON ar.lesson_id = l.id + WHERE ar.id = $1 AND ar.organization_id = $2 + "# + ) + .bind(response_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| { + tracing::error!("Error fetching audio response: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + match response { + Some(r) => Ok(Json(r)), + None => Err(StatusCode::NOT_FOUND), + } +} + +/// Get audio data as base64 for playback +pub async fn get_audio_response_audio( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path(response_id): Path, +) -> Result { + // Only instructors, admins, and the owner can access + let audio_data: Option> = sqlx::query_scalar( + "SELECT audio_data FROM audio_responses WHERE id = $1 AND organization_id = $2" + ) + .bind(response_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| { + tracing::error!("Error fetching audio data: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + match audio_data { + Some(data) => { + // Decode from base64 + let audio_bytes = base64::engine::general_purpose::STANDARD.decode(&data) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, "audio/webm") + .header(axum::http::header::CONTENT_DISPOSITION, "inline") + .body(axum::body::Body::from(audio_bytes)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .into_response()) + } + None => Err(StatusCode::NOT_FOUND), + } +} + +/// Teacher evaluates an audio response +pub async fn teacher_evaluate_audio( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(response_id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + // Only instructors and admins can evaluate + if claims.role != "admin" && claims.role != "instructor" { + return Err(StatusCode::FORBIDDEN); + } + + // Validate score + if payload.teacher_score < 0 || payload.teacher_score > 100 { + return Err(StatusCode::BAD_REQUEST); + } + + // Get current response to determine new status + let current_status: String = sqlx::query_scalar( + "SELECT status::text FROM audio_responses WHERE id = $1 AND organization_id = $2" + ) + .bind(response_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| { + tracing::error!("Error fetching audio response: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .unwrap_or_else(|| "pending".to_string()); + + // Determine new status + let new_status = if current_status == "ai_evaluated" { + "both_evaluated" + } else { + "teacher_evaluated" + }; + + // Update the response + let updated = sqlx::query( + r#" + UPDATE audio_responses + SET + teacher_score = $1, + teacher_feedback = $2, + teacher_evaluated_at = NOW(), + teacher_evaluated_by = $3, + status = $4, + updated_at = NOW() + WHERE id = $5 AND organization_id = $6 + RETURNING id + "# + ) + .bind(payload.teacher_score) + .bind(&payload.teacher_feedback) + .bind(claims.sub) + .bind(new_status) + .bind(response_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| { + tracing::error!("Error updating audio response: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + match updated { + Some(_) => Ok(Json(json!({ + "success": true, + "message": "Evaluación guardada exitosamente" + }))), + None => Err(StatusCode::NOT_FOUND), + } +} + +/// Get audio response statistics for a course +pub async fn get_audio_response_stats( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, +) -> Result, StatusCode> { + // Only instructors and admins can access + if claims.role != "admin" && claims.role != "instructor" { + return Err(StatusCode::FORBIDDEN); + } + + let stats = sqlx::query_as::<_, common::models::AudioResponseStats>( + r#" + SELECT + organization_id, + course_id, + lesson_id, + COUNT(*) as total_responses, + COUNT(*) FILTER (WHERE ai_score IS NOT NULL) as ai_evaluated, + COUNT(*) FILTER (WHERE teacher_score IS NOT NULL) as teacher_evaluated, + COUNT(*) FILTER (WHERE status = 'both_evaluated') as fully_evaluated, + COUNT(*) FILTER (WHERE status = 'pending') as pending, + AVG(ai_score) FILTER (WHERE ai_score IS NOT NULL) as avg_ai_score, + AVG(teacher_score) FILTER (WHERE teacher_score IS NOT NULL) as avg_teacher_score + FROM audio_responses + WHERE course_id = $1 AND organization_id = $2 + GROUP BY organization_id, course_id, lesson_id + "# + ) + .bind(course_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| { + tracing::error!("Error fetching audio response stats: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + match stats { + Some(s) => Ok(Json(s)), + None => Ok(Json(common::models::AudioResponseStats { + organization_id: org_ctx.id, + course_id, + lesson_id: Uuid::nil(), + total_responses: 0, + ai_evaluated: 0, + teacher_evaluated: 0, + fully_evaluated: 0, + pending: 0, + avg_ai_score: None, + avg_teacher_score: None, + })), + } +} + #[derive(Deserialize)] pub struct ChatPayload { pub message: String, diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index cd492e8..4f4c06d 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -66,17 +66,48 @@ async fn main() { }); // CORS configuration - Allow multiple origins for development and production + // Using a predicate closure to support wildcard subdomains for norteamericano.cl + use tower_http::cors::AllowOrigin; + let cors = CorsLayer::new() - .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_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool { + let origin_str = origin.to_str().unwrap_or(""); + + // Development origins + let allowed_origins = [ + "http://localhost:3000", + "http://localhost:3003", + "http://127.0.0.1:3000", + "http://127.0.0.1:3003", + "http://192.168.0.254:3000", + "http://192.168.0.254:3003", + "http://192.168.0.254", + // Production - Norteamericano domains (HTTPS) + "https://studio.norteamericano.cl", + "https://learning.norteamericano.cl", + ]; + + // Check exact matches + if allowed_origins.contains(&origin_str) { + return true; + } + + // Check wildcard for subdomains: https://*.norteamericano.cl + if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") { + let subdomain = origin_str + .strip_prefix("https://") + .unwrap_or("") + .strip_suffix(".norteamericano.cl") + .unwrap_or(""); + + // Allow any subdomain (e.g., api., cdn., admin., etc.) + if !subdomain.is_empty() && !subdomain.contains('/') { + return true; + } + } + + false + })) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH]) .allow_headers([ header::CONTENT_TYPE, @@ -158,6 +189,12 @@ async fn main() { .route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap)) .route("/audio/evaluate", post(handlers::evaluate_audio_response)) .route("/audio/evaluate-file", post(handlers::evaluate_audio_file)) + // Audio Response Teacher Routes + .route("/audio-responses", get(handlers::get_audio_responses)) + .route("/audio-responses/{id}", get(handlers::get_audio_response_detail)) + .route("/audio-responses/{id}/audio", get(handlers::get_audio_response_audio)) + .route("/audio-responses/{id}/evaluate", post(handlers::teacher_evaluate_audio)) + .route("/courses/{id}/audio-responses/stats", get(handlers::get_audio_response_stats)) .route("/lessons/{id}/chat", post(handlers::chat_with_tutor)) .route("/lessons/{id}/chat-role-play", post(handlers::chat_role_play)) .route("/lessons/{id}/code-hint", post(handlers::get_code_hint)) diff --git a/setup-nginx-ssl.sh b/setup-nginx-ssl.sh new file mode 100755 index 0000000..b1986d0 --- /dev/null +++ b/setup-nginx-ssl.sh @@ -0,0 +1,398 @@ +#!/bin/bash + +# OpenCCB SSL Configuration Script +# Copia archivos de configuración para nginx con SSL +# Dominios: studio.norteamericano.com y learning.norteamericano.com +# NOTA: Asume que nginx ya está instalado en el servidor remoto + +set -e + +echo "====================================================" +echo " 🔒 OpenCCB SSL Configuration" +echo "====================================================" +echo "" + +# Configuración +NGINX_SITES_AVAILABLE="/etc/nginx/sites-available" +NGINX_SITES_ENABLED="/etc/nginx/sites-enabled" +CERTBOT_PATH="/etc/letsencrypt" + +echo "📋 Configuración:" +echo " NGINX_SITES_AVAILABLE: $NGINX_SITES_AVAILABLE" +echo " NGINX_SITES_ENABLED: $NGINX_SITES_ENABLED" +echo " CERTBOT_PATH: $CERTBOT_PATH" +echo "" + +# Verificar que nginx está instalado +if ! command -v nginx &> /dev/null; then + echo "❌ ERROR: nginx no está instalado" + exit 1 +fi + +echo "✅ nginx verificado: $(nginx -v 2>&1)" +echo "" + +# Crear directorios si no existen +echo "📁 Verificando directorios..." +sudo mkdir -p "$NGINX_SITES_AVAILABLE" +sudo mkdir -p "$NGINX_SITES_ENABLED" +sudo mkdir -p /var/www/certbot +echo " ✅ Directorios verificados" +echo "" + +# ======================================== +# Crear configuración de nginx para Studio (HTTP primero) +# ======================================== +echo "📝 Creando configuración HTTP para studio.norteamericano.com..." + +sudo tee /etc/nginx/sites-available/studio.norteamericano.com > /dev/null << 'EOF' +server { + listen 80; + listen [::]:80; + server_name studio.norteamericano.com; + + # ACME challenge para Let's Encrypt + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Todo el tráfico va al root para validación + location / { + return 200 "OpenCCB Studio - SSL Pending"; + add_header Content-Type text/plain; + } +} +EOF + +echo " ✅ Configuración HTTP de studio creada" + +# ======================================== +# Crear configuración de nginx para Learning (HTTP primero) +# ======================================== +echo "📝 Creando configuración HTTP para learning.norteamericano.com..." + +sudo tee /etc/nginx/sites-available/learning.norteamericano.com > /dev/null << 'EOF' +server { + listen 80; + listen [::]:80; + server_name learning.norteamericano.com; + + # ACME challenge para Let's Encrypt + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Todo el tráfico va al root para validación + location / { + return 200 "OpenCCB Experience - SSL Pending"; + add_header Content-Type text/plain; + } +} +EOF + +echo " ✅ Configuración HTTP de learning creada" + +# ======================================== +# Habilitar sitios +# ======================================== +echo "🔗 Habilitando sitios..." + +# Eliminar default si existe +sudo rm -f "$NGINX_SITES_ENABLED/default" 2>/dev/null || true + +# Crear enlaces simbólicos +sudo ln -sf "$NGINX_SITES_AVAILABLE/studio.norteamericano.com" "$NGINX_SITES_ENABLED/studio.norteamericano.com" +sudo ln -sf "$NGINX_SITES_AVAILABLE/learning.norteamericano.com" "$NGINX_SITES_ENABLED/learning.norteamericano.com" + +echo " ✅ Sitios habilitados" +echo "" + +# ======================================== +# Verificar configuración de nginx +# ======================================== +echo "🔍 Verificando configuración de nginx..." +if sudo nginx -t; then + echo " ✅ Configuración de nginx es válida" + sudo systemctl reload nginx + echo " ✅ nginx recargado" +else + echo " ❌ ERROR: Configuración de nginx inválida" + exit 1 +fi +echo "" + +# ======================================== +# Crear script de instalación de certificados +# ======================================== +echo "📝 Creando script de instalación de certificados..." + +sudo tee /usr/local/bin/install-ssl-certs.sh > /dev/null << 'EOF' +#!/bin/bash + +# Script para instalar certificados SSL con Let's Encrypt +# Ejecutar después de que el DNS esté propagado + +set -e + +echo "====================================================" +echo " 🔒 Instalación de Certificados SSL" +echo "====================================================" +echo "" + +NGINX_SITES_AVAILABLE="/etc/nginx/sites-available" + +# Verificar certbot +if ! command -v certbot &> /dev/null; then + echo "❌ certbot no está instalado" + echo " Instalando certbot..." + sudo apt-get update + sudo apt-get install -y certbot +fi + +echo "✅ certbot verificado: $(certbot --version)" +echo "" + +# Crear directorio para challenges +sudo mkdir -p /var/www/certbot + +# Obtener certificados para studio +echo "📜 Obteniendo certificado para studio.norteamericano.com..." +sudo certbot certonly --webroot \ + -w /var/www/certbot \ + -d studio.norteamericano.com \ + --email admin@norteamericano.com \ + --agree-tos \ + --non-interactive \ + --force-renewal + +echo " ✅ Certificado de studio obtenido" +echo "" + +# Obtener certificados para learning +echo "📜 Obteniendo certificado para learning.norteamericano.com..." +sudo certbot certonly --webroot \ + -w /var/www/certbot \ + -d learning.norteamericano.com \ + --email admin@norteamericano.com \ + --agree-tos \ + --non-interactive \ + --force-renewal + +echo " ✅ Certificado de learning obtenido" +echo "" + +# ======================================== +# Actualizar configuraciones de nginx con SSL +# ======================================== +echo "📝 Actualizando configuraciones de nginx con SSL..." + +# Studio con SSL +sudo tee /etc/nginx/sites-available/studio.norteamericano.com > /dev/null << 'NGINX_EOF' +server { + listen 80; + listen [::]:80; + server_name studio.norteamericano.com; + + # ACME challenge para Let's Encrypt + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Redirigir HTTP a HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name studio.norteamericano.com; + + # SSL Configuration + ssl_certificate /etc/letsencrypt/live/studio.norteamericano.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/studio.norteamericano.com/privkey.pem; + + # SSL optimizado + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Proxy a OpenCCB Studio (puerto 3000) + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 90; + } + + # ACME challenge para renovación + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } +} +NGINX_EOF + +echo " ✅ Configuración de studio actualizada con SSL" + +# Learning con SSL +sudo tee /etc/nginx/sites-available/learning.norteamericano.com > /dev/null << 'NGINX_EOF' +server { + listen 80; + listen [::]:80; + server_name learning.norteamericano.com; + + # ACME challenge para Let's Encrypt + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Redirigir HTTP a HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name learning.norteamericano.com; + + # SSL Configuration + ssl_certificate /etc/letsencrypt/live/learning.norteamericano.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/learning.norteamericano.com/privkey.pem; + + # SSL optimizado + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Proxy a OpenCCB Experience (puerto 3003) + location / { + proxy_pass http://localhost:3003; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 90; + } + + # ACME challenge para renovación + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } +} +NGINX_EOF + +echo " ✅ Configuración de learning actualizada con SSL" +echo "" + +# Verificar que los certificados existen +if [ -f "/etc/letsencrypt/live/studio.norteamericano.com/fullchain.pem" ] && \ + [ -f "/etc/letsencrypt/live/learning.norteamericano.com/fullchain.pem" ]; then + echo "✅ Certificados instalados exitosamente" + echo "" + + # Verificar y recargar nginx + echo "🔄 Verificando configuración de nginx..." + if sudo nginx -t; then + echo " ✅ Configuración válida" + echo "🔄 Recargando nginx..." + sudo systemctl reload nginx + echo " ✅ nginx recargado" + else + echo " ❌ ERROR: Configuración inválida" + exit 1 + fi + echo "" + + echo "====================================================" + echo " ✅ SSL Configurado Exitosamente" + echo "====================================================" + echo "" + echo "🌐 URLs:" + echo " https://studio.norteamericano.com" + echo " https://learning.norteamericano.com" + echo "" + echo "📋 Los certificados se renovarán automáticamente" + echo " Renovación automática: certbot renew" + echo "" +else + echo "❌ ERROR: Los certificados no se instalaron correctamente" + exit 1 +fi +EOF + +sudo chmod +x /usr/local/bin/install-ssl-certs.sh +echo " ✅ Script de instalación creado: /usr/local/bin/install-ssl-certs.sh" +echo "" + +# ======================================== +# Crear script de renovación automática +# ======================================== +echo "📝 Configurando renovación automática..." + +sudo tee /etc/cron.daily/certbot-renewal > /dev/null << 'EOF' +#!/bin/bash +# Renovación automática de certificados SSL +/usr/local/bin/install-ssl-certs.sh --quiet 2>&1 | logger -t certbot-renewal +EOF + +sudo chmod +x /etc/cron.daily/certbot-renewal +echo " ✅ Renovación automática configurada (cron diario)" +echo "" + +# ======================================== +# Resumen +# ======================================== +echo "====================================================" +echo " ✅ Configuración SSL Completada" +echo "====================================================" +echo "" +echo "📋 Archivos creados:" +echo " - /etc/nginx/sites-available/studio.norteamericano.com" +echo " - /etc/nginx/sites-available/learning.norteamericano.com" +echo " - /usr/local/bin/install-ssl-certs.sh" +echo " - /etc/cron.daily/certbot-renewal" +echo "" +echo "🔍 Verificación de DNS:" +echo " studio.norteamericano.com → $(dig +short studio.norteamericano.com | head -1)" +echo " learning.norteamericano.com → $(dig +short learning.norteamericano.com | head -1)" +echo "" +echo "📝 Próximos pasos:" +echo " 1. Verifica que el DNS esté propagado (5-10 minutos)" +echo " 2. Ejecuta: sudo /usr/local/bin/install-ssl-certs.sh" +echo " 3. Inicia OpenCCB: docker-compose up -d" +echo "" +echo "🔒 Después de instalar certificados:" +echo " https://studio.norteamericano.com" +echo " https://learning.norteamericano.com" +echo "" diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 1e53d8d..204b27f 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -1446,3 +1446,121 @@ pub struct QuestionBankFilters { pub search: Option, pub has_audio: Option, } + +// ==================== AUDIO RESPONSE MODELS ==================== +// For speaking practice exercises with AI + Teacher evaluation + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AudioResponseStatus { + Pending, + AiEvaluated, + TeacherEvaluated, + BothEvaluated, +} + +impl std::fmt::Display for AudioResponseStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AudioResponseStatus::Pending => write!(f, "pending"), + AudioResponseStatus::AiEvaluated => write!(f, "ai_evaluated"), + AudioResponseStatus::TeacherEvaluated => write!(f, "teacher_evaluated"), + AudioResponseStatus::BothEvaluated => write!(f, "both_evaluated"), + } + } +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct AudioResponse { + pub id: Uuid, + pub organization_id: Uuid, + pub user_id: Uuid, + pub course_id: Uuid, + pub lesson_id: Uuid, + pub block_id: Uuid, + pub prompt: String, + pub transcript: Option, + pub audio_url: Option, + pub audio_data: Option>, + + // AI Evaluation + pub ai_score: Option, + pub ai_found_keywords: Option>, + pub ai_feedback: Option, + pub ai_evaluated_at: Option>, + + // Teacher Evaluation + pub teacher_score: Option, + pub teacher_feedback: Option, + pub teacher_evaluated_at: Option>, + pub teacher_evaluated_by: Option, + + // Status and metadata + pub status: AudioResponseStatus, + pub attempt_number: i32, + pub duration_seconds: Option, + pub metadata: Option, + + // Timestamps + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AudioResponseWithUser { + pub id: Uuid, + pub user_id: Uuid, + pub student_name: String, + pub student_email: String, + pub course_id: Uuid, + pub course_title: String, + pub lesson_id: Uuid, + pub lesson_title: String, + pub block_id: Uuid, + pub prompt: String, + pub transcript: Option, + pub ai_score: Option, + pub ai_feedback: Option, + pub teacher_score: Option, + pub teacher_feedback: Option, + pub status: AudioResponseStatus, + pub created_at: DateTime, + pub attempt_number: i32, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct AudioResponseStats { + pub organization_id: Uuid, + pub course_id: Uuid, + pub lesson_id: Uuid, + pub total_responses: i64, + pub ai_evaluated: i64, + pub teacher_evaluated: i64, + pub fully_evaluated: i64, + pub pending: i64, + pub avg_ai_score: Option, + pub avg_teacher_score: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CreateAudioResponsePayload { + pub lesson_id: Uuid, + pub block_id: Uuid, + pub prompt: String, + pub transcript: Option, + pub duration_seconds: Option, + pub metadata: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UpdateAudioResponsePayload { + pub teacher_score: i32, + pub teacher_feedback: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AudioResponseEvaluation { + pub score: i32, + pub found_keywords: Vec, + pub feedback: String, +} diff --git a/test_query.js b/test_query.js deleted file mode 100644 index 294116a..0000000 --- a/test_query.js +++ /dev/null @@ -1,31 +0,0 @@ -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/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 3a9a937..e43ab5f 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -430,6 +430,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les keywords={block.keywords} timeLimit={block.timeLimit} isGraded={lesson.is_graded} + lessonId={params.lessonId} + blockId={block.id} onComplete={(score) => handleBlockComplete(block.id, score)} /> ); diff --git a/web/experience/src/components/blocks/AudioResponsePlayer.tsx b/web/experience/src/components/blocks/AudioResponsePlayer.tsx index 7247031..5c30afa 100644 --- a/web/experience/src/components/blocks/AudioResponsePlayer.tsx +++ b/web/experience/src/components/blocks/AudioResponsePlayer.tsx @@ -10,6 +10,8 @@ interface AudioResponsePlayerProps { keywords?: string[]; timeLimit?: number; isGraded?: boolean; + lessonId?: string; + blockId?: string; onComplete?: (score: number, transcript: string) => void; } @@ -19,6 +21,8 @@ export default function AudioResponsePlayer({ keywords = [], timeLimit, isGraded = false, + lessonId, + blockId, onComplete }: AudioResponsePlayerProps) { const [isRecording, setIsRecording] = useState(false); @@ -195,9 +199,22 @@ export default function AudioResponsePlayer({ return; } + if (!lessonId || !blockId) { + console.error("Missing lessonId or blockId"); + alert("Error: Missing lesson or block information"); + return; + } + setIsTranscribing(true); try { - const result = await lmsApi.evaluateAudioFile(audioBlob, prompt, keywords); + const result = await lmsApi.evaluateAudioFile( + audioBlob, + prompt, + keywords, + lessonId, + blockId, + recordingTime + ); setEvaluation({ score: result.score, foundKeywords: result.found_keywords, diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index f6cabee..0a12a95 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -604,17 +604,23 @@ export const lmsApi = { body: JSON.stringify({ transcript, prompt, keywords }) }); }, - async evaluateAudioFile(file: Blob, prompt: string, keywords: string[]): Promise { + async evaluateAudioFile(file: Blob, prompt: string, keywords: string[], lessonId: string, blockId: string, duration?: number): Promise { const formData = new FormData(); formData.append('file', file, 'recorded_audio.webm'); formData.append('prompt', prompt); formData.append('keywords', JSON.stringify(keywords)); + formData.append('lesson_id', lessonId); + formData.append('block_id', blockId); + if (duration) { + formData.append('duration', duration.toString()); + } const token = getToken(); return fetch(`${getLmsApiUrl()}/audio/evaluate-file`, { method: 'POST', headers: { ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + // Don't set Content-Type for FormData - browser sets it with boundary }, body: formData }).then(async res => { diff --git a/web/studio/Dockerfile b/web/studio/Dockerfile index 96875a7..0fd91df 100644 --- a/web/studio/Dockerfile +++ b/web/studio/Dockerfile @@ -20,7 +20,9 @@ COPY web/studio/package*.json ./ RUN npm ci COPY web/studio/ . ARG NEXT_PUBLIC_CMS_API_URL +ARG NEXT_PUBLIC_LMS_API_URL ENV NEXT_PUBLIC_CMS_API_URL=$NEXT_PUBLIC_CMS_API_URL +ENV NEXT_PUBLIC_LMS_API_URL=$NEXT_PUBLIC_LMS_API_URL RUN npm run build # Final stage diff --git a/web/studio/src/app/admin/audio-evaluations/page.tsx b/web/studio/src/app/admin/audio-evaluations/page.tsx new file mode 100644 index 0000000..e09ffc2 --- /dev/null +++ b/web/studio/src/app/admin/audio-evaluations/page.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { + Mic, + Play, + Pause, + CheckCircle, + AlertCircle, + BrainCircuit, + User, + Calendar, + BookOpen, + Filter, + Download, + RefreshCcw +} from "lucide-react"; +import { lmsApi, type AudioResponse, type AudioResponseFilters } from "@/lib/api"; +import PageLayout from "@/components/PageLayout"; +import AuthGuard from "@/components/AuthGuard"; + +export default function AudioEvaluationsPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [evaluations, setEvaluations] = useState([]); + const [selectedEvaluation, setSelectedEvaluation] = useState(null); + const [teacherScore, setTeacherScore] = useState(50); + const [teacherFeedback, setTeacherFeedback] = useState(""); + const [filters, setFilters] = useState({}); + const [playingId, setPlayingId] = useState(null); + const [audioUrl, setAudioUrl] = useState(null); + + const fetchEvaluations = async () => { + setLoading(true); + try { + const data = await lmsApi.getAudioResponses(filters); + setEvaluations(data); + } catch (error) { + console.error("Error fetching audio evaluations:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchEvaluations(); + }, [filters]); + + const handlePlayAudio = async (id: string) => { + if (playingId === id) { + // Stop playing + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + } + setPlayingId(null); + setAudioUrl(null); + } else { + try { + const audioBlob = await lmsApi.getAudioResponseAudio(id); + const url = URL.createObjectURL(audioBlob); + + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + } + + setAudioUrl(url); + setPlayingId(id); + + const audio = new Audio(url); + audio.onended = () => { + setPlayingId(null); + setAudioUrl(null); + }; + audio.play(); + } catch (error) { + console.error("Error playing audio:", error); + alert("Error al reproducir el audio"); + } + } + }; + + const handleSubmitEvaluation = async () => { + if (!selectedEvaluation) return; + + try { + await lmsApi.evaluateAudioResponse( + selectedEvaluation.id, + teacherScore, + teacherFeedback + ); + alert("Evaluación guardada exitosamente"); + setSelectedEvaluation(null); + fetchEvaluations(); + } catch (error) { + console.error("Error submitting evaluation:", error); + alert("Error al guardar la evaluación"); + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'pending': + return Pendiente; + case 'ai_evaluated': + return IA Evaluada; + case 'teacher_evaluated': + return Profesor Evaluó; + case 'both_evaluated': + return Completa; + default: + return null; + } + }; + + const getScoreColor = (score: number | null) => { + if (score === null) return 'text-gray-400'; + if (score >= 70) return 'text-green-600 dark:text-green-400'; + if (score >= 40) return 'text-yellow-600 dark:text-yellow-400'; + return 'text-red-600 dark:text-red-400'; + }; + + return ( + +
+ {/* Filters */} +
+
+ +

Filtros

+
+
+
+ + +
+
+ + setFilters({ ...filters, course_id: e.target.value || undefined })} + placeholder="UUID del curso" + className="w-full mt-1 px-4 py-2 bg-white dark:bg-black/40 border border-gray-200 dark:border-white/10 rounded-xl text-sm font-medium outline-none focus:ring-2 focus:ring-purple-500/20" + /> +
+
+ + setFilters({ ...filters, lesson_id: e.target.value || undefined })} + placeholder="UUID de la lección" + className="w-full mt-1 px-4 py-2 bg-white dark:bg-black/40 border border-gray-200 dark:border-white/10 rounded-xl text-sm font-medium outline-none focus:ring-2 focus:ring-purple-500/20" + /> +
+
+ +
+ + {/* Evaluations Table */} +
+
+ + + + + + + + + + + + + + + + {loading ? ( + + + + ) : evaluations.length === 0 ? ( + + + + ) : ( + evaluations.map((eval_) => ( + + + + + + + + + + + + )) + )} + +
EstudianteCurso / LecciónPromptTranscripciónIAProfesorEstadoFechaAcciones
+
+ + Cargando evaluaciones... +
+
+ +

No hay evaluaciones de audio

+
+
+
+ {eval_.student_name.charAt(0).toUpperCase()} +
+
+

{eval_.student_name}

+

{eval_.student_email}

+
+
+
+
+

{eval_.course_title}

+

{eval_.lesson_title}

+
+
+

{eval_.prompt}

+
+

+ {eval_.transcript || Sin transcripción} +

+
+ {eval_.ai_score !== null ? ( +
+

{eval_.ai_score}%

+ {eval_.ai_found_keywords && eval_.ai_found_keywords.length > 0 && ( +

+ Keywords: {eval_.ai_found_keywords.join(', ')} +

+ )} +
+ ) : ( + - + )} +
+ {eval_.teacher_score !== null ? ( +

{eval_.teacher_score}%

+ ) : ( + - + )} +
+ {getStatusBadge(eval_.status)} + +
+ + {new Date(eval_.created_at).toLocaleDateString()} +
+
+
+ + +
+
+
+
+ + {/* Evaluation Modal */} + {selectedEvaluation && ( +
+
+
+
+

Evaluar Respuesta de Audio

+ +
+ + {/* Student Info */} +
+
+ +

{selectedEvaluation.student_name}

+
+
+ +

{selectedEvaluation.course_title} → {selectedEvaluation.lesson_title}

+
+
+ + {/* Prompt */} +
+ +

{selectedEvaluation.prompt}

+
+ + {/* Transcript */} +
+ +

+ {selectedEvaluation.transcript || Sin transcripción disponible} +

+
+ + {/* AI Evaluation */} + {selectedEvaluation.ai_score !== null && ( +
+
+ + +
+
+

{selectedEvaluation.ai_score}%

+
+ {selectedEvaluation.ai_feedback && ( +

"{selectedEvaluation.ai_feedback}"

+ )} +
+ )} + + {/* Teacher Evaluation */} +
+
+ + setTeacherScore(parseInt(e.target.value))} + className="w-full mt-1 px-4 py-3 bg-white dark:bg-black/40 border border-gray-200 dark:border-white/10 rounded-xl text-lg font-bold outline-none focus:ring-2 focus:ring-purple-500/20" + /> +
+
+ +