1use std::collections::hash_map::DefaultHasher;
11use std::hash::{Hash, Hasher};
12
13use async_trait::async_trait;
14
15use crate::provider::LlmProvider;
16
17#[derive(Debug, Clone)]
19pub struct AiSpamResult {
20 pub score: f64,
22 pub reason: String,
24}
25
26#[async_trait]
31pub trait SpamCache: Send + Sync {
32 async fn get(&self, key: &str) -> Option<String>;
34 async fn set(&self, key: &str, value: &str, ttl_secs: u64);
36}
37
38pub async fn classify(
44 provider: &dyn LlmProvider,
45 cache: Option<&dyn SpamCache>,
46 sender: &str,
47 subject: &str,
48 body_preview: &str,
49) -> Option<AiSpamResult> {
50 let cache_key = make_cache_key(sender, subject, body_preview);
51
52 if let Some(cache) = cache
53 && let Some(cached) = cache.get(&cache_key).await
54 && let Some(result) = parse_cached(&cached)
55 {
56 tracing::debug!(event = "ai_spam_cache_hit", key = %cache_key);
57 return Some(result);
58 }
59
60 let system = "You are a spam classifier. Analyze emails and respond with ONLY a JSON object: {\"score\": <0.0-10.0>, \"reason\": \"<brief reason>\"}. Score guide: 0=clearly legitimate, 5=suspicious, 10=obvious spam";
61
62 let user_message =
63 format!("Sender: {sender}\nSubject: {subject}\nBody preview: {body_preview}");
64
65 let text = provider.complete(system, &user_message, 0.1).await?;
66 let result = parse_ai_response(&text)?;
67
68 if let Some(cache) = cache {
69 let cached = serde_json::json!({"s": result.score, "r": result.reason}).to_string();
70 cache.set(&cache_key, &cached, 86400).await;
71 }
72
73 tracing::info!(
74 event = "ai_spam_classified",
75 score = result.score,
76 reason = %result.reason,
77 );
78
79 Some(result)
80}
81
82fn make_cache_key(sender: &str, subject: &str, body_preview: &str) -> String {
83 let mut hasher = DefaultHasher::new();
84 sender.hash(&mut hasher);
85 subject.hash(&mut hasher);
86 body_preview.hash(&mut hasher);
87 let hash = hasher.finish();
88 format!("ai:{hash:x}")
89}
90
91fn parse_cached(s: &str) -> Option<AiSpamResult> {
92 let v: serde_json::Value = serde_json::from_str(s).ok()?;
93 let score = v["s"].as_f64()?;
94 let reason = v["r"].as_str().unwrap_or("").to_string();
95 Some(AiSpamResult { score, reason })
96}
97
98fn parse_ai_response(text: &str) -> Option<AiSpamResult> {
99 let start = text.find('{')?;
100 let end = text.rfind('}')? + 1;
101 let json_str = &text[start..end];
102 let v: serde_json::Value = serde_json::from_str(json_str).ok()?;
103 let score = v["score"].as_f64()?;
104 let reason = v["reason"].as_str().unwrap_or("").to_string();
105 Some(AiSpamResult {
106 score: score.clamp(0.0, 10.0),
107 reason,
108 })
109}
110
111#[cfg(feature = "kevy-cache")]
112pub use kevy_impl::KevySpamCache;
113
114#[cfg(feature = "kevy-cache")]
115mod kevy_impl {
116 use super::SpamCache;
117 use async_trait::async_trait;
118 use redis::AsyncCommands;
119
120 #[derive(Debug, Clone)]
127 pub struct KevySpamCache {
128 conn: redis::aio::ConnectionManager,
129 }
130
131 impl KevySpamCache {
132 pub fn new(conn: redis::aio::ConnectionManager) -> Self {
134 Self { conn }
135 }
136 }
137
138 #[async_trait]
139 impl SpamCache for KevySpamCache {
140 async fn get(&self, key: &str) -> Option<String> {
141 let mut conn = self.conn.clone();
142 conn.get::<_, Option<String>>(key).await.ok().flatten()
143 }
144
145 async fn set(&self, key: &str, value: &str, ttl_secs: u64) {
146 let mut conn = self.conn.clone();
147 let _: Result<(), _> = conn.set_ex(key, value, ttl_secs).await;
148 }
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn parse_ai_response_valid() {
158 let r = parse_ai_response(r#"{"score": 7.5, "reason": "phishing attempt"}"#).unwrap();
159 assert!((r.score - 7.5).abs() < 0.01);
160 assert_eq!(r.reason, "phishing attempt");
161 }
162
163 #[test]
164 fn parse_ai_response_with_surrounding_text() {
165 let r = parse_ai_response(
166 r#"Here is my analysis: {"score": 2.0, "reason": "legitimate newsletter"} hope that helps"#,
167 )
168 .unwrap();
169 assert!((r.score - 2.0).abs() < 0.01);
170 }
171
172 #[test]
173 fn parse_ai_response_invalid() {
174 assert!(parse_ai_response("no json here").is_none());
175 assert!(parse_ai_response(r#"{"no_score": true}"#).is_none());
176 }
177
178 #[test]
179 fn parse_ai_response_clamps_score() {
180 let r = parse_ai_response(r#"{"score": 15.0, "reason": "very spam"}"#).unwrap();
181 assert!((r.score - 10.0).abs() < 0.01);
182 }
183
184 #[test]
185 fn cache_key_format() {
186 let key = make_cache_key("user@example.com", "Hello World", "body");
187 assert!(key.starts_with("ai:"));
188 let key2 = make_cache_key("other@example.com", "Hello World", "body");
189 assert_ne!(key, key2);
190 }
191
192 #[test]
193 fn parse_cached_roundtrip() {
194 let cached = r#"{"s":7.5,"r":"phishing attempt"}"#;
195 let r = parse_cached(cached).unwrap();
196 assert!((r.score - 7.5).abs() < 0.01);
197 assert_eq!(r.reason, "phishing attempt");
198 }
199
200 #[test]
201 fn parse_cached_with_pipe_in_reason() {
202 let cached = r#"{"s":3.0,"r":"too many links | phishing indicators"}"#;
203 let r = parse_cached(cached).unwrap();
204 assert!((r.score - 3.0).abs() < 0.01);
205 assert_eq!(r.reason, "too many links | phishing indicators");
206 }
207}
208
209#[cfg(test)]
210mod integration_tests {
211 use super::*;
212 use crate::provider::LlmProvider;
213 use std::sync::Mutex;
214
215 struct MockProvider {
218 canned: String,
219 calls: Mutex<u32>,
220 }
221
222 #[async_trait]
223 impl LlmProvider for MockProvider {
224 async fn complete(&self, _system: &str, _user: &str, _temp: f32) -> Option<String> {
225 *self.calls.lock().unwrap() += 1;
226 Some(self.canned.clone())
227 }
228 async fn embed(&self, _text: &str) -> Option<Vec<f32>> {
229 None
230 }
231 fn model_id(&self) -> &str {
232 "mock/1"
233 }
234 }
235
236 struct DeadProvider;
238
239 #[async_trait]
240 impl LlmProvider for DeadProvider {
241 async fn complete(&self, _system: &str, _user: &str, _temp: f32) -> Option<String> {
242 None
243 }
244 async fn embed(&self, _text: &str) -> Option<Vec<f32>> {
245 None
246 }
247 fn model_id(&self) -> &str {
248 "dead/0"
249 }
250 }
251
252 struct MemCache {
254 inner: Mutex<std::collections::HashMap<String, String>>,
255 }
256
257 #[async_trait]
258 impl SpamCache for MemCache {
259 async fn get(&self, key: &str) -> Option<String> {
260 self.inner.lock().unwrap().get(key).cloned()
261 }
262 async fn set(&self, key: &str, value: &str, _ttl: u64) {
263 self.inner.lock().unwrap().insert(key.into(), value.into());
264 }
265 }
266
267 #[tokio::test]
268 async fn classify_returns_score_from_provider() {
269 let provider = MockProvider {
270 canned: r#"{"score": 7.5, "reason": "phishing pattern"}"#.into(),
271 calls: Mutex::new(0),
272 };
273 let result = classify(&provider, None, "evil@x", "Win now!", "click here")
274 .await
275 .expect("classify must succeed");
276 assert!((result.score - 7.5).abs() < 0.01);
277 assert_eq!(result.reason, "phishing pattern");
278 }
279
280 #[tokio::test]
281 async fn classify_returns_none_on_dead_provider() {
282 let result = classify(&DeadProvider, None, "any@x", "subj", "body").await;
283 assert!(result.is_none());
284 }
285
286 #[tokio::test]
287 async fn classify_unparseable_response_returns_none() {
288 let provider = MockProvider {
289 canned: "I don't speak JSON".into(),
290 calls: Mutex::new(0),
291 };
292 assert!(classify(&provider, None, "x", "y", "z").await.is_none());
293 }
294
295 #[tokio::test]
296 async fn classify_writes_to_cache_on_miss() {
297 let provider = MockProvider {
298 canned: r#"{"score": 2.0, "reason": "legit"}"#.into(),
299 calls: Mutex::new(0),
300 };
301 let cache = MemCache {
302 inner: Mutex::new(Default::default()),
303 };
304 let _ = classify(&provider, Some(&cache), "a", "b", "c").await;
305 assert_eq!(
306 cache.inner.lock().unwrap().len(),
307 1,
308 "cache must hold one entry"
309 );
310 }
311
312 #[tokio::test]
313 async fn classify_hits_cache_on_second_call() {
314 let provider = MockProvider {
315 canned: r#"{"score": 5.0, "reason": "borderline"}"#.into(),
316 calls: Mutex::new(0),
317 };
318 let cache = MemCache {
319 inner: Mutex::new(Default::default()),
320 };
321
322 let r1 = classify(&provider, Some(&cache), "a", "b", "c")
323 .await
324 .unwrap();
325 let r2 = classify(&provider, Some(&cache), "a", "b", "c")
326 .await
327 .unwrap();
328
329 assert!((r1.score - 5.0).abs() < 0.01);
330 assert!((r2.score - 5.0).abs() < 0.01);
331 assert_eq!(
332 *provider.calls.lock().unwrap(),
333 1,
334 "second call should hit cache, not provider"
335 );
336 }
337
338 #[tokio::test]
339 async fn classify_different_inputs_use_different_cache_keys() {
340 let provider = MockProvider {
341 canned: r#"{"score": 3.0, "reason": "ok"}"#.into(),
342 calls: Mutex::new(0),
343 };
344 let cache = MemCache {
345 inner: Mutex::new(Default::default()),
346 };
347
348 classify(&provider, Some(&cache), "a", "subject1", "body").await;
349 classify(&provider, Some(&cache), "a", "subject2", "body").await;
350
351 assert_eq!(cache.inner.lock().unwrap().len(), 2);
352 assert_eq!(*provider.calls.lock().unwrap(), 2);
353 }
354
355 #[tokio::test]
356 async fn classify_caches_negative_decisions_too() {
357 let provider = MockProvider {
358 canned: r#"{"score": 0.1, "reason": "totally legit"}"#.into(),
359 calls: Mutex::new(0),
360 };
361 let cache = MemCache {
362 inner: Mutex::new(Default::default()),
363 };
364 classify(&provider, Some(&cache), "a", "b", "c").await;
365 assert_eq!(
366 cache.inner.lock().unwrap().len(),
367 1,
368 "low-score result still cached"
369 );
370 }
371
372 #[tokio::test]
375 async fn classify_handles_empty_strings() {
376 let provider = MockProvider {
379 canned: r#"{"score": 0.0, "reason": "empty"}"#.into(),
380 calls: Mutex::new(0),
381 };
382 let cache = MemCache {
383 inner: Mutex::new(Default::default()),
384 };
385 let r = classify(&provider, Some(&cache), "", "", "").await.unwrap();
386 assert!((r.score - 0.0).abs() < 0.01);
387 assert_eq!(cache.inner.lock().unwrap().len(), 1);
388 }
389
390 #[tokio::test]
391 async fn classify_cache_corrupted_value_falls_through_to_provider() {
392 let provider = MockProvider {
395 canned: r#"{"score": 4.0, "reason": "fresh"}"#.into(),
396 calls: Mutex::new(0),
397 };
398 let cache = MemCache {
399 inner: Mutex::new(Default::default()),
400 };
401 let key = make_cache_key("a", "b", "c");
403 cache
404 .inner
405 .lock()
406 .unwrap()
407 .insert(key.clone(), "not-json".to_string());
408 let r = classify(&provider, Some(&cache), "a", "b", "c")
409 .await
410 .unwrap();
411 assert!((r.score - 4.0).abs() < 0.01, "fell through to provider");
412 let cached = cache.inner.lock().unwrap().get(&key).cloned().unwrap();
414 assert!(cached.contains("\"s\":4.0") || cached.contains("\"s\":4"));
415 }
416
417 #[tokio::test]
418 async fn classify_subject_change_busts_cache() {
419 let provider = MockProvider {
420 canned: r#"{"score": 1.0, "reason": "ok"}"#.into(),
421 calls: Mutex::new(0),
422 };
423 let cache = MemCache {
424 inner: Mutex::new(Default::default()),
425 };
426 classify(&provider, Some(&cache), "a", "subj1", "body").await;
427 classify(&provider, Some(&cache), "a", "subj2", "body").await;
428 assert_eq!(*provider.calls.lock().unwrap(), 2);
430 assert_eq!(cache.inner.lock().unwrap().len(), 2);
431 }
432
433 #[tokio::test]
434 async fn classify_returns_none_when_score_field_missing() {
435 let provider = MockProvider {
437 canned: r#"{"reason": "I forgot the score"}"#.into(),
438 calls: Mutex::new(0),
439 };
440 let r = classify(&provider, None, "a", "b", "c").await;
441 assert!(r.is_none());
442 }
443
444 #[tokio::test]
445 async fn classify_score_clamped_after_provider() {
446 let provider = MockProvider {
448 canned: r#"{"score": 99.9, "reason": "off the scale"}"#.into(),
449 calls: Mutex::new(0),
450 };
451 let r = classify(&provider, None, "a", "b", "c").await.unwrap();
452 assert!((r.score - 10.0).abs() < 0.01);
453 }
454
455 #[tokio::test]
456 async fn classify_negative_score_clamped() {
457 let provider = MockProvider {
458 canned: r#"{"score": -5.0, "reason": "negative"}"#.into(),
459 calls: Mutex::new(0),
460 };
461 let r = classify(&provider, None, "a", "b", "c").await.unwrap();
462 assert!((r.score - 0.0).abs() < 0.01);
463 }
464}