############################################################### # main_app.py — Agentic Language Partner UI (Streamlit) ############################################################### import json import random import re from datetime import datetime from pathlib import Path from typing import Dict, List, Any import streamlit as st import streamlit.components.v1 as components from deep_translator import GoogleTranslator from pydub import AudioSegment from io import BytesIO from .auth import ( authenticate_user, register_user, get_user_prefs, update_user_prefs, ) from .config import get_user_dir from .conversation_core import ConversationManager from .flashcards_tools import ( list_user_decks, load_deck, _get_decks_dir, save_deck, generate_flashcards_from_text, generate_flashcards_from_ocr_results, ) from .ocr_tools import ocr_and_translate_batch from .viewers import generate_flashcard_viewer_for_user ############################################################### # PAGE + GLOBAL STYLE ############################################################### st.set_page_config( page_title="Agentic Language Partner", layout="wide", page_icon="🌐", ) st.markdown( """ """, unsafe_allow_html=True, ) ############################################################### # HELPERS / GLOBALS ############################################################### # ------------------------------------------------------------ # Model preload / Conversation manager # ------------------------------------------------------------ def preload_models(): """ Loads all heavy models ONCE at startup. Safe for HuggingFace Spaces CPU environment. """ from .conversation_core import load_partner_lm, load_whisper_pipe # Qwen LM try: load_partner_lm() except Exception as e: print("[preload_models] ERROR loading Qwen model:", e) # Whisper ASR try: load_whisper_pipe() except Exception as e: print("[preload_models] ERROR loading Whisper pipeline:", e) def get_conv_manager() -> ConversationManager: if "conv_manager" not in st.session_state: prefs = st.session_state["prefs"] st.session_state["conv_manager"] = ConversationManager( target_language=prefs.get("target_language", "english"), native_language=prefs.get("native_language", "english"), cefr_level=prefs.get("cefr_level", "B1"), topic=prefs.get("topic", "general conversation"), ) return st.session_state["conv_manager"] def ensure_default_decks(username: str): decks_dir = _get_decks_dir(username) alpha = decks_dir / "alphabet.json" if not alpha.exists(): save_deck(alpha, { "name": "Alphabet (A–Z)", "cards": [{"front": chr(65+i), "back": f"Letter {chr(65+i)}"} for i in range(26)], "tags": ["starter"], }) nums = decks_dir / "numbers_1_10.json" if not nums.exists(): save_deck(nums, { "name": "Numbers 1–10", "cards": [{"front": str(i), "back": f"Number {i}"} for i in range(1, 11)], "tags": ["starter"], }) greetings = decks_dir / "greetings_intros.json" if not greetings.exists(): save_deck(greetings, { "name": "Greetings & Introductions", "cards": [ {"front": "Hallo!", "back": "Hello!"}, {"front": "Wie geht's?", "back": "How are you?"}, {"front": "Ich heiße …", "back": "My name is …"}, {"front": "Freut mich!", "back": "Nice to meet you!"}, ], "tags": ["starter"], }) def ui_clean_assistant_text(text: str) -> str: if not text: return "" text = re.sub(r"(?i)\b(user|assistant|system):\s*", "", text) text = re.sub(r"\s{2,}", " ", text) return text.strip() def save_current_conversation(username: str, name: str) -> Path: """Save chat_history as JSON, stripping non-serializable fields (audio bytes).""" user_dir = get_user_dir(username) save_dir = user_dir / "chats" / "saved" save_dir.mkdir(parents=True, exist_ok=True) cleaned_messages = [] for m in st.session_state.get("chat_history", []): cleaned_messages.append( { "role": m.get("role"), "text": m.get("text"), "explanation": m.get("explanation"), # store only a flag for audio, not raw bytes "audio_present": bool(m.get("audio")), } ) payload = { "name": name, "timestamp": datetime.utcnow().isoformat(), "messages": cleaned_messages, } fname = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + ".json" path = save_dir / fname path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") return path ############################################################### # CHAT HANDLING ############################################################### def handle_user_message(username: str, text: str): text = text.strip() if not text: return conv = get_conv_manager() st.session_state["chat_history"].append( {"role": "user", "text": text, "audio": None, "explanation": None} ) with st.spinner("Thinking…"): result = conv.reply(text) reply_text = ui_clean_assistant_text(result.get("reply_text", "")) reply_audio = result.get("audio", None) explanation = ui_clean_assistant_text(result.get("explanation", "")) st.session_state["chat_history"].append( { "role": "assistant", "text": reply_text, "audio": reply_audio, "explanation": explanation, } ) ############################################################### # AUTH ############################################################### def login_view(): st.title("🌐 Agentic Language Partner") tab1, tab2 = st.tabs(["Login", "Register"]) with tab1: u = st.text_input("Username") p = st.text_input("Password", type="password") if st.button("Login"): if authenticate_user(u, p): st.session_state["user"] = u st.session_state["prefs"] = get_user_prefs(u) st.rerun() else: st.error("Invalid login.") with tab2: u = st.text_input("New username") p = st.text_input("New password", type="password") if st.button("Register"): if register_user(u, p): st.success("Registered! Please log in.") else: st.error("Username already exists.") ############################################################### # SIDEBAR SETTINGS ############################################################### def sidebar_settings(username: str): st.sidebar.header("⚙ Settings") prefs = st.session_state["prefs"] langs = ["english", "spanish", "german", "russian", "japanese", "chinese", "korean"] tgt = st.sidebar.selectbox( "Target language", langs, index=langs.index(prefs.get("target_language", "english")), key="sidebar_target", ) nat = st.sidebar.selectbox( "Native language", langs, index=langs.index(prefs.get("native_language", "english")), key="sidebar_native", ) cefr_levels = ["A1", "A2", "B1", "B2", "C1", "C2"] level = st.sidebar.selectbox( "CEFR Level", cefr_levels, index=cefr_levels.index(prefs.get("cefr_level", "B1")), key="sidebar_cefr", ) topic = st.sidebar.text_input( "Conversation Topic", prefs.get("topic", "general conversation"), key="sidebar_topic", ) show_exp = st.sidebar.checkbox( "Show Explanations", value=prefs.get("show_explanations", True), key="sidebar_show_exp", ) if st.sidebar.button("Save Settings"): new = { "target_language": tgt, "native_language": nat, "cefr_level": level, "topic": topic, "show_explanations": show_exp, } st.session_state["prefs"] = new update_user_prefs(username, new) if "conv_manager" in st.session_state: del st.session_state["conv_manager"] st.sidebar.success("Settings saved!") ############################################################### # DASHBOARD TAB ############################################################### def dashboard_tab(username: str): st.title("Agentic Language Partner — Dashboard") prefs = st.session_state["prefs"] langs = ["english", "spanish", "german", "russian", "japanese", "chinese", "korean"] st.subheader("Language Settings") col1, col2, col3 = st.columns(3) with col1: native = st.selectbox( "Native language", langs, index=langs.index(prefs.get("native_language", "english")), key="dash_native_language", ) with col2: target = st.selectbox( "Target language", langs, index=langs.index(prefs.get("target_language", "english")), key="dash_target_language", ) with col3: cefr_levels = ["A1", "A2", "B1", "B2", "C1", "C2"] level = st.selectbox( "CEFR Level", cefr_levels, index=cefr_levels.index(prefs.get("cefr_level", "B1")), key="dash_cefr_level", ) topic = st.text_input( "Conversation Topic", prefs.get("topic", "general conversation"), key="dash_topic", ) if st.button("Save Language Settings", key="dash_save_lang"): new = { "native_language": native, "target_language": target, "cefr_level": level, "topic": topic, "show_explanations": prefs.get("show_explanations", True), } st.session_state["prefs"] = new update_user_prefs(username, new) if "conv_manager" in st.session_state: del st.session_state["conv_manager"] st.success("Language settings saved!") st.markdown("---") ########################################################### # MICROPHONE & TRANSCRIPTION CALIBRATION (native phrase) ########################################################### st.subheader("Microphone & Transcription Calibration") st.write( "To verify that audio recording and transcription are working, " "please repeat this phrase in your native language:\n\n" "> \"Hello, my name is [Your Name], and I am here to practice languages.\"\n\n" "Upload or record a short clip and then run transcription to check accuracy." ) calib_col1, calib_col2 = st.columns([2, 1]) with calib_col1: calib_file = st.file_uploader( "Upload or record a short audio sample (e.g., WAV/MP3)", type=["wav", "mp3", "m4a", "ogg"], key="calibration_audio", ) if calib_file is not None: st.caption("Calibration audio loaded. Click 'Transcribe sample' to test.") if st.button("Transcribe sample", key="calibration_transcribe"): if calib_file is None: st.warning("Please upload or record a short calibration clip first.") else: conv = get_conv_manager() try: raw = calib_file.read() seg = AudioSegment.from_file(BytesIO(raw)) seg = seg.set_frame_rate(16000).set_channels(1) with st.spinner("Transcribing calibration audio…"): text_out, det_lang, det_prob = conv.transcribe( seg, spoken_lang=st.session_state["prefs"]["native_language"] ) st.session_state["calibration_result"] = { "text": text_out, "det_lang": det_lang, "det_prob": det_prob, } st.success("Calibration transcript updated.") except Exception as e: st.error(f"Calibration error: {e}") with calib_col2: if st.session_state.get("calibration_result"): res = st.session_state["calibration_result"] st.markdown("**Calibration transcript:**") st.info(res.get("text", "")) st.caption( f"Detected lang: {res.get('det_lang','?')} · Confidence ~ {res.get('det_prob', 0):.2f}" ) else: st.caption("No calibration transcript yet.") st.markdown("---") ########################################################### # TOOL OVERVIEW ########################################################### st.subheader("Tools Overview") c1, c2, c3 = st.columns(3) with c1: st.markdown("### 🎙️ Conversation Partner") st.write("Real-time language practice with microphone support (via audio uploads).") with c2: st.markdown("### 🃏 Flashcards & Quizzes") st.write("Starter decks: Alphabet, Numbers, Greetings.") with c3: st.markdown("### 📷 OCR Helper") st.write("Upload images to extract and translate text.") # ------------------------------------------------------------ # Settings tab (restore missing function) # ------------------------------------------------------------ def settings_tab(username: str): """Minimal settings tab so main() can call it safely.""" st.header("Settings") st.subheader("User Preferences") prefs = st.session_state.get("prefs", {}) st.json(prefs) st.markdown("---") st.subheader("System Status") st.write("Models preloaded:", st.session_state.get("models_loaded", False)) st.markdown( "This is a placeholder settings panel. " "You can customize this later with user-specific configuration." ) ############################################################### # CONVERSATION TAB ############################################################### def conversation_tab(username: str): import re from datetime import datetime from deep_translator import GoogleTranslator st.header("Conversation") # ------------------------------------------ # INITIAL STATE # ------------------------------------------ if "chat_history" not in st.session_state: st.session_state["chat_history"] = [] if "pending_transcript" not in st.session_state: st.session_state["pending_transcript"] = "" if "speech_state" not in st.session_state: st.session_state["speech_state"] = "idle" # idle | pending_speech if "recorder_key" not in st.session_state: st.session_state["recorder_key"] = 0 conv = get_conv_manager() prefs = st.session_state.get("prefs", {}) show_exp = prefs.get("show_explanations", True) # ------------------------------------------ # RESET BUTTON (ONLY ONE) # ------------------------------------------ if st.button("🔄 Reset Conversation"): st.session_state["chat_history"] = [] st.session_state["pending_transcript"] = "" st.session_state["speech_state"] = "idle" st.session_state["recorder_key"] += 1 st.rerun() # ------------------------------------------ # FIRST MESSAGE GREETING # ------------------------------------------ if len(st.session_state["chat_history"]) == 0: lang = conv.target_language.lower() topic = prefs.get("topic", "").strip() default_greetings = { "english": "Hello! I heard you want to practice with me. How is your day going?", "german": "Hallo! Ich habe gehört, dass du üben möchtest. Wie geht dein Tag bisher?", "spanish": "¡Hola! Escuché que querías practicar conmigo. ¿Cómo va tu día?", "japanese":"こんにちは!練習したいと聞きました。今日はどんな一日ですか?", } intro = default_greetings.get(lang, default_greetings["english"]) if topic and topic.lower() != "general conversation": try: intro = GoogleTranslator(source="en", target=lang).translate( f"Hello! Let's talk about {topic}. What do you think about it?" ) except Exception: pass st.session_state["chat_history"].append( {"role":"assistant","text":intro,"audio":None,"explanation":None} ) # ------------------------------------------ # LAYOUT # ------------------------------------------ col_chat, col_saved = st.columns([3,1]) # =========================== # LEFT: CHAT WINDOW # =========================== with col_chat: st.markdown('
', unsafe_allow_html=True) for msg in st.session_state["chat_history"]: role = msg["role"] bubble = "chat-bubble-user" if role == "user" else "chat-bubble-assistant" row = "chat-row-user" if role == "user" else "chat-row-assistant" st.markdown( f'
{msg["text"]}
', unsafe_allow_html=True, ) if role == "assistant" and msg.get("audio"): st.audio(msg["audio"], format="audio/mp3") if role == "assistant": try: tr = GoogleTranslator(source="auto", target=conv.native_language).translate(msg["text"]) st.markdown(f'
{tr}
', unsafe_allow_html=True) except: pass if show_exp and msg.get("explanation"): exp = msg["explanation"] # Force EXACTLY ONE sentence exp = re.split(r"(?<=[.!?])\s+", exp)[0].strip() # Remove any meta nonsense ("version:", "meaning:", "this sentence", etc) exp = re.sub(r"(?i)(english version|the meaning|this sentence|the german sentence).*", "", exp).strip() if exp: st.markdown(f'
{exp}
', unsafe_allow_html=True) # scroll st.markdown(""" """, unsafe_allow_html=True) # ------------------------------- # AUDIO UPLOAD / RECORDING # ------------------------------- st.markdown('
', unsafe_allow_html=True) audio_file = st.file_uploader( "🎤 Upload or record an audio message", type=["wav", "mp3", "m4a", "ogg"], key=f"chat_audio_{st.session_state['recorder_key']}", ) # ------------------------------------------ # STATE: idle → file → transcribe # ------------------------------------------ if st.session_state["speech_state"] == "idle": if audio_file is not None: raw = audio_file.read() try: seg = AudioSegment.from_file(BytesIO(raw)) seg = seg.set_frame_rate(16000).set_channels(1) with st.spinner("Transcribing…"): txt, lang, conf = conv.transcribe(seg, spoken_lang=conv.target_language) st.session_state["pending_transcript"] = txt.strip() st.session_state["speech_state"] = "pending_speech" st.session_state["recorder_key"] += 1 st.rerun() except Exception as e: st.error(f"Audio decode/transcription error: {e}") # ------------------------------------------ # STATE: pending_speech → confirm # ------------------------------------------ if st.session_state["speech_state"] == "pending_speech": st.write("### Confirm your spoken message:") st.info(st.session_state["pending_transcript"]) c1, c2 = st.columns([1,1]) with c1: if st.button("Send message", key="send_pending"): txt = st.session_state["pending_transcript"] with st.spinner("Partner is responding…"): handle_user_message(username, txt) # cleanup st.session_state["speech_state"] = "idle" st.session_state["pending_transcript"] = "" st.session_state["recorder_key"] += 1 st.rerun() with c2: if st.button("Discard", key="discard_pending"): st.session_state["speech_state"] = "idle" st.session_state["pending_transcript"] = "" st.session_state["recorder_key"] += 1 st.rerun() # ------------------------------- # TYPED TEXT INPUT # ------------------------------- typed = st.text_input("Type your message:", key="typed_input") if typed.strip() and st.button("Send typed message"): handle_user_message(username, typed.strip()) st.session_state["typed_input"] = "" st.rerun() st.markdown("
", unsafe_allow_html=True) # ====================================================== # RIGHT: SAVED CONVERSATIONS # ====================================================== with col_saved: from pathlib import Path import json st.markdown("### Saved Conversations") default_name = datetime.utcnow().strftime("Session %Y-%m-%d %H:%M") name_box = st.text_input("Name conversation", value=default_name) if st.button("Save conversation"): if not st.session_state["chat_history"]: st.warning("Nothing to save.") else: safe = re.sub(r"[^0-9A-Za-z_-]", "_", name_box) path = save_current_conversation(username, safe) st.success(f"Saved as {path.name}") saved_dir = get_user_dir(username) / "chats" / "saved" saved_dir.mkdir(parents=True, exist_ok=True) files = sorted(saved_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) for f in files: data = json.loads(f.read_text()) sess_name = data.get("name", f.stem) msgs = data.get("messages", []) with st.expander(f"{sess_name} ({len(msgs)} msgs)"): deck_name = st.text_input(f"Deck name for {sess_name}", value=f"deck_{f.stem}") if st.button(f"Export {f.stem}", key=f"export_{f.stem}"): body = "\n".join(m["text"] for m in msgs if m["role"] == "assistant") deck_path = generate_flashcards_from_text( username=username, text=body, deck_name=deck_name, target_lang=prefs["native_language"], tags=["conversation"], ) st.success(f"Deck exported: {deck_path.name}") if st.button(f"Delete {f.stem}", key=f"delete_{f.stem}"): f.unlink() st.rerun() ############################################################### # OCR TAB ############################################################### def ocr_tab(username: str): st.header("OCR → Flashcards") imgs = st.file_uploader("Upload images", ["png", "jpg", "jpeg"], accept_multiple_files=True) tgt = st.selectbox("Translate to", ["en", "de", "ja", "zh-cn", "es"]) deck_name = st.text_input("Deck name", "ocr_vocab") if st.button("Create Deck from OCR"): if not imgs: st.warning("Upload at least one image.") return with st.spinner("Running OCR…"): results = ocr_and_translate_batch([f.read() for f in imgs], target_lang=tgt) deck_path = generate_flashcards_from_ocr_results( username=username, ocr_results=results, deck_name=deck_name, target_lang=tgt, tags=["ocr"], ) st.success(f"Deck saved: {deck_path}") ############################################################### # FLASHCARDS TAB ############################################################### # Lightweight TTS function with caching (no heavy model loading) @st.cache_data(ttl=3600, show_spinner=False) def _cached_tts(text: str, lang: str = "en") -> bytes: """Generate TTS audio using gTTS with caching.""" import io from gtts import gTTS # Language mapping lang_map = { "english": "en", "spanish": "es", "german": "de", "russian": "ru", "japanese": "ja", "chinese": "zh-cn", "korean": "ko", "french": "fr", } tts_lang = lang_map.get(lang.lower(), lang) try: tts = gTTS(text=text, lang=tts_lang) buf = io.BytesIO() tts.write_to_fp(buf) return buf.getvalue() except Exception: return None def flashcards_tab(username: str): import re # --------------------------------------------------------- # Helpers # --------------------------------------------------------- def normalize(s: str) -> str: """lowercase + strip non-alphanumerics for loose grading.""" s = s.lower() s = re.sub(r"[^a-z0-9]+", "", s) return s def card_front_html(text: str) -> str: return f"""
{text}
""" def card_back_html(front: str, back: str) -> str: return f"""
{front}
{back}
""" # --------------------------------------------------------- # Load deck # --------------------------------------------------------- st.header("Flashcards") decks = list_user_decks(username) if not decks: st.info("No decks available yet.") return deck_name = st.selectbox("Select deck", sorted(decks.keys())) deck_path = decks[deck_name] deck = load_deck(deck_path) cards = deck.get("cards", []) tags = deck.get("tags", []) if not cards: st.warning("Deck is empty.") return st.write(f"Total cards: **{len(cards)}**") if tags: st.caption("Tags: " + ", ".join(tags)) # Delete deck button if st.button("Delete deck"): deck_path.unlink() st.rerun() # --------------------------------------------------------- # Session state setup # --------------------------------------------------------- key = f"fc_{deck_name}_" ss = st.session_state if key + "init" not in ss: ss[key + "mode"] = "Study" ss[key + "idx"] = 0 ss[key + "show_back"] = False # test state ss[key + "test_active"] = False ss[key + "test_order"] = [] ss[key + "test_pos"] = 0 ss[key + "test_results"] = [] ss[key + "init"] = True mode = st.radio("Mode", ["Study", "Test"], horizontal=True, key=key + "mode") st.markdown("---") # ======================================================= # CENTER PANEL # ======================================================= with st.container(): # --------------------------------------------------- # STUDY MODE # --------------------------------------------------- if mode == "Study": idx = ss[key + "idx"] % len(cards) card = cards[idx] show_back = ss[key + "show_back"] st.markdown("### Study Mode") st.markdown("---") # Get target language for TTS prefs = st.session_state.get("prefs", {}) tts_lang = prefs.get("target_language", "english") # CARD DISPLAY if not show_back: st.markdown(card_front_html(card["front"]), unsafe_allow_html=True) # Pronounce button for front if st.button("🔊 Pronounce", key=key + f"tts_front_{idx}"): audio = _cached_tts(card["front"], tts_lang) if audio: st.audio(audio, format="audio/mp3") else: st.warning("TTS not available for this text.") else: st.markdown(card_back_html(card["front"], card["back"]), unsafe_allow_html=True) # Pronounce button for back (use native language) native_lang = prefs.get("native_language", "english") if st.button("🔊 Pronounce", key=key + f"tts_back_{idx}"): audio = _cached_tts(card["back"], native_lang) if audio: st.audio(audio, format="audio/mp3") else: st.warning("TTS not available for this text.") # FLIPBOOK CONTROLS st.markdown("---") # Use number input for card navigation (no rerun needed) new_idx = st.number_input( f"Card ({idx + 1} of {len(cards)})", min_value=1, max_value=len(cards), value=idx + 1, key=key + "card_num" ) - 1 if new_idx != idx: ss[key + "idx"] = new_idx ss[key + "show_back"] = False c1, c2 = st.columns(2) with c1: if st.button("Flip Card", key=key + "flip"): ss[key + "show_back"] = not show_back with c2: if st.button("Next →", key=key + "next"): ss[key + "idx"] = (idx + 1) % len(cards) ss[key + "show_back"] = False # --------------------------------------------------- # TEST MODE # --------------------------------------------------- else: # Initial test setup if not ss[key + "test_active"]: st.markdown("### Test Setup") num_q = st.slider("Number of questions", 3, min(20, len(cards)), min(5, len(cards)), key=key+"nq") if st.button("Start Test", key=key+"begin"): order = list(range(len(cards))) random.shuffle(order) order = order[:num_q] ss[key + "test_active"] = True ss[key + "test_order"] = order ss[key + "test_pos"] = 0 ss[key + "test_results"] = [] else: order = ss[key + "test_order"] pos = ss[key + "test_pos"] results = ss[key + "test_results"] # Test Complete if pos >= len(order): correct = sum(r["correct"] for r in results) st.markdown(f"### Test Complete — Score: {correct}/{len(results)} ({correct/len(results)*100:.1f}%)") st.markdown("---") for i, r in enumerate(results, 1): emoji = "✅" if r["correct"] else "❌" st.write(f"**{i}.** {r['front']} → expected **{r['back']}**, you answered *{r['user_answer']}* {emoji}") if st.button("Restart Test", key=key+"restart"): ss[key + "test_active"] = False ss[key + "test_pos"] = 0 ss[key + "test_results"] = [] ss[key + "test_order"] = [] else: # Current question cid = order[pos] card = cards[cid] st.progress(pos / len(order)) st.caption(f"Question {pos+1} / {len(order)}") st.markdown(card_front_html(card["front"]), unsafe_allow_html=True) # Pronounce button for test prefs = st.session_state.get("prefs", {}) tts_lang = prefs.get("target_language", "english") if st.button("🔊 Pronounce", key=key+f"tts_test_{pos}"): audio = _cached_tts(card["front"], tts_lang) if audio: st.audio(audio, format="audio/mp3") user_answer = st.text_input("Your answer:", key=key+f"ans_{pos}") if st.button("Submit Answer", key=key+f"submit_{pos}"): ua = user_answer.strip() correct = normalize(ua) == normalize(card["back"]) # Flash feedback if correct: st.success("Correct!") else: st.error(f"Incorrect — expected: {card['back']}") results.append({ "front": card["front"], "back": card["back"], "user_answer": ua, "correct": correct, }) ss[key + "test_results"] = results ss[key + "test_pos"] = pos + 1 # ======================================================= # DECK AT A GLANCE (FULL WIDTH) # ======================================================= st.markdown("---") with st.expander("Deck at a glance", expanded=False): if cards: for i, c in enumerate(cards, start=1): front = c.get("front", "") back = c.get("back", "") score = c.get("score", 0) reviews = c.get("reviews", 0) st.markdown(f"**{i}.** {front} → {back} *(Score: {score}, Reviews: {reviews})*") else: st.info("No cards in this deck.") ############################################################### # QUIZ TAB ############################################################### def quiz_tab(username: str): # Custom CSS for Quiz UI st.markdown(""" """, unsafe_allow_html=True) st.markdown("## 🎯 Quiz Mode") ensure_default_decks(username) user_dir = get_user_dir(username) quiz_dir = user_dir / "quizzes" quiz_dir.mkdir(exist_ok=True) decks = list_user_decks(username) if not decks: st.info("No decks available. Create some flashcards first!") return # Quiz state keys qkey = "quiz_state_" # Initialize state if qkey + "active" not in st.session_state: st.session_state[qkey + "active"] = False st.session_state[qkey + "questions"] = [] st.session_state[qkey + "current"] = 0 st.session_state[qkey + "answers"] = {} st.session_state[qkey + "show_feedback"] = False st.session_state[qkey + "last_correct"] = None # ===================================================== # QUIZ SETUP (not active) # ===================================================== if not st.session_state[qkey + "active"]: st.markdown("### Select Decks & Start") col1, col2 = st.columns([2, 1]) with col1: selected = st.multiselect( "Choose decks to quiz from:", sorted(decks.keys()), default=list(decks.keys())[:1] if decks else [] ) with col2: num_q = st.slider("Number of questions", 3, 15, 5) if selected and st.button("🚀 Start Quiz", use_container_width=True): # Build question pool pool = [] for name in selected: deck_cards = load_deck(decks[name])["cards"] for card in deck_cards: pool.append({"front": card.get("front", ""), "back": card.get("back", "")}) if len(pool) < 2: st.warning("Need at least 2 cards to create a quiz.") return # Generate questions questions = [] used_prompts = set() for _ in range(min(num_q, len(pool))): # Pick a card not yet used available = [c for c in pool if c["front"] not in used_prompts] if not available: break c = random.choice(available) used_prompts.add(c["front"]) # Randomly choose question type qtype = random.choice(["mc", "mc", "fill"]) # More MC questions if qtype == "mc" and len(pool) >= 4: # Multiple choice - get wrong options wrong_pool = [x["back"] for x in pool if x["front"] != c["front"]] wrong_opts = random.sample(wrong_pool, min(3, len(wrong_pool))) opts = [c["back"]] + wrong_opts random.shuffle(opts) questions.append({ "type": "mc", "prompt": c["front"], "options": opts, "answer": c["back"] }) else: # Fill in the blank questions.append({ "type": "fill", "prompt": c["front"], "answer": c["back"] }) # Save quiz qid = datetime.utcnow().strftime("quiz_%Y%m%d_%H%M%S") quiz_data = {"id": qid, "questions": questions} (quiz_dir / f"{qid}.json").write_text(json.dumps(quiz_data, indent=2)) # Set state st.session_state[qkey + "active"] = True st.session_state[qkey + "questions"] = questions st.session_state[qkey + "current"] = 0 st.session_state[qkey + "answers"] = {} st.session_state[qkey + "show_feedback"] = False return # ===================================================== # QUIZ IN PROGRESS # ===================================================== questions = st.session_state[qkey + "questions"] current = st.session_state[qkey + "current"] answers = st.session_state[qkey + "answers"] show_feedback = st.session_state[qkey + "show_feedback"] # ===================================================== # QUIZ COMPLETE - Show Results # ===================================================== if current >= len(questions): correct_count = sum(1 for a in answers.values() if a["correct"]) total = len(questions) percentage = (correct_count / total) * 100 # Score display if percentage >= 80: grade_emoji = "🏆" grade_text = "Excellent!" grade_color = "#28a745" elif percentage >= 60: grade_emoji = "👍" grade_text = "Good job!" grade_color = "#17a2b8" elif percentage >= 40: grade_emoji = "📚" grade_text = "Keep practicing!" grade_color = "#ffc107" else: grade_emoji = "💪" grade_text = "Don't give up!" grade_color = "#dc3545" st.markdown(f"""
{grade_emoji}
{correct_count}/{total}
{percentage:.0f}% - {grade_text}
""", unsafe_allow_html=True) # Detailed results st.markdown("### 📋 Review All Answers") for i, q in enumerate(questions): ans_data = answers.get(i, {}) is_correct = ans_data.get("correct", False) user_ans = ans_data.get("given", "No answer") if is_correct: st.markdown(f"""
Q{i+1}: {q['prompt']}
✅ Your answer: {user_ans}
""", unsafe_allow_html=True) else: st.markdown(f"""
Q{i+1}: {q['prompt']}
❌ Your answer: {user_ans}
✅ Correct answer: {q['answer']}
""", unsafe_allow_html=True) st.markdown("---") if st.button("🔄 Start New Quiz", use_container_width=True): st.session_state[qkey + "active"] = False st.session_state[qkey + "questions"] = [] st.session_state[qkey + "current"] = 0 st.session_state[qkey + "answers"] = {} st.session_state[qkey + "show_feedback"] = False return # ===================================================== # CURRENT QUESTION # ===================================================== q = questions[current] # Progress bar progress = (current) / len(questions) st.progress(progress) st.markdown(f"**Question {current + 1} of {len(questions)}**") # Question card st.markdown(f"""
{q['prompt']}
""", unsafe_allow_html=True) # Show feedback from previous submission if show_feedback and st.session_state[qkey + "last_correct"] is not None: last_ans = answers.get(current, {}) if st.session_state[qkey + "last_correct"]: st.markdown("""
Correct! Well done!
""", unsafe_allow_html=True) else: st.markdown(f"""
Incorrect!
The correct answer is: {q['answer']}
""", unsafe_allow_html=True) # Next button if st.button("Next Question →", use_container_width=True): st.session_state[qkey + "current"] = current + 1 st.session_state[qkey + "show_feedback"] = False st.session_state[qkey + "last_correct"] = None else: # Answer input if q["type"] == "mc": st.markdown("**Choose the correct answer:**") choice = st.radio( "Options:", q["options"], key=f"quiz_mc_{current}", label_visibility="collapsed" ) if st.button("✓ Submit Answer", use_container_width=True, key=f"quiz_sub_{current}"): is_correct = (choice == q["answer"]) st.session_state[qkey + "answers"][current] = { "given": choice, "correct": is_correct, "expected": q["answer"] } st.session_state[qkey + "show_feedback"] = True st.session_state[qkey + "last_correct"] = is_correct else: # fill in blank st.markdown("**Type your answer:**") user_input = st.text_input( "Your answer:", key=f"quiz_fill_{current}", label_visibility="collapsed", placeholder="Type the translation..." ) if st.button("✓ Submit Answer", use_container_width=True, key=f"quiz_sub_{current}"): is_correct = (user_input.strip().lower() == q["answer"].strip().lower()) st.session_state[qkey + "answers"][current] = { "given": user_input, "correct": is_correct, "expected": q["answer"] } st.session_state[qkey + "show_feedback"] = True st.session_state[qkey + "last_correct"] = is_correct # Quit button st.markdown("---") if st.button("🚪 Quit Quiz", use_container_width=True): st.session_state[qkey + "active"] = False st.session_state[qkey + "questions"] = [] st.session_state[qkey + "current"] = 0 st.session_state[qkey + "answers"] = {} st.session_state[qkey + "show_feedback"] = False ############################################################### # MAIN ############################################################### def main(): # ---------- AUTH ---------- if "user" not in st.session_state: login_view() return username = st.session_state["user"] st.sidebar.write(f"Logged in as **{username}**") if st.sidebar.button("Log out"): st.session_state.clear() st.rerun() # ---------- LOAD MODELS + PREFS ---------- preload_models() sidebar_settings(username) ensure_default_decks(username) # ---------- TABS ---------- tab_labels = ["Dashboard", "Conversation", "OCR", "Flashcards", "Quiz", "Settings"] tabs = st.tabs(tab_labels) tab_dash, tab_conv, tab_ocr, tab_flash, tab_quiz, tab_settings = tabs with tab_dash: dashboard_tab(username) with tab_conv: conversation_tab(username) with tab_ocr: ocr_tab(username) with tab_flash: flashcards_tab(username) with tab_quiz: quiz_tab(username) with tab_settings: settings_tab(username) if __name__ == "__main__": main()