From 00825c6618ef78f0fb2dd69a85e904c0a365987f Mon Sep 17 00:00:00 2001 From: lashman Date: Thu, 27 Mar 2025 21:33:16 +0300 Subject: [PATCH] clue blocklist and seeds --- cruciverb-core/src/clue/mod.rs | 527 ++++++++++++++++++++++++++++++++ cruciverb-core/src/clue/seed.rs | 9 + 2 files changed, 536 insertions(+) create mode 100644 cruciverb-core/src/clue/mod.rs create mode 100644 cruciverb-core/src/clue/seed.rs diff --git a/cruciverb-core/src/clue/mod.rs b/cruciverb-core/src/clue/mod.rs new file mode 100644 index 0000000..54cb0c5 --- /dev/null +++ b/cruciverb-core/src/clue/mod.rs @@ -0,0 +1,527 @@ +pub mod seed; + +use std::collections::HashMap; +use std::path::Path; + +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClueEntry { + pub text: String, + pub difficulty: u8, +} + +pub struct ClueDatabase { + json_clues: HashMap>, + db: Option>, +} + +impl ClueDatabase { + pub fn empty() -> Self { + Self { + json_clues: HashMap::new(), + db: None, + } + } + + pub fn load_json(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let raw: HashMap> = serde_json::from_str(&content)?; + let json_clues = raw + .into_iter() + .map(|(k, v)| (k.to_uppercase(), v)) + .collect(); + Ok(Self { + json_clues, + db: None, + }) + } + + pub fn load_sqlite(db_path: &Path) -> Result { + let db = Connection::open(db_path) + .map_err(|e| crate::error::CrucivError::Dictionary(e.to_string()))?; + Ok(Self { + json_clues: HashMap::new(), + db: Some(std::sync::Mutex::new(db)), + }) + } + + pub fn load(data_dir: &Path) -> Result { + let db_path = data_dir.join("clues.db"); + let json_path = data_dir.join("clues.json"); + + if db_path.exists() { + Self::load_sqlite(&db_path) + } else if json_path.exists() { + Self::load_json(&json_path) + } else { + Ok(Self::empty()) + } + } + + pub fn get_clue(&self, word: &str, difficulty: u8) -> Result { + let upper = word.to_uppercase(); + + // try sqlite with difficulty matching + if let Some(db) = &self.db { + let conn = db.lock().unwrap(); + if let Some(clue) = query_sqlite(&conn, &upper, difficulty) { + return Ok(clue); + } + } + + // fall back to json + if let Some(entries) = self.json_clues.get(&upper) { + if let Some(entry) = entries.first() { + return Ok(entry.text.clone()); + } + } + + // last resort placeholder so unclued words still render as fill-in-the-blanks + Ok(format!("___ ({} letter word)", word.len())) + } + + /// Get word difficulty scores for dictionary boosting. + /// Returns a map of word -> difficulty score (1.0=easy to 6.0=hard). + pub fn word_difficulties(&self) -> std::collections::HashMap { + let mut result = std::collections::HashMap::new(); + if let Some(db) = &self.db { + let conn = db.lock().unwrap(); + if let Ok(mut stmt) = conn.prepare("SELECT word, score FROM word_difficulty") { + if let Ok(rows) = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)) + }) { + for r in rows.flatten() { + result.insert(r.0, r.1); + } + } + }; + } + result + } + + pub fn word_count(&self) -> usize { + let json_count = self.json_clues.len(); + let db_count = self + .db + .as_ref() + .map(|db| { + let conn = db.lock().unwrap(); + conn.query_row("SELECT COUNT(DISTINCT word) FROM clues", [], |row| { + row.get::<_, usize>(0) + }) + .unwrap_or(0) + }) + .unwrap_or(0); + json_count + db_count + } + + pub fn all_words(&self) -> std::collections::HashSet { + let mut words: std::collections::HashSet = self + .json_clues + .keys() + .filter(|w| w.chars().all(|c| c.is_alphabetic())) + .cloned() + .collect(); + if let Some(db) = &self.db { + let conn = db.lock().unwrap(); + if let Ok(mut stmt) = conn.prepare("SELECT DISTINCT word FROM clues") { + if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) { + for w in rows.flatten() { + if w.chars().all(|c| c.is_alphabetic()) { + words.insert(w); + } + } + } + }; + } + words + } + + /// Words that have at least one non-cryptic clue. + /// Use this for dictionary filtering to avoid placing words + /// that can't actually be clued in normal puzzles. + pub fn clueable_words(&self) -> std::collections::HashSet { + let mut words: std::collections::HashSet = self + .json_clues + .keys() + .filter(|w| w.chars().all(|c| c.is_alphabetic())) + .cloned() + .collect(); + if let Some(db) = &self.db { + let conn = db.lock().unwrap(); + if let Ok(mut stmt) = + conn.prepare("SELECT DISTINCT word FROM clues WHERE source != 'cryptic'") + { + if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) { + for w in rows.flatten() { + if w.chars().all(|c| c.is_alphabetic()) { + words.insert(w); + } + } + } + }; + } + words + } + + pub fn suggestions(&self, word: &str, limit: usize) -> Vec { + let upper = word.to_uppercase(); + let mut result = Vec::new(); + if let Some(entries) = self.json_clues.get(&upper) { + for e in entries.iter().take(limit) { + result.push(e.text.clone()); + } + } + if let Some(db) = &self.db { + let conn = db.lock().unwrap(); + if let Ok(mut stmt) = + conn.prepare("SELECT DISTINCT clue FROM clues WHERE word = ?1 LIMIT ?2") + { + if let Ok(rows) = stmt.query_map( + rusqlite::params![upper, (limit - result.len()) as i64], + |row| row.get::<_, String>(0), + ) { + for r in rows.flatten() { + if !result.contains(&r) { + result.push(r); + } + } + } + }; + } + result.truncate(limit); + result + } + + pub fn lookup(&self, word: &str, difficulty: u8) -> Option { + let upper = word.to_uppercase(); + + if let Some(entries) = self.json_clues.get(&upper) { + if let Some(entry) = entries.iter().find(|e| e.difficulty == difficulty) { + return Some(entry.clone()); + } + if let Some(entry) = entries.first() { + return Some(entry.clone()); + } + } + + if let Some(db) = &self.db { + let conn = db.lock().unwrap(); + if let Some(clue) = query_sqlite(&conn, &upper, difficulty) { + return Some(ClueEntry { + text: clue, + difficulty, + }); + } + } + + None + } + + pub fn db_ref(&self) -> Option> { + self.db.as_ref().map(|m| m.lock().unwrap()) + } + + pub fn find_word_for_clue(&self, clue_text: &str) -> Option { + for (word, entries) in &self.json_clues { + for e in entries { + if e.text == clue_text { + return Some(word.clone()); + } + } + } + None + } + + pub fn random_clues(&self, count: usize) -> Vec<(String, String)> { + let mut result = Vec::new(); + if let Some(db) = &self.db { + let conn = db.lock().unwrap(); + // pick random word+clue pairs, prefer common words (3-8 letters) + if let Ok(mut stmt) = conn.prepare( + "SELECT word, clue FROM clues WHERE length(word) BETWEEN 3 AND 8 ORDER BY RANDOM() LIMIT ?1" + ) { + if let Ok(rows) = stmt.query_map(rusqlite::params![count as i64], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?)) + }) { + for r in rows.flatten() { + result.push(r); + } + } + }; + } + if result.is_empty() { + // fallback to json clues + for (word, entries) in self.json_clues.iter().take(count) { + if let Some(e) = entries.first() { + result.push((e.text.clone(), word.clone())); + } + } + } + result + } + + pub fn random_obscure_clues(&self, count: usize) -> Vec<(String, String)> { + let mut result = Vec::new(); + if let Some(db) = &self.db { + let conn = db.lock().unwrap(); + if let Ok(mut stmt) = conn.prepare( + "SELECT word, clue FROM clues WHERE length(word) >= 7 AND source != 'cryptic' ORDER BY RANDOM() LIMIT ?1" + ) { + if let Ok(rows) = stmt.query_map(rusqlite::params![count as i64], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?)) + }) { + for r in rows.flatten() { + result.push(r); + } + } + }; + } + if result.is_empty() { + for (word, entries) in self + .json_clues + .iter() + .filter(|(w, _)| w.len() >= 7) + .take(count) + { + if let Some(e) = entries.first() { + result.push((e.text.clone(), word.clone())); + } + } + } + if result.is_empty() { + return self.random_clues(count); + } + result + } + + /// Returns (clue_text, real_answer, vec_of_fake_clues) + pub fn random_clues_with_fakes( + &self, + count: usize, + fakes: usize, + ) -> Vec<(String, String, Vec)> { + let mut result = Vec::new(); + let clues = self.random_clues(count + count * fakes); + if clues.len() < count + fakes { + // not enough data, just return what we have with empty fakes + for (clue, word) in clues.into_iter().take(count) { + result.push((clue, word, Vec::new())); + } + return result; + } + let main: Vec<_> = clues[..count].to_vec(); + let extra: Vec<_> = clues[count..].to_vec(); + for (i, (clue, word)) in main.into_iter().enumerate() { + let fake_clues: Vec = extra + .iter() + .filter(|(_, w)| w != &word) + .skip(i * fakes) + .take(fakes) + .map(|(c, _)| c.clone()) + .collect(); + result.push((clue, word, fake_clues)); + } + result + } +} + +// cryptic clue database (separate schema: answer/clue/definition) +/// Cryptic clue access - reads from the same clues.db using source='cryptic' +pub struct CrypticClueDb { + db: std::sync::Mutex, +} + +impl CrypticClueDb { + pub fn load(path: &Path) -> Result { + let db = Connection::open(path) + .map_err(|e| crate::error::CrucivError::Dictionary(e.to_string()))?; + Ok(Self { + db: std::sync::Mutex::new(db), + }) + } + + pub fn get_clue(&self, word: &str, _difficulty: u8) -> Result { + let upper = word.to_uppercase(); + let conn = self.db.lock().unwrap(); + conn.query_row( + "SELECT clue FROM clues WHERE word = ?1 AND source = 'cryptic' ORDER BY RANDOM() LIMIT 1", + [&upper], + |row| row.get::<_, String>(0), + ).map_err(|_| crate::error::CrucivError::MissingClue(word.to_string())) + } + + pub fn get_clue_with_meta( + &self, + word: &str, + ) -> Option<(String, Option, Option)> { + let upper = word.to_uppercase(); + let conn = self.db.lock().unwrap(); + conn.query_row( + "SELECT clue, definition, wordplay_type FROM clues WHERE word = ?1 AND source = 'cryptic' ORDER BY RANDOM() LIMIT 1", + [&upper], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?)), + ).ok() + } + + pub fn suggestions(&self, word: &str, limit: usize) -> Vec { + let upper = word.to_uppercase(); + let mut result = Vec::new(); + let conn = self.db.lock().unwrap(); + if let Ok(mut stmt) = conn.prepare( + "SELECT DISTINCT clue FROM clues WHERE word = ?1 AND source = 'cryptic' LIMIT ?2", + ) { + if let Ok(rows) = stmt.query_map(rusqlite::params![upper, limit as i64], |row| { + row.get::<_, String>(0) + }) { + for r in rows.flatten() { + result.push(r); + } + } + } + result + } + + pub fn word_count(&self) -> usize { + let conn = self.db.lock().unwrap(); + conn.query_row( + "SELECT COUNT(DISTINCT word) FROM clues WHERE source = 'cryptic'", + [], + |row| row.get::<_, usize>(0), + ) + .unwrap_or(0) + } + + pub fn all_words(&self) -> std::collections::HashSet { + let mut words = std::collections::HashSet::new(); + let conn = self.db.lock().unwrap(); + if let Ok(mut stmt) = conn.prepare( + "SELECT DISTINCT word FROM clues WHERE source = 'cryptic' AND LENGTH(word) >= 3", + ) { + if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) { + for w in rows.flatten() { + if w.chars().all(|c| c.is_alphabetic()) { + words.insert(w); + } + } + } + } + words + } +} + +fn query_sqlite(db: &Connection, word: &str, difficulty: u8) -> Option { + if difficulty <= 1 { + // very easy / easy: pick shortest straightforward non-cryptic clue + // exclude fill-in-blanks (___), abbreviation markers, and foreign clues + let easy_filter = "AND source != 'cryptic' AND clue NOT LIKE '%\\_\\_\\_%' ESCAPE '\\' AND clue NOT LIKE '%bbr%' AND clue NOT LIKE '%org.%' AND clue NOT LIKE '%Var.%' AND clue NOT LIKE '%: %' AND clue NOT LIKE '%Sp.%' AND clue NOT LIKE '%Fr.%' AND clue NOT LIKE '%Lat.%' AND clue NOT LIKE '%Ger.%'"; + return db.query_row( + &format!("SELECT clue FROM clues WHERE word = ?1 AND difficulty <= ?2 {} ORDER BY LENGTH(clue) ASC LIMIT 1", easy_filter), + rusqlite::params![word, difficulty], + |row| row.get::<_, String>(0), + ).ok().or_else(|| { + // widen to medium, still filtering junk + db.query_row( + &format!("SELECT clue FROM clues WHERE word = ?1 AND difficulty <= 2 {} ORDER BY LENGTH(clue) ASC LIMIT 1", easy_filter), + [word], + |row| row.get::<_, String>(0), + ).ok() + }).or_else(|| { + // any non-cryptic clue, prefer easier + db.query_row( + "SELECT clue FROM clues WHERE word = ?1 AND source != 'cryptic' ORDER BY difficulty ASC, LENGTH(clue) ASC LIMIT 1", + [word], + |row| row.get::<_, String>(0), + ).ok() + }); + } + + // medium/hard: random non-cryptic clue at matching difficulty, fall back to closest + match db.query_row( + "SELECT clue FROM clues WHERE word = ?1 AND difficulty = ?2 AND source != 'cryptic' ORDER BY RANDOM() LIMIT 1", + rusqlite::params![word, difficulty], + |row| row.get::<_, String>(0), + ) { + Ok(clue) => Some(clue), + Err(rusqlite::Error::QueryReturnedNoRows) => { + match db.query_row( + "SELECT clue FROM clues WHERE word = ?1 AND source != 'cryptic' ORDER BY ABS(difficulty - ?2), RANDOM() LIMIT 1", + rusqlite::params![word, difficulty], + |row| row.get::<_, String>(0), + ) { + Ok(clue) => Some(clue), + Err(rusqlite::Error::QueryReturnedNoRows) => None, + Err(e) => { + tracing::warn!("clue query error for {}: {}", word, e); + None + } + } + } + Err(e) => { + tracing::warn!("clue query error for {}: {}", word, e); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_db() -> ClueDatabase { + let mut clues = HashMap::new(); + clues.insert( + "CAT".to_string(), + vec![ + ClueEntry { + text: "A furry pet".into(), + difficulty: 1, + }, + ClueEntry { + text: "Feline friend".into(), + difficulty: 2, + }, + ClueEntry { + text: "Felix, for one".into(), + difficulty: 3, + }, + ], + ); + clues.insert( + "DOG".to_string(), + vec![ClueEntry { + text: "Man's best friend".into(), + difficulty: 1, + }], + ); + ClueDatabase { + json_clues: clues, + db: None, + } + } + + #[test] + fn exact_difficulty_match() { + let db = test_db(); + let clue = db.lookup("CAT", 2).unwrap(); + assert_eq!(clue.text, "Feline friend"); + } + + #[test] + fn fallback_to_adjacent() { + let db = test_db(); + let clue = db.lookup("DOG", 2).unwrap(); + assert_eq!(clue.text, "Man's best friend"); + } + + #[test] + fn missing_word_gets_fallback() { + let db = test_db(); + let clue = db.get_clue("XYZ", 1).unwrap(); + assert!(clue.contains("letter word")); + } +} diff --git a/cruciverb-core/src/clue/seed.rs b/cruciverb-core/src/clue/seed.rs new file mode 100644 index 0000000..4d43c6b --- /dev/null +++ b/cruciverb-core/src/clue/seed.rs @@ -0,0 +1,9 @@ +use std::path::Path; + +use crate::error::Result; + +use super::ClueDatabase; + +pub fn load_clue_database(data_dir: &Path) -> Result { + ClueDatabase::load(data_dir) +}