1use std::collections::{HashMap, HashSet};
15
16const MIN_SENTENCE_CHARS: usize = 24;
17const MAX_SENTENCE_CHARS: usize = 400;
18
19const STOPWORDS: &[&str] = &[
20 "the", "and", "for", "are", "but", "not", "you", "all", "any", "can", "had", "her", "was",
21 "one", "our", "out", "day", "get", "has", "him", "his", "how", "man", "new", "now", "old",
22 "see", "two", "way", "who", "did", "its", "let", "put", "say", "she", "too", "use", "that",
23 "this", "with", "from", "they", "have", "were", "will", "your", "what", "when", "your", "than",
24 "then", "them", "into", "more", "some", "such", "only", "also", "been", "very", "just", "over",
25];
26
27const FILLER: &[&str] = &[
28 "um",
29 "uh",
30 "erm",
31 "hmm",
32 "like",
33 "basically",
34 "actually",
35 "literally",
36 "honestly",
37 "okay",
38 "ok",
39 "yeah",
40 "right",
41 "so",
42 "well",
43 "anyway",
44 "anyways",
45];
46
47pub fn facts_scored(text: &str, query: Option<&str>, max_items: usize) -> Vec<(String, f32)> {
53 select_top_scored(facts_ranked(text, query), max_items)
54 .into_iter()
55 .map(|(text, raw)| (text, factual_confidence(raw)))
56 .collect()
57}
58
59fn factual_confidence(raw: f32) -> f32 {
61 (0.55 + 0.09 * raw).clamp(0.5, 0.97)
62}
63
64fn facts_ranked(text: &str, query: Option<&str>) -> Vec<(f64, usize, String)> {
65 let qterms = query_terms(query);
66 let mut scored = Vec::new();
67 for (idx, sentence) in split_sentences(text).into_iter().enumerate() {
68 let len = sentence.chars().count();
69 if !(MIN_SENTENCE_CHARS..=MAX_SENTENCE_CHARS).contains(&len) {
70 continue;
71 }
72 let base = factual_score(&sentence);
73 if base <= 0.0 {
74 continue;
75 }
76 let score = base + query_boost(&sentence, &qterms);
77 scored.push((score, idx, sentence));
78 }
79 scored
80}
81
82pub fn quotes_scored(text: &str, query: Option<&str>, max_items: usize) -> Vec<(String, f32)> {
85 normalize_conf(select_top_scored(quotes_ranked(text, query), max_items))
86}
87
88fn quotes_ranked(text: &str, query: Option<&str>) -> Vec<(f64, usize, String)> {
89 let sentences = split_sentences(text);
90 let freq = term_frequencies(&sentences);
91 let qterms = query_terms(query);
92
93 let mut scored = Vec::new();
94 for (idx, sentence) in sentences.into_iter().enumerate() {
95 let len = sentence.chars().count();
96 if !(MIN_SENTENCE_CHARS..=MAX_SENTENCE_CHARS).contains(&len) {
97 continue;
98 }
99 let centrality = centrality_score(&sentence, &freq);
100 let score = centrality + query_boost(&sentence, &qterms) * 3.0;
101 if score <= 0.0 {
102 continue;
103 }
104 scored.push((score, idx, sentence));
105 }
106 scored
107}
108
109pub fn summarize_prose(text: &str, max_chars: usize, query: Option<&str>) -> String {
119 if text.len() <= max_chars {
120 return transcript_summary(text, max_chars);
121 }
122 let mode = if query.is_some() {
123 crate::core::extractive::RankMode::Query
124 } else {
125 crate::core::extractive::RankMode::Centrality
126 };
127 if let Some(ranked) = crate::core::extractive::rank_and_squeeze(text, max_chars, mode, query) {
128 return ranked;
129 }
130 transcript_summary(text, max_chars)
131}
132
133pub fn transcript_summary(text: &str, max_chars: usize) -> String {
135 let mut kept: Vec<String> = Vec::new();
136 let mut total = 0usize;
137
138 for sentence in split_sentences(text) {
139 let cleaned = strip_filler(&sentence);
140 let cleaned = cleaned.trim();
141 if cleaned.chars().count() < 8 {
142 continue;
143 }
144 if let Some(last) = kept.last()
145 && jaccard(last, cleaned) > 0.8
146 {
147 continue;
148 }
149 if total + cleaned.len() > max_chars && !kept.is_empty() {
150 break;
151 }
152 total += cleaned.len();
153 kept.push(cleaned.to_string());
154 }
155 kept.join(" ")
156}
157
158pub fn squeeze_prose(text: &str, max_chars: usize) -> String {
171 const RECENT: usize = 12;
172 let mut out: Vec<String> = Vec::new();
173 let mut recent: Vec<String> = Vec::new();
174 let mut total = 0usize;
175 let mut blank_run = 0u32;
176
177 for raw in text.lines() {
178 let line = raw.trim_end();
179 if line.trim().is_empty() {
180 blank_run += 1;
181 if blank_run == 1 && !out.is_empty() {
182 out.push(String::new());
183 }
184 continue;
185 }
186 blank_run = 0;
187
188 let normalized = line.trim();
189 if !is_protected_line(line) && recent.iter().any(|p| jaccard(p, normalized) > 0.9) {
190 continue;
191 }
192
193 if total + line.len() > max_chars && !out.is_empty() {
194 out.push("…[truncated]".to_string());
195 break;
196 }
197 total += line.len();
198 out.push(line.to_string());
199
200 recent.push(normalized.to_string());
201 if recent.len() > RECENT {
202 recent.remove(0);
203 }
204 }
205
206 while out.last().is_some_and(String::is_empty) {
207 out.pop();
208 }
209 out.join("\n")
210}
211
212pub(crate) fn is_protected_line(line: &str) -> bool {
215 let t = line.trim_start();
216 t.starts_with("Source:")
217 || t.starts_with("Site:")
218 || t.starts_with("http://")
219 || t.starts_with("https://")
220 || t.starts_with("- [")
221 || t.starts_with("> ")
222 || t.starts_with('#')
223 || t.starts_with("---")
224}
225
226pub fn split_sentences(text: &str) -> Vec<String> {
230 let mut sentences = Vec::new();
231 for line in text.lines() {
232 let line = line.trim();
233 if line.is_empty() {
234 continue;
235 }
236 let mut current = String::new();
237 let mut chars = line.chars().peekable();
238 while let Some(c) = chars.next() {
239 current.push(c);
240 if matches!(c, '.' | '!' | '?') {
241 let boundary = chars.peek().is_none_or(|n| n.is_whitespace());
242 if boundary {
243 push_trimmed(&mut sentences, ¤t);
244 current.clear();
245 }
246 }
247 }
248 push_trimmed(&mut sentences, ¤t);
249 }
250 sentences
251}
252
253fn push_trimmed(acc: &mut Vec<String>, s: &str) {
254 let trimmed = s.trim();
255 if !trimmed.is_empty() {
256 acc.push(trimmed.to_string());
257 }
258}
259
260fn factual_score(sentence: &str) -> f64 {
263 let lower = sentence.to_lowercase();
264 let mut score = 0.0;
265
266 if sentence.chars().any(|c| c.is_ascii_digit()) {
267 score += 1.0;
268 }
269 if sentence.contains('%') || sentence.contains('$') || sentence.contains('€') {
270 score += 1.0;
271 }
272 if has_year(sentence) {
273 score += 1.0;
274 }
275 if has_magnitude_word(&lower) {
276 score += 1.0;
277 }
278 if proper_noun_runs(sentence) >= 1 {
279 score += 0.5;
280 }
281 score
282}
283
284fn has_year(sentence: &str) -> bool {
285 let bytes = sentence.as_bytes();
286 let mut run = 0;
287 for &b in bytes {
288 if b.is_ascii_digit() {
289 run += 1;
290 if run == 4 {
291 return true;
292 }
293 } else {
294 run = 0;
295 }
296 }
297 false
298}
299
300fn has_magnitude_word(lower: &str) -> bool {
301 const WORDS: &[&str] = &[
302 "percent",
303 "million",
304 "billion",
305 "trillion",
306 "thousand",
307 "kg",
308 "km",
309 "mph",
310 "gb",
311 "mb",
312 "tb",
313 "ghz",
314 "kwh",
315 "celsius",
316 "fahrenheit",
317 "dollars",
318 "euros",
319 ];
320 WORDS.iter().any(|w| contains_word(lower, w))
321}
322
323fn proper_noun_runs(sentence: &str) -> usize {
324 let mut runs = 0;
325 let mut consecutive = 0;
326 for (i, word) in sentence.split_whitespace().enumerate() {
327 let is_cap = word.chars().next().is_some_and(char::is_uppercase);
328 if is_cap && i > 0 {
330 consecutive += 1;
331 if consecutive == 2 {
332 runs += 1;
333 }
334 } else {
335 consecutive = 0;
336 }
337 }
338 runs
339}
340
341fn term_frequencies(sentences: &[String]) -> HashMap<String, usize> {
342 let mut freq = HashMap::new();
343 for sentence in sentences {
344 for word in content_words(sentence) {
345 *freq.entry(word).or_insert(0) += 1;
346 }
347 }
348 freq
349}
350
351fn centrality_score(sentence: &str, freq: &HashMap<String, usize>) -> f64 {
352 let words = content_words(sentence);
353 if words.is_empty() {
354 return 0.0;
355 }
356 let sum: usize = words.iter().filter_map(|w| freq.get(w)).sum();
357 sum as f64 / (words.len() as f64).sqrt()
358}
359
360fn query_terms(query: Option<&str>) -> HashSet<String> {
361 query
362 .map(|q| {
363 q.split(|c: char| !c.is_alphanumeric())
364 .filter(|w| w.len() >= 3)
365 .map(str::to_lowercase)
366 .collect()
367 })
368 .unwrap_or_default()
369}
370
371fn query_boost(sentence: &str, qterms: &HashSet<String>) -> f64 {
372 if qterms.is_empty() {
373 return 0.0;
374 }
375 let lower = sentence.to_lowercase();
376 qterms.iter().filter(|t| contains_word(&lower, t)).count() as f64
377}
378
379fn select_top_scored(
380 mut scored: Vec<(f64, usize, String)>,
381 max_items: usize,
382) -> Vec<(String, f32)> {
383 scored.sort_by(|a, b| {
384 b.0.partial_cmp(&a.0)
385 .unwrap_or(std::cmp::Ordering::Equal)
386 .then(a.1.cmp(&b.1))
387 });
388
389 let mut seen = HashSet::new();
390 let mut chosen: Vec<(usize, String, f64)> = Vec::new();
391 for (score, idx, sentence) in scored {
392 if seen.insert(norm_key(&sentence)) {
393 chosen.push((idx, sentence, score));
394 if chosen.len() >= max_items {
395 break;
396 }
397 }
398 }
399 chosen.sort_by_key(|(idx, _, _)| *idx);
400 chosen
401 .into_iter()
402 .map(|(_, s, sc)| (s, sc as f32))
403 .collect()
404}
405
406fn normalize_conf(items: Vec<(String, f32)>) -> Vec<(String, f32)> {
409 if items.is_empty() {
410 return items;
411 }
412 let max = items.iter().map(|(_, s)| *s).fold(f32::MIN, f32::max);
413 let min = items.iter().map(|(_, s)| *s).fold(f32::MAX, f32::min);
414 let span = max - min;
415 if span < f32::EPSILON {
416 return items.into_iter().map(|(t, _)| (t, 0.8)).collect();
417 }
418 items
419 .into_iter()
420 .map(|(t, s)| (t, 0.45 + 0.5 * (s - min) / span))
421 .collect()
422}
423
424fn content_words(sentence: &str) -> Vec<String> {
427 sentence
428 .split(|c: char| !c.is_alphanumeric())
429 .filter(|w| w.len() >= 3)
430 .map(str::to_lowercase)
431 .filter(|w| !STOPWORDS.contains(&w.as_str()))
432 .collect()
433}
434
435fn word_set(s: &str) -> HashSet<String> {
436 s.split(|c: char| !c.is_alphanumeric())
437 .filter(|w| !w.is_empty())
438 .map(str::to_lowercase)
439 .collect()
440}
441
442fn jaccard(a: &str, b: &str) -> f64 {
443 let sa = word_set(a);
444 let sb = word_set(b);
445 if sa.is_empty() && sb.is_empty() {
446 return 1.0;
447 }
448 let inter = sa.intersection(&sb).count() as f64;
449 let union = sa.union(&sb).count() as f64;
450 if union == 0.0 { 0.0 } else { inter / union }
451}
452
453fn strip_filler(sentence: &str) -> String {
454 sentence
455 .split_whitespace()
456 .filter(|tok| {
457 let core: String = tok
458 .chars()
459 .filter(|c| c.is_alphanumeric())
460 .collect::<String>()
461 .to_lowercase();
462 !core.is_empty() && !FILLER.contains(&core.as_str())
463 })
464 .collect::<Vec<_>>()
465 .join(" ")
466}
467
468fn contains_word(haystack: &str, word: &str) -> bool {
469 let mut start = 0;
470 while let Some(pos) = haystack[start..].find(word) {
471 let idx = start + pos;
472 let before = idx
473 .checked_sub(1)
474 .is_none_or(|i| !haystack.as_bytes()[i].is_ascii_alphanumeric());
475 let after_idx = idx + word.len();
476 let after = haystack
477 .as_bytes()
478 .get(after_idx)
479 .is_none_or(|b| !b.is_ascii_alphanumeric());
480 if before && after {
481 return true;
482 }
483 start = idx + word.len();
484 }
485 false
486}
487
488fn norm_key(s: &str) -> String {
489 s.chars()
490 .filter(|c| c.is_alphanumeric())
491 .collect::<String>()
492 .to_lowercase()
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 fn names(scored: Vec<(String, f32)>) -> Vec<String> {
501 scored.into_iter().map(|(s, _)| s).collect()
502 }
503
504 #[test]
505 fn splits_sentences_across_lines() {
506 let text = "First sentence here. Second one follows!\nThird line stands alone?";
507 let s = split_sentences(text);
508 assert_eq!(s.len(), 3);
509 assert_eq!(s[0], "First sentence here.");
510 assert_eq!(s[2], "Third line stands alone?");
511 }
512
513 #[test]
514 fn facts_keep_numeric_and_drop_fluff() {
515 let text = "Revenue grew to 12 million dollars in 2023. \
516 I really enjoyed the lovely afternoon weather today.";
517 let f = names(facts_scored(text, None, 5));
518 assert_eq!(f.len(), 1);
519 assert!(f[0].contains("12 million"));
520 }
521
522 #[test]
523 fn facts_respect_query_boost_and_limit() {
524 let text = "The rocket reached 400 km altitude. \
525 The budget was 5 billion euros overall. \
526 Apollo Eleven landed in 1969 successfully.";
527 let f = names(facts_scored(text, Some("budget"), 1));
528 assert_eq!(f.len(), 1);
529 assert!(f[0].contains("budget"));
530 }
531
532 #[test]
533 fn quotes_prefer_query_relevant_sentences() {
534 let text = "Climate policy shapes future energy markets across regions. \
535 The cat sat quietly on the warm windowsill all day. \
536 Energy markets respond to climate policy and carbon pricing.";
537 let q = names(quotes_scored(text, Some("climate energy"), 2));
538 assert_eq!(q.len(), 2);
539 assert!(
540 q.iter().all(
541 |s| s.to_lowercase().contains("energy") || s.to_lowercase().contains("climate")
542 )
543 );
544 }
545
546 #[test]
547 fn transcript_summary_strips_filler_and_dupes() {
548 let text = "Um so basically the model is really fast. \
549 Um so basically the model is really fast. \
550 Actually it scales to millions of requests.";
551 let summary = transcript_summary(text, 500);
552 assert!(!summary.to_lowercase().contains("basically"));
553 assert_eq!(summary.matches("the model is really fast").count(), 1);
555 assert!(summary.contains("scales to millions"));
556 }
557
558 #[test]
559 fn transcript_summary_respects_budget() {
560 let text = "Alpha statement number one here. Beta statement number two here. \
561 Gamma statement number three here.";
562 let summary = transcript_summary(text, 30);
563 assert!(summary.len() <= 60, "got {} chars", summary.len());
564 assert!(summary.contains("Alpha"));
565 }
566
567 #[test]
568 fn summarize_prose_below_budget_matches_transcript_summary() {
569 let text = "Um so basically the cache is fast. Actually it also persists.";
572 assert_eq!(
573 summarize_prose(text, 10_000, Some("cache")),
574 transcript_summary(text, 10_000)
575 );
576 }
577
578 #[test]
579 fn summarize_prose_is_deterministic_and_bounded_when_oversized() {
580 let text = "Sentence about alpha topic here. ".repeat(40);
584 let a = summarize_prose(&text, 120, Some("alpha"));
585 let b = summarize_prose(&text, 120, Some("alpha"));
586 assert_eq!(a, b);
587 assert!(!a.is_empty() && a.len() < text.len());
588 }
589
590 #[test]
591 fn squeeze_prose_dedupes_and_collapses_blanks() {
592 let text = "Rust is a systems programming language focused on safety.\n\n\n\
593 Rust is a systems programming language focused on safety.\n\
594 It guarantees memory safety without a garbage collector.";
595 let out = squeeze_prose(text, 10_000);
596 assert_eq!(out.matches("focused on safety").count(), 1);
598 assert!(!out.contains("\n\n\n"));
600 assert!(out.contains("memory safety"));
601 }
602
603 #[test]
604 fn squeeze_prose_keeps_protected_lines() {
605 let text = "- [Home](https://x.com)\n- [Home](https://x.com)\n\
606 > A quote that repeats.\n> A quote that repeats.";
607 let out = squeeze_prose(text, 10_000);
608 assert_eq!(out.matches("[Home]").count(), 2);
610 assert_eq!(out.matches("A quote that repeats").count(), 2);
611 }
612
613 #[test]
614 fn squeeze_prose_caps_length() {
615 let big = "This is a unique sentence number ";
616 let text = (0..500)
617 .map(|i| format!("{big}{i}."))
618 .collect::<Vec<_>>()
619 .join("\n");
620 let out = squeeze_prose(&text, 400);
621 assert!(out.contains("…[truncated]"));
622 assert!(out.len() <= 600, "got {} chars", out.len());
623 }
624
625 #[test]
626 fn contains_word_matches_whole_words_only() {
627 assert!(contains_word("the budget is large", "budget"));
628 assert!(!contains_word("budgetary spending", "budget"));
629 }
630
631 #[test]
632 fn facts_scored_assigns_bounded_confidence() {
633 let text = "Revenue grew to 12 million dollars in 2023. \
634 Apollo Eleven landed on the Moon in 1969 successfully. \
635 The annual budget was 5 billion euros overall.";
636 let scored = facts_scored(text, None, 3);
637 assert!(!scored.is_empty(), "expected scored facts");
638 for (_, conf) in &scored {
639 assert!(
640 (0.0..=1.0).contains(conf),
641 "confidence out of range: {conf}"
642 );
643 }
644 }
645
646 #[test]
647 fn facts_confidence_scales_with_signals() {
648 let rich =
650 factual_confidence(factual_score("Revenue grew to 12 million dollars in 2023.") as f32);
651 let thin = factual_confidence(factual_score("There were 3 cats.") as f32);
652 assert!(rich > thin, "rich={rich} thin={thin}");
653 assert!((0.5..=0.97).contains(&rich));
654 }
655
656 #[test]
657 fn quotes_single_item_gets_default_confidence() {
658 let scored = normalize_conf(vec![("only one".to_string(), 4.2)]);
659 assert_eq!(scored.len(), 1);
660 assert!((scored[0].1 - 0.8).abs() < 1e-6);
661 }
662}