1use std::sync::Arc;
4
5use axum::extract::State;
6use axum::response::IntoResponse;
7use axum::{Json, http::HeaderMap};
8use serde::{Deserialize, Serialize};
9
10use phrona::SearchOptions;
11use phrona::models::{Category, ResultItem, TimeRange};
12
13use crate::{AppError, AppResult, AppState, JsonBody};
14
15#[derive(Deserialize)]
21pub struct TavilyRequest {
22 pub query: String,
24 #[serde(default)]
25 pub api_key: Option<String>,
27 #[serde(default)]
28 pub search_depth: Option<String>,
30 #[serde(default)]
31 pub topic: Option<String>,
33 #[serde(default)]
34 pub days: Option<u32>,
36 #[serde(default)]
37 pub max_results: Option<usize>,
39 #[serde(default)]
40 pub include_images: bool,
42 #[serde(default)]
43 pub include_answer: bool,
45 #[serde(default)]
46 pub include_raw_content: bool,
48 #[serde(default)]
51 pub include_image_descriptions: bool,
52 #[serde(default)]
53 pub include_domains: Option<Vec<String>>,
55 #[serde(default)]
56 pub exclude_domains: Option<Vec<String>>,
58}
59
60#[derive(Serialize)]
62pub struct TavilyResponse {
63 pub query: String,
65 pub follow_up_questions: Vec<String>,
67 pub response_time: f64,
69 pub answer: Option<String>,
71 pub images: Option<Vec<String>>,
73 pub results: Vec<TavilyResult>,
75}
76
77#[derive(Serialize)]
79pub struct TavilyResult {
80 pub title: String,
82 pub url: String,
84 pub content: String,
86 pub score: f64,
88 pub raw_content: Option<String>,
90}
91
92fn days_to_range(days: u32) -> Option<TimeRange> {
93 Some(match days {
94 0..=1 => TimeRange::Day,
95 2..=7 => TimeRange::Week,
96 8..=30 => TimeRange::Month,
97 _ => TimeRange::Year,
98 })
99}
100
101fn apply_domains(query: &mut String, include: &[String], exclude: &[String]) {
102 if !include.is_empty() {
103 let sites: Vec<String> = include.iter().map(|d| format!("site:{d}")).collect();
104 query.push_str(&format!(" ({})", sites.join(" OR ")));
105 }
106 for d in exclude {
107 query.push_str(&format!(" -site:{d}"));
108 }
109}
110
111fn to_tavily_result(r: &ResultItem, pos: usize) -> (String, String, String, f64) {
112 let score = phrona::rank::positional_score(pos);
113 match r {
114 ResultItem::Web(w) => (w.title.clone(), w.url.clone(), w.description.clone(), score),
115 ResultItem::News(n) => (n.title.clone(), n.url.clone(), n.description.clone(), score),
116 ResultItem::Video(v) => (v.title.clone(), v.url.clone(), v.description.clone(), score),
117 ResultItem::Image(i) => (i.title.clone(), i.url.clone(), i.source.clone(), score),
118 ResultItem::Book(b) => (b.title.clone(), b.url.clone(), b.info.clone(), score),
119 }
120}
121
122pub async fn search(
126 State(state): State<Arc<AppState>>,
127 headers: HeaderMap,
128 JsonBody(req): JsonBody<TavilyRequest>,
129) -> AppResult<impl IntoResponse> {
130 let key = crate::auth_key(&headers, req.api_key.as_deref());
133 if !state.authorized(key.as_deref()) {
134 return Err(AppError::unauthorized());
135 }
136
137 let mut opts = SearchOptions::new(req.query.clone());
138 let topic_is_news = matches!(req.topic.as_deref(), Some("news"));
139 opts.category = if topic_is_news {
140 Category::News
141 } else {
142 Category::Web
143 };
144 let depth = req.search_depth.as_deref().unwrap_or("basic");
145 if !matches!(depth, "basic" | "advanced") {
148 return Err(AppError::bad_request(format!(
149 "invalid search_depth '{depth}', expected 'basic' or 'advanced'"
150 )));
151 }
152 if depth == "basic" {
153 let mut engines = match opts.category {
154 Category::News => vec!["bing_news".into(), "duckduckgo_news".into()],
155 _ => vec!["bing".into(), "duckduckgo".into()],
156 };
157 if req.include_answer {
158 engines.push("grokipedia".into());
159 }
160 opts.engines = engines;
161 }
162 if let Some(days) = req.days {
163 opts.time_range = days_to_range(days);
164 } else if topic_is_news {
165 opts.time_range = Some(TimeRange::Week);
167 }
168 opts.max_results = req.max_results.unwrap_or(5).clamp(1, 20);
169 if let Some(include) = &req.include_domains {
170 apply_domains(&mut opts.query, include, &[]);
171 }
172 if let Some(exclude) = &req.exclude_domains {
173 apply_domains(&mut opts.query, &[], exclude);
174 }
175
176 let started = std::time::Instant::now();
177 let resp = state.client.search(opts).await?;
178 let response_time = started.elapsed().as_secs_f64();
179
180 let limit = resp.total.min(req.max_results.unwrap_or(5).clamp(1, 20));
181 let mut results: Vec<TavilyResult> = Vec::with_capacity(limit);
182 let mut images: Vec<String> = Vec::new();
183 for (i, r) in resp.results.iter().take(limit).enumerate() {
184 let (title, url, content, score) = to_tavily_result(r, i);
185 if let ResultItem::Image(img) = r
186 && !img.image_url.is_empty()
187 {
188 images.push(img.image_url.clone());
189 }
190 results.push(TavilyResult {
191 title,
192 url,
193 content,
194 score,
195 raw_content: None,
196 });
197 }
198
199 if req.include_images {
200 let mut img_opts = SearchOptions::new(req.query.clone());
203 img_opts.category = Category::Images;
204 img_opts.max_results = limit.clamp(1, 8);
205 if let Ok(img_resp) = state.client.search(img_opts).await {
206 for r in img_resp.results.iter().take(8) {
207 if let ResultItem::Image(img) = r
208 && !img.image_url.is_empty()
209 {
210 images.push(img.image_url.clone());
211 }
212 }
213 }
214 }
215
216 if req.include_raw_content {
217 let client = state.client.http();
218 let urls: Vec<String> = results.iter().map(|r| r.url.clone()).collect();
219 let pages = phrona::extract_many(client, &urls, 8000, Some(&req.query)).await;
220 for (r, page) in results.iter_mut().zip(pages) {
221 r.raw_content = Some(match page {
222 Ok(p) => p.text,
223 Err(e) => format!("extract failed: {e}"),
224 });
225 }
226 }
227
228 Ok(Json(TavilyResponse {
229 query: req.query.clone(),
230 follow_up_questions: Vec::new(),
231 response_time,
232 answer: req.include_answer.then_some(resp.answer.clone()).flatten(),
233 images: (req.include_images && !images.is_empty()).then_some(images),
234 results,
235 }))
236}