"""Persian Language Tutor — Gradio UI.""" import json import os import tempfile import time import gradio as gr import db from modules import vocab, dashboard, essay, tutor, idioms from modules.essay import GCSE_THEMES from modules.tutor import THEME_PROMPTS from anki_export import export_deck # ---------- Initialise ---------- db.init_db() vocabulary = vocab.load_vocab() categories = ["All"] + vocab.get_categories() # ---------- Helper ---------- def _rtl(text, size="2em"): return f'
{text}
' # ================================================================ # TAB HANDLERS # ================================================================ # ---------- Dashboard ---------- def refresh_dashboard(): overview_md = dashboard.format_overview_markdown() cat_data = dashboard.get_category_breakdown() quiz_data = dashboard.get_recent_quizzes() return overview_md, cat_data, quiz_data # ---------- Vocabulary Search ---------- def do_search(query, category): results = vocab.search(query) if category and category != "All": results = [r for r in results if r["category"] == category] if not results: return "No results found." lines = [] for r in results: status = vocab.get_word_status(r["id"]) icon = {"new": "⬜", "learning": "🟨", "mastered": "🟩"}.get(status, "⬜") lines.append( f'{icon} **{r["english"]}** — ' f'{r["persian"]}' f' ({r.get("finglish", "")})' ) return "\n\n".join(lines) def do_random_word(category, transliteration): entry = vocab.get_random_word(category=category) if not entry: return "No words found." return vocab.format_word_card(entry, show_transliteration=transliteration) # ---------- Flashcards ---------- def start_flashcards(category, direction): batch = vocab.get_flashcard_batch(count=10, category=category) if not batch: return "No words available.", [], 0, 0, "", gr.update(visible=False) first = batch[0] if direction == "English → Persian": prompt = f'
{first["english"]}
' else: prompt = _rtl(first["persian"]) return ( prompt, # card_display batch, # batch state 0, # current index 0, # score "", # answer_box cleared gr.update(visible=True), # answer_area visible ) def submit_answer(user_answer, batch, index, score, direction, transliteration): if not batch or index >= len(batch): return "Session complete!", batch, index, score, "", gr.update(visible=False), "" entry = batch[index] dir_key = "en_to_fa" if direction == "English → Persian" else "fa_to_en" is_correct, correct_answer, _ = vocab.check_answer(entry["id"], user_answer, direction=dir_key) if is_correct: score += 1 result = "✅ **Correct!**" else: result = f"❌ **Incorrect.** The answer is: " if dir_key == "en_to_fa": result += f'{correct_answer}' else: result += correct_answer card_info = vocab.format_word_card(entry, show_transliteration=transliteration) feedback = f"{result}\n\n{card_info}\n\n---\n*Rate your recall to continue:*" return feedback, batch, index, score, "", gr.update(visible=True), "" def rate_and_next(rating_str, batch, index, score, direction): if not batch or index >= len(batch): return "Session complete!", batch, index, score, gr.update(visible=False) import fsrs as fsrs_mod rating_map = { "Again": fsrs_mod.Rating.Again, "Hard": fsrs_mod.Rating.Hard, "Good": fsrs_mod.Rating.Good, "Easy": fsrs_mod.Rating.Easy, } rating = rating_map.get(rating_str, fsrs_mod.Rating.Good) entry = batch[index] db.update_word_progress(entry["id"], rating) index += 1 if index >= len(batch): summary = f"## Session Complete!\n\n**Score:** {score}/{len(batch)}\n\n" summary += f"**Accuracy:** {score/len(batch)*100:.0f}%" return summary, batch, index, score, gr.update(visible=False) next_entry = batch[index] if direction == "English → Persian": prompt = f'
{next_entry["english"]}
' else: prompt = _rtl(next_entry["persian"]) return prompt, batch, index, score, gr.update(visible=True) # ---------- Idioms ---------- def show_random_idiom(transliteration): expr = idioms.get_random_expression() return idioms.format_expression(expr, show_transliteration=transliteration), expr def explain_idiom(expr_state): if not expr_state: return "Pick an idiom first." return idioms.explain_expression(expr_state) def browse_idioms(transliteration): exprs = idioms.get_all_expressions() lines = [] for e in exprs: line = f'**{e["persian"]}** — {e["english"]}' if transliteration != "off": line += f' *({e["finglish"]})*' lines.append(line) return "\n\n".join(lines) # ---------- Tutor ---------- def start_tutor_lesson(theme): response, messages, system = tutor.start_lesson(theme) chat_history = [{"role": "assistant", "content": response}] return chat_history, messages, system, time.time() def send_tutor_message(user_msg, chat_history, messages, system, audio_input): # Use STT if audio provided and no text if audio_input is not None and (not user_msg or not user_msg.strip()): try: from stt import transcribe_persian user_msg = transcribe_persian(audio_input) except Exception: user_msg = "" if not user_msg or not user_msg.strip(): return chat_history, messages, "", None response, messages = tutor.process_response(user_msg, messages, system=system) chat_history.append({"role": "user", "content": user_msg}) chat_history.append({"role": "assistant", "content": response}) return chat_history, messages, "", None def save_tutor(theme, messages, start_time): if messages and len(messages) > 1: tutor.save_session(theme, messages, start_time) return "Session saved!" return "Nothing to save." # ---------- Essay ---------- def submit_essay(text, theme): if not text or not text.strip(): return "Please write an essay first." return essay.mark_essay(text, theme) def load_essay_history(): return essay.get_essay_history() # ---------- Settings / Export ---------- def do_anki_export(cats_selected): v = vocab.load_vocab() cats = cats_selected if cats_selected else None path = os.path.join(tempfile.gettempdir(), "gcse-persian.apkg") export_deck(v, categories=cats, output_path=path) return path def reset_progress(): conn = db.get_connection() conn.execute("DELETE FROM word_progress") conn.execute("DELETE FROM quiz_sessions") conn.execute("DELETE FROM essays") conn.execute("DELETE FROM tutor_sessions") conn.commit() return "Progress reset." # ================================================================ # GRADIO UI # ================================================================ with gr.Blocks(title="Persian Language Tutor") as app: gr.Markdown("# 🇮🇷 Persian Language Tutor\n*GCSE Persian vocabulary with spaced repetition*") # Shared state transliteration_state = gr.State(value="Finglish") with gr.Tabs(): # ==================== DASHBOARD ==================== with gr.Tab("📊 Dashboard"): overview_md = gr.Markdown("Loading...") with gr.Row(): cat_table = gr.Dataframe( headers=["Category", "Total", "Seen", "Mastered", "Progress"], label="Category Breakdown", ) quiz_table = gr.Dataframe( headers=["Date", "Category", "Score", "Duration"], label="Recent Quizzes", ) refresh_btn = gr.Button("Refresh", variant="secondary") refresh_btn.click( fn=refresh_dashboard, outputs=[overview_md, cat_table, quiz_table], ) # ==================== VOCABULARY ==================== with gr.Tab("📚 Vocabulary"): with gr.Row(): search_box = gr.Textbox( label="Search (English or Persian)", placeholder="Type to search...", ) vocab_cat = gr.Dropdown( choices=categories, value="All", label="Category" ) search_btn = gr.Button("Search", variant="primary") random_btn = gr.Button("Random Word") search_results = gr.Markdown("Search for a word above.") search_btn.click( fn=do_search, inputs=[search_box, vocab_cat], outputs=[search_results], ) search_box.submit( fn=do_search, inputs=[search_box, vocab_cat], outputs=[search_results], ) random_btn.click( fn=do_random_word, inputs=[vocab_cat, transliteration_state], outputs=[search_results], ) # ==================== FLASHCARDS ==================== with gr.Tab("🃏 Flashcards"): with gr.Row(): fc_category = gr.Dropdown( choices=categories, value="All", label="Category" ) fc_direction = gr.Radio( ["English → Persian", "Persian → English"], value="English → Persian", label="Direction", ) start_fc_btn = gr.Button("Start Session", variant="primary") card_display = gr.Markdown("Press 'Start Session' to begin.") # Hidden states fc_batch = gr.State([]) fc_index = gr.State(0) fc_score = gr.State(0) with gr.Group(visible=False) as answer_area: answer_box = gr.Textbox( label="Your answer", placeholder="Type your answer...", rtl=True, ) submit_ans_btn = gr.Button("Submit Answer", variant="primary") answer_feedback = gr.Markdown("") with gr.Row(): btn_again = gr.Button("Again", variant="stop") btn_hard = gr.Button("Hard", variant="secondary") btn_good = gr.Button("Good", variant="primary") btn_easy = gr.Button("Easy", variant="secondary") start_fc_btn.click( fn=start_flashcards, inputs=[fc_category, fc_direction], outputs=[card_display, fc_batch, fc_index, fc_score, answer_box, answer_area], ) submit_ans_btn.click( fn=submit_answer, inputs=[answer_box, fc_batch, fc_index, fc_score, fc_direction, transliteration_state], outputs=[card_display, fc_batch, fc_index, fc_score, answer_box, answer_area, answer_feedback], ) answer_box.submit( fn=submit_answer, inputs=[answer_box, fc_batch, fc_index, fc_score, fc_direction, transliteration_state], outputs=[card_display, fc_batch, fc_index, fc_score, answer_box, answer_area, answer_feedback], ) for btn, label in [(btn_again, "Again"), (btn_hard, "Hard"), (btn_good, "Good"), (btn_easy, "Easy")]: btn.click( fn=rate_and_next, inputs=[gr.State(label), fc_batch, fc_index, fc_score, fc_direction], outputs=[card_display, fc_batch, fc_index, fc_score, answer_area], ) # ==================== IDIOMS ==================== with gr.Tab("💬 Idioms & Expressions"): idiom_display = gr.Markdown("Click 'Random Idiom' or browse below.") idiom_state = gr.State(None) with gr.Row(): random_idiom_btn = gr.Button("Random Idiom", variant="primary") explain_idiom_btn = gr.Button("Explain Usage") browse_idiom_btn = gr.Button("Browse All") idiom_explanation = gr.Markdown("") random_idiom_btn.click( fn=show_random_idiom, inputs=[transliteration_state], outputs=[idiom_display, idiom_state], ) explain_idiom_btn.click( fn=explain_idiom, inputs=[idiom_state], outputs=[idiom_explanation], ) browse_idiom_btn.click( fn=browse_idioms, inputs=[transliteration_state], outputs=[idiom_display], ) # ==================== TUTOR ==================== with gr.Tab("🎓 Tutor"): tutor_theme = gr.Dropdown( choices=list(THEME_PROMPTS.keys()), value="Identity and culture", label="Theme", ) start_lesson_btn = gr.Button("New Lesson", variant="primary") chatbot = gr.Chatbot(label="Conversation") # Tutor states tutor_messages = gr.State([]) tutor_system = gr.State("") tutor_start_time = gr.State(0) with gr.Row(): tutor_input = gr.Textbox( label="Your message", placeholder="Type in English or Persian...", scale=3, ) tutor_mic = gr.Audio( sources=["microphone"], type="numpy", label="Speak", scale=1, ) send_btn = gr.Button("Send", variant="primary") save_btn = gr.Button("Save Session", variant="secondary") save_status = gr.Markdown("") start_lesson_btn.click( fn=start_tutor_lesson, inputs=[tutor_theme], outputs=[chatbot, tutor_messages, tutor_system, tutor_start_time], ) send_btn.click( fn=send_tutor_message, inputs=[tutor_input, chatbot, tutor_messages, tutor_system, tutor_mic], outputs=[chatbot, tutor_messages, tutor_input, tutor_mic], ) tutor_input.submit( fn=send_tutor_message, inputs=[tutor_input, chatbot, tutor_messages, tutor_system, tutor_mic], outputs=[chatbot, tutor_messages, tutor_input, tutor_mic], ) save_btn.click( fn=save_tutor, inputs=[tutor_theme, tutor_messages, tutor_start_time], outputs=[save_status], ) # ==================== ESSAY ==================== with gr.Tab("✍️ Essay"): essay_theme = gr.Dropdown( choices=GCSE_THEMES, value="Identity and culture", label="Theme", ) essay_input = gr.Textbox( label="Write your essay in Persian", lines=10, rtl=True, placeholder="اینجا بنویسید...", ) submit_essay_btn = gr.Button("Submit for Marking", variant="primary") essay_feedback = gr.Markdown("Write an essay and submit for AI marking.") gr.Markdown("### Essay History") essay_history_table = gr.Dataframe( headers=["Date", "Theme", "Grade", "Preview"], label="Past Essays", ) refresh_essays_btn = gr.Button("Refresh History") submit_essay_btn.click( fn=submit_essay, inputs=[essay_input, essay_theme], outputs=[essay_feedback], ) refresh_essays_btn.click( fn=load_essay_history, outputs=[essay_history_table], ) # ==================== SETTINGS ==================== with gr.Tab("⚙️ Settings"): gr.Markdown("## Settings") transliteration_radio = gr.Radio( ["off", "Finglish", "Academic"], value="Finglish", label="Transliteration", ) ollama_model = gr.Textbox( label="Ollama Model", value="qwen2.5:7b", info="Model used for fast AI responses", ) whisper_size = gr.Dropdown( choices=["tiny", "base", "small", "medium", "large-v3"], value="medium", label="Whisper Model Size", ) gr.Markdown("### Anki Export") export_cats = gr.Dropdown( choices=vocab.get_categories(), multiselect=True, label="Categories to export (empty = all)", ) export_btn = gr.Button("Export to Anki (.apkg)", variant="primary") export_file = gr.File(label="Download") export_btn.click(fn=do_anki_export, inputs=[export_cats], outputs=[export_file]) gr.Markdown("### Reset") reset_btn = gr.Button("Reset All Progress", variant="stop") reset_status = gr.Markdown("") reset_btn.click(fn=reset_progress, outputs=[reset_status]) # Wire transliteration state transliteration_radio.change( fn=lambda x: x, inputs=[transliteration_radio], outputs=[transliteration_state], ) # Load dashboard on app start app.load(fn=refresh_dashboard, outputs=[overview_md, cat_table, quiz_table]) if __name__ == "__main__": app.launch(theme=gr.themes.Soft())