feat: Localize default UI language and service messages to Spanish.

This commit is contained in:
2026-01-26 09:18:42 -03:00
parent c774c3608b
commit 7a0a42ed25
3 changed files with 122 additions and 81 deletions
+73 -73
View File
@@ -131,14 +131,14 @@ pub async fn register(
Json(payload): Json<AuthPayload>, Json(payload): Json<AuthPayload>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> { ) -> Result<Json<AuthResponse>, (StatusCode, String)> {
let password_hash = hash(payload.password, DEFAULT_COST) let password_hash = hash(payload.password, DEFAULT_COST)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?; .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error al procesar la contraseña".into()))?;
let full_name = payload.full_name.unwrap_or_else(|| { let full_name = payload.full_name.unwrap_or_else(|| {
payload payload
.email .email
.split('@') .split('@')
.next() .next()
.unwrap_or("Student") .unwrap_or("Estudiante")
.to_string() .to_string()
}); });
@@ -155,7 +155,7 @@ pub async fn register(
.bind(&org_name) .bind(&org_name)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al buscar o crear la organización: {}", e)))?
} else { } else {
sqlx::query_as::<_, Organization>( sqlx::query_as::<_, Organization>(
"SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'", "SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'",
@@ -165,7 +165,7 @@ pub async fn register(
.map_err(|_| { .map_err(|_| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Default organization not found".into(), "Organización por defecto no encontrada".into(),
) )
})? })?
}; };
@@ -179,7 +179,7 @@ pub async fn register(
.bind(organization.id) .bind(organization.id)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?; .map_err(|e| (StatusCode::CONFLICT, format!("El usuario ya existe o error en la BD: {}", e)))?;
tx.commit() tx.commit()
.await .await
@@ -188,7 +188,7 @@ pub async fn register(
let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| { let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"JWT generation failed".into(), "Error al generar el token de acceso".into(),
) )
})?; })?;
@@ -217,15 +217,15 @@ pub async fn login(
.bind(&payload.email) .bind(&payload.email)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?; .map_err(|_| (StatusCode::UNAUTHORIZED, "Credenciales inválidas".into()))?;
if !verify(payload.password, &user.password_hash).map_err(|_| { if !verify(payload.password, &user.password_hash).map_err(|_| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Verification failed".into(), "Error de verificación".into(),
) )
})? { })? {
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into())); return Err((StatusCode::UNAUTHORIZED, "Credenciales inválidas".into()));
} }
let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| { let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| {
@@ -635,7 +635,7 @@ pub async fn submit_lesson_score(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if max_attempts.is_none() { if max_attempts.is_none() {
return Err((StatusCode::NOT_FOUND, "Lesson not found".into())); return Err((StatusCode::NOT_FOUND, "Lección no encontrada".into()));
} }
let max_attempts = max_attempts.flatten(); let max_attempts = max_attempts.flatten();
@@ -653,7 +653,7 @@ pub async fn submit_lesson_score(
if count >= max { if count >= max {
return Err(( return Err((
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"Maximum attempts reached for this assessment".into(), "Se ha alcanzado el número máximo de intentos para esta evaluación".into(),
)); ));
} }
} }
@@ -1057,7 +1057,7 @@ pub async fn update_user(
Json(payload): Json<serde_json::Value>, Json(payload): Json<serde_json::Value>,
) -> Result<Json<UserResponse>, (StatusCode, String)> { ) -> Result<Json<UserResponse>, (StatusCode, String)> {
if claims.sub != id { if claims.sub != id {
return Err((StatusCode::FORBIDDEN, "Not authorized".into())); return Err((StatusCode::FORBIDDEN, "No autorizado".into()));
} }
let full_name = payload.get("full_name").and_then(|f| f.as_str()); let full_name = payload.get("full_name").and_then(|f| f.as_str());
@@ -1131,7 +1131,7 @@ pub async fn get_recommendations(
.iter() .iter()
.find(|l| l.id == grade.lesson_id) .find(|l| l.id == grade.lesson_id)
.map(|l| l.title.clone()) .map(|l| l.title.clone())
.unwrap_or_else(|| "Unknown Lesson".to_string()); .unwrap_or_else(|| "Lección desconocida".to_string());
performance_summary.push_str(&format!( performance_summary.push_str(&format!(
"- Lesson: {}, Score: {}%\n", "- Lesson: {}, Score: {}%\n",
@@ -1141,7 +1141,7 @@ pub async fn get_recommendations(
} }
if performance_summary.is_empty() { if performance_summary.is_empty() {
performance_summary = "Student hasn't completed any assessments yet.".to_string(); performance_summary = "El estudiante aún no ha completado ninguna evaluación.".to_string();
} }
// 4. Call Ollama // 4. Call Ollama
@@ -1173,11 +1173,11 @@ pub async fn get_recommendations(
"messages": [ "messages": [
{ {
"role": "system", "role": "system",
"content": "You are a supportive and professional English Tutor. Based on the student's performance, suggest 3 highly personalized study recommendations to improve their English skills (grammar, vocabulary, speaking). Focus on areas where they scored low. Return ONLY a valid JSON object starting with { \"recommendations\": [...] }. Each object MUST have: 'title', 'description', 'lesson_id' (a valid UUID or null), 'priority' ('high', 'medium', 'low'), and 'reason'. Respond in Spanish in a motivating and encouraging tone." "content": "Eres un tutor de inglés profesional y empático. Basándote en el desempeño del estudiante, sugiere 3 recomendaciones de estudio altamente personalizadas para mejorar sus habilidades en inglés (gramática, vocabulario, habla). Enfócate en las áreas donde obtuvo puntuaciones bajas. Devuelve ÚNICAMENTE un objeto JSON válido que comience con { \"recommendations\": [...] }. Cada objeto DEBE tener: 'title', 'description', 'lesson_id' (un UUID válido o null), 'priority' ('high', 'medium', 'low') y 'reason'. Responde en español con un tono motivador y alentador."
}, },
{ {
"role": "user", "role": "user",
"content": format!("Student performance in course:\n{}", performance_summary) "content": format!("Desempeño del estudiante en el curso:\n{}", performance_summary)
} }
], ],
"response_format": { "type": "json_object" } "response_format": { "type": "json_object" }
@@ -1214,12 +1214,12 @@ pub async fn evaluate_audio_response(
) )
}; };
let system_prompt = "You are an expert English Teacher. Evaluate the student's spoken response transcript. \ let system_prompt = "Eres un profesor de inglés experto. Evalúa la transcripción de la respuesta hablada del estudiante. \
Compare it against the prompt and expected keywords. \ Compárala con el prompt y las palabras clave esperadas. \
Provide a score from 0 to 100. \ Proporciona una puntuación de 0 a 100. \
Identify which keywords were used. \ Identifica qué palabras clave fueron utilizadas. \
Give constructive feedback in Spanish about their pronunciation (based on the transcript quality) and content. \ Da retroalimentación constructiva en español sobre su pronunciación (basándote en la calidad de la transcripción) y contenido. \
Return ONLY a JSON object: { \"score\": number, \"found_keywords\": [string], \"feedback\": string }."; Devuelve ÚNICAMENTE un objeto JSON: { \"score\": number, \"found_keywords\": [string], \"feedback\": string }.";
let user_content = format!( let user_content = format!(
"Prompt: {}\nExpected Keywords: {:?}\nStudent Transcript: {}", "Prompt: {}\nExpected Keywords: {:?}\nStudent Transcript: {}",
@@ -1285,7 +1285,7 @@ pub async fn evaluate_audio_file(
} }
if audio_data.is_empty() { if audio_data.is_empty() {
return Err((StatusCode::BAD_REQUEST, "No audio file provided".into())); return Err((StatusCode::BAD_REQUEST, "No se proporcionó ningún archivo de audio".into()));
} }
// 1. Send to Whisper // 1. Send to Whisper
@@ -1301,21 +1301,21 @@ pub async fn evaluate_audio_file(
.multipart(form) .multipart(form)
.send() .send()
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Whisper request failed: {}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error en la solicitud a Whisper: {}", e)))?;
if !response.status().is_success() { if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default(); let err_body = response.text().await.unwrap_or_default();
tracing::error!("Whisper error: {}", err_body); tracing::error!("Whisper error: {}", err_body);
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Whisper API error: {}", err_body))); return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de Whisper: {}", err_body)));
} }
let transcription_result: serde_json::Value = response.json().await let transcription_result: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse Whisper response: {}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de Whisper: {}", e)))?;
let transcript = transcription_result["text"].as_str().unwrap_or("").to_string(); let transcript = transcription_result["text"].as_str().unwrap_or("").to_string();
if transcript.is_empty() { if transcript.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Whisper could not detect any speech. Please speak louder or check your mic.".into())); return Err((StatusCode::BAD_REQUEST, "Whisper no pudo detectar voz. Por favor, habla más fuerte o revisa tu micrófono.".into()));
} }
let keywords: Vec<String> = if keywords_str.trim().starts_with('[') { let keywords: Vec<String> = if keywords_str.trim().starts_with('[') {
@@ -1338,12 +1338,12 @@ pub async fn evaluate_audio_file(
) )
}; };
let system_prompt = "You are an expert Teacher. Evaluate the student's spoken response transcript. \ let system_prompt = "Eres un profesor experto. Evalúa la transcripción de la respuesta hablada del estudiante. \
Compare it against the prompt and expected keywords. \ Compárala con el prompt y las palabras clave esperadas. \
Provide a score from 0 to 100. \ Proporciona una puntuación de 0 a 100. \
Identify which keywords were used. \ Identifica qué palabras clave fueron utilizadas. \
Give constructive feedback in Spanish about their pronunciation (based on the transcript quality) and content. \ Da retroalimentación constructiva en español sobre su pronunciación (basándote en la calidad de la transcripción) y contenido. \
Return ONLY a JSON object: { \"score\": number, \"found_keywords\": [string], \"feedback\": string }."; Devuelve ÚNICAMENTE un objeto JSON: { \"score\": number, \"found_keywords\": [string], \"feedback\": string }.";
let user_content = format!( let user_content = format!(
"Prompt: {}\nExpected Keywords: {:?}\nStudent Transcript: {}", "Prompt: {}\nExpected Keywords: {:?}\nStudent Transcript: {}",
@@ -1363,10 +1363,10 @@ pub async fn evaluate_audio_file(
})) }))
.send() .send()
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error en la solicitud de IA: {}", e)))?;
let ai_data: serde_json::Value = response.json().await let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI response parse failed: {}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
let grading: AudioGradingResponse = serde_json::from_value( let grading: AudioGradingResponse = serde_json::from_value(
ai_data["choices"][0]["message"]["content"] ai_data["choices"][0]["message"]["content"]
@@ -1409,14 +1409,14 @@ pub async fn chat_with_tutor(
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?; .map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
// 1.5 Fetch previous lessons in the course for context // 1.5 Fetch previous lessons in the course for context
let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1") let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1")
.bind(lesson.module_id) .bind(lesson.module_id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch module context".into()))?; .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error al obtener el contexto del módulo".into()))?;
let previous_lessons = sqlx::query( let previous_lessons = sqlx::query(
r#" r#"
@@ -1445,7 +1445,7 @@ pub async fn chat_with_tutor(
history_context.push_str(&format!( history_context.push_str(&format!(
"Past Lesson: {}\nSummary: {}\n\n", "Past Lesson: {}\nSummary: {}\n\n",
title, title,
summary.as_deref().unwrap_or("No summary available.") summary.as_deref().unwrap_or("No hay resumen disponible.")
)); ));
} }
} }
@@ -1456,7 +1456,7 @@ pub async fn chat_with_tutor(
"CURRENT Lesson Title: {}\nSummary: {}\nTranscription (Partial): {}\n\n--- CURRENT LESSON CONTENT (BLOCKS & ACTIVITIES) ---\n{}\n{}", "CURRENT Lesson Title: {}\nSummary: {}\nTranscription (Partial): {}\n\n--- CURRENT LESSON CONTENT (BLOCKS & ACTIVITIES) ---\n{}\n{}",
lesson.title, lesson.title,
lesson.summary.as_deref().unwrap_or_default(), lesson.summary.as_deref().unwrap_or_default(),
lesson.transcription.as_ref().and_then(|t| t.get("text").and_then(|text| text.as_str())).unwrap_or("No transcript available."), lesson.transcription.as_ref().and_then(|t| t.get("text").and_then(|text| text.as_str())).unwrap_or("No hay transcripción disponible."),
block_content, block_content,
history_context history_context
); );
@@ -1475,12 +1475,12 @@ pub async fn chat_with_tutor(
.bind(org_ctx.id) .bind(org_ctx.id)
.bind(claims.sub) .bind(claims.sub)
.bind(Some(lesson_id)) .bind(Some(lesson_id))
.bind(format!("Chat about {}", lesson.title)) .bind(format!("Chat sobre {}", lesson.title))
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::error!("Failed to create chat session: {}", e); tracing::error!("Failed to create chat session: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to create chat session".into()) (StatusCode::INTERNAL_SERVER_ERROR, "Error al crear la sesión de chat".into())
})?; })?;
use sqlx::Row; use sqlx::Row;
@@ -1510,7 +1510,7 @@ pub async fn chat_with_tutor(
let mut memory_context = String::new(); let mut memory_context = String::new();
if !history_rows.is_empty() { if !history_rows.is_empty() {
memory_context.push_str("\n--- CONVERSATION HISTORY (RECENT) ---\n"); memory_context.push_str("\n--- HISTORIAL RECIENTE DE LA CONVERSACIÓN ---\n");
// Reverse to get chronological order // Reverse to get chronological order
for row in history_rows.into_iter().rev() { for row in history_rows.into_iter().rev() {
let role: String = row.get("role"); let role: String = row.get("role");
@@ -1537,7 +1537,7 @@ pub async fn chat_with_tutor(
let mut kb_context = String::new(); let mut kb_context = String::new();
if !search_results.is_empty() { if !search_results.is_empty() {
kb_context.push_str("\n--- ADDITIONAL KNOWLEDGE BASE CONTEXT ---\n"); kb_context.push_str("\n--- CONTEXTO ADICIONAL DE LA BASE DE CONOCIMIENTOS ---\n");
for row in search_results { for row in search_results {
let chunk: String = row.get("content_chunk"); let chunk: String = row.get("content_chunk");
kb_context.push_str(&format!("Relevant Snippet: {}\n\n", chunk)); kb_context.push_str(&format!("Relevant Snippet: {}\n\n", chunk));
@@ -1557,20 +1557,20 @@ pub async fn chat_with_tutor(
}; };
let system_prompt = format!( let system_prompt = format!(
"You are an expert AI Teaching Assistant for the OpenCCB platform. \ "Eres un asistente pedagógico de IA experto para la plataforma OpenCCB. \
Your purpose is to help the student understand the content of this lesson and how it relates to previous lessons in the course. \ Tu propósito es ayudar al estudiante a comprender el contenido de esta lección y cómo se relaciona con las lecciones anteriores del curso. \
\ \
STRICT RULES: \ REGLAS ESTRICTAS: \
1. You can ONLY answer questions related to the CURRENT lesson, the PAST lessons, or the provided KNOWLEDGE BASE CONTEXT. \ 1. Solo puedes responder preguntas relacionadas con la lección ACTUAL, las lecciones PASADAS o el CONTEXTO de la BASE DE CONOCIMIENTOS proporcionado. \
2. If a student asks about topics NOT covered in the provided contexts (e.g., general knowledge, future topics, or off-topic conversation), \ 2. Si un estudiante pregunta sobre temas NO cubiertos en los contextos proporcionados (ej. cultura general, temas futuros o conversaciones fuera de tema), \
you MUST politely decline and remind them that you are here only to help with the course content up to this point. \ DEBES rechazar cortésmente y recordarle que estás aquí solo para ayudar con el contenido del curso hasta este punto. \
3. CRITICAL: Do NOT provide direct answers for the CURRENT lesson's activities, quizzes, or code exercises. \ 3. CRÍTICO: NO proporciones respuestas directas para las actividades, cuestionarios o ejercicios de código de la lección ACTUAL. \
Even if the answer is in the memory or knowledge base, you must only provide hints or explain concepts. \ Incluso si la respuesta está en la memoria o base de conocimientos, solo debes proporcionar pistas o explicar conceptos. \
4. Use the CONVERSATION HISTORY to maintain continuity and provide personalized help based on previous questions. \ 4. Usa el HISTORIAL DE LA CONVERSACIÓN para mantener la continuidad y brindar ayuda personalizada basada en preguntas anteriores. \
5. Maintain a supportive, encouraging, and educational tone. \ 5. Mantén un tono de apoyo, alentador y educativo. \
6. Answer in the same language as the student's question. \ 6. Responde en el mismo idioma de la pregunta del estudiante. \
\ \
LESSON & HISTORY CONTEXT:\n{}\n{}\n{}", CONTEXTO DE LA LECCIÓN E HISTORIAL:\n{}\n{}\n{}",
context, context,
memory_context, memory_context,
kb_context kb_context
@@ -1589,15 +1589,15 @@ pub async fn chat_with_tutor(
})) }))
.send() .send()
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error en la solicitud de IA: {}", e)))?;
if !response.status().is_success() { if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default(); let err_body = response.text().await.unwrap_or_default();
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", err_body))); return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de IA: {}", err_body)));
} }
let ai_data: serde_json::Value = response.json().await let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
let tutor_response = ai_data["choices"][0]["message"]["content"] let tutor_response = ai_data["choices"][0]["message"]["content"]
.as_str() .as_str()
@@ -1634,7 +1634,7 @@ pub async fn get_lesson_feedback(
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?; .map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?;
// 2. Fetch user's grade for this lesson // 2. Fetch user's grade for this lesson
let grade = sqlx::query_as::<_, common::models::UserGrade>( let grade = sqlx::query_as::<_, common::models::UserGrade>(
@@ -1645,7 +1645,7 @@ pub async fn get_lesson_feedback(
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "No grade found for this lesson".into()))?; .ok_or((StatusCode::BAD_REQUEST, "No se encontró calificación para esta lección".into()))?;
let score_pct = (grade.score * 100.0) as i32; let score_pct = (grade.score * 100.0) as i32;
@@ -1678,18 +1678,18 @@ pub async fn get_lesson_feedback(
}; };
let system_prompt = format!( let system_prompt = format!(
"You are an expert AI Teaching Assistant. The student has completed a graded assessment and is now seeing their final results. \ "Eres un asistente pedagógico de IA experto. El estudiante ha completado una evaluación calificada y ahora está viendo sus resultados finales. \
Provide a personalized message based on their score ({}%). \ Proporciona un mensaje personalizado basado en su puntuación ({}%). \
\ \
STRICT RULES: \ REGLAS ESTRICTAS: \
1. Base your feedback ONLY on the lesson content and the student's performance. \ 1. Basa tu retroalimentación ÚNICAMENTE en el contenido de la lección y el desempeño del estudiante. \
2. If the score is high (>= 80%), congratulate them warmly. \ 2. Si la puntuación es alta (>= 80%), felicítalo calurosamente. \
3. If the score is medium (60-79%), acknowledge their effort and suggest specific areas to improve based on the lesson content. \ 3. Si la puntuación es media (60-79%), reconoce su esfuerzo y sugiere áreas específicas para mejorar basadas en el contenido de la lección. \
4. If the score is low (< 60%), provide encouragement and list specific topics or related blocks they should repeat or review to improve. \ 4. Si la puntuación es baja (< 60%), brinda aliento y numera temas específicos o bloques relacionados que debería repetir o revisar para mejorar. \
5. Keep the message concise, supportive, and professional. \ 5. Mantén el mensaje conciso, de apoyo y profesional. \
6. Answer in Spanish as the platform is mainly used in that language. \ 6. Responde en español ya que la plataforma se usa principalmente en ese idioma. \
\ \
LESSON CONTEXT:\n{}", CONTEXTO DE LA LECCIÓN:\n{}",
score_pct, score_pct,
context context
); );
@@ -1707,15 +1707,15 @@ pub async fn get_lesson_feedback(
})) }))
.send() .send()
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error en la solicitud de IA: {}", e)))?;
if !response.status().is_success() { if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default(); let err_body = response.text().await.unwrap_or_default();
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", err_body))); return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de IA: {}", err_body)));
} }
let ai_data: serde_json::Value = response.json().await let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
let tutor_response = ai_data["choices"][0]["message"]["content"] let tutor_response = ai_data["choices"][0]["message"]["content"]
.as_str() .as_str()
+48 -7
View File
@@ -1,29 +1,46 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { lmsApi, Course, Module, Recommendation } from "@/lib/api"; import { lmsApi, Course, Module, Recommendation, UserGrade } from "@/lib/api";
import { Sparkles, AlertTriangle, ArrowRight } from "lucide-react"; import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react"; import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
export default function CourseOutlinePage({ params }: { params: { id: string } }) { export default function CourseOutlinePage({ params }: { params: { id: string } }) {
const { user } = useAuth();
const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null); const [courseData, setCourseData] = useState<(Course & { modules: Module[] }) | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [recommendations, setRecommendations] = useState<Recommendation[]>([]); const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loadingAI, setLoadingAI] = useState(false); const [loadingAI, setLoadingAI] = useState(false);
const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
useEffect(() => { useEffect(() => {
lmsApi.getCourseOutline(params.id) const fetchData = async () => {
.then(data => setCourseData({ ...data.course, modules: data.modules })) try {
.catch(console.error) setLoading(true);
.finally(() => setLoading(false)); const data = await lmsApi.getCourseOutline(params.id);
setCourseData({ ...data.course, modules: data.modules });
if (user) {
const grades = await lmsApi.getUserGrades(user.id, params.id);
setUserGrades(grades);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
fetchData();
setLoadingAI(true); setLoadingAI(true);
lmsApi.getRecommendations(params.id) lmsApi.getRecommendations(params.id)
.then(res => setRecommendations(res.recommendations)) .then(res => setRecommendations(res.recommendations))
.catch(console.error) .catch(console.error)
.finally(() => setLoadingAI(false)); .finally(() => setLoadingAI(false));
}, [params.id]); }, [params.id, user]);
if (loading) { if (loading) {
return ( return (
@@ -41,6 +58,29 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
if (!courseData) return <div className="text-center py-20 text-gray-500">Curso no encontrado.</div>; if (!courseData) return <div className="text-center py-20 text-gray-500">Curso no encontrado.</div>;
const getStatusIcon = (lessonId: string, isGraded: boolean, allowRetry: boolean) => {
const grade = userGrades.find(g => g.lesson_id === lessonId);
if (!grade) {
return <Circle size={18} className="text-white/20" />;
}
if (isGraded) {
const passing = courseData.passing_percentage || 70;
if (grade.score >= passing) {
return <CheckCircle2 size={18} className="text-green-500" />;
} else {
return (
<div className="flex items-center gap-1">
<XCircle size={18} className="text-red-500" />
{allowRetry && <span className="text-[8px] font-black uppercase text-white/40">Repetible</span>}
</div>
);
}
}
return <CheckCircle2 size={18} className="text-white/40" />;
};
return ( return (
<div className="max-w-4xl mx-auto px-6 py-20"> <div className="max-w-4xl mx-auto px-6 py-20">
<div className="mb-16"> <div className="mb-16">
@@ -189,6 +229,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div> </div>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
{lesson.due_date && ( {lesson.due_date && (
<div className="text-right hidden sm:block"> <div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div> <div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
+1 -1
View File
@@ -17,7 +17,7 @@ interface I18nContextType {
const I18nContext = createContext<I18nContextType | undefined>(undefined); const I18nContext = createContext<I18nContextType | undefined>(undefined);
export function I18nProvider({ children }: { children: React.ReactNode }) { export function I18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState('en'); const [language, setLanguageState] = useState('es');
useEffect(() => { useEffect(() => {
const savedLang = localStorage.getItem('studio_language'); const savedLang = localStorage.getItem('studio_language');