Skip to main content

reflex/pulse/
llm_cache.rs

1//! LLM response cache for Pulse narration
2//!
3//! Caches LLM-generated summaries keyed by structural context hash.
4//! Same structural inputs → cache hit, regardless of LLM provider or model.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10/// A cached LLM response
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CachedResponse {
13    pub context_hash: String,
14    pub response: String,
15    pub timestamp: String,
16}
17
18/// LLM response cache manager
19pub struct LlmCache {
20    cache_dir: PathBuf,
21}
22
23impl LlmCache {
24    /// Create a new LLM cache at the given directory
25    pub fn new(reflex_cache_path: &Path) -> Self {
26        Self {
27            cache_dir: reflex_cache_path.join("pulse").join("llm-cache"),
28        }
29    }
30
31    /// Create from an already-resolved cache directory path
32    pub fn from_dir(cache_dir: PathBuf) -> Self {
33        Self { cache_dir }
34    }
35
36    /// Get the cache directory path
37    pub fn cache_dir(&self) -> &Path {
38        &self.cache_dir
39    }
40
41    /// Compute a cache key from structural context
42    ///
43    /// Key: blake3(snapshot_id + module_path + structural_context_hash)
44    pub fn compute_key(snapshot_id: &str, module_path: &str, context: &str) -> String {
45        let input = format!("{}:{}:{}", snapshot_id, module_path, context);
46        blake3::hash(input.as_bytes()).to_hex().to_string()
47    }
48
49    /// Look up a cached response
50    pub fn get(&self, key: &str) -> Result<Option<CachedResponse>> {
51        let path = self.cache_dir.join(format!("{}.json", key));
52        if !path.exists() {
53            return Ok(None);
54        }
55
56        let content = std::fs::read_to_string(&path)
57            .context("Failed to read LLM cache entry")?;
58        let cached: CachedResponse = serde_json::from_str(&content)
59            .context("Failed to parse LLM cache entry")?;
60        Ok(Some(cached))
61    }
62
63    /// Store a response in the cache
64    pub fn put(&self, key: &str, context_hash: &str, response: &str) -> Result<()> {
65        std::fs::create_dir_all(&self.cache_dir)
66            .context("Failed to create LLM cache directory")?;
67
68        let entry = CachedResponse {
69            context_hash: context_hash.to_string(),
70            response: response.to_string(),
71            timestamp: chrono::Local::now().to_rfc3339(),
72        };
73
74        let json = serde_json::to_string_pretty(&entry)?;
75        let path = self.cache_dir.join(format!("{}.json", key));
76        std::fs::write(&path, json)
77            .context("Failed to write LLM cache entry")?;
78
79        Ok(())
80    }
81
82    /// Clear all cached responses
83    pub fn clear(&self) -> Result<()> {
84        if self.cache_dir.exists() {
85            std::fs::remove_dir_all(&self.cache_dir)
86                .context("Failed to clear LLM cache")?;
87        }
88        Ok(())
89    }
90
91    /// Count cached entries
92    pub fn count(&self) -> usize {
93        if !self.cache_dir.exists() {
94            return 0;
95        }
96        std::fs::read_dir(&self.cache_dir)
97            .map(|entries| entries.filter(|e| e.is_ok()).count())
98            .unwrap_or(0)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_cache_key_determinism() {
108        let key1 = LlmCache::compute_key("snap1", "src", "context_abc");
109        let key2 = LlmCache::compute_key("snap1", "src", "context_abc");
110        assert_eq!(key1, key2);
111
112        let key3 = LlmCache::compute_key("snap1", "src", "context_different");
113        assert_ne!(key1, key3);
114    }
115
116    #[test]
117    fn test_cache_roundtrip() {
118        let dir = tempfile::tempdir().unwrap();
119        let cache = LlmCache::new(dir.path());
120
121        let key = "test_key_123";
122        assert!(cache.get(key).unwrap().is_none());
123        assert_eq!(cache.count(), 0);
124
125        cache.put(key, "hash123", "This module handles authentication.").unwrap();
126        assert_eq!(cache.count(), 1);
127
128        let cached = cache.get(key).unwrap().unwrap();
129        assert_eq!(cached.response, "This module handles authentication.");
130        assert_eq!(cached.context_hash, "hash123");
131    }
132}