diff --git a/.env.example b/.env.example index dc50e84..365c4a0 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,6 @@ AI_PROVIDER=local OPENAI_API_KEY= # Local AI (Ollama & Whisper) -LOCAL_WHISPER_URL=http://localhost:8000 -LOCAL_OLLAMA_URL=http://localhost:11434 -LOCAL_LLM_MODEL=llama3 +LOCAL_WHISPER_URL=http://t-800:9000 +LOCAL_OLLAMA_URL=http://t-800:11434 +LOCAL_LLM_MODEL=llama3.2:3b diff --git a/docker-compose.yml b/docker-compose.yml index 3cd31a0..21fa435 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,6 @@ services: - "host.docker.internal:host-gateway" depends_on: - db - - ollama experience: build: @@ -45,21 +44,6 @@ services: env_file: .env depends_on: - db - - ollama - - ollama: - image: ollama/ollama:latest - ports: - - "11434:11434" - volumes: - - ollama_data:/root/.ollama - #deploy: - # resources: - # reservations: - # devices: - # - driver: nvidia - # count: 1 - # capabilities: [ gpu ] e2e: build: @@ -78,4 +62,3 @@ services: volumes: postgres_data: uploads_data: - ollama_data: diff --git a/install.sh b/install.sh index b769854..1947cdd 100755 --- a/install.sh +++ b/install.sh @@ -73,20 +73,6 @@ if ! command -v sqlx &> /dev/null; then cargo install sqlx-cli --no-default-features --features postgres fi -# 3. Hardware Detection -echo "" -echo "🔍 Detecting hardware..." -HAS_NVIDIA=false -if command -v nvidia-smi &> /dev/null && nvidia-smi -L &> /dev/null; then - echo "🚀 NVIDIA GPU Detected!" - HAS_NVIDIA=true -elif command -v lspci &> /dev/null && lspci | grep -i nvidia &> /dev/null; then - echo "🚀 NVIDIA GPU Detected (lspci)!" - HAS_NVIDIA=true -else - echo "💻 No NVIDIA GPU found. Using CPU mode." -fi - # 4. Environment Configuration echo "" if [ ! -f ".env" ]; then @@ -107,28 +93,22 @@ update_env() { fi } -# Auto-configure AI variables based on hardware -if [ "$HAS_NVIDIA" = true ]; then - update_env "LOCAL_LLM_MODEL" "llama3.2:1b" - # Uncomment GPU deploy section in docker-compose.yml while preserving indentation - sed -i 's/^ #deploy:/ deploy:/' docker-compose.yml - sed -i 's/^ # resources:/ resources:/' docker-compose.yml - sed -i 's/^ # reservations:/ reservations:/' docker-compose.yml - sed -i 's/^ # devices:/ devices:/' docker-compose.yml - sed -i 's/^ # - driver: nvidia/ - driver: nvidia/' docker-compose.yml - sed -i 's/^ # count: 1/ count: 1/' docker-compose.yml - sed -i 's/^ # capabilities: \[ gpu \]/ capabilities: [ gpu ]/' docker-compose.yml -else - update_env "LOCAL_LLM_MODEL" "phi3:mini" - # Comment GPU deploy section in docker-compose.yml - sed -i 's/^ deploy:/ #deploy:/' docker-compose.yml - sed -i 's/^ resources:/ # resources:/' docker-compose.yml - sed -i 's/^ reservations:/ # reservations:/' docker-compose.yml - sed -i 's/^ devices:/ # devices:/' docker-compose.yml - sed -i 's/^ - driver: nvidia/ # - driver: nvidia/' docker-compose.yml - sed -i 's/^ count: 1/ # count: 1/' docker-compose.yml - sed -i 's/^ capabilities: \[ gpu \]/ # capabilities: [ gpu ]/' docker-compose.yml -fi +# 5. Remote AI Configuration +echo "" +echo "🔍 Configuring Remote AI Services..." +read -p "Enter Remote Ollama URL [http://t-800:11434]: " REMOTE_OLLAMA_URL +REMOTE_OLLAMA_URL=${REMOTE_OLLAMA_URL:-http://t-800:11434} +read -p "Enter Remote Whisper URL [http://t-800:9000]: " REMOTE_WHISPER_URL +REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-http://t-800:9000} +read -p "Enter Model name (on remote server) [llama3.2:3b]: " LLM_MODEL +LLM_MODEL=${LLM_MODEL:-llama3.2:3b} + +update_env "AI_PROVIDER" "local" +update_env "LOCAL_OLLAMA_URL" "$REMOTE_OLLAMA_URL" +update_env "LOCAL_WHISPER_URL" "$REMOTE_WHISPER_URL" +update_env "LOCAL_LLM_MODEL" "$LLM_MODEL" + +# AI setup is now purely remote. Skipping local container configuration. # Ask for DB credentials if not set if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' -f2) == "" ]]; then @@ -138,49 +118,36 @@ if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_cms?sslmode=disable" update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_lms?sslmode=disable" update_env "JWT_SECRET" "supersecretsecret" - update_env "AI_PROVIDER" "local" - update_env "LOCAL_OLLAMA_URL" "http://ollama:11434" update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001" update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002" fi -# 5. AI Stack Setup (Containerized) -echo "⏳ Starting Ollama container..." -docker compose up -d ollama - -echo "⏳ Waiting for Ollama to be ready..." -until docker exec openccb-ollama-1 ollama list &> /dev/null; do sleep 2; done - -echo "📥 Downloading models..." -if [ "$HAS_NVIDIA" = true ]; then - docker exec openccb-ollama-1 ollama pull llama3 -else - docker exec openccb-ollama-1 ollama pull llama3:8b -fi +# 5. AI Stack Setup (Skipped - using remote) +echo "🌐 Using remote AI services at $REMOTE_OLLAMA_URL and $REMOTE_WHISPER_URL" # 6. Database Initialization (Integrated db-mgmt.sh) echo "" read -p "Do you want a CLEAN installation? (This will DELETE all existing data) [y/N]: " CLEAN_INSTALL if [[ "$CLEAN_INSTALL" =~ ^[Yy]$ ]]; then echo "🐘 Resetting database for a clean installation..." - docker compose down -v || true + sudo docker compose down -v || true fi echo "🐘 Starting database with Docker..." -docker compose up -d db +sudo docker compose up -d db -echo "⏳ Waiting for database to be ready..." +echo "⏳ Waiting for database to be ready (container)..." RETRIES=30 -until docker exec openccb-db-1 pg_isready -U user &> /dev/null || [ $RETRIES -eq 0 ]; do +until sudo docker exec openccb-db-1 pg_isready -U user &> /dev/null || [ $RETRIES -eq 0 ]; do echo -n "." sleep 1 RETRIES=$((RETRIES-1)) done echo "" -# Reset retries for the second check and ensure we can actually execute queries -RETRIES=30 -until docker exec openccb-db-1 psql -U user -d openccb -c "SELECT 1" &> /dev/null || [ $RETRIES -eq 0 ]; do +echo "⏳ Waiting for database port (host)..." +RETRIES=10 +until curl -s localhost:5432 &> /dev/null || [ $RETRIES -eq 0 ]; do echo -n "+" sleep 1 RETRIES=$((RETRIES-1)) @@ -188,10 +155,12 @@ done echo "" if [ $RETRIES -eq 0 ]; then - echo "❌ Database failed to start in time." - exit 1 + echo "⚠️ Wait for host port timed out, but continuing..." fi +# Extra buffer for PostgreSQL initialization +sleep 2 + CMS_URL=$(grep "CMS_DATABASE_URL=" .env | cut -d'=' -f2-) LMS_URL=$(grep "LMS_DATABASE_URL=" .env | cut -d'=' -f2-) @@ -204,7 +173,7 @@ DATABASE_URL=$LMS_URL sqlx migrate run --source services/lms-service/migrations # 7. System Initialization (Integrated init-system.sh) echo "" echo "🔍 Checking for existing administrator..." -ADMIN_EXISTS=$(docker exec openccb-db-1 psql -U user -d openccb_cms -t -c "SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin');" | xargs 2>/dev/null || echo "f") +ADMIN_EXISTS=$(sudo docker exec openccb-db-1 psql -U user -d openccb_cms -t -c "SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin');" | xargs 2>/dev/null || echo "f") if [ "$ADMIN_EXISTS" != "t" ]; then echo "👤 Configure Initial Administrator" @@ -220,26 +189,30 @@ fi echo "" echo "🚀 Starting all services..." -docker compose up -d --build +sudo docker compose up -d --build if [ "$ADMIN_EXISTS" != "t" ]; then echo "⏳ Waiting for CMS API to be ready..." API_URL="http://localhost:3001" START_WAIT=$SECONDS - until curl -s "$API_URL/auth/login" &> /dev/null || [ $((SECONDS - START_WAIT)) -gt 60 ]; do sleep 2; done - - PAYLOAD=$(jq -n \ - --arg email "$ADMIN_EMAIL" \ - --arg password "$ADMIN_PASS" \ - --arg full_name "$ADMIN_NAME" \ - --arg org_name "$ORG_NAME" \ - --arg role "admin" \ - '{email: $email, password: $password, full_name: $full_name, organization_name: $org_name, role: $role}') + PAYLOAD=$(cat < Result { + let api_key = headers + .get("X-API-Key") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let org_id: Uuid = sqlx::query_scalar("SELECT id FROM organizations WHERE api_key = $1") + .bind(Uuid::parse_str(api_key).map_err(|_| StatusCode::UNAUTHORIZED)?) + .fetch_one(pool) + .await + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + Ok(org_id) +} + +pub async fn create_course_external( + State(pool): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result, StatusCode> { + let org_id = validate_api_key(&headers, &pool).await?; + + // We reuse the internal logic but with the org_id from the API key + // We need to provide a mock claims for handlers::create_course or refactor it. + // Simplifying for now: direct DB call or calling handlers with constructed context. + + let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?; + let description = payload.get("description").and_then(|d| d.as_str()); + + let course = sqlx::query_as::<_, Course>( + "INSERT INTO courses (organization_id, title, description, instructor_id, pacing_mode) + VALUES ($1, $2, $3, '00000000-0000-0000-0000-000000000001', $4) RETURNING *" + ) + .bind(org_id) + .bind(title) + .bind(description) + .bind(payload.get("pacing_mode").and_then(|p| p.as_str()).unwrap_or("self_paced")) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("External course creation failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(course)) +} + +pub async fn get_course_external( + State(pool): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, StatusCode> { + let org_id = validate_api_key(&headers, &pool).await?; + + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + Ok(Json(json!({ "course": course }))) +} + +pub async fn trigger_transcription_external( + State(pool): State, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let org_id = validate_api_key(&headers, &pool).await?; + + // Verify lesson belongs to org + let _ = sqlx::query("SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // Queue transcription + sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1") + .bind(id) + .execute(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::ACCEPTED) +} diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 5cfb25f..6c1f8fa 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -730,18 +730,91 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), .await .map_err(|e| format!("File read failed ({}): {}", file_path, e))?; - // 4. Transcription service is currently disabled in favor of Llama 3 - tracing::warn!("Transcription service is disabled for lesson {}. Using Whisper removal policy.", lesson_id); + // 4. Send to Whisper + let whisper_url = env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); + let client = reqwest::Client::new(); - sqlx::query( - "UPDATE lessons SET transcription_status = 'failed' WHERE id = $1", - ) - .bind(lesson_id) - .execute(&pool) - .await - .map_err(|e| format!("Failed to update status to failed: {}", e))?; + // We assume a standard Whisper API (like faster-whisper-server or openai-compatible) + let form = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(file_data).file_name(filename.to_string())) + .text("model", "whisper-1") + .text("response_format", "json"); - return Err("Transcription service is currently disabled. All AI features now use Llama 3 directly.".to_string()); + let response = client.post(format!("{}/v1/audio/transcriptions", whisper_url)) + .multipart(form) + .send() + .await + .map_err(|e| format!("Whisper request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let err_body = response.text().await.unwrap_or_default(); + return Err(format!("Whisper API error: {} - {}", status, err_body)); + } + + let transcription_result: serde_json::Value = response.json().await + .map_err(|e| format!("Failed to parse Whisper response: {}", e))?; + + // 5. Update lesson with transcription + sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2") + .bind(&transcription_result) + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| format!("Failed to update lesson with transcription: {}", e))?; + + // 6. Optional: Trigger Summarization using Ollama + let full_text = transcription_result["text"].as_str().unwrap_or(""); + if !full_text.is_empty() { + if let Ok(summary) = generate_summary_with_ollama(full_text).await { + let _ = sqlx::query("UPDATE lessons SET summary = $1 WHERE id = $2") + .bind(summary) + .bind(lesson_id) + .execute(&pool) + .await; + } + } + + Ok(()) +} + +async fn generate_summary_with_ollama(text: &str) -> Result { + let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); + let client = reqwest::Client::new(); + + let prompt = format!( + "Resume el siguiente texto de forma concisa y estructurada en español:\n\n{}", + text + ); + + let response = client.post(format!("{}/v1/chat/completions", base_url)) + .json(&serde_json::json!({ + "model": model, + "messages": [ + { + "role": "user", + "content": prompt + } + ], + "temperature": 0.5 + })) + .send() + .await + .map_err(|e| format!("Ollama summary request failed: {}", e))?; + + if !response.status().is_success() { + return Err("Ollama summary API error".into()); + } + + let result: serde_json::Value = response.json().await.map_err(|e| e.to_string())?; + let summary = result["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("") + .trim() + .to_string(); + + Ok(summary) } pub async fn get_lesson_vtt( diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 0cc2578..5708d02 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -3,6 +3,7 @@ pub mod exporter; mod handlers; mod handlers_branding; mod webhooks; +mod external_handlers; use axum::{ Router, @@ -172,8 +173,14 @@ async fn main() { common::middleware::org_extractor_middleware, )); + let api_routes = Router::new() + .route("/v1/courses", post(external_handlers::create_course_external)) + .route("/v1/courses/{id}", get(external_handlers::get_course_external)) + .route("/v1/lessons/{id}/transcribe", post(external_handlers::trigger_transcription_external)); + // Rutas públicas que no requieren autenticación let public_routes = Router::new() + .nest("/api/external", api_routes) .route("/auth/register", post(handlers::register)) .route("/auth/login", post(handlers::login)) .route("/auth/sso/login/{org_id}", get(handlers::sso_login_init))