fix(learning): persist AI feedback, improve rubric typing UX, and prevent lesson action overlap
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user