Skip to main content

chatty_rs/context/
compressor.rs

1#[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        // Calculate the offset of the message, we will ignore the last 2
75        // messages (1 user and 1 system) in-case user is asking for
76        // regeneration response.
77        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}