use crate::chat::ChatRequest;
use crate::core::{
create_genai_client, discover_graph_schema, execute_cypher_query, generate_cypher_query_with_skills,
generate_final_answer,
};
use crate::skills::SkillCatalog;
use serde::{Deserialize, Serialize};
use std::error::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextToCypherRequest {
pub graph_name: String,
pub chat_request: ChatRequest,
pub model: Option<String>,
pub key: Option<String>,
pub falkordb_connection: Option<String>,
#[serde(default)]
pub cypher_only: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TextToCypherResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cypher_query: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cypher_result: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub answer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl TextToCypherResponse {
#[must_use]
pub fn is_success(&self) -> bool {
self.status == "success"
}
#[must_use]
pub fn is_error(&self) -> bool {
self.status == "error"
}
#[must_use]
pub fn success(
schema: String,
cypher_query: String,
cypher_result: Option<String>,
answer: Option<String>,
) -> Self {
Self {
status: "success".to_string(),
schema: Some(schema),
cypher_query: Some(cypher_query),
cypher_result,
answer,
error: None,
}
}
#[must_use]
pub fn error(error_message: String) -> Self {
Self {
status: "error".to_string(),
schema: None,
cypher_query: None,
cypher_result: None,
answer: None,
error: Some(error_message),
}
}
}
pub async fn process_text_to_cypher(
request: TextToCypherRequest,
default_model: Option<String>,
default_key: Option<String>,
default_connection: String,
) -> TextToCypherResponse {
process_text_to_cypher_with_skills(request, default_model, default_key, default_connection, None).await
}
#[allow(clippy::too_many_lines)]
pub async fn process_text_to_cypher_with_skills(
request: TextToCypherRequest,
default_model: Option<String>,
default_key: Option<String>,
default_connection: String,
skill_catalog: Option<&SkillCatalog>,
) -> TextToCypherResponse {
let model = request.model.clone().or(default_model);
let key = request.key.clone().or(default_key);
let has_custom_connection = request.falkordb_connection.is_some();
let falkordb_connection = request.falkordb_connection.clone().unwrap_or(default_connection);
let Some(model) = model else {
return TextToCypherResponse::error("Model must be provided either in request or as DEFAULT_MODEL".to_string());
};
let client = create_genai_client(key.as_deref());
let service_target = match client.resolve_service_target(&model).await {
Ok(target) => target,
Err(e) => {
return TextToCypherResponse::error(format!("Failed to resolve service target: {e}"));
}
};
tracing::info!(
"Processing text-to-cypher for graph: {} using model: {} ({:?})",
request.graph_name,
model,
service_target.model.adapter_kind
);
let schema = if request.cypher_only && !has_custom_connection {
tracing::info!("Skipping schema discovery in cypher_only mode");
"{}".to_string()
} else {
match discover_graph_schema(&falkordb_connection, &request.graph_name).await {
Ok(s) => {
tracing::info!("Schema discovered successfully");
s
}
Err(e) => {
return TextToCypherResponse::error(format!("Failed to discover schema: {e}"));
}
}
};
let cypher_query =
match generate_cypher_query_with_skills(&request.chat_request, &schema, &client, &model, skill_catalog).await {
Ok(q) => q,
Err(e) => {
return TextToCypherResponse::error(format!("Failed to generate query: {e}"));
}
};
tracing::info!("Cypher query generated: {}", cypher_query);
if request.cypher_only {
return TextToCypherResponse::success(schema, cypher_query, None, None);
}
let cypher_result = match execute_cypher_query(&cypher_query, &request.graph_name, &falkordb_connection, true).await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("Query execution failed, attempting self-healing: {}", e);
match attempt_self_healing(
&request,
&schema,
&cypher_query,
&e.to_string(),
&client,
&model,
&falkordb_connection,
skill_catalog,
)
.await
{
Ok((healed_query, healed_result)) => {
tracing::info!("Self-healing successful");
let answer = match generate_final_answer(
&request.chat_request,
&healed_query,
&healed_result,
&client,
&model,
)
.await
{
Ok(a) => Some(a),
Err(e) => {
tracing::error!("Failed to generate answer: {}", e);
None
}
};
return TextToCypherResponse::success(schema, healed_query, Some(healed_result), answer);
}
Err(heal_error) => {
return TextToCypherResponse::error(format!(
"Query execution failed: {e}. Self-healing also failed: {heal_error}"
));
}
}
}
};
tracing::info!("Query executed successfully");
let answer =
match generate_final_answer(&request.chat_request, &cypher_query, &cypher_result, &client, &model).await {
Ok(a) => Some(a),
Err(e) => {
return TextToCypherResponse::error(format!("Failed to generate answer: {e}"));
}
};
TextToCypherResponse::success(schema, cypher_query, Some(cypher_result), answer)
}
#[allow(clippy::too_many_arguments)]
async fn attempt_self_healing(
request: &TextToCypherRequest,
schema: &str,
failed_query: &str,
error_message: &str,
client: &genai::Client,
model: &str,
falkordb_connection: &str,
skill_catalog: Option<&SkillCatalog>,
) -> Result<(String, String), Box<dyn Error + Send + Sync>> {
use crate::chat::{ChatMessage, ChatRole};
tracing::info!("Attempting self-healing for failed query");
let mut retry_request = request.chat_request.clone();
retry_request.messages.push(ChatMessage {
role: ChatRole::Assistant,
content: failed_query.to_string(),
});
retry_request.messages.push(ChatMessage {
role: ChatRole::User,
content: format!(
"The previous query failed with error: {error_message}. Please generate a corrected Cypher query."
),
});
let healed_query = generate_cypher_query_with_skills(&retry_request, schema, client, model, skill_catalog).await?;
tracing::info!("Self-healed query generated: {}", healed_query);
let result = execute_cypher_query(&healed_query, &request.graph_name, falkordb_connection, true).await?;
Ok((healed_query, result))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::{ChatMessage, ChatRole};
#[test]
fn test_response_is_success() {
let response = TextToCypherResponse::success(
"schema".to_string(),
"MATCH (n) RETURN n".to_string(),
Some("result".to_string()),
Some("answer".to_string()),
);
assert!(response.is_success());
assert!(!response.is_error());
}
#[test]
fn test_response_is_error() {
let response = TextToCypherResponse::error("Something went wrong".to_string());
assert!(response.is_error());
assert!(!response.is_success());
}
#[test]
fn test_success_response_structure() {
let response = TextToCypherResponse::success(
"test_schema".to_string(),
"MATCH (n) RETURN n".to_string(),
Some("test_result".to_string()),
Some("test_answer".to_string()),
);
assert_eq!(response.status, "success");
assert_eq!(response.schema, Some("test_schema".to_string()));
assert_eq!(response.cypher_query, Some("MATCH (n) RETURN n".to_string()));
assert_eq!(response.cypher_result, Some("test_result".to_string()));
assert_eq!(response.answer, Some("test_answer".to_string()));
assert_eq!(response.error, None);
}
#[test]
fn test_error_response_structure() {
let response = TextToCypherResponse::error("Test error".to_string());
assert_eq!(response.status, "error");
assert_eq!(response.schema, None);
assert_eq!(response.cypher_query, None);
assert_eq!(response.cypher_result, None);
assert_eq!(response.answer, None);
assert_eq!(response.error, Some("Test error".to_string()));
}
#[test]
fn test_request_serialization() {
let request = TextToCypherRequest {
graph_name: "test_graph".to_string(),
chat_request: ChatRequest {
messages: vec![ChatMessage {
role: ChatRole::User,
content: "Find all nodes".to_string(),
}],
},
model: Some("gpt-4o-mini".to_string()),
key: Some("test-key".to_string()),
falkordb_connection: Some("falkor://localhost:6379".to_string()),
cypher_only: false,
};
let json = serde_json::to_string(&request).unwrap();
let deserialized: TextToCypherRequest = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.graph_name, "test_graph");
assert_eq!(deserialized.model, Some("gpt-4o-mini".to_string()));
assert!(!deserialized.cypher_only);
}
#[test]
fn test_request_default_values() {
let json = r#"{
"graph_name": "test",
"chat_request": {
"messages": []
}
}"#;
let request: TextToCypherRequest = serde_json::from_str(json).unwrap();
assert_eq!(request.graph_name, "test");
assert_eq!(request.model, None);
assert_eq!(request.key, None);
assert!(!request.cypher_only);
}
#[test]
fn test_response_serialization() {
let response = TextToCypherResponse::success(
"schema".to_string(),
"MATCH (n) RETURN n".to_string(),
None,
Some("answer".to_string()),
);
let json = serde_json::to_string(&response).unwrap();
let deserialized: TextToCypherResponse = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.status, "success");
assert_eq!(deserialized.cypher_query, Some("MATCH (n) RETURN n".to_string()));
assert_eq!(deserialized.cypher_result, None);
}
#[test]
fn test_request_clone() {
let request = TextToCypherRequest {
graph_name: "test".to_string(),
chat_request: ChatRequest { messages: vec![] },
model: Some("gpt-4".to_string()),
key: None,
falkordb_connection: None,
cypher_only: true,
};
let cloned = request.clone();
assert_eq!(cloned.graph_name, request.graph_name);
assert_eq!(cloned.model, request.model);
assert_eq!(cloned.cypher_only, request.cypher_only);
}
}