Files
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

154 lines
4.7 KiB
Python

"""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}
all_progress = db.get_all_word_progress()
# Prefer unseen words
unseen = [e for e in pool if e["id"] not in seen_ids and e["id"] not in all_progress]
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"