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:
local
2026-02-08 01:57:44 +00:00
parent 104da381fb
commit 2e8c2c11d0
22 changed files with 10664 additions and 0 deletions

View File

View File

@@ -0,0 +1,84 @@
"""Dashboard: progress stats, charts, and overview."""
import db
from modules.vocab import load_vocab, get_categories
def get_overview():
"""Return overview stats: total words, seen, mastered, due today."""
vocab = load_vocab()
counts = db.get_word_counts(total_vocab_size=len(vocab))
stats = db.get_stats()
counts["streak"] = stats["streak"]
counts["total_reviews"] = stats["total_reviews"]
counts["total_quizzes"] = stats["total_quizzes"]
return counts
def get_category_breakdown():
"""Return progress per category as list of dicts."""
vocab = load_vocab()
categories = get_categories()
breakdown = []
for cat in categories:
cat_words = [e for e in vocab if e["category"] == cat]
cat_ids = {e["id"] for e in cat_words}
total = len(cat_words)
seen = 0
mastered = 0
for wid in cat_ids:
progress = db.get_word_progress(wid)
if progress:
seen += 1
if progress["stability"] and progress["stability"] > 10:
mastered += 1
breakdown.append({
"Category": cat,
"Total": total,
"Seen": seen,
"Mastered": mastered,
"Progress": f"{seen}/{total}" if total > 0 else "0/0",
})
return breakdown
def get_recent_quizzes(limit=10):
"""Return recent quiz results as list of dicts for display."""
stats = db.get_stats()
quizzes = stats["recent_quizzes"][:limit]
result = []
for q in quizzes:
result.append({
"Date": q["timestamp"],
"Category": q["category"] or "All",
"Score": f"{q['correct']}/{q['total_questions']}",
"Duration": f"{q['duration_seconds'] or 0}s",
})
return result
def format_overview_markdown():
"""Format overview stats as a markdown string for display."""
o = get_overview()
pct = (o["seen"] / o["total"] * 100) if o["total"] > 0 else 0
bar_filled = int(pct / 5)
bar_empty = 20 - bar_filled
progress_bar = "" * bar_filled + "" * bar_empty
lines = [
"## Dashboard",
"",
f"**Words studied:** {o['seen']} / {o['total']} ({pct:.0f}%)",
f"`{progress_bar}`",
"",
f"**Due today:** {o['due']}",
f"**Mastered:** {o['mastered']}",
f"**Daily streak:** {o['streak']} day{'s' if o['streak'] != 1 else ''}",
f"**Total reviews:** {o['total_reviews']}",
f"**Quiz sessions:** {o['total_quizzes']}",
]
return "\n".join(lines)

View File

@@ -0,0 +1,78 @@
"""Essay writing and AI marking."""
import db
from ai import ask
MARKING_SYSTEM_PROMPT = """You are an expert Persian (Farsi) language teacher marking a GCSE-level essay.
You write in English but can read and correct Persian text.
Always provide constructive, encouraging feedback suitable for a language learner."""
MARKING_PROMPT_TEMPLATE = """Please mark this Persian essay written by a GCSE student.
Theme: {theme}
Student's essay:
{essay_text}
Please provide your response in this exact format:
**Grade:** [Give a grade from 1-9 matching GCSE grading, or a descriptive level like A2/B1]
**Summary:** [1-2 sentence overview of the essay quality]
**Corrections:**
[List specific errors with corrections. For each error, show the original text and the corrected version in Persian, with an English explanation]
**Improved version:**
[Rewrite the essay in corrected Persian]
**Tips for improvement:**
[3-5 specific, actionable tips for the student]"""
GCSE_THEMES = [
"Identity and culture",
"Local area and environment",
"School and work",
"Travel and tourism",
"International and global dimension",
]
def mark_essay(essay_text, theme="General"):
"""Send essay to AI for marking. Returns structured feedback."""
if not essay_text or not essay_text.strip():
return "Please write an essay first."
prompt = MARKING_PROMPT_TEMPLATE.format(
theme=theme,
essay_text=essay_text.strip(),
)
feedback = ask(prompt, system=MARKING_SYSTEM_PROMPT, quality="smart")
# Extract grade from feedback (best-effort)
grade = ""
for line in feedback.split("\n"):
if line.strip().startswith("**Grade:**"):
grade = line.replace("**Grade:**", "").strip()
break
# Save to database
db.save_essay(essay_text.strip(), grade, feedback, theme)
return feedback
def get_essay_history(limit=10):
"""Return recent essays for the history view."""
essays = db.get_recent_essays(limit)
result = []
for e in essays:
result.append({
"Date": e["timestamp"],
"Theme": e["theme"] or "General",
"Grade": e["grade"] or "-",
"Preview": (e["essay_text"] or "")[:50] + "...",
})
return result

