diff --git a/Cargo.lock b/Cargo.lock index 0c57f93..64f849e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1479,6 +1479,7 @@ dependencies = [ "common", "dotenvy", "jsonwebtoken", + "reqwest 0.12.26", "serde", "serde_json", "sqlx", diff --git a/roadmap.md b/roadmap.md index 25953fb..539329b 100644 --- a/roadmap.md +++ b/roadmap.md @@ -95,7 +95,7 @@ - [x] Resúmenes de lecciones generados por IA (Implementado) - [x] Transcripción y traducción de video en tiempo real (IA Local) (Implementado) - [x] Generación automática de quices (Implementado) -- [ ] **Rutas de Aprendizaje Personalizadas**: Recomendaciones impulsadas por IA +- [x] **Rutas de Aprendizaje Personalizadas**: Recomendaciones impulsadas por IA - [x] **Gamificación Base**: (Implementada a nivel de sistema) - [x] Medallas y logros - [x] Tablas de clasificación (Leaderboards) diff --git a/services/lms-service/Cargo.toml b/services/lms-service/Cargo.toml index cc71369..2441352 100644 --- a/services/lms-service/Cargo.toml +++ b/services/lms-service/Cargo.toml @@ -19,3 +19,4 @@ dotenvy.workspace = true tower-http.workspace = true bcrypt.workspace = true jsonwebtoken.workspace = true +reqwest = { version = "0.12", features = ["json"] } diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index c7e1ba7..6c9cda7 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -8,10 +8,11 @@ use common::auth::{Claims, create_jwt}; use common::middleware::Org; use common::models::{ AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics, - Module, Notification, Organization, User, UserResponse, + Module, Notification, Organization, RecommendationResponse, User, UserResponse, }; use serde::Deserialize; use sqlx::{PgPool, Row}; +use std::env; use uuid::Uuid; pub async fn enroll_user( @@ -1037,3 +1038,105 @@ pub async fn update_user( language: user.language, })) } + +pub async fn get_recommendations( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, +) -> Result, (StatusCode, String)> { + let user_id = claims.sub; + + // 1. Fetch performance data (recent grades) + let grades: Vec = sqlx::query_as::<_, common::models::UserGrade>( + "SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2 ORDER BY created_at DESC LIMIT 10" + ) + .bind(user_id) + .bind(course_id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 2. Fetch lesson metadata (titles) for context + #[derive(sqlx::FromRow)] + struct LessonContext { + id: Uuid, + title: String, + } + + let lessons = + sqlx::query_as::<_, LessonContext>("SELECT id, title FROM lessons WHERE course_id = $1") + .bind(course_id) + .fetch_all(&pool) + .await + .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 3. Prepare AI context + let mut performance_summary = String::new(); + for grade in &grades { + let lesson_title = lessons + .iter() + .find(|l| l.id == grade.lesson_id) + .map(|l| l.title.clone()) + .unwrap_or_else(|| "Unknown Lesson".to_string()); + + performance_summary.push_str(&format!( + "- Lesson: {}, Score: {}%\n", + lesson_title, + (grade.score * 100.0) as i32 + )); + } + + if performance_summary.is_empty() { + performance_summary = "Student hasn't completed any assessments yet.".to_string(); + } + + // 4. Call Ollama + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = + env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string()); + ( + format!("{}/v1/chat/completions", base_url), + "".to_string(), + model, + ) + } else { + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()), + "gpt-4-turbo".to_string(), + ) + }; + + let response = client.post(&url) + .header("Content-Type", "application/json") + .header("Authorization", auth_header) + .json(&serde_json::json!({ + "model": model, + "messages": [ + { + "role": "system", + "content": "You are a helpful educational AI tutor. Based on the student's performance, suggest 3 highly personalized study recommendations. Focus on areas where they scored low. Return ONLY a valid JSON object starting with { \"recommendations\": [...] }. Each object MUST have: 'title', 'description', 'lesson_id' (a valid UUID or null), 'priority' ('high', 'medium', 'low'), and 'reason'. Respond in Spanish." + }, + { + "role": "user", + "content": format!("Student performance in course:\n{}", performance_summary) + } + ], + "response_format": { "type": "json_object" } + })) + .send() + .await + .map_err(|e: reqwest::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let ai_response: RecommendationResponse = response + .json() + .await + .map_err(|e: reqwest::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(ai_response)) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index ac7170e..2f31992 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -61,6 +61,10 @@ async fn main() { "/courses/{id}/analytics/advanced", get(handlers::get_advanced_analytics), ) + .route( + "/courses/{id}/recommendations", + get(handlers::get_recommendations), + ) .route( "/users/{id}/gamification", get(handlers::get_user_gamification), diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index f270fc6..b7f9f4b 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -275,6 +275,20 @@ pub struct AuditLog { pub created_at: DateTime, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Recommendation { + pub title: String, + pub description: String, + pub lesson_id: Option, + pub priority: String, // "high", "medium", "low" + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RecommendationResponse { + pub recommendations: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index 67261b5..b41c17b 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -1,19 +1,28 @@ "use client"; import { useEffect, useState } from "react"; -import { lmsApi, Course, Module } from "@/lib/api"; +import { lmsApi, Course, Module, Recommendation } from "@/lib/api"; +import { Sparkles, AlertTriangle, ArrowRight } from "lucide-react"; import Link from "next/link"; import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react"; export default function CourseOutlinePage({ params }: { params: { id: string } }) { const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null); const [loading, setLoading] = useState(true); + const [recommendations, setRecommendations] = useState([]); + const [loadingAI, setLoadingAI] = useState(false); useEffect(() => { lmsApi.getCourseOutline(params.id) .then(setCourseData) .catch(console.error) .finally(() => setLoading(false)); + + setLoadingAI(true); + lmsApi.getRecommendations(params.id) + .then(res => setRecommendations(res.recommendations)) + .catch(console.error) + .finally(() => setLoadingAI(false)); }, [params.id]); if (loading) { @@ -94,6 +103,61 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } + {/* AI Recommendations Section */} + {(loadingAI || recommendations.length > 0) && ( +
+
+
+ +
+
+

Tu Ruta de Aprendizaje IA

+

Sugerencias personalizadas basadas en tu rendimiento

+
+
+ +
+ {loadingAI ? ( +
+
+
+
+ ) : ( + recommendations.map((rec, i) => ( +
+
+
+
+
+ Prioridad {rec.priority} +
+ {rec.priority === 'high' && } +
+

{rec.title}

+

{rec.description}

+
+

¿Por qué?

+

{rec.reason}

+
+
+ {rec.lesson_id && ( + + + + )} +
+
+ )) + )} +
+
+ )} +
{courseData.modules.map((module, idx) => (
diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 16197a6..27b1b08 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -16,6 +16,17 @@ export interface Organization { primary_color?: string; secondary_color?: string; } +export interface Recommendation { + title: string; + description: string; + lesson_id: string | null; + priority: "high" | "medium" | "low"; + reason: string; +} + +export interface RecommendationResponse { + recommendations: Recommendation[]; +} export interface Course { id: string; @@ -284,5 +295,8 @@ export const lmsApi = { return apiFetch(`/notifications/${id}/read`, { method: 'POST' }); + }, + async getRecommendations(courseId: string): Promise { + return apiFetch(`/courses/${courseId}/recommendations`, {}, true); } };