use serde::{Deserialize, Serialize};
use crate::cache::PageCache;
use crate::config::BotConfig;
use crate::util::{extract_fanfic_url, normalize_url};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Intent {
Search {
query: Option<String>,
fandom: Option<String>,
min_words: Option<i64>,
complete: Option<bool>,
},
Ask {
query: String,
},
Quote {
phrase: String,
},
Recs,
Fresh,
Gems,
Roll,
Download {
url: String,
},
Bookmark {
url: String,
},
Metadata {
url: String,
},
Help {
question: String,
},
#[serde(rename = "none")]
None {
reply: String,
},
}
impl Intent {
pub fn describe(&self) -> String {
match self {
Self::Search { query, fandom, min_words, complete } => {
let mut s = String::from("search");
if let Some(q) = query {
if !q.is_empty() {
s.push_str(&format!(": \"{q}\""));
}
}
if let Some(f) = fandom {
if !f.is_empty() {
s.push_str(&format!(" · fandom {f}"));
}
}
if let Some(m) = min_words {
s.push_str(&format!(" · ≥{m} words"));
}
if *complete == Some(true) {
s.push_str(" · complete");
}
s
}
Self::Ask { query } => format!("ask: \"{query}\""),
Self::Quote { phrase } => format!("quote: \"{phrase}\""),
Self::Recs => "recommendations".into(),
Self::Fresh => "fresh recommendations".into(),
Self::Gems => "hidden gems".into(),
Self::Roll => "roll".into(),
Self::Download { url } => format!("download: {url}"),
Self::Bookmark { url } => format!("bookmark: {url}"),
Self::Metadata { url } => format!("metadata: {url}"),
Self::Help { question } => format!("help: {question}"),
Self::None { reply } => format!("chat: {}", crate::util::truncate(reply, 40)),
}
}
}
pub fn cache_key_for(text: &str) -> String {
let t = text.trim().to_lowercase();
format!("archivist:intentcache:{}", sha256_hex(t.as_bytes()))
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
let out = hasher.finalize();
let mut s = String::with_capacity(64);
for b in out {
s.push_str(&format!("{b:02x}"));
}
s
}
const ACTION_WHITELIST: &[&str] = &[
"search", "ask", "quote", "recs", "fresh", "gems", "roll", "metadata",
"download", "bookmark", "help", "none",
];
fn is_whitelisted(action: &str) -> bool {
ACTION_WHITELIST.contains(&action)
}
fn sanitize(raw: &serde_json::Value) -> Option<Intent> {
let action = raw.get("action").and_then(|v| v.as_str()).unwrap_or("").to_string();
if !is_whitelisted(&action) {
return None;
}
let params = raw.get("params").cloned().unwrap_or_else(|| serde_json::json!({}));
let s = |k: &str| params.get(k).and_then(|v| v.as_str()).map(|v| v.to_string());
let i = |k: &str| params.get(k).and_then(|v| v.as_i64());
let b = |k: &str| params.get(k).and_then(|v| v.as_bool());
match action.as_str() {
"search" => {
let query = s("query").filter(|q| !q.is_empty());
let fandom = s("fandom").filter(|f| !f.is_empty());
let min_words = i("min_words").filter(|v| *v >= 0);
let complete = b("complete");
Some(Intent::Search { query, fandom, min_words, complete })
}
"ask" => s("query").filter(|q| !q.is_empty()).map(|query| Intent::Ask { query }),
"quote" => s("phrase").filter(|p| !p.is_empty()).map(|phrase| Intent::Quote { phrase }),
"recs" => Some(Intent::Recs),
"fresh" => Some(Intent::Fresh),
"gems" => Some(Intent::Gems),
"roll" => Some(Intent::Roll),
"download" => s("url").filter(|u| !u.is_empty()).map(|url| Intent::Download { url }),
"bookmark" => s("url").filter(|u| !u.is_empty()).map(|url| Intent::Bookmark { url }),
"metadata" => s("url").filter(|u| !u.is_empty()).map(|url| Intent::Metadata { url }),
"help" => s("question").filter(|q| !q.is_empty()).map(|question| Intent::Help { question }),
"none" => {
let reply = s("reply").unwrap_or_default();
Some(Intent::None { reply })
}
_ => None,
}
}
fn prompt_for(text: &str) -> String {
format!(
r#"You convert a user's message into ONE action for the fanfic-archivist bot.
Reply with ONLY a JSON object: {{"action": "...", "params": {{...}}}}. No prose.
Actions (closed set — never invent others):
- "search": find fics by keywords/filters. params: query(str), fandom(str?), min_words(int?), complete(bool?)
- "ask": find fics from a natural-language description. params: query(str)
- "quote": find fics containing an exact phrase/dialogue. params: phrase(str)
- "recs" | "fresh" | "gems" | "roll": recommendation modes. params: {{}}
- "download" | "bookmark" | "metadata": act on a fanfiction URL. params: url(str)
- "help": a question about using the bot or FicHub. params: question(str)
- "none": chit-chat / anything else. params: reply(str) — one short helpful line.
Rules:
- If the message contains a fanfiction URL, prefer metadata/download/bookmark.
- If they describe what to read (trope/ship/fandom/length), use search or ask.
- If they quote dialogue ("find the fic where X says Y"), use quote.
- Keep params minimal and faithful. Do not invent filters.
Examples:
"completed dark harry over 50k" -> {{"action":"search","params":{{"query":"dark harry","complete":true,"min_words":50000}}}}
"something cozy with a coffee shop au" -> {{"action":"ask","params":{{"query":"cozy coffee shop au"}}}}
"where does she say 'I solemnly swear'" -> {{"action":"quote","params":{{"phrase":"I solemnly swear"}}}}
"https://archiveofourown.org/works/123" -> {{"action":"metadata","params":{{"url":"https://archiveofourown.org/works/123"}}}}
"how do I send to my kindle?" -> {{"action":"help","params":{{"question":"how do I send to my kindle?"}}}}
"hello" -> {{"action":"none","params":{{"reply":"Hi! Ask me to find, download, or bookmark fics."}}}}
User message: {text}
"#
)
}
pub async fn classify(
text: &str,
config: &std::sync::Arc<BotConfig>,
cache: &PageCache,
http: &reqwest::Client,
) -> (Intent, bool) {
let trimmed = text.trim();
if trimmed.is_empty() {
return (Intent::None { reply: "Hi! Ask me to find, download, or bookmark fics.".into() }, false);
}
if let Some(url) = extract_fanfic_url(trimmed) {
let normalized = normalize_url(&url);
let lower = trimmed.to_lowercase();
let intent = if lower.contains("bookmark") || lower.contains("book mark") {
Intent::Bookmark { url: normalized.clone() }
} else if lower.contains("download") {
Intent::Download { url: normalized }
} else {
Intent::Metadata { url: normalized }
};
return (intent, false);
}
let key = cache_key_for(trimmed);
if let Some(v) = cache.cached_raw(&key).await {
if let Some(intent) = sanitize(&v) {
return (intent, true);
}
}
if config.llm_enabled {
if let Some(intent) = classify_with_llm(trimmed, config, http).await {
if let Ok(v) = serde_json::to_value(&intent) {
cache.cache_raw(&key, config.intent_cache_ttl, &v).await;
}
return (intent, false);
}
}
(heuristic(trimmed), false)
}
async fn classify_with_llm(
text: &str,
config: &std::sync::Arc<BotConfig>,
http: &reqwest::Client,
) -> Option<Intent> {
let url = format!("{}/api/generate", config.ollama_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": config.intent_model,
"prompt": prompt_for(text),
"stream": false,
"format": "json",
"options": { "temperature": 0 },
});
let resp = tokio::time::timeout(
std::time::Duration::from_secs(config.intent_timeout_secs),
http.post(&url).json(&body).send(),
)
.await
.ok()?
.ok()?;
let status = resp.status();
if !status.is_success() {
tracing::warn!("intent LLM HTTP {status}");
return None;
}
let json: serde_json::Value = resp.json().await.ok()?;
let text_out = json.get("response").and_then(|v| v.as_str())?;
let parsed: serde_json::Value = serde_json::from_str(text_out).ok()?;
sanitize(&parsed)
}
fn heuristic(text: &str) -> Intent {
let lower = text.to_lowercase();
let s = |words: &[&str]| words.iter().any(|w| lower.contains(w));
if s(&["recs", "recommend", "recommendation"]) {
return Intent::Recs;
}
if s(&["fresh"]) {
return Intent::Fresh;
}
if s(&["gems", "underrated"]) {
return Intent::Gems;
}
if s(&["roll", "random", "blind date", "surprise me"]) {
return Intent::Roll;
}
if s(&["how do i", "how do you", "how to", "what is", "what are", "help", "guide"]) {
return Intent::Help {
question: text.trim().to_string(),
};
}
if s(&["quote", "says", "said", "dialogue", "line where", "the fic where"]) {
return Intent::Quote {
phrase: text.trim().trim_start_matches("quote").trim().to_string(),
};
}
if s(&["bookmark", "save this", "add to library"]) {
return Intent::Ask {
query: text.trim().to_string(),
};
}
if s(&["download"]) {
return Intent::Ask {
query: text.trim().to_string(),
};
}
if s(&["search", "find", "looking for", "looking for a fic", "fic where", "fic about", "recommend me"]) {
return Intent::Ask {
query: text.trim().to_string(),
};
}
Intent::None {
reply: "Hi! Ask me to find, download, or bookmark fics.".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_accepts_whitelisted() {
let v = serde_json::json!({
"action": "search",
"params": {"query": "dark harry", "complete": true, "min_words": 50000}
});
let i = sanitize(&v).unwrap();
assert_eq!(
i,
Intent::Search {
query: Some("dark harry".into()),
fandom: None,
min_words: Some(50000),
complete: Some(true),
}
);
}
#[test]
fn sanitize_rejects_unknown_action() {
let v = serde_json::json!({"action": "rate", "params": {"stars": 5}});
assert!(sanitize(&v).is_none());
}
#[test]
fn sanitize_rejects_injection_action() {
let v = serde_json::json!({"action": "admin", "params": {"cmd": "drop database"}});
assert!(sanitize(&v).is_none());
}
#[test]
fn sanitize_clamps_negative_words() {
let v = serde_json::json!({"action": "search", "params": {"query": "x", "min_words": -5}});
let i = sanitize(&v).unwrap();
match i {
Intent::Search { min_words, .. } => assert_eq!(min_words, None),
_ => panic!("expected search"),
}
}
#[test]
fn sanitize_requires_url_for_download() {
let v = serde_json::json!({"action": "download", "params": {}});
assert!(sanitize(&v).is_none());
}
#[test]
fn cache_key_is_stable_and_namespaced() {
let k1 = cache_key_for(" Find me a FIC ");
let k2 = cache_key_for("find me a fic");
assert_eq!(k1, k2);
assert!(k1.starts_with("archivist:intentcache:"));
assert_eq!(k1.len(), "archivist:intentcache:".len() + 64);
}
#[test]
fn heuristic_detects_help() {
let i = heuristic("how do I send to my kindle?");
assert!(matches!(i, Intent::Help { .. }));
}
#[test]
fn heuristic_detects_recs() {
let i = heuristic("give me recommendations please");
assert_eq!(i, Intent::Recs);
}
}