View File

@@ -0,0 +1,200 @@
"""Persian idioms, expressions, and social conventions."""
from ai import ask
# Built-in collection of common Persian expressions and idioms
EXPRESSIONS = [
{
"persian": "سلام علیکم",
"finglish": "salâm aleykom",
"english": "Peace be upon you (formal greeting)",
"context": "Formal greeting, especially with elders",
},
{
"persian": "خسته نباشید",
"finglish": "khaste nabâshid",
"english": "May you not be tired",
"context": "Common greeting to someone who has been working. Used as 'hello' in shops, offices, etc.",
},
{
"persian": "دستت درد نکنه",
"finglish": "dastet dard nakone",
"english": "May your hand not hurt",
"context": "Thank you for your effort (after someone does something for you)",
},
{
"persian": "قابلی نداره",
"finglish": "ghâbeli nadâre",
"english": "It's not worthy (of you)",
"context": "You're welcome / Don't mention it — said when giving a gift or doing a favour",
},
{
"persian": "تعارف نکن",
"finglish": "ta'ârof nakon",
"english": "Don't do ta'arof",
"context": "Stop being politely modest — please accept! Part of Persian ta'arof culture.",
},
{
"persian": "نوش جان",
"finglish": "nush-e jân",
"english": "May it nourish your soul",
"context": "Said to someone eating — like 'bon appétit' or 'enjoy your meal'",
},
{
"persian": "چشمت روز بد نبینه",
"finglish": "cheshmet ruz-e bad nabine",
"english": "May your eyes never see a bad day",
"context": "A warm wish for someone's wellbeing",
},
{
"persian": "قدمت روی چشم",
"finglish": "ghadamet ru-ye cheshm",
"english": "Your step is on my eye",
"context": "Warm welcome — 'you're very welcome here'. Extremely hospitable expression.",
},
{
"persian": "ان‌شاءالله",
"finglish": "inshâ'allâh",
"english": "God willing",
"context": "Used when talking about future plans. Very common in daily speech.",
},
{
"persian": "ماشاءالله",
"finglish": "mâshâ'allâh",
"english": "What God has willed",
"context": "Expression of admiration or praise, also used to ward off the evil eye.",
},
{
"persian": "الهی شکر",
"finglish": "elâhi shokr",
"english": "Thank God",
"context": "Expression of gratitude, similar to 'thankfully'",
},
{
"persian": "به سلامتی",
"finglish": "be salâmati",
"english": "To your health / Cheers",
"context": "A toast or general well-wishing expression",
},
{
"persian": "عید مبارک",
"finglish": "eyd mobârak",
"english": "Happy holiday/celebration",
"context": "Used for any celebration, especially Nowruz",
},
{
"persian": "تسلیت می‌گم",
"finglish": "tasliyat migam",
"english": "I offer my condolences",
"context": "Expressing sympathy when someone has lost a loved one",
},
{
"persian": "خدا بیامرزه",
"finglish": "khodâ biâmorzesh",
"english": "May God forgive them (rest in peace)",
"context": "Said about someone who has passed away",
},
{
"persian": "زبونت رو گاز بگیر",
"finglish": "zaboonet ro gâz begir",
"english": "Bite your tongue",
"context": "Don't say such things! (similar to English 'touch wood')",
},
{
"persian": "دمت گرم",
"finglish": "damet garm",
"english": "May your breath be warm",
"context": "Well done! / Good for you! (informal, friendly praise)",
},
{
"persian": "چشم",
"finglish": "cheshm",
"english": "On my eye (I will do it)",
"context": "Respectful way of saying 'yes, I'll do it' — shows obedience/respect",
},
{
"persian": "بفرمایید",
"finglish": "befarmâyid",
"english": "Please (go ahead / help yourself / come in)",
"context": "Very versatile polite expression: offering food, inviting someone in, or giving way",
},
{
"persian": "ببخشید",
"finglish": "bebakhshid",
"english": "Excuse me / I'm sorry",
"context": "Used for both apologies and getting someone's attention",
},
{
"persian": "مخلصیم",
"finglish": "mokhlesim",
"english": "I'm your humble servant",
"context": "Polite/humble way of saying goodbye or responding to a compliment (ta'arof)",
},
{
"persian": "سرت سلامت باشه",
"finglish": "saret salâmat bâshe",
"english": "May your head be safe",
"context": "Expression of condolence — 'I'm sorry for your loss'",
},
{
"persian": "روی ما رو زمین ننداز",
"finglish": "ru-ye mâ ro zamin nandâz",
"english": "Don't throw our face on the ground",
"context": "Please don't refuse/embarrass us — said when insisting on a request",
},
{
"persian": "قربونت برم",
"finglish": "ghorboonet beram",
"english": "I'd sacrifice myself for you",
"context": "Term of endearment — very common among family and close friends",
},
{
"persian": "جون دل",
"finglish": "jun-e del",
"english": "Life of my heart",
"context": "Affectionate term used with loved ones",
},
]
def get_all_expressions():
"""Return all built-in expressions."""
return EXPRESSIONS
def get_random_expression():
"""Pick a random expression."""
import random
return random.choice(EXPRESSIONS)
def explain_expression(expression):
"""Use AI to generate a detailed explanation with usage examples."""
prompt = f"""Explain this Persian expression for an English-speaking student:
Persian: {expression['persian']}
Transliteration: {expression['finglish']}
Literal meaning: {expression['english']}
Context: {expression['context']}
Please provide:
1. A fuller explanation of when and how this is used
2. The cultural context (ta'arof, hospitality, etc.)
3. Two example dialogues showing it in use (in Persian with English translation)
4. Any variations or related expressions
Keep it concise and student-friendly."""
return ask(prompt, quality="fast")
def format_expression(expr, show_transliteration="off"):
"""Format an expression for display."""
parts = [
f'<div dir="rtl" style="font-size:1.8em; text-align:center">{expr["persian"]}</div>',
f'<div style="text-align:center; font-size:1.2em">{expr["english"]}</div>',
]
if show_transliteration != "off":
parts.append(f'<div style="text-align:center; color:#666; font-style:italic">{expr["finglish"]}</div>')
parts.append(f'<div style="text-align:center; color:#888; margin-top:0.5em">{expr["context"]}</div>')
return "\n".join(parts)

