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).
This commit is contained in:
dev 2026-10-12 13:01:35 +05:30
parent 4427837e7a
commit 231dd0385e
2 changed files with 63 additions and 2 deletions

View file

@ -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 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 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 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_chapter ON questions(chapter_id);
CREATE INDEX IF NOT EXISTS idx_questions_subtopic ON questions(subtopic_id); CREATE INDEX IF NOT EXISTS idx_questions_subtopic ON questions(subtopic_id);
CREATE INDEX IF NOT EXISTS idx_questions_subject ON questions(subject); 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) .execute(pool)
.await?; .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<String> = sqlx::query("PRAGMA table_info(seen_questions)")
.fetch_all(pool)
.await
.unwrap_or_default()
.iter()
.map(|r| r.get::<String, _>("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") let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM questions")
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;

View file

@ -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. // Deduplicate: a question can match multiple subtopics if the form sent both.
ids.sort_unstable(); ids.sort_unstable();
ids.dedup(); 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::<Vec<_>>().join(",");
let scope_st: String = subtopics.iter().map(|st| st.to_string()).collect::<Vec<_>>().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() { if ids.is_empty() {
anyhow::bail!("No questions left for that selection. Turn off 'unseen only' to reuse questions."); 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::<i64>() as u64 ^ count as u64; let seed = uid.len() as u64 ^ chapters.iter().sum::<i64>() as u64 ^ count as u64;
shuffle(&mut ids, seed); shuffle(&mut ids, seed);
ids.truncate(count); ids.truncate(count);
@ -343,9 +379,11 @@ pub async fn finalize(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(i32,
.await?; .await?;
for r in &rows { for r in &rows {
if !r.selected.trim().is_empty() { 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(&uid)
.bind(r.q.id) .bind(r.q.id)
.bind(&now)
.execute(pool) .execute(pool)
.await?; .await?;
} }