diff --git a/frontend/src/app/user/my-courses/page.tsx b/frontend/src/app/user/my-courses/page.tsx index 5817d241..db6f19f8 100644 --- a/frontend/src/app/user/my-courses/page.tsx +++ b/frontend/src/app/user/my-courses/page.tsx @@ -175,7 +175,8 @@ function MyCoursesContent() { return () => { cancelled = true; }; - }, [authLoading, isAuthenticated]); // Removed selectedId to prevent infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, isAuthenticated]); // selectedId intentionally omitted to prevent infinite loop // Load recommended courses immediately on mount useEffect(() => { diff --git a/frontend/src/app/user/my-quizzes/[quizId]/play/page.tsx b/frontend/src/app/user/my-quizzes/[quizId]/play/page.tsx index 7b94b34e..2c0d8e3c 100644 --- a/frontend/src/app/user/my-quizzes/[quizId]/play/page.tsx +++ b/frontend/src/app/user/my-quizzes/[quizId]/play/page.tsx @@ -227,7 +227,8 @@ export default function QuizPlayPage({ params }: PageProps) { }; load(); return () => { cancelled = true; }; - }, [quizId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [quizId]); // answerDisplayMode intentionally omitted const handleTimeUp = useCallback(() => { // Prevent duplicate execution @@ -269,7 +270,8 @@ export default function QuizPlayPage({ params }: PageProps) { // Reset flag after transition isProcessingTimeout.current = false; }, 1500); - }, [quiz, currentIndex, answers, timePerQuestion]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [quiz, currentIndex, answers, timePerQuestion]); // handleSubmit intentionally omitted to avoid circular dependency // Timer countdown useEffect(() => { diff --git a/frontend/src/app/user/ranking/page.tsx b/frontend/src/app/user/ranking/page.tsx index b7d87a0a..43813e11 100644 --- a/frontend/src/app/user/ranking/page.tsx +++ b/frontend/src/app/user/ranking/page.tsx @@ -11,7 +11,7 @@ export default function RankingPage() { const handleLogout = useCallback(() => router.push('/auth/login'), [router]); const { user, dashboardData, loading } = useDashboard(handleLogout); - const users = dashboardData?.info?.user_top_rank || []; + const users = useMemo(() => dashboardData?.info?.user_top_rank || [], [dashboardData]); // Find competitors (user + 1 above + 1 below) const competitors = useMemo(() => { diff --git a/frontend/src/app/user/tracking/page.tsx b/frontend/src/app/user/tracking/page.tsx index 3f8be2ff..92782621 100644 --- a/frontend/src/app/user/tracking/page.tsx +++ b/frontend/src/app/user/tracking/page.tsx @@ -44,9 +44,11 @@ function TrackingInner() { if (resp.status === 200 && Array.isArray(resp.data?.items)) { const items = resp.data.items as GenerationItem[]; items.sort((a, b) => { - if (!a.updated_at) return 1; - if (!b.updated_at) return -1; - return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); + const aTime = a.updated_at || a.last_updated; + const bTime = b.updated_at || b.last_updated; + if (!aTime) return 1; + if (!bTime) return -1; + return new Date(bTime).getTime() - new Date(aTime).getTime(); }); setCourseItems(items); } else if (resp.status === 401) { diff --git a/frontend/src/components/user/chatbot/LessonChatbot.tsx b/frontend/src/components/user/chatbot/LessonChatbot.tsx index 0206c407..aa9b5a97 100644 --- a/frontend/src/components/user/chatbot/LessonChatbot.tsx +++ b/frontend/src/components/user/chatbot/LessonChatbot.tsx @@ -23,6 +23,7 @@ export default function LessonChatbot({ lessonId, courseId }: ChatbotProps) { const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(false); const [pendingChatId, setPendingChatId] = useState(null); + const [pollStartTime, setPollStartTime] = useState(null); const messagesEndRef = useRef(null); const lastActivityRef = useRef(Date.now()); @@ -73,8 +74,27 @@ export default function LessonChatbot({ lessonId, courseId }: ChatbotProps) { useEffect(() => { if (!pendingChatId) return; + const POLLING_TIMEOUT = 180000; // 3 minutes (longer timeout for when course generation is running) + const pollAnswer = async () => { try { + // Check timeout + if (pollStartTime && Date.now() - pollStartTime > POLLING_TIMEOUT) { + setMessages(prev => [ + ...prev, + { + id: pendingChatId, + role: 'assistant', + content: 'Xin lỗi, hệ thống đang bận xử lý. Câu hỏi này có thể mất vài phút để trả lời. Bạn vui lòng thử lại sau nhé! 🙏', + timestamp: new Date(), + }, + ]); + setPendingChatId(null); + setIsLoading(false); + setPollStartTime(null); + return; + } + const response = await chatbotApi.getAnswer(pendingChatId); const data = response.data; @@ -90,6 +110,7 @@ export default function LessonChatbot({ lessonId, courseId }: ChatbotProps) { ]); setPendingChatId(null); setIsLoading(false); + setPollStartTime(null); } else if (data.status === 500) { const errorMessage = data.message || 'Xin lỗi, mình gặp lỗi khi xử lý câu hỏi của bạn. Bạn có thể thử lại không?'; setMessages(prev => [ @@ -103,11 +124,21 @@ export default function LessonChatbot({ lessonId, courseId }: ChatbotProps) { ]); setPendingChatId(null); setIsLoading(false); + setPollStartTime(null); + } + // For 202 (processing) or 404 (not yet created), continue polling + } catch (error: unknown) { + // If 404, treat as still processing (Lambda hasn't created record yet) + if (error && typeof error === 'object' && 'response' in error) { + const err = error as { response?: { status?: number } }; + if (err.response?.status === 404) { + return; // Continue polling + } } - } catch (error) { console.error('Error polling answer:', error); setPendingChatId(null); setIsLoading(false); + setPollStartTime(null); } }; @@ -116,10 +147,11 @@ export default function LessonChatbot({ lessonId, courseId }: ChatbotProps) { return () => { if (pollingIntervalRef.current) { + setPollStartTime(Date.now()); // Start timeout timer clearInterval(pollingIntervalRef.current); } }; - }, [pendingChatId]); + }, [pendingChatId, pollStartTime]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); diff --git a/frontend/src/components/user/create-course/SuccessStep.tsx b/frontend/src/components/user/create-course/SuccessStep.tsx index 56ca5292..1c13af6c 100644 --- a/frontend/src/components/user/create-course/SuccessStep.tsx +++ b/frontend/src/components/user/create-course/SuccessStep.tsx @@ -6,7 +6,7 @@ import { Progress } from '@/components/ui/progress'; import { CourseDraftState } from '@/types/create-course'; import { AgenticCourseResponse, AgenticCreateCourseResponse } from '@/lib/api/agentic'; import { courseApi } from '@/lib/api/course'; -import { CheckCircle, Clock, Zap, FileText, Hash } from 'lucide-react'; +import { CheckCircle, Clock, Zap, FileText, BookOpen, ChevronDown, ChevronUp } from 'lucide-react'; interface SuccessStepProps { draft: CourseDraftState; @@ -17,70 +17,75 @@ interface SuccessStepProps { type GenerationStatus = { course_id: string; + user_id?: string; + overall_status?: 'processing' | 'done' | 'error'; + error_message?: string; + title?: string; description?: string; - progress?: string; - title_ready?: boolean; - lessons_ready?: boolean; - final_ready?: boolean; - vectorized?: boolean; - lessons_count?: number; - lessons_planned?: number; - roadmap_count?: number; - final_count?: number; + vectorization_status?: string; + vectorization_chunks?: number; + + planning_status?: string; + planning_course_title?: string; + planning_roadmap_count?: number; + + lessons_status?: string; + lessons_completed?: number; + lessons_total?: number; + lessons_list?: Array<{ id: string; title: string }>; + + tests_status?: string; + tests_completed?: number; + tests_total?: number; + + start_timestamp?: number; updated_at?: string; + last_updated?: string; + end_timestamp?: number; }; function getGenerationStatus(status: GenerationStatus | null) { - // Always show 4 steps with default values, update when API returns const steps = [ { - key: 'vectorized', + key: 'vectorization', label: 'Phân tích tài liệu', - done: status?.vectorized || false, + done: status?.vectorization_status === 'done', description: 'Vectorize và index tài liệu vào OpenSearch', count: null, icon: FileText }, { - key: 'title_ready', - label: 'Tạo kế hoạch', - done: status?.title_ready || false, - description: 'Sinh tiêu đề, mô tả và roadmap học tập', - count: status?.roadmap_count, + key: 'planning', + label: 'Tạo kế hoạch khóa học', + done: status?.planning_status === 'done', + description: `Sinh tiêu đề, mô tả và roadmap học tập${status?.planning_roadmap_count ? ` (${status.planning_roadmap_count} modules)` : ''}`, + count: status?.planning_roadmap_count, icon: Zap }, { - key: 'lesson_planned', - label: 'Lập kế hoạch bài học', - done: (status?.title_ready && status?.lessons_planned) || false, - description: `Xác định số lượng bài học cần tạo${status?.lessons_planned ? ` (${status.lessons_planned} bài)` : ''}`, - count: status?.lessons_planned, - icon: Clock - }, - { - key: 'lessons_ready', + key: 'lessons', label: 'Tạo nội dung bài học', - done: status?.lessons_ready || false, - description: `Sinh nội dung chi tiết cho từng bài học${status?.lessons_count ? ` (${status.lessons_count}/${status.lessons_planned || 0} bài)` : ''}`, - count: status?.lessons_count, + done: status?.lessons_status === 'done', + description: `Sinh nội dung chi tiết cho từng bài học${status?.lessons_completed && status?.lessons_total ? ` (${status.lessons_completed}/${status.lessons_total} bài)` : ''}`, + count: status?.lessons_completed && status?.lessons_total ? `${status.lessons_completed}/${status.lessons_total}` : status?.lessons_total, icon: CheckCircle }, + { + key: 'tests', + label: 'Tạo câu hỏi kiểm tra', + done: status?.tests_status === 'done', + description: `Sinh câu hỏi trắc nghiệm cho từng bài học${status?.tests_completed && status?.tests_total ? ` (${status.tests_completed}/${status.tests_total} bài)` : ''}`, + count: status?.tests_completed && status?.tests_total ? `${status.tests_completed}/${status.tests_total}` : status?.tests_total, + icon: Clock + }, ]; const completedSteps = steps.filter(s => s.done).length; - const isComplete = completedSteps === steps.length; + const isComplete = status?.overall_status === 'done'; const currentStep = steps.find(s => !s.done); const progressPercent = (completedSteps / steps.length) * 100; - - let overallStatus: 'done' | 'processing' | 'error' = 'processing'; - if (status) { - if (isComplete || status.final_ready === true) { - overallStatus = 'done'; - } else if (status.final_ready === false && status.progress?.includes('error')) { - overallStatus = 'error'; - } - } + const overallStatus = status?.overall_status || 'processing'; return { steps, completedSteps, isComplete, currentStep, progressPercent, overallStatus }; } @@ -101,6 +106,7 @@ const isCourseResponse = (value: AgenticCreateCourseResponse | null): value is A export function SuccessStep({ draft, result, onRestart, onGoToCourses }: SuccessStepProps) { const [generationStatus, setGenerationStatus] = useState(null); const [isPolling, setIsPolling] = useState(false); + const [isLessonsExpanded, setIsLessonsExpanded] = useState(false); const lessons = useMemo(() => (isCourseResponse(result) ? result.course_lessons : []), [result]); const hasResult = isCourseResponse(result); @@ -144,7 +150,8 @@ export function SuccessStep({ draft, result, onRestart, onGoToCourses }: Success } else { setIsPolling(false); } - }, [courseId, status.overallStatus]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [courseId, status.overallStatus]); // fetchGenerationStatus intentionally omitted to prevent re-creation loop const renderContent = (content: string) => { @@ -217,9 +224,9 @@ export function SuccessStep({ draft, result, onRestart, onGoToCourses }: Success {/* Course Info - only show if loaded */} - {generationStatus?.title && ( + {(generationStatus?.planning_course_title || generationStatus?.title) && (
-

