Skip to main content

mentedb_server/
handlers.rs

1//! Axum handler functions for the MenteDB REST API.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use crate::auth::AuthenticatedAgent;
7use axum::Extension;
8use axum::Json;
9use axum::extract::{Path, State};
10use axum::http::StatusCode;
11use axum::response::IntoResponse;
12use mentedb_core::edge::EdgeType;
13use mentedb_core::memory::{AttributeValue, MemoryType};
14use mentedb_core::space::Permission;
15use mentedb_core::{MemoryEdge, MemoryNode};
16use mentedb_extraction::{
17    ExtractionConfig, ExtractionPipeline, HttpExtractionProvider,
18    map_extraction_type_to_memory_type,
19};
20use serde_json::{Map, Value, json};
21use tracing::error;
22use uuid::Uuid;
23
24use crate::error::ApiError;
25use crate::state::AppState;
26use mentedb_core::types::{AgentId, MemoryId, SpaceId, UserId};
27
28// ---------------------------------------------------------------------------
29// GET /v1/health
30// ---------------------------------------------------------------------------
31
32/// Health check endpoint. Returns server uptime and status.
33pub async fn health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
34    let uptime = state.start_time.elapsed().as_secs();
35    Json(json!({
36        "status": "ok",
37        "version": "0.1.0",
38        "uptime_seconds": uptime,
39    }))
40}
41
42// ---------------------------------------------------------------------------
43// GET /v1/stats
44// ---------------------------------------------------------------------------
45
46/// Returns database statistics (memory count, index size, etc.).
47pub async fn stats(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, ApiError> {
48    let uptime = state.start_time.elapsed().as_secs();
49    let db = &*state.db;
50    let memory_count = db.memory_count();
51
52    Ok(Json(json!({
53        "memory_count": memory_count,
54        "uptime_seconds": uptime,
55    })))
56}
57
58// ---------------------------------------------------------------------------
59// POST /v1/memories
60// ---------------------------------------------------------------------------
61
62/// Stores a new memory node in the database.
63pub async fn store_memory(
64    State(state): State<Arc<AppState>>,
65    agent: Option<Extension<AuthenticatedAgent>>,
66    Json(req): Json<Value>,
67) -> Result<impl IntoResponse, ApiError> {
68    let agent_id = AgentId(parse_uuid(&req, "agent_id")?);
69    if let Some(Extension(ref authed)) = agent {
70        let tid: AgentId = authed
71            .agent_id
72            .parse()
73            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
74        if tid != agent_id {
75            return Err(ApiError::Forbidden(
76                "agent_id in request body does not match token".into(),
77            ));
78        }
79    }
80    let memory_type = parse_memory_type(&req)?;
81
82    let content = req
83        .get("content")
84        .and_then(|v| v.as_str())
85        .ok_or_else(|| ApiError::BadRequest("missing or invalid 'content'".into()))?
86        .to_string();
87
88    let embedding = parse_embedding(&req, false)?;
89
90    let tags: Vec<String> = match req.get("tags").and_then(|v| v.as_array()) {
91        Some(arr) => arr
92            .iter()
93            .filter_map(|v| v.as_str().map(String::from))
94            .collect(),
95        None => vec![],
96    };
97
98    let attributes = parse_attributes(req.get("attributes"));
99
100    let space_id = match req.get("space_id") {
101        Some(v) => v
102            .as_str()
103            .and_then(|s| s.parse::<SpaceId>().ok())
104            .ok_or_else(|| ApiError::BadRequest("invalid 'space_id'".into()))?,
105        None => SpaceId::nil(),
106    };
107
108    let now = std::time::SystemTime::now()
109        .duration_since(std::time::UNIX_EPOCH)
110        .unwrap_or_default()
111        .as_micros() as u64;
112
113    let id = MemoryId::new();
114
115    let salience = req.get("salience").and_then(|v| v.as_f64()).unwrap_or(0.5) as f32;
116    let confidence = req
117        .get("confidence")
118        .and_then(|v| v.as_f64())
119        .unwrap_or(1.0) as f32;
120
121    let node = MemoryNode {
122        id,
123        agent_id,
124        user_id: UserId::nil(),
125        memory_type,
126        embedding,
127        content: content.clone(),
128        created_at: now,
129        accessed_at: now,
130        access_count: 0,
131        salience,
132        confidence,
133        space_id,
134        attributes,
135        tags,
136        valid_from: req.get("valid_from").and_then(|v| v.as_u64()),
137        valid_until: req.get("valid_until").and_then(|v| v.as_u64()),
138    };
139
140    let db = &*state.db;
141    db.store(node).map_err(|e| {
142        error!("store failed: {e}");
143        ApiError::Internal(format!("store failed: {e}"))
144    })?;
145
146    // Auto-extract: if enabled and content looks like a conversation, queue extraction
147    if state.auto_extract
148        && state.extraction_config.is_some()
149        && looks_like_conversation(&content)
150        && let Some(tx) = &state.extraction_tx
151    {
152        let req = crate::extraction_queue::ExtractionRequest {
153            config: state.extraction_config.clone().unwrap(),
154            content: content.clone(),
155            agent_id,
156            space_id,
157            db: state.db.clone(),
158        };
159        if tx.try_send(req).is_err() {
160            tracing::warn!("extraction queue full, skipping auto-extract");
161        }
162    }
163
164    Ok((
165        StatusCode::CREATED,
166        Json(json!({ "id": id.to_string(), "status": "stored" })),
167    ))
168}
169
170// ---------------------------------------------------------------------------
171// GET /v1/memories/:id
172// ---------------------------------------------------------------------------
173
174/// Retrieves a single memory by its UUID.
175pub async fn get_memory(
176    State(state): State<Arc<AppState>>,
177    agent: Option<Extension<AuthenticatedAgent>>,
178    Path(id_str): Path<String>,
179) -> Result<impl IntoResponse, ApiError> {
180    let id: MemoryId = id_str
181        .parse()
182        .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
183
184    let db = &*state.db;
185    let node = db
186        .get_memory(id)
187        .map_err(|_| ApiError::NotFound(format!("memory {id} not found")))?;
188
189    if let Some(Extension(ref authed)) = agent {
190        let tid: AgentId = authed
191            .agent_id
192            .parse()
193            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
194        if node.agent_id != tid {
195            return Err(ApiError::Forbidden(
196                "memory belongs to a different agent".into(),
197            ));
198        }
199    }
200
201    Ok(Json(memory_node_to_json(&node)))
202}
203
204// ---------------------------------------------------------------------------
205// DELETE /v1/memories/:id
206// ---------------------------------------------------------------------------
207
208/// Deletes a memory from the database.
209pub async fn forget_memory(
210    State(state): State<Arc<AppState>>,
211    agent: Option<Extension<AuthenticatedAgent>>,
212    Path(id_str): Path<String>,
213) -> Result<impl IntoResponse, ApiError> {
214    let id: MemoryId = id_str
215        .parse()
216        .map_err(|_| ApiError::BadRequest("invalid memory ID".into()))?;
217
218    let db = &*state.db;
219    if let Some(Extension(ref authed)) = agent {
220        let tid: AgentId = authed
221            .agent_id
222            .parse()
223            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
224        let window = db.recall("RECALL memories LIMIT 1000").map_err(|e| {
225            error!("recall failed: {e}");
226            ApiError::Internal(format!("recall failed: {e}"))
227        })?;
228        let mut found = false;
229        for block in &window.blocks {
230            for scored in &block.memories {
231                if scored.memory.id == id {
232                    if scored.memory.agent_id != tid {
233                        return Err(ApiError::Forbidden(
234                            "memory belongs to a different agent".into(),
235                        ));
236                    }
237                    found = true;
238                    break;
239                }
240            }
241            if found {
242                break;
243            }
244        }
245        if !found {
246            return Err(ApiError::NotFound(format!("memory {id} not found")));
247        }
248    }
249    db.forget(id).map_err(|e| {
250        error!("forget failed: {e}");
251        ApiError::Internal(format!("forget failed: {e}"))
252    })?;
253
254    Ok(Json(json!({ "status": "deleted" })))
255}
256
257// ---------------------------------------------------------------------------
258// POST /v1/recall
259// ---------------------------------------------------------------------------
260
261/// Recalls memories matching an MQL query string.
262pub async fn recall_memories(
263    State(state): State<Arc<AppState>>,
264    Json(req): Json<Value>,
265) -> Result<impl IntoResponse, ApiError> {
266    let query = req
267        .get("query")
268        .and_then(|v| v.as_str())
269        .ok_or_else(|| ApiError::BadRequest("missing 'query' field".into()))?;
270
271    let db = &*state.db;
272    let window = db.recall(query).map_err(|e| {
273        error!("recall failed: {e}");
274        ApiError::Internal(format!("recall failed: {e}"))
275    })?;
276
277    let memory_count: usize = window.blocks.iter().map(|b| b.memories.len()).sum();
278    Ok(Json(json!({
279        "context": window.format,
280        "total_tokens": window.total_tokens,
281        "memory_count": memory_count,
282    })))
283}
284
285// ---------------------------------------------------------------------------
286// POST /v1/search
287// ---------------------------------------------------------------------------
288
289/// Searches for memories similar to a given embedding vector.
290pub async fn search_similar(
291    State(state): State<Arc<AppState>>,
292    Json(req): Json<Value>,
293) -> Result<impl IntoResponse, ApiError> {
294    let embedding = parse_embedding(&req, true)?;
295    let k = req.get("k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
296
297    let db = &*state.db;
298    let results = db.recall_similar(&embedding, k).map_err(|e| {
299        error!("search failed: {e}");
300        ApiError::Internal(format!("search failed: {e}"))
301    })?;
302
303    let items: Vec<Value> = results
304        .iter()
305        .map(|(id, score)| json!({ "id": id.to_string(), "score": score }))
306        .collect();
307
308    Ok(Json(json!({ "results": items })))
309}
310
311// ---------------------------------------------------------------------------
312// POST /v1/edges
313// ---------------------------------------------------------------------------
314
315/// Creates a typed, weighted edge between two memories.
316pub async fn create_edge(
317    State(state): State<Arc<AppState>>,
318    Json(req): Json<Value>,
319) -> Result<impl IntoResponse, ApiError> {
320    let source = MemoryId(parse_uuid(&req, "source")?);
321    let target = MemoryId(parse_uuid(&req, "target")?);
322
323    let edge_type_str = req
324        .get("edge_type")
325        .and_then(|v| v.as_str())
326        .ok_or_else(|| ApiError::BadRequest("missing 'edge_type'".into()))?;
327
328    let edge_type = parse_edge_type(edge_type_str)
329        .ok_or_else(|| ApiError::BadRequest(format!("unknown edge_type: {edge_type_str}")))?;
330
331    let weight = req.get("weight").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
332
333    let now = std::time::SystemTime::now()
334        .duration_since(std::time::UNIX_EPOCH)
335        .unwrap_or_default()
336        .as_micros() as u64;
337
338    let valid_from = req.get("valid_from").and_then(|v| v.as_u64());
339    let valid_until = req.get("valid_until").and_then(|v| v.as_u64());
340
341    let edge = MemoryEdge {
342        source,
343        target,
344        edge_type,
345        weight,
346        created_at: now,
347        valid_from,
348        valid_until,
349        label: req
350            .get("label")
351            .and_then(|v| v.as_str())
352            .map(|s| s.to_string()),
353    };
354
355    let db = &*state.db;
356    db.relate(edge).map_err(|e| {
357        error!("relate failed: {e}");
358        ApiError::Internal(format!("relate failed: {e}"))
359    })?;
360
361    Ok((StatusCode::CREATED, Json(json!({ "status": "created" }))))
362}
363
364// ---------------------------------------------------------------------------
365// POST /v1/ingest
366// ---------------------------------------------------------------------------
367
368/// Ingest a conversation: extract memories via LLM, filter, and store them.
369pub async fn ingest_conversation(
370    State(state): State<Arc<AppState>>,
371    agent: Option<Extension<AuthenticatedAgent>>,
372    Json(req): Json<Value>,
373) -> Result<impl IntoResponse, ApiError> {
374    let agent_id = AgentId(parse_uuid(&req, "agent_id")?);
375    if let Some(Extension(ref authed)) = agent {
376        let tid: AgentId = authed
377            .agent_id
378            .parse()
379            .map_err(|_| ApiError::Internal("token contains invalid agent_id UUID".into()))?;
380        if tid != agent_id {
381            return Err(ApiError::Forbidden(
382                "agent_id in request body does not match token".into(),
383            ));
384        }
385    }
386
387    let extraction_config = state.extraction_config.as_ref().ok_or_else(|| {
388        ApiError::ServiceUnavailable(
389            "LLM provider not configured. Set MENTEDB_LLM_PROVIDER env var.".into(),
390        )
391    })?;
392
393    let conversation = req
394        .get("conversation")
395        .and_then(|v| v.as_str())
396        .ok_or_else(|| ApiError::BadRequest("missing or invalid 'conversation'".into()))?;
397
398    let space_id = match req.get("space_id") {
399        Some(v) => v
400            .as_str()
401            .and_then(|s| s.parse::<SpaceId>().ok())
402            .ok_or_else(|| ApiError::BadRequest("invalid 'space_id'".into()))?,
403        None => SpaceId::nil(),
404    };
405
406    let stats = run_extraction(
407        extraction_config,
408        conversation,
409        agent_id,
410        space_id,
411        &state.db,
412    )
413    .await?;
414
415    Ok((StatusCode::OK, Json(stats)))
416}
417
418/// Run the extraction pipeline and store accepted memories. Returns JSON stats.
419async fn run_extraction(
420    config: &ExtractionConfig,
421    conversation: &str,
422    agent_id: AgentId,
423    space_id: SpaceId,
424    db: &mentedb::MenteDb,
425) -> Result<Value, ApiError> {
426    let provider = HttpExtractionProvider::new(config.clone()).map_err(|e| {
427        error!("extraction provider init failed: {e}");
428        ApiError::Internal(format!("extraction provider init failed: {e}"))
429    })?;
430
431    let pipeline = ExtractionPipeline::new(provider, config.clone());
432
433    let all_memories = pipeline
434        .extract_from_conversation(conversation)
435        .await
436        .map_err(|e| {
437            error!("extraction failed: {e}");
438            ApiError::Internal(format!("extraction failed: {e}"))
439        })?;
440
441    let total_extracted = all_memories.len();
442    let quality_passed = pipeline.filter_quality(&all_memories);
443    let rejected_low_quality = total_extracted - quality_passed.len();
444
445    let now = std::time::SystemTime::now()
446        .duration_since(std::time::UNIX_EPOCH)
447        .unwrap_or_default()
448        .as_micros() as u64;
449
450    let mut stored_ids = Vec::new();
451    for memory in &quality_passed {
452        let memory_type = map_extraction_type_to_memory_type(&memory.memory_type);
453        let id = MemoryId::new();
454        let node = MemoryNode {
455            id,
456            agent_id,
457            user_id: UserId::nil(),
458            memory_type,
459            embedding: vec![],
460            content: memory.content.clone(),
461            created_at: now,
462            accessed_at: now,
463            access_count: 0,
464            salience: memory.confidence,
465            confidence: memory.confidence,
466            space_id,
467            attributes: std::collections::HashMap::new(),
468            tags: memory.tags.clone(),
469            valid_from: None,
470            valid_until: None,
471        };
472        match db.store(node) {
473            Ok(()) => stored_ids.push(id.to_string()),
474            Err(e) => {
475                tracing::warn!(error = %e, "failed to store extracted memory, skipping");
476            }
477        }
478    }
479
480    Ok(json!({
481        "memories_stored": stored_ids.len(),
482        "rejected_low_quality": rejected_low_quality,
483        "rejected_duplicate": 0,
484        "contradictions": 0,
485        "stored_ids": stored_ids,
486    }))
487}
488
489/// Heuristic: does this content look like a multi-turn conversation?
490fn looks_like_conversation(content: &str) -> bool {
491    let lower = content.to_lowercase();
492    let patterns = ["user:", "assistant:", "human:", "ai:", "\nq:", "\na:"];
493    let matches = patterns.iter().filter(|p| lower.contains(*p)).count();
494    matches >= 2
495}
496
497// ---------------------------------------------------------------------------
498// Helpers
499// ---------------------------------------------------------------------------
500
501fn parse_uuid(val: &Value, field: &str) -> Result<Uuid, ApiError> {
502    val.get(field)
503        .and_then(|v| v.as_str())
504        .ok_or_else(|| ApiError::BadRequest(format!("missing '{field}'")))
505        .and_then(|s| {
506            Uuid::parse_str(s)
507                .map_err(|_| ApiError::BadRequest(format!("invalid UUID for '{field}'")))
508        })
509}
510
511fn parse_memory_type(val: &Value) -> Result<MemoryType, ApiError> {
512    let s = val
513        .get("memory_type")
514        .and_then(|v| v.as_str())
515        .ok_or_else(|| ApiError::BadRequest("missing 'memory_type'".into()))?;
516
517    match s.to_lowercase().as_str() {
518        "episodic" => Ok(MemoryType::Episodic),
519        "semantic" => Ok(MemoryType::Semantic),
520        "procedural" => Ok(MemoryType::Procedural),
521        "antipattern" | "anti_pattern" => Ok(MemoryType::AntiPattern),
522        "reasoning" => Ok(MemoryType::Reasoning),
523        "correction" => Ok(MemoryType::Correction),
524        _ => Err(ApiError::BadRequest(format!("unknown memory_type: {s}"))),
525    }
526}
527
528fn parse_edge_type(s: &str) -> Option<EdgeType> {
529    match s.to_lowercase().as_str() {
530        "caused" => Some(EdgeType::Caused),
531        "before" => Some(EdgeType::Before),
532        "related" => Some(EdgeType::Related),
533        "contradicts" => Some(EdgeType::Contradicts),
534        "supports" => Some(EdgeType::Supports),
535        "supersedes" => Some(EdgeType::Supersedes),
536        "derived" => Some(EdgeType::Derived),
537        "partof" | "part_of" => Some(EdgeType::PartOf),
538        _ => None,
539    }
540}
541
542fn parse_embedding(val: &Value, required: bool) -> Result<Vec<f32>, ApiError> {
543    match val.get("embedding").and_then(|v| v.as_array()) {
544        Some(arr) => {
545            let mut emb = Vec::with_capacity(arr.len());
546            for v in arr {
547                let f = v.as_f64().ok_or_else(|| {
548                    ApiError::BadRequest("embedding values must be numbers".into())
549                })?;
550                emb.push(f as f32);
551            }
552            Ok(emb)
553        }
554        None if required => Err(ApiError::BadRequest("missing 'embedding' array".into())),
555        None => Ok(vec![]),
556    }
557}
558
559fn parse_attributes(val: Option<&Value>) -> HashMap<String, AttributeValue> {
560    let mut map = HashMap::new();
561    if let Some(Value::Object(obj)) = val {
562        for (k, v) in obj {
563            let av = match v {
564                Value::String(s) => AttributeValue::String(s.clone()),
565                Value::Number(n) => {
566                    if let Some(i) = n.as_i64() {
567                        AttributeValue::Integer(i)
568                    } else if let Some(f) = n.as_f64() {
569                        AttributeValue::Float(f)
570                    } else {
571                        continue;
572                    }
573                }
574                Value::Bool(b) => AttributeValue::Boolean(*b),
575                _ => continue,
576            };
577            map.insert(k.clone(), av);
578        }
579    }
580    map
581}
582
583fn memory_node_to_json(node: &MemoryNode) -> Value {
584    let mut attrs = Map::new();
585    for (k, v) in &node.attributes {
586        let jv = match v {
587            AttributeValue::String(s) => Value::String(s.clone()),
588            AttributeValue::Integer(i) => json!(i),
589            AttributeValue::Float(f) => json!(f),
590            AttributeValue::Boolean(b) => json!(b),
591            AttributeValue::Bytes(b) => json!(b),
592        };
593        attrs.insert(k.clone(), jv);
594    }
595
596    let memory_type_str = format!("{:?}", node.memory_type).to_lowercase();
597
598    json!({
599        "id": node.id.to_string(),
600        "agent_id": node.agent_id.to_string(),
601        "memory_type": memory_type_str,
602        "embedding": node.embedding,
603        "content": node.content,
604        "created_at": node.created_at,
605        "accessed_at": node.accessed_at,
606        "access_count": node.access_count,
607        "salience": node.salience,
608        "confidence": node.confidence,
609        "space_id": node.space_id.to_string(),
610        "attributes": attrs,
611        "tags": node.tags,
612    })
613}
614
615fn parse_permission(s: &str) -> Result<Permission, ApiError> {
616    match s.to_lowercase().as_str() {
617        "read" => Ok(Permission::Read),
618        "write" => Ok(Permission::Write),
619        "readwrite" | "read_write" => Ok(Permission::ReadWrite),
620        "admin" => Ok(Permission::Admin),
621        _ => Err(ApiError::BadRequest(format!("unknown permission: {s}"))),
622    }
623}
624fn resolve_agent_id(
625    agent: &Option<Extension<AuthenticatedAgent>>,
626    body: Option<&Value>,
627) -> Result<AgentId, ApiError> {
628    if let Some(Extension(a)) = agent {
629        return a
630            .agent_id
631            .parse::<AgentId>()
632            .map_err(|_| ApiError::Unauthorized("invalid agent_id in token".into()));
633    }
634    if let Some(val) = body {
635        return Ok(AgentId(parse_uuid(val, "agent_id")?));
636    }
637    Err(ApiError::Unauthorized("no agent_id available".into()))
638}
639fn is_admin_token(agent: &Option<Extension<AuthenticatedAgent>>) -> bool {
640    matches!(agent, Some(Extension(a)) if a.admin)
641}
642pub async fn create_space(
643    State(state): State<Arc<AppState>>,
644    agent: Option<Extension<AuthenticatedAgent>>,
645    Json(req): Json<Value>,
646) -> Result<impl IntoResponse, ApiError> {
647    let agent_id = resolve_agent_id(&agent, Some(&req))?;
648    let name = req
649        .get("name")
650        .and_then(|v| v.as_str())
651        .ok_or_else(|| ApiError::BadRequest("missing 'name' field".into()))?;
652    let mut spaces = state.spaces.write().await;
653    let space = spaces.create_space(name, agent_id);
654    Ok((
655        StatusCode::CREATED,
656        Json(
657            json!({"id": space.id.to_string(), "name": space.name, "owner": space.owner.to_string()}),
658        ),
659    ))
660}
661pub async fn list_spaces(
662    State(state): State<Arc<AppState>>,
663    agent: Option<Extension<AuthenticatedAgent>>,
664) -> Result<impl IntoResponse, ApiError> {
665    let agent_id = resolve_agent_id(&agent, None)?;
666    let spaces = state.spaces.read().await;
667    let list: Vec<Value> = spaces
668        .list_spaces_for_agent(agent_id)
669        .iter()
670        .map(|s| json!({"id": s.id.to_string(), "name": s.name, "owner": s.owner.to_string()}))
671        .collect();
672    Ok(Json(json!({"spaces": list})))
673}
674pub async fn grant_space_access(
675    State(state): State<Arc<AppState>>,
676    agent: Option<Extension<AuthenticatedAgent>>,
677    Path(space_id_str): Path<String>,
678    Json(req): Json<Value>,
679) -> Result<impl IntoResponse, ApiError> {
680    let space_id: SpaceId = space_id_str
681        .parse()
682        .map_err(|_| ApiError::BadRequest("invalid space ID".into()))?;
683    let caller_id = resolve_agent_id(&agent, None)?;
684    let target_agent = AgentId(parse_uuid(&req, "agent_id")?);
685    let perm_str = req
686        .get("permission")
687        .and_then(|v| v.as_str())
688        .ok_or_else(|| ApiError::BadRequest("missing 'permission' field".into()))?;
689    let perm = parse_permission(perm_str)?;
690    let mut spaces = state.spaces.write().await;
691    if !is_admin_token(&agent) && !spaces.check_access(space_id, caller_id, Permission::Admin) {
692        return Err(ApiError::Forbidden(
693            "only space owner or admin can grant access".into(),
694        ));
695    }
696    spaces.grant_access(space_id, target_agent, perm);
697    Ok(Json(json!({"status": "granted"})))
698}