use std::cmp::Ordering;
use std::time::Duration;
use anyhow::{Context, Result};
use reqwest::StatusCode;
use scraper::{Html, Selector};
use crate::entities::profile::ToolId;
use crate::shared::api::{EmbedRole, Embedder};
use super::{Tool, ToolContext, ToolOutcome};
pub(crate) const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/124.0 Safari/537.36";
pub(crate) const ACCEPT_HTML: &str =
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
pub(crate) const ACCEPT_LANGUAGE: &str = "en-US,en;q=0.9,ru;q=0.8";
const DEFAULT_MAX_RESULTS: usize = 5;
const MAX_RESULTS_CAP: usize = 10;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const MAX_CONTENT_CHARS: usize = 1500;
const MIN_FRAGMENT_CHARS: usize = 40;
const RERANK_EMBED_CHARS: usize = 800;
const DDG: &str = "ddg";
const PROVIDER_COOLDOWN: Duration = Duration::from_secs(300);
#[derive(Debug, Clone, Copy, PartialEq)]
enum Method {
PostForm,
GetQuery,
}
struct Provider {
name: &'static str,
family: &'static str,
url: &'static str,
method: Method,
link_sel: &'static str,
title_sel: &'static str,
snippet_sel: &'static str,
}
const PROVIDERS: &[Provider] = &[
Provider {
name: "DuckDuckGo lite",
family: DDG,
url: "https://lite.duckduckgo.com/lite/",
method: Method::PostForm,
link_sel: "a.result-link",
title_sel: "a.result-link",
snippet_sel: "td.result-snippet",
},
Provider {
name: "DuckDuckGo html",
family: DDG,
url: "https://html.duckduckgo.com/html/",
method: Method::PostForm,
link_sel: "a.result__a",
title_sel: "a.result__a",
snippet_sel: "a.result__snippet",
},
Provider {
name: "Mojeek",
family: "mojeek",
url: "https://www.mojeek.com/search",
method: Method::GetQuery,
link_sel: "a.title",
title_sel: "a.title",
snippet_sel: "p.s",
},
Provider {
name: "Ecosia",
family: "ecosia",
url: "https://www.ecosia.org/search",
method: Method::GetQuery,
link_sel: r#"a[data-test-id="result-link"]"#,
title_sel: r#"[data-test-id="result-title"]"#,
snippet_sel: r#"[data-test-id="web-result-description"]"#,
},
];
#[derive(Debug, Clone, PartialEq)]
pub struct SearchResult {
pub title: String,
pub url: String,
pub snippet: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ApiBackend {
slot: crate::shared::secrets::SearchSlot,
key: String,
}
impl ApiBackend {
fn name(&self) -> &'static str {
self.slot.display_name()
}
fn family(&self) -> &'static str {
use crate::shared::secrets::SearchSlot::*;
match self.slot {
Tavily => "tavily",
}
}
}
pub fn keyed_backends(
provider: crate::shared::config::WebProvider,
keys: &[(crate::shared::secrets::SearchSlot, String)],
) -> Vec<ApiBackend> {
if provider == crate::shared::config::WebProvider::FreeOnly {
return Vec::new();
}
keys.iter()
.filter(|(_, key)| !key.trim().is_empty())
.map(|(slot, key)| ApiBackend {
slot: *slot,
key: key.clone(),
})
.collect()
}
#[derive(Clone, Copy)]
enum Backend<'a> {
Api(&'a ApiBackend),
Scraped(&'static Provider),
}
impl Backend<'_> {
fn name(&self) -> &'static str {
match self {
Self::Api(a) => a.name(),
Self::Scraped(p) => p.name,
}
}
fn family(&self) -> &'static str {
match self {
Self::Api(a) => a.family(),
Self::Scraped(p) => p.family,
}
}
}
enum Attempt {
Results(Vec<SearchResult>),
Empty,
Blocked,
}
pub struct WebSearch {
http: crate::shared::net::GuardedClient,
fetch_content_default: bool,
api: Vec<ApiBackend>,
cooldown: std::sync::Mutex<Vec<(&'static str, std::time::Instant)>>,
}
impl Default for WebSearch {
fn default() -> Self {
Self::new(
true,
crate::shared::net::AddressPolicy::PublicOnly,
Vec::new(),
)
}
}
impl WebSearch {
pub fn new(
fetch_content_default: bool,
policy: crate::shared::net::AddressPolicy,
api: Vec<ApiBackend>,
) -> Self {
let http = crate::shared::net::GuardedClient::new(policy, REQUEST_TIMEOUT);
Self {
http,
fetch_content_default,
api,
cooldown: std::sync::Mutex::new(Vec::new()),
}
}
fn mark_blocked(&self, family: &'static str) {
let Ok(mut seen) = self.cooldown.lock() else {
return; };
let now = std::time::Instant::now();
match seen.iter_mut().find(|(f, _)| *f == family) {
Some((_, at)) => *at = now,
None => seen.push((family, now)),
}
}
fn blocked_at(&self, family: &str) -> Option<std::time::Instant> {
let seen = self.cooldown.lock().ok()?;
let now = std::time::Instant::now();
seen.iter()
.find(|(f, _)| *f == family)
.map(|(_, at)| *at)
.filter(|at| now.duration_since(*at) < PROVIDER_COOLDOWN)
}
fn backend_order(&self) -> Vec<Backend<'_>> {
let all = self
.api
.iter()
.map(Backend::Api)
.chain(PROVIDERS.iter().map(Backend::Scraped));
let mut order: Vec<(Option<std::time::Instant>, usize, Backend<'_>)> = all
.enumerate()
.map(|(i, b)| (self.blocked_at(b.family()), i, b))
.collect();
order.sort_by_key(|(at, i, _)| (*at, *i));
order.into_iter().map(|(_, _, b)| b).collect()
}
async fn fetch(
&self,
provider: &Provider,
query: &str,
loc: &crate::shared::i18n::Locale,
) -> Result<Option<String>> {
let req = match provider.method {
Method::PostForm => self
.http
.post(provider.url)
.with_context(|| {
loc.tf("tool.web_search.err.url_parse", &[("name", provider.name)])
})?
.form(&[("q", query)]),
Method::GetQuery => {
let mut url = reqwest::Url::parse(provider.url).with_context(|| {
loc.tf("tool.web_search.err.url_parse", &[("name", provider.name)])
})?;
url.query_pairs_mut().append_pair("q", query);
self.http.get(url.as_str()).with_context(|| {
loc.tf("tool.web_search.err.url_parse", &[("name", provider.name)])
})?
}
};
let resp = req
.header(reqwest::header::USER_AGENT, USER_AGENT)
.header(reqwest::header::ACCEPT, ACCEPT_HTML)
.header(reqwest::header::ACCEPT_LANGUAGE, ACCEPT_LANGUAGE)
.send()
.await
.with_context(|| loc.tf("tool.web_search.err.request", &[("name", provider.name)]))?;
let status = resp.status();
let body = resp
.text()
.await
.with_context(|| loc.tf("tool.web_search.err.read", &[("name", provider.name)]))?;
if is_throttled(status, &body) {
return Ok(None);
}
if !status.is_success() {
anyhow::bail!(loc.tf(
"tool.web_search.err.status",
&[("name", provider.name), ("status", &status.to_string())]
));
}
Ok(Some(body))
}
async fn fetch_content(&self, url: &str) -> Option<String> {
let request = match self.http.get(url) {
Ok(r) => r,
Err(err) => {
tracing::debug!(url, %err, "web search: the result page's address is refused");
return None;
}
};
let resp = match request
.header(reqwest::header::USER_AGENT, USER_AGENT)
.header(reqwest::header::ACCEPT, ACCEPT_HTML)
.header(reqwest::header::ACCEPT_LANGUAGE, ACCEPT_LANGUAGE)
.send()
.await
{
Ok(r) => r,
Err(err) => {
tracing::debug!(url, error = %err, "web search: the page failed to load");
return None;
}
};
if !resp.status().is_success() {
tracing::debug!(url, status = %resp.status(), "web search: the page returned a non-2xx status");
return None;
}
let is_html = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|t| t.contains("html"))
.unwrap_or(true);
if !is_html {
return None;
}
let body = match crate::shared::http_text::read(resp).await {
Ok(body) => body,
Err(err) => {
tracing::debug!(url, %err, "web search: the page body could not be read");
return None;
}
};
let text = extract_readable(&body.text, MAX_CONTENT_CHARS);
if text.is_empty() {
tracing::debug!(url, "web search: no readable text extracted from the page");
}
(!text.is_empty()).then_some(text)
}
async fn enrich_with_content(&self, results: &mut [SearchResult]) {
let pending = needs_content(results);
let contents = futures_util::future::join_all(
pending.iter().map(|&i| self.fetch_content(&results[i].url)),
)
.await;
for (i, c) in pending.into_iter().zip(contents) {
if let Some(c) = c {
results[i].content = c;
}
}
}
async fn run_providers(
&self,
query: &str,
max: usize,
want_content: bool,
loc: &crate::shared::i18n::Locale,
) -> (
Vec<SearchResult>,
bool,
Option<anyhow::Error>,
Option<&'static str>,
) {
let mut got_clean_page = false;
let mut last_err: Option<anyhow::Error> = None;
let mut results = Vec::new();
let mut answered_by = None;
for backend in self.backend_order() {
match self
.run_backend(backend, query, max, want_content, loc)
.await
{
Ok(Attempt::Results(r)) => {
results = r;
answered_by = Some(backend.name());
break;
}
Ok(Attempt::Empty) => got_clean_page = true,
Ok(Attempt::Blocked) => {
tracing::debug!(
provider = backend.name(),
"web search: blocked, trying the next backend"
);
self.mark_blocked(backend.family());
}
Err(err) => {
tracing::warn!(provider = backend.name(), error = %err, "web search: backend error");
last_err = Some(err);
}
}
}
(results, got_clean_page, last_err, answered_by)
}
async fn run_backend(
&self,
backend: Backend<'_>,
query: &str,
max: usize,
want_content: bool,
loc: &crate::shared::i18n::Locale,
) -> Result<Attempt> {
match backend {
Backend::Scraped(p) => self.run_scraped(p, query, max, loc).await,
Backend::Api(a) => self.run_api(a, query, max, want_content, loc).await,
}
}
async fn run_scraped(
&self,
provider: &'static Provider,
query: &str,
max: usize,
loc: &crate::shared::i18n::Locale,
) -> Result<Attempt> {
let Some(html) = self.fetch(provider, query, loc).await? else {
return Ok(Attempt::Blocked);
};
let r = parse_results(
&html,
provider.link_sel,
provider.title_sel,
provider.snippet_sel,
max,
);
if !r.is_empty() {
return Ok(Attempt::Results(r));
}
Ok(if is_challenge_page(&html, query) {
Attempt::Blocked
} else {
Attempt::Empty
})
}
async fn run_api(
&self,
api: &ApiBackend,
query: &str,
max: usize,
want_content: bool,
loc: &crate::shared::i18n::Locale,
) -> Result<Attempt> {
use crate::shared::secrets::SearchSlot::*;
let name = api.name();
let http = self.http.unchecked_inner();
let req = match api.slot {
Tavily => http
.post("https://api.tavily.com/search")
.bearer_auth(&api.key)
.json(&serde_json::json!({
"query": query,
"max_results": max,
"include_raw_content": want_content,
})),
};
let resp = req
.send()
.await
.with_context(|| loc.tf("tool.web_search.err.request", &[("name", name)]))?;
let status = resp.status();
if status == StatusCode::TOO_MANY_REQUESTS {
return Ok(Attempt::Blocked);
}
if matches!(
status,
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN | StatusCode::PAYMENT_REQUIRED
) {
anyhow::bail!(loc.tf("tool.web_search.err.key_rejected", &[("name", name)]));
}
if !status.is_success() {
anyhow::bail!(loc.tf(
"tool.web_search.err.status",
&[("name", name), ("status", &status.to_string())]
));
}
let body: serde_json::Value = resp
.json()
.await
.with_context(|| loc.tf("tool.web_search.err.read", &[("name", name)]))?;
let results = match api.slot {
Tavily => parse_tavily(&body, max),
};
Ok(if results.is_empty() {
Attempt::Empty
} else {
Attempt::Results(results)
})
}
}
fn needs_content(results: &[SearchResult]) -> Vec<usize> {
results
.iter()
.enumerate()
.filter(|(_, r)| r.content.is_empty())
.map(|(i, _)| i)
.collect()
}
fn parse_tavily(body: &serde_json::Value, max: usize) -> Vec<SearchResult> {
let Some(items) = body.get("results").and_then(|v| v.as_array()) else {
return Vec::new();
};
items
.iter()
.filter_map(|it| {
let url = it.get("url")?.as_str()?.trim();
let title = it.get("title").and_then(|v| v.as_str()).unwrap_or_default();
(!url.is_empty() && !title.is_empty()).then(|| SearchResult {
title: collapse_ws(title),
url: url.to_string(),
snippet: collapse_ws(it.get("content").and_then(|v| v.as_str()).unwrap_or("")),
content: it
.get("raw_content")
.and_then(|v| v.as_str())
.map(|c| truncate_chars(c.trim(), MAX_CONTENT_CHARS))
.unwrap_or_default(),
})
})
.take(max)
.collect()
}
async fn rerank_by_embeddings(
embedder: &dyn Embedder,
query: &str,
results: &mut Vec<SearchResult>,
) {
if results.len() < 2 {
return; }
let query_vec = match embedder
.embed(vec![query.to_string()], EmbedRole::Query)
.await
{
Ok(mut v) if !v.is_empty() => v.remove(0),
Ok(_) => {
tracing::debug!("reranking skipped: the embedder returned no query vector");
return;
}
Err(e) => {
tracing::debug!(error = %e, "reranking skipped: the embedder is unavailable");
return;
}
};
let texts: Vec<String> = results.iter().map(rerank_text).collect();
let vecs = match embedder.embed(texts, EmbedRole::Passage).await {
Ok(v) if v.len() == results.len() => v,
Ok(_) => return, Err(err) => {
tracing::debug!(error = %err, "web search: reranking unavailable, keeping the provider order");
return;
}
};
let order = rerank_order(&query_vec, &vecs);
*results = order.into_iter().map(|i| results[i].clone()).collect();
}
fn rerank_text(r: &SearchResult) -> String {
let body = if r.content.is_empty() {
r.snippet.clone()
} else {
truncate_chars(&r.content, RERANK_EMBED_CHARS)
};
format!("{}\n{}", r.title, body).trim().to_string()
}
fn rerank_order(query_vec: &[f32], doc_vecs: &[Vec<f32>]) -> Vec<usize> {
let sims: Vec<f32> = doc_vecs.iter().map(|v| cosine(query_vec, v)).collect();
let mut order: Vec<usize> = (0..doc_vecs.len()).collect();
order.sort_by(|&a, &b| sims[b].partial_cmp(&sims[a]).unwrap_or(Ordering::Equal));
order
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let mut dot = 0.0f32;
let mut na = 0.0f32;
let mut nb = 0.0f32;
for (x, y) in a.iter().zip(b) {
dot += x * y;
na += x * x;
nb += y * y;
}
if na == 0.0 || nb == 0.0 {
return 0.0;
}
dot / (na.sqrt() * nb.sqrt())
}
pub(crate) fn extract_readable(html: &str, max_chars: usize) -> String {
let doc = Html::parse_document(html);
let scope_sel = Selector::parse("article, main").unwrap();
let para_sel = Selector::parse("p, li").unwrap();
let take = |el: scraper::ElementRef| -> Option<String> {
if in_boilerplate(el) {
return None;
}
let t = collapse_ws(&el.text().collect::<String>());
(t.chars().count() >= MIN_FRAGMENT_CHARS).then_some(t)
};
let mut parts: Vec<String> = doc
.select(&scope_sel)
.flat_map(|root| root.select(¶_sel).filter_map(take).collect::<Vec<_>>())
.collect();
if parts.is_empty() {
parts = doc.select(¶_sel).filter_map(take).collect();
}
let mut out = String::new();
for p in parts {
if out.chars().count() >= max_chars {
break;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(&p);
}
truncate_chars(&out, max_chars)
}
pub(crate) fn extract_rich(html: &str, max_chars: usize) -> String {
let doc = Html::parse_document(html);
let scope_sel = Selector::parse("article, main").unwrap();
let block_sel = Selector::parse(
r#"h1, h2, h3, h4, h5, h6, p, li, blockquote, pre, div[class*="language-"]"#,
)
.unwrap();
let mut blocks: Vec<Block> = doc
.select(&scope_sel)
.flat_map(|root| collect_blocks(root, &block_sel))
.collect();
if blocks.is_empty() {
blocks = collect_blocks(doc.root_element(), &block_sel);
}
join_blocks(blocks, max_chars)
}
fn collect_blocks(root: scraper::ElementRef, block_sel: &Selector) -> Vec<Block> {
let mut emitted = std::collections::HashSet::new();
let mut out = Vec::new();
for el in root.select(block_sel) {
if in_boilerplate(el) || el.ancestors().any(|a| emitted.contains(&a.id())) {
continue;
}
if let Some(b) = block_from(el) {
emitted.insert(el.id());
out.push(b);
}
}
out
}
fn join_blocks(blocks: Vec<Block>, max_chars: usize) -> String {
let mut out = String::new();
let mut prev: Option<Kind> = None;
for b in blocks {
if out.chars().count() >= max_chars {
break;
}
if let Some(p) = prev {
out.push_str(if p == Kind::Item && b.kind == Kind::Item {
"\n"
} else {
"\n\n"
});
}
out.push_str(&b.text);
prev = Some(b.kind);
}
truncate_chars(&out, max_chars)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Heading,
Item,
Other,
}
struct Block {
kind: Kind,
text: String,
}
fn block_from(el: scraper::ElementRef) -> Option<Block> {
let name = el.value().name();
if let Some(level) = heading_level(name) {
let t = collapse_ws(&el.text().collect::<String>());
return (!t.is_empty()).then(|| Block {
kind: Kind::Heading,
text: format!("{} {t}", "#".repeat(level)),
});
}
if name == "pre" || has_language_class(el) {
let raw = el.text().collect::<String>();
let code = raw.trim_matches('\n').trim_end();
return (!code.trim().is_empty()).then(|| Block {
kind: Kind::Other,
text: format!("```{}\n{code}\n```", code_language(el).unwrap_or_default()),
});
}
let t = collapse_ws(&el.text().collect::<String>());
if t.chars().count() < MIN_FRAGMENT_CHARS {
return None;
}
Some(if name == "li" {
Block {
kind: Kind::Item,
text: format!("- {t}"),
}
} else {
Block {
kind: Kind::Other,
text: t,
}
})
}
fn heading_level(name: &str) -> Option<usize> {
name.strip_prefix('h')
.and_then(|n| n.parse().ok())
.filter(|l| (1..=6).contains(l))
}
fn has_language_class(el: scraper::ElementRef) -> bool {
el.value().classes().any(is_language_class)
}
fn is_language_class(c: &str) -> bool {
c.starts_with("language-") || c.starts_with("lang-")
}
fn code_language(el: scraper::ElementRef) -> Option<String> {
let from = |e: scraper::ElementRef| -> Option<String> {
e.value()
.classes()
.find(|c| is_language_class(c))
.and_then(|c| c.split_once('-'))
.map(|(_, lang)| lang.to_string())
.filter(|l| !l.is_empty())
};
from(el).or_else(|| {
let code_sel = Selector::parse("code").unwrap();
el.select(&code_sel).next().and_then(from)
})
}
fn in_boilerplate(el: scraper::ElementRef) -> bool {
el.ancestors().any(|n| {
n.value()
.as_element()
.map(|e| matches!(e.name(), "nav" | "header" | "footer" | "aside"))
.unwrap_or(false)
})
}
pub(crate) fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
s.chars().take(max).collect()
}
#[async_trait::async_trait]
impl Tool for WebSearch {
fn id(&self) -> ToolId {
super::WEB_SEARCH_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::ExternalWorld
}
fn ui_label(&self) -> &'static str {
"web search"
}
fn gate(&self) -> Option<crate::features::tools::meta::ToolGate> {
Some(crate::features::tools::meta::ToolGate::Web)
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.web_search.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {"type": "string"},
"max_results": {"type": "integer", "minimum": 1, "maximum": MAX_RESULTS_CAP},
"fetch_content": {
"type": "boolean",
"description": loc.t("tool.web_search.param.fetch_content")
}
},
"required": ["query"]
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let query = args
.get("query")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.web_search.err.query_empty")))?;
let max = args
.get("max_results")
.and_then(|v| v.as_u64())
.map(|n| (n as usize).clamp(1, MAX_RESULTS_CAP))
.unwrap_or(DEFAULT_MAX_RESULTS);
let fetch_content = args
.get("fetch_content")
.and_then(|v| v.as_bool())
.unwrap_or(self.fetch_content_default);
let (mut results, got_clean_page, last_err, answered_by) =
self.run_providers(query, max, fetch_content, ctx.loc).await;
if results.is_empty() {
return no_results_outcome(got_clean_page, last_err, ctx.loc);
}
if fetch_content {
self.enrich_with_content(&mut results).await;
rerank_by_embeddings(ctx.embedder.as_ref(), query, &mut results).await;
}
Ok(ToolOutcome::text(format_results(
&results,
answered_by,
ctx.loc,
)))
}
}
fn no_results_outcome(
got_clean_page: bool,
last_err: Option<anyhow::Error>,
loc: &'static crate::shared::i18n::Locale,
) -> Result<ToolOutcome> {
if got_clean_page {
return Ok(ToolOutcome::text(
loc.t("tool.web_search.result.no_results"),
));
}
if let Some(err) = last_err {
return Err(err).with_context(|| loc.t("tool.web_search.err.all_unavailable").to_string());
}
anyhow::bail!(loc.t("tool.web_search.err.throttled"));
}
fn format_results(
results: &[SearchResult],
answered_by: Option<&str>,
loc: &crate::shared::i18n::Locale,
) -> String {
let mut out = format!(
"{}\n",
match answered_by {
Some(name) => loc.tf(
"tool.web_search.result.header_via",
&[("n", &results.len().to_string()), ("name", name)]
),
None => loc.tf(
"tool.web_search.result.header",
&[("n", &results.len().to_string())]
),
}
);
for (i, r) in results.iter().enumerate() {
out.push_str(&format!("{}. {} โ {}\n", i + 1, r.title, r.url));
if !r.snippet.is_empty() {
out.push_str(&format!(" {}\n", r.snippet));
}
if !r.content.is_empty() {
out.push_str(" ");
out.push_str(loc.t("tool.web_search.result.content_label"));
out.push('\n');
out.push_str(&r.content);
out.push('\n');
}
}
out.trim_end().to_string()
}
fn is_throttled(status: StatusCode, body: &str) -> bool {
matches!(
status,
StatusCode::ACCEPTED | StatusCode::FORBIDDEN | StatusCode::TOO_MANY_REQUESTS
) || body.contains("anomaly")
}
const CHALLENGE_TITLES: &[&str] = &[
"captcha",
"verification required",
"attention required",
"just a moment",
"access denied",
"robot check",
"security check",
];
const CHALLENGE_MARKERS: &[&str] = &[
"verification required",
"complete the challenge",
"unusual traffic",
"are you a robot",
"enable javascript and cookies",
];
fn is_challenge_page(body: &str, query: &str) -> bool {
let title = page_title(body);
let query = collapse_ws(&query.to_lowercase());
if !title.is_empty() && !query.is_empty() && title.contains(&query) {
return false; }
if CHALLENGE_TITLES.iter().any(|m| title.starts_with(m)) {
return true;
}
let lower = body.to_ascii_lowercase();
CHALLENGE_MARKERS.iter().any(|m| lower.contains(m))
}
fn page_title(body: &str) -> String {
let doc = Html::parse_document(body);
let sel = Selector::parse("title").unwrap();
doc.select(&sel)
.next()
.map(|el| collapse_ws(&el.text().collect::<String>()).to_lowercase())
.unwrap_or_default()
}
fn parse_results(
html: &str,
link_q: &str,
title_q: &str,
snippet_q: &str,
max: usize,
) -> Vec<SearchResult> {
let doc = Html::parse_document(html);
let link_sel = Selector::parse(link_q).unwrap();
let title_sel = Selector::parse(title_q).unwrap();
let snippet_sel = Selector::parse(snippet_q).unwrap();
let urls: Vec<String> = doc
.select(&link_sel)
.map(|el| extract_real_url(el.value().attr("href").unwrap_or_default()))
.collect();
let titles: Vec<String> = doc
.select(&title_sel)
.map(|el| collapse_ws(&el.text().collect::<String>()))
.collect();
let snippets: Vec<String> = doc
.select(&snippet_sel)
.map(|el| collapse_ws(&el.text().collect::<String>()))
.collect();
let mut results = Vec::new();
for i in 0..urls.len().min(titles.len()) {
if results.len() >= max {
break;
}
let url = urls[i].clone();
let title = titles[i].clone();
if title.is_empty() || url.is_empty() {
continue;
}
results.push(SearchResult {
title,
url,
snippet: snippets.get(i).cloned().unwrap_or_default(),
content: String::new(),
});
}
results
}
fn extract_real_url(href: &str) -> String {
if let Some(pos) = href.find("uddg=") {
let rest = &href[pos + 5..];
let encoded = rest.split('&').next().unwrap_or(rest);
return percent_decode(encoded);
}
if let Some(stripped) = href.strip_prefix("//") {
return format!("https://{stripped}");
}
href.to_string()
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push((h * 16 + l) as u8);
i += 3;
continue;
}
out.push(b'%');
i += 1;
}
b'+' => {
out.push(b' ');
i += 1;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn collapse_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::config::WebProvider;
use crate::shared::secrets::SearchSlot;
#[tokio::test]
async fn a_result_page_is_read_in_its_own_encoding() {
let prose = "ะกัะฐัะฐั ัััะฐะฝะธัะฐ ะฒ ะบะพะดะธัะพะฒะบะต windows-1251 ะฟะพะฟะฐะปะฐ ะฒ ะฒัะดะฐัั ะธ ะดะพะปะถะฝะฐ ัะธัะฐัััั.";
let (base, _h) = crate::features::image_fetch::stub::serve(vec![
crate::shared::http_text::testkit::legacy_page_response("ะะฐะณะพะปะพะฒะพะบ", prose),
]);
let search = WebSearch::new(
true,
crate::shared::net::AddressPolicy::Unrestricted,
Vec::new(),
);
let text = search.fetch_content(&format!("{base}/old.html")).await;
assert!(
text.as_deref().is_some_and(|t| t.contains(prose)),
"{text:?}"
);
}
#[test]
fn web_search_description_is_localized() {
use crate::shared::i18n::{Lang, locale};
let tool = WebSearch::default();
let (ru, en) = (locale(Lang::Ru), locale(Lang::En));
assert_ne!(tool.description(ru), tool.description(en));
let e = tool.description(en);
assert!(
!e.chars()
.any(|c| ('ะฐ'..='ั').contains(&c) || ('ะ'..='ะฏ').contains(&c)),
"Cyrillic in the en description: {e}"
);
}
const FIXTURE: &str = r#"
<html><body><table>
<tr><td>1. </td><td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=x" class="result-link">ะัะธะผะตั A</a>
</td></tr>
<tr><td class="result-snippet">ะกะฝะธะฟะฟะตั ะฟัะพ A</td></tr>
<tr><td>2. </td><td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fb" class="result-link">ะัะธะผะตั B</a>
</td></tr>
<tr><td class="result-snippet">ะกะฝะธะฟะฟะตั B</td></tr>
</table></body></html>
"#;
const FIXTURE_HTML: &str = r#"
<html><body>
<div class="result">
<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.net%2Fx&rut=y">ะัะธะผะตั X</a>
<a class="result__snippet" href="/snip">ะกะฝะธะฟะฟะตั X</a>
</div>
</body></html>
"#;
const FIXTURE_MOJEEK: &str = r#"
<html><body><ul class="results-standard">
<li><h2><a class="title" title="https://example.io/m" href="https://example.io/m">ะัะธะผะตั M</a></h2>
<p class="s">ะกะฝะธะฟะฟะตั M</p></li>
</ul></body></html>
"#;
const FIXTURE_ECOSIA: &str = r#"
<html><body>
<div class="result">
<a data-test-id="result-link" href="https://example.dev/e" tabindex="-1">https://example.dev/e</a>
<div data-test-id="result-title">ะัะธะผะตั E</div>
<p data-test-id="web-result-description">ะกะฝะธะฟะฟะตั E</p>
</div>
</body></html>
"#;
const LITE: (&str, &str) = ("a.result-link", "td.result-snippet");
#[test]
fn parses_results_and_decodes_urls() {
let results = parse_results(FIXTURE, LITE.0, LITE.0, LITE.1, 5);
assert_eq!(results.len(), 2);
assert_eq!(results[0].title, "ะัะธะผะตั A");
assert_eq!(results[0].url, "https://example.com/a");
assert_eq!(results[0].snippet, "ะกะฝะธะฟะฟะตั ะฟัะพ A"); assert_eq!(results[1].url, "https://example.org/b");
}
#[test]
fn parses_html_endpoint_layout() {
let results = parse_results(
FIXTURE_HTML,
"a.result__a",
"a.result__a",
"a.result__snippet",
5,
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "ะัะธะผะตั X");
assert_eq!(results[0].url, "https://example.net/x");
assert_eq!(results[0].snippet, "ะกะฝะธะฟะฟะตั X");
}
#[test]
fn parses_mojeek_layout() {
let results = parse_results(FIXTURE_MOJEEK, "a.title", "a.title", "p.s", 5);
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "ะัะธะผะตั M");
assert_eq!(results[0].url, "https://example.io/m");
assert_eq!(results[0].snippet, "ะกะฝะธะฟะฟะตั M");
}
#[test]
fn parses_ecosia_layout_separate_title_and_link() {
let eco = PROVIDERS.iter().find(|p| p.name == "Ecosia").unwrap();
let results = parse_results(
FIXTURE_ECOSIA,
eco.link_sel,
eco.title_sel,
eco.snippet_sel,
5,
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "ะัะธะผะตั E");
assert_eq!(results[0].url, "https://example.dev/e");
assert_eq!(results[0].snippet, "ะกะฝะธะฟะฟะตั E");
}
#[test]
fn provider_selectors_match_fixtures() {
let lite = &PROVIDERS[0];
assert_eq!((lite.link_sel, lite.snippet_sel), LITE);
assert!(
!parse_results(FIXTURE, lite.link_sel, lite.title_sel, lite.snippet_sel, 5).is_empty()
);
let mojeek = PROVIDERS.iter().find(|p| p.name == "Mojeek").unwrap();
assert!(
!parse_results(
FIXTURE_MOJEEK,
mojeek.link_sel,
mojeek.title_sel,
mojeek.snippet_sel,
5
)
.is_empty()
);
}
#[test]
fn respects_max_results() {
assert_eq!(parse_results(FIXTURE, LITE.0, LITE.0, LITE.1, 1).len(), 1);
}
#[test]
fn detects_throttling() {
assert!(is_throttled(
StatusCode::ACCEPTED,
"<html>ััะพ ัะณะพะดะฝะพ</html>"
));
assert!(is_throttled(StatusCode::FORBIDDEN, ""));
assert!(is_throttled(StatusCode::TOO_MANY_REQUESTS, ""));
assert!(is_throttled(
StatusCode::OK,
"...If this error persists... anomaly ..."
));
assert!(!is_throttled(StatusCode::OK, FIXTURE));
}
#[test]
fn a_captcha_behind_a_200_is_recognized_as_a_challenge() {
let mojeek = "<html><body>Captcha Search Web Images News Verification required \
Please complete the challenge to continue. Waiting for verification.</body></html>";
assert!(is_challenge_page(mojeek, "rust language"));
assert!(is_challenge_page(
"<p>We detected UNUSUAL TRAFFIC from your network</p>",
"rust language"
));
assert!(!is_challenge_page(FIXTURE, "rust language"));
assert!(!is_challenge_page(FIXTURE_MOJEEK, "rust language"));
}
#[test]
fn a_block_page_is_recognized_by_its_title_alone() {
let reworded = "<html><head><title>Captcha</title></head>\
<body><h1>Please prove you are human</h1></body></html>";
let lower = reworded.to_ascii_lowercase();
assert!(
!CHALLENGE_MARKERS.iter().any(|m| lower.contains(m)),
"fixture must not carry a body phrase, or it proves nothing"
);
assert!(is_challenge_page(reworded, "rust language"));
assert!(is_challenge_page(
"<title>Attention Required! | Cloudflare</title>",
"rust language"
));
}
#[test]
fn a_fruitless_search_for_captcha_is_not_a_challenge() {
let empty_results = "<html><head><title>captcha bypass - Mojeek Search</title></head>\
<body><p>No results found.</p></body></html>";
assert!(!is_challenge_page(empty_results, "captcha bypass"));
assert!(is_challenge_page(empty_results, "rust language"));
}
#[test]
fn page_title_is_normalized() {
assert_eq!(
page_title("<html><head><title> Just\n a Moment </title></head></html>"),
"just a moment"
);
assert_eq!(page_title("<html><body>no title</body></html>"), "");
}
#[test]
fn provider_order_is_the_declared_one_when_nothing_is_cooling() {
let tool = WebSearch::default();
let names: Vec<_> = tool.backend_order().iter().map(|b| b.name()).collect();
assert_eq!(
names,
PROVIDERS.iter().map(|p| p.name).collect::<Vec<_>>(),
"no cooldown recorded must mean no reordering"
);
}
fn keys() -> Vec<(SearchSlot, String)> {
vec![(SearchSlot::Tavily, "tvly-x".into())]
}
#[test]
fn without_a_key_the_order_is_the_keyless_chain_alone() {
for provider in WebProvider::ALL {
let tool = WebSearch::new(
true,
crate::shared::net::AddressPolicy::PublicOnly,
keyed_backends(provider, &[]),
);
let names: Vec<_> = tool.backend_order().iter().map(|b| b.name()).collect();
assert_eq!(
names,
PROVIDERS.iter().map(|p| p.name).collect::<Vec<_>>(),
"{provider:?} with no key must be the keyless chain"
);
}
}
#[test]
fn the_provider_choice_decides_whether_keys_are_used() {
let names = |p| -> Vec<&'static str> {
keyed_backends(p, &keys())
.iter()
.map(|b| b.name())
.collect()
};
assert_eq!(names(WebProvider::Auto), vec!["Tavily"]);
assert!(names(WebProvider::FreeOnly).is_empty());
}
#[test]
fn a_blank_key_yields_no_backend() {
let blank = vec![(SearchSlot::Tavily, " ".to_string())];
assert!(keyed_backends(WebProvider::Auto, &blank).is_empty());
}
#[test]
fn keyed_backends_are_tried_before_the_free_chain() {
let tool = WebSearch::new(
true,
crate::shared::net::AddressPolicy::PublicOnly,
keyed_backends(WebProvider::Auto, &keys()),
);
let names: Vec<_> = tool.backend_order().iter().map(|b| b.name()).collect();
assert_eq!(names[0], "Tavily");
assert_eq!(names.len(), 1 + PROVIDERS.len());
}
#[test]
fn a_rate_limited_keyed_backend_moves_back_too() {
let tool = WebSearch::new(
true,
crate::shared::net::AddressPolicy::PublicOnly,
keyed_backends(WebProvider::Auto, &keys()),
);
tool.mark_blocked("tavily");
let names: Vec<_> = tool.backend_order().iter().map(|b| b.name()).collect();
assert_eq!(
names[0], "DuckDuckGo lite",
"with the keyed backend cooling, the free chain leads"
);
assert_eq!(
names.last(),
Some(&"Tavily"),
"the rate-limited one goes last, but is still there"
);
}
#[test]
fn tavily_results_are_parsed_with_their_page_text() {
let body = serde_json::json!({"results": [
{"title": "Rust", "url": "https://rust-lang.org",
"content": "A language empowering everyone", "raw_content": " Full page text. "},
{"title": "No URL"},
{"title": "Ratatui", "url": "https://ratatui.rs", "content": "TUI library"}
]});
let r = parse_tavily(&body, 5);
assert_eq!(r.len(), 2, "an item without a url is dropped");
assert_eq!(r[0].url, "https://rust-lang.org");
assert_eq!(r[0].snippet, "A language empowering everyone");
assert_eq!(
r[0].content, "Full page text.",
"raw_content lands as content, trimmed โ that is what spares the fetch"
);
assert!(
r[1].content.is_empty(),
"no raw_content means the page still needs fetching"
);
assert_eq!(parse_tavily(&body, 1).len(), 1, "max_results is honoured");
}
#[test]
fn a_malformed_api_body_parses_to_nothing_rather_than_panicking() {
for body in [
serde_json::json!({}),
serde_json::json!({"results": [{}]}),
serde_json::json!({"results": "not a list"}),
serde_json::json!({"web": {}}),
serde_json::json!(null),
] {
assert!(parse_tavily(&body, 5).is_empty());
}
}
#[test]
fn a_block_moves_the_whole_family_to_the_back() {
let tool = WebSearch::default();
tool.mark_blocked(DDG);
let names: Vec<_> = tool.backend_order().iter().map(|b| b.name()).collect();
assert_eq!(
names,
vec!["Mojeek", "Ecosia", "DuckDuckGo lite", "DuckDuckGo html"]
);
}
#[test]
fn a_cooling_provider_is_reordered_not_skipped() {
let tool = WebSearch::default();
for family in [DDG, "mojeek", "ecosia"] {
tool.mark_blocked(family);
}
assert_eq!(
tool.backend_order().len(),
PROVIDERS.len(),
"no provider may be dropped from the order"
);
}
#[test]
fn the_oldest_block_is_retried_first() {
let tool = WebSearch::default();
tool.mark_blocked("ecosia");
std::thread::sleep(Duration::from_millis(20));
tool.mark_blocked(DDG);
std::thread::sleep(Duration::from_millis(20));
tool.mark_blocked("mojeek");
let names: Vec<_> = tool.backend_order().iter().map(|b| b.name()).collect();
assert_eq!(
names,
vec!["Ecosia", "DuckDuckGo lite", "DuckDuckGo html", "Mojeek"]
);
}
#[test]
fn percent_decode_handles_encoded_url() {
assert_eq!(
percent_decode("https%3A%2F%2Fa.b%2Fc%20d"),
"https://a.b/c d"
);
}
#[test]
fn extract_real_url_protocol_relative() {
assert_eq!(extract_real_url("//host.tld/path"), "https://host.tld/path");
}
const PAGE_HTML: &str = r#"
<html><head><style>.x{color:red}</style><script>var a=1;</script></head>
<body>
<nav><a href="/">ะะปะฐะฒะฝะฐั</a> <a href="/about">ะ ะฝะฐั</a></nav>
<header>ะจะฐะฟะบะฐ ัะฐะนัะฐ</header>
<main>
<h1>ะะฐะณะพะปะพะฒะพะบ</h1>
<p>ะญัะพ ะฟะตัะฒัะน ัะพะดะตัะถะฐัะตะปัะฝัะน ะฐะฑะทะฐั ััะฐััะธ, ะดะพััะฐัะพัะฝะพ ะดะปะธะฝะฝัะน, ััะพะฑั ะฟัะพะนัะธ ัะธะปััั ะผะธะฝะธะผะฐะปัะฝะพะน ะดะปะธะฝั.</p>
<p>ะะพัะพัะบะธะน.</p>
<p>ะัะพัะพะน ัะพะดะตัะถะฐัะตะปัะฝัะน ะฐะฑะทะฐั ััะฐััะธ ั ะดะพะฟะพะปะฝะธัะตะปัะฝัะผะธ ะฟะพะดัะพะฑะฝะพัััะผะธ ะฟะพ ัะตะผะต ะทะฐะฟัะพัะฐ ะฟะพะปัะทะพะฒะฐัะตะปั.</p>
</main>
<footer>ะะพะดะฒะฐะป ัะฐะนัะฐ ัะพ ัััะปะบะฐะผะธ</footer>
</body></html>
"#;
#[test]
fn extract_readable_picks_main_paragraphs() {
let text = extract_readable(PAGE_HTML, 1000);
assert!(text.contains("ะฟะตัะฒัะน ัะพะดะตัะถะฐัะตะปัะฝัะน ะฐะฑะทะฐั"));
assert!(text.contains("ะัะพัะพะน ัะพะดะตัะถะฐัะตะปัะฝัะน ะฐะฑะทะฐั"));
assert!(!text.contains("var a"));
assert!(!text.contains("color:red"));
assert!(!text.contains("ะะพัะพัะบะธะน."));
}
#[test]
fn extract_readable_skips_boilerplate() {
let html = r#"<html><body>
<nav><p>ะะตัะตะนัะธ ะบ ัะฐะทะดะตะปะฐะผ ัะฐะนัะฐ, ััะปัะณะธ, ัะตะฝั, ะบะพะฝัะฐะบัั, ะฟะพะดะดะตัะถะบะฐ ะธ ะฟะพะผะพัั.</p></nav>
<div><p>ะญัะพ ะฝะฐััะพััะธะน ัะพะดะตัะถะฐัะตะปัะฝัะน ะฐะฑะทะฐั ััะฐััะธ ะดะพััะฐัะพัะฝะพะน ะดะปะธะฝั.</p></div>
<footer><p>ะัะต ะฟัะฐะฒะฐ ะทะฐัะธัะตะฝั, ะฟะพะปะธัะธะบะฐ ะบะพะฝัะธะดะตะฝัะธะฐะปัะฝะพััะธ, ััะปะพะฒะธั ะธัะฟะพะปัะทะพะฒะฐะฝะธั ัะตัะฒะธัะฐ.</p></footer>
</body></html>"#;
let text = extract_readable(html, 1000);
assert!(text.contains("ะฝะฐััะพััะธะน ัะพะดะตัะถะฐัะตะปัะฝัะน ะฐะฑะทะฐั"));
assert!(!text.contains("ะะตัะตะนัะธ ะบ ัะฐะทะดะตะปะฐะผ"));
assert!(!text.contains("ะัะต ะฟัะฐะฒะฐ ะทะฐัะธัะตะฝั"));
}
#[test]
fn extract_readable_falls_back_without_main() {
let html = r#"<html><body>
<p>ะะฑะทะฐั ะฑะตะท ะพะฑัััะบะธ main, ะฝะพ ัะพะดะตัะถะฐัะตะปัะฝัะน ะธ ะดะพััะฐัะพัะฝะพ ะดะปะธะฝะฝัะน ะดะปั ัะธะปัััะฐ.</p>
</body></html>"#;
let text = extract_readable(html, 1000);
assert!(text.contains("ะะฑะทะฐั ะฑะตะท ะพะฑัััะบะธ main"));
}
#[test]
fn extract_readable_truncates_to_max() {
let text = extract_readable(PAGE_HTML, 20);
assert!(text.chars().count() <= 20);
}
const DOC_HTML: &str = r#"
<html><head><title>Memory management</title></head><body>
<nav><a href="/">Home</a></nav>
<main>
<h2 id="control">Control <a class="header-anchor">#</a></h2>
<p>You can take advantage of V's autofree engine and define a free() method
on custom data types, which is what the example below shows:</p>
<div class="language-v">struct MyType {}
@[unsafe]
fn (data &MyType) free() {
// ...
}
</div>
<p>Just as the compiler frees C data types with C's free(), it will statically
insert free() calls for your data type at the end of each lifetime.</p>
</main>
</body></html>
"#;
#[test]
fn rich_extraction_keeps_headings_and_code() {
let text = extract_rich(DOC_HTML, 10_000);
assert!(text.contains("## Control"), "heading missing: {text}");
assert!(
text.contains("```v"),
"code fence with language missing: {text}"
);
assert!(
text.contains("fn (data &MyType) free()"),
"the code example itself is missing: {text}"
);
assert!(
text.contains("struct MyType {}\n"),
"code was collapsed: {text}"
);
assert!(text.contains("autofree engine"), "prose missing: {text}");
assert!(!text.contains("Home"), "nav leaked in: {text}");
let prose = extract_readable(DOC_HTML, 10_000);
assert!(!prose.contains("Control"), "prose path changed: {prose}");
assert!(
!prose.contains("struct MyType"),
"prose path changed: {prose}"
);
}
#[test]
fn a_wrapped_code_block_is_not_emitted_twice() {
let html = r#"<html><body><main>
<div class="language-rust"><pre><code class="language-rust">let x = 1;</code></pre></div>
</main></body></html>"#;
let text = extract_rich(html, 10_000);
assert_eq!(text.matches("let x = 1;").count(), 1, "duplicated: {text}");
assert_eq!(text.matches("```").count(), 2, "not one fence: {text}");
}
#[test]
fn code_language_comes_from_the_inner_code_tag() {
let html = r#"<html><body><main>
<pre><code class="language-python">print(1)</code></pre>
</main></body></html>"#;
assert!(extract_rich(html, 10_000).contains("```python"));
}
#[test]
fn short_code_and_headings_survive_the_fragment_floor() {
let html = r#"<html><body><main>
<h3>Control</h3><pre>v -autofree</pre>
</main></body></html>"#;
let text = extract_rich(html, 10_000);
assert!(text.contains("### Control"), "got: {text}");
assert!(text.contains("v -autofree"), "got: {text}");
}
#[test]
fn rich_extraction_skips_boilerplate_and_truncates() {
let html = r#"<html><body>
<nav><pre>menu code that is not content</pre></nav>
<p>ะะฐััะพััะธะน ัะพะดะตัะถะฐัะตะปัะฝัะน ะฐะฑะทะฐั ัััะฐะฝะธัั, ะดะพััะฐัะพัะฝะพ ะดะปะธะฝะฝัะน ะดะปั ะฟะพัะพะณะฐ.</p>
</body></html>"#;
let text = extract_rich(html, 10_000);
assert!(!text.contains("menu code"), "nav leaked in: {text}");
assert!(text.contains("ัะพะดะตัะถะฐัะตะปัะฝัะน ะฐะฑะทะฐั"));
assert!(extract_rich(DOC_HTML, 20).chars().count() <= 20);
}
#[test]
fn cosine_basic() {
assert!((cosine(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-6);
assert!(cosine(&[1.0, 0.0], &[0.0, 1.0]).abs() < 1e-6);
assert_eq!(cosine(&[1.0], &[1.0, 0.0]), 0.0);
assert_eq!(cosine(&[0.0, 0.0], &[1.0, 0.0]), 0.0);
}
#[test]
fn rerank_order_sorts_by_similarity() {
let query = vec![1.0, 0.0];
let docs = vec![
vec![0.0, 1.0], vec![1.0, 0.0], vec![0.7, 0.7], ];
let order = rerank_order(&query, &docs);
assert_eq!(order, vec![1, 2, 0]);
}
#[test]
fn rerank_order_is_stable_on_ties() {
let query = vec![1.0, 0.0];
let docs = vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]];
assert_eq!(rerank_order(&query, &docs), vec![0, 1, 2]);
}
#[test]
fn rerank_text_prefers_content_over_snippet() {
let r = SearchResult {
title: "ะะฐะณะพะปะพะฒะพะบ".into(),
url: "https://e/x".into(),
snippet: "ัะฝะธะฟะฟะตั".into(),
content: "ะธะทะฒะปะตััะฝะฝัะน ะบะพะฝัะตะฝั".into(),
};
let t = rerank_text(&r);
assert!(t.contains("ะะฐะณะพะปะพะฒะพะบ"));
assert!(t.contains("ะธะทะฒะปะตััะฝะฝัะน ะบะพะฝัะตะฝั"));
assert!(!t.contains("ัะฝะธะฟะฟะตั"));
let r2 = SearchResult {
content: String::new(),
..r
};
assert!(rerank_text(&r2).contains("ัะฝะธะฟะฟะตั"));
}
#[tokio::test]
async fn rerank_reorders_results_by_query() {
use crate::shared::api::mock::MockEmbedder;
let embedder = MockEmbedder::new(64);
let mut results = vec![
SearchResult {
title: "ะัะพ ะฟะพะณะพะดั".into(),
url: "https://e/weather".into(),
snippet: String::new(),
content: "ัะตะณะพะดะฝั ะดะพะถะดั ะธ ะฒะตัะตั, ะฟัะพะณะฝะพะท ะฟะพะณะพะดั ะฝะฐ ะทะฐะฒััะฐ".into(),
},
SearchResult {
title: "ะฏะทัะบ Rust".into(),
url: "https://e/rust".into(),
snippet: String::new(),
content: "rust ัะธััะตะผะฝัะน ัะทัะบ ะฟัะพะณัะฐะผะผะธัะพะฒะฐะฝะธั ั ะฑะตะทะพะฟะฐัะฝะพะน ะฟะฐะผัััั".into(),
},
];
rerank_by_embeddings(&embedder, "rust ัะทัะบ ะฟัะพะณัะฐะผะผะธัะพะฒะฐะฝะธั", &mut results).await;
assert_eq!(results[0].url, "https://e/rust");
}
#[tokio::test]
async fn rerank_splits_the_query_from_the_page_texts() {
use crate::features::tools::testkit::RoleRecorder;
let rec = RoleRecorder::new();
let mut results = vec![
SearchResult {
title: "ะัะพ ะฟะพะณะพะดั".into(),
url: "https://e/weather".into(),
snippet: "ะดะพะถะดั".into(),
content: String::new(),
},
SearchResult {
title: "ะฏะทัะบ Rust".into(),
url: "https://e/rust".into(),
snippet: "ัะธััะตะผะฝัะน ัะทัะบ".into(),
content: String::new(),
},
];
rerank_by_embeddings(&rec, "ััะพ ัะฐะบะพะต rust?", &mut results).await;
assert_eq!(rec.roles(), vec![EmbedRole::Query, EmbedRole::Passage]);
let calls = rec.calls.lock().unwrap();
assert_eq!(calls[0].0, vec!["ััะพ ัะฐะบะพะต rust?".to_string()]);
assert_eq!(calls[1].0.len(), 2, "one text per result, query excluded");
}
#[tokio::test]
async fn rerank_noop_when_embedder_unavailable() {
use crate::shared::api::UnavailableEmbedder;
let mut results = vec![
SearchResult {
title: "A".into(),
url: "https://e/a".into(),
snippet: "s".into(),
content: "ะบะพะฝัะตะฝั a".into(),
},
SearchResult {
title: "B".into(),
url: "https://e/b".into(),
snippet: "s".into(),
content: "ะบะพะฝัะตะฝั b".into(),
},
];
rerank_by_embeddings(&UnavailableEmbedder, "ะทะฐะฟัะพั", &mut results).await;
assert_eq!(results[0].url, "https://e/a");
assert_eq!(results[1].url, "https://e/b");
}
#[test]
fn only_results_without_content_are_fetched() {
let r = |content: &str| SearchResult {
title: "t".into(),
url: "https://example.test/p".into(),
snippet: "s".into(),
content: content.into(),
};
let results = vec![r("already have this"), r(""), r("and this")];
assert_eq!(
needs_content(&results),
vec![1],
"only the result with no content may be fetched"
);
assert!(
needs_content(&[r("x"), r("y")]).is_empty(),
"a fully populated set must issue no fetches at all"
);
assert_eq!(needs_content(&[r(""), r("")]), vec![0, 1]);
}
#[tokio::test]
#[ignore = "requires network access to search providers"]
async fn live_search_returns_results() {
let tool = WebSearch::default();
let (_dir, _storage, ctx) = super::super::testkit::ctx_with_backends(
uuid::Uuid::new_v4(),
std::sync::Arc::new(crate::shared::api::mock::MockBackend::scripted(vec![])),
std::sync::Arc::new(crate::shared::api::mock::MockEmbedder::new(16)),
);
let out = match tool
.invoke(
&ctx,
serde_json::json!({"query": "rust language", "max_results": 3}),
)
.await
{
Ok(out) => out,
Err(e) => {
if e.to_string()
.contains(ctx.loc.t("tool.web_search.err.throttled"))
{
eprintln!("skip: every search provider is throttling this IP");
return;
}
panic!("web search failed: {e:#}");
}
};
assert!(out.result.contains("http"), "got: {}", out.result);
}
#[tokio::test]
#[ignore = "requires a keyed search provider (TAVILY_API_KEY)"]
async fn live_tavily_returns_page_text_so_the_tool_need_not_fetch_it() {
let Ok(key) = std::env::var("TAVILY_API_KEY") else {
eprintln!("skip: no Tavily key configured");
return;
};
let tool = WebSearch::default();
let api = ApiBackend {
slot: SearchSlot::Tavily,
key,
};
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let attempt = tool
.run_api(&api, "rust programming language", 3, true, loc)
.await
.expect("Tavily request failed");
let Attempt::Results(results) = attempt else {
panic!("Tavily returned no results for a query that certainly has some");
};
assert!(
results.iter().any(|r| !r.content.is_empty()),
"no result carried page text โ has `include_raw_content` been renamed? results: {:?}",
results.iter().map(|r| &r.url).collect::<Vec<_>>()
);
assert!(
needs_content(&results).len() < results.len(),
"if every result still needs fetching, the keyed backend saved nothing"
);
}
#[tokio::test]
#[ignore = "requires a keyed search provider (TAVILY_API_KEY)"]
async fn live_keyed_search_survives_ten_searches_in_a_row() {
let keyed: Vec<_> = [(SearchSlot::Tavily, "TAVILY_API_KEY")]
.into_iter()
.filter_map(|(slot, var)| Some((slot, std::env::var(var).ok()?)))
.collect();
if keyed.is_empty() {
eprintln!("skip: no keyed search provider configured");
return;
}
let backends = keyed_backends(WebProvider::Auto, &keyed);
let names: Vec<_> = backends.iter().map(|b| b.name()).collect();
eprintln!("keyed backends under test: {names:?}");
let tool = WebSearch::new(
false, crate::shared::net::AddressPolicy::PublicOnly,
backends,
);
let (_dir, _storage, ctx) = super::super::testkit::ctx_with_backends(
uuid::Uuid::new_v4(),
std::sync::Arc::new(crate::shared::api::mock::MockBackend::scripted(vec![])),
std::sync::Arc::new(crate::shared::api::mock::MockEmbedder::new(16)),
);
let queries = [
"rust ratatui widget",
"llama.cpp jinja template",
"sqlite-vec vector search",
"feature sliced design",
"tokio cancellation token",
"wasmer wasix python",
"duckduckgo lite anti-bot",
"tavily search api",
"anti-bot rate limiting http 429",
"rust edition 2024 changes",
];
for (i, q) in queries.iter().enumerate() {
let out = tool
.invoke(&ctx, serde_json::json!({"query": q, "max_results": 3}))
.await
.unwrap_or_else(|e| panic!("search {} of 10 ({q:?}) failed: {e:#}", i + 1));
assert!(
out.result.contains("http"),
"search {} of 10 ({q:?}) returned no links: {}",
i + 1,
out.result
);
assert!(
names.iter().any(|n| out.result.contains(n)),
"search {} of 10 fell through to the keyless chain: {}",
i + 1,
out.result
);
}
}
}