Add persian-tutor: Gradio-based GCSE Persian language learning app
Vocabulary study with FSRS spaced repetition, AI tutoring (Ollama/Claude), essay marking, idioms browser, Anki export, and dashboard. 918 vocabulary entries across 39 categories. 41 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
511
python/persian-tutor/app.py
Normal file
511
python/persian-tutor/app.py
Normal file
@@ -0,0 +1,511 @@
|
||||
"""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'<div dir="rtl" style="font-size:{size}; text-align:center">{text}</div>'
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 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'<span dir="rtl">{r["persian"]}</span>'
|
||||
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'<div style="font-size:2em; text-align:center">{first["english"]}</div>'
|
||||
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'<span dir="rtl">{correct_answer}</span>'
|
||||
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'<div style="font-size:2em; text-align:center">{next_entry["english"]}</div>'
|
||||
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'**<span dir="rtl">{e["persian"]}</span>** — {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())
|
||||
Reference in New Issue
Block a user