Skip to main content

convergio_knowledge/
routes.rs

1//! HTTP routes for the knowledge vector store.
2//!
3//! - POST /api/knowledge/search  — semantic search
4//! - POST /api/knowledge/write   — store a knowledge entry
5//! - GET  /api/knowledge/stats   — store statistics
6//! - DELETE /api/knowledge/:id   — delete an entry
7
8use std::sync::Arc;
9
10use axum::extract::{Path, State};
11use axum::routing::{delete, get, post};
12use axum::{Json, Router};
13use convergio_db::pool::ConnPool;
14use serde_json::{json, Value};
15
16use crate::store::LanceVectorStore;
17use crate::types::{SearchRequest, WriteRequest};
18
19pub struct KnowledgeState {
20    pub pool: ConnPool,
21    pub store: Option<Arc<LanceVectorStore>>,
22}
23
24pub fn knowledge_routes(state: Arc<KnowledgeState>) -> Router {
25    Router::new()
26        .route("/api/knowledge/search", post(handle_search))
27        .route("/api/knowledge/write", post(handle_write))
28        .route("/api/knowledge/stats", get(handle_stats))
29        .route("/api/knowledge/seed", post(handle_seed))
30        .route("/api/knowledge/prune", post(handle_prune))
31        .route("/api/knowledge/:id", delete(handle_delete))
32        .with_state(state)
33}
34
35async fn handle_search(
36    State(s): State<Arc<KnowledgeState>>,
37    Json(req): Json<SearchRequest>,
38) -> Json<Value> {
39    let embedding = crate::embedder::embed(&req.query).await;
40    let fetch_limit =
41        if req.org_id.is_some() || req.source_type.is_some() || req.project_id.is_some() {
42            req.limit * 3
43        } else {
44            req.limit
45        };
46
47    let Some(ref store) = s.store else {
48        return Json(json!({"results": [], "error": "knowledge store not available"}));
49    };
50    match store.search(&embedding, fetch_limit).await {
51        Ok(mut results) => {
52            // Filter by org — when federated, also include public entries
53            if let Some(ref org) = req.org_id {
54                if req.federated {
55                    results.retain(|r| {
56                        r.entry.org_id.as_deref() == Some(org.as_str())
57                            || r.entry.visibility == "public"
58                    });
59                } else {
60                    results.retain(|r| r.entry.org_id.as_deref() == Some(org.as_str()));
61                }
62            }
63            if let Some(ref st) = req.source_type {
64                results.retain(|r| r.entry.source_type == *st);
65            }
66            if let Some(ref proj) = req.project_id {
67                results.retain(|r| r.entry.project_id.as_deref() == Some(proj.as_str()));
68            }
69            // Filter by minimum score threshold
70            results.retain(|r| r.score >= req.min_score);
71            // Deduplicate by content prefix (first 200 chars)
72            let mut seen = std::collections::HashSet::new();
73            results.retain(|r| {
74                let key: String = r.entry.content.chars().take(200).collect();
75                seen.insert(key)
76            });
77            results.truncate(req.limit);
78            // Truncate content to save tokens — full content available via /api/knowledge/:id
79            for r in &mut results {
80                if r.entry.content.len() > 200 {
81                    r.entry.content = r.entry.content.chars().take(200).collect::<String>() + "…";
82                }
83            }
84            let count = results.len();
85            Json(json!({
86                "results": results,
87                "query": req.query,
88                "count": count,
89                "min_score": req.min_score,
90            }))
91        }
92        Err(e) => Json(json!({"error": e})),
93    }
94}
95
96async fn handle_write(
97    State(s): State<Arc<KnowledgeState>>,
98    Json(req): Json<WriteRequest>,
99) -> Json<Value> {
100    let Some(ref store) = s.store else {
101        return Json(json!({"error": "knowledge store not available"}));
102    };
103    let embedding = crate::embedder::embed(&req.content).await;
104
105    match store
106        .insert(
107            &req.content,
108            &req.source_type,
109            &req.source_id,
110            req.org_id.as_deref(),
111            req.agent_id.as_deref(),
112            req.project_id.as_deref(),
113            &req.visibility,
114            &embedding,
115        )
116        .await
117    {
118        Ok(id) => Json(json!({"id": id, "status": "stored"})),
119        Err(e) => Json(json!({"error": e})),
120    }
121}
122
123async fn handle_stats(State(s): State<Arc<KnowledgeState>>) -> Json<Value> {
124    let Some(ref store) = s.store else {
125        return Json(json!({"error": "knowledge store not available"}));
126    };
127    match store.stats().await {
128        Ok(stats) => Json(json!(stats)),
129        Err(e) => Json(json!({"error": e})),
130    }
131}
132
133async fn handle_delete(
134    State(s): State<Arc<KnowledgeState>>,
135    Path(id): Path<String>,
136) -> Json<Value> {
137    let Some(ref store) = s.store else {
138        return Json(json!({"error": "knowledge store not available"}));
139    };
140    match store.delete(&id).await {
141        Ok(true) => Json(json!({"deleted": true})),
142        Ok(false) => Json(json!({"error": "not found"})),
143        Err(e) => Json(json!({"error": e})),
144    }
145}
146
147/// POST /api/knowledge/seed — ingest all known knowledge sources into vectors.
148async fn handle_seed(State(s): State<Arc<KnowledgeState>>) -> Json<Value> {
149    let Some(ref store) = s.store else {
150        return Json(json!({"error": "knowledge store not available"}));
151    };
152    let report = crate::seed::seed_baseline(store, &s.pool).await;
153    Json(json!({
154        "total": report.total,
155        "by_source": report.by_source,
156        "skipped": report.skipped,
157        "errors": report.errors
158    }))
159}
160
161/// POST /api/knowledge/prune — auto-prune stale and duplicate entries (#688).
162async fn handle_prune(
163    State(s): State<Arc<KnowledgeState>>,
164    Json(req): Json<crate::pruning::PruneRequest>,
165) -> Json<Value> {
166    let Some(ref store) = s.store else {
167        return Json(json!({"error": "knowledge store not available"}));
168    };
169    let report = crate::pruning::prune(store, &req).await;
170    Json(json!(report))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn search_request_deserializes() {
179        let json = r#"{"query": "agent task", "limit": 10}"#;
180        let req: SearchRequest = serde_json::from_str(json).unwrap();
181        assert_eq!(req.query, "agent task");
182        assert_eq!(req.limit, 10);
183    }
184
185    #[test]
186    fn write_request_deserializes() {
187        let json = r#"{"content": "test", "source_type": "doc", "source_id": "d1"}"#;
188        let req: WriteRequest = serde_json::from_str(json).unwrap();
189        assert_eq!(req.source_type, "doc");
190        assert!(req.org_id.is_none());
191    }
192}