feat: Implement and display AI-powered course recommendations with a new API endpoint and frontend UI.

This commit is contained in:
2026-01-18 15:24:56 -03:00
parent 46b6253f22
commit 57594ce628
8 changed files with 204 additions and 3 deletions
Generated
+1
View File
@@ -1479,6 +1479,7 @@ dependencies = [
"common", "common",
"dotenvy", "dotenvy",
"jsonwebtoken", "jsonwebtoken",
"reqwest 0.12.26",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
+1 -1
View File
@@ -95,7 +95,7 @@
- [x] Resúmenes de lecciones generados por IA (Implementado) - [x] Resúmenes de lecciones generados por IA (Implementado)
- [x] Transcripción y traducción de video en tiempo real (IA Local) (Implementado) - [x] Transcripción y traducción de video en tiempo real (IA Local) (Implementado)
- [x] Generación automática de quices (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] **Gamificación Base**: (Implementada a nivel de sistema)
- [x] Medallas y logros - [x] Medallas y logros
- [x] Tablas de clasificación (Leaderboards) - [x] Tablas de clasificación (Leaderboards)
+1
View File
@@ -19,3 +19,4 @@ dotenvy.workspace = true
tower-http.workspace = true tower-http.workspace = true
bcrypt.workspace = true bcrypt.workspace = true
jsonwebtoken.workspace = true jsonwebtoken.workspace = true
reqwest = { version = "0.12", features = ["json"] }
+104 -1
View File
@@ -8,10 +8,11 @@ use common::auth::{Claims, create_jwt};
use common::middleware::Org; use common::middleware::Org;
use common::models::{ use common::models::{
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics, AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
Module, Notification, Organization, User, UserResponse, Module, Notification, Organization, RecommendationResponse, User, UserResponse,
}; };
use serde::Deserialize; use serde::Deserialize;
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use std::env;
use uuid::Uuid; use uuid::Uuid;
pub async fn enroll_user( pub async fn enroll_user(
@@ -1037,3 +1038,105 @@ pub async fn update_user(
language: user.language, language: user.language,
})) }))
} }
pub async fn get_recommendations(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<RecommendationResponse>, (StatusCode, String)> {
let user_id = claims.sub;
// 1. Fetch performance data (recent grades)
let grades: Vec<common::models::UserGrade> = 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))
}
+4
View File
@@ -61,6 +61,10 @@ async fn main() {
"/courses/{id}/analytics/advanced", "/courses/{id}/analytics/advanced",
get(handlers::get_advanced_analytics), get(handlers::get_advanced_analytics),
) )
.route(
"/courses/{id}/recommendations",
get(handlers::get_recommendations),
)
.route( .route(
"/users/{id}/gamification", "/users/{id}/gamification",
get(handlers::get_user_gamification), get(handlers::get_user_gamification),
+14
View File
@@ -275,6 +275,20 @@ pub struct AuditLog {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Recommendation {
pub title: String,
pub description: String,
pub lesson_id: Option<Uuid>,
pub priority: String, // "high", "medium", "low"
pub reason: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RecommendationResponse {
pub recommendations: Vec<Recommendation>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+65 -1
View File
@@ -1,19 +1,28 @@
"use client"; "use client";
import { useEffect, useState } from "react"; 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 Link from "next/link";
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react"; import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
export default function CourseOutlinePage({ params }: { params: { id: string } }) { export default function CourseOutlinePage({ params }: { params: { id: string } }) {
const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null); const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loadingAI, setLoadingAI] = useState(false);
useEffect(() => { useEffect(() => {
lmsApi.getCourseOutline(params.id) lmsApi.getCourseOutline(params.id)
.then(setCourseData) .then(setCourseData)
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
setLoadingAI(true);
lmsApi.getRecommendations(params.id)
.then(res => setRecommendations(res.recommendations))
.catch(console.error)
.finally(() => setLoadingAI(false));
}, [params.id]); }, [params.id]);
if (loading) { if (loading) {
@@ -94,6 +103,61 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div> </div>
</div> </div>
{/* AI Recommendations Section */}
{(loadingAI || recommendations.length > 0) && (
<div className="mb-20">
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl glass border-purple-500/20 bg-purple-500/10 flex items-center justify-center">
<Sparkles size={18} className="text-purple-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Tu Ruta de Aprendizaje IA</h2>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Sugerencias personalizadas basadas en tu rendimiento</p>
</div>
</div>
<div className="grid gap-4">
{loadingAI ? (
<div className="glass-card border-white/5 bg-white/5 animate-pulse p-8">
<div className="h-4 w-1/3 bg-white/10 rounded mb-4"></div>
<div className="h-3 w-2/3 bg-white/10 rounded"></div>
</div>
) : (
recommendations.map((rec, i) => (
<div key={i} className="glass-card border-white/5 hover:border-purple-500/30 transition-all p-6 group">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className={`px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest ${rec.priority === 'high' ? 'bg-red-500/10 text-red-400 border border-red-500/20' :
rec.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20' :
'bg-green-500/10 text-green-400 border border-green-500/20'
}`}>
Prioridad {rec.priority}
</div>
{rec.priority === 'high' && <AlertTriangle size={12} className="text-red-400" />}
</div>
<h3 className="text-lg font-bold text-white">{rec.title}</h3>
<p className="text-sm text-gray-400 leading-relaxed max-w-2xl">{rec.description}</p>
<div className="bg-white/5 rounded-lg p-3 inline-block">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-1 italic">¿Por qué?</p>
<p className="text-xs text-gray-300 font-medium">{rec.reason}</p>
</div>
</div>
{rec.lesson_id && (
<Link href={`/courses/${params.id}/lessons/${rec.lesson_id}`}>
<button className="whitespace-nowrap px-6 py-3 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 text-purple-400 font-bold text-[10px] uppercase tracking-widest flex items-center gap-2 group-hover:gap-4 transition-all">
Ir a la Lección <ArrowRight size={14} />
</button>
</Link>
)}
</div>
</div>
))
)}
</div>
</div>
)}
<div className="space-y-12"> <div className="space-y-12">
{courseData.modules.map((module, idx) => ( {courseData.modules.map((module, idx) => (
<div key={module.id} className="relative"> <div key={module.id} className="relative">
+14
View File
@@ -16,6 +16,17 @@ export interface Organization {
primary_color?: string; primary_color?: string;
secondary_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 { export interface Course {
id: string; id: string;
@@ -284,5 +295,8 @@ export const lmsApi = {
return apiFetch(`/notifications/${id}/read`, { return apiFetch(`/notifications/${id}/read`, {
method: 'POST' method: 'POST'
}); });
},
async getRecommendations(courseId: string): Promise<RecommendationResponse> {
return apiFetch(`/courses/${courseId}/recommendations`, {}, true);
} }
}; };