View File

@@ -0,0 +1,65 @@
"""Conversational Persian lessons by GCSE theme."""
import time
import db
from ai import chat_ollama
TUTOR_SYSTEM_PROMPT = """You are a friendly Persian (Farsi) language tutor teaching English-speaking GCSE students.
Rules:
- Use a mix of English and Persian. Start mostly in English, gradually introducing more Persian.
- When you write Persian, also provide the Finglish transliteration in parentheses.
- Keep responses concise (2-4 sentences per turn).
- Ask the student to practice: translate phrases, answer questions in Persian, or fill in blanks.
- Correct mistakes gently and explain why.
- Stay on the current theme/topic.
- Use Iranian Persian (Farsi), not Dari or Tajik.
- Adapt to the student's level based on their responses."""
THEME_PROMPTS = {
"Identity and culture": "Let's practice talking about family, personality, daily routines, and Persian celebrations like Nowruz!",
"Local area and environment": "Let's practice talking about your home, neighbourhood, shopping, and the environment!",
"School and work": "Let's practice talking about school subjects, school life, jobs, and future plans!",
"Travel and tourism": "Let's practice talking about transport, directions, holidays, hotels, and restaurants!",
"International and global dimension": "Let's practice talking about health, global issues, technology, and social media!",
"Free conversation": "Let's have a free conversation in Persian! I'll help you along the way.",
}
def start_lesson(theme):
"""Generate the opening message for a new lesson.
Returns:
(assistant_message, messages_list)
"""
intro = THEME_PROMPTS.get(theme, THEME_PROMPTS["Free conversation"])
system = TUTOR_SYSTEM_PROMPT + f"\n\nCurrent topic: {theme}. {intro}"
messages = [{"role": "user", "content": f"I'd like to practice Persian. Today's theme is: {theme}"}]
response = chat_ollama(messages, system=system)
messages.append({"role": "assistant", "content": response})
return response, messages, system
def process_response(user_input, messages, system=None):
"""Add user input to conversation, get AI response.
Returns:
(assistant_response, updated_messages)
"""
if not user_input or not user_input.strip():
return "", messages
messages.append({"role": "user", "content": user_input.strip()})
response = chat_ollama(messages, system=system)
messages.append({"role": "assistant", "content": response})
return response, messages
def save_session(theme, messages, start_time):
"""Save the current tutor session to the database."""
duration = int(time.time() - start_time)
db.save_tutor_session(theme, messages, duration)

