Spaces:
Runtime error
Runtime error
| """ | |
| ThutoAI - Complete School Assistant with Teacher Assignments & Analytics Dashboard | |
| Meaning: "Thuto" = Learning/Education (Setswana β used for branding only) | |
| β Teacher Assignment Posting β Assign work to class groups | |
| β π Analytics Dashboard β Track student engagement, assignments, groups | |
| β π Dark Mode + πΌοΈ Profile Pictures | |
| β ποΈ Voice Input + π Assignment Tracker + π₯ Class Groups | |
| β All deprecation warnings fixed (Gradio 4.0+ compatible) | |
| β Fully commented | |
| """ | |
| import os | |
| import gradio as gr | |
| from datetime import datetime, timedelta | |
| from typing import List, Dict, Optional | |
| import time | |
| import json | |
| import base64 | |
| from collections import Counter | |
| # ==================== STUDENT SERVICE ==================== | |
| class StudentService: | |
| """Manages student accounts, chat history, files, assignments, groups, and profile pictures.""" | |
| def __init__(self): | |
| self.students = { | |
| "student1": {"password": "pass123", "name": "John Doe", "avatar": None}, | |
| "student2": {"password": "pass456", "name": "Jane Smith", "avatar": None}, | |
| "student3": {"password": "pass789", "name": "Alex Johnson", "avatar": None} | |
| } | |
| self.student_sessions = { | |
| "student1": { | |
| "chat_history": [], | |
| "files": [], | |
| "assignments": [ | |
| {"id": 1, "title": "Math Quiz", "due_date": "2025-04-25", "course": "MATH10A", "status": "pending", "assigned_by": "Mr. Smith"}, | |
| {"id": 2, "title": "Science Lab Report", "due_date": "2025-04-30", "course": "SCI11B", "status": "pending", "assigned_by": "Dr. Lee"} | |
| ], | |
| "groups": ["MATH10A", "SCI11B"], | |
| "dark_mode": False | |
| }, | |
| "student2": { | |
| "chat_history": [], | |
| "files": [], | |
| "assignments": [ | |
| {"id": 3, "title": "History Essay", "due_date": "2025-04-22", "course": "HIST9A", "status": "overdue", "assigned_by": "Ms. Brown"}, | |
| {"id": 4, "title": "English Reading", "due_date": "2025-04-28", "course": "ENG10A", "status": "pending", "assigned_by": "Mr. White"} | |
| ], | |
| "groups": ["HIST9A", "ENG10A"], | |
| "dark_mode": True | |
| }, | |
| "student3": { | |
| "chat_history": [], | |
| "files": [], | |
| "assignments": [], | |
| "groups": ["MATH10A", "ENG10A"], | |
| "dark_mode": False | |
| } | |
| } | |
| self.valid_groups = ["MATH10A", "MATH10B", "SCI11A", "SCI11B", "ENG10A", "ENG10B", "HIST9A", "HIST9B"] | |
| self.all_assignments = [] # Master list for analytics | |
| def register_student(self, username: str, password: str, name: str) -> str: | |
| if not username or not password or not name: | |
| return "β οΈ All fields required." | |
| if username in self.students: | |
| return "β οΈ Username already exists." | |
| self.students[username] = {"password": password, "name": name, "avatar": None} | |
| self.student_sessions[username] = { | |
| "chat_history": [], | |
| "files": [], | |
| "assignments": [], | |
| "groups": [], | |
| "dark_mode": False | |
| } | |
| return "β Account created! Please log in." | |
| def authenticate_student(self, username: str, password: str) -> Optional[Dict]: | |
| student = self.students.get(username) | |
| if student and student["password"] == password: | |
| if username not in self.student_sessions: | |
| self.student_sessions[username] = { | |
| "chat_history": [], | |
| "files": [], | |
| "assignments": [], | |
| "groups": [], | |
| "dark_mode": False | |
| } | |
| return { | |
| "name": student["name"], | |
| "avatar": student["avatar"], | |
| "dark_mode": self.student_sessions[username]["dark_mode"] | |
| } | |
| return None | |
| def update_dark_mode(self, username: str, is_dark: bool): | |
| if username in self.student_sessions: | |
| self.student_sessions[username]["dark_mode"] = is_dark | |
| def update_avatar(self, username: str, avatar_path: str): | |
| if username in self.students and avatar_path: | |
| self.students[username]["avatar"] = avatar_path | |
| def get_chat_history(self, username: str) -> List: | |
| return self.student_sessions.get(username, {}).get("chat_history", []) | |
| def add_to_chat_history(self, username: str, user_msg: str, bot_reply: str): | |
| if username in self.student_sessions: | |
| self.student_sessions[username]["chat_history"].append((user_msg, bot_reply)) | |
| def get_files(self, username: str) -> List: | |
| return self.student_sessions.get(username, {}).get("files", []) | |
| def add_file(self, username: str, filename: str): | |
| if username in self.student_sessions: | |
| self.student_sessions[username]["files"].append({ | |
| "name": filename, | |
| "uploaded_at": datetime.now().strftime("%Y-%m-%d %H:%M") | |
| }) | |
| def get_assignments(self, username: str) -> List: | |
| """Get assignments for groups student has joined.""" | |
| if username not in self.student_sessions: | |
| return [] | |
| student_groups = set(self.student_sessions[username]["groups"]) | |
| all_assignments = [] | |
| for user_data in self.student_sessions.values(): | |
| for assignment in user_data.get("assignments", []): | |
| if assignment["course"] in student_groups: | |
| all_assignments.append(assignment) | |
| # Remove duplicates by ID | |
| seen = set() | |
| unique_assignments = [] | |
| for assignment in all_assignments: | |
| if assignment["id"] not in seen: | |
| seen.add(assignment["id"]) | |
| unique_assignments.append(assignment) | |
| return sorted(unique_assignments, key=lambda x: x["due_date"]) | |
| def get_groups(self, username: str) -> List: | |
| return self.student_sessions.get(username, {}).get("groups", []) | |
| def join_group(self, username: str, group_code: str) -> str: | |
| if group_code.upper() not in self.valid_groups: | |
| return "β Invalid group code. Ask your teacher for the correct code." | |
| if username in self.student_sessions: | |
| if group_code.upper() not in self.student_sessions[username]["groups"]: | |
| self.student_sessions[username]["groups"].append(group_code.upper()) | |
| return f"β Joined group: {group_code.upper()}" | |
| return "β Login required." | |
| def leave_group(self, username: str, group_code: str) -> str: | |
| if username in self.student_sessions: | |
| if group_code.upper() in self.student_sessions[username]["groups"]: | |
| self.student_sessions[username]["groups"].remove(group_code.upper()) | |
| return f"β Left group: {group_code.upper()}" | |
| return "β Group not found or not joined." | |
| # Analytics methods | |
| def get_total_students(self) -> int: | |
| return len(self.students) | |
| def get_active_students(self) -> int: | |
| return len([s for s in self.student_sessions.keys() if len(self.student_sessions[s]["assignments"]) > 0]) | |
| def get_total_assignments(self) -> int: | |
| total = 0 | |
| for user_data in self.student_sessions.values(): | |
| total += len(user_data.get("assignments", [])) | |
| return total | |
| def get_completed_assignments(self) -> int: | |
| completed = 0 | |
| for user_data in self.student_sessions.values(): | |
| for assignment in user_data.get("assignments", []): | |
| if assignment["status"] == "completed": | |
| completed += 1 | |
| return completed | |
| def get_group_popularity(self) -> Dict: | |
| all_groups = [] | |
| for user_data in self.student_sessions.values(): | |
| all_groups.extend(user_data.get("groups", [])) | |
| return dict(Counter(all_groups)) | |
| def get_recent_activity(self) -> List: | |
| activity = [] | |
| for username, data in self.student_sessions.items(): | |
| if data["assignments"]: | |
| latest = max(data["assignments"], key=lambda x: x.get("assigned_date", "2025-01-01")) | |
| activity.append({ | |
| "student": self.students[username]["name"], | |
| "last_assignment": latest["title"], | |
| "date": latest.get("assigned_date", "Unknown") | |
| }) | |
| return sorted(activity, key=lambda x: x["date"], reverse=True)[:5] | |
| # Initialize student service | |
| student_service = StudentService() | |
| # ==================== SCHOOL SERVICE ==================== | |
| class SchoolService: | |
| """Handles announcements, AI context, and shared assignments.""" | |
| def __init__(self): | |
| self.announcements = [ | |
| { | |
| "id": 1, | |
| "title": "Math Final Exam", | |
| "content": "Covers chapters 1-5. Bring calculator and ruler.", | |
| "course": "Mathematics", | |
| "date": "2025-04-15", | |
| "priority": "high", | |
| "posted_by": "Mr. Smith", | |
| "views": 45 | |
| }, | |
| { | |
| "id": 2, | |
| "title": "Science Project Due", | |
| "content": "Submit your ecology report by Friday. Include bibliography.", | |
| "course": "Science", | |
| "date": "2025-04-12", | |
| "priority": "normal", | |
| "posted_by": "Dr. Lee", | |
| "views": 32 | |
| }, | |
| { | |
| "id": 3, | |
| "title": "Library Extended Hours", | |
| "content": "Open until 9 PM during exam week. Quiet study zones available.", | |
| "course": "General", | |
| "date": "2025-04-10", | |
| "priority": "low", | |
| "posted_by": "Librarian", | |
| "views": 67 | |
| }, | |
| ] | |
| self.courses = ["All", "Mathematics", "Science", "English", "History", "General"] | |
| self.total_announcements = len(self.announcements) | |
| self.total_files = 0 | |
| self.assignment_id_counter = 5 # Start after existing assignments | |
| def get_announcements(self, course_filter: str = "All") -> List[Dict]: | |
| if course_filter == "All": | |
| return self.announcements | |
| return [a for a in self.announcements if a["course"] == course_filter] | |
| def add_announcement(self, title: str, content: str, course: str, priority: str, posted_by: str = "Admin"): | |
| new_id = max([a["id"] for a in self.announcements], default=0) + 1 | |
| self.announcements.append({ | |
| "id": new_id, | |
| "title": title, | |
| "content": content, | |
| "course": course, | |
| "date": datetime.now().strftime("%Y-%m-%d"), | |
| "priority": priority, | |
| "posted_by": posted_by, | |
| "views": 0 | |
| }) | |
| self.total_announcements += 1 | |
| def get_school_context_for_ai(self) -> str: | |
| context = "Current School Announcements:\n" | |
| for ann in self.announcements[-5:]: | |
| context += f"- [{ann['course']}] {ann['title']}: {ann['content'][:80]}... (Priority: {ann['priority']})\n" | |
| return context | |
| def get_stats(self) -> Dict: | |
| return { | |
| "total_announcements": self.total_announcements, | |
| "total_files": self.total_files, | |
| "active_courses": len(set(a["course"] for a in self.announcements if a["course"] != "General")), | |
| "high_priority": len([a for a in self.announcements if a["priority"] == "high"]), | |
| "total_views": sum(a["views"] for a in self.announcements) | |
| } | |
| def create_assignment(self, title: str, description: str, due_date: str, course: str, assigned_by: str) -> int: | |
| """Create assignment for a specific class group.""" | |
| assignment_id = self.assignment_id_counter | |
| self.assignment_id_counter += 1 | |
| new_assignment = { | |
| "id": assignment_id, | |
| "title": title, | |
| "description": description, | |
| "due_date": due_date, | |
| "course": course, | |
| "assigned_by": assigned_by, | |
| "assigned_date": datetime.now().strftime("%Y-%m-%d"), | |
| "status": "pending" | |
| } | |
| # Assign to all students in this group | |
| for username, data in student_service.student_sessions.items(): | |
| if course in data["groups"]: | |
| data["assignments"].append(new_assignment.copy()) | |
| return assignment_id | |
| school_service = SchoolService() | |
| # ==================== ADMIN SERVICE ==================== | |
| class AdminService: | |
| """Handles teacher and admin authentication.""" | |
| def __init__(self): | |
| self.teachers = { | |
| "[email protected]": {"password": "password123", "name": "Mr. Smith", "role": "teacher"}, | |
| "[email protected]": {"password": "science123", "name": "Dr. Lee", "role": "teacher"}, | |
| "[email protected]": {"password": "letmein", "name": "Admin", "role": "admin"} | |
| } | |
| def authenticate(self, username: str, password: str) -> Optional[Dict]: | |
| user = self.teachers.get(username) | |
| if user and user["password"] == password: | |
| return {"name": user["name"], "role": user["role"]} | |
| return None | |
| admin_service = AdminService() | |
| # ==================== OPENAI SETUP ==================== | |
| OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") | |
| USE_OPENAI = bool(OPENAI_API_KEY) | |
| if USE_OPENAI: | |
| try: | |
| from openai import OpenAI | |
| client = OpenAI(api_key=OPENAI_API_KEY) | |
| except Exception as e: | |
| print(f"β οΈ OpenAI error: {e}") | |
| USE_OPENAI = False | |
| else: | |
| print("β οΈ OPENAI_API_KEY not set. Using mock responses.") | |
| # ==================== AI CHAT FUNCTION ==================== | |
| def ai_chat(message: str, history: List, username: str = "guest") -> tuple: | |
| """Generate AI response with loading state and save to history.""" | |
| if not message.strip(): | |
| return history, "" | |
| thinking_msg = "π€ ThutoAI is thinking..." | |
| history.append((message, thinking_msg)) | |
| yield history, "" | |
| if USE_OPENAI: | |
| try: | |
| system_prompt = f"""You are ThutoAI, a friendly and knowledgeable AI assistant for students. | |
| Context from school: | |
| {school_service.get_school_context_for_ai()} | |
| Guidelines: | |
| - Be encouraging, clear, and concise. | |
| - Use emojis sparingly to keep it fun πππ― | |
| - If asked about deadlines or exams, refer to context. | |
| - Offer study tips if appropriate. | |
| - Never invent facts β say 'I don't know' if unsure.""" | |
| response = client.chat.completions.create( | |
| model="gpt-3.5-turbo", | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": message} | |
| ], | |
| temperature=0.7, | |
| max_tokens=500 | |
| ) | |
| reply = response.choices[0].message.content.strip() | |
| keywords = ["exam", "test", "due", "assignment", "deadline", "when", "what", "grade", "score"] | |
| if any(kw in message.lower() for kw in keywords): | |
| matches = school_service.get_announcements() | |
| relevant = [a for a in matches if any(kw in a["title"].lower() or kw in a["content"].lower() for kw in keywords)] | |
| if relevant: | |
| reply += "\n\nπ **Quick Info from School:**" | |
| for ann in relevant[:2]: | |
| emoji = "π¨" if ann["priority"] == "high" else "π" if ann["priority"] == "normal" else "βΉοΈ" | |
| reply += f"\n{emoji} **{ann['title']}** ({ann['course']})\n β {ann['content'][:70]}..." | |
| except Exception as e: | |
| reply = f"β οΈ Sorry, I had a glitch: {str(e)}" | |
| else: | |
| time.sleep(1.5) | |
| reply = f"π Hi! I'm ThutoAI. You asked: '{message}'.\nπ‘ *Pro tip: Add your OpenAI API key in HF Secrets for smarter answers!*" | |
| history[-1] = (message, reply) | |
| if username != "guest": | |
| student_service.add_to_chat_history(username, message, reply) | |
| yield history, "" | |
| # ==================== UI RENDERING HELPERS ==================== | |
| def render_announcements(course: str) -> str: | |
| """Render announcements with modern cards.""" | |
| announcements = school_service.get_announcements(course) | |
| if not announcements: | |
| return """ | |
| <div style='text-align: center; padding: 40px; color: #6c757d;'> | |
| <div style='font-size: 4em; margin-bottom: 16px;'>π</div> | |
| <h3>No announcements for this course.</h3> | |
| <p>Check back later or select "All" to see everything.</p> | |
| </div> | |
| """ | |
| html = "<div style='display: grid; gap: 16px;'>" | |
| priority_icons = {"high": "π¨", "normal": "π", "low": "βΉοΈ"} | |
| priority_colors = {"high": "#dc3545", "normal": "#ffc107", "low": "#6c757d"} | |
| for ann in announcements: | |
| icon = priority_icons.get(ann["priority"], "π") | |
| color = priority_colors.get(ann["priority"], "#6c757d") | |
| html += f""" | |
| <div style=' | |
| background: white; | |
| border-radius: 12px; | |
| padding: 20px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.08); | |
| border-left: 4px solid {color}; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| '> | |
| <div style='display: flex; align-items: flex-start; gap: 12px;'> | |
| <div style='font-size: 1.5em;'>{icon}</div> | |
| <div style='flex: 1;'> | |
| <div style='display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;'> | |
| <h3 style='margin: 0; color: #212529;'>{ann['title']}</h3> | |
| <span style=' | |
| background: {color}; | |
| color: white; | |
| padding: 4px 10px; | |
| border-radius: 20px; | |
| font-size: 0.8em; | |
| font-weight: bold; | |
| '>{ann['priority'].upper()}</span> | |
| </div> | |
| <p style='margin: 8px 0; color: #495057; line-height: 1.5;'>{ann['content']}</p> | |
| <div style='display: flex; justify-content: space-between; font-size: 0.85em; color: #6c757d; margin-top: 12px;'> | |
| <span>π {ann['course']}</span> | |
| <span>π {ann['date']}</span> | |
| <span>π¨βπ« {ann['posted_by']}</span> | |
| <span>ποΈ {ann['views']} views</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| html += "</div>" | |
| return html | |
| def render_assignments(assignments: List[Dict]) -> str: | |
| """Render assignments in a clean, prioritized list.""" | |
| if not assignments: | |
| return """ | |
| <div style='text-align: center; padding: 40px; color: #6c757d;'> | |
| <div style='font-size: 4em; margin-bottom: 16px;'>π </div> | |
| <h3>No upcoming assignments.</h3> | |
| <p>Ask your teacher or join a class group to see assignments.</p> | |
| </div> | |
| """ | |
| html = "<div style='display: grid; gap: 16px;'>" | |
| today = datetime.today().date() | |
| for task in assignments: | |
| due_date = datetime.strptime(task["due_date"], "%Y-%m-%d").date() | |
| days_left = (due_date - today).days | |
| is_overdue = days_left < 0 | |
| is_today = days_left == 0 | |
| if is_overdue: | |
| badge = "π¨ OVERDUE" | |
| color = "#dc3545" | |
| elif is_today: | |
| badge = "π― TODAY" | |
| color = "#fd7e14" | |
| elif days_left <= 2: | |
| badge = f"β οΈ Due in {days_left} day{'s' if days_left != 1 else ''}" | |
| color = "#ffc107" | |
| else: | |
| badge = f"β Due in {days_left} days" | |
| color = "#28a745" | |
| html += f""" | |
| <div style=' | |
| background: white; | |
| border-radius: 12px; | |
| padding: 20px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.08); | |
| border-left: 4px solid {color}; | |
| transition: transform 0.2s; | |
| '> | |
| <div style='display: flex; justify-content: space-between; align-items: flex-start;'> | |
| <div> | |
| <h3 style='margin: 0 0 8px 0; color: #212529;'>{task['title']}</h3> | |
| <div style='color: #6c757d; margin-bottom: 8px;'>π {task['course']} Β· π©βπ« {task['assigned_by']}</div> | |
| <div style='color: #495057;'>π Due: {task['due_date']}</div> | |
| <div style='color: #6c757d; font-size: 0.9em;'>{task.get('description', '')}</div> | |
| </div> | |
| <span style=' | |
| background: {color}; | |
| color: white; | |
| padding: 6px 12px; | |
| border-radius: 20px; | |
| font-weight: bold; | |
| font-size: 0.85em; | |
| align-self: flex-start; | |
| '>{badge}</span> | |
| </div> | |
| </div> | |
| """ | |
| html += "</div>" | |
| return html | |
| def render_groups(groups: List[str]) -> str: | |
| """Render joined class groups.""" | |
| if not groups: | |
| return """ | |
| <div style='text-align: center; padding: 40px; color: #6c757d;'> | |
| <div style='font-size: 4em; margin-bottom: 16px;'>π₯</div> | |
| <h3>You haven't joined any class groups yet.</h3> | |
| <p>Ask your teacher for a group code to join your class.</p> | |
| </div> | |
| """ | |
| html = "<div style='display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;'>" | |
| for group in groups: | |
| html += f""" | |
| <div style=' | |
| background: #e3f2fd; | |
| border-radius: 12px; | |
| padding: 20px; | |
| text-align: center; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.05); | |
| border: 2px solid #2196f3; | |
| '> | |
| <div style='font-size: 1.5em; margin-bottom: 8px;'>π</div> | |
| <h3 style='margin: 0; color: #1976d2;'>{group}</h3> | |
| <p style='margin: 8px 0 0 0; color: #555; font-size: 0.9em;'>Class Group</p> | |
| </div> | |
| """ | |
| html += "</div>" | |
| return html | |
| def get_avatar_html(avatar_path: Optional[str], name: str) -> str: | |
| """Generate HTML for avatar display.""" | |
| if avatar_path: | |
| try: | |
| with open(avatar_path, "rb") as f: | |
| img_data = base64.b64encode(f.read()).decode() | |
| img_html = f'<img src="data:image/png;base64,{img_data}" style="width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 2px solid #6e8efb;">' | |
| except: | |
| img_html = f'<div style="width: 60px; height: 60px; border-radius: 50%; background: #6e8efb; color: white; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 1.2em; border: 2px solid #6e8efb;">{name[0].upper()}</div>' | |
| else: | |
| img_html = f'<div style="width: 60px; height: 60px; border-radius: 50%; background: #6e8efb; color: white; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 1.2em; border: 2px solid #6e8efb;">{name[0].upper()}</div>' | |
| return img_html | |
| def render_analytics() -> str: | |
| """Render analytics dashboard for admins.""" | |
| total_students = student_service.get_total_students() | |
| active_students = student_service.get_active_students() | |
| total_assignments = student_service.get_total_assignments() | |
| completed_assignments = student_service.get_completed_assignments() | |
| group_popularity = student_service.get_group_popularity() | |
| recent_activity = student_service.get_recent_activity() | |
| school_stats = school_service.get_stats() | |
| completion_rate = (completed_assignments / total_assignments * 100) if total_assignments > 0 else 0 | |
| html = f""" | |
| <div style='display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; margin-bottom: 32px;'> | |
| <div style='background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);'> | |
| <h3 style='color: #212529; margin: 0 0 16px 0;'>π₯ Student Engagement</h3> | |
| <div style='display: grid; grid-template-columns: 1fr 1fr; gap: 16px;'> | |
| <div style='text-align: center; padding: 16px; background: #e3f2fd; border-radius: 8px;'> | |
| <div style='font-size: 2em; color: #1976d2;'>{total_students}</div> | |
| <div>Total Students</div> | |
| </div> | |
| <div style='text-align: center; padding: 16px; background: #e8f5e8; border-radius: 8px;'> | |
| <div style='font-size: 2em; color: #2e7d32;'>{active_students}</div> | |
| <div>Active Students</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div style='background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);'> | |
| <h3 style='color: #212529; margin: 0 0 16px 0;'>π Assignment Stats</h3> | |
| <div style='display: grid; grid-template-columns: 1fr 1fr; gap: 16px;'> | |
| <div style='text-align: center; padding: 16px; background: #fff3e0; border-radius: 8px;'> | |
| <div style='font-size: 2em; color: #ef6c00;'>{total_assignments}</div> | |
| <div>Total Assignments</div> | |
| </div> | |
| <div style='text-align: center; padding: 16px; background: #f3e5f5; border-radius: 8px;'> | |
| <div style='font-size: 2em; color: #7b1fa2;'>{completion_rate:.1f}%</div> | |
| <div>Completion Rate</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div style='display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px;'> | |
| <div style='background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);'> | |
| <h3 style='color: #212529; margin: 0 0 16px 0;'>π Popular Class Groups</h3> | |
| <div style='display: grid; gap: 12px;'> | |
| """ | |
| sorted_groups = sorted(group_popularity.items(), key=lambda x: x[1], reverse=True) | |
| for group, count in sorted_groups[:5]: | |
| percentage = (count / total_students * 100) if total_students > 0 else 0 | |
| html += f""" | |
| <div style='display: flex; align-items: center; gap: 12px;'> | |
| <div style='width: 100%; background: #e0e0e0; height: 8px; border-radius: 4px;'> | |
| <div style='width: {percentage}%; background: #6e8efb; height: 100%; border-radius: 4px;'></div> | |
| </div> | |
| <div style='min-width: 80px;'>{group}</div> | |
| <div style='color: #6c757d;'>{count} students</div> | |
| </div> | |
| """ | |
| html += """ | |
| </div> | |
| </div> | |
| <div style='background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);'> | |
| <h3 style='color: #212529; margin: 0 0 16px 0;'>π’ Announcement Stats</h3> | |
| <div style='display: grid; gap: 12px;'> | |
| """ | |
| for ann in school_service.announcements[:5]: | |
| views = ann.get("views", 0) | |
| max_views = max(a.get("views", 0) for a in school_service.announcements) if school_service.announcements else 1 | |
| width_percent = (views / max_views * 100) if max_views > 0 else 0 | |
| html += f""" | |
| <div style='display: flex; align-items: center; gap: 12px;'> | |
| <div style='width: 100%; background: #e0e0e0; height: 8px; border-radius: 4px;'> | |
| <div style='width: {width_percent}%; background: #ff7043; height: 100%; border-radius: 4px;'></div> | |
| </div> | |
| <div style='min-width: 120px; font-size: 0.9em;'>{ann['title'][:20]}...</div> | |
| <div style='color: #6c757d;'>{views} views</div> | |
| </div> | |
| """ | |
| html += """ | |
| </div> | |
| </div> | |
| </div> | |
| <div style='background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);'> | |
| <h3 style='color: #212529; margin: 0 0 16px 0;'>β±οΈ Recent Activity</h3> | |
| <div style='display: grid; gap: 12px;'> | |
| """ | |
| for activity in recent_activity: | |
| html += f""" | |
| <div style='padding: 12px; background: #f8f9fa; border-radius: 8px; display: flex; justify-content: space-between; align-items: center;'> | |
| <div> | |
| <strong>{activity['student']}</strong> received assignment: <em>{activity['last_assignment']}</em> | |
| </div> | |
| <div style='color: #6c757d; font-size: 0.9em;'>{activity['date']}</div> | |
| </div> | |
| """ | |
| html += """ | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| # ==================== STATE MANAGEMENT ==================== | |
| CURRENT_USER = "guest" | |
| CURRENT_TEACHER = None | |
| DARK_MODE = False | |
| def login_student(username: str, password: str) -> tuple: | |
| global CURRENT_USER, DARK_MODE | |
| student_data = student_service.authenticate_student(username, password) | |
| if student_data: | |
| CURRENT_USER = username | |
| DARK_MODE = student_data["dark_mode"] | |
| chat_history = student_service.get_chat_history(username) | |
| files = student_service.get_files(username) | |
| assignments = student_service.get_assignments(username) | |
| groups = student_service.get_groups(username) | |
| avatar_html = get_avatar_html(student_data["avatar"], student_data["name"]) | |
| welcome_msg = f"Welcome back, {student_data['name']}!" | |
| css_class = "dark-mode" if DARK_MODE else "" | |
| return ( | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| chat_history, | |
| files, | |
| render_assignments(assignments), | |
| render_groups(groups), | |
| welcome_msg, | |
| gr.update(value=student_data["name"], visible=True), | |
| gr.update(visible=True), | |
| gr.update(value=avatar_html), | |
| gr.update(value="π Light Mode" if DARK_MODE else "βοΈ Dark Mode"), | |
| css_class | |
| ) | |
| return ( | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| [], | |
| [], | |
| "", | |
| "", | |
| "β Invalid username or password", | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(), | |
| gr.update(), | |
| "" | |
| ) | |
| def register_student(username: str, password: str, name: str) -> str: | |
| return student_service.register_student(username, password, name) | |
| def logout_student() -> tuple: | |
| global CURRENT_USER, DARK_MODE | |
| CURRENT_USER = "guest" | |
| DARK_MODE = False | |
| return ( | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| [], | |
| [], | |
| "", | |
| "", | |
| "", | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(), | |
| gr.update(value="βοΈ Dark Mode"), | |
| "" | |
| ) | |
| def toggle_dark_mode() -> tuple: | |
| global DARK_MODE, CURRENT_USER | |
| DARK_MODE = not DARK_MODE | |
| if CURRENT_USER != "guest": | |
| student_service.update_dark_mode(CURRENT_USER, DARK_MODE) | |
| btn_text = "π Light Mode" if DARK_MODE else "βοΈ Dark Mode" | |
| css_class = "dark-mode" if DARK_MODE else "" | |
| return btn_text, css_class | |
| def upload_avatar(file) -> str: | |
| if not file: | |
| return "β No file selected" | |
| if CURRENT_USER == "guest": | |
| return "β Please log in first" | |
| student_service.update_avatar(CURRENT_USER, file.name) | |
| return f"β Avatar updated!" | |
| def join_group(group_code: str) -> tuple: | |
| if CURRENT_USER == "guest": | |
| return "β Please log in first.", "" | |
| result = student_service.join_group(CURRENT_USER, group_code) | |
| groups = student_service.get_groups(CURRENT_USER) | |
| return result, render_groups(groups) | |
| def leave_group(group_code: str) -> tuple: | |
| if CURRENT_USER == "guest": | |
| return "β Please log in first.", "" | |
| result = student_service.leave_group(CURRENT_USER, group_code) | |
| groups = student_service.get_groups(CURRENT_USER) | |
| return result, render_groups(groups) | |
| def upload_file_for_student(file) -> str: | |
| if not file: | |
| return "β No file selected" | |
| result = f"β Uploaded: {file.name}" | |
| if CURRENT_USER != "guest": | |
| student_service.add_file(CURRENT_USER, file.name) | |
| return result | |
| # ==================== TEACHER FUNCTIONS ==================== | |
| def teacher_login(username: str, password: str) -> tuple: | |
| global CURRENT_TEACHER | |
| teacher_data = admin_service.authenticate(username, password) | |
| if teacher_data and teacher_data["role"] == "teacher": | |
| CURRENT_TEACHER = teacher_data | |
| return ( | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| f"β Welcome, {teacher_data['name']}!", | |
| gr.update(visible=True) | |
| ) | |
| elif teacher_data and teacher_data["role"] == "admin": | |
| CURRENT_TEACHER = teacher_data | |
| return ( | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| f"β Welcome, Admin {teacher_data['name']}!", | |
| gr.update(visible=True) | |
| ) | |
| return ( | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| "β Invalid credentials or not a teacher account", | |
| gr.update(visible=False) | |
| ) | |
| def teacher_logout(): | |
| global CURRENT_TEACHER | |
| CURRENT_TEACHER = None | |
| return ( | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| "", | |
| gr.update(visible=False) | |
| ) | |
| def create_assignment(title: str, description: str, due_date: str, course: str) -> str: | |
| if not CURRENT_TEACHER: | |
| return "π Please log in as teacher first." | |
| if not title or not due_date or not course: | |
| return "β οΈ Title, due date, and course are required." | |
| if course not in student_service.valid_groups: | |
| return "β οΈ Invalid course/group. Choose from available groups." | |
| assignment_id = school_service.create_assignment( | |
| title=title, | |
| description=description, | |
| due_date=due_date, | |
| course=course, | |
| assigned_by=CURRENT_TEACHER["name"] | |
| ) | |
| # Count how many students received this assignment | |
| recipients = 0 | |
| for data in student_service.student_sessions.values(): | |
| if course in data["groups"]: | |
| recipients += 1 | |
| return f"β Assignment created! ID: {assignment_id}. Sent to {recipients} students in {course}." | |
| # ==================== VOICE INPUT ==================== | |
| VOICE_JS = """ | |
| async function startVoiceInput() { | |
| if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) { | |
| alert('ποΈ Voice input is not supported in your browser. Try Chrome or Edge.'); | |
| return ''; | |
| } | |
| const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)(); | |
| recognition.lang = 'en-US'; | |
| recognition.interimResults = false; | |
| recognition.maxAlternatives = 1; | |
| return new Promise((resolve) => { | |
| recognition.start(); | |
| recognition.onresult = (event) => { | |
| const transcript = event.results[0][0].transcript; | |
| resolve(transcript); | |
| }; | |
| recognition.onerror = (event) => { | |
| alert('ποΈ Error: ' + event.error); | |
| resolve(''); | |
| }; | |
| }); | |
| } | |
| """ | |
| def voice_input_handler() -> str: | |
| return "" | |
| # ==================== CUSTOM CSS ==================== | |
| CUSTOM_CSS = """ | |
| .gradio-container { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 16px; | |
| transition: background-color 0.3s, color 0.3s; | |
| } | |
| .dark-mode { | |
| --background-fill-primary: #1e1e1e !important; | |
| --background-fill-secondary: #2d2d2d !important; | |
| --text-color: #f0f0f0 !important; | |
| --button-primary-background-fill: #5a5a5a !important; | |
| --button-secondary-background-fill: #3a3a3a !important; | |
| --input-background-fill: #2d2d2d !important; | |
| --input-border-color: #444 !important; | |
| } | |
| .primary { | |
| background: linear-gradient(135deg, #6e8efb, #a777e3) !important; | |
| border: none !important; | |
| color: white !important; | |
| } | |
| .chatbot-container { | |
| background: #f8f9fa !important; | |
| border-radius: 16px !important; | |
| } | |
| .dark-mode .chatbot-container { | |
| background: #2d2d2d !important; | |
| } | |
| .user, .bot { | |
| border-radius: 18px !important; | |
| padding: 12px 16px !important; | |
| } | |
| .file-upload { | |
| border: 2px dashed #6e8efb !important; | |
| border-radius: 12px !important; | |
| background: #f8f9ff !important; | |
| } | |
| .dark-mode .file-upload { | |
| background: #2a2a2a !important; | |
| border-color: #5a5a5a !important; | |
| } | |
| .analytics-card { | |
| background: white; | |
| padding: 20px; | |
| border-radius: 12px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.08); | |
| } | |
| """ | |
| # ==================== BUILD UI ==================== | |
| with gr.Blocks(css=CUSTOM_CSS, theme=gr.themes.Soft(primary_hue="indigo")) as demo: | |
| # Header with dark mode toggle | |
| with gr.Row(): | |
| gr.Markdown("# π ThutoAI β Your AI School Assistant") | |
| dark_mode_btn = gr.Button("βοΈ Dark Mode", variant="secondary") | |
| # ========= STUDENT LOGIN/REGISTER ========= | |
| with gr.Group() as login_group: | |
| gr.Markdown("### π Student Login") | |
| with gr.Row(): | |
| login_username = gr.Textbox(label="Username", scale=3) | |
| login_password = gr.Textbox(label="Password", type="password", scale=3) | |
| login_btn = gr.Button("π Login", variant="primary") | |
| register_btn = gr.Button("π Register", variant="secondary") | |
| login_status = gr.Textbox(label="Status", interactive=False) | |
| with gr.Accordion("π Register New Account", open=False): | |
| reg_username = gr.Textbox(label="Username") | |
| reg_password = gr.Textbox(label="Password", type="password") | |
| reg_name = gr.Textbox(label="Full Name") | |
| reg_btn = gr.Button("Create Account") | |
| reg_status = gr.Textbox(label="Registration Status") | |
| # ========= MAIN APP (hidden until login) ========= | |
| with gr.Group(visible=False) as main_app: | |
| # Profile header | |
| with gr.Row(): | |
| avatar_display = gr.HTML() | |
| user_display = gr.Textbox(label="Logged in as", interactive=False, visible=True) | |
| logout_btn = gr.Button("β¬ οΈ Logout", variant="secondary") | |
| with gr.Tabs(): | |
| with gr.Tab("π’ Announcements"): | |
| gr.Markdown("### Filter by Course or Subject") | |
| with gr.Row(): | |
| course_filter = gr.Dropdown( | |
| choices=school_service.courses, | |
| value="All", | |
| label="Select Course", | |
| scale=3 | |
| ) | |
| refresh_btn = gr.Button("π Refresh", variant="secondary", scale=1) | |
| announcements_html = gr.HTML() | |
| course_filter.change(fn=render_announcements, inputs=course_filter, outputs=announcements_html) | |
| refresh_btn.click(fn=render_announcements, inputs=course_filter, outputs=announcements_html) | |
| demo.load(fn=render_announcements, inputs=course_filter, outputs=announcements_html) | |
| with gr.Tab("π¬ Ask ThutoAI"): | |
| gr.Markdown("### π‘ Ask me anything β I'm here to help!") | |
| chatbot = gr.Chatbot( | |
| height=480, | |
| type='messages' # β FIXED: Replaced deprecated bubble_full_width | |
| ) | |
| with gr.Row(): | |
| msg = gr.Textbox( | |
| label="Type your question", | |
| placeholder="E.g., How do I prepare for the Math exam?", | |
| scale=7 | |
| ) | |
| voice_btn = gr.Button("ποΈ Speak", variant="secondary", scale=1) | |
| submit_btn = gr.Button("β€ Send", variant="primary", scale=1) | |
| clear_btn = gr.Button("ποΈ Clear Chat", variant="secondary") | |
| def respond(message, chat_history): | |
| for updated_history, _ in ai_chat(message, chat_history, CURRENT_USER): | |
| yield updated_history, "" | |
| msg.submit(respond, [msg, chatbot], [msg, chatbot]) | |
| submit_btn.click(respond, [msg, chatbot], [msg, chatbot]) | |
| clear_btn.click(lambda: [], None, chatbot) | |
| voice_btn.click( | |
| fn=voice_input_handler, | |
| inputs=None, | |
| outputs=msg, | |
| js=VOICE_JS + "return startVoiceInput();" | |
| ) | |
| with gr.Tab("π Assignments"): | |
| gr.Markdown("### π Your Upcoming Assignments & Exams") | |
| assignments_display = gr.HTML() | |
| demo.load( | |
| fn=lambda: render_assignments(student_service.get_assignments(CURRENT_USER)) if CURRENT_USER != "guest" else "", | |
| inputs=None, | |
| outputs=assignments_display | |
| ) | |
| with gr.Tab("π₯ Class Groups"): | |
| gr.Markdown("### π Join Your Class Groups") | |
| with gr.Row(): | |
| group_code_input = gr.Textbox(label="Group Code (e.g., MATH10A)", scale=3) | |
| join_btn = gr.Button("β Join Group", variant="primary", scale=1) | |
| leave_btn = gr.Button("β Leave Group", variant="secondary", scale=1) | |
| group_status = gr.Textbox(label="Status") | |
| groups_display = gr.HTML() | |
| join_btn.click( | |
| fn=join_group, | |
| inputs=group_code_input, | |
| outputs=[group_status, groups_display] | |
| ) | |
| leave_btn.click( | |
| fn=leave_group, | |
| inputs=group_code_input, | |
| outputs=[group_status, groups_display] | |
| ) | |
| demo.load( | |
| fn=lambda: render_groups(student_service.get_groups(CURRENT_USER)) if CURRENT_USER != "guest" else "", | |
| inputs=None, | |
| outputs=groups_display | |
| ) | |
| with gr.Tab("π My Files"): | |
| gr.Markdown("### π Upload & Manage Study Materials") | |
| with gr.Row(): | |
| file_input = gr.File(label="Drag & drop or click to upload", elem_classes=["file-upload"]) | |
| upload_btn = gr.Button("π€ Upload", variant="primary") | |
| upload_status = gr.Textbox(label="Status") | |
| file_list = gr.JSON(label="Your Files") | |
| upload_btn.click( | |
| fn=upload_file_for_student, | |
| inputs=file_input, | |
| outputs=upload_status | |
| ) | |
| demo.load( | |
| fn=lambda: student_service.get_files(CURRENT_USER) if CURRENT_USER != "guest" else [], | |
| inputs=None, | |
| outputs=file_list | |
| ) | |
| with gr.Tab("πΌοΈ Profile"): | |
| gr.Markdown("### πΌοΈ Update Your Profile Picture") | |
| with gr.Row(): | |
| avatar_input = gr.File(label="Choose an image (PNG, JPG)", file_types=["image"]) | |
| upload_avatar_btn = gr.Button("π€ Upload Avatar", variant="primary") | |
| avatar_status = gr.Textbox(label="Status") | |
| upload_avatar_btn.click( | |
| fn=upload_avatar, | |
| inputs=avatar_input, | |
| outputs=avatar_status | |
| ) | |
| demo.load( | |
| fn=lambda: get_avatar_html( | |
| student_service.students[CURRENT_USER]["avatar"] if CURRENT_USER != "guest" else None, | |
| student_service.students[CURRENT_USER]["name"] if CURRENT_USER != "guest" else "Guest" | |
| ) if CURRENT_USER != "guest" else "", | |
| inputs=None, | |
| outputs=avatar_display | |
| ) | |
| with gr.Tab("π©βπ« Teacher Panel"): | |
| gr.Markdown("### π Teacher Login (for assignment creation)") | |
| with gr.Group() as teacher_login_group: | |
| teacher_username = gr.Textbox(label="Teacher Username") | |
| teacher_password = gr.Textbox(label="Password", type="password") | |
| teacher_login_btn = gr.Button("π Login", variant="primary") | |
| teacher_status = gr.Textbox(label="Status") | |
| with gr.Group(visible=False) as teacher_dashboard: | |
| gr.Markdown("### βοΈ Create New Assignment") | |
| assignment_title = gr.Textbox(label="Assignment Title", placeholder="e.g., Chapter 5 Quiz") | |
| assignment_description = gr.Textbox(label="Description", placeholder="Instructions for students...", lines=2) | |
| assignment_due_date = gr.Textbox(label="Due Date (YYYY-MM-DD)", placeholder="2025-05-01") | |
| assignment_course = gr.Dropdown( | |
| choices=student_service.valid_groups, | |
| label="Assign to Class Group", | |
| value="MATH10A" | |
| ) | |
| create_assignment_btn = gr.Button("π¬ Create Assignment", variant="primary") | |
| assignment_result = gr.Textbox(label="Result") | |
| # β FIXED: Removed deprecated 'inline' parameter | |
| assignment_priority = gr.Radio( | |
| ["low", "normal", "high"], | |
| label="Priority", | |
| value="normal" | |
| ) | |
| teacher_logout_btn = gr.Button("β¬ οΈ Logout", variant="secondary") | |
| teacher_login_btn.click( | |
| fn=teacher_login, | |
| inputs=[teacher_username, teacher_password], | |
| outputs=[teacher_login_group, teacher_dashboard, teacher_status, create_assignment_btn] | |
| ) | |
| teacher_logout_btn.click( | |
| fn=teacher_logout, | |
| inputs=None, | |
| outputs=[teacher_login_group, teacher_dashboard, teacher_status, assignment_result] | |
| ) | |
| create_assignment_btn.click( | |
| fn=create_assignment, | |
| inputs=[assignment_title, assignment_description, assignment_due_date, assignment_course], | |
| outputs=assignment_result | |
| ) | |
| with gr.Tab("π Admin Analytics"): | |
| gr.Markdown("### π School Analytics Dashboard") | |
| analytics_display = gr.HTML() | |
| refresh_analytics_btn = gr.Button("π Refresh Analytics") | |
| refresh_analytics_btn.click( | |
| fn=render_analytics, | |
| inputs=None, | |
| outputs=analytics_display | |
| ) | |
| demo.load( | |
| fn=render_analytics, | |
| inputs=None, | |
| outputs=analytics_display | |
| ) | |
| # Logout button (already in header) | |
| # ========= EVENT HANDLERS ========= | |
| login_btn.click( | |
| fn=login_student, | |
| inputs=[login_username, login_password], | |
| outputs=[ | |
| login_group, main_app, chatbot, gr.update(), assignments_display, groups_display, | |
| login_status, user_display, logout_btn, avatar_display, dark_mode_btn, gr.update() | |
| ] | |
| ) | |
| register_btn.click( | |
| fn=register_student, | |
| inputs=[reg_username, reg_password, reg_name], | |
| outputs=reg_status | |
| ) | |
| dark_mode_btn.click( | |
| fn=toggle_dark_mode, | |
| inputs=None, | |
| outputs=[dark_mode_btn, gr.update()] | |
| ) | |
| # Launch app | |
| if __name__ == "__main__": | |
| demo.launch() |