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 std::time::Duration;
117
118 use async_trait::async_trait;
119 use kevy_embedded::Store;
120
121 use super::SpamCache;
122
123 #[derive(Clone)]
130 pub struct KevySpamCache {
131 store: Store,
132 }
133
134 impl std::fmt::Debug for KevySpamCache {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 f.debug_struct("KevySpamCache").finish_non_exhaustive()
137 }
138 }
139
140 impl KevySpamCache {
141 pub fn new(store: Store) -> Self {
145 Self { store }
146 }
147 }
148
149 #[async_trait]
150 impl SpamCache for KevySpamCache {
151 async fn get(&self, key: &str) -> Option<String> {
152 self.store
153 .get(key.as_bytes())
154 .ok()
155 .flatten()
156 .and_then(|bytes| String::from_utf8(bytes).ok())
157 }
158
159 async fn set(&self, key: &str, value: &str, ttl_secs: u64) {
160 let _ = self.store.set_with_ttl(
161 key.as_bytes(),
162 value.as_bytes(),
163 Duration::from_secs(ttl_secs),
164 );
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn parse_ai_response_valid() {
175 let r = parse_ai_response(r#"{"score": 7.5, "reason": "phishing attempt"}"#).unwrap();
176 assert!((r.score - 7.5).abs() < 0.01);
177 assert_eq!(r.reason, "phishing attempt");
178 }
179
180 #[test]
181 fn parse_ai_response_with_surrounding_text() {
182 let r = parse_ai_response(
183 r#"Here is my analysis: {"score": 2.0, "reason": "legitimate newsletter"} hope that helps"#,
184 )
185 .unwrap();
186 assert!((r.score - 2.0).abs() < 0.01);
187 }
188
189 #[test]
190 fn parse_ai_response_invalid() {
191 assert!(parse_ai_response("no json here").is_none());
192 assert!(parse_ai_response(r#"{"no_score": true}"#).is_none());
193 }
194
195 #[test]
196 fn parse_ai_response_clamps_score() {
197 let r = parse_ai_response(r#"{"score": 15.0, "reason": "very spam"}"#).unwrap();
198 assert!((r.score - 10.0).abs() < 0.01);
199 }
200
201 #[test]
202 fn cache_key_format() {
203 let key = make_cache_key("user@example.com", "Hello World", "body");
204 assert!(key.starts_with("ai:"));
205 let key2 = make_cache_key("other@example.com", "Hello World", "body");
206 assert_ne!(key, key2);
207 }
208
209 #[test]
210 fn parse_cached_roundtrip() {
211 let cached = r#"{"s":7.5,"r":"phishing attempt"}"#;
212 let r = parse_cached(cached).unwrap();
213 assert!((r.score - 7.5).abs() < 0.01);
214 assert_eq!(r.reason, "phishing attempt");
215 }
216
217 #[test]
218 fn parse_cached_with_pipe_in_reason() {
219 let cached = r#"{"s":3.0,"r":"too many links | phishing indicators"}"#;
220 let r = parse_cached(cached).unwrap();
221 assert!((r.score - 3.0).abs() < 0.01);
222 assert_eq!(r.reason, "too many links | phishing indicators");
223 }
224}
225
226#[cfg(test)]
227mod integration_tests {
228 use super::*;
229 use crate::provider::LlmProvider;
230 use std::sync::Mutex;
231
232 struct MockProvider {
235 canned: String,
236 calls: Mutex<u32>,
237 }
238
239 #[async_trait]
240 impl LlmProvider for MockProvider {
241 async fn complete(&self, _system: &str, _user: &str, _temp: f32) -> Option<String> {
242 *self.calls.lock().unwrap() += 1;
243 Some(self.canned.clone())
244 }
245 async fn embed(&self, _text: &str) -> Option<Vec<f32>> {
246 None
247 }
248 fn model_id(&self) -> &str {
249 "mock/1"
250 }
251 }
252
253 struct DeadProvider;
255
256 #[async_trait]
257 impl LlmProvider for DeadProvider {
258 async fn complete(&self, _system: &str, _user: &str, _temp: f32) -> Option<String> {
259 None
260 }
261 async fn embed(&self, _text: &str) -> Option<Vec<f32>> {
262 None
263 }
264 fn model_id(&self) -> &str {
265 "dead/0"
266 }
267 }
268
269 struct MemCache {
271 inner: Mutex<std::collections::HashMap<String, String>>,
272 }
273
274 #[async_trait]
275 impl SpamCache for MemCache {
276 async fn get(&self, key: &str) -> Option<String> {
277 self.inner.lock().unwrap().get(key).cloned()
278 }
279 async fn set(&self, key: &str, value: &str, _ttl: u64) {
280 self.inner.lock().unwrap().insert(key.into(), value.into());
281 }
282 }
283
284 #[tokio::test]
285 async fn classify_returns_score_from_provider() {
286 let provider = MockProvider {
287 canned: r#"{"score": 7.5, "reason": "phishing pattern"}"#.into(),
288 calls: Mutex::new(0),
289 };
290 let result = classify(&provider, None, "evil@x", "Win now!", "click here")
291 .await
292 .expect("classify must succeed");
293 assert!((result.score - 7.5).abs() < 0.01);
294 assert_eq!(result.reason, "phishing pattern");
295 }
296
297 #[tokio::test]
298 async fn classify_returns_none_on_dead_provider() {
299 let result = classify(&DeadProvider, None, "any@x", "subj", "body").await;
300 assert!(result.is_none());
301 }
302
303 #[tokio::test]
304 async fn classify_unparseable_response_returns_none() {
305 let provider = MockProvider {
306 canned: "I don't speak JSON".into(),
307 calls: Mutex::new(0),
308 };
309 assert!(classify(&provider, None, "x", "y", "z").await.is_none());
310 }
311
312 #[tokio::test]
313 async fn classify_writes_to_cache_on_miss() {
314 let provider = MockProvider {
315 canned: r#"{"score": 2.0, "reason": "legit"}"#.into(),
316 calls: Mutex::new(0),
317 };
318 let cache = MemCache {
319 inner: Mutex::new(Default::default()),
320 };
321 let _ = classify(&provider, Some(&cache), "a", "b", "c").await;
322 assert_eq!(
323 cache.inner.lock().unwrap().len(),
324 1,
325 "cache must hold one entry"
326 );
327 }
328
329 #[tokio::test]
330 async fn classify_hits_cache_on_second_call() {
331 let provider = MockProvider {
332 canned: r#"{"score": 5.0, "reason": "borderline"}"#.into(),
333 calls: Mutex::new(0),
334 };
335 let cache = MemCache {
336 inner: Mutex::new(Default::default()),
337 };
338
339 let r1 = classify(&provider, Some(&cache), "a", "b", "c")
340 .await
341 .unwrap();
342 let r2 = classify(&provider, Some(&cache), "a", "b", "c")
343 .await
344 .unwrap();
345
346 assert!((r1.score - 5.0).abs() < 0.01);
347 assert!((r2.score - 5.0).abs() < 0.01);
348 assert_eq!(
349 *provider.calls.lock().unwrap(),
350 1,
351 "second call should hit cache, not provider"
352 );
353 }
354
355 #[tokio::test]
356 async fn classify_different_inputs_use_different_cache_keys() {
357 let provider = MockProvider {
358 canned: r#"{"score": 3.0, "reason": "ok"}"#.into(),
359 calls: Mutex::new(0),
360 };
361 let cache = MemCache {
362 inner: Mutex::new(Default::default()),
363 };
364
365 classify(&provider, Some(&cache), "a", "subject1", "body").await;
366 classify(&provider, Some(&cache), "a", "subject2", "body").await;
367
368 assert_eq!(cache.inner.lock().unwrap().len(), 2);
369 assert_eq!(*provider.calls.lock().unwrap(), 2);
370 }
371
372 #[tokio::test]
373 async fn classify_caches_negative_decisions_too() {
374 let provider = MockProvider {
375 canned: r#"{"score": 0.1, "reason": "totally legit"}"#.into(),
376 calls: Mutex::new(0),
377 };
378 let cache = MemCache {
379 inner: Mutex::new(Default::default()),
380 };
381 classify(&provider, Some(&cache), "a", "b", "c").await;
382 assert_eq!(
383 cache.inner.lock().unwrap().len(),
384 1,
385 "low-score result still cached"
386 );
387 }
388
389 #[tokio::test]
392 async fn classify_handles_empty_strings() {
393 let provider = MockProvider {
396 canned: r#"{"score": 0.0, "reason": "empty"}"#.into(),
397 calls: Mutex::new(0),
398 };
399 let cache = MemCache {
400 inner: Mutex::new(Default::default()),
401 };
402 let r = classify(&provider, Some(&cache), "", "", "").await.unwrap();
403 assert!((r.score - 0.0).abs() < 0.01);
404 assert_eq!(cache.inner.lock().unwrap().len(), 1);
405 }
406
407 #[tokio::test]
408 async fn classify_cache_corrupted_value_falls_through_to_provider() {
409 let provider = MockProvider {
412 canned: r#"{"score": 4.0, "reason": "fresh"}"#.into(),
413 calls: Mutex::new(0),
414 };
415 let cache = MemCache {
416 inner: Mutex::new(Default::default()),
417 };
418 let key = make_cache_key("a", "b", "c");
420 cache
421 .inner
422 .lock()
423 .unwrap()
424 .insert(key.clone(), "not-json".to_string());
425 let r = classify(&provider, Some(&cache), "a", "b", "c")
426 .await
427 .unwrap();
428 assert!((r.score - 4.0).abs() < 0.01, "fell through to provider");
429 let cached = cache.inner.lock().unwrap().get(&key).cloned().unwrap();
431 assert!(cached.contains("\"s\":4.0") || cached.contains("\"s\":4"));
432 }
433
434 #[tokio::test]
435 async fn classify_subject_change_busts_cache() {
436 let provider = MockProvider {
437 canned: r#"{"score": 1.0, "reason": "ok"}"#.into(),
438 calls: Mutex::new(0),
439 };
440 let cache = MemCache {
441 inner: Mutex::new(Default::default()),
442 };
443 classify(&provider, Some(&cache), "a", "subj1", "body").await;
444 classify(&provider, Some(&cache), "a", "subj2", "body").await;
445 assert_eq!(*provider.calls.lock().unwrap(), 2);
447 assert_eq!(cache.inner.lock().unwrap().len(), 2);
448 }
449
450 #[tokio::test]
451 async fn classify_returns_none_when_score_field_missing() {
452 let provider = MockProvider {
454 canned: r#"{"reason": "I forgot the score"}"#.into(),
455 calls: Mutex::new(0),
456 };
457 let r = classify(&provider, None, "a", "b", "c").await;
458 assert!(r.is_none());
459 }
460
461 #[tokio::test]
462 async fn classify_score_clamped_after_provider() {
463 let provider = MockProvider {
465 canned: r#"{"score": 99.9, "reason": "off the scale"}"#.into(),
466 calls: Mutex::new(0),
467 };
468 let r = classify(&provider, None, "a", "b", "c").await.unwrap();
469 assert!((r.score - 10.0).abs() < 0.01);
470 }
471
472 #[tokio::test]
473 async fn classify_negative_score_clamped() {
474 let provider = MockProvider {
475 canned: r#"{"score": -5.0, "reason": "negative"}"#.into(),
476 calls: Mutex::new(0),
477 };
478 let r = classify(&provider, None, "a", "b", "c").await.unwrap();
479 assert!((r.score - 0.0).abs() < 0.01);
480 }
481}