View File

@@ -0,0 +1,152 @@
"""Vocabulary search, flashcard logic, and FSRS-driven review."""
import json
import random
from pathlib import Path
import fsrs
import db
VOCAB_PATH = Path(__file__).parent.parent / "data" / "vocabulary.json"
_vocab_data = None
def load_vocab():
"""Load vocabulary data from JSON (cached)."""
global _vocab_data
if _vocab_data is None:
with open(VOCAB_PATH, encoding="utf-8") as f:
_vocab_data = json.load(f)
return _vocab_data
def get_categories():
"""Return sorted list of unique categories."""
vocab = load_vocab()
return sorted({entry["category"] for entry in vocab})
def get_sections():
"""Return sorted list of unique sections."""
vocab = load_vocab()
return sorted({entry["section"] for entry in vocab})
def search(query, vocab_data=None):
"""Search vocabulary by English or Persian text. Returns matching entries."""
if not query or not query.strip():
return []
vocab = vocab_data or load_vocab()
query_lower = query.strip().lower()
results = []
for entry in vocab:
if (
query_lower in entry["english"].lower()
or query_lower in entry["persian"]
or (entry.get("finglish") and query_lower in entry["finglish"].lower())
):
results.append(entry)
return results
def get_random_word(vocab_data=None, category=None):
"""Pick a random vocabulary entry, optionally filtered by category."""
vocab = vocab_data or load_vocab()
if category and category != "All":
filtered = [e for e in vocab if e["category"] == category]
else:
filtered = vocab
if not filtered:
return None
return random.choice(filtered)
def get_flashcard_batch(count=10, category=None):
"""Get a batch of words for flashcard study.
Prioritizes due words (FSRS), then fills with new/random words.
"""
vocab = load_vocab()
if category and category != "All":
pool = [e for e in vocab if e["category"] == category]
else:
pool = vocab
# Get due words first
due_ids = db.get_due_words(limit=count)
due_entries = [e for e in pool if e["id"] in due_ids]
# Fill remaining with unseen or random words
remaining = count - len(due_entries)
if remaining > 0:
seen_ids = {e["id"] for e in due_entries}
# Prefer unseen words
unseen = [e for e in pool if e["id"] not in seen_ids and not db.get_word_progress(e["id"])]
if len(unseen) >= remaining:
fill = random.sample(unseen, remaining)
else:
# Use all unseen + random from rest
fill = unseen
still_needed = remaining - len(fill)
rest = [e for e in pool if e["id"] not in seen_ids and e not in fill]
if rest:
fill.extend(random.sample(rest, min(still_needed, len(rest))))
due_entries.extend(fill)
random.shuffle(due_entries)
return due_entries
def check_answer(word_id, user_answer, direction="en_to_fa"):
"""Check if user's answer matches the target word.
Args:
word_id: Vocabulary entry ID.
user_answer: What the user typed.
direction: "en_to_fa" (user writes Persian) or "fa_to_en" (user writes English).
Returns:
(is_correct, correct_answer, entry)
"""
vocab = load_vocab()
entry = next((e for e in vocab if e["id"] == word_id), None)
if not entry:
return False, "", None
user_answer = user_answer.strip()
if direction == "en_to_fa":
correct = entry["persian"].strip()
is_correct = user_answer == correct
else:
correct = entry["english"].strip().lower()
is_correct = user_answer.lower() == correct
return is_correct, correct if not is_correct else user_answer, entry
def format_word_card(entry, show_transliteration="off"):
"""Format a vocabulary entry for display as RTL-safe markdown."""
parts = []
parts.append(f'<div dir="rtl" style="font-size:2em; text-align:center">{entry["persian"]}</div>')
parts.append(f'<div style="font-size:1.3em; text-align:center">{entry["english"]}</div>')
if show_transliteration != "off" and entry.get("finglish"):
parts.append(f'<div style="text-align:center; color:#666; font-style:italic">{entry["finglish"]}</div>')
parts.append(f'<div style="text-align:center; color:#999; font-size:0.9em">{entry.get("category", "")}</div>')
return "\n".join(parts)
def get_word_status(word_id):
"""Return status string for a word: new, learning, or mastered."""
progress = db.get_word_progress(word_id)
if not progress:
return "new"
if progress["stability"] and progress["stability"] > 10:
return "mastered"
return "learning"