Skip to main content

rustyclaw_core/mnemo/
summarizer.rs

1//! Summarization backends for memory compaction.
2
3use super::traits::{MemoryEntry, SummaryKind, Summarizer};
4use anyhow::Result;
5use async_trait::async_trait;
6
7/// LLM-backed summarizer using provider infrastructure.
8pub struct LlmSummarizer {
9    http: reqwest::Client,
10    base_url: String,
11    model: String,
12    api_key: Option<String>,
13}
14
15impl LlmSummarizer {
16    pub fn new(base_url: String, model: String, api_key: Option<String>) -> Self {
17        Self {
18            http: reqwest::Client::new(),
19            base_url,
20            model,
21            api_key,
22        }
23    }
24
25    /// Set the API key for authentication.
26    pub fn with_api_key(mut self, key: String) -> Self {
27        self.api_key = Some(key);
28        self
29    }
30
31    async fn call_llm(&self, prompt: &str) -> Result<String> {
32        let messages = vec![
33            serde_json::json!({
34                "role": "system",
35                "content": "You are a concise summarizer. Preserve key facts, decisions, and context. Be brief but complete."
36            }),
37            serde_json::json!({
38                "role": "user",
39                "content": prompt
40            }),
41        ];
42
43        let body = serde_json::json!({
44            "model": self.model,
45            "messages": messages,
46            "max_tokens": 500,
47            "temperature": 0.3,
48        });
49
50        let mut request = self
51            .http
52            .post(format!("{}/chat/completions", self.base_url))
53            .json(&body);
54
55        if let Some(ref key) = self.api_key {
56            request = request.bearer_auth(key);
57        }
58
59        let response = request.send().await?;
60        let json: serde_json::Value = response.json().await?;
61
62        let content = json["choices"][0]["message"]["content"]
63            .as_str()
64            .unwrap_or("")
65            .to_string();
66
67        Ok(content)
68    }
69}
70
71#[async_trait]
72impl Summarizer for LlmSummarizer {
73    fn name(&self) -> &str {
74        "llm"
75    }
76
77    async fn summarize(&self, entries: &[MemoryEntry], kind: SummaryKind) -> Result<String> {
78        let formatted: Vec<String> = entries
79            .iter()
80            .map(|e| {
81                if e.depth == 0 {
82                    format!("- {}: {}", e.role, e.content)
83                } else {
84                    format!("- summary-d{}: {}", e.depth, e.content)
85                }
86            })
87            .collect();
88
89        let prompt = match kind {
90            SummaryKind::Leaf => format!(
91                "Summarize these conversation messages into a brief summary (max 150 words). Preserve key facts, decisions, and action items:\n\n{}",
92                formatted.join("\n")
93            ),
94            SummaryKind::Condensed => format!(
95                "Condense these summaries into a single meta-summary (max 100 words). Preserve the most important facts:\n\n{}",
96                formatted.join("\n")
97            ),
98        };
99
100        self.call_llm(&prompt).await
101    }
102}
103
104/// Deterministic fallback summarizer (truncation-based).
105pub struct DeterministicSummarizer {
106    chars_per_entry: usize,
107    max_total_chars: usize,
108}
109
110impl DeterministicSummarizer {
111    pub fn new(chars_per_entry: usize, max_total_chars: usize) -> Self {
112        Self {
113            chars_per_entry,
114            max_total_chars,
115        }
116    }
117
118    fn truncate(&self, text: &str, max: usize) -> String {
119        if text.len() <= max {
120            text.to_string()
121        } else {
122            format!("{}…", &text[..max.saturating_sub(1)])
123        }
124    }
125}
126
127#[async_trait]
128impl Summarizer for DeterministicSummarizer {
129    fn name(&self) -> &str {
130        "deterministic"
131    }
132
133    async fn summarize(&self, entries: &[MemoryEntry], _kind: SummaryKind) -> Result<String> {
134        let mut parts = Vec::new();
135        let mut total = 0;
136
137        for e in entries {
138            let truncated = self.truncate(&e.content, self.chars_per_entry);
139            let line = if e.depth == 0 {
140                format!("- {}: {}", e.role, truncated)
141            } else {
142                format!("- summary-d{}: {}", e.depth, truncated)
143            };
144            let line_len = line.len();
145
146            if total + line_len > self.max_total_chars {
147                break;
148            }
149
150            parts.push(line);
151            total += line_len;
152        }
153
154        Ok(parts.join("\n"))
155    }
156}