use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::error::JammiError;
use jammi_db::source::{FileFormat, SourceConnection, SourceType};
use jammi_db::TenantId;
use tonic::{Request, Response, Status};
use std::collections::HashMap;
use arrow::array::{Array, Float32Array, RecordBatch, StringArray};
use arrow::util::display::{ArrayFormatter, FormatOptions};
use jammi_ai::search::SearchBuilder;
use crate::grpc::proto::embedding::embedding_service_server::EmbeddingService;
use crate::grpc::proto::embedding::{
search_request::Query as ProtoQuery, AddSourceRequest, EncodeAudioQueryRequest,
EncodeAudioQueryResponse, FileFormat as ProtoFileFormat, GenerateAudioEmbeddingsRequest,
ResultTable, SearchHit, SearchRequest, SearchResponse, SourceKind as ProtoSourceKind,
};
use crate::grpc::session::SessionTenant;
pub struct EmbeddingServer {
session: Arc<InferenceSession>,
}
impl EmbeddingServer {
pub fn new(session: Arc<InferenceSession>) -> Self {
Self { session }
}
}
#[tonic::async_trait]
impl EmbeddingService for EmbeddingServer {
async fn add_source(&self, request: Request<AddSourceRequest>) -> Result<Response<()>, Status> {
let tenant = session_tenant(&request);
let req = request.into_inner();
if req.source_id.is_empty() {
return Err(Status::invalid_argument("source_id is required"));
}
let source_type = source_type_from_proto(req.source_kind)?;
let connection = connection_from_proto(req.connection)?;
scoped(&self.session, tenant, || {
self.session
.add_source(&req.source_id, source_type, connection)
})
.await
.map_err(map_engine_error)?;
Ok(Response::new(()))
}
async fn generate_audio_embeddings(
&self,
request: Request<GenerateAudioEmbeddingsRequest>,
) -> Result<Response<ResultTable>, Status> {
let tenant = session_tenant(&request);
let req = request.into_inner();
require_nonempty(&req.source_id, "source_id")?;
require_nonempty(&req.model_id, "model_id")?;
require_nonempty(&req.audio_column, "audio_column")?;
require_nonempty(&req.key_column, "key_column")?;
let record = scoped(&self.session, tenant, || {
self.session.generate_audio_embeddings(
&req.source_id,
&req.model_id,
&req.audio_column,
&req.key_column,
)
})
.await
.map_err(map_engine_error)?;
Ok(Response::new(ResultTable {
table_name: record.table_name,
source_id: record.source_id,
model_id: record.model_id,
dimensions: record.dimensions.unwrap_or(0),
row_count: record.row_count as u64,
status: record.status,
}))
}
async fn encode_audio_query(
&self,
request: Request<EncodeAudioQueryRequest>,
) -> Result<Response<EncodeAudioQueryResponse>, Status> {
let tenant = session_tenant(&request);
let req = request.into_inner();
require_nonempty(&req.model_id, "model_id")?;
if req.audio_bytes.is_empty() {
return Err(Status::invalid_argument("audio_bytes is required"));
}
let embedding = scoped(&self.session, tenant, || {
self.session
.encode_audio_query(&req.model_id, &req.audio_bytes)
})
.await
.map_err(map_engine_error)?;
Ok(Response::new(EncodeAudioQueryResponse { embedding }))
}
async fn search(
&self,
request: Request<SearchRequest>,
) -> Result<Response<SearchResponse>, Status> {
let tenant = session_tenant(&request);
let req = request.into_inner();
require_nonempty(&req.source_id, "source_id")?;
let query = req.query.ok_or_else(|| {
Status::invalid_argument("query (query_vector or row_key) is required")
})?;
let k = req.k as usize;
let select = req.select;
let filter = req.filter;
let batches = scoped(&self.session, tenant, || async {
let builder = match query {
ProtoQuery::QueryVector(v) => {
self.session.search(&req.source_id, v.values, k).await?
}
ProtoQuery::RowKey(key) => {
self.session.search_by_id(&req.source_id, &key, k).await?
}
};
let builder = match filter.as_deref() {
Some(predicate) => builder.filter(predicate)?,
None => builder,
};
let builder = apply_select(builder, &select)?;
builder.run().await
})
.await
.map_err(map_engine_error)?;
let hits = batches_to_hits(&batches, &select)?;
Ok(Response::new(SearchResponse { hits }))
}
}
fn apply_select(builder: SearchBuilder, select: &[String]) -> Result<SearchBuilder, JammiError> {
if select.is_empty() {
return Ok(builder);
}
let mut columns: Vec<String> = vec!["_row_id".to_string(), "similarity".to_string()];
for name in select {
if name != "_row_id" && name != "similarity" {
columns.push(name.clone());
}
}
builder.select(&columns)
}
fn batches_to_hits(batches: &[RecordBatch], select: &[String]) -> Result<Vec<SearchHit>, Status> {
let mut hits = Vec::new();
let format = FormatOptions::default();
for batch in batches {
let keys = column_as::<StringArray>(batch, "_row_id")?;
let scores = column_as::<Float32Array>(batch, "similarity")?;
let formatters: Vec<(String, ArrayFormatter)> = select
.iter()
.map(|name| {
let array = batch.column_by_name(name).ok_or_else(|| {
Status::invalid_argument(format!("select column '{name}' not in results"))
})?;
let formatter = ArrayFormatter::try_new(array.as_ref(), &format)
.map_err(|e| Status::internal(format!("format column '{name}': {e}")))?;
Ok((name.clone(), formatter))
})
.collect::<Result<_, Status>>()?;
for row in 0..batch.num_rows() {
let columns: HashMap<String, String> = formatters
.iter()
.map(|(name, fmt)| (name.clone(), fmt.value(row).to_string()))
.collect();
hits.push(SearchHit {
key: keys.value(row).to_string(),
score: scores.value(row),
columns,
});
}
}
Ok(hits)
}
fn column_as<'a, A: Array + 'static>(batch: &'a RecordBatch, name: &str) -> Result<&'a A, Status> {
batch
.column_by_name(name)
.ok_or_else(|| Status::internal(format!("search result missing '{name}' column")))?
.as_any()
.downcast_ref::<A>()
.ok_or_else(|| {
Status::internal(format!("search result '{name}' column has unexpected type"))
})
}
fn session_tenant<T>(request: &Request<T>) -> Option<TenantId> {
request
.extensions()
.get::<SessionTenant>()
.and_then(|s| s.0)
}
async fn scoped<F, Fut, T>(
session: &Arc<InferenceSession>,
tenant: Option<TenantId>,
f: F,
) -> Result<T, JammiError>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<T, JammiError>>,
{
match tenant {
Some(t) => session.with_tenant_scoped(t, |_scope| f()).await,
None => f().await,
}
}
fn require_nonempty(value: &str, field: &str) -> Result<(), Status> {
if value.is_empty() {
Err(Status::invalid_argument(format!("{field} is required")))
} else {
Ok(())
}
}
fn source_type_from_proto(kind: i32) -> Result<SourceType, Status> {
match ProtoSourceKind::try_from(kind) {
Ok(ProtoSourceKind::File) => Ok(SourceType::File),
Ok(ProtoSourceKind::Postgres) => Ok(SourceType::Postgres),
Ok(ProtoSourceKind::Mysql) => Ok(SourceType::Mysql),
Ok(ProtoSourceKind::Unspecified) | Err(_) => {
Err(Status::invalid_argument("source_kind must be specified"))
}
}
}
fn file_format_from_proto(format: i32) -> Result<Option<FileFormat>, Status> {
match ProtoFileFormat::try_from(format) {
Ok(ProtoFileFormat::Parquet) => Ok(Some(FileFormat::Parquet)),
Ok(ProtoFileFormat::Csv) => Ok(Some(FileFormat::Csv)),
Ok(ProtoFileFormat::Json) => Ok(Some(FileFormat::Json)),
Ok(ProtoFileFormat::Avro) => Ok(Some(FileFormat::Avro)),
Ok(ProtoFileFormat::Unspecified) | Err(_) => Ok(None),
}
}
fn connection_from_proto(
conn: Option<crate::grpc::proto::embedding::SourceConnection>,
) -> Result<SourceConnection, Status> {
let conn = conn.ok_or_else(|| Status::invalid_argument("connection is required"))?;
let url = if conn.url.is_empty() {
None
} else {
Some(conn.url)
};
Ok(SourceConnection {
url,
format: file_format_from_proto(conn.format)?,
..Default::default()
})
}
fn map_engine_error(err: JammiError) -> Status {
match err {
JammiError::Source { source_id, message } => {
Status::invalid_argument(format!("source {source_id}: {message}"))
}
JammiError::Model { model_id, message } => {
Status::invalid_argument(format!("model {model_id}: {message}"))
}
JammiError::Tenant(detail) => Status::invalid_argument(format!("tenant: {detail}")),
JammiError::Config(detail) => Status::invalid_argument(format!("config: {detail}")),
JammiError::Schema { .. } => Status::invalid_argument(err.to_string()),
JammiError::Inference(detail) => Status::internal(format!("inference: {detail}")),
other => Status::internal(other.to_string()),
}
}