feat: Implement and display AI-powered course recommendations with a new API endpoint and frontend UI.
This commit is contained in:
Generated
+1
@@ -1479,6 +1479,7 @@ dependencies = [
|
||||
"common",
|
||||
"dotenvy",
|
||||
"jsonwebtoken",
|
||||
"reqwest 0.12.26",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -19,3 +19,4 @@ dotenvy.workspace = true
|
||||
tower-http.workspace = true
|
||||
bcrypt.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
@@ -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<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))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -275,6 +275,20 @@ pub struct AuditLog {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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<Recommendation[]>([]);
|
||||
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 } }
|
||||
</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">
|
||||
{courseData.modules.map((module, idx) => (
|
||||
<div key={module.id} className="relative">
|
||||
|
||||
@@ -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<RecommendationResponse> {
|
||||
return apiFetch(`/courses/${courseId}/recommendations`, {}, true);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user