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