fix(learning): persist AI feedback, improve rubric typing UX, and prevent lesson action overlap

This commit is contained in:
2026-04-09 12:02:53 -04:00
parent 9929ff38fb
commit f2cae88a3b
3 changed files with 180 additions and 48 deletions
+76 -39
View File
@@ -3799,6 +3799,25 @@ pub async fn get_lesson_feedback(
let score_pct = (grade.score * 100.0) as i32;
// Reuse cached feedback when it was generated for the same attempt count.
if let Some(metadata) = grade.metadata.as_ref() {
let cached_attempts = metadata
.get("ai_lesson_feedback_attempts")
.and_then(|v| v.as_i64());
let cached_feedback = metadata
.get("ai_lesson_feedback")
.and_then(|v| v.as_str());
if cached_attempts == Some(grade.attempts_count as i64) {
if let Some(feedback) = cached_feedback {
return Ok(Json(ChatResponse {
response: feedback.to_string(),
session_id: Uuid::nil(),
}));
}
}
}
let fallback_feedback = if score_pct >= 80 {
"Excelente trabajo. Tu rendimiento demuestra dominio de los conceptos clave de esta lección. Mantén ese nivel en la siguiente actividad.".to_string()
} else if score_pct >= 60 {
@@ -3873,55 +3892,73 @@ pub async fn get_lesson_feedback(
)
.await;
let response = match response_result {
Ok(Ok(resp)) => resp,
let tutor_response = match response_result {
Ok(Ok(response)) => {
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
tracing::warn!("Feedback IA: proveedor devolvió error: {}", err_body);
fallback_feedback.clone()
} else {
let ai_data: serde_json::Value = match timeout(Duration::from_secs(8), response.json()).await {
Ok(Ok(data)) => data,
Ok(Err(e)) => {
tracing::warn!("Feedback IA: error parseando respuesta: {}", e);
serde_json::Value::Null
}
Err(_) => {
tracing::warn!("Feedback IA: timeout parseando respuesta");
serde_json::Value::Null
}
};
ai_data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or(&fallback_feedback)
.to_string()
}
}
Ok(Err(e)) => {
tracing::warn!("Feedback IA: error solicitando proveedor: {}", e);
return Ok(Json(ChatResponse {
response: fallback_feedback,
session_id: Uuid::nil(),
}));
fallback_feedback.clone()
}
Err(_) => {
tracing::warn!("Feedback IA: timeout de proveedor externo");
return Ok(Json(ChatResponse {
response: fallback_feedback,
session_id: Uuid::nil(),
}));
fallback_feedback.clone()
}
};
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
tracing::warn!("Feedback IA: proveedor devolvió error: {}", err_body);
return Ok(Json(ChatResponse {
response: fallback_feedback,
session_id: Uuid::nil(),
}));
// Persist feedback so repeated views do not trigger AI every time.
let mut new_metadata = grade.metadata.clone().unwrap_or_else(|| json!({}));
if !new_metadata.is_object() {
new_metadata = json!({});
}
if let Some(obj) = new_metadata.as_object_mut() {
obj.insert(
"ai_lesson_feedback".to_string(),
serde_json::Value::String(tutor_response.clone()),
);
obj.insert(
"ai_lesson_feedback_attempts".to_string(),
serde_json::Value::from(grade.attempts_count),
);
obj.insert(
"ai_lesson_feedback_score_pct".to_string(),
serde_json::Value::from(score_pct),
);
obj.insert(
"ai_lesson_feedback_generated_at".to_string(),
serde_json::Value::String(Utc::now().to_rfc3339()),
);
}
let ai_data: serde_json::Value = match timeout(Duration::from_secs(8), response.json()).await {
Ok(Ok(data)) => data,
Ok(Err(e)) => {
tracing::warn!("Feedback IA: error parseando respuesta: {}", e);
return Ok(Json(ChatResponse {
response: fallback_feedback,
session_id: Uuid::nil(),
}));
}
Err(_) => {
tracing::warn!("Feedback IA: timeout parseando respuesta");
return Ok(Json(ChatResponse {
response: fallback_feedback,
session_id: Uuid::nil(),
}));
}
};
let tutor_response = ai_data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or(&fallback_feedback)
.to_string();
if let Err(e) = sqlx::query("UPDATE user_grades SET metadata = $1, updated_at = NOW() WHERE id = $2")
.bind(new_metadata)
.bind(grade.id)
.execute(&pool)
.await
{
tracing::warn!("No se pudo persistir feedback IA en user_grades: {}", e);
}
Ok(Json(ChatResponse {
response: tutor_response,
@@ -232,7 +232,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
{/* Main Content Area */}
<main className="flex-1 flex flex-col relative overflow-hidden">
<div className="absolute top-4 left-4 z-10 flex gap-2">
<div className="relative z-10 flex flex-wrap gap-2 px-6 pt-4 pb-2">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="hidden lg:block p-3 rounded-xl glass border-black/10 dark:border-white/10 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-all bg-black/5 dark:bg-black/40"
@@ -206,14 +206,20 @@ export default function RubricEditor({ rubricId, courseId, onClose, onSaved }: R
<input
type="text"
value={rubric.name}
onChange={(e) => handleUpdateRubric(e.target.value, rubric.description || "")}
onChange={(e) =>
setRubric(prev => prev ? { ...prev, name: e.target.value } : prev)
}
onBlur={() => handleUpdateRubric(rubric.name, rubric.description || "")}
placeholder="Rubric Name"
className="bg-transparent text-3xl font-black text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500/30 rounded px-2 w-full uppercase tracking-tighter"
/>
<input
type="text"
value={rubric.description || ""}
onChange={(e) => handleUpdateRubric(rubric.name, e.target.value)}
onChange={(e) =>
setRubric(prev => prev ? { ...prev, description: e.target.value } : prev)
}
onBlur={() => handleUpdateRubric(rubric.name, rubric.description || "")}
placeholder="Add a description..."
className="bg-transparent text-sm font-bold text-slate-400 dark:text-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500/30 rounded px-2 mt-2 w-full italic"
/>
@@ -271,13 +277,35 @@ export default function RubricEditor({ rubricId, courseId, onClose, onSaved }: R
<input
type="text"
value={item.name}
onChange={(e) => handleUpdateCriterion(item.id, { name: e.target.value })}
onChange={(e) =>
setRubric(prev => {
if (!prev) return prev;
return {
...prev,
criteria: prev.criteria.map(c =>
c.id === item.id ? { ...c, name: e.target.value } : c
)
};
})
}
onBlur={() => handleUpdateCriterion(item.id, { name: item.name })}
className="bg-transparent text-xl font-black text-slate-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 rounded-lg px-2 w-full uppercase tracking-tight"
/>
<input
type="text"
value={item.description || ""}
onChange={(e) => handleUpdateCriterion(item.id, { description: e.target.value })}
onChange={(e) =>
setRubric(prev => {
if (!prev) return prev;
return {
...prev,
criteria: prev.criteria.map(c =>
c.id === item.id ? { ...c, description: e.target.value } : c
)
};
})
}
onBlur={() => handleUpdateCriterion(item.id, { description: item.description || "" })}
placeholder="Focus area (e.g. Critical Thinking, Technical Accuracy...)"
className="bg-transparent text-sm font-medium text-slate-500 dark:text-gray-500 focus:outline-none w-full mt-2 italic px-2"
/>
@@ -288,7 +316,19 @@ export default function RubricEditor({ rubricId, courseId, onClose, onSaved }: R
<input
type="number"
value={item.max_points}
onChange={(e) => handleUpdateCriterion(item.id, { max_points: parseInt(e.target.value) || 0 })}
onChange={(e) => {
const points = parseInt(e.target.value) || 0;
setRubric(prev => {
if (!prev) return prev;
return {
...prev,
criteria: prev.criteria.map(c =>
c.id === item.id ? { ...c, max_points: points } : c
)
};
});
}}
onBlur={() => handleUpdateCriterion(item.id, { max_points: item.max_points })}
className="bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-xl px-3 py-2 w-20 text-center text-blue-600 dark:text-blue-400 font-black text-lg focus:outline-none focus:ring-2 focus:ring-blue-500/30 transition-all shadow-sm"
/>
</div>
@@ -324,13 +364,49 @@ export default function RubricEditor({ rubricId, courseId, onClose, onSaved }: R
<input
type="text"
value={level.name}
onChange={(e) => handleUpdateLevel(level.id, item.id, { name: e.target.value })}
onChange={(e) =>
setRubric(prev => {
if (!prev) return prev;
return {
...prev,
criteria: prev.criteria.map(c =>
c.id === item.id
? {
...c,
levels: c.levels.map(l =>
l.id === level.id ? { ...l, name: e.target.value } : l
)
}
: c
)
};
})
}
onBlur={() => handleUpdateLevel(level.id, item.id, { name: level.name })}
placeholder="Level (e.g. Master)"
className="bg-transparent text-sm font-black text-slate-900 dark:text-gray-200 focus:outline-none uppercase tracking-tight"
/>
<textarea
value={level.description || ""}
onChange={(e) => handleUpdateLevel(level.id, item.id, { description: e.target.value })}
onChange={(e) =>
setRubric(prev => {
if (!prev) return prev;
return {
...prev,
criteria: prev.criteria.map(c =>
c.id === item.id
? {
...c,
levels: c.levels.map(l =>
l.id === level.id ? { ...l, description: e.target.value } : l
)
}
: c
)
};
})
}
onBlur={() => handleUpdateLevel(level.id, item.id, { description: level.description || "" })}
placeholder="Grade description..."
className="bg-transparent text-xs font-medium text-slate-500 dark:text-gray-500 focus:outline-none resize-none h-20 leading-relaxed italic"
/>
@@ -339,7 +415,26 @@ export default function RubricEditor({ rubricId, courseId, onClose, onSaved }: R
<input
type="number"
value={level.points}
onChange={(e) => handleUpdateLevel(level.id, item.id, { points: parseInt(e.target.value) || 0 })}
onChange={(e) => {
const points = parseInt(e.target.value) || 0;
setRubric(prev => {
if (!prev) return prev;
return {
...prev,
criteria: prev.criteria.map(c =>
c.id === item.id
? {
...c,
levels: c.levels.map(l =>
l.id === level.id ? { ...l, points } : l
)
}
: c
)
};
});
}}
onBlur={() => handleUpdateLevel(level.id, item.id, { points: level.points })}
className="bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-lg px-2 py-1 w-14 text-xs font-black text-center text-blue-600 dark:text-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500/30 shadow-inner"
/>
</div>