{generationStatus.title}

+

{generationStatus.planning_course_title || generationStatus.title}

{generationStatus.description && (

{generationStatus.description}

)} @@ -234,6 +241,19 @@ export function SuccessStep({ draft, result, onRestart, onGoToCourses }: Success
)} + {/* Error state */} + {status.overallStatus === 'error' && generationStatus?.error_message && ( +
+
+ +

Lỗi xảy ra

+
+

Quá trình tạo khóa học gặp sự cố

+

“{generationStatus.error_message}”

+

Vui lòng thử lại hoặc liên hệ hỗ trợ

+
+ )} + {/* Progress Bar */}
@@ -250,6 +270,9 @@ export function SuccessStep({ draft, result, onRestart, onGoToCourses }: Success
{status.steps.map((step) => { const Icon = step.icon; + const isLessonsStep = step.key === 'lessons'; + const hasLessons = isLessonsStep && generationStatus?.lessons_list && generationStatus.lessons_list.length > 0; + return (

{step.description}

+ + {/* Lessons List Expandable Section */} + {hasLessons && ( +
+ + + {isLessonsExpanded && ( +
+ {generationStatus.lessons_list?.map((lesson, idx) => ( +
+
+ {idx + 1} +
+
+

+ {lesson.title} +

+
+
+ ))} +
+ )} +
+ )}
{step.done ? ( @@ -306,37 +369,22 @@ export function SuccessStep({ draft, result, onRestart, onGoToCourses }: Success
{/* Additional Info */} - {generationStatus && ( -
-
- - Course ID: - - {courseId} - -
- {generationStatus.roadmap_count && ( -
- Roadmap: - {generationStatus.roadmap_count} bước -
- )} - {generationStatus.lessons_planned && ( -
- Số bài học: - - {generationStatus.lessons_count || 0}/{generationStatus.lessons_planned} bài - -
- )} - {generationStatus.final_count && ( -
- Tổng nội dung: - {generationStatus.final_count} items -
- )} +
+
+ Roadmap: + {generationStatus?.planning_roadmap_count || '—'}
- )} +
+ Số bài học: + + {generationStatus?.lessons_completed || 0}/{generationStatus?.lessons_total || '—'} bài + +
+
+ Câu hỏi kiểm tra: + {generationStatus?.tests_total || '—'} bài +
+
)} diff --git a/frontend/src/components/user/generation/GenerationCard.tsx b/frontend/src/components/user/generation/GenerationCard.tsx index 8b7dfb25..acce05d4 100644 --- a/frontend/src/components/user/generation/GenerationCard.tsx +++ b/frontend/src/components/user/generation/GenerationCard.tsx @@ -6,53 +6,71 @@ import { CheckCircle, Clock, Zap, FileText, User, CalendarClock } from "lucide-r export type GenerationItem = { course_id: string; user_id?: string; + overall_status?: 'processing' | 'done' | 'error'; + error_message?: string; + title?: string; description?: string; - progress?: string; - title_ready?: boolean; - lessons_ready?: boolean; - tests_ready?: boolean; - final_ready?: boolean; - vectorized?: boolean; - lessons_count?: number; - lessons_planned?: number; - roadmap_count?: number; - tests_count?: number; - final_count?: number; + vectorization_status?: string; + vectorization_chunks?: number; + + planning_status?: string; + planning_course_title?: string; + planning_roadmap_count?: number; + + lessons_status?: string; + lessons_completed?: number; + lessons_total?: number; + lessons_list?: Array<{ id: string; title: string }>; + + tests_status?: string; + tests_completed?: number; + tests_total?: number; + + start_timestamp?: number; updated_at?: string; + last_updated?: string; + end_timestamp?: number; }; function getGenerationStatus(item: GenerationItem) { const steps = [ - { key: 'vectorized', label: 'Vectorized', done: Boolean(item.vectorized), count: null }, - { key: 'title_ready', label: 'Plan', done: Boolean(item.title_ready), count: item.roadmap_count }, - { key: 'lessons_ready', label: 'Lessons', done: Boolean(item.lessons_ready), count: item.lessons_count }, - { key: 'tests_ready', label: 'Tests', done: Boolean(item.tests_ready), count: item.tests_count }, + { + key: 'vectorization', + label: 'Vectorized', + done: item.vectorization_status === 'done', + count: null + }, + { + key: 'planning', + label: 'Plan', + done: item.planning_status === 'done', + count: item.planning_roadmap_count + }, + { + key: 'lessons', + label: 'Lessons', + done: item.lessons_status === 'done', + count: item.lessons_completed && item.lessons_total ? `${item.lessons_completed}/${item.lessons_total}` : item.lessons_total + }, + { + key: 'tests', + label: 'Tests', + done: item.tests_status === 'done', + count: item.tests_completed && item.tests_total ? `${item.tests_completed}/${item.tests_total}` : item.tests_total + }, ]; const completedSteps = steps.filter(s => s.done).length; - const isComplete = completedSteps === steps.length; + const isComplete = item.overall_status === 'done'; const currentStep = steps.find(s => !s.done); const progressPercent = (completedSteps / steps.length) * 100; - - // Use final_ready as overall status indicator, but also check if all steps are done - let overallStatus: 'done' | 'processing' | 'error' = 'processing'; - if (isComplete || item.final_ready === true) { - overallStatus = 'done'; - } else if (item.final_ready === false && item.progress?.includes('error')) { - overallStatus = 'error'; - } + const overallStatus = item.overall_status || 'processing'; return { steps, completedSteps, isComplete, currentStep, progressPercent, overallStatus }; } -// Truncate text for display -function truncateText(text: string, maxLength: number) { - if (text.length <= maxLength) return text; - return text.slice(0, maxLength - 3) + "..."; -} - -// Format time difference +// Format time difference (prefers updated_at over last_updated) function getTimeElapsed(dateStr?: string): string { if (!dateStr) return "Không rõ"; @@ -96,12 +114,12 @@ export default function GenerationCard({ userNames?: Record; }) { const status = getGenerationStatus(item); - const displayTitle = item.title || "Đang tạo khóa học..."; - const displayDesc = item.description ? truncateText(item.description, 120) : null; - const timeElapsed = getTimeElapsed(item.updated_at); + const displayTitle = item.planning_course_title || "Đang tạo khóa học..."; + const displayDesc = item.error_message || null; + const timeElapsed = getTimeElapsed(item.updated_at || item.last_updated); const creatorName = item.user_id && userNames?.[item.user_id] ? userNames[item.user_id] : getUserDisplayName(item.user_id); - const lessonsInfo = item.lessons_planned - ? `${item.lessons_count || 0}/${item.lessons_planned}` + const lessonsInfo = item.lessons_total + ? `${item.lessons_completed || 0}/${item.lessons_total}` : null; return ( diff --git a/frontend/src/components/user/generation/GenerationDetailModal.tsx b/frontend/src/components/user/generation/GenerationDetailModal.tsx index c0996d3e..0f68e111 100644 --- a/frontend/src/components/user/generation/GenerationDetailModal.tsx +++ b/frontend/src/components/user/generation/GenerationDetailModal.tsx @@ -1,4 +1,5 @@ "use client"; +import { useState } from 'react'; import { Dialog, DialogContent, @@ -7,53 +8,46 @@ import { DialogDescription, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; -import { CheckCircle, Clock, Zap, Calendar, Hash } from "lucide-react"; +import { CheckCircle, Clock, Zap, Calendar, ChevronDown, ChevronUp, BookOpen } from "lucide-react"; import { GenerationItem } from "./GenerationCard"; function getDetailedStatus(item: GenerationItem) { const steps = [ { - key: 'vectorized', + key: 'vectorization', label: 'Phân tích tài liệu', - done: Boolean(item.vectorized), + done: item.vectorization_status === 'done', description: 'Vectorize và index tài liệu vào OpenSearch', count: null }, { - key: 'title_ready', + key: 'planning', label: 'Tạo kế hoạch khóa học', - done: Boolean(item.title_ready), - description: 'Sinh tiêu đề, mô tả và roadmap học tập', - count: item.roadmap_count + done: item.planning_status === 'done', + description: `Sinh tiêu đề, mô tả và roadmap học tập${item.planning_roadmap_count ? ` (${item.planning_roadmap_count} modules)` : ''}`, + count: item.planning_roadmap_count }, { - key: 'lessons_ready', - label: 'Lập kế hoạch bài học', - done: Boolean(item.lessons_ready), - description: `Xác định số lượng bài học cần tạo${item.lessons_count ? ` (${item.lessons_count} bài)` : ''}`, - count: item.lessons_count + key: 'lessons', + label: 'Tạo nội dung bài học', + done: item.lessons_status === 'done', + description: `Sinh nội dung chi tiết cho từng bài học${item.lessons_completed && item.lessons_total ? ` (${item.lessons_completed}/${item.lessons_total} bài hoàn thành)` : ''}`, + count: item.lessons_completed && item.lessons_total ? `${item.lessons_completed}/${item.lessons_total}` : item.lessons_total }, { - key: 'tests_ready', - label: 'Tạo nội dung bài học', - done: Boolean(item.tests_ready), - description: `Sinh nội dung chi tiết cho từng bài học${item.lessons_count ? ` (${item.lessons_count} bài hoàn thành)` : ''}`, - count: null + key: 'tests', + label: 'Tạo câu hỏi kiểm tra', + done: item.tests_status === 'done', + description: `Sinh câu hỏi trắc nghiệm cho từng bài học${item.tests_completed && item.tests_total ? ` (${item.tests_completed}/${item.tests_total} bài)` : ''}`, + count: item.tests_completed && item.tests_total ? `${item.tests_completed}/${item.tests_total}` : item.tests_total }, ]; const completedSteps = steps.filter(s => s.done).length; - const isComplete = completedSteps === steps.length; + const isComplete = item.overall_status === 'done'; const currentStep = steps.find(s => !s.done); const progressPercent = (completedSteps / steps.length) * 100; - - // Use final_ready as overall status, but also check if all steps are done - let overallStatus: 'done' | 'processing' | 'error' = 'processing'; - if (isComplete || item.final_ready === true) { - overallStatus = 'done'; - } else if (item.final_ready === false && item.progress?.includes('error')) { - overallStatus = 'error'; - } + const overallStatus = item.overall_status || 'processing'; return { steps, completedSteps, isComplete, currentStep, progressPercent, overallStatus }; } @@ -67,10 +61,12 @@ export default function GenerationDetailModal({ isOpen: boolean; onClose: () => void; }) { + const [isLessonsExpanded, setIsLessonsExpanded] = useState(false); + if (!item) return null; const status = getDetailedStatus(item); - const updated = item.updated_at ? new Date(item.updated_at).toLocaleString('vi-VN') : "Chưa có thông tin"; + const updated = (item.updated_at || item.last_updated) ? new Date(item.updated_at || item.last_updated || '').toLocaleString('vi-VN') : "Chưa có thông tin"; return ( @@ -94,9 +90,9 @@ export default function GenerationDetailModal({
{/* Course Info */}
- {item.title && ( + {item.planning_course_title && (
-

{item.title}

+

{item.planning_course_title}

)} {item.description && ( @@ -107,31 +103,24 @@ export default function GenerationDetailModal({
-
- - Course ID: - - {item.course_id} - -
- {item.lessons_planned && ( + {item.planning_roadmap_count && (
- Số bài học: - - {item.lessons_count || 0}/{item.lessons_planned} bài - + Roadmap: + {item.planning_roadmap_count} modules
)} - {item.roadmap_count && ( + {item.lessons_total && (
- Roadmap: - {item.roadmap_count} bước + Số bài học: + + {item.lessons_completed || 0}/{item.lessons_total} bài +
)} - {item.final_count !== undefined && ( + {item.tests_total !== undefined && (
Câu hỏi kiểm tra: - {item.final_count} câu + {item.tests_total} bài
)}
@@ -199,24 +188,22 @@ export default function GenerationDetailModal({

Lỗi xảy ra

Quá trình tạo khóa học gặp sự cố

+ {item.error_message && ( +

“{item.error_message}”

+ )}

Vui lòng thử lại hoặc liên hệ hỗ trợ

)} - {/* Progress Text */} - {item.progress && ( -
-

Trạng thái hiện tại

-

“{item.progress}”

-
- )} - {/* Detailed Steps */}

Chi tiết các bước

{status.steps.map((step, index) => { const isActive = !status.isComplete && status.currentStep?.key === step.key; + const isLessonsStep = step.key === 'lessons'; + const hasLessons = isLessonsStep && item.lessons_list && item.lessons_list.length > 0; + return (
{step.description}

+ + {/* Lessons List Expandable Section */} + {hasLessons && ( +
+ + + {isLessonsExpanded && ( +
+ {item.lessons_list?.map((lesson, idx) => ( +
+
+ {idx + 1} +
+
+

+ {lesson.title} +

+
+
+ ))} +
+ )} +
+ )}
diff --git a/services/course-service/src/controllers/chatbot_controller.py b/services/course-service/src/controllers/chatbot_controller.py index 9ce2a464..cdd40641 100644 --- a/services/course-service/src/controllers/chatbot_controller.py +++ b/services/course-service/src/controllers/chatbot_controller.py @@ -42,6 +42,40 @@ def submit_question( chat_id = request.chat_id or str(uuid.uuid4()) try: + # Create placeholder record in DynamoDB immediately to prevent 404 + dynamodb = _get_dynamodb_client() + table_name = _get_chat_table_name() + now = datetime.now(timezone.utc).isoformat() + + try: + # Create comprehensive placeholder to prevent Lambda overwrite + item = { + "chat_id": {"S": chat_id}, + "user_id": {"S": user_id}, + "message": {"S": request.message}, + "lesson_id": {"S": request.lesson_id or ""}, + "course_id": {"S": request.course_id or ""}, + "status": {"S": "processing"}, + "created_at": {"S": now}, + "updated_at": {"S": now}, + "response": {"S": ""}, # Empty response initially + "error_message": {"S": ""}, + } + + # Add context_chunks as empty list in DynamoDB attribute value format + item["context_chunks"] = {"L": []} # type: ignore + + print(f"Creating placeholder record for chat_id: {chat_id} in table: {table_name}") + dynamodb.put_item( + TableName=table_name, + Item=item + ) + print(f"Placeholder created successfully for chat_id: {chat_id}") + except ClientError as db_error: + # Log but don't fail - Lambda will create it later + print(f"Warning: Failed to create placeholder record for {chat_id}: {db_error}") + + # Send to SQS for async processing send_chatbot_question( queue_url=self.sqs_queue_url, chat_id=chat_id, diff --git a/services/course-service/src/controllers/course_controller.py b/services/course-service/src/controllers/course_controller.py index 742ae055..7490305b 100644 --- a/services/course-service/src/controllers/course_controller.py +++ b/services/course-service/src/controllers/course_controller.py @@ -1391,7 +1391,7 @@ def submit_assessment_controller(request: Request, course_id: str, lesson_id: st continue selected = int(ans.answer) db_answer = cast(int, getattr(qa, "answer")) - correct = (db_answer % 4) + 1 + correct = db_answer is_correct = selected == correct if is_correct: correct_count += 1 @@ -1466,7 +1466,7 @@ def submit_assessment_controller(request: Request, course_id: str, lesson_id: st "require_exp": None, "exp_needed_for_next": None, "rank": None, - "award_failed": True, # Signal to FE that exp wasn't saved + "award_failed": True, } elif passed and not is_newly_completed: logger.info(f"⚠️ Lesson {lesson_id} retaken for practice - no exp awarded (already completed before)") diff --git a/services/course-service/src/services/status_service.py b/services/course-service/src/services/status_service.py index 4cbd7d9e..7520db33 100644 --- a/services/course-service/src/services/status_service.py +++ b/services/course-service/src/services/status_service.py @@ -56,24 +56,76 @@ def fetch_generation_status(course_id: str) -> Optional[Dict[str, Any]]: if table is None: return None try: - resp = table.get_item(Key={"course_id": course_id}) + resp = table.get_item(Key={"course_id": course_id}, ConsistentRead=True) item = resp.get("Item") if not item: return None - plan = bool(item.get("title_ready", False)) - lessons = bool(item.get("lessons_ready", False)) - final = bool(item.get("final_ready", False)) - vectorized = bool(item.get("vectorized", False)) - overall = plan and lessons and final + + # Extract status fields - read directly from new schema + vectorization_status = item.get("vectorization_status", "pending") + planning_status = item.get("planning_status", "pending") + lessons_status = item.get("lessons_status", "pending") + tests_status = item.get("tests_status", "pending") + + # If planning or lessons started, vectorization must be done + if planning_status == "pending" or lessons_status != "pending": + vectorization_status = "done" + + # Read overall_status directly from DynamoDB if available + overall_status = item.get("overall_status") + if not overall_status: + # Fallback: calculate from individual statuses + all_done = ( + vectorization_status == "done" and + planning_status == "done" and + lessons_status == "done" and + tests_status == "done" + ) + overall_status = "done" if all_done else "processing" + if item.get("error_message"): + overall_status = "error" + + # Build lessons list + lessons_list = item.get("lessons_list", []) + lessons_completed = item.get("lessons_completed", 0) + lessons_total = item.get("lessons_total") or item.get("roadmap_count") or 0 + + # Fallback: calculate from lessons_data if lessons_list is empty + if not lessons_list and item.get("lessons_data"): + for lesson in item.get("lessons_data", []): + lessons_list.append({ + "id": lesson.get("id", ""), + "title": lesson.get("title", "") + }) + if lesson.get("completed"): + lessons_completed += 1 + elif not lessons_completed and lessons_status == "done": + lessons_completed = lessons_total + return { - "status": overall, - "vectorized": vectorized, - "generated_plan": plan, - "generated_lessons": lessons, - "generated_final_test": final, - # Optional extras to help the UI if needed - "progress": item.get("progress"), + "overall_status": overall_status, + "error_message": item.get("error_message", ""), + + "vectorization_status": vectorization_status, + "vectorization_chunks": item.get("vectorization_chunks", 0), + + "planning_status": planning_status, + "planning_course_title": item.get("planning_course_title") or item.get("title", ""), + "planning_roadmap_count": item.get("planning_roadmap_count") or item.get("roadmap_count", 0), + + "lessons_status": lessons_status, + "lessons_completed": lessons_completed, + "lessons_total": lessons_total, + "lessons_list": lessons_list, + + "tests_status": tests_status, + "tests_completed": item.get("tests_completed") or (lessons_total if tests_status == "done" else 0), + "tests_total": item.get("tests_total") or lessons_total, + + "start_timestamp": item.get("start_timestamp"), "updated_at": item.get("updated_at"), + "last_updated": item.get("updated_at"), + "end_timestamp": item.get("end_timestamp"), } except (ClientError, BotoCoreError) as e: logger.error("DynamoDB get_item error: %s", e) @@ -98,33 +150,76 @@ def fetch_user_generations(user_id: str) -> Optional[List[Dict[str, Any]]]: scan_kwargs = {"FilterExpression": Attr("user_id").eq(str(user_id))} resp = table.scan(**scan_kwargs) items.extend(resp.get("Items", []) or []) - # Handle pagination while "LastEvaluatedKey" in resp: resp = table.scan(ExclusiveStartKey=resp["LastEvaluatedKey"], **scan_kwargs) items.extend(resp.get("Items", []) or []) - # Normalize a subset of fields for frontend normalized: List[Dict[str, Any]] = [] for it in items: - normalized.append( - { - "course_id": it.get("course_id"), - "user_id": it.get("user_id"), - "title": it.get("title"), - "description": it.get("description"), - "progress": it.get("progress"), - "title_ready": bool(it.get("title_ready", False)), - "lessons_ready": bool(it.get("lessons_ready", False)), - "tests_ready": bool(it.get("tests_ready", False)), - "final_ready": bool(it.get("final_ready", False)), - "vectorized": bool(it.get("vectorized", False)), - "lessons_count": it.get("lessons_count"), - "lessons_planned": it.get("lessons_planned"), - "roadmap_count": it.get("roadmap_count"), - "tests_count": it.get("lessons_count"), - "updated_at": it.get("updated_at"), - } - ) + vectorization_status = it.get("vectorization_status", "pending") + planning_status = it.get("planning_status", "pending") + lessons_status = it.get("lessons_status", "pending") + tests_status = it.get("tests_status", "pending") + + if planning_status == "pending" or lessons_status != "pending": + vectorization_status = "done" + + overall_status = it.get("overall_status") + if not overall_status: + all_done = ( + vectorization_status == "done" and + planning_status == "done" and + lessons_status == "done" and + tests_status == "done" + ) + overall_status = "done" if all_done else "processing" + if it.get("error_message"): + overall_status = "error" + + # Build lessons list + lessons_list = it.get("lessons_list", []) + lessons_completed = it.get("lessons_completed", 0) + lessons_total = it.get("lessons_total") or it.get("roadmap_count") or 0 + + # Fallback: calculate from lessons_data if lessons_list is empty + if not lessons_list and it.get("lessons_data"): + for lesson in it.get("lessons_data", []): + lessons_list.append({ + "id": lesson.get("id", ""), + "title": lesson.get("title", "") + }) + if lesson.get("completed"): + lessons_completed += 1 + elif not lessons_completed and lessons_status == "done": + lessons_completed = lessons_total + + normalized.append({ + "course_id": it.get("course_id"), + "user_id": it.get("user_id"), + "overall_status": overall_status, + "error_message": it.get("error_message", ""), + + "vectorization_status": vectorization_status, + "vectorization_chunks": it.get("vectorization_chunks", 0), + + "planning_status": planning_status, + "planning_course_title": it.get("planning_course_title") or it.get("title", ""), + "planning_roadmap_count": it.get("planning_roadmap_count") or it.get("roadmap_count", 0), + + "lessons_status": lessons_status, + "lessons_completed": lessons_completed, + "lessons_total": lessons_total, + "lessons_list": lessons_list, + + "tests_status": tests_status, + "tests_completed": it.get("tests_completed") or (lessons_total if tests_status == "done" else 0), + "tests_total": it.get("tests_total") or lessons_total, + + "start_timestamp": it.get("start_timestamp"), + "updated_at": it.get("updated_at"), + "last_updated": it.get("updated_at"), + "end_timestamp": it.get("end_timestamp"), + }) return normalized except (ClientError, BotoCoreError) as e: logger.error("DynamoDB scan error: %s", e) diff --git a/services/quiz-service/src/services/status_service.py b/services/quiz-service/src/services/status_service.py index 03f7d2f1..284cf4f0 100644 --- a/services/quiz-service/src/services/status_service.py +++ b/services/quiz-service/src/services/status_service.py @@ -80,6 +80,10 @@ def fetch_generation_status(quiz_id: str) -> Optional[Dict[str, Any]]: plan_ready = bool(item.get("plan_ready", False) or item.get("title_ready", False)) questions_ready = bool(item.get("questions_ready", False) or item.get("cards_ready", False)) + # If questions started, vectorization must be done + if questions_ready: + vectorized = True + # Status from agentic-service tracking status = item.get("status", "unknown") progress_percentage = int(item.get("progress_percentage", 0)) @@ -148,6 +152,10 @@ def fetch_user_quiz_generations(user_id: str) -> Optional[List[Dict[str, Any]]]: plan_ready = bool(it.get("plan_ready", False) or it.get("title_ready", False)) questions_ready = bool(it.get("questions_ready", False) or it.get("cards_ready", False)) + # If questions started, vectorization must be done + if questions_ready: + vectorized = True + normalized.append({ "quiz_id": it.get("quiz_id"), "user_id": it.get("user_id"),