feat: Introduce My Learning page with user progress and gamification, standardize LLM models on Llama 3, and optimize branding logo image loading.

This commit is contained in:
2026-01-19 13:02:11 -03:00
parent 21b2f12485
commit 1c55cc4ae7
10 changed files with 267 additions and 140 deletions
+7 -7
View File
@@ -53,13 +53,13 @@ services:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]
#deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [ gpu ]
e2e:
build:
+2 -6
View File
@@ -109,8 +109,6 @@ update_env() {
# Auto-configure AI variables based on hardware
if [ "$HAS_NVIDIA" = true ]; then
update_env "WHISPER_IMAGE" "fedirz/faster-whisper-server:latest-cuda"
update_env "WHISPER_DEVICE" "cuda"
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
@@ -121,8 +119,6 @@ if [ "$HAS_NVIDIA" = true ]; then
sed -i 's/^ # count: 1/ count: 1/' docker-compose.yml
sed -i 's/^ # capabilities: \[ gpu \]/ capabilities: [ gpu ]/' docker-compose.yml
else
update_env "WHISPER_IMAGE" "fedirz/faster-whisper-server:latest-cpu"
update_env "WHISPER_DEVICE" "cpu"
update_env "LOCAL_LLM_MODEL" "phi3:mini"
# Comment GPU deploy section in docker-compose.yml
sed -i 's/^ deploy:/ #deploy:/' docker-compose.yml
@@ -157,9 +153,9 @@ 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:8b
docker exec openccb-ollama-1 ollama pull llama3
else
docker exec openccb-ollama-1 ollama pull phi3:mini
docker exec openccb-ollama-1 ollama pull llama3:8b
fi
# 6. Database Initialization (Integrated db-mgmt.sh)
+4 -4
View File
@@ -92,10 +92,10 @@
- [x] Métricas de retención (Implementado)
- [x] Mapas de calor de participación (Heatmaps) (Implementado)
- [x] **Integración de IA**:
- [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)
- [x] **Rutas de Aprendizaje Personalizadas**: Recomendaciones impulsadas por IA
- [x] Resúmenes de lecciones generados por IA (Llama 3)
- [x] Generación automática de quices (Llama 3)
- [ ] Transcripción y traducción de video en tiempo real (Postpuesto - Reemplazado por Llama 3 para otras funciones)
- [x] **Rutas de Aprendizaje Personalizadas**: Recomendaciones impulsadas por Llama 3 (Implementado)
- [x] **Gamificación Base**: (Implementada a nivel de sistema)
- [x] Medallas y logros
- [x] Tablas de clasificación (Leaderboards)
+5 -101
View File
@@ -730,114 +730,18 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(),
.await
.map_err(|e| format!("File read failed ({}): {}", file_path, e))?;
// 4. Configuration
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let client = reqwest::Client::new();
// 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);
let (url, auth_header, model) = if provider == "local" {
let base_url =
env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
(
format!("{}/v1/audio/transcriptions", base_url),
"".to_string(),
"medium".to_string(),
)
} else {
let api_key = env::var("OPENAI_API_KEY").map_err(|_| "Missing OPENAI_API_KEY")?;
(
"https://api.openai.com/v1/audio/transcriptions".to_string(),
format!("Bearer {}", api_key),
"whisper-1".to_string(),
)
};
let part = reqwest::multipart::Part::bytes(file_data)
.file_name(filename.to_string())
.mime_str("application/octet-stream")
.map_err(|e| format!("Multipart part creation failed: {}", e))?;
let form = reqwest::multipart::Form::new()
.part("file", part)
.text("model", model)
.text("response_format", "verbose_json");
let mut request = client.post(&url).multipart(form);
if !auth_header.is_empty() {
request = request.header("Authorization", auth_header);
}
let response = request
.send()
.await
.map_err(|e| format!("Transcription request failed: {}", e))?;
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
return Err(format!("Transcription API error: {}", err_body));
}
let whisper_data: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Whisper JSON parse failed: {}", e))?;
// Extract text and segments (cues)
let text = whisper_data["text"].as_str().unwrap_or_default();
let segments = whisper_data["segments"].as_array();
let mut cues = Vec::new();
if let Some(segments) = segments {
for s in segments {
cues.push(json!({
"start": s["start"],
"end": s["end"],
"text": s["text"]
}));
}
}
let transcription = json!({
"en": text,
"es": "",
"cues": cues
});
// 5. Update initial transcription
sqlx::query(
"UPDATE lessons SET transcription = $1, transcription_status = 'processing' WHERE id = $2",
"UPDATE lessons SET transcription_status = 'failed' WHERE id = $1",
)
.bind(&transcription)
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| format!("Initial database update failed: {}", e))?;
.map_err(|e| format!("Failed to update status to failed: {}", e))?;
// 6. Translation (Optional/Background within the task)
let es_text = match translate_text(text, "es").await {
Ok(t) => t,
Err(e) => {
tracing::error!("Translation failed for lesson {}: {}", lesson_id, e);
"".to_string()
}
};
let final_transcription = json!({
"en": text,
"es": es_text,
"cues": cues
});
// 7. Final Update
sqlx::query(
"UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2",
)
.bind(final_transcription)
.bind(lesson_id)
.execute(&pool)
.await
.map_err(|e| format!("Final database update failed: {}", e))?;
Ok(())
return Err("Transcription service is currently disabled. All AI features now use Llama 3 directly.".to_string());
}
pub async fn get_lesson_vtt(
-12
View File
@@ -568,7 +568,6 @@
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -631,7 +630,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -1105,7 +1103,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1516,7 +1513,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2215,7 +2211,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -2378,7 +2373,6 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3803,7 +3797,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -5145,7 +5138,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5346,7 +5338,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -5358,7 +5349,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6301,7 +6291,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6479,7 +6468,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useEffect, useState } from "react";
import { lmsApi, Course, Module } from "@/lib/api";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
import { BookOpen, TrendingUp, Clock, CheckCircle2, Award, Target } from "lucide-react";
interface CourseWithModules extends Course {
modules?: Module[];
}
interface EnrollmentWithProgress {
course: CourseWithModules;
progress: number;
lastAccessed?: string;
}
export default function MyLearningPage() {
const [enrollments, setEnrollments] = useState<EnrollmentWithProgress[]>([]);
const [loading, setLoading] = useState(true);
const [gamification, setGamification] = useState<{ points: number, level: number, badges: any[] } | null>(null);
const { user } = useAuth();
useEffect(() => {
const fetchData = async () => {
if (!user) {
setLoading(false);
return;
}
try {
const enrollmentData = await lmsApi.getEnrollments(user.id);
const gamificationData = await lmsApi.getGamification(user.id);
setGamification(gamificationData);
// Fetch course details for each enrollment
const enrichedEnrollments: EnrollmentWithProgress[] = [];
for (const enrollment of enrollmentData) {
try {
const outline = await lmsApi.getCourseOutline(enrollment.course_id);
// TODO: Implement actual progress tracking
// For now, show 0% progress for all courses
const progress = 0;
enrichedEnrollments.push({
course: outline,
progress,
lastAccessed: enrollment.enroled_at
});
} catch (err) {
console.error(`Error loading course ${enrollment.course_id}`, err);
}
}
setEnrollments(enrichedEnrollments);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
fetchData();
}, [user]);
if (loading) {
return (
<div className="max-w-7xl mx-auto px-6 py-20">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{[1, 2, 3].map(i => (
<div key={i} className="h-80 glass-card animate-pulse bg-white/5 border-white/5 rounded-3xl"></div>
))}
</div>
</div>
);
}
if (!user) {
return (
<div className="max-w-7xl mx-auto px-6 py-20 text-center">
<h1 className="text-4xl font-black mb-4">Inicia Sesión</h1>
<p className="text-gray-500 mb-8">Debes iniciar sesión para ver tus cursos.</p>
<Link href="/auth/login" className="btn-primary">
Ir a Login
</Link>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-6 py-20">
{/* Header */}
<div className="mb-12 flex flex-col md:flex-row md:items-end justify-between gap-8">
<div className="space-y-4">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-indigo-500">
<BookOpen size={14} />
<span>Mi Aprendizaje</span>
</div>
<h1 className="text-6xl font-black tracking-tighter leading-none">
Mis <span className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-purple-600">Cursos</span>
</h1>
<p className="text-gray-500 font-medium max-w-xl text-lg">
Continúa tu viaje de aprendizaje donde lo dejaste.
</p>
</div>
{/* Gamification Stats */}
{gamification && (
<div className="flex items-center gap-4">
<div className="glass-card p-4 border-indigo-500/20 bg-indigo-500/5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-black text-lg">
{gamification.level}
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Nivel</p>
<p className="text-sm font-bold text-white">{gamification.points} XP</p>
</div>
</div>
</div>
{gamification.badges.length > 0 && (
<div className="glass-card p-4 border-amber-500/20 bg-amber-500/5">
<div className="flex items-center gap-3">
<Award className="w-8 h-8 text-amber-400" />
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Medallas</p>
<p className="text-sm font-bold text-white">{gamification.badges.length}</p>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Enrolled Courses */}
{enrollments.length === 0 ? (
<div className="glass-card p-12 text-center border-dashed">
<div className="w-20 h-20 rounded-full bg-indigo-500/10 flex items-center justify-center mx-auto mb-6">
<Target className="w-10 h-10 text-indigo-400" />
</div>
<h3 className="text-2xl font-black mb-3">No estás inscrito en ningún curso</h3>
<p className="text-gray-500 mb-8 max-w-md mx-auto">
Explora nuestro catálogo y comienza tu viaje de aprendizaje hoy.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl font-bold transition-all"
>
<BookOpen size={16} />
Explorar Catálogo
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{enrollments.map(({ course, progress }) => (
<Link
key={course.id}
href={`/courses/${course.id}`}
className="glass-card group hover:border-indigo-500/50 transition-all duration-300 overflow-hidden"
>
<div className="p-6 space-y-4">
{/* Progress Ring */}
<div className="flex items-center justify-between">
<div className="relative w-16 h-16">
<svg className="w-16 h-16 transform -rotate-90">
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="none"
className="text-white/10"
/>
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="none"
strokeDasharray={`${2 * Math.PI * 28}`}
strokeDashoffset={`${2 * Math.PI * 28 * (1 - progress / 100)}`}
className="text-indigo-500 transition-all duration-500"
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-black text-white">{Math.round(progress)}%</span>
</div>
</div>
{progress === 100 && (
<CheckCircle2 className="w-8 h-8 text-green-400" />
)}
</div>
{/* Course Info */}
<div>
<h3 className="text-xl font-black tracking-tight mb-2 group-hover:text-indigo-400 transition-colors">
{course?.title || 'Curso sin título'}
</h3>
<p className="text-sm text-gray-500 line-clamp-2">
{course?.description || "Continúa aprendiendo..."}
</p>
</div>
{/* Stats */}
<div className="flex items-center gap-4 pt-4 border-t border-white/5">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Clock size={12} />
<span>{course.modules?.length || 0} módulos</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<TrendingUp size={12} />
<span>{progress < 100 ? 'En progreso' : 'Completado'}</span>
</div>
</div>
{/* Continue Button */}
<button className="w-full py-2.5 bg-white/5 hover:bg-indigo-600 border border-white/10 hover:border-indigo-500 rounded-xl font-bold text-sm transition-all group-hover:translate-x-1">
{progress === 0 ? 'Comenzar' : progress === 100 ? 'Revisar' : 'Continuar'}
</button>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+6 -3
View File
@@ -1,5 +1,6 @@
"use client";
import Image from "next/image";
import { useState, useEffect, useRef, useCallback } from "react";
import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "@/context/I18nContext";
@@ -124,10 +125,12 @@ export default function ProfilePage() {
<div className="relative mb-6">
<div className="w-32 h-32 rounded-full bg-blue-600/20 border-4 border-white/5 flex items-center justify-center overflow-hidden shadow-2xl relative">
{avatarUrl ? (
<img
<Image
src={getImageUrl(avatarUrl)}
alt={fullName}
className="w-full h-full object-cover"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 128px"
/>
) : (
<span className="text-5xl font-black text-blue-400">
@@ -196,7 +199,7 @@ export default function ProfilePage() {
<div key={badge.id} className="group relative">
<div className="w-full aspect-square rounded-xl bg-white/5 border border-white/5 flex items-center justify-center hover:bg-white/10 transition-colors">
{badge.icon_url ? (
<img src={badge.icon_url} alt={badge.name} className="w-8 h-8 opacity-60 grayscale group-hover:grayscale-0 group-hover:opacity-100 transition-all" />
<Image src={badge.icon_url} alt={badge.name} width={32} height={32} className="opacity-60 grayscale group-hover:grayscale-0 group-hover:opacity-100 transition-all" />
) : (
<Award size={24} className="text-gray-600 group-hover:text-yellow-500 transition-colors" />
)}
+2 -1
View File
@@ -1,6 +1,7 @@
'use client';
import Link from "next/link";
import Image from "next/image";
import { useBranding } from "@/context/BrandingContext";
import { useAuth } from "@/context/AuthContext";
import { useTranslation } from "@/context/I18nContext";
@@ -17,7 +18,7 @@ export default function AppHeader() {
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
{branding?.logo_url ? (
<img src={branding.logo_url} alt={branding.name} className="w-full h-full object-contain" />
<Image src={branding.logo_url} alt={branding.name} fill className="object-contain" sizes="40px" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500 to-blue-700">L</div>
)}
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useRef } from "react";
import Image from "next/image";
import { Search, CheckCircle, XCircle, MousePointer2 } from "lucide-react";
interface Hotspot {
@@ -88,10 +89,12 @@ export default function HotspotPlayer({
onClick={handleClick}
className="relative aspect-video rounded-3xl overflow-hidden border-4 border-white/10 bg-black cursor-crosshair group select-none shadow-2xl"
>
<img
<Image
src={imageUrl}
alt={title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
fill
className="object-cover transition-transform duration-700 group-hover:scale-[1.02]"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
{/* Overlay found hotspots */}
@@ -144,8 +147,8 @@ export default function HotspotPlayer({
<div
key={h.id}
className={`px-4 py-2 rounded-xl text-xs font-black tracking-widest uppercase transition-all flex items-center gap-2 ${found.includes(h.id)
? "bg-green-500 text-white translate-y-[-2px] shadow-lg shadow-green-500/20"
: "bg-white/5 text-gray-500 border border-white/5"
? "bg-green-500 text-white translate-y-[-2px] shadow-lg shadow-green-500/20"
: "bg-white/5 text-gray-500 border border-white/5"
}`}
>
{found.includes(h.id) ? <CheckCircle size={14} /> : <div className="w-1 h-1 rounded-full bg-current" />}
+1 -1
View File
@@ -297,6 +297,6 @@ export const lmsApi = {
});
},
async getRecommendations(courseId: string): Promise<RecommendationResponse> {
return apiFetch(`/courses/${courseId}/recommendations`, {}, true);
return apiFetch(`/courses/${courseId}/recommendations`);
}
};