use crate::error::DbError;
use crate::sdbql::{parse, QueryExecutor};
use crate::server::handlers::AppState;
use crate::server::llm_client::{LLMClient, Message};
use axum::{
extract::{Path, State},
response::IntoResponse,
Json,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
const NL_HISTORY_COLLECTION: &str = "_nl_history";
const MAX_FEW_SHOT_EXAMPLES: usize = 5;
#[derive(Debug, Deserialize)]
pub struct NLQueryRequest {
pub query: String,
#[serde(default = "default_true")]
pub execute: bool,
pub provider: Option<String>,
pub model: Option<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize)]
pub struct NLFeedbackRequest {
pub query: String,
pub original_sdbql: String,
pub corrected_sdbql: String,
}
#[derive(Debug, Serialize)]
pub struct NLQueryResponse {
pub sdbql: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Vec<Value>>,
pub attempts: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub history_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct NLFeedbackResponse {
pub status: String,
pub message: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct NLHistoryEntry {
query: String,
sdbql: String,
#[serde(skip_serializing_if = "Option::is_none")]
corrected_sdbql: Option<String>,
success: bool,
created_at: String,
}
#[derive(Debug, Serialize)]
pub struct NLQueryError {
pub error: String,
pub last_attempt: Option<String>,
pub parse_error: Option<String>,
}
#[derive(Debug)]
struct CollectionMeta {
name: String,
doc_count: usize,
fields: HashMap<String, String>, indexes: Vec<String>,
}
struct SchemaContext {
collections: Vec<CollectionMeta>,
}
impl SchemaContext {
fn build(storage: &crate::storage::StorageEngine, db_name: &str) -> Result<Self, DbError> {
let db = storage.get_database(db_name)?;
let collection_names = db.list_collections();
let mut collections = Vec::new();
for name in collection_names {
if name.starts_with('_') {
continue;
}
if let Ok(coll) = db.get_collection(&name) {
let doc_count = coll.count();
let sample_docs = coll.scan(Some(5));
let mut fields: HashMap<String, String> = HashMap::new();
for doc in sample_docs {
let value = doc.to_value();
if let Value::Object(obj) = value {
for (key, val) in obj {
fields.entry(key).or_insert_with(|| match val {
Value::Null => "null".to_string(),
Value::Bool(_) => "boolean".to_string(),
Value::Number(_) => "number".to_string(),
Value::String(_) => "string".to_string(),
Value::Array(_) => "array".to_string(),
Value::Object(_) => "object".to_string(),
});
}
}
}
let index_stats = coll.list_indexes();
let indexes: Vec<String> = index_stats
.iter()
.map(|idx| format!("{}({})", idx.name, idx.fields.join(", ")))
.collect();
collections.push(CollectionMeta {
name,
doc_count,
fields,
indexes,
});
}
}
Ok(SchemaContext { collections })
}
fn to_prompt(&self) -> String {
let mut result = String::new();
for coll in &self.collections {
result.push_str(&format!(
"### Collection: `{}` ({} documents)\n",
coll.name, coll.doc_count
));
result.push_str("Fields:\n");
let mut sorted_fields: Vec<_> = coll.fields.iter().collect();
sorted_fields.sort_by_key(|(k, _)| *k);
for (field, type_name) in sorted_fields {
result.push_str(&format!(" - `{}`: {}\n", field, type_name));
}
if !coll.indexes.is_empty() {
result.push_str("Indexes:\n");
for idx in &coll.indexes {
result.push_str(&format!(" - {}\n", idx));
}
}
result.push('\n');
}
result
}
}
fn ensure_history_collection(
storage: &crate::storage::StorageEngine,
db_name: &str,
) -> Result<crate::storage::Collection, DbError> {
let db = storage.get_database(db_name)?;
if db.get_collection(NL_HISTORY_COLLECTION).is_err() {
db.create_collection(NL_HISTORY_COLLECTION.to_string(), None)?;
}
db.get_collection(NL_HISTORY_COLLECTION)
}
fn load_few_shot_examples(
storage: &crate::storage::StorageEngine,
db_name: &str,
) -> Vec<(String, String)> {
let mut examples = Vec::new();
if let Ok(db) = storage.get_database(db_name) {
if let Ok(coll) = db.get_collection(NL_HISTORY_COLLECTION) {
let docs = coll.scan(Some(50)); let mut entries: Vec<(String, String, bool, String)> = Vec::new();
for doc in docs {
let value = doc.to_value();
if let (Some(query), Some(sdbql), Some(success)) = (
value.get("query").and_then(|v| v.as_str()),
value.get("sdbql").and_then(|v| v.as_str()),
value.get("success").and_then(|v| v.as_bool()),
) {
if success {
let final_sdbql = value
.get("corrected_sdbql")
.and_then(|v| v.as_str())
.unwrap_or(sdbql);
let has_correction = value.get("corrected_sdbql").is_some();
let created_at = value
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
entries.push((
query.to_string(),
final_sdbql.to_string(),
has_correction,
created_at,
));
}
}
}
entries.sort_by(|a, b| {
match (a.2, b.2) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => b.3.cmp(&a.3), }
});
for (query, sdbql, _, _) in entries.into_iter().take(MAX_FEW_SHOT_EXAMPLES) {
examples.push((query, sdbql));
}
}
}
examples
}
fn save_to_history(
storage: &crate::storage::StorageEngine,
db_name: &str,
query: &str,
sdbql: &str,
success: bool,
) -> Option<String> {
if let Ok(coll) = ensure_history_collection(storage, db_name) {
let entry = NLHistoryEntry {
query: query.to_string(),
sdbql: sdbql.to_string(),
corrected_sdbql: None,
success,
created_at: Utc::now().to_rfc3339(),
};
if let Ok(value) = serde_json::to_value(&entry) {
if let Ok(doc) = coll.insert(value) {
return Some(doc.key.clone());
}
}
}
None
}
fn build_system_prompt(
schema: &SchemaContext,
provider: Option<&str>,
examples: &[(String, String)],
) -> String {
let reference = if provider.is_some_and(|p| p.eq_ignore_ascii_case("ollama")) {
r#"SDBQL Basic Syntax:
- FOR doc IN collection FILTER doc.field == value RETURN doc
- Operators: ==, !=, <, <=, >, >=, AND, OR, NOT, LIKE, IN
- Functions: LENGTH(), COUNT(), SUM(), AVG(), MIN(), MAX()
- Aggregation: COLLECT var = doc.field INTO group
- Sorting: SORT doc.field ASC/DESC LIMIT 10"#
} else {
include_str!("../../docs/SDBQL_REFERENCE.md")
};
let examples_section = if examples.is_empty() {
String::new()
} else {
let mut section = String::from("\n## Successful Examples from This Database\n");
for (i, (query, sdbql)) in examples.iter().enumerate() {
section.push_str(&format!(
"Example {}:\n Query: \"{}\"\n SDBQL: {}\n\n",
i + 1,
query,
sdbql
));
}
section
};
format!(
r#"You are a SDBQL query translator. Convert natural language to valid SDBQL queries.
## Database Schema
{}
## SDBQL Syntax Reference
{}
{}
## Rules / Best Practices
1. Return ONLY the SDBQL query - no explanations, no markdown code blocks.
2. Use the exact collection and field names from the schema.
3. For aggregations, prefer `COLLECT ... WITH COUNT INTO ...` syntax.
4. For searching text, prefer `LIKE` for simple patterns.
5. For recent items, sort by timestamp field DESC and LIMIT.
6. Use `LET` variables to simplify complex logic or subqueries.
7. Use `Not In` operator `x NOT IN [...]` instead of `!(x IN [...])`.
User Query: "#,
schema.to_prompt(),
reference,
examples_section
)
}
pub async fn nl_query(
State(state): State<AppState>,
Path(db_name): Path<String>,
Json(req): Json<NLQueryRequest>,
) -> Result<impl IntoResponse, DbError> {
let schema = SchemaContext::build(&state.storage, &db_name)?;
if schema.collections.is_empty() {
return Err(DbError::ExecutionError(
"No collections found in database. Create collections first.".to_string(),
));
}
let client = LLMClient::from_storage(&state.storage, &db_name, req.provider.as_deref(), None)?;
let examples = load_few_shot_examples(&state.storage, &db_name);
let system_prompt = build_system_prompt(&schema, req.provider.as_deref(), &examples);
let mut messages = vec![Message::system(&system_prompt), Message::user(&req.query)];
let mut last_sdbql = String::new();
let mut last_error = String::new();
for attempt in 1..=3u32 {
let sdbql = client.chat(messages.clone()).await?;
let sdbql = if let Some(start) = sdbql.find("```") {
let rest = &sdbql[start + 3..];
let code_start = if let Some(newline_pos) = rest.find('\n') {
newline_pos + 1
} else {
0
};
let code_end = if let Some(end) = rest[code_start..].find("```") {
end
} else {
rest.len() - code_start
};
rest[code_start..code_start + code_end].trim().to_string()
} else {
sdbql.trim().to_string()
};
last_sdbql = sdbql.clone();
match parse(&sdbql) {
Ok(query) => {
let history_id =
save_to_history(&state.storage, &db_name, &req.query, &sdbql, true);
if req.execute {
let executor = QueryExecutor::with_database(&state.storage, db_name.clone());
let results = executor.execute(&query)?;
return Ok(Json(NLQueryResponse {
sdbql,
result: Some(results),
attempts: attempt,
history_id,
}));
}
return Ok(Json(NLQueryResponse {
sdbql,
result: None,
attempts: attempt,
history_id,
}));
}
Err(e) => {
last_error = e.to_string();
messages.push(Message::assistant(&sdbql));
messages.push(Message::user(&format!(
"Parse error: {}. Please fix the SDBQL query. Return ONLY the corrected query.",
e
)));
}
}
}
let _ = save_to_history(&state.storage, &db_name, &req.query, &last_sdbql, false);
Err(DbError::ExecutionError(format!(
"Failed to generate valid SDBQL after 3 attempts. Last attempt: '{}'. Error: {}",
last_sdbql, last_error
)))
}
pub async fn nl_feedback(
State(state): State<AppState>,
Path(db_name): Path<String>,
Json(req): Json<NLFeedbackRequest>,
) -> Result<impl IntoResponse, DbError> {
if let Err(e) = parse(&req.corrected_sdbql) {
return Err(DbError::ExecutionError(format!(
"Corrected SDBQL is invalid: {}",
e
)));
}
let coll = ensure_history_collection(&state.storage, &db_name)?;
let docs = coll.scan(Some(100));
let mut found_key: Option<String> = None;
for doc in docs {
let value = doc.to_value();
if let (Some(query), Some(sdbql)) = (
value.get("query").and_then(|v| v.as_str()),
value.get("sdbql").and_then(|v| v.as_str()),
) {
if query == req.query && sdbql == req.original_sdbql {
found_key = Some(doc.key.clone());
break;
}
}
}
if let Some(key) = found_key {
let update = serde_json::json!({
"corrected_sdbql": req.corrected_sdbql,
"success": true
});
coll.update(&key, update)?;
Ok(Json(NLFeedbackResponse {
status: "updated".to_string(),
message: "Correction saved. Future queries will learn from this.".to_string(),
}))
} else {
let entry = NLHistoryEntry {
query: req.query,
sdbql: req.original_sdbql,
corrected_sdbql: Some(req.corrected_sdbql),
success: true,
created_at: Utc::now().to_rfc3339(),
};
let value = serde_json::to_value(&entry)?;
coll.insert(value)?;
Ok(Json(NLFeedbackResponse {
status: "created".to_string(),
message: "Correction saved as new example. Future queries will learn from this."
.to_string(),
}))
}
}