use dioxus::prelude::*; #[cfg(feature = "server")] mod inner { use serde::{Deserialize, Serialize}; /// A single message in the OpenAI-compatible chat format used by LiteLLM. #[derive(Serialize)] pub(super) struct ChatMessage { pub role: String, pub content: String, } /// Request body for the OpenAI-compatible chat completions endpoint. #[derive(Serialize)] pub(super) struct ChatCompletionRequest { pub model: String, pub messages: Vec, /// Disable streaming so we get a single JSON response. pub stream: bool, } /// A single choice in the chat completions response. #[derive(Deserialize)] pub(super) struct ChatChoice { pub message: ChatResponseMessage, } /// The assistant message returned inside a choice. #[derive(Deserialize)] pub(super) struct ChatResponseMessage { pub content: String, } /// Top-level response from the `/v1/chat/completions` endpoint. #[derive(Deserialize)] pub(super) struct ChatCompletionResponse { pub choices: Vec, } /// Fetch the full text content of a webpage by downloading its HTML /// and extracting the main article body, skipping navigation, headers, /// footers, and sidebars. /// /// Uses a tiered extraction strategy: /// 1. Try content within `
`, `
`, or `[role="main"]` /// 2. Fall back to all `

` tags outside excluded containers /// /// # Arguments /// /// * `url` - The article URL to fetch /// /// # Returns /// /// The extracted text, or `None` if the fetch/parse fails. /// Text is capped at 8000 characters to stay within LLM context limits. pub(super) async fn fetch_article_text(url: &str) -> Option { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .ok()?; let resp = client .get(url) .header("User-Agent", "CERTifAI/1.0 (Article Summarizer)") .send() .await .ok()?; if !resp.status().is_success() { return None; } let html = resp.text().await.ok()?; parse_article_html(&html) } /// Parse article text from raw HTML without any network I/O. /// /// Uses a tiered extraction strategy: /// 1. Try content within `

`, `
`, or `[role="main"]` /// 2. Fall back to all `

