1use std::collections::HashSet;
2use std::hash::{Hash, Hasher};
3
4const ABBREVIATIONS: &[&str] = &[
5 "e.g.", "i.e.", "etc.", "vs.", "cf.", "approx.", "Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.",
6 "Jr.", "Fig.", "Eq.", "Vol.", "No.",
7];
8
9#[derive(Debug, Clone)]
11#[non_exhaustive]
12pub struct ExtractionConfig {
13 pub context_window: usize,
15 pub dedup_enabled: bool,
17 pub max_passage_sentences: usize,
30}
31
32impl Default for ExtractionConfig {
33 fn default() -> Self {
34 Self {
35 context_window: 1,
36 dedup_enabled: true,
37 max_passage_sentences: 6,
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct ExtractionEntry {
45 pub entry_id: String,
47 pub content: String,
49}
50
51#[derive(Debug, Clone)]
53#[non_exhaustive]
54pub struct ExtractedPassage {
55 pub text: String,
57 pub source_entry_id: String,
59 pub triggering_terms: Vec<String>,
65 pub content_hash: String,
69}
70
71#[derive(Debug, Clone)]
72struct WindowRange {
73 start: usize,
74 end: usize,
75 terms: Vec<String>,
76}
77
78#[must_use]
80#[allow(
81 clippy::implicit_hasher,
82 reason = "HashSet with default hasher is acceptable for deterministic local dedup"
83)]
84pub fn extract_passages(
85 entries: &[ExtractionEntry],
86 high_recurrence_terms: &[String],
87 config: &ExtractionConfig,
88) -> Vec<ExtractedPassage> {
89 if entries.is_empty() || high_recurrence_terms.is_empty() {
90 return Vec::new();
91 }
92
93 let normalized_terms: Vec<(String, String)> = high_recurrence_terms
94 .iter()
95 .cloned()
96 .map(|term| {
97 let lower = term.to_lowercase();
98 (term, lower)
99 })
100 .collect();
101
102 let mut passages: Vec<ExtractedPassage> = Vec::new();
103
104 for entry in entries {
105 let sentences = split_into_sentences(&entry.content);
106 if sentences.is_empty() {
107 continue;
108 }
109
110 let merged_ranges =
111 collect_merged_ranges(&sentences, &normalized_terms, config.context_window);
112 let ranges = split_ranges_by_max_passage_sentences(
113 &sentences,
114 &normalized_terms,
115 merged_ranges,
116 config.max_passage_sentences,
117 );
118 for range in ranges {
119 let passage_text = sentences[range.start..=range.end].join("\n");
120 let trimmed_text = passage_text.trim().to_string();
121 if trimmed_text.is_empty() {
122 continue;
123 }
124
125 let content_hash = hash_text(&trimmed_text);
126 passages.push(ExtractedPassage {
127 text: trimmed_text,
128 source_entry_id: entry.entry_id.clone(),
129 triggering_terms: range.terms,
130 content_hash,
131 });
132 }
133 }
134
135 if config.dedup_enabled {
136 let mut seen_hashes: HashSet<String> = HashSet::new();
137 passages.retain(|passage| seen_hashes.insert(passage.content_hash.clone()));
138 }
139
140 passages
141}
142
143fn split_into_sentences(content: &str) -> Vec<String> {
144 let mut sentences = Vec::new();
145
146 for line in content.split('\n') {
147 let trimmed_line = line.trim();
148 if trimmed_line.is_empty() {
149 continue;
150 }
151
152 sentences.extend(split_line_into_sentences(trimmed_line));
153 }
154
155 sentences
156}
157
158fn split_line_into_sentences(line: &str) -> Vec<String> {
159 let mut sentences = Vec::new();
160 let chars: Vec<(usize, char)> = line.char_indices().collect();
161 let mut start = 0_usize;
162
163 for (index, (byte_index, ch)) in chars.iter().copied().enumerate() {
164 if !matches!(ch, '.' | '!' | '?') {
165 continue;
166 }
167
168 let prev_is_dot = index > 0 && chars[index - 1].1 == '.';
169 if ch == '.' && prev_is_dot {
170 continue;
171 }
172
173 let punctuation_end = byte_index + ch.len_utf8();
174 let is_end_of_line = index + 1 == chars.len();
175
176 if ch == '.' && ends_with_known_abbreviation(&line[..punctuation_end]) {
177 continue;
178 }
179
180 let should_split = is_end_of_line || {
181 let mut lookahead = index + 1;
182 let mut saw_whitespace = false;
183
184 while lookahead < chars.len() && chars[lookahead].1.is_whitespace() {
185 saw_whitespace = true;
186 lookahead += 1;
187 }
188
189 saw_whitespace && lookahead < chars.len() && chars[lookahead].1.is_uppercase()
190 };
191
192 if should_split {
193 let sentence = line[start..punctuation_end].trim();
194 if !sentence.is_empty() {
195 sentences.push(sentence.to_string());
196 }
197 start = punctuation_end;
198 }
199 }
200
201 if start < line.len() {
202 let tail = line[start..].trim();
203 if !tail.is_empty() {
204 sentences.push(tail.to_string());
205 }
206 }
207
208 sentences
209}
210
211fn ends_with_known_abbreviation(text: &str) -> bool {
212 let text_lower = text.to_lowercase();
213 ABBREVIATIONS
214 .iter()
215 .any(|abbreviation| text_lower.ends_with(&abbreviation.to_lowercase()))
216}
217
218fn collect_merged_ranges(
228 sentences: &[String],
229 normalized_terms: &[(String, String)],
230 context_window: usize,
231) -> Vec<WindowRange> {
232 let mut raw_ranges: Vec<WindowRange> = Vec::new();
233
234 for (sentence_index, sentence) in sentences.iter().enumerate() {
235 let sentence_lower = sentence.to_lowercase();
236 let mut matched_terms: Vec<String> = normalized_terms
237 .iter()
238 .filter_map(|(original, lower)| {
239 if sentence_lower.contains(lower) {
240 Some(original.clone())
241 } else {
242 None
243 }
244 })
245 .collect();
246
247 if matched_terms.is_empty() {
248 continue;
249 }
250
251 matched_terms.sort();
252 matched_terms.dedup();
253
254 let start = sentence_index.saturating_sub(context_window);
255 let end = usize::min(
256 sentences.len().saturating_sub(1),
257 sentence_index + context_window,
258 );
259 raw_ranges.push(WindowRange {
260 start,
261 end,
262 terms: matched_terms,
263 });
264 }
265
266 if raw_ranges.is_empty() {
267 return raw_ranges;
268 }
269
270 raw_ranges.sort_by(|left, right| {
271 left.start
272 .cmp(&right.start)
273 .then_with(|| left.end.cmp(&right.end))
274 });
275
276 let mut merged: Vec<WindowRange> = Vec::new();
277 for range in raw_ranges {
278 if let Some(last) = merged.last_mut() {
279 if range.start <= last.end + 1 {
280 last.end = usize::max(last.end, range.end);
281 for term in range.terms {
282 if !last.terms.contains(&term) {
283 last.terms.push(term);
284 }
285 }
286 last.terms.sort();
287 continue;
288 }
289 }
290
291 merged.push(range);
292 }
293
294 merged
295}
296
297fn split_ranges_by_max_passage_sentences(
298 sentences: &[String],
299 normalized_terms: &[(String, String)],
300 ranges: Vec<WindowRange>,
301 max_passage_sentences: usize,
302) -> Vec<WindowRange> {
303 if ranges.is_empty() {
304 return ranges;
305 }
306
307 let chunk_size = max_passage_sentences.max(1);
308
309 let mut split_ranges: Vec<WindowRange> = Vec::new();
310
311 for range in ranges {
312 let span_len = range.end.saturating_sub(range.start) + 1;
313 if span_len <= chunk_size {
314 split_ranges.push(range);
315 continue;
316 }
317
318 let mut chunk_start = range.start;
319 while chunk_start <= range.end {
320 let chunk_end = usize::min(
321 range.end,
322 chunk_start.saturating_add(chunk_size).saturating_sub(1),
323 );
324
325 let terms = collect_terms_in_range(sentences, normalized_terms, chunk_start, chunk_end);
326 if !terms.is_empty() {
327 split_ranges.push(WindowRange {
328 start: chunk_start,
329 end: chunk_end,
330 terms,
331 });
332 }
333
334 chunk_start = chunk_end.saturating_add(1);
335 }
336 }
337
338 split_ranges
339}
340
341fn collect_terms_in_range(
342 sentences: &[String],
343 normalized_terms: &[(String, String)],
344 start: usize,
345 end: usize,
346) -> Vec<String> {
347 debug_assert!(
348 start < sentences.len(),
349 "chunk_start out of bounds: {start} >= {}",
350 sentences.len()
351 );
352 debug_assert!(
353 end < sentences.len(),
354 "chunk_end out of bounds: {end} >= {}",
355 sentences.len()
356 );
357 debug_assert!(
358 start <= end,
359 "invalid chunk range: start ({start}) > end ({end})"
360 );
361
362 let lower_sentences: Vec<String> = sentences[start..=end]
364 .iter()
365 .map(|sentence| sentence.to_lowercase())
366 .collect();
367
368 let mut matched_terms: Vec<String> = normalized_terms
369 .iter()
370 .filter_map(|(original, lower)| {
371 let has_match = lower_sentences
372 .iter()
373 .any(|sentence| sentence.contains(lower));
374 if has_match {
375 Some(original.clone())
376 } else {
377 None
378 }
379 })
380 .collect();
381
382 matched_terms.sort();
383 matched_terms.dedup();
384 matched_terms
385}
386
387fn hash_text(text: &str) -> String {
388 let mut hasher = std::collections::hash_map::DefaultHasher::new();
389 text.hash(&mut hasher);
390 format!("{:016x}", hasher.finish())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::{extract_passages, split_into_sentences, ExtractionConfig, ExtractionEntry};
396
397 fn single_entry(content: &str) -> Vec<ExtractionEntry> {
398 vec![ExtractionEntry {
399 entry_id: "entry-1".to_string(),
400 content: content.to_string(),
401 }]
402 }
403
404 fn default_terms(term: &str) -> Vec<String> {
405 vec![term.to_string()]
406 }
407
408 #[test]
409 fn extract_empty_content() {
410 let entries = single_entry("");
411 let config = ExtractionConfig::default();
412
413 let passages = extract_passages(&entries, &default_terms("context"), &config);
414 assert!(passages.is_empty());
415 }
416
417 #[test]
418 fn extract_no_matching_terms() {
419 let entries = single_entry("Alpha sentence. Beta sentence.");
420 let config = ExtractionConfig::default();
421
422 let passages = extract_passages(&entries, &default_terms("context"), &config);
423 assert!(passages.is_empty());
424 }
425
426 #[test]
427 fn extract_single_sentence_entry() {
428 let entries = single_entry("Context appears here.");
429 let config = ExtractionConfig::default();
430
431 let passages = extract_passages(&entries, &default_terms("context"), &config);
432 assert_eq!(passages.len(), 1);
433 assert_eq!(passages[0].text, "Context appears here.");
434 }
435
436 #[test]
437 fn extract_term_at_start() {
438 let entries = single_entry("Context starts here. Second sentence. Third sentence.");
439 let config = ExtractionConfig::default();
440
441 let passages = extract_passages(&entries, &default_terms("context"), &config);
442 assert_eq!(passages.len(), 1);
443 assert_eq!(passages[0].text, "Context starts here.\nSecond sentence.");
444 }
445
446 #[test]
447 fn extract_term_at_end() {
448 let entries = single_entry("First sentence. Context ends here.");
449 let config = ExtractionConfig::default();
450
451 let passages = extract_passages(&entries, &default_terms("context"), &config);
452 assert_eq!(passages.len(), 1);
453 assert_eq!(passages[0].text, "First sentence.\nContext ends here.");
454 }
455
456 #[test]
457 fn extract_window_zero() {
458 let entries = single_entry("First sentence. Context middle. Last sentence.");
459 let config = ExtractionConfig {
460 context_window: 0,
461 dedup_enabled: true,
462 max_passage_sentences: 6,
463 };
464
465 let passages = extract_passages(&entries, &default_terms("context"), &config);
466 assert_eq!(passages.len(), 1);
467 assert_eq!(passages[0].text, "Context middle.");
468 }
469
470 #[test]
471 fn extract_overlapping_windows_merge() {
472 let entries = single_entry("One. Alpha context. Three. Beta context. Five. Six.");
473 let terms = vec!["alpha".to_string(), "beta".to_string()];
474 let config = ExtractionConfig::default();
475
476 let passages = extract_passages(&entries, &terms, &config);
477 assert_eq!(passages.len(), 1);
478 assert_eq!(
479 passages[0].text,
480 "One.\nAlpha context.\nThree.\nBeta context.\nFive."
481 );
482 }
483
484 #[test]
485 fn extract_dedup_across_entries() {
486 let entries = vec![
487 ExtractionEntry {
488 entry_id: "entry-1".to_string(),
489 content: "Context is stable.".to_string(),
490 },
491 ExtractionEntry {
492 entry_id: "entry-2".to_string(),
493 content: "Context is stable.".to_string(),
494 },
495 ];
496 let config = ExtractionConfig::default();
497
498 let passages = extract_passages(&entries, &default_terms("context"), &config);
499 assert_eq!(passages.len(), 1);
500 assert_eq!(passages[0].source_entry_id, "entry-1");
501 }
502
503 #[test]
504 fn extract_multiple_terms_one_sentence() {
505 let entries = single_entry("Context forge appears in one sentence.");
506 let terms = vec!["context".to_string(), "forge".to_string()];
507 let config = ExtractionConfig {
508 context_window: 0,
509 dedup_enabled: true,
510 max_passage_sentences: 6,
511 };
512
513 let passages = extract_passages(&entries, &terms, &config);
514 assert_eq!(passages.len(), 1);
515 assert_eq!(
516 passages[0].triggering_terms,
517 vec!["context".to_string(), "forge".to_string()]
518 );
519 }
520
521 #[test]
522 fn extract_newline_separated_messages() {
523 let sentences = split_into_sentences("line one\nline two\nline three");
524 assert_eq!(sentences.len(), 3);
525 }
526
527 #[test]
528 fn extract_punctuation_within_line() {
529 let sentences = split_into_sentences("First sentence. Second sentence. Third.");
530 assert_eq!(sentences.len(), 3);
531 }
532
533 #[test]
534 fn split_exclamation_mark_terminates_sentence() {
535 let sentences = split_into_sentences("It works! Context is next.");
536 assert_eq!(sentences.len(), 2);
537 assert_eq!(sentences[0], "It works!");
538 assert_eq!(sentences[1], "Context is next.");
539 }
540
541 #[test]
542 fn split_question_mark_terminates_sentence() {
543 let sentences = split_into_sentences("Is this correct? Context says yes.");
544 assert_eq!(sentences.len(), 2);
545 assert_eq!(sentences[0], "Is this correct?");
546 assert_eq!(sentences[1], "Context says yes.");
547 }
548
549 #[test]
550 fn split_abbreviation_not_split() {
551 let sentences = split_into_sentences("Use e.g. This method works.");
552 assert_eq!(sentences.len(), 1);
553 }
554
555 #[test]
556 fn split_title_abbreviation_not_split() {
557 let sentences = split_into_sentences("See Dr. Smith for details.");
558 assert_eq!(sentences.len(), 1);
559 }
560
561 #[test]
562 fn split_ellipsis_not_split() {
563 let sentences = split_into_sentences("Well... Maybe next time.");
564 assert_eq!(sentences.len(), 1);
565 }
566
567 #[test]
568 fn extract_mixed_boundaries() {
569 let sentences = split_into_sentences("Line with two sentences. Second here.\nNew line.");
570 assert_eq!(sentences.len(), 3);
571 }
572
573 #[test]
574 fn extract_empty_lines_are_boundaries() {
575 let sentences = split_into_sentences("Sentence one.\n\nSentence two.");
576 assert_eq!(sentences.len(), 2);
577 }
578
579 #[test]
580 fn extract_ngram_term_matching() {
581 let entries = single_entry("We are validating context forge behavior.");
582 let terms = vec!["context forge".to_string()];
583 let config = ExtractionConfig {
584 context_window: 0,
585 dedup_enabled: true,
586 max_passage_sentences: 6,
587 };
588
589 let passages = extract_passages(&entries, &terms, &config);
590 assert_eq!(passages.len(), 1);
591 }
592
593 #[test]
594 fn extract_substring_term_matches_derived_words() {
595 let entries = single_entry("The code was context forged overnight.");
596 let terms = vec!["context forge".to_string()];
597 let config = ExtractionConfig {
598 context_window: 0,
599 dedup_enabled: true,
600 max_passage_sentences: 6,
601 };
602
603 let passages = extract_passages(&entries, &terms, &config);
604 assert_eq!(
605 passages.len(),
606 1,
607 "substring match should find derived words"
608 );
609 }
610
611 #[test]
612 fn extract_adjacent_windows_merge() {
613 let entries = vec![ExtractionEntry {
614 entry_id: "entry-1".to_string(),
615 content: "Alpha context. Two. Three. Beta context. Five.".to_string(),
616 }];
617 let terms = vec!["alpha".to_string(), "beta".to_string()];
618 let config = ExtractionConfig::default();
619
620 let passages = extract_passages(&entries, &terms, &config);
621 assert_eq!(passages.len(), 1, "adjacent windows should merge");
622 assert!(passages[0].text.contains("Two."));
623 assert!(passages[0].text.contains("Three."));
624 }
625
626 #[test]
627 fn extract_case_insensitive_matching() {
628 let entries = single_entry("The Context is important.");
629 let config = ExtractionConfig {
630 context_window: 0,
631 dedup_enabled: true,
632 max_passage_sentences: 6,
633 };
634
635 let passages = extract_passages(&entries, &default_terms("context"), &config);
636 assert_eq!(passages.len(), 1);
637 }
638
639 #[test]
640 fn extract_dedup_disabled() {
641 let entries = vec![
642 ExtractionEntry {
643 entry_id: "entry-1".to_string(),
644 content: "Context is stable.".to_string(),
645 },
646 ExtractionEntry {
647 entry_id: "entry-2".to_string(),
648 content: "Context is stable.".to_string(),
649 },
650 ];
651 let config = ExtractionConfig {
652 context_window: 1,
653 dedup_enabled: false,
654 max_passage_sentences: 6,
655 };
656
657 let passages = extract_passages(&entries, &default_terms("context"), &config);
658 assert_eq!(passages.len(), 2);
659 }
660
661 #[test]
662 fn extract_version_numbers_not_split() {
663 let sentences = split_into_sentences("Using v0.3.1 in production.");
664 assert_eq!(sentences.len(), 1);
665 assert_eq!(sentences[0], "Using v0.3.1 in production.");
666 }
667
668 #[test]
669 fn extract_file_extensions_not_split() {
670 let sentences = split_into_sentences("Edit file.rs for changes.");
671 assert_eq!(sentences.len(), 1);
672 assert_eq!(sentences[0], "Edit file.rs for changes.");
673 }
674
675 #[test]
676 fn extract_over_cap_splits_into_multiple_passages() {
677 let entries = single_entry(
678 "Context one. Context two. Context three. Context four. Context five. Context six. Context seven. Context eight. Context nine. Context ten.",
679 );
680 let config = ExtractionConfig {
681 context_window: 0,
682 dedup_enabled: true,
683 max_passage_sentences: 6,
684 };
685
686 let passages = extract_passages(&entries, &default_terms("context"), &config);
687 assert_eq!(passages.len(), 2);
688 assert_eq!(passages[0].text.lines().count(), 6);
689 assert_eq!(passages[1].text.lines().count(), 4);
690 }
691
692 #[test]
693 fn extract_cap_zero_emits_single_sentence_passages() {
694 let entries = single_entry("Context one. Context two. Context three.");
695 let config = ExtractionConfig {
696 context_window: 1,
697 dedup_enabled: true,
698 max_passage_sentences: 0,
699 };
700
701 let passages = extract_passages(&entries, &default_terms("context"), &config);
702 assert_eq!(passages.len(), 3);
703 assert_eq!(passages[0].text, "Context one.");
704 assert_eq!(passages[1].text, "Context two.");
705 assert_eq!(passages[2].text, "Context three.");
706 }
707
708 #[test]
709 fn extract_split_re_evaluates_triggering_terms_per_chunk() {
710 let entries = single_entry(
711 "One. Alpha signal. Three. Four. Five. Six. Seven. Eight. Nine. Beta signal. Eleven. Twelve.",
712 );
713 let terms = vec!["alpha".to_string(), "beta".to_string()];
714 let config = ExtractionConfig {
715 context_window: 5,
716 dedup_enabled: true,
717 max_passage_sentences: 6,
718 };
719
720 let passages = extract_passages(&entries, &terms, &config);
721 assert_eq!(passages.len(), 2);
722 assert_eq!(passages[0].triggering_terms, vec!["alpha".to_string()]);
723 assert_eq!(passages[1].triggering_terms, vec!["beta".to_string()]);
724 }
725
726 #[test]
727 fn extract_split_discards_chunks_without_matching_terms() {
728 let entries = single_entry(
729 "Alpha signal. Two. Three. Four. Five. Six. Seven. Eight. Nine. Ten. Eleven. Twelve. Thirteen. Fourteen. Fifteen. Sixteen. Seventeen. Beta signal.",
730 );
731 let terms = vec!["alpha".to_string(), "beta".to_string()];
732 let config = ExtractionConfig {
733 context_window: 8,
734 dedup_enabled: true,
735 max_passage_sentences: 6,
736 };
737
738 let passages = extract_passages(&entries, &terms, &config);
739 assert_eq!(passages.len(), 2);
740 assert_eq!(passages[0].triggering_terms, vec!["alpha".to_string()]);
741 assert_eq!(passages[1].triggering_terms, vec!["beta".to_string()]);
742 }
743}