feat: Add Mermaid diagram block with AI generation capabilities to lessons.
This commit is contained in:
@@ -1725,6 +1725,112 @@ pub async fn reorder_lessons(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GenerateMermaidPayload {
|
||||
pub prompt_hint: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn generate_mermaid_diagram(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
Json(payload): Json<GenerateMermaidPayload>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
tracing::info!("Generating Mermaid Diagram for lesson_id={}", lesson_id);
|
||||
|
||||
// Fetch lesson for 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, "Lección no encontrada".into()))?;
|
||||
|
||||
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "local".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.2:3b".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-4o".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let transcription_str = lesson.transcription.as_ref().and_then(|v| v.as_str());
|
||||
let summary_str = lesson.summary.as_deref();
|
||||
let lesson_context = transcription_str.or(summary_str).unwrap_or("Conceptos generales de la lección.");
|
||||
let user_hint = payload.prompt_hint.unwrap_or_else(|| "Extrae los conceptos y flujos principales de la lección.".to_string());
|
||||
|
||||
let system_prompt = format!(
|
||||
"Eres un experto arquitecto de información y especialista en diagramación usando Mermaid.js.\n\
|
||||
Tu tarea es generar el código de un diagrama Mermaid que resuma o conceptualice el siguiente contenido de la lección.\n\
|
||||
INSTRUCCIONES CRÍTICAS:\n\
|
||||
1. Genera SOLO código Mermaid válido.\n\
|
||||
2. NO uses bloques de código con markdown o backticks (```mermaid ... ```). Genera el texto en crudo directamente.\n\
|
||||
3. NO agregues introducciones, explicaciones, ni conclusiones.\n\
|
||||
4. NO saludes.\n\
|
||||
5. Si es aplicable, usa 'flowchart TD', 'mindmap', 'sequenceDiagram' o similares.\n\n\
|
||||
Contexto de la lección:\n{}\n\n\
|
||||
Instrucciones adicionales del usuario:\n{}",
|
||||
lesson_context, user_hint
|
||||
);
|
||||
|
||||
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 el código Mermaid directamente." }
|
||||
],
|
||||
"temperature": 0.3
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("LLM Request failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error contacting AI provider".into())
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let err_body = response.text().await.unwrap_or_default();
|
||||
tracing::error!("LLM Error response: {}", err_body);
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI provider returned an error".into()));
|
||||
}
|
||||
|
||||
let ai_data: serde_json::Value = response.json().await.map_err(|e| {
|
||||
tracing::error!("Failed to parse LLM JSON: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error parsing AI response".into())
|
||||
})?;
|
||||
|
||||
let ai_response = ai_data["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
// Clean any accidental markdown backticks the LLM might have still inserted
|
||||
let cleaned_response = ai_response
|
||||
.strip_prefix("```mermaid\n").unwrap_or(ai_response)
|
||||
.strip_prefix("```\n").unwrap_or(ai_response)
|
||||
.strip_suffix("```").unwrap_or(ai_response).trim();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"mermaid_code": cleaned_response,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GenerateHotspotsPayload {
|
||||
pub image_url: String,
|
||||
|
||||
@@ -149,6 +149,7 @@ async fn main() {
|
||||
.route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz))
|
||||
.route("/lessons/{id}/generate-role-play", post(handlers::generate_role_play))
|
||||
.route("/lessons/{id}/generate-hotspots", post(handlers::generate_hotspots))
|
||||
.route("/lessons/{id}/generate-mermaid", post(handlers::generate_mermaid_diagram))
|
||||
.route("/courses/generate", post(handlers::generate_course))
|
||||
.route("/courses/{id}/export", get(handlers::export_course))
|
||||
.route("/courses/import", post(handlers::import_course))
|
||||
|
||||
@@ -2356,14 +2356,36 @@ pub async fn chat_with_tutor(
|
||||
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, "Lección no encontrada".into()))?;
|
||||
// 1. Fetch lesson context with access check (matches get_lesson_content)
|
||||
let is_preview = claims.token_type.as_deref() == Some("preview");
|
||||
|
||||
let lesson = if is_preview {
|
||||
sqlx::query_as::<_, Lesson>(
|
||||
"SELECT l.* FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
WHERE l.id = $1 AND l.organization_id = $2",
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.bind(claims.org)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, Lesson>(
|
||||
"SELECT l.* FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
LEFT JOIN enrollments e ON m.course_id = e.course_id AND e.user_id = $2
|
||||
WHERE l.id = $1 AND (e.id IS NOT NULL OR l.is_previewable = true OR $3 = 'admin')",
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.bind(claims.sub)
|
||||
.bind(&claims.role)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
}.map_err(|e| {
|
||||
tracing::error!("chat_with_tutor: DB error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error de base de datos".into())
|
||||
})?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Lección no encontrada o acceso denegado".into()))?;
|
||||
|
||||
// 1.5 Fetch previous lessons in the course for context
|
||||
let module = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE id = $1")
|
||||
@@ -2538,8 +2560,8 @@ pub async fn chat_with_tutor(
|
||||
\
|
||||
REGLAS ESTRICTAS: \
|
||||
1. Solo puedes responder preguntas relacionadas con la lección ACTUAL, las lecciones PASADAS o el CONTEXTO de la BASE DE CONOCIMIENTOS proporcionado. \
|
||||
2. Si un estudiante pregunta sobre temas NO cubiertos en los contextos proporcionados (ej. cultura general, temas futuros o conversaciones fuera de tema), \
|
||||
DEBES rechazar cortésmente y recordarle que estás aquí solo para ayudar con el contenido del curso hasta este punto. \
|
||||
2. Si un estudiante hace preguntas de cultura general, eventos futuros o fuera de tema, \
|
||||
puedes responder brevemente de forma amigable usando tus conocimientos generales. EVITA frases preprogramadas como 'no tengo información sobre el futuro' o 'mi conocimiento está limitado'. Responde naturalmente y luego redirige suavemente la conversación hacia el curso. \
|
||||
3. CRÍTICO: NO proporciones respuestas directas para las actividades, cuestionarios o ejercicios de código de la lección ACTUAL. \
|
||||
Incluso si la respuesta está en la memoria o base de conocimientos, solo debes proporcionar pistas o explicar conceptos. \
|
||||
4. Usa el HISTORIAL DE LA CONVERSACIÓN para mantener la continuidad y brindar ayuda personalizada basada en preguntas anteriores. \
|
||||
|
||||
Reference in New Issue
Block a user