feat: SAM integration, deployment scripts, and audio response enhancements
- Add SAM (Sistema de Administración Académica) integration with sync endpoints - Add deployment automation (deploy.sh, remote-setup.sh, setup-nginx-ssl.sh) - Add nginx proxy configuration for SSL with Let's Encrypt - Add audio response support for student lessons (migrations, handlers) - Add audio evaluations admin page - Update CORS to support wildcard subdomains for norteamericano.cl - Add comprehensive deployment documentation (DESPLIEGUE.md, ManualDeConfiguracion.md) - Update docker-compose.yml with nginx-proxy and acme-companion services - Remove outdated documentation files Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
+24
-10
@@ -1,18 +1,32 @@
|
|||||||
# Database URLs for local development (outside Docker)
|
# ========================================
|
||||||
# Change 'db' to 'localhost' if running the services natively
|
# OpenCCB Environment Configuration
|
||||||
# NOTE: If port 5432 is occupied, use 5433 instead
|
# ========================================
|
||||||
CMS_DATABASE_URL=postgresql://user:password@localhost:5433/openccb_cms
|
# NOTA: Este archivo es solo un ejemplo.
|
||||||
LMS_DATABASE_URL=postgresql://user:password@localhost:5433/openccb_lms
|
# 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)
|
# Database URLs (producción: db:5432, desarrollo: localhost:5434)
|
||||||
JWT_SECRET=supersecret
|
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
|
# Logging
|
||||||
RUST_LOG=info
|
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
|
# AI Configuration
|
||||||
# Providers: 'openai' or 'local'
|
# Providers: 'openai' or 'local'
|
||||||
AI_PROVIDER=local
|
AI_PROVIDER=local
|
||||||
@@ -25,7 +39,7 @@ LOCAL_LLM_MODEL=llama3.2:3b
|
|||||||
|
|
||||||
# Embedding Model for semantic search (pgvector)
|
# Embedding Model for semantic search (pgvector)
|
||||||
EMBEDDING_MODEL=nomic-embed-text
|
EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
# Mercado Pago Configuration
|
# Mercado Pago Configuration
|
||||||
MP_ACCESS_TOKEN=
|
MP_ACCESS_TOKEN=
|
||||||
MP_PUBLIC_KEY=
|
MP_PUBLIC_KEY=
|
||||||
|
|||||||
@@ -33,3 +33,7 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
# --- Project-specific Development Keys ---
|
# --- Project-specific Development Keys ---
|
||||||
services/lms-service/dev_keys/
|
services/lms-service/dev_keys/
|
||||||
|
|
||||||
|
# --- SSH Keys ---
|
||||||
|
*.pem
|
||||||
|
ubuntu.pem
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(rm *)",
|
||||||
|
"Bash(sed *)",
|
||||||
|
"Bash(ls *)",
|
||||||
|
"Bash(true)",
|
||||||
|
"Bash(cargo check *)",
|
||||||
|
"Bash(cargo build *)",
|
||||||
|
"Bash(chmod *)",
|
||||||
|
"Bash(mkdir *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <nombre-modelo>
|
|
||||||
|
|
||||||
# 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
|
|
||||||
@@ -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<EmbeddingResponse>
|
|
||||||
embedding_to_pgvector(embedding) → String // "[0.1,0.2,...]"
|
|
||||||
pgvector_to_embedding(pgvector) → Vec<f32>
|
|
||||||
```
|
|
||||||
|
|
||||||
**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<i32>, // NUEVO: Filtrar por curso MySQL
|
|
||||||
level: Option<CourseLevel>,
|
|
||||||
course_type: Option<CourseType>,
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**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<QuestionBank, Error>
|
|
||||||
|
|
||||||
// Buscar contexto relevante
|
|
||||||
find_relevant_context(pool, topic, organization_id) → Vec<String>
|
|
||||||
|
|
||||||
// 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
|
|
||||||
@@ -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=<generada_aleatoriamente>
|
||||||
|
JWT_SECRET=<generada_aleatoriamente>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
+345
@@ -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
|
||||||
@@ -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** 🎉
|
|
||||||
@@ -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 (
|
|
||||||
<div>
|
|
||||||
<h1>{t('dashboard.welcome')}</h1>
|
|
||||||
<select
|
|
||||||
value={language}
|
|
||||||
onChange={(e) => setLanguage(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="es">ES</option>
|
|
||||||
<option value="en">EN</option>
|
|
||||||
<option value="pt">PT</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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 <Loading />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>Idioma del curso: {courseLanguage}</p>
|
|
||||||
{isFixedLanguage() && (
|
|
||||||
<p>Este curso usa idioma fijo</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**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 (
|
|
||||||
<select
|
|
||||||
value={currentLanguage}
|
|
||||||
onChange={(e) => changeLanguage(e.target.value)}
|
|
||||||
disabled={!canChangeLanguage}
|
|
||||||
>
|
|
||||||
<option value="es">Español</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="pt">Português</option>
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 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
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{courses.map(course => (
|
|
||||||
<CourseCard key={course.id} course={course} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Tablas Responsivas
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full">
|
|
||||||
{/* Tabla completa */}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Tipografía Fluida
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold">
|
|
||||||
Título Responsivo
|
|
||||||
</h1>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Espaciado Responsivo
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div className="px-4 md:px-6 lg:px-8 py-4 md:py-6 lg:py-8">
|
|
||||||
{/* Contenido */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 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
|
|
||||||
@@ -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
|
|
||||||
@@ -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 <TOKEN>
|
|
||||||
|
|
||||||
{
|
|
||||||
"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 <TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
**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 <TOKEN>
|
|
||||||
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
@@ -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 <repo-url>
|
||||||
|
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=<generar_con_script>
|
||||||
|
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
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
- **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.
|
- **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)
|
## �️ Próximos Pasos (Roadmap 2024-2025)
|
||||||
|
|
||||||
OpenCCB evoluciona constantemente. Estos son los pilares de nuestro desarrollo futuro:
|
OpenCCB evoluciona constantemente. Estos son los pilares de nuestro desarrollo futuro:
|
||||||
|
|||||||
@@ -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
|
|
||||||
<div className="min-h-screen flex flex-col">
|
|
||||||
{/* Header responsivo */}
|
|
||||||
<header className="h-16 px-4 md:px-6">
|
|
||||||
{/* Logo y navegación */}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Contenido principal */}
|
|
||||||
<main className="flex-1 px-4 md:px-6 py-4 md:py-8">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
|
|
||||||
{courses.map(course => (
|
|
||||||
<CourseCard key={course.id} course={course} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tablas de Datos
|
|
||||||
|
|
||||||
**Mobile:**
|
|
||||||
- Scroll horizontal
|
|
||||||
- O tarjetas apiladas en lugar de tabla
|
|
||||||
|
|
||||||
**Desktop:**
|
|
||||||
- Tabla completa visible
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full">
|
|
||||||
{/* tabla */}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tipografía Fluida
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Títulos responsivos
|
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold">
|
|
||||||
Título
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
// Texto de párrafo
|
|
||||||
<p className="text-sm md:text-base lg:text-lg">
|
|
||||||
Contenido
|
|
||||||
</p>
|
|
||||||
|
|
||||||
// Texto pequeño
|
|
||||||
<span className="text-xs md:text-sm">
|
|
||||||
Metadata
|
|
||||||
</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Espaciado Responsivo
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Padding responsivo
|
|
||||||
<div className="px-4 md:px-6 lg:px-8 py-4 md:py-6 lg:py-8">
|
|
||||||
{/* contenido */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Gap responsivo
|
|
||||||
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-8">
|
|
||||||
{/* items */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
<Image
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
fill
|
|
||||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Video responsivo
|
|
||||||
<div className="aspect-video w-full">
|
|
||||||
<video controls className="w-full h-full">
|
|
||||||
{/* sources */}
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
<nav className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t md:hidden">
|
|
||||||
<div className="flex justify-around items-center h-16">
|
|
||||||
{/* íconos de navegación */}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Desktop: Sidebar Fija
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<aside className="hidden md:block w-64 fixed left-0 top-16 bottom-0 overflow-y-auto">
|
|
||||||
{/* navegación lateral */}
|
|
||||||
</aside>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tablas Responsivas
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Opción 1: Scroll horizontal
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
{/* tabla completa */}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Opción 2: Tarjetas en mobile
|
|
||||||
<div className="block md:hidden">
|
|
||||||
{items.map(item => (
|
|
||||||
<Card key={item.id} item={item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<table>
|
|
||||||
{/* tabla completa */}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rendimiento
|
|
||||||
|
|
||||||
### Lazy Loading
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Imágenes
|
|
||||||
<Image
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
loading="lazy"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Componentes pesados
|
|
||||||
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
|
|
||||||
loading: () => <p>Cargando...</p>,
|
|
||||||
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
|
|
||||||
@@ -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
|
|
||||||
@@ -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**
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
+97
-44
@@ -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:
|
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:
|
db:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: openccb-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: user
|
POSTGRES_USER: user
|
||||||
POSTGRES_PASSWORD: password
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||||
POSTGRES_DB: openccb
|
POSTGRES_DB: openccb
|
||||||
ports:
|
|
||||||
- "5433:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- 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:
|
studio:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: web/studio/Dockerfile
|
dockerfile: web/studio/Dockerfile
|
||||||
network: host
|
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001}
|
NEXT_PUBLIC_CMS_API_URL: http://studio.norteamericano.com
|
||||||
dns:
|
NEXT_PUBLIC_LMS_API_URL: http://learning.norteamericano.com
|
||||||
- 8.8.8.8
|
container_name: openccb-studio
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
- "3001:3001"
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://user:password@db:5432/openccb_cms
|
- VIRTUAL_HOST=studio.norteamericano.com
|
||||||
JWT_SECRET: ${JWT_SECRET:-openccb_secret_key_2025_production}
|
- VIRTUAL_PORT=3000
|
||||||
NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001}
|
- LETSENCRYPT_HOST=studio.norteamericano.com
|
||||||
RUST_LOG: debug
|
- LMS_INTERNAL_URL=http://experience:3002
|
||||||
LMS_INTERNAL_URL: http://experience:3002
|
- NEXT_PUBLIC_LMS_API_URL=http://learning.norteamericano.com
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -35,48 +87,49 @@ services:
|
|||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
- "t-800:192.168.0.5"
|
- "t-800:192.168.0.5"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- openccb-network
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Experience + LMS
|
||||||
|
# ========================================
|
||||||
experience:
|
experience:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: web/experience/Dockerfile
|
dockerfile: web/experience/Dockerfile
|
||||||
network: host
|
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_LMS_API_URL: ${NEXT_PUBLIC_LMS_API_URL:-http://localhost:3002}
|
NEXT_PUBLIC_LMS_API_URL: http://learning.norteamericano.com
|
||||||
NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001}
|
NEXT_PUBLIC_CMS_API_URL: http://studio.norteamericano.com
|
||||||
dns:
|
container_name: openccb-experience
|
||||||
- 8.8.8.8
|
|
||||||
ports:
|
|
||||||
- "3003:3003"
|
|
||||||
- "3002:3002"
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://user:password@db:5432/openccb_lms
|
- VIRTUAL_HOST=learning.norteamericano.com
|
||||||
JWT_SECRET: ${JWT_SECRET:-openccb_secret_key_2025_production}
|
- VIRTUAL_PORT=3003
|
||||||
RUST_LOG: debug
|
- LETSENCRYPT_HOST=learning.norteamericano.com
|
||||||
NEXT_PUBLIC_LMS_API_URL: ${NEXT_PUBLIC_LMS_API_URL:-http://localhost:3002}
|
- NEXT_PUBLIC_CMS_API_URL=http://studio.norteamericano.com
|
||||||
NEXT_PUBLIC_CMS_API_URL: ${NEXT_PUBLIC_CMS_API_URL:-http://localhost:3001}
|
|
||||||
env_file: .env
|
env_file: .env
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
- "t-800:192.168.0.5"
|
- "t-800:192.168.0.5"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
e2e:
|
networks:
|
||||||
build:
|
- openccb-network
|
||||||
context: ./e2e
|
restart: always
|
||||||
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" ]
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Volúmenes y Redes
|
||||||
|
# ========================================
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
|
certs:
|
||||||
|
vhost:
|
||||||
|
html:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
openccb-network:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
+138
-76
@@ -4,24 +4,43 @@
|
|||||||
# This script automates the setup of OpenCCB:
|
# This script automates the setup of OpenCCB:
|
||||||
# 1. Prerequisite checks (Rust, Node.js, Docker, sqlx-cli)
|
# 1. Prerequisite checks (Rust, Node.js, Docker, sqlx-cli)
|
||||||
# 2. Hardware detection (NVIDIA GPU vs CPU)
|
# 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)
|
# 4. Database creation and migrations (CMS, LMS, AI Bridge)
|
||||||
# 5. System initialization (Admin account and Organization)
|
# 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
|
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 "===================================================="
|
||||||
echo " 🚀 Bienvenido al Instalador de OpenCCB v2.0"
|
echo " 🚀 Bienvenido al Instalador de OpenCCB v3.0"
|
||||||
echo " (Con Búsqueda Semántica PGVector)"
|
echo " (Con Búsqueda Semántica PGVector + Deploy)"
|
||||||
echo "===================================================="
|
echo "===================================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
FAST_MODE="false"
|
FAST_MODE="false"
|
||||||
|
DEPLOY_MODE="false"
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
if [ "$arg" == "--fast" ]; then
|
if [ "$arg" == "--fast" ]; then
|
||||||
FAST_MODE="true"
|
FAST_MODE="true"
|
||||||
|
elif [ "$arg" == "--deploy" ]; then
|
||||||
|
DEPLOY_MODE="true"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -113,67 +132,70 @@ ENV_CHOICE=$(echo "$ENV_CHOICE" | tr '[:upper:]' '[:lower:]')
|
|||||||
ENV_CHOICE=${ENV_CHOICE:-prod}
|
ENV_CHOICE=${ENV_CHOICE:-prod}
|
||||||
update_env "ENVIRONMENT" "$ENV_CHOICE"
|
update_env "ENVIRONMENT" "$ENV_CHOICE"
|
||||||
|
|
||||||
# 6. Configuración de IA Remota
|
# 6. Configuración de IA Remota (Automática según entorno)
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔍 Configurando Servicios de IA Remota ($ENV_CHOICE)..."
|
echo "🔍 Configurando Servicios de IA Remota ($ENV_CHOICE)..."
|
||||||
|
|
||||||
|
# Configuración automática según entorno
|
||||||
if [ "$ENV_CHOICE" == "dev" ]; then
|
if [ "$ENV_CHOICE" == "dev" ]; then
|
||||||
DEFAULT_OLLAMA="http://t-800:11434"
|
DEFAULT_OLLAMA="http://t-800:11434"
|
||||||
DEFAULT_WHISPER="http://t-800:9000"
|
DEFAULT_WHISPER="http://t-800:9000"
|
||||||
|
DEFAULT_IMAGE="http://t-800:8080"
|
||||||
|
echo " ✅ Entorno de DESARROLLO detectado"
|
||||||
else
|
else
|
||||||
DEFAULT_OLLAMA="http://t-800.norteamericano.cl:11434"
|
DEFAULT_OLLAMA="http://t-800.norteamericano.cl:11434"
|
||||||
DEFAULT_WHISPER="http://t-800.norteamericano.cl:9000"
|
DEFAULT_WHISPER="http://t-800.norteamericano.cl:9000"
|
||||||
|
DEFAULT_IMAGE="http://t-800.norteamericano.cl:8080"
|
||||||
|
echo " ✅ Entorno de PRODUCCIÓN detectado"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
read -p "Ingrese la URL de Ollama Remoto [$DEFAULT_OLLAMA]: " REMOTE_OLLAMA_URL
|
# Configurar con valores por defecto (sin preguntar)
|
||||||
REMOTE_OLLAMA_URL=${REMOTE_OLLAMA_URL:-$DEFAULT_OLLAMA}
|
REMOTE_OLLAMA_URL="$DEFAULT_OLLAMA"
|
||||||
read -p "Ingrese la URL de Whisper Remoto [$DEFAULT_WHISPER]: " REMOTE_WHISPER_URL
|
REMOTE_WHISPER_URL="$DEFAULT_WHISPER"
|
||||||
REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-$DEFAULT_WHISPER}
|
REMOTE_IMAGE_URL="$DEFAULT_IMAGE"
|
||||||
read -p "Ingrese la URL del Image Bridge Remoto [http://t-800:8080]: " REMOTE_IMAGE_URL
|
LLM_MODEL="llama3.2:3b"
|
||||||
REMOTE_IMAGE_URL=${REMOTE_IMAGE_URL:-"http://t-800:8080"}
|
EMBEDDING_MODEL="nomic-embed-text"
|
||||||
read -p "Ingrese el nombre del Modelo (en el servidor remoto) [llama3.2:3b]: " LLM_MODEL
|
|
||||||
LLM_MODEL=${LLM_MODEL:-llama3.2:3b}
|
echo " 🤖 Ollama: $REMOTE_OLLAMA_URL"
|
||||||
read -p "Ingrese el nombre del Modelo de Embeddings [nomic-embed-text]: " EMBEDDING_MODEL
|
echo " 🎤 Whisper: $REMOTE_WHISPER_URL"
|
||||||
EMBEDDING_MODEL=${EMBEDDING_MODEL:-nomic-embed-text}
|
echo " 🖼️ Image Bridge: $REMOTE_IMAGE_URL"
|
||||||
|
echo " 🧠 Modelo LLM: $LLM_MODEL"
|
||||||
|
echo " 📊 Embeddings: $EMBEDDING_MODEL"
|
||||||
|
|
||||||
update_env "AI_PROVIDER" "local"
|
update_env "AI_PROVIDER" "local"
|
||||||
update_env "LOCAL_LLM_MODEL" "$LLM_MODEL"
|
update_env "LOCAL_LLM_MODEL" "$LLM_MODEL"
|
||||||
update_env "LOCAL_VIDEO_BRIDGE_URL" "$REMOTE_IMAGE_URL"
|
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
|
# Configuración SAM (opcional)
|
||||||
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)
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔌 Configuración de Base de Datos para Bridge Remoto"
|
echo "🔌 Configuración de Integración SAM"
|
||||||
echo "Si el equipo t-800 necesita reportar progreso, debe conocer la IP de este servidor."
|
read -p "¿Desea configurar la conexión a la base de datos SAM? [y/N]: " CONFIGURE_SAM
|
||||||
SERVER_IP=$(ip -4 -o addr show | awk '{print $4}' | cut -d/ -f1 | grep -v '127.0.0.1' | head -n 1)
|
if [[ "$CONFIGURE_SAM" =~ ^[Yy]$ ]]; then
|
||||||
DEFAULT_BRIDGE_DB="postgresql://user:${DB_PASS:-password}@${SERVER_IP}:5432/openccb_cms?sslmode=disable"
|
read -p "Ingrese SAM_DATABASE_URL []: " SAM_DB_URL
|
||||||
read -p "Ingrese la URL de la DB que verá el Bridge Remoto [$DEFAULT_BRIDGE_DB]: " BRIDGE_DB_URL
|
SAM_DB_URL=${SAM_DB_URL:-""}
|
||||||
BRIDGE_DB_URL=${BRIDGE_DB_URL:-$DEFAULT_BRIDGE_DB}
|
if [ -n "$SAM_DB_URL" ]; then
|
||||||
update_env "BRIDGE_DATABASE_URL" "$BRIDGE_DB_URL"
|
update_env "SAM_DATABASE_URL" "$SAM_DB_URL"
|
||||||
|
echo " ✅ SAM_DATABASE_URL configurada"
|
||||||
# AI setup is now purely remote. Skipping local container configuration.
|
fi
|
||||||
|
else
|
||||||
|
echo " ℹ️ SAM no configurado. Puede configurarlo luego en .env"
|
||||||
|
fi
|
||||||
|
|
||||||
# Solicitar credenciales de DB si no están configuradas
|
# Solicitar credenciales de DB si no están configuradas
|
||||||
if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' -f2) == "" ]]; then
|
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
|
read -p "Ingrese la Contraseña de la Base de Datos [password]: " DB_PASS
|
||||||
DB_PASS=${DB_PASS:-password}
|
DB_PASS=${DB_PASS:-password}
|
||||||
update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5433/openccb?sslmode=disable"
|
# Usar puerto 5434 (5432 y 5433 ya están en uso)
|
||||||
update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5433/openccb_cms?sslmode=disable"
|
update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5434/openccb?sslmode=disable"
|
||||||
update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5433/openccb_lms?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 "JWT_SECRET" "supersecretsecret"
|
||||||
update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001"
|
update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001"
|
||||||
update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3003"
|
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
|
# 6. Inicialización de la Base de Datos
|
||||||
echo ""
|
echo ""
|
||||||
read -p "¿Desea una instalación LIMPIA? (Esto ELIMINARÁ todos los datos existentes) [y/N]: " CLEAN_INSTALL
|
echo "🔌 Configuración de Base de Datos"
|
||||||
if [[ "$CLEAN_INSTALL" =~ ^[Yy]$ ]]; then
|
echo " Puerto: 5433 (PostgreSQL existente)"
|
||||||
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 ""
|
||||||
|
|
||||||
echo "⏳ Esperando al puerto de la base de datos (host)..."
|
# Preguntar si PostgreSQL ya está corriendo (producción)
|
||||||
RETRIES=10
|
read -p "¿PostgreSQL ya está corriendo en un contenedor? [y/N]: " DB_EXISTS
|
||||||
until curl -s localhost:5432 &> /dev/null || [ $RETRIES -eq 0 ]; do
|
DB_EXISTS=${DB_EXISTS:-N}
|
||||||
echo -n "+"
|
|
||||||
sleep 1
|
|
||||||
RETRIES=$((RETRIES-1))
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ $RETRIES -eq 0 ]; then
|
if [[ ! "$DB_EXISTS" =~ ^[Yy]$ ]]; then
|
||||||
echo "⚠️ Tiempo de espera agotado para el puerto del host, pero continuando..."
|
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
|
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-)
|
CMS_URL=$(grep "CMS_DATABASE_URL=" .env | cut -d'=' -f2-)
|
||||||
LMS_URL=$(grep "LMS_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 " # Detectar preguntas duplicadas"
|
||||||
echo " curl -G \"http://localhost:3001/question-bank/similar/{id}?threshold=0.95\""
|
echo " curl -G \"http://localhost:3001/question-bank/similar/{id}?threshold=0.95\""
|
||||||
echo "===================================================="
|
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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Proxy configuration for nginx
|
||||||
|
map $http_x_forwarded_proto $origin_proto {
|
||||||
|
default $http_x_forwarded_proto;
|
||||||
|
"" $scheme;
|
||||||
|
}
|
||||||
Executable
+161
@@ -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 ""
|
||||||
@@ -1,7 +1,29 @@
|
|||||||
-- AI Usage Logs: Update log_ai_usage function to include prompt and response
|
-- AI Usage Logs: Add prompt and response columns
|
||||||
-- Note: prompt and response columns already exist from previous migration
|
-- 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(
|
CREATE OR REPLACE FUNCTION log_ai_usage(
|
||||||
p_user_id UUID,
|
p_user_id UUID,
|
||||||
p_org_id UUID,
|
p_org_id UUID,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -339,15 +339,42 @@ pub async fn get_courses(
|
|||||||
let is_super_admin = claims.role == "admin"
|
let is_super_admin = claims.role == "admin"
|
||||||
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
&& 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<Course> = if is_super_admin {
|
||||||
|
// Super admin sees all courses
|
||||||
sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.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")
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1")
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.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)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters for SAM queries
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SamStudentFilters {
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub nombre: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==================== 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<PgPool>,
|
||||||
|
) -> Result<Json<SamSyncResponse>, (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<SamStudentInfo> = rows.iter().map(|row| {
|
||||||
|
SamStudentInfo {
|
||||||
|
id_alumno: row.get("id_alumno"),
|
||||||
|
nombre: row.get("nombre"),
|
||||||
|
email: row.get("email"),
|
||||||
|
telefono: row.get::<Option<String>, _>("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<String>)> = 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<PgPool>,
|
||||||
|
) -> Result<Json<SamSyncResponse>, (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<SamAssignmentInfo> = 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<PgPool>,
|
||||||
|
Query(filters): Query<SamStudentFilters>,
|
||||||
|
) -> Result<Json<Vec<serde_json::Value>>, (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<serde_json::Value> = rows.iter().map(|row| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": row.get::<Uuid, _>("id"),
|
||||||
|
"email": row.get::<String, _>("email"),
|
||||||
|
"full_name": row.get::<String, _>("full_name"),
|
||||||
|
"sam_student_id": row.get::<String, _>("sam_student_id"),
|
||||||
|
"is_sam_student": row.get::<bool, _>("is_sam_student"),
|
||||||
|
"sam_verified_at": row.get::<Option<chrono::DateTime<chrono::Utc>>, _>("sam_verified_at"),
|
||||||
|
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("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<PgPool>,
|
||||||
|
Path(sam_student_id): Path<String>,
|
||||||
|
) -> Result<Json<Vec<serde_json::Value>>, (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<serde_json::Value> = rows.iter().map(|row| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": row.get::<Uuid, _>("id"),
|
||||||
|
"title": row.get::<String, _>("title"),
|
||||||
|
"description": row.get::<Option<String>, _>("description"),
|
||||||
|
"is_active": row.get::<bool, _>("is_active"),
|
||||||
|
"synced_at": row.get::<Option<chrono::DateTime<chrono::Utc>>, _>("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<PgPool>,
|
||||||
|
) -> Result<Json<SamSyncResponse>, (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<SamStudentInfo> = rows.iter().map(|row| {
|
||||||
|
SamStudentInfo {
|
||||||
|
id_alumno: row.get("id_alumno"),
|
||||||
|
nombre: row.get("nombre"),
|
||||||
|
email: row.get("email"),
|
||||||
|
telefono: row.get::<Option<String>, _>("telefono"),
|
||||||
|
activo: row.get("activo"),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Sync each student
|
||||||
|
for sam_student in sam_students {
|
||||||
|
let existing_user: Option<(Uuid, Option<String>)> = 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<SamAssignmentInfo> = 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ mod handlers_test_templates;
|
|||||||
mod handlers_question_bank;
|
mod handlers_question_bank;
|
||||||
mod handlers_admin;
|
mod handlers_admin;
|
||||||
mod handlers_embeddings;
|
mod handlers_embeddings;
|
||||||
|
mod handlers_sam;
|
||||||
mod webhooks;
|
mod webhooks;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -97,17 +98,48 @@ async fn main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// CORS configuration - Allow multiple origins for development and production
|
// 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()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin([
|
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
||||||
"http://localhost:3000".parse::<http::HeaderValue>().unwrap(),
|
let origin_str = origin.to_str().unwrap_or("");
|
||||||
"http://localhost:3003".parse::<http::HeaderValue>().unwrap(),
|
|
||||||
"http://127.0.0.1:3000".parse::<http::HeaderValue>().unwrap(),
|
// Development origins
|
||||||
"http://127.0.0.1:3003".parse::<http::HeaderValue>().unwrap(),
|
let allowed_origins = [
|
||||||
"http://192.168.0.254:3000".parse::<http::HeaderValue>().unwrap(),
|
"http://localhost:3000",
|
||||||
"http://192.168.0.254:3003".parse::<http::HeaderValue>().unwrap(),
|
"http://localhost:3003",
|
||||||
// Allow any origin for development (remove in production)
|
"http://127.0.0.1:3000",
|
||||||
"http://192.168.0.254".parse::<http::HeaderValue>().unwrap(),
|
"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_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH, Method::HEAD])
|
||||||
.allow_headers([
|
.allow_headers([
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
@@ -392,6 +424,27 @@ async fn main() {
|
|||||||
"/question-bank/{id}/embedding/regenerate",
|
"/question-bank/{id}/embedding/regenerate",
|
||||||
post(handlers_embeddings::regenerate_question_embedding),
|
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
|
// Admin routes
|
||||||
.route(
|
.route(
|
||||||
"/admin/token-usage",
|
"/admin/token-usage",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 $$;
|
||||||
@@ -6,6 +6,7 @@ use axum::{
|
|||||||
Extension,
|
Extension,
|
||||||
};
|
};
|
||||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use common::auth::{Claims, create_jwt};
|
use common::auth::{Claims, create_jwt};
|
||||||
use common::middleware::Org;
|
use common::middleware::Org;
|
||||||
use common::models::{
|
use common::models::{
|
||||||
@@ -15,6 +16,7 @@ use common::models::{
|
|||||||
};
|
};
|
||||||
use crate::external_db::MySqlPool;
|
use crate::external_db::MySqlPool;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
// Simple token counter (approximate: 1 token ≈ 4 characters in English, ~3-5 in Spanish)
|
// Simple token counter (approximate: 1 token ≈ 4 characters in English, ~3-5 in Spanish)
|
||||||
fn count_tokens(text: &str) -> i32 {
|
fn count_tokens(text: &str) -> i32 {
|
||||||
@@ -2103,6 +2105,7 @@ pub async fn get_recommendations(
|
|||||||
pub async fn evaluate_audio_response(
|
pub async fn evaluate_audio_response(
|
||||||
Org(_org_ctx): Org,
|
Org(_org_ctx): Org,
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<AudioGradingPayload>,
|
Json(payload): Json<AudioGradingPayload>,
|
||||||
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
|
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
|
||||||
// Check token limit before proceeding (estimate 1500 tokens for audio evaluation)
|
// 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(
|
pub async fn evaluate_audio_file(
|
||||||
Org(_org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
_claims: Claims,
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
|
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
|
||||||
|
let mut lesson_id_str = String::new();
|
||||||
|
let mut block_id_str = String::new();
|
||||||
let mut prompt = String::new();
|
let mut prompt = String::new();
|
||||||
let mut keywords_str = String::new();
|
let mut keywords_str = String::new();
|
||||||
let mut audio_data = Vec::new();
|
let mut audio_data = Vec::new();
|
||||||
let mut filename = "audio.webm".to_string();
|
let mut filename = "audio.webm".to_string();
|
||||||
|
let mut duration_seconds: Option<i32> = None;
|
||||||
|
|
||||||
|
tracing::info!("Received audio evaluation request from user: {}", claims.sub);
|
||||||
|
|
||||||
while let Some(field) = multipart
|
while let Some(field) = multipart
|
||||||
.next_field()
|
.next_field()
|
||||||
@@ -2198,8 +2207,24 @@ pub async fn evaluate_audio_file(
|
|||||||
{
|
{
|
||||||
let name = field.name().unwrap_or_default().to_string();
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
match name.as_str() {
|
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(),
|
"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" => {
|
"file" => {
|
||||||
filename = field.file_name().unwrap_or("audio.webm").to_string();
|
filename = field.file_name().unwrap_or("audio.webm").to_string();
|
||||||
audio_data = field
|
audio_data = field
|
||||||
@@ -2207,18 +2232,35 @@ pub async fn evaluate_audio_file(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.to_vec();
|
.to_vec();
|
||||||
|
tracing::info!("Received audio file: {} bytes", audio_data.len());
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if audio_data.is_empty() {
|
if audio_data.is_empty() {
|
||||||
|
tracing::error!("No audio data received");
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"No se proporcionó ningún archivo de audio".into(),
|
"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
|
// 1. Send to Whisper
|
||||||
let whisper_url =
|
let whisper_url =
|
||||||
env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
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()
|
let form = reqwest::multipart::Form::new()
|
||||||
.part(
|
.part(
|
||||||
"file",
|
"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("model", "whisper-1")
|
||||||
.text("response_format", "json");
|
.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)))?;
|
).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))
|
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<String>,
|
||||||
|
pub ai_score: Option<i32>,
|
||||||
|
pub ai_found_keywords: Option<Vec<String>>,
|
||||||
|
pub ai_feedback: Option<String>,
|
||||||
|
pub teacher_score: Option<i32>,
|
||||||
|
pub teacher_feedback: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub attempt_number: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AudioResponseFilters {
|
||||||
|
pub course_id: Option<Uuid>,
|
||||||
|
pub lesson_id: Option<Uuid>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<PgPool>,
|
||||||
|
Query(filters): Query<AudioResponseFilters>,
|
||||||
|
) -> Result<Json<Vec<AudioResponseListItem>>, 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<PgPool>,
|
||||||
|
Path(response_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<AudioResponseListItem>, 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<PgPool>,
|
||||||
|
Path(response_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
// Only instructors, admins, and the owner can access
|
||||||
|
let audio_data: Option<Vec<u8>> = 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<PgPool>,
|
||||||
|
Path(response_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<common::models::UpdateAudioResponsePayload>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<PgPool>,
|
||||||
|
Path(course_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<common::models::AudioResponseStats>, 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)]
|
#[derive(Deserialize)]
|
||||||
pub struct ChatPayload {
|
pub struct ChatPayload {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
|||||||
@@ -66,17 +66,48 @@ async fn main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// CORS configuration - Allow multiple origins for development and production
|
// 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()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin([
|
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
||||||
"http://localhost:3000".parse::<http::HeaderValue>().unwrap(),
|
let origin_str = origin.to_str().unwrap_or("");
|
||||||
"http://localhost:3003".parse::<http::HeaderValue>().unwrap(),
|
|
||||||
"http://127.0.0.1:3000".parse::<http::HeaderValue>().unwrap(),
|
// Development origins
|
||||||
"http://127.0.0.1:3003".parse::<http::HeaderValue>().unwrap(),
|
let allowed_origins = [
|
||||||
"http://192.168.0.254:3000".parse::<http::HeaderValue>().unwrap(),
|
"http://localhost:3000",
|
||||||
"http://192.168.0.254:3003".parse::<http::HeaderValue>().unwrap(),
|
"http://localhost:3003",
|
||||||
// Allow any origin for development (remove in production)
|
"http://127.0.0.1:3000",
|
||||||
"http://192.168.0.254".parse::<http::HeaderValue>().unwrap(),
|
"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_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
|
||||||
.allow_headers([
|
.allow_headers([
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
@@ -158,6 +189,12 @@ async fn main() {
|
|||||||
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
|
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
|
||||||
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
|
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
|
||||||
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
|
.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", post(handlers::chat_with_tutor))
|
||||||
.route("/lessons/{id}/chat-role-play", post(handlers::chat_role_play))
|
.route("/lessons/{id}/chat-role-play", post(handlers::chat_role_play))
|
||||||
.route("/lessons/{id}/code-hint", post(handlers::get_code_hint))
|
.route("/lessons/{id}/code-hint", post(handlers::get_code_hint))
|
||||||
|
|||||||
Executable
+398
@@ -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 ""
|
||||||
@@ -1446,3 +1446,121 @@ pub struct QuestionBankFilters {
|
|||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
pub has_audio: Option<bool>,
|
pub has_audio: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 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<String>,
|
||||||
|
pub audio_url: Option<String>,
|
||||||
|
pub audio_data: Option<Vec<u8>>,
|
||||||
|
|
||||||
|
// AI Evaluation
|
||||||
|
pub ai_score: Option<i32>,
|
||||||
|
pub ai_found_keywords: Option<Vec<String>>,
|
||||||
|
pub ai_feedback: Option<String>,
|
||||||
|
pub ai_evaluated_at: Option<DateTime<Utc>>,
|
||||||
|
|
||||||
|
// Teacher Evaluation
|
||||||
|
pub teacher_score: Option<i32>,
|
||||||
|
pub teacher_feedback: Option<String>,
|
||||||
|
pub teacher_evaluated_at: Option<DateTime<Utc>>,
|
||||||
|
pub teacher_evaluated_by: Option<Uuid>,
|
||||||
|
|
||||||
|
// Status and metadata
|
||||||
|
pub status: AudioResponseStatus,
|
||||||
|
pub attempt_number: i32,
|
||||||
|
pub duration_seconds: Option<i32>,
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub ai_score: Option<i32>,
|
||||||
|
pub ai_feedback: Option<String>,
|
||||||
|
pub teacher_score: Option<i32>,
|
||||||
|
pub teacher_feedback: Option<String>,
|
||||||
|
pub status: AudioResponseStatus,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
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<f32>,
|
||||||
|
pub avg_teacher_score: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct CreateAudioResponsePayload {
|
||||||
|
pub lesson_id: Uuid,
|
||||||
|
pub block_id: Uuid,
|
||||||
|
pub prompt: String,
|
||||||
|
pub transcript: Option<String>,
|
||||||
|
pub duration_seconds: Option<i32>,
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UpdateAudioResponsePayload {
|
||||||
|
pub teacher_score: i32,
|
||||||
|
pub teacher_feedback: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct AudioResponseEvaluation {
|
||||||
|
pub score: i32,
|
||||||
|
pub found_keywords: Vec<String>,
|
||||||
|
pub feedback: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -430,6 +430,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
keywords={block.keywords}
|
keywords={block.keywords}
|
||||||
timeLimit={block.timeLimit}
|
timeLimit={block.timeLimit}
|
||||||
isGraded={lesson.is_graded}
|
isGraded={lesson.is_graded}
|
||||||
|
lessonId={params.lessonId}
|
||||||
|
blockId={block.id}
|
||||||
onComplete={(score) => handleBlockComplete(block.id, score)}
|
onComplete={(score) => handleBlockComplete(block.id, score)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface AudioResponsePlayerProps {
|
|||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
timeLimit?: number;
|
timeLimit?: number;
|
||||||
isGraded?: boolean;
|
isGraded?: boolean;
|
||||||
|
lessonId?: string;
|
||||||
|
blockId?: string;
|
||||||
onComplete?: (score: number, transcript: string) => void;
|
onComplete?: (score: number, transcript: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ export default function AudioResponsePlayer({
|
|||||||
keywords = [],
|
keywords = [],
|
||||||
timeLimit,
|
timeLimit,
|
||||||
isGraded = false,
|
isGraded = false,
|
||||||
|
lessonId,
|
||||||
|
blockId,
|
||||||
onComplete
|
onComplete
|
||||||
}: AudioResponsePlayerProps) {
|
}: AudioResponsePlayerProps) {
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
@@ -195,9 +199,22 @@ export default function AudioResponsePlayer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!lessonId || !blockId) {
|
||||||
|
console.error("Missing lessonId or blockId");
|
||||||
|
alert("Error: Missing lesson or block information");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsTranscribing(true);
|
setIsTranscribing(true);
|
||||||
try {
|
try {
|
||||||
const result = await lmsApi.evaluateAudioFile(audioBlob, prompt, keywords);
|
const result = await lmsApi.evaluateAudioFile(
|
||||||
|
audioBlob,
|
||||||
|
prompt,
|
||||||
|
keywords,
|
||||||
|
lessonId,
|
||||||
|
blockId,
|
||||||
|
recordingTime
|
||||||
|
);
|
||||||
setEvaluation({
|
setEvaluation({
|
||||||
score: result.score,
|
score: result.score,
|
||||||
foundKeywords: result.found_keywords,
|
foundKeywords: result.found_keywords,
|
||||||
|
|||||||
@@ -604,17 +604,23 @@ export const lmsApi = {
|
|||||||
body: JSON.stringify({ transcript, prompt, keywords })
|
body: JSON.stringify({ transcript, prompt, keywords })
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async evaluateAudioFile(file: Blob, prompt: string, keywords: string[]): Promise<AudioGradingResponse> {
|
async evaluateAudioFile(file: Blob, prompt: string, keywords: string[], lessonId: string, blockId: string, duration?: number): Promise<AudioGradingResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file, 'recorded_audio.webm');
|
formData.append('file', file, 'recorded_audio.webm');
|
||||||
formData.append('prompt', prompt);
|
formData.append('prompt', prompt);
|
||||||
formData.append('keywords', JSON.stringify(keywords));
|
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();
|
const token = getToken();
|
||||||
return fetch(`${getLmsApiUrl()}/audio/evaluate-file`, {
|
return fetch(`${getLmsApiUrl()}/audio/evaluate-file`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
// Don't set Content-Type for FormData - browser sets it with boundary
|
||||||
},
|
},
|
||||||
body: formData
|
body: formData
|
||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ COPY web/studio/package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY web/studio/ .
|
COPY web/studio/ .
|
||||||
ARG NEXT_PUBLIC_CMS_API_URL
|
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_CMS_API_URL=$NEXT_PUBLIC_CMS_API_URL
|
||||||
|
ENV NEXT_PUBLIC_LMS_API_URL=$NEXT_PUBLIC_LMS_API_URL
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Final stage
|
# Final stage
|
||||||
|
|||||||
@@ -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<AudioResponse[]>([]);
|
||||||
|
const [selectedEvaluation, setSelectedEvaluation] = useState<AudioResponse | null>(null);
|
||||||
|
const [teacherScore, setTeacherScore] = useState<number>(50);
|
||||||
|
const [teacherFeedback, setTeacherFeedback] = useState<string>("");
|
||||||
|
const [filters, setFilters] = useState<AudioResponseFilters>({});
|
||||||
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(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 <span className="px-3 py-1 bg-yellow-500/10 border border-yellow-500/20 rounded-full text-xs font-bold text-yellow-600 dark:text-yellow-400">Pendiente</span>;
|
||||||
|
case 'ai_evaluated':
|
||||||
|
return <span className="px-3 py-1 bg-blue-500/10 border border-blue-500/20 rounded-full text-xs font-bold text-blue-600 dark:text-blue-400">IA Evaluada</span>;
|
||||||
|
case 'teacher_evaluated':
|
||||||
|
return <span className="px-3 py-1 bg-purple-500/10 border border-purple-500/20 rounded-full text-xs font-bold text-purple-600 dark:text-purple-400">Profesor Evaluó</span>;
|
||||||
|
case 'both_evaluated':
|
||||||
|
return <span className="px-3 py-1 bg-green-500/10 border border-green-500/20 rounded-full text-xs font-bold text-green-600 dark:text-green-400">Completa</span>;
|
||||||
|
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 (
|
||||||
|
<PageLayout title="Evaluaciones de Audio">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="p-6 glass border-black/5 dark:border-white/5 rounded-3xl bg-black/[0.02] dark:bg-black/20">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Filter className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Filtros</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Estado</label>
|
||||||
|
<select
|
||||||
|
value={filters.status || ''}
|
||||||
|
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined })}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="pending">Pendiente</option>
|
||||||
|
<option value="ai_evaluated">Solo IA</option>
|
||||||
|
<option value="teacher_evaluated">Solo Profesor</option>
|
||||||
|
<option value="both_evaluated">Completa</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Curso ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filters.course_id || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Lección ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filters.lesson_id || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFilters({});
|
||||||
|
fetchEvaluations();
|
||||||
|
}}
|
||||||
|
className="mt-4 flex items-center gap-2 px-4 py-2 glass hover:bg-black/5 dark:hover:bg-white/10 rounded-xl text-sm font-bold text-purple-600 dark:text-purple-400 transition-all"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="w-4 h-4" />
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Evaluations Table */}
|
||||||
|
<div className="glass border-black/5 dark:border-white/5 rounded-3xl bg-black/[0.02] dark:bg-black/20 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gradient-to-r from-purple-600/10 to-pink-600/10 border-b border-black/5 dark:border-white/5">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Estudiante</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Curso / Lección</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Prompt</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Transcripción</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">IA</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Profesor</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Estado</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Fecha</th>
|
||||||
|
<th className="px-6 py-4 text-left text-xs font-black uppercase tracking-widest text-gray-500 dark:text-gray-400">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-black/5 dark:divide-white/5">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="px-6 py-12 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-3 text-gray-400">
|
||||||
|
<RefreshCcw className="w-6 h-6 animate-spin" />
|
||||||
|
<span className="text-sm font-medium">Cargando evaluaciones...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : evaluations.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="px-6 py-12 text-center text-gray-400">
|
||||||
|
<Mic className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||||
|
<p className="text-sm font-medium">No hay evaluaciones de audio</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
evaluations.map((eval_) => (
|
||||||
|
<tr key={eval_.id} className="hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
||||||
|
{eval_.student_name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-gray-900 dark:text-gray-100">{eval_.student_name}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{eval_.student_email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="max-w-[200px]">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" title={eval_.course_title}>{eval_.course_title}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate" title={eval_.lesson_title}>{eval_.lesson_title}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 max-w-[200px] truncate" title={eval_.prompt}>{eval_.prompt}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 max-w-[200px] truncate" title={eval_.transcript || ''}>
|
||||||
|
{eval_.transcript || <span className="text-gray-400 italic">Sin transcripción</span>}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{eval_.ai_score !== null ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className={`text-lg font-black ${getScoreColor(eval_.ai_score)}`}>{eval_.ai_score}%</p>
|
||||||
|
{eval_.ai_found_keywords && eval_.ai_found_keywords.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Keywords: {eval_.ai_found_keywords.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{eval_.teacher_score !== null ? (
|
||||||
|
<p className={`text-lg font-black ${getScoreColor(eval_.teacher_score)}`}>{eval_.teacher_score}%</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{getStatusBadge(eval_.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(eval_.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePlayAudio(eval_.id)}
|
||||||
|
className="p-2 glass hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-all"
|
||||||
|
title="Reproducir audio"
|
||||||
|
>
|
||||||
|
{playingId === eval_.id ? (
|
||||||
|
<Pause className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEvaluation(eval_)}
|
||||||
|
className="p-2 glass hover:bg-purple-500/10 rounded-lg transition-all"
|
||||||
|
title="Evaluar"
|
||||||
|
>
|
||||||
|
<BrainCircuit className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Evaluation Modal */}
|
||||||
|
{selectedEvaluation && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="glass border-black/5 dark:border-white/5 rounded-3xl bg-white dark:bg-gray-900 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-8 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-2xl font-black text-gray-900 dark:text-white">Evaluar Respuesta de Audio</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEvaluation(null)}
|
||||||
|
className="p-2 glass hover:bg-black/5 dark:hover:bg-white/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Student Info */}
|
||||||
|
<div className="p-4 bg-gradient-to-r from-purple-500/10 to-pink-500/10 rounded-2xl border border-purple-500/20">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<User className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
<p className="text-sm font-bold text-gray-900 dark:text-gray-100">{selectedEvaluation.student_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BookOpen className="w-4 h-4 text-gray-500" />
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400">{selectedEvaluation.course_title} → {selectedEvaluation.lesson_title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Prompt</label>
|
||||||
|
<p className="mt-1 text-lg font-medium text-gray-900 dark:text-gray-100">{selectedEvaluation.prompt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transcript */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Transcripción</label>
|
||||||
|
<p className="mt-1 p-4 bg-black/5 dark:bg-white/5 rounded-xl text-gray-700 dark:text-gray-300">
|
||||||
|
{selectedEvaluation.transcript || <span className="italic text-gray-400">Sin transcripción disponible</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Evaluation */}
|
||||||
|
{selectedEvaluation.ai_score !== null && (
|
||||||
|
<div className="p-4 bg-blue-500/10 border border-blue-500/20 rounded-2xl space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BrainCircuit className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
<label className="text-xs font-bold text-blue-600 dark:text-blue-400 uppercase tracking-wider">Evaluación de IA</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<p className="text-3xl font-black text-blue-600 dark:text-blue-400">{selectedEvaluation.ai_score}%</p>
|
||||||
|
</div>
|
||||||
|
{selectedEvaluation.ai_feedback && (
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 italic">"{selectedEvaluation.ai_feedback}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Teacher Evaluation */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Puntuación (0-100)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={teacherScore}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Feedback</label>
|
||||||
|
<textarea
|
||||||
|
value={teacherFeedback}
|
||||||
|
onChange={(e) => setTeacherFeedback(e.target.value)}
|
||||||
|
placeholder="Escribe tu feedback para el estudiante..."
|
||||||
|
rows={4}
|
||||||
|
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-sm font-medium outline-none focus:ring-2 focus:ring-purple-500/20 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEvaluation(null)}
|
||||||
|
className="flex-1 py-3 glass hover:bg-black/5 dark:hover:bg-white/10 rounded-xl font-bold text-gray-600 dark:text-gray-300 transition-all"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitEvaluation}
|
||||||
|
className="flex-1 py-3 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 rounded-xl font-bold text-white shadow-lg shadow-purple-500/30 transition-all"
|
||||||
|
>
|
||||||
|
Guardar Evaluación
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Activity,
|
Activity,
|
||||||
TrendingUp
|
TrendingUp,
|
||||||
|
Mic
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -21,6 +22,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{ icon: LayoutDashboard, label: "Dashboard", href: "/admin" },
|
{ icon: LayoutDashboard, label: "Dashboard", href: "/admin" },
|
||||||
{ icon: Building2, label: "Organizations", href: "/admin/organizations" },
|
{ icon: Building2, label: "Organizations", href: "/admin/organizations" },
|
||||||
{ icon: Users, label: "Users", href: "/admin/users" },
|
{ icon: Users, label: "Users", href: "/admin/users" },
|
||||||
|
{ icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" },
|
||||||
{ icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" },
|
{ icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" },
|
||||||
{ icon: Activity, label: "System Tasks", href: "/admin/tasks" },
|
{ icon: Activity, label: "System Tasks", href: "/admin/tasks" },
|
||||||
{ icon: TrendingUp, label: "Control Global IA", href: "/admin/ai-usage-global" },
|
{ icon: TrendingUp, label: "Control Global IA", href: "/admin/ai-usage-global" },
|
||||||
|
|||||||
+133
-3
@@ -1,11 +1,17 @@
|
|||||||
const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
|
const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
|
||||||
|
// Si hay una variable de entorno definida, usarla siempre
|
||||||
|
if (envVar && envVar.trim() !== '') {
|
||||||
|
return envVar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback para desarrollo local
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
// Detect if we are on a custom domain or IP
|
|
||||||
const protocol = window.location.protocol;
|
const protocol = window.location.protocol;
|
||||||
return `${protocol}//${hostname}:${defaultPort}`;
|
return `${protocol}//${hostname}:${defaultPort}`;
|
||||||
}
|
}
|
||||||
return envVar || `http://localhost:${defaultPort}`;
|
|
||||||
|
return `http://localhost:${defaultPort}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
|
export const API_BASE_URL = getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
|
||||||
@@ -763,7 +769,6 @@ export const cmsApi = {
|
|||||||
},
|
},
|
||||||
getSSOConfig: (): Promise<OrganizationSSOConfig> => apiFetch('/organization/sso'),
|
getSSOConfig: (): Promise<OrganizationSSOConfig> => apiFetch('/organization/sso'),
|
||||||
updateSSOConfig: (payload: Partial<OrganizationSSOConfig>): Promise<void> => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }),
|
updateSSOConfig: (payload: Partial<OrganizationSSOConfig>): Promise<void> => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
getOrganization: (): Promise<Organization> => apiFetch('/organization'),
|
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
|
register: (payload: AuthPayload): Promise<AuthResponse> => apiFetch('/auth/register', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
@@ -1209,6 +1214,37 @@ export const lmsApi = {
|
|||||||
|
|
||||||
getMyBadges: (): Promise<Badge[]> =>
|
getMyBadges: (): Promise<Badge[]> =>
|
||||||
apiFetch(`/my/badges`, {}, true),
|
apiFetch(`/my/badges`, {}, true),
|
||||||
|
|
||||||
|
// Audio Responses
|
||||||
|
getAudioResponses: (filters?: AudioResponseFilters): Promise<AudioResponse[]> => {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (filters?.course_id) query.course_id = filters.course_id;
|
||||||
|
if (filters?.lesson_id) query.lesson_id = filters.lesson_id;
|
||||||
|
if (filters?.status) query.status = filters.status;
|
||||||
|
if (filters?.user_id) query.user_id = filters.user_id;
|
||||||
|
return apiFetch('/audio-responses', { method: 'GET', query }, true);
|
||||||
|
},
|
||||||
|
getAudioResponseDetail: (id: string): Promise<AudioResponse> =>
|
||||||
|
apiFetch(`/audio-responses/${id}`, { method: 'GET' }, true),
|
||||||
|
getAudioResponseAudio: (id: string): Promise<Blob> => {
|
||||||
|
const token = getToken();
|
||||||
|
return fetch(`${LMS_API_BASE_URL}/audio-responses/${id}/audio`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
}
|
||||||
|
}).then(async res => {
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch audio');
|
||||||
|
return res.blob();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
evaluateAudioResponse: (id: string, teacherScore: number, teacherFeedback?: string): Promise<{ success: boolean; message: string }> =>
|
||||||
|
apiFetch(`/audio-responses/${id}/evaluate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ teacher_score: teacherScore, teacher_feedback: teacherFeedback })
|
||||||
|
}, true),
|
||||||
|
getCourseAudioResponseStats: (courseId: string): Promise<AudioResponseStats> =>
|
||||||
|
apiFetch(`/courses/${courseId}/audio-responses/stats`, { method: 'GET' }, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Meeting {
|
export interface Meeting {
|
||||||
@@ -1384,4 +1420,98 @@ export interface TestTemplateFilters {
|
|||||||
test_type?: TestType;
|
test_type?: TestType;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AUDIO RESPONSE INTERFACES ====================
|
||||||
|
|
||||||
|
export interface AudioResponse {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
student_name: string;
|
||||||
|
student_email: string;
|
||||||
|
course_id: string;
|
||||||
|
course_title: string;
|
||||||
|
lesson_id: string;
|
||||||
|
lesson_title: string;
|
||||||
|
block_id: string;
|
||||||
|
prompt: string;
|
||||||
|
transcript: string | null;
|
||||||
|
ai_score: number | null;
|
||||||
|
ai_found_keywords: string[] | null;
|
||||||
|
ai_feedback: string | null;
|
||||||
|
teacher_score: number | null;
|
||||||
|
teacher_feedback: string | null;
|
||||||
|
status: 'pending' | 'ai_evaluated' | 'teacher_evaluated' | 'both_evaluated';
|
||||||
|
created_at: string;
|
||||||
|
attempt_number: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioResponseStats {
|
||||||
|
organization_id: string;
|
||||||
|
course_id: string;
|
||||||
|
lesson_id: string;
|
||||||
|
total_responses: number;
|
||||||
|
ai_evaluated: number;
|
||||||
|
teacher_evaluated: number;
|
||||||
|
fully_evaluated: number;
|
||||||
|
pending: number;
|
||||||
|
avg_ai_score: number | null;
|
||||||
|
avg_teacher_score: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioResponseFilters {
|
||||||
|
course_id?: string;
|
||||||
|
lesson_id?: string;
|
||||||
|
status?: string;
|
||||||
|
user_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AUDIO RESPONSE API ====================
|
||||||
|
|
||||||
|
export async function getAudioResponses(filters?: AudioResponseFilters): Promise<AudioResponse[]> {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (filters?.course_id) query.course_id = filters.course_id;
|
||||||
|
if (filters?.lesson_id) query.lesson_id = filters.lesson_id;
|
||||||
|
if (filters?.status) query.status = filters.status;
|
||||||
|
if (filters?.user_id) query.user_id = filters.user_id;
|
||||||
|
|
||||||
|
return apiFetch('/audio-responses', { method: 'GET', query }, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAudioResponseDetail(id: string): Promise<AudioResponse> {
|
||||||
|
return apiFetch(`/audio-responses/${id}`, { method: 'GET' }, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAudioResponseAudio(id: string): Promise<Blob> {
|
||||||
|
const token = getToken();
|
||||||
|
const response = await fetch(`${LMS_API_BASE_URL}/audio-responses/${id}/audio`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch audio');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateAudioResponse(
|
||||||
|
id: string,
|
||||||
|
teacherScore: number,
|
||||||
|
teacherFeedback?: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
return apiFetch(`/audio-responses/${id}/evaluate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
teacher_score: teacherScore,
|
||||||
|
teacher_feedback: teacherFeedback
|
||||||
|
})
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCourseAudioResponseStats(courseId: string): Promise<AudioResponseStats> {
|
||||||
|
return apiFetch(`/courses/${courseId}/audio-responses/stats`, { method: 'GET' }, true);
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user