use serde::{Deserialize, Serialize};
use crate::{
Engine, EngineError, FtsPropertyPathMode, FtsPropertyPathSpec, FtsPropertySchemaRecord,
};
use fathomdb_engine::{
BatchEmbedder, Capabilities, ConfigureEmbeddingOutcome, ConfigureVecOutcome, CurrentConfig,
EmbedderError, FtsProfile, KindDescription, ProjectionImpact, QueryEmbedder,
QueryEmbedderIdentity, VecIndexStatus, VecProfile, VectorSource,
};
#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PyPropertyPathMode {
Scalar,
Recursive,
}
impl From<PyPropertyPathMode> for FtsPropertyPathMode {
fn from(value: PyPropertyPathMode) -> Self {
match value {
PyPropertyPathMode::Scalar => Self::Scalar,
PyPropertyPathMode::Recursive => Self::Recursive,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct PyPropertyPathSpec {
pub path: String,
pub mode: PyPropertyPathMode,
#[serde(default)]
pub weight: Option<f32>,
}
impl From<PyPropertyPathSpec> for FtsPropertyPathSpec {
fn from(value: PyPropertyPathSpec) -> Self {
let base = match value.mode {
PyPropertyPathMode::Recursive => FtsPropertyPathSpec::recursive(value.path),
PyPropertyPathMode::Scalar => FtsPropertyPathSpec::scalar(value.path),
};
match value.weight {
Some(w) => base.with_weight(w),
None => base,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct PyRegisterFtsPropertySchemaRequest {
pub kind: String,
pub entries: Vec<PyPropertyPathSpec>,
#[serde(default = "default_separator")]
pub separator: String,
#[serde(default)]
pub exclude_paths: Vec<String>,
}
fn default_separator() -> String {
" ".to_owned()
}
#[derive(Debug)]
pub enum AdminFfiError {
Parse(serde_json::Error),
Engine(EngineError),
Serialize(serde_json::Error),
}
impl std::fmt::Display for AdminFfiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(e) => write!(f, "admin request JSON parse error: {e}"),
Self::Engine(e) => write!(f, "admin operation error: {e}"),
Self::Serialize(e) => write!(f, "admin response serialize error: {e}"),
}
}
}
impl std::error::Error for AdminFfiError {}
pub fn register_fts_property_schema_with_entries_json(
engine: &Engine,
request_json: &str,
) -> Result<String, AdminFfiError> {
let request: PyRegisterFtsPropertySchemaRequest =
serde_json::from_str(request_json).map_err(AdminFfiError::Parse)?;
let entries: Vec<FtsPropertyPathSpec> = request.entries.into_iter().map(Into::into).collect();
let record: FtsPropertySchemaRecord = engine
.register_fts_property_schema_with_entries(
&request.kind,
&entries,
Some(request.separator.as_str()),
&request.exclude_paths,
)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&record).map_err(AdminFfiError::Serialize)
}
#[derive(Debug, Deserialize)]
struct SetFtsProfileRequest {
kind: String,
tokenizer: String,
}
pub fn set_fts_profile_json(engine: &Engine, request_json: &str) -> Result<String, AdminFfiError> {
let request: SetFtsProfileRequest =
serde_json::from_str(request_json).map_err(AdminFfiError::Parse)?;
let profile: FtsProfile = engine
.admin()
.service()
.set_fts_profile(&request.kind, &request.tokenizer)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&profile).map_err(AdminFfiError::Serialize)
}
pub fn get_fts_profile_json(engine: &Engine, kind: &str) -> Result<String, AdminFfiError> {
let profile: Option<FtsProfile> = engine
.admin()
.service()
.get_fts_profile(kind)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&profile).map_err(AdminFfiError::Serialize)
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct SetVecProfileRequest {
model_identity: String,
#[serde(default)]
model_version: Option<String>,
dimensions: u32,
#[serde(default)]
normalization_policy: Option<String>,
}
pub fn set_vec_profile_json(engine: &Engine, request_json: &str) -> Result<String, AdminFfiError> {
let _validated: SetVecProfileRequest =
serde_json::from_str(request_json).map_err(AdminFfiError::Parse)?;
let profile: VecProfile = engine
.admin()
.service()
.set_vec_profile(request_json)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&profile).map_err(AdminFfiError::Serialize)
}
pub fn get_vec_profile_json(engine: &Engine, kind: &str) -> Result<String, AdminFfiError> {
let profile: Option<VecProfile> = engine
.admin()
.service()
.get_vec_profile(kind)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&profile).map_err(AdminFfiError::Serialize)
}
pub fn preview_projection_impact_json(
engine: &Engine,
kind: &str,
facet: &str,
) -> Result<String, AdminFfiError> {
let impact: ProjectionImpact = engine
.admin()
.service()
.preview_projection_impact(kind, facet)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&impact).map_err(AdminFfiError::Serialize)
}
#[derive(Debug, Deserialize)]
struct ConfigureVecKindRequest {
kind: String,
#[serde(default = "default_vec_source")]
source: String,
}
fn default_vec_source() -> String {
"chunks".to_owned()
}
#[derive(Debug, Deserialize)]
struct ConfigureEmbeddingRequest {
model_identity: String,
#[serde(default)]
model_version: Option<String>,
dimensions: u32,
#[serde(default)]
normalization_policy: Option<String>,
#[serde(default = "default_max_tokens")]
max_tokens: usize,
#[serde(default)]
acknowledge_rebuild_impact: bool,
}
fn default_max_tokens() -> usize {
512
}
#[derive(Debug)]
struct IdentityOnlyEmbedder {
identity: QueryEmbedderIdentity,
max_tokens: usize,
}
impl QueryEmbedder for IdentityOnlyEmbedder {
fn embed_query(&self, _text: &str) -> Result<Vec<f32>, EmbedderError> {
Err(EmbedderError::Unavailable(
"identity-only FFI shim cannot embed; configure_embedding only reads identity()"
.to_owned(),
))
}
fn identity(&self) -> QueryEmbedderIdentity {
self.identity.clone()
}
fn max_tokens(&self) -> usize {
self.max_tokens
}
}
pub fn configure_vec_kind_json(
engine: &Engine,
request_json: &str,
) -> Result<String, AdminFfiError> {
let request: ConfigureVecKindRequest =
serde_json::from_str(request_json).map_err(AdminFfiError::Parse)?;
let source = match request.source.as_str() {
"chunks" => VectorSource::Chunks,
other => {
return Err(AdminFfiError::Engine(EngineError::InvalidConfig(format!(
"unsupported vector source mode: {other:?}"
))));
}
};
let outcome: ConfigureVecOutcome = engine
.admin()
.service()
.configure_vec_kind(&request.kind, source)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&outcome).map_err(AdminFfiError::Serialize)
}
pub fn configure_embedding_json(
engine: &Engine,
request_json: &str,
) -> Result<String, AdminFfiError> {
let request: ConfigureEmbeddingRequest =
serde_json::from_str(request_json).map_err(AdminFfiError::Parse)?;
let identity = QueryEmbedderIdentity {
model_identity: request.model_identity,
model_version: request.model_version.unwrap_or_default(),
dimension: request.dimensions as usize,
normalization_policy: request.normalization_policy.unwrap_or_default(),
};
let shim = IdentityOnlyEmbedder {
identity,
max_tokens: request.max_tokens,
};
let outcome: ConfigureEmbeddingOutcome = engine
.admin()
.service()
.configure_embedding(&shim, request.acknowledge_rebuild_impact)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&outcome).map_err(AdminFfiError::Serialize)
}
pub fn get_vec_index_status_json(engine: &Engine, kind: &str) -> Result<String, AdminFfiError> {
let status: VecIndexStatus = engine
.admin()
.service()
.get_vec_index_status(kind)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&status).map_err(AdminFfiError::Serialize)
}
pub fn capabilities_json() -> Result<String, AdminFfiError> {
let caps: Capabilities = fathomdb_engine::AdminService::capabilities();
serde_json::to_string(&caps).map_err(AdminFfiError::Serialize)
}
pub fn current_config_json(engine: &Engine) -> Result<String, AdminFfiError> {
let cfg: CurrentConfig = engine
.admin()
.service()
.current_config()
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&cfg).map_err(AdminFfiError::Serialize)
}
pub fn describe_kind_json(engine: &Engine, kind: &str) -> Result<String, AdminFfiError> {
let desc: KindDescription = engine
.admin()
.service()
.describe_kind(kind)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&desc).map_err(AdminFfiError::Serialize)
}
#[derive(Debug, Deserialize)]
struct ConfigureVecKindsRequest {
items: Vec<ConfigureVecKindsItem>,
}
#[derive(Debug, Deserialize)]
struct ConfigureVecKindsItem {
kind: String,
source: String,
}
pub fn configure_vec_kinds_json(
engine: &Engine,
request_json: &str,
) -> Result<String, AdminFfiError> {
let request: ConfigureVecKindsRequest =
serde_json::from_str(request_json).map_err(AdminFfiError::Parse)?;
let mut items: Vec<(String, VectorSource)> = Vec::with_capacity(request.items.len());
for it in request.items {
let source = match it.source.as_str() {
"chunks" => VectorSource::Chunks,
other => {
return Err(AdminFfiError::Engine(EngineError::InvalidConfig(format!(
"unsupported vector source mode: {other:?}"
))));
}
};
items.push((it.kind, source));
}
let outcomes: Vec<ConfigureVecOutcome> = engine
.admin()
.service()
.configure_vec_kinds(&items)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&outcomes).map_err(AdminFfiError::Serialize)
}
#[derive(Debug, Deserialize)]
struct DrainVectorProjectionRequest {
timeout_ms: u64,
}
struct QueryEmbedderBatchAdapter<'a> {
inner: &'a dyn QueryEmbedder,
}
impl BatchEmbedder for QueryEmbedderBatchAdapter<'_> {
fn batch_embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
let mut out = Vec::with_capacity(texts.len());
for text in texts {
out.push(self.inner.embed_query(text)?);
}
Ok(out)
}
fn identity(&self) -> QueryEmbedderIdentity {
self.inner.identity()
}
fn max_tokens(&self) -> usize {
self.inner.max_tokens()
}
}
pub fn drain_vector_projection_json(
engine: &Engine,
request_json: &str,
) -> Result<String, AdminFfiError> {
let request: DrainVectorProjectionRequest =
serde_json::from_str(request_json).map_err(AdminFfiError::Parse)?;
let embedder_arc = engine
.coordinator()
.query_embedder()
.cloned()
.ok_or_else(|| AdminFfiError::Engine(EngineError::EmbedderNotConfigured))?;
let adapter = QueryEmbedderBatchAdapter {
inner: embedder_arc.as_ref(),
};
let report = engine
.admin()
.service()
.drain_vector_projection(
&adapter,
std::time::Duration::from_millis(request.timeout_ms),
)
.map_err(AdminFfiError::Engine)?;
serde_json::to_string(&report).map_err(AdminFfiError::Serialize)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::{PyPropertyPathMode, PyPropertyPathSpec, PyRegisterFtsPropertySchemaRequest};
use crate::FtsPropertyPathSpec;
#[test]
fn property_path_mode_snake_case_wire_form() {
let json = serde_json::to_string(&PyPropertyPathMode::Scalar).expect("serialize");
assert_eq!(json, "\"scalar\"");
let json = serde_json::to_string(&PyPropertyPathMode::Recursive).expect("serialize");
assert_eq!(json, "\"recursive\"");
}
#[test]
fn property_path_spec_roundtrip() {
let spec = PyPropertyPathSpec {
path: "$.payload".to_owned(),
mode: PyPropertyPathMode::Recursive,
weight: None,
};
let json = serde_json::to_string(&spec).expect("serialize");
let parsed: PyPropertyPathSpec = serde_json::from_str(&json).expect("deserialize");
assert_eq!(spec, parsed);
}
#[test]
fn register_request_defaults_separator_and_exclude_paths() {
let request: PyRegisterFtsPropertySchemaRequest =
serde_json::from_str(r#"{"kind":"K","entries":[{"path":"$.title","mode":"scalar"}]}"#)
.expect("parse");
assert_eq!(request.kind, "K");
assert_eq!(request.separator, " ");
assert!(request.exclude_paths.is_empty());
assert_eq!(request.entries.len(), 1);
}
#[test]
fn weight_round_trips_through_py_property_path_spec() {
let json = r#"{"path": "$.title", "mode": "scalar", "weight": 10.0}"#;
let spec: PyPropertyPathSpec = serde_json::from_str(json).expect("deserialize");
assert_eq!(spec.weight, Some(10.0_f32));
let fts_spec: FtsPropertyPathSpec = spec.into();
let _ = fts_spec; }
#[test]
fn weight_absent_defaults_to_none() {
let json = r#"{"path": "$.body", "mode": "scalar"}"#;
let spec: PyPropertyPathSpec = serde_json::from_str(json).expect("deserialize");
assert_eq!(spec.weight, None);
}
#[test]
fn register_request_roundtrip_recursive_entry() {
let request = PyRegisterFtsPropertySchemaRequest {
kind: "KnowledgeItem".to_owned(),
entries: vec![
PyPropertyPathSpec {
path: "$.title".to_owned(),
mode: PyPropertyPathMode::Scalar,
weight: None,
},
PyPropertyPathSpec {
path: "$.payload".to_owned(),
mode: PyPropertyPathMode::Recursive,
weight: None,
},
],
separator: " ".to_owned(),
exclude_paths: vec!["$.payload.ignored".to_owned()],
};
let json = serde_json::to_string(&request).expect("serialize");
let parsed: PyRegisterFtsPropertySchemaRequest =
serde_json::from_str(&json).expect("deserialize");
assert_eq!(request, parsed);
}
}