From 231dd0385e6eec8dec8ce935b65675e2c6caa09f Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 12 Oct 2026 13:01:35 +0530 Subject: [PATCH] FIFO question recycle: exhausted subtopics return oldest-seen first seen_questions gains seen_at (with ALTER TABLE migration for existing DBs, index created after the migrate step). Submit stamps seen_at with an upsert so repeats refresh recency. build_paper tops up short unseen-only pools from longest-ago-seen in scope instead of erroring. Verified live: 1-question subtopic served the same seen question on repeat builds (303, not 400). --- src/db.rs | 25 ++++++++++++++++++++++++- src/exam.rs | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/db.rs b/src/db.rs index f67e5dc..1e9d032 100644 --- a/src/db.rs +++ b/src/db.rs @@ -13,7 +13,7 @@ pub async fn init_db(pool: &SqlitePool) -> anyhow::Result<()> { CREATE TABLE IF NOT EXISTS mistake_tags(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(attempt_id, question_id, tag)); CREATE TABLE IF NOT EXISTS sessions(token TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pkce(state TEXT PRIMARY KEY, verifier TEXT NOT NULL, created_at TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS seen_questions(user_id TEXT NOT NULL, question_id INTEGER NOT NULL, PRIMARY KEY(user_id, question_id)); + CREATE TABLE IF NOT EXISTS seen_questions(user_id TEXT NOT NULL, question_id INTEGER NOT NULL, seen_at TEXT NOT NULL DEFAULT '', PRIMARY KEY(user_id, question_id)); CREATE INDEX IF NOT EXISTS idx_questions_chapter ON questions(chapter_id); CREATE INDEX IF NOT EXISTS idx_questions_subtopic ON questions(subtopic_id); CREATE INDEX IF NOT EXISTS idx_questions_subject ON questions(subject); @@ -26,6 +26,29 @@ pub async fn init_db(pool: &SqlitePool) -> anyhow::Result<()> { ) .execute(pool) .await?; + // Migrate pre-seen_at databases: CREATE TABLE IF NOT EXISTS cannot add + // the column to an existing table, so ALTER it in before creating the + // index that references it. + { + use sqlx::Row; + let cols: Vec = sqlx::query("PRAGMA table_info(seen_questions)") + .fetch_all(pool) + .await + .unwrap_or_default() + .iter() + .map(|r| r.get::("name")) + .collect(); + if !cols.iter().any(|c| c == "seen_at") { + sqlx::query("ALTER TABLE seen_questions ADD COLUMN seen_at TEXT NOT NULL DEFAULT ''") + .execute(pool) + .await + .ok(); + } + sqlx::query("CREATE INDEX IF NOT EXISTS idx_seen_user_time ON seen_questions(user_id, seen_at)") + .execute(pool) + .await + .ok(); + } let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM questions") .fetch_one(pool) .await?; diff --git a/src/exam.rs b/src/exam.rs index f4acae1..a8fbc15 100644 --- a/src/exam.rs +++ b/src/exam.rs @@ -125,9 +125,45 @@ pub async fn build_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow: // Deduplicate: a question can match multiple subtopics if the form sent both. ids.sort_unstable(); ids.dedup(); + // FIFO recycle: unseen-only starves once a subtopic is exhausted. Top up + // from longest-ago-seen first so familiar questions return oldest first + // instead of erroring. seen_at='' rows (pre-migration) sort first. + let mut recycled = 0; + if unseen && (ids.len() as i64) < count as i64 { + let need = count as usize - ids.len(); + let scope_ch: String = chapters.iter().map(|c| c.to_string()).collect::>().join(","); + let scope_st: String = subtopics.iter().map(|st| st.to_string()).collect::>().join(","); + // Static query per branch (sqlx); scope filter applied in Rust below. + let seen_rows: Vec<(i64, i64, i64)> = sqlx::query_as( + "SELECT q.id, q.chapter_id, q.subtopic_id FROM questions q + JOIN seen_questions sq ON sq.question_id=q.id AND sq.user_id=? + ORDER BY sq.seen_at ASC", + ) + .bind(uid) + .fetch_all(pool) + .await + .unwrap_or_default(); + let in_scope = |ch: i64, st: i64| { + chapters.contains(&ch) + && (subtopics.is_empty() || subtopics.contains(&st)) + }; + let _ = (scope_ch, scope_st); + for (qid, ch, st) in seen_rows { + if ids.len() >= count as usize { + break; + } + if !in_scope(ch, st) || ids.contains(&qid) { + continue; + } + ids.push(qid); + recycled += 1; + } + let _ = need; + } if ids.is_empty() { anyhow::bail!("No questions left for that selection. Turn off 'unseen only' to reuse questions."); } + let _ = recycled; let seed = uid.len() as u64 ^ chapters.iter().sum::() as u64 ^ count as u64; shuffle(&mut ids, seed); ids.truncate(count); @@ -343,9 +379,11 @@ pub async fn finalize(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(i32, .await?; for r in &rows { if !r.selected.trim().is_empty() { - sqlx::query("INSERT OR IGNORE INTO seen_questions(user_id,question_id) VALUES(?,?)") + let now = Utc::now().to_rfc3339(); + sqlx::query("INSERT INTO seen_questions(user_id,question_id,seen_at) VALUES(?,?,?) ON CONFLICT(user_id,question_id) DO UPDATE SET seen_at=excluded.seen_at") .bind(&uid) .bind(r.q.id) + .bind(&now) .execute(pool) .await?; }