Skip to main content

phrona_api/
grounding.rs

1//! AI grounding endpoint: extractive answers over ranked search results.
2
3use 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/// POST /v1/grounding - AI grounding request; credentials via headers or
16/// the JSON body.
17#[derive(Deserialize)]
18pub struct GroundingRequest {
19    /// The search query to ground an answer on.
20    pub query: String,
21    #[serde(default)]
22    /// Optional API key; the `Authorization` header is the preferred fallback.
23    pub api_key: Option<String>,
24    #[serde(default)]
25    /// Maximum number of sources to return (clamped to 1..=50).
26    pub max_results: Option<usize>,
27    #[serde(default)]
28    /// Optional search category (`web`, `images`, `news`, `videos`,
29    /// `books`, `code`, `papers`, `archives`).
30    pub category: Option<String>,
31    #[serde(default)]
32    /// Optional time range filter (`day`, `week`, `month`, `year`).
33    pub time_range: Option<String>,
34    #[serde(default)]
35    /// Optional comma-separated engine restriction (as in `/v1/search`).
36    pub engines: Option<String>,
37    #[serde(default)]
38    /// Optional region hint (`us-en`).
39    pub region: Option<String>,
40    #[serde(default)]
41    /// Optional language hint (`en`).
42    pub language: Option<String>,
43    #[serde(default)]
44    /// Optional safesearch level (`off`, `moderate`, `strict`).
45    pub safesearch: Option<String>,
46    #[serde(default)]
47    /// Optional engine-specific filter string.
48    pub filters: Option<String>,
49    #[serde(default)]
50    /// Result page (1-based), like `phrona ground --page`.
51    pub page: Option<u32>,
52}
53
54/// GET /v1/grounding?query=... - same feature; auth is header-only.
55#[derive(Deserialize)]
56pub struct GroundingGetParams {
57    /// The search query to ground an answer on.
58    pub query: String,
59    #[serde(default)]
60    /// Maximum number of sources to return (clamped to 1..=50).
61    pub max_results: Option<usize>,
62    #[serde(default)]
63    /// Optional search category (`web`, `images`, `news`, `videos`,
64    /// `books`, `code`, `papers`, `archives`).
65    pub category: Option<String>,
66    #[serde(default)]
67    /// Optional time range filter (`day`, `week`, `month`, `year`).
68    pub time_range: Option<String>,
69    #[serde(default)]
70    /// Optional comma-separated engine restriction (as in `/v1/search`).
71    pub engines: Option<String>,
72    #[serde(default)]
73    /// Optional region hint (`us-en`).
74    pub region: Option<String>,
75    #[serde(default)]
76    /// Optional language hint (`en`).
77    pub language: Option<String>,
78    #[serde(default)]
79    /// Optional safesearch level (`off`, `moderate`, `strict`).
80    pub safesearch: Option<String>,
81    #[serde(default)]
82    /// Optional engine-specific filter string.
83    pub filters: Option<String>,
84    #[serde(default)]
85    /// Result page (1-based).
86    pub page: Option<u32>,
87}
88
89/// The response to a grounding request: an extractive answer plus the
90/// ranked sources it was built from.
91#[derive(Serialize)]
92pub struct GroundingResponse {
93    /// The query the answer was built for.
94    pub query: String,
95    /// Extractive answer synthesized from the sources.
96    pub answer: String,
97    /// Ranked sources supporting the answer.
98    pub sources: Vec<GroundingSource>,
99    /// Wall-clock time spent searching, in seconds.
100    pub response_time: f64,
101}
102
103/// One ranked source for a grounding answer.
104#[derive(Serialize)]
105pub struct GroundingSource {
106    /// Page title of the source.
107    pub title: String,
108    /// URL of the source.
109    pub url: String,
110    /// Readable text content used for grounding.
111    pub content: String,
112    /// Positional relevance score (1.0 down to 0.05).
113    pub score: f64,
114}
115
116/// Build an extractive answer from the top results. The library answer
117/// (e.g. from grokipedia) takes precedence; otherwise the strongest
118/// snippets are stitched together without claiming LLM generation.
119fn 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
147/// `GET /v1/grounding?query=...`: header-auth variant of the grounding
148/// endpoint.
149pub 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, &params_from_get(&p)).await
158}
159
160/// `POST /v1/grounding`: body variant of the grounding endpoint; the API key
161/// may come from the `Authorization` header or the JSON body.
162pub 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, &params_from_post(&p)).await
171}
172
173/// Shared option set between the GET and POST variants (minus auth).
174struct 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    // aligned with the CLI and web UI ground default
221    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}