Remove AI image generation functionality from CMS and expand Docker ignore rules.

This commit is contained in:
2026-03-09 10:06:26 -03:00
parent bf3f06d831
commit 7406de9a1b
11 changed files with 146 additions and 778 deletions
+43 -15
View File
@@ -1,17 +1,45 @@
target # Build Artifacts
**/target target/
node_modules **/target/
**/node_modules node_modules/
.next **/node_modules/
**/.next .next/
.git **/ .next/
**/.git dist/
**/dist/
# Virtual Environments
venv/
**/venv/
.venv/
**/.venv/
env/
**/env/
__pycache__/
**/__pycache__/
# Environments and Secrets
.env .env
**/.env **/.env
**/.env.local .env.local
**/.env.example .env.*.local
*.log
e2e # Git and OS
docs .git/
artifacts .gitignore
.gemini .dockerignore
**/.DS_Store
Thumbs.db
# Storage and Data
uploads/
**/uploads/
storage/
volumes/
postgres_data/
# Huge binary files/libraries
*.so
*.dll
*.dylib
*.exe
+2 -5
View File
@@ -27,9 +27,8 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
- **Custom AI Quizzes**: Generación de quices con contexto pedagógico y tipo de pregunta personalizable (opción múltiple, V/F, etc.). - **Custom AI Quizzes**: Generación de quices con contexto pedagógico y tipo de pregunta personalizable (opción múltiple, V/F, etc.).
- **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada. - **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada.
- **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots). - **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots).
- **AI Image Generation**: Generación automática de contenido visual a partir de guiones de lecciones o prompts personalizados, con soporte para resoluciones personalizadas (HD/4K) y gestión de portadas de curso de alta calidad.
- **Course Marketing & Summary**: Sistema de metadatos estructurados (objetivos, requisitos) y landing pages premium para una mejor presentación de cursos. - **Course Marketing & Summary**: Sistema de metadatos estructurados (objetivos, requisitos) y landing pages premium para una mejor presentación de cursos.
- **Global AI Task Dashboard**: Panel unificado de control en consola administrativa para monitorear, reintentar y cancelar tareas de IA en segundo plano (transcripciones, imágenes, etc). - **Global AI Task Dashboard**: Panel unificado de control en consola administrativa para monitorear, reintentar y cancelar tareas de IA en segundo plano (transcripciones, quices, etc).
- **Dynamic API Resolution**: Resolución inteligente de endpoints que permite el acceso desde cualquier dispositivo en la red local (WiFi) sin configuración manual. - **Dynamic API Resolution**: Resolución inteligente de endpoints que permite el acceso desde cualquier dispositivo en la red local (WiFi) sin configuración manual.
- **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes. - **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes.
- **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso. - **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso.
@@ -80,7 +79,6 @@ OpenCCB es altamente escalable. A continuación se detallan los requisitos recom
- **IA Local**: - **IA Local**:
- **Faster-Whisper**: Transcripción de audio a texto. - **Faster-Whisper**: Transcripción de audio a texto.
- **Ollama**: Traducción inteligente (EN -> ES), resúmenes y generación de cuestionarios. - **Ollama**: Traducción inteligente (EN -> ES), resúmenes y generación de cuestionarios.
- **Stable Diffusion**: Motor de generación de imágenes optimizado para CPU.
- **i18n Infrastructure**: Sistema de traducción reactivo para soporte global. - **i18n Infrastructure**: Sistema de traducción reactivo para soporte global.
- **Document Management**: Motor de previsualización de documentos PDF nativo. - **Document Management**: Motor de previsualización de documentos PDF nativo.
@@ -646,8 +644,7 @@ Obtiene una lista de todas las organizaciones registradas.
- **Course Teams UI**: Panel de gestión para añadir y configurar roles de instructores secundarios y asistentes. - **Course Teams UI**: Panel de gestión para añadir y configurar roles de instructores secundarios y asistentes.
- **Course Preview Badges**: Indicadores visuales y lógica de acceso para lecciones accesibles sin suscripción. - **Course Preview Badges**: Indicadores visuales y lógica de acceso para lecciones accesibles sin suscripción.
- **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo. - **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo.
- **Premium Course Summaries**: Presentación de cursos con diseño de alta fidelidad, integración de imágenes generadas por IA y desgloses de objetivos de aprendizaje. - **Premium Course Summaries**: Presentación de cursos con diseño de alta fidelidad y desgloses de objetivos de aprendizaje.
- **AI Image Resolution Selector**: Control granular sobre las dimensiones de salida para activos visuales generados por IA (720p, 1080p, 4K).
## Próximos Pasos (Roadmap 2024-2025) ## Próximos Pasos (Roadmap 2024-2025)
+2 -4
View File
@@ -219,15 +219,13 @@
- [x] Visualización de progreso y nivel de XP. - [x] Visualización de progreso y nivel de XP.
## Fase 19: Presentación Visual y Marketing de Cursos ✅ ## Fase 19: Presentación Visual y Marketing de Cursos ✅
- [x] **Generación de Imágenes de Alta Resolución**: Soporte para dimensiones personalizadas (HD/4K) para lecciones y cursos via AI Bridge. (Completado)
- [x] **Metadatos de Marketing Estructurados**: Captura de objetivos, requisitos, público objetivo y certificación en Studio. (Completado) - [x] **Metadatos de Marketing Estructurados**: Captura de objetivos, requisitos, público objetivo y certificación en Studio. (Completado)
- [x] **Premium Course Summary**: Nueva interfaz de "Acerca del Curso" en Experience con diseño de alta fidelidad y navegación por pestañas. (Completado) - [x] **Premium Course Summary**: Nueva interfaz de "Acerca del Curso" en Experience con diseño de alta fidelidad y navegación por pestañas. (Completado)
- [x] **Gestión de Portadas de Curso**: Integración de imágenes generadas por IA como activos principales del curso con previsualización dinámica. (Completado) - [x] **Dashboard Global de Tareas AI**: Panel unificado de control en consola administrativa para monitorear, reintentar y cancelar todas las generaciones en segundo plano (videos, transcripciones, quices, etc). (Completado)
- [x] **Dashboard Global de Tareas AI**: Panel centralizado para monitorizar, reintentar y cancelar todas las generaciones en segundo plano (imágenes, videos, transcripciones). (Completado)
--- ---
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos**, **Generación de Imágenes HD/4K** y **Landing Pages de Cursos (Marketing) automatizadas**. **Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos** y **Landing Pages de Cursos (Marketing) automatizadas**.
**Próximas Prioridades**: **Próximas Prioridades**:
1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1. 1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
+48 -397
View File
@@ -1309,381 +1309,6 @@ pub async fn generate_quiz(
Ok(Json(quiz_blocks)) Ok(Json(quiz_blocks))
} }
#[derive(Deserialize)]
pub struct VideoAIRequest {
pub prompt: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
}
pub async fn generate_image(
Org(org_ctx): Org,
claims: common::auth::Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<VideoAIRequest>,
) -> Result<Json<Lesson>, StatusCode> {
if let Some(prompt) = &payload.prompt {
tracing::info!("Received prompt for video generation: {}", prompt);
}
// 1. Fetch lesson
let _lesson =
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Set status to queued
let updated_lesson = sqlx::query_as::<_, Lesson>(
"UPDATE lessons SET video_generation_status = 'queued', video_generation_error = NULL WHERE id = $1 RETURNING *",
)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Database update failed (video queued): {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
log_action(
&pool,
org_ctx.id,
claims.sub,
"VIDEO_GENERATION_QUEUED",
"Lesson",
id,
json!({ "status": "queued" }),
)
.await;
// 3. Spawn background task
let pool_clone = pool.clone();
let prompt_to_task = payload.prompt.clone();
let user_id = claims.sub;
let width = payload.width;
let height = payload.height;
tokio::spawn(async move {
if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task, Some(user_id), false, width, height).await {
tracing::error!("Image generation task failed for lesson {}: {}", id, e);
}
});
Ok(Json(updated_lesson))
}
pub async fn generate_course_image(
Org(org_ctx): Org,
claims: common::auth::Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<VideoAIRequest>,
) -> Result<Json<Course>, 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. Set status to queued
let updated_course = sqlx::query_as::<_, Course>(
"UPDATE courses SET generation_status = 'queued', generation_error = NULL WHERE id = $1 RETURNING *",
)
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Database update failed (course image queued): {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
log_action(
&pool,
org_ctx.id,
claims.sub,
"COURSE_IMAGE_GENERATION_QUEUED",
"Course",
id,
json!({ "status": "queued" }),
)
.await;
// 3. Spawn background task
let pool_clone = pool.clone();
let prompt_to_task = payload.prompt.clone();
let user_id = claims.sub;
let width = payload.width;
let height = payload.height;
tokio::spawn(async move {
if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task, Some(user_id), true, width, height).await {
tracing::error!("Image generation task failed for course {}: {}", id, e);
}
});
Ok(Json(updated_course))
}
pub async fn run_image_generation_task(
pool: PgPool,
id: Uuid,
custom_prompt: Option<String>,
user_id: Option<Uuid>,
is_course: bool,
width: Option<u32>,
height: Option<u32>
) -> Result<(), String> {
let (status_col, progress_col, error_col, table_name) = if is_course {
("generation_status", "generation_progress", "generation_error", "courses")
} else {
("video_generation_status", "generation_progress", "video_generation_error", "lessons")
};
// 1. Set status to processing ONLY if it's still queued (not cancelled/idle)
let query = format!(
"UPDATE {} SET {} = 'processing' WHERE id = $1 AND {} = 'queued'",
table_name, status_col, status_col
);
let rows_affected = sqlx::query(&query)
.bind(id)
.execute(&pool)
.await
.map_err(|e| format!("Update to processing failed: {}", e))?
.rows_affected();
if rows_affected == 0 {
tracing::info!("Task {} was cancelled or is already processing. Aborting background thread.", id);
return Ok(());
}
// 2. Call Local Video Bridge (Python) with Retry Logic
let client = reqwest::Client::new();
let bridge_base_url = std::env::var("LOCAL_VIDEO_BRIDGE_URL")
.unwrap_or_else(|_| "http://t-800:8080".to_string());
let bridge_url = format!("{}/generate", bridge_base_url);
// Fallback logic for prompt: Custom Prompt > Title
let final_prompt = match custom_prompt {
Some(p) if !p.is_empty() => p,
_ => {
let title: String = sqlx::query_scalar(&format!("SELECT title FROM {} WHERE id = $1", table_name))
.bind(id)
.fetch_one(&pool)
.await
.map_err(|e| format!("Failed to fetch fallback prompt: {}", e))?;
title
}
};
let database_url = std::env::var("BRIDGE_DATABASE_URL")
.unwrap_or_else(|_| std::env::var("DATABASE_URL").unwrap_or_default());
let mut payload = serde_json::json!({
"prompt": final_prompt,
"lesson_id": id.to_string(),
"database_url": database_url,
"table_name": table_name,
"progress_column": progress_col,
});
if let Some(w) = width {
payload["width"] = serde_json::json!(w);
}
if let Some(h) = height {
payload["height"] = serde_json::json!(h);
}
let mut retry_count = 0;
let max_retries = 3; // Initial quick retries
let long_retry_delay = tokio::time::Duration::from_secs(600); // 10 minutes
loop {
let response_result = client.post(&bridge_url)
.json(&payload)
.send()
.await;
match response_result {
Ok(response) => {
if response.status().is_success() {
let result: serde_json::Value = response.json().await
.map_err(|e| format!("Failed to parse video bridge response: {}", e))?;
let bridge_content_url = result["url"].as_str()
.ok_or_else(|| "Video bridge response missing URL".to_string())?;
// Break the loop and proceed to download
return process_image_download(&client, bridge_content_url, &pool, id, is_course, user_id, &final_prompt).await;
} else {
let err_text = response.text().await.unwrap_or_default();
let _ = sqlx::query(&format!("UPDATE {} SET {} = $1, {} = 'error' WHERE id = $2", table_name, error_col, status_col))
.bind(&err_text)
.bind(id)
.execute(&pool)
.await;
return Err(format!("Video bridge error: {}", err_text));
}
}
Err(e) => {
tracing::warn!("Failed to reach AI bridge at {}: {}. Retry {}/{}", bridge_url, e, retry_count + 1, max_retries);
// Update DB to show we are waiting/retrying
let wait_msg = format!("El servidor de IA (t-800) no responde. Reintentando automáticamente en 10 min... (Error: {})", e);
let _ = sqlx::query(&format!("UPDATE {} SET {} = $1, {} = 'queued' WHERE id = $2", table_name, error_col, status_col))
.bind(&wait_msg)
.bind(id)
.execute(&pool)
.await;
// Check if task was cancelled while we were about to sleep
let current_status: String = sqlx::query_scalar(&format!("SELECT {} FROM {} WHERE id = $1", status_col, table_name))
.bind(id)
.fetch_one(&pool)
.await
.unwrap_or_else(|_| "error".to_string());
if current_status == "idle" || current_status == "error" {
tracing::info!("Task {} was cancelled or errored during retry. Aborting.", id);
return Ok(());
}
retry_count += 1;
// Wait 10 minutes before next attempt
tokio::time::sleep(long_retry_delay).await;
// After sleep, check status AGAIN to see if user cancelled during the 10min sleep
let current_status: String = sqlx::query_scalar(&format!("SELECT {} FROM {} WHERE id = $1", status_col, table_name))
.bind(id)
.fetch_one(&pool)
.await
.unwrap_or_else(|_| "error".to_string());
if current_status != "queued" {
tracing::info!("Task {} status changed to {} during sleep. Aborting retry.", id, current_status);
return Ok(());
}
// Set back to processing to "lock" it again for this attempt
let rows_affected = sqlx::query(&format!("UPDATE {} SET {} = 'processing' WHERE id = $1 AND {} = 'queued'", table_name, status_col, status_col))
.bind(id)
.execute(&pool)
.await
.map_err(|e| e.to_string())?
.rows_affected();
if rows_affected == 0 {
return Ok(());
}
}
}
}
}
async fn process_image_download(
client: &reqwest::Client,
bridge_content_url: &str,
pool: &PgPool,
id: Uuid,
is_course: bool,
user_id: Option<Uuid>,
final_prompt: &str
) -> Result<(), String> {
// --- Download image and store as Asset ---
// 1. Download from bridge
let image_bytes = client.get(bridge_content_url)
.send()
.await
.map_err(|e| format!("Failed to download generated image: {}", e))?
.bytes()
.await
.map_err(|e| format!("Failed to read image bytes: {}", e))?;
// 2. Fetch context
let (org_id, course_id, instructor_id): (Uuid, Uuid, Uuid) = if is_course {
let c = sqlx::query_as::<sqlx::Postgres, (Uuid, Uuid, Uuid)>(
"SELECT organization_id, id as course_id, instructor_id FROM courses WHERE id = $1"
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| format!("Failed to fetch course context: {}", e))?;
c
} else {
sqlx::query_as::<sqlx::Postgres, (Uuid, Uuid, Uuid)>(
"SELECT l.organization_id, m.course_id, c.instructor_id
FROM lessons l
JOIN modules m ON l.module_id = m.id
JOIN courses c ON m.course_id = c.id
WHERE l.id = $1"
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| format!("Failed to fetch lesson context: {}", e))?
};
let final_user_id = user_id.unwrap_or(instructor_id);
// 3. Save file locally
let asset_id = Uuid::new_v4();
let storage_filename = format!("{}.png", asset_id);
let storage_path = format!("uploads/{}", storage_filename);
let _ = tokio::fs::create_dir_all("uploads").await;
tokio::fs::write(&storage_path, &image_bytes).await.map_err(|e| e.to_string())?;
let size_bytes = image_bytes.len() as i64;
let local_url = format!("/assets/{}", storage_filename);
// 4. Register in assets table
sqlx::query(
r#"
INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
)
.bind(asset_id)
.bind(org_id)
.bind(final_user_id)
.bind(course_id)
.bind(format!("AI: {}", final_prompt))
.bind(&storage_path)
.bind("image/png")
.bind(size_bytes)
.execute(pool)
.await
.map_err(|e| format!("Failed to register asset: {}", e))?;
// 5. Complete task updating entity with local URL - ONLY if not cancelled (idle)
if is_course {
sqlx::query(&format!(
"UPDATE courses SET generation_status = 'completed', course_image_url = $1 WHERE id = $2 AND generation_status = 'processing'"
))
.bind(local_url)
.bind(id)
.execute(pool)
.await
.map_err(|e| format!("Failed to complete course task: {}", e))?;
} else {
sqlx::query(&format!(
"UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2 AND video_generation_status = 'processing'"
))
.bind(local_url)
.bind(id)
.execute(pool)
.await
.map_err(|e| format!("Failed to complete lesson task: {}", e))?;
}
Ok(())
}
pub async fn get_lesson( pub async fn get_lesson(
Org(org_ctx): Org, Org(org_ctx): Org,
State(pool): State<PgPool>, State(pool): State<PgPool>,
@@ -3921,29 +3546,55 @@ RULES:
request = request.header("Authorization", auth_header); request = request.header("Authorization", auth_header);
} }
let response = request.send().await.map_err(|e| { let response_result = request.send().await;
tracing::error!("LLM request failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !response.status().is_success() { let mut content_str = match response_result {
let err_body = response.text().await.unwrap_or_default(); Ok(response) if response.status().is_success() => {
tracing::error!("LLM API error: {}", err_body); let llm_data: serde_json::Value = response.json().await.map_err(|e| {
return Err(StatusCode::INTERNAL_SERVER_ERROR); tracing::error!("Failed to parse LLM JSON response: {}", e);
} StatusCode::INTERNAL_SERVER_ERROR
})?;
let llm_data: serde_json::Value = response.json().await.map_err(|e| { tracing::info!("LLM Response received successfully");
tracing::error!("Failed to parse LLM JSON response: {}", e); llm_data["choices"][0]["message"]["content"]
StatusCode::INTERNAL_SERVER_ERROR .as_str()
})?; .unwrap_or("{}")
.trim()
tracing::info!("LLM Response received successfully"); .to_string()
}
let mut content_str = llm_data["choices"][0]["message"]["content"] Ok(response) => {
.as_str() let err_body = response.text().await.unwrap_or_default();
.unwrap_or("{}") tracing::error!("LLM API error (generating course): {}", err_body);
.trim() tracing::warn!("Falling back to default course template.");
.to_string(); r#"{
"title": "Nueva Plantilla de Curso",
"description": "El servidor de IA no respondió. Esta es una plantilla básica que puedes editar directamente.",
"modules": [
{
"title": "Módulo 1",
"lessons": [
{ "title": "Lección 1", "content_type": "text" }
]
}
]
}"#.to_string()
}
Err(e) => {
tracing::error!("LLM request failed (generating course): {}", e);
tracing::warn!("Falling back to default course template due to unreachable AI.");
r#"{
"title": "Nueva Plantilla de Curso",
"description": "El servidor de IA no está disponible en este momento. Esta es una plantilla básica.",
"modules": [
{
"title": "Módulo 1",
"lessons": [
{ "title": "Lección 1", "content_type": "text" }
]
}
]
}"#.to_string()
}
};
tracing::info!("Extracted content string (length: {})", content_str.len()); tracing::info!("Extracted content string (length: {})", content_str.len());
+4 -85
View File
@@ -36,34 +36,6 @@ pub async fn get_background_tasks(
JOIN courses c ON m.course_id = c.id JOIN courses c ON m.course_id = c.id
WHERE l.transcription_status IN ('queued', 'processing', 'failed') WHERE l.transcription_status IN ('queued', 'processing', 'failed')
UNION ALL
SELECT
l.id,
l.title,
c.title as course_title,
'lesson_image' as task_type,
l.video_generation_status as status,
l.generation_progress as progress,
l.updated_at
FROM lessons l
JOIN modules m ON l.module_id = m.id
JOIN courses c ON m.course_id = c.id
WHERE l.video_generation_status IN ('queued', 'processing', 'failed')
UNION ALL
SELECT
c.id,
c.title as title,
NULL as course_title,
'course_image' as task_type,
c.generation_status as status,
c.generation_progress as progress,
c.updated_at
FROM courses c
WHERE c.generation_status IN ('queued', 'processing', 'failed')
ORDER BY updated_at DESC ORDER BY updated_at DESC
"#; "#;
@@ -83,23 +55,15 @@ pub async fn get_background_tasks(
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct LessonStatusRow { struct LessonStatusRow {
transcription_status: Option<String>, transcription_status: Option<String>,
video_generation_status: Option<String>,
}
#[derive(sqlx::FromRow)]
struct CourseStatusRow {
generation_status: Option<String>,
} }
pub async fn retry_task( pub async fn retry_task(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
// We need to know WHAT to retry.
// Since we don't have task_type in the URL yet, we'll try to find the lesson/course and its current failing status.
// Check lessons for transcription or image failures // Check lessons for transcription failures
let lesson = sqlx::query_as::<_, LessonStatusRow>("SELECT transcription_status, video_generation_status FROM lessons WHERE id = $1") let lesson = sqlx::query_as::<_, LessonStatusRow>("SELECT transcription_status FROM lessons WHERE id = $1")
.bind(id) .bind(id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await
@@ -114,44 +78,6 @@ pub async fn retry_task(
}); });
return Ok(StatusCode::ACCEPTED); return Ok(StatusCode::ACCEPTED);
} }
if l.video_generation_status.as_deref() == Some("failed") {
tokio::spawn(async move {
// For image generation, we need the worker pool.
// Wait, run_image_generation_task is in handlers.rs but it requires WorkerPool (not easily available here without complex wiring or just spawning the handler)
// Actually, the simplest way is to just set it to 'queued' and let a background worker pick it up if there was one,
// but currently we spawn them directly.
// For now, let's call the same handler logic.
// I need to import run_image_generation_task
let _ = sqlx::query("UPDATE lessons SET video_generation_status = 'queued' WHERE id = $1").bind(id).execute(&pool_clone).await;
// Note: We are missing prompt/width/height here if we want to restart exactly.
// But generally retry means restart with same params.
// We'll need to fetch them.
// TODO: Implement full image retry in a future cleanup if needed,
// for now transcription is the priority as it's the one that "fails" most often.
// Image generation usually works or the bridge is down.
});
return Ok(StatusCode::ACCEPTED);
}
}
// Check courses
let course = sqlx::query_as::<_, CourseStatusRow>("SELECT generation_status FROM courses WHERE id = $1")
.bind(id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(c) = course {
if c.generation_status.as_deref() == Some("error") || c.generation_status.as_deref() == Some("failed") {
let pool_clone = pool.clone();
tokio::spawn(async move {
let _ = sqlx::query("UPDATE courses SET generation_status = 'queued' WHERE id = $1").bind(id).execute(&pool_clone).await;
// Same as above, needs a worker to pick it up.
});
return Ok(StatusCode::ACCEPTED);
}
} }
Ok(StatusCode::NOT_FOUND) Ok(StatusCode::NOT_FOUND)
@@ -161,16 +87,9 @@ pub async fn cancel_task(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
// Try to cancel in both tables // Only cancel transcription in lessons
let _ = sqlx::query( let _ = sqlx::query(
"UPDATE lessons SET transcription_status = 'idle', video_generation_status = 'idle' WHERE id = $1" "UPDATE lessons SET transcription_status = 'idle' WHERE id = $1"
)
.bind(id)
.execute(&pool)
.await;
let _ = sqlx::query(
"UPDATE courses SET generation_status = 'idle' WHERE id = $1"
) )
.bind(id) .bind(id)
.execute(&pool) .execute(&pool)
-60
View File
@@ -74,65 +74,7 @@ async fn main() {
} }
} }
// Check for queued video generations
let queued_video_lessons: Vec<sqlx::types::Uuid> = match sqlx::query_scalar(
"SELECT id FROM lessons WHERE video_generation_status = 'queued' LIMIT 5",
)
.fetch_all(&worker_pool)
.await
{
Ok(ids) => ids,
Err(e) => {
tracing::error!("Failed to fetch queued video lessons: {}", e);
tokio::time::sleep(Duration::from_secs(10)).await;
continue;
}
};
for lesson_id in queued_video_lessons {
tracing::info!("Processing video generation for lesson: {}", lesson_id);
if let Err(e) =
handlers::run_image_generation_task(worker_pool.clone(), lesson_id, None, None, false, None, None).await
{
tracing::error!("Image generation task failed for lesson {}: {}", lesson_id, e);
let _ = sqlx::query(
"UPDATE lessons SET video_generation_status = 'failed' WHERE id = $1",
)
.bind(lesson_id)
.execute(&worker_pool)
.await;
}
}
// Check for queued course image generations
let queued_course_ids: Vec<sqlx::types::Uuid> = match sqlx::query_scalar(
"SELECT id FROM courses WHERE generation_status = 'queued' LIMIT 5",
)
.fetch_all(&worker_pool)
.await
{
Ok(ids) => ids,
Err(e) => {
tracing::error!("Failed to fetch queued course images: {}", e);
tokio::time::sleep(Duration::from_secs(10)).await;
continue;
}
};
for course_id in queued_course_ids {
tracing::info!("Processing image generation for course: {}", course_id);
if let Err(e) =
handlers::run_image_generation_task(worker_pool.clone(), course_id, None, None, true, None, None).await
{
tracing::error!("Course image generation failed for {}: {}", course_id, e);
let _ = sqlx::query(
"UPDATE courses SET generation_status = 'failed' WHERE id = $1",
)
.bind(course_id)
.execute(&worker_pool)
.await;
}
}
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
} }
@@ -205,8 +147,6 @@ async fn main() {
.route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt)) .route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt))
.route("/lessons/{id}/summarize", post(handlers::summarize_lesson)) .route("/lessons/{id}/summarize", post(handlers::summarize_lesson))
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz)) .route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
.route("/lessons/{id}/generate-image", post(handlers::generate_image))
.route("/courses/{id}/generate-image", post(handlers::generate_course_image))
.route("/courses/generate", post(handlers::generate_course)) .route("/courses/generate", post(handlers::generate_course))
.route("/courses/{id}/export", get(handlers::export_course)) .route("/courses/{id}/export", get(handlers::export_course))
.route("/courses/import", post(handlers::import_course)) .route("/courses/import", post(handlers::import_course))
+29 -6
View File
@@ -2043,13 +2043,36 @@ pub async fn get_recommendations(
"response_format": { "type": "json_object" } "response_format": { "type": "json_object" }
})) }))
.send() .send()
.await .await;
.map_err(|e: reqwest::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let ai_response: RecommendationResponse = response let ai_response: RecommendationResponse = match response {
.json() Ok(res) if res.status().is_success() => {
.await res.json().await.unwrap_or_else(|_| RecommendationResponse {
.map_err(|e: reqwest::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; recommendations: vec![
common::models::Recommendation {
title: "Continúa practicando".to_string(),
description: "Sigue revisando las lecciones anteriores para consolidar tu conocimiento.".to_string(),
lesson_id: None,
priority: "medium".to_string(),
reason: "El servidor de IA no pudo generar recomendaciones personalizadas en este momento.".to_string(),
}
]
})
},
_ => {
RecommendationResponse {
recommendations: vec![
common::models::Recommendation {
title: "Repaso General".to_string(),
description: "Te recomendamos revisar los temas donde has tenido menor puntaje recientemente.".to_string(),
lesson_id: None,
priority: "high".to_string(),
reason: "Servidor de IA temporalmente fuera de servicio. Mostrando recomendación genérica.".to_string(),
}
]
}
}
};
Ok(Json(ai_response)) Ok(Json(ai_response))
} }
@@ -24,7 +24,7 @@ import AITutor from "@/components/AITutor";
import LessonLockedView from "@/components/LessonLockedView"; import LessonLockedView from "@/components/LessonLockedView";
import StudentNotes from "@/components/StudentNotes"; import StudentNotes from "@/components/StudentNotes";
import { ListMusic, StickyNote } from "lucide-react"; import { ListMusic, StickyNote } from "lucide-react";
import ReactMarkdown from "react-markdown";
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) { export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
const [lesson, setLesson] = useState<Lesson | null>(null); const [lesson, setLesson] = useState<Lesson | null>(null);
const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null); const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
@@ -280,12 +280,22 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
{lesson.summary && ( {lesson.summary && (
<div className="p-8 rounded-3xl bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-500/20 animate-in fade-in slide-in-from-top-4 duration-1000"> <div className="p-8 rounded-3xl bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-500/20 animate-in fade-in slide-in-from-top-4 duration-1000">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400 mb-4 flex items-center gap-2"> <h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-600 dark:text-blue-400 mb-4 flex items-center gap-2">
<span className="text-base"></span> Resumen <span className="text-base"></span> Resumen
</h3> </h3>
<p className="text-lg text-gray-300 leading-relaxed font-medium italic"> <div className="text-sm text-slate-700 dark:text-slate-300 leading-relaxed font-medium">
&quot;{lesson.summary}&quot; <ReactMarkdown
</p> components={{
p: ({ node, ...props }) => <p className="mb-4 last:mb-0" {...props} />,
strong: ({ node, ...props }) => <strong className="font-bold text-slate-900 dark:text-white" {...props} />,
em: ({ node, ...props }) => <em className="italic text-slate-800 dark:text-slate-200" {...props} />,
ul: ({ node, ...props }) => <ul className="list-disc pl-5 mb-4 space-y-1" {...props} />,
li: ({ node, ...props }) => <li {...props} />
}}
>
{lesson.summary}
</ReactMarkdown>
</div>
</div> </div>
)} )}
+1 -1
View File
@@ -21,6 +21,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
const [lessonDependencies, setLessonDependencies] = useState<any[]>([]); const [lessonDependencies, setLessonDependencies] = useState<any[]>([]);
const [instructors, setInstructors] = useState<any[]>([]); const [instructors, setInstructors] = useState<any[]>([]);
const [meetings, setMeetings] = useState<Meeting[]>([]); const [meetings, setMeetings] = useState<Meeting[]>([]);
const [activeTab, setActiveTab] = useState<'outline' | 'about'>('outline');
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -145,7 +146,6 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
return <CheckCircle2 size={18} className="text-black/20 dark:text-white/40" />; return <CheckCircle2 size={18} className="text-black/20 dark:text-white/40" />;
}; };
const [activeTab, setActiveTab] = useState<'outline' | 'about'>('outline');
return ( return (
<div className="max-w-4xl mx-auto px-6 py-20 pb-40"> <div className="max-w-4xl mx-auto px-6 py-20 pb-40">
@@ -3,45 +3,27 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import {
cmsApi, cmsApi,
Course, Course
getImageUrl
} from "@/lib/api"; } from "@/lib/api";
import { import {
Save, Save,
Sparkles,
Image as ImageIcon,
Type, Type,
Target, Target,
AlertCircle, AlertCircle,
CheckCircle2,
Clock, Clock,
Award, Award,
Zap, Zap,
Maximize, Megaphone
Monitor,
Square,
Smartphone,
X
} from "lucide-react"; } from "lucide-react";
interface MarketingTabProps { interface MarketingTabProps {
courseId: string; courseId: string;
} }
const RESOLUTIONS = [
{ label: "16:9 Landscape", width: 1024, height: 576, icon: Monitor },
{ label: "1:1 Square", width: 1024, height: 1024, icon: Square },
{ label: "4:3 Classic", width: 1024, height: 768, icon: Maximize },
{ label: "9:16 Portrait", width: 576, height: 1024, icon: Smartphone },
];
export default function MarketingTab({ courseId }: MarketingTabProps) { export default function MarketingTab({ courseId }: MarketingTabProps) {
const [course, setCourse] = useState<Course | null>(null); const [course, setCourse] = useState<Course | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [prompt, setPrompt] = useState("");
const [selectedRes, setSelectedRes] = useState(RESOLUTIONS[0]);
const [isGenerating, setIsGenerating] = useState(false);
// Form states // Form states
const [objectives, setObjectives] = useState(""); const [objectives, setObjectives] = useState("");
@@ -63,12 +45,6 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
setDuration(meta.duration || ""); setDuration(meta.duration || "");
setModulesSummary(meta.modules_summary || ""); setModulesSummary(meta.modules_summary || "");
setCertificationInfo(meta.certification_info || ""); setCertificationInfo(meta.certification_info || "");
if (data.generation_status === 'processing' || data.generation_status === 'queued') {
setIsGenerating(true);
} else {
setIsGenerating(false);
}
} catch (err) { } catch (err) {
console.error("Failed to load course", err); console.error("Failed to load course", err);
} finally { } finally {
@@ -80,22 +56,6 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
loadCourse(); loadCourse();
}, [courseId]); }, [courseId]);
// Polling for generation status
useEffect(() => {
let interval: NodeJS.Timeout;
if (isGenerating) {
interval = setInterval(async () => {
const updated = await cmsApi.getCourse(courseId);
setCourse(updated);
if (updated.generation_status === 'completed' || updated.generation_status === 'error') {
setIsGenerating(false);
clearInterval(interval);
}
}, 3000);
}
return () => clearInterval(interval);
}, [isGenerating, courseId]);
const handleSaveMetadata = async () => { const handleSaveMetadata = async () => {
try { try {
setSaving(true); setSaving(true);
@@ -117,34 +77,6 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
} }
}; };
const handleGenerateImage = async () => {
try {
setIsGenerating(true);
await cmsApi.generateCourseImage(courseId, {
prompt: prompt || course?.title,
width: selectedRes.width,
height: selectedRes.height
});
} catch (err) {
console.error("Generation failed", err);
alert("Failed to start generation.");
setIsGenerating(false);
}
};
const handleCancelGeneration = async () => {
try {
await cmsApi.updateCourse(courseId, {
generation_status: 'idle'
} as any);
setIsGenerating(false);
setCourse(prev => prev ? { ...prev, generation_status: 'idle' } : null);
} catch (err) {
console.error("Cancel failed", err);
alert("Failed to cancel generation.");
}
};
if (loading) return ( if (loading) return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" /> <div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
@@ -154,132 +86,6 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
return ( return (
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* ── SECTION: COURSE IMAGE AI ── */}
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[3rem] overflow-hidden shadow-sm hover:shadow-xl transition-all duration-500">
<div className="p-10 lg:p-14 flex flex-col lg:flex-row gap-12">
{/* Left: Preview */}
<div className="lg:w-1/2 space-y-6">
<div className="group relative aspect-video rounded-[2.5rem] bg-slate-100 dark:bg-black/20 overflow-hidden border border-slate-200 dark:border-white/5 shadow-inner">
{course?.course_image_url ? (
<img
src={getImageUrl(course.course_image_url)}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-[2s]"
alt="Course Preview"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400 gap-4">
<ImageIcon size={64} className="opacity-20 stroke-[1]" />
<p className="text-[10px] font-black uppercase tracking-[0.3em] opacity-40">No preview generated</p>
</div>
)}
{isGenerating && (
<div className="absolute inset-0 bg-white/60 dark:bg-black/80 backdrop-blur-md flex flex-col items-center justify-center p-12 text-center">
<Zap size={48} className="text-blue-500 animate-pulse mb-6" />
<h4 className="text-xl font-black uppercase tracking-tight text-slate-900 dark:text-white mb-4">Generating Visual Intelligence...</h4>
<div className="w-full h-3 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mb-4 border border-white/10">
<div
className="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-500 ease-out"
style={{ width: `${course?.generation_progress || 0}%` }}
/>
</div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-blue-600 dark:text-blue-400 mb-8">
Analysis Phase: {course?.generation_progress || 0}% Complete
</p>
<button
onClick={handleCancelGeneration}
className="flex items-center gap-2 px-6 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded-full border border-red-500/20 transition-all active:scale-95 group/cancel"
>
<X size={14} className="group-hover/cancel:rotate-90 transition-transform" />
<span className="text-[10px] font-black uppercase tracking-[0.1em]">Abort Mission</span>
</button>
</div>
)}
</div>
{course?.generation_error && (
<div className="p-6 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-3xl flex items-center gap-4 text-red-600 dark:text-red-400">
<AlertCircle size={24} />
<div className="flex-1">
<p className="text-[10px] font-black uppercase tracking-widest mb-1">Neural Engine Error</p>
<p className="text-sm font-medium">{course.generation_error}</p>
</div>
</div>
)}
</div>
{/* Right: Controls */}
<div className="lg:w-1/2 space-y-10">
<div>
<h3 className="text-3xl font-black uppercase tracking-tighter text-slate-900 dark:text-white flex items-center gap-4">
<Sparkles className="text-blue-500" />
AI Visual Identity
</h3>
<p className="text-slate-500 dark:text-gray-500 mt-2 font-medium">Generate a cinematic landing page image using Stable Diffusion.</p>
</div>
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 ml-2">Creative Prompt</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={course?.title || "Describe the visual mood of your course..."}
className="w-full h-32 bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[2rem] p-6 text-slate-900 dark:text-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 transition-all resize-none font-medium shadow-inner"
/>
</div>
<div className="space-y-4">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500 ml-2">Resolution Matrix</label>
<div className="grid grid-cols-2 gap-4">
{RESOLUTIONS.map((res) => {
const Icon = res.icon;
const isSelected = selectedRes.label === res.label;
return (
<button
key={res.label}
onClick={() => setSelectedRes(res)}
className={`p-5 rounded-3xl border transition-all flex items-center gap-4 group ${isSelected
? "bg-blue-600 border-blue-500 text-white shadow-xl shadow-blue-500/20 active:scale-95"
: "bg-white dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-600 dark:text-gray-400 hover:bg-slate-50 dark:hover:bg-white/10 active:scale-95 shadow-sm"
}`}
>
<div className={`p-2 rounded-xl ${isSelected ? "bg-white/20" : "bg-slate-100 dark:bg-white/10 group-hover:scale-110 transition-transform"}`}>
<Icon size={20} />
</div>
<div className="text-left">
<p className="text-xs font-black uppercase tracking-tight">{res.label}</p>
<p className={`text-[10px] font-black opacity-60 ${isSelected ? "text-white" : "text-slate-400"}`}>{res.width}x{res.height}</p>
</div>
</button>
);
})}
</div>
</div>
<button
onClick={handleGenerateImage}
disabled={isGenerating}
className={`w-full py-5 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-[2rem] font-black text-[10px] uppercase tracking-[0.3em] shadow-2xl transition-all active:scale-[0.98] flex items-center justify-center gap-4 group ${isGenerating ? 'opacity-50 cursor-not-allowed' : 'hover:scale-[1.02]'}`}
>
{isGenerating ? (
<>
<div className="w-5 h-5 border-2 border-slate-300 border-t-blue-500 rounded-full animate-spin" />
Synthesizing...
</>
) : (
<>
<Sparkles size={20} className="group-hover:rotate-12 transition-transform" />
Energize Neural Engine
</>
)}
</button>
</div>
</div>
</div>
{/* ── SECTION: CORE MARKETING DATA ── */} {/* ── SECTION: CORE MARKETING DATA ── */}
<div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[3rem] p-10 lg:p-14 space-y-12 shadow-sm"> <div className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-[3rem] p-10 lg:p-14 space-y-12 shadow-sm">
<div className="flex items-center justify-between gap-6 flex-wrap"> <div className="flex items-center justify-between gap-6 flex-wrap">
@@ -381,5 +187,3 @@ export default function MarketingTab({ courseId }: MarketingTabProps) {
</div> </div>
); );
} }
import { Megaphone } from "lucide-react";
-2
View File
@@ -646,8 +646,6 @@ export const cmsApi = {
updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), updateLesson: (id: string, payload: Partial<Lesson>): Promise<Lesson> => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }), summarizeLesson: (id: string): Promise<Lesson> => apiFetch(`/lessons/${id}/summarize`, { method: 'POST' }),
generateQuiz: (id: string, payload: { context?: string, quiz_type?: string }): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST', body: JSON.stringify(payload) }), generateQuiz: (id: string, payload: { context?: string, quiz_type?: string }): Promise<Block[]> => apiFetch(`/lessons/${id}/generate-quiz`, { method: 'POST', body: JSON.stringify(payload) }),
generateImage: (id: string, payload: { prompt?: string, width?: number, height?: number } = {}): Promise<Lesson> => apiFetch(`/lessons/${id}/generate-image`, { method: 'POST', body: JSON.stringify(payload) }),
generateCourseImage: (id: string, payload: { prompt?: string, width?: number, height?: number } = {}): Promise<Course> => apiFetch(`/courses/${id}/generate-image`, { method: 'POST', body: JSON.stringify(payload) }),
reviewText: (text: string): Promise<{ suggestion: string, comments: string }> => apiFetch('/api/ai/review-text', { method: 'POST', body: JSON.stringify({ text }) }), reviewText: (text: string): Promise<{ suggestion: string, comments: string }> => apiFetch('/api/ai/review-text', { method: 'POST', body: JSON.stringify({ text }) }),
deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }), deleteModule: (id: string): Promise<void> => apiFetch(`/modules/${id}`, { method: 'DELETE' }),
deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }), deleteLesson: (id: string): Promise<void> => apiFetch(`/lessons/${id}`, { method: 'DELETE' }),