feat: Implement AI tutor functionality, add branding fields, and improve API URL handling.

This commit is contained in:
2026-01-23 14:48:41 -03:00
parent 60e2af72f0
commit 470c7f0172
30 changed files with 1352 additions and 274 deletions
+339 -8
View File
@@ -262,6 +262,7 @@ pub async fn get_course_catalog(
State(pool): State<PgPool>,
Query(query): Query<CatalogQuery>,
) -> Result<Json<Vec<Course>>, StatusCode> {
tracing::info!("get_course_catalog: org_id={:?}, user_id={:?}", query.organization_id, query.user_id);
let courses = match (query.organization_id, query.user_id) {
(Some(org_id), Some(user_id)) => {
sqlx::query_as::<_, Course>(
@@ -464,17 +465,22 @@ pub async fn ingest_course(
}
pub async fn get_course_outline(
Org(_org_ctx): Org,
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
tracing::info!("get_course_outline: fetching course {}", id);
tracing::info!("get_course_outline: id={}, caller_org={}", id, org_ctx.id);
// 1. Fetch Course
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
.map_err(|e| {
tracing::error!("get_course_outline: course fetch failed for {}: {}", id, e);
StatusCode::NOT_FOUND
})?;
tracing::info!("get_course_outline: course found, title='{}'", course.title);
// 2. Fetch Modules
let modules =
@@ -482,7 +488,12 @@ pub async fn get_course_outline(
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
tracing::error!("get_course_outline: modules fetch failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
tracing::info!("get_course_outline: found {} modules", modules.len());
// 3. Fetch Organization
let organization = sqlx::query_as::<_, common::models::Organization>(
@@ -491,7 +502,12 @@ pub async fn get_course_outline(
.bind(course.organization_id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
tracing::error!("get_course_outline: organization fetch failed for {}: {}", course.organization_id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
tracing::info!("get_course_outline: organization found: {}", organization.name);
// 4. Fetch Grading Categories
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
@@ -500,7 +516,10 @@ pub async fn get_course_outline(
.bind(id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
tracing::error!("get_course_outline: grading categories fetch failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 5. Fetch Lessons
let mut pub_modules = Vec::new();
@@ -511,7 +530,10 @@ pub async fn get_course_outline(
.bind(module.id)
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
tracing::error!("get_course_outline: lessons fetch failed for module {}: {}", module.id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
pub_modules.push(common::models::PublishedModule { module, lessons });
}
@@ -540,10 +562,11 @@ pub async fn get_lesson_content(
}
pub async fn get_user_enrollments(
Org(_org_ctx): Org,
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
tracing::info!("get_user_enrollments: user_id={}, caller_org_id={}", user_id, org_ctx.id);
let enrollments =
sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1")
.bind(user_id)
@@ -1345,3 +1368,311 @@ pub async fn evaluate_audio_file(
Ok(Json(grading))
}
#[derive(Deserialize)]
pub struct ChatPayload {
pub message: String,
}
#[derive(Serialize)]
pub struct ChatResponse {
pub response: String,
}
pub async fn chat_with_tutor(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Path(lesson_id): Path<Uuid>,
Json(payload): Json<ChatPayload>,
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
// 1. Fetch lesson context (summary and transcription)
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?;
// 1.5 Fetch previous lessons in the course for context
let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1")
.bind(lesson.module_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch module context".into()))?;
let previous_lessons = sqlx::query!(
r#"
SELECT l.title, l.summary
FROM lessons l
JOIN modules m ON l.module_id = m.id
WHERE m.course_id = $1
AND (m.position < $2 OR (m.position = $2 AND l.position < $3))
ORDER BY m.position, l.position
"#,
module.course_id,
module.position,
lesson.position
)
.fetch_all(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch previous lessons".into()))?;
let mut history_context = String::new();
if !previous_lessons.is_empty() {
history_context.push_str("\n--- PAST LESSONS HISTORY (FOR CONTEXT) ---\n");
for prev in previous_lessons {
history_context.push_str(&format!(
"Past Lesson: {}\nSummary: {}\n\n",
prev.title,
prev.summary.as_deref().unwrap_or("No summary available.")
));
}
}
let block_content = extract_block_content(&lesson.metadata);
let context = format!(
"CURRENT Lesson Title: {}\nSummary: {}\nTranscription (Partial): {}\n\n--- CURRENT LESSON CONTENT (BLOCKS & ACTIVITIES) ---\n{}\n{}",
lesson.title,
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."),
block_content,
history_context
);
// 2. Setup AI request
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let client = reqwest::Client::new();
let (url, auth_header, model) = if provider == "local" {
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()),
"gpt-4-turbo".to_string(),
)
};
let system_prompt = format!(
"You are an expert AI Teaching Assistant for the OpenCCB platform. \
Your purpose is to help the student understand the content of this lesson and how it relates to previous lessons in the course. \
\
STRICT RULES: \
1. You can ONLY answer questions related to the CURRENT lesson or the PAST lessons provided in the context. \
2. If a student asks about topics NOT covered in the current or past lessons (e.g., general knowledge, future topics, or off-topic conversation), \
you MUST politely decline and remind them that you are here only to help with the course content up to this point. \
3. CRITICAL: Do NOT provide direct answers for the CURRENT lesson's activities, quizzes, or code exercises. \
Even if the answer could be inferred from past lessons, you must only provide hints, explain underlying concepts, or guide the student to find the answer themselves. \
4. Maintain a supportive, encouraging, and educational tone. \
5. Answer in the same language as the student's question. \
\
LESSON CONTEXT:\n{}",
context
);
let response = client.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", auth_header)
.json(&serde_json::json!({
"model": model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": payload.message }
],
"temperature": 0.7
}))
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?;
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", err_body)));
}
let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?;
let tutor_response = ai_data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("Lo siento, tuve un problema procesando tu pregunta.")
.to_string();
Ok(Json(ChatResponse { response: tutor_response }))
}
pub async fn get_lesson_feedback(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(lesson_id): Path<Uuid>,
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
let user_id = claims.sub;
// 1. Fetch lesson context
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Lesson not found".into()))?;
// 2. Fetch user's grade for this lesson
let grade = sqlx::query_as::<_, common::models::UserGrade>(
"SELECT * FROM user_grades WHERE user_id = $1 AND lesson_id = $2"
)
.bind(user_id)
.bind(lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "No grade found for this lesson".into()))?;
let score_pct = (grade.score * 100.0) as i32;
let block_content = extract_block_content(&lesson.metadata);
let context = format!(
"Lesson Title: {}\nSummary: {}\nStudent Score: {}%\nMax Attempts: {}\nAttempts Used: {}\n\n--- LESSON CONTENT ---\n{}",
lesson.title,
lesson.summary.as_deref().unwrap_or_default(),
score_pct,
lesson.max_attempts.unwrap_or(0),
grade.attempts_count,
block_content
);
// 3. Setup AI request
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let client = reqwest::Client::new();
let (url, auth_header, model) = if provider == "local" {
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()),
"gpt-4-turbo".to_string(),
)
};
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. \
Provide a personalized message based on their score ({}%). \
\
STRICT RULES: \
1. Base your feedback ONLY on the lesson content and the student's performance. \
2. If the score is high (>= 80%), congratulate them warmly. \
3. If the score is medium (60-79%), acknowledge their effort and suggest specific areas to improve based on the lesson content. \
4. If the score is low (< 60%), provide encouragement and list specific topics or related blocks they should repeat or review to improve. \
5. Keep the message concise, supportive, and professional. \
6. Answer in Spanish as the platform is mainly used in that language. \
\
LESSON CONTEXT:\n{}",
score_pct,
context
);
let response = client.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", auth_header)
.json(&serde_json::json!({
"model": model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": "Genera mi retroalimentación personalizada basada en mis resultados." }
],
"temperature": 0.7
}))
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?;
if !response.status().is_success() {
let err_body = response.text().await.unwrap_or_default();
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("AI API error: {}", err_body)));
}
let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse AI response: {}", e)))?;
let tutor_response = ai_data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("Buen trabajo completando la lección. Revisa tus resultados arriba.")
.to_string();
Ok(Json(ChatResponse { response: tutor_response }))
}
fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
let mut block_content = String::new();
if let Some(meta) = metadata {
if let Some(blocks) = meta.get("blocks").and_then(|b| b.as_array()) {
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
let title = block.get("title").and_then(|t| t.as_str()).unwrap_or("");
block_content.push_str(&format!("\n--- Block: {} ({}) ---\n", title, block_type));
match block_type {
"description" | "fill-in-the-blanks" => {
if let Some(content) = block.get("content").and_then(|c| c.as_str()) {
block_content.push_str(content);
}
}
"quiz" => {
if let Some(questions) = block.get("quiz_data").and_then(|q| q.get("questions")).and_then(|qs| qs.as_array()) {
for (i, q) in questions.iter().enumerate() {
let question_text = q.get("question").and_then(|qt| qt.as_str()).unwrap_or("");
block_content.push_str(&format!("Q{}: {}\n", i + 1, question_text));
}
}
}
"matching" | "memory-match" => {
if let Some(pairs) = block.get("pairs").and_then(|p| p.as_array()) {
for (i, p) in pairs.iter().enumerate() {
let left = p.get("left").and_then(|l| l.as_str()).unwrap_or("");
let right = p.get("right").and_then(|r| r.as_str()).unwrap_or("");
block_content.push_str(&format!("Pair {}: {} <-> {}\n", i + 1, left, right));
}
}
}
"ordering" => {
if let Some(items) = block.get("items").and_then(|i| i.as_array()) {
for (i, item) in items.iter().enumerate() {
let text = item.as_str().unwrap_or("");
block_content.push_str(&format!("Item {}: {}\n", i + 1, text));
}
}
}
"short-answer" | "audio-response" => {
if let Some(prompt) = block.get("prompt").and_then(|p| p.as_str()) {
block_content.push_str(&format!("Prompt: {}\n", prompt));
}
}
"code" => {
if let Some(instructions) = block.get("instructions").and_then(|i| i.as_str()) {
block_content.push_str(&format!("Instructions: {}\n", instructions));
}
}
"hotspot" => {
if let Some(description) = block.get("description").and_then(|d| d.as_str()) {
block_content.push_str(&format!("Description: {}\n", description));
}
}
_ => {}
}
block_content.push_str("\n");
}
}
}
block_content
}
+2
View File
@@ -81,6 +81,8 @@ async fn main() {
)
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
.route("/lessons/{id}/chat", post(handlers::chat_with_tutor))
.route("/lessons/{id}/feedback", get(handlers::get_lesson_feedback))
.route("/notifications", get(handlers::get_notifications))
.route(
"/notifications/{id}/read",