chatty_rs/context/
compressor.rs1#[cfg(test)]
2#[path = "compressor_test.rs"]
3mod tests;
4
5use crate::backend::ArcBackend;
6use crate::config::ContextCompression;
7use crate::config::constants::{KEEP_N_MEESAGES, MAX_CONTEXT_LENGTH, MAX_CONVO_LENGTH};
8use crate::models::{
9 ArcEventTx, BackendPrompt, Context as ConvoContext, Conversation, Event, Message,
10};
11use eyre::{Context, Result, bail};
12use std::sync::Arc;
13use tokio::sync::mpsc;
14
15pub struct Compressor {
16 enabled: bool,
17
18 max_context_length: usize,
19 max_convo_length: usize,
20 keep_n_messages: usize,
21
22 backend: ArcBackend,
23}
24
25impl Compressor {
26 pub fn new(backend: ArcBackend) -> Self {
27 Self {
28 enabled: false,
29 backend,
30 max_context_length: MAX_CONTEXT_LENGTH,
31 max_convo_length: MAX_CONVO_LENGTH,
32 keep_n_messages: KEEP_N_MEESAGES,
33 }
34 }
35
36 pub fn from_config(mut self, cfg: &ContextCompression) -> Self {
37 self.enabled = cfg.enabled;
38 self.max_context_length = cfg.max_tokens;
39 self.max_convo_length = cfg.max_messages;
40 self.keep_n_messages = cfg.keep_n_messages.max(KEEP_N_MEESAGES);
41 self
42 }
43
44 pub fn is_enabled(&self) -> bool {
45 self.enabled
46 }
47
48 pub fn with_enabled(mut self, enabled: bool) -> Self {
49 self.enabled = enabled;
50 self
51 }
52
53 pub fn with_context_length(mut self, length: usize) -> Self {
54 self.max_context_length = length;
55 self
56 }
57
58 pub fn with_keep_n_messages(mut self, size: usize) -> Self {
59 self.keep_n_messages = size.max(KEEP_N_MEESAGES);
60 self
61 }
62
63 pub fn with_conversation_length(mut self, length: usize) -> Self {
64 self.max_convo_length = length;
65 self
66 }
67
68 pub fn should_compress(&self, convo: &Conversation) -> bool {
69 if !self.enabled || convo.len() < self.keep_n_messages {
70 return false;
71 }
72
73 let total_tokens = convo.token_count();
74 let offset = match convo.last_message() {
78 Some(msg) => {
79 if msg.is_system() {
80 2
81 } else {
82 1
83 }
84 }
85 _ => 0,
86 } as isize;
87 let message_count = (calculate_convo_len(convo) as isize - offset).max(0) as usize;
88 total_tokens > self.max_context_length || message_count > self.max_convo_length
89 }
90
91 pub async fn compress(
92 &self,
93 model: &str,
94 convo: &Conversation,
95 ) -> Result<Option<ConvoContext>> {
96 if !self.should_compress(convo) {
97 return Ok(None);
98 }
99
100 let end_checkpoint = match find_checkpoint(convo, self.keep_n_messages) {
101 Some(checkpoint) => checkpoint,
102 _ => return Ok(None),
103 };
104
105 let start_checkpoint = match convo.contexts().last() {
106 Some(ctx) => match convo
107 .messages()
108 .iter()
109 .position(|msg| msg.id() == ctx.last_message_id())
110 {
111 Some(index) => index + 1,
112 _ => 0,
113 },
114 _ => 0,
115 };
116
117 let last_message_id = convo.messages()[end_checkpoint].id();
118 let mut messages = convo
119 .contexts()
120 .iter()
121 .map(Message::from)
122 .collect::<Vec<_>>();
123
124 messages.extend(convo.messages()[start_checkpoint..end_checkpoint + 1].to_vec());
125
126 let message = messages
127 .iter()
128 .map(|msg| format!("{}: {}", message_categorize(msg), msg.text()))
129 .collect::<Vec<_>>()
130 .join("\n");
131
132 let prompt = BackendPrompt::new(format!(
133 r#"Summarize the following conversation in a compact yet comprehensive manner.
134Focus on the key points, decisions, and any critical information exchanged, while omitting trivial or redundant details. Include specific actions or plans that were agreed upon.
135Ensure that the summary is understandable on its own, providing enough context for someone who hasn't read the entire conversation.
136Aim to capture the essence of the discussion while keeping the summary as concise as possible.
137The summary should be started with Summary: and end with a period.
138---
139{}"#,
140 message
141 )).with_model(model).with_no_generate_title();
142
143 let (tx, mut rx) = mpsc::unbounded_channel::<Event>();
144 let sender: ArcEventTx = Arc::new(tx);
145 self.backend
146 .get_completion(prompt, sender)
147 .await
148 .wrap_err("getting completion")?;
149
150 let mut context = ConvoContext::new(last_message_id);
151 while let Some(event) = rx.recv().await {
152 match event {
153 Event::ChatCompletionResponse(msg) => {
154 context.append_content(msg.text);
155 if msg.done {
156 context = context.with_id(msg.id);
157 if let Some(usage) = msg.usage {
158 context = context.with_token_count(usage.completion_tokens);
159 }
160 break;
161 }
162 }
163 _ => bail!("Unexpected event: {:?}", event),
164 }
165 }
166
167 if context.content().is_empty() {
168 return Ok(None);
169 }
170
171 Ok(Some(context))
172 }
173}
174
175fn find_checkpoint(conversation: &Conversation, keep_n_messages: usize) -> Option<usize> {
176 let mut last = conversation.len() - 1 - keep_n_messages;
177 while last > 0 && !conversation.messages()[last].is_system() {
178 last -= 1;
179 }
180 if last == 0 {
181 return None;
182 }
183 Some(last)
184}
185
186fn message_categorize(message: &Message) -> String {
187 if message.is_context() {
188 "Context".to_string()
189 } else if message.is_system() {
190 "System".to_string()
191 } else {
192 "User".to_string()
193 }
194}
195
196fn calculate_convo_len(convo: &Conversation) -> usize {
197 if convo.contexts().is_empty() {
198 return convo.len();
199 }
200 let last_message_id = convo.contexts().last().unwrap().last_message_id();
201 let last_message_index = convo
202 .messages()
203 .iter()
204 .position(|msg| msg.id() == last_message_id)
205 .unwrap_or(convo.len());
206 convo.len() - last_message_index
207}