Spaces:
Sleeping
Sleeping
Upload 13 files
Browse files- .gitignore +13 -0
- app.py +1243 -0
- content_generator.py +63 -0
- evaluation.py +53 -0
- flashcard_generator.py +36 -0
- gamification.py +180 -0
- learning_progress.json +46 -0
- notes.json +12 -0
- pdf_export.py +274 -0
- quiz_generator.py +60 -0
- requirements.txt +5 -0
- tutor.py +43 -0
- utils.py +158 -0
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.pyo
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
.venv/
|
| 7 |
+
*.egg-info/
|
| 8 |
+
dist/
|
| 9 |
+
build/
|
| 10 |
+
.DS_Store
|
| 11 |
+
*.log
|
| 12 |
+
learning_progress.json
|
| 13 |
+
notes.json
|
app.py
ADDED
|
@@ -0,0 +1,1243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import streamlit as st
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from content_generator import generate_content
|
| 5 |
+
from quiz_generator import generate_quiz
|
| 6 |
+
from evaluation import evaluate_answers
|
| 7 |
+
from flashcard_generator import generate_flashcards
|
| 8 |
+
from tutor import get_tutor_reply
|
| 9 |
+
from pdf_export import export_study_notes_pdf, export_quiz_results_pdf
|
| 10 |
+
from gamification import (
|
| 11 |
+
load_gamification, save_gamification,
|
| 12 |
+
update_streak, award_xp, check_and_award_badges,
|
| 13 |
+
get_level, get_xp_for_quiz, record_quiz,
|
| 14 |
+
BADGES, XP_STUDY_SESSION, XP_FLASHCARD_DECK,
|
| 15 |
+
)
|
| 16 |
+
from utils import (
|
| 17 |
+
get_topics, save_progress, load_progress,
|
| 18 |
+
get_weak_topics, get_learning_path,
|
| 19 |
+
load_notes, save_note, delete_note,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# ββ Page config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
st.set_page_config(
|
| 24 |
+
page_title="LearnCraft β Personalized Learning",
|
| 25 |
+
page_icon="π",
|
| 26 |
+
layout="wide",
|
| 27 |
+
initial_sidebar_state="expanded",
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# ββ Light Theme CSS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
st.markdown("""
|
| 32 |
+
<style>
|
| 33 |
+
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,400;0,700;0,900;1,400&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap');
|
| 34 |
+
|
| 35 |
+
:root {
|
| 36 |
+
--bg: #f7f4ef;
|
| 37 |
+
--surface: #ffffff;
|
| 38 |
+
--sidebar: #1e1b4b;
|
| 39 |
+
--accent: #6c47ff;
|
| 40 |
+
--accent2: #f97316;
|
| 41 |
+
--accent3: #10b981;
|
| 42 |
+
--text: #1a1523;
|
| 43 |
+
--muted: #6b6880;
|
| 44 |
+
--card: #ffffff;
|
| 45 |
+
--border: #e2ddf5;
|
| 46 |
+
--tag-bg: #ede9fe;
|
| 47 |
+
--tag-color: #6c47ff;
|
| 48 |
+
--shadow: 0 2px 16px rgba(108,71,255,0.08);
|
| 49 |
+
--shadow-lg: 0 8px 40px rgba(108,71,255,0.13);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
html, body, [class*="css"] {
|
| 53 |
+
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
| 54 |
+
background-color: var(--bg) !important;
|
| 55 |
+
color: var(--text) !important;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.stApp { background: var(--bg) !important; }
|
| 59 |
+
|
| 60 |
+
/* Sidebar */
|
| 61 |
+
[data-testid="stSidebar"] {
|
| 62 |
+
background: var(--sidebar) !important;
|
| 63 |
+
border-right: none !important;
|
| 64 |
+
}
|
| 65 |
+
[data-testid="stSidebar"] * { color: #e2e0f5 !important; }
|
| 66 |
+
[data-testid="stSidebar"] h1,
|
| 67 |
+
[data-testid="stSidebar"] h2,
|
| 68 |
+
[data-testid="stSidebar"] h3 { color: #fff !important; }
|
| 69 |
+
|
| 70 |
+
/* Headings */
|
| 71 |
+
h1, h2, h3 {
|
| 72 |
+
font-family: 'Fraunces', serif !important;
|
| 73 |
+
color: var(--text) !important;
|
| 74 |
+
letter-spacing: -0.02em;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/* Cards */
|
| 78 |
+
.learn-card {
|
| 79 |
+
background: var(--card);
|
| 80 |
+
border: 1px solid var(--border);
|
| 81 |
+
border-radius: 18px;
|
| 82 |
+
padding: 1.6rem;
|
| 83 |
+
margin: 0.75rem 0;
|
| 84 |
+
box-shadow: var(--shadow);
|
| 85 |
+
transition: transform 0.18s, box-shadow 0.18s;
|
| 86 |
+
}
|
| 87 |
+
.learn-card:hover {
|
| 88 |
+
transform: translateY(-3px);
|
| 89 |
+
box-shadow: var(--shadow-lg);
|
| 90 |
+
border-color: var(--accent);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/* Hero */
|
| 94 |
+
.hero {
|
| 95 |
+
background: linear-gradient(135deg, #6c47ff 0%, #a78bfa 55%, #f97316 100%);
|
| 96 |
+
border-radius: 24px;
|
| 97 |
+
padding: 3.5rem 2.5rem;
|
| 98 |
+
text-align: center;
|
| 99 |
+
margin-bottom: 2rem;
|
| 100 |
+
position: relative;
|
| 101 |
+
overflow: hidden;
|
| 102 |
+
box-shadow: var(--shadow-lg);
|
| 103 |
+
}
|
| 104 |
+
.hero::before {
|
| 105 |
+
content: '';
|
| 106 |
+
position: absolute; top:-60%; left:-30%;
|
| 107 |
+
width: 200%; height: 200%;
|
| 108 |
+
background: radial-gradient(circle, rgba(255,255,255,0.12) 0%, transparent 55%);
|
| 109 |
+
pointer-events: none;
|
| 110 |
+
}
|
| 111 |
+
.hero h1 {
|
| 112 |
+
font-family: 'Fraunces', serif !important;
|
| 113 |
+
font-size: 3.2rem !important;
|
| 114 |
+
color: #fff !important;
|
| 115 |
+
margin-bottom: 0.4rem !important;
|
| 116 |
+
text-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
| 117 |
+
}
|
| 118 |
+
.hero p { color: rgba(255,255,255,0.88) !important; font-size: 1.1rem; font-weight: 400; }
|
| 119 |
+
|
| 120 |
+
/* Tag pill */
|
| 121 |
+
.tag {
|
| 122 |
+
display: inline-block;
|
| 123 |
+
background: rgba(255,255,255,0.22);
|
| 124 |
+
color: #fff !important;
|
| 125 |
+
border: 1px solid rgba(255,255,255,0.35);
|
| 126 |
+
border-radius: 100px;
|
| 127 |
+
padding: 0.28rem 0.9rem;
|
| 128 |
+
font-size: 0.75rem;
|
| 129 |
+
font-weight: 700;
|
| 130 |
+
letter-spacing: 0.07em;
|
| 131 |
+
text-transform: uppercase;
|
| 132 |
+
margin-bottom: 1rem;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/* Content blocks */
|
| 136 |
+
.content-block {
|
| 137 |
+
background: var(--card);
|
| 138 |
+
border-left: 4px solid var(--accent);
|
| 139 |
+
border-radius: 0 14px 14px 0;
|
| 140 |
+
padding: 1.5rem 2rem;
|
| 141 |
+
margin: 0.9rem 0;
|
| 142 |
+
box-shadow: var(--shadow);
|
| 143 |
+
line-height: 1.8;
|
| 144 |
+
}
|
| 145 |
+
.content-block h3 {
|
| 146 |
+
color: var(--accent) !important;
|
| 147 |
+
font-size: 1.15rem !important;
|
| 148 |
+
margin-bottom: 0.6rem !important;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* Score badge */
|
| 152 |
+
.score-badge {
|
| 153 |
+
font-size: 4.5rem;
|
| 154 |
+
font-family: 'Fraunces', serif;
|
| 155 |
+
font-weight: 900;
|
| 156 |
+
background: linear-gradient(135deg, #6c47ff, #f97316);
|
| 157 |
+
-webkit-background-clip: text;
|
| 158 |
+
-webkit-text-fill-color: transparent;
|
| 159 |
+
background-clip: text;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/* Progress bar */
|
| 163 |
+
.stProgress > div > div {
|
| 164 |
+
background: linear-gradient(90deg, var(--accent), var(--accent2)) !important;
|
| 165 |
+
border-radius: 99px !important;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/* Buttons */
|
| 169 |
+
.stButton > button {
|
| 170 |
+
background: linear-gradient(135deg, #6c47ff, #8b6eff) !important;
|
| 171 |
+
color: #fff !important;
|
| 172 |
+
font-weight: 600 !important;
|
| 173 |
+
border: none !important;
|
| 174 |
+
border-radius: 12px !important;
|
| 175 |
+
padding: 0.62rem 2rem !important;
|
| 176 |
+
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
| 177 |
+
box-shadow: 0 4px 14px rgba(108,71,255,0.25) !important;
|
| 178 |
+
transition: opacity 0.18s, transform 0.18s !important;
|
| 179 |
+
}
|
| 180 |
+
.stButton > button:hover {
|
| 181 |
+
opacity: 0.9 !important;
|
| 182 |
+
transform: translateY(-1px) !important;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
/* Inputs */
|
| 186 |
+
.stSelectbox > div > div,
|
| 187 |
+
.stTextInput > div > div,
|
| 188 |
+
.stTextArea > div > div,
|
| 189 |
+
.stNumberInput > div > div {
|
| 190 |
+
background: var(--card) !important;
|
| 191 |
+
border-color: var(--border) !important;
|
| 192 |
+
color: var(--text) !important;
|
| 193 |
+
border-radius: 12px !important;
|
| 194 |
+
box-shadow: var(--shadow) !important;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* Radio */
|
| 198 |
+
.stRadio > div { gap: 0.5rem; }
|
| 199 |
+
.stRadio label {
|
| 200 |
+
background: var(--card) !important;
|
| 201 |
+
border: 1.5px solid var(--border) !important;
|
| 202 |
+
border-radius: 12px !important;
|
| 203 |
+
padding: 0.65rem 1.1rem !important;
|
| 204 |
+
transition: border-color 0.18s !important;
|
| 205 |
+
}
|
| 206 |
+
.stRadio label:hover { border-color: var(--accent) !important; }
|
| 207 |
+
|
| 208 |
+
/* Metrics */
|
| 209 |
+
[data-testid="stMetricValue"] {
|
| 210 |
+
color: var(--accent) !important;
|
| 211 |
+
font-family: 'Fraunces', serif !important;
|
| 212 |
+
font-size: 2rem !important;
|
| 213 |
+
}
|
| 214 |
+
[data-testid="stMetricLabel"] { color: var(--muted) !important; font-size: 0.82rem !important; }
|
| 215 |
+
|
| 216 |
+
/* Tabs */
|
| 217 |
+
.stTabs [data-baseweb="tab"] { color: var(--muted) !important; font-weight: 600 !important; }
|
| 218 |
+
.stTabs [aria-selected="true"] { color: var(--accent) !important; }
|
| 219 |
+
.stTabs [data-baseweb="tab-highlight"] { background: var(--accent) !important; }
|
| 220 |
+
.stTabs [data-baseweb="tab-border"] { background: var(--border) !important; }
|
| 221 |
+
|
| 222 |
+
/* Dividers */
|
| 223 |
+
hr { border-color: var(--border) !important; }
|
| 224 |
+
|
| 225 |
+
/* Alerts */
|
| 226 |
+
.stSuccess, .stWarning, .stError, .stInfo { border-radius: 12px !important; }
|
| 227 |
+
|
| 228 |
+
/* Dataframe */
|
| 229 |
+
[data-testid="stDataFrame"] { border-radius: 14px !important; overflow: hidden; box-shadow: var(--shadow); }
|
| 230 |
+
|
| 231 |
+
/* Flashcard flip */
|
| 232 |
+
.flip-card {
|
| 233 |
+
perspective: 900px;
|
| 234 |
+
height: 190px;
|
| 235 |
+
cursor: pointer;
|
| 236 |
+
margin: 0.5rem 0;
|
| 237 |
+
}
|
| 238 |
+
.flip-inner {
|
| 239 |
+
position: relative; width: 100%; height: 100%;
|
| 240 |
+
transition: transform 0.55s cubic-bezier(.4,2,.55,.44);
|
| 241 |
+
transform-style: preserve-3d;
|
| 242 |
+
}
|
| 243 |
+
.flip-card.flipped .flip-inner { transform: rotateY(180deg); }
|
| 244 |
+
.flip-front, .flip-back {
|
| 245 |
+
position: absolute; width: 100%; height: 100%;
|
| 246 |
+
backface-visibility: hidden;
|
| 247 |
+
border-radius: 16px;
|
| 248 |
+
display: flex; align-items: center; justify-content: center;
|
| 249 |
+
padding: 1.2rem;
|
| 250 |
+
text-align: center;
|
| 251 |
+
box-shadow: var(--shadow);
|
| 252 |
+
}
|
| 253 |
+
.flip-front {
|
| 254 |
+
background: var(--card);
|
| 255 |
+
border: 2px solid var(--border);
|
| 256 |
+
color: var(--text);
|
| 257 |
+
font-weight: 600; font-size: 1rem;
|
| 258 |
+
}
|
| 259 |
+
.flip-back {
|
| 260 |
+
background: linear-gradient(135deg, #6c47ff, #8b6eff);
|
| 261 |
+
border: 2px solid transparent;
|
| 262 |
+
color: #fff;
|
| 263 |
+
font-size: 0.92rem;
|
| 264 |
+
transform: rotateY(180deg);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/* Sidebar nav buttons */
|
| 268 |
+
[data-testid="stSidebar"] .stButton > button {
|
| 269 |
+
background: rgba(255,255,255,0.08) !important;
|
| 270 |
+
color: #e2e0f5 !important;
|
| 271 |
+
border: 1px solid rgba(255,255,255,0.12) !important;
|
| 272 |
+
text-align: left !important;
|
| 273 |
+
box-shadow: none !important;
|
| 274 |
+
border-radius: 10px !important;
|
| 275 |
+
}
|
| 276 |
+
[data-testid="stSidebar"] .stButton > button:hover {
|
| 277 |
+
background: rgba(255,255,255,0.18) !important;
|
| 278 |
+
transform: none !important;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/* Chat bubbles */
|
| 282 |
+
.chat-user {
|
| 283 |
+
background: linear-gradient(135deg, #6c47ff, #8b6eff);
|
| 284 |
+
color: #fff;
|
| 285 |
+
border-radius: 18px 18px 4px 18px;
|
| 286 |
+
padding: 0.75rem 1.1rem;
|
| 287 |
+
margin: 0.4rem 0 0.4rem 20%;
|
| 288 |
+
font-size: 0.95rem;
|
| 289 |
+
line-height: 1.55;
|
| 290 |
+
box-shadow: 0 2px 8px rgba(108,71,255,0.18);
|
| 291 |
+
}
|
| 292 |
+
.chat-ai {
|
| 293 |
+
background: #fff;
|
| 294 |
+
color: #1a1523;
|
| 295 |
+
border: 1.5px solid #e2ddf5;
|
| 296 |
+
border-radius: 18px 18px 18px 4px;
|
| 297 |
+
padding: 0.75rem 1.1rem;
|
| 298 |
+
margin: 0.4rem 20% 0.4rem 0;
|
| 299 |
+
font-size: 0.95rem;
|
| 300 |
+
line-height: 1.6;
|
| 301 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
| 302 |
+
}
|
| 303 |
+
.chat-label { font-size:0.72rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; margin-bottom:0.15rem; }
|
| 304 |
+
.badge-card { background:#fff; border:1.5px solid #e2ddf5; border-radius:14px; padding:1rem 0.8rem; text-align:center; box-shadow:0 2px 10px rgba(108,71,255,0.07); transition:transform 0.18s; }
|
| 305 |
+
.badge-card:hover { transform:translateY(-3px); }
|
| 306 |
+
.badge-card.earned { border-color:#6c47ff; background:#ede9fe; }
|
| 307 |
+
.badge-card.locked { opacity:0.45; filter:grayscale(0.6); }
|
| 308 |
+
.toast { background:linear-gradient(135deg,#6c47ff,#8b6eff); color:#fff; border-radius:14px; padding:0.9rem 1.3rem; margin:0.4rem 0; display:flex; align-items:center; gap:0.75rem; box-shadow:0 4px 18px rgba(108,71,255,0.25); }
|
| 309 |
+
</style>
|
| 310 |
+
""", unsafe_allow_html=True)
|
| 311 |
+
|
| 312 |
+
# ββ Session state init ββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½βββββββ
|
| 313 |
+
defaults = {
|
| 314 |
+
"page": "home",
|
| 315 |
+
"content": None,
|
| 316 |
+
"quiz": None,
|
| 317 |
+
"answers": {},
|
| 318 |
+
"submitted": False,
|
| 319 |
+
"progress": load_progress(),
|
| 320 |
+
"quiz_start_time": None,
|
| 321 |
+
"quiz_elapsed": None,
|
| 322 |
+
"flashcards": [],
|
| 323 |
+
"fc_index": 0,
|
| 324 |
+
"fc_flipped": False,
|
| 325 |
+
"daily_goal": 3,
|
| 326 |
+
"sessions_today": 0,
|
| 327 |
+
# Gamification
|
| 328 |
+
"gami": load_gamification(),
|
| 329 |
+
"new_badges": [],
|
| 330 |
+
"xp_gained": 0,
|
| 331 |
+
"level_up_msg": None,
|
| 332 |
+
# Tutor chat
|
| 333 |
+
"tutor_messages": [],
|
| 334 |
+
"tutor_topic": "",
|
| 335 |
+
}
|
| 336 |
+
for k, v in defaults.items():
|
| 337 |
+
if k not in st.session_state:
|
| 338 |
+
st.session_state[k] = v
|
| 339 |
+
|
| 340 |
+
# ββ Sidebar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 341 |
+
with st.sidebar:
|
| 342 |
+
st.markdown("""
|
| 343 |
+
<div style='padding:1.2rem 0 2rem 0;'>
|
| 344 |
+
<div style='font-size:2.4rem; margin-bottom:0.25rem;'>π</div>
|
| 345 |
+
<div style='font-family: Fraunces, serif; font-size:1.5rem; font-weight:700; color:#fff;'>LearnCraft</div>
|
| 346 |
+
<div style='color:rgba(226,224,245,0.6); font-size:0.82rem;'>Personalized Learning Platform</div>
|
| 347 |
+
</div>
|
| 348 |
+
""", unsafe_allow_html=True)
|
| 349 |
+
|
| 350 |
+
pages = {
|
| 351 |
+
"π Home": "home",
|
| 352 |
+
"π Study Content": "study",
|
| 353 |
+
"π Flashcards": "flashcards",
|
| 354 |
+
"π§© Take Quiz": "quiz",
|
| 355 |
+
"π€ AI Tutor": "tutor",
|
| 356 |
+
"π
Achievements": "achievements",
|
| 357 |
+
"π My Progress": "progress",
|
| 358 |
+
"π My Notes": "notes",
|
| 359 |
+
}
|
| 360 |
+
for label, key in pages.items():
|
| 361 |
+
is_active = st.session_state.page == key
|
| 362 |
+
btn_label = f"βΆ {label}" if is_active else label
|
| 363 |
+
if st.button(btn_label, key=f"nav_{key}", use_container_width=True):
|
| 364 |
+
st.session_state.page = key
|
| 365 |
+
st.session_state.submitted = False
|
| 366 |
+
st.rerun()
|
| 367 |
+
|
| 368 |
+
st.markdown("---")
|
| 369 |
+
|
| 370 |
+
# XP & Level display
|
| 371 |
+
gami = st.session_state.gami
|
| 372 |
+
xp = gami.get("xp", 0)
|
| 373 |
+
streak = gami.get("streak", 0)
|
| 374 |
+
level_name, next_level_name, xp_to_next, level_pct = get_level(xp)
|
| 375 |
+
badges_earned = len(gami.get("badges", []))
|
| 376 |
+
|
| 377 |
+
st.markdown(f"""
|
| 378 |
+
<div style='margin-bottom:0.6rem;'>
|
| 379 |
+
<div style='color:rgba(226,224,245,0.55); font-size:0.72rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.3rem;'>Your Level</div>
|
| 380 |
+
<div style='color:#fff; font-size:1.15rem; font-weight:700;'>{level_name}</div>
|
| 381 |
+
<div style='color:#a78bfa; font-size:0.8rem; margin-top:0.1rem;'>{xp} XP {f"Β· {xp_to_next} to {next_level_name}" if next_level_name else "Β· MAX LEVEL"}</div>
|
| 382 |
+
<div style='background:rgba(255,255,255,0.12); border-radius:99px; height:6px; margin-top:6px; overflow:hidden;'>
|
| 383 |
+
<div style='height:100%; width:{level_pct}%; background:linear-gradient(90deg,#a78bfa,#f97316); border-radius:99px;'></div>
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
""", unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
# Daily goal tracker
|
| 389 |
+
progress = st.session_state.progress
|
| 390 |
+
sessions_today = sum(
|
| 391 |
+
1 for s in progress.get("sessions", [])
|
| 392 |
+
if s.get("date") == str(__import__("datetime").date.today())
|
| 393 |
+
)
|
| 394 |
+
goal = st.session_state.daily_goal
|
| 395 |
+
goal_pct = min(sessions_today / goal, 1.0)
|
| 396 |
+
st.markdown(f"""
|
| 397 |
+
<div style='margin-bottom:0.3rem;'>
|
| 398 |
+
<div style='color:rgba(226,224,245,0.55); font-size:0.75rem; font-weight:600; text-transform:uppercase; letter-spacing:0.05em;'>Today's Goal</div>
|
| 399 |
+
<div style='color:#fff; font-size:1.3rem; font-weight:700; margin:0.15rem 0;'>{sessions_today} / {goal} sessions</div>
|
| 400 |
+
</div>
|
| 401 |
+
""", unsafe_allow_html=True)
|
| 402 |
+
st.progress(goal_pct)
|
| 403 |
+
|
| 404 |
+
st.markdown("<div style='height:0.4rem'></div>", unsafe_allow_html=True)
|
| 405 |
+
|
| 406 |
+
topics_count = len(set(progress.get("topics_studied", [])))
|
| 407 |
+
best_score = progress.get("best_score", 0)
|
| 408 |
+
scores = progress.get("scores", [])
|
| 409 |
+
avg_score = round(sum(scores)/len(scores), 1) if scores else 0
|
| 410 |
+
|
| 411 |
+
st.markdown(f"""
|
| 412 |
+
<div style='display:flex; gap:0.4rem; margin-top:0.5rem; flex-wrap:wrap;'>
|
| 413 |
+
<div style='flex:1; min-width:48px; background:rgba(255,255,255,0.07); border-radius:10px; padding:0.5rem 0.4rem; text-align:center;'>
|
| 414 |
+
<div style='font-size:1.1rem; font-weight:700; color:#f97316;'>π₯{streak}</div>
|
| 415 |
+
<div style='font-size:0.63rem; color:rgba(226,224,245,0.55);'>Streak</div>
|
| 416 |
+
</div>
|
| 417 |
+
<div style='flex:1; min-width:48px; background:rgba(255,255,255,0.07); border-radius:10px; padding:0.5rem 0.4rem; text-align:center;'>
|
| 418 |
+
<div style='font-size:1.1rem; font-weight:700; color:#a78bfa;'>{topics_count}</div>
|
| 419 |
+
<div style='font-size:0.63rem; color:rgba(226,224,245,0.55);'>Topics</div>
|
| 420 |
+
</div>
|
| 421 |
+
<div style='flex:1; min-width:48px; background:rgba(255,255,255,0.07); border-radius:10px; padding:0.5rem 0.4rem; text-align:center;'>
|
| 422 |
+
<div style='font-size:1.1rem; font-weight:700; color:#fbbf24;'>π
{badges_earned}</div>
|
| 423 |
+
<div style='font-size:0.63rem; color:rgba(226,224,245,0.55);'>Badges</div>
|
| 424 |
+
</div>
|
| 425 |
+
<div style='flex:1; min-width:48px; background:rgba(255,255,255,0.07); border-radius:10px; padding:0.5rem 0.4rem; text-align:center;'>
|
| 426 |
+
<div style='font-size:1.1rem; font-weight:700; color:#10b981;'>{best_score}%</div>
|
| 427 |
+
<div style='font-size:0.63rem; color:rgba(226,224,245,0.55);'>Best</div>
|
| 428 |
+
</div>
|
| 429 |
+
</div>
|
| 430 |
+
""", unsafe_allow_html=True)
|
| 431 |
+
|
| 432 |
+
# ββ Page routing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 433 |
+
page = st.session_state.page
|
| 434 |
+
|
| 435 |
+
# ββββββββββββββββββββββ HOME ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 436 |
+
if page == "home":
|
| 437 |
+
st.markdown("""
|
| 438 |
+
<div class='hero'>
|
| 439 |
+
<div class='tag'>AI-Powered Learning</div>
|
| 440 |
+
<h1>LearnCraft</h1>
|
| 441 |
+
<p>Generate personalized study material, flashcards & quizzes<br>tailored exactly to your level and learning goals.</p>
|
| 442 |
+
</div>
|
| 443 |
+
""", unsafe_allow_html=True)
|
| 444 |
+
|
| 445 |
+
weak = get_weak_topics(st.session_state.progress)
|
| 446 |
+
if weak:
|
| 447 |
+
st.warning(f"π **Recommended Review:** You scored below 60% on: {', '.join(weak)}. Consider revisiting these topics!")
|
| 448 |
+
|
| 449 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 450 |
+
features = [
|
| 451 |
+
("π", "Smart Content", "AI-generated study notes adapted to Beginner, Intermediate, or Advanced levels in multiple styles."),
|
| 452 |
+
("π", "Flashcards", "Flip-card revision sessions with auto-generated front/back cards. Perfect for memorisation."),
|
| 453 |
+
("π§©", "Custom Quizzes", "MCQ, True/False, Fill-in-the-Blank and Short Answer quizzes with timed mode."),
|
| 454 |
+
("π", "Analytics", "Score history charts, per-topic bests, streak tracking and weak-topic detection."),
|
| 455 |
+
]
|
| 456 |
+
for col, (icon, title, desc) in zip([c1, c2, c3, c4], features):
|
| 457 |
+
with col:
|
| 458 |
+
st.markdown(f"""
|
| 459 |
+
<div class='learn-card'>
|
| 460 |
+
<div style='font-size:2rem; margin-bottom:0.5rem;'>{icon}</div>
|
| 461 |
+
<h3 style='font-size:1.1rem !important; margin-bottom:0.4rem;'>{title}</h3>
|
| 462 |
+
<p style='color:#6b6880; font-size:0.87rem; line-height:1.6; margin:0;'>{desc}</p>
|
| 463 |
+
</div>
|
| 464 |
+
""", unsafe_allow_html=True)
|
| 465 |
+
|
| 466 |
+
st.markdown("---")
|
| 467 |
+
st.markdown("### π Quick Start")
|
| 468 |
+
qa, qb, qc = st.columns(3)
|
| 469 |
+
with qa:
|
| 470 |
+
if st.button("π Generate Study Notes", use_container_width=True):
|
| 471 |
+
st.session_state.page = "study"; st.rerun()
|
| 472 |
+
with qb:
|
| 473 |
+
if st.button("π Practice Flashcards", use_container_width=True):
|
| 474 |
+
st.session_state.page = "flashcards"; st.rerun()
|
| 475 |
+
with qc:
|
| 476 |
+
if st.button("π§© Start a Quiz", use_container_width=True):
|
| 477 |
+
st.session_state.page = "quiz"; st.rerun()
|
| 478 |
+
|
| 479 |
+
# Recent activity
|
| 480 |
+
sessions = st.session_state.progress.get("sessions", [])
|
| 481 |
+
if sessions:
|
| 482 |
+
st.markdown("---")
|
| 483 |
+
st.markdown("### π Recent Activity")
|
| 484 |
+
recent = sessions[-5:][::-1]
|
| 485 |
+
for s in recent:
|
| 486 |
+
score = s.get("score", 0)
|
| 487 |
+
color = "#10b981" if score >= 80 else "#f97316" if score >= 60 else "#ef4444"
|
| 488 |
+
st.markdown(f"""
|
| 489 |
+
<div style='background:#fff; border:1px solid #e2ddf5; border-radius:12px; padding:0.75rem 1.2rem; margin:0.3rem 0; display:flex; align-items:center; justify-content:space-between; box-shadow:0 2px 8px rgba(108,71,255,0.06);'>
|
| 490 |
+
<div>
|
| 491 |
+
<span style='font-weight:600; color:#1a1523;'>{s.get("topic","Unknown")}</span>
|
| 492 |
+
<span style='color:#6b6880; font-size:0.82rem; margin-left:0.5rem;'>{s.get("date","")}</span>
|
| 493 |
+
</div>
|
| 494 |
+
<div style='font-weight:700; color:{color}; font-family:Fraunces,serif; font-size:1.1rem;'>{score}%</div>
|
| 495 |
+
</div>
|
| 496 |
+
""", unsafe_allow_html=True)
|
| 497 |
+
|
| 498 |
+
# ββββββββββββββββββββββ STUDY ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 499 |
+
elif page == "study":
|
| 500 |
+
st.markdown("## π Study Content Generator")
|
| 501 |
+
st.markdown("<div style='color:#6b6880; margin-bottom:1rem;'>Choose a topic and level to get personalized AI-generated study notes.</div>", unsafe_allow_html=True)
|
| 502 |
+
|
| 503 |
+
col1, col2 = st.columns([2, 1])
|
| 504 |
+
with col1:
|
| 505 |
+
topic = st.text_input("π Topic", placeholder="e.g. Photosynthesis, World War II, Python Functionsβ¦")
|
| 506 |
+
custom_focus = st.text_area("π― Focus area (optional)", placeholder="e.g. focus on the Calvin cycle, or key dates and battlesβ¦", height=75)
|
| 507 |
+
with col2:
|
| 508 |
+
level = st.selectbox("π Your Level", ["Beginner", "Intermediate", "Advanced"])
|
| 509 |
+
content_type = st.selectbox("π Content Style", ["Summary Notes", "Detailed Explanation", "Bullet Points", "Concept Map"])
|
| 510 |
+
|
| 511 |
+
if topic:
|
| 512 |
+
path = get_learning_path(topic)
|
| 513 |
+
if path:
|
| 514 |
+
path_html = " β ".join(
|
| 515 |
+
f"<span style='color:#6c47ff; font-weight:700;'>{s}</span>" if s.lower() == topic.lower()
|
| 516 |
+
else f"<span style='color:#6b6880;'>{s}</span>"
|
| 517 |
+
for s in path
|
| 518 |
+
)
|
| 519 |
+
st.markdown(f"""
|
| 520 |
+
<div style='background:#fff; border:1px solid #e2ddf5; border-radius:12px; padding:0.8rem 1.3rem; margin-bottom:1rem; box-shadow:0 2px 8px rgba(108,71,255,0.06);'>
|
| 521 |
+
πΊοΈ <strong style='color:#1a1523;'>Suggested Path:</strong> {path_html}
|
| 522 |
+
</div>
|
| 523 |
+
""", unsafe_allow_html=True)
|
| 524 |
+
|
| 525 |
+
if st.button("β¨ Generate Content", use_container_width=True):
|
| 526 |
+
if not topic.strip():
|
| 527 |
+
st.warning("Please enter a topic first.")
|
| 528 |
+
else:
|
| 529 |
+
with st.spinner("Crafting your personalized contentβ¦"):
|
| 530 |
+
content = generate_content(topic, level, content_type, custom_focus)
|
| 531 |
+
st.session_state.content = content
|
| 532 |
+
save_progress(st.session_state.progress, topic=topic.title())
|
| 533 |
+
# Gamification: award XP for study session
|
| 534 |
+
gami = update_streak(st.session_state.gami)
|
| 535 |
+
gami, xp_amt, lvl_up = award_xp(gami, XP_STUDY_SESSION, "study_session")
|
| 536 |
+
topics_count = len(set(st.session_state.progress.get("topics_studied", [])))
|
| 537 |
+
new_b = check_and_award_badges(gami, {"topics_count": topics_count, "event": ""})
|
| 538 |
+
save_gamification(gami)
|
| 539 |
+
st.session_state.gami = gami
|
| 540 |
+
if new_b:
|
| 541 |
+
st.session_state.new_badges = new_b
|
| 542 |
+
if lvl_up:
|
| 543 |
+
st.session_state.level_up_msg = lvl_up
|
| 544 |
+
|
| 545 |
+
if st.session_state.content:
|
| 546 |
+
st.markdown("---")
|
| 547 |
+
data = st.session_state.content
|
| 548 |
+
|
| 549 |
+
m1, m2, m3 = st.columns(3)
|
| 550 |
+
m1.metric("Topic", data["topic"])
|
| 551 |
+
m2.metric("Level", data["level"])
|
| 552 |
+
m3.metric("Est. Read Time", data["read_time"])
|
| 553 |
+
|
| 554 |
+
if data.get("ai_generated"):
|
| 555 |
+
st.info("β¨ Content is AI-generated and tailored to your topic and level.")
|
| 556 |
+
|
| 557 |
+
for section in data["sections"]:
|
| 558 |
+
st.markdown(f"""
|
| 559 |
+
<div class='content-block'>
|
| 560 |
+
<h3>π {section['title']}</h3>
|
| 561 |
+
<div>{section['content']}</div>
|
| 562 |
+
</div>
|
| 563 |
+
""", unsafe_allow_html=True)
|
| 564 |
+
|
| 565 |
+
if data.get("key_terms"):
|
| 566 |
+
st.markdown("### π Key Terms")
|
| 567 |
+
cols = st.columns(3)
|
| 568 |
+
for i, term in enumerate(data["key_terms"]):
|
| 569 |
+
with cols[i % 3]:
|
| 570 |
+
st.markdown(f"""
|
| 571 |
+
<div style='background:#f7f4ef; border:1px solid #e2ddf5; border-radius:10px; padding:0.7rem 1rem; margin:0.3rem 0;'>
|
| 572 |
+
<div style='color:#6c47ff; font-weight:700; font-size:0.9rem;'>{term['term']}</div>
|
| 573 |
+
<div style='color:#6b6880; font-size:0.82rem; margin-top:0.2rem;'>{term['definition']}</div>
|
| 574 |
+
</div>
|
| 575 |
+
""", unsafe_allow_html=True)
|
| 576 |
+
|
| 577 |
+
if data.get("summary"):
|
| 578 |
+
st.markdown("### π‘ Quick Summary")
|
| 579 |
+
st.info(data["summary"])
|
| 580 |
+
|
| 581 |
+
st.markdown("---")
|
| 582 |
+
col_note, col_quiz, col_flash = st.columns(3)
|
| 583 |
+
with col_note:
|
| 584 |
+
st.markdown("##### π Save a Note")
|
| 585 |
+
note_text = st.text_area("Note", placeholder="Something to rememberβ¦", height=75, label_visibility="collapsed")
|
| 586 |
+
if st.button("πΎ Save Note"):
|
| 587 |
+
if note_text.strip():
|
| 588 |
+
save_note(note_text, data["topic"])
|
| 589 |
+
# Badge for first note
|
| 590 |
+
gami = st.session_state.gami
|
| 591 |
+
new_b = check_and_award_badges(gami, {"event": "note_saved"})
|
| 592 |
+
if new_b:
|
| 593 |
+
save_gamification(gami)
|
| 594 |
+
st.session_state.gami = gami
|
| 595 |
+
st.session_state.new_badges = new_b
|
| 596 |
+
st.success("Note saved!")
|
| 597 |
+
else:
|
| 598 |
+
st.warning("Write something first.")
|
| 599 |
+
with col_quiz:
|
| 600 |
+
st.markdown("##### π§© Test Yourself")
|
| 601 |
+
st.markdown("<div style='color:#6b6880; font-size:0.88rem; margin-bottom:0.6rem;'>Take a quiz on this exact topic.</div>", unsafe_allow_html=True)
|
| 602 |
+
if st.button("π§© Start Quiz on This Topic", use_container_width=True):
|
| 603 |
+
st.session_state.page = "quiz"
|
| 604 |
+
st.session_state.quiz_topic = data["topic"]
|
| 605 |
+
st.session_state.quiz_level = data["level"]
|
| 606 |
+
st.session_state.submitted = False
|
| 607 |
+
st.rerun()
|
| 608 |
+
with col_flash:
|
| 609 |
+
st.markdown("##### π Flashcard Mode")
|
| 610 |
+
st.markdown("<div style='color:#6b6880; font-size:0.88rem; margin-bottom:0.6rem;'>Generate flip-cards for quick revision.</div>", unsafe_allow_html=True)
|
| 611 |
+
if st.button("π Generate Flashcards", use_container_width=True):
|
| 612 |
+
st.session_state.fc_topic = data["topic"]
|
| 613 |
+
st.session_state.fc_level = data["level"]
|
| 614 |
+
st.session_state.page = "flashcards"
|
| 615 |
+
st.rerun()
|
| 616 |
+
|
| 617 |
+
# PDF Export
|
| 618 |
+
st.markdown("---")
|
| 619 |
+
st.markdown("##### π Export as PDF")
|
| 620 |
+
if st.button("β¬οΈ Download Study Notes PDF", use_container_width=True):
|
| 621 |
+
with st.spinner("Generating PDFβ¦"):
|
| 622 |
+
pdf_bytes = export_study_notes_pdf(data)
|
| 623 |
+
st.download_button(
|
| 624 |
+
label="π₯ Click to Download PDF",
|
| 625 |
+
data=pdf_bytes,
|
| 626 |
+
file_name=f"LearnCraft_{data['topic'].replace(' ','_')}_Notes.pdf",
|
| 627 |
+
mime="application/pdf",
|
| 628 |
+
use_container_width=True,
|
| 629 |
+
)
|
| 630 |
+
|
| 631 |
+
# ββββββββββββββββββββββ FLASHCARDS ββββββββββββββββββββββββββββββββββββββββββ
|
| 632 |
+
elif page == "flashcards":
|
| 633 |
+
st.markdown("## π Flashcard Studio")
|
| 634 |
+
st.markdown("<div style='color:#6b6880; margin-bottom:1rem;'>Click any card to flip it and reveal the answer.</div>", unsafe_allow_html=True)
|
| 635 |
+
|
| 636 |
+
col1, col2 = st.columns([2, 1])
|
| 637 |
+
with col1:
|
| 638 |
+
default_topic = getattr(st.session_state, "fc_topic", "")
|
| 639 |
+
fc_topic = st.text_input("π Topic", value=default_topic, placeholder="e.g. Quantum Mechanics, World War IIβ¦")
|
| 640 |
+
with col2:
|
| 641 |
+
default_level = getattr(st.session_state, "fc_level", "Intermediate")
|
| 642 |
+
fc_level = st.selectbox("π Level", ["Beginner", "Intermediate", "Advanced"],
|
| 643 |
+
index=["Beginner", "Intermediate", "Advanced"].index(default_level))
|
| 644 |
+
num_cards = st.slider("Number of Cards", min_value=5, max_value=20, value=10)
|
| 645 |
+
|
| 646 |
+
if st.button("π Generate Flashcards", use_container_width=True):
|
| 647 |
+
if not fc_topic.strip():
|
| 648 |
+
st.warning("Please enter a topic.")
|
| 649 |
+
else:
|
| 650 |
+
with st.spinner("Creating your flashcard setβ¦"):
|
| 651 |
+
cards = generate_flashcards(fc_topic, fc_level, num_cards)
|
| 652 |
+
st.session_state.flashcards = cards
|
| 653 |
+
st.session_state.fc_index = 0
|
| 654 |
+
st.session_state.fc_flipped = False
|
| 655 |
+
# Gamification: XP for flashcard deck
|
| 656 |
+
gami = update_streak(st.session_state.gami)
|
| 657 |
+
gami, xp_amt, lvl_up = award_xp(gami, XP_FLASHCARD_DECK, "flashcard_deck")
|
| 658 |
+
new_b = check_and_award_badges(gami, {"event": "flashcards"})
|
| 659 |
+
save_gamification(gami)
|
| 660 |
+
st.session_state.gami = gami
|
| 661 |
+
if new_b:
|
| 662 |
+
st.session_state.new_badges = new_b
|
| 663 |
+
if lvl_up:
|
| 664 |
+
st.session_state.level_up_msg = lvl_up
|
| 665 |
+
|
| 666 |
+
cards = st.session_state.flashcards
|
| 667 |
+
if cards:
|
| 668 |
+
st.markdown("---")
|
| 669 |
+
idx = st.session_state.fc_index
|
| 670 |
+
total_fc = len(cards)
|
| 671 |
+
card = cards[idx]
|
| 672 |
+
flipped = st.session_state.fc_flipped
|
| 673 |
+
|
| 674 |
+
# Progress
|
| 675 |
+
st.markdown(f"""
|
| 676 |
+
<div style='display:flex; justify-content:space-between; align-items:center; margin-bottom:0.8rem;'>
|
| 677 |
+
<div style='color:#6b6880; font-size:0.88rem; font-weight:600;'>Card {idx+1} of {total_fc}</div>
|
| 678 |
+
<div style='color:#6c47ff; font-size:0.88rem; font-weight:600;'>{round((idx+1)/total_fc*100)}% through deck</div>
|
| 679 |
+
</div>
|
| 680 |
+
""", unsafe_allow_html=True)
|
| 681 |
+
st.progress((idx + 1) / total_fc)
|
| 682 |
+
|
| 683 |
+
# Flip card (CSS-based)
|
| 684 |
+
flipped_class = "flipped" if flipped else ""
|
| 685 |
+
st.markdown(f"""
|
| 686 |
+
<div class="flip-card {flipped_class}" id="fc-main" onclick="this.classList.toggle('flipped')">
|
| 687 |
+
<div class="flip-inner">
|
| 688 |
+
<div class="flip-front">
|
| 689 |
+
<div>
|
| 690 |
+
<div style='font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:#6b6880; margin-bottom:0.5rem;'>QUESTION</div>
|
| 691 |
+
<div style='font-size:1.05rem; font-weight:600; color:#1a1523;'>{card['front']}</div>
|
| 692 |
+
</div>
|
| 693 |
+
</div>
|
| 694 |
+
<div class="flip-back">
|
| 695 |
+
<div>
|
| 696 |
+
<div style='font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:rgba(255,255,255,0.7); margin-bottom:0.5rem;'>ANSWER</div>
|
| 697 |
+
<div style='font-size:0.95rem;'>{card['back']}</div>
|
| 698 |
+
</div>
|
| 699 |
+
</div>
|
| 700 |
+
</div>
|
| 701 |
+
</div>
|
| 702 |
+
""", unsafe_allow_html=True)
|
| 703 |
+
|
| 704 |
+
st.markdown("")
|
| 705 |
+
nav1, nav2, nav3 = st.columns([1, 2, 1])
|
| 706 |
+
with nav1:
|
| 707 |
+
if st.button("β¬
Previous", use_container_width=True, disabled=(idx == 0)):
|
| 708 |
+
st.session_state.fc_index = idx - 1
|
| 709 |
+
st.session_state.fc_flipped = False
|
| 710 |
+
st.rerun()
|
| 711 |
+
with nav2:
|
| 712 |
+
if st.button("π Flip Card", use_container_width=True):
|
| 713 |
+
st.session_state.fc_flipped = not st.session_state.fc_flipped
|
| 714 |
+
st.rerun()
|
| 715 |
+
with nav3:
|
| 716 |
+
if st.button("Next β‘", use_container_width=True, disabled=(idx == total_fc - 1)):
|
| 717 |
+
st.session_state.fc_index = idx + 1
|
| 718 |
+
st.session_state.fc_flipped = False
|
| 719 |
+
st.rerun()
|
| 720 |
+
|
| 721 |
+
# Mini deck overview
|
| 722 |
+
st.markdown("---")
|
| 723 |
+
st.markdown("### π All Cards in This Deck")
|
| 724 |
+
for i, c in enumerate(cards):
|
| 725 |
+
bg = "#ede9fe" if i == idx else "#fff"
|
| 726 |
+
bd = "#6c47ff" if i == idx else "#e2ddf5"
|
| 727 |
+
st.markdown(f"""
|
| 728 |
+
<div style='background:{bg}; border:1.5px solid {bd}; border-radius:10px; padding:0.6rem 1rem; margin:0.3rem 0; display:flex; justify-content:space-between;'>
|
| 729 |
+
<div style='color:#1a1523; font-size:0.88rem; font-weight:600;'>{i+1}. {c['front']}</div>
|
| 730 |
+
</div>
|
| 731 |
+
""", unsafe_allow_html=True)
|
| 732 |
+
|
| 733 |
+
# ββββββββββββββββββββββ QUIZ ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 734 |
+
elif page == "quiz":
|
| 735 |
+
st.markdown("## π§© Quiz Generator")
|
| 736 |
+
|
| 737 |
+
if not st.session_state.submitted:
|
| 738 |
+
st.markdown("<div style='color:#6b6880; margin-bottom:1rem;'>Configure your quiz, then test your knowledge!</div>", unsafe_allow_html=True)
|
| 739 |
+
|
| 740 |
+
col1, col2 = st.columns([2, 1])
|
| 741 |
+
with col1:
|
| 742 |
+
default_topic = getattr(st.session_state, "quiz_topic", "")
|
| 743 |
+
topic = st.text_input("π Topic", value=default_topic, placeholder="e.g. Machine Learning, Calculusβ¦")
|
| 744 |
+
with col2:
|
| 745 |
+
default_level = getattr(st.session_state, "quiz_level", "Intermediate")
|
| 746 |
+
level = st.selectbox("π Difficulty", ["Beginner", "Intermediate", "Advanced"],
|
| 747 |
+
index=["Beginner", "Intermediate", "Advanced"].index(default_level))
|
| 748 |
+
|
| 749 |
+
col3, col4, col5 = st.columns(3)
|
| 750 |
+
with col3:
|
| 751 |
+
num_q = st.number_input("Number of Questions", min_value=3, max_value=15, value=5)
|
| 752 |
+
with col4:
|
| 753 |
+
q_type = st.selectbox("Question Type", ["Mixed", "Multiple Choice", "True/False", "Short Answer", "Fill in the Blank"])
|
| 754 |
+
with col5:
|
| 755 |
+
time_limit = st.selectbox("β±οΈ Time Limit", ["No limit", "5 minutes", "10 minutes", "15 minutes"])
|
| 756 |
+
|
| 757 |
+
if st.button("π² Generate Quiz", use_container_width=True):
|
| 758 |
+
if not topic.strip():
|
| 759 |
+
st.warning("Please enter a topic.")
|
| 760 |
+
else:
|
| 761 |
+
with st.spinner("Building your quizβ¦"):
|
| 762 |
+
quiz = generate_quiz(topic, level, num_q, q_type)
|
| 763 |
+
st.session_state.quiz = quiz
|
| 764 |
+
st.session_state.answers = {}
|
| 765 |
+
st.session_state.submitted = False
|
| 766 |
+
st.session_state.quiz_start_time = time.time()
|
| 767 |
+
st.session_state.time_limit = time_limit
|
| 768 |
+
st.session_state.quiz_elapsed = None
|
| 769 |
+
|
| 770 |
+
if st.session_state.quiz and not st.session_state.submitted:
|
| 771 |
+
quiz = st.session_state.quiz
|
| 772 |
+
|
| 773 |
+
if st.session_state.quiz_start_time:
|
| 774 |
+
elapsed = int(time.time() - st.session_state.quiz_start_time)
|
| 775 |
+
limit = st.session_state.get("time_limit", "No limit")
|
| 776 |
+
if limit != "No limit":
|
| 777 |
+
limit_secs = int(limit.split()[0]) * 60
|
| 778 |
+
remaining = limit_secs - elapsed
|
| 779 |
+
if remaining <= 0:
|
| 780 |
+
st.error("β±οΈ Time's up! Submittingβ¦")
|
| 781 |
+
st.session_state.quiz_elapsed = elapsed
|
| 782 |
+
st.session_state.submitted = True
|
| 783 |
+
st.rerun()
|
| 784 |
+
else:
|
| 785 |
+
r_m, r_s = divmod(remaining, 60)
|
| 786 |
+
st.info(f"β±οΈ Time remaining: {r_m}m {r_s}s")
|
| 787 |
+
else:
|
| 788 |
+
m, s = divmod(elapsed, 60)
|
| 789 |
+
st.info(f"β±οΈ Elapsed: {m}m {s}s")
|
| 790 |
+
|
| 791 |
+
st.markdown("---")
|
| 792 |
+
st.markdown(f"### π {quiz['title']}")
|
| 793 |
+
st.markdown(f"<div style='color:#6b6880; margin-bottom:1.5rem;'>{len(quiz['questions'])} questions Β· {quiz['difficulty']} Β· {quiz['topic']}</div>", unsafe_allow_html=True)
|
| 794 |
+
|
| 795 |
+
for i, q in enumerate(quiz["questions"]):
|
| 796 |
+
st.markdown(f"""
|
| 797 |
+
<div class='learn-card'>
|
| 798 |
+
<div style='color:#6b6880; font-size:0.75rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; margin-bottom:0.5rem;'>Question {i+1} Β· {q['type']}</div>
|
| 799 |
+
<div style='font-size:1.02rem; font-weight:600; color:#1a1523; margin-bottom:1rem;'>{q['question']}</div>
|
| 800 |
+
</div>
|
| 801 |
+
""", unsafe_allow_html=True)
|
| 802 |
+
|
| 803 |
+
if q["type"] in ("Multiple Choice", "True/False"):
|
| 804 |
+
ans = st.radio(f"Answer Q{i+1}", q["options"], key=f"q_{i}", label_visibility="collapsed")
|
| 805 |
+
st.session_state.answers[i] = ans
|
| 806 |
+
elif q["type"] == "Fill in the Blank":
|
| 807 |
+
ans = st.text_input(f"Blank Q{i+1}", key=f"q_{i}", label_visibility="collapsed", placeholder="Type the missing wordβ¦")
|
| 808 |
+
st.session_state.answers[i] = ans
|
| 809 |
+
else:
|
| 810 |
+
ans = st.text_input(f"Answer Q{i+1}", key=f"q_{i}", label_visibility="collapsed", placeholder="Type your answerβ¦")
|
| 811 |
+
st.session_state.answers[i] = ans
|
| 812 |
+
|
| 813 |
+
st.markdown("")
|
| 814 |
+
if st.button("β
Submit Quiz", use_container_width=True):
|
| 815 |
+
st.session_state.quiz_elapsed = int(time.time() - st.session_state.quiz_start_time)
|
| 816 |
+
st.session_state.submitted = True
|
| 817 |
+
st.rerun()
|
| 818 |
+
|
| 819 |
+
else:
|
| 820 |
+
# ββ Results ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 821 |
+
quiz = st.session_state.quiz
|
| 822 |
+
results = evaluate_answers(quiz, st.session_state.answers)
|
| 823 |
+
score = results["score_percent"]
|
| 824 |
+
elapsed = st.session_state.get("quiz_elapsed", 0) or 0
|
| 825 |
+
em, es = divmod(elapsed, 60)
|
| 826 |
+
|
| 827 |
+
st.markdown(f"""
|
| 828 |
+
<div class='hero'>
|
| 829 |
+
<div class='tag'>Quiz Complete</div>
|
| 830 |
+
<div class='score-badge'>{score}%</div>
|
| 831 |
+
<div style='color:rgba(255,255,255,0.85); margin-top:0.4rem;'>{results['correct']} / {results['total']} correct</div>
|
| 832 |
+
<div style='color:rgba(255,255,255,0.7); font-size:0.9rem; margin-top:0.3rem;'>β±οΈ Completed in {em}m {es}s</div>
|
| 833 |
+
<div style='margin-top:1rem; font-size:1.1rem; color:#fff;'>{results['feedback']}</div>
|
| 834 |
+
</div>
|
| 835 |
+
""", unsafe_allow_html=True)
|
| 836 |
+
|
| 837 |
+
save_progress(st.session_state.progress, score=score, topic=quiz["topic"])
|
| 838 |
+
|
| 839 |
+
# Gamification: award XP for quiz
|
| 840 |
+
gami = update_streak(st.session_state.gami)
|
| 841 |
+
xp_for_quiz = get_xp_for_quiz(score)
|
| 842 |
+
gami, xp_amt, lvl_up = award_xp(gami, xp_for_quiz)
|
| 843 |
+
gami = record_quiz(gami, score, len(set(st.session_state.progress.get("topics_studied", []))))
|
| 844 |
+
topics_count = len(set(st.session_state.progress.get("topics_studied", [])))
|
| 845 |
+
quizzes_count = gami.get("total_quizzes", 0)
|
| 846 |
+
new_b = check_and_award_badges(gami, {
|
| 847 |
+
"score": score, "topics_count": topics_count,
|
| 848 |
+
"quizzes_count": quizzes_count, "event": "quiz",
|
| 849 |
+
})
|
| 850 |
+
save_gamification(gami)
|
| 851 |
+
st.session_state.gami = gami
|
| 852 |
+
if new_b:
|
| 853 |
+
st.session_state.new_badges = new_b
|
| 854 |
+
if lvl_up:
|
| 855 |
+
st.session_state.level_up_msg = lvl_up
|
| 856 |
+
|
| 857 |
+
# Show XP toast
|
| 858 |
+
st.markdown(f"""
|
| 859 |
+
<div class='toast'>
|
| 860 |
+
<div style='font-size:1.5rem;'>β‘</div>
|
| 861 |
+
<div>
|
| 862 |
+
<div style='font-weight:700; font-size:0.95rem;'>+{xp_for_quiz} XP earned!</div>
|
| 863 |
+
<div style='font-size:0.82rem; opacity:0.85;'>Total: {gami.get("xp",0)} XP Β· {get_level(gami.get("xp",0))[0]}</div>
|
| 864 |
+
</div>
|
| 865 |
+
</div>
|
| 866 |
+
""", unsafe_allow_html=True)
|
| 867 |
+
|
| 868 |
+
# Show new badges
|
| 869 |
+
if st.session_state.new_badges:
|
| 870 |
+
for bk in st.session_state.new_badges:
|
| 871 |
+
b = BADGES.get(bk, {})
|
| 872 |
+
st.markdown(f"""
|
| 873 |
+
<div class='toast' style='background:linear-gradient(135deg,#f97316,#fbbf24);'>
|
| 874 |
+
<div style='font-size:1.5rem;'>{b.get("icon","π
")}</div>
|
| 875 |
+
<div>
|
| 876 |
+
<div style='font-weight:700; font-size:0.95rem;'>Badge Unlocked: {b.get("name","")}</div>
|
| 877 |
+
<div style='font-size:0.82rem; opacity:0.85;'>{b.get("desc","")}</div>
|
| 878 |
+
</div>
|
| 879 |
+
</div>
|
| 880 |
+
""", unsafe_allow_html=True)
|
| 881 |
+
st.session_state.new_badges = []
|
| 882 |
+
|
| 883 |
+
if lvl_up:
|
| 884 |
+
st.balloons()
|
| 885 |
+
st.success(f"π Level Up! You reached **{lvl_up}**!")
|
| 886 |
+
st.session_state.level_up_msg = None
|
| 887 |
+
|
| 888 |
+
if score < 60:
|
| 889 |
+
st.warning(f"π Score below 60%. We recommend revisiting **{quiz['topic']}**.")
|
| 890 |
+
|
| 891 |
+
# Score meter
|
| 892 |
+
col_meter = st.columns([1, 2, 1])[1]
|
| 893 |
+
with col_meter:
|
| 894 |
+
level_label = "Excellent π" if score == 100 else "Great π" if score >= 80 else "Good π" if score >= 60 else "Fair π" if score >= 40 else "Keep Going πͺ"
|
| 895 |
+
st.markdown(f"<div style='text-align:center; color:#6c47ff; font-weight:700; margin-bottom:0.3rem;'>{level_label}</div>", unsafe_allow_html=True)
|
| 896 |
+
st.progress(score / 100)
|
| 897 |
+
|
| 898 |
+
st.markdown("### π Answer Review")
|
| 899 |
+
for i, q in enumerate(quiz["questions"]):
|
| 900 |
+
correct = results["details"][i]["correct"]
|
| 901 |
+
user_ans = st.session_state.answers.get(i, "")
|
| 902 |
+
color = "#10b981" if correct else "#ef4444"
|
| 903 |
+
icon = "β
" if correct else "β"
|
| 904 |
+
st.markdown(f"""
|
| 905 |
+
<div style='background:#fff; border:1px solid {color}33; border-left:4px solid {color}; border-radius:0 14px 14px 0; padding:1.2rem 1.5rem; margin:0.6rem 0; box-shadow:0 2px 8px rgba(0,0,0,0.04);'>
|
| 906 |
+
<div style='font-weight:700; margin-bottom:0.5rem; color:#1a1523;'>{icon} Q{i+1}: {q['question']}</div>
|
| 907 |
+
<div style='font-size:0.88rem; color:#6b6880;'>Your answer: <span style='color:{color}; font-weight:600;'>{user_ans if user_ans else "No answer"}</span></div>
|
| 908 |
+
<div style='font-size:0.88rem; color:#6b6880;'>Correct: <span style='color:#10b981; font-weight:600;'>{results["details"][i]["correct_answer"]}</span></div>
|
| 909 |
+
{f'<div style="font-size:0.82rem; color:#6b6880; margin-top:0.35rem; font-style:italic;">{results["details"][i]["explanation"]}</div>' if results["details"][i].get("explanation") else ""}
|
| 910 |
+
</div>
|
| 911 |
+
""", unsafe_allow_html=True)
|
| 912 |
+
|
| 913 |
+
st.markdown("")
|
| 914 |
+
c1, c2, c3 = st.columns(3)
|
| 915 |
+
with c1:
|
| 916 |
+
if st.button("π Retake Quiz", use_container_width=True):
|
| 917 |
+
st.session_state.submitted = False
|
| 918 |
+
st.session_state.answers = {}
|
| 919 |
+
st.session_state.quiz_start_time = time.time()
|
| 920 |
+
st.rerun()
|
| 921 |
+
with c2:
|
| 922 |
+
if st.button("π Study This Topic", use_container_width=True):
|
| 923 |
+
st.session_state.page = "study"
|
| 924 |
+
st.session_state.submitted = False
|
| 925 |
+
st.rerun()
|
| 926 |
+
with c3:
|
| 927 |
+
if st.button("π Flashcard Revision", use_container_width=True):
|
| 928 |
+
st.session_state.fc_topic = quiz["topic"]
|
| 929 |
+
st.session_state.fc_level = quiz["difficulty"]
|
| 930 |
+
st.session_state.page = "flashcards"
|
| 931 |
+
st.session_state.submitted = False
|
| 932 |
+
st.rerun()
|
| 933 |
+
|
| 934 |
+
# PDF Export for quiz results
|
| 935 |
+
st.markdown("---")
|
| 936 |
+
if st.button("β¬οΈ Download Quiz Results PDF", use_container_width=True):
|
| 937 |
+
with st.spinner("Generating PDFβ¦"):
|
| 938 |
+
pdf_bytes = export_quiz_results_pdf(quiz, results, st.session_state.answers)
|
| 939 |
+
st.download_button(
|
| 940 |
+
label="π₯ Click to Download Results PDF",
|
| 941 |
+
data=pdf_bytes,
|
| 942 |
+
file_name=f"LearnCraft_{quiz['topic'].replace(' ','_')}_Results.pdf",
|
| 943 |
+
mime="application/pdf",
|
| 944 |
+
use_container_width=True,
|
| 945 |
+
)
|
| 946 |
+
|
| 947 |
+
# ββββββββββββββββββββββ PROGRESS ββββββββββββββββββββββββββββββββββββββββββββ
|
| 948 |
+
elif page == "progress":
|
| 949 |
+
st.markdown("## π My Learning Progress")
|
| 950 |
+
progress = st.session_state.progress
|
| 951 |
+
scores = progress.get("scores", [])
|
| 952 |
+
sessions = progress.get("sessions", [])
|
| 953 |
+
|
| 954 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 955 |
+
topics_list = list(set(progress.get("topics_studied", [])))
|
| 956 |
+
avg = round(sum(scores)/len(scores), 1) if scores else 0
|
| 957 |
+
c1.metric("π Topics Studied", len(topics_list))
|
| 958 |
+
c2.metric("π Best Score", f"{progress.get('best_score', 0)}%")
|
| 959 |
+
c3.metric("π Avg Score", f"{avg}%")
|
| 960 |
+
c4.metric("π― Total Quizzes", len(scores))
|
| 961 |
+
|
| 962 |
+
st.markdown("---")
|
| 963 |
+
|
| 964 |
+
if sessions:
|
| 965 |
+
st.markdown("### π Score History")
|
| 966 |
+
df = pd.DataFrame(sessions)
|
| 967 |
+
df.index = range(1, len(df) + 1)
|
| 968 |
+
df.index.name = "Quiz #"
|
| 969 |
+
st.line_chart(df[["score"]].rename(columns={"score": "Score (%)"}), color="#6c47ff")
|
| 970 |
+
|
| 971 |
+
topic_scores = progress.get("topic_scores", {})
|
| 972 |
+
if topic_scores:
|
| 973 |
+
st.markdown("### π
Best Score Per Topic")
|
| 974 |
+
ts_df = pd.DataFrame([
|
| 975 |
+
{"Topic": t, "Best Score (%)": s, "Status": "β
Passing" if s >= 60 else "β οΈ Needs Review"}
|
| 976 |
+
for t, s in sorted(topic_scores.items(), key=lambda x: -x[1])
|
| 977 |
+
])
|
| 978 |
+
st.dataframe(ts_df, use_container_width=True, hide_index=True)
|
| 979 |
+
|
| 980 |
+
weak = get_weak_topics(progress)
|
| 981 |
+
if weak:
|
| 982 |
+
st.markdown("### β οΈ Topics to Improve")
|
| 983 |
+
for t in weak:
|
| 984 |
+
st.markdown(f"""
|
| 985 |
+
<div style='background:#fff; border:1px solid #ef444433; border-left:4px solid #ef4444; border-radius:0 12px 12px 0; padding:0.65rem 1.1rem; margin:0.35rem 0; color:#ef4444; font-weight:600;'>
|
| 986 |
+
β οΈ {t} β below 60%
|
| 987 |
+
</div>
|
| 988 |
+
""", unsafe_allow_html=True)
|
| 989 |
+
|
| 990 |
+
if topics_list:
|
| 991 |
+
st.markdown("### ποΈ Topics Covered")
|
| 992 |
+
cols = st.columns(3)
|
| 993 |
+
for i, t in enumerate(topics_list):
|
| 994 |
+
with cols[i % 3]:
|
| 995 |
+
best = topic_scores.get(t, 0)
|
| 996 |
+
color = "#10b981" if best >= 80 else "#f97316" if best >= 60 else "#ef4444"
|
| 997 |
+
st.markdown(f"""
|
| 998 |
+
<div style='background:#fff; border:1px solid #e2ddf5; border-radius:10px; padding:0.7rem 1rem; margin:0.3rem 0; display:flex; justify-content:space-between; align-items:center;'>
|
| 999 |
+
<div style='color:#1a1523; font-weight:600; font-size:0.9rem;'>π {t}</div>
|
| 1000 |
+
<div style='color:{color}; font-weight:700; font-size:0.9rem;'>{best}%</div>
|
| 1001 |
+
</div>
|
| 1002 |
+
""", unsafe_allow_html=True)
|
| 1003 |
+
|
| 1004 |
+
if not topics_list and not scores:
|
| 1005 |
+
st.markdown("""
|
| 1006 |
+
<div style='text-align:center; padding:4rem 2rem; color:#6b6880;'>
|
| 1007 |
+
<div style='font-size:3rem; margin-bottom:1rem;'>π±</div>
|
| 1008 |
+
<div style='font-size:1.2rem;'>No activity yet. Start learning to see your progress!</div>
|
| 1009 |
+
</div>
|
| 1010 |
+
""", unsafe_allow_html=True)
|
| 1011 |
+
if st.button("π Start Learning Now", use_container_width=True):
|
| 1012 |
+
st.session_state.page = "study"; st.rerun()
|
| 1013 |
+
|
| 1014 |
+
st.markdown("")
|
| 1015 |
+
if st.button("ποΈ Reset All Progress", type="secondary"):
|
| 1016 |
+
st.session_state.progress = {"topics_studied": [], "scores": [], "best_score": 0, "sessions": [], "topic_scores": {}}
|
| 1017 |
+
save_progress(st.session_state.progress)
|
| 1018 |
+
st.success("Progress reset.")
|
| 1019 |
+
st.rerun()
|
| 1020 |
+
|
| 1021 |
+
# ββββββββββββββββββββββ NOTES βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1022 |
+
elif page == "notes":
|
| 1023 |
+
st.markdown("## π My Notes")
|
| 1024 |
+
st.markdown("<div style='color:#6b6880; margin-bottom:1rem;'>Notes you've saved while studying.</div>", unsafe_allow_html=True)
|
| 1025 |
+
|
| 1026 |
+
notes = load_notes()
|
| 1027 |
+
|
| 1028 |
+
if not notes:
|
| 1029 |
+
st.markdown("""
|
| 1030 |
+
<div style='text-align:center; padding:4rem 2rem; color:#6b6880;'>
|
| 1031 |
+
<div style='font-size:3rem; margin-bottom:1rem;'>π</div>
|
| 1032 |
+
<div style='font-size:1.2rem;'>No notes yet. Save notes from the Study page!</div>
|
| 1033 |
+
</div>
|
| 1034 |
+
""", unsafe_allow_html=True)
|
| 1035 |
+
if st.button("π Go to Study", use_container_width=True):
|
| 1036 |
+
st.session_state.page = "study"; st.rerun()
|
| 1037 |
+
else:
|
| 1038 |
+
# Search filter
|
| 1039 |
+
search = st.text_input("π Search notes", placeholder="Filter by keywordβ¦")
|
| 1040 |
+
filtered = [n for n in reversed(notes) if not search or search.lower() in n.get("note","").lower() or search.lower() in n.get("topic","").lower()]
|
| 1041 |
+
st.markdown(f"**{len(filtered)} note(s)**")
|
| 1042 |
+
|
| 1043 |
+
for i, note in enumerate(filtered):
|
| 1044 |
+
actual_index = notes.index(note) if note in notes else -1
|
| 1045 |
+
col_note, col_del = st.columns([11, 1])
|
| 1046 |
+
with col_note:
|
| 1047 |
+
st.markdown(f"""
|
| 1048 |
+
<div style='background:#fff; border:1px solid #e2ddf5; border-left:4px solid #6c47ff; border-radius:0 14px 14px 0; padding:1rem 1.5rem; margin:0.5rem 0; box-shadow:0 2px 8px rgba(108,71,255,0.06);'>
|
| 1049 |
+
<div style='color:#6c47ff; font-size:0.78rem; font-weight:700; margin-bottom:0.4rem;'>π {note.get("topic","Unknown")} Β· {note.get("date","")}</div>
|
| 1050 |
+
<div style='color:#1a1523; font-size:0.95rem; line-height:1.65;'>{note.get("note","")}</div>
|
| 1051 |
+
</div>
|
| 1052 |
+
""", unsafe_allow_html=True)
|
| 1053 |
+
with col_del:
|
| 1054 |
+
if st.button("ποΈ", key=f"del_{i}", help="Delete note"):
|
| 1055 |
+
if actual_index >= 0:
|
| 1056 |
+
delete_note(actual_index)
|
| 1057 |
+
st.rerun()
|
| 1058 |
+
|
| 1059 |
+
# ββββββββββββββββββββββ AI TUTOR βββββββββββββββββββββββββββββββββββββββββββββ
|
| 1060 |
+
elif page == "tutor":
|
| 1061 |
+
st.markdown("## π€ AI Tutor")
|
| 1062 |
+
st.markdown("<div style='color:#6b6880; margin-bottom:1rem;'>Ask anything about any topic. Your tutor remembers the full conversation.</div>", unsafe_allow_html=True)
|
| 1063 |
+
|
| 1064 |
+
# Topic context selector
|
| 1065 |
+
col_t, col_c = st.columns([3, 1])
|
| 1066 |
+
with col_t:
|
| 1067 |
+
tutor_topic = st.text_input(
|
| 1068 |
+
"π Topic context (optional)",
|
| 1069 |
+
value=st.session_state.tutor_topic,
|
| 1070 |
+
placeholder="e.g. Quantum Mechanics β helps the tutor stay focused",
|
| 1071 |
+
)
|
| 1072 |
+
st.session_state.tutor_topic = tutor_topic
|
| 1073 |
+
with col_c:
|
| 1074 |
+
st.markdown("<div style='height:1.85rem'></div>", unsafe_allow_html=True)
|
| 1075 |
+
if st.button("ποΈ Clear Chat", use_container_width=True):
|
| 1076 |
+
st.session_state.tutor_messages = []
|
| 1077 |
+
st.rerun()
|
| 1078 |
+
|
| 1079 |
+
st.markdown("---")
|
| 1080 |
+
|
| 1081 |
+
# Render conversation history
|
| 1082 |
+
msgs = st.session_state.tutor_messages
|
| 1083 |
+
if not msgs:
|
| 1084 |
+
st.markdown("""
|
| 1085 |
+
<div style='text-align:center; padding:3rem 2rem; color:#6b6880;'>
|
| 1086 |
+
<div style='font-size:3rem; margin-bottom:0.75rem;'>π€</div>
|
| 1087 |
+
<div style='font-size:1.1rem; font-weight:600; color:#1a1523; margin-bottom:0.4rem;'>Hi! I'm your LearnCraft Tutor.</div>
|
| 1088 |
+
<div style='font-size:0.95rem;'>Ask me anything about your topic β concepts, examples, quick tests, or explanations.</div>
|
| 1089 |
+
</div>
|
| 1090 |
+
""", unsafe_allow_html=True)
|
| 1091 |
+
|
| 1092 |
+
# Quick-start prompts
|
| 1093 |
+
st.markdown("#### π‘ Try asking:")
|
| 1094 |
+
prompts = [
|
| 1095 |
+
"Explain this topic like I'm 10 years old",
|
| 1096 |
+
"Give me 3 real-world examples",
|
| 1097 |
+
"What are the most common mistakes beginners make?",
|
| 1098 |
+
"Quiz me with one question",
|
| 1099 |
+
]
|
| 1100 |
+
p_cols = st.columns(2)
|
| 1101 |
+
for i, prompt in enumerate(prompts):
|
| 1102 |
+
with p_cols[i % 2]:
|
| 1103 |
+
if st.button(f'"{prompt}"', key=f"prompt_{i}", use_container_width=True):
|
| 1104 |
+
full_prompt = prompt + (f" about {tutor_topic}" if tutor_topic else "")
|
| 1105 |
+
st.session_state.tutor_messages.append({"role": "user", "content": full_prompt})
|
| 1106 |
+
with st.spinner("Thinkingβ¦"):
|
| 1107 |
+
reply = get_tutor_reply(st.session_state.tutor_messages, tutor_topic)
|
| 1108 |
+
st.session_state.tutor_messages.append({"role": "assistant", "content": reply})
|
| 1109 |
+
st.rerun()
|
| 1110 |
+
else:
|
| 1111 |
+
for msg in msgs:
|
| 1112 |
+
if msg["role"] == "user":
|
| 1113 |
+
st.markdown(f"""
|
| 1114 |
+
<div style='text-align:right; margin-bottom:0.15rem;'>
|
| 1115 |
+
<span style='font-size:0.72rem; font-weight:700; color:#6b6880; text-transform:uppercase; letter-spacing:0.05em;'>You</span>
|
| 1116 |
+
</div>
|
| 1117 |
+
<div class='chat-user'>{msg['content']}</div>
|
| 1118 |
+
""", unsafe_allow_html=True)
|
| 1119 |
+
else:
|
| 1120 |
+
st.markdown(f"""
|
| 1121 |
+
<div style='margin-bottom:0.15rem;'>
|
| 1122 |
+
<span style='font-size:0.72rem; font-weight:700; color:#6c47ff; text-transform:uppercase; letter-spacing:0.05em;'>π€ Tutor</span>
|
| 1123 |
+
</div>
|
| 1124 |
+
<div class='chat-ai'>{msg['content']}</div>
|
| 1125 |
+
""", unsafe_allow_html=True)
|
| 1126 |
+
|
| 1127 |
+
# Input box
|
| 1128 |
+
st.markdown("<div style='height:1rem'></div>", unsafe_allow_html=True)
|
| 1129 |
+
with st.form("chat_form", clear_on_submit=True):
|
| 1130 |
+
user_input = st.text_input(
|
| 1131 |
+
"Your question",
|
| 1132 |
+
placeholder="Ask your tutor anythingβ¦",
|
| 1133 |
+
label_visibility="collapsed",
|
| 1134 |
+
)
|
| 1135 |
+
submitted = st.form_submit_button("Send β€", use_container_width=True)
|
| 1136 |
+
|
| 1137 |
+
if submitted and user_input.strip():
|
| 1138 |
+
st.session_state.tutor_messages.append({"role": "user", "content": user_input.strip()})
|
| 1139 |
+
with st.spinner("Tutor is thinkingβ¦"):
|
| 1140 |
+
reply = get_tutor_reply(st.session_state.tutor_messages, tutor_topic)
|
| 1141 |
+
st.session_state.tutor_messages.append({"role": "assistant", "content": reply})
|
| 1142 |
+
st.rerun()
|
| 1143 |
+
|
| 1144 |
+
# ββββββββββββββββββββββ ACHIEVEMENTS ββββββββββββββββββββββββββββββββββββββββ
|
| 1145 |
+
elif page == "achievements":
|
| 1146 |
+
gami = st.session_state.gami
|
| 1147 |
+
xp = gami.get("xp", 0)
|
| 1148 |
+
streak = gami.get("streak", 0)
|
| 1149 |
+
earned = set(gami.get("badges", []))
|
| 1150 |
+
level_name, next_level_name, xp_to_next, level_pct = get_level(xp)
|
| 1151 |
+
|
| 1152 |
+
st.markdown("## π
Achievements")
|
| 1153 |
+
st.markdown("<div style='color:#6b6880; margin-bottom:1rem;'>Your XP, level, streak and badges.</div>", unsafe_allow_html=True)
|
| 1154 |
+
|
| 1155 |
+
# XP / Level hero card
|
| 1156 |
+
st.markdown(f"""
|
| 1157 |
+
<div style='background:linear-gradient(135deg,#6c47ff,#a78bfa,#f97316); border-radius:22px; padding:2.5rem 2rem; text-align:center; margin-bottom:1.5rem; box-shadow:0 8px 40px rgba(108,71,255,0.18);'>
|
| 1158 |
+
<div style='font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:rgba(255,255,255,0.7); margin-bottom:0.5rem;'>Current Level</div>
|
| 1159 |
+
<div style='font-family:Fraunces,serif; font-size:2.8rem; font-weight:900; color:#fff; margin-bottom:0.2rem;'>{level_name}</div>
|
| 1160 |
+
<div style='color:rgba(255,255,255,0.85); font-size:1.1rem; font-weight:600;'>{xp} XP total</div>
|
| 1161 |
+
{"<div style='color:rgba(255,255,255,0.65); font-size:0.88rem; margin-top:0.3rem;'>" + str(xp_to_next) + " XP to " + str(next_level_name) + "</div>" if next_level_name else "<div style='color:rgba(255,255,255,0.65); font-size:0.88rem; margin-top:0.3rem;'>Maximum level reached! π</div>"}
|
| 1162 |
+
<div style='background:rgba(255,255,255,0.2); border-radius:99px; height:8px; margin:1rem auto 0; max-width:320px; overflow:hidden;'>
|
| 1163 |
+
<div style='height:100%; width:{level_pct}%; background:#fff; border-radius:99px;'></div>
|
| 1164 |
+
</div>
|
| 1165 |
+
</div>
|
| 1166 |
+
""", unsafe_allow_html=True)
|
| 1167 |
+
|
| 1168 |
+
# Stats row
|
| 1169 |
+
total_quizzes = gami.get("total_quizzes", 0)
|
| 1170 |
+
badges_count = len(earned)
|
| 1171 |
+
s1, s2, s3, s4 = st.columns(4)
|
| 1172 |
+
for col, icon, val, label in [
|
| 1173 |
+
(s1, "π₯", f"{streak} days", "Current Streak"),
|
| 1174 |
+
(s2, "β‘", f"{xp} XP", "Total XP"),
|
| 1175 |
+
(s3, "π§©", str(total_quizzes), "Quizzes Done"),
|
| 1176 |
+
(s4, "π
", f"{badges_count}/{len(BADGES)}", "Badges Earned"),
|
| 1177 |
+
]:
|
| 1178 |
+
with col:
|
| 1179 |
+
st.markdown(f"""
|
| 1180 |
+
<div style='background:#fff; border:1.5px solid #e2ddf5; border-radius:16px; padding:1.2rem 0.8rem; text-align:center; box-shadow:0 2px 10px rgba(108,71,255,0.07);'>
|
| 1181 |
+
<div style='font-size:1.6rem; margin-bottom:0.3rem;'>{icon}</div>
|
| 1182 |
+
<div style='font-family:Fraunces,serif; font-size:1.4rem; font-weight:700; color:#1a1523;'>{val}</div>
|
| 1183 |
+
<div style='font-size:0.75rem; color:#6b6880; font-weight:600;'>{label}</div>
|
| 1184 |
+
</div>
|
| 1185 |
+
""", unsafe_allow_html=True)
|
| 1186 |
+
|
| 1187 |
+
st.markdown("---")
|
| 1188 |
+
st.markdown("### π
Badge Collection")
|
| 1189 |
+
|
| 1190 |
+
# Filter tabs
|
| 1191 |
+
filter_tab1, filter_tab2 = st.tabs(["All Badges", "Earned Only"])
|
| 1192 |
+
|
| 1193 |
+
def render_badges(badge_list):
|
| 1194 |
+
cols = st.columns(4)
|
| 1195 |
+
for i, (key, badge) in enumerate(badge_list):
|
| 1196 |
+
is_earned = key in earned
|
| 1197 |
+
card_class = "badge-card earned" if is_earned else "badge-card locked"
|
| 1198 |
+
lock_icon = badge["icon"] if is_earned else "π"
|
| 1199 |
+
opacity = "1" if is_earned else "0.5"
|
| 1200 |
+
with cols[i % 4]:
|
| 1201 |
+
st.markdown(f"""
|
| 1202 |
+
<div class='{card_class}' style='opacity:{opacity};'>
|
| 1203 |
+
<div style='font-size:2rem; margin-bottom:0.4rem;'>{lock_icon}</div>
|
| 1204 |
+
<div style='font-weight:700; font-size:0.88rem; color:#1a1523; margin-bottom:0.2rem;'>{badge["name"]}</div>
|
| 1205 |
+
<div style='font-size:0.76rem; color:#6b6880; line-height:1.4;'>{badge["desc"]}</div>
|
| 1206 |
+
{"<div style='margin-top:0.4rem; font-size:0.7rem; font-weight:700; color:#6c47ff; text-transform:uppercase; letter-spacing:0.05em;'>β Earned</div>" if is_earned else ""}
|
| 1207 |
+
</div>
|
| 1208 |
+
""", unsafe_allow_html=True)
|
| 1209 |
+
|
| 1210 |
+
with filter_tab1:
|
| 1211 |
+
render_badges(list(BADGES.items()))
|
| 1212 |
+
with filter_tab2:
|
| 1213 |
+
earned_list = [(k, v) for k, v in BADGES.items() if k in earned]
|
| 1214 |
+
if earned_list:
|
| 1215 |
+
render_badges(earned_list)
|
| 1216 |
+
else:
|
| 1217 |
+
st.markdown("""
|
| 1218 |
+
<div style='text-align:center; padding:3rem; color:#6b6880;'>
|
| 1219 |
+
<div style='font-size:2.5rem; margin-bottom:0.75rem;'>π</div>
|
| 1220 |
+
<div>No badges yet β complete quizzes and study sessions to earn them!</div>
|
| 1221 |
+
</div>
|
| 1222 |
+
""", unsafe_allow_html=True)
|
| 1223 |
+
|
| 1224 |
+
# How to earn XP guide
|
| 1225 |
+
st.markdown("---")
|
| 1226 |
+
st.markdown("### β‘ How to Earn XP")
|
| 1227 |
+
xp_guide = [
|
| 1228 |
+
("π", "Study session", "+10 XP"),
|
| 1229 |
+
("π", "Flashcard deck", "+10 XP"),
|
| 1230 |
+
("π§©", "Complete a quiz", "+20 XP"),
|
| 1231 |
+
("π―", "Score β₯ 60%", "+15 XP"),
|
| 1232 |
+
("π", "Score β₯ 80%", "+30 XP"),
|
| 1233 |
+
("π―", "Perfect score 100%","+50 XP"),
|
| 1234 |
+
]
|
| 1235 |
+
xp_cols = st.columns(3)
|
| 1236 |
+
for i, (icon, action, xp_val) in enumerate(xp_guide):
|
| 1237 |
+
with xp_cols[i % 3]:
|
| 1238 |
+
st.markdown(f"""
|
| 1239 |
+
<div style='background:#fff; border:1px solid #e2ddf5; border-radius:12px; padding:0.8rem 1rem; margin:0.3rem 0; display:flex; justify-content:space-between; align-items:center; box-shadow:0 1px 6px rgba(108,71,255,0.05);'>
|
| 1240 |
+
<div style='font-size:0.9rem; color:#1a1523;'>{icon} {action}</div>
|
| 1241 |
+
<div style='font-weight:700; color:#6c47ff; font-size:0.9rem;'>{xp_val}</div>
|
| 1242 |
+
</div>
|
| 1243 |
+
""", unsafe_allow_html=True)
|
content_generator.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from groq import Groq
|
| 3 |
+
|
| 4 |
+
STYLE_TIMES = {
|
| 5 |
+
"Summary Notes": "3-5 min",
|
| 6 |
+
"Detailed Explanation": "8-12 min",
|
| 7 |
+
"Bullet Points": "2-4 min",
|
| 8 |
+
"Concept Map": "5-7 min"
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
GROQ_API_KEY = "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"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def generate_content(topic, level, content_style, focus=""):
|
| 15 |
+
focus_note = " Focus specifically on: " + focus + "." if focus else ""
|
| 16 |
+
prompt = (
|
| 17 |
+
"Generate structured study notes for '" + topic + "' at " + level + " level "
|
| 18 |
+
"in the style of " + content_style + "." + focus_note + "\n\n"
|
| 19 |
+
"Return ONLY a JSON object with:\n"
|
| 20 |
+
"- sections: array of 3 objects each with 'title' and 'content'\n"
|
| 21 |
+
"- key_terms: array of 4-6 objects each with 'term' and 'definition'\n"
|
| 22 |
+
"- summary: one string (1-2 sentences)\n\n"
|
| 23 |
+
"JSON only, no markdown, no backticks, no extra text."
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
client = Groq(api_key=GROQ_API_KEY)
|
| 28 |
+
r = client.chat.completions.create(
|
| 29 |
+
model="llama-3.3-70b-versatile",
|
| 30 |
+
messages=[{"role": "user", "content": prompt}],
|
| 31 |
+
max_tokens=1500
|
| 32 |
+
)
|
| 33 |
+
raw = r.choices[0].message.content.strip()
|
| 34 |
+
# Strip markdown fences if model adds them anyway
|
| 35 |
+
if raw.startswith("```"):
|
| 36 |
+
raw = raw.split("```", 2)[1]
|
| 37 |
+
if raw.startswith("json"):
|
| 38 |
+
raw = raw[4:]
|
| 39 |
+
data = json.loads(raw.strip())
|
| 40 |
+
return {
|
| 41 |
+
"topic": topic.title(),
|
| 42 |
+
"level": level,
|
| 43 |
+
"style": content_style,
|
| 44 |
+
"read_time": STYLE_TIMES.get(content_style, "5 min"),
|
| 45 |
+
"sections": data["sections"],
|
| 46 |
+
"key_terms": data["key_terms"],
|
| 47 |
+
"summary": data["summary"],
|
| 48 |
+
"ai_generated": True,
|
| 49 |
+
}
|
| 50 |
+
except Exception as e:
|
| 51 |
+
# Graceful fallback so the app never crashes
|
| 52 |
+
return {
|
| 53 |
+
"topic": topic.title(),
|
| 54 |
+
"level": level,
|
| 55 |
+
"style": content_style,
|
| 56 |
+
"read_time": STYLE_TIMES.get(content_style, "5 min"),
|
| 57 |
+
"sections": [
|
| 58 |
+
{"title": "Overview", "content": f"Study notes for {topic} at {level} level could not be generated. Please try again."},
|
| 59 |
+
],
|
| 60 |
+
"key_terms": [],
|
| 61 |
+
"summary": f"Content generation failed: {str(e)}",
|
| 62 |
+
"ai_generated": False,
|
| 63 |
+
}
|
evaluation.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
evaluation.py
|
| 3 |
+
Evaluates quiz answers and returns detailed results.
|
| 4 |
+
Supports Multiple Choice, True/False, Short Answer, Fill in the Blank.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def evaluate_answers(quiz: dict, answers: dict) -> dict:
|
| 9 |
+
questions = quiz["questions"]
|
| 10 |
+
total = len(questions)
|
| 11 |
+
correct_count = 0
|
| 12 |
+
details = []
|
| 13 |
+
|
| 14 |
+
for i, q in enumerate(questions):
|
| 15 |
+
user_ans = str(answers.get(i, "")).strip().lower()
|
| 16 |
+
correct_ans = str(q.get("answer", "")).strip().lower()
|
| 17 |
+
q_type = q.get("type", "")
|
| 18 |
+
|
| 19 |
+
if q_type in ("Short Answer", "Fill in the Blank"):
|
| 20 |
+
# Accept if either string contains the other
|
| 21 |
+
is_correct = correct_ans in user_ans or user_ans in correct_ans
|
| 22 |
+
else:
|
| 23 |
+
is_correct = user_ans == correct_ans
|
| 24 |
+
|
| 25 |
+
if is_correct:
|
| 26 |
+
correct_count += 1
|
| 27 |
+
|
| 28 |
+
details.append({
|
| 29 |
+
"correct": is_correct,
|
| 30 |
+
"correct_answer": q.get("answer", "N/A"),
|
| 31 |
+
"explanation": q.get("explanation", ""),
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
score_pct = round((correct_count / total) * 100) if total > 0 else 0
|
| 35 |
+
|
| 36 |
+
if score_pct == 100:
|
| 37 |
+
feedback = "π Perfect score! Outstanding work!"
|
| 38 |
+
elif score_pct >= 80:
|
| 39 |
+
feedback = "π Excellent! You have a strong grasp of the material."
|
| 40 |
+
elif score_pct >= 60:
|
| 41 |
+
feedback = "π Good effort! Review the questions you missed to reinforce your understanding."
|
| 42 |
+
elif score_pct >= 40:
|
| 43 |
+
feedback = "π Keep practising. Revisit the study material for the topics you found tricky."
|
| 44 |
+
else:
|
| 45 |
+
feedback = "πͺ Don't give up! Go back to the study notes and try again β you'll improve."
|
| 46 |
+
|
| 47 |
+
return {
|
| 48 |
+
"score_percent": score_pct,
|
| 49 |
+
"correct": correct_count,
|
| 50 |
+
"total": total,
|
| 51 |
+
"feedback": feedback,
|
| 52 |
+
"details": details,
|
| 53 |
+
}
|
flashcard_generator.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
flashcard_generator.py
|
| 3 |
+
Generates flashcard sets from a topic using Groq LLM.
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
from groq import Groq
|
| 7 |
+
|
| 8 |
+
GROQ_API_KEY = "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"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def generate_flashcards(topic: str, level: str, num_cards: int = 10) -> list:
|
| 12 |
+
"""Return a list of {front, back} flashcard dicts."""
|
| 13 |
+
prompt = (
|
| 14 |
+
f"Generate {num_cards} flashcards for '{topic}' at {level} level.\n"
|
| 15 |
+
"Return ONLY a JSON object with key 'cards' (array).\n"
|
| 16 |
+
"Each card needs:\n"
|
| 17 |
+
" - front: a short question or term (max 15 words)\n"
|
| 18 |
+
" - back: the answer or definition (max 40 words)\n"
|
| 19 |
+
"JSON only, no markdown, no backticks, no extra text."
|
| 20 |
+
)
|
| 21 |
+
try:
|
| 22 |
+
client = Groq(api_key=GROQ_API_KEY)
|
| 23 |
+
r = client.chat.completions.create(
|
| 24 |
+
model="llama-3.3-70b-versatile",
|
| 25 |
+
messages=[{"role": "user", "content": prompt}],
|
| 26 |
+
max_tokens=1500,
|
| 27 |
+
)
|
| 28 |
+
raw = r.choices[0].message.content.strip()
|
| 29 |
+
if raw.startswith("```"):
|
| 30 |
+
raw = raw.split("```", 2)[1]
|
| 31 |
+
if raw.startswith("json"):
|
| 32 |
+
raw = raw[4:]
|
| 33 |
+
data = json.loads(raw.strip())
|
| 34 |
+
return data.get("cards", [])
|
| 35 |
+
except Exception as e:
|
| 36 |
+
return [{"front": "Generation failed", "back": str(e)}]
|
gamification.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
gamification.py
|
| 3 |
+
Handles XP, streaks, badges, and levels for LearnCraft.
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from datetime import date, timedelta
|
| 8 |
+
|
| 9 |
+
GAMIFICATION_FILE = "gamification.json"
|
| 10 |
+
|
| 11 |
+
# ββ XP values ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 12 |
+
XP_STUDY_SESSION = 10
|
| 13 |
+
XP_QUIZ_COMPLETE = 20
|
| 14 |
+
XP_PERFECT_SCORE = 50
|
| 15 |
+
XP_SCORE_ABOVE_80 = 30
|
| 16 |
+
XP_SCORE_ABOVE_60 = 15
|
| 17 |
+
XP_FLASHCARD_DECK = 10
|
| 18 |
+
XP_STREAK_BONUS = 5 # per day of streak
|
| 19 |
+
|
| 20 |
+
# ββ Level thresholds βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
LEVELS = [
|
| 22 |
+
(0, "π± Seedling"),
|
| 23 |
+
(50, "π Reader"),
|
| 24 |
+
(150, "π Student"),
|
| 25 |
+
(300, "π¬ Scholar"),
|
| 26 |
+
(500, "π Expert"),
|
| 27 |
+
(800, "π Master"),
|
| 28 |
+
(1200, "π Genius"),
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
# ββ Badge definitions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
BADGES = {
|
| 33 |
+
"first_quiz": {"name": "First Quiz", "icon": "π―", "desc": "Complete your first quiz"},
|
| 34 |
+
"perfect_score": {"name": "Perfect Score", "icon": "π―", "desc": "Score 100% on a quiz"},
|
| 35 |
+
"streak_3": {"name": "3-Day Streak", "icon": "π₯", "desc": "Study 3 days in a row"},
|
| 36 |
+
"streak_7": {"name": "Week Warrior", "icon": "β‘", "desc": "Study 7 days in a row"},
|
| 37 |
+
"streak_14": {"name": "Fortnight Hero", "icon": "ποΈ", "desc": "Study 14 days in a row"},
|
| 38 |
+
"topics_5": {"name": "Explorer", "icon": "πΊοΈ", "desc": "Study 5 different topics"},
|
| 39 |
+
"topics_10": {"name": "Polymath", "icon": "π§ ", "desc": "Study 10 different topics"},
|
| 40 |
+
"quizzes_10": {"name": "Quiz Master", "icon": "π§©", "desc": "Complete 10 quizzes"},
|
| 41 |
+
"quizzes_25": {"name": "Quiz Champion", "icon": "π
", "desc": "Complete 25 quizzes"},
|
| 42 |
+
"score_above_80": {"name": "High Achiever", "icon": "π", "desc": "Score above 80% on a quiz"},
|
| 43 |
+
"flashcards": {"name": "Card Shark", "icon": "π", "desc": "Complete a flashcard deck"},
|
| 44 |
+
"level_scholar": {"name": "Scholar", "icon": "π¬", "desc": "Reach Scholar level (300 XP)"},
|
| 45 |
+
"level_expert": {"name": "Expert", "icon": "π", "desc": "Reach Expert level (500 XP)"},
|
| 46 |
+
"level_master": {"name": "Master", "icon": "π", "desc": "Reach Master level (800 XP)"},
|
| 47 |
+
"notes_saver": {"name": "Note Taker", "icon": "π", "desc": "Save your first note"},
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def load_gamification() -> dict:
|
| 52 |
+
if os.path.exists(GAMIFICATION_FILE):
|
| 53 |
+
try:
|
| 54 |
+
with open(GAMIFICATION_FILE, "r") as f:
|
| 55 |
+
return json.load(f)
|
| 56 |
+
except (json.JSONDecodeError, IOError):
|
| 57 |
+
pass
|
| 58 |
+
return {
|
| 59 |
+
"xp": 0,
|
| 60 |
+
"badges": [],
|
| 61 |
+
"streak": 0,
|
| 62 |
+
"last_study_date": None,
|
| 63 |
+
"total_quizzes": 0,
|
| 64 |
+
"study_dates": [],
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def save_gamification(data: dict) -> None:
|
| 69 |
+
try:
|
| 70 |
+
with open(GAMIFICATION_FILE, "w") as f:
|
| 71 |
+
json.dump(data, f, indent=2)
|
| 72 |
+
except IOError:
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_level(xp: int) -> tuple:
|
| 77 |
+
"""Return (level_name, xp_for_next, xp_in_current_level, progress_pct)."""
|
| 78 |
+
current_level = LEVELS[0]
|
| 79 |
+
next_level = None
|
| 80 |
+
for i, (threshold, name) in enumerate(LEVELS):
|
| 81 |
+
if xp >= threshold:
|
| 82 |
+
current_level = (threshold, name)
|
| 83 |
+
next_level = LEVELS[i + 1] if i + 1 < len(LEVELS) else None
|
| 84 |
+
if next_level:
|
| 85 |
+
xp_start = current_level[0]
|
| 86 |
+
xp_end = next_level[0]
|
| 87 |
+
progress = (xp - xp_start) / (xp_end - xp_start)
|
| 88 |
+
return current_level[1], next_level[1], xp_end - xp, round(progress * 100)
|
| 89 |
+
return current_level[1], None, 0, 100
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def update_streak(data: dict) -> dict:
|
| 93 |
+
today = str(date.today())
|
| 94 |
+
yesterday = str(date.today() - timedelta(days=1))
|
| 95 |
+
last = data.get("last_study_date")
|
| 96 |
+
|
| 97 |
+
study_dates = data.get("study_dates", [])
|
| 98 |
+
if today not in study_dates:
|
| 99 |
+
study_dates.append(today)
|
| 100 |
+
data["study_dates"] = study_dates
|
| 101 |
+
|
| 102 |
+
if last == today:
|
| 103 |
+
pass # already counted today
|
| 104 |
+
elif last == yesterday:
|
| 105 |
+
data["streak"] = data.get("streak", 0) + 1
|
| 106 |
+
data["last_study_date"] = today
|
| 107 |
+
else:
|
| 108 |
+
data["streak"] = 1
|
| 109 |
+
data["last_study_date"] = today
|
| 110 |
+
|
| 111 |
+
return data
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def award_xp(data: dict, amount: int, reason: str = "") -> tuple:
|
| 115 |
+
"""Add XP and return (new_data, xp_awarded, level_up_msg)."""
|
| 116 |
+
old_xp = data.get("xp", 0)
|
| 117 |
+
old_level = get_level(old_xp)[0]
|
| 118 |
+
data["xp"] = old_xp + amount
|
| 119 |
+
new_level = get_level(data["xp"])[0]
|
| 120 |
+
level_up = new_level if new_level != old_level else None
|
| 121 |
+
return data, amount, level_up
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def check_and_award_badges(data: dict, context: dict) -> list:
|
| 125 |
+
"""
|
| 126 |
+
Check badge conditions and award new ones.
|
| 127 |
+
context keys: score, topics_count, quizzes_count, event
|
| 128 |
+
Returns list of newly awarded badge keys.
|
| 129 |
+
"""
|
| 130 |
+
earned = set(data.get("badges", []))
|
| 131 |
+
new_ones = []
|
| 132 |
+
|
| 133 |
+
score = context.get("score", -1)
|
| 134 |
+
topics_count = context.get("topics_count", 0)
|
| 135 |
+
quizzes_count = context.get("quizzes_count", 0)
|
| 136 |
+
event = context.get("event", "")
|
| 137 |
+
streak = data.get("streak", 0)
|
| 138 |
+
xp = data.get("xp", 0)
|
| 139 |
+
|
| 140 |
+
checks = {
|
| 141 |
+
"first_quiz": quizzes_count >= 1,
|
| 142 |
+
"perfect_score": score == 100,
|
| 143 |
+
"streak_3": streak >= 3,
|
| 144 |
+
"streak_7": streak >= 7,
|
| 145 |
+
"streak_14": streak >= 14,
|
| 146 |
+
"topics_5": topics_count >= 5,
|
| 147 |
+
"topics_10": topics_count >= 10,
|
| 148 |
+
"quizzes_10": quizzes_count >= 10,
|
| 149 |
+
"quizzes_25": quizzes_count >= 25,
|
| 150 |
+
"score_above_80": score >= 80,
|
| 151 |
+
"flashcards": event == "flashcards",
|
| 152 |
+
"level_scholar": xp >= 300,
|
| 153 |
+
"level_expert": xp >= 500,
|
| 154 |
+
"level_master": xp >= 800,
|
| 155 |
+
"notes_saver": event == "note_saved",
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
for key, condition in checks.items():
|
| 159 |
+
if condition and key not in earned:
|
| 160 |
+
earned.add(key)
|
| 161 |
+
new_ones.append(key)
|
| 162 |
+
|
| 163 |
+
data["badges"] = list(earned)
|
| 164 |
+
return new_ones
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def record_quiz(data: dict, score: int, topics_count: int) -> dict:
|
| 168 |
+
data["total_quizzes"] = data.get("total_quizzes", 0) + 1
|
| 169 |
+
return data
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def get_xp_for_quiz(score: int) -> int:
|
| 173 |
+
xp = XP_QUIZ_COMPLETE
|
| 174 |
+
if score == 100:
|
| 175 |
+
xp += XP_PERFECT_SCORE
|
| 176 |
+
elif score >= 80:
|
| 177 |
+
xp += XP_SCORE_ABOVE_80
|
| 178 |
+
elif score >= 60:
|
| 179 |
+
xp += XP_SCORE_ABOVE_60
|
| 180 |
+
return xp
|
learning_progress.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"topics_studied": [
|
| 3 |
+
"quantum mechanics",
|
| 4 |
+
"Quantum Mechanics",
|
| 5 |
+
"food",
|
| 6 |
+
"ML"
|
| 7 |
+
],
|
| 8 |
+
"scores": [
|
| 9 |
+
60,
|
| 10 |
+
60,
|
| 11 |
+
60,
|
| 12 |
+
60,
|
| 13 |
+
20
|
| 14 |
+
],
|
| 15 |
+
"best_score": 60,
|
| 16 |
+
"topic_scores": {
|
| 17 |
+
"Quantum Mechanics": 60
|
| 18 |
+
},
|
| 19 |
+
"sessions": [
|
| 20 |
+
{
|
| 21 |
+
"topic": "Quantum Mechanics",
|
| 22 |
+
"score": 60,
|
| 23 |
+
"date": "2026-04-22"
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"topic": "Quantum Mechanics",
|
| 27 |
+
"score": 60,
|
| 28 |
+
"date": "2026-04-22"
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"topic": "Quantum Mechanics",
|
| 32 |
+
"score": 60,
|
| 33 |
+
"date": "2026-04-22"
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"topic": "Quantum Mechanics",
|
| 37 |
+
"score": 60,
|
| 38 |
+
"date": "2026-04-22"
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"topic": "Quantum Mechanics",
|
| 42 |
+
"score": 20,
|
| 43 |
+
"date": "2026-04-22"
|
| 44 |
+
}
|
| 45 |
+
]
|
| 46 |
+
}
|
notes.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"topic": "Quantum Mechanics",
|
| 4 |
+
"note": "Quantum mechanics is a branch of physics that studies the behavior of matter and energy at the smallest scales, introducing new concepts such as wave-particle duality and uncertainty. The key principles of quantum mechanics, including superposition and the Heisenberg Uncertainty Principle, form the basis of quantum theory and help to explain the behavior of particles at the atomic and subatomic level.",
|
| 5 |
+
"date": "2026-04-22"
|
| 6 |
+
},
|
| 7 |
+
{
|
| 8 |
+
"topic": "Quantum Mechanics",
|
| 9 |
+
"note": "Quantum mechanics is a fundamental theory that describes the behavior of matter and energy at the smallest scales, and has numerous applications in fields such as chemistry, materials science, and electronics. It is based on principles such as wave-particle duality, uncertainty, and superposition, and has led to new areas of research, including quantum computing and quantum information theory.\n\n",
|
| 10 |
+
"date": "2026-04-22"
|
| 11 |
+
}
|
| 12 |
+
]
|
pdf_export.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pdf_export.py
|
| 3 |
+
Generates formatted PDF exports for study notes and quiz results using reportlab.
|
| 4 |
+
"""
|
| 5 |
+
import io
|
| 6 |
+
from datetime import date
|
| 7 |
+
from reportlab.lib.pagesizes import A4
|
| 8 |
+
from reportlab.lib import colors
|
| 9 |
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 10 |
+
from reportlab.lib.units import mm
|
| 11 |
+
from reportlab.platypus import (
|
| 12 |
+
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
| 13 |
+
HRFlowable, KeepTogether
|
| 14 |
+
)
|
| 15 |
+
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
| 16 |
+
|
| 17 |
+
# ββ Brand colours βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
PURPLE = colors.HexColor("#6c47ff")
|
| 19 |
+
ORANGE = colors.HexColor("#f97316")
|
| 20 |
+
LIGHT = colors.HexColor("#f7f4ef")
|
| 21 |
+
MUTED = colors.HexColor("#6b6880")
|
| 22 |
+
DARK = colors.HexColor("#1a1523")
|
| 23 |
+
WHITE = colors.white
|
| 24 |
+
GREEN = colors.HexColor("#10b981")
|
| 25 |
+
RED = colors.HexColor("#ef4444")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _base_styles():
|
| 29 |
+
base = getSampleStyleSheet()
|
| 30 |
+
styles = {
|
| 31 |
+
"title": ParagraphStyle(
|
| 32 |
+
"Title2", parent=base["Title"],
|
| 33 |
+
fontSize=26, textColor=PURPLE, spaceAfter=4,
|
| 34 |
+
fontName="Helvetica-Bold", alignment=TA_CENTER,
|
| 35 |
+
),
|
| 36 |
+
"subtitle": ParagraphStyle(
|
| 37 |
+
"Subtitle", parent=base["Normal"],
|
| 38 |
+
fontSize=11, textColor=MUTED, spaceAfter=16,
|
| 39 |
+
fontName="Helvetica", alignment=TA_CENTER,
|
| 40 |
+
),
|
| 41 |
+
"section_head": ParagraphStyle(
|
| 42 |
+
"SectionHead", parent=base["Heading2"],
|
| 43 |
+
fontSize=13, textColor=PURPLE, spaceBefore=14, spaceAfter=6,
|
| 44 |
+
fontName="Helvetica-Bold",
|
| 45 |
+
),
|
| 46 |
+
"body": ParagraphStyle(
|
| 47 |
+
"Body2", parent=base["Normal"],
|
| 48 |
+
fontSize=10, textColor=DARK, spaceAfter=6,
|
| 49 |
+
fontName="Helvetica", leading=15,
|
| 50 |
+
),
|
| 51 |
+
"term": ParagraphStyle(
|
| 52 |
+
"Term", parent=base["Normal"],
|
| 53 |
+
fontSize=10, textColor=PURPLE, spaceAfter=2,
|
| 54 |
+
fontName="Helvetica-Bold",
|
| 55 |
+
),
|
| 56 |
+
"definition": ParagraphStyle(
|
| 57 |
+
"Def", parent=base["Normal"],
|
| 58 |
+
fontSize=9.5, textColor=MUTED, spaceAfter=8,
|
| 59 |
+
fontName="Helvetica", leading=14,
|
| 60 |
+
),
|
| 61 |
+
"summary_box": ParagraphStyle(
|
| 62 |
+
"SummaryBox", parent=base["Normal"],
|
| 63 |
+
fontSize=10, textColor=DARK, spaceAfter=6,
|
| 64 |
+
fontName="Helvetica-Oblique", leading=15,
|
| 65 |
+
),
|
| 66 |
+
"label": ParagraphStyle(
|
| 67 |
+
"Label", parent=base["Normal"],
|
| 68 |
+
fontSize=8, textColor=MUTED, spaceAfter=2,
|
| 69 |
+
fontName="Helvetica", alignment=TA_CENTER,
|
| 70 |
+
),
|
| 71 |
+
"score_big": ParagraphStyle(
|
| 72 |
+
"ScoreBig", parent=base["Normal"],
|
| 73 |
+
fontSize=40, textColor=PURPLE, spaceAfter=4,
|
| 74 |
+
fontName="Helvetica-Bold", alignment=TA_CENTER,
|
| 75 |
+
),
|
| 76 |
+
"feedback": ParagraphStyle(
|
| 77 |
+
"Feedback", parent=base["Normal"],
|
| 78 |
+
fontSize=12, textColor=DARK, spaceAfter=12,
|
| 79 |
+
fontName="Helvetica-Bold", alignment=TA_CENTER,
|
| 80 |
+
),
|
| 81 |
+
"q_text": ParagraphStyle(
|
| 82 |
+
"QText", parent=base["Normal"],
|
| 83 |
+
fontSize=10, textColor=DARK, spaceAfter=3,
|
| 84 |
+
fontName="Helvetica-Bold",
|
| 85 |
+
),
|
| 86 |
+
"q_detail": ParagraphStyle(
|
| 87 |
+
"QDetail", parent=base["Normal"],
|
| 88 |
+
fontSize=9.5, textColor=MUTED, spaceAfter=2,
|
| 89 |
+
fontName="Helvetica",
|
| 90 |
+
),
|
| 91 |
+
}
|
| 92 |
+
return styles
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def export_study_notes_pdf(content: dict) -> bytes:
|
| 96 |
+
"""Generate a PDF for study notes. Returns bytes."""
|
| 97 |
+
buffer = io.BytesIO()
|
| 98 |
+
doc = SimpleDocTemplate(
|
| 99 |
+
buffer, pagesize=A4,
|
| 100 |
+
leftMargin=20*mm, rightMargin=20*mm,
|
| 101 |
+
topMargin=18*mm, bottomMargin=18*mm,
|
| 102 |
+
)
|
| 103 |
+
s = _base_styles()
|
| 104 |
+
story = []
|
| 105 |
+
|
| 106 |
+
# Header
|
| 107 |
+
story.append(Paragraph("LearnCraft", s["title"]))
|
| 108 |
+
story.append(Paragraph("Personalized Study Notes", s["subtitle"]))
|
| 109 |
+
story.append(HRFlowable(width="100%", thickness=2, color=PURPLE, spaceAfter=12))
|
| 110 |
+
|
| 111 |
+
# Meta table
|
| 112 |
+
meta_data = [
|
| 113 |
+
["Topic", content.get("topic", "β"), "Level", content.get("level", "β")],
|
| 114 |
+
["Style", content.get("style", "β"), "Read Time", content.get("read_time", "β")],
|
| 115 |
+
["Generated", str(date.today()), "", ""],
|
| 116 |
+
]
|
| 117 |
+
meta_table = Table(meta_data, colWidths=[30*mm, 65*mm, 30*mm, 50*mm])
|
| 118 |
+
meta_table.setStyle(TableStyle([
|
| 119 |
+
("FONTNAME", (0,0), (-1,-1), "Helvetica"),
|
| 120 |
+
("FONTSIZE", (0,0), (-1,-1), 9),
|
| 121 |
+
("FONTNAME", (0,0), (0,-1), "Helvetica-Bold"),
|
| 122 |
+
("FONTNAME", (2,0), (2,-1), "Helvetica-Bold"),
|
| 123 |
+
("TEXTCOLOR", (0,0), (0,-1), PURPLE),
|
| 124 |
+
("TEXTCOLOR", (2,0), (2,-1), PURPLE),
|
| 125 |
+
("TEXTCOLOR", (1,0), (1,-1), DARK),
|
| 126 |
+
("TEXTCOLOR", (3,0), (3,-1), DARK),
|
| 127 |
+
("BACKGROUND", (0,0), (-1,-1), LIGHT),
|
| 128 |
+
("ROWBACKGROUNDS", (0,0), (-1,-1), [LIGHT, WHITE]),
|
| 129 |
+
("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#e2ddf5")),
|
| 130 |
+
("ROUNDEDCORNERS", [4]),
|
| 131 |
+
("TOPPADDING", (0,0), (-1,-1), 5),
|
| 132 |
+
("BOTTOMPADDING",(0,0), (-1,-1), 5),
|
| 133 |
+
("LEFTPADDING", (0,0), (-1,-1), 8),
|
| 134 |
+
]))
|
| 135 |
+
story.append(meta_table)
|
| 136 |
+
story.append(Spacer(1, 10))
|
| 137 |
+
|
| 138 |
+
# Sections
|
| 139 |
+
for section in content.get("sections", []):
|
| 140 |
+
story.append(Paragraph(f"βΈ {section['title']}", s["section_head"]))
|
| 141 |
+
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2ddf5"), spaceAfter=6))
|
| 142 |
+
story.append(Paragraph(section["content"], s["body"]))
|
| 143 |
+
|
| 144 |
+
# Key terms
|
| 145 |
+
key_terms = content.get("key_terms", [])
|
| 146 |
+
if key_terms:
|
| 147 |
+
story.append(Spacer(1, 6))
|
| 148 |
+
story.append(Paragraph("Key Terms", s["section_head"]))
|
| 149 |
+
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2ddf5"), spaceAfter=8))
|
| 150 |
+
for term in key_terms:
|
| 151 |
+
story.append(Paragraph(term["term"], s["term"]))
|
| 152 |
+
story.append(Paragraph(term["definition"], s["definition"]))
|
| 153 |
+
|
| 154 |
+
# Summary
|
| 155 |
+
summary = content.get("summary", "")
|
| 156 |
+
if summary:
|
| 157 |
+
story.append(Spacer(1, 4))
|
| 158 |
+
story.append(Paragraph("Quick Summary", s["section_head"]))
|
| 159 |
+
summary_table = Table([[Paragraph(f'"{summary}"', s["summary_box"])]], colWidths=[170*mm])
|
| 160 |
+
summary_table.setStyle(TableStyle([
|
| 161 |
+
("BACKGROUND", (0,0), (-1,-1), colors.HexColor("#ede9fe")),
|
| 162 |
+
("LEFTPADDING", (0,0), (-1,-1), 12),
|
| 163 |
+
("RIGHTPADDING", (0,0), (-1,-1), 12),
|
| 164 |
+
("TOPPADDING", (0,0), (-1,-1), 10),
|
| 165 |
+
("BOTTOMPADDING",(0,0), (-1,-1), 10),
|
| 166 |
+
("ROUNDEDCORNERS", [8]),
|
| 167 |
+
]))
|
| 168 |
+
story.append(summary_table)
|
| 169 |
+
|
| 170 |
+
# Footer
|
| 171 |
+
story.append(Spacer(1, 16))
|
| 172 |
+
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#e2ddf5"), spaceAfter=6))
|
| 173 |
+
story.append(Paragraph(f"Generated by LearnCraft Β· {date.today()}", s["label"]))
|
| 174 |
+
|
| 175 |
+
doc.build(story)
|
| 176 |
+
return buffer.getvalue()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def export_quiz_results_pdf(quiz: dict, results: dict, answers: dict) -> bytes:
|
| 180 |
+
"""Generate a PDF for quiz results. Returns bytes."""
|
| 181 |
+
buffer = io.BytesIO()
|
| 182 |
+
doc = SimpleDocTemplate(
|
| 183 |
+
buffer, pagesize=A4,
|
| 184 |
+
leftMargin=20*mm, rightMargin=20*mm,
|
| 185 |
+
topMargin=18*mm, bottomMargin=18*mm,
|
| 186 |
+
)
|
| 187 |
+
s = _base_styles()
|
| 188 |
+
story = []
|
| 189 |
+
|
| 190 |
+
# Header
|
| 191 |
+
story.append(Paragraph("LearnCraft", s["title"]))
|
| 192 |
+
story.append(Paragraph("Quiz Results Report", s["subtitle"]))
|
| 193 |
+
story.append(HRFlowable(width="100%", thickness=2, color=PURPLE, spaceAfter=14))
|
| 194 |
+
|
| 195 |
+
# Score hero
|
| 196 |
+
score = results["score_percent"]
|
| 197 |
+
score_col = GREEN if score >= 60 else RED
|
| 198 |
+
story.append(Paragraph(f"{score}%", ParagraphStyle(
|
| 199 |
+
"BigScore", fontSize=48, textColor=score_col,
|
| 200 |
+
fontName="Helvetica-Bold", alignment=TA_CENTER, spaceAfter=4,
|
| 201 |
+
)))
|
| 202 |
+
story.append(Paragraph(
|
| 203 |
+
f"{results['correct']} / {results['total']} correct Β· {results['feedback']}",
|
| 204 |
+
s["feedback"]
|
| 205 |
+
))
|
| 206 |
+
story.append(Spacer(1, 6))
|
| 207 |
+
|
| 208 |
+
# Meta
|
| 209 |
+
meta_data = [
|
| 210 |
+
["Topic", quiz.get("topic", "β"), "Difficulty", quiz.get("difficulty", "β")],
|
| 211 |
+
["Questions", str(results["total"]), "Date", str(date.today())],
|
| 212 |
+
]
|
| 213 |
+
meta_table = Table(meta_data, colWidths=[30*mm, 65*mm, 30*mm, 50*mm])
|
| 214 |
+
meta_table.setStyle(TableStyle([
|
| 215 |
+
("FONTNAME", (0,0), (-1,-1), "Helvetica"),
|
| 216 |
+
("FONTSIZE", (0,0), (-1,-1), 9),
|
| 217 |
+
("FONTNAME", (0,0), (0,-1), "Helvetica-Bold"),
|
| 218 |
+
("FONTNAME", (2,0), (2,-1), "Helvetica-Bold"),
|
| 219 |
+
("TEXTCOLOR", (0,0), (0,-1), PURPLE),
|
| 220 |
+
("TEXTCOLOR", (2,0), (2,-1), PURPLE),
|
| 221 |
+
("BACKGROUND", (0,0), (-1,-1), LIGHT),
|
| 222 |
+
("ROWBACKGROUNDS", (0,0), (-1,-1), [LIGHT, WHITE]),
|
| 223 |
+
("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#e2ddf5")),
|
| 224 |
+
("TOPPADDING", (0,0), (-1,-1), 5),
|
| 225 |
+
("BOTTOMPADDING",(0,0), (-1,-1), 5),
|
| 226 |
+
("LEFTPADDING", (0,0), (-1,-1), 8),
|
| 227 |
+
]))
|
| 228 |
+
story.append(meta_table)
|
| 229 |
+
story.append(Spacer(1, 14))
|
| 230 |
+
|
| 231 |
+
# Answer review
|
| 232 |
+
story.append(Paragraph("Answer Review", s["section_head"]))
|
| 233 |
+
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#e2ddf5"), spaceAfter=8))
|
| 234 |
+
|
| 235 |
+
for i, q in enumerate(quiz.get("questions", [])):
|
| 236 |
+
correct = results["details"][i]["correct"]
|
| 237 |
+
user_ans = str(answers.get(i, "No answer"))
|
| 238 |
+
right_ans = results["details"][i]["correct_answer"]
|
| 239 |
+
explanation= results["details"][i].get("explanation", "")
|
| 240 |
+
icon = "β" if correct else "β"
|
| 241 |
+
bg_color = colors.HexColor("#d1fae5") if correct else colors.HexColor("#fee2e2")
|
| 242 |
+
icon_color = GREEN if correct else RED
|
| 243 |
+
|
| 244 |
+
block = [
|
| 245 |
+
[
|
| 246 |
+
Paragraph(f"<font color='{'#10b981' if correct else '#ef4444'}'><b>{icon}</b></font> Q{i+1}: {q['question']}", s["q_text"]),
|
| 247 |
+
],
|
| 248 |
+
[
|
| 249 |
+
Paragraph(
|
| 250 |
+
f"Your answer: <font color='{'#10b981' if correct else '#ef4444'}'><b>{user_ans}</b></font> | "
|
| 251 |
+
f"Correct: <font color='#10b981'><b>{right_ans}</b></font>"
|
| 252 |
+
+ (f"<br/><i>{explanation}</i>" if explanation else ""),
|
| 253 |
+
s["q_detail"]
|
| 254 |
+
),
|
| 255 |
+
],
|
| 256 |
+
]
|
| 257 |
+
t = Table(block, colWidths=[170*mm])
|
| 258 |
+
t.setStyle(TableStyle([
|
| 259 |
+
("BACKGROUND", (0,0), (-1,-1), bg_color),
|
| 260 |
+
("LEFTPADDING", (0,0), (-1,-1), 10),
|
| 261 |
+
("RIGHTPADDING", (0,0), (-1,-1), 10),
|
| 262 |
+
("TOPPADDING", (0,0), (-1,-1), 8),
|
| 263 |
+
("BOTTOMPADDING", (0,0), (-1,-1), 8),
|
| 264 |
+
("ROUNDEDCORNERS", [6]),
|
| 265 |
+
]))
|
| 266 |
+
story.append(KeepTogether([t, Spacer(1, 5)]))
|
| 267 |
+
|
| 268 |
+
# Footer
|
| 269 |
+
story.append(Spacer(1, 12))
|
| 270 |
+
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#e2ddf5"), spaceAfter=6))
|
| 271 |
+
story.append(Paragraph(f"Generated by LearnCraft Β· {date.today()}", s["label"]))
|
| 272 |
+
|
| 273 |
+
doc.build(story)
|
| 274 |
+
return buffer.getvalue()
|
quiz_generator.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from groq import Groq
|
| 3 |
+
|
| 4 |
+
GROQ_API_KEY = "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"
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def generate_quiz(topic, level, num_questions, q_type):
|
| 8 |
+
if q_type != "Mixed":
|
| 9 |
+
type_instruction = "All questions must be type: " + q_type + "."
|
| 10 |
+
else:
|
| 11 |
+
type_instruction = "Mix these types: Multiple Choice, True/False, Fill in the Blank, Short Answer."
|
| 12 |
+
|
| 13 |
+
prompt = (
|
| 14 |
+
"Generate " + str(num_questions) + " quiz questions about '" + topic + "' at " + level + " level.\n"
|
| 15 |
+
+ type_instruction + "\n\n"
|
| 16 |
+
"Return ONLY a JSON object with key 'questions' (array).\n"
|
| 17 |
+
"Each question needs:\n"
|
| 18 |
+
" - type: one of 'Multiple Choice', 'True/False', 'Fill in the Blank', 'Short Answer'\n"
|
| 19 |
+
" - question: the question text (for Fill in the Blank, use ___ for the blank)\n"
|
| 20 |
+
" - options: 4 strings for MC, ['True','False'] for TF, [] for others\n"
|
| 21 |
+
" - answer: the correct answer string\n"
|
| 22 |
+
" - explanation: one sentence explaining why\n\n"
|
| 23 |
+
"JSON only, no markdown, no backticks, no extra text."
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
client = Groq(api_key=GROQ_API_KEY)
|
| 28 |
+
r = client.chat.completions.create(
|
| 29 |
+
model="llama-3.3-70b-versatile",
|
| 30 |
+
messages=[{"role": "user", "content": prompt}],
|
| 31 |
+
max_tokens=1500
|
| 32 |
+
)
|
| 33 |
+
raw = r.choices[0].message.content.strip()
|
| 34 |
+
if raw.startswith("```"):
|
| 35 |
+
raw = raw.split("```", 2)[1]
|
| 36 |
+
if raw.startswith("json"):
|
| 37 |
+
raw = raw[4:]
|
| 38 |
+
data = json.loads(raw.strip())
|
| 39 |
+
return {
|
| 40 |
+
"title": topic.title() + " Quiz",
|
| 41 |
+
"topic": topic.title(),
|
| 42 |
+
"difficulty": level,
|
| 43 |
+
"questions": data["questions"]
|
| 44 |
+
}
|
| 45 |
+
except Exception as e:
|
| 46 |
+
# Fallback single question so app never crashes
|
| 47 |
+
return {
|
| 48 |
+
"title": topic.title() + " Quiz",
|
| 49 |
+
"topic": topic.title(),
|
| 50 |
+
"difficulty": level,
|
| 51 |
+
"questions": [
|
| 52 |
+
{
|
| 53 |
+
"type": "Short Answer",
|
| 54 |
+
"question": f"Quiz generation failed ({str(e)}). Please retry.",
|
| 55 |
+
"options": [],
|
| 56 |
+
"answer": "retry",
|
| 57 |
+
"explanation": "An error occurred while generating the quiz."
|
| 58 |
+
}
|
| 59 |
+
]
|
| 60 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.32.0
|
| 2 |
+
pandas>=2.0.0
|
| 3 |
+
groq>=0.4.0
|
| 4 |
+
reportlab>=4.0.0
|
| 5 |
+
plotly>=5.18.0
|
tutor.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tutor.py
|
| 3 |
+
AI Tutor chat powered by Groq. Maintains conversation history per session.
|
| 4 |
+
"""
|
| 5 |
+
from groq import Groq
|
| 6 |
+
|
| 7 |
+
GROQ_API_KEY = "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"
|
| 8 |
+
|
| 9 |
+
SYSTEM_PROMPT = """You are LearnCraft Tutor β a friendly, encouraging, and highly knowledgeable AI tutor.
|
| 10 |
+
Your role is to help students understand topics deeply, answer their questions clearly, and guide them
|
| 11 |
+
step-by-step when they are confused.
|
| 12 |
+
|
| 13 |
+
Guidelines:
|
| 14 |
+
- Be concise but thorough. Use examples and analogies generously.
|
| 15 |
+
- If a student seems confused, break things down into smaller steps.
|
| 16 |
+
- Celebrate correct answers and effort warmly.
|
| 17 |
+
- Never just give answers to homework β guide the student to figure it out.
|
| 18 |
+
- Format responses with clear structure (use short paragraphs, numbered steps where helpful).
|
| 19 |
+
- Keep responses under 200 words unless a complex explanation is needed.
|
| 20 |
+
- Always end with a follow-up question or encouragement to keep the student engaged.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_tutor_reply(messages: list, topic: str = "") -> str:
|
| 25 |
+
"""
|
| 26 |
+
Send conversation history to Groq and return the tutor's reply.
|
| 27 |
+
messages: list of {"role": "user"/"assistant", "content": str}
|
| 28 |
+
"""
|
| 29 |
+
system = SYSTEM_PROMPT
|
| 30 |
+
if topic:
|
| 31 |
+
system += f"\n\nThe student is currently studying: {topic}. Focus your answers around this topic when relevant."
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
client = Groq(api_key=GROQ_API_KEY)
|
| 35 |
+
response = client.chat.completions.create(
|
| 36 |
+
model="llama-3.3-70b-versatile",
|
| 37 |
+
messages=[{"role": "system", "content": system}] + messages,
|
| 38 |
+
max_tokens=400,
|
| 39 |
+
temperature=0.7,
|
| 40 |
+
)
|
| 41 |
+
return response.choices[0].message.content.strip()
|
| 42 |
+
except Exception as e:
|
| 43 |
+
return f"Sorry, I couldn't connect right now. Please try again! (Error: {str(e)})"
|
utils.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
utils.py
|
| 3 |
+
Helper utilities: progress persistence, notes, topic lists, learning paths, etc.
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from datetime import date
|
| 8 |
+
|
| 9 |
+
PROGRESS_FILE = "learning_progress.json"
|
| 10 |
+
NOTES_FILE = "notes.json"
|
| 11 |
+
|
| 12 |
+
TOPICS = [
|
| 13 |
+
"Photosynthesis",
|
| 14 |
+
"Machine Learning",
|
| 15 |
+
"World War II",
|
| 16 |
+
"Python Functions",
|
| 17 |
+
"Calculus",
|
| 18 |
+
"Climate Change",
|
| 19 |
+
"The French Revolution",
|
| 20 |
+
"DNA Replication",
|
| 21 |
+
"Object-Oriented Programming",
|
| 22 |
+
"The Solar System",
|
| 23 |
+
"Economics Supply & Demand",
|
| 24 |
+
"Quantum Mechanics",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
LEARNING_PATHS = {
|
| 28 |
+
"machine learning": ["Python Functions", "Calculus", "Economics Supply & Demand", "Machine Learning"],
|
| 29 |
+
"quantum mechanics": ["Calculus", "The Solar System", "Quantum Mechanics"],
|
| 30 |
+
"dna replication": ["Photosynthesis", "DNA Replication"],
|
| 31 |
+
"calculus": ["Python Functions", "Calculus"],
|
| 32 |
+
"object-oriented programming": ["Python Functions", "Object-Oriented Programming"],
|
| 33 |
+
"climate change": ["Photosynthesis", "Economics Supply & Demand", "Climate Change"],
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_topics() -> list:
|
| 38 |
+
return sorted(TOPICS)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_progress() -> dict:
|
| 42 |
+
"""Load progress from JSON file, or return empty structure."""
|
| 43 |
+
if os.path.exists(PROGRESS_FILE):
|
| 44 |
+
try:
|
| 45 |
+
with open(PROGRESS_FILE, "r") as f:
|
| 46 |
+
return json.load(f)
|
| 47 |
+
except (json.JSONDecodeError, IOError):
|
| 48 |
+
pass
|
| 49 |
+
return {
|
| 50 |
+
"topics_studied": [],
|
| 51 |
+
"scores": [],
|
| 52 |
+
"best_score": 0,
|
| 53 |
+
"topic_scores": {}, # {topic: best_score_int}
|
| 54 |
+
"sessions": [], # [{topic, score, date}]
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def save_progress(progress: dict, topic: str = None, score: int = None) -> None:
|
| 59 |
+
"""Update and persist progress data."""
|
| 60 |
+
if "topic_scores" not in progress:
|
| 61 |
+
progress["topic_scores"] = {}
|
| 62 |
+
if "sessions" not in progress:
|
| 63 |
+
progress["sessions"] = []
|
| 64 |
+
|
| 65 |
+
if topic:
|
| 66 |
+
studied = progress.get("topics_studied", [])
|
| 67 |
+
if topic not in studied:
|
| 68 |
+
studied.append(topic)
|
| 69 |
+
progress["topics_studied"] = studied
|
| 70 |
+
|
| 71 |
+
if score is not None:
|
| 72 |
+
scores = progress.get("scores", [])
|
| 73 |
+
scores.append(score)
|
| 74 |
+
progress["scores"] = scores
|
| 75 |
+
if score > progress.get("best_score", 0):
|
| 76 |
+
progress["best_score"] = score
|
| 77 |
+
|
| 78 |
+
# Per-topic best score (store single int, not list)
|
| 79 |
+
if topic:
|
| 80 |
+
ts = progress["topic_scores"]
|
| 81 |
+
existing = ts.get(topic, 0)
|
| 82 |
+
# Handle legacy list format
|
| 83 |
+
if isinstance(existing, list):
|
| 84 |
+
existing = max(existing) if existing else 0
|
| 85 |
+
ts[topic] = max(existing, score)
|
| 86 |
+
progress["topic_scores"] = ts
|
| 87 |
+
|
| 88 |
+
# Session log
|
| 89 |
+
sessions = progress.get("sessions", [])
|
| 90 |
+
sessions.append({
|
| 91 |
+
"topic": topic or "Unknown",
|
| 92 |
+
"score": score,
|
| 93 |
+
"date": str(date.today())
|
| 94 |
+
})
|
| 95 |
+
progress["sessions"] = sessions
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
with open(PROGRESS_FILE, "w") as f:
|
| 99 |
+
json.dump(progress, f, indent=2)
|
| 100 |
+
except IOError:
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def get_weak_topics(progress: dict) -> list:
|
| 105 |
+
"""Return topics where the best score is below 60%."""
|
| 106 |
+
result = []
|
| 107 |
+
for topic, score in progress.get("topic_scores", {}).items():
|
| 108 |
+
# Handle legacy list format
|
| 109 |
+
if isinstance(score, list):
|
| 110 |
+
score = max(score) if score else 0
|
| 111 |
+
if score < 60:
|
| 112 |
+
result.append(topic)
|
| 113 |
+
return result
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def get_learning_path(topic: str) -> list:
|
| 117 |
+
"""Return recommended learning path for a topic, or empty list."""
|
| 118 |
+
return LEARNING_PATHS.get(topic.lower().strip(), [])
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ββ Notes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
+
|
| 123 |
+
def load_notes() -> list:
|
| 124 |
+
"""Load saved notes from JSON file."""
|
| 125 |
+
if os.path.exists(NOTES_FILE):
|
| 126 |
+
try:
|
| 127 |
+
with open(NOTES_FILE, "r") as f:
|
| 128 |
+
return json.load(f)
|
| 129 |
+
except (json.JSONDecodeError, IOError):
|
| 130 |
+
pass
|
| 131 |
+
return []
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def save_note(note_text: str, topic: str) -> None:
|
| 135 |
+
"""Append a note and persist to file."""
|
| 136 |
+
notes = load_notes()
|
| 137 |
+
notes.append({
|
| 138 |
+
"topic": topic,
|
| 139 |
+
"note": note_text,
|
| 140 |
+
"date": str(date.today())
|
| 141 |
+
})
|
| 142 |
+
try:
|
| 143 |
+
with open(NOTES_FILE, "w") as f:
|
| 144 |
+
json.dump(notes, f, indent=2)
|
| 145 |
+
except IOError:
|
| 146 |
+
pass
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def delete_note(index: int) -> None:
|
| 150 |
+
"""Delete a note by its index."""
|
| 151 |
+
notes = load_notes()
|
| 152 |
+
if 0 <= index < len(notes):
|
| 153 |
+
notes.pop(index)
|
| 154 |
+
try:
|
| 155 |
+
with open(NOTES_FILE, "w") as f:
|
| 156 |
+
json.dump(notes, f, indent=2)
|
| 157 |
+
except IOError:
|
| 158 |
+
pass
|