feat: add PluginBlock component for rendering external web components in sandboxed iframes

feat: implement PluginsPage for managing plugins with create, toggle, and delete functionalities

feat: create PedagogicalAnalyticsPage for displaying course analytics including quality metrics, discrimination index, and curricular suggestions

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 12:22:05 -04:00
parent 675fa1e299
commit f6a3f6aedf
23 changed files with 2580 additions and 24 deletions
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS lesson_collaborative_canvases (
lesson_id UUID PRIMARY KEY REFERENCES lessons(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
canvas_state JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_lesson_collaborative_canvases_org_id
ON lesson_collaborative_canvases (organization_id);
@@ -0,0 +1,2 @@
ALTER TABLE lesson_collaborative_canvases
ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,30 @@
-- Fase 35: Ecosistema de Plugins
-- Tabla de plugins registrados por organización
CREATE TABLE IF NOT EXISTS org_plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
component_url TEXT NOT NULL,
icon_url TEXT,
config JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_org_plugins_org_id ON org_plugins(organization_id);
-- Trigger para updated_at automático
CREATE OR REPLACE FUNCTION update_org_plugins_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_org_plugins_updated_at
BEFORE UPDATE ON org_plugins
FOR EACH ROW EXECUTE FUNCTION update_org_plugins_updated_at();
+43 -7
View File
@@ -15,9 +15,12 @@ pub struct BackgroundTask {
pub id: Uuid,
pub title: String,
pub course_title: Option<String>,
pub task_type: String, // 'transcription'
pub task_type: String,
pub status: String,
pub progress: i32,
pub processed_items: i64,
pub failed_items: i64,
pub error_message: Option<String>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -25,7 +28,8 @@ pub async fn get_background_tasks(
State(pool): State<PgPool>,
) -> Result<Json<Vec<BackgroundTask>>, (StatusCode, String)> {
let query = r#"
SELECT id, title, course_title, task_type, status, progress, updated_at
SELECT id, title, course_title, task_type, status, progress,
processed_items, failed_items, error_message, updated_at
FROM (
SELECT
l.id,
@@ -34,6 +38,9 @@ pub async fn get_background_tasks(
'lesson_transcription' as task_type,
l.transcription_status as status,
0 as progress,
0::bigint as processed_items,
0::bigint as failed_items,
NULL::text as error_message,
l.updated_at
FROM lessons l
JOIN modules m ON l.module_id = m.id
@@ -49,6 +56,9 @@ pub async fn get_background_tasks(
t.task_type,
t.status,
t.progress,
t.processed_items::bigint,
t.failed_items::bigint,
t.error_message,
t.updated_at
FROM background_tasks t
WHERE t.task_type = 'zip_rag_import'
@@ -289,13 +299,39 @@ pub async fn cancel_task(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
// Only cancel transcription in lessons
let _ = sqlx::query(
"UPDATE lessons SET transcription_status = 'idle' WHERE id = $1"
// Try to cancel a transcription lesson first
let lesson_result = sqlx::query(
"UPDATE lessons SET transcription_status = 'idle' WHERE id = $1 AND transcription_status IN ('queued', 'processing')"
)
.bind(id)
.execute(&pool)
.await;
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
if lesson_result.rows_affected() > 0 {
return Ok(StatusCode::NO_CONTENT);
}
// Try to cancel a zip_rag_import background task
let task_result = sqlx::query(
r#"
UPDATE background_tasks
SET status = 'failed',
error_message = 'Cancelado manualmente',
updated_at = NOW()
WHERE id = $1
AND task_type = 'zip_rag_import'
AND status IN ('queued', 'processing')
"#
)
.bind(id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if task_result.rows_affected() > 0 {
return Ok(StatusCode::NO_CONTENT);
}
Ok(StatusCode::NOT_FOUND)
}
@@ -0,0 +1,272 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use common::middleware::Org;
use common::auth::Claims;
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use uuid::Uuid;
// ─────────────────────────────────────────────────────────────────────────────
// Tipos
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct OrgPlugin {
pub id: Uuid,
pub organization_id: Uuid,
pub name: String,
pub description: String,
pub component_url: String,
pub icon_url: Option<String>,
pub config: serde_json::Value,
pub enabled: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePluginPayload {
pub name: String,
pub description: Option<String>,
pub component_url: String,
pub icon_url: Option<String>,
pub config: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePluginPayload {
pub name: Option<String>,
pub description: Option<String>,
pub component_url: Option<String>,
pub icon_url: Option<String>,
pub config: Option<serde_json::Value>,
pub enabled: Option<bool>,
}
// ─────────────────────────────────────────────────────────────────────────────
// GET /plugins — listar plugins de la org
// ─────────────────────────────────────────────────────────────────────────────
pub async fn list_plugins(
Org(org_ctx): Org,
State(pool): State<PgPool>,
) -> Result<Json<Vec<OrgPlugin>>, (StatusCode, String)> {
let rows = sqlx::query(
"SELECT id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
FROM org_plugins
WHERE organization_id = $1
ORDER BY created_at ASC",
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let plugins = rows
.into_iter()
.map(|r| OrgPlugin {
id: r.get("id"),
organization_id: r.get("organization_id"),
name: r.get("name"),
description: r.get("description"),
component_url: r.get("component_url"),
icon_url: r.get("icon_url"),
config: r.get("config"),
enabled: r.get("enabled"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
})
.collect();
Ok(Json(plugins))
}
// ─────────────────────────────────────────────────────────────────────────────
// GET /plugins/enabled — solo plugins activos (usado por Experience)
// ─────────────────────────────────────────────────────────────────────────────
pub async fn list_enabled_plugins(
Org(org_ctx): Org,
State(pool): State<PgPool>,
) -> Result<Json<Vec<OrgPlugin>>, (StatusCode, String)> {
let rows = sqlx::query(
"SELECT id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
FROM org_plugins
WHERE organization_id = $1 AND enabled = TRUE
ORDER BY created_at ASC",
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let plugins = rows
.into_iter()
.map(|r| OrgPlugin {
id: r.get("id"),
organization_id: r.get("organization_id"),
name: r.get("name"),
description: r.get("description"),
component_url: r.get("component_url"),
icon_url: r.get("icon_url"),
config: r.get("config"),
enabled: r.get("enabled"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
})
.collect();
Ok(Json(plugins))
}
// ─────────────────────────────────────────────────────────────────────────────
// POST /plugins — crear plugin
// ─────────────────────────────────────────────────────────────────────────────
pub async fn create_plugin(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<CreatePluginPayload>,
) -> Result<(StatusCode, Json<OrgPlugin>), (StatusCode, String)> {
// Validación básica de URL (solo https permitido para componentes externos)
if !payload.component_url.starts_with("https://") {
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"component_url debe usar HTTPS".to_string(),
));
}
let row = sqlx::query(
r#"
INSERT INTO org_plugins (organization_id, name, description, component_url, icon_url, config)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
"#,
)
.bind(org_ctx.id)
.bind(&payload.name)
.bind(payload.description.as_deref().unwrap_or(""))
.bind(&payload.component_url)
.bind(&payload.icon_url)
.bind(payload.config.unwrap_or(serde_json::json!({})))
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::CREATED, Json(OrgPlugin {
id: row.get("id"),
organization_id: row.get("organization_id"),
name: row.get("name"),
description: row.get("description"),
component_url: row.get("component_url"),
icon_url: row.get("icon_url"),
config: row.get("config"),
enabled: row.get("enabled"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})))
}
// ─────────────────────────────────────────────────────────────────────────────
// PUT /plugins/{id} — actualizar plugin
// ─────────────────────────────────────────────────────────────────────────────
pub async fn update_plugin(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path(plugin_id): Path<Uuid>,
Json(payload): Json<UpdatePluginPayload>,
) -> Result<Json<OrgPlugin>, (StatusCode, String)> {
// Verificar que pertenece a esta org
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM org_plugins WHERE id = $1 AND organization_id = $2)",
)
.bind(plugin_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists {
return Err((StatusCode::NOT_FOUND, "Plugin no encontrado".to_string()));
}
// Validar URL si se actualiza
if let Some(url) = &payload.component_url {
if !url.starts_with("https://") {
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"component_url debe usar HTTPS".to_string(),
));
}
}
let row = sqlx::query(
r#"
UPDATE org_plugins SET
name = COALESCE($3, name),
description = COALESCE($4, description),
component_url = COALESCE($5, component_url),
icon_url = COALESCE($6, icon_url),
config = COALESCE($7, config),
enabled = COALESCE($8, enabled)
WHERE id = $1 AND organization_id = $2
RETURNING id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
"#,
)
.bind(plugin_id)
.bind(org_ctx.id)
.bind(&payload.name)
.bind(&payload.description)
.bind(&payload.component_url)
.bind(&payload.icon_url)
.bind(&payload.config)
.bind(payload.enabled)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(OrgPlugin {
id: row.get("id"),
organization_id: row.get("organization_id"),
name: row.get("name"),
description: row.get("description"),
component_url: row.get("component_url"),
icon_url: row.get("icon_url"),
config: row.get("config"),
enabled: row.get("enabled"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}))
}
// ─────────────────────────────────────────────────────────────────────────────
// DELETE /plugins/{id} — eliminar plugin
// ─────────────────────────────────────────────────────────────────────────────
pub async fn delete_plugin(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path(plugin_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
let result = sqlx::query(
"DELETE FROM org_plugins WHERE id = $1 AND organization_id = $2",
)
.bind(plugin_id)
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Plugin no encontrado".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
+14
View File
@@ -15,6 +15,7 @@ mod handlers_question_bank;
mod handlers_admin;
mod handlers_embeddings;
mod handlers_sam;
mod handlers_plugins;
mod webhooks;
use axum::{
@@ -548,6 +549,19 @@ async fn main() {
"/admin/users/{user_id}/token-limit/check",
get(handlers_admin::check_user_token_limit),
)
// Fase 35: Ecosistema de Plugins
.route(
"/plugins",
get(handlers_plugins::list_plugins).post(handlers_plugins::create_plugin),
)
.route(
"/plugins/enabled",
get(handlers_plugins::list_enabled_plugins),
)
.route(
"/plugins/{id}",
put(handlers_plugins::update_plugin).delete(handlers_plugins::delete_plugin),
)
.route_layer(middleware::from_fn(
common::middleware::org_extractor_middleware,
))
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS lesson_collaborative_canvases (
lesson_id UUID PRIMARY KEY REFERENCES lessons(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
canvas_state JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_lesson_collaborative_canvases_org_id
ON lesson_collaborative_canvases (organization_id);
@@ -0,0 +1,2 @@
ALTER TABLE lesson_collaborative_canvases
ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,15 @@
-- Fase 35: Ecosistema de Plugins (lms-service mirror)
CREATE TABLE IF NOT EXISTS org_plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
component_url TEXT NOT NULL,
icon_url TEXT,
config JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_org_plugins_org_id ON org_plugins(organization_id);
+272
View File
@@ -1434,6 +1434,278 @@ pub async fn get_lesson_content(
Ok(Json(lesson))
}
#[derive(Debug, Serialize)]
pub struct CollaborativeCanvasResponse {
pub lesson_id: Uuid,
pub canvas_state: serde_json::Value,
pub revision: i64,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateCollaborativeCanvasPayload {
pub canvas_state: serde_json::Value,
pub expected_revision: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct UpdateCollaborativeCanvasResponse {
pub lesson_id: Uuid,
pub revision: i64,
pub updated_at: DateTime<Utc>,
}
pub async fn get_lesson_collaborative_canvas(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<CollaborativeCanvasResponse>, StatusCode> {
let lesson_exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)",
)
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!(
"get_lesson_collaborative_canvas: failed to validate lesson {} in org {}: {}",
id,
org_ctx.id,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !lesson_exists {
return Err(StatusCode::NOT_FOUND);
}
#[derive(sqlx::FromRow)]
struct CanvasRow {
canvas_state: serde_json::Value,
revision: i64,
updated_at: DateTime<Utc>,
}
let canvas = sqlx::query_as::<_, CanvasRow>(
r#"
SELECT canvas_state, revision, updated_at
FROM lesson_collaborative_canvases
WHERE lesson_id = $1 AND organization_id = $2
"#,
)
.bind(id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!(
"get_lesson_collaborative_canvas: failed to fetch canvas for lesson {}: {}",
id,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let response = if let Some(canvas) = canvas {
CollaborativeCanvasResponse {
lesson_id: id,
canvas_state: canvas.canvas_state,
revision: canvas.revision,
updated_at: Some(canvas.updated_at),
}
} else {
CollaborativeCanvasResponse {
lesson_id: id,
canvas_state: json!({}),
revision: 0,
updated_at: None,
}
};
Ok(Json(response))
}
pub async fn update_lesson_collaborative_canvas(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateCollaborativeCanvasPayload>,
) -> Result<Json<UpdateCollaborativeCanvasResponse>, (StatusCode, String)> {
let lesson_exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)",
)
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!(
"update_lesson_collaborative_canvas: failed to validate lesson {} in org {}: {}",
id,
org_ctx.id,
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error validando lección para canvas colaborativo".to_string(),
)
})?;
if !lesson_exists {
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".to_string()));
}
if let Some(expected_revision) = payload.expected_revision {
#[derive(sqlx::FromRow)]
struct RevisionRow {
revision: i64,
updated_at: DateTime<Utc>,
}
let updated = sqlx::query_as::<_, RevisionRow>(
r#"
UPDATE lesson_collaborative_canvases
SET
canvas_state = $3,
updated_by = $4,
updated_at = NOW(),
revision = revision + 1
WHERE lesson_id = $1
AND organization_id = $2
AND revision = $5
RETURNING revision, updated_at
"#,
)
.bind(id)
.bind(org_ctx.id)
.bind(&payload.canvas_state)
.bind(claims.sub)
.bind(expected_revision)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!(
"update_lesson_collaborative_canvas: optimistic update failed for lesson {}: {}",
id,
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error actualizando canvas colaborativo".to_string(),
)
})?;
if let Some(row) = updated {
return Ok(Json(UpdateCollaborativeCanvasResponse {
lesson_id: id,
revision: row.revision,
updated_at: row.updated_at,
}));
}
if expected_revision == 0 {
let inserted = sqlx::query_as::<_, RevisionRow>(
r#"
INSERT INTO lesson_collaborative_canvases (
lesson_id,
organization_id,
canvas_state,
updated_by,
revision,
updated_at
)
VALUES ($1, $2, $3, $4, 1, NOW())
ON CONFLICT (lesson_id) DO NOTHING
RETURNING revision, updated_at
"#,
)
.bind(id)
.bind(org_ctx.id)
.bind(&payload.canvas_state)
.bind(claims.sub)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!(
"update_lesson_collaborative_canvas: insert-on-zero failed for lesson {}: {}",
id,
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error creando canvas colaborativo".to_string(),
)
})?;
if let Some(row) = inserted {
return Ok(Json(UpdateCollaborativeCanvasResponse {
lesson_id: id,
revision: row.revision,
updated_at: row.updated_at,
}));
}
}
return Err((
StatusCode::CONFLICT,
"Conflicto de edición: el canvas fue actualizado por otro usuario. Recarga y vuelve a intentar.".to_string(),
));
}
#[derive(sqlx::FromRow)]
struct RevisionRow {
revision: i64,
updated_at: DateTime<Utc>,
}
let row = sqlx::query_as::<_, RevisionRow>(
r#"
INSERT INTO lesson_collaborative_canvases (
lesson_id,
organization_id,
canvas_state,
updated_by,
revision,
updated_at
)
VALUES ($1, $2, $3, $4, 1, NOW())
ON CONFLICT (lesson_id)
DO UPDATE SET
canvas_state = EXCLUDED.canvas_state,
updated_by = EXCLUDED.updated_by,
updated_at = NOW(),
revision = lesson_collaborative_canvases.revision + 1
RETURNING revision, updated_at
"#,
)
.bind(id)
.bind(org_ctx.id)
.bind(&payload.canvas_state)
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!(
"update_lesson_collaborative_canvas: failed to upsert canvas for lesson {}: {}",
id,
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error guardando canvas colaborativo".to_string(),
)
})?;
Ok(Json(UpdateCollaborativeCanvasResponse {
lesson_id: id,
revision: row.revision,
updated_at: row.updated_at,
}))
}
pub async fn get_user_enrollments(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -0,0 +1,435 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use common::middleware::Org;
use serde::Serialize;
use sqlx::{PgPool, Row};
use uuid::Uuid;
// ─────────────────────────────────────────────────────────────────────────────
// Structs de respuesta
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct LessonQualityMetric {
pub lesson_id: Uuid,
pub lesson_title: String,
pub position: i32,
/// % de inscritos que entregaron al menos una vez
pub completion_rate: f64,
/// Promedio de intentos por alumno que entregó
pub avg_attempts: f64,
/// Promedio de puntaje (01)
pub avg_score: f64,
/// % de alumnos que reprobaron en todos sus intentos (score < 0.6)
pub failure_rate: f64,
/// Número de alumnos que nunca interactuaron con la lección
pub abandonment_count: i64,
}
#[derive(Debug, Serialize)]
pub struct CourseQualityMetrics {
pub course_id: Uuid,
pub enrolled: i64,
pub lessons: Vec<LessonQualityMetric>,
}
#[derive(Debug, Serialize)]
pub struct QuizDiscriminationItem {
pub lesson_id: Uuid,
pub lesson_title: String,
/// Texto truncado de la pregunta (block_id como proxy)
pub block_id: String,
/// Correlación punto-biserial approx: diferencia de score medio
/// entre alumnos que respondieron bien vs mal esa pregunta
pub discrimination_index: f64,
/// % de alumnos que acertaron esta pregunta
pub facility_index: f64,
pub sample_size: i64,
}
#[derive(Debug, Serialize)]
pub struct CourseDiscriminationReport {
pub course_id: Uuid,
pub items: Vec<QuizDiscriminationItem>,
}
#[derive(Debug, Serialize)]
pub struct CurricularSuggestion {
pub lesson_id: Uuid,
pub lesson_title: String,
pub kind: &'static str,
pub message: String,
pub severity: &'static str,
}
#[derive(Debug, Serialize)]
pub struct CurricularSuggestionsReport {
pub course_id: Uuid,
pub suggestions: Vec<CurricularSuggestion>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Endpoint 1: Métricas de Calidad
// GET /courses/{id}/pedagogical/quality-metrics
// ─────────────────────────────────────────────────────────────────────────────
pub async fn get_lesson_quality_metrics(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<CourseQualityMetrics>, (StatusCode, String)> {
let enrolled: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2",
)
.bind(course_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if enrolled == 0 {
return Ok(Json(CourseQualityMetrics {
course_id,
enrolled: 0,
lessons: vec![],
}));
}
let rows = sqlx::query(
r#"
SELECT
l.id AS lesson_id,
l.title AS lesson_title,
l.position,
-- Alumnos que entregaron al menos una vez
COUNT(DISTINCT g.user_id)::float8 / NULLIF($3::float8, 0) AS completion_rate,
-- Promedio de intentos entre quienes entregaron
COALESCE(AVG(g.attempts_count)::float8, 0) AS avg_attempts,
-- Puntaje medio
COALESCE(AVG(g.score)::float8, 0) AS avg_score,
-- Tasa de fallo (todos los intentos con score < 0.6)
(
SELECT COALESCE(
COUNT(DISTINCT g2.user_id)::float8 / NULLIF(COUNT(DISTINCT g.user_id)::float8, 0),
0
)
FROM user_grades g2
WHERE g2.lesson_id = l.id
AND g2.organization_id = $2
AND g2.score < 0.6
) AS failure_rate,
-- Alumnos inscritos que NUNCA enviaron esta lección
(
$3 - COUNT(DISTINCT g.user_id)
) AS abandonment_count
FROM lessons l
JOIN modules m ON l.module_id = m.id
LEFT JOIN user_grades g
ON g.lesson_id = l.id AND g.organization_id = $2
WHERE m.course_id = $1
AND l.organization_id = $2
GROUP BY l.id, l.title, l.position
ORDER BY l.position
"#,
)
.bind(course_id)
.bind(org_ctx.id)
.bind(enrolled)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let lessons = rows
.into_iter()
.map(|row| LessonQualityMetric {
lesson_id: row.get("lesson_id"),
lesson_title: row.get("lesson_title"),
position: row.get("position"),
completion_rate: row.get::<f64, _>("completion_rate"),
avg_attempts: row.get::<f64, _>("avg_attempts"),
avg_score: row.get::<f64, _>("avg_score"),
failure_rate: row.get::<f64, _>("failure_rate"),
abandonment_count: row.get::<i64, _>("abandonment_count"),
})
.collect();
Ok(Json(CourseQualityMetrics {
course_id,
enrolled,
lessons,
}))
}
// ─────────────────────────────────────────────────────────────────────────────
// Endpoint 2: Índice de Discriminación de preguntas
// GET /courses/{id}/pedagogical/discrimination-index
//
// Usa punto-biserial simplificado: compara el puntaje global del curso entre
// alumnos que acertaron cada bloque vs los que fallaron.
// La columna metadata->>'block_scores' guarda un objeto {block_id: score_0_1}.
// ─────────────────────────────────────────────────────────────────────────────
pub async fn get_quiz_discrimination_index(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<CourseDiscriminationReport>, (StatusCode, String)> {
use std::collections::HashMap;
let rows = sqlx::query(
r#"
SELECT
g.lesson_id,
l.title AS lesson_title,
bs.key AS block_id,
(bs.value::text::float8) AS block_score,
g.score AS overall_score
FROM user_grades g
JOIN lessons l ON l.id = g.lesson_id
JOIN LATERAL jsonb_each(
CASE
WHEN g.metadata ? 'block_scores'
THEN g.metadata -> 'block_scores'
ELSE '{}'::jsonb
END
) AS bs(key, value) ON TRUE
WHERE g.course_id = $1
AND g.organization_id = $2
AND (bs.value::text) ~ '^[0-9]+\.?[0-9]*$'
"#,
)
.bind(course_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if rows.is_empty() {
return Ok(Json(CourseDiscriminationReport {
course_id,
items: vec![],
}));
}
struct BlockStats {
lesson_id: Uuid,
lesson_title: String,
block_id: String,
pass_scores: Vec<f64>,
fail_scores: Vec<f64>,
pass_count: i64,
total: i64,
}
let mut map: HashMap<String, BlockStats> = HashMap::new();
for row in &rows {
let lesson_id: Uuid = row.get("lesson_id");
let lesson_title: String = row.get("lesson_title");
let block_id: String = row.get("block_id");
let block_score: f64 = row.get("block_score");
let overall_score: f64 = row.get("overall_score");
let key = format!("{}/{}", lesson_id, block_id);
let entry = map.entry(key).or_insert_with(|| BlockStats {
lesson_id,
lesson_title: lesson_title.clone(),
block_id: block_id.clone(),
pass_scores: vec![],
fail_scores: vec![],
pass_count: 0,
total: 0,
});
entry.total += 1;
if block_score >= 0.6 {
entry.pass_count += 1;
entry.pass_scores.push(overall_score);
} else {
entry.fail_scores.push(overall_score);
}
}
let mean = |v: &[f64]| -> f64 {
if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 }
};
let mut items: Vec<QuizDiscriminationItem> = map
.into_values()
.filter(|s| s.total >= 3)
.map(|s| {
let facility = s.pass_count as f64 / s.total as f64;
let discrimination = mean(&s.pass_scores) - mean(&s.fail_scores);
QuizDiscriminationItem {
lesson_id: s.lesson_id,
lesson_title: s.lesson_title,
block_id: s.block_id,
discrimination_index: (discrimination * 100.0).round() / 100.0,
facility_index: (facility * 100.0).round() / 100.0,
sample_size: s.total,
}
})
.collect();
items.sort_by(|a, b| a.discrimination_index.partial_cmp(&b.discrimination_index).unwrap_or(std::cmp::Ordering::Equal));
Ok(Json(CourseDiscriminationReport { course_id, items }))
}
// ─────────────────────────────────────────────────────────────────────────────
// Endpoint 3: Sugerencias Curriculares con reglas basadas en datos
// GET /courses/{id}/pedagogical/suggestions
// ─────────────────────────────────────────────────────────────────────────────
pub async fn get_curricular_suggestions(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<CurricularSuggestionsReport>, (StatusCode, String)> {
let enrolled: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2",
)
.bind(course_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if enrolled < 5 {
return Ok(Json(CurricularSuggestionsReport {
course_id,
suggestions: vec![],
}));
}
let rows = sqlx::query(
r#"
SELECT
l.id AS lesson_id,
l.title AS lesson_title,
l.position,
COUNT(DISTINCT g.user_id)::float8 / $3::float8 AS completion_rate,
COALESCE(AVG(g.score)::float8, 0) AS avg_score,
COALESCE(AVG(g.attempts_count)::float8, 0) AS avg_attempts,
-- Tasa de abandono
($3 - COUNT(DISTINCT g.user_id))::float8 / $3::float8 AS abandonment_rate
FROM lessons l
JOIN modules m ON l.module_id = m.id
LEFT JOIN user_grades g
ON g.lesson_id = l.id AND g.organization_id = $2
WHERE m.course_id = $1
AND l.organization_id = $2
GROUP BY l.id, l.title, l.position
ORDER BY l.position
"#,
)
.bind(course_id)
.bind(org_ctx.id)
.bind(enrolled)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut suggestions: Vec<CurricularSuggestion> = vec![];
for row in &rows {
let lesson_id: Uuid = row.get("lesson_id");
let lesson_title: String = row.get("lesson_title");
let completion_rate: f64 = row.get("completion_rate");
let avg_score: f64 = row.get("avg_score");
let avg_attempts: f64 = row.get("avg_attempts");
let abandonment_rate: f64 = row.get("abandonment_rate");
// Regla 1: Alto abandono sin completar
if abandonment_rate > 0.50 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "high_abandonment",
message: format!(
"{:.0}% de los alumnos no completan esta lección. Considera dividirla en partes más cortas o revisar si el contenido es demasiado extenso.",
abandonment_rate * 100.0
),
severity: "high",
});
}
// Regla 2: Puntaje promedio muy bajo con muchos intentos → dificultad excesiva
if avg_score < 0.45 && avg_attempts > 2.0 && completion_rate > 0.3 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "excessive_difficulty",
message: format!(
"Puntaje promedio de {:.0}% tras {:.1} intentos. La lección puede ser excesivamente difícil; revisa las preguntas o añade material de apoyo previo.",
avg_score * 100.0,
avg_attempts
),
severity: "high",
});
}
// Regla 3: Puntaje muy alto con pocos intentos → podría ser demasiado fácil
if avg_score > 0.95 && avg_attempts < 1.2 && completion_rate > 0.5 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "too_easy",
message: format!(
"Puntaje promedio de {:.0}% en el primer intento. Esta lección puede no estar generando aprendizaje real; considera aumentar la complejidad o añadir preguntas de análisis.",
avg_score * 100.0
),
severity: "info",
});
}
// Regla 4: Baja tasa de finalización en lección no final → posible bloqueo
if completion_rate < 0.30 && abandonment_rate < 0.50 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "low_completion",
message: format!(
"Solo {:.0}% de alumnos completa esta lección. Si no hay abandonos, puede haber un bloqueo técnico o de prerrequisitos. Verifica dependencias y contenido de la lección.",
completion_rate * 100.0
),
severity: "medium",
});
}
// Regla 5: Muchos reintentos con buen puntaje final → la práctica funciona, destacar
if avg_attempts > 3.0 && avg_score > 0.75 {
suggestions.push(CurricularSuggestion {
lesson_id,
lesson_title: lesson_title.clone(),
kind: "effective_practice",
message: format!(
"Alumnos usan en promedio {:.1} intentos y llegan a {:.0}%. Esta lección fomenta el aprendizaje por práctica de forma efectiva.",
avg_attempts,
avg_score * 100.0
),
severity: "positive",
});
}
}
suggestions.sort_by_key(|s| match s.severity {
"high" => 0,
"medium" => 1,
"info" => 2,
"positive" => 3,
_ => 4,
});
Ok(Json(CurricularSuggestionsReport {
course_id,
suggestions,
}))
}
+19
View File
@@ -1,6 +1,7 @@
mod db_util;
mod handlers;
mod handlers_announcements;
mod handlers_pedagogical;
mod handlers_email;
mod handlers_scorm;
mod handlers_search;
@@ -161,6 +162,11 @@ async fn main() {
.route("/courses/{id}/outline", get(handlers::get_course_outline))
.route("/courses/{id}/progress-stats", get(handlers::get_student_progress_stats))
.route("/lessons/{id}", get(handlers::get_lesson_content))
.route(
"/lessons/{id}/collaborative-canvas",
get(handlers::get_lesson_collaborative_canvas)
.put(handlers::update_lesson_collaborative_canvas),
)
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
.route("/bookmarks", get(handlers::get_user_bookmarks))
.route("/grades", post(handlers::submit_lesson_score))
@@ -258,6 +264,19 @@ async fn main() {
"/ai/data-ethics/summary",
get(handlers_data_ethics::get_data_ethics_summary),
)
// Análisis Pedagógico Profundo (Fase 34)
.route(
"/courses/{id}/pedagogical/quality-metrics",
get(handlers_pedagogical::get_lesson_quality_metrics),
)
.route(
"/courses/{id}/pedagogical/discrimination-index",
get(handlers_pedagogical::get_quiz_discrimination_index),
)
.route(
"/courses/{id}/pedagogical/suggestions",
get(handlers_pedagogical::get_curricular_suggestions),
)
// Moderación humana para FAQ basada en chats de alumnos
.route(
"/faq/review/import-candidates",