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:
@@ -31,6 +31,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
const [transcriptOpen, setTranscriptOpen] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [userGrade, setUserGrade] = useState<UserGrade | null>(null);
|
||||
const [allGrades, setAllGrades] = useState<UserGrade[]>([]);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,6 +46,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
|
||||
if (user) {
|
||||
const grades = await lmsApi.getUserGrades(user.id, params.id);
|
||||
setAllGrades(grades);
|
||||
const currentGrade = grades.find((g: UserGrade) => g.lesson_id === params.lessonId);
|
||||
setUserGrade(currentGrade || null);
|
||||
}
|
||||
@@ -108,6 +110,15 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
{ ...userGrade?.metadata, block_scores: newBlockScores }
|
||||
);
|
||||
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}`);
|
||||
} catch (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 (
|
||||
<div className="flex h-[calc(100vh-64px)] overflow-hidden">
|
||||
{/* 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">
|
||||
{course.modules.map((module) => (
|
||||
<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">
|
||||
{module.lessons.map((l) => (
|
||||
<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'}`}
|
||||
>
|
||||
<div className="flex-1 truncate">{l.title}</div>
|
||||
{/* Placeholder for progress checkmark */}
|
||||
<div className="w-4 h-4 rounded-full border border-white/10" />
|
||||
<div className={`w-2.5 h-2.5 rounded-full transition-all duration-500 ${getStatusColor(getLessonStatus(l))}`} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -173,8 +227,16 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
<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="space-y-4">
|
||||
<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>
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,17 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(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(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
@@ -33,8 +42,13 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
setIsLoading(true);
|
||||
|
||||
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 }]);
|
||||
|
||||
if (newSessionId && newSessionId !== sessionId) {
|
||||
setSessionId(newSessionId);
|
||||
localStorage.setItem(`tutor_session_${lessonId}`, newSessionId);
|
||||
}
|
||||
} catch (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." }]);
|
||||
@@ -95,8 +109,8 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
|
||||
{msg.role === 'user' ? <User size={16} /> : <Bot size={16} />}
|
||||
</div>
|
||||
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed ${msg.role === 'user'
|
||||
? 'bg-blue-600 text-white rounded-tr-none'
|
||||
: 'bg-white/5 text-gray-200 border border-white/5 rounded-tl-none'
|
||||
? 'bg-blue-600 text-white rounded-tr-none'
|
||||
: 'bg-white/5 text-gray-200 border border-white/5 rounded-tl-none'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
|
||||
@@ -354,13 +354,13 @@ export const lmsApi = {
|
||||
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`, {
|
||||
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`);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user