Files
Code/python/persian-tutor/app.py
dl92 3a8705ece8 Fix bugs, N+1 queries, and wire settings in persian-tutor
- Replace inline __import__("datetime").timedelta hack with proper import
- Remove unused import random in anki_export.py
- Add error handling for Claude CLI subprocess failures in ai.py
- Fix hardcoded absolute path in stt.py with relative Path resolution
- Fix N+1 DB queries in vocab.get_flashcard_batch and dashboard.get_category_breakdown
  by adding db.get_all_word_progress() batch query
- Wire Ollama model and Whisper size settings to actually update config
  via ai.set_ollama_model() and stt.set_whisper_size()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:40:24 +00:00

526 lines
18 KiB
Python

"""Persian Language Tutor — Gradio UI."""
import json
import os
import tempfile
import time
import gradio as gr
import ai
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 update_ollama_model(model):
ai.set_ollama_model(model)
def update_whisper_size(size):
from stt import set_whisper_size
set_whisper_size(size)
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])
# Wire model settings
ollama_model.change(fn=update_ollama_model, inputs=[ollama_model])
whisper_size.change(fn=update_whisper_size, inputs=[whisper_size])
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())