feat: implement AI tutor memory and RAG system for continuous learning

- Added chat sessions and message persistence for interaction history.
- Integrated Knowledge Base (RAG) using PostgreSQL Full Text Search.
- Implemented automated ingestion of lesson content during course sync.
- Updated AITutor frontend to support persistent session IDs via localStorage.
- Added database migrations for chat_sessions, chat_messages, and knowledge_base.
- Fixed SQLx build issues to allow offline Docker image compilation.
This commit is contained in:
2026-01-23 15:59:53 -03:00
parent 470c7f0172
commit c774c3608b
7 changed files with 300 additions and 30 deletions
+2
View File
@@ -32,6 +32,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
- **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes. - **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes.
- **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso. - **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso.
- **Persistent Grade Locking**: Bloqueo persistente de lecciones calificadas tras agotar los intentos, con retroalimentación personalizada generada por IA. - **Persistent Grade Locking**: Bloqueo persistente de lecciones calificadas tras agotar los intentos, con retroalimentación personalizada generada por IA.
- **Color-Coded Progress Navigation**: Sistema visual de seguimiento de progreso mediante colores (Verde: Completado, Amarillo: En Proceso, Rojo: Repetible) tanto a nivel de lección como de módulo.
## Requisitos del Sistema ## Requisitos del Sistema
@@ -438,6 +439,7 @@ Obtiene una lista de todas las organizaciones registradas.
- **Mobile-First Navigation**: Responsive sliding menus and adaptive layouts for all screen sizes. - **Mobile-First Navigation**: Responsive sliding menus and adaptive layouts for all screen sizes.
- **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers. - **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers.
- **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results. - **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results.
- **Color-Coded Navigation**: Real-time visual progress indicators for lessons and modules (Green/Yellow/Red).
## 📄 Licencia ## 📄 Licencia
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
+1
View File
@@ -163,6 +163,7 @@
- [x] **Locked Lesson AI Feedback**: Generación de retroalimentación motivacional para lecciones bloqueadas (Completado) - [x] **Locked Lesson AI Feedback**: Generación de retroalimentación motivacional para lecciones bloqueadas (Completado)
- [x] **Context Enrichment**: Ingesta de bloques interactivos en el motor de RAG (Completado) - [x] **Context Enrichment**: Ingesta de bloques interactivos en el motor de RAG (Completado)
- [x] **Course History Context**: Capacidad del tutor para recordar lecciones previas (Completado) - [x] **Course History Context**: Capacidad del tutor para recordar lecciones previas (Completado)
- [x] **Color-Coded Progress Status**: Seguimiento visual por colores (Verde/Amarillo/Rojo) en sidebar y cabeceras (Completado)
## Fase 16: Estabilidad y UX Avanzada (En Progreso) ## Fase 16: Estabilidad y UX Avanzada (En Progreso)
- [/] **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción. - [/] **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción.
@@ -0,0 +1,39 @@
-- Migration: AI Training (Memory & RAG)
-- Create tables for chat persistent memory and knowledge base ingestion
-- 1. Chat Sessions Table
CREATE TABLE IF NOT EXISTS chat_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE,
title TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 2. Chat Messages Table
CREATE TABLE IF NOT EXISTS chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL, -- 'user' or 'assistant'
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_id ON chat_messages(session_id);
-- 3. Knowledge Base Table
CREATE TABLE IF NOT EXISTS knowledge_base (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
source_type VARCHAR(50) NOT NULL, -- 'lesson_content', 'file_supplementary', 'interaction_summary'
source_id UUID, -- References lesson_id, file_id, etc.
content_chunk TEXT NOT NULL,
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', content_chunk)) STORED,
metadata JSONB, -- Additional info like chapter, page number, etc.
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for Full Text Search
CREATE INDEX IF NOT EXISTS idx_knowledge_base_search ON knowledge_base USING GIN(search_vector);
CREATE INDEX IF NOT EXISTS idx_knowledge_base_org_on ON knowledge_base(organization_id);
+171 -19
View File
@@ -410,7 +410,7 @@ pub async fn ingest_course(
} }
// 4. Insert Modules and Lessons // 4. Insert Modules and Lessons
for pub_module in payload.modules { for pub_module in &payload.modules {
sqlx::query( sqlx::query(
"INSERT INTO modules (id, course_id, title, position, created_at, organization_id) "INSERT INTO modules (id, course_id, title, position, created_at, organization_id)
VALUES ($1, $2, $3, $4, $5, $6)", VALUES ($1, $2, $3, $4, $5, $6)",
@@ -425,7 +425,7 @@ pub async fn ingest_course(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
for lesson in pub_module.lessons { for lesson in &pub_module.lessons {
sqlx::query( sqlx::query(
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status) "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)" VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)"
@@ -461,6 +461,21 @@ pub async fn ingest_course(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 5. Background Ingestion of Knowledge Base
// We do this after commit to ensure lesson IDs are persistent
for pub_module in &payload.modules {
for lesson in &pub_module.lessons {
let block_content = extract_block_content(&lesson.metadata);
if !block_content.trim().is_empty() {
let _ = ingest_lesson_knowledge(&pool, org_id, lesson.id, &block_content).await;
}
// Also ingest summary as a high-relevance chunk
if let Some(summary) = &lesson.summary {
let _ = ingest_lesson_knowledge(&pool, org_id, lesson.id, summary).await;
}
}
}
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
@@ -1372,16 +1387,18 @@ pub async fn evaluate_audio_file(
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ChatPayload { pub struct ChatPayload {
pub message: String, pub message: String,
pub session_id: Option<Uuid>,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct ChatResponse { pub struct ChatResponse {
pub response: String, pub response: String,
pub session_id: Uuid,
} }
pub async fn chat_with_tutor( pub async fn chat_with_tutor(
Org(org_ctx): Org, Org(org_ctx): Org,
_claims: Claims, claims: Claims,
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(lesson_id): Path<Uuid>, Path(lesson_id): Path<Uuid>,
Json(payload): Json<ChatPayload>, Json(payload): Json<ChatPayload>,
@@ -1401,7 +1418,7 @@ pub async fn chat_with_tutor(
.await .await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch module context".into()))?; .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch module context".into()))?;
let previous_lessons = sqlx::query!( let previous_lessons = sqlx::query(
r#" r#"
SELECT l.title, l.summary SELECT l.title, l.summary
FROM lessons l FROM lessons l
@@ -1410,10 +1427,10 @@ pub async fn chat_with_tutor(
AND (m.position < $2 OR (m.position = $2 AND l.position < $3)) AND (m.position < $2 OR (m.position = $2 AND l.position < $3))
ORDER BY m.position, l.position ORDER BY m.position, l.position
"#, "#,
module.course_id,
module.position,
lesson.position
) )
.bind(module.course_id)
.bind(module.position)
.bind(lesson.position)
.fetch_all(&pool) .fetch_all(&pool)
.await .await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch previous lessons".into()))?; .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch previous lessons".into()))?;
@@ -1422,10 +1439,13 @@ pub async fn chat_with_tutor(
if !previous_lessons.is_empty() { if !previous_lessons.is_empty() {
history_context.push_str("\n--- PAST LESSONS HISTORY (FOR CONTEXT) ---\n"); history_context.push_str("\n--- PAST LESSONS HISTORY (FOR CONTEXT) ---\n");
for prev in previous_lessons { for prev in previous_lessons {
use sqlx::Row;
let title: String = prev.get("title");
let summary: Option<String> = prev.get("summary");
history_context.push_str(&format!( history_context.push_str(&format!(
"Past Lesson: {}\nSummary: {}\n\n", "Past Lesson: {}\nSummary: {}\n\n",
prev.title, title,
prev.summary.as_deref().unwrap_or("No summary available.") summary.as_deref().unwrap_or("No summary available.")
)); ));
} }
} }
@@ -1445,6 +1465,85 @@ pub async fn chat_with_tutor(
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let client = reqwest::Client::new(); let client = reqwest::Client::new();
// 2.1 Handle Session and Memory
let session_id = if let Some(sid) = payload.session_id {
sid
} else {
let row = sqlx::query(
"INSERT INTO chat_sessions (organization_id, user_id, lesson_id, title) VALUES ($1, $2, $3, $4) RETURNING id"
)
.bind(org_ctx.id)
.bind(claims.sub)
.bind(Some(lesson_id))
.bind(format!("Chat about {}", lesson.title))
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Failed to create chat session: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to create chat session".into())
})?;
use sqlx::Row;
let sid: Uuid = row.get(0);
sid
};
// Save user message
sqlx::query(
"INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)"
)
.bind(session_id)
.bind("user")
.bind(&payload.message)
.execute(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save user message".into()))?;
// Fetch last 6 messages for context
let history_rows = sqlx::query(
"SELECT role, content FROM chat_messages WHERE session_id = $1 ORDER BY created_at DESC LIMIT 6"
)
.bind(session_id)
.fetch_all(&pool)
.await
.unwrap_or_default();
let mut memory_context = String::new();
if !history_rows.is_empty() {
memory_context.push_str("\n--- CONVERSATION HISTORY (RECENT) ---\n");
// Reverse to get chronological order
for row in history_rows.into_iter().rev() {
let role: String = row.get("role");
let content: String = row.get("content");
memory_context.push_str(&format!("{}: {}\n", role.to_uppercase(), content));
}
}
// 2.2 Knowledge Base Retrieval (RAG)
let search_results = sqlx::query(
r#"
SELECT content_chunk
FROM knowledge_base
WHERE organization_id = $1
AND search_vector @@ plainto_tsquery('english', $2)
LIMIT 3
"#,
)
.bind(org_ctx.id)
.bind(&payload.message)
.fetch_all(&pool)
.await
.unwrap_or_default();
let mut kb_context = String::new();
if !search_results.is_empty() {
kb_context.push_str("\n--- ADDITIONAL KNOWLEDGE BASE CONTEXT ---\n");
for row in search_results {
let chunk: String = row.get("content_chunk");
kb_context.push_str(&format!("Relevant Snippet: {}\n\n", chunk));
}
}
let (url, auth_header, model) = if provider == "local" { 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 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()); let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
@@ -1462,16 +1561,19 @@ pub async fn chat_with_tutor(
Your purpose is to help the student understand the content of this lesson and how it relates to previous lessons in the course. \ 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: \ STRICT RULES: \
1. You can ONLY answer questions related to the CURRENT lesson or the PAST lessons provided in the context. \ 1. You can ONLY answer questions related to the CURRENT lesson, the PAST lessons, or the provided KNOWLEDGE BASE 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), \ 2. If a student asks about topics NOT covered in the provided contexts (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. \ 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. \ 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. \ Even if the answer is in the memory or knowledge base, you must only provide hints or explain concepts. \
4. Maintain a supportive, encouraging, and educational tone. \ 4. Use the CONVERSATION HISTORY to maintain continuity and provide personalized help based on previous questions. \
5. Answer in the same language as the student's question. \ 5. Maintain a supportive, encouraging, and educational tone. \
6. Answer in the same language as the student's question. \
\ \
LESSON CONTEXT:\n{}", LESSON & HISTORY CONTEXT:\n{}\n{}\n{}",
context context,
memory_context,
kb_context
); );
let response = client.post(&url) let response = client.post(&url)
@@ -1502,7 +1604,20 @@ pub async fn chat_with_tutor(
.unwrap_or("Lo siento, tuve un problema procesando tu pregunta.") .unwrap_or("Lo siento, tuve un problema procesando tu pregunta.")
.to_string(); .to_string();
Ok(Json(ChatResponse { response: tutor_response })) // Save assistant response
let _ = sqlx::query(
"INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)"
)
.bind(session_id)
.bind("assistant")
.bind(&tutor_response)
.execute(&pool)
.await;
Ok(Json(ChatResponse {
response: tutor_response,
session_id,
}))
} }
pub async fn get_lesson_feedback( pub async fn get_lesson_feedback(
@@ -1607,11 +1722,43 @@ pub async fn get_lesson_feedback(
.unwrap_or("Buen trabajo completando la lección. Revisa tus resultados arriba.") .unwrap_or("Buen trabajo completando la lección. Revisa tus resultados arriba.")
.to_string(); .to_string();
Ok(Json(ChatResponse { response: tutor_response })) Ok(Json(ChatResponse {
response: tutor_response,
session_id: Uuid::nil(),
}))
} }
pub async fn ingest_lesson_knowledge(
pool: &PgPool,
org_id: Uuid,
lesson_id: Uuid,
content: &str,
) -> Result<(), sqlx::Error> {
// Split content into chunks of ~1000 characters for better RAG granularity
let chunks: Vec<&str> = content.as_bytes()
.chunks(1000)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect();
for chunk in chunks {
if chunk.trim().is_empty() { continue; }
sqlx::query(
"INSERT INTO knowledge_base (organization_id, source_type, source_id, content_chunk)
VALUES ($1, $2, $3, $4)"
)
.bind(org_id)
.bind("lesson_content")
.bind(Some(lesson_id))
.bind(chunk)
.execute(pool)
.await?;
}
Ok(())
}
fn extract_block_content(metadata: &Option<serde_json::Value>) -> String { fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
let mut block_content = String::new(); let mut block_content = String::new();
if let Some(meta) = metadata { if let Some(meta) = metadata {
@@ -1663,9 +1810,14 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
block_content.push_str(&format!("Instructions: {}\n", instructions)); block_content.push_str(&format!("Instructions: {}\n", instructions));
} }
} }
"document" => {
if let Some(desc) = block.get("description").and_then(|d| d.as_str()) {
block_content.push_str(&format!("Document Description: {}\n", desc));
}
}
"hotspot" => { "hotspot" => {
if let Some(description) = block.get("description").and_then(|d| d.as_str()) { 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(&format!("Hotspot Activity Description: {}\n", description));
} }
} }
_ => {} _ => {}
@@ -31,6 +31,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
const [transcriptOpen, setTranscriptOpen] = useState(true); const [transcriptOpen, setTranscriptOpen] = useState(true);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [userGrade, setUserGrade] = useState<UserGrade | null>(null); const [userGrade, setUserGrade] = useState<UserGrade | null>(null);
const [allGrades, setAllGrades] = useState<UserGrade[]>([]);
const { user } = useAuth(); const { user } = useAuth();
useEffect(() => { useEffect(() => {
@@ -45,6 +46,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
if (user) { if (user) {
const grades = await lmsApi.getUserGrades(user.id, params.id); const grades = await lmsApi.getUserGrades(user.id, params.id);
setAllGrades(grades);
const currentGrade = grades.find((g: UserGrade) => g.lesson_id === params.lessonId); const currentGrade = grades.find((g: UserGrade) => g.lesson_id === params.lessonId);
setUserGrade(currentGrade || null); setUserGrade(currentGrade || null);
} }
@@ -108,6 +110,15 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
{ ...userGrade?.metadata, block_scores: newBlockScores } { ...userGrade?.metadata, block_scores: newBlockScores }
); );
setUserGrade(res); setUserGrade(res);
setAllGrades(prev => {
const idx = prev.findIndex(g => g.lesson_id === params.lessonId);
if (idx >= 0) {
const newGrades = [...prev];
newGrades[idx] = res;
return newGrades;
}
return [...prev, res];
});
console.log(`Score for block ${blockId} submitted: ${score}`); console.log(`Score for block ${blockId} submitted: ${score}`);
} catch (err) { } catch (err) {
console.error(`Failed to submit score for block ${blockId}`, err); console.error(`Failed to submit score for block ${blockId}`, err);
@@ -115,6 +126,47 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
} }
}; };
const getLessonStatus = (l: Lesson) => {
const grade = allGrades.find(g => g.lesson_id === l.id);
const isCurrent = l.id === params.lessonId;
// Condition for Completed (Green)
// 1. Quizzes/Tests answered completely (score exists)
// 2. Non-repeatable lessons finished
if (grade && grade.attempts_count > 0) {
if (l.content_type === 'quiz' || l.content_type === 'activity' || !l.allow_retry) {
return 'completed'; // Green
}
if (l.allow_retry) {
return 'repeatable'; // Red
}
}
if (isCurrent || (grade && grade.attempts_count > 0)) {
return 'in-progress'; // Yellow
}
return 'not-started';
};
const getModuleStatus = (m: Module) => {
const statuses = m.lessons.map(l => getLessonStatus(l));
if (statuses.every(s => s === 'completed')) return 'completed';
if (statuses.some(s => s === 'in-progress' || s === 'completed' || s === 'repeatable')) return 'in-progress';
return 'not-started';
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-500 shadow-[0_0_10px_rgba(34,197,94,0.5)]';
case 'in-progress': return 'bg-yellow-500 shadow-[0_0_10px_rgba(234,179,8,0.5)]';
case 'repeatable': return 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]';
default: return 'bg-white/10';
}
};
return ( return (
<div className="flex h-[calc(100vh-64px)] overflow-hidden"> <div className="flex h-[calc(100vh-64px)] overflow-hidden">
{/* Navigation Sidebar */} {/* Navigation Sidebar */}
@@ -129,7 +181,10 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
<div className="flex-1 overflow-y-auto py-4 px-3 space-y-6"> <div className="flex-1 overflow-y-auto py-4 px-3 space-y-6">
{course.modules.map((module) => ( {course.modules.map((module) => (
<div key={module.id} className="space-y-2"> <div key={module.id} className="space-y-2">
<h4 className="px-3 text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">{module.title}</h4> <div className="flex items-center justify-between px-3 mb-2">
<h4 className="text-[10px] font-black uppercase tracking-widest text-gray-500">{module.title}</h4>
<div className={`w-1.5 h-1.5 rounded-full ${getStatusColor(getModuleStatus(module))}`} />
</div>
<div className="space-y-1"> <div className="space-y-1">
{module.lessons.map((l) => ( {module.lessons.map((l) => (
<Link <Link
@@ -138,8 +193,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
className={`sidebar-link ${l.id === params.lessonId ? 'sidebar-link-active' : 'sidebar-link-inactive'}`} className={`sidebar-link ${l.id === params.lessonId ? 'sidebar-link-active' : 'sidebar-link-inactive'}`}
> >
<div className="flex-1 truncate">{l.title}</div> <div className="flex-1 truncate">{l.title}</div>
{/* Placeholder for progress checkmark */} <div className={`w-2.5 h-2.5 rounded-full transition-all duration-500 ${getStatusColor(getLessonStatus(l))}`} />
<div className="w-4 h-4 rounded-full border border-white/10" />
</Link> </Link>
))} ))}
</div> </div>
@@ -173,9 +227,17 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
<div className="flex-1 overflow-y-auto px-6 py-12"> <div className="flex-1 overflow-y-auto px-6 py-12">
<div className="max-w-4xl mx-auto space-y-20 pb-40"> <div className="max-w-4xl mx-auto space-y-20 pb-40">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-blue-400"> <div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-blue-400">
<span>{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}</span> <span>{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}</span>
</div> </div>
<div className={`px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-widest text-white ${getStatusColor(getLessonStatus(lesson))}`}>
{getLessonStatus(lesson) === 'completed' && "Completada"}
{getLessonStatus(lesson) === 'in-progress' && "En Curso"}
{getLessonStatus(lesson) === 'repeatable' && "Repetible"}
{getLessonStatus(lesson) === 'not-started' && "No Iniciada"}
</div>
</div>
<h1 className="text-4xl font-black tracking-tighter text-white">{lesson.title}</h1> <h1 className="text-4xl font-black tracking-tighter text-white">{lesson.title}</h1>
</div> </div>
+15 -1
View File
@@ -16,8 +16,17 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Load session from localStorage on mount
const savedSession = localStorage.getItem(`tutor_session_${lessonId}`);
if (savedSession) {
setSessionId(savedSession);
}
}, [lessonId]);
useEffect(() => { useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -33,8 +42,13 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
setIsLoading(true); setIsLoading(true);
try { try {
const { response } = await lmsApi.chatWithTutor(lessonId, userMessage); const { response, session_id: newSessionId } = await lmsApi.chatWithTutor(lessonId, userMessage, sessionId || undefined);
setMessages(prev => [...prev, { role: 'tutor', content: response }]); setMessages(prev => [...prev, { role: 'tutor', content: response }]);
if (newSessionId && newSessionId !== sessionId) {
setSessionId(newSessionId);
localStorage.setItem(`tutor_session_${lessonId}`, newSessionId);
}
} catch (error) { } catch (error) {
console.error("Chat error:", error); console.error("Chat error:", error);
setMessages(prev => [...prev, { role: 'tutor', content: "Lo siento, hubo un error conectando con el tutor. Por favor intenta de nuevo." }]); setMessages(prev => [...prev, { role: 'tutor', content: "Lo siento, hubo un error conectando con el tutor. Por favor intenta de nuevo." }]);
+3 -3
View File
@@ -354,13 +354,13 @@ export const lmsApi = {
return res.json(); return res.json();
}); });
}, },
async chatWithTutor(lessonId: string, message: string): Promise<{ response: string }> { async chatWithTutor(lessonId: string, message: string, sessionId?: string): Promise<{ response: string, session_id: string }> {
return apiFetch(`/lessons/${lessonId}/chat`, { return apiFetch(`/lessons/${lessonId}/chat`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ message }) body: JSON.stringify({ message, session_id: sessionId })
}); });
}, },
async getLessonFeedback(lessonId: string): Promise<{ response: string }> { async getLessonFeedback(lessonId: string): Promise<{ response: string, session_id: string }> {
return apiFetch(`/lessons/${lessonId}/feedback`); return apiFetch(`/lessons/${lessonId}/feedback`);
} }
}; };