Download app.py from noodledom/focusflow: direct link, hf CLI and curl.
- Browser
- Download file 89.6 kB
-
https://huggingface.co/spaces/noodledom/focusflow/resolve/main/app.py
- Command line
-
hf download hf://spaces/noodledom/focusflow/app.py
-
curl -L -o app.py https://huggingface.co/spaces/noodledom/focusflow/resolve/main/app.py
89.6 kB
| import streamlit as st | |
| import requests | |
| import pandas as pd | |
| import plotly.express as px | |
| import time | |
| import streamlit.components.v1 as components | |
| try: | |
| from streamlit_calendar import calendar as st_calendar | |
| _HAS_CALENDAR = True | |
| except ImportError: | |
| _HAS_CALENDAR = False | |
| st_calendar = None | |
| from datetime import date | |
| import os | |
| # ----------------------------------------------------------------------------- | |
| # 1. CONFIG & CSS | |
| # ----------------------------------------------------------------------------- | |
| st.set_page_config( | |
| page_title="FocusFlow", | |
| page_icon="🎯", | |
| layout="wide", | |
| initial_sidebar_state="expanded" | |
| ) | |
| st.markdown(""" | |
| <style> | |
| /* Main background */ | |
| .stApp { | |
| background-color: #0F0F1A; | |
| color: #E2E8F0; | |
| } | |
| /* Sidebar styling */ | |
| [data-testid="stSidebar"] { | |
| background-color: #1A1A2E; | |
| border-right: 1px solid #2D2D44; | |
| } | |
| /* Cards */ | |
| [data-testid="stExpander"] { | |
| background-color: #1E1E30; | |
| border: 1px solid #2D2D44; | |
| border-radius: 10px; | |
| margin-bottom: 8px; | |
| } | |
| /* Buttons */ | |
| .stButton > button { | |
| background: linear-gradient(135deg, #7C3AED, #3B82F6); | |
| color: white; | |
| border: none; | |
| border-radius: 8px; | |
| font-weight: 600; | |
| } | |
| .stButton > button:hover { | |
| background: linear-gradient(135deg, #6D28D9, #2563EB); | |
| transform: translateY(-1px); | |
| } | |
| /* Sign out button override */ | |
| .stButton > button[kind="secondary"] { | |
| background: #374151; | |
| color: #E2E8F0; | |
| } | |
| /* Input fields */ | |
| .stTextInput > div > div > input, | |
| .stTextArea > div > div > textarea { | |
| background-color: #1E1E30; | |
| color: #E2E8F0; | |
| border: 1px solid #374151; | |
| border-radius: 8px; | |
| } | |
| /* Section headers */ | |
| h1, h2, h3 { | |
| color: #E2E8F0; | |
| } | |
| /* Metric cards */ | |
| [data-testid="metric-container"] { | |
| background-color: #1E1E30; | |
| border: 1px solid #2D2D44; | |
| border-radius: 10px; | |
| padding: 16px; | |
| } | |
| /* Hide streamlit branding */ | |
| #MainMenu {visibility: hidden;} | |
| footer {visibility: hidden;} | |
| /* Online badge */ | |
| .online-badge { | |
| background: #10B981; | |
| color: white; | |
| padding: 2px 10px; | |
| border-radius: 20px; | |
| font-size: 12px; | |
| font-weight: bold; | |
| } | |
| /* Source cards */ | |
| .source-card { | |
| background: #1E1E30; | |
| border: 1px solid #374151; | |
| border-radius: 8px; | |
| padding: 10px 14px; | |
| margin: 6px 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| st.markdown(""" | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); | |
| /* ========== MATERIAL DESIGN FOUNDATION ========== */ | |
| :root { | |
| --md-primary: #1A73E8; | |
| --md-primary-dark: #1557B0; | |
| --md-elevation-1: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15); | |
| --md-elevation-2: 0 1px 2px 0 rgba(60,64,67,.3), 0 2px 6px 2px rgba(60,64,67,.15); | |
| --md-elevation-3: 0 4px 8px 3px rgba(60,64,67,.15), 0 1px 3px rgba(60,64,67,.3); | |
| --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); | |
| --transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1); | |
| --radius-md: 8px; | |
| } | |
| html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; } | |
| /* Global Reset & Fonts */ | |
| html, body, [class*="css"] { | |
| font-family: 'Inter', sans-serif; | |
| color: #1F2937; | |
| background-color: #FAFAFA; | |
| } | |
| /* Material Design Button Enhancements */ | |
| .stButton > button { | |
| box-shadow: var(--md-elevation-2) !important; | |
| transition: all var(--transition-fast) !important; | |
| border-radius: var(--radius-md) !important; | |
| } | |
| .stButton > button:hover { | |
| box-shadow: var(--md-elevation-3) !important; | |
| transform: translateY(-1px); | |
| } | |
| .stButton > button:active { | |
| transform: translateY(0); | |
| } | |
| /* Smooth Animations */ | |
| @keyframes fadeInUp { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .stChatMessage { animation: fadeInUp var(--transition-normal) ease-out; } | |
| /* Touch-Friendly Mobile */ | |
| @media (max-width: 768px) { | |
| .stButton > button { | |
| min-height: 48px !important; | |
| font-size: 16px !important; | |
| } | |
| body { font-size: 16px; } | |
| h1 { font-size: 24px !important; } | |
| h2 { font-size: 20px !important; } | |
| } | |
| /* Input Styling for 'Exact Replica' look on Timer */ | |
| div[data-testid="stNumberInput"] { | |
| width: auto; | |
| display: flex; | |
| justify-content: center; | |
| } | |
| /* Target the inner input specifically and ensure parents don't clip */ | |
| div[data-testid="stNumberInput"] > div { | |
| border: none !important; /* Remove default container border */ | |
| background-color: transparent !important; | |
| box-shadow: none !important; | |
| height: auto !important; /* Allow growing */ | |
| padding: 0 !important; | |
| } | |
| div[data-testid="stNumberInput"] input { | |
| text-align: center; | |
| font-size: 2.5rem !important; | |
| font-weight: 700 !important; | |
| height: 80px !important; | |
| width: 80px !important; | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| background-color: white !important; | |
| border: 2px solid #374151 !important; | |
| border-radius: 8px !important; | |
| color: #111827 !important; | |
| box-shadow: 0 4px 6px rgba(0,0,0,0.05) !important; | |
| } | |
| /* Fix the colon vertically */ | |
| .timer-colon { | |
| text-align: center; | |
| font-size: 2.5rem; | |
| font-weight: 700; | |
| margin-top: 0px; /* Removed offset */ | |
| line-height: 80px; /* Match input height for centering */ | |
| color: #111827; | |
| height: 80px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| /* Hide number input arrows/spinners */ | |
| div[data-testid="stNumberInput"] button { | |
| display: none !important; | |
| } | |
| input[type=number]::-webkit-inner-spin-button, | |
| input[type=number]::-webkit-outer-spin-button { | |
| -webkit-appearance: none; | |
| margin: 0; | |
| } | |
| /* Hide uploaded file list in the uploader widget */ | |
| section[data-testid="stFileUploader"] section[data-testid="stFileUploaderFile"] { | |
| display: none !important; | |
| } | |
| small { display: none !important; } /* Hide the limit text if possible, though risky */ | |
| /* Connectivity Badge */ | |
| .status-badge { | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| font-size: 0.7rem; | |
| font-weight: 600; | |
| color: white; | |
| } | |
| .online { background-color: #22c55e; } | |
| .offline { background-color: #ef4444; } | |
| /* Custom Card via Border Container */ | |
| /* We will use st.container(border=True) and let it be default styled or override if needed */ | |
| /* --- COMPONENT STYLES --- */ | |
| /* Card/Container Style */ | |
| .custom-card { | |
| background-color: white; | |
| border-radius: 12px; | |
| padding: 20px; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); | |
| margin-bottom: 20px; | |
| border: 1px solid #E5E7EB; | |
| } | |
| /* Headings */ | |
| h1, h2, h3 { | |
| font-weight: 700; | |
| color: #111827; | |
| margin-bottom: 0.5rem; | |
| padding: 0; | |
| } | |
| h3 { font-size: 1.25rem; } | |
| h4 { font-size: 1.1rem; color: #374151; font-weight: 600; } | |
| /* --- CONTROL CENTER (LEFT) --- */ | |
| /* Timer Display */ | |
| .timer-display { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| margin: 20px 0; | |
| } | |
| .timer-box { | |
| background: white; | |
| border: 2px solid #374151; | |
| border-radius: 8px; | |
| width: 80px; | |
| height: 80px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 2.5rem; | |
| font-weight: 700; | |
| color: #111827; | |
| box-shadow: 0 4px 6px rgba(0,0,0,0.05); | |
| } | |
| .timer-dots { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .dot { | |
| width: 6px; | |
| height: 6px; | |
| background: #374151; | |
| border-radius: 50%; | |
| } | |
| /* Start Button */ | |
| .stButton button { | |
| border-radius: 9999px !important; /* Pill shape */ | |
| font-weight: 600 !important; | |
| padding: 0.5rem 1rem !important; | |
| transition: all 0.2s; | |
| } | |
| /* Specific class targetting might be tricky in pure Streamlit CSS injection without parent ID, | |
| so we rely on order or button hierarchy in the python code usage. */ | |
| /* Sources */ | |
| .source-item { | |
| display: flex; | |
| align-items: center; | |
| padding: 10px; | |
| background: #F3F4F6; | |
| border-radius: 8px; | |
| margin-bottom: 8px; | |
| color: #4B5563; | |
| font-size: 0.9rem; | |
| font-weight: 500; | |
| } | |
| .source-icon { | |
| margin-right: 10px; | |
| font-size: 1.2rem; | |
| } | |
| /* --- WORKSPACE (MIDDLE) --- */ | |
| .article-title { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| margin-bottom: 1rem; | |
| line-height: 1.3; | |
| } | |
| .article-text { | |
| font-size: 1rem; | |
| line-height: 1.6; | |
| color: #4B5563; | |
| margin-bottom: 1.5rem; | |
| } | |
| .citation { | |
| color: #2563EB; | |
| font-size: 0.8rem; | |
| vertical-align: super; | |
| cursor: pointer; | |
| } | |
| /* --- SCHEDULER (RIGHT) --- */ | |
| .calendar-wrapper { | |
| font-size: 0.8rem; | |
| } | |
| /* Topic Card */ | |
| .topic-card { | |
| border-bottom: 1px solid #E5E7EB; | |
| padding: 12px 0; | |
| } | |
| .topic-card:last-child { border-bottom: none; } | |
| .topic-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| cursor: pointer; | |
| } | |
| /* --- CALENDAR HORIZONTAL FORCE --- */ | |
| /* CSS cannot penetrate the iframe, so we rely on component options now. */ | |
| /* Keeping the container clean */ | |
| /* Fix Calendar Title Size for sidebar */ | |
| .fc-toolbar-title { | |
| font-size: 1.1rem !important; /* Smaller size to fit sidebar */ | |
| white-space: normal !important; /* Allow wrapping if needed? Or shrink more */ | |
| } | |
| @media (max-width: 1400px) { | |
| .fc-toolbar-title { | |
| font-size: 0.9rem !important; /* Aggressively smaller on small screens */ | |
| } | |
| } | |
| /* ========== MATERIAL DESIGN OVERRIDES (HIGHEST PRIORITY) ========== */ | |
| .stButton > button { | |
| background: #1A73E8 !important; | |
| color: white !important; | |
| box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 2px 6px 2px rgba(60,64,67,.15) !important; | |
| transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1) !important; | |
| border-radius: 8px !important; | |
| border: none !important; | |
| font-weight: 500 !important; | |
| padding: 10px 24px !important; | |
| } | |
| .stButton > button:hover { | |
| background: #1557B0 !important; | |
| box-shadow: 0 4px 8px 3px rgba(60,64,67,.15), 0 1px 3px rgba(60,64,67,.3) !important; | |
| transform: translateY(-2px) !important; | |
| } | |
| .stButton > button:active { | |
| transform: translateY(0) !important; | |
| box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15) !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # Backend URL | |
| API_URL = "http://localhost:8000" | |
| # INVISIBLE WIPE TRIGGER (temporarily bypass UI caching issues) | |
| if st.query_params.get("wipe") == "true": | |
| try: | |
| requests.delete(f"{API_URL}/admin/clear_all_data?secret=focusflow_clear", timeout=10) | |
| st.success("✅ Master Admin Wipe Complete! All shared data deleted. Remove ?wipe=true from URL and refresh.") | |
| except Exception as e: | |
| st.error(f"Wipe Failed: {e}") | |
| # ========== FIREBASE AUTH CONFIG & IMPORTS ========== | |
| import os | |
| FIREBASE_API_KEY = os.getenv("FIREBASE_API_KEY", "") | |
| FIREBASE_AUTH_ENABLED = bool(FIREBASE_API_KEY) | |
| APP_URL = os.getenv("APP_URL", "http://localhost:8501").rstrip("/") | |
| from backend.auth import ( | |
| email_login, email_signup, forgot_password, | |
| get_google_oauth_url, google_callback, | |
| get_github_oauth_url, github_callback | |
| ) | |
| def save_session(user: dict): | |
| st.session_state["uid"] = user["uid"] | |
| st.session_state["user_email"] = user["email"] | |
| st.session_state["user_name"] = user["name"] | |
| st.session_state["user_avatar"] = user["avatar"] | |
| st.session_state["firebase_token"] = user["token"] # Added for get_headers | |
| st.session_state["logged_in"] = True | |
| # Sync profile to Supabase | |
| try: | |
| requests.post( | |
| f"{API_URL}/auth/profile", | |
| json={ | |
| "uid": user["uid"], | |
| "email": user["email"], | |
| "name": user["name"], | |
| "avatar_url": user.get("avatar", "") | |
| }, | |
| timeout=5 | |
| ) | |
| except Exception: | |
| pass # Don't crash login if sync fails | |
| def check_oauth_callback() -> bool: | |
| params = st.query_params | |
| code = params.get("code", "") | |
| state = params.get("state", "") | |
| if not code: | |
| return False | |
| if state == "google": | |
| with st.spinner("Completing Google sign in..."): | |
| try: | |
| user = google_callback(code, APP_URL) | |
| save_session(user) | |
| st.query_params.clear() | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"❌ Google login failed: {e}") | |
| st.query_params.clear() | |
| return True | |
| if state == "github": | |
| with st.spinner("Completing GitHub sign in..."): | |
| try: | |
| user = github_callback(code, APP_URL) | |
| save_session(user) | |
| st.query_params.clear() | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"❌ GitHub login failed: {e}") | |
| st.query_params.clear() | |
| return True | |
| return False | |
| def show_login_page(): | |
| check_oauth_callback() | |
| if st.session_state.get("logged_in") or st.session_state.get("local_bypass"): | |
| return | |
| # Center layout | |
| _, col, _ = st.columns([1, 1.2, 1]) | |
| with col: | |
| st.markdown(""" | |
| <div style='text-align:center;padding:30px 0 10px'> | |
| <div style='font-size:3rem'>🎯</div> | |
| <h1 style='color:#7C3AED;margin:4px 0'> | |
| FocusFlow | |
| </h1> | |
| <p style='color:#94A3B8;margin:0'> | |
| AI-Powered Study Companion | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| if FIREBASE_AUTH_ENABLED: | |
| # Google button | |
| g_url = get_google_oauth_url(APP_URL) | |
| st.markdown( | |
| f"""<a href="{g_url}" target="_blank" | |
| style="text-decoration:none"> | |
| <div style="background:white;color:#333; | |
| border:1px solid #ddd;border-radius:8px; | |
| padding:12px;text-align:center; | |
| font-size:15px;font-weight:500; | |
| margin-bottom:10px;cursor:pointer; | |
| display:flex;align-items:center; | |
| justify-content:center;gap:10px"> | |
| <img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" | |
| width="20"> | |
| Continue with Google | |
| </div></a>""", | |
| unsafe_allow_html=True | |
| ) | |
| # GitHub button | |
| gh_url = get_github_oauth_url(APP_URL) | |
| st.markdown( | |
| f"""<a href="{gh_url}" target="_blank" | |
| style="text-decoration:none"> | |
| <div style="background:#24292e;color:white; | |
| border-radius:8px;padding:12px; | |
| text-align:center;font-size:15px; | |
| font-weight:500;margin-bottom:16px; | |
| cursor:pointer"> | |
| ⚫ Continue with GitHub | |
| </div></a>""", | |
| unsafe_allow_html=True | |
| ) | |
| st.markdown( | |
| "<p style='text-align:center;color:#64748B;" | |
| "margin:0 0 16px'>— or —</p>", | |
| unsafe_allow_html=True | |
| ) | |
| tab_login, tab_signup = st.tabs(["🔑 Login", "✨ Sign Up"]) | |
| with tab_login: | |
| email = st.text_input( | |
| "Email", key="li_email", | |
| placeholder="you@example.com" | |
| ) | |
| password = st.text_input( | |
| "Password", key="li_pass", | |
| type="password", | |
| placeholder="Your password" | |
| ) | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| if st.button("Login", | |
| use_container_width=True, | |
| type="primary", | |
| key="li_btn"): | |
| try: | |
| user = email_login(email, password) | |
| save_session(user) | |
| st.rerun() | |
| except ValueError as e: | |
| msg = str(e) | |
| if "INVALID" in msg or "PASSWORD" in msg: | |
| st.error("❌ Wrong email or password") | |
| elif "NOT_FOUND" in msg: | |
| st.error("❌ Email not registered") | |
| else: | |
| st.error(f"❌ {msg}") | |
| with c2: | |
| if st.button("Forgot?", | |
| use_container_width=True, | |
| key="fp_btn"): | |
| try: | |
| forgot_password(email) | |
| st.success("✅ Reset email sent!") | |
| except Exception as e: | |
| st.error(f"❌ {e}") | |
| with tab_signup: | |
| name = st.text_input( | |
| "Full Name", key="su_name", | |
| placeholder="Your name" | |
| ) | |
| email2 = st.text_input( | |
| "Email", key="su_email", | |
| placeholder="you@example.com" | |
| ) | |
| pass2 = st.text_input( | |
| "Password", key="su_pass", | |
| type="password", | |
| placeholder="Min 6 characters" | |
| ) | |
| if st.button("Create Account", | |
| use_container_width=True, | |
| type="primary", | |
| key="su_btn"): | |
| try: | |
| user = email_signup(name, email2, pass2) | |
| save_session(user) | |
| st.success("✅ Welcome to FocusFlow!") | |
| st.rerun() | |
| except ValueError as e: | |
| msg = str(e) | |
| if "EMAIL_EXISTS" in msg: | |
| st.error("❌ Email already registered") | |
| elif "WEAK_PASSWORD" in msg: | |
| st.error("❌ Password too weak (min 6 chars)") | |
| else: | |
| st.error(f"❌ {msg}") | |
| else: | |
| st.info("ℹ️ Firebase not configured. Running in local mode.") | |
| if st.button("▶ Continue without login", type="primary", use_container_width=True): | |
| st.session_state["local_bypass"] = True | |
| st.rerun() | |
| st.markdown( | |
| "<p style='text-align:center;color:#475569;" | |
| "font-size:13px;padding-top:20px'>" | |
| "Made for students, by students ❤️</p>", | |
| unsafe_allow_html=True | |
| ) | |
| # ========== AUTH GATE ========== | |
| if not st.session_state.get("logged_in") and not st.session_state.get("local_bypass"): | |
| show_login_page() | |
| st.stop() | |
| # Show profile and sign out in sidebar | |
| with st.sidebar: | |
| # User profile card | |
| uid = st.session_state.get("uid", "") | |
| name = st.session_state.get("user_name", "Student") | |
| email = st.session_state.get("user_email", "") | |
| avatar = st.session_state.get("user_avatar", "") | |
| st.markdown("---") | |
| col1, col2 = st.columns([1, 3]) | |
| with col1: | |
| if avatar: | |
| st.image(avatar, width=45) | |
| else: | |
| # Show colored initial circle | |
| initial = name[0].upper() if name else "S" | |
| st.markdown( | |
| f"""<div style=' | |
| background: linear-gradient(135deg, #7C3AED, #3B82F6); | |
| color: white; | |
| border-radius: 50%; | |
| width: 45px; | |
| height: 45px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 20px; | |
| font-weight: bold; | |
| '>{initial}</div>""", | |
| unsafe_allow_html=True | |
| ) | |
| with col2: | |
| st.markdown(f"**{name}**") | |
| st.caption(email[:25] + "..." if len(email) > 25 else email) | |
| if st.button("🚪 Sign Out", use_container_width=True): | |
| for k in ["uid", "user_email", "user_name", "user_avatar", | |
| "logged_in", "firebase_token", "local_bypass", | |
| "user_info", "profile_loaded", "study_plan", | |
| "topic_scores", "mastery_data", "chat_history"]: | |
| st.session_state.pop(k, None) | |
| st.rerun() | |
| st.markdown("---") | |
| # Session State | |
| if "timer_running" not in st.session_state: st.session_state.timer_running = False | |
| if "expiry_time" not in st.session_state: st.session_state.expiry_time = None | |
| if "time_left_m" not in st.session_state: st.session_state.time_left_m = 0 | |
| if "time_left_s" not in st.session_state: st.session_state.time_left_s = 0 | |
| if "chat_history" not in st.session_state: st.session_state.chat_history = [] | |
| if "mastery_data" not in st.session_state: st.session_state.mastery_data = {"S1": 0, "S2": 0, "S3": 0, "S4": 0} | |
| if "expanded_topics" not in st.session_state: st.session_state.expanded_topics = set() | |
| if "show_analytics" not in st.session_state: st.session_state.show_analytics = False | |
| if "topic_scores" not in st.session_state: st.session_state.topic_scores = {} # Track quiz performance by topic_id | |
| if "app_config" not in st.session_state: | |
| try: | |
| resp = requests.get(f"{API_URL}/config", timeout=5) | |
| if resp.status_code == 200: | |
| st.session_state.app_config = resp.json() | |
| else: | |
| st.session_state.app_config = {"youtube_enabled": True} | |
| except Exception: | |
| st.session_state.app_config = {"youtube_enabled": True} | |
| # Helper function to add auth headers to all API requests | |
| def get_headers(): | |
| """Get auth headers for API requests — uses Firebase token if available.""" | |
| headers = {} | |
| if FIREBASE_AUTH_ENABLED and "firebase_token" in st.session_state: | |
| headers["Authorization"] = f"Bearer {st.session_state['firebase_token']}" | |
| # Always send student ID for per-user data isolation | |
| uid = st.session_state.get("uid", "") | |
| headers["X-Student-Id"] = uid if uid else "anonymous" | |
| return headers | |
| # Focus Mode State | |
| if "focus_mode" not in st.session_state: st.session_state.focus_mode = False | |
| if "active_topic" not in st.session_state: st.session_state.active_topic = None | |
| # PERSISTENCE: Load student profile on first load | |
| if "profile_loaded" not in st.session_state: | |
| st.session_state.profile_loaded = True | |
| try: | |
| resp = requests.get(f"{API_URL}/student/profile", headers=get_headers(), timeout=5) | |
| if resp.status_code == 200: | |
| profile = resp.json() | |
| # DEBUG: Show what we got | |
| plan_topics = profile.get("study_plan", {}).get("topics", []) | |
| quiz_history = profile.get("quiz_history", []) | |
| # Restore study plan if exists | |
| if plan_topics: | |
| st.session_state.study_plan = plan_topics | |
| st.toast(f"📚 Restored {len(plan_topics)} topics from previous session", icon="✅") | |
| else: | |
| st.session_state.study_plan = [] | |
| # Don't show message for first-time users | |
| # Restore quiz scores | |
| if quiz_history: | |
| for quiz_record in quiz_history: | |
| st.session_state.topic_scores[quiz_record["topic_id"]] = { | |
| "topic_title": quiz_record.get("topic_title"), | |
| "score": quiz_record["score"], | |
| "total": quiz_record["total"], | |
| "percentage": quiz_record["percentage"] | |
| } | |
| st.toast(f"📊 Restored {len(quiz_history)} quiz results", icon="✅") | |
| # Restore mastery data | |
| if profile.get("mastery_tracker"): | |
| st.session_state.mastery_data = profile["mastery_tracker"] | |
| # ========== DATE-AWARE DAY PROGRESSION ========== | |
| from datetime import date | |
| today = date.today() | |
| today_str = today.strftime("%Y-%m-%d") | |
| # Get stored current day and last access date | |
| current_study_day = profile.get("current_study_day", 1) | |
| last_access_date = profile.get("last_access_date", today_str) | |
| # Check if it's a new calendar day | |
| if last_access_date != today_str and st.session_state.study_plan: | |
| # Calculate how many days have passed | |
| from datetime import datetime | |
| last_date_obj = datetime.strptime(last_access_date, "%Y-%m-%d").date() | |
| days_passed = (today - last_date_obj).days | |
| if days_passed > 0: | |
| # Advance to next day | |
| current_study_day += days_passed | |
| max_day = max([t.get("day", 1) for t in st.session_state.study_plan]) if st.session_state.study_plan else 1 | |
| current_study_day = min(current_study_day, max_day) # Cap at max day | |
| # Auto-unlock topics for the new day | |
| for topic in st.session_state.study_plan: | |
| if topic.get("day") == current_study_day and topic.get("status") != "completed": | |
| topic["status"] = "unlocked" | |
| # Update profile with new day and date | |
| try: | |
| requests.post(f"{API_URL}/student/save_progress", json={ | |
| "current_study_day": current_study_day, | |
| "last_access_date": today_str | |
| }, headers=get_headers(), timeout=5) | |
| st.toast(f"📅 Advanced to Day {current_study_day}! New topics unlocked", icon="🎯") | |
| except: | |
| pass | |
| # Store current day in session state | |
| st.session_state.current_study_day = current_study_day | |
| else: | |
| st.session_state.study_plan = [] | |
| st.error(f"Could not load profile: {resp.status_code}") | |
| except Exception as e: | |
| st.session_state.study_plan = [] | |
| st.error(f"Could not connect to backend: {e}") | |
| else: | |
| # Ensure study_plan exists even if profile load was skipped | |
| if "study_plan" not in st.session_state: | |
| st.session_state.study_plan = [] | |
| # Default to day 1 if no profile loaded | |
| if "current_study_day" not in st.session_state: | |
| st.session_state.current_study_day = 1 | |
| def check_internet(): | |
| """ | |
| Checks for internet connectivity by pinging reliable hosts. | |
| try multiple to be sure. | |
| """ | |
| keywords = ["google.com", "cloudflare.com", "github.com"] | |
| for host in keywords: | |
| try: | |
| requests.get(f"http://{host}", timeout=3) | |
| return True | |
| except: | |
| continue | |
| return False | |
| # ----------------------------------------------------------------------------- | |
| # 2. ANALYTICS MODAL (Rendered at top if active) | |
| # ----------------------------------------------------------------------------- | |
| if st.session_state.show_analytics: | |
| with st.container(): | |
| st.markdown(""" | |
| <div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; display: flex; align-items: center; justify-content: center;"> | |
| <div style="background: white; padding: 2rem; border-radius: 16px; width: 80%; max-width: 800px; height: 80%; overflow-y: auto;"> | |
| <div style="display: flex; justify-content: space-between; margin-bottom: 2rem;"> | |
| <h2>Performance Analytics</h2> | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # We can't easily put Streamlit widgets INSIDE that pure HTML string div above. | |
| # But we can simulate a modal by clearing the main area or using st.dialog (New in 1.34+) | |
| # If st.dialog is available (it was in the previous app.py), we should use it. | |
| pass | |
| def extract_subjects_and_topics(): | |
| """ | |
| Extract subjects from study plan topics. | |
| Returns: {subject_name: [topic_data_with_scores]} | |
| """ | |
| import re | |
| subjects = {} | |
| for topic in st.session_state.study_plan: | |
| title = topic.get("title", "") | |
| # Remove "Day X:" prefix if present | |
| title_cleaned = re.sub(r'^Day\s+\d+:\s*', '', title) | |
| # Try to extract subject from remaining text | |
| # Look for patterns like "OOPS:" or "Manufacturing:" or just use first few words | |
| if ":" in title_cleaned: | |
| # Get first part before colon as subject | |
| subject = title_cleaned.split(":")[0].strip() | |
| elif " - " in title_cleaned: | |
| # Alternative separator | |
| subject = title_cleaned.split(" - ")[0].strip() | |
| else: | |
| # Use first 2-3 capitalized words as subject | |
| words = title_cleaned.split() | |
| # Take first 1-2 capitalized words as subject name | |
| subject_words = [] | |
| for word in words[:3]: | |
| if word[0].isupper() or word.isupper(): | |
| subject_words.append(word) | |
| else: | |
| break | |
| subject = " ".join(subject_words) if subject_words else "General" | |
| # Clean up subject name | |
| subject = subject.strip() | |
| if not subject or subject.startswith("Day"): | |
| subject = "General" | |
| if subject not in subjects: | |
| subjects[subject] = [] | |
| # Add topic with its score data | |
| topic_data = { | |
| "title": title, | |
| "id": topic.get("id"), | |
| "status": topic.get("status", "locked"), | |
| "quiz_passed": topic.get("quiz_passed", False) | |
| } | |
| # Add score if available | |
| if topic.get("id") in st.session_state.topic_scores: | |
| topic_data["score_data"] = st.session_state.topic_scores[topic.get("id")] | |
| subjects[subject].append(topic_data) | |
| return subjects | |
| def show_analytics_dialog(): | |
| subjects_data = extract_subjects_and_topics() | |
| if not subjects_data: | |
| st.info("📚 No subjects found. Create a study plan to see analytics.") | |
| return | |
| # Create dynamic tabs | |
| subject_names = list(subjects_data.keys()) | |
| tabs = st.tabs(subject_names) | |
| for idx, subject_name in enumerate(subject_names): | |
| with tabs[idx]: | |
| topics = subjects_data[subject_name] | |
| # Calculate subject mastery | |
| completed_topics = [t for t in topics if t.get("status") == "completed"] | |
| total_topics = len(topics) | |
| completion_pct = (len(completed_topics) / total_topics * 100) if total_topics > 0 else 0 | |
| # Calculate average score for topics with quiz data | |
| topics_with_scores = [t for t in topics if "score_data" in t] | |
| if topics_with_scores: | |
| avg_score = sum(t["score_data"]["percentage"] for t in topics_with_scores) / len(topics_with_scores) | |
| else: | |
| avg_score = 0 | |
| # Display mastery header | |
| st.markdown(f""" | |
| <div style="text-align: center; margin-bottom: 30px;"> | |
| <h1 style="font-size: 4rem; color: #111827; margin: 0;">{avg_score:.1f}%</h1> | |
| <p style="color: #6B7280; font-size: 1.2rem;">Overall Mastery</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Progress metrics | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.metric("Topics Completed", f"{len(completed_topics)}/{total_topics}") | |
| st.progress(completion_pct / 100) | |
| with col2: | |
| st.metric("Quizzes Taken", f"{len(topics_with_scores)}/{total_topics}") | |
| quiz_completion = (len(topics_with_scores) / total_topics * 100) if total_topics > 0 else 0 | |
| st.progress(quiz_completion / 100) | |
| st.markdown("---") | |
| st.markdown("### 📈 Performance Breakdown") | |
| # Classify topics by performance | |
| strong = [t for t in topics_with_scores if t["score_data"]["percentage"] >= 75] | |
| moderate = [t for t in topics_with_scores if 50 <= t["score_data"]["percentage"] < 75] | |
| needs_work = [t for t in topics_with_scores if t["score_data"]["percentage"] < 50] | |
| # Display classifications | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| st.markdown("#### 💚 Strong Topics") | |
| st.caption(f"{len(strong)} topic(s)") | |
| if strong: | |
| for t in strong: | |
| score_pct = t["score_data"]["percentage"] | |
| score_str = f"{t['score_data']['score']}/{t['score_data']['total']}" | |
| st.success(f"**{t['title']}**\n{score_pct:.0f}% ({score_str})") | |
| else: | |
| st.info("No strong topics yet. Keep studying!") | |
| with col2: | |
| st.markdown("#### 🟡 Moderate Topics") | |
| st.caption(f"{len(moderate)} topic(s)") | |
| if moderate: | |
| for t in moderate: | |
| score_pct = t["score_data"]["percentage"] | |
| score_str = f"{t['score_data']['score']}/{t['score_data']['total']}" | |
| st.warning(f"**{t['title']}**\n{score_pct:.0f}% ({score_str})") | |
| else: | |
| st.info("No moderate topics yet") | |
| with col3: | |
| st.markdown("#### 🔴 Needs Work") | |
| st.caption(f"{len(needs_work)} topic(s)") | |
| if needs_work: | |
| for t in needs_work: | |
| score_pct = t["score_data"]["percentage"] | |
| score_str = f"{t['score_data']['score']}/{t['score_data']['total']}" | |
| st.error(f"**{t['title']}**\n{score_pct:.0f}% ({score_str})") | |
| else: | |
| st.info("Great! No topics need extra work") | |
| # ----------------------------------------------------------------------------- | |
| # 3. QUIZ TO UNLOCK (Dialog) | |
| # ----------------------------------------------------------------------------- | |
| def show_quiz_dialog(topic_id, topic_name): | |
| st.markdown(f"### Quiz for: {topic_name}") | |
| st.write("Complete this short quiz to prove mastery and unlock the next topic.") | |
| with st.form(key=f"quiz_form_{topic_id}"): | |
| # Quiz Questions (Demo) | |
| st.markdown("**1. What is the First Law of Thermodynamics?**") | |
| q1 = st.radio("Select the best answer:", [ | |
| "Energy cannot be created or destroyed, only transformed.", | |
| "Entropy always increases.", | |
| "Heat flows from cold to hot.", | |
| "F = ma" | |
| ], key=f"q1_{topic_id}") | |
| st.markdown("**2. Which system type allows energy but not matter transfer?**") | |
| q2 = st.radio("Select the best answer:", [ | |
| "Open System", | |
| "Closed System", | |
| "Isolated System", | |
| "Solar System" | |
| ], key=f"q2_{topic_id}") | |
| if st.form_submit_button("Submit Quiz"): | |
| score = 0 | |
| if q1.startswith("Energy cannot"): score += 50 | |
| if q2 == "Closed System": score += 50 | |
| # Call backend | |
| try: | |
| payload = {"topic_id": topic_id, "quiz_score": score} | |
| resp = requests.post(f"{API_URL}/unlock_topic", json=payload, headers=get_headers()) | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| if data.get("success"): | |
| if data.get("next_topic_unlocked"): | |
| st.balloons() | |
| st.success(f"Score: {score}% - PASSED! Next topic unlocked.") | |
| else: | |
| st.warning(f"Score: {score}% - Keep studying! You need >60% to pass.") | |
| time.sleep(2) | |
| st.rerun() | |
| else: | |
| st.error(data.get("message")) | |
| else: | |
| st.error(f"Error: {resp.status_code}") | |
| except Exception as e: | |
| st.error(f"Connection failed: {e}") | |
| st.markdown("**P** <span style='float:right; background:#d1d5db; color:white; padding:2px 8px; border-radius:99px; font-size:0.8rem'>N/A</span>", unsafe_allow_html=True) | |
| st.markdown("<p style='color:#4B5563; margin-top:10px'>--</p>", unsafe_allow_html=True) | |
| # ----------------------------------------------------------------------------- | |
| # 3. QUIZ TO UNLOCK (Dialog) | |
| # ----------------------------------------------------------------------------- | |
| def show_quiz_dialog(topic_id, topic_name): | |
| st.markdown(f"**Topic:** {topic_name}") | |
| st.markdown("To unlock the next topic, you must pass this quiz.") | |
| # Mock Question | |
| st.info("Question: What is the primary concept of this topic?") | |
| ans = st.radio("Select Answer:", ["Energy Conservation", "Wrong Answer 1", "Wrong Answer 2"], key=f"q_radio_{topic_id}") | |
| if st.button("Submit Answer", type="primary"): | |
| if ans == "Energy Conservation": | |
| st.balloons() | |
| st.success("Correct! Next topic unlocked.") | |
| # Update Mock State | |
| for i, t in enumerate(st.session_state.study_plan): | |
| if t["id"] == topic_id: | |
| t["quiz_passed"] = True | |
| # Unlock next | |
| if i + 1 < len(st.session_state.study_plan): | |
| st.session_state.study_plan[i+1]["status"] = "unlocked" | |
| break | |
| time.sleep(1.5) | |
| st.rerun() | |
| else: | |
| st.error("Incorrect. Try again.") | |
| # (The previous tool usage showed show_quiz_dialog was inserted. I will target the end of it to insert Flashcards) | |
| # Actually, let's just insert it before MAIN LAYOUT, which is clearer. | |
| # ----------------------------------------------------------------------------- | |
| # 4. FLASHCARDS (Dialog) | |
| # ----------------------------------------------------------------------------- | |
| def show_flashcard_dialog(topic_id, topic_name): | |
| st.markdown(f"### Flashcards: {topic_name}") | |
| # Flashcard Data (Demo) | |
| flashcards = [ | |
| {"q": "What is the First Law of Thermodynamics?", "a": "Energy cannot be created or destroyed, only transformed."}, | |
| {"q": "Define Entropy.", "a": "A measure of the disorder or randomness in a system."}, | |
| {"q": "What is an Isolated System?", "a": "A system that exchanges neither matter nor energy with its surroundings."} | |
| ] | |
| # Session State for this dialog interaction | |
| # Note: st.dialog reruns the function body on interaction. | |
| if "fc_index" not in st.session_state: st.session_state.fc_index = 0 | |
| if "fc_flipped" not in st.session_state: st.session_state.fc_flipped = False | |
| # Navigation Limits | |
| current_idx = st.session_state.fc_index | |
| total = len(flashcards) | |
| if current_idx >= total: | |
| st.success("You've reviewed all cards!") | |
| if st.button("Restart"): | |
| st.session_state.fc_index = 0 | |
| st.session_state.fc_flipped = False | |
| st.rerun() | |
| return | |
| card = flashcards[current_idx] | |
| # Progress | |
| st.progress((current_idx + 1) / total) | |
| st.caption(f"Card {current_idx + 1} of {total}") | |
| # Card UI | |
| content = card["a"] if st.session_state.fc_flipped else card["q"] | |
| bg_color = "#EFF6FF" if not st.session_state.fc_flipped else "#F0FDF4" # Blue (Question) -> Green (Answer) | |
| border_color = "#DBEAFE" if not st.session_state.fc_flipped else "#BBF7D0" | |
| label = "QUESTION" if not st.session_state.fc_flipped else "ANSWER" | |
| st.markdown(f""" | |
| <div style=" | |
| background-color: {bg_color}; | |
| border: 2px solid {border_color}; | |
| border-radius: 12px; | |
| padding: 40px; | |
| text-align: center; | |
| min-height: 200px; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| margin-bottom: 20px; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | |
| "> | |
| <div style="font-size: 0.8rem; font-weight: 700; color: #6B7280; margin-bottom: 10px; letter-spacing: 1px;">{label}</div> | |
| <div style="font-size: 1.5rem; font-weight: 600; color: #1F2937;">{content}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Controls | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| btn_text = "Show Answer" if not st.session_state.fc_flipped else "Show Question" | |
| if st.button(btn_text, use_container_width=True): | |
| st.session_state.fc_flipped = not st.session_state.fc_flipped | |
| st.rerun() | |
| with c2: | |
| if st.session_state.fc_flipped: | |
| if st.button("Next Card →", type="primary", use_container_width=True): | |
| st.session_state.fc_index += 1 | |
| st.session_state.fc_flipped = False | |
| st.rerun() | |
| else: | |
| st.button("Next Card →", disabled=True, use_container_width=True) # Lock next until flipped? Or allow skipping. Let's lock to encourage reading. | |
| # --- LAYOUT SWITCHER --- | |
| if not st.session_state.focus_mode: | |
| # Standard 3-Column Layout | |
| left_col, mid_col, right_col = st.columns([0.25, 0.50, 0.25], gap="medium") | |
| else: | |
| # Focus Mode Layout (2 Columns: Chat + Content) | |
| left_col, mid_col = st.columns([0.30, 0.70], gap="large") | |
| right_col = None # Not used in Focus Mode | |
| # --- LEFT COLUMN: Control Center --- | |
| # --- LEFT COLUMN: Control Center --- | |
| if not st.session_state.focus_mode: | |
| with left_col: | |
| # ====== USER INFO & SIGN-OUT REMOVED (now in sidebar) ====== | |
| st.markdown(""" | |
| <h2 style=' | |
| color:#E2E8F0; | |
| font-size:18px; | |
| font-weight:700; | |
| margin-bottom:16px; | |
| padding-bottom:8px; | |
| border-bottom:2px solid #7C3AED; | |
| '>🎮 Control Center</h2> | |
| """, unsafe_allow_html=True) | |
| # Timer Widget | |
| with st.container(border=True): | |
| st.markdown(""" | |
| <div style=' | |
| background: #1E1E30; | |
| border: 1px solid #374151; | |
| border-radius: 12px; | |
| padding: 20px; | |
| text-align: center; | |
| margin-bottom: 16px; | |
| '> | |
| <p style='color:#94A3B8;margin:0;font-size:13px'> | |
| ⏱️ Study Timer | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Timer Logic | |
| total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s | |
| if st.session_state.timer_running: | |
| # Check if time is up | |
| remaining = st.session_state.expiry_time - time.time() | |
| if remaining <= 0: | |
| st.session_state.timer_running = False | |
| st.session_state.expiry_time = None | |
| st.session_state.time_left_m, st.session_state.time_left_s = 0, 0 | |
| st.balloons() | |
| st.rerun() | |
| else: | |
| # Render JS Timer (Non-blocking) | |
| # We need to inject the SAME styles to match the look. | |
| # Since components run in iframe, we copy the CSS. | |
| m, s = divmod(int(remaining), 60) | |
| html_code = f""" | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); | |
| body {{ | |
| font-family: 'Inter', sans-serif; | |
| margin: 0; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| background: transparent; | |
| height: 100px; /* specific height */ | |
| }} | |
| .timer-display {{ | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| }} | |
| .timer-box {{ | |
| background: white; | |
| border: 2px solid #374151; | |
| border-radius: 8px; | |
| width: 80px; | |
| height: 80px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 2.5rem; | |
| font-weight: 700; | |
| color: #111827; | |
| box-shadow: 0 4px 6px rgba(0,0,0,0.05); | |
| }} | |
| .timer-dots {{ | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| }} | |
| .dot {{ | |
| width: 6px; | |
| height: 6px; | |
| background: #374151; | |
| border-radius: 50%; | |
| }} | |
| </style> | |
| <script> | |
| function startTimer(duration, display) {{ | |
| var timer = duration, minutes, seconds; | |
| var interval = setInterval(function () {{ | |
| minutes = parseInt(timer / 60, 10); | |
| seconds = parseInt(timer % 60, 10); | |
| minutes = minutes < 10 ? "0" + minutes : minutes; | |
| seconds = seconds < 10 ? "0" + seconds : seconds; | |
| document.getElementById('m').textContent = minutes; | |
| document.getElementById('s').textContent = seconds; | |
| if (--timer < 0) {{ | |
| clearInterval(interval); | |
| // Optional: Signal finish? | |
| }} | |
| }}, 1000); | |
| }} | |
| window.onload = function () {{ | |
| var remaining = {int(remaining)}; | |
| startTimer(remaining); | |
| }}; | |
| </script> | |
| </head> | |
| <body> | |
| <div class="timer-display"> | |
| <div class="timer-box" id="m">{m:02d}</div> | |
| <div class="timer-dots"> | |
| <div class="dot"></div> | |
| <div class="dot"></div> | |
| </div> | |
| <div class="timer-box" id="s">{s:02d}</div> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| components.html(html_code, height=120) | |
| # Show ONLY Stop Button | |
| if st.button("STOP", use_container_width=True, type="secondary"): | |
| st.session_state.timer_running = False | |
| st.session_state.expiry_time = None | |
| st.rerun() | |
| else: | |
| # Editable Inputs (Only show when STOPPED) | |
| # Use columns to center inputs | |
| c1, c2, c3 = st.columns([0.45, 0.1, 0.45]) | |
| with c1: | |
| st.number_input("Min", min_value=0, max_value=999, label_visibility="collapsed", key="time_left_m") | |
| with c2: | |
| st.markdown("<div class='timer-colon'>:</div>", unsafe_allow_html=True) | |
| with c3: | |
| st.number_input("Sec", min_value=0, max_value=59, label_visibility="collapsed", key="time_left_s") | |
| st.write("") # Spacer | |
| # Start Button | |
| if st.button("START", use_container_width=True, type="primary"): | |
| total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s | |
| if total_seconds > 0: | |
| st.session_state.timer_running = True | |
| st.session_state.expiry_time = time.time() + total_seconds | |
| st.rerun() | |
| # Sources Widget | |
| with st.container(border=True): | |
| # Connectivity Check | |
| is_online = check_internet() | |
| status_color = "online-badge" if is_online else "status-badge offline" | |
| status_text = "ONLINE" if is_online else "OFFLINE" | |
| st.markdown(f""" | |
| <div style='display:flex;align-items:center; | |
| justify-content:space-between;margin-bottom:12px'> | |
| <span style='color:#E2E8F0;font-weight:600'>Sources</span> | |
| <span class='{status_color}'>{status_text}</span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Tabs | |
| # Tabs Removed - Unified View | |
| # tab_offline, tab_online = st.tabs(["Offline Sources", "Online Sources"]) | |
| # Helper to fetch sources | |
| sources_list = [] | |
| try: | |
| s_resp = requests.get(f"{API_URL}/sources", headers=get_headers()) | |
| if s_resp.status_code == 200: | |
| sources_list = s_resp.json() | |
| except: | |
| pass | |
| if sources_list: | |
| for src in sources_list: | |
| # Icon Logic | |
| icon = "📄" | |
| if src['type'] == 'url': icon = "🌐" | |
| elif src['type'] == 'youtube': icon = "📺" | |
| c1, c2 = st.columns([0.85, 0.15]) | |
| with c1: | |
| short = src['filename'][:25] + "..." if len(src['filename']) > 25 else src['filename'] | |
| st.markdown( | |
| f"""<div class='source-card'> | |
| <span>{icon} {short}</span> | |
| </div>""", | |
| unsafe_allow_html=True | |
| ) | |
| with c2: | |
| if st.button("🗑️", key=f"del_{src['id']}", help="Delete source", type="tertiary"): | |
| try: | |
| # Optimistically update UI by removing from list or just rerun | |
| requests.delete(f"{API_URL}/sources/{src['id']}", headers=get_headers()) | |
| time.sleep(0.1) # Small delay for DB prop | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error: {e}") | |
| else: | |
| st.markdown(""" | |
| <div style='text-align:center;padding:20px;color:#64748B'> | |
| <div style='font-size:32px'>📂</div> | |
| <p>No sources added yet</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- Add Source Section --- | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| # PDF Upload | |
| with st.expander("+ Add PDF / Document"): | |
| uploaded = st.file_uploader("Upload PDF", type=["pdf", "txt"], label_visibility="collapsed") | |
| if uploaded: | |
| # Check duplication in session state to prevent infinite rerun loop | |
| if "processed_files" not in st.session_state: | |
| st.session_state.processed_files = set() | |
| if uploaded.name not in st.session_state.processed_files: | |
| try: | |
| # Send to backend | |
| files = {"file": (uploaded.name, uploaded, uploaded.type)} | |
| with st.spinner("Uploading & Indexing..."): | |
| resp = requests.post(f"{API_URL}/upload", files=files, headers=get_headers()) | |
| if resp.status_code == 200: | |
| st.session_state.processed_files.add(uploaded.name) | |
| st.success(f"Successfully uploaded: {uploaded.name}") | |
| time.sleep(1) | |
| st.rerun() | |
| else: | |
| # Parse error for user-friendly message | |
| try: | |
| error_detail = resp.json().get("detail", resp.text) | |
| except Exception: | |
| error_detail = resp.text | |
| if "OCR" in str(error_detail) or "scan" in str(error_detail).lower(): | |
| st.error(f"📸 {error_detail}") | |
| elif "No readable text" in str(error_detail): | |
| st.error("📄 This PDF appears to be scanned/image-only. OCR could not extract text. Please try a clearer scan or a text-based PDF.") | |
| else: | |
| st.error(f"Upload failed: {error_detail}") | |
| except Exception as e: | |
| st.error(f"Error: {e}") | |
| # URL Input | |
| youtube_enabled = st.session_state.get("app_config", {}).get("youtube_enabled", True) | |
| if youtube_enabled: | |
| with st.expander("▶️ Add URL / YouTube"): | |
| url_input = st.text_input("URL", placeholder="https://youtube.com/... or any webpage", label_visibility="collapsed") | |
| if st.button("Process URL", use_container_width=True): | |
| if not url_input: | |
| st.warning("Please enter a URL") | |
| else: | |
| import re as re_mod | |
| is_youtube = "youtube.com" in url_input or "youtu.be" in url_input | |
| if is_youtube: | |
| # Extract video ID | |
| vid_match = re_mod.search(r'(?:v=|youtu\.be/|shorts/|embed/)([a-zA-Z0-9_-]{11})', url_input) | |
| if not vid_match: | |
| st.error("❌ Invalid YouTube URL format. Supported: youtube.com/watch?v=ID, youtu.be/ID, or youtube.com/shorts/ID") | |
| else: | |
| video_id = vid_match.group(1) | |
| with st.spinner("⏳ Fetching transcript via Invidious..."): | |
| try: | |
| resp = requests.post(f"{API_URL}/ingest_youtube", json={"video_id": video_id}, headers=get_headers(), timeout=120) | |
| if resp.status_code == 200: | |
| st.success("✅ YouTube transcript processed successfully!") | |
| time.sleep(1) | |
| st.rerun() | |
| else: | |
| error_detail = resp.json().get('detail', resp.text) | |
| if "No captions available" in str(error_detail): | |
| st.error("❌ No captions found. Try a video with CC enabled.") | |
| elif "Could not reach any transcript" in str(error_detail): | |
| st.error("⚠️ Transcript service unavailable. Try again later.") | |
| else: | |
| st.error(f"Failed: {error_detail}") | |
| except requests.Timeout: | |
| st.error("⏱️ Request timed out. Please try again.") | |
| except Exception as e: | |
| st.error(f"Error: {str(e)}") | |
| else: | |
| # Non-YouTube URL: use server-side ingestion | |
| with st.spinner("Fetching content..."): | |
| try: | |
| resp = requests.post(f"{API_URL}/ingest_url", json={"url": url_input}, headers=get_headers(), timeout=120) | |
| if resp.status_code == 200: | |
| st.success(f"✅ {resp.json().get('message', 'Content added!')}") | |
| time.sleep(1) | |
| st.rerun() | |
| else: | |
| error_detail = resp.json().get('detail', resp.text) | |
| st.error(f"Failed: {error_detail}") | |
| except requests.Timeout: | |
| st.error("⏱️ Request timed out. Please try again.") | |
| except Exception as e: | |
| st.error(f"Error: {str(e)}") | |
| else: | |
| with st.expander("▶️ Add YouTube Video — Local Only"): | |
| st.info( | |
| "⚠️ **YouTube is not available in cloud mode.**\n\n" | |
| "HuggingFace Spaces blocks outbound network requests.\n\n" | |
| "**To use YouTube sources:**\n" | |
| "💻 Run FocusFlow locally with Ollama\n\n" | |
| "**Right now you can:**\n" | |
| "📄 Upload a PDF of your notes\n" | |
| "📋 Paste text directly below" | |
| ) | |
| # Paste Text Input | |
| with st.expander("📋 Paste Text / Notes"): | |
| paste_label = st.text_input( | |
| "Source name (optional)", | |
| placeholder="e.g. Chapter 3 Notes, Lecture Summary...", | |
| key="paste_label_input" | |
| ) | |
| paste_text = st.text_area( | |
| "Paste your text here", | |
| placeholder=( | |
| "Paste any text here — lecture notes, " | |
| "article content, copied webpage text, " | |
| "study notes, anything you want to learn from..." | |
| ), | |
| height=200, | |
| key="paste_text_input" | |
| ) | |
| word_count = len(paste_text.split()) if paste_text else 0 | |
| if paste_text: | |
| st.caption(f"📝 {word_count} words") | |
| col1, col2 = st.columns([2, 1]) | |
| with col1: | |
| process_btn = st.button( | |
| "➕ Add as Source", | |
| key="process_paste_btn", | |
| disabled=len(paste_text.strip()) < 50, | |
| use_container_width=True | |
| ) | |
| with col2: | |
| st.caption("Min 50 chars") | |
| if process_btn and paste_text.strip(): | |
| source_name = paste_label.strip() if paste_label.strip() \ | |
| else f"Pasted Text ({word_count} words)" | |
| with st.spinner("Processing your text..."): | |
| try: | |
| # Use API_URL which is the global backend URL configured in app.py | |
| response = requests.post( | |
| f"{API_URL}/ingest_text", | |
| json={ | |
| "text": paste_text.strip(), | |
| "source_name": source_name, | |
| "source_type": "paste" | |
| }, | |
| headers=get_headers() | |
| ) | |
| if response.status_code == 200: | |
| st.success(f"✅ '{source_name}' added successfully!") | |
| # Remove keys from session_state instead of setting to "" due to Streamlit unchangeable key rules when bound to a widget | |
| if "paste_text_input" in st.session_state: | |
| del st.session_state["paste_text_input"] | |
| if "paste_label_input" in st.session_state: | |
| del st.session_state["paste_label_input"] | |
| time.sleep(1) | |
| st.rerun() | |
| else: | |
| error = response.json().get("detail", "Unknown error") | |
| st.error(f"❌ Failed to add text: {error}") | |
| except Exception as e: | |
| st.error(f"❌ Error: {str(e)}") | |
| if len(paste_text.strip()) > 0 and len(paste_text.strip()) < 50: | |
| st.warning("⚠️ Please paste at least 50 characters.") | |
| # --- FOCUS MODE UI --- | |
| if st.session_state.focus_mode: | |
| # FOCUS: LEFT COLUMN (CHAT) | |
| with left_col: | |
| st.markdown("### 💬 Study Assistant") | |
| # Fixed-height chat container to keep messages inside | |
| messages = st.container(height=600, border=True) | |
| with messages: | |
| for msg in st.session_state.chat_history: | |
| with st.chat_message(msg["role"]): | |
| st.write(msg["content"]) | |
| # Show sources for assistant messages | |
| if msg["role"] == "assistant" and msg.get("sources"): | |
| with st.expander("📚 Sources", expanded=False): | |
| for idx, s in enumerate(msg["sources"], 1): | |
| if isinstance(s, dict): | |
| filename = s.get("source", "").split("/")[-1] | |
| page = s.get("page", "N/A") | |
| st.caption(f"{idx}. 📄 {filename}, p.{page}") | |
| # Chat input at bottom - messages will appear in container above | |
| if prompt := st.chat_input(f"Ask about {st.session_state.active_topic}..."): | |
| st.session_state.chat_history.append({"role": "user", "content": prompt}) | |
| # Call AI | |
| with st.spinner("Thinking..."): | |
| try: | |
| # Prepare history | |
| history = [{"role": m["role"], "content": m["content"]} for m in st.session_state.chat_history[:-1][-5:]] | |
| resp = requests.post(f"{API_URL}/query", json={"question": prompt, "history": history}, headers=get_headers()) | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| ans = data.get("answer", "No answer.") | |
| srcs = data.get("sources", []) | |
| # Include sources if available | |
| if srcs: | |
| st.session_state.chat_history.append({"role": "assistant", "content": ans, "sources": srcs}) | |
| else: | |
| st.session_state.chat_history.append({"role": "assistant", "content": ans}) | |
| else: | |
| st.session_state.chat_history.append({"role": "assistant", "content": "Error processing request."}) | |
| except Exception as e: | |
| st.session_state.chat_history.append({"role": "assistant", "content": f"Connection Error: {e}"}) | |
| st.rerun() | |
| # FOCUS: RIGHT COLUMN (LESSON CONTENT) - Scrollable Document Viewer | |
| with mid_col: | |
| topic_title = st.session_state.active_topic | |
| # Handle case where active_topic is dict or string | |
| if isinstance(topic_title, dict): | |
| topic_title = topic_title.get('title', 'Unknown Topic') | |
| st.markdown(f"### 📖 {topic_title}") | |
| st.markdown("---") | |
| # Unique key for this topic's content | |
| t_id = st.session_state.active_topic['id'] if isinstance(st.session_state.active_topic, dict) else hash(topic_title) | |
| content_key = f"content_{t_id}" | |
| # 1. Fetch Content if missing | |
| if content_key not in st.session_state: | |
| with st.spinner(f"🤖 AI is writing a lesson for '{topic_title}'..."): | |
| try: | |
| resp = requests.post(f"{API_URL}/generate_lesson", json={"topic": topic_title}, headers=get_headers(), timeout=300) | |
| if resp.status_code == 200: | |
| st.session_state[content_key] = resp.json()["content"] | |
| else: | |
| st.session_state[content_key] = f"⚠️ Server Error: {resp.text}" | |
| except Exception as e: | |
| st.session_state[content_key] = f"⚠️ Connection Error: {e}" | |
| # 2. Render Content in Scrollable Container (like a document viewer) | |
| lesson_container = st.container(height=650, border=True) | |
| with lesson_container: | |
| st.markdown(st.session_state[content_key]) | |
| # 3. Exit Button (stays fixed below the scrollable content) | |
| if st.button("⬅ Finish & Return", use_container_width=True): | |
| st.session_state.focus_mode = False | |
| st.rerun() | |
| # --- MIDDLE COLUMN: Intelligent Workspace --- | |
| # --- MIDDLE COLUMN: Intelligent Workspace --- | |
| if not st.session_state.focus_mode: | |
| with mid_col: | |
| # Header | |
| h_col1, h_col2 = st.columns([0.8, 0.2]) | |
| with h_col1: | |
| st.markdown(""" | |
| <h2 style=' | |
| color:#E2E8F0; | |
| font-size:18px; | |
| font-weight:700; | |
| margin-bottom:16px; | |
| padding-bottom:8px; | |
| border-bottom:2px solid #3B82F6; | |
| '>🧠 Intelligent Workspace</h2> | |
| """, unsafe_allow_html=True) | |
| with h_col2: | |
| if st.button("📊 Analytics"): | |
| show_analytics_dialog() | |
| # Reading Content / Chat Area | |
| # Use native container with border to replace "custom-card" and fix "small box" issue | |
| with st.container(border=True): | |
| # 1. Chat History / Content (Scrollable Container) | |
| # using height=500 to create a scrolling area like a real chat app | |
| chat_container = st.container(height=500) | |
| with chat_container: | |
| if not st.session_state.chat_history: | |
| # Welcome Content | |
| st.markdown(""" | |
| <div style=' | |
| background: linear-gradient(135deg, #1E1E30, #2D1B69); | |
| border: 1px solid #7C3AED44; | |
| border-radius: 16px; | |
| padding: 40px; | |
| text-align: center; | |
| margin: 20px 0; | |
| '> | |
| <div style='font-size:48px;margin-bottom:16px'>📚</div> | |
| <h2 style='color:#E2E8F0;margin:0 0 8px'> | |
| Welcome to FocusFlow | |
| </h2> | |
| <p style='color:#94A3B8;margin:0 0 24px'> | |
| Your AI-powered study companion | |
| </p> | |
| <div style=' | |
| display:flex; | |
| justify-content:center; | |
| gap:24px; | |
| flex-wrap:wrap; | |
| '> | |
| <div style='color:#94A3B8;font-size:14px'> | |
| 📄 Upload a PDF | |
| </div> | |
| <div style='color:#94A3B8;font-size:14px'> | |
| 📋 Paste your notes | |
| </div> | |
| <div style='color:#94A3B8;font-size:14px'> | |
| 🗓️ Generate a study plan | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| # Chat Messages | |
| # Chat Messages | |
| for i, msg in enumerate(st.session_state.chat_history): | |
| with st.chat_message(msg["role"]): | |
| st.markdown(msg["content"]) | |
| # Source Display Logic (MUST BE INSIDE THE LOOP) | |
| if msg["role"] == "assistant" and msg.get("sources"): | |
| with st.expander("📚 View Sources", expanded=False): | |
| st.caption("Information retrieved from:") | |
| for idx, s in enumerate(msg["sources"], 1): | |
| # Crash Proof Check: Handle string vs dict | |
| if isinstance(s, str): | |
| st.markdown(f"**{idx}.** {s[:100]}...") | |
| else: | |
| # It is a dictionary | |
| src = s.get("source", "Document") | |
| # Extract filename from path | |
| filename = src.split("/")[-1] if "/" in src else src | |
| page_num = s.get("page", "N/A") | |
| # Display with nice formatting | |
| st.markdown(f"**{idx}.** 📄 `{filename}` • Page {page_num}") | |
| # 2. Input Area (Pinned to bottom of the visible card by being outside scroll container) | |
| with st.form(key="chat_form", clear_on_submit=True): | |
| cols = st.columns([0.85, 0.15]) | |
| with cols[0]: | |
| user_input = st.text_input("Ask a question...", placeholder="Ask a question about your documents...", label_visibility="collapsed", key="chat_input_widget") | |
| with cols[1]: | |
| submit_button = st.form_submit_button("Send", use_container_width=True) | |
| if submit_button and user_input: | |
| st.session_state.chat_history.append({"role": "user", "content": user_input}) | |
| try: | |
| with st.spinner("Thinking..."): | |
| # Prepare history (exclude sources for cleanliness) | |
| history = [ | |
| {"role": msg["role"], "content": msg["content"]} | |
| for msg in st.session_state.chat_history[:-1][-5:] # Last 5 valid history items before current question | |
| ] | |
| resp = requests.post(f"{API_URL}/query", json={"question": user_input, "history": history}, headers=get_headers()) | |
| if resp.status_code == 200: | |
| try: | |
| data = resp.json() | |
| ans = data.get("answer", "No answer.") | |
| srcs = data.get("sources", []) | |
| if srcs: | |
| st.session_state.chat_history.append({"role": "assistant", "content": ans, "sources": srcs}) | |
| else: | |
| st.session_state.chat_history.append({"role": "assistant", "content": ans}) | |
| except Exception as e: | |
| st.session_state.chat_history.append({"role": "assistant", "content": f"Error parsing response: {e}\\n\\nRaw text: {resp.text}"}) | |
| else: | |
| st.session_state.chat_history.append({"role": "assistant", "content": "Error."}) | |
| except Exception as e: | |
| st.session_state.chat_history.append({"role": "assistant", "content": f"Connection Error: {e}"}) | |
| st.rerun() | |
| # --- RIGHT COLUMN: Scheduler --- | |
| # --- RIGHT COLUMN: Scheduler --- | |
| # --- RIGHT COLUMN: Scheduler --- | |
| if right_col: | |
| with right_col: | |
| # --- CALENDAR WIDGET --- | |
| st.markdown(""" | |
| <h2 style=' | |
| color:#E2E8F0; | |
| font-size:18px; | |
| font-weight:700; | |
| margin-bottom:16px; | |
| padding-bottom:8px; | |
| border-bottom:2px solid #10B981; | |
| '>📅 Study Calendar</h2> | |
| """, unsafe_allow_html=True) | |
| today = date.today() | |
| selected_date = st.date_input("Select Date", value=today) | |
| # --- LOGIC: Show plan for selected date --- | |
| # If user selects a future date, show its plan inline | |
| if selected_date != today: | |
| delta = selected_date - today | |
| day_offset = delta.days + 1 | |
| st.markdown(f"### 📋 Plan for {selected_date}") | |
| # Filter plan for this hypothetical day | |
| day_tasks = [t for t in st.session_state.study_plan if t.get("day") == day_offset] | |
| if day_tasks: | |
| for t in day_tasks: | |
| st.markdown(f"- **{t['title']}**") | |
| else: | |
| st.info("No plan generated for this specific date yet.") | |
| st.markdown("---") | |
| # --- B. TALK TO CALENDAR --- | |
| with st.form("calendar_chat_form", clear_on_submit=True): | |
| plan_query = st.text_input("Talk to Calendar...", placeholder="e.g., 'Make a 3 day plan'") | |
| submitted = st.form_submit_button("🚀 Generate Plan") | |
| if submitted and plan_query: | |
| with st.spinner("🤖 AI (1B) is thinking..."): | |
| try: | |
| # Increased timeout to 300s for safety | |
| resp = requests.post(f"{API_URL}/generate_plan", json={"request_text": plan_query}, headers=get_headers(), timeout=300) | |
| if resp.status_code == 200: | |
| plan_data = resp.json() | |
| raw_plan = plan_data.get("days", []) | |
| # ROBUST SANITIZATION LOOP | |
| for index, task in enumerate(raw_plan): | |
| # 1. FORCE UNLOCK DAY 1 (The Fix) | |
| if index == 0: | |
| task["status"] = "unlocked" | |
| task["locked"] = False | |
| else: | |
| # Default logic for others: Trust 'status' or default to 'locked' | |
| # We ignore the 'locked' boolean fallback to be stricter, | |
| # ensuring only Day 1 is open initially if not specified. | |
| task["status"] = task.get("status", "locked") | |
| # 2. Fix IDs & Keys | |
| if "id" not in task: task["id"] = index + 1 | |
| task["quiz_passed"] = task.get("quiz_passed", False) | |
| task["title"] = task.get("topic", f"Topic {task['id']}") # Fallback title | |
| st.session_state.study_plan = raw_plan | |
| # AUTO-SAVE: Persist the new plan | |
| try: | |
| num_days = max([t.get("day", 1) for t in raw_plan]) if raw_plan else 0 | |
| save_resp = requests.post(f"{API_URL}/student/save_plan", json={ | |
| "topics": raw_plan, | |
| "num_days": num_days | |
| }, headers=get_headers(), timeout=5) | |
| if save_resp.status_code == 200: | |
| st.toast(f"💾 Progress saved: {len(raw_plan)} topics", icon="✅") | |
| else: | |
| st.warning(f"Could not save progress: {save_resp.text}") | |
| except Exception as e: | |
| st.warning(f"Could not save progress: {e}") | |
| st.success("📅 Plan Created! Check Today's Topics.") | |
| st.rerun() | |
| else: | |
| st.error(f"Failed: {resp.text}") | |
| except Exception as e: | |
| st.error(f"Error: {e}") | |
| # --- TODAY'S TOPICS (FILTERED BY CURRENT STUDY DAY) --- | |
| current_day = st.session_state.get("current_study_day", 1) | |
| st.markdown(f"### Today's Topics (Day {current_day})") | |
| # FILTER: Show tasks for current study day | |
| todays_tasks = [t for t in st.session_state.study_plan if t.get("day") == current_day] | |
| if not todays_tasks: | |
| st.caption("No tasks for today. Ask the calendar to make a plan!") | |
| else: | |
| # Group tasks by subject if multiple topics per day | |
| if len(todays_tasks) > 1: | |
| st.caption(f"📚 {len(todays_tasks)} topics to cover today") | |
| for i, task in enumerate(todays_tasks): | |
| # Display subject badge if available | |
| subject_badge = "" | |
| if "subject" in task and task["subject"]: | |
| subject_badge = f"**{task['subject']}** • " | |
| # 1. COMPLETED | |
| if task["status"] == "completed": | |
| st.success(f"✅ {subject_badge}{task['title']}") | |
| # (Flashcards button REMOVED as requested) | |
| # 2. ACTIVE / UNLOCKED | |
| elif task["status"] == "unlocked": | |
| with st.container(border=True): | |
| # Show subject badge prominently | |
| if "subject" in task and task["subject"]: | |
| st.caption(f"📘 {task['subject']}") | |
| st.markdown(f"**{task['title']}**") | |
| # The Focus Mode Button | |
| if st.button(f"🚀 Start Learning", key=f"start_{task['id']}"): | |
| st.session_state.focus_mode = True | |
| st.session_state.active_topic = task['title'] | |
| st.rerun() | |
| # 1. THE QUIZ BUTTON | |
| if st.button(f"📝 Take Quiz (Unlock Next)", key=f"quiz_btn_{task['id']}"): | |
| st.session_state[f"show_quiz_{task['id']}"] = True | |
| st.rerun() | |
| # 2. THE QUIZ (Inline - no dialog to avoid Streamlit error) | |
| if st.session_state.get(f"show_quiz_{task['id']}", False): | |
| st.markdown("---") | |
| st.write("### 🧠 Knowledge Check") | |
| # 1. FETCH QUIZ DATA (Dynamic) | |
| quiz_key = f"quiz_data_{task['id']}" | |
| if quiz_key not in st.session_state: | |
| with st.spinner(f"🤖 Generating quiz for '{task['title']}'..."): | |
| try: | |
| resp = requests.post(f"{API_URL}/generate_quiz", json={"topic": task['title']}, headers=get_headers(), timeout=120) | |
| if resp.status_code == 200: | |
| st.session_state[quiz_key] = resp.json().get("quiz", []) | |
| else: | |
| st.error("Failed to generate quiz.") | |
| except Exception as e: | |
| st.error(f"Connection error: {e}") | |
| quiz_data = st.session_state.get(quiz_key, []) | |
| if quiz_data: | |
| st.caption("Answer the questions below. Next topic unlocks automatically.") | |
| score = 0 | |
| user_answers = {} | |
| # 2. RENDER QUESTIONS | |
| for i, q in enumerate(quiz_data): | |
| st.markdown(f"**Q{i+1}: {q['question']}**") | |
| user_answers[i] = st.radio( | |
| "Select one:", | |
| q['options'], | |
| key=f"q_{task['id']}_{i}" | |
| ) | |
| st.markdown("---") | |
| col1, col2 = st.columns([1, 1]) | |
| with col1: | |
| if st.button("🚀 Submit Quiz", key=f"submit_{task['id']}", use_container_width=True): | |
| # GRADING LOGIC | |
| for i, q in enumerate(quiz_data): | |
| if user_answers[i] == q['answer']: | |
| score += 1 | |
| # STORE SCORE FOR ANALYTICS | |
| st.session_state.topic_scores[task['id']] = { | |
| "topic_title": task['title'], | |
| "score": score, | |
| "total": len(quiz_data), | |
| "percentage": (score / len(quiz_data)) * 100 | |
| } | |
| st.info(f"📊 Your Score: {score}/{len(quiz_data)}") | |
| # ALWAYS UNLOCK NEXT TOPIC | |
| st.balloons() | |
| # --- ADAPTIVE LOGIC (Optional based on score) --- | |
| if score == 3: | |
| st.toast("🚀 Perfect Score! Accelerating future plan...", icon="⚡") | |
| for future_task in st.session_state.study_plan: | |
| if future_task["id"] > task["id"]: | |
| if "Advanced" not in future_task["title"]: | |
| future_task["title"] = f"Advanced: {future_task['title']}" | |
| future_task["details"] = "Deep dive with complex examples. (AI Adjusted)" | |
| elif score == 2: | |
| st.toast("⚠️ Good effort! Adding revision steps...", icon="🛡️") | |
| for future_task in st.session_state.study_plan: | |
| if future_task["id"] > task["id"]: | |
| if "Review" not in future_task["title"]: | |
| future_task["title"] = f"Review & {future_task['title']}" | |
| future_task["details"] = "Includes recap of previous concepts. (AI Adjusted)" | |
| st.success(f"✅ Quiz completed! Unlocking next topic...") | |
| time.sleep(1) | |
| # UNLOCK NEXT TOPIC | |
| task["status"] = "completed" | |
| task["quiz_passed"] = True | |
| current_id = task["id"] | |
| for next_task in st.session_state.study_plan: | |
| if next_task["id"] == current_id + 1: | |
| next_task["status"] = "unlocked" | |
| next_task["locked"] = False | |
| break | |
| # AUTO-SAVE: Persist quiz score and completion | |
| try: | |
| subject = task.get("subject", "General") | |
| requests.post(f"{API_URL}/student/quiz_complete", json={ | |
| "topic_id": task["id"], | |
| "topic_title": task["title"], | |
| "subject": subject, | |
| "score": score, | |
| "total": len(quiz_data), | |
| "time_taken": 0 | |
| }, headers=get_headers(), timeout=5) | |
| except Exception: | |
| pass # Silent fail for auto-save | |
| # Close Quiz | |
| st.session_state[f"show_quiz_{task['id']}"] = False | |
| st.rerun() | |
| with col2: | |
| if st.button("✕ Cancel", key=f"cancel_{task['id']}", use_container_width=True): | |
| st.session_state[f"show_quiz_{task['id']}"] = False | |
| st.rerun() | |
| # 3. LOCKED | |
| else: | |
| with st.container(border=True): | |
| st.markdown(f"🔒 <span style='color:gray'>{task['title']}</span>", unsafe_allow_html=True) | |