Skip to main content

matrixcode_core/compress/
semantic.rs

1//! Semantic compression using AI summarization.
2//!
3//! Instead of simple truncation, this module uses a small model to summarize
4//! historical messages while preserving key information.
5
6use crate::providers::{Message, MessageContent, Role};
7use crate::compress::hardcode_config::HardcodeConfig;
8use super::prompts_zh::SUMMARY_PROMPT;
9use serde::{Deserialize, Serialize};
10
11/// Summary of a conversation segment.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ConversationSummary {
14    /// Key decisions made
15    pub decisions: Vec<String>,
16    /// Important facts discovered
17    pub facts: Vec<String>,
18    /// Tools used and their results
19    pub tool_usage: Vec<ToolUsage>,
20    /// Errors encountered and how they were resolved
21    pub issues: Vec<Issue>,
22    /// Overall summary text
23    pub summary: String,
24}
25
26/// Record of tool usage in a conversation.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ToolUsage {
29    pub tool_name: String,
30    pub purpose: String,
31    pub outcome: String,
32}
33
34/// Record of an issue and its resolution.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Issue {
37    pub problem: String,
38    pub solution: String,
39}
40
41/// Semantic compressor that uses AI to summarize messages.
42pub struct SemanticCompressor {
43    /// Hardcode configuration
44    hardcode_config: HardcodeConfig,
45}
46
47impl Default for SemanticCompressor {
48    fn default() -> Self {
49        Self {
50            hardcode_config: HardcodeConfig::default(),
51        }
52    }
53}
54
55impl SemanticCompressor {
56    pub fn new() -> Self {
57        Self {
58            hardcode_config: HardcodeConfig::default(),
59        }
60    }
61
62    /// Extract key information from a message.
63    pub fn extract_key_info(message: &Message) -> KeyInfo {
64        let mut info = KeyInfo::default();
65
66        // Check content for important patterns
67        if let MessageContent::Text(text) = &message.content {
68            // Detect decisions (中英文)
69            if text.contains("decided") || text.contains("decision") 
70                || text.contains("决定") || text.contains("choose") || text.contains("selected") {
71                info.has_decision = true;
72            }
73
74            // Detect errors (中英文)
75            if text.contains("error") || text.contains("failed") 
76                || text.contains("错误") || text.contains("失败") || text.contains("异常") {
77                info.has_error = true;
78            }
79
80            // Detect tool usage
81            if text.contains("tool") || text.contains("function") {
82                info.has_tool_use = true;
83            }
84
85            // Detect code
86            if text.contains("```") || text.contains("fn ") || text.contains("function ") {
87                info.has_code = true;
88            }
89        }
90
91        // Check for tool blocks
92        if let MessageContent::Blocks(blocks) = &message.content {
93            for block in blocks {
94                match block {
95                    crate::providers::ContentBlock::ToolUse { name, .. } => {
96                        info.tool_names.push(name.clone());
97                        info.has_tool_use = true;
98                    }
99                    crate::providers::ContentBlock::ToolResult { content, .. } => {
100                        if content.contains("error") || content.contains("failed") {
101                            info.has_error = true;
102                        }
103                    }
104                    _ => {}
105                }
106            }
107        }
108
109        info
110    }
111
112    /// Check if messages should be semantically compressed.
113    pub fn should_summarize(&self, messages: &[Message]) -> bool {
114        if messages.is_empty() {
115            return false;
116        }
117
118        // Check if there's enough content to summarize
119        let has_substantial_content = messages.iter().any(|m| {
120            matches!(&m.content, MessageContent::Text(t) if t.len() > self.hardcode_config.summary_length_threshold)
121        });
122
123        // Check if there are multiple messages
124        has_substantial_content && messages.len() >= 3
125    }
126
127    /// Generate a summary prompt for the messages.
128    pub fn create_summary_prompt(messages: &[Message]) -> String {
129        let mut conversation = String::new();
130        
131        for msg in messages {
132            let role = match msg.role {
133                Role::User => "用户",
134                Role::Assistant => "助手",
135                Role::System => "系统",
136                Role::Tool => "工具",
137            };
138
139            if let MessageContent::Text(text) = &msg.content {
140                conversation.push_str(&format!("{}: {}\n", role, text));
141            } else if let MessageContent::Blocks(blocks) = &msg.content {
142                for block in blocks {
143                    if let crate::providers::ContentBlock::Text { text } = block {
144                        conversation.push_str(&format!("{}: {}\n", role, text));
145                    }
146                }
147            }
148        }
149
150        SUMMARY_PROMPT.replace("{conversation}", &conversation)
151    }
152
153    /// Create a compressed summary message.
154    pub fn create_summary_message(summary: ConversationSummary) -> Message {
155        let mut content = String::new();
156        content.push_str("📝 **对话摘要**\n\n");
157
158        if !summary.decisions.is_empty() {
159            content.push_str("**决策:**\n");
160            for decision in &summary.decisions {
161                content.push_str(&format!("- {}\n", decision));
162            }
163            content.push('\n');
164        }
165
166        if !summary.facts.is_empty() {
167            content.push_str("**关键事实:**\n");
168            for fact in &summary.facts {
169                content.push_str(&format!("- {}\n", fact));
170            }
171            content.push('\n');
172        }
173
174        if !summary.tool_usage.is_empty() {
175            content.push_str("**使用的工具:**\n");
176            for tool in &summary.tool_usage {
177                content.push_str(&format!("- {}: {}\n", tool.tool_name, tool.outcome));
178            }
179            content.push('\n');
180        }
181
182        if !summary.issues.is_empty() {
183            content.push_str("**解决的问题:**\n");
184            for issue in &summary.issues {
185                content.push_str(&format!("- 问题: {}\n  解决: {}\n", issue.problem, issue.solution));
186            }
187            content.push('\n');
188        }
189
190        content.push_str(&format!("**Overall:** {}", summary.summary));
191
192        Message {
193            role: Role::System,
194            content: MessageContent::Text(content),
195        }
196    }
197}
198
199/// Key information extracted from a message.
200#[derive(Debug, Default)]
201pub struct KeyInfo {
202    pub has_decision: bool,
203    pub has_error: bool,
204    pub has_tool_use: bool,
205    pub has_code: bool,
206    pub tool_names: Vec<String>,
207}
208
209/// Strategy for semantic compression.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum SemanticStrategy {
212    /// Don't use semantic compression (truncate only)
213    None,
214    /// Use semantic compression for old messages
215    OldOnly,
216    /// Use semantic compression for all compressible messages
217    Aggressive,
218}
219
220impl Default for SemanticStrategy {
221    fn default() -> Self {
222        Self::OldOnly
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::providers::{ContentBlock, Message, MessageContent, Role};
230
231    #[test]
232    fn test_extract_key_info_decision() {
233        let msg = Message {
234            role: Role::Assistant,
235            content: MessageContent::Text("I decided to use Rust for the project.".to_string()),
236        };
237        let info = SemanticCompressor::extract_key_info(&msg);
238        assert!(info.has_decision);
239    }
240
241    #[test]
242    fn test_extract_key_info_error() {
243        let msg = Message {
244            role: Role::Assistant,
245            content: MessageContent::Text("The operation failed with error code 404.".to_string()),
246        };
247        let info = SemanticCompressor::extract_key_info(&msg);
248        assert!(info.has_error);
249    }
250
251    #[test]
252    fn test_extract_key_info_tool() {
253        let msg = Message {
254            role: Role::Assistant,
255            content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
256                id: "tool_1".to_string(),
257                name: "bash".to_string(),
258                input: serde_json::json!({"command": "ls"}),
259            }]),
260        };
261        let info = SemanticCompressor::extract_key_info(&msg);
262        assert!(info.has_tool_use);
263        assert!(info.tool_names.contains(&"bash".to_string()));
264    }
265
266    #[test]
267    fn test_should_summarize() {
268        // Too few messages
269        let messages = vec![Message {
270            role: Role::User,
271            content: MessageContent::Text("Hello".to_string()),
272        }];
273        let compressor = SemanticCompressor::default();
274        assert!(!compressor.should_summarize(&messages));
275
276        // Enough messages with substantial content (需要超过 200 字符)
277        let messages = vec![
278            Message {
279                role: Role::User,
280                content: MessageContent::Text("This is a longer message with more than two hundred characters to test the substantial content check. We need to make sure it's long enough. Adding more text to ensure the message has sufficient length for the test requirement.".to_string()),
281            },
282            Message {
283                role: Role::Assistant,
284                content: MessageContent::Text("Response 1".to_string()),
285            },
286            Message {
287                role: Role::User,
288                content: MessageContent::Text("Query 2".to_string()),
289            },
290        ];
291        let compressor = SemanticCompressor::default();
292        assert!(compressor.should_summarize(&messages));
293    }
294
295    #[test]
296    fn test_create_summary_message() {
297        let summary = ConversationSummary {
298            decisions: vec!["Use Rust for backend".to_string()],
299            facts: vec!["Project uses PostgreSQL".to_string()],
300            tool_usage: vec![ToolUsage {
301                tool_name: "bash".to_string(),
302                purpose: "Run tests".to_string(),
303                outcome: "All tests passed".to_string(),
304            }],
305            issues: vec![Issue {
306                problem: "Compilation error".to_string(),
307                solution: "Fixed missing import".to_string(),
308            }],
309            summary: "Completed initial setup and testing.".to_string(),
310        };
311
312        let msg = SemanticCompressor::create_summary_message(summary);
313        assert!(matches!(msg.role, Role::System));
314        
315        if let MessageContent::Text(text) = &msg.content {
316            assert!(text.contains("决策"));
317            assert!(text.contains("关键事实"));
318            assert!(text.contains("使用的工具"));
319            assert!(text.contains("解决的问题"));
320        } else {
321            panic!("Expected text content");
322        }
323    }
324}