use std::collections::HashMap;
use std::time::Duration;
pub mod dto;
pub mod observer;
pub mod reconcile;
pub mod sync;
use async_trait::async_trait;
use dto::{
AnswerResult, DeleteRequest, EmbeddingsResponse, Health, HistoryTurn, IndexDocsRequest,
JobAccepted, JobStatus, QueryRequest, WireDoc,
};
pub use dto::{ChunkResult, WireSection};
pub use observer::{DirtyOp, DirtySet, RagObserver};
pub use reconcile::{ReconcilePlan, diff as reconcile_diff};
#[derive(Debug, thiserror::Error)]
pub enum RagError {
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("server returned {status}: {body}")]
Status { status: u16, body: String },
#[error("{0}")]
Protocol(String),
}
impl RagError {
pub fn is_auth(&self) -> bool {
matches!(
self,
RagError::Status {
status: 401 | 403,
..
}
)
}
}
#[async_trait]
pub trait RagTransport: Send + Sync {
async fn push_docs(&self, docs: Vec<WireDoc>) -> Result<(), RagError>;
async fn delete_paths(&self, paths: Vec<String>) -> Result<(), RagError>;
async fn server_hashes(&self) -> Result<HashMap<String, String>, RagError>;
}
#[async_trait]
impl RagTransport for RagClient {
async fn push_docs(&self, docs: Vec<WireDoc>) -> Result<(), RagError> {
RagClient::push_docs(self, docs).await.map(|_job_id| ())
}
async fn delete_paths(&self, paths: Vec<String>) -> Result<(), RagError> {
RagClient::delete_paths(self, paths).await
}
async fn server_hashes(&self) -> Result<HashMap<String, String>, RagError> {
RagClient::server_hashes(self).await
}
}
pub fn hash_string(hash: u64) -> String {
hash.to_string()
}
#[derive(Debug, Clone, Copy)]
pub enum ContextSize {
Small,
Medium,
Large,
}
impl ContextSize {
fn as_str(self) -> &'static str {
match self {
ContextSize::Small => "small",
ContextSize::Medium => "medium",
ContextSize::Large => "large",
}
}
}
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const PUSH_TIMEOUT: Duration = Duration::from_secs(120);
fn shared_http() -> reqwest::Client {
static HTTP: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
HTTP.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.build()
.expect("build HTTP client")
})
.clone()
}
#[derive(Clone)]
pub struct RagClient {
http: reqwest::Client,
base_url: String,
token: Option<String>,
vault_id: String,
}
impl RagClient {
pub fn new(
base_url: impl Into<String>,
token: Option<String>,
vault_id: impl Into<String>,
) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
Self {
http: shared_http(),
base_url,
token,
vault_id: vault_id.into(),
}
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.token {
Some(token) => req.bearer_auth(token),
None => req,
}
}
async fn ok(resp: reqwest::Response) -> Result<reqwest::Response, RagError> {
if resp.status().is_success() {
Ok(resp)
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(RagError::Status { status, body })
}
}
pub async fn health(&self) -> Result<Health, RagError> {
let resp = self.auth(self.http.get(self.url("/health"))).send().await?;
Ok(Self::ok(resp).await?.json::<Health>().await?)
}
pub async fn push_docs(&self, docs: Vec<WireDoc>) -> Result<String, RagError> {
let body = IndexDocsRequest {
vault_id: self.vault_id.clone(),
docs,
};
let resp = self
.auth(self.http.post(self.url("/api/index/docs")).json(&body))
.timeout(PUSH_TIMEOUT)
.send()
.await?;
Ok(Self::ok(resp).await?.json::<JobAccepted>().await?.job_id)
}
pub async fn delete_paths(&self, paths: Vec<String>) -> Result<(), RagError> {
let body = DeleteRequest {
vault_id: self.vault_id.clone(),
paths,
};
let resp = self
.auth(self.http.post(self.url("/api/index/delete")).json(&body))
.send()
.await?;
Self::ok(resp).await?;
Ok(())
}
pub async fn server_hashes(&self) -> Result<HashMap<String, String>, RagError> {
let path = format!("/api/collections/{}/hashes", self.vault_id);
let resp = self.auth(self.http.get(self.url(&path))).send().await?;
Ok(Self::ok(resp)
.await?
.json::<HashMap<String, String>>()
.await?)
}
pub async fn search(
&self,
query: &str,
context_size: Option<ContextSize>,
) -> Result<Vec<ChunkResult>, RagError> {
let body = QueryRequest {
vault_id: self.vault_id.clone(),
query: query.to_string(),
context_size: context_size.map(|c| c.as_str().to_string()),
history: vec![],
};
let resp = self
.auth(self.http.post(self.url("/api/embeddings")).json(&body))
.send()
.await?;
Ok(Self::ok(resp)
.await?
.json::<EmbeddingsResponse>()
.await?
.chunks)
}
pub async fn ask(
&self,
query: &str,
history: &[(String, String)],
context_size: Option<ContextSize>,
) -> Result<AnswerResult, RagError> {
let body = QueryRequest {
vault_id: self.vault_id.clone(),
query: query.to_string(),
context_size: context_size.map(|c| c.as_str().to_string()),
history: history
.iter()
.map(|(q, a)| HistoryTurn {
question: q.clone(),
answer: a.clone(),
})
.collect(),
};
let resp = self
.auth(self.http.post(self.url("/api/answer")).json(&body))
.send()
.await?;
let job_id = Self::ok(resp).await?.json::<JobAccepted>().await?.job_id;
self.poll_answer(&job_id).await
}
const ANSWER_POLL_ATTEMPTS: u32 = 720;
async fn poll_answer(&self, job_id: &str) -> Result<AnswerResult, RagError> {
let path = format!("/api/job/{job_id}");
let mut consecutive_errors = 0u32;
for _ in 0..Self::ANSWER_POLL_ATTEMPTS {
let status = match self.poll_once(&path).await {
Ok(s) => {
consecutive_errors = 0;
s
}
Err(e) => {
consecutive_errors += 1;
if consecutive_errors >= 15 {
return Err(e);
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue;
}
};
match status.status.as_str() {
"completed" => {
let result = status
.result
.ok_or_else(|| RagError::Protocol("completed job had no result".into()))?;
return serde_json::from_value::<AnswerResult>(result)
.map_err(|e| RagError::Protocol(format!("bad answer result: {e}")));
}
"failed" => {
return Err(RagError::Protocol(
status.error.unwrap_or_else(|| "answer job failed".into()),
));
}
_ => tokio::time::sleep(std::time::Duration::from_secs(1)).await,
}
}
Err(RagError::Protocol("answer job timed out".into()))
}
async fn poll_once(&self, path: &str) -> Result<JobStatus, RagError> {
let resp = self.auth(self.http.get(self.url(path))).send().await?;
Ok(Self::ok(resp).await?.json::<JobStatus>().await?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_auth_matches_only_credential_rejections() {
let status = |status| RagError::Status {
status,
body: String::new(),
};
assert!(status(401).is_auth());
assert!(status(403).is_auth());
assert!(!status(500).is_auth());
assert!(!status(404).is_auth());
assert!(!RagError::Protocol("boom".into()).is_auth());
}
}