1use std::sync::Arc;
4
5use axum::Json;
6use axum::extract::State;
7use axum::response::IntoResponse;
8use serde::{Deserialize, Serialize};
9
10use phrona::SearchOptions;
11use phrona::models::{Category, TimeRange};
12
13use crate::{AppError, AppResult, AppState, HeaderAuth, JsonBody, JsonQuery};
14
15#[derive(Deserialize)]
18pub struct GroundingRequest {
19 pub query: String,
21 #[serde(default)]
22 pub api_key: Option<String>,
24 #[serde(default)]
25 pub max_results: Option<usize>,
27 #[serde(default)]
28 pub category: Option<String>,
31 #[serde(default)]
32 pub time_range: Option<String>,
34 #[serde(default)]
35 pub engines: Option<String>,
37 #[serde(default)]
38 pub region: Option<String>,
40 #[serde(default)]
41 pub language: Option<String>,
43 #[serde(default)]
44 pub safesearch: Option<String>,
46 #[serde(default)]
47 pub filters: Option<String>,
49 #[serde(default)]
50 pub page: Option<u32>,
52}
53
54#[derive(Deserialize)]
56pub struct GroundingGetParams {
57 pub query: String,
59 #[serde(default)]
60 pub max_results: Option<usize>,
62 #[serde(default)]
63 pub category: Option<String>,
66 #[serde(default)]
67 pub time_range: Option<String>,
69 #[serde(default)]
70 pub engines: Option<String>,
72 #[serde(default)]
73 pub region: Option<String>,
75 #[serde(default)]
76 pub language: Option<String>,
78 #[serde(default)]
79 pub safesearch: Option<String>,
81 #[serde(default)]
82 pub filters: Option<String>,
84 #[serde(default)]
85 pub page: Option<u32>,
87}
88
89#[derive(Serialize)]
92pub struct GroundingResponse {
93 pub query: String,
95 pub answer: String,
97 pub sources: Vec<GroundingSource>,
99 pub response_time: f64,
101}
102
103#[derive(Serialize)]
105pub struct GroundingSource {
106 pub title: String,
108 pub url: String,
110 pub content: String,
112 pub score: f64,
114}
115
116fn synthesize_answer(resp: &phrona::SearchResponse, sources: &[GroundingSource]) -> String {
120 if let Some(a) = &resp.answer
121 && !a.trim().is_empty()
122 {
123 return a.clone();
124 }
125 if sources.is_empty() {
126 return format!("No results found for \"{}\".", resp.query);
127 }
128 let mut parts = Vec::new();
129 for (i, s) in sources.iter().take(3).enumerate() {
130 let content = s.content.trim();
131 if content.is_empty() {
132 continue;
133 }
134 let excerpt = content.chars().take(400).collect::<String>();
135 parts.push(format!("Source {} ({}): {excerpt}", i + 1, s.url));
136 }
137 if parts.is_empty() {
138 return format!("{} sources found for \"{}\".", sources.len(), resp.query);
139 }
140 format!(
141 "Extractive summary for \"{}\":\n{}",
142 resp.query,
143 parts.join("\n")
144 )
145}
146
147pub async fn get(
150 State(state): State<Arc<AppState>>,
151 auth: HeaderAuth,
152 JsonQuery(p): JsonQuery<GroundingGetParams>,
153) -> AppResult<impl IntoResponse> {
154 if !state.authorized(auth.key()) {
155 return Err(AppError::unauthorized());
156 }
157 run(&state, ¶ms_from_get(&p)).await
158}
159
160pub async fn post(
163 State(state): State<Arc<AppState>>,
164 headers: axum::http::HeaderMap,
165 JsonBody(p): JsonBody<GroundingRequest>,
166) -> AppResult<impl IntoResponse> {
167 if !state.authorized(crate::auth_key(&headers, p.api_key.as_deref()).as_deref()) {
168 return Err(AppError::unauthorized());
169 }
170 run(&state, ¶ms_from_post(&p)).await
171}
172
173struct GroundingParams {
175 query: String,
176 page: Option<u32>,
177 max_results: Option<usize>,
178 category: Option<String>,
179 time_range: Option<String>,
180 engines: Option<String>,
181 region: Option<String>,
182 language: Option<String>,
183 safesearch: Option<String>,
184 filters: Option<String>,
185}
186
187fn params_from_get(p: &GroundingGetParams) -> GroundingParams {
188 GroundingParams {
189 query: p.query.clone(),
190 page: p.page,
191 max_results: p.max_results,
192 category: p.category.clone(),
193 time_range: p.time_range.clone(),
194 engines: p.engines.clone(),
195 region: p.region.clone(),
196 language: p.language.clone(),
197 safesearch: p.safesearch.clone(),
198 filters: p.filters.clone(),
199 }
200}
201
202fn params_from_post(p: &GroundingRequest) -> GroundingParams {
203 GroundingParams {
204 query: p.query.clone(),
205 page: p.page,
206 max_results: p.max_results,
207 category: p.category.clone(),
208 time_range: p.time_range.clone(),
209 engines: p.engines.clone(),
210 region: p.region.clone(),
211 language: p.language.clone(),
212 safesearch: p.safesearch.clone(),
213 filters: p.filters.clone(),
214 }
215}
216
217async fn run(state: &AppState, p: &GroundingParams) -> AppResult<Json<GroundingResponse>> {
218 let mut opts = SearchOptions::new(p.query.clone());
219 opts.page = p.page.unwrap_or(1).max(1);
220 opts.max_results = p.max_results.unwrap_or(8).clamp(1, 50);
222 if let Some(c) = &p.category {
223 opts.category = c.parse::<Category>().map_err(|_| {
224 AppError::bad_request(format!(
225 "invalid category, expected one of: {}",
226 Category::list_str()
227 ))
228 })?;
229 }
230 if let Some(t) = &p.time_range {
231 opts.time_range = Some(t.parse::<TimeRange>().map_err(|_| {
232 AppError::bad_request("invalid time_range, expected day|week|month|year")
233 })?);
234 }
235 if let Some(e) = &p.engines {
236 opts.engines = e
237 .split(',')
238 .map(|s| s.trim().to_string())
239 .filter(|s| !s.is_empty())
240 .collect();
241 for name in &opts.engines {
242 if phrona::engine::engine_by_name(name).is_none() {
243 return Err(AppError::bad_request(format!(
244 "unknown engine '{name}'; see /v1/engines"
245 )));
246 }
247 }
248 }
249 opts.region = p.region.clone().filter(|r| !r.trim().is_empty());
250 opts.language = p.language.clone().filter(|l| !l.trim().is_empty());
251 if let Some(s) = &p.safesearch {
252 opts.safesearch = s.parse::<phrona::SafeSearch>().map_err(|_| {
253 AppError::bad_request("invalid safesearch, expected off|moderate|strict")
254 })?;
255 }
256 opts.filters = p.filters.clone().filter(|f| !f.trim().is_empty());
257
258 let started = std::time::Instant::now();
259 let resp = state.client.search(opts).await?;
260 let response_time = started.elapsed().as_secs_f64();
261
262 let mut sources: Vec<GroundingSource> = Vec::new();
263 for (i, r) in resp.results.iter().enumerate() {
264 let score = phrona::rank::positional_score(i);
265 let (title, url, content) = match r {
266 phrona::ResultItem::Web(w) => (&w.title, &w.url, &w.description),
267 phrona::ResultItem::News(n) => (&n.title, &n.url, &n.description),
268 phrona::ResultItem::Video(v) => (&v.title, &v.url, &v.description),
269 phrona::ResultItem::Image(i) => (&i.title, &i.url, &i.source),
270 phrona::ResultItem::Book(b) => (&b.title, &b.url, &b.info),
271 };
272 sources.push(GroundingSource {
273 title: title.clone(),
274 url: url.clone(),
275 content: content.clone(),
276 score,
277 });
278 }
279
280 let answer = synthesize_answer(&resp, &sources);
281 Ok(Json(GroundingResponse {
282 query: resp.query.clone(),
283 answer,
284 sources,
285 response_time,
286 }))
287}