use super::system::AppState;
use crate::error::DbError;
use crate::graph::build::{run_build, BuildOptions};
use crate::sdbql::QueryExecutor;
use axum::{
extract::{Path, Query, State},
response::Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
fn graph_executor<'a>(
storage: &'a crate::storage::StorageEngine,
db_name: &str,
) -> QueryExecutor<'a> {
QueryExecutor::with_database(storage, db_name.to_string())
}
#[derive(Debug, Deserialize)]
pub struct NeighborsRequest {
pub edge_collection: String,
pub seeds: Value,
#[serde(default)]
pub options: Option<Value>,
}
#[derive(Debug, Deserialize)]
pub struct GraphRagRequest {
pub seed_collection: String,
pub vector_index: String,
pub edge_collection: String,
pub query_vector: Vec<f32>,
#[serde(default)]
pub options: Option<Value>,
}
#[derive(Debug, Deserialize)]
pub struct CommunitySearchRequest {
pub query_text: String,
#[serde(default)]
pub options: Option<Value>,
}
pub async fn graph_neighbors(
State(state): State<AppState>,
Path(db_name): Path<String>,
Json(req): Json<NeighborsRequest>,
) -> Result<Json<Value>, DbError> {
let executor = graph_executor(&state.storage, &db_name);
let results = executor.neighbors(&req.edge_collection, req.seeds, req.options)?;
let count = results.as_array().map(|a| a.len()).unwrap_or(0);
Ok(Json(json!({ "results": results, "count": count })))
}
pub async fn graph_rag_search(
State(state): State<AppState>,
Path(db_name): Path<String>,
Json(req): Json<GraphRagRequest>,
) -> Result<Json<Value>, DbError> {
let executor = graph_executor(&state.storage, &db_name);
let results = executor.graph_rag(
&req.seed_collection,
&req.vector_index,
&req.edge_collection,
json!(req.query_vector),
req.options,
)?;
let count = results.as_array().map(|a| a.len()).unwrap_or(0);
Ok(Json(json!({ "results": results, "count": count })))
}
pub async fn community_search(
State(state): State<AppState>,
Path(db_name): Path<String>,
Json(req): Json<CommunitySearchRequest>,
) -> Result<Json<Value>, DbError> {
let executor = graph_executor(&state.storage, &db_name);
let results = executor.community_search(&req.query_text, req.options)?;
let count = results.as_array().map(|a| a.len()).unwrap_or(0);
Ok(Json(json!({ "results": results, "count": count })))
}
#[derive(Debug, Deserialize)]
pub struct BuildCommunitiesRequest {
pub edge_collection: String,
#[serde(default)]
pub resolution: Option<f64>,
#[serde(default)]
pub min_community_size: Option<usize>,
#[serde(default)]
pub summarize: Option<bool>,
#[serde(default)]
pub max_communities: Option<usize>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub seed: Option<u64>,
}
const BUILD_STALE_AFTER: chrono::Duration = chrono::Duration::hours(1);
pub async fn build_communities(
State(state): State<AppState>,
Path(db_name): Path<String>,
Json(req): Json<BuildCommunitiesRequest>,
) -> Result<Json<Value>, DbError> {
let database = state.storage.get_database(&db_name)?;
database.get_collection(&req.edge_collection)?;
let request_id = uuid::Uuid::new_v4().to_string();
let run_id = format!("run_{}", uuid::Uuid::new_v4().simple());
let opts = BuildOptions {
resolution: req.resolution.unwrap_or(1.0),
min_community_size: req.min_community_size.unwrap_or(3),
summarize: req.summarize.unwrap_or(false),
max_communities: req.max_communities.unwrap_or(50),
provider: req.provider.clone(),
seed: req.seed.unwrap_or(42),
};
let requests = database.get_or_create_collection("_graph_build_requests")?;
if let Some(pending) = active_build_for(&requests, &req.edge_collection) {
return Err(DbError::ConflictError(format!(
"a community build for '{}' is already running (request_id {})",
req.edge_collection, pending
)));
}
let _ = requests.insert(json!({
"_key": request_id,
"status": "pending",
"edge_collection": req.edge_collection,
"run_id": run_id,
"summarize": opts.summarize,
"started_at": chrono::Utc::now().to_rfc3339(),
}));
let storage = state.storage.clone();
let db = db_name.clone();
let edge = req.edge_collection.clone();
let rid = run_id.clone();
let req_id = request_id.clone();
tokio::spawn(async move {
let result = run_build(storage.clone(), &db, &edge, &rid, &opts).await;
if let Ok(database) = storage.get_database(&db) {
if let Ok(requests) = database.get_or_create_collection("_graph_build_requests") {
let update = match &result {
Ok(outcome) => json!({
"_key": req_id, "status": "done",
"edge_collection": edge, "run_id": rid,
"communities_found": outcome.communities_found,
"summarized": outcome.summarized,
}),
Err(e) => json!({
"_key": req_id, "status": "failed",
"edge_collection": edge, "run_id": rid,
"error": e.to_string(),
}),
};
let _ = requests.update(&req_id, update);
}
}
});
Ok(Json(json!({
"request_id": request_id,
"run_id": run_id,
"status": "pending",
})))
}
fn active_build_for(
requests: &crate::storage::Collection,
edge_collection: &str,
) -> Option<String> {
let cutoff = chrono::Utc::now() - BUILD_STALE_AFTER;
requests.scan(None).into_iter().find_map(|d| {
let v = d.to_value();
let pending = v.get("status").and_then(|x| x.as_str()) == Some("pending");
let same_edge = v.get("edge_collection").and_then(|x| x.as_str()) == Some(edge_collection);
let fresh = v
.get("started_at")
.and_then(|x| x.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.is_some_and(|t| t.with_timezone(&chrono::Utc) > cutoff);
(pending && same_edge && fresh).then(|| d.key.clone())
})
}
pub async fn build_status(
State(state): State<AppState>,
Path((db_name, request_id)): Path<(String, String)>,
) -> Result<Json<Value>, DbError> {
let database = state.storage.get_database(&db_name)?;
let requests = database.get_collection("_graph_build_requests")?;
let doc = requests.get(&request_id)?;
Ok(Json(doc.to_value()))
}
pub async fn list_communities(
State(state): State<AppState>,
Path(db_name): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, DbError> {
let database = state.storage.get_database(&db_name)?;
let coll = match database.get_collection("_graph_communities") {
Ok(c) => c,
Err(_) => return Ok(Json(json!({ "communities": [], "total": 0 }))),
};
let run_id = params.get("run_id");
let edge_collection = params.get("edge_collection");
let limit: usize = params
.get("limit")
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let offset: usize = params
.get("offset")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let matching: Vec<Value> = coll
.scan(None)
.into_iter()
.map(|doc| doc.to_value())
.filter(|v| {
let run_ok = run_id
.is_none_or(|rid| v.get("run_id").and_then(|x| x.as_str()) == Some(rid.as_str()));
let edge_ok = edge_collection.is_none_or(|ec| {
v.get("edge_collection").and_then(|x| x.as_str()) == Some(ec.as_str())
});
run_ok && edge_ok
})
.collect();
let total = matching.len();
let items: Vec<Value> = matching.into_iter().skip(offset).take(limit).collect();
Ok(Json(
json!({ "communities": items, "total": total, "limit": limit, "offset": offset }),
))
}