Skip to main content

lean_ctx/core/web/
distill.rs

1//! Extractive research-compression modes for prose and transcripts.
2//!
3//! These are deterministic, heuristic distillations — no LLM in the loop — so
4//! they are cheap, reproducible, and safe to run inside a synchronous tool
5//! handler. They turn a cleaned article or transcript into the high-signal
6//! subset an agent actually needs:
7//!
8//! * [`facts_scored`] — sentences carrying factual signals (numbers, dates,
9//!   entities), each with a confidence score.
10//! * [`quotes_scored`] — the most central / query-relevant sentences, as
11//!   evidence, each with a confidence score.
12//! * [`transcript_summary`] — de-duplicated, filler-stripped spoken text.
13
14use 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
47/// Extract sentences carrying factual signals, ranked and de-duplicated. Each
48/// sentence carries a confidence (`[0.0, 1.0]`) so callers can build attributable
49/// `crate::core::evidence::Claim`s. Facts use an *absolute* mapping (more
50/// factual signals → higher confidence) rather than min-max, so the score is
51/// meaningful even when the top sentences tie.
52pub 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
59/// Map a raw factual score (≈ number of factual signals) to absolute confidence.
60fn 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
82/// Extract the most central (or query-relevant) sentences as quotable evidence.
83/// Each sentence carries a source-relative confidence (`[0.0, 1.0]`).
84pub 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
109/// Summarize prose to a `max_chars` budget, query-aware.
110///
111/// For inputs that already fit the budget this is exactly [`transcript_summary`]
112/// (filler-strip + adjacent-dedup, no truncation) — no behaviour change. For
113/// OVERSIZED inputs, where [`transcript_summary`] would FIFO-truncate to the
114/// prefix, it instead uses extractive ranking (`crate::core::extractive`) to
115/// keep the most query-relevant (or, without a query, the most central)
116/// sentences. Falls back to [`transcript_summary`] when the embedding engine is
117/// unavailable, so no build/OS regresses.
118pub 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
133/// Condense a transcript: strip filler, drop near-duplicate runs, cap length.
134pub 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
158/// Line-structure-preserving prose squeeze for the proxy tool-result funnel.
159///
160/// Unlike [`transcript_summary`] (which collapses everything into one paragraph),
161/// this keeps paragraph/heading shape and only:
162/// * collapses runs of blank lines to a single blank,
163/// * drops a line that is a near-duplicate of a recently kept line
164///   (boilerplate / nav repeats common in scraped pages),
165/// * caps total length to `max_chars` with a truncation marker.
166///
167/// Filler-word stripping is intentionally *not* applied here: words like
168/// "so" / "like" / "right" carry meaning in written prose and are only noise in
169/// spoken transcripts.
170pub 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
212/// Lines that must survive dedup: citations, links, headings and quote/list
213/// markers carry attribution or structure even when textually similar.
214pub(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
226// ── Sentence splitting ─────────────────────────────────────────────────────
227
228/// Split text into trimmed, non-empty sentences across line boundaries.
229pub 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, &current);
244                    current.clear();
245                }
246            }
247        }
248        push_trimmed(&mut sentences, &current);
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
260// ── Scoring ────────────────────────────────────────────────────────────────
261
262fn 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        // Ignore the very first word (sentence-initial capital is not a signal).
329        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
406/// Map raw heuristic scores onto a source-relative confidence in `[0.45, 0.95]`
407/// (single/uniform item → 0.8). Deterministic min-max within the selected set.
408fn 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
424// ── Word helpers ───────────────────────────────────────────────────────────
425
426fn 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    /// Drop confidence scores so ranking assertions read like prod callers.
500    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        // Near-duplicate second line is dropped.
554        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        // When the text already fits, summarize_prose is exactly the
570        // filler-stripping transcript_summary — no extractive path, no change.
571        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        // Oversized input: in `cargo test` the engine is never loaded, so this
581        // exercises the graceful fallback to transcript_summary. Determinism and
582        // the budget must hold on every build.
583        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        // Near-duplicate line dropped.
597        assert_eq!(out.matches("focused on safety").count(), 1);
598        // Blank run collapsed to at most a single blank line.
599        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        // Protected (link/quote) lines are never deduped away.
609        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        // Rich fact (digits + magnitude + year) should outrank a thin one.
649        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}