use std::sync::Arc;
use axum::extract::State;
use axum::response::IntoResponse;
use axum::{Json, http::HeaderMap};
use serde::{Deserialize, Serialize};
use phrona::SearchOptions;
use phrona::models::{Category, ResultItem, TimeRange};
use crate::{AppError, AppResult, AppState, JsonBody};
#[derive(Deserialize)]
pub struct TavilyRequest {
pub query: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub search_depth: Option<String>,
#[serde(default)]
pub topic: Option<String>,
#[serde(default)]
pub days: Option<u32>,
#[serde(default)]
pub max_results: Option<usize>,
#[serde(default)]
pub include_images: bool,
#[serde(default)]
pub include_answer: bool,
#[serde(default)]
pub include_raw_content: bool,
#[serde(default)]
pub include_image_descriptions: bool,
#[serde(default)]
pub include_domains: Option<Vec<String>>,
#[serde(default)]
pub exclude_domains: Option<Vec<String>>,
}
#[derive(Serialize)]
pub struct TavilyResponse {
pub query: String,
pub follow_up_questions: Vec<String>,
pub response_time: f64,
pub answer: Option<String>,
pub images: Option<Vec<String>>,
pub results: Vec<TavilyResult>,
}
#[derive(Serialize)]
pub struct TavilyResult {
pub title: String,
pub url: String,
pub content: String,
pub score: f64,
pub raw_content: Option<String>,
}
fn days_to_range(days: u32) -> Option<TimeRange> {
Some(match days {
0..=1 => TimeRange::Day,
2..=7 => TimeRange::Week,
8..=30 => TimeRange::Month,
_ => TimeRange::Year,
})
}
fn apply_domains(query: &mut String, include: &[String], exclude: &[String]) {
if !include.is_empty() {
let sites: Vec<String> = include.iter().map(|d| format!("site:{d}")).collect();
query.push_str(&format!(" ({})", sites.join(" OR ")));
}
for d in exclude {
query.push_str(&format!(" -site:{d}"));
}
}
fn to_tavily_result(r: &ResultItem, pos: usize) -> (String, String, String, f64) {
let score = (1.0 - pos as f64 * 0.05).max(0.05);
match r {
ResultItem::Web(w) => (w.title.clone(), w.url.clone(), w.description.clone(), score),
ResultItem::News(n) => (n.title.clone(), n.url.clone(), n.description.clone(), score),
ResultItem::Video(v) => (v.title.clone(), v.url.clone(), v.description.clone(), score),
ResultItem::Image(i) => (i.title.clone(), i.url.clone(), i.source.clone(), score),
ResultItem::Book(b) => (b.title.clone(), b.url.clone(), b.info.clone(), score),
}
}
pub async fn search(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
JsonBody(req): JsonBody<TavilyRequest>,
) -> AppResult<impl IntoResponse> {
let key = crate::auth_key(&headers, req.api_key.as_deref());
if !state.authorized(key.as_deref()) {
return Err(AppError::unauthorized());
}
let mut opts = SearchOptions::new(req.query.clone());
let topic_is_news = matches!(req.topic.as_deref(), Some("news"));
opts.category = if topic_is_news {
Category::News
} else {
Category::Web
};
let depth = req.search_depth.as_deref().unwrap_or("basic");
if !matches!(depth, "basic" | "advanced") {
return Err(AppError::bad_request(format!(
"invalid search_depth '{depth}', expected 'basic' or 'advanced'"
)));
}
if depth == "basic" {
let mut engines = match opts.category {
Category::News => vec!["bing_news".into(), "duckduckgo_news".into()],
_ => vec!["bing".into(), "duckduckgo".into()],
};
if req.include_answer {
engines.push("grokipedia".into());
}
opts.engines = engines;
}
if let Some(days) = req.days {
opts.time_range = days_to_range(days);
} else if topic_is_news {
opts.time_range = Some(TimeRange::Week);
}
opts.max_results = req.max_results.unwrap_or(5).clamp(1, 20);
if let Some(include) = &req.include_domains {
apply_domains(&mut opts.query, include, &[]);
}
if let Some(exclude) = &req.exclude_domains {
apply_domains(&mut opts.query, &[], exclude);
}
let started = std::time::Instant::now();
let resp = state.client.search(opts).await?;
let response_time = started.elapsed().as_secs_f64();
let limit = resp.total.min(req.max_results.unwrap_or(5).clamp(1, 20));
let mut results: Vec<TavilyResult> = Vec::with_capacity(limit);
let mut images: Vec<String> = Vec::new();
for (i, r) in resp.results.iter().take(limit).enumerate() {
let (title, url, content, score) = to_tavily_result(r, i);
if let ResultItem::Image(img) = r
&& !img.image_url.is_empty()
{
images.push(img.image_url.clone());
}
results.push(TavilyResult {
title,
url,
content,
score,
raw_content: None,
});
}
if req.include_images {
let mut img_opts = SearchOptions::new(req.query.clone());
img_opts.category = Category::Images;
img_opts.max_results = limit.clamp(1, 8);
if let Ok(img_resp) = state.client.search(img_opts).await {
for r in img_resp.results.iter().take(8) {
if let ResultItem::Image(img) = r
&& !img.image_url.is_empty()
{
images.push(img.image_url.clone());
}
}
}
}
if req.include_raw_content {
let client = state.client.http();
let urls: Vec<String> = results.iter().map(|r| r.url.clone()).collect();
let pages = phrona::extract_many(client, &urls, 8000, Some(&req.query)).await;
for (r, page) in results.iter_mut().zip(pages) {
r.raw_content = Some(match page {
Ok(p) => p.text,
Err(e) => format!("extract failed: {e}"),
});
}
}
Ok(Json(TavilyResponse {
query: req.query.clone(),
follow_up_questions: Vec::new(),
response_time,
answer: req.include_answer.then_some(resp.answer.clone()).flatten(),
images: (req.include_images && !images.is_empty()).then_some(images),
results,
}))
}