Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions Dockerfile.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Build stage - Build from source to include code changes
FROM rust:alpine AS builder

# Install build dependencies
RUN apk add --no-cache musl-dev openssl-dev pkgconfig curl

# Set working directory
WORKDIR /app

# Copy Cargo files
COPY Cargo.toml Cargo.lock ./

# Copy source code
COPY src ./src
COPY templates ./templates

# Build the application in release mode
RUN cargo build --release

# Runtime stage - Use FalkorDB as base image
FROM falkordb/falkordb:latest

# Install runtime dependencies and supervisord
RUN apt-get update && apt-get install -y ca-certificates supervisor && rm -rf /var/lib/apt/lists/*

# Create a non-root user for security (if not already exists)
RUN groupadd -g 1000 appuser 2>/dev/null || true && \
useradd -m -s /bin/bash -u 1000 -g appuser appuser 2>/dev/null || true

# Set the working directory for our application
WORKDIR /app

# Copy the compiled binary from the builder stage
COPY --from=builder /app/target/release/text-to-cypher /app/text-to-cypher

# Copy the templates from the builder stage
COPY --from=builder /app/templates ./templates

# Create import directory and set permissions
RUN mkdir -p /var/lib/FalkorDB/import && \
mkdir -p /tmp/falkordb-import && \
chown -R appuser:appuser /var/lib/FalkorDB/import && \
chmod -R 755 /var/lib/FalkorDB/import && \
chown -R appuser:appuser /tmp/falkordb-import && \
chmod -R 755 /tmp/falkordb-import && \
chown -R appuser:appuser /var/lib/falkordb && \
chmod -R 755 /var/lib/falkordb && \
chown -R appuser:appuser /var/lib/FalkorDB && \
chmod -R 755 /var/lib/FalkorDB

# Change ownership to the non-root user
RUN chown -R appuser:appuser /app

# Expose the ports your application runs on (in addition to FalkorDB's ports)
EXPOSE 8080 3001

# Copy supervisord configuration and scripts
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY entrypoint.sh /entrypoint.sh

# Create supervisor log directory and make scripts executable
RUN mkdir -p /var/log/supervisor && \
chmod +x /entrypoint.sh

# Use ENTRYPOINT instead of CMD to ensure it runs
ENTRYPOINT ["/entrypoint.sh"]
119 changes: 119 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,11 @@ struct ErrorResponse {
error: String,
}

#[derive(Serialize, Deserialize, ToSchema)]
struct CypherResponse {
query: String,
}

#[derive(Serialize, Deserialize, ToSchema)]
struct GraphQueryRequest {
data: Vec<serde_json::Value>,
Expand Down Expand Up @@ -1026,6 +1031,72 @@ async fn text_to_cypher(req: actix_web::web::Json<TextToCypherRequest>) -> Resul
Ok(Sse::from_stream(stream))
}

#[utoipa::path(
post,
path = "/generate_cypher",
request_body = TextToCypherRequest,
responses(
(status = 200, description = "Successfully generated Cypher query", body = CypherResponse),
(status = 400, description = "Failed to generate Cypher query", body = ErrorResponse)
)
)]
#[post("/generate_cypher")]
async fn generate_cypher(req: actix_web::web::Json<TextToCypherRequest>) -> Result<impl Responder, actix_web::Error> {
let mut request = req.into_inner();
let config = AppConfig::get();

// Apply defaults from .env file if values are not provided
if request.model.is_none() {
request.model.clone_from(&config.default_model);
}

if request.key.is_none() {
request.key.clone_from(&config.default_key);
}

// Ensure we have a model after applying defaults
if request.model.is_none() {
return Ok(HttpResponse::BadRequest().json(ErrorResponse {
error: "Model must be provided either in request or as DEFAULT_MODEL in .env file".to_string()
}));
}

let model = request.model.as_ref().unwrap(); // Safe to unwrap after the check above

let client = request.key.as_ref().map_or_else(genai::Client::default, |key| {
let key = key.clone(); // Clone the key for use in the closure
let auth_resolver = AuthResolver::from_resolver_fn(
move |model_iden: ModelIden| -> Result<Option<AuthData>, genai::resolver::Error> {
let ModelIden {
adapter_kind,
model_name,
} = model_iden;
tracing::info!("Using custom auth provider for {adapter_kind} (model: {model_name})");

// Use the provided key instead of reading from environment
Ok(Some(AuthData::from_single(key.clone())))
},
);
genai::Client::builder().with_auth_resolver(auth_resolver).build()
});

// Handle service target resolution errors
let service_target = match client.resolve_service_target(model).await {
Ok(target) => target,
Err(e) => {
return Ok(HttpResponse::BadRequest().json(ErrorResponse {
error: format!("Failed to resolve service target: {e}")
}));
}
};

// Generate Cypher query without executing it or streaming the response
match generate_cypher_only(&request, &client, &service_target, model).await {
Ok(cypher) => Ok(HttpResponse::Ok().json(CypherResponse { query: cypher })),
Err(e) => Ok(HttpResponse::BadRequest().json(ErrorResponse { error: e }))
}
}

#[allow(clippy::cognitive_complexity)]
async fn process_text_to_cypher_request(
request: TextToCypherRequest,
Expand Down Expand Up @@ -1155,6 +1226,51 @@ async fn generate_final_answer(
execute_chat_stream(client, model, genai_chat_request, tx).await;
}

/// Generate only the Cypher query without executing it
async fn generate_cypher_only(
request: &TextToCypherRequest,
client: &genai::Client,
_service_target: &genai::ServiceTarget,
model: &str,
) -> Result<String, String> {
tracing::info!("Processing generate_cypher request: {request:?}");

let falkordb_connection = request
.clone()
.falkordb_connection
.unwrap_or_else(|| AppConfig::get().falkordb_connection.clone());

// Get schema for the graph
let schema = match get_graph_schema_string(&falkordb_connection, &request.graph_name).await {
Ok(schema) => schema,
Err(e) => return Err(format!("Failed to discover schema: {}", e)),
};

// Generate Cypher query
tracing::info!("Generating Cypher query using schema ...");
let genai_chat_request = generate_create_cypher_query_chat_request(&request.chat_request, &schema);

// Make the actual request to the model
let chat_response = match client.exec_chat(model, genai_chat_request, None).await {
Ok(response) => response,
Err(e) => return Err(format!("Chat request failed: {}", e)),
};

let query = chat_response
.content_text_into_string()
.unwrap_or_else(|| String::from(""));

if query.trim().is_empty() || query.trim() == "NO ANSWER" {
tracing::warn!("No query generated from AI model");
return Err("No valid query was generated".into());
}

let clean_query = query.replace('\n', " ").replace("```", "").trim().to_string();
tracing::info!("Generated Cypher query: {}", clean_query);

Ok(clean_query)
}

#[allow(dead_code)]
async fn graph_query(
query: &str,
Expand Down Expand Up @@ -1774,6 +1890,7 @@ fn process_last_request_prompt(
#[openapi(
paths(
text_to_cypher,
generate_cypher,
clear_schema_cache,
load_csv_endpoint,
echo_endpoint,
Expand All @@ -1793,6 +1910,7 @@ fn process_last_request_prompt(
ChatRole,
ConfiguredModelResponse,
ErrorResponse,
CypherResponse,
GraphQueryRequest,
GraphListRequest,
GraphDeleteRequest,
Expand Down Expand Up @@ -1831,6 +1949,7 @@ async fn main() -> std::io::Result<()> {
let http_server = HttpServer::new(|| {
App::new()
.service(text_to_cypher)
.service(generate_cypher)
.service(clear_schema_cache)
.service(load_csv_endpoint)
.service(echo_endpoint)
Expand Down