Skip to main content

langchain_rust/tools/wikipedia/
wikipedia.rs

1//! Wikipedia API Tool for LangChain Rust
2//!
3//! This module provides a tool for searching and fetching content from Wikipedia
4//! using the MediaWiki API. It implements the `Tool` trait to be used with
5//! LangChain agents and chains.
6//!
7//! # Examples
8//!
9//! ```no_run
10//! use langchain_rust::tools::{Tool, WikipediaQuery};
11//! use serde_json::json;
12//!
13//! #[tokio::main]
14//! async fn main() {
15//!     let wiki = WikipediaQuery::default();
16//!     let result = wiki.run(json!("Rust programming language")).await.unwrap();
17//!     println!("{}", result);
18//! }
19//! ```
20//!
21//! # Configuration
22//!
23//! Use [`WikipediaQueryOptions`] or the builder methods on [`WikipediaQuery`] to
24//! control how many results are fetched, how long each summary may be, and which
25//! language edition of Wikipedia is queried:
26//!
27//! ```no_run
28//! use langchain_rust::tools::WikipediaQuery;
29//!
30//! let wiki = WikipediaQuery::with_lang("es")
31//!     .with_top_k_results(2)
32//!     .with_max_doc_content_length(1000);
33//! ```
34//!
35//! # Output format
36//!
37//! Each matching article is rendered as a `Page: <title>` line followed by a
38//! `Summary: <intro extract>` line. Multiple articles are separated by a blank
39//! line. If nothing matches, a `No results found for query: ...` message is
40//! returned rather than an error, so agents can continue reasoning.
41
42use async_trait::async_trait;
43use serde::Deserialize;
44use serde_json::Value;
45use std::error::Error;
46
47use crate::tools::Tool;
48
49/// User agent sent with every request. Wikipedia rejects requests without one.
50const APP_USER_AGENT: &str = concat!(
51    env!("CARGO_PKG_NAME"),
52    "/",
53    env!("CARGO_PKG_VERSION"),
54    " https://github.com/Abraxas-365/langchain-rust"
55);
56
57/// Configuration options for Wikipedia queries
58#[derive(Debug, Clone)]
59pub struct WikipediaQueryOptions {
60    /// Maximum number of search results to return
61    pub top_k_results: usize,
62    /// Maximum length of each document content in characters
63    pub max_doc_content_length: usize,
64    /// Language code for Wikipedia (e.g., "en", "es", "fr").
65    ///
66    /// Must match `[a-z-]+`, since it is used to build the API hostname.
67    pub lang: String,
68}
69
70impl Default for WikipediaQueryOptions {
71    fn default() -> Self {
72        Self {
73            top_k_results: 3,
74            max_doc_content_length: 4000,
75            lang: "en".to_string(),
76        }
77    }
78}
79
80/// Wikipedia API response structures
81#[derive(Debug, Deserialize)]
82struct WikipediaSearchResponse {
83    query: SearchQuery,
84}
85
86#[derive(Debug, Deserialize)]
87struct SearchQuery {
88    search: Vec<SearchResult>,
89}
90
91#[derive(Debug, Deserialize)]
92struct SearchResult {
93    title: String,
94}
95
96#[derive(Debug, Deserialize)]
97struct WikipediaPageResponse {
98    query: PageQuery,
99}
100
101#[derive(Debug, Deserialize)]
102struct PageQuery {
103    pages: std::collections::HashMap<String, PageContent>,
104}
105
106#[derive(Debug, Deserialize)]
107struct PageContent {
108    title: String,
109    extract: Option<String>,
110}
111
112/// A tool for querying Wikipedia articles
113///
114/// This tool searches Wikipedia and returns summaries of the top matching articles.
115/// It's useful for answering general questions about people, places, companies, facts,
116/// historical events, and other subjects that have Wikipedia articles.
117///
118/// # Examples
119///
120/// ```no_run
121/// use langchain_rust::tools::{Tool, WikipediaQuery, WikipediaQueryOptions};
122/// use serde_json::json;
123///
124/// #[tokio::main]
125/// async fn main() {
126///     let options = WikipediaQueryOptions {
127///         top_k_results: 2,
128///         max_doc_content_length: 2000,
129///         lang: "en".to_string(),
130///     };
131///
132///     let wiki = WikipediaQuery::new(options);
133///     let result = wiki.run(json!("LangChain")).await.unwrap();
134///     println!("{}", result);
135/// }
136/// ```
137pub struct WikipediaQuery {
138    pub options: WikipediaQueryOptions,
139    client: reqwest::Client,
140    /// Overrides the `https://{lang}.wikipedia.org` origin. Used for tests.
141    base_url: Option<String>,
142}
143
144impl WikipediaQuery {
145    /// Creates a new WikipediaQuery with custom options
146    ///
147    /// # Arguments
148    ///
149    /// * `options` - Configuration options for Wikipedia queries
150    pub fn new(options: WikipediaQueryOptions) -> Self {
151        let client = reqwest::Client::builder()
152            .user_agent(APP_USER_AGENT)
153            .build()
154            .expect("failed to build reqwest client");
155
156        Self {
157            options,
158            client,
159            base_url: None,
160        }
161    }
162
163    /// Creates a WikipediaQuery with a specific language
164    ///
165    /// # Arguments
166    ///
167    /// * `lang` - Language code (e.g., "en", "es", "fr", "de")
168    pub fn with_lang(lang: impl Into<String>) -> Self {
169        Self::new(WikipediaQueryOptions {
170            lang: lang.into(),
171            ..Default::default()
172        })
173    }
174
175    /// Sets the maximum number of results to return
176    pub fn with_top_k_results(mut self, top_k: usize) -> Self {
177        self.options.top_k_results = top_k;
178        self
179    }
180
181    /// Sets the maximum content length per document
182    pub fn with_max_doc_content_length(mut self, max_len: usize) -> Self {
183        self.options.max_doc_content_length = max_len;
184        self
185    }
186
187    /// Overrides the API origin (e.g. `http://127.0.0.1:1234`) instead of
188    /// deriving it from the configured language. Mainly useful for pointing the
189    /// tool at a mock server in tests or at a self-hosted MediaWiki instance.
190    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
191        self.base_url = Some(base_url.into());
192        self
193    }
194
195    /// Builds the Wikipedia API URL for the configured language
196    fn get_api_url(&self) -> Result<String, Box<dyn Error>> {
197        let origin = match &self.base_url {
198            Some(base) => base.trim_end_matches('/').to_string(),
199            None => {
200                let lang = &self.options.lang;
201                let valid =
202                    !lang.is_empty() && lang.chars().all(|c| c.is_ascii_lowercase() || c == '-');
203                if !valid {
204                    return Err(format!(
205                        "Invalid Wikipedia language code '{}': must match [a-z-]+",
206                        lang
207                    )
208                    .into());
209                }
210                format!("https://{}.wikipedia.org", lang)
211            }
212        };
213        Ok(format!("{}/w/api.php", origin))
214    }
215
216    /// Searches Wikipedia for articles matching the query
217    async fn search(&self, query: &str) -> Result<Vec<String>, Box<dyn Error>> {
218        let api_url = self.get_api_url()?;
219        let params = [
220            ("action", "query"),
221            ("list", "search"),
222            ("srsearch", query),
223            ("format", "json"),
224            ("srlimit", &self.options.top_k_results.to_string()),
225        ];
226
227        let response = self
228            .client
229            .get(&api_url)
230            .query(&params)
231            .send()
232            .await?
233            .error_for_status()?
234            .json::<WikipediaSearchResponse>()
235            .await?;
236
237        Ok(response.query.search.into_iter().map(|r| r.title).collect())
238    }
239
240    /// Fetches the content of a specific Wikipedia page
241    async fn fetch_page(&self, title: &str) -> Result<String, Box<dyn Error>> {
242        let api_url = self.get_api_url()?;
243        let params = [
244            ("action", "query"),
245            ("prop", "extracts"),
246            ("titles", title),
247            ("format", "json"),
248            ("explaintext", "true"),
249            ("exintro", "true"),
250        ];
251
252        let response = self
253            .client
254            .get(&api_url)
255            .query(&params)
256            .send()
257            .await?
258            .error_for_status()?
259            .json::<WikipediaPageResponse>()
260            .await?;
261
262        match response.query.pages.values().next() {
263            Some(page) => {
264                let extract = page.extract.as_deref().unwrap_or("");
265                let truncated: String = extract
266                    .chars()
267                    .take(self.options.max_doc_content_length)
268                    .collect();
269                Ok(format!("Page: {}\nSummary: {}", page.title, truncated))
270            }
271            None => Err("Page not found".into()),
272        }
273    }
274}
275
276impl Default for WikipediaQuery {
277    fn default() -> Self {
278        Self::new(WikipediaQueryOptions::default())
279    }
280}
281
282#[async_trait]
283impl Tool for WikipediaQuery {
284    fn name(&self) -> String {
285        "wikipedia-api".to_string()
286    }
287
288    fn description(&self) -> String {
289        "A wrapper around Wikipedia. \
290         Useful for when you need to answer general questions about \
291         people, places, companies, facts, historical events, or other subjects. \
292         Input should be a search query."
293            .to_string()
294    }
295
296    async fn run(&self, input: Value) -> Result<String, Box<dyn Error>> {
297        let query = match input {
298            Value::String(s) => s,
299            Value::Object(mut map) => map
300                .remove("input")
301                .and_then(|v| v.as_str().map(String::from))
302                .ok_or("Invalid input format: expected 'input' field")?,
303            _ => return Err("Input must be a string or object with 'input' field".into()),
304        };
305
306        if query.trim().is_empty() {
307            return Err("Query cannot be empty".into());
308        }
309
310        // Search for relevant pages
311        let titles = self.search(&query).await?;
312
313        if titles.is_empty() {
314            return Ok(format!("No results found for query: {}", query));
315        }
316
317        // Fetch content for all found pages, skipping any that fail individually
318        let mut results = Vec::new();
319        for title in &titles {
320            if let Ok(content) = self.fetch_page(title).await {
321                results.push(content);
322            }
323        }
324
325        if results.is_empty() {
326            return Ok("No page content could be retrieved".to_string());
327        }
328
329        Ok(results.join("\n\n"))
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use mockito::Matcher;
337    use serde_json::json;
338
339    #[test]
340    fn test_default_options() {
341        let options = WikipediaQueryOptions::default();
342        assert_eq!(options.top_k_results, 3);
343        assert_eq!(options.max_doc_content_length, 4000);
344        assert_eq!(options.lang, "en");
345    }
346
347    #[test]
348    fn test_custom_options() {
349        let options = WikipediaQueryOptions {
350            top_k_results: 5,
351            max_doc_content_length: 2000,
352            lang: "es".to_string(),
353        };
354        assert_eq!(options.top_k_results, 5);
355        assert_eq!(options.max_doc_content_length, 2000);
356        assert_eq!(options.lang, "es");
357    }
358
359    #[test]
360    fn test_tool_name() {
361        let wiki = WikipediaQuery::default();
362        assert_eq!(wiki.name(), "wikipedia-api");
363    }
364
365    #[test]
366    fn test_tool_description() {
367        let wiki = WikipediaQuery::default();
368        let desc = wiki.description();
369        assert!(desc.contains("Wikipedia"));
370        assert!(desc.contains("search query"));
371    }
372
373    #[test]
374    fn test_builder_pattern() {
375        let wiki = WikipediaQuery::default()
376            .with_top_k_results(5)
377            .with_max_doc_content_length(2000);
378
379        assert_eq!(wiki.options.top_k_results, 5);
380        assert_eq!(wiki.options.max_doc_content_length, 2000);
381    }
382
383    #[test]
384    fn test_with_lang() {
385        let wiki = WikipediaQuery::with_lang("fr");
386        assert_eq!(wiki.options.lang, "fr");
387        assert_eq!(
388            wiki.get_api_url().unwrap(),
389            "https://fr.wikipedia.org/w/api.php"
390        );
391    }
392
393    #[test]
394    fn test_api_url_construction() {
395        let wiki_en = WikipediaQuery::default();
396        assert_eq!(
397            wiki_en.get_api_url().unwrap(),
398            "https://en.wikipedia.org/w/api.php"
399        );
400
401        let wiki_es = WikipediaQuery::with_lang("es");
402        assert_eq!(
403            wiki_es.get_api_url().unwrap(),
404            "https://es.wikipedia.org/w/api.php"
405        );
406
407        let wiki_hyphen = WikipediaQuery::with_lang("zh-yue");
408        assert_eq!(
409            wiki_hyphen.get_api_url().unwrap(),
410            "https://zh-yue.wikipedia.org/w/api.php"
411        );
412    }
413
414    #[test]
415    fn test_invalid_lang_rejected() {
416        for bad in ["", "EN", "en.evil.com", "en/", "e n", "ja_JP"] {
417            let wiki = WikipediaQuery::with_lang(bad);
418            let err = wiki.get_api_url().unwrap_err().to_string();
419            assert!(err.contains("Invalid Wikipedia language code"), "{bad:?}");
420        }
421    }
422
423    #[test]
424    fn test_base_url_override() {
425        let wiki = WikipediaQuery::default().with_base_url("http://127.0.0.1:9999/");
426        assert_eq!(
427            wiki.get_api_url().unwrap(),
428            "http://127.0.0.1:9999/w/api.php"
429        );
430    }
431
432    #[tokio::test]
433    async fn test_empty_query_error() {
434        let wiki = WikipediaQuery::default();
435        let result = wiki.run(json!("")).await;
436        assert!(result.is_err());
437        assert!(result.unwrap_err().to_string().contains("empty"));
438    }
439
440    #[tokio::test]
441    async fn test_invalid_input_format() {
442        let wiki = WikipediaQuery::default();
443        let result = wiki.run(json!(123)).await;
444        assert!(result.is_err());
445    }
446
447    #[tokio::test]
448    async fn test_invalid_lang_errors_at_run() {
449        let wiki = WikipediaQuery::with_lang("en.evil.com");
450        let err = wiki.run(json!("anything")).await.unwrap_err().to_string();
451        assert!(err.contains("Invalid Wikipedia language code"));
452    }
453
454    // ---------------- mock server tests ----------------
455
456    fn search_body(titles: &[&str]) -> String {
457        json!({
458            "query": {
459                "search": titles.iter().map(|t| json!({"title": t})).collect::<Vec<_>>()
460            }
461        })
462        .to_string()
463    }
464
465    fn page_body(title: &str, extract: &str) -> String {
466        json!({
467            "query": {
468                "pages": {
469                    "1": {"title": title, "extract": extract}
470                }
471            }
472        })
473        .to_string()
474    }
475
476    fn mock_search(
477        server: &mut mockito::ServerGuard,
478        query: &str,
479        titles: &[&str],
480    ) -> mockito::Mock {
481        server
482            .mock("GET", "/w/api.php")
483            .match_query(Matcher::AllOf(vec![
484                Matcher::UrlEncoded("action".into(), "query".into()),
485                Matcher::UrlEncoded("list".into(), "search".into()),
486                Matcher::UrlEncoded("srsearch".into(), query.into()),
487            ]))
488            .with_header("content-type", "application/json")
489            .with_body(search_body(titles))
490            .create()
491    }
492
493    fn mock_page(server: &mut mockito::ServerGuard, title: &str, extract: &str) -> mockito::Mock {
494        server
495            .mock("GET", "/w/api.php")
496            .match_query(Matcher::AllOf(vec![
497                Matcher::UrlEncoded("prop".into(), "extracts".into()),
498                Matcher::UrlEncoded("titles".into(), title.into()),
499            ]))
500            .with_header("content-type", "application/json")
501            .with_body(page_body(title, extract))
502            .create()
503    }
504
505    #[tokio::test]
506    async fn test_run_against_mock_server() {
507        let mut server = mockito::Server::new_async().await;
508        let search = mock_search(
509            &mut server,
510            "rust",
511            &["Rust (programming language)", "Rust"],
512        );
513        let page1 = mock_page(
514            &mut server,
515            "Rust (programming language)",
516            "Rust is a systems language.",
517        );
518        let page2 = mock_page(&mut server, "Rust", "Rust is an iron oxide.");
519
520        let wiki = WikipediaQuery::default().with_base_url(server.url());
521        let out = wiki.run(json!("rust")).await.unwrap();
522
523        search.assert();
524        page1.assert();
525        page2.assert();
526        assert_eq!(
527            out,
528            "Page: Rust (programming language)\nSummary: Rust is a systems language.\n\n\
529             Page: Rust\nSummary: Rust is an iron oxide."
530        );
531    }
532
533    #[tokio::test]
534    async fn test_object_input_against_mock_server() {
535        let mut server = mockito::Server::new_async().await;
536        let _search = mock_search(&mut server, "ferris", &["Ferris"]);
537        let _page = mock_page(&mut server, "Ferris", "Ferris is a crab.");
538
539        let wiki = WikipediaQuery::default().with_base_url(server.url());
540        let out = wiki.run(json!({"input": "ferris"})).await.unwrap();
541        assert_eq!(out, "Page: Ferris\nSummary: Ferris is a crab.");
542    }
543
544    #[tokio::test]
545    async fn test_no_results_from_mock_server() {
546        let mut server = mockito::Server::new_async().await;
547        let _search = mock_search(&mut server, "nothing", &[]);
548
549        let wiki = WikipediaQuery::default().with_base_url(server.url());
550        let out = wiki.run(json!("nothing")).await.unwrap();
551        assert_eq!(out, "No results found for query: nothing");
552    }
553
554    #[tokio::test]
555    async fn test_truncation_counts_chars_not_bytes() {
556        let mut server = mockito::Server::new_async().await;
557        let _search = mock_search(&mut server, "日本", &["日本"]);
558        // Multi-byte characters: byte slicing at 5 would panic or split a char.
559        let _page = mock_page(&mut server, "日本", "日本は島国です。");
560
561        let wiki = WikipediaQuery::default()
562            .with_base_url(server.url())
563            .with_max_doc_content_length(5);
564        let out = wiki.run(json!("日本")).await.unwrap();
565        assert_eq!(out, "Page: 日本\nSummary: 日本は島国");
566    }
567
568    #[tokio::test]
569    async fn test_zero_max_length_does_not_panic() {
570        let mut server = mockito::Server::new_async().await;
571        let _search = mock_search(&mut server, "x", &["X"]);
572        let _page = mock_page(&mut server, "X", "some text");
573
574        let wiki = WikipediaQuery::default()
575            .with_base_url(server.url())
576            .with_max_doc_content_length(0);
577        let out = wiki.run(json!("x")).await.unwrap();
578        assert_eq!(out, "Page: X\nSummary: ");
579    }
580
581    #[tokio::test]
582    async fn test_search_http_error_is_propagated() {
583        let mut server = mockito::Server::new_async().await;
584        let _search = server
585            .mock("GET", "/w/api.php")
586            .match_query(Matcher::UrlEncoded("list".into(), "search".into()))
587            .with_status(503)
588            .with_body("<html>Service Unavailable</html>")
589            .create();
590
591        let wiki = WikipediaQuery::default().with_base_url(server.url());
592        let err = wiki.run(json!("rust")).await.unwrap_err().to_string();
593        assert!(err.contains("503"), "unexpected error: {err}");
594    }
595
596    #[tokio::test]
597    async fn test_failed_page_fetch_is_skipped() {
598        let mut server = mockito::Server::new_async().await;
599        let _search = mock_search(&mut server, "mixed", &["Good", "Bad"]);
600        let _good = mock_page(&mut server, "Good", "fine");
601        let _bad = server
602            .mock("GET", "/w/api.php")
603            .match_query(Matcher::AllOf(vec![
604                Matcher::UrlEncoded("prop".into(), "extracts".into()),
605                Matcher::UrlEncoded("titles".into(), "Bad".into()),
606            ]))
607            .with_status(500)
608            .create();
609
610        let wiki = WikipediaQuery::default().with_base_url(server.url());
611        let out = wiki.run(json!("mixed")).await.unwrap();
612        assert_eq!(out, "Page: Good\nSummary: fine");
613    }
614
615    #[tokio::test]
616    async fn test_all_page_fetches_failing_returns_message() {
617        let mut server = mockito::Server::new_async().await;
618        let _search = mock_search(&mut server, "broken", &["Bad"]);
619        let _bad = server
620            .mock("GET", "/w/api.php")
621            .match_query(Matcher::UrlEncoded("prop".into(), "extracts".into()))
622            .with_status(500)
623            .create();
624
625        let wiki = WikipediaQuery::default().with_base_url(server.url());
626        let out = wiki.run(json!("broken")).await.unwrap();
627        assert_eq!(out, "No page content could be retrieved");
628    }
629
630    // Integration tests - these require internet connection
631    #[tokio::test]
632    #[ignore] // Remove ignore to run with network access
633    async fn test_search_integration() {
634        let wiki = WikipediaQuery::default();
635        let titles = wiki.search("Rust programming language").await.unwrap();
636        assert!(!titles.is_empty());
637        assert!(titles.len() <= 3); // Default top_k_results
638    }
639
640    #[tokio::test]
641    #[ignore] // Remove ignore to run with network access
642    async fn test_fetch_page_integration() {
643        let wiki = WikipediaQuery::default();
644        let content = wiki
645            .fetch_page("Rust (programming language)")
646            .await
647            .unwrap();
648        assert!(content.contains("Page:"));
649        assert!(content.contains("Summary:"));
650    }
651
652    #[tokio::test]
653    #[ignore] // Remove ignore to run with network access
654    async fn test_run_integration() {
655        let wiki = WikipediaQuery::default();
656        let result = wiki.run(json!("Rust programming language")).await.unwrap();
657        assert!(result.contains("Page:"));
658        assert!(result.contains("Summary:"));
659    }
660
661    #[tokio::test]
662    #[ignore] // Remove ignore to run with network access
663    async fn test_spanish_wikipedia() {
664        let wiki = WikipediaQuery::with_lang("es");
665        let result = wiki.run(json!("Rust lenguaje programación")).await.unwrap();
666        assert!(result.contains("Page:"));
667    }
668
669    #[tokio::test]
670    #[ignore] // Remove ignore to run with network access
671    async fn test_content_truncation() {
672        let wiki = WikipediaQuery::default().with_max_doc_content_length(100);
673        let result = wiki.run(json!("United States")).await.unwrap();
674        // The summary for each page should be truncated
675        for line in result.lines() {
676            if let Some(summary) = line.strip_prefix("Summary: ") {
677                assert!(summary.chars().count() <= 100);
678            }
679        }
680    }
681
682    #[tokio::test]
683    #[ignore] // Remove ignore to run with network access
684    async fn test_top_k_results() {
685        let wiki = WikipediaQuery::default().with_top_k_results(2);
686        let result = wiki.run(json!("programming")).await.unwrap();
687        // Count number of "Page:" occurrences
688        let page_count = result.matches("Page:").count();
689        assert!(page_count <= 2);
690    }
691
692    #[tokio::test]
693    #[ignore] // Remove ignore to run with network access
694    async fn test_nonexistent_page() {
695        let wiki = WikipediaQuery::default();
696        let result = wiki.run(json!("xyzabc123nonexistentpage999")).await;
697        // Should either return no results or handle gracefully
698        assert!(result.is_ok());
699    }
700}