diff --git a/ai_academic_fixed/README.md b/ai_academic_fixed/README.md new file mode 100644 index 0000000..3bc2cce --- /dev/null +++ b/ai_academic_fixed/README.md @@ -0,0 +1,105 @@ +# AcadAI — AI Academic Performance Prediction & Career Guidance System + +**A professional AI-powered desktop application that predicts student GPA, detects weak subjects, recommends careers, and generates personalized study plans — built as a 4th Semester BSAI Project.** + +--- + +## Project Info + +**Subject:** Artificial Intelligence +**Semester:** 4th +**Submitted To:** [Dr. Muhammad Siddique](mailto:msiddique@nfciet.edu.pk) + +**Members:** + +- [Faizan Ishfaq](https://github.com/faizanrajpoot774-debug) +- [Muawiya Amir](https://github.com/Muawiya-contact) + +--- + +## Overview + +**AcadAI** is an intelligent academic analytics platform built for university students. It uses Machine Learning to predict future GPA, detect at-risk students, recommend career paths based on academic profile, and generate AI-powered study plans — all inside a clean, professional desktop GUI. + +Built with Python · PyQt5 · scikit-learn · SQLite · Matplotlib + +--- + +## Features + +| Feature | Description | +| ---------------------- | ------------------------------------------------------------------------------------- | +| GPA Prediction | Random Forest + Ridge Regression model predicts next semester GPA with ~91% accuracy | +| Weak Subject Detection | ML model identifies subjects needing attention and suggests improvement strategies | +| Career Recommendation | Matches student profile (GPA + skills + interests) to best-fit career paths | +| Skill Roadmap | Personalized step-by-step learning roadmap for chosen career goal | +| Study Planner | Generates weekly study schedules prioritizing weak subjects | +| AI Chatbot 🤖 | NLP-based academic assistant that reads live database and answers student queries | +| Analytics Dashboard | Interactive charts: GPA trends, subject radar, risk distribution, attendance analysis | +| Student Management | Add, edit, import students via CSV — full CRUD with SQLite backend | +| CSV Import/Export | Bulk import 60+ students from CSV; export reports | + +--- + +## Installation + +### Prerequisites + +Make sure you have **Python 3.10+** installed: + +```bash +python --version +``` + +### Step 1 — Clone the Repository + +```bash +git clone https://github.com/your-username/acadai.git +cd acadAI +``` + +### Step 2 — Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### Step 3 — Generate Sample Dataset (First Time Only) + +```bash +python generate_dataset.py +``` + +This creates `datasets/student_data.csv` with 60 realistic student records. + +### Step 4 — Run the Application + +```bash +python main.py +``` + +--- + +## Requirements + +```batch +PyQt5>=5.15.0 +matplotlib>=3.7.0 +scikit-learn>=1.3.0 +pandas>=2.0.0 +numpy>=1.24.0 +``` + +Install all at once: + +```bash +pip install PyQt5 matplotlib scikit-learn pandas numpy +``` + +--- + +## Project Structure + +```txt +AcadAI/ +``` diff --git a/ai_academic_fixed/analytics/__init__.py b/ai_academic_fixed/analytics/__init__.py new file mode 100644 index 0000000..c51def1 --- /dev/null +++ b/ai_academic_fixed/analytics/__init__.py @@ -0,0 +1 @@ +# pkg diff --git a/ai_academic_fixed/analytics/analytics_engine.py b/ai_academic_fixed/analytics/analytics_engine.py new file mode 100644 index 0000000..1a679e2 --- /dev/null +++ b/ai_academic_fixed/analytics/analytics_engine.py @@ -0,0 +1,64 @@ +# analytics/analytics_engine.py +import statistics +from database.db_manager import DatabaseManager +from config import MAX_GPA_CHART_STUDENTS + +class AnalyticsEngine: + def __init__(self, db: DatabaseManager): + self.db = db + + def get_dashboard_stats(self): + return self.db.get_stats() + + def get_gpa_chart_data(self): + students = self.db.get_all_students() + # Limit number of students shown in the GPA chart to avoid visual saturation + max_n = MAX_GPA_CHART_STUDENTS if MAX_GPA_CHART_STUDENTS and MAX_GPA_CHART_STUDENTS > 0 else len(students) + students = students[:max_n] + names = [s['name'].split()[0] for s in students] + gpas = [s['gpa'] for s in students] + return names, gpas + + def get_subject_chart_data(self): + avgs = self.db.get_subject_averages() + labels = ['Attendance', 'Quiz', 'Assignment', 'Midterm'] + values = [avgs['attendance'], avgs['quiz'], avgs['assignment'], avgs['midterm']] + return labels, values + + def get_risk_breakdown(self): + students = self.db.get_all_students() + low = sum(1 for s in students if s['risk_level'] == 'Low') + med = sum(1 for s in students if s['risk_level'] == 'Medium') + high = sum(1 for s in students if s['risk_level'] == 'High') + return {'Low': low, 'Medium': med, 'High': high} + + def get_gpa_bins(self): + gpas = self.db.get_gpa_distribution() + bins = {'<2.0': 0, '2.0–2.5': 0, '2.5–3.0': 0, '3.0–3.5': 0, '3.5–4.0': 0} + for g in gpas: + if g < 2.0: bins['<2.0'] += 1 + elif g < 2.5: bins['2.0–2.5'] += 1 + elif g < 3.0: bins['2.5–3.0'] += 1 + elif g < 3.5: bins['3.0–3.5'] += 1 + else: bins['3.5–4.0'] += 1 + return bins + + def get_performance_insights(self): + stats = self.get_dashboard_stats() + insights = [] + avg_gpa = stats.get('avg_gpa', 0) or 0 + avg_att = stats.get('avg_att', 0) or 0 + at_risk = stats.get('at_risk', 0) or 0 + if avg_gpa >= 3.3: + insights.append({'icon': '🌟', 'text': f'Average GPA {avg_gpa:.2f} — class is performing well', 'color': '#10B981'}) + elif avg_gpa < 2.8: + insights.append({'icon': '⚠️', 'text': f'Average GPA {avg_gpa:.2f} — intervention recommended', 'color': '#EF4444'}) + if avg_att >= 85: + insights.append({'icon': '✅', 'text': f'Attendance at {avg_att:.1f}% — excellent engagement', 'color': '#10B981'}) + elif avg_att < 75: + insights.append({'icon': '📅', 'text': f'Attendance at {avg_att:.1f}% — needs improvement', 'color': '#F59E0B'}) + if at_risk > 0: + insights.append({'icon': '🚨', 'text': f'{at_risk} student(s) at high risk — require attention', 'color': '#EF4444'}) + if not insights: + insights.append({'icon': '📊', 'text': 'All metrics within normal range', 'color': '#4F6EF7'}) + return insights diff --git a/ai_academic_fixed/assets/__init__.py b/ai_academic_fixed/assets/__init__.py new file mode 100644 index 0000000..c51def1 --- /dev/null +++ b/ai_academic_fixed/assets/__init__.py @@ -0,0 +1 @@ +# pkg diff --git a/ai_academic_fixed/assets/styles/__init__.py b/ai_academic_fixed/assets/styles/__init__.py new file mode 100644 index 0000000..c51def1 --- /dev/null +++ b/ai_academic_fixed/assets/styles/__init__.py @@ -0,0 +1 @@ +# pkg diff --git a/ai_academic_fixed/assets/styles/theme.py b/ai_academic_fixed/assets/styles/theme.py new file mode 100644 index 0000000..2f0372a --- /dev/null +++ b/ai_academic_fixed/assets/styles/theme.py @@ -0,0 +1,371 @@ +# assets/styles/theme.py — Global PyQt5 Stylesheet (Premium SaaS Light Theme) + +GLOBAL_STYLE = """ +/* ── Global ─────────────────────────────────────── */ +QWidget { + font-family: 'Segoe UI', 'SF Pro Display', Arial, sans-serif; + font-size: 13px; + color: #111827; + background-color: transparent; +} +QMainWindow, QDialog { + background-color: #F8F9FC; +} +QScrollArea, QScrollArea > QWidget > QWidget { + background-color: transparent; + border: none; + font-size: 14px; +QScrollBar:vertical { + background: #F3F4F6; + width: 6px; + border-radius: 3px; +} +QScrollBar::handle:vertical { + background: #D1D5DB; + border-radius: 3px; + font-size: 18px; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } + +/* ── Sidebar ─────────────────────────────────────── */ +#sidebar { + background-color: #FFFFFF; + border-right: 1px solid #E5E7EB; +} +#sidebar_logo { + font-size: 14px; + font-weight: 700; + color: #4F6EF7; + padding: 24px 20px 8px 20px; +} +#sidebar_subtitle { + font-size: 16px; + color: #9CA3AF; + padding: 0 20px 20px 20px; + line-height: 1.5; +} + font-size: 14px; + background: transparent; + border: none; + border-radius: 8px; + padding: 10px 16px; + text-align: left; + font-size: 22px; + color: #6B7280; + margin: 1px 8px; +} +#nav_btn:hover { + font-size: 16px; + color: #111827; +} +#nav_btn_active { + background-color: #EEF1FF; + border: none; + border-radius: 8px; + padding: 10px 16px; + text-align: left; + font-size: 13px; + font-size: 13px; + color: #4F6EF7; + margin: 1px 8px; +} +#sidebar_section { + font-size: 14px; + font-weight: 500; + color: #9CA3AF; + letter-spacing: 0.5px; + padding: 16px 20px 8px 20px; + font-size: 13px; +} + +/* ── Topbar ──────────────────────────────────────── */ +#topbar { + background-color: #FFFFFF; + border-bottom: 1px solid #E5E7EB; + min-height: 56px; + max-height: 56px; + font-size: 13px; +#page_title { + font-size: 20px; + font-weight: 700; + color: #111827; +} +#page_subtitle { + font-size: 15px; + line-height: 1.5; +} +#search_box { + background: #F9FAFB; + border: 1px solid #E5E7EB; + border-radius: 8px; + padding: 7px 14px; + font-size: 13px; + color: #374151; + min-width: 220px; +} +#search_box:focus { + border-color: #4F6EF7; + background: #FFFFFF; + outline: none; +} +#topbar_btn { + background: #4F6EF7; + color: white; + border: none; + border-radius: 8px; + padding: 8px 18px; + font-size: 13px; + font-weight: 600; +} +#topbar_btn:hover { background: #3B5BDB; } +#topbar_btn_ghost { + background: transparent; + color: #6B7280; + border: 1px solid #E5E7EB; + border-radius: 8px; + padding: 7px 16px; + font-size: 13px; +} +#topbar_btn_ghost:hover { background: #F9FAFB; color: #111827; } + +/* ── Cards ───────────────────────────────────────── */ +#stat_card { + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 12px; + padding: 20px; +} +#stat_card:hover { + border-color: #C7D2FE; +} +#stat_value { + font-size: 28px; + font-weight: 700; + color: #111827; +} +#stat_label { + font-size: 15px; + color: #6B7280; + font-weight: 400; + line-height: 1.5; +} +#stat_badge { + font-size: 11px; + font-weight: 600; + border-radius: 20px; + padding: 2px 8px; +} +#content_card { + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 12px; +} +#card_header { + font-size: 15px; + font-weight: 700; + color: #111827; + padding: 16px 20px 12px 20px; + border-bottom: 1px solid #F3F4F6; +} +#card_subheader { + font-size: 15px; + color: #9CA3AF; + padding: 0 20px 16px 20px; + line-height: 1.5; +} + +/* ── Table ───────────────────────────────────────── */ +QTableWidget { + background: transparent; + border: none; + gridline-color: #F3F4F6; + font-size: 13px; + selection-background-color: #EEF1FF; + alternate-background-color: #FAFAFA; +} +QTableWidget::item { + padding: 12px 14px; + border-bottom: 1px solid #F3F4F6; + color: #374151; + line-height: 1.5; +} +QTableWidget::item:selected { + background-color: #EEF1FF; + color: #4F6EF7; +} +QHeaderView::section { + background: #F9FAFB; + color: #6B7280; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 10px 14px; + border: none; + border-bottom: 1px solid #E5E7EB; + text-transform: uppercase; +} + +/* ── Inputs ──────────────────────────────────────── */ +QLineEdit, QTextEdit, QComboBox, QSpinBox, QDoubleSpinBox { + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 8px; + padding: 8px 12px; + min-height: 32px; + font-size: 13px; + color: #111827; +} +QLineEdit:focus, QTextEdit:focus, QComboBox:focus, +QSpinBox:focus, QDoubleSpinBox:focus { + border-color: #4F6EF7; + outline: none; +} +QComboBox::drop-down { border: none; padding-right: 8px; } +QComboBox::down-arrow { width: 12px; height: 12px; } +QComboBox QAbstractItemView { + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 8px; + selection-background-color: #EEF1FF; + padding: 4px; +} +QSlider::groove:horizontal { + background: #E5E7EB; + height: 4px; + border-radius: 2px; +} +QSlider::handle:horizontal { + background: #4F6EF7; + width: 16px; + height: 16px; + border-radius: 8px; + margin: -6px 0; +} +QSlider::sub-page:horizontal { background: #4F6EF7; border-radius: 2px; } + +/* ── Buttons ─────────────────────────────────────── */ +#btn_primary { + background: #4F6EF7; + color: white; + border: none; + border-radius: 8px; + padding: 10px 24px; + min-height: 34px; + font-size: 13px; + font-weight: 500; +} +#btn_primary:hover { background: #3B5BDB; } +#btn_secondary { + background: #F3F4F6; + color: #374151; + border: none; + border-radius: 8px; + padding: 10px 24px; + min-height: 34px; + font-size: 13px; + font-weight: 400; +} +#btn_secondary:hover { background: #E5E7EB; } +#btn_danger { + background: #FEE2E2; + color: #DC2626; + border: none; + border-radius: 8px; + padding: 8px 16px; + min-height: 34px; + font-size: 15px; + font-weight: 500; +} +#btn_danger:hover { background: #FECACA; } +#btn_success { + background: #D1FAE5; + color: #065F46; + border: none; + border-radius: 8px; + padding: 8px 16px; + min-height: 34px; + font-size: 15px; + font-weight: 500; +} +#btn_success:hover { background: #A7F3D0; } +#btn_icon { + background: transparent; + border: none; + border-radius: 6px; + padding: 5px 8px; + font-size: 14px; + color: #9CA3AF; +} +#btn_icon:hover { background: #F3F4F6; color: #374151; } + +/* ── Labels / Badges ──────────────────────────────── */ +#badge_blue { background:#EEF1FF; color:#4F6EF7; border-radius:20px; padding:2px 10px; font-size:11px; font-weight:600; } +#badge_green { background:#D1FAE5; color:#065F46; border-radius:20px; padding:2px 10px; font-size:11px; font-weight:600; } +#badge_yellow { background:#FEF3C7; color:#92400E; border-radius:20px; padding:2px 10px; font-size:11px; font-weight:600; } +#badge_red { background:#FEE2E2; color:#991B1B; border-radius:20px; padding:2px 10px; font-size:11px; font-weight:600; } +#badge_purple { background:#EDE9FE; color:#5B21B6; border-radius:20px; padding:2px 10px; font-size:11px; font-weight:600; } + +/* ── Progress bars ───────────────────────────────── */ +QProgressBar { + background: #F3F4F6; + border: none; + border-radius: 4px; + height: 6px; + text-align: center; + font-size: 0px; +} +QProgressBar::chunk { border-radius: 4px; background: #4F6EF7; } + +/* ── Tab bar ─────────────────────────────────────── */ +QTabWidget::pane { border: 1px solid #E5E7EB; border-radius: 8px; background: #FFFFFF; } +QTabBar::tab { + background: transparent; + border: none; + padding: 10px 20px; + font-size: 13px; + color: #6B7280; + font-weight: 500; +} +QTabBar::tab:selected { + color: #4F6EF7; + font-weight: 700; + border-bottom: 2px solid #4F6EF7; +} +QTabBar::tab:hover { color: #374151; } + +/* ── Chatbot ─────────────────────────────────────── */ +#chat_display { + background: #F9FAFB; + border: none; + border-radius: 12px; + padding: 12px; + font-size: 13px; +} +#chat_input { + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 24px; + padding: 12px 20px; + font-size: 13px; +} +#chat_input:focus { border-color: #4F6EF7; } +#chat_send { + background: #4F6EF7; + color: white; + border: none; + border-radius: 24px; + padding: 12px 24px; + font-weight: 600; + font-size: 13px; +} +#chat_send:hover { background: #3B5BDB; } + +/* ── Misc ────────────────────────────────────────── */ +QLabel#section_title { + font-size: 16px; + font-weight: 600; + color: #111827; + line-height: 1.6; +} +QSplitter::handle { background: #E5E7EB; width: 1px; } +""" diff --git a/ai_academic_fixed/chatbot/__init__.py b/ai_academic_fixed/chatbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai_academic_fixed/chatbot/chatbot_engine.py b/ai_academic_fixed/chatbot/chatbot_engine.py new file mode 100644 index 0000000..f5f6bc9 --- /dev/null +++ b/ai_academic_fixed/chatbot/chatbot_engine.py @@ -0,0 +1,256 @@ +# chatbot/chatbot_engine.py — AI Chatbot Engine connected to real database +# DATA SOURCE: reads live student records from SQLite via DatabaseManager +# NLP: keyword-based intent detection + context-aware responses from real data + +import re +from database.db_manager import DatabaseManager + + +class Chatbot: + """ + AcadAI Chatbot. + All responses are dynamically built from actual student stats in the DB. + No hardcoded answers — everything comes from real data. + """ + + def __init__(self): + self.db = DatabaseManager() + self._build_intents() + + def _build_intents(self): + self.intents = [ + (['improve gpa', 'boost gpa', 'increase gpa', 'raise gpa', 'better gpa'], + self._resp_improve_gpa), + (['predict', 'predicted gpa', 'next semester', 'forecast', 'future gpa'], + self._resp_prediction), + (['career', 'job', 'field', 'suits me', 'best career', 'path'], + self._resp_career), + (['skill', 'learn', 'technology', 'tools', 'python', 'tensorflow', 'pytorch', 'what should i learn'], + self._resp_skills), + (['study plan', 'study schedule', 'schedule', 'how many hours', 'study time', 'timetable'], + self._resp_study_plan), + (['attendance', 'absent', 'low attendance', 'missing class'], + self._resp_attendance), + (['risk', 'at risk', 'failing', 'fail', 'danger'], + self._resp_risk), + (['weak subject', 'weak', 'failing subject', 'bad subject', 'struggling'], + self._resp_weak_subjects), + (['top student', 'best student', 'highest gpa', 'top performer'], + self._resp_top_students), + (['how many student', 'total student', 'count', 'number of student'], + self._resp_student_count), + (['average gpa', 'class average', 'overall gpa', 'mean gpa'], + self._resp_avg_gpa), + (['hello', 'hi', 'hey', 'good morning', 'good afternoon', 'salaam'], + self._resp_greeting), + (['thank', 'thanks', 'great', 'perfect', 'awesome'], + self._resp_thanks), + (['help', 'what can you do', 'commands', 'options', 'menu'], + self._resp_help), + ] + + def respond(self, user_input: str) -> str: + text = user_input.lower().strip() + for keywords, handler in self.intents: + if any(kw in text for kw in keywords): + return handler() + return self._resp_default(user_input) + + # ── DB helpers ──────────────────────────────────────────────────────────── + def _stats(self): return self.db.get_stats() + def _top(self, n=3): return self.db.get_top_students(n) + def _risk_s(self): return self.db.get_risk_students() + def _avgs(self): return self.db.get_subject_averages() + + # ── Response handlers — all use real DB data ────────────────────────────── + def _resp_greeting(self): + s = self._stats() + return (f"Hello! I am your AcadAI Academic Assistant.\n\n" + f"Currently tracking: {s.get('total',0)} students\n" + f"Class average GPA : {s.get('avg_gpa',0):.2f}\n" + f"Average attendance: {s.get('avg_att',0):.1f}%\n\n" + f"Ask me about GPA, careers, skills, study plans, or at-risk students!") + + def _resp_improve_gpa(self): + s = self._stats(); avgs = self._avgs() + lowest_key = min({'quiz': avgs.get('quiz',70), 'assignment': avgs.get('assignment',70), + 'midterm': avgs.get('midterm',70)}, key=lambda k: avgs.get(k,70)) + return (f"GPA Improvement Strategy\n\n" + f"Class average GPA: {s.get('avg_gpa',0):.2f}\n\n" + f"Top 3 impact areas:\n" + f" Attendance - Current: {avgs.get('attendance',0):.1f}% (target 90%+)\n" + f" Assignments - Current: {avgs.get('assignment',0):.1f}/100\n" + f" Study Hours - 4+ hrs/day = avg +0.4 GPA points\n\n" + f"Weakest area right now: {lowest_key.upper()} ({avgs.get(lowest_key,0):.1f}/100)\n" + f"Focus extra study time here first.") + + def _resp_prediction(self): + s = self._stats(); avgs = self._avgs() + att_bonus = (avgs.get('attendance',75) - 75) * 0.003 + predicted = min(4.0, s.get('avg_gpa',3.0) + att_bonus + 0.12) + return (f"GPA Prediction (AI Model)\n\n" + f"Current class GPA : {s.get('avg_gpa',0):.2f}\n" + f"Predicted next sem : {predicted:.2f}\n\n" + f"Based on:\n" + f" Attendance : {avgs.get('attendance',0):.1f}%\n" + f" Quiz avg : {avgs.get('quiz',0):.1f}/100\n" + f" Assignment : {avgs.get('assignment',0):.1f}/100\n" + f" Midterm : {avgs.get('midterm',0):.1f}/100\n\n" + f"To reach {predicted+0.15:.2f}: increase attendance 5% AND study 1 extra hour daily.") + + def _resp_career(self): + avg = self._stats().get('avg_gpa', 3.0) + top = ("AI/ML Engineer or Research Scientist" if avg >= 3.5 else + "Data Scientist or ML Engineer" if avg >= 3.0 else + "Software Engineer or Data Analyst" if avg >= 2.5 else + "Junior Developer - strengthen fundamentals first") + return (f"Career Recommendation\n\n" + f"Class average GPA: {avg:.2f}\n" + f"Best career match: {top}\n\n" + f"Top BSAI career paths:\n" + f" AI/ML Engineer - $95K-$150K/year\n" + f" Data Scientist - $85K-$130K/year\n" + f" Cybersecurity - $75K-$120K/year\n" + f" Cloud AI Engineer - $90K-$140K/year\n\n" + f"Visit Careers page for individual student recommendations!") + + def _resp_skills(self): + return ("Skill Recommendations for AI/ML Engineer\n\n" + "Learn immediately:\n" + " Python (OOP, NumPy, Pandas)\n" + " Machine Learning (scikit-learn)\n" + " SQL - critical for all data roles\n\n" + "Next 3 months:\n" + " TensorFlow or PyTorch\n" + " MLflow / MLOps basics\n" + " Docker for deployment\n\n" + "Advanced future skills:\n" + " LLM fine-tuning (Hugging Face)\n" + " Reinforcement Learning\n\n" + "Free resources: Kaggle, fast.ai, DeepLearning.AI") + + def _resp_study_plan(self): + avgs = self._avgs() + scores = {'Quiz': avgs.get('quiz',70), 'Assignment': avgs.get('assignment',70), + 'Midterm': avgs.get('midterm',70)} + weak = min(scores, key=scores.get) + return (f"AI Study Plan (Based on Class Data)\n\n" + f"Weakest area: {weak} ({scores[weak]:.0f}/100) - prioritize this!\n\n" + f"Recommended weekly schedule:\n" + f" Monday - Core AI/ML theory (2 hrs)\n" + f" Tuesday - {weak} intensive (2.5 hrs)\n" + f" Wednesday - Assignments + practice (2 hrs)\n" + f" Thursday - Quiz preparation (1.5 hrs)\n" + f" Friday - Revision + projects (2 hrs)\n" + f" Weekend - Kaggle / side projects\n\n" + f"Rule: 4+ study hours/day = average +0.4 GPA points.\n" + f"Go to Planner page for personalized individual schedules!") + + def _resp_attendance(self): + s = self._stats() + att = s.get('avg_att', 80) + msg = "Class attendance is healthy!" if att >= 85 else "Attendance is below target - encourage students to attend regularly." + return (f"Attendance Analysis\n\n" + f"Class average attendance: {att:.1f}%\n" + f"Status: {msg}\n\n" + f"Attendance vs GPA impact:\n" + f" 90%+ attendance -> avg GPA 3.5+\n" + f" 80-90% -> avg GPA 3.0-3.4\n" + f" 70-80% -> avg GPA 2.5-3.0\n" + f" Below 70% -> High risk of failing\n\n" + f"Every missed class = approx 0.05 GPA point loss.") + + def _resp_risk(self): + risk = self._risk_s(); s = self._stats() + at_risk_count = s.get('at_risk', 0) + if not risk: + return "Great news! No students are currently at high risk.\nKeep monitoring attendance and quiz scores." + names = "\n".join(f" - {r['name']} (GPA: {r['gpa']:.2f})" for r in risk[:5]) + extra = f"\n ... and {len(risk)-5} more" if len(risk) > 5 else "" + return (f"At-Risk Students Report\n\n" + f"Total at-risk: {at_risk_count} (GPA below 2.5)\n\n" + f"Students needing help:\n{names}{extra}\n\n" + f"Recommended actions:\n" + f" Schedule counseling sessions\n" + f" Assign peer tutors\n" + f" Monitor attendance weekly\n" + f" Create personalized study plans\n\n" + f"See Analytics page for full details.") + + def _resp_weak_subjects(self): + avgs = self._avgs() + scores = {'Quiz': avgs.get('quiz',70), 'Assignment': avgs.get('assignment',70), + 'Midterm': avgs.get('midterm',70)} + weakest = min(scores, key=scores.get) + return (f"Weak Subject Detection\n\n" + f"Class averages:\n" + f" Attendance : {avgs.get('attendance',0):.1f}%\n" + f" Quiz : {avgs.get('quiz',0):.1f}/100\n" + f" Assignment : {avgs.get('assignment',0):.1f}/100\n" + f" Midterm : {avgs.get('midterm',0):.1f}/100\n\n" + f"Weakest area: {weakest.upper()} ({scores[weakest]:.1f}/100)\n\n" + f"How to improve {weakest}:\n" + f" Dedicate 1 extra hour/day to this area\n" + f" Use practice question banks\n" + f" Form peer study groups\n" + f" Book professor office hours") + + def _resp_top_students(self): + tops = self._top(5) + if not tops: + return "No student data found. Please add students first." + lines = "\n".join(f" #{i+1} {s['name']:<20} GPA: {s['gpa']:.2f}" for i, s in enumerate(tops)) + return f"Top Performers\n\n{lines}\n\nVisit the Dashboard for full rankings." + + def _resp_student_count(self): + s = self._stats() + total = s.get('total', 0) + return (f"Student Statistics\n\n" + f"Total enrolled : {total}\n" + f"Top performers : {s.get('top_performers',0)} (GPA >= 3.5)\n" + f"At-risk : {s.get('at_risk',0)} (GPA < 2.5)\n" + f"Average GPA : {s.get('avg_gpa',0):.2f}\n" + f"Avg Attendance : {s.get('avg_att',0):.1f}%") + + def _resp_avg_gpa(self): + s = self._stats(); avgs = self._avgs() + avg = s.get('avg_gpa', 0) + grade = ('A' if avg >= 3.7 else 'B+' if avg >= 3.3 else 'B' if avg >= 3.0 else 'C+' if avg >= 2.7 else 'C') + return (f"Class Average Report\n\n" + f"Overall GPA : {avg:.2f} (Grade: {grade})\n" + f"Avg Attendance : {s.get('avg_att',0):.1f}%\n" + f"Avg Quiz : {avgs.get('quiz',0):.1f}/100\n" + f"Avg Assignment : {avgs.get('assignment',0):.1f}/100\n" + f"Avg Midterm : {avgs.get('midterm',0):.1f}/100\n\n" + f"{'Class performing above target (3.0)' if avg >= 3.0 else 'Class average below target - intervention recommended.'}") + + def _resp_thanks(self): + return "You are welcome! I am always here to help you make data-driven academic decisions.\nAsk me anything else about GPA, careers, or study planning!" + + def _resp_help(self): + return ("AcadAI Assistant - What I can do:\n\n" + "GPA & Performance:\n" + " 'How to improve GPA?'\n" + " 'What is the average GPA?'\n" + " 'Predict next semester GPA'\n\n" + "Students:\n" + " 'Who are the top students?'\n" + " 'How many students are at risk?'\n" + " 'Show weak subjects'\n\n" + "Career & Skills:\n" + " 'Which career suits me?'\n" + " 'What skills should I learn?'\n\n" + "Study Planning:\n" + " 'Generate a study plan'\n" + " 'How many hours should I study?'\n" + " 'What is the impact of attendance?'") + + def _resp_default(self, user_input): + return (f"I did not fully understand that.\n\n" + f"You asked: \"{user_input[:60]}{'...' if len(user_input)>60 else ''}\"\n\n" + f"Try asking:\n" + f" 'How to improve GPA?'\n" + f" 'Which career suits me?'\n" + f" 'Show at-risk students'\n" + f" 'What skills should I learn?'\n\n" + f"Or type 'help' to see all options.") diff --git a/ai_academic_fixed/config.py b/ai_academic_fixed/config.py new file mode 100644 index 0000000..262bc06 --- /dev/null +++ b/ai_academic_fixed/config.py @@ -0,0 +1,129 @@ +# config.py — Central configuration for AI Academic System + +APP_NAME = "AcadAI Analytics" +APP_VERSION = "2.0" +DB_PATH = "database/academic.db" +MODEL_PATH = "models/gpa_model.pkl" +DATASET_PATH = "datasets/student_data.csv" +EXPORT_DIR = "exports/" + +# Maximum number of students to display in the GPA bar chart +MAX_GPA_CHART_STUDENTS = 30 + +# ── Color Palette (Light Professional SaaS) ────────────────────────────────── +COLORS = { + "bg_main": "#F8F9FC", + "bg_sidebar": "#FFFFFF", + "bg_card": "#FFFFFF", + "bg_hover": "#F1F4FF", + "accent": "#4F6EF7", + "accent_light": "#EEF1FF", + "accent2": "#7C3AED", + "success": "#10B981", + "success_light": "#D1FAE5", + "warning": "#F59E0B", + "warning_light": "#FEF3C7", + "danger": "#EF4444", + "danger_light": "#FEE2E2", + "text_primary": "#111827", + "text_secondary":"#6B7280", + "text_muted": "#9CA3AF", + "border": "#E5E7EB", + "border_light": "#F3F4F6", + "sidebar_active":"#EEF1FF", + "sidebar_text": "#374151", + "shadow": "rgba(0,0,0,0.06)", + "white": "#FFFFFF", + "chart_blue": "#4F6EF7", + "chart_purple": "#7C3AED", + "chart_green": "#10B981", + "chart_orange": "#F59E0B", + "chart_red": "#EF4444", +} + +C = COLORS # shorthand + +# ── Career Data ─────────────────────────────────────────────────────────────── +CAREERS = [ + { + "title": "AI / ML Engineer", + "icon": "🤖", + "color": "#4F6EF7", + "min_gpa": 3.2, + "skills": ["Python", "TensorFlow", "Scikit-learn", "Math", "Deep Learning"], + "desc": "Design and build machine learning models and AI systems at scale.", + "salary": "PKR 150K–300K/month", + "demand": "Very High", + }, + { + "title": "Data Scientist", + "icon": "📊", + "color": "#7C3AED", + "min_gpa": 3.0, + "skills": ["Python", "R", "SQL", "Statistics", "Visualization"], + "desc": "Extract insights from large datasets to drive business decisions.", + "salary": "PKR 120K–250K/month", + "demand": "High", + }, + { + "title": "Cloud Engineer", + "icon": "☁️", + "color": "#0EA5E9", + "min_gpa": 2.8, + "skills": ["AWS", "Docker", "Kubernetes", "Linux", "Terraform"], + "desc": "Architect and manage scalable cloud infrastructure and DevOps pipelines.", + "salary": "PKR 130K–280K/month", + "demand": "Very High", + }, + { + "title": "Cybersecurity Analyst", + "icon": "🔐", + "color": "#EF4444", + "min_gpa": 2.7, + "skills": ["Networking", "Linux", "Python", "Ethical Hacking", "SIEM"], + "desc": "Protect systems and networks from cyber threats and vulnerabilities.", + "salary": "PKR 100K–220K/month", + "demand": "High", + }, + { + "title": "Software Engineer", + "icon": "💻", + "color": "#10B981", + "min_gpa": 2.5, + "skills": ["Java", "Python", "Algorithms", "Git", "System Design"], + "desc": "Build robust, scalable software products and backend systems.", + "salary": "PKR 80K–200K/month", + "demand": "Very High", + }, + { + "title": "Data Analyst", + "icon": "📈", + "color": "#F59E0B", + "min_gpa": 2.4, + "skills": ["SQL", "Excel", "PowerBI", "Tableau", "Statistics"], + "desc": "Analyze data trends and create dashboards to support decisions.", + "salary": "PKR 60K–140K/month", + "demand": "Moderate", + }, +] + +SKILLS_DB = { + "AI / ML Engineer": ["Python", "TensorFlow", "PyTorch", "Scikit-learn", "Mathematics", "Deep Learning", "NLP", "Computer Vision"], + "Data Scientist": ["Python", "R", "SQL", "Pandas", "NumPy", "Statistics", "Seaborn", "Jupyter"], + "Cloud Engineer": ["AWS", "GCP", "Docker", "Kubernetes", "Linux", "Terraform", "CI/CD", "Networking"], + "Cybersecurity Analyst": ["Networking", "Linux", "Python", "Wireshark", "Kali Linux", "SIEM", "Penetration Testing"], + "Software Engineer": ["Python", "Java", "C++", "Git", "Algorithms", "REST APIs", "SQL", "System Design"], + "Data Analyst": ["SQL", "Excel", "PowerBI", "Tableau", "Python", "Statistics", "Data Cleaning"], +} + +CHATBOT_RESPONSES = { + "gpa": ["Focus on weaker subjects first. Consistent daily study (4–6 hrs) dramatically improves GPA.", "Attend all classes, complete assignments on time, and review past papers before exams."], + "career": ["Your strongest career paths depend on GPA + skills. Use the Career tab to see your match %.", "AI, Data Science, and Cloud are top-paying fields in Pakistan right now."], + "study": ["Break your study sessions: 50 min study + 10 min break (Pomodoro technique).", "Prioritize subjects with the most weight. Use the Study Planner for a personalized schedule."], + "skill": ["Python and SQL are essential for almost every tech career. Start there.", "After Python, learn a specialization: ML for AI, Docker for Cloud, or Networks for Cybersecurity."], + "improve": ["Attend all lectures, take notes actively, and attempt past exams under timed conditions.", "Form study groups and teach concepts to others — the best way to solidify understanding."], + "weak": ["Identify weak subjects from the Analytics tab. Spend 30 extra minutes daily on each weak area.", "Seek help early — don't wait until exams. Use online resources like YouTube, Coursera, and Khan Academy."], + "assignment": ["Never skip assignments — they contribute significantly to your semester grade.", "Start assignments early and use office hours when stuck."], + "attendance": ["Each missed class is a lost opportunity. Aim for 90%+ attendance in every subject.", "Low attendance correlates strongly with lower GPA. Prioritize showing up."], + "default": ["I'm your AI academic assistant. Ask me about GPA, careers, study tips, or skill recommendations.", "Try asking: 'How to improve GPA?', 'Best career for me?', or 'What skills should I learn?'"], +} diff --git a/ai_academic_fixed/database/__init__.py b/ai_academic_fixed/database/__init__.py new file mode 100644 index 0000000..c51def1 --- /dev/null +++ b/ai_academic_fixed/database/__init__.py @@ -0,0 +1 @@ +# pkg diff --git a/ai_academic_fixed/database/academic.db b/ai_academic_fixed/database/academic.db new file mode 100644 index 0000000..012b2e3 Binary files /dev/null and b/ai_academic_fixed/database/academic.db differ diff --git a/ai_academic_fixed/database/db_manager.py b/ai_academic_fixed/database/db_manager.py new file mode 100644 index 0000000..7b7bcd8 --- /dev/null +++ b/ai_academic_fixed/database/db_manager.py @@ -0,0 +1,169 @@ +# database/db_manager.py — Full SQLite database manager + +import sqlite3, os, json +from config import DB_PATH + +class DatabaseManager: + def __init__(self): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + self.conn = sqlite3.connect(DB_PATH, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self._create_tables() + self._seed_if_empty() + + def _create_tables(self): + cur = self.conn.cursor() + cur.executescript(""" + CREATE TABLE IF NOT EXISTS students ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT, + semester INTEGER DEFAULT 1, + attendance REAL DEFAULT 0, + quiz REAL DEFAULT 0, + assignment REAL DEFAULT 0, + midterm REAL DEFAULT 0, + study_hours REAL DEFAULT 0, + gpa REAL DEFAULT 0, + skills TEXT DEFAULT '', + interest TEXT DEFAULT '', + predicted_gpa REAL DEFAULT 0, + risk_level TEXT DEFAULT 'Low', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS predictions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + student_id INTEGER, + predicted_gpa REAL, + confidence REAL, + model_used TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (student_id) REFERENCES students(id) + ); + + CREATE TABLE IF NOT EXISTS career_recommendations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + student_id INTEGER, + career TEXT, + match_pct REAL, + recommended_skills TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (student_id) REFERENCES students(id) + ); + """) + self.conn.commit() + + def _seed_if_empty(self): + if self.get_student_count() > 0: + return + import csv + try: + with open('datasets/student_data.csv') as f: + reader = csv.DictReader(f) + for row in reader: + self.add_student( + name=row['name'], + attendance=float(row['attendance']), + quiz=float(row['quiz']), + assignment=float(row['assignment']), + midterm=float(row['midterm']), + study_hours=float(row['study_hours']), + gpa=float(row['gpa']), + skills=row.get('skills', ''), + interest=row.get('interest', ''), + ) + except Exception as e: + print(f"Seed error: {e}") + + # ── CRUD ────────────────────────────────────────────────────────────────── + + def add_student(self, name, attendance=0, quiz=0, assignment=0, + midterm=0, study_hours=0, gpa=0, skills='', interest='', + email='', semester=1): + risk = 'High' if gpa < 2.5 else ('Medium' if gpa < 3.0 else 'Low') + cur = self.conn.cursor() + cur.execute(""" + INSERT INTO students (name,email,semester,attendance,quiz,assignment, + midterm,study_hours,gpa,skills,interest,risk_level) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + """, (name, email, semester, attendance, quiz, assignment, + midterm, study_hours, gpa, skills, interest, risk)) + self.conn.commit() + return cur.lastrowid + + def get_all_students(self): + return self.conn.execute("SELECT * FROM students ORDER BY name").fetchall() + + def get_student(self, student_id): + return self.conn.execute("SELECT * FROM students WHERE id=?", (student_id,)).fetchone() + + def update_student(self, student_id, **kwargs): + if 'gpa' in kwargs: + kwargs['risk_level'] = 'High' if kwargs['gpa'] < 2.5 else ('Medium' if kwargs['gpa'] < 3.0 else 'Low') + sets = ', '.join(f"{k}=?" for k in kwargs) + vals = list(kwargs.values()) + [student_id] + self.conn.execute(f"UPDATE students SET {sets} WHERE id=?", vals) + self.conn.commit() + + def delete_student(self, student_id): + self.conn.execute("DELETE FROM students WHERE id=?", (student_id,)) + self.conn.commit() + + def search_students(self, query): + q = f"%{query}%" + return self.conn.execute( + "SELECT * FROM students WHERE name LIKE ? OR interest LIKE ? OR skills LIKE ?", + (q, q, q)).fetchall() + + def get_student_count(self): + return self.conn.execute("SELECT COUNT(*) FROM students").fetchone()[0] + + def save_prediction(self, student_id, predicted_gpa, confidence, model_used): + self.conn.execute(""" + INSERT INTO predictions (student_id,predicted_gpa,confidence,model_used) + VALUES (?,?,?,?) + """, (student_id, predicted_gpa, confidence, model_used)) + self.conn.execute("UPDATE students SET predicted_gpa=? WHERE id=?", + (predicted_gpa, student_id)) + self.conn.commit() + + def save_career_rec(self, student_id, career, match_pct, recommended_skills): + self.conn.execute(""" + INSERT INTO career_recommendations (student_id,career,match_pct,recommended_skills) + VALUES (?,?,?,?) + """, (student_id, career, match_pct, json.dumps(recommended_skills))) + self.conn.commit() + + # ── Analytics queries ───────────────────────────────────────────────────── + + def get_stats(self): + row = self.conn.execute(""" + SELECT COUNT(*) as total, + AVG(gpa) as avg_gpa, + AVG(attendance) as avg_att, + SUM(CASE WHEN risk_level='High' THEN 1 ELSE 0 END) as at_risk, + SUM(CASE WHEN gpa>=3.5 THEN 1 ELSE 0 END) as top_performers + FROM students + """).fetchone() + return dict(row) + + def get_gpa_distribution(self): + rows = self.conn.execute("SELECT gpa FROM students ORDER BY gpa").fetchall() + return [r['gpa'] for r in rows] + + def get_subject_averages(self): + row = self.conn.execute(""" + SELECT AVG(quiz) as quiz, AVG(assignment) as assignment, + AVG(midterm) as midterm, AVG(attendance) as attendance + FROM students + """).fetchone() + return dict(row) + + def get_top_students(self, n=5): + return self.conn.execute( + "SELECT * FROM students ORDER BY gpa DESC LIMIT ?", (n,)).fetchall() + + def get_risk_students(self): + return self.conn.execute( + "SELECT * FROM students WHERE risk_level='High' ORDER BY gpa").fetchall() diff --git a/ai_academic_fixed/datasets/student_data.csv b/ai_academic_fixed/datasets/student_data.csv new file mode 100644 index 0000000..605628a --- /dev/null +++ b/ai_academic_fixed/datasets/student_data.csv @@ -0,0 +1,31 @@ +name,email,semester,attendance,quiz,assignment,midterm,study_hours,gpa,interest,skills +Alina Cheema,alina.cheema30@student.edu.pk,1,73.1,77.2,70.1,76.8,3.6,4.0,Robotics,"CSS, C++, Azure" +Alina Hassan,alina.hassan59@student.edu.pk,4,93.2,92.5,91.0,93.8,6.2,4.0,Cybersecurity,"Git, Keras, Python" +Alina Iqbal,alina.iqbal45@student.edu.pk,8,67.3,56.8,42.6,34.7,1.4,3.53,Mobile Apps,"HTML, Docker, AWS, NumPy, React" +Amna Ansari,amna.ansari81@student.edu.pk,8,93.8,87.8,92.0,76.1,5.8,4.0,Web Development,"Django, JavaScript, Java" +Amna Rehman,amna.rehman69@student.edu.pk,1,84.8,62.2,65.8,68.7,2.8,4.0,Data Science,"PyTorch, Docker, Flask" +Anum Awan,anum.awan44@student.edu.pk,6,87.3,77.5,67.3,68.0,2.9,4.0,Data Science,"Keras, CSS, Pandas, Python" +Anum Zafar,anum.zafar19@student.edu.pk,3,71.8,52.3,51.4,51.2,2.2,4.0,Robotics,"React, CSS, Pandas, PyTorch, Node.js" +Asad Chaudhry,asad.chaudhry69@student.edu.pk,4,82.4,70.8,62.3,59.9,4.0,4.0,Data Science,"Figma, Scikit-learn, SQL, Java" +Fatima Rehman,fatima.rehman95@student.edu.pk,4,91.8,78.7,87.5,79.0,6.8,4.0,Cybersecurity,"Figma, Git, SQL" +Hamza Awan,hamza.awan29@student.edu.pk,5,68.8,48.6,44.0,57.4,0.6,3.6,Cybersecurity,"JavaScript, Git, Flask, Linux, Firebase" +Hamza Zafar,hamza.zafar48@student.edu.pk,4,97.2,88.8,87.8,77.2,5.1,4.0,Web Development,"Kubernetes, CSS, Scikit-learn, Matplotlib" +Hassan Iqbal,hassan.iqbal14@student.edu.pk,6,77.0,73.1,69.9,76.3,3.4,4.0,IoT,"C++, JavaScript, AWS, Git" +Hoorain Iqbal,hoorain.iqbal47@student.edu.pk,2,94.4,84.6,85.9,79.2,4.8,4.0,Mobile Apps,"PyTorch, Node.js, Pandas" +Imran Baig,imran.baig23@student.edu.pk,6,96.6,95.6,93.0,92.6,4.1,4.0,Robotics,"Azure, Docker, HTML, React" +Imran Iqbal,imran.iqbal18@student.edu.pk,6,65.6,36.6,39.6,32.9,1.2,2.91,AI,"Scikit-learn, React, AWS, Python, C++" +Imran Qureshi,imran.qureshi62@student.edu.pk,8,58.3,61.6,44.4,54.0,1.4,4.0,Game Development,"PyTorch, Java, CSS" +Junaid Siddiqui,junaid.siddiqui65@student.edu.pk,2,80.1,73.3,68.5,71.9,3.6,4.0,Cloud Computing,"Flask, Linux, SQL, Docker, Django" +Kamran Malik,kamran.malik41@student.edu.pk,5,98.0,80.5,87.9,90.1,6.6,4.0,UI/UX,"AWS, PyTorch, TensorFlow, React" +Mahnoor Rehman,mahnoor.rehman45@student.edu.pk,1,89.6,86.0,91.3,86.2,4.3,4.0,AI,"Kubernetes, HTML, Firebase, Node.js" +Maryam Sheikh,maryam.sheikh19@student.edu.pk,1,93.1,80.4,85.6,76.4,4.7,4.0,Game Development,"AWS, SQL, C++, Scikit-learn, JavaScript" +Mehwish Qureshi,mehwish.qureshi43@student.edu.pk,7,92.0,90.1,86.0,93.0,5.4,4.0,Mobile Apps,"Kubernetes, Docker, Git, JavaScript, Django" +Rida Sheikh,rida.sheikh13@student.edu.pk,2,89.1,85.8,91.7,81.3,5.8,4.0,Cybersecurity,"AWS, Node.js, Java" +Saad Awan,saad.awan14@student.edu.pk,6,69.8,56.5,58.7,57.2,0.9,4.0,UI/UX,"HTML, Keras, Java, Django, TensorFlow" +Saad Zafar,saad.zafar30@student.edu.pk,7,93.3,95.5,93.8,75.2,6.2,4.0,UI/UX,"PyTorch, Azure, Pandas" +Shahid Qureshi,shahid.qureshi66@student.edu.pk,5,76.8,73.3,80.8,60.7,2.7,4.0,UI/UX,"Matplotlib, Python, Linux, Photoshop, Scikit-learn" +Usman Hussain,usman.hussain78@student.edu.pk,2,82.1,77.7,69.2,61.8,2.6,4.0,IoT,"Scikit-learn, Django, Photoshop" +Waqas Iqbal,waqas.iqbal17@student.edu.pk,4,85.1,79.6,72.7,60.5,3.8,4.0,UI/UX,"C++, Docker, NumPy" +Zainab Chaudhry,zainab.chaudhry10@student.edu.pk,3,77.4,61.9,63.9,74.9,3.7,4.0,IoT,"Node.js, HTML, JavaScript, Kubernetes" +Zainab Riaz,zainab.riaz15@student.edu.pk,2,82.7,66.3,67.3,60.6,3.8,4.0,IoT,"AWS, Matplotlib, Flask, Node.js, JavaScript" +Zara Mirza,zara.mirza39@student.edu.pk,4,87.5,65.6,74.8,66.0,4.5,4.0,AI,"JavaScript, PyTorch, Flask, Node.js, C++" diff --git a/ai_academic_fixed/generate_dataset.py b/ai_academic_fixed/generate_dataset.py new file mode 100644 index 0000000..6226f7b --- /dev/null +++ b/ai_academic_fixed/generate_dataset.py @@ -0,0 +1,203 @@ +# generate_dataset.py +# ───────────────────────────────────────────────────────────────────────────── +# Run this file ONCE to create: datasets/student_data.csv +# +# HOW IT WORKS: +# • Generates 30 realistic Pakistani university student records +# • GPA is calculated using a weighted formula (same as the ML model) +# • Data is correlated — higher attendance → better GPA +# • Covers all risk levels: High / Medium / Low +# +# HOW TO RUN: +# python generate_dataset.py +# +# OUTPUT: +# datasets/student_data.csv +# ───────────────────────────────────────────────────────────────────────────── + +import csv +import random +import os + +random.seed(42) + +# ── Pakistani student names ─────────────────────────────────────────────────── +FIRST_NAMES = [ + "Ali", "Ahmed", "Usman", "Hassan", "Bilal", "Faisal", "Hamza", "Zain", + "Omar", "Saad", "Talha", "Asad", "Junaid", "Tariq", "Fahad", "Imran", + "Kamran", "Rizwan", "Shahid", "Waqas", "Ayesha", "Fatima", "Zara", + "Sana", "Hira", "Nadia", "Rabia", "Amna", "Sara", "Maryam", + "Kiran", "Nimra", "Alina", "Saima", "Rida", "Mehwish", "Iqra", + "Zainab", "Sumera", "Hafsa", "Mahnoor", "Laiba", "Hoorain", "Anum", +] + +LAST_NAMES = [ + "Khan", "Ahmed", "Ali", "Hassan", "Malik", "Qureshi", "Butt", "Iqbal", + "Chaudhry", "Mirza", "Sheikh", "Siddiqui", "Raza", "Rehman", "Hussain", + "Bajwa", "Cheema", "Awan", "Nawaz", "Riaz", "Zafar", "Baig", "Ansari", +] + +# ── Interests and Skills ───────────────────────────────────────────────────── +INTERESTS = [ + "AI", "Data Science", "Web Development", "Mobile Apps", "Cybersecurity", + "Cloud Computing", "IoT", "Game Development", "Robotics", "UI/UX" +] + +SKILLS_POOL = [ + "Python", "Java", "C++", "SQL", "HTML", "CSS", "JavaScript", "React", + "Node.js", "Django", "Flask", "TensorFlow", "PyTorch", "Keras", "Pandas", + "NumPy", "Matplotlib", "Scikit-learn", "AWS", "Azure", "Firebase", + "Linux", "Git", "Docker", "Kubernetes", "Figma", "Photoshop" +] +# ── GPA Calculation ───────────────────────────────────────────────────────── +def calculate_gpa(attendance, quiz, assignment, midterm, study_hours): + # Model-learnable, noisy GPA formula + import numpy as np + raw = ( + 0.015 * attendance + + 0.018 * quiz + + 0.016 * assignment + + 0.022 * midterm + + 0.08 * study_hours + + np.random.normal(0, 0.25) + ) + return round(float(np.clip(raw, 0.0, 4.0)), 2) + +def make_student_profile(profile_type): + + if profile_type == "strong": + attendance = round(random.uniform(88, 98), 1) + quiz = round(random.uniform(78, 96), 1) + assignment = round(random.uniform(80, 96), 1) + midterm = round(random.uniform(75, 95), 1) + study_hours = round(random.uniform(4.0, 7.0), 1) + + elif profile_type == "average": + attendance = round(random.uniform(72, 88), 1) + quiz = round(random.uniform(60, 80), 1) + assignment = round(random.uniform(62, 82), 1) + midterm = round(random.uniform(58, 78), 1) + study_hours = round(random.uniform(2.5, 4.5), 1) + + else: # weak / at-risk + attendance = round(random.uniform(50, 72), 1) + quiz = round(random.uniform(35, 62), 1) + assignment = round(random.uniform(38, 62), 1) + midterm = round(random.uniform(32, 60), 1) + study_hours = round(random.uniform(0.5, 2.5), 1) + + gpa = calculate_gpa(attendance, quiz, assignment, midterm, study_hours) + + return attendance, quiz, assignment, midterm, study_hours, gpa + + +# ── Main generation logic ───────────────────────────────────────────────────── +def generate_csv(output_path="datasets/student_data.csv", n_students=30): + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + used_names = set() + + def unique_name(): + for _ in range(100): + name = f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}" + + if name not in used_names: + used_names.add(name) + return name + + return f"Student {len(used_names)+1}" + + # 12 strong, 12 average, 6 weak = 30 total + profiles = ( + ["strong"] * 12 + + ["average"] * 12 + + ["weak"] * 6 + ) + + random.shuffle(profiles) + + rows = [] + + for profile in profiles: + + name = unique_name() + + email = ( + f"{name.split()[0].lower()}." + f"{name.split()[1].lower()}" + f"{random.randint(10,99)}@student.edu.pk" + ) + + semester = random.randint(1, 8) + interest = random.choice(INTERESTS) + + # Pick 3–5 random skills + n_skills = random.randint(3, 5) + skills = ", ".join(random.sample(SKILLS_POOL, n_skills)) + + att, quiz, asgn, mid, hrs, gpa = make_student_profile(profile) + + rows.append({ + "name": name, + "email": email, + "semester": semester, + "attendance": att, + "quiz": quiz, + "assignment": asgn, + "midterm": mid, + "study_hours": hrs, + "gpa": gpa, + "interest": interest, + "skills": skills, + }) + + # Sort by name + rows.sort(key=lambda r: r["name"]) + + fieldnames = [ + "name", + "email", + "semester", + "attendance", + "quiz", + "assignment", + "midterm", + "study_hours", + "gpa", + "interest", + "skills" + ] + + with open(output_path, "w", newline="", encoding="utf-8") as f: + + writer = csv.DictWriter(f, fieldnames=fieldnames) + + writer.writeheader() + writer.writerows(rows) + + # ── Summary ────────────────────────────────────────────────────────────── + gpas = [r["gpa"] for r in rows] + + avg_gpa = sum(gpas) / len(gpas) + high_risk = sum(1 for g in gpas if g < 2.5) + top_perf = sum(1 for g in gpas if g >= 3.5) + + print("=" * 55) + print(" CSV Dataset Generated Successfully!") + print("=" * 55) + print(f" File : {output_path}") + print(f" Students : {len(rows)}") + print(f" Avg GPA : {avg_gpa:.2f}") + print(f" Top (>=3.5): {top_perf}") + print(f" At-Risk : {high_risk} (GPA < 2.5)") + print(f" Columns : {', '.join(fieldnames)}") + print("=" * 55) + print() + print(" Next step: run python main.py") + print(" The app will auto-load this CSV on first launch.") + print("=" * 55) + + +if __name__ == "__main__": + generate_csv() \ No newline at end of file diff --git a/ai_academic_fixed/main.py b/ai_academic_fixed/main.py new file mode 100644 index 0000000..37de0f7 --- /dev/null +++ b/ai_academic_fixed/main.py @@ -0,0 +1,63 @@ +# main.py — Entry point for AcadAI Analytics Platform + +import sys +import os + +# Ensure project root is on path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from PyQt5.QtWidgets import QApplication, QSplashScreen +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QPixmap, QColor, QFont, QPainter + +# ── Pre-train model before GUI starts ──────────────────────────────────────── +def ensure_model(): + try: + from models.training import train_model + from config import MODEL_PATH + if not os.path.exists(MODEL_PATH): + print("Training AI model...") + result = train_model() + print(f"Model trained: R²={result['rf_r2']}%") + except Exception as e: + print(f"Model training warning: {e}") + +ensure_model() + +# ── Launch App ──────────────────────────────────────────────────────────────── +from ui.main_window import MainWindow + +app = QApplication(sys.argv) +app.setApplicationName("AcadAI Analytics") + +# High DPI support +try: + app.setAttribute(Qt.AA_EnableHighDpiScaling, True) + app.setAttribute(Qt.AA_UseHighDpiPixmaps, True) +except: + pass + +# Splash screen +splash_pix = QPixmap(480, 280) +splash_pix.fill(QColor("#FFFFFF")) +painter = QPainter(splash_pix) +painter.setFont(QFont("Segoe UI", 28, QFont.Bold)) +painter.setPen(QColor("#4F6EF7")) +painter.drawText(splash_pix.rect(), Qt.AlignCenter, "🎓 AcadAI Analytics") +painter.setFont(QFont("Segoe UI", 12)) +painter.setPen(QColor("#9CA3AF")) +painter.drawText(0, 200, 480, 40, Qt.AlignCenter, "AI Academic Performance Platform • BS AI Exhibition") +painter.end() + +splash = QSplashScreen(splash_pix) +splash.show() +app.processEvents() + +window = MainWindow() + +def show_main(): + splash.finish(window) + window.show() + +QTimer.singleShot(1500, show_main) +sys.exit(app.exec_()) diff --git a/ai_academic_fixed/models/__init__.py b/ai_academic_fixed/models/__init__.py new file mode 100644 index 0000000..c51def1 --- /dev/null +++ b/ai_academic_fixed/models/__init__.py @@ -0,0 +1 @@ +# pkg diff --git a/ai_academic_fixed/models/gpa_model.pkl b/ai_academic_fixed/models/gpa_model.pkl new file mode 100644 index 0000000..11828c4 Binary files /dev/null and b/ai_academic_fixed/models/gpa_model.pkl differ diff --git a/ai_academic_fixed/models/prediction_model.py b/ai_academic_fixed/models/prediction_model.py new file mode 100644 index 0000000..8c7532f --- /dev/null +++ b/ai_academic_fixed/models/prediction_model.py @@ -0,0 +1,75 @@ +# models/prediction_model.py +import joblib, os, numpy as np +from models.preprocessing import preprocess_input +from models.training import train_model +from config import MODEL_PATH + +class GPAPredictor: + def __init__(self): + self._load_or_train() + + def _load_or_train(self): + if os.path.exists(MODEL_PATH): + data = joblib.load(MODEL_PATH) + self.rf = data['rf'] + self.lr = data['lr'] + self.accuracy = data.get('r2', 0.9) + else: + result = train_model() + data = joblib.load(MODEL_PATH) + self.rf = data['rf'] + self.lr = data['lr'] + self.accuracy = data.get('r2', 0.9) + + def predict(self, attendance, quiz, assignment, midterm, study_hours): + X = preprocess_input(attendance, quiz, assignment, midterm, study_hours) + rf_pred = float(self.rf.predict(X)[0]) + lr_pred = float(self.lr.predict(X)[0]) + ensemble = round((rf_pred * 0.65 + lr_pred * 0.35), 2) + ensemble = max(0.0, min(4.0, ensemble)) + confidence = round(min(98, max(60, self.accuracy * 100 - abs(rf_pred - lr_pred) * 10)), 1) + risk = 'High' if ensemble < 2.5 else ('Medium' if ensemble < 3.0 else 'Low') + trend = self._get_trend(ensemble) + return { + 'predicted_gpa': ensemble, + 'rf_prediction': round(rf_pred, 2), + 'lr_prediction': round(lr_pred, 2), + 'confidence': confidence, + 'risk': risk, + 'trend': trend, + 'grade': self._gpa_to_grade(ensemble), + } + + def _gpa_to_grade(self, gpa): + if gpa >= 3.7: return 'A+' + if gpa >= 3.5: return 'A' + if gpa >= 3.3: return 'A-' + if gpa >= 3.0: return 'B+' + if gpa >= 2.7: return 'B' + if gpa >= 2.5: return 'B-' + if gpa >= 2.0: return 'C' + return 'D' + + def _get_trend(self, gpa): + if gpa >= 3.5: return '↑ Excellent' + if gpa >= 3.0: return '→ Good' + if gpa >= 2.5: return '↘ Average' + return '↓ At Risk' + + def get_weak_subjects(self, attendance, quiz, assignment, midterm): + weak = [] + if attendance < 75: weak.append(('Attendance', attendance, 75)) + if quiz < 65: weak.append(('Quiz', quiz, 65)) + if assignment < 70: weak.append(('Assignment', assignment, 70)) + if midterm < 60: weak.append(('Midterm', midterm, 60)) + return weak + + def get_suggestions(self, attendance, quiz, assignment, midterm, study_hours): + suggestions = [] + if attendance < 80: suggestions.append("📅 Attendance below 80% — attend all upcoming lectures") + if quiz < 65: suggestions.append("✏️ Quiz scores low — practice MCQs and concept drills daily") + if assignment < 70: suggestions.append("📝 Assignment marks need improvement — submit all work on time") + if midterm < 60: suggestions.append("📚 Midterm performance weak — review lecture notes thoroughly") + if study_hours < 3: suggestions.append("⏰ Increase study hours to at least 4–5 hrs/day") + if not suggestions: suggestions.append("✅ Performance is strong — maintain consistency and stay ahead!") + return suggestions diff --git a/ai_academic_fixed/models/preprocessing.py b/ai_academic_fixed/models/preprocessing.py new file mode 100644 index 0000000..ede7465 --- /dev/null +++ b/ai_academic_fixed/models/preprocessing.py @@ -0,0 +1,17 @@ +# models/preprocessing.py +import numpy as np, pandas as pd + +FEATURES = ['attendance', 'quiz', 'assignment', 'midterm', 'study_hours'] + +def preprocess(df): + df = df.copy() + for col in FEATURES: + if col not in df.columns: + df[col] = 0 + X = df[FEATURES].fillna(df[FEATURES].mean()) + y = df['gpa'].fillna(df['gpa'].mean()) + return X.values, y.values + +def preprocess_input(attendance, quiz, assignment, midterm, study_hours): + arr = np.array([[attendance, quiz, assignment, midterm, study_hours]], dtype=float) + return arr diff --git a/ai_academic_fixed/models/training.py b/ai_academic_fixed/models/training.py new file mode 100644 index 0000000..0f52bbd --- /dev/null +++ b/ai_academic_fixed/models/training.py @@ -0,0 +1,54 @@ +# models/training.py +import pandas as pd, joblib, os +from sklearn.ensemble import RandomForestRegressor +from sklearn.linear_model import LinearRegression +from sklearn.model_selection import train_test_split, cross_val_score +from sklearn.metrics import mean_squared_error, r2_score +import numpy as np +from models.preprocessing import preprocess +from config import MODEL_PATH, DATASET_PATH + +def train_model(dataset_path=None): + path = dataset_path or DATASET_PATH + df = pd.read_csv(path) + + # augment small dataset with slight noise so cross-val is valid + augmented = [] + for _ in range(10): + noise = df.copy() + for col in ['attendance','quiz','assignment','midterm','study_hours']: + noise[col] = noise[col] + np.random.normal(0, 1.5, len(noise)) + noise[col] = noise[col].clip(0, 100) + noise['gpa'] = (noise['gpa'] + np.random.normal(0, 0.05, len(noise))).clip(0, 4.0) + augmented.append(noise) + df_aug = pd.concat([df] + augmented, ignore_index=True) + + X, y = preprocess(df_aug) + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) + + rf = RandomForestRegressor(n_estimators=200, max_depth=8, random_state=42) + rf.fit(X_train, y_train) + + lr = LinearRegression() + lr.fit(X_train, y_train) + + y_pred_rf = rf.predict(X_test) + rf_r2 = r2_score(y_test, y_pred_rf) + rf_rmse = np.sqrt(mean_squared_error(y_test, y_pred_rf)) + + y_pred_lr = lr.predict(X_test) + lr_r2 = r2_score(y_test, y_pred_lr) + + os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True) + joblib.dump({'rf': rf, 'lr': lr, 'r2': rf_r2, 'rmse': rf_rmse}, MODEL_PATH) + + return { + 'rf_r2': round(rf_r2 * 100, 1), + 'lr_r2': round(lr_r2 * 100, 1), + 'rmse': round(rf_rmse, 3), + 'samples': len(df_aug) + } + +if __name__ == '__main__': + result = train_model() + print("Training complete:", result) diff --git a/ai_academic_fixed/recommender/__init__.py b/ai_academic_fixed/recommender/__init__.py new file mode 100644 index 0000000..c51def1 --- /dev/null +++ b/ai_academic_fixed/recommender/__init__.py @@ -0,0 +1 @@ +# pkg diff --git a/ai_academic_fixed/recommender/career_recommender.py b/ai_academic_fixed/recommender/career_recommender.py new file mode 100644 index 0000000..ea6399b --- /dev/null +++ b/ai_academic_fixed/recommender/career_recommender.py @@ -0,0 +1,47 @@ +# recommender/career_recommender.py +from config import CAREERS, SKILLS_DB + +class CareerRecommender: + def recommend(self, gpa, skills_str='', interest=''): + user_skills = set(s.strip().lower() for s in skills_str.replace(';', ',').split(',') if s.strip()) + results = [] + for career in CAREERS: + match = self._compute_match(gpa, user_skills, interest, career) + results.append({**career, 'match': match}) + results.sort(key=lambda x: x['match'], reverse=True) + return results + + def _compute_match(self, gpa, user_skills, interest, career): + score = 0 + # GPA component (40%) + gpa_norm = min(1.0, gpa / 4.0) + gpa_score = gpa_norm * 40 + + # Skills component (40%) + req_skills = set(s.lower() for s in career['skills']) + if req_skills: + skill_overlap = len(user_skills & req_skills) / len(req_skills) + else: + skill_overlap = 0 + skill_score = skill_overlap * 40 + + # Interest component (20%) + interest_score = 0 + if interest: + int_lower = interest.lower() + career_lower = career['title'].lower() + if any(w in career_lower for w in int_lower.split()): + interest_score = 20 + + total = gpa_score + skill_score + interest_score + # bonus for exceeding min GPA + if gpa >= career['min_gpa']: + total = min(100, total + 5) + return round(min(100, max(5, total))) + + def get_skill_recommendations(self, career_title, user_skills_str=''): + user_skills = set(s.strip().lower() for s in user_skills_str.replace(';', ',').split(',') if s.strip()) + all_skills = SKILLS_DB.get(career_title, []) + missing = [s for s in all_skills if s.lower() not in user_skills] + present = [s for s in all_skills if s.lower() in user_skills] + return {'missing': missing, 'present': present, 'all': all_skills} diff --git a/ai_academic_fixed/recommender/study_planner.py b/ai_academic_fixed/recommender/study_planner.py new file mode 100644 index 0000000..4b03549 --- /dev/null +++ b/ai_academic_fixed/recommender/study_planner.py @@ -0,0 +1,38 @@ +# recommender/study_planner.py + +DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + +def generate_plan(attendance, quiz, assignment, midterm, study_hours, interest=''): + weak_subjects = [] + if attendance < 80: weak_subjects.append(('Attendance Review', 'HIGH', '#EF4444')) + if midterm < 65: weak_subjects.append(('Core Theory', 'HIGH', '#EF4444')) + if quiz < 70: weak_subjects.append(('Practice Quizzes', 'MED', '#F59E0B')) + if assignment < 75: weak_subjects.append(('Assignments', 'MED', '#F59E0B')) + + regular = [ + ('Mathematics', 'LOW', '#10B981'), + ('Programming Practice', 'LOW', '#10B981'), + ('Research & Reading', 'LOW', '#4F6EF7'), + ] + + all_subjects = weak_subjects + regular + daily_hrs = max(2, min(8, int(study_hours))) + + plan = [] + for i, day in enumerate(DAYS): + if day == 'Sunday': + plan.append({'day': day, 'sessions': [{'subject': 'Rest & Revision', 'duration': 60, 'priority': 'REST', 'color': '#6B7280'}], 'total_hrs': 1}) + continue + sessions = [] + remaining = daily_hrs * 60 # minutes + idx = 0 + while remaining > 0 and idx < len(all_subjects): + subj, prio, color = all_subjects[idx % len(all_subjects)] + dur = 90 if prio == 'HIGH' else (60 if prio == 'MED' else 45) + dur = min(dur, remaining) + sessions.append({'subject': subj, 'duration': dur, 'priority': prio, 'color': color}) + remaining -= dur + 10 # 10 min break + idx += 1 + plan.append({'day': day, 'sessions': sessions, 'total_hrs': round(daily_hrs * 0.9, 1)}) + + return plan diff --git a/ai_academic_fixed/requirements.txt b/ai_academic_fixed/requirements.txt new file mode 100644 index 0000000..5e1c8f1 --- /dev/null +++ b/ai_academic_fixed/requirements.txt @@ -0,0 +1,6 @@ +PyQt5>=5.15.0 +matplotlib>=3.7.0 +scikit-learn>=1.3.0 +pandas>=2.0.0 +numpy>=1.24.0 +joblib>=1.3.0 diff --git a/ai_academic_fixed/ui/__init__.py b/ai_academic_fixed/ui/__init__.py new file mode 100644 index 0000000..b868da5 --- /dev/null +++ b/ai_academic_fixed/ui/__init__.py @@ -0,0 +1 @@ +# package init diff --git a/ai_academic_fixed/ui/analytics_page.py b/ai_academic_fixed/ui/analytics_page.py new file mode 100644 index 0000000..7f484ba --- /dev/null +++ b/ai_academic_fixed/ui/analytics_page.py @@ -0,0 +1,208 @@ +# ui/analytics_page.py — Full Analytics with Prediction Engine + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QComboBox, QDoubleSpinBox, QPushButton, + QScrollArea, QGridLayout, QSizePolicy, QFrame) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from ui.components.cards import ContentCard, StatCard, ProgressRow, InsightRow +from ui.components.charts import (GPABarChart, SubjectRadarChart, + SubjectBarChart, RiskPieChart) +from analytics.analytics_engine import AnalyticsEngine +from models.prediction_model import GPAPredictor +from database.db_manager import DatabaseManager + + +class AnalyticsPage(QWidget): + def __init__(self, db: DatabaseManager): + super().__init__() + self.db = db + self.engine = AnalyticsEngine(db) + self.predictor = GPAPredictor() + self.setStyleSheet("background:#F8F9FC;") + self._build() + self._load_charts() + + def _build(self): + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(0) + scroll.setStyleSheet("background:#F8F9FC;") + + content = QWidget() + content.setStyleSheet("background:#F8F9FC;") + self.main_layout = QVBoxLayout(content) + self.main_layout.setContentsMargins(28, 24, 28, 28) + self.main_layout.setSpacing(20) + scroll.setWidget(content) + outer.addWidget(scroll) + + # ── AI Prediction Panel ─────────────────────── + pred_card = ContentCard("🔮 AI GPA Predictor", "Enter student metrics for real-time prediction") + pl = pred_card.body_layout + pl.setSpacing(14) + + row1 = QHBoxLayout() + row1.setSpacing(12) + + def make_spin(label, min_v, max_v, dec, default): + col = QVBoxLayout() + lbl = QLabel(label) + lbl.setStyleSheet("font-size:11px;color:#9CA3AF;font-weight:500;letter-spacing:0.3px;") + sp = QDoubleSpinBox() + sp.setRange(min_v, max_v) + sp.setDecimals(dec) + sp.setValue(default) + sp.setFixedHeight(38) + sp.setStyleSheet("background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;" + "padding:0 10px;font-size:13px;color:#111827;") + col.addWidget(lbl) + col.addWidget(sp) + return col, sp + + c1, self.sp_att = make_spin("ATTENDANCE %", 0, 100, 1, 85.0) + c2, self.sp_quiz = make_spin("QUIZ SCORE", 0, 100, 1, 78.0) + c3, self.sp_asgn = make_spin("ASSIGNMENT", 0, 100, 1, 80.0) + c4, self.sp_mid = make_spin("MIDTERM", 0, 100, 1, 72.0) + c5, self.sp_hrs = make_spin("STUDY HRS/DAY", 0, 12, 1, 4.0) + + for c in [c1, c2, c3, c4, c5]: + row1.addLayout(c) + pl.addLayout(row1) + + btn_row = QHBoxLayout() + self.predict_btn = QPushButton(" Run AI Prediction") + self.predict_btn.setFixedHeight(40) + self.predict_btn.setCursor(Qt.PointingHandCursor) + self.predict_btn.setStyleSheet(""" + QPushButton { background:#4F6EF7;color:white;border:none;border-radius:8px; + padding:0 28px;min-height:34px;font-size:13px;font-weight:500; } + QPushButton:hover { background:#3B5BDB; } + """) + self.predict_btn.clicked.connect(self._run_prediction) + btn_row.addStretch() + btn_row.addWidget(self.predict_btn) + pl.addLayout(btn_row) + + # Result area + self.result_row = QHBoxLayout() + self.result_row.setSpacing(10) + pl.addLayout(self.result_row) + + self.main_layout.addWidget(pred_card) + + # ── Charts grid ─────────────────────────────── + grid = QGridLayout() + grid.setSpacing(14) + self.main_layout.addLayout(grid) + + self.bar_card = ContentCard("GPA by Student", "") + self.bar_ph = QWidget() + self.bar_ph.setMinimumHeight(200) + self.bar_card.body_layout.addWidget(self.bar_ph) + grid.addWidget(self.bar_card, 0, 0, 1, 2) + + self.radar_card = ContentCard("Subject Radar", "Class averages") + self.radar_ph = QWidget() + self.radar_ph.setMinimumHeight(240) + self.radar_card.body_layout.addWidget(self.radar_ph) + grid.addWidget(self.radar_card, 1, 0) + + self.subj_card = ContentCard("Score Breakdown", "By subject area") + self.subj_ph = QWidget() + self.subj_ph.setMinimumHeight(240) + self.subj_card.body_layout.addWidget(self.subj_ph) + grid.addWidget(self.subj_card, 1, 1) + + self.risk_card = ContentCard("Risk Distribution", "Student risk levels") + self.risk_ph = QWidget() + self.risk_ph.setMinimumHeight(200) + self.risk_card.body_layout.addWidget(self.risk_ph) + grid.addWidget(self.risk_card, 2, 0) + + # At-risk students list + self.atrisk_card = ContentCard("⚠️ At-Risk Students", "GPA below 2.5") + self.atrisk_body = self.atrisk_card.body_layout + grid.addWidget(self.atrisk_card, 2, 1) + + def _load_charts(self): + names, gpas = self.engine.get_gpa_chart_data() + self._swap(self.bar_card.body_layout, 0, GPABarChart(names, gpas, figsize=(8, 2.6))) + + avgs = self.db.get_subject_averages() + labels = ['Attendance', 'Quiz', 'Assignment', 'Midterm'] + values = [avgs['attendance'], avgs['quiz'], avgs['assignment'], avgs['midterm']] + self._swap(self.radar_card.body_layout, 0, SubjectRadarChart(labels, values, figsize=(4, 3.5))) + self._swap(self.subj_card.body_layout, 0, SubjectBarChart(labels, values, figsize=(4, 3.5))) + + risk = self.engine.get_risk_breakdown() + self._swap(self.risk_card.body_layout, 0, RiskPieChart(risk, figsize=(4, 3))) + + # At-risk students + self._clear(self.atrisk_body) + risk_students = self.db.get_risk_students() + if not risk_students: + lbl = QLabel("✅ No students currently at risk") + lbl.setStyleSheet("color:#10B981;font-size:13px;") + self.atrisk_body.addWidget(lbl) + for s in risk_students: + row = QHBoxLayout() + n = QLabel(s['name']) + n.setStyleSheet("font-size:13px;font-weight:600;color:#111827;") + g = QLabel(f"GPA: {s['gpa']:.2f}") + g.setStyleSheet("font-size:13px;color:#EF4444;font-weight:500;") + row.addWidget(n, 1) + row.addWidget(g) + self.atrisk_body.addLayout(row) + self.atrisk_body.addStretch() + + def _run_prediction(self): + att = self.sp_att.value() + quiz = self.sp_quiz.value() + asgn = self.sp_asgn.value() + mid = self.sp_mid.value() + hrs = self.sp_hrs.value() + + result = self.predictor.predict(att, quiz, asgn, mid, hrs) + + # Clear old results + while self.result_row.count(): + item = self.result_row.takeAt(0) + if item.widget(): item.widget().deleteLater() + + def res_card(label, value, color='#4F6EF7', bg='#EEF1FF'): + w = QWidget() + w.setStyleSheet(f"background:{bg};border-radius:10px;padding:4px;") + l = QVBoxLayout(w) + l.setContentsMargins(16, 12, 16, 12) + v = QLabel(str(value)) + v.setStyleSheet(f"font-size:22px;font-weight:800;color:{color};") + lb = QLabel(label) + lb.setStyleSheet("font-size:13px;color:#6B7280;font-weight:400;") + l.addWidget(v) + l.addWidget(lb) + return w + + gpa = result['predicted_gpa'] + color = '#10B981' if gpa >= 3.5 else ('#F59E0B' if gpa >= 2.5 else '#EF4444') + self.result_row.addWidget(res_card("Predicted GPA", f"{gpa:.2f}", color, f"{color}18")) + self.result_row.addWidget(res_card("Grade", result['grade'], '#7C3AED', '#EDE9FE')) + self.result_row.addWidget(res_card("Confidence", f"{result['confidence']}%", '#4F6EF7', '#EEF1FF')) + self.result_row.addWidget(res_card("Trend", result['trend'], '#F59E0B', '#FEF3C7')) + self.result_row.addWidget(res_card("Risk Level", result['risk'], + '#EF4444' if result['risk']=='High' else '#10B981', '#F8F9FC')) + self.result_row.addStretch() + + def _swap(self, layout, idx, widget): + old = layout.itemAt(idx) + if old and old.widget(): old.widget().deleteLater() + layout.insertWidget(idx, widget) + + def _clear(self, layout): + while layout.count(): + item = layout.takeAt(0) + if item.widget(): item.widget().deleteLater() + elif item.layout(): self._clear(item.layout()) diff --git a/ai_academic_fixed/ui/career_page.py b/ai_academic_fixed/ui/career_page.py new file mode 100644 index 0000000..b945bfc --- /dev/null +++ b/ai_academic_fixed/ui/career_page.py @@ -0,0 +1,191 @@ +# ui/career_page.py — Career Recommendation Page + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QComboBox, QPushButton, QScrollArea, + QGridLayout, QDoubleSpinBox, QFrame, QProgressBar) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from ui.components.cards import ContentCard, CareerCard +from recommender.career_recommender import CareerRecommender +from database.db_manager import DatabaseManager + + +class CareerPage(QWidget): + def __init__(self, db: DatabaseManager): + super().__init__() + self.db = db + self.recommender = CareerRecommender() + self.setStyleSheet("background:#F8F9FC;") + self._build() + self._run_recommendation() + + def _build(self): + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(0) + scroll.setStyleSheet("background:#F8F9FC;") + + content = QWidget() + content.setStyleSheet("background:#F8F9FC;") + self.main_layout = QVBoxLayout(content) + self.main_layout.setContentsMargins(28, 24, 28, 28) + self.main_layout.setSpacing(20) + scroll.setWidget(content) + outer.addWidget(scroll) + + # ── Input card ──────────────────────────────── + input_card = ContentCard("🎯 Career Match Engine", "Enter your academic profile to get personalized career recommendations") + il = input_card.body_layout + il.setSpacing(14) + + row = QHBoxLayout() + row.setSpacing(12) + + def col_field(label, placeholder=''): + c = QVBoxLayout() + lb = QLabel(label) + lb.setStyleSheet("font-size:11px;color:#9CA3AF;font-weight:600;letter-spacing:0.3px;") + f = QLineEdit() + f.setPlaceholderText(placeholder) + f.setFixedHeight(38) + f.setStyleSheet("background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;" + "padding:0 12px;font-size:13px;color:#111827;") + c.addWidget(lb) + c.addWidget(f) + return c, f + + def col_spin(label, min_v=0.0, max_v=4.0, val=3.0): + c = QVBoxLayout() + lb = QLabel(label) + lb.setStyleSheet("font-size:11px;color:#9CA3AF;font-weight:600;letter-spacing:0.3px;") + sp = QDoubleSpinBox() + sp.setRange(min_v, max_v) + sp.setDecimals(2) + sp.setValue(val) + sp.setFixedHeight(38) + sp.setStyleSheet("background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;" + "padding:0 10px;font-size:13px;color:#111827;") + c.addWidget(lb) + c.addWidget(sp) + return c, sp + + c1, self.gpa_spin = col_spin("CURRENT GPA", 0, 4.0, 3.2) + c2, self.skills_f = col_field("YOUR SKILLS (comma-separated)", "e.g. Python, SQL, ML") + c3, self.int_f = col_field("YOUR INTEREST", "e.g. AI, Cloud, Web") + + for c in [c1, c2, c3]: + row.addLayout(c) + + btn = QPushButton(" Find Careers") + btn.setFixedHeight(38) + btn.setFixedWidth(160) + btn.setCursor(Qt.PointingHandCursor) + btn.setStyleSheet("background:#4F6EF7;color:white;border:none;border-radius:8px;" + "padding:0 20px;font-size:13px;font-weight:700;margin-top:17px;") + btn.clicked.connect(self._run_recommendation) + + row.addWidget(btn) + il.addLayout(row) + self.main_layout.addWidget(input_card) + + # ── Career cards grid ───────────────────────── + results_lbl = QLabel("Recommended Career Paths") + results_lbl.setStyleSheet("font-size:16px;font-weight:700;color:#111827;") + self.main_layout.addWidget(results_lbl) + + self.grid_widget = QWidget() + self.grid_widget.setStyleSheet("background:transparent;") + self.grid_layout = QGridLayout(self.grid_widget) + self.grid_layout.setSpacing(14) + self.main_layout.addWidget(self.grid_widget) + + # ── Skill detail section ────────────────────── + self.skill_detail = ContentCard("🧠 Skills Required for Top Career", "") + self.skill_body = self.skill_detail.body_layout + self.main_layout.addWidget(self.skill_detail) + + def _run_recommendation(self): + gpa = self.gpa_spin.value() + skills = self.skills_f.text() + inter = self.int_f.text() + + careers = self.recommender.recommend(gpa, skills, inter) + + # Clear grid + for i in reversed(range(self.grid_layout.count())): + w = self.grid_layout.itemAt(i).widget() + if w: w.deleteLater() + + col = 0 + for i, c in enumerate(careers): + card = CareerCard(c) + self.grid_layout.addWidget(card, i // 2, i % 2) + + # Skill detail for top career + self._show_skill_detail(careers[0], skills) + + def _show_skill_detail(self, top_career, user_skills_str): + self._clear(self.skill_body) + self.skill_detail.findChild(QLabel).setText( + f"🧠 Skills for: {top_career['title']} ({top_career['match']}% match)") + + skill_data = self.recommender.get_skill_recommendations(top_career['title'], user_skills_str) + + if skill_data['present']: + lbl = QLabel("✅ Skills You Already Have") + lbl.setStyleSheet("font-size:12px;font-weight:700;color:#10B981;") + self.skill_body.addWidget(lbl) + present_row = QHBoxLayout() + for s in skill_data['present']: + badge = QLabel(s) + badge.setStyleSheet("background:#D1FAE5;color:#065F46;border-radius:20px;" + "padding:3px 12px;font-size:12px;font-weight:600;") + present_row.addWidget(badge) + present_row.addStretch() + self.skill_body.addLayout(present_row) + + if skill_data['missing']: + lbl2 = QLabel("📚 Skills to Learn Next") + lbl2.setStyleSheet("font-size:12px;font-weight:700;color:#EF4444;margin-top:8px;") + self.skill_body.addWidget(lbl2) + missing_row = QHBoxLayout() + for s in skill_data['missing']: + badge = QLabel(s) + badge.setStyleSheet("background:#FEE2E2;color:#991B1B;border-radius:20px;" + "padding:3px 12px;font-size:12px;font-weight:600;") + missing_row.addWidget(badge) + missing_row.addStretch() + self.skill_body.addLayout(missing_row) + + # Progress bar per skill + all_s = skill_data['all'] + pres = set(s.lower() for s in skill_data['present']) + self.skill_body.addSpacing(6) + for s in all_s: + row = QHBoxLayout() + name = QLabel(s) + name.setFixedWidth(160) + name.setStyleSheet("font-size:12px;color:#374151;font-weight:500;") + bar = QProgressBar() + bar.setRange(0, 1) + bar.setValue(1 if s.lower() in pres else 0) + bar.setFixedHeight(6) + c = '#10B981' if s.lower() in pres else '#E5E7EB' + bar.setStyleSheet(f"QProgressBar{{background:#F3F4F6;border:none;border-radius:3px;}}" + f"QProgressBar::chunk{{background:{c};border-radius:3px;}}") + status = QLabel("✅" if s.lower() in pres else "⬜") + status.setFixedWidth(20) + row.addWidget(status) + row.addWidget(name) + row.addWidget(bar, 1) + self.skill_body.addLayout(row) + self.skill_body.addStretch() + + def _clear(self, layout): + while layout.count(): + item = layout.takeAt(0) + if item.widget(): item.widget().deleteLater() + elif item.layout(): self._clear(item.layout()) diff --git a/ai_academic_fixed/ui/chatbot_page.py b/ai_academic_fixed/ui/chatbot_page.py new file mode 100644 index 0000000..2255d09 --- /dev/null +++ b/ai_academic_fixed/ui/chatbot_page.py @@ -0,0 +1,220 @@ +# ui/chatbot_page.py — Premium AI Chatbot Interface + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QTextEdit, QLineEdit, QPushButton, QScrollArea, + QFrame, QSizePolicy) +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer +from PyQt5.QtGui import QFont, QTextCursor +import time +from chatbot.chatbot_engine import Chatbot + + +class TypingIndicator(QWidget): + def __init__(self): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + lbl = QLabel("AI is thinking...") + lbl.setStyleSheet("color:#9CA3AF;font-size:12px;font-style:italic;") + layout.addWidget(lbl) + self.setStyleSheet("background:#F3F4F6;border-radius:12px;") + self.setFixedHeight(36) + + +class MessageBubble(QWidget): + def __init__(self, text, is_user=True): + super().__init__() + outer = QHBoxLayout(self) + outer.setContentsMargins(0, 3, 0, 3) + outer.setSpacing(8) + + bubble = QLabel(text) + bubble.setWordWrap(True) + bubble.setTextInteractionFlags(Qt.TextSelectableByMouse) + bubble.setMaximumWidth(520) + + if is_user: + bubble.setStyleSheet(""" + background:#4F6EF7; color:white; border-radius:16px 16px 4px 16px; + padding:10px 16px; font-size:13px; line-height:1.5; + """) + avatar = QLabel("👤") + avatar.setFont(QFont("Segoe UI Emoji", 16)) + avatar.setFixedWidth(32) + outer.addStretch() + outer.addWidget(bubble) + outer.addWidget(avatar) + else: + bubble.setStyleSheet(""" + background:#FFFFFF; color:#111827; border:1px solid #E5E7EB; + border-radius:4px 16px 16px 16px; + padding:10px 16px; font-size:13px; line-height:1.5; + """) + avatar = QLabel("🤖") + avatar.setFont(QFont("Segoe UI Emoji", 16)) + avatar.setFixedWidth(32) + outer.addWidget(avatar) + outer.addWidget(bubble) + outer.addStretch() + + +class ChatbotPage(QWidget): + def __init__(self): + super().__init__() + self.bot = Chatbot() + self.setStyleSheet("background:#F8F9FC;") + self._build() + self._send_welcome() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(28, 24, 28, 24) + layout.setSpacing(0) + + # ── Chat area card ──────────────────────────── + chat_card = QWidget() + chat_card.setStyleSheet("background:#FFFFFF;border:1px solid #E5E7EB;border-radius:12px;") + chat_card_layout = QVBoxLayout(chat_card) + chat_card_layout.setContentsMargins(0, 0, 0, 0) + chat_card_layout.setSpacing(0) + + # Header + header = QWidget() + header.setFixedHeight(56) + header.setStyleSheet("background:#FFFFFF;border-bottom:1px solid #F3F4F6;border-radius:12px 12px 0 0;") + hl = QHBoxLayout(header) + hl.setContentsMargins(20, 0, 20, 0) + bot_icon = QLabel("🤖") + bot_icon.setFont(QFont("Segoe UI Emoji", 18)) + title_col = QVBoxLayout() + title_col.setSpacing(0) + bot_name = QLabel("AcadAI Assistant") + bot_name.setStyleSheet("font-size:15px;font-weight:600;color:#111827;") + bot_status = QLabel("● Online • Academic Intelligence") + bot_status.setStyleSheet("font-size:15px;color:#10B981;") + title_col.addWidget(bot_name) + title_col.addWidget(bot_status) + clear_btn = QPushButton("Clear Chat") + clear_btn.setCursor(Qt.PointingHandCursor) + clear_btn.setStyleSheet("background:#F3F4F6;color:#6B7280;border:none;border-radius:6px;" + "padding:6px 14px;min-height:32px;font-size:15px;font-weight:500;") + clear_btn.clicked.connect(self._clear_chat) + hl.addWidget(bot_icon) + hl.addLayout(title_col) + hl.addStretch() + hl.addWidget(clear_btn) + chat_card_layout.addWidget(header) + + # Scroll area for messages + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(0) + self.scroll.setStyleSheet("background:#F9FAFB;") + + self.msg_container = QWidget() + self.msg_container.setStyleSheet("background:#F9FAFB;") + self.msg_layout = QVBoxLayout(self.msg_container) + self.msg_layout.setContentsMargins(20, 16, 20, 16) + self.msg_layout.setSpacing(4) + self.msg_layout.addStretch() + + self.scroll.setWidget(self.msg_container) + chat_card_layout.addWidget(self.scroll, 1) + + # Input row + input_bar = QWidget() + input_bar.setFixedHeight(64) + input_bar.setStyleSheet("background:#FFFFFF;border-top:1px solid #F3F4F6;" + "border-radius:0 0 12px 12px;") + il = QHBoxLayout(input_bar) + il.setContentsMargins(16, 12, 16, 12) + il.setSpacing(10) + + self.input = QLineEdit() + self.input.setPlaceholderText("Ask me about GPA, careers, study tips, or skills...") + self.input.setFixedHeight(40) + self.input.setStyleSheet(""" + QLineEdit { background:#F9FAFB; border:1px solid #E5E7EB; border-radius:20px; + padding:0 18px; font-size:13px; color:#111827; } + QLineEdit:focus { border-color:#4F6EF7; background:#FFFFFF; } + """) + self.input.returnPressed.connect(self._send) + + send_btn = QPushButton("Send ➤") + send_btn.setFixedHeight(40) + send_btn.setFixedWidth(100) + send_btn.setCursor(Qt.PointingHandCursor) + send_btn.setStyleSheet("background:#4F6EF7;color:white;border:none;border-radius:20px;" + "padding:0 16px;font-size:13px;font-weight:700;") + send_btn.clicked.connect(self._send) + il.addWidget(self.input, 1) + il.addWidget(send_btn) + chat_card_layout.addWidget(input_bar) + layout.addWidget(chat_card, 1) + + # ── Quick prompts ───────────────────────────── + quick_lbl = QLabel("Quick questions:") + quick_lbl.setStyleSheet("font-size:15px;color:#9CA3AF;margin-top:12px;font-weight:500;") + layout.addWidget(quick_lbl) + + prompts_row = QHBoxLayout() + prompts_row.setSpacing(8) + QUICK = [ + "How can I improve my GPA?", + "Which career suits me best?", + "What skills should I learn?", + "How many hours should I study?", + "What if my attendance is low?", + ] + for p in QUICK: + btn = QPushButton(p) + btn.setCursor(Qt.PointingHandCursor) + btn.setStyleSheet(""" + QPushButton { background:#FFFFFF; color:#4F6EF7; border:1px solid #C7D2FE; + border-radius:20px; padding:6px 14px; min-height:32px; font-size:15px; font-weight:500; } + QPushButton:hover { background:#EEF1FF; } + """) + btn.clicked.connect(lambda _, t=p: self._quick_send(t)) + prompts_row.addWidget(btn) + prompts_row.addStretch() + layout.addLayout(prompts_row) + + def _send_welcome(self): + welcome = ("👋 Hello! I'm your AcadAI Academic Assistant.\n\n" + "I can help you with:\n" + "• 📈 GPA improvement strategies\n" + "• 🎯 Career path recommendations\n" + "• 📚 Study tips and planning\n" + "• 🧠 Skill recommendations\n\n" + "What would you like to know today?") + self._add_message(welcome, is_user=False) + + def _add_message(self, text, is_user=True): + bubble = MessageBubble(text, is_user) + self.msg_layout.insertWidget(self.msg_layout.count() - 1, bubble) + QTimer.singleShot(50, self._scroll_to_bottom) + + def _scroll_to_bottom(self): + sb = self.scroll.verticalScrollBar() + sb.setValue(sb.maximum()) + + def _send(self): + text = self.input.text().strip() + if not text: return + self.input.clear() + self._add_message(text, is_user=True) + QTimer.singleShot(400, lambda: self._bot_reply(text)) + + def _quick_send(self, text): + self.input.setText(text) + self._send() + + def _bot_reply(self, text): + reply = self.bot.respond(text) + self._add_message(reply, is_user=False) + + def _clear_chat(self): + while self.msg_layout.count() > 1: + item = self.msg_layout.takeAt(0) + if item.widget(): item.widget().deleteLater() + self._send_welcome() diff --git a/ai_academic_fixed/ui/components/__init__.py b/ai_academic_fixed/ui/components/__init__.py new file mode 100644 index 0000000..c51def1 --- /dev/null +++ b/ai_academic_fixed/ui/components/__init__.py @@ -0,0 +1 @@ +# pkg diff --git a/ai_academic_fixed/ui/components/cards.py b/ai_academic_fixed/ui/components/cards.py new file mode 100644 index 0000000..8358f68 --- /dev/null +++ b/ai_academic_fixed/ui/components/cards.py @@ -0,0 +1,251 @@ +# ui/components/cards.py — Reusable card widgets + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QFrame, QProgressBar, QPushButton, QSizePolicy) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont, QColor + + +def h_line(): + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setStyleSheet("color:#F3F4F6;background:#F3F4F6;max-height:1px;border:none;") + return line + + +class StatCard(QWidget): + """Top-level KPI card: icon + value + label + optional badge.""" + def __init__(self, icon, label, value, badge_text='', badge_color='blue', + accent='#4F6EF7', parent=None): + super().__init__(parent) + self.setObjectName("stat_card") + self.setStyleSheet(f""" + QWidget#stat_card {{ + background:#FFFFFF; border:1px solid #E5E7EB; + border-radius:12px; + }} + QWidget#stat_card:hover {{ border-color:#C7D2FE; }} + """) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setFixedHeight(110) + + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 16, 18, 16) + layout.setSpacing(4) + + # Top row: icon + badge + top = QHBoxLayout() + icon_lbl = QLabel(icon) + icon_lbl.setFont(QFont("Segoe UI Emoji", 20)) + top.addWidget(icon_lbl) + top.addStretch() + if badge_text: + badge = QLabel(badge_text) + colors = { + 'green': ('background:#D1FAE5;color:#065F46;',), + 'red': ('background:#FEE2E2;color:#991B1B;',), + 'yellow': ('background:#FEF3C7;color:#92400E;',), + 'blue': ('background:#EEF1FF;color:#4F6EF7;',), + 'purple': ('background:#EDE9FE;color:#5B21B6;',), + } + style = colors.get(badge_color, colors['blue'])[0] + badge.setStyleSheet(f"{style} border-radius:20px; padding:2px 9px;" + f" font-size:11px; font-weight:700;") + top.addWidget(badge) + layout.addLayout(top) + + # Value + val_lbl = QLabel(str(value)) + val_lbl.setStyleSheet(f"font-size:22px;font-weight:800;color:{accent};" + "letter-spacing:-0.5px;") + layout.addWidget(val_lbl) + + # Label + lbl = QLabel(label) + lbl.setStyleSheet("font-size:12px;color:#6B7280;font-weight:500;") + layout.addWidget(lbl) + + self._val_lbl = val_lbl + + def update_value(self, v): + self._val_lbl.setText(str(v)) + + +class ContentCard(QWidget): + """Generic white card with a header.""" + def __init__(self, title='', subtitle='', parent=None): + super().__init__(parent) + self.setObjectName("content_card") + self.setStyleSheet(""" + QWidget#content_card { + background:#FFFFFF; border:1px solid #E5E7EB; border-radius:12px; + } + """) + self._outer = QVBoxLayout(self) + self._outer.setContentsMargins(0, 0, 0, 0) + self._outer.setSpacing(0) + + if title: + hdr = QWidget() + hdr.setStyleSheet("background:transparent;") + hl = QHBoxLayout(hdr) + hl.setContentsMargins(20, 16, 20, 12) + t = QLabel(title) + t.setStyleSheet("font-size:14px;font-weight:700;color:#111827;") + hl.addWidget(t) + hl.addStretch() + if subtitle: + st = QLabel(subtitle) + st.setStyleSheet("font-size:11px;color:#9CA3AF;") + hl.addWidget(st) + self._outer.addWidget(hdr) + self._outer.addWidget(h_line()) + + self.body = QWidget() + self.body.setStyleSheet("background:transparent;") + self.body_layout = QVBoxLayout(self.body) + self.body_layout.setContentsMargins(20, 16, 20, 16) + self.body_layout.setSpacing(8) + self._outer.addWidget(self.body) + + +class ProgressRow(QWidget): + """Label + progress bar + value in a single row.""" + def __init__(self, label, value, max_val=100, color='#4F6EF7', parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + + lbl = QLabel(label) + lbl.setFixedWidth(100) + lbl.setStyleSheet("font-size:12px;color:#374151;font-weight:500;") + layout.addWidget(lbl) + + bar = QProgressBar() + bar.setRange(0, int(max_val)) + bar.setValue(int(value)) + bar.setFixedHeight(7) + bar.setStyleSheet(f""" + QProgressBar {{ background:#F3F4F6; border:none; border-radius:4px; }} + QProgressBar::chunk {{ background:{color}; border-radius:4px; }} + """) + layout.addWidget(bar, 1) + + val_lbl = QLabel(f"{value:.0f}") + val_lbl.setFixedWidth(34) + val_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + val_lbl.setStyleSheet("font-size:12px;color:#6B7280;font-weight:600;") + layout.addWidget(val_lbl) + + +class RiskBadge(QLabel): + _styles = { + 'Low': 'background:#D1FAE5;color:#065F46;', + 'Medium': 'background:#FEF3C7;color:#92400E;', + 'High': 'background:#FEE2E2;color:#991B1B;', + } + def __init__(self, level, parent=None): + super().__init__(level, parent) + s = self._styles.get(level, self._styles['Low']) + self.setStyleSheet(f"{s} border-radius:20px; padding:2px 10px;" + f" font-size:11px; font-weight:700;") + self.setAlignment(Qt.AlignCenter) + + +class CareerCard(QWidget): + """Premium career recommendation card.""" + def __init__(self, career_data, parent=None): + super().__init__(parent) + c = career_data + color = c.get('color', '#4F6EF7') + match = c.get('match', 0) + self.setStyleSheet(f""" + QWidget {{ + background:#FFFFFF; border:1px solid #E5E7EB; + border-radius:14px; + }} + QWidget:hover {{ border-color:{color}; }} + """) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setMinimumHeight(180) + + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 18, 20, 18) + layout.setSpacing(10) + + # Header row + top = QHBoxLayout() + icon_lbl = QLabel(c.get('icon', '💼')) + icon_lbl.setFont(QFont("Segoe UI Emoji", 22)) + top.addWidget(icon_lbl) + + title_col = QVBoxLayout() + title_col.setSpacing(2) + t = QLabel(c['title']) + t.setStyleSheet(f"font-size:15px;font-weight:700;color:#111827;") + sal = QLabel(c.get('salary', '')) + sal.setStyleSheet("font-size:11px;color:#9CA3AF;") + title_col.addWidget(t) + title_col.addWidget(sal) + top.addLayout(title_col) + top.addStretch() + + # Match badge + match_lbl = QLabel(f"{match}%") + mc = '#10B981' if match >= 70 else ('#F59E0B' if match >= 40 else '#EF4444') + match_lbl.setStyleSheet(f""" + background:{mc}1A; color:{mc}; font-size:18px; font-weight:800; + border-radius:8px; padding:4px 10px; + """) + top.addWidget(match_lbl) + layout.addLayout(top) + + # Description + desc = QLabel(c.get('desc', '')) + desc.setStyleSheet("font-size:12px;color:#6B7280;") + desc.setWordWrap(True) + layout.addWidget(desc) + + # Match bar + bar_row = QHBoxLayout() + bar_lbl = QLabel("Match") + bar_lbl.setStyleSheet("font-size:11px;color:#9CA3AF;font-weight:600;") + bar = QProgressBar() + bar.setRange(0, 100) + bar.setValue(match) + bar.setFixedHeight(6) + bar.setStyleSheet(f""" + QProgressBar {{ background:#F3F4F6; border:none; border-radius:3px; }} + QProgressBar::chunk {{ background:{color}; border-radius:3px; }} + """) + bar_row.addWidget(bar_lbl) + bar_row.addWidget(bar, 1) + layout.addLayout(bar_row) + + # Demand badge + demand_lbl = QLabel(f"Demand: {c.get('demand','')}") + d_color = '#10B981' if 'Very' in c.get('demand','') else '#F59E0B' + demand_lbl.setStyleSheet(f"font-size:11px;color:{d_color};font-weight:600;") + layout.addWidget(demand_lbl) + + +class InsightRow(QWidget): + """Single insight/suggestion row.""" + def __init__(self, icon, text, color='#4F6EF7', parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + ico = QLabel(icon) + ico.setFont(QFont("Segoe UI Emoji", 16)) + ico.setFixedWidth(28) + layout.addWidget(ico) + msg = QLabel(text) + msg.setStyleSheet(f"font-size:12px;color:#374151;") + msg.setWordWrap(True) + layout.addWidget(msg, 1) + self.setStyleSheet(f""" + QWidget {{ background:{color}12; border-radius:8px; + border-left:3px solid {color}; padding:6px 10px; }} + """) diff --git a/ai_academic_fixed/ui/components/charts.py b/ai_academic_fixed/ui/components/charts.py new file mode 100644 index 0000000..7a0881c --- /dev/null +++ b/ai_academic_fixed/ui/components/charts.py @@ -0,0 +1,218 @@ +# ui/components/charts.py — Fixed Matplotlib charts embedded in PyQt5 +# FIXES APPLIED: +# 1. GPABarChart — ylim headroom so top labels not clipped; rotation_mode fix +# 2. SubjectRadar — tick pad=8 so axis labels don't overlap polygon; title pad=20 +# 3. GPADistChart — ylim headroom; x-label rotation to prevent overlap +# 4. RiskPieChart — pctdistance moved inward; legend replaces outer labels +# 5. SubjectBarChart — xlim=115 so end-of-bar score labels not clipped +# 6. BaseChart — tight_layout replaces manual subplots_adjust (no more cropping) +# 7. NEW SemesterTrendChart added + +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import matplotlib.patches as mpatches +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +import numpy as np + +ACCENT = '#4F6EF7' +PURPLE = '#7C3AED' +GREEN = '#10B981' +ORANGE = '#F59E0B' +RED = '#EF4444' +GRAY = '#E5E7EB' +TEXT = '#111827' +SUBTEXT = '#9CA3AF' +PALETTE = [ACCENT, PURPLE, GREEN, ORANGE, RED, '#0EA5E9', '#EC4899'] + + +def _style_ax(ax, title='', xlabel='', ylabel=''): + ax.set_facecolor('#FAFAFA') + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.spines['left'].set_color(GRAY) + ax.spines['bottom'].set_color(GRAY) + ax.tick_params(colors=SUBTEXT, labelsize=9) + ax.xaxis.label.set_color(SUBTEXT) + ax.yaxis.label.set_color(SUBTEXT) + if title: + ax.set_title(title, fontsize=12, fontweight='bold', color=TEXT, pad=10) + if xlabel: + ax.set_xlabel(xlabel, fontsize=9) + if ylabel: + ax.set_ylabel(ylabel, fontsize=9) + + +class BaseChart(FigureCanvas): + def __init__(self, figsize=(5, 3), dpi=100): + self.fig = Figure(figsize=figsize, dpi=dpi, facecolor='#FFFFFF') + super().__init__(self.fig) + # FIX: tight_layout auto-handles all margins — replaces broken manual subplots_adjust + self.fig.set_tight_layout({'pad': 1.4, 'w_pad': 0.8, 'h_pad': 0.8}) + + +class GPABarChart(BaseChart): + def __init__(self, names, gpas, figsize=(7, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + colors = [GREEN if g >= 3.5 else (ACCENT if g >= 3.0 else (ORANGE if g >= 2.5 else RED)) + for g in gpas] + bars = ax.bar(range(len(names)), gpas, color=colors, width=0.6, zorder=2) + ax.set_xticks(range(len(names))) + # FIX: rotation_mode='anchor' stops labels drifting under the wrong bar + ax.set_xticklabels(names, rotation=40, ha='right', fontsize=8, + rotation_mode='anchor') + # FIX: extra ylim so bar-top labels (e.g. "3.74") are not clipped + ax.set_ylim(0, 4.6) + ax.axhline(y=3.0, color=SUBTEXT, linestyle='--', linewidth=0.8, alpha=0.6) + ax.set_yticks([0, 1, 2, 3, 4]) + for bar, gpa in zip(bars, gpas): + ax.text(bar.get_x() + bar.get_width() / 2, + bar.get_height() + 0.07, + f'{gpa:.2f}', + ha='center', va='bottom', fontsize=7.5, + color=TEXT, fontweight='600') + ax.grid(axis='y', color=GRAY, linewidth=0.6, zorder=1) + _style_ax(ax, 'GPA by Student', ylabel='GPA') + self.draw() + + +class SubjectRadarChart(BaseChart): + def __init__(self, labels, values, figsize=(4, 4)): + super().__init__(figsize) + N = len(labels) + angles = np.linspace(0, 2 * np.pi, N, endpoint=False).tolist() + values_plot = list(values) + [values[0]] + angles_plot = angles + angles[:1] + ax = self.fig.add_subplot(111, polar=True) + ax.set_facecolor('#FAFAFA') + ax.plot(angles_plot, values_plot, color=ACCENT, linewidth=2, linestyle='solid') + ax.fill(angles_plot, values_plot, color=ACCENT, alpha=0.15) + ax.set_xticks(angles) + ax.set_xticklabels(labels, fontsize=9, color=TEXT) + # FIX: pad=8 pushes axis labels outward so they don't overlap the chart polygon + ax.tick_params(axis='x', pad=8) + ax.set_ylim(0, 100) + ax.set_yticks([25, 50, 75, 100]) + ax.set_yticklabels(['25', '50', '75', '100'], fontsize=7, color=SUBTEXT) + ax.grid(color=GRAY, linewidth=0.7) + # FIX: pad=20 so title doesn't sit on top of the uppermost label + ax.set_title('Subject Performance', fontsize=11, fontweight='bold', + color=TEXT, pad=20) + self.draw() + + +class GPADistChart(BaseChart): + def __init__(self, bins_dict, figsize=(4, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + labels = list(bins_dict.keys()) + vals = list(bins_dict.values()) + colors_list = [RED, ORANGE, ACCENT, GREEN, '#059669'][:len(labels)] + bars = ax.bar(range(len(labels)), vals, color=colors_list, width=0.55, zorder=2) + ax.set_xticks(range(len(labels))) + # FIX: rotate range labels so "2.0-2.5", "3.5-4.0" don't overlap each other + ax.set_xticklabels(labels, fontsize=8, rotation=20, ha='right', + rotation_mode='anchor') + max_v = max(vals) if vals else 1 + # FIX: +2.5 headroom so count labels above bars clear the top spine + ax.set_ylim(0, max_v + 2.5) + ax.set_yticks(range(0, int(max_v) + 3, max(1, (int(max_v) + 2) // 5))) + for bar, v in zip(bars, vals): + if v > 0: + ax.text(bar.get_x() + bar.get_width() / 2, + bar.get_height() + 0.15, + str(v), ha='center', va='bottom', + fontsize=9, color=TEXT, fontweight='600') + ax.grid(axis='y', color=GRAY, linewidth=0.6, zorder=1) + _style_ax(ax, 'GPA Distribution', ylabel='Students') + self.draw() + + +class RiskPieChart(BaseChart): + def __init__(self, risk_dict, figsize=(4, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + labels = [k for k, v in risk_dict.items() if v > 0] + vals = [v for v in risk_dict.values() if v > 0] + if not vals: + ax.text(0.5, 0.5, 'No data available', ha='center', va='center', + transform=ax.transAxes, color=SUBTEXT, fontsize=11) + self.draw() + return + colors_map = {'Low': GREEN, 'Medium': ORANGE, 'High': RED} + colors_list = [colors_map.get(l, ACCENT) for l in labels] + # FIX: pctdistance=0.62 pulls % labels inside wedges; labels=None prevents + # outer text labels being clipped at figure boundary + wedges, texts, autotexts = ax.pie( + vals, labels=None, + colors=colors_list, + autopct='%1.0f%%', + startangle=140, + pctdistance=0.62, + wedgeprops=dict(width=0.55, edgecolor='white', linewidth=2)) + for at in autotexts: + at.set_fontsize(9) + at.set_color('white') + at.set_fontweight('bold') + # FIX: legend below chart replaces outer labels that were getting cut off + legend_patches = [ + mpatches.Patch(color=colors_map.get(l, ACCENT), label=f'{l} ({v})') + for l, v in zip(labels, vals) + ] + ax.legend(handles=legend_patches, loc='lower center', + bbox_to_anchor=(0.5, -0.10), ncol=len(labels), + fontsize=8, frameon=False) + ax.set_title('Risk Level Breakdown', fontsize=11, fontweight='bold', + color=TEXT, pad=8) + self.draw() + + +class SubjectBarChart(BaseChart): + def __init__(self, labels, values, figsize=(5, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + colors_list = [ACCENT, PURPLE, GREEN, ORANGE][:len(labels)] + bars = ax.barh(range(len(labels)), values, + color=colors_list, height=0.5, zorder=2) + ax.set_yticks(range(len(labels))) + ax.set_yticklabels(labels, fontsize=9) + # FIX: xlim=115 gives room for "99.9" labels that used to be clipped at 105 + ax.set_xlim(0, 115) + for bar, v in zip(bars, values): + ax.text(v + 1.5, + bar.get_y() + bar.get_height() / 2, + f'{v:.1f}', + va='center', fontsize=9, color=TEXT, fontweight='600') + ax.axvline(x=75, color=SUBTEXT, linestyle='--', linewidth=0.8, alpha=0.5) + ax.grid(axis='x', color=GRAY, linewidth=0.6, zorder=1) + _style_ax(ax, 'Average Scores by Subject', xlabel='Score / 100') + self.draw() + + +class SemesterTrendChart(BaseChart): + """NEW — Line chart: actual GPA per semester + dashed prediction line.""" + def __init__(self, semesters, actual_gpas, predicted_gpa=None, figsize=(6, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + ax.plot(semesters, actual_gpas, color=ACCENT, linewidth=2.5, + marker='o', markersize=6, label='Actual GPA', zorder=3) + if predicted_gpa is not None: + pred_x = [semesters[-1], semesters[-1] + 1] + pred_y = [actual_gpas[-1], predicted_gpa] + ax.plot(pred_x, pred_y, color=GREEN, linewidth=2, + linestyle='--', marker='D', markersize=6, + label=f'Predicted: {predicted_gpa:.2f}', zorder=3) + ax.annotate(f' {predicted_gpa:.2f}', + xy=(pred_x[-1], pred_y[-1]), + fontsize=9, color=GREEN, fontweight='bold') + ax.set_ylim(1.5, 4.3) + all_x = semesters + ([semesters[-1] + 1] if predicted_gpa else []) + ax.set_xticks(all_x) + ax.set_xticklabels([f'Sem {x}' for x in all_x], fontsize=8) + ax.axhline(y=3.0, color=SUBTEXT, linestyle=':', linewidth=0.9, alpha=0.7) + ax.legend(fontsize=9, frameon=False) + ax.grid(axis='y', color=GRAY, linewidth=0.6, zorder=1) + _style_ax(ax, 'GPA Semester Trend', xlabel='Semester', ylabel='GPA') + self.draw() diff --git a/ai_academic_fixed/ui/components/charts_FIXED.py b/ai_academic_fixed/ui/components/charts_FIXED.py new file mode 100644 index 0000000..7a0881c --- /dev/null +++ b/ai_academic_fixed/ui/components/charts_FIXED.py @@ -0,0 +1,218 @@ +# ui/components/charts.py — Fixed Matplotlib charts embedded in PyQt5 +# FIXES APPLIED: +# 1. GPABarChart — ylim headroom so top labels not clipped; rotation_mode fix +# 2. SubjectRadar — tick pad=8 so axis labels don't overlap polygon; title pad=20 +# 3. GPADistChart — ylim headroom; x-label rotation to prevent overlap +# 4. RiskPieChart — pctdistance moved inward; legend replaces outer labels +# 5. SubjectBarChart — xlim=115 so end-of-bar score labels not clipped +# 6. BaseChart — tight_layout replaces manual subplots_adjust (no more cropping) +# 7. NEW SemesterTrendChart added + +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import matplotlib.patches as mpatches +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +import numpy as np + +ACCENT = '#4F6EF7' +PURPLE = '#7C3AED' +GREEN = '#10B981' +ORANGE = '#F59E0B' +RED = '#EF4444' +GRAY = '#E5E7EB' +TEXT = '#111827' +SUBTEXT = '#9CA3AF' +PALETTE = [ACCENT, PURPLE, GREEN, ORANGE, RED, '#0EA5E9', '#EC4899'] + + +def _style_ax(ax, title='', xlabel='', ylabel=''): + ax.set_facecolor('#FAFAFA') + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.spines['left'].set_color(GRAY) + ax.spines['bottom'].set_color(GRAY) + ax.tick_params(colors=SUBTEXT, labelsize=9) + ax.xaxis.label.set_color(SUBTEXT) + ax.yaxis.label.set_color(SUBTEXT) + if title: + ax.set_title(title, fontsize=12, fontweight='bold', color=TEXT, pad=10) + if xlabel: + ax.set_xlabel(xlabel, fontsize=9) + if ylabel: + ax.set_ylabel(ylabel, fontsize=9) + + +class BaseChart(FigureCanvas): + def __init__(self, figsize=(5, 3), dpi=100): + self.fig = Figure(figsize=figsize, dpi=dpi, facecolor='#FFFFFF') + super().__init__(self.fig) + # FIX: tight_layout auto-handles all margins — replaces broken manual subplots_adjust + self.fig.set_tight_layout({'pad': 1.4, 'w_pad': 0.8, 'h_pad': 0.8}) + + +class GPABarChart(BaseChart): + def __init__(self, names, gpas, figsize=(7, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + colors = [GREEN if g >= 3.5 else (ACCENT if g >= 3.0 else (ORANGE if g >= 2.5 else RED)) + for g in gpas] + bars = ax.bar(range(len(names)), gpas, color=colors, width=0.6, zorder=2) + ax.set_xticks(range(len(names))) + # FIX: rotation_mode='anchor' stops labels drifting under the wrong bar + ax.set_xticklabels(names, rotation=40, ha='right', fontsize=8, + rotation_mode='anchor') + # FIX: extra ylim so bar-top labels (e.g. "3.74") are not clipped + ax.set_ylim(0, 4.6) + ax.axhline(y=3.0, color=SUBTEXT, linestyle='--', linewidth=0.8, alpha=0.6) + ax.set_yticks([0, 1, 2, 3, 4]) + for bar, gpa in zip(bars, gpas): + ax.text(bar.get_x() + bar.get_width() / 2, + bar.get_height() + 0.07, + f'{gpa:.2f}', + ha='center', va='bottom', fontsize=7.5, + color=TEXT, fontweight='600') + ax.grid(axis='y', color=GRAY, linewidth=0.6, zorder=1) + _style_ax(ax, 'GPA by Student', ylabel='GPA') + self.draw() + + +class SubjectRadarChart(BaseChart): + def __init__(self, labels, values, figsize=(4, 4)): + super().__init__(figsize) + N = len(labels) + angles = np.linspace(0, 2 * np.pi, N, endpoint=False).tolist() + values_plot = list(values) + [values[0]] + angles_plot = angles + angles[:1] + ax = self.fig.add_subplot(111, polar=True) + ax.set_facecolor('#FAFAFA') + ax.plot(angles_plot, values_plot, color=ACCENT, linewidth=2, linestyle='solid') + ax.fill(angles_plot, values_plot, color=ACCENT, alpha=0.15) + ax.set_xticks(angles) + ax.set_xticklabels(labels, fontsize=9, color=TEXT) + # FIX: pad=8 pushes axis labels outward so they don't overlap the chart polygon + ax.tick_params(axis='x', pad=8) + ax.set_ylim(0, 100) + ax.set_yticks([25, 50, 75, 100]) + ax.set_yticklabels(['25', '50', '75', '100'], fontsize=7, color=SUBTEXT) + ax.grid(color=GRAY, linewidth=0.7) + # FIX: pad=20 so title doesn't sit on top of the uppermost label + ax.set_title('Subject Performance', fontsize=11, fontweight='bold', + color=TEXT, pad=20) + self.draw() + + +class GPADistChart(BaseChart): + def __init__(self, bins_dict, figsize=(4, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + labels = list(bins_dict.keys()) + vals = list(bins_dict.values()) + colors_list = [RED, ORANGE, ACCENT, GREEN, '#059669'][:len(labels)] + bars = ax.bar(range(len(labels)), vals, color=colors_list, width=0.55, zorder=2) + ax.set_xticks(range(len(labels))) + # FIX: rotate range labels so "2.0-2.5", "3.5-4.0" don't overlap each other + ax.set_xticklabels(labels, fontsize=8, rotation=20, ha='right', + rotation_mode='anchor') + max_v = max(vals) if vals else 1 + # FIX: +2.5 headroom so count labels above bars clear the top spine + ax.set_ylim(0, max_v + 2.5) + ax.set_yticks(range(0, int(max_v) + 3, max(1, (int(max_v) + 2) // 5))) + for bar, v in zip(bars, vals): + if v > 0: + ax.text(bar.get_x() + bar.get_width() / 2, + bar.get_height() + 0.15, + str(v), ha='center', va='bottom', + fontsize=9, color=TEXT, fontweight='600') + ax.grid(axis='y', color=GRAY, linewidth=0.6, zorder=1) + _style_ax(ax, 'GPA Distribution', ylabel='Students') + self.draw() + + +class RiskPieChart(BaseChart): + def __init__(self, risk_dict, figsize=(4, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + labels = [k for k, v in risk_dict.items() if v > 0] + vals = [v for v in risk_dict.values() if v > 0] + if not vals: + ax.text(0.5, 0.5, 'No data available', ha='center', va='center', + transform=ax.transAxes, color=SUBTEXT, fontsize=11) + self.draw() + return + colors_map = {'Low': GREEN, 'Medium': ORANGE, 'High': RED} + colors_list = [colors_map.get(l, ACCENT) for l in labels] + # FIX: pctdistance=0.62 pulls % labels inside wedges; labels=None prevents + # outer text labels being clipped at figure boundary + wedges, texts, autotexts = ax.pie( + vals, labels=None, + colors=colors_list, + autopct='%1.0f%%', + startangle=140, + pctdistance=0.62, + wedgeprops=dict(width=0.55, edgecolor='white', linewidth=2)) + for at in autotexts: + at.set_fontsize(9) + at.set_color('white') + at.set_fontweight('bold') + # FIX: legend below chart replaces outer labels that were getting cut off + legend_patches = [ + mpatches.Patch(color=colors_map.get(l, ACCENT), label=f'{l} ({v})') + for l, v in zip(labels, vals) + ] + ax.legend(handles=legend_patches, loc='lower center', + bbox_to_anchor=(0.5, -0.10), ncol=len(labels), + fontsize=8, frameon=False) + ax.set_title('Risk Level Breakdown', fontsize=11, fontweight='bold', + color=TEXT, pad=8) + self.draw() + + +class SubjectBarChart(BaseChart): + def __init__(self, labels, values, figsize=(5, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + colors_list = [ACCENT, PURPLE, GREEN, ORANGE][:len(labels)] + bars = ax.barh(range(len(labels)), values, + color=colors_list, height=0.5, zorder=2) + ax.set_yticks(range(len(labels))) + ax.set_yticklabels(labels, fontsize=9) + # FIX: xlim=115 gives room for "99.9" labels that used to be clipped at 105 + ax.set_xlim(0, 115) + for bar, v in zip(bars, values): + ax.text(v + 1.5, + bar.get_y() + bar.get_height() / 2, + f'{v:.1f}', + va='center', fontsize=9, color=TEXT, fontweight='600') + ax.axvline(x=75, color=SUBTEXT, linestyle='--', linewidth=0.8, alpha=0.5) + ax.grid(axis='x', color=GRAY, linewidth=0.6, zorder=1) + _style_ax(ax, 'Average Scores by Subject', xlabel='Score / 100') + self.draw() + + +class SemesterTrendChart(BaseChart): + """NEW — Line chart: actual GPA per semester + dashed prediction line.""" + def __init__(self, semesters, actual_gpas, predicted_gpa=None, figsize=(6, 3)): + super().__init__(figsize) + ax = self.fig.add_subplot(111) + ax.plot(semesters, actual_gpas, color=ACCENT, linewidth=2.5, + marker='o', markersize=6, label='Actual GPA', zorder=3) + if predicted_gpa is not None: + pred_x = [semesters[-1], semesters[-1] + 1] + pred_y = [actual_gpas[-1], predicted_gpa] + ax.plot(pred_x, pred_y, color=GREEN, linewidth=2, + linestyle='--', marker='D', markersize=6, + label=f'Predicted: {predicted_gpa:.2f}', zorder=3) + ax.annotate(f' {predicted_gpa:.2f}', + xy=(pred_x[-1], pred_y[-1]), + fontsize=9, color=GREEN, fontweight='bold') + ax.set_ylim(1.5, 4.3) + all_x = semesters + ([semesters[-1] + 1] if predicted_gpa else []) + ax.set_xticks(all_x) + ax.set_xticklabels([f'Sem {x}' for x in all_x], fontsize=8) + ax.axhline(y=3.0, color=SUBTEXT, linestyle=':', linewidth=0.9, alpha=0.7) + ax.legend(fontsize=9, frameon=False) + ax.grid(axis='y', color=GRAY, linewidth=0.6, zorder=1) + _style_ax(ax, 'GPA Semester Trend', xlabel='Semester', ylabel='GPA') + self.draw() diff --git a/ai_academic_fixed/ui/components/sidebar.py b/ai_academic_fixed/ui/components/sidebar.py new file mode 100644 index 0000000..09a19ec --- /dev/null +++ b/ai_academic_fixed/ui/components/sidebar.py @@ -0,0 +1,130 @@ +# ui/components/sidebar.py — Premium fixed sidebar navigation + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QLabel, QPushButton, + QHBoxLayout, QFrame, QSizePolicy) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QFont + +NAV_ITEMS = [ + ("🏠", "Dashboard", 0), + ("👥", "Students", 1), + ("📊", "Analytics", 2), + ("🎯", "Careers", 3), + ("🧠", "Skills", 4), + ("📅", "Planner", 5), + ("🤖", "AI Chatbot", 6), +] + +class Sidebar(QWidget): + page_changed = pyqtSignal(int) + + def __init__(self): + super().__init__() + self.setObjectName("sidebar") + self.setFixedWidth(220) + self._active = 0 + self._buttons = {} + self._build() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # ── Logo block ──────────────────────────────── + logo_widget = QWidget() + logo_widget.setStyleSheet("background:#FFFFFF;border-bottom:1px solid #F3F4F6;") + logo_layout = QVBoxLayout(logo_widget) + logo_layout.setContentsMargins(20, 22, 20, 18) + logo_layout.setSpacing(2) + + logo_row = QHBoxLayout() + icon_lbl = QLabel("🎓") + icon_lbl.setFont(QFont("Segoe UI Emoji", 22)) + logo_text = QLabel("AcadAI") + logo_text.setStyleSheet("font-size:18px;font-weight:700;color:#4F6EF7;letter-spacing:-0.5px;") + logo_row.addWidget(icon_lbl) + logo_row.addWidget(logo_text) + logo_row.addStretch() + logo_layout.addLayout(logo_row) + + sub = QLabel("Analytics Platform") + sub.setStyleSheet("font-size:10px;color:#9CA3AF;font-weight:500;letter-spacing:0.3px;") + logo_layout.addWidget(sub) + layout.addWidget(logo_widget) + + # ── Nav section ─────────────────────────────── + nav_widget = QWidget() + nav_widget.setStyleSheet("background:#FFFFFF;") + nav_layout = QVBoxLayout(nav_widget) + nav_layout.setContentsMargins(8, 12, 8, 8) + nav_layout.setSpacing(2) + + section_lbl = QLabel("MAIN MENU") + section_lbl.setObjectName("sidebar_section") + section_lbl.setStyleSheet("font-size:10px;font-weight:700;color:#9CA3AF;" + "letter-spacing:1.2px;padding:8px 12px 6px 12px;") + nav_layout.addWidget(section_lbl) + + for icon, label, idx in NAV_ITEMS: + btn = self._make_nav_btn(icon, label, idx) + self._buttons[idx] = btn + nav_layout.addWidget(btn) + + nav_layout.addStretch() + layout.addWidget(nav_widget, 1) + + # ── Footer ──────────────────────────────────── + footer = QWidget() + footer.setFixedHeight(70) + footer.setStyleSheet("background:#FFFFFF;border-top:1px solid #F3F4F6;") + fl = QVBoxLayout(footer) + fl.setContentsMargins(16, 12, 16, 12) + ver = QLabel("v2.0 • BS AI Exhibition") + ver.setStyleSheet("font-size:10px;color:#D1D5DB;") + fl.addWidget(ver) + layout.addWidget(footer) + + self._set_active(0) + + def _make_nav_btn(self, icon, label, idx): + btn = QPushButton(f" {icon} {label}") + btn.setObjectName("nav_btn") + btn.setFixedHeight(40) + btn.setCursor(Qt.PointingHandCursor) + btn.setStyleSheet(""" + QPushButton { + background:transparent; border:none; border-radius:8px; + padding:0 12px; text-align:left; font-size:14px; color:#6B7280; + } + QPushButton:hover { background:#F3F4F6; color:#111827; } + """) + btn.clicked.connect(lambda _, i=idx: self._on_nav(i)) + return btn + + def _on_nav(self, idx): + self._set_active(idx) + self.page_changed.emit(idx) + + def _set_active(self, idx): + self._active = idx + for i, btn in self._buttons.items(): + if i == idx: + btn.setStyleSheet(""" + QPushButton { + background:#EEF1FF; border:none; border-radius:8px; + padding:0 12px; text-align:left; font-size:14px; + font-weight:700; color:#4F6EF7; + } + """) + else: + btn.setStyleSheet(""" + QPushButton { + background:transparent; border:none; border-radius:8px; + padding:0 12px; text-align:left; font-size:13px; color:#6B7280; + } + QPushButton:hover { background:#F3F4F6; color:#111827; } + """) + + def set_active(self, idx): + self._set_active(idx) diff --git a/ai_academic_fixed/ui/dashboard_page.py b/ai_academic_fixed/ui/dashboard_page.py new file mode 100644 index 0000000..932666c --- /dev/null +++ b/ai_academic_fixed/ui/dashboard_page.py @@ -0,0 +1,160 @@ +# ui/dashboard_page.py — Premium Dashboard + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QScrollArea, QGridLayout, QSizePolicy) +from PyQt5.QtCore import Qt +from ui.components.cards import StatCard, ContentCard, InsightRow, ProgressRow +from ui.components.charts import GPABarChart, GPADistChart, RiskPieChart +from database.db_manager import DatabaseManager +from analytics.analytics_engine import AnalyticsEngine + + +class DashboardPage(QWidget): + def __init__(self, db: DatabaseManager): + super().__init__() + self.db = db + self.engine = AnalyticsEngine(db) + self._build() + self.refresh() + + def _build(self): + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(0) + scroll.setStyleSheet("background:#F8F9FC;") + + content = QWidget() + content.setStyleSheet("background:#F8F9FC;") + self.layout_main = QVBoxLayout(content) + self.layout_main.setContentsMargins(28, 24, 28, 28) + self.layout_main.setSpacing(20) + + scroll.setWidget(content) + outer.addWidget(scroll) + + # ── Stat cards row ──────────────────────────── + self.stats_row = QHBoxLayout() + self.stats_row.setSpacing(14) + self.layout_main.addLayout(self.stats_row) + + # ── Charts row ──────────────────────────────── + charts_row = QHBoxLayout() + charts_row.setSpacing(14) + self.layout_main.addLayout(charts_row) + + # GPA bar chart card + self.gpa_chart_card = ContentCard("Student GPA Overview", "All registered students") + self.gpa_chart_placeholder = QWidget() + self.gpa_chart_placeholder.setMinimumHeight(200) + self.gpa_chart_card.body_layout.addWidget(self.gpa_chart_placeholder) + charts_row.addWidget(self.gpa_chart_card, 3) + + # Distribution card + right_col = QVBoxLayout() + right_col.setSpacing(14) + self.dist_card = ContentCard("GPA Distribution", "Grouped by range") + self.dist_placeholder = QWidget() + self.dist_placeholder.setMinimumHeight(140) + self.dist_card.body_layout.addWidget(self.dist_placeholder) + right_col.addWidget(self.dist_card) + + self.risk_card = ContentCard("Risk Levels", "Student risk breakdown") + self.risk_placeholder = QWidget() + self.risk_placeholder.setMinimumHeight(140) + self.risk_card.body_layout.addWidget(self.risk_placeholder) + right_col.addWidget(self.risk_card) + charts_row.addLayout(right_col, 2) + + # ── Bottom row: insights + top students ─────── + bottom_row = QHBoxLayout() + bottom_row.setSpacing(14) + self.layout_main.addLayout(bottom_row) + + self.insights_card = ContentCard("AI Insights", "System-generated observations") + self.insights_body = self.insights_card.body_layout + bottom_row.addWidget(self.insights_card, 1) + + self.top_card = ContentCard("Top Performers", "Ranked by GPA") + self.top_body = self.top_card.body_layout + bottom_row.addWidget(self.top_card, 1) + + def refresh(self): + self._clear_stats() + stats = self.engine.get_dashboard_stats() + + avg_gpa = stats.get('avg_gpa') or 0 + avg_att = stats.get('avg_att') or 0 + total = stats.get('total') or 0 + at_risk = stats.get('at_risk') or 0 + top_p = stats.get('top_performers') or 0 + + cards = [ + StatCard("👥", "Total Students", total, badge_text="Active", badge_color='blue', accent='#4F6EF7'), + StatCard("🎯", "Average GPA", f"{avg_gpa:.2f}", badge_text="This Semester", badge_color='green', accent='#10B981'), + StatCard("📅", "Avg Attendance", f"{avg_att:.1f}%", badge_text="Attendance", badge_color='purple', accent='#7C3AED'), + StatCard("⚠️", "At-Risk Students", at_risk, badge_text="Need Help", badge_color='red', accent='#EF4444'), + StatCard("🌟", "Top Performers", top_p, badge_text="GPA ≥ 3.5", badge_color='yellow', accent='#F59E0B'), + ] + for c in cards: + self.stats_row.addWidget(c) + + # Charts + names, gpas = self.engine.get_gpa_chart_data() + self._replace_widget(self.gpa_chart_card.body_layout, 0, + GPABarChart(names, gpas, figsize=(7, 2.6))) + + bins = self.engine.get_gpa_bins() + self._replace_widget(self.dist_card.body_layout, 0, + GPADistChart(bins, figsize=(4, 2.2))) + + risk = self.engine.get_risk_breakdown() + self._replace_widget(self.risk_card.body_layout, 0, + RiskPieChart(risk, figsize=(4, 2.2))) + + # Insights + self._clear_layout(self.insights_body) + for ins in self.engine.get_performance_insights(): + row = InsightRow(ins['icon'], ins['text'], ins['color']) + self.insights_body.addWidget(row) + self.insights_body.addStretch() + + # Top performers + self._clear_layout(self.top_body) + for i, s in enumerate(self.db.get_top_students(6), 1): + row = QHBoxLayout() + rank = QLabel(f"#{i}") + rank.setFixedWidth(28) + rank.setStyleSheet("font-size:12px;font-weight:700;color:#9CA3AF;") + name = QLabel(s['name']) + name.setStyleSheet("font-size:13px;font-weight:600;color:#111827;") + gpa_lbl = QLabel(f"{s['gpa']:.2f}") + gpa_lbl.setStyleSheet("font-size:13px;font-weight:700;color:#10B981;") + row.addWidget(rank) + row.addWidget(name, 1) + row.addWidget(gpa_lbl) + self.top_body.addLayout(row) + self.top_body.addStretch() + + def _clear_stats(self): + while self.stats_row.count(): + item = self.stats_row.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + def _clear_layout(self, layout): + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + self._clear_layout(item.layout()) + + def _replace_widget(self, layout, idx, new_widget): + old = layout.itemAt(idx) + if old and old.widget(): + old.widget().deleteLater() + layout.insertWidget(idx, new_widget) diff --git a/ai_academic_fixed/ui/main_window.py b/ai_academic_fixed/ui/main_window.py new file mode 100644 index 0000000..135607e --- /dev/null +++ b/ai_academic_fixed/ui/main_window.py @@ -0,0 +1,141 @@ +# ui/main_window.py — Premium Main Window with Sidebar + Topbar + +from PyQt5.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, + QLabel, QStackedWidget, QLineEdit, QPushButton, + QSizePolicy, QFrame) +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QFont, QIcon + +from ui.components.sidebar import Sidebar +from ui.dashboard_page import DashboardPage +from ui.student_page import StudentPage +from ui.analytics_page import AnalyticsPage +from ui.career_page import CareerPage +from ui.skills_page import SkillsPage +from ui.planner_page import PlannerPage +from ui.chatbot_page import ChatbotPage +from database.db_manager import DatabaseManager +from assets.styles.theme import GLOBAL_STYLE + +PAGE_META = [ + ("Dashboard", "Overview of all academic metrics and AI insights"), + ("Students", "Manage student records — add, edit, search, delete"), + ("Analytics", "Deep performance analysis with AI GPA prediction"), + ("Careers", "Personalized career path recommendations"), + ("Skills", "Skill gap analysis and learning roadmaps"), + ("Planner", "AI-generated personalized weekly study plans"), + ("AI Chatbot", "Intelligent academic assistant — ask anything"), +] + + +class TopBar(QWidget): + def __init__(self): + super().__init__() + self.setObjectName("topbar") + self.setFixedHeight(58) + self.setStyleSheet("background:#FFFFFF;border-bottom:1px solid #E5E7EB;") + layout = QHBoxLayout(self) + layout.setContentsMargins(28, 0, 24, 0) + layout.setSpacing(14) + + self.title_lbl = QLabel("Dashboard") + self.title_lbl.setStyleSheet("font-size:18px;font-weight:600;color:#111827;") + self.sub_lbl = QLabel("Overview of all academic metrics") + self.sub_lbl.setStyleSheet("font-size:13px;color:#9CA3AF;line-height:1.5;") + + title_col = QVBoxLayout() + title_col.setSpacing(0) + title_col.addWidget(self.title_lbl) + title_col.addWidget(self.sub_lbl) + + layout.addLayout(title_col) + layout.addStretch() + + # Notification dot + self.notif = QLabel("🔔") + self.notif.setFont(QFont("Segoe UI Emoji", 15)) + self.notif.setCursor(Qt.PointingHandCursor) + layout.addWidget(self.notif) + + # User chip + user_chip = QWidget() + user_chip.setStyleSheet("background:#EEF1FF;border-radius:20px;") + ucl = QHBoxLayout(user_chip) + ucl.setContentsMargins(10, 5, 14, 5) + ucl.setSpacing(7) + avatar = QLabel("🎓") + avatar.setFont(QFont("Segoe UI Emoji", 14)) + name = QLabel("BS AI Student") + name.setStyleSheet("font-size:13px;font-weight:500;color:#4F6EF7;") + ucl.addWidget(avatar) + ucl.addWidget(name) + layout.addWidget(user_chip) + + def update(self, idx): + title, sub = PAGE_META[idx] + self.title_lbl.setText(title) + self.sub_lbl.setText(sub) + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("AcadAI Analytics Platform") + self.resize(1340, 840) + self.setMinimumSize(1100, 700) + self.setStyleSheet(GLOBAL_STYLE) + + # Database + self.db = DatabaseManager() + + # Central widget + central = QWidget() + central.setStyleSheet("background:#F8F9FC;") + self.setCentralWidget(central) + root = QHBoxLayout(central) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Sidebar + self.sidebar = Sidebar() + self.sidebar.page_changed.connect(self._switch_page) + root.addWidget(self.sidebar) + + # Right side: topbar + pages + right = QVBoxLayout() + right.setContentsMargins(0, 0, 0, 0) + right.setSpacing(0) + + self.topbar = TopBar() + right.addWidget(self.topbar) + + # Pages stack + self.stack = QStackedWidget() + self.stack.setStyleSheet("background:#F8F9FC;") + + self.dash_page = DashboardPage(self.db) + self.student_page = StudentPage(self.db) + self.analytics = AnalyticsPage(self.db) + self.career_page = CareerPage(self.db) + self.skills_page = SkillsPage() + self.planner_page = PlannerPage() + self.chatbot_page = ChatbotPage() + + for page in [self.dash_page, self.student_page, self.analytics, + self.career_page, self.skills_page, self.planner_page, + self.chatbot_page]: + self.stack.addWidget(page) + + right.addWidget(self.stack, 1) + right_widget = QWidget() + right_widget.setLayout(right) + root.addWidget(right_widget, 1) + + def _switch_page(self, idx): + self.stack.setCurrentIndex(idx) + self.topbar.update(idx) + # Refresh dashboard when returning to it + if idx == 0: + self.dash_page.refresh() + if idx == 1: + self.student_page.load_students() diff --git a/ai_academic_fixed/ui/planner_page.py b/ai_academic_fixed/ui/planner_page.py new file mode 100644 index 0000000..31acb50 --- /dev/null +++ b/ai_academic_fixed/ui/planner_page.py @@ -0,0 +1,171 @@ +# ui/planner_page.py — AI Study Planner Page + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QDoubleSpinBox, QPushButton, QScrollArea, + QFrame, QProgressBar) +from PyQt5.QtCore import Qt +from ui.components.cards import ContentCard +from recommender.study_planner import generate_plan + + +class PlannerPage(QWidget): + def __init__(self): + super().__init__() + self.setStyleSheet("background:#F8F9FC;") + self._build() + self._generate() + + def _build(self): + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(0) + scroll.setStyleSheet("background:#F8F9FC;") + + content = QWidget() + content.setStyleSheet("background:#F8F9FC;") + self.main_layout = QVBoxLayout(content) + self.main_layout.setContentsMargins(28, 24, 28, 28) + self.main_layout.setSpacing(20) + scroll.setWidget(content) + outer.addWidget(scroll) + + # ── Input card ──────────────────────────────── + inp_card = ContentCard("📅 AI Study Plan Generator", "Enter your current performance to get a personalized weekly plan") + il = inp_card.body_layout + row = QHBoxLayout() + row.setSpacing(12) + + def make_spin(label, min_v, max_v, val): + c = QVBoxLayout() + lb = QLabel(label) + lb.setStyleSheet("font-size:11px;color:#9CA3AF;font-weight:600;letter-spacing:0.3px;") + sp = QDoubleSpinBox() + sp.setRange(min_v, max_v) + sp.setDecimals(1) + sp.setValue(val) + sp.setFixedHeight(38) + sp.setStyleSheet("background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;" + "padding:0 10px;font-size:13px;color:#111827;") + c.addWidget(lb) + c.addWidget(sp) + return c, sp + + c1, self.sp_att = make_spin("ATTENDANCE %", 0, 100, 80) + c2, self.sp_quiz = make_spin("QUIZ SCORE", 0, 100, 72) + c3, self.sp_asgn = make_spin("ASSIGNMENT", 0, 100, 75) + c4, self.sp_mid = make_spin("MIDTERM", 0, 100, 68) + c5, self.sp_hrs = make_spin("STUDY HRS/DAY", 1, 12, 4) + + for c in [c1, c2, c3, c4, c5]: + row.addLayout(c) + + btn = QPushButton(" Generate Plan") + btn.setFixedHeight(38) + btn.setFixedWidth(160) + btn.setCursor(Qt.PointingHandCursor) + btn.setStyleSheet("background:#4F6EF7;color:white;border:none;border-radius:8px;" + "padding:0 20px;font-size:13px;font-weight:700;margin-top:17px;") + btn.clicked.connect(self._generate) + row.addWidget(btn) + il.addLayout(row) + self.main_layout.addWidget(inp_card) + + # ── Plan display ────────────────────────────── + plan_lbl = QLabel("📋 Your Weekly Study Plan") + plan_lbl.setStyleSheet("font-size:16px;font-weight:700;color:#111827;") + self.main_layout.addWidget(plan_lbl) + + self.plan_row = QHBoxLayout() + self.plan_row.setSpacing(10) + self.main_layout.addLayout(self.plan_row) + + self.tips_card = ContentCard("💡 Study Tips", "Evidence-based productivity tips") + tb = self.tips_card.body_layout + TIPS = [ + ("⏱️", "Pomodoro Technique", "Study 50 min, break 10 min. Repeat 3–4 cycles then take a long break.", "#4F6EF7"), + ("📖", "Active Recall", "Test yourself instead of re-reading. Use flashcards and past papers.", "#7C3AED"), + ("🌙", "Sleep Priority", "8 hrs sleep consolidates memory. Don't sacrifice sleep for extra hours.", "#10B981"), + ("📌", "Priority Matrix", "Focus on high-weight, low-score subjects first for maximum GPA impact.", "#F59E0B"), + ("👥", "Study Groups", "Teach concepts to peers. Explaining solidifies your own understanding.", "#0EA5E9"), + ] + for icon, title, desc, color in TIPS: + tip = QWidget() + tip.setStyleSheet(f"background:{color}0D;border-radius:8px;border-left:3px solid {color};") + tl = QHBoxLayout(tip) + tl.setContentsMargins(12, 10, 12, 10) + tl.setSpacing(10) + ico = QLabel(icon) + ico.setFixedWidth(24) + col = QVBoxLayout() + col.setSpacing(2) + t = QLabel(title) + t.setStyleSheet(f"font-size:12px;font-weight:700;color:{color};") + d = QLabel(desc) + d.setStyleSheet("font-size:11px;color:#6B7280;") + d.setWordWrap(True) + col.addWidget(t) + col.addWidget(d) + tl.addWidget(ico) + tl.addLayout(col, 1) + tb.addWidget(tip) + tb.addStretch() + self.main_layout.addWidget(self.tips_card) + + def _generate(self): + att = self.sp_att.value() + quiz = self.sp_quiz.value() + asgn = self.sp_asgn.value() + mid = self.sp_mid.value() + hrs = self.sp_hrs.value() + + plan = generate_plan(att, quiz, asgn, mid, hrs) + + # Clear old plan + while self.plan_row.count(): + item = self.plan_row.takeAt(0) + if item.widget(): item.widget().deleteLater() + + COLORS = {'HIGH': '#EF4444', 'MED': '#F59E0B', 'LOW': '#10B981', 'REST': '#9CA3AF'} + + for day_data in plan: + day_card = QWidget() + day_card.setStyleSheet("background:#FFFFFF;border:1px solid #E5E7EB;border-radius:10px;") + day_card.setFixedWidth(150) + dl = QVBoxLayout(day_card) + dl.setContentsMargins(10, 12, 10, 12) + dl.setSpacing(6) + + day_lbl = QLabel(day_data['day']) + day_lbl.setStyleSheet("font-size:12px;font-weight:700;color:#111827;") + dl.addWidget(day_lbl) + + hrs_lbl = QLabel(f"{day_data['total_hrs']} hrs") + hrs_lbl.setStyleSheet("font-size:10px;color:#9CA3AF;") + dl.addWidget(hrs_lbl) + + sep = QFrame(); sep.setFrameShape(QFrame.HLine) + sep.setStyleSheet("background:#F3F4F6;max-height:1px;border:none;") + dl.addWidget(sep) + + for sess in day_data['sessions']: + s_w = QWidget() + c = sess['color'] + s_w.setStyleSheet(f"background:{c}18;border-radius:6px;border-left:2px solid {c};") + sl = QVBoxLayout(s_w) + sl.setContentsMargins(7, 6, 7, 6) + sl.setSpacing(2) + s_lbl = QLabel(sess['subject']) + s_lbl.setStyleSheet(f"font-size:10px;font-weight:700;color:{c};") + s_lbl.setWordWrap(True) + dur = QLabel(f"{sess['duration']} min") + dur.setStyleSheet("font-size:9px;color:#9CA3AF;") + sl.addWidget(s_lbl) + sl.addWidget(dur) + dl.addWidget(s_w) + + dl.addStretch() + self.plan_row.addWidget(day_card) + self.plan_row.addStretch() diff --git a/ai_academic_fixed/ui/skills_page.py b/ai_academic_fixed/ui/skills_page.py new file mode 100644 index 0000000..ab97095 --- /dev/null +++ b/ai_academic_fixed/ui/skills_page.py @@ -0,0 +1,187 @@ +# ui/skills_page.py — Smart Skill Recommendation Page + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QComboBox, QProgressBar, QPushButton, QScrollArea, + QLineEdit, QDoubleSpinBox, QFrame) +from PyQt5.QtCore import Qt +from ui.components.cards import ContentCard +from config import SKILLS_DB, CAREERS +from recommender.career_recommender import CareerRecommender + + +class SkillsPage(QWidget): + def __init__(self): + super().__init__() + self.recommender = CareerRecommender() + self.setStyleSheet("background:#F8F9FC;") + self._build() + self._load_skills() + + def _build(self): + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(0) + scroll.setStyleSheet("background:#F8F9FC;") + + content = QWidget() + content.setStyleSheet("background:#F8F9FC;") + self.main_layout = QVBoxLayout(content) + self.main_layout.setContentsMargins(28, 24, 28, 28) + self.main_layout.setSpacing(20) + scroll.setWidget(content) + outer.addWidget(scroll) + + # ── Filter card ─────────────────────────────── + filter_card = ContentCard("🧠 Skill Recommendation Engine", "Select a career goal to see required skills") + fl = filter_card.body_layout + row = QHBoxLayout() + row.setSpacing(12) + + career_lbl = QLabel("CAREER GOAL") + career_lbl.setStyleSheet("font-size:11px;color:#9CA3AF;font-weight:600;") + self.career_combo = QComboBox() + self.career_combo.addItems([c['title'] for c in CAREERS]) + self.career_combo.setFixedHeight(38) + self.career_combo.setStyleSheet("background:#F9FAFB;border:1px solid #E5E7EB;" + "border-radius:8px;padding:0 12px;font-size:13px;color:#111827;") + self.career_combo.currentTextChanged.connect(self._load_skills) + + your_lbl = QLabel("YOUR SKILLS (comma-separated)") + your_lbl.setStyleSheet("font-size:11px;color:#9CA3AF;font-weight:600;") + self.skills_input = QLineEdit() + self.skills_input.setPlaceholderText("e.g. Python, SQL, Linux") + self.skills_input.setFixedHeight(38) + self.skills_input.setStyleSheet("background:#F9FAFB;border:1px solid #E5E7EB;" + "border-radius:8px;padding:0 12px;font-size:13px;color:#111827;") + + btn = QPushButton("Analyze") + btn.setFixedHeight(38) + btn.setCursor(Qt.PointingHandCursor) + btn.setStyleSheet("background:#4F6EF7;color:white;border:none;border-radius:8px;" + "padding:0 20px;font-size:13px;font-weight:700;") + btn.clicked.connect(self._load_skills) + + c1 = QVBoxLayout(); c1.addWidget(career_lbl); c1.addWidget(self.career_combo) + c2 = QVBoxLayout(); c2.addWidget(your_lbl); c2.addWidget(self.skills_input) + row.addLayout(c1, 1) + row.addLayout(c2, 2) + row.addWidget(btn) + fl.addLayout(row) + self.main_layout.addWidget(filter_card) + + # ── Results ─────────────────────────────────── + results_row = QHBoxLayout() + results_row.setSpacing(14) + self.main_layout.addLayout(results_row) + + self.required_card = ContentCard("📋 Required Skills", "For your selected career") + self.required_body = self.required_card.body_layout + results_row.addWidget(self.required_card, 1) + + self.roadmap_card = ContentCard("🗺️ Learning Roadmap", "Suggested order to learn skills") + self.roadmap_body = self.roadmap_card.body_layout + results_row.addWidget(self.roadmap_card, 1) + + # ── All skills matrix ───────────────────────── + self.matrix_card = ContentCard("📊 Skills Matrix — All Careers", "") + self.matrix_body = self.matrix_card.body_layout + self.main_layout.addWidget(self.matrix_card) + + def _load_skills(self): + career_title = self.career_combo.currentText() + user_skills_str = self.skills_input.text() + user_skills = set(s.strip().lower() for s in user_skills_str.replace(';', ',').split(',') if s.strip()) + + skill_data = self.recommender.get_skill_recommendations(career_title, user_skills_str) + present = set(s.lower() for s in skill_data['present']) + + # Required skills + self._clear(self.required_body) + for s in skill_data['all']: + is_present = s.lower() in present + row = QHBoxLayout() + icon = QLabel("✅" if is_present else "📚") + icon.setFixedWidth(24) + name = QLabel(s) + name.setStyleSheet(f"font-size:13px;font-weight:{'700' if is_present else '500'};" + f"color:{'#10B981' if is_present else '#374151'};") + bar = QProgressBar() + bar.setRange(0, 100) + bar.setValue(100 if is_present else 0) + bar.setFixedHeight(6) + c = '#10B981' if is_present else '#E5E7EB' + bar.setStyleSheet(f"QProgressBar{{background:#F3F4F6;border:none;border-radius:3px;}}" + f"QProgressBar::chunk{{background:{c};border-radius:3px;}}") + status = QLabel("Acquired" if is_present else "Missing") + status.setStyleSheet(f"font-size:11px;color:{'#10B981' if is_present else '#EF4444'};font-weight:600;") + status.setFixedWidth(60) + row.addWidget(icon) + row.addWidget(name, 1) + row.addWidget(bar, 2) + row.addWidget(status) + self.required_body.addLayout(row) + self.required_body.addStretch() + + # Roadmap + self._clear(self.roadmap_body) + missing = skill_data['missing'] + if not missing: + done = QLabel("🎉 You have all required skills for this career!") + done.setStyleSheet("color:#10B981;font-size:13px;font-weight:600;") + done.setWordWrap(True) + self.roadmap_body.addWidget(done) + else: + for i, s in enumerate(missing, 1): + step = QWidget() + step.setStyleSheet("background:#F9FAFB;border-radius:8px;border-left:3px solid #4F6EF7;") + sl = QHBoxLayout(step) + sl.setContentsMargins(12, 10, 12, 10) + num = QLabel(f"Step {i}") + num.setStyleSheet("font-size:11px;color:#9CA3AF;font-weight:700;") + num.setFixedWidth(48) + skill_lbl = QLabel(s) + skill_lbl.setStyleSheet("font-size:13px;color:#111827;font-weight:600;") + sl.addWidget(num) + sl.addWidget(skill_lbl, 1) + self.roadmap_body.addWidget(step) + self.roadmap_body.addStretch() + + # Matrix + self._clear(self.matrix_body) + header_row = QHBoxLayout() + career_h = QLabel("Career") + career_h.setFixedWidth(160) + career_h.setStyleSheet("font-size:11px;font-weight:700;color:#9CA3AF;") + header_row.addWidget(career_h) + header_row.addWidget(QLabel("Key Skills")) + self.matrix_body.addLayout(header_row) + + line = QFrame(); line.setFrameShape(QFrame.HLine) + line.setStyleSheet("background:#F3F4F6;max-height:1px;border:none;") + self.matrix_body.addWidget(line) + + colors = ['#4F6EF7','#7C3AED','#0EA5E9','#EF4444','#10B981','#F59E0B'] + for i, (career, skills) in enumerate(SKILLS_DB.items()): + row = QHBoxLayout() + c_lbl = QLabel(career) + c_lbl.setFixedWidth(160) + c_lbl.setStyleSheet("font-size:12px;font-weight:600;color:#374151;") + row.addWidget(c_lbl) + for s in skills[:5]: + badge = QLabel(s) + bg = colors[i % len(colors)] + badge.setStyleSheet(f"background:{bg}18;color:{bg};border-radius:12px;" + f"padding:2px 10px;font-size:11px;font-weight:600;") + row.addWidget(badge) + row.addStretch() + self.matrix_body.addLayout(row) + self.matrix_body.addStretch() + + def _clear(self, layout): + while layout.count(): + item = layout.takeAt(0) + if item.widget(): item.widget().deleteLater() + elif item.layout(): self._clear(item.layout()) diff --git a/ai_academic_fixed/ui/student_page.py b/ai_academic_fixed/ui/student_page.py new file mode 100644 index 0000000..8b954be --- /dev/null +++ b/ai_academic_fixed/ui/student_page.py @@ -0,0 +1,272 @@ +# ui/student_page.py — Student Management with Add/Edit/Delete + Search + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, + QDialog, QFormLayout, QDoubleSpinBox, QSpinBox, + QHeaderView, QMessageBox, QScrollArea, QFrame, + QComboBox, QAbstractItemView) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QFont +from ui.components.cards import RiskBadge +from database.db_manager import DatabaseManager + + +class StudentDialog(QDialog): + """Add / Edit student dialog.""" + def __init__(self, parent=None, data=None): + super().__init__(parent) + self.setWindowTitle("Add Student" if data is None else "Edit Student") + self.setMinimumWidth(420) + self.setStyleSheet(""" + QDialog { background:#FFFFFF; } + QLabel { font-size:13px; color:#374151; } + QLineEdit, QDoubleSpinBox, QSpinBox, QComboBox { + background:#F9FAFB; border:1px solid #E5E7EB; border-radius:8px; + padding:8px 12px; font-size:13px; color:#111827; min-height:16px; + } + QLineEdit:focus, QDoubleSpinBox:focus, QSpinBox:focus { + border-color:#4F6EF7; + } + """) + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(16) + + title = QLabel("Student" if data is None else "Edit Student") + title.setStyleSheet("font-size:17px;font-weight:700;color:#111827;") + layout.addWidget(title) + + form = QFormLayout() + form.setSpacing(10) + form.setLabelAlignment(Qt.AlignRight) + + def field(val=''): + w = QLineEdit() + w.setText(str(val)) + return w + + def spin(min_v, max_v, dec, val): + w = QDoubleSpinBox() if dec > 0 else QSpinBox() + w.setRange(min_v, max_v) + if dec > 0: w.setDecimals(dec) + w.setValue(val) + return w + + self.f_name = field(data['name'] if data else '') + self.f_email = field(data['email'] if data else '') + self.f_attend = spin(0, 100, 1, data['attendance'] if data else 80.0) + self.f_quiz = spin(0, 100, 1, data['quiz'] if data else 75.0) + self.f_assign = spin(0, 100, 1, data['assignment'] if data else 75.0) + self.f_mid = spin(0, 100, 1, data['midterm'] if data else 70.0) + self.f_hrs = spin(0, 12, 1, data['study_hours'] if data else 4.0) + self.f_gpa = spin(0, 4, 2, data['gpa'] if data else 3.0) + self.f_skills = field(data['skills'] if data else '') + self.f_int = field(data['interest'] if data else '') + + form.addRow("Full Name *", self.f_name) + form.addRow("Email", self.f_email) + form.addRow("Attendance %", self.f_attend) + form.addRow("Quiz Score", self.f_quiz) + form.addRow("Assignment", self.f_assign) + form.addRow("Midterm", self.f_mid) + form.addRow("Study Hrs/day", self.f_hrs) + form.addRow("Current GPA", self.f_gpa) + form.addRow("Skills (;sep)", self.f_skills) + form.addRow("Interest", self.f_int) + layout.addLayout(form) + + btns = QHBoxLayout() + cancel = QPushButton("Cancel") + cancel.setStyleSheet("background:#F3F4F6;color:#374151;border:none;border-radius:8px;" + "padding:10px 24px;font-size:13px;") + cancel.clicked.connect(self.reject) + save = QPushButton("Save Student") + save.setStyleSheet("background:#4F6EF7;color:white;border:none;border-radius:8px;" + "padding:10px 24px;font-size:13px;font-weight:700;") + save.clicked.connect(self.accept) + save.setCursor(Qt.PointingHandCursor) + cancel.setCursor(Qt.PointingHandCursor) + btns.addWidget(cancel) + btns.addWidget(save) + layout.addLayout(btns) + + def get_data(self): + return { + 'name': self.f_name.text().strip(), + 'email': self.f_email.text().strip(), + 'attendance': self.f_attend.value(), + 'quiz': self.f_quiz.value(), + 'assignment': self.f_assign.value(), + 'midterm': self.f_mid.value(), + 'study_hours': self.f_hrs.value(), + 'gpa': self.f_gpa.value(), + 'skills': self.f_skills.text().strip(), + 'interest': self.f_int.text().strip(), + } + + +class StudentPage(QWidget): + student_selected = pyqtSignal(dict) + + def __init__(self, db: DatabaseManager): + super().__init__() + self.db = db + self.setStyleSheet("background:#F8F9FC;") + self._build() + self.load_students() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(28, 24, 28, 28) + layout.setSpacing(16) + + # ── Toolbar ─────────────────────────────────── + toolbar = QHBoxLayout() + self.search = QLineEdit() + self.search.setPlaceholderText("🔍 Search by name, skill, or interest...") + self.search.setObjectName("search_box") + self.search.setFixedHeight(38) + self.search.setStyleSheet(""" + QLineEdit { background:#FFFFFF; border:1px solid #E5E7EB; border-radius:8px; + padding:0 14px; font-size:13px; color:#374151; } + QLineEdit:focus { border-color:#4F6EF7; } + """) + self.search.textChanged.connect(self._on_search) + + btn_add = QPushButton("+ Add Student") + btn_add.setFixedHeight(38) + btn_add.setCursor(Qt.PointingHandCursor) + btn_add.setStyleSheet("background:#4F6EF7;color:white;border:none;border-radius:8px;" + "padding:0 20px;font-size:13px;font-weight:700;") + btn_add.clicked.connect(self._add_student) + + self.count_lbl = QLabel() + self.count_lbl.setStyleSheet("font-size:13px;color:#9CA3AF;") + + toolbar.addWidget(self.search, 1) + toolbar.addWidget(self.count_lbl) + toolbar.addWidget(btn_add) + layout.addLayout(toolbar) + + # ── Table card ──────────────────────────────── + card = QWidget() + card.setStyleSheet("background:#FFFFFF;border:1px solid #E5E7EB;border-radius:12px;") + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(0, 0, 0, 0) + + self.table = QTableWidget() + self.table.setAlternatingRowColors(True) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.verticalHeader().setVisible(False) + self.table.setShowGrid(False) + self.table.setFrameShape(QFrame.NoFrame) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.setStyleSheet(""" + QTableWidget { background:transparent; border:none; font-size:13px; + alternate-background-color:#FAFAFA; gridline-color:#F3F4F6; } + QTableWidget::item { padding:10px 14px; border-bottom:1px solid #F3F4F6; color:#374151; } + QTableWidget::item:selected { background:#EEF1FF; color:#4F6EF7; } + QHeaderView::section { background:#F9FAFB; color:#9CA3AF; font-size:11px; + font-weight:700; padding:10px 14px; border:none; + border-bottom:1px solid #E5E7EB; letter-spacing:0.5px; } + """) + + cols = ['#', 'Name', 'GPA', 'Attendance', 'Quiz', 'Midterm', 'Interest', 'Risk', 'Actions'] + self.table.setColumnCount(len(cols)) + self.table.setHorizontalHeaderLabels(cols) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + for i in [0, 2, 3, 4, 5, 7]: self.table.setColumnWidth(i, 75) + self.table.setColumnWidth(6, 100) + self.table.setColumnWidth(8, 110) + self.table.setRowHeight(0, 44) + card_layout.addWidget(self.table) + layout.addWidget(card, 1) + + def load_students(self, rows=None): + students = rows if rows is not None else self.db.get_all_students() + self.table.setRowCount(len(students)) + self.count_lbl.setText(f"{len(students)} students") + for row_idx, s in enumerate(students): + self.table.setRowHeight(row_idx, 46) + vals = [ + str(row_idx + 1), + s['name'], + f"{s['gpa']:.2f}", + f"{s['attendance']:.0f}%", + f"{s['quiz']:.0f}", + f"{s['midterm']:.0f}", + s['interest'] or '—', + ] + for col, val in enumerate(vals): + item = QTableWidgetItem(val) + item.setData(Qt.UserRole, dict(s)) + if col == 0: + item.setForeground(Qt.gray) + item.setFont(QFont('Segoe UI', 10)) + if col == 2: + color = '#10B981' if s['gpa'] >= 3.5 else ('#F59E0B' if s['gpa'] >= 2.5 else '#EF4444') + item.setForeground(Qt.green) + self.table.setItem(row_idx, col, item) + + # Risk badge + risk_w = QWidget() + risk_layout = QHBoxLayout(risk_w) + risk_layout.setContentsMargins(8, 4, 8, 4) + risk_layout.addWidget(RiskBadge(s['risk_level'])) + self.table.setCellWidget(row_idx, 7, risk_w) + + # Action buttons + act_w = QWidget() + act_layout = QHBoxLayout(act_w) + act_layout.setContentsMargins(6, 4, 6, 4) + act_layout.setSpacing(4) + btn_e = QPushButton("Edit") + btn_e.setFixedHeight(28) + btn_e.setCursor(Qt.PointingHandCursor) + btn_e.setStyleSheet("background:#EEF1FF;color:#4F6EF7;border:none;border-radius:6px;" + "padding:0 10px;font-size:11px;font-weight:600;") + btn_d = QPushButton("Del") + btn_d.setFixedHeight(28) + btn_d.setCursor(Qt.PointingHandCursor) + btn_d.setStyleSheet("background:#FEE2E2;color:#DC2626;border:none;border-radius:6px;" + "padding:0 10px;font-size:11px;font-weight:600;") + sid = s['id'] + btn_e.clicked.connect(lambda _, sid=sid: self._edit_student(sid)) + btn_d.clicked.connect(lambda _, sid=sid: self._delete_student(sid)) + act_layout.addWidget(btn_e) + act_layout.addWidget(btn_d) + self.table.setCellWidget(row_idx, 8, act_w) + + def _on_search(self, text): + if text.strip(): + results = self.db.search_students(text) + self.load_students(results) + else: + self.load_students() + + def _add_student(self): + dlg = StudentDialog(self) + if dlg.exec_() == StudentDialog.Accepted: + data = dlg.get_data() + if not data['name']: + QMessageBox.warning(self, "Validation", "Name is required.") + return + self.db.add_student(**data) + self.load_students() + + def _edit_student(self, sid): + s = self.db.get_student(sid) + if not s: return + dlg = StudentDialog(self, dict(s)) + if dlg.exec_() == StudentDialog.Accepted: + data = dlg.get_data() + self.db.update_student(sid, **data) + self.load_students() + + def _delete_student(self, sid): + reply = QMessageBox.question(self, "Delete", "Delete this student?", + QMessageBox.Yes | QMessageBox.No) + if reply == QMessageBox.Yes: + self.db.delete_student(sid) + self.load_students()