1pub mod citation;
17pub mod distill;
18pub mod feed;
19pub mod fetch;
20pub mod html_to_text;
21pub mod pdf;
22pub mod rewrite;
23pub mod url_guard;
24pub mod youtube;
25
26use crate::core::evidence::Claim;
27
28use citation::Citation;
29
30pub const DEFAULT_MAX_TOKENS: usize = 6000;
32pub const DEFAULT_MAX_ITEMS: usize = 12;
34const MAX_LINKS: usize = 100;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ReadMode {
39 Auto,
42 Markdown,
44 Text,
46 Links,
48 Facts,
50 Quotes,
52 Transcript,
54}
55
56impl ReadMode {
57 pub fn parse(s: &str) -> Option<Self> {
58 match s.trim().to_ascii_lowercase().as_str() {
59 "auto" => Some(Self::Auto),
60 "markdown" | "md" => Some(Self::Markdown),
61 "text" | "plain" => Some(Self::Text),
62 "links" => Some(Self::Links),
63 "facts" => Some(Self::Facts),
64 "quotes" => Some(Self::Quotes),
65 "transcript" | "summary" => Some(Self::Transcript),
66 _ => None,
67 }
68 }
69
70 pub fn label(self) -> &'static str {
71 match self {
72 Self::Auto => "auto",
73 Self::Markdown => "markdown",
74 Self::Text => "text",
75 Self::Links => "links",
76 Self::Facts => "facts",
77 Self::Quotes => "quotes",
78 Self::Transcript => "transcript",
79 }
80 }
81}
82
83pub struct ReadOptions<'a> {
85 pub url: &'a str,
86 pub mode: ReadMode,
87 pub query: Option<&'a str>,
88 pub max_tokens: usize,
89 pub max_items: usize,
90 pub timeout_secs: u64,
91}
92
93impl<'a> ReadOptions<'a> {
94 pub fn new(url: &'a str) -> Self {
95 Self {
96 url,
97 mode: ReadMode::Auto,
98 query: None,
99 max_tokens: DEFAULT_MAX_TOKENS,
100 max_items: DEFAULT_MAX_ITEMS,
101 timeout_secs: fetch::DEFAULT_TIMEOUT_SECS,
102 }
103 }
104}
105
106pub struct ReadResult {
108 pub content: String,
110 pub mode: ReadMode,
112 pub original_tokens: usize,
114 pub final_url: String,
115}
116
117pub type AdmitText<'a> = &'a dyn Fn(&str) -> Result<String, String>;
120
121pub fn read_url(opts: &ReadOptions) -> Result<ReadResult, String> {
126 read_url_admitted(opts, &|text| Ok(text.to_string()))
127}
128
129pub fn read_url_admitted(opts: &ReadOptions, admit: AdmitText<'_>) -> Result<ReadResult, String> {
141 let rewritten = rewrite::rewrite_url(opts.url);
144 let url = rewritten.as_deref().unwrap_or(opts.url);
145
146 if let Some(id) = youtube::video_id(url) {
147 let transcript = youtube::fetch_transcript(&id, opts.timeout_secs)?;
148 return distil_transcript(
149 &transcript.full_text,
150 transcript.title,
151 transcript.source_url,
152 opts,
153 admit,
154 );
155 }
156
157 let effective = ReadOptions {
158 url,
159 mode: opts.mode,
160 query: opts.query,
161 max_tokens: opts.max_tokens,
162 max_items: opts.max_items,
163 timeout_secs: opts.timeout_secs,
164 };
165 read_web(&effective, admit)
166}
167
168struct Extracted {
170 title: Option<String>,
171 markdown: String,
172 links: Vec<html_to_text::Link>,
173 original_tokens: usize,
174}
175
176fn read_web(opts: &ReadOptions, admit: AdmitText<'_>) -> Result<ReadResult, String> {
177 let doc = fetch::fetch(opts.url, fetch::DEFAULT_MAX_BYTES, opts.timeout_secs)?;
178 if doc.status >= 400 {
179 return Err(format!("HTTP {} from {}", doc.status, doc.final_url));
180 }
181
182 let is_pdf = doc.content_type.contains("pdf")
183 || (doc.content_type.is_empty() && pdf::looks_like_pdf(&doc.bytes));
184
185 let (title, markdown, links, original_tokens) = if is_pdf {
186 let text = pdf::extract_text(&doc.bytes)?;
187 let tokens = crate::core::tokens::count_tokens(&text);
188 (None, text, Vec::new(), tokens)
189 } else {
190 let body = doc.body_text();
191 let tokens = crate::core::tokens::count_tokens(&body);
192 let looks_html = body.trim_start().starts_with('<');
193 if feed::looks_like_feed(&doc.content_type, &body) {
196 let parsed = feed::parse(&body, &doc.final_url);
197 (parsed.title, parsed.markdown, Vec::new(), tokens)
198 } else if is_html(&doc.content_type) || (doc.content_type.is_empty() && looks_html) {
199 let parsed = html_to_text::parse(&body);
200 (parsed.title, parsed.markdown, parsed.links, tokens)
201 } else if is_textual(&doc.content_type) {
202 (None, body, Vec::new(), tokens)
203 } else {
204 return Err(format!(
205 "unsupported content type '{}' for {} (extractable: HTML, PDF, plain text)",
206 doc.content_type, doc.final_url
207 ));
208 }
209 };
210
211 distil_page(
212 Extracted {
213 title,
214 markdown,
215 links,
216 original_tokens,
217 },
218 doc.final_url,
219 opts,
220 admit,
221 )
222}
223
224fn admitted_title(title: Option<String>, admit: AdmitText<'_>) -> Option<String> {
227 title.and_then(|title| admit(&title).ok())
228}
229
230fn distil_page(
234 extracted: Extracted,
235 final_url: String,
236 opts: &ReadOptions,
237 admit: AdmitText<'_>,
238) -> Result<ReadResult, String> {
239 let markdown = admit(&extracted.markdown)?;
240
241 let effective = match opts.mode {
242 ReadMode::Auto => ReadMode::Markdown,
243 other => other,
244 };
245
246 let body = render_mode(effective, &markdown, &extracted.links, &final_url, opts);
247 let body = if effective == ReadMode::Links {
248 admit(&body)?
249 } else {
250 body
251 };
252 let body = apply_persona_compressor(body, effective);
253 let trimmed = enforce_budget(&body, opts.max_tokens);
254 let citation = Citation::new(&final_url, admitted_title(extracted.title, admit));
255
256 Ok(ReadResult {
257 content: format!("{trimmed}{}", citation.footer()),
258 mode: effective,
259 original_tokens: extracted.original_tokens,
260 final_url,
261 })
262}
263
264fn distil_transcript(
267 full_text: &str,
268 title: Option<String>,
269 source_url: String,
270 opts: &ReadOptions,
271 admit: AdmitText<'_>,
272) -> Result<ReadResult, String> {
273 let original_tokens = crate::core::tokens::count_tokens(full_text);
274 let full_text = admit(full_text)?;
275
276 let effective = match opts.mode {
277 ReadMode::Auto => ReadMode::Transcript,
278 other => other,
279 };
280
281 let body = match effective {
282 ReadMode::Facts => render_facts(&claims_from(
283 distill::facts_scored(&full_text, opts.query, opts.max_items),
284 &source_url,
285 )),
286 ReadMode::Quotes => render_quotes(&claims_from(
287 distill::quotes_scored(&full_text, opts.query, opts.max_items),
288 &source_url,
289 )),
290 ReadMode::Links => "Links are not available for video transcripts.".to_string(),
291 _ => distill::summarize_prose(&full_text, opts.max_tokens.saturating_mul(4), opts.query),
292 };
293
294 let body = apply_persona_compressor(body, effective);
295 let trimmed = enforce_budget(&body, opts.max_tokens);
296 let citation = Citation::new(&source_url, admitted_title(title, admit));
297
298 Ok(ReadResult {
299 content: format!("{trimmed}{}", citation.footer()),
300 mode: effective,
301 original_tokens,
302 final_url: source_url,
303 })
304}
305
306fn render_mode(
307 mode: ReadMode,
308 markdown: &str,
309 links: &[html_to_text::Link],
310 base_url: &str,
311 opts: &ReadOptions,
312) -> String {
313 match mode {
314 ReadMode::Markdown | ReadMode::Auto => markdown.to_string(),
315 ReadMode::Text => html_to_text::markdown_to_text(markdown),
316 ReadMode::Links => render_links(links, base_url),
317 ReadMode::Facts => {
318 let plain = html_to_text::markdown_to_text(markdown);
319 let claims = claims_from(
320 distill::facts_scored(&plain, opts.query, opts.max_items),
321 base_url,
322 );
323 render_facts(&claims)
324 }
325 ReadMode::Quotes => {
326 let plain = html_to_text::markdown_to_text(markdown);
327 let claims = claims_from(
328 distill::quotes_scored(&plain, opts.query, opts.max_items),
329 base_url,
330 );
331 render_quotes(&claims)
332 }
333 ReadMode::Transcript => {
334 let plain = html_to_text::markdown_to_text(markdown);
335 distill::summarize_prose(&plain, opts.max_tokens.saturating_mul(4), opts.query)
336 }
337 }
338}
339
340fn render_links(links: &[html_to_text::Link], base_url: &str) -> String {
341 if links.is_empty() {
342 return "No links found.".to_string();
343 }
344 let base = url_guard::validate(base_url).ok();
345 let mut seen = std::collections::HashSet::new();
346 let mut out = Vec::new();
347 for link in links {
348 let abs = absolutize(&link.href, base.as_ref());
349 if seen.insert(abs.clone()) {
350 out.push(format!("- [{}]({abs})", link.text));
351 if out.len() >= MAX_LINKS {
352 break;
353 }
354 }
355 }
356 out.join("\n")
357}
358
359fn absolutize(href: &str, base: Option<&url_guard::SafeUrl>) -> String {
360 if href.starts_with("http://") || href.starts_with("https://") {
361 return href.to_string();
362 }
363 match base {
364 Some(b) => fetch::resolve_redirect(b, href),
365 None => href.to_string(),
366 }
367}
368
369fn claims_from(scored: Vec<(String, f32)>, source: &str) -> Vec<Claim> {
371 scored
372 .into_iter()
373 .map(|(text, conf)| Claim::new(text, conf).with_source(source))
374 .collect()
375}
376
377fn render_facts(claims: &[Claim]) -> String {
380 if claims.is_empty() {
381 return "No matching content found.".to_string();
382 }
383 claims
384 .iter()
385 .map(|c| format!("- ({:.2}) {}", c.confidence, c.text))
386 .collect::<Vec<_>>()
387 .join("\n")
388}
389
390fn render_quotes(claims: &[Claim]) -> String {
391 if claims.is_empty() {
392 return "No quotable content found.".to_string();
393 }
394 claims
395 .iter()
396 .map(|c| format!("> ({:.2}) {}", c.confidence, c.text))
397 .collect::<Vec<_>>()
398 .join("\n\n")
399}
400
401fn apply_persona_compressor(body: String, mode: ReadMode) -> String {
407 if !matches!(
408 mode,
409 ReadMode::Auto | ReadMode::Markdown | ReadMode::Text | ReadMode::Transcript
410 ) {
411 return body;
412 }
413 let name = crate::core::persona::active().compressor;
414 if name == "identity" {
415 return body;
416 }
417 let Some(compressor) = crate::core::extension_registry::global()
418 .read()
419 .ok()
420 .and_then(|reg| reg.compressor(&name))
421 else {
422 tracing::debug!("persona compressor '{name}' not registered; passing through");
423 return body;
424 };
425 compressor.compress(&body, None)
426}
427
428fn enforce_budget(content: &str, max_tokens: usize) -> String {
430 let cut = cut_to_budget(content, max_tokens);
431 if cut == content {
432 return cut;
433 }
434 crate::core::recovery::with_original_pointer("ctx_url_read", "budget", content, cut)
435}
436
437fn cut_to_budget(content: &str, max_tokens: usize) -> String {
438 let tokens = crate::core::tokens::count_tokens(content);
439 if tokens <= max_tokens {
440 return content.to_string();
441 }
442 if let Some(trimmed) = trim_at_chunk_boundaries(content, max_tokens) {
446 return trimmed;
447 }
448 let total_chars = content.chars().count();
451 let ratio = max_tokens as f64 / tokens as f64;
452 let keep = ((total_chars as f64 * ratio) as usize).max(1);
453 let truncated: String = content.chars().take(keep).collect();
454 format!("{truncated}\n\n…[truncated to fit ~{max_tokens} token budget]")
455}
456
457fn trim_at_chunk_boundaries(content: &str, max_tokens: usize) -> Option<String> {
465 use crate::core::tokens::count_tokens;
466 let name = crate::core::persona::active().chunker;
467 let chunker = crate::core::extension_registry::global()
468 .read()
469 .ok()?
470 .chunker(&name)?;
471 let chunks = chunker.chunk(content);
472 if chunks.len() < 2 {
473 return None;
474 }
475 let mut cut = 0usize;
476 let mut used = 0usize;
477 let mut search_from = 0usize;
478 for chunk in &chunks {
479 let chunk_tokens = count_tokens(chunk);
480 if used + chunk_tokens > max_tokens {
481 break;
482 }
483 let rel = content.get(search_from..)?.find(chunk.as_str())?;
484 let end = search_from + rel + chunk.len();
485 used += chunk_tokens;
486 cut = end;
487 search_from = end;
488 }
489 if cut == 0 {
490 return None;
491 }
492 Some(format!(
493 "{}\n\n…[truncated to fit ~{max_tokens} token budget]",
494 content[..cut].trim_end()
495 ))
496}
497
498fn is_html(content_type: &str) -> bool {
499 content_type.contains("html") || content_type.contains("xml")
500}
501
502fn is_textual(content_type: &str) -> bool {
503 content_type.starts_with("text/")
504 || content_type.contains("json")
505 || content_type.contains("markdown")
506 || content_type.contains("plain")
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 fn page(markdown: &str) -> Extracted {
514 Extracted {
515 title: Some("Launch plan".to_string()),
516 markdown: markdown.to_string(),
517 links: vec![html_to_text::Link {
518 text: "internal roadmap".into(),
519 href: "https://intra.example/roadmap".into(),
520 }],
521 original_tokens: 100,
522 }
523 }
524
525 fn options(mode: ReadMode) -> ReadOptions<'static> {
526 ReadOptions {
527 mode,
528 ..ReadOptions::new("https://docs.example/page")
529 }
530 }
531
532 const ALL_MODES: [ReadMode; 7] = [
533 ReadMode::Auto,
534 ReadMode::Markdown,
535 ReadMode::Text,
536 ReadMode::Links,
537 ReadMode::Facts,
538 ReadMode::Quotes,
539 ReadMode::Transcript,
540 ];
541
542 #[test]
547 fn a_refused_source_yields_nothing_in_any_mode() {
548 let refuse = |text: &str| -> Result<String, String> {
549 if text.contains("CONFIDENTIAL") {
550 Err("source content withheld by policy".to_string())
551 } else {
552 Ok(text.to_string())
553 }
554 };
555 let source = "CONFIDENTIAL\n\nThe launch moves to 14 March 2027. Revenue grew 40% in Q3.";
556 for mode in ALL_MODES {
557 let Err(reason) = distil_page(
558 page(source),
559 "https://docs.example/page".to_string(),
560 &options(mode),
561 &refuse,
562 ) else {
563 panic!("mode {} delivered a refused page", mode.label());
564 };
565 assert_eq!(reason, "source content withheld by policy");
566 let Err(reason) = distil_transcript(
567 source,
568 None,
569 "https://youtu.be/x".to_string(),
570 &options(mode),
571 &refuse,
572 ) else {
573 panic!("mode {} delivered a refused transcript", mode.label());
574 };
575 assert_eq!(reason, "source content withheld by policy");
576 }
577 }
578
579 #[test]
583 fn distilled_parts_derive_only_from_admitted_text() {
584 let mask = |text: &str| -> Result<String, String> {
585 if text == "Launch plan" {
586 return Err("withheld".to_string());
587 }
588 Ok(text
589 .replace("ORBITAL-7", "[masked]")
590 .replace("intra.example", "[masked]"))
591 };
592 let identity = |text: &str| -> Result<String, String> { Ok(text.to_string()) };
593 let source = "Project ORBITAL-7 ships on 14 March 2027. Revenue grew 40% in Q3 2026.";
594 for mode in ALL_MODES {
595 let masked = distil_page(
596 page(source),
597 "https://docs.example/page".to_string(),
598 &options(mode),
599 &mask,
600 )
601 .unwrap();
602 assert!(!masked.content.contains("ORBITAL-7"), "{}", mode.label());
603 assert!(
604 !masked.content.contains("intra.example"),
605 "{}",
606 mode.label()
607 );
608 assert!(!masked.content.contains("Launch plan"), "{}", mode.label());
609 assert_eq!(masked.original_tokens, 100);
610
611 let masked = distil_transcript(
612 source,
613 Some("Launch plan".to_string()),
614 "https://youtu.be/x".to_string(),
615 &options(mode),
616 &mask,
617 )
618 .unwrap();
619 assert!(!masked.content.contains("ORBITAL-7"), "{}", mode.label());
620 assert!(!masked.content.contains("Launch plan"), "{}", mode.label());
621 }
622 let plain = distil_page(
623 page(source),
624 "https://docs.example/page".to_string(),
625 &options(ReadMode::Markdown),
626 &identity,
627 )
628 .unwrap();
629 assert!(plain.content.starts_with(source), "{}", plain.content);
630 }
631
632 #[test]
633 fn read_mode_parsing_is_lenient() {
634 assert_eq!(ReadMode::parse("MD"), Some(ReadMode::Markdown));
635 assert_eq!(ReadMode::parse(" transcript "), Some(ReadMode::Transcript));
636 assert_eq!(ReadMode::parse("summary"), Some(ReadMode::Transcript));
637 assert_eq!(ReadMode::parse("bogus"), None);
638 }
639
640 #[test]
641 fn content_type_classification() {
642 assert!(is_html("text/html"));
643 assert!(is_html("application/xhtml+xml"));
644 assert!(is_textual("text/plain"));
645 assert!(is_textual("application/json"));
646 assert!(!is_html("application/pdf"));
647 assert!(!is_textual("application/pdf"));
648 }
649
650 #[test]
651 fn claim_renderers_handle_empty_and_confidence() {
652 assert_eq!(render_facts(&[]), "No matching content found.");
653 assert_eq!(render_quotes(&[]), "No quotable content found.");
654
655 let claims = claims_from(
656 vec![("Alpha".to_string(), 0.9), ("Beta".to_string(), 0.5)],
657 "https://src.example/page",
658 );
659 assert_eq!(render_facts(&claims), "- (0.90) Alpha\n- (0.50) Beta");
660 assert_eq!(
661 claims[0].source_url.as_deref(),
662 Some("https://src.example/page")
663 );
664 }
665
666 #[test]
667 fn render_links_absolutizes_and_dedupes() {
668 let links = vec![
669 html_to_text::Link {
670 text: "rel".into(),
671 href: "/about".into(),
672 },
673 html_to_text::Link {
674 text: "abs".into(),
675 href: "https://y.com/z".into(),
676 },
677 html_to_text::Link {
678 text: "dup".into(),
679 href: "https://y.com/z".into(),
680 },
681 ];
682 let out = render_links(&links, "https://x.com/dir/page");
683 assert!(out.contains("[rel](https://x.com/about)"));
684 assert!(out.contains("[abs](https://y.com/z)"));
685 assert_eq!(out.matches("https://y.com/z").count(), 1);
686 }
687
688 #[test]
689 fn enforce_budget_truncates_when_over() {
690 let big = "word ".repeat(5000);
691 let out = enforce_budget(&big, 50);
692 assert!(out.contains("[truncated"));
693 assert!(crate::core::tokens::count_tokens(&out) < crate::core::tokens::count_tokens(&big));
694 }
695
696 #[test]
697 fn enforce_budget_keeps_small_content() {
698 let small = "short content";
699 assert_eq!(enforce_budget(small, 1000), small);
700 }
701
702 #[test]
703 fn persona_chunker_trims_at_paragraph_boundaries() {
704 let _guard = crate::core::data_dir::test_env_lock();
705 crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
706
707 let para = "alpha beta gamma delta epsilon zeta eta theta";
708 let content = format!("{para}\n\n{para}\n\n{para}\n\n{para}");
709 let budget = crate::core::tokens::count_tokens(para) * 2 + 1;
710 let out = enforce_budget(&content, budget);
711
712 crate::test_env::remove_var("LEAN_CTX_PERSONA");
713
714 assert!(out.contains("[truncated"), "{out}");
715 let body = out.split("\n\n…[truncated").next().unwrap();
718 assert_eq!(body, format!("{para}\n\n{para}"), "{out}");
719 }
720
721 #[test]
722 fn persona_compressor_strips_markdown_noise_for_research() {
723 let _guard = crate::core::data_dir::test_env_lock();
724 crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
725
726 let body = "Intro \n\n\
727 See [the docs](https://docs.example/page) for details."
728 .to_string();
729 let out = apply_persona_compressor(body, ReadMode::Markdown);
730
731 crate::test_env::remove_var("LEAN_CTX_PERSONA");
732
733 assert!(out.contains("the docs"), "{out}");
734 assert!(!out.contains("https://docs.example"), "{out}");
735 assert!(!out.contains("img.example"), "{out}");
736 }
737
738 #[test]
739 fn persona_compressor_leaves_extractive_modes_verbatim() {
740 let _guard = crate::core::data_dir::test_env_lock();
741 crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
742
743 let body = "- (0.90) A [cited](https://src.example) claim.".to_string();
744 let out = apply_persona_compressor(body.clone(), ReadMode::Facts);
745
746 crate::test_env::remove_var("LEAN_CTX_PERSONA");
747
748 assert_eq!(out, body);
749 }
750
751 #[test]
752 fn persona_compressor_is_identity_for_coding() {
753 let _guard = crate::core::data_dir::test_env_lock();
754 crate::test_env::set_var("LEAN_CTX_PERSONA", "coding");
755
756 let body = "Anything  stays.".to_string();
757 let out = apply_persona_compressor(body.clone(), ReadMode::Markdown);
758
759 crate::test_env::remove_var("LEAN_CTX_PERSONA");
760
761 assert_eq!(out, body);
762 }
763}