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>
512 lines
18 KiB
Python
512 lines
18 KiB
Python
"""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())
|