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:
234
python/persian-tutor/db.py
Normal file
234
python/persian-tutor/db.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""SQLite database layer with FSRS spaced repetition integration."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import fsrs
|
||||
|
||||
DB_PATH = Path(__file__).parent / "data" / "progress.db"
|
||||
|
||||
_conn = None
|
||||
_scheduler = fsrs.Scheduler()
|
||||
|
||||
|
||||
def get_connection():
|
||||
"""Return the shared SQLite connection (singleton)."""
|
||||
global _conn
|
||||
if _conn is None:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
|
||||
_conn.row_factory = sqlite3.Row
|
||||
_conn.execute("PRAGMA journal_mode=WAL")
|
||||
return _conn
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Create all tables if they don't exist. Called once at startup."""
|
||||
conn = get_connection()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS word_progress (
|
||||
word_id TEXT PRIMARY KEY,
|
||||
fsrs_state TEXT,
|
||||
due TIMESTAMP,
|
||||
stability REAL,
|
||||
difficulty REAL,
|
||||
reps INTEGER DEFAULT 0,
|
||||
lapses INTEGER DEFAULT 0,
|
||||
last_review TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quiz_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
category TEXT,
|
||||
total_questions INTEGER,
|
||||
correct INTEGER,
|
||||
duration_seconds INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS essays (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
essay_text TEXT,
|
||||
grade TEXT,
|
||||
feedback TEXT,
|
||||
theme TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tutor_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
theme TEXT,
|
||||
messages TEXT,
|
||||
duration_seconds INTEGER
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_word_progress(word_id):
|
||||
"""Return learning state for one word, or None if never reviewed."""
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM word_progress WHERE word_id = ?", (word_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def update_word_progress(word_id, rating):
|
||||
"""Run FSRS algorithm, update due date/stability/difficulty.
|
||||
|
||||
Args:
|
||||
word_id: Vocabulary entry ID.
|
||||
rating: fsrs.Rating value (Again=1, Hard=2, Good=3, Easy=4).
|
||||
"""
|
||||
conn = get_connection()
|
||||
existing = get_word_progress(word_id)
|
||||
|
||||
if existing and existing["fsrs_state"]:
|
||||
card = fsrs.Card.from_dict(json.loads(existing["fsrs_state"]))
|
||||
else:
|
||||
card = fsrs.Card()
|
||||
|
||||
card, review_log = _scheduler.review_card(card, rating)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
card_json = json.dumps(card.to_dict(), default=str)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO word_progress
|
||||
(word_id, fsrs_state, due, stability, difficulty, reps, lapses, last_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
word_id,
|
||||
card_json,
|
||||
card.due.isoformat(),
|
||||
card.stability,
|
||||
card.difficulty,
|
||||
(existing["reps"] + 1) if existing else 1,
|
||||
existing["lapses"] if existing else 0,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return card
|
||||
|
||||
|
||||
def get_due_words(limit=20):
|
||||
"""Return word IDs where due <= now, ordered by due date."""
|
||||
conn = get_connection()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
rows = conn.execute(
|
||||
"SELECT word_id FROM word_progress WHERE due <= ? ORDER BY due LIMIT ?",
|
||||
(now, limit),
|
||||
).fetchall()
|
||||
return [row["word_id"] for row in rows]
|
||||
|
||||
|
||||
def get_word_counts(total_vocab_size=0):
|
||||
"""Return dict with total/seen/mastered/due counts for dashboard."""
|
||||
conn = get_connection()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
seen = conn.execute("SELECT COUNT(*) FROM word_progress").fetchone()[0]
|
||||
mastered = conn.execute(
|
||||
"SELECT COUNT(*) FROM word_progress WHERE stability > 10"
|
||||
).fetchone()[0]
|
||||
due = conn.execute(
|
||||
"SELECT COUNT(*) FROM word_progress WHERE due <= ?", (now,)
|
||||
).fetchone()[0]
|
||||
|
||||
return {
|
||||
"total": total_vocab_size,
|
||||
"seen": seen,
|
||||
"mastered": mastered,
|
||||
"due": due,
|
||||
}
|
||||
|
||||
|
||||
def record_quiz_session(category, total_questions, correct, duration_seconds):
|
||||
"""Log a completed flashcard session."""
|
||||
conn = get_connection()
|
||||
conn.execute(
|
||||
"INSERT INTO quiz_sessions (category, total_questions, correct, duration_seconds) VALUES (?, ?, ?, ?)",
|
||||
(category, total_questions, correct, duration_seconds),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def save_essay(essay_text, grade, feedback, theme):
|
||||
"""Save an essay + AI feedback."""
|
||||
conn = get_connection()
|
||||
conn.execute(
|
||||
"INSERT INTO essays (essay_text, grade, feedback, theme) VALUES (?, ?, ?, ?)",
|
||||
(essay_text, grade, feedback, theme),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def save_tutor_session(theme, messages, duration_seconds):
|
||||
"""Save a tutor conversation."""
|
||||
conn = get_connection()
|
||||
conn.execute(
|
||||
"INSERT INTO tutor_sessions (theme, messages, duration_seconds) VALUES (?, ?, ?)",
|
||||
(theme, json.dumps(messages, ensure_ascii=False), duration_seconds),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_stats():
|
||||
"""Aggregate data for the dashboard."""
|
||||
conn = get_connection()
|
||||
|
||||
recent_quizzes = conn.execute(
|
||||
"SELECT * FROM quiz_sessions ORDER BY timestamp DESC LIMIT 10"
|
||||
).fetchall()
|
||||
|
||||
total_reviews = conn.execute(
|
||||
"SELECT COALESCE(SUM(reps), 0) FROM word_progress"
|
||||
).fetchone()[0]
|
||||
|
||||
total_quizzes = conn.execute(
|
||||
"SELECT COUNT(*) FROM quiz_sessions"
|
||||
).fetchone()[0]
|
||||
|
||||
# Streak: count consecutive days with activity
|
||||
days = conn.execute(
|
||||
"SELECT DISTINCT DATE(last_review) as d FROM word_progress WHERE last_review IS NOT NULL ORDER BY d DESC"
|
||||
).fetchall()
|
||||
|
||||
streak = 0
|
||||
today = datetime.now(timezone.utc).date()
|
||||
for i, row in enumerate(days):
|
||||
day = datetime.fromisoformat(row["d"]).date() if isinstance(row["d"], str) else row["d"]
|
||||
expected = today - __import__("datetime").timedelta(days=i)
|
||||
if day == expected:
|
||||
streak += 1
|
||||
else:
|
||||
break
|
||||
|
||||
return {
|
||||
"recent_quizzes": [dict(r) for r in recent_quizzes],
|
||||
"total_reviews": total_reviews,
|
||||
"total_quizzes": total_quizzes,
|
||||
"streak": streak,
|
||||
}
|
||||
|
||||
|
||||
def get_recent_essays(limit=10):
|
||||
"""Return recent essays for the essay history view."""
|
||||
conn = get_connection()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM essays ORDER BY timestamp DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def close():
|
||||
"""Close the database connection."""
|
||||
global _conn
|
||||
if _conn:
|
||||
_conn.close()
|
||||
_conn = None
|
||||
Reference in New Issue
Block a user