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:
0
python/persian-tutor/modules/__init__.py
Normal file
0
python/persian-tutor/modules/__init__.py
Normal file
84
python/persian-tutor/modules/dashboard.py
Normal file
84
python/persian-tutor/modules/dashboard.py
Normal 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)
|
||||
78
python/persian-tutor/modules/essay.py
Normal file
78
python/persian-tutor/modules/essay.py
Normal 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
|
||||
200
python/persian-tutor/modules/idioms.py
Normal file
200
python/persian-tutor/modules/idioms.py
Normal 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)
|
||||
65
python/persian-tutor/modules/tutor.py
Normal file
65
python/persian-tutor/modules/tutor.py
Normal 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)
|
||||
152
python/persian-tutor/modules/vocab.py
Normal file
152
python/persian-tutor/modules/vocab.py
Normal 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"
|
||||
Reference in New Issue
Block a user