` tags outside excluded containers /// /// # Arguments /// /// * `html` - Raw HTML string to parse /// /// # Returns /// /// The extracted text, or `None` if extraction yields < 100 chars. /// Output is capped at 8000 characters. pub(crate) fn parse_article_html(html: &str) -> Option { let document = scraper::Html::parse_document(html); // Strategy 1: Extract from semantic article containers. // Most news sites wrap the main content in

,
, // or an element with role="main". let article_selector = scraper::Selector::parse("article, main, [role='main']").ok()?; let paragraph_sel = scraper::Selector::parse("p, h1, h2, h3, li").ok()?; let mut text_parts: Vec = Vec::with_capacity(64); for container in document.select(&article_selector) { for element in container.select(¶graph_sel) { collect_text_fragment(element, &mut text_parts); } } // Strategy 2: If article containers yielded little text, fall back // to all

tags that are NOT inside nav/header/footer/aside. if joined_len(&text_parts) < 200 { text_parts.clear(); let all_p = scraper::Selector::parse("p").ok()?; // Tags whose descendants should be excluded from extraction const EXCLUDED_TAGS: &[&str] = &["nav", "header", "footer", "aside", "script", "style"]; for element in document.select(&all_p) { // Walk ancestors and skip if inside an excluded container. // Checks tag names directly to avoid ego_tree version issues. let inside_excluded = element.ancestors().any(|ancestor| { ancestor .value() .as_element() .is_some_and(|el| EXCLUDED_TAGS.contains(&el.name.local.as_ref())) }); if !inside_excluded { collect_text_fragment(element, &mut text_parts); } } } let full_text = text_parts.join("\n\n"); if full_text.len() < 100 { return None; } // Cap at 8000 chars to stay within reasonable LLM context let truncated: String = full_text.chars().take(8000).collect(); Some(truncated) } /// Extract text from an HTML element and append it to the parts list /// if it meets a minimum length threshold. fn collect_text_fragment(element: scraper::ElementRef<'_>, parts: &mut Vec) { let text: String = element.text().collect::>().join(" "); let trimmed = text.trim().to_string(); // Skip very short fragments (nav items, buttons, etc.) if trimmed.len() >= 30 { parts.push(trimmed); } } /// Sum the total character length of all collected text parts. pub(crate) fn joined_len(parts: &[String]) -> usize { parts.iter().map(|s| s.len()).sum() } } /// Summarize an article using a LiteLLM proxy. /// /// First attempts to fetch the full article text from the provided URL. /// If that fails (paywall, timeout, etc.), falls back to the search snippet. /// This mirrors how Perplexity fetches and reads source pages before answering. /// /// # Arguments /// /// * `snippet` - The search result snippet (fallback content) /// * `article_url` - The original article URL to fetch full text from /// * `litellm_url` - Base URL of the LiteLLM proxy (e.g. "http://localhost:4000") /// * `model` - The model ID to use (e.g. "qwen3-32b") /// /// # Returns /// /// A summary string generated by the LLM, or a `ServerFnError` on failure /// /// # Errors /// /// Returns `ServerFnError` if the LiteLLM request fails or response parsing fails #[post("/api/summarize")] pub async fn summarize_article( snippet: String, article_url: String, litellm_url: String, model: String, ) -> Result { use inner::{fetch_article_text, ChatCompletionRequest, ChatCompletionResponse, ChatMessage}; let state: crate::infrastructure::ServerState = dioxus_fullstack::FullstackContext::extract().await?; // Use caller-provided values or fall back to ServerState config let base_url = if litellm_url.is_empty() { state.services.litellm_url.clone() } else { litellm_url }; let model = if model.is_empty() { state.services.litellm_model.clone() } else { model }; let api_key = state.services.litellm_api_key.clone(); // Try to fetch the full article; fall back to the search snippet let article_text = fetch_article_text(&article_url).await.unwrap_or(snippet); let request_body = ChatCompletionRequest { model, stream: false, messages: vec![ChatMessage { role: "user".into(), content: format!( "You are a news summarizer. Summarize the following article text \ in 2-3 concise paragraphs. Focus only on the key points and \ implications. Do NOT comment on the source, the date, the URL, \ the formatting, or whether the content seems complete or not. \ Just summarize whatever content is provided.\n\n\ {article_text}" ), }], }; let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); let client = reqwest::Client::new(); let mut request = client .post(&url) .header("content-type", "application/json") .json(&request_body); if !api_key.is_empty() { request = request.header("Authorization", format!("Bearer {api_key}")); } let resp = request .send() .await .map_err(|e| ServerFnError::new(format!("LiteLLM request failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(ServerFnError::new(format!( "LiteLLM returned {status}: {body}" ))); } let body: ChatCompletionResponse = resp .json() .await .map_err(|e| ServerFnError::new(format!("Failed to parse LiteLLM response: {e}")))?; body.choices .first() .map(|choice| choice.message.content.clone()) .ok_or_else(|| ServerFnError::new("Empty response from LiteLLM")) } /// A lightweight chat message for the follow-up conversation. /// Uses simple String role ("system"/"user"/"assistant") for OpenAI compatibility. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct FollowUpMessage { pub role: String, pub content: String, } /// Send a follow-up question about an article using a LiteLLM proxy. /// /// Accepts the full conversation history (system context + prior turns) and /// returns the assistant's next response. The system message should contain /// the article text and summary so the LLM has full context. /// /// # Arguments /// /// * `messages` - The conversation history including system context /// * `litellm_url` - Base URL of the LiteLLM proxy /// * `model` - The model ID to use /// /// # Returns /// /// The assistant's response text, or a `ServerFnError` on failure /// /// # Errors /// /// Returns `ServerFnError` if the LiteLLM request fails or response parsing fails #[post("/api/chat")] pub async fn chat_followup( messages: Vec, litellm_url: String, model: String, ) -> Result { use inner::{ChatCompletionRequest, ChatCompletionResponse, ChatMessage}; let state: crate::infrastructure::ServerState = dioxus_fullstack::FullstackContext::extract().await?; let base_url = if litellm_url.is_empty() { state.services.litellm_url.clone() } else { litellm_url }; let model = if model.is_empty() { state.services.litellm_model.clone() } else { model }; let api_key = state.services.litellm_api_key.clone(); // Convert FollowUpMessage to inner ChatMessage for the request let chat_messages: Vec = messages .into_iter() .map(|m| ChatMessage { role: m.role, content: m.content, }) .collect(); let request_body = ChatCompletionRequest { model, stream: false, messages: chat_messages, }; let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); let client = reqwest::Client::new(); let mut request = client .post(&url) .header("content-type", "application/json") .json(&request_body); if !api_key.is_empty() { request = request.header("Authorization", format!("Bearer {api_key}")); } let resp = request .send() .await .map_err(|e| ServerFnError::new(format!("LiteLLM request failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(ServerFnError::new(format!( "LiteLLM returned {status}: {body}" ))); } let body: ChatCompletionResponse = resp .json() .await .map_err(|e| ServerFnError::new(format!("Failed to parse LiteLLM response: {e}")))?; body.choices .first() .map(|choice| choice.message.content.clone()) .ok_or_else(|| ServerFnError::new("Empty response from LiteLLM")) } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; // ----------------------------------------------------------------------- // FollowUpMessage serde tests // ----------------------------------------------------------------------- #[test] fn followup_message_serde_round_trip() { let msg = FollowUpMessage { role: "assistant".into(), content: "Here is my answer.".into(), }; let json = serde_json::to_string(&msg).expect("serialize FollowUpMessage"); let back: FollowUpMessage = serde_json::from_str(&json).expect("deserialize FollowUpMessage"); assert_eq!(msg, back); } #[test] fn followup_message_deserialize_from_json_literal() { let json = r#"{"role":"system","content":"You are helpful."}"#; let msg: FollowUpMessage = serde_json::from_str(json).expect("deserialize literal"); assert_eq!(msg.role, "system"); assert_eq!(msg.content, "You are helpful."); } // ----------------------------------------------------------------------- // joined_len and parse_article_html tests (server feature required) // ----------------------------------------------------------------------- #[cfg(feature = "server")] mod server_tests { use super::super::inner::{joined_len, parse_article_html}; use pretty_assertions::assert_eq; #[test] fn joined_len_empty_input() { assert_eq!(joined_len(&[]), 0); } #[test] fn joined_len_sums_correctly() { let parts = vec!["abc".into(), "de".into(), "fghij".into()]; assert_eq!(joined_len(&parts), 10); } // ------------------------------------------------------------------- // parse_article_html tests // ------------------------------------------------------------------- // Helper: generate a string of given length from a repeated word. fn lorem(len: usize) -> String { "Lorem ipsum dolor sit amet consectetur adipiscing elit " .repeat((len / 55) + 1) .chars() .take(len) .collect() } #[test] fn article_tag_extracts_text() { let body = lorem(250); let html = format!("

{body}

"); let result = parse_article_html(&html); assert!(result.is_some(), "expected Some for article tag"); assert!(result.unwrap().contains("Lorem")); } #[test] fn main_tag_extracts_text() { let body = lorem(250); let html = format!("

{body}

"); let result = parse_article_html(&html); assert!(result.is_some(), "expected Some for main tag"); } #[test] fn fallback_to_p_tags_when_article_main_yield_little() { // No
/
, so falls back to

tags let body = lorem(250); let html = format!("

{body}

"); let result = parse_article_html(&html); assert!(result.is_some(), "expected fallback to

tags"); } #[test] fn excludes_nav_footer_aside_content() { // Content only inside excluded containers -- should be excluded let body = lorem(250); let html = format!( "\

\

{body}

\ \ " ); let result = parse_article_html(&html); assert!(result.is_none(), "expected None for excluded-only content"); } #[test] fn returns_none_when_text_too_short() { let html = "

Short.

"; let result = parse_article_html(html); assert!(result.is_none(), "expected None for short text"); } #[test] fn truncates_at_8000_chars() { let body = lorem(10000); let html = format!("

{body}

"); let result = parse_article_html(&html).expect("expected Some"); assert!( result.len() <= 8000, "expected <= 8000 chars, got {}", result.len() ); } #[test] fn skips_fragments_under_30_chars() { // Only fragments < 30 chars -- should yield None let html = "
\

Short frag one

\

Another tiny bit

\
"; let result = parse_article_html(html); assert!(result.is_none(), "expected None for tiny fragments"); } #[test] fn extracts_from_role_main_attribute() { let body = lorem(250); let html = format!( "\

{body}

\ " ); let result = parse_article_html(&html); assert!(result.is_some(), "expected Some for role=main"); } } }