feat: Implement AI-driven lesson summaries, automate quiz generation, add gamification base, and introduce Studio organization management.

This commit is contained in:
2025-12-26 14:58:58 -03:00
parent 2378f616aa
commit e98a16d860
26 changed files with 791 additions and 82 deletions
@@ -4,7 +4,7 @@
-- 1. Create organizations table
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,2 @@
-- Add summary to lessons
ALTER TABLE lessons ADD COLUMN summary TEXT;
+154 -13
View File
@@ -292,6 +292,79 @@ pub async fn process_transcription(
Ok(Json(updated_lesson))
}
pub async fn summarize_lesson(
claims: common::auth::Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, StatusCode> {
// 1. Fetch lesson
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Simulate AI Summarization based on content
// In a real scenario, this would call an LLM with the transcription or blocks content
let mock_summary = format!(
"This lesson, titled '{}', covers the fundamental concepts of the topic. It includes interactive elements designed to reinforce learning through practice and assessment.",
lesson.title
);
// 3. Update lesson
let updated_lesson = sqlx::query_as::<_, Lesson>(
"UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *"
)
.bind(mock_summary)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
log_action(&pool, claims.sub, "SUMMARY_GENERATED", "Lesson", id, json!({})).await;
Ok(Json(updated_lesson))
}
pub async fn generate_quiz(
claims: common::auth::Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, StatusCode> {
// 1. Fetch lesson
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Simulate AI Quiz Generation
// Normally would use lesson content (transcription, blocks, etc.)
let quiz_blocks = json!([
{
"id": Uuid::new_v4().to_string(),
"type": "quiz",
"title": "Automated Content Check",
"quiz_data": {
"questions": [
{
"id": "q1",
"type": "multiple-choice",
"question": format!("Based on '{}', what is the primary objective?", lesson.title),
"options": ["Option A", "Option B", "Option C", "Option D"],
"correctAnswer": 0,
"explanation": "This question was generated automatically based on the lesson title."
}
]
}
}
]);
log_action(&pool, claims.sub, "QUIZ_GENERATED", "Lesson", id, json!({})).await;
Ok(Json(quiz_blocks))
}
pub async fn get_lesson(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -319,15 +392,6 @@ pub async fn update_lesson(
let content_url = payload.get("content_url").and_then(|t| t.as_str());
let position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32);
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool());
let grading_category_id = payload.get("grading_category_id")
.and_then(|v| {
if v.is_null() {
Some(None)
} else {
v.as_str().and_then(|s| Uuid::parse_str(s).ok()).map(Some)
}
});
let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32);
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool());
let metadata = payload.get("metadata");
@@ -342,8 +406,9 @@ pub async fn update_lesson(
grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END,
metadata = COALESCE($8, metadata),
max_attempts = COALESCE($9, max_attempts),
allow_retry = COALESCE($10, allow_retry)
WHERE id = $11 RETURNING *"
allow_retry = COALESCE($10, allow_retry),
summary = COALESCE($11, summary)
WHERE id = $12 RETURNING *"
)
.bind(title)
.bind(content_type)
@@ -355,6 +420,7 @@ pub async fn update_lesson(
.bind(metadata)
.bind(max_attempts)
.bind(allow_retry)
.bind(payload.get("summary").and_then(|v| v.as_str()))
.bind(id)
.fetch_one(&pool)
.await
@@ -606,7 +672,10 @@ pub async fn register(
.bind(&org_name)
.fetch_one(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)))?;
.map_err(|e| {
tracing::error!("Failed to create/find org: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e))
})?;
let user = sqlx::query_as::<_, User>(
"INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5) RETURNING *"
@@ -618,7 +687,10 @@ pub async fn register(
.bind(organization.id)
.fetch_one(&mut *tx)
.await
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
.map_err(|e| {
tracing::error!("Failed to create user: {}", e);
(StatusCode::CONFLICT, format!("User already exists or DB error: {}", e))
})?;
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -739,3 +811,72 @@ pub async fn get_audit_logs(
Ok(Json(logs))
}
pub async fn get_organization(
Org(org_ctx): Org,
State(pool): State<PgPool>,
) -> Result<Json<Organization>, StatusCode> {
let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(Json(org))
}
#[derive(Serialize)]
pub struct ModuleWithLessons {
#[serde(flatten)]
pub module: Module,
pub lessons: Vec<Lesson>,
}
#[derive(Serialize)]
pub struct CourseWithOutline {
#[serde(flatten)]
pub course: Course,
pub modules: Vec<ModuleWithLessons>,
}
pub async fn get_course_outline(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<CourseWithOutline>, StatusCode> {
// 1. Fetch Course
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Fetch Modules
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut modules_with_lessons = Vec::new();
// 3. Fetch Lessons (This could be optimized with a single query, but N+1 is acceptable for course editor scale)
for module in modules {
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
.bind(module.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
modules_with_lessons.push(ModuleWithLessons {
module,
lessons,
});
}
Ok(Json(CourseWithOutline {
course,
modules: modules_with_lessons,
}))
}
@@ -0,0 +1,56 @@
#[derive(Serialize)]
pub struct ModuleWithLessons {
#[serde(flatten)]
pub module: Module,
pub lessons: Vec<Lesson>,
}
#[derive(Serialize)]
pub struct CourseWithOutline {
#[serde(flatten)]
pub course: Course,
pub modules: Vec<ModuleWithLessons>,
}
pub async fn get_course_outline(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<CourseWithOutline>, StatusCode> {
// 1. Fetch Course
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Fetch Modules
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut modules_with_lessons = Vec::new();
// 3. Fetch Lessons (This could be optimized with a single query, but N+1 is acceptable for course editor scale)
for module in modules {
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position")
.bind(module.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
modules_with_lessons.push(ModuleWithLessons {
module,
lessons,
});
}
Ok(Json(CourseWithOutline {
course,
modules: modules_with_lessons,
}))
}
@@ -0,0 +1,13 @@
pub async fn get_organization(
Org(org_ctx): Org,
State(pool): State<PgPool>,
) -> Result<Json<Organization>, StatusCode> {
let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(Json(org))
}
+8 -4
View File
@@ -1,7 +1,7 @@
mod handlers;
use axum::{
routing::{get, post, delete, put},
routing::{get, post, delete},
Router,
middleware,
};
@@ -37,18 +37,22 @@ async fn main() {
// Rutas protegidas que requieren autenticación y contexto de organización
let protected_routes = Router::new()
.route("/courses", get(handlers::get_courses).post(handlers::create_course))
.route("/courses/:id", get(handlers::get_course).put(handlers::update_course))
.route("/courses/{id}", get(handlers::get_course).put(handlers::update_course))
.route("/courses/{id}/publish", post(handlers::publish_course))
.route("/courses/{id}/outline", get(handlers::get_course_outline))
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
.route("/modules", get(handlers::get_modules).post(handlers::create_module))
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
.route("/lessons/:id", get(handlers::get_lesson).put(handlers::update_lesson))
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
.route("/lessons/{id}/transcribe", post(handlers::process_transcription))
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
.route("/grading", post(handlers::create_grading_category))
.route("/grading/:id", delete(handlers::delete_grading_category))
.route("/grading/{id}", delete(handlers::delete_grading_category))
.route("/courses/{id}/grading", get(handlers::get_grading_categories))
.route("/audit-logs", get(handlers::get_audit_logs))
.route("/assets/upload", post(handlers::upload_asset))
.route("/organization", get(handlers::get_organization))
.route_layer(middleware::from_fn(common::middleware::org_extractor_middleware));
// Rutas públicas que no requieren autenticación
@@ -0,0 +1,42 @@
-- Migration: Gamification (Points and Badges)
-- Scoped to LMS service where student activity happens
-- 1. Create badges table
CREATE TABLE badges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
icon_url VARCHAR(255),
requirement_type VARCHAR(50) NOT NULL, -- 'points', 'course_completion', 'assessment_perfect'
requirement_value INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 2. User Badges (Many-to-Many)
CREATE TABLE user_badges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
badge_id UUID NOT NULL,
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, badge_id)
);
-- 3. Points Log
CREATE TABLE points_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
organization_id UUID NOT NULL,
amount INT NOT NULL,
reason VARCHAR(255) NOT NULL, -- 'lesson_completion', 'assessment_pass', 'streak'
entity_type VARCHAR(50), -- 'lesson', 'course'
entity_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 4. Initial Badges for each organization (optional, can be done via API)
INSERT INTO badges (organization_id, name, description, requirement_type, requirement_value)
VALUES
('00000000-0000-0000-0000-000000000001', 'First Steps', 'Completed your first lesson', 'points', 10),
('00000000-0000-0000-0000-000000000001', 'Quick Learner', 'Earned 100 points', 'points', 100),
('00000000-0000-0000-0000-000000000001', 'Course Master', 'Completed a full course', 'course_completion', 1);
+82
View File
@@ -405,9 +405,91 @@ pub async fn submit_lesson_score(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 4. Grant Points
let points_to_grant = 20; // Base points for any lesson
let _ = sqlx::query(
"INSERT INTO points_log (user_id, organization_id, amount, reason, entity_type, entity_id) VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(payload.user_id)
.bind(org_ctx.id)
.bind(points_to_grant)
.bind("lesson_completion")
.bind("lesson")
.bind(payload.lesson_id)
.execute(&pool)
.await;
// 5. Check for new badges (Trigger-like logic in code)
// For now, very simple: if they reached a points threshold
let total_points: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1")
.bind(payload.user_id)
.fetch_one(&pool)
.await
.unwrap_or(0);
let eligible_badges = sqlx::query(
"SELECT id FROM badges WHERE organization_id = $1 AND requirement_type = 'points' AND requirement_value <= $2 AND id NOT IN (SELECT badge_id FROM user_badges WHERE user_id = $3)"
)
.bind(org_ctx.id)
.bind(total_points as i32)
.bind(payload.user_id)
.fetch_all(&pool)
.await;
if let Ok(new_badges) = eligible_badges {
for b in new_badges {
let badge_id: Uuid = b.get("id");
let _ = sqlx::query("INSERT INTO user_badges (user_id, badge_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
.bind(payload.user_id)
.bind(badge_id)
.execute(&pool)
.await;
}
}
Ok(Json(grade))
}
#[derive(serde::Serialize)]
pub struct GamificationStatus {
pub points: i64,
pub badges: Vec<BadgeResponse>,
}
#[derive(serde::Serialize, sqlx::FromRow)]
pub struct BadgeResponse {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub icon_url: Option<String>,
pub earned_at: chrono::DateTime<chrono::Utc>,
}
pub async fn get_user_gamification(
Org(_org_ctx): Org,
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<GamificationStatus>, StatusCode> {
let points: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1")
.bind(user_id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let badges = sqlx::query_as::<_, BadgeResponse>(
"SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at
FROM user_badges ub
JOIN badges b ON ub.badge_id = b.id
WHERE ub.user_id = $1"
)
.bind(user_id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(GamificationStatus { points, badges }))
}
pub async fn get_user_course_grades(
Org(org_ctx): Org,
State(pool): State<PgPool>,
+4 -3
View File
@@ -1,7 +1,7 @@
mod handlers;
use axum::{
routing::{get, post, put},
routing::{get, post},
Router,
middleware,
};
@@ -36,12 +36,13 @@ async fn main() {
let protected_routes = Router::new()
.route("/enroll", post(handlers::enroll_user))
.route("/enrollments/:id", get(handlers::get_user_enrollments))
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
.route("/courses/{id}/outline", get(handlers::get_course_outline))
.route("/lessons/:id", get(handlers::get_lesson_content))
.route("/lessons/{id}", get(handlers::get_lesson_content))
.route("/grades", post(handlers::submit_lesson_score))
.route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades))
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
.route("/users/{id}/gamification", get(handlers::get_user_gamification))
.route_layer(middleware::from_fn(common::middleware::org_extractor_middleware));
let public_routes = Router::new()