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",
|
"common",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"reqwest 0.12.26",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user