reflex/pulse/
llm_cache.rs1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CachedResponse {
13 pub context_hash: String,
14 pub response: String,
15 pub timestamp: String,
16}
17
18pub struct LlmCache {
20 cache_dir: PathBuf,
21}
22
23impl LlmCache {
24 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 pub fn from_dir(cache_dir: PathBuf) -> Self {
33 Self { cache_dir }
34 }
35
36 pub fn cache_dir(&self) -> &Path {
38 &self.cache_dir
39 }
40
41 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 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 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 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 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}