rustyclaw_core/mnemo/
summarizer.rs1use super::traits::{MemoryEntry, SummaryKind, Summarizer};
4use anyhow::Result;
5use async_trait::async_trait;
6
7pub 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 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
104pub 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}