Skip to content

Commit 8f4fe9a

Browse files
fabio-rovaiclaude
andcommitted
feat: web verification module — search each claim against the internet
verify.rs: extract verifiable claims from ontology nodes, search via DuckDuckGo HTML (no API key), tag each claim as verified/unverifiable/ contradicted. Uses reqwest (already a dep) + regex-lite + urlencoding. Claim types searched: - Citations: author names, years, publications - Quantified evidence: statistics with context - Evidence with named entities: projects, organisations, people Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aa17708 commit 8f4fe9a

4 files changed

Lines changed: 311 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ chrono = "0.4"
2323
uuid = { version = "1", features = ["v4"] }
2424
pdf-extract = "0.8"
2525
regex = "1"
26+
regex-lite = "0.1"
27+
urlencoding = "2"
2628
schemars = "1"
2729
serde_yaml = "0.9"
2830
dirs = "6"

crates/core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod server;
1515
pub mod firewall;
1616
pub mod gate;
1717
pub mod rules;
18+
pub mod verify;
1819
pub mod memory;
1920
pub mod batch;
2021
pub mod semantic;

crates/core/src/verify.rs

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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("&amp;", "&")
282+
.replace("&lt;", "<")
283+
.replace("&gt;", ">")
284+
.replace("&quot;", "\"")
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

Comments
 (0)