little-librarian/src/api/query.rs

128 lines
3.3 KiB
Rust

//! Query endpoint handlers and response types.
use std::sync::Arc;
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use snafu::{ResultExt, Snafu, ensure};
use utoipa::ToSchema;
use super::{TAG, error::HttpStatus, state::AppState};
use crate::{http_error, query, storage::DocumentMatch};
const MAX_LIMIT: usize = 10;
/// Errors that occur during query processing.
#[derive(Debug, Snafu)]
pub enum QueryError {
#[snafu(display("'limit' query parameter must be a positive integer <= {MAX_LIMIT}."))]
Limit,
#[snafu(display("Failed to run query."))]
Query { source: query::AskError },
}
impl HttpStatus for QueryError {
fn status_code(&self) -> StatusCode {
match self {
QueryError::Limit => StatusCode::BAD_REQUEST,
QueryError::Query { source: _ } => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
http_error!(QueryError);
/// Query parameters for search requests.
#[derive(Deserialize)]
pub struct QueryParams {
/// Maximum number of results to return.
pub limit: Option<usize>,
}
/// Response format for successful query requests.
#[derive(Debug, Serialize, ToSchema)]
pub struct QueryResponse {
/// List of matching document chunks.
pub results: Vec<DocumentResult>,
/// Total number of results returned.
pub count: usize,
/// Original query text that was searched.
pub query: String,
}
impl From<(Vec<DocumentMatch>, String)> for QueryResponse {
fn from((documents, query): (Vec<DocumentMatch>, String)) -> Self {
let results: Vec<DocumentResult> = documents.into_iter().map(Into::into).collect();
let count = results.len();
Self {
results,
count,
query,
}
}
}
/// A single document search result.
#[derive(Debug, Serialize, ToSchema)]
pub struct DocumentResult {
/// Calibre book ID containing this text.
pub book_id: i64,
/// Text content of the matching chunk.
pub text_chunk: String,
/// Similarity score between 0.0 and 1.0.
pub similarity: f64,
}
impl From<DocumentMatch> for DocumentResult {
fn from(doc: DocumentMatch) -> Self {
Self {
book_id: doc.book_id,
text_chunk: doc.text_chunk,
similarity: doc.similarity,
}
}
}
/// Execute a semantic search query against the document database.
#[utoipa::path(
post,
path = "/query",
tag = TAG,
params(
("limit" = Option<usize>, Query, description = "Maximum number of results")
),
request_body = String,
responses(
(status = OK, body = QueryResponse),
(status = 400, description = "Wrong parameter."),
(status = 500, description = "Failure to query database.")
)
)]
pub async fn route(
Query(params): Query<QueryParams>,
State(state): State<Arc<AppState>>,
body: String,
) -> Result<Json<QueryResponse>, QueryError> {
let limit = params.limit.unwrap_or(5);
ensure!(limit <= MAX_LIMIT, LimitSnafu);
let results = query::ask(
&body,
&state.db,
&state.tokenizer,
&state.embedder,
&state.reranker,
state.chunk_size,
limit,
)
.await
.context(QuerySnafu)?;
let response = QueryResponse::from((results, body));
Ok(Json(response))
}