#![recursion_limit = "256"]
#![doc(test(attr(recursion_limit = "256")))]
pub mod chat;
pub mod core;
pub mod error;
pub mod formatter;
pub mod models_catalog;
pub mod processor;
pub mod schema;
pub mod skills;
pub mod template;
pub mod udf;
pub mod usage;
pub mod validator;
pub use chat::{ChatMessage, ChatRequest, ChatRole};
pub use error::ErrorResponse;
pub use genai::adapter::AdapterKind;
pub use processor::{
TextToCypherRequest, TextToCypherResponse, process_text_to_cypher_with_context, process_text_to_cypher_with_skills,
};
pub use skills::{SkillCatalog, SkillProfile};
pub use udf::{UdfCatalog, UdfError, UdfFunction, UdfLibrary, UdfSource};
pub use usage::TokenUsage;
#[cfg(feature = "server")]
pub mod mcp;
pub struct TextToCypherClient {
model: String,
api_key: String,
falkordb_connection: String,
llm_endpoint: Option<String>,
skill_catalog: Option<SkillCatalog>,
udf_source: UdfSource,
}
impl TextToCypherClient {
#[must_use]
pub fn new(
model: impl Into<String>,
api_key: impl Into<String>,
falkordb_connection: impl Into<String>,
) -> Self {
Self {
model: model.into(),
api_key: api_key.into(),
falkordb_connection: falkordb_connection.into(),
llm_endpoint: None,
skill_catalog: Some(SkillCatalog::builtin()),
udf_source: UdfSource::Off,
}
}
#[must_use]
pub fn with_llm_endpoint(
mut self,
endpoint: impl Into<String>,
) -> Self {
self.llm_endpoint = Some(endpoint.into());
self
}
#[must_use]
pub fn with_skills(
mut self,
catalog: SkillCatalog,
) -> Self {
self.skill_catalog = Some(catalog);
self
}
#[must_use]
pub fn with_additional_skills(
mut self,
catalog: SkillCatalog,
) -> Self {
self.skill_catalog = Some(match self.skill_catalog.take() {
Some(existing) => existing.merged_with(catalog),
None => catalog,
});
self
}
#[must_use]
pub fn without_skills(mut self) -> Self {
self.skill_catalog = None;
self
}
#[must_use]
pub fn with_discovered_udfs(mut self) -> Self {
self.udf_source = UdfSource::Discover;
self
}
#[must_use]
pub fn with_udfs(
mut self,
catalog: UdfCatalog,
) -> Self {
self.udf_source = UdfSource::Provided(catalog);
self
}
#[must_use]
pub fn without_udfs(mut self) -> Self {
self.udf_source = UdfSource::Off;
self
}
pub async fn text_to_cypher(
&self,
graph_name: impl Into<String>,
request: ChatRequest,
) -> Result<TextToCypherResponse, Box<dyn std::error::Error + Send + Sync>> {
let req = TextToCypherRequest {
graph_name: graph_name.into(),
chat_request: request,
model: Some(self.model.clone()),
key: Some(self.api_key.clone()),
falkordb_connection: Some(self.falkordb_connection.clone()),
llm_endpoint: self.llm_endpoint.clone(),
cypher_only: false,
};
let response = processor::process_text_to_cypher_with_context(
req,
Some(self.model.clone()),
Some(self.api_key.clone()),
self.falkordb_connection.clone(),
self.skill_catalog.as_ref(),
&self.udf_source,
)
.await;
if response.is_error() {
return Err(response.error.unwrap_or_else(|| "Unknown error".to_string()).into());
}
Ok(response)
}
pub async fn cypher_only(
&self,
graph_name: impl Into<String>,
request: ChatRequest,
) -> Result<TextToCypherResponse, Box<dyn std::error::Error + Send + Sync>> {
let req = TextToCypherRequest {
graph_name: graph_name.into(),
chat_request: request,
model: Some(self.model.clone()),
key: Some(self.api_key.clone()),
falkordb_connection: Some(self.falkordb_connection.clone()),
llm_endpoint: self.llm_endpoint.clone(),
cypher_only: true,
};
let response = processor::process_text_to_cypher_with_context(
req,
Some(self.model.clone()),
Some(self.api_key.clone()),
self.falkordb_connection.clone(),
self.skill_catalog.as_ref(),
&self.udf_source,
)
.await;
if response.is_error() {
return Err(response.error.unwrap_or_else(|| "Unknown error".to_string()).into());
}
Ok(response)
}
pub async fn discover_schema(
&self,
graph_name: impl Into<String>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
core::discover_graph_schema(&self.falkordb_connection, &graph_name.into()).await
}
pub async fn list_models(
&self,
adapter_kind: AdapterKind,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let client = core::create_genai_client_with_endpoint(Some(&self.api_key), self.llm_endpoint.as_deref());
core::list_adapter_models_with_endpoint(adapter_kind, &client, self.llm_endpoint.as_deref()).await
}
pub async fn list_all_models(
&self
) -> Result<std::collections::HashMap<AdapterKind, Vec<String>>, Box<dyn std::error::Error + Send + Sync>> {
let client = core::create_genai_client_with_endpoint(Some(&self.api_key), self.llm_endpoint.as_deref());
core::list_all_models_with_endpoint(&client, self.llm_endpoint.as_deref()).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = TextToCypherClient::new("gpt-4o-mini", "test-api-key", "falkor://127.0.0.1:6379");
assert_eq!(client.model, "gpt-4o-mini");
assert_eq!(client.api_key, "test-api-key");
assert_eq!(client.falkordb_connection, "falkor://127.0.0.1:6379");
assert_eq!(client.llm_endpoint, None);
}
#[test]
fn test_client_creation_with_string() {
let client = TextToCypherClient::new(
"anthropic:claude-3".to_string(),
"key123".to_string(),
"falkor://localhost:6379".to_string(),
);
assert_eq!(client.model, "anthropic:claude-3");
assert_eq!(client.api_key, "key123");
assert_eq!(client.falkordb_connection, "falkor://localhost:6379");
assert_eq!(client.llm_endpoint, None);
assert!(client.skill_catalog.is_some());
}
#[test]
fn test_client_with_llm_endpoint() {
let client = TextToCypherClient::new("openai::local-model", "key", "falkor://localhost:6379")
.with_llm_endpoint("http://localhost:1234/v1");
assert_eq!(client.llm_endpoint, Some("http://localhost:1234/v1".to_string()));
}
#[test]
fn test_client_with_skills() {
let catalog = SkillCatalog::empty();
let client = TextToCypherClient::new("gpt-4o-mini", "key", "falkor://localhost:6379").with_skills(catalog);
assert!(client.skill_catalog.is_some());
}
#[test]
fn test_new_client_includes_builtin_skills() {
let client = TextToCypherClient::new("gpt-4o-mini", "key", "falkor://127.0.0.1:6379");
let catalog = client.skill_catalog.as_ref().expect("builtin skills by default");
assert!(!catalog.is_empty());
assert!(catalog.get_skill("falkordb-fulltext-search").is_some());
}
#[test]
fn test_without_skills_clears_builtin() {
let client = TextToCypherClient::new("m", "k", "falkor://127.0.0.1:6379").without_skills();
assert!(client.skill_catalog.is_none());
}
#[test]
fn test_with_skills_replaces_builtin() {
let client = TextToCypherClient::new("m", "k", "falkor://127.0.0.1:6379").with_skills(SkillCatalog::empty());
assert!(client.skill_catalog.as_ref().unwrap().is_empty());
}
#[test]
fn test_new_client_has_udf_off_by_default() {
let client = TextToCypherClient::new("m", "k", "falkor://127.0.0.1:6379");
assert_eq!(client.udf_source, UdfSource::Off);
}
#[test]
fn test_with_discovered_udfs_sets_discover() {
let client = TextToCypherClient::new("m", "k", "falkor://127.0.0.1:6379").with_discovered_udfs();
assert_eq!(client.udf_source, UdfSource::Discover);
}
#[test]
fn test_with_udfs_sets_provided_catalog() {
let catalog = UdfCatalog::from_libraries(vec![UdfLibrary {
name: "mylib".to_string(),
functions: vec![UdfFunction::new("Foo")],
}]);
let client = TextToCypherClient::new("m", "k", "falkor://127.0.0.1:6379").with_udfs(catalog.clone());
assert_eq!(client.udf_source, UdfSource::Provided(catalog));
}
#[test]
fn test_without_udfs_resets_to_off() {
let client = TextToCypherClient::new("m", "k", "falkor://127.0.0.1:6379")
.with_discovered_udfs()
.without_udfs();
assert_eq!(client.udf_source, UdfSource::Off);
}
#[test]
fn test_with_additional_skills_keeps_builtin() {
let client =
TextToCypherClient::new("m", "k", "falkor://127.0.0.1:6379").with_additional_skills(SkillCatalog::empty());
assert!(
client
.skill_catalog
.as_ref()
.unwrap()
.get_skill("falkordb-fulltext-search")
.is_some()
);
}
#[test]
fn test_chat_request_construction() {
let request = ChatRequest {
messages: vec![
ChatMessage {
role: ChatRole::User,
content: "Hello".to_string(),
},
ChatMessage {
role: ChatRole::Assistant,
content: "Hi there".to_string(),
},
],
};
assert_eq!(request.messages.len(), 2);
assert_eq!(request.messages[0].role, ChatRole::User);
assert_eq!(request.messages[1].role, ChatRole::Assistant);
}
#[test]
fn test_chat_role_serialization() {
let role = ChatRole::User;
let json = serde_json::to_string(&role).unwrap();
assert_eq!(json, r#""user""#);
let role = ChatRole::Assistant;
let json = serde_json::to_string(&role).unwrap();
assert_eq!(json, r#""assistant""#);
let role = ChatRole::System;
let json = serde_json::to_string(&role).unwrap();
assert_eq!(json, r#""system""#);
}
#[test]
fn test_chat_message_serialization() {
let message = ChatMessage {
role: ChatRole::User,
content: "Test message".to_string(),
};
let json = serde_json::to_string(&message).unwrap();
let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.role, ChatRole::User);
assert_eq!(deserialized.content, "Test message");
}
#[test]
fn test_chat_request_serialization() {
let request = ChatRequest {
messages: vec![ChatMessage {
role: ChatRole::User,
content: "Find all nodes".to_string(),
}],
};
let json = serde_json::to_string(&request).unwrap();
let deserialized: ChatRequest = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.messages.len(), 1);
assert_eq!(deserialized.messages[0].content, "Find all nodes");
}
#[test]
fn test_error_response_structure() {
let error = ErrorResponse {
error: "Test error".to_string(),
message: "Detailed message".to_string(),
status_code: 500,
};
assert_eq!(error.error, "Test error");
assert_eq!(error.message, "Detailed message");
assert_eq!(error.status_code, 500);
}
#[test]
fn test_chat_role_equality() {
assert_eq!(ChatRole::User, ChatRole::User);
assert_eq!(ChatRole::Assistant, ChatRole::Assistant);
assert_eq!(ChatRole::System, ChatRole::System);
assert_ne!(ChatRole::User, ChatRole::Assistant);
}
#[test]
fn test_client_with_different_models() {
let models = vec!["gpt-4o-mini", "gpt-4o", "anthropic:claude-3", "gemini:gemini-2.0-flash-exp"];
for model in models {
let client = TextToCypherClient::new(model, "key", "falkor://localhost:6379");
assert_eq!(client.model, model);
}
}
}