use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use phrona::SearchOptions;
use phrona::models::{Category, TimeRange};
use crate::{AppError, AppResult, AppState, HeaderAuth, JsonBody, JsonQuery};
#[derive(Deserialize)]
pub struct GroundingRequest {
pub query: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub max_results: Option<usize>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub time_range: Option<String>,
#[serde(default)]
pub engines: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub safesearch: Option<String>,
#[serde(default)]
pub filters: Option<String>,
#[serde(default)]
pub page: Option<u32>,
}
#[derive(Deserialize)]
pub struct GroundingGetParams {
pub query: String,
#[serde(default)]
pub max_results: Option<usize>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub time_range: Option<String>,
#[serde(default)]
pub engines: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub safesearch: Option<String>,
#[serde(default)]
pub filters: Option<String>,
#[serde(default)]
pub page: Option<u32>,
}
#[derive(Serialize)]
pub struct GroundingResponse {
pub query: String,
pub answer: String,
pub sources: Vec<GroundingSource>,
pub response_time: f64,
}
#[derive(Serialize)]
pub struct GroundingSource {
pub title: String,
pub url: String,
pub content: String,
pub score: f64,
}
fn synthesize_answer(resp: &phrona::SearchResponse, sources: &[GroundingSource]) -> String {
if let Some(a) = &resp.answer
&& !a.trim().is_empty()
{
return a.clone();
}
if sources.is_empty() {
return format!("No results found for \"{}\".", resp.query);
}
let mut parts = Vec::new();
for (i, s) in sources.iter().take(3).enumerate() {
let content = s.content.trim();
if content.is_empty() {
continue;
}
let excerpt = content.chars().take(400).collect::<String>();
parts.push(format!("Source {} ({}): {excerpt}", i + 1, s.url));
}
if parts.is_empty() {
return format!("{} sources found for \"{}\".", sources.len(), resp.query);
}
format!(
"Extractive summary for \"{}\":\n{}",
resp.query,
parts.join("\n")
)
}
pub async fn get(
State(state): State<Arc<AppState>>,
auth: HeaderAuth,
JsonQuery(p): JsonQuery<GroundingGetParams>,
) -> AppResult<impl IntoResponse> {
if !state.authorized(auth.key()) {
return Err(AppError::unauthorized());
}
run(&state, ¶ms_from_get(&p)).await
}
pub async fn post(
State(state): State<Arc<AppState>>,
headers: axum::http::HeaderMap,
JsonBody(p): JsonBody<GroundingRequest>,
) -> AppResult<impl IntoResponse> {
if !state.authorized(crate::auth_key(&headers, p.api_key.as_deref()).as_deref()) {
return Err(AppError::unauthorized());
}
run(&state, ¶ms_from_post(&p)).await
}
struct GroundingParams {
query: String,
page: Option<u32>,
max_results: Option<usize>,
category: Option<String>,
time_range: Option<String>,
engines: Option<String>,
region: Option<String>,
language: Option<String>,
safesearch: Option<String>,
filters: Option<String>,
}
fn params_from_get(p: &GroundingGetParams) -> GroundingParams {
GroundingParams {
query: p.query.clone(),
page: p.page,
max_results: p.max_results,
category: p.category.clone(),
time_range: p.time_range.clone(),
engines: p.engines.clone(),
region: p.region.clone(),
language: p.language.clone(),
safesearch: p.safesearch.clone(),
filters: p.filters.clone(),
}
}
fn params_from_post(p: &GroundingRequest) -> GroundingParams {
GroundingParams {
query: p.query.clone(),
page: p.page,
max_results: p.max_results,
category: p.category.clone(),
time_range: p.time_range.clone(),
engines: p.engines.clone(),
region: p.region.clone(),
language: p.language.clone(),
safesearch: p.safesearch.clone(),
filters: p.filters.clone(),
}
}
async fn run(state: &AppState, p: &GroundingParams) -> AppResult<Json<GroundingResponse>> {
let mut opts = SearchOptions::new(p.query.clone());
opts.page = p.page.unwrap_or(1).max(1);
opts.max_results = p.max_results.unwrap_or(8).clamp(1, 50);
if let Some(c) = &p.category {
opts.category = c.parse::<Category>().map_err(|_| {
AppError::bad_request(format!(
"invalid category, expected one of: {}",
Category::list_str()
))
})?;
}
if let Some(t) = &p.time_range {
opts.time_range = Some(t.parse::<TimeRange>().map_err(|_| {
AppError::bad_request("invalid time_range, expected day|week|month|year")
})?);
}
if let Some(e) = &p.engines {
opts.engines = e
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
for name in &opts.engines {
if phrona::engine::engine_by_name(name).is_none() {
return Err(AppError::bad_request(format!(
"unknown engine '{name}'; see /v1/engines"
)));
}
}
}
opts.region = p.region.clone().filter(|r| !r.trim().is_empty());
opts.language = p.language.clone().filter(|l| !l.trim().is_empty());
if let Some(s) = &p.safesearch {
opts.safesearch = s.parse::<phrona::SafeSearch>().map_err(|_| {
AppError::bad_request("invalid safesearch, expected off|moderate|strict")
})?;
}
opts.filters = p.filters.clone().filter(|f| !f.trim().is_empty());
let started = std::time::Instant::now();
let resp = state.client.search(opts).await?;
let response_time = started.elapsed().as_secs_f64();
let mut sources: Vec<GroundingSource> = Vec::new();
for (i, r) in resp.results.iter().enumerate() {
let score = phrona::rank::positional_score(i);
let (title, url, content) = match r {
phrona::ResultItem::Web(w) => (&w.title, &w.url, &w.description),
phrona::ResultItem::News(n) => (&n.title, &n.url, &n.description),
phrona::ResultItem::Video(v) => (&v.title, &v.url, &v.description),
phrona::ResultItem::Image(i) => (&i.title, &i.url, &i.source),
phrona::ResultItem::Book(b) => (&b.title, &b.url, &b.info),
};
sources.push(GroundingSource {
title: title.clone(),
url: url.clone(),
content: content.clone(),
score,
});
}
let answer = synthesize_answer(&resp, &sources);
Ok(Json(GroundingResponse {
query: resp.query.clone(),
answer,
sources,
response_time,
}))
}