Files
poimen-memory/crates/mem-cli/src/handlers/visualize_handler.rs
T

262 lines
7.6 KiB
Rust
Raw Normal View History

/// HTTP handler for POST /memory/visualize endpoint.
///
/// Serves graph visualization queries with pagination support.
/// Used for testing queries and understanding context depth impact.
use actix_web::{web, HttpRequest, HttpResponse};
use serde_json::json;
use crate::query::visualize::{VisualizeRequest, GraphVisualizer};
use crate::jwt_validator::validate_token;
use crate::rate_limiter::RateLimiter;
/// POST /memory/visualize
///
/// Query knowledge graph around a search query, with pagination.
///
/// # Request
/// ```json
/// {
/// "project": "poimen",
/// "query": "kubernetes troubleshooting",
/// "depth": 2,
/// "limit": 50,
/// "page": 1,
/// "include_low_confidence": false
/// }
/// ```
///
/// # Response (200 OK)
/// ```json
/// {
/// "query": "kubernetes troubleshooting",
/// "project": "poimen",
/// "pagination": {
/// "page": 1,
/// "limit": 50,
/// "total_nodes": 487,
/// "total_pages": 10,
/// "has_next": true,
/// "has_prev": false
/// },
/// "depth_breakdown": {
/// "depth_0": 12,
/// "depth_1": 234,
/// "depth_2": 241
/// },
/// "nodes": [...],
/// "edges": [...],
/// "performance": {
/// "query_time_ms": 145,
/// "depth_1_time_ms": 45,
/// "depth_2_time_ms": 100,
/// "total_time_ms": 157
/// },
/// "recommendations": [
/// {
/// "issue": "high_result_count",
/// "suggestion": "Try depth=1 to reduce from 487→234 nodes",
/// "expected_latency_ms": 95
/// }
/// ]
/// }
/// ```
pub async fn handle_visualize(
req: HttpRequest,
body: web::Json<VisualizeRequest>,
rate_limiter: web::Data<RateLimiter>,
) -> HttpResponse {
// 1. Extract and validate token
let token = match extract_bearer_token(&req) {
Ok(t) => t,
Err(e) => {
return error_response(401, "unauthorized", &format!("Missing token: {}", e));
}
};
let claims = match validate_token(&token) {
Ok(c) => c,
Err(e) => {
return error_response(401, "unauthorized", &format!("Invalid token: {}", e));
}
};
// 2. Check rate limit (visualize: 100/hour)
let user_id = &claims.sub;
if !rate_limiter.check_limit(user_id, "visualize", 100) {
return error_response(
429,
"rate_limit_exceeded",
"Visualize limit: 100/hour",
);
}
// 3. Validate request
if body.project.is_empty() || body.query.is_empty() {
return error_response(400, "invalid_request", "Missing project or query");
}
// 4. Check project access
// TODO: Verify user has access to this project via RBAC
// 5. Execute visualization query
let response = match GraphVisualizer::visualize(&body, "").await {
Ok(r) => r,
Err(e) => {
return error_response(500, "internal_error", &format!("Visualization failed: {}", e));
}
};
// 6. Return response
HttpResponse::Ok().json(json!({
"query": response.query,
"project": response.project,
"pagination": response.pagination,
"depth_breakdown": {
"depth_0": response.depth_breakdown.depth_0,
"depth_1": response.depth_breakdown.depth_1,
"depth_2": response.depth_breakdown.depth_2,
"depth_3": response.depth_breakdown.depth_3,
},
"nodes": response.nodes,
"edges": response.edges,
"performance": {
"query_time_ms": response.performance.query_time_ms,
"depth_times_ms": response.performance.depth_times_ms,
"total_time_ms": response.performance.total_time_ms,
},
"recommendations": response.recommendations,
}))
}
/// GET /memory/visualize/layouts
///
/// Return available layout algorithms for graph visualization.
pub async fn handle_visualize_layouts() -> HttpResponse {
HttpResponse::Ok().json(json!({
"layouts": [
{
"id": "force",
"name": "Force-Directed",
"description": "Physics simulation (good for dense graphs)",
"params": {
"strength": -30,
"distance": 100
}
},
{
"id": "hierarchy",
"name": "Hierarchical",
"description": "Top-down tree layout",
"params": {
"gap": 40
}
},
{
"id": "circular",
"name": "Circular",
"description": "Nodes on a circle",
"params": {
"radius": 200
}
}
]
}))
}
/// GET /memory/visualize/styles
///
/// Return node/edge styling presets.
pub async fn handle_visualize_styles() -> HttpResponse {
HttpResponse::Ok().json(json!({
"node_colors": {
"person": "#4CAF50",
"tool": "#2196F3",
"concept": "#9C27B0",
"location": "#FF9800",
"organization": "#F44336",
"event": "#FFC107"
},
"edge_styles": {
"high_confidence": {
"stroke": "#333",
"strokeWidth": 3,
"animated": true
},
"medium_confidence": {
"stroke": "#666",
"strokeWidth": 2,
"animated": false
},
"low_confidence": {
"stroke": "#999",
"strokeWidth": 1,
"strokeDasharray": "5,5"
},
"contradiction": {
"stroke": "#F44336",
"strokeWidth": 3,
"animated": true
},
"under_review": {
"stroke": "#FFC107",
"strokeWidth": 2,
"strokeDasharray": "3,3"
}
}
}))
}
// ─────────────────────────────────────────────────────────────────────────────
fn extract_bearer_token(req: &HttpRequest) -> Result<String, String> {
let header = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or("Missing Authorization header")?;
if !header.starts_with("Bearer ") {
return Err("Invalid Authorization format".to_string());
}
Ok(header[7..].to_string())
}
fn error_response(status: u16, code: &str, message: &str) -> HttpResponse {
let status_code = actix_web::http::StatusCode::from_u16(status)
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
HttpResponse::build(status_code).json(json!({
"error": code,
"message": message,
"request_id": uuid::Uuid::new_v4().to_string(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_bearer_token_valid() {
// Mock request with Bearer token
// Note: This is simplified; real test would use actix test utilities
let token = "eyJ0eXAiOiJKV1QiLCJhbGc...";
let header = format!("Bearer {}", token);
assert!(header.starts_with("Bearer "));
}
#[test]
fn test_error_response_400() {
let resp = error_response(400, "bad_request", "Invalid input");
assert_eq!(resp.status(), actix_web::http::StatusCode::BAD_REQUEST);
}
#[test]
fn test_error_response_401() {
let resp = error_response(401, "unauthorized", "Invalid token");
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
}
}