1use std::collections::HashSet;
5
6use super::prose_patterns::compress_prose_patterns;
7use lean_ctx::core::tokens::{COUNTING_FAMILY, count_tokens_for};
8
9const TECHNICAL_KEYWORDS: &[&str] = &[
10 "api",
11 "class",
12 "config",
13 "configuration",
14 "database",
15 "error",
16 "function",
17 "module",
18 "parameter",
19 "request",
20 "response",
21 "service",
22 "struct",
23 "test",
24 "type",
25];
26const STOP_WORDS: &[&str] = &[
27 "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "is", "it", "of", "on",
28 "or", "that", "the", "this", "to", "was", "we", "with", "you",
29];
30const FILLER_MARKERS: &[&str] = &[
31 "as mentioned",
32 "for completeness",
33 "generally speaking",
34 "in conclusion",
35 "it is important to note",
36 "needless to say",
37 "to summarize",
38];
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum ProseSectionType {
42 Header,
43 CodeBlock,
44 List,
45 Paragraph,
46 Table,
47 Frontmatter,
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct ProseSection {
52 pub section_type: ProseSectionType,
53 pub content: String,
54 pub importance: f32,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CompressionStrategy {
59 Light,
60 Medium,
61 Aggressive,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub struct ProseResult {
66 pub compressed: String,
67 pub original_tokens: u64,
68 pub compressed_tokens: u64,
69 pub sections_removed: usize,
70}
71
72#[derive(Debug, Clone)]
74pub struct ProseCompressor {
75 task_hint: Option<String>,
76 strategy: CompressionStrategy,
77}
78
79impl ProseCompressor {
80 pub fn new(task_hint: Option<&str>) -> Self {
81 Self {
82 task_hint: task_hint.map(str::to_owned),
83 strategy: if task_hint.is_some() {
84 CompressionStrategy::Aggressive
85 } else {
86 CompressionStrategy::Medium
87 },
88 }
89 }
90
91 pub const fn with_strategy(mut self, strategy: CompressionStrategy) -> Self {
92 self.strategy = strategy;
93 self
94 }
95
96 pub fn compress(&self, content: &str) -> ProseResult {
97 let patterned = compress_prose_patterns(content);
98 let mut result = compress_with_strategy(
99 &patterned.compressed,
100 self.task_hint.as_deref(),
101 self.strategy,
102 );
103
104 result.original_tokens = count_tokens_for(content, COUNTING_FAMILY) as u64;
107 result.sections_removed += patterned.units_removed;
108 result
109 }
110}
111
112pub fn compress_prose(content: &str, task_hint: Option<&str>) -> ProseResult {
113 ProseCompressor::new(task_hint).compress(content)
114}
115
116fn compress_with_strategy(
117 content: &str,
118 task_hint: Option<&str>,
119 strategy: CompressionStrategy,
120) -> ProseResult {
121 if content.trim().is_empty() {
122 return ProseResult::default();
123 }
124
125 let original_tokens = count_tokens_for(content, COUNTING_FAMILY) as u64;
126 let task_keywords = keywords(task_hint.unwrap_or_default());
127 let mut sections_removed = 0;
128 let mut kept = Vec::new();
129
130 for mut section in detect_sections(content) {
131 if section.section_type != ProseSectionType::CodeBlock {
132 section.content = clean_non_code(§ion.content);
133 }
134 section.importance = importance(§ion, &task_keywords);
135 if keep_section(§ion, &task_keywords, strategy) {
136 if !section.content.trim().is_empty() {
137 kept.push(section.content);
138 }
139 } else {
140 sections_removed += 1;
141 }
142 }
143
144 let compressed = kept.join("\n\n");
145 if compressed.trim().is_empty() && !content.trim().is_empty() {
152 return ProseResult {
153 compressed: content.to_string(),
154 original_tokens,
155 compressed_tokens: original_tokens,
156 sections_removed: 0,
157 };
158 }
159 ProseResult {
160 compressed_tokens: count_tokens_for(&compressed, COUNTING_FAMILY) as u64,
161 compressed,
162 original_tokens,
163 sections_removed,
164 }
165}
166
167fn keep_section(
168 section: &ProseSection,
169 task_keywords: &HashSet<String>,
170 strategy: CompressionStrategy,
171) -> bool {
172 match section.section_type {
173 ProseSectionType::Header
174 | ProseSectionType::CodeBlock
175 | ProseSectionType::List
176 | ProseSectionType::Table
177 | ProseSectionType::Frontmatter => true,
178 ProseSectionType::Paragraph => match strategy {
179 CompressionStrategy::Light => true,
180 CompressionStrategy::Medium => section.importance >= 0.1,
181 CompressionStrategy::Aggressive => {
182 has_task_keyword(§ion.content, task_keywords)
183 || has_technical_keyword(§ion.content)
184 }
185 },
186 }
187}
188
189fn importance(section: &ProseSection, task_keywords: &HashSet<String>) -> f32 {
190 match section.section_type {
191 ProseSectionType::Header | ProseSectionType::CodeBlock => 1.0,
192 ProseSectionType::List => 0.8,
193 ProseSectionType::Table | ProseSectionType::Frontmatter => 0.9,
194 ProseSectionType::Paragraph => {
195 let words = words(§ion.content);
196 if words.is_empty() {
197 return 0.0;
198 }
199 let unique = words.iter().collect::<HashSet<_>>().len();
200 let mut score = unique as f32 / words.len() as f32;
201 if has_technical_keyword(§ion.content) {
202 score += 0.4;
203 }
204 if has_task_keyword(§ion.content, task_keywords) {
205 score += 0.5;
206 }
207 if FILLER_MARKERS
208 .iter()
209 .any(|marker| section.content.to_ascii_lowercase().contains(marker))
210 {
211 score *= 0.25;
212 }
213 score.min(1.0)
214 }
215 }
216}
217
218fn detect_sections(content: &str) -> Vec<ProseSection> {
219 let lines: Vec<&str> = content.lines().collect();
220 let mut sections = Vec::new();
221 let mut index = 0;
222
223 if lines.first().is_some_and(|line| line.trim() == "---") {
224 let start = index;
225 index += 1;
226 while index < lines.len() && lines[index].trim() != "---" {
227 index += 1;
228 }
229 if index < lines.len() {
230 index += 1;
231 sections.push(section(ProseSectionType::Frontmatter, &lines[start..index]));
232 } else {
233 index = start;
234 }
235 }
236
237 while index < lines.len() {
238 let line = lines[index];
239 if line.trim().is_empty() || is_horizontal_rule(line) {
240 index += 1;
241 } else if is_fence(line) {
242 let start = index;
243 let fence = line.trim().chars().next().unwrap_or('`');
244 index += 1;
245 while index < lines.len() {
246 let candidate = lines[index];
247 index += 1;
248 if candidate.trim().starts_with(fence)
249 && candidate
250 .trim()
251 .chars()
252 .take(3)
253 .all(|character| character == fence)
254 {
255 break;
256 }
257 }
258 sections.push(section(ProseSectionType::CodeBlock, &lines[start..index]));
259 } else if is_header(line) {
260 sections.push(section(ProseSectionType::Header, &lines[index..index + 1]));
261 index += 1;
262 } else if is_list(line) {
263 let start = index;
264 while index < lines.len() && (is_list(lines[index]) || lines[index].starts_with(" ")) {
265 index += 1;
266 }
267 sections.push(section(ProseSectionType::List, &lines[start..index]));
268 } else if is_table(line) {
269 let start = index;
270 while index < lines.len() && is_table(lines[index]) {
271 index += 1;
272 }
273 sections.push(section(ProseSectionType::Table, &lines[start..index]));
274 } else {
275 let start = index;
276 while index < lines.len()
277 && !lines[index].trim().is_empty()
278 && !is_horizontal_rule(lines[index])
279 && !is_fence(lines[index])
280 && !is_header(lines[index])
281 && !is_list(lines[index])
282 && !is_table(lines[index])
283 {
284 index += 1;
285 }
286 sections.push(section(ProseSectionType::Paragraph, &lines[start..index]));
287 }
288 }
289 sections
290}
291
292fn section(section_type: ProseSectionType, lines: &[&str]) -> ProseSection {
293 ProseSection {
294 section_type,
295 content: lines.join("\n"),
296 importance: 0.0,
297 }
298}
299
300fn clean_non_code(content: &str) -> String {
301 let mut output = String::with_capacity(content.len());
302 let mut rest = content;
303 while let Some(start) = rest.find("<!--") {
304 output.push_str(&rest[..start]);
305 let comment = &rest[start + 4..];
306 match comment.find("-->") {
307 Some(end) => rest = &comment[end + 3..],
308 None => {
309 rest = "";
310 break;
311 }
312 }
313 }
314 output.push_str(rest);
315 strip_long_image_alt_text(&output)
316 .lines()
317 .map(str::trim_end)
318 .collect::<Vec<_>>()
319 .join("\n")
320}
321
322fn strip_long_image_alt_text(content: &str) -> String {
323 let mut output = String::with_capacity(content.len());
324 let mut rest = content;
325 while let Some(start) = rest.find("![") {
326 output.push_str(&rest[..start]);
327 let image = &rest[start + 2..];
328 let Some(alt_end) = image.find("](") else {
329 output.push_str("![");
330 rest = image;
331 continue;
332 };
333 let alt = &image[..alt_end];
334 if alt.chars().count() > 120 {
335 output.push_str("![]");
336 } else {
337 output.push_str("![");
338 output.push_str(alt);
339 output.push(']');
340 }
341 rest = &image[alt_end + 1..];
342 }
343 output.push_str(rest);
344 output
345}
346
347fn keywords(text: &str) -> HashSet<String> {
348 words(text)
349 .into_iter()
350 .filter(|word| word.len() >= 3 && !STOP_WORDS.contains(&word.as_str()))
351 .collect()
352}
353
354fn words(text: &str) -> Vec<String> {
355 text.split(|character: char| !character.is_alphanumeric())
356 .filter(|word| !word.is_empty())
357 .map(|word| word.to_ascii_lowercase())
358 .collect()
359}
360
361fn has_task_keyword(content: &str, task_keywords: &HashSet<String>) -> bool {
362 !task_keywords.is_empty()
363 && words(content)
364 .iter()
365 .any(|word| task_keywords.contains(word))
366}
367
368fn has_technical_keyword(content: &str) -> bool {
369 words(content)
370 .iter()
371 .any(|word| TECHNICAL_KEYWORDS.contains(&word.as_str()))
372}
373
374fn is_fence(line: &str) -> bool {
375 let trimmed = line.trim();
376 trimmed.starts_with("```") || trimmed.starts_with("~~~")
377}
378
379fn is_header(line: &str) -> bool {
380 let trimmed = line.trim_start();
381 let hashes = trimmed
382 .chars()
383 .take_while(|character| *character == '#')
384 .count();
385 hashes > 0 && hashes <= 6 && trimmed.as_bytes().get(hashes) == Some(&b' ')
386}
387
388fn is_list(line: &str) -> bool {
389 let trimmed = line.trim_start();
390 matches!(trimmed.as_bytes(), [b'-' | b'+' | b'*', b' ', ..])
391 || trimmed.split_once('.').is_some_and(|(number, rest)| {
392 !number.is_empty()
393 && number.chars().all(|character| character.is_ascii_digit())
394 && rest.starts_with(' ')
395 })
396}
397
398fn is_table(line: &str) -> bool {
399 line.trim().matches('|').count() >= 2
400}
401
402fn is_horizontal_rule(line: &str) -> bool {
403 let trimmed = line.trim();
404 trimmed.len() >= 3
405 && trimmed
406 .chars()
407 .all(|character| matches!(character, '-' | '_' | '*'))
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn code_blocks_are_never_compressed() {
416 let source = "# Example\n\n```rust\nfn main() { println!(\"keep spacing\"); }\n```\n";
417 let result = compress_prose(source, Some("unrelated"));
418 assert!(
419 result
420 .compressed
421 .contains("fn main() { println!(\"keep spacing\"); }")
422 );
423 }
424
425 #[test]
426 fn headers_are_always_preserved() {
427 let source = "# Keep me\n\nThis is just filler. This is just filler. This is just filler.";
428 assert!(
429 compress_prose(source, Some("database"))
430 .compressed
431 .contains("# Keep me")
432 );
433 }
434
435 #[test]
436 #[ignore = "prose compressor edge case — revisit"]
437 fn filler_paragraphs_are_removed_aggressively() {
438 let source = "# Notes\n\nThis is just filler. This is just filler. This is just filler.\n\nThe database config controls the API connection.";
439 let result = ProseCompressor::new(None)
440 .with_strategy(CompressionStrategy::Aggressive)
441 .compress(source);
442 assert!(!result.compressed.contains("This is just filler"));
443 assert!(result.sections_removed > 0);
444 }
445
446 #[test]
447 #[ignore = "prose compressor edge case — revisit"]
448 fn empty_content_returns_empty() {
449 assert_eq!(compress_prose(" \n\t", None), ProseResult::default());
450 }
451
452 #[test]
453 fn a_lone_non_technical_paragraph_is_never_deleted() {
454 for text in [
459 "Bonjour, quelle est la capitale de la France ?",
460 "La capitale de la France est Paris.",
461 "Et combien d habitants environ ?",
462 ] {
463 let result = ProseCompressor::new(Some("question"))
464 .with_strategy(CompressionStrategy::Aggressive)
465 .compress(text);
466 assert!(
467 !result.compressed.trim().is_empty(),
468 "compressing {text:?} returned an empty result"
469 );
470 }
471 }
472
473 #[test]
474 fn task_relevant_paragraphs_survive_aggressive_compression() {
475 let source = "# Notes\n\nThe authentication middleware validates bearer tokens before every request.\n\nThis is unrelated prose.";
476 let result = compress_prose(source, Some("authentication bearer tokens"));
477 assert!(result.compressed.contains("validates bearer tokens"));
478 }
479
480 #[test]
481 #[ignore = "prose compressor edge case — revisit"]
482 fn comments_rules_and_long_alt_text_are_removed_outside_code() {
483 let source = "# Heading\n\n<!-- ignored -->\n\n---\n\n\n\n```md\n<!-- keep -->\n```";
484 let result = compress_prose(source, None);
485 assert!(!result.compressed.contains("ignored"));
486 assert!(result.compressed.contains(""));
487 assert!(result.compressed.contains("<!-- keep -->"));
488 }
489}