|
| 1 | +//! Web verification — checks factual claims against the internet. |
| 2 | +//! |
| 3 | +//! Each claim node from the ontology gets a search query, a web result, |
| 4 | +//! and a verification status: verified, unverifiable, or contradicted. |
| 5 | +
|
| 6 | +use serde::{Deserialize, Serialize}; |
| 7 | + |
| 8 | +/// Verification result for a single claim. |
| 9 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 10 | +pub struct ClaimVerification { |
| 11 | + /// The claim text (from the ontology node) |
| 12 | + pub claim: String, |
| 13 | + /// The search query generated for this claim |
| 14 | + pub search_query: String, |
| 15 | + /// What was found (or not) |
| 16 | + pub search_result: String, |
| 17 | + /// Verification status |
| 18 | + pub status: VerificationStatus, |
| 19 | +} |
| 20 | + |
| 21 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] |
| 22 | +pub enum VerificationStatus { |
| 23 | + /// Found corroborating evidence online |
| 24 | + Verified, |
| 25 | + /// No evidence found — claim cannot be confirmed |
| 26 | + Unverifiable, |
| 27 | + /// Found evidence that contradicts the claim |
| 28 | + Contradicted, |
| 29 | + /// Not yet checked |
| 30 | + Pending, |
| 31 | +} |
| 32 | + |
| 33 | +impl std::fmt::Display for VerificationStatus { |
| 34 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 35 | + match self { |
| 36 | + VerificationStatus::Verified => write!(f, "VERIFIED"), |
| 37 | + VerificationStatus::Unverifiable => write!(f, "UNVERIFIABLE"), |
| 38 | + VerificationStatus::Contradicted => write!(f, "CONTRADICTED"), |
| 39 | + VerificationStatus::Pending => write!(f, "PENDING"), |
| 40 | + } |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +/// Full verification report for a document. |
| 45 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 46 | +pub struct VerificationReport { |
| 47 | + pub total_claims: usize, |
| 48 | + pub verified: usize, |
| 49 | + pub unverifiable: usize, |
| 50 | + pub contradicted: usize, |
| 51 | + pub claims: Vec<ClaimVerification>, |
| 52 | +} |
| 53 | + |
| 54 | +impl std::fmt::Display for VerificationReport { |
| 55 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 56 | + write!(f, "Verification: {}/{} verified, {} unverifiable, {} contradicted", |
| 57 | + self.verified, self.total_claims, self.unverifiable, self.contradicted) |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +/// Extract verifiable claims from ontology nodes. |
| 62 | +/// Returns (claim_text, search_query) pairs. |
| 63 | +pub fn extract_verifiable_claims(nodes: &[crate::argument_graph::ArgumentNode]) -> Vec<(String, String)> { |
| 64 | + use crate::argument_graph::NodeType; |
| 65 | + |
| 66 | + let mut claims = Vec::new(); |
| 67 | + |
| 68 | + for node in nodes { |
| 69 | + let query = match node.node_type { |
| 70 | + // Citations: search for the exact reference |
| 71 | + NodeType::Citation => { |
| 72 | + let text = node.source_text.as_deref().unwrap_or(&node.text); |
| 73 | + extract_citation_query(text) |
| 74 | + } |
| 75 | + // Quantified evidence: search for the statistic |
| 76 | + NodeType::QuantifiedEvidence => { |
| 77 | + let text = node.source_text.as_deref().unwrap_or(&node.text); |
| 78 | + extract_stat_query(text) |
| 79 | + } |
| 80 | + // Evidence: search if it contains named entities |
| 81 | + NodeType::Evidence => { |
| 82 | + let text = node.source_text.as_deref().unwrap_or(&node.text); |
| 83 | + if has_named_entity(text) { |
| 84 | + Some(extract_entity_query(text)) |
| 85 | + } else { |
| 86 | + None |
| 87 | + } |
| 88 | + } |
| 89 | + // Thesis/claims: generally not web-verifiable |
| 90 | + _ => None, |
| 91 | + }; |
| 92 | + |
| 93 | + if let Some(q) = query { |
| 94 | + let claim_text = node.source_text.as_deref().unwrap_or(&node.text).to_string(); |
| 95 | + claims.push((claim_text, q)); |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + claims |
| 100 | +} |
| 101 | + |
| 102 | +/// Search the web for a query using a simple HTTPS request. |
| 103 | +/// Returns the page content or error message. |
| 104 | +pub async fn web_search(query: &str) -> Result<String, String> { |
| 105 | + // Use DuckDuckGo HTML search (no API key needed) |
| 106 | + let encoded = urlencoding::encode(query); |
| 107 | + let url = format!("https://html.duckduckgo.com/html/?q={}", encoded); |
| 108 | + |
| 109 | + let client = reqwest::Client::builder() |
| 110 | + .user_agent("BITF-Verify/1.0") |
| 111 | + .timeout(std::time::Duration::from_secs(10)) |
| 112 | + .build() |
| 113 | + .map_err(|e| format!("Client error: {}", e))?; |
| 114 | + |
| 115 | + let resp = client.get(&url) |
| 116 | + .send() |
| 117 | + .await |
| 118 | + .map_err(|e| format!("Request failed: {}", e))?; |
| 119 | + |
| 120 | + let text = resp.text() |
| 121 | + .await |
| 122 | + .map_err(|e| format!("Read failed: {}", e))?; |
| 123 | + |
| 124 | + // Extract result snippets from DuckDuckGo HTML |
| 125 | + let snippets = extract_snippets(&text); |
| 126 | + if snippets.is_empty() { |
| 127 | + Ok("0 results found".to_string()) |
| 128 | + } else { |
| 129 | + Ok(snippets.join("\n---\n")) |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +/// Verify a list of claims against the web. |
| 134 | +pub async fn verify_claims(claims: &[(String, String)]) -> VerificationReport { |
| 135 | + let mut results = Vec::new(); |
| 136 | + let mut verified = 0; |
| 137 | + let mut unverifiable = 0; |
| 138 | + let mut contradicted = 0; |
| 139 | + |
| 140 | + for (claim, query) in claims { |
| 141 | + let (status, search_result) = match web_search(query).await { |
| 142 | + Ok(content) => { |
| 143 | + if content == "0 results found" || content.len() < 50 { |
| 144 | + (VerificationStatus::Unverifiable, "No relevant results found".to_string()) |
| 145 | + } else { |
| 146 | + // Check if results corroborate or contradict |
| 147 | + let claim_lower = claim.to_lowercase(); |
| 148 | + let content_lower = content.to_lowercase(); |
| 149 | + |
| 150 | + // Simple heuristic: if key terms from claim appear in results |
| 151 | + let claim_words: Vec<&str> = claim_lower.split_whitespace() |
| 152 | + .filter(|w| w.len() > 4) |
| 153 | + .collect(); |
| 154 | + let matches = claim_words.iter() |
| 155 | + .filter(|w| content_lower.contains(**w)) |
| 156 | + .count(); |
| 157 | + let match_ratio = if claim_words.is_empty() { 0.0 } else { |
| 158 | + matches as f64 / claim_words.len() as f64 |
| 159 | + }; |
| 160 | + |
| 161 | + if match_ratio > 0.5 { |
| 162 | + (VerificationStatus::Verified, format!("Found corroborating results ({}% term match)", (match_ratio * 100.0) as u32)) |
| 163 | + } else { |
| 164 | + (VerificationStatus::Unverifiable, format!("Results found but no corroboration ({}% term match)", (match_ratio * 100.0) as u32)) |
| 165 | + } |
| 166 | + } |
| 167 | + } |
| 168 | + Err(e) => { |
| 169 | + (VerificationStatus::Pending, format!("Search failed: {}", e)) |
| 170 | + } |
| 171 | + }; |
| 172 | + |
| 173 | + match status { |
| 174 | + VerificationStatus::Verified => verified += 1, |
| 175 | + VerificationStatus::Unverifiable => unverifiable += 1, |
| 176 | + VerificationStatus::Contradicted => contradicted += 1, |
| 177 | + VerificationStatus::Pending => {} |
| 178 | + } |
| 179 | + |
| 180 | + results.push(ClaimVerification { |
| 181 | + claim: if claim.len() > 100 { format!("{}...", &claim[..100]) } else { claim.clone() }, |
| 182 | + search_query: query.clone(), |
| 183 | + search_result, |
| 184 | + status, |
| 185 | + }); |
| 186 | + } |
| 187 | + |
| 188 | + VerificationReport { |
| 189 | + total_claims: results.len(), |
| 190 | + verified, |
| 191 | + unverifiable, |
| 192 | + contradicted, |
| 193 | + claims: results, |
| 194 | + } |
| 195 | +} |
| 196 | + |
| 197 | +// === Helper functions === |
| 198 | + |
| 199 | +fn extract_citation_query(text: &str) -> Option<String> { |
| 200 | + // Look for author names, years, publication names |
| 201 | + let mut parts = Vec::new(); |
| 202 | + |
| 203 | + // Find year pattern |
| 204 | + let year_re = regex_lite::Regex::new(r"\b(19|20)\d{2}\b").ok()?; |
| 205 | + if let Some(m) = year_re.find(text) { |
| 206 | + parts.push(m.as_str().to_string()); |
| 207 | + } |
| 208 | + |
| 209 | + // Find capitalized names (likely proper nouns) |
| 210 | + for word in text.split_whitespace() { |
| 211 | + let clean = word.trim_matches(|c: char| !c.is_alphanumeric()); |
| 212 | + if clean.len() > 2 && clean.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) |
| 213 | + && !["The", "This", "That", "Our", "We", "In", "For", "And", "But"].contains(&clean) |
| 214 | + { |
| 215 | + parts.push(clean.to_string()); |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + if parts.len() >= 2 { |
| 220 | + Some(parts.join(" ")) |
| 221 | + } else { |
| 222 | + None |
| 223 | + } |
| 224 | +} |
| 225 | + |
| 226 | +fn extract_stat_query(text: &str) -> Option<String> { |
| 227 | + // Look for numbers with context |
| 228 | + let num_re = regex_lite::Regex::new(r"\d+[\.\d]*\s*[%£$€MBKk]|\d+[\.\d]*\s*(million|billion|percent|improvement|reduction|increase)").ok()?; |
| 229 | + if let Some(m) = num_re.find(text) { |
| 230 | + // Get surrounding context |
| 231 | + let start = m.start().saturating_sub(30); |
| 232 | + let end = (m.end() + 30).min(text.len()); |
| 233 | + let context = &text[start..end]; |
| 234 | + // Extract key words for search |
| 235 | + let words: Vec<&str> = context.split_whitespace() |
| 236 | + .filter(|w| w.len() > 3) |
| 237 | + .take(8) |
| 238 | + .collect(); |
| 239 | + if words.len() >= 3 { |
| 240 | + return Some(words.join(" ")); |
| 241 | + } |
| 242 | + } |
| 243 | + None |
| 244 | +} |
| 245 | + |
| 246 | +fn has_named_entity(text: &str) -> bool { |
| 247 | + // Check if text contains proper nouns (capitalized words that aren't sentence starters) |
| 248 | + let words: Vec<&str> = text.split_whitespace().collect(); |
| 249 | + for (i, word) in words.iter().enumerate() { |
| 250 | + if i == 0 { continue; } |
| 251 | + let clean = word.trim_matches(|c: char| !c.is_alphanumeric()); |
| 252 | + if clean.len() > 2 && clean.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) { |
| 253 | + return true; |
| 254 | + } |
| 255 | + } |
| 256 | + false |
| 257 | +} |
| 258 | + |
| 259 | +fn extract_entity_query(text: &str) -> String { |
| 260 | + let words: Vec<&str> = text.split_whitespace().collect(); |
| 261 | + let entities: Vec<&str> = words.iter() |
| 262 | + .filter(|w| { |
| 263 | + let clean = w.trim_matches(|c: char| !c.is_alphanumeric()); |
| 264 | + clean.len() > 2 && clean.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) |
| 265 | + && !["The", "This", "That", "Our", "We", "In", "For", "And", "But", "A"].contains(&clean) |
| 266 | + }) |
| 267 | + .copied() |
| 268 | + .take(6) |
| 269 | + .collect(); |
| 270 | + entities.join(" ") |
| 271 | +} |
| 272 | + |
| 273 | +fn extract_snippets(html: &str) -> Vec<String> { |
| 274 | + let mut snippets = Vec::new(); |
| 275 | + // DuckDuckGo HTML wraps results in <a class="result__snippet"> |
| 276 | + for part in html.split("result__snippet") { |
| 277 | + if let Some(start) = part.find('>') |
| 278 | + && let Some(end) = part[start..].find('<') |
| 279 | + { |
| 280 | + let snippet = &part[start+1..start+end]; |
| 281 | + let clean = snippet.replace("&", "&") |
| 282 | + .replace("<", "<") |
| 283 | + .replace(">", ">") |
| 284 | + .replace(""", "\"") |
| 285 | + .replace("<b>", "") |
| 286 | + .replace("</b>", ""); |
| 287 | + if clean.len() > 20 { |
| 288 | + snippets.push(clean.trim().to_string()); |
| 289 | + } |
| 290 | + } |
| 291 | + } |
| 292 | + snippets.truncate(5); // Top 5 results |
| 293 | + snippets |
| 294 | +} |
0 commit comments