Skip to main content

matrixcode_core/compress/
summarizer.rs

1//! Content summarization for large tool results.
2//!
3//! Provides light and deep summarization modes to compress large
4//! content while preserving key information.
5
6use anyhow::Result;
7
8use crate::providers::{ChatRequest, ContentBlock, Message, MessageContent, Provider, Role};
9use crate::truncate::truncate_with_suffix;
10
11/// Summarizer for large content.
12pub struct Summarizer {
13    /// Fast model for light summarization.
14    fast_model: Box<dyn Provider>,
15    /// Optional main model for deep summarization.
16    main_model: Option<Box<dyn Provider>>,
17}
18
19impl Summarizer {
20    /// Create a new summarizer with fast model only.
21    pub fn new(fast_model: Box<dyn Provider>) -> Self {
22        Self {
23            fast_model,
24            main_model: None,
25        }
26    }
27
28    /// Create a new summarizer with both models.
29    pub fn new_with_main(fast_model: Box<dyn Provider>, main_model: Box<dyn Provider>) -> Self {
30        Self {
31            fast_model,
32            main_model: Some(main_model),
33        }
34    }
35
36    /// Generate a light summary (quick, key points only).
37    pub async fn summarize_light(&self, content: &str) -> Result<String> {
38        let truncated = truncate_with_suffix(content, 3000);
39        let prompt = format!(
40            "将以下内容压缩为简洁摘要(保留关键信息,200字以内):\n{}",
41            truncated
42        );
43
44        let response = self.fast_model.chat(build_summary_request(prompt)).await?;
45        let summary = extract_summary_text(&response);
46
47        Ok(summary)
48    }
49
50    /// Generate a deep summary (more detailed).
51    pub async fn summarize_deep(&self, content: &str) -> Result<String> {
52        let truncated = truncate_with_suffix(content, 5000);
53        let prompt = format!(
54            "将以下内容压缩为详细摘要(保留所有重要细节,500字以内):\n{}",
55            truncated
56        );
57
58        let model = self.main_model.as_ref().unwrap_or(&self.fast_model);
59        let response = model.chat(build_summary_request(prompt)).await?;
60        let summary = extract_summary_text(&response);
61
62        Ok(summary)
63    }
64
65    /// Smart truncation preserving structure.
66    pub fn smart_truncate(content: &str, target_tokens: u32) -> String {
67        // Estimate: 4 chars per token for ASCII, 1.5 for Chinese
68        let estimated_chars = (target_tokens as f64 * 3.0) as usize;
69        truncate_with_suffix(content, estimated_chars)
70    }
71
72    /// Truncate preserving beginning and end.
73    pub fn truncate_preserve_ends(content: &str, target_tokens: u32) -> String {
74        let estimated_chars = (target_tokens as f64 * 3.0) as usize;
75
76        if content.len() <= estimated_chars {
77            return content.to_string();
78        }
79
80        // Split: 60% beginning, 40% end
81        let begin_len = (estimated_chars as f64 * 0.6) as usize;
82        let end_len = estimated_chars.saturating_sub(begin_len).saturating_sub(20); // Leave room for "..."
83
84        if end_len == 0 {
85            // Too short, just truncate
86            return truncate_with_suffix(content, estimated_chars);
87        }
88
89        let begin = truncate_with_suffix(content, begin_len);
90        let end_start = content.len().saturating_sub(end_len);
91
92        // Find safe UTF-8 boundary
93        let end_start = find_char_boundary(content, end_start);
94        let end = &content[end_start..];
95
96        format!("{}...\n[内容截断]\n...{}", begin, end)
97    }
98
99    /// Check if content needs summarization.
100    pub fn needs_summary(content: &str, threshold_tokens: u32) -> bool {
101        estimate_tokens_str(content) >= threshold_tokens
102    }
103}
104
105/// Build a summary request.
106fn build_summary_request(prompt: String) -> ChatRequest {
107    ChatRequest {
108        messages: vec![Message {
109            role: Role::User,
110            content: MessageContent::Text(prompt),
111        }],
112        tools: vec![],
113        system: Some(SUMMARY_SYSTEM_PROMPT.to_string()),
114        think: false,
115        max_tokens: 512,
116        server_tools: vec![],
117        enable_caching: false,
118    }
119}
120
121/// Extract summary text from response.
122fn extract_summary_text(response: &crate::providers::ChatResponse) -> String {
123    response
124        .content
125        .iter()
126        .filter_map(|b| {
127            if let ContentBlock::Text { text } = b {
128                Some(text.clone())
129            } else {
130                None
131            }
132        })
133        .collect::<Vec<_>>()
134        .join("\n")
135}
136
137/// Find a safe UTF-8 character boundary.
138fn find_char_boundary(s: &str, max: usize) -> usize {
139    let max = max.min(s.len());
140    let mut end = max;
141    while end > 0 && !s.is_char_boundary(end) {
142        end -= 1;
143    }
144    end
145}
146
147/// Estimate tokens from string (simplified).
148fn estimate_tokens_str(s: &str) -> u32 {
149    let (ascii, non_ascii) = count_chars(s);
150    let ascii_tokens = (ascii as f64 * 0.25).ceil() as u32;
151    let non_ascii_tokens = (non_ascii as f64 * 0.67).ceil() as u32;
152    ascii_tokens + non_ascii_tokens
153}
154
155/// Count ASCII and non-ASCII characters.
156fn count_chars(s: &str) -> (u32, u32) {
157    let mut ascii = 0u32;
158    let mut non_ascii = 0u32;
159    for ch in s.chars() {
160        if ch.is_ascii() {
161            ascii += 1;
162        } else {
163            non_ascii += 1;
164        }
165    }
166    (ascii, non_ascii)
167}
168
169const SUMMARY_SYSTEM_PROMPT: &str = r#"CRITICAL: 仅用文本响应。不要调用任何工具。
170
171你是一个内容摘要助手。将长内容压缩为结构化摘要。
172
173输出要求:
174- 结构化:使用关键信息列表格式
175- 关键:保留重要操作、决策、结果
176- 简洁:控制在指定字数以内
177
178输出格式:
179【操作】执行的主要操作
180【结果】关键输出或结果
181【要点】重要发现或注意事项
182
183输出摘要后立即停止。
184请直接输出摘要内容。"#;
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_smart_truncate() {
192        let content = "这是一段很长的内容需要截断处理";
193        let result = Summarizer::smart_truncate(content, 5);
194        assert!(result.len() <= 20);
195        assert!(result.ends_with("..."));
196    }
197
198    #[test]
199    fn test_truncate_preserve_ends() {
200        // Need longer content to trigger truncation (500 tokens ~ 1500 chars)
201        let content = "开头内容中间很长的部分结尾内容".repeat(50);
202        let result = Summarizer::truncate_preserve_ends(&content, 100);
203        assert!(result.contains("开头"));
204        assert!(result.contains("结尾"));
205        assert!(result.contains("[内容截断]"));
206    }
207
208    #[test]
209    fn test_needs_summary() {
210        let short = "短内容";
211        assert!(!Summarizer::needs_summary(short, 100));
212
213        let long = "这是一段很长的内容...".repeat(100);
214        assert!(Summarizer::needs_summary(&long, 100));
215    }
216
217    #[test]
218    fn test_estimate_tokens_str() {
219        let ascii = "hello world";
220        let tokens = estimate_tokens_str(ascii);
221        assert!(tokens > 0 && tokens < 10);
222
223        let chinese = "你好世界";
224        let tokens = estimate_tokens_str(chinese);
225        assert!(tokens > 0);
226    }
227}