Skip to main content

semantic_memory_mcp/
http_server.rs

1//! HTTP search server for semantic-memory-mcp.
2//!
3//! A minimal HTTP server that exposes the most-used semantic-memory
4//! operations over a local TCP port. Runs alongside the stdio MCP
5//! transport so the same warm process serves both MCP clients and
6//! HTTP clients (hooks, benchmarks, scripts).
7//!
8//! Endpoints:
9//!   POST /search   {"query": "...", "top_k": 10} -> search results
10//!   POST /stats    {} -> DB stats
11//!   POST /add      {"content": "...", "namespace": "..."} -> fact_id
12//!   GET  /health   -> {"ok": true}
13
14use std::io::{BufRead, BufReader, Read, Write};
15use std::net::TcpListener;
16use tokio::runtime::Handle;
17use tokio::task::block_in_place;
18
19use crate::bridge::MemoryBridge;
20
21/// Call Ollama to rate each result's relevance to the query (1-5) and sort descending.
22/// Returns a new vec with a `rerank_score` field added to each result object.
23fn rerank_results(
24    query: &str,
25    results: &[serde_json::Value],
26    model: &str,
27) -> Vec<serde_json::Value> {
28    let client = reqwest::blocking::Client::new();
29    let mut scored: Vec<(f64, serde_json::Value)> = results
30        .iter()
31        .map(|r| {
32            let content = r.get("content").and_then(|v| v.as_str()).unwrap_or("");
33            let truncated: String = content.chars().take(500).collect();
34            let prompt = format!(
35                "Rate the relevance of this document to the query on a scale of 1-5. Reply with ONLY the number.\nQuery: {query}\nDocument: {truncated}\nRating:"
36            );
37            let body = serde_json::json!({
38                "model": model,
39                "prompt": prompt,
40                "stream": false,
41                "options": {"temperature": 0, "num_predict": 1}
42            });
43            let rating = client
44                .post("http://127.0.0.1:11434/api/generate")
45                .json(&body)
46                .send()
47                .ok()
48                .and_then(|resp| resp.json::<serde_json::Value>().ok())
49                .and_then(|v| {
50                    v.get("response")
51                        .and_then(|r| r.as_str())
52                        .and_then(|s| s.trim().chars().next())
53                        .and_then(|c| c.to_digit(10))
54                        .map(|d| d as f64)
55                })
56                .unwrap_or(1.0);
57            (rating, r.clone())
58        })
59        .collect();
60    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
61    scored
62        .into_iter()
63        .map(|(score, mut r)| {
64            if let Some(obj) = r.as_object_mut() {
65                obj.insert("rerank_score".to_string(), serde_json::json!(score));
66            }
67            r
68        })
69        .collect()
70}
71
72pub fn start_http_server(port: u16, bridge: MemoryBridge, handle: Handle) {
73    std::thread::spawn(move || {
74        let listener = match TcpListener::bind(("127.0.0.1", port)) {
75            Ok(l) => {
76                eprintln!("HTTP search server listening on 127.0.0.1:{}", port);
77                l
78            }
79            Err(e) => {
80                eprintln!("Failed to bind HTTP port {}: {}", port, e);
81                return;
82            }
83        };
84
85        for stream in listener.incoming() {
86            let stream = match stream {
87                Ok(s) => s,
88                Err(_) => continue,
89            };
90
91            let bridge = bridge.clone();
92            let h = handle.clone();
93            std::thread::spawn(move || {
94                handle_connection(stream, bridge, h);
95            });
96        }
97    });
98}
99
100fn handle_connection(
101    mut stream: std::net::TcpStream,
102    bridge: MemoryBridge,
103    handle: Handle,
104) {
105    let mut reader = BufReader::new(stream.try_clone().expect("clone"));
106    let mut request_line = String::new();
107    if reader.read_line(&mut request_line).is_err() {
108        return;
109    }
110
111    let parts: Vec<&str> = request_line.split_whitespace().collect();
112    if parts.len() < 2 {
113        return;
114    }
115    let method = parts[0];
116    let path = parts[1];
117
118    let mut content_length = 0;
119    loop {
120        let mut header = String::new();
121        if reader.read_line(&mut header).is_err() {
122            return;
123        }
124        if header.trim().is_empty() {
125            break;
126        }
127        if let Some(len_str) = header
128            .strip_prefix("Content-Length:")
129            .or_else(|| header.strip_prefix("content-length:"))
130        {
131            content_length = len_str.trim().parse().unwrap_or(0);
132        }
133    }
134
135    let mut body = vec![0u8; content_length];
136    if content_length > 0 && reader.read_exact(&mut body).is_err() {
137        return;
138    }
139    let body_str = String::from_utf8_lossy(&body);
140
141    let (status, response) = match (method, path) {
142        ("GET", "/health") => (
143            "200 OK",
144            serde_json::json!({"ok": true, "service": "semantic-memory-mcp"}),
145        ),
146        ("POST", "/search") => handle_search(&body_str, &bridge, &handle),
147        ("POST", "/search-routed") => handle_search_routed(&body_str, &bridge, &handle),
148        ("POST", "/rerank") => handle_rerank(&body_str),
149        ("POST", "/stats") => handle_stats(&bridge, &handle),
150        ("POST", "/add") => handle_add_fact(&body_str, &bridge, &handle),
151        ("POST", "/add-edge") => {
152            handle_add_edge(&body_str, &bridge, &handle)
153        }
154        ("POST", "/delete-fact") => {
155            handle_delete_fact(&body_str, &bridge, &handle)
156        }
157        ("POST", "/record-outcome") => handle_record_outcome(&body_str, &bridge, &handle),
158        ("GET", "/verify-integrity") => handle_verify_integrity(&bridge, &handle),
159        ("POST", "/discord") => handle_discord(&body_str, &bridge, &handle),
160        ("POST", "/maintenance/check") => handle_maintenance_check(&bridge, &handle),
161        ("POST", "/maintenance/vacuum") => handle_maintenance_vacuum(&bridge, &handle),
162        ("POST", "/maintenance/reembed") => handle_maintenance_reembed(&bridge, &handle),
163        ("POST", "/maintenance/reconcile") => handle_maintenance_reconcile(&body_str, &bridge, &handle),
164        ("POST", "/maintenance/compact-hnsw") => handle_maintenance_compact_hnsw(&bridge, &handle),
165        ("POST", "/maintenance/auto-edge") => handle_maintenance_auto_edge(&body_str, &bridge, &handle),
166        _ => (
167            "404 Not Found",
168            serde_json::json!({"error": "not found", "path": path}),
169        ),
170    };
171
172    let response_str = serde_json::to_string(&response).unwrap_or_default();
173    let response_bytes = response_str.as_bytes();
174    let http_response = format!(
175        "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
176        status,
177        response_bytes.len()
178    );
179
180    let _ = stream.write_all(http_response.as_bytes());
181    let _ = stream.write_all(response_bytes);
182    let _ = stream.flush();
183}
184
185fn handle_search(
186    body: &str,
187    bridge: &MemoryBridge,
188    handle: &Handle,
189) -> (&'static str, serde_json::Value) {
190    let params: serde_json::Value = match serde_json::from_str(body) {
191        Ok(v) => v,
192        Err(e) => {
193            return (
194                "400 Bad Request",
195                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
196            )
197        }
198    };
199
200    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
201    let top_k = params.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
202    let namespaces: Option<Vec<String>> = params
203        .get("namespaces")
204        .and_then(|v| serde_json::from_value(v.clone()).ok());
205    let do_rerank = params.get("rerank").and_then(|v| v.as_bool()).unwrap_or(false);
206
207    if query.is_empty() {
208        return (
209            "400 Bad Request",
210            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
211        );
212    }
213
214    let store = &bridge.store;
215    let ns_slice: Option<Vec<&str>> = namespaces
216        .as_ref()
217        .map(|v| v.iter().map(|s| s.as_str()).collect());
218    // Fetch top_k * 2 candidates when reranking so the LLM has a richer pool to sort.
219    let fetch_k = if do_rerank { top_k * 2 } else { top_k };
220    let result = block_in_place(|| {
221        handle.block_on(store.search(query, Some(fetch_k), ns_slice.as_deref(), None))
222    });
223
224    match result {
225        Ok(results) => {
226            let json_results: Vec<serde_json::Value> = results
227                .iter()
228                .map(|r| {
229                    let namespace = match &r.source {
230                        semantic_memory::SearchSource::Fact { namespace, .. } => namespace.clone(),
231                        semantic_memory::SearchSource::Chunk { document_title, .. } => document_title.clone(),
232                        _ => String::new(),
233                    };
234                    serde_json::json!({
235                        "result_id": r.source.result_id(),
236                        "content": r.content,
237                        "score": r.score,
238                        "cosine_similarity": r.cosine_similarity,
239                        "namespace": namespace,
240                    })
241                })
242                .collect();
243
244            let final_results: Vec<serde_json::Value> = if do_rerank && !json_results.is_empty() {
245                rerank_results(query, &json_results, "granite4.1:3b")
246                    .into_iter()
247                    .take(top_k)
248                    .collect()
249            } else {
250                json_results
251            };
252
253            let count = final_results.len();
254            let provenance = serde_json::json!({
255                "stages_fired": {
256                    "bm25": true,
257                    "vector": true,
258                    "late_interaction": false,
259                    "rerank": do_rerank,
260                },
261                "result_count": count,
262                "view": "semantic",
263                "widening_occurred": false,
264                "widening_reason": null,
265                "verification_status": "verified",
266            });
267            (
268                "200 OK",
269                serde_json::json!({
270                    "ok": true,
271                    "query": query,
272                    "top_k": top_k,
273                    "results": final_results,
274                    "count": count,
275                    "reranked": do_rerank,
276                    "provenance": provenance,
277                }),
278            )
279        }
280        Err(e) => (
281            "500 Internal Server Error",
282            serde_json::json!({"ok": false, "error": format!("search error: {e}")}),
283        ),
284    }
285}
286
287/// Handle /search-routed: routing-aware search with full pipeline.
288///
289/// Uses the library's routing system to profile the query and decide which
290/// retrieval stages to activate. For class C/D queries with contradictions,
291/// runs factor graph belief propagation and decoder syndrome detection.
292/// When discord is enabled, runs second-order retrieval via graph neighborhood.
293/// Optionally groups results by community.
294fn handle_search_routed(
295    body: &str,
296    bridge: &MemoryBridge,
297    handle: &Handle,
298) -> (&'static str, serde_json::Value) {
299    use semantic_memory::integration::plan_execution;
300    use semantic_memory::routing::RetrievalRouter;
301
302    let params: serde_json::Value = match serde_json::from_str(body) {
303        Ok(v) => v,
304        Err(e) => {
305            return (
306                "400 Bad Request",
307                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
308            )
309        }
310    };
311
312    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
313    let base_top_k = params.get("top_k").and_then(|v| v.as_u64()).unwrap_or(12) as usize;
314    let query_class = params.get("query_class").and_then(|v| v.as_str()).unwrap_or("A");
315    let namespaces: Option<Vec<String>> = params
316        .get("namespaces")
317        .and_then(|v| serde_json::from_value(v.clone()).ok());
318    let contradictions: Vec<(String, String)> = params
319        .get("contradictions")
320        .and_then(|v| serde_json::from_value(v.clone()).ok())
321        .unwrap_or_default();
322    let group_by_community = params.get("group_by_community").and_then(|v| v.as_bool()).unwrap_or(false);
323
324    if query.is_empty() {
325        return (
326            "400 Bad Request",
327            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
328        );
329    }
330
331    // Use the routing system to profile the query
332    let router = RetrievalRouter {
333        decoder_enabled: true,
334        discord_enabled: true,
335        corpus_density: 0.5,
336        ..Default::default()
337    };
338    let decision = router.route_query(query);
339    let contras = contradictions.clone();
340    let plan = plan_execution(&decision, contras.clone());
341
342    // Class D (SYNTHESIS): retrieve more candidates to support comprehensive answers
343    let top_k = if query_class == "D" {
344        (base_top_k * 2).min(20)
345    } else {
346        base_top_k
347    };
348
349    let store = &bridge.store;
350    let ns_slice: Option<Vec<&str>> = namespaces
351        .as_ref()
352        .map(|v| v.iter().map(|s| s.as_str()).collect());
353
354    // Class C (CONTRADICTION): use ExactSearch context for higher-fidelity results
355    let result = if query_class == "C" {
356        use semantic_memory::{ExactnessProfile, SearchContext};
357        let mut ctx = SearchContext::default_now();
358        ctx.exactness_profile = ExactnessProfile::PreferExact;
359        block_in_place(|| {
360            handle.block_on(store.search_with_context(
361                query,
362                Some(top_k),
363                ns_slice.as_deref(),
364                None,
365                ctx,
366            ))
367        })
368        .map(|r| r.results)
369    } else {
370        block_in_place(|| {
371            handle.block_on(store.search(query, Some(top_k), ns_slice.as_deref(), None))
372        })
373    };
374
375    match result {
376        Ok(results) => {
377            let json_results: Vec<serde_json::Value> = results
378                .iter()
379                .map(|r| {
380                    let namespace = match &r.source {
381                        semantic_memory::SearchSource::Fact { namespace, .. } => namespace.clone(),
382                        semantic_memory::SearchSource::Chunk { document_title, .. } => {
383                            document_title.clone()
384                        }
385                        _ => String::new(),
386                    };
387                    serde_json::json!({
388                        "result_id": r.source.result_id(),
389                        "content": r.content,
390                        "score": r.score,
391                        "cosine_similarity": r.cosine_similarity,
392                        "namespace": namespace,
393                        "source_type": match &r.source {
394                            semantic_memory::SearchSource::Fact { .. } => "fact",
395                            semantic_memory::SearchSource::Chunk { .. } => "chunk",
396                            semantic_memory::SearchSource::Message { .. } => "message",
397                            _ => "unknown",
398                        },
399                    })
400                })
401                .collect();
402
403            let mut factor_graph_payload = serde_json::json!({"enabled": false});
404            let mut decoder_executed = false;
405            let mut discord_executed = false;
406            let mut discord_results_payload: Vec<serde_json::Value> = Vec::new();
407
408            // Factor graph belief propagation for class C/D with contradictions
409            if decision.decoder {
410                #[cfg(feature = "full")]
411                {
412                    use semantic_memory::factor_graph::{factors_from_edges, FactorGraph, FactorGraphConfig};
413
414                    let graph_edges = block_in_place(|| {
415                        handle.block_on(store.list_all_graph_edges())
416                    });
417
418                    if let Ok(edges) = graph_edges {
419                        let raw_edges: Vec<(
420                            String,
421                            String,
422                            semantic_memory::GraphEdgeType,
423                            f64,
424                            Option<String>,
425                        )> = edges
426                            .iter()
427                            .map(|edge| {
428                                let parsed_type = edge
429                                    .edge_type_parsed
430                                    .clone()
431                                    .or_else(|| serde_json::from_str(&edge.edge_type).ok())
432                                    .unwrap_or(semantic_memory::GraphEdgeType::Entity {
433                                        relation: "unknown".to_string(),
434                                    });
435                                (
436                                    edge.source.clone(),
437                                    edge.target.clone(),
438                                    parsed_type,
439                                    edge.weight,
440                                    edge.metadata.clone(),
441                                )
442                            })
443                            .collect();
444
445                        let nodes: Vec<(String, f64)> = results
446                            .iter()
447                            .map(|r| (r.source.result_id(), r.score))
448                            .collect();
449                        let factors = factors_from_edges(&raw_edges);
450                        let graph =
451                            FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
452                        let propagated = graph.propagate();
453                        let top_beliefs = propagated.top_k(top_k);
454
455                        factor_graph_payload = serde_json::json!({
456                            "enabled": true,
457                            "top_k_beliefs": top_beliefs
458                                .into_iter()
459                                .map(|(item_id, belief)| serde_json::json!({
460                                    "item_id": item_id,
461                                    "belief": belief,
462                                }))
463                                .collect::<Vec<_>>(),
464                            "iterations": propagated.iterations,
465                            "converged": propagated.converged,
466                            "elapsed_ms": propagated.elapsed_ms,
467                            "factor_counts": {
468                                "semantic": propagated.factor_counts.semantic,
469                                "temporal": propagated.factor_counts.temporal,
470                                "causal": propagated.factor_counts.causal,
471                                "entity": propagated.factor_counts.entity,
472                                "total": propagated.factor_counts.total(),
473                            },
474                        });
475                        decoder_executed = true;
476                    }
477                }
478
479                // Decoder syndrome detection for contradictions
480                if !plan.contradictions.is_empty() {
481                    use semantic_memory::decoder::{compute_correction, detect_syndromes};
482                    let result_scores: Vec<(String, f64)> = results
483                        .iter()
484                        .map(|r| (r.source.result_id(), r.score))
485                        .collect();
486                    let syndromes = detect_syndromes(&result_scores, &plan.contradictions);
487                    let _ = compute_correction(&syndromes, 10.0);
488                    decoder_executed = true;
489                }
490            }
491
492            // Discord second-order retrieval
493            if plan.use_discord {
494                use semantic_memory::discord::DiscordScorer;
495                let direct_ids: Vec<String> = results.iter().map(|r| r.source.result_id()).collect();
496                let existing_ids: std::collections::HashSet<String> =
497                    direct_ids.iter().cloned().collect();
498                let edges_result = block_in_place(|| {
499                    handle.block_on(store.list_graph_edges_for_neighborhood(
500                        direct_ids.clone(),
501                        2,
502                        200,
503                    ))
504                });
505                if let Ok(raw_edges) = edges_result {
506                    let edge_refs: Vec<semantic_memory::discord::GraphEdgeRef> = raw_edges
507                        .iter()
508                        .map(|edge| {
509                            let parsed_type = edge
510                                .edge_type_parsed
511                                .clone()
512                                .or_else(|| serde_json::from_str(&edge.edge_type).ok())
513                                .unwrap_or(semantic_memory::GraphEdgeType::Entity {
514                                    relation: "unknown".to_string(),
515                                });
516                            let type_str = match parsed_type {
517                                semantic_memory::GraphEdgeType::Semantic { .. } => "semantic",
518                                semantic_memory::GraphEdgeType::Temporal { .. } => "temporal",
519                                semantic_memory::GraphEdgeType::Causal { .. } => "causal",
520                                semantic_memory::GraphEdgeType::Entity { .. } => "entity",
521                            };
522                            semantic_memory::discord::GraphEdgeRef {
523                                source: edge.source.clone(),
524                                target: edge.target.clone(),
525                                edge_type: type_str.to_string(),
526                                weight: edge.weight,
527                            }
528                        })
529                        .collect();
530                    let scorer = DiscordScorer::with_defaults();
531                    let discord_hits = scorer.score(&direct_ids, &edge_refs);
532                    for hit in &discord_hits {
533                        if !existing_ids.contains(&hit.item_id) {
534                            // Fetch the fact's content directly from the DB.
535                            // get_fact expects a bare UUID (without "fact:" prefix).
536                            let bare_id = hit.item_id.strip_prefix("fact:").unwrap_or(&hit.item_id);
537                            let (content, namespace) = {
538                                let fact_result = handle.block_on(
539                                    store.get_fact(bare_id)
540                                );
541                                match fact_result {
542                                    Ok(Some(fact)) => (fact.content, fact.namespace),
543                                    Ok(None) => {
544                                        eprintln!("[discord] get_fact returned None for id={}", bare_id);
545                                        (String::new(), String::new())
546                                    }
547                                    Err(e) => {
548                                        eprintln!("[discord] get_fact error for id={}: {}", bare_id, e);
549                                        (String::new(), String::new())
550                                    }
551                                }
552                            };
553                            discord_results_payload.push(serde_json::json!({
554                                "result_id": hit.item_id,
555                                "content": content,
556                                "namespace": namespace,
557                                "discord_score": hit.discord_score,
558                                "anchor_ids": hit.anchor_ids,
559                                "relationship_types": hit.relationship_types,
560                            }));
561                        }
562                    }
563                    discord_executed = true;
564                }
565            }
566
567            // Community grouping (opt-in)
568            let grouped_results_payload: serde_json::Value = if group_by_community {
569                let seed_ids: Vec<String> = results.iter().map(|r| r.source.result_id()).collect();
570                let edges_result = block_in_place(|| {
571                    handle.block_on(store.list_graph_edges_for_neighborhood(
572                        seed_ids.clone(),
573                        2,
574                        200,
575                    ))
576                });
577                let edges: Vec<(String, String)> = match edges_result {
578                    Ok(raw_edges) => raw_edges
579                        .iter()
580                        .map(|edge| (edge.source.clone(), edge.target.clone()))
581                        .collect(),
582                    Err(_) => Vec::new(),
583                };
584                if !edges.is_empty() {
585                    use semantic_memory::community::detect_communities;
586                    let communities = detect_communities(&edges, 1.0, 42);
587                    let mut member_to_comm: std::collections::HashMap<String, String> =
588                        std::collections::HashMap::new();
589                    for c in &communities {
590                        for m in &c.members {
591                            member_to_comm.insert(m.clone(), c.id.clone());
592                        }
593                    }
594                    let mut groups: std::collections::HashMap<
595                        String,
596                        Vec<serde_json::Value>,
597                    > = std::collections::HashMap::new();
598                    let mut ungrouped: Vec<serde_json::Value> = Vec::new();
599                    for r in &json_results {
600                        if let Some(rid) = r.get("result_id").and_then(|v| v.as_str()) {
601                            match member_to_comm.get(rid).cloned() {
602                                Some(cid) => {
603                                    groups.entry(cid).or_default().push(r.clone())
604                                }
605                                None => ungrouped.push(r.clone()),
606                            }
607                        }
608                    }
609                    let mut map = serde_json::Map::new();
610                    for (cid, items) in groups {
611                        map.insert(
612                            format!("community_{cid}"),
613                            serde_json::json!(items),
614                        );
615                    }
616                    if !ungrouped.is_empty() {
617                        map.insert(
618                            "ungrouped".to_string(),
619                            serde_json::json!(ungrouped),
620                        );
621                    }
622                    serde_json::Value::Object(map)
623                } else {
624                    serde_json::Value::Null
625                }
626            } else {
627                serde_json::Value::Null
628            };
629
630            // Query provenance: declare which retrieval stages contributed
631            let provenance = serde_json::json!({
632                "stages_fired": {
633                    "bm25": results.iter().any(|r| r.bm25_rank.is_some()),
634                    "vector": results.iter().any(|r| r.vector_rank.is_some()),
635                    "late_interaction": true,
636                    "discord": discord_executed,
637                    "decoder": decoder_executed,
638                },
639                "result_count": results.len(),
640                "view": "routed",
641                "query_class": query_class,
642                "widening_occurred": false,
643                "widening_reason": null,
644                "verification_status": "verified",
645            });
646
647            (
648                "200 OK",
649                serde_json::json!({
650                    "ok": true,
651                    "query": query,
652                    "top_k": base_top_k,
653                    "results": json_results,
654                    "provenance": provenance,
655                    "query_class": query_class,
656                    "routed": true,
657                    "routing_decision": {
658                        "bm25_coarse": decision.bm25_coarse,
659                        "vector_medium": decision.vector_medium,
660                        "rerank_fine": decision.rerank_fine,
661                        "graph_expansion": decision.graph_expansion,
662                        "decoder": decision.decoder,
663                        "discord": decision.discord,
664                        "no_retrieval": decision.no_retrieval,
665                        "reasoning": decision.reasoning,
666                    },
667                    "decoder_planned": plan.use_decoder,
668                    "decoder_executed": decoder_executed,
669                    "discord_planned": plan.use_discord,
670                    "discord_executed": discord_executed,
671                    "discord_results": discord_results_payload,
672                    "factor_graph": factor_graph_payload,
673                    "grouped_results": grouped_results_payload,
674                }),
675            )
676        }
677        Err(e) => (
678            "500 Internal Server Error",
679            serde_json::json!({"ok": false, "error": format!("search error: {e}")}),
680        ),
681    }
682}
683
684fn handle_stats(
685    bridge: &MemoryBridge,
686    handle: &Handle,
687) -> (&'static str, serde_json::Value) {
688    let store = &bridge.store;
689    let result = block_in_place(|| handle.block_on(store.stats()));
690    match result {
691        Ok(stats) => (
692            "200 OK",
693            serde_json::json!({
694                "ok": true,
695                "facts": stats.total_facts,
696                "documents": stats.total_documents,
697                "chunks": stats.total_chunks,
698                "db_size_mb": (stats.database_size_bytes as f64) / (1024.0 * 1024.0),
699            }),
700        ),
701        Err(e) => (
702            "500 Internal Server Error",
703            serde_json::json!({"ok": false, "error": format!("{e}")}),
704        ),
705    }
706}
707
708fn handle_rerank(body: &str) -> (&'static str, serde_json::Value) {
709    let params: serde_json::Value = match serde_json::from_str(body) {
710        Ok(v) => v,
711        Err(e) => {
712            return (
713                "400 Bad Request",
714                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
715            )
716        }
717    };
718
719    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
720    let model = params
721        .get("model")
722        .and_then(|v| v.as_str())
723        .unwrap_or("granite4.1:3b");
724    let results = match params.get("results").and_then(|v| v.as_array()) {
725        Some(r) => r.clone(),
726        None => {
727            return (
728                "400 Bad Request",
729                serde_json::json!({"ok": false, "error": "missing 'results' array"}),
730            )
731        }
732    };
733
734    if query.is_empty() {
735        return (
736            "400 Bad Request",
737            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
738        );
739    }
740
741    let reranked = rerank_results(query, &results, model);
742    let count = reranked.len();
743    (
744        "200 OK",
745        serde_json::json!({
746            "ok": true,
747            "results": reranked,
748            "count": count,
749        }),
750    )
751}
752
753fn handle_add_fact(
754    body: &str,
755    bridge: &MemoryBridge,
756    handle: &Handle,
757) -> (&'static str, serde_json::Value) {
758    let params: serde_json::Value = match serde_json::from_str(body) {
759        Ok(v) => v,
760        Err(e) => {
761            return (
762                "400 Bad Request",
763                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
764            )
765        }
766    };
767
768    let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
769    let namespace = params
770        .get("namespace")
771        .and_then(|v| v.as_str())
772        .unwrap_or("general");
773    let source = params.get("source").and_then(|v| v.as_str());
774
775    if content.is_empty() {
776        return (
777            "400 Bad Request",
778            serde_json::json!({"ok": false, "error": "missing 'content' field"}),
779        );
780    }
781
782    let store = &bridge.store;
783    let result =
784        block_in_place(|| handle.block_on(store.add_fact(namespace, content, source, None)));
785
786    match result {
787        Ok(fact_id) => (
788            "200 OK",
789            serde_json::json!({"ok": true, "fact_id": fact_id}),
790        ),
791        Err(e) => (
792            "500 Internal Server Error",
793            serde_json::json!({"ok": false, "error": format!("{e}")}),
794        ),
795    }
796}
797
798/// Handle /add-edge: add a graph edge between two facts.
799fn handle_add_edge(
800    body: &str,
801    bridge: &MemoryBridge,
802    handle: &Handle,
803) -> (&'static str, serde_json::Value) {
804    let params: serde_json::Value = match serde_json::from_str(body) {
805        Ok(v) => v,
806        Err(e) => {
807            return (
808                "400 Bad Request",
809                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
810            )
811        }
812    };
813
814    let source = params.get("source").and_then(|v| v.as_str()).unwrap_or("");
815    let target = params.get("target").and_then(|v| v.as_str()).unwrap_or("");
816    let edge_type_str = params.get("edge_type").and_then(|v| v.as_str()).unwrap_or("semantic");
817    let weight = params.get("weight").and_then(|v| v.as_f64()).unwrap_or(1.0);
818    let cosine_similarity = params.get("cosine_similarity").and_then(|v| v.as_f64());
819    let relation = params.get("relation").and_then(|v| v.as_str());
820
821    if source.is_empty() || target.is_empty() {
822        return (
823            "400 Bad Request",
824            serde_json::json!({"ok": false, "error": "missing 'source' or 'target'"}),
825        );
826    }
827
828    let edge_type = match edge_type_str {
829        "semantic" => semantic_memory::GraphEdgeType::Semantic {
830            cosine_similarity: cosine_similarity.unwrap_or(weight) as f32,
831        },
832        "temporal" => semantic_memory::GraphEdgeType::Temporal {
833            delta_secs: params.get("delta_secs").and_then(|v| v.as_u64()).unwrap_or(0),
834        },
835        "causal" => semantic_memory::GraphEdgeType::Causal {
836            confidence: cosine_similarity.unwrap_or(weight) as f32,
837            evidence_ids: Vec::new(),
838        },
839        "entity" => semantic_memory::GraphEdgeType::Entity {
840            relation: relation.unwrap_or("mentions").to_string(),
841        },
842        _ => semantic_memory::GraphEdgeType::Semantic {
843            cosine_similarity: cosine_similarity.unwrap_or(weight) as f32,
844        },
845    };
846
847    let store = &bridge.store;
848    let result = block_in_place(|| {
849        handle.block_on(store.add_graph_edge(source, target, edge_type, weight, None))
850    });
851
852    match result {
853        Ok(edge) => (
854            "200 OK",
855            serde_json::json!({"ok": true, "edge_id": edge.id}),
856        ),
857        Err(e) => (
858            "500 Internal Server Error",
859            serde_json::json!({"ok": false, "error": format!("{e}")}),
860        ),
861    }
862}
863
864/// Handle /delete-fact: hard-delete a fact by ID.
865fn handle_delete_fact(
866    body: &str,
867    bridge: &MemoryBridge,
868    handle: &Handle,
869) -> (&'static str, serde_json::Value) {
870    let params: serde_json::Value = match serde_json::from_str(body) {
871        Ok(v) => v,
872        Err(e) => {
873            return (
874                "400 Bad Request",
875                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
876            )
877        }
878    };
879
880    let fact_id = params.get("fact_id").and_then(|v| v.as_str()).unwrap_or("");
881    if fact_id.is_empty() {
882        return (
883            "400 Bad Request",
884            serde_json::json!({"ok": false, "error": "missing 'fact_id'"}),
885        );
886    }
887
888    let bare_id = fact_id.strip_prefix("fact:").unwrap_or(fact_id);
889    let store = &bridge.store;
890    let result = block_in_place(|| handle.block_on(store.delete_fact(bare_id)));
891
892    match result {
893        Ok(()) => ("200 OK", serde_json::json!({"ok": true, "deleted": true})),
894        Err(e) => (
895            "500 Internal Server Error",
896            serde_json::json!({"ok": false, "error": format!("{e}")}),
897        ),
898    }
899}
900
901/// Handle /record-outcome: record a search outcome for RL routing feedback.
902fn handle_record_outcome(
903    body: &str,
904    bridge: &MemoryBridge,
905    handle: &Handle,
906) -> (&'static str, serde_json::Value) {
907    use semantic_memory::rl_routing::{record_routing_outcome, RoutingOutcome};
908    use semantic_memory::routing::{QueryProfile, RetrievalRouter};
909
910    let params: serde_json::Value = match serde_json::from_str(body) {
911        Ok(v) => v,
912        Err(e) => {
913            return (
914                "400 Bad Request",
915                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
916            )
917        }
918    };
919
920    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
921    let outcome = params.get("outcome").and_then(|v| v.as_str()).unwrap_or("neutral");
922    let _query_class = params.get("query_class").and_then(|v| v.as_str()).unwrap_or("A");
923
924    if query.is_empty() {
925        return (
926            "400 Bad Request",
927            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
928        );
929    }
930
931    let outcome_enum = match outcome.to_lowercase().as_str() {
932        "good" => RoutingOutcome::Good,
933        "bad" => RoutingOutcome::Bad,
934        "neutral" => RoutingOutcome::Neutral,
935        _ => {
936            return (
937                "400 Bad Request",
938                serde_json::json!({"ok": false, "error": "outcome must be 'good', 'bad', or 'neutral'"}),
939            )
940        }
941    };
942
943    let profile = QueryProfile::from_query(query);
944    let router = RetrievalRouter::default();
945    let decision = router.route(&profile);
946
947    let store = &bridge.store;
948    // Load persisted policy (or default if none saved yet)
949    let mut policy = block_in_place(|| handle.block_on(store.load_routing_policy()))
950        .ok()
951        .flatten()
952        .unwrap_or_default();
953    record_routing_outcome(&mut policy, &profile, &decision, outcome_enum);
954    // Save updated policy
955    let _ = block_in_place(|| handle.block_on(store.save_routing_policy(&policy)));
956
957    (
958        "200 OK",
959        serde_json::json!({
960            "ok": true,
961            "recorded": true,
962            "outcome": outcome,
963            "routing_decision": {
964                "bm25_coarse": decision.bm25_coarse,
965                "vector_medium": decision.vector_medium,
966                "rerank_fine": decision.rerank_fine,
967                "graph_expansion": decision.graph_expansion,
968                "decoder": decision.decoder,
969                "discord": decision.discord,
970                "no_retrieval": decision.no_retrieval,
971                "reasoning": decision.reasoning,
972            },
973            "policy_state": {
974                "trained_examples": policy.trained_examples,
975                "baseline": policy.baseline,
976            },
977        }),
978    )
979}
980
981/// Handle GET /verify-integrity: check DB integrity using real library checks.
982fn handle_verify_integrity(
983    bridge: &MemoryBridge,
984    handle: &Handle,
985) -> (&'static str, serde_json::Value) {
986    let store = &bridge.store;
987    let result = block_in_place(|| {
988        handle.block_on(store.verify_integrity(semantic_memory::VerifyMode::Quick))
989    });
990
991    match result {
992        Ok(report) => (
993            "200 OK",
994            serde_json::json!({
995                "ok": report.ok,
996                "integrity": report.ok,
997                "schema_version": report.schema_version,
998                "fact_count": report.fact_count,
999                "chunk_count": report.chunk_count,
1000                "message_count": report.message_count,
1001                "facts_missing_embeddings": report.facts_missing_embeddings,
1002                "chunks_missing_embeddings": report.chunks_missing_embeddings,
1003                "issues": report.issues,
1004                "issue_count": report.issues.len(),
1005                "message": if report.ok { "All integrity checks passed".to_string() } else { format!("{} integrity issues found", report.issues.len()) },
1006            }),
1007        ),
1008        Err(e) => (
1009            "500 Internal Server Error",
1010            serde_json::json!({"ok": false, "integrity": false, "error": format!("verify_integrity error: {e}")}),
1011        ),
1012    }
1013}
1014
1015/// Handle POST /discord: second-order retrieval via graph neighborhood.
1016///
1017/// Accepts {"query": "...", "top_k": 5, "direct_ids": ["fact:uuid1", ...]}.
1018/// If direct_ids not provided, runs a search first to get top_k results.
1019fn handle_discord(
1020    body: &str,
1021    bridge: &MemoryBridge,
1022    handle: &Handle,
1023) -> (&'static str, serde_json::Value) {
1024    use semantic_memory::discord::DiscordScorer;
1025
1026    let params: serde_json::Value = match serde_json::from_str(body) {
1027        Ok(v) => v,
1028        Err(e) => {
1029            return (
1030                "400 Bad Request",
1031                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
1032            )
1033        }
1034    };
1035
1036    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
1037    let top_k = params.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
1038
1039    // Get direct_ids from params, or run a search to get them
1040    let direct_ids: Vec<String> = match params.get("direct_ids").and_then(|v| v.as_array()) {
1041        Some(arr) => arr
1042            .iter()
1043            .filter_map(|v| v.as_str().map(|s| s.to_string()))
1044            .collect(),
1045        None => {
1046            // Need a query to search
1047            if query.is_empty() {
1048                return (
1049                    "400 Bad Request",
1050                    serde_json::json!({"ok": false, "error": "either 'direct_ids' or 'query' must be provided"}),
1051                );
1052            }
1053            let store = &bridge.store;
1054            let search_result = block_in_place(|| {
1055                handle.block_on(store.search(query, Some(top_k), None, None))
1056            });
1057            match search_result {
1058                Ok(results) => results.iter().map(|r| r.source.result_id()).collect(),
1059                Err(e) => {
1060                    return (
1061                        "500 Internal Server Error",
1062                        serde_json::json!({"ok": false, "error": format!("search error: {e}")}),
1063                    )
1064                }
1065            }
1066        }
1067    };
1068
1069    if direct_ids.is_empty() {
1070        return (
1071            "200 OK",
1072            serde_json::json!({"ok": true, "discord_results": [], "count": 0, "edges_loaded": 0}),
1073        );
1074    }
1075
1076    let store = &bridge.store;
1077    // Load graph edges for the neighborhood
1078    let edges_result = block_in_place(|| {
1079        handle.block_on(store.list_graph_edges_for_neighborhood(
1080            direct_ids.clone(),
1081            2,
1082            200,
1083        ))
1084    });
1085
1086    let edges: Vec<semantic_memory::discord::GraphEdgeRef> = match edges_result {
1087        Ok(raw_edges) => raw_edges
1088            .iter()
1089            .map(|edge| {
1090                let parsed_type = edge
1091                    .edge_type_parsed
1092                    .clone()
1093                    .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1094                    .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1095                        relation: "unknown".to_string(),
1096                    });
1097                let type_str = match parsed_type {
1098                    semantic_memory::GraphEdgeType::Semantic { .. } => "semantic",
1099                    semantic_memory::GraphEdgeType::Temporal { .. } => "temporal",
1100                    semantic_memory::GraphEdgeType::Causal { .. } => "causal",
1101                    semantic_memory::GraphEdgeType::Entity { .. } => "entity",
1102                };
1103                semantic_memory::discord::GraphEdgeRef {
1104                    source: edge.source.clone(),
1105                    target: edge.target.clone(),
1106                    edge_type: type_str.to_string(),
1107                    weight: edge.weight,
1108                }
1109            })
1110            .collect(),
1111        Err(e) => {
1112            return (
1113                "500 Internal Server Error",
1114                serde_json::json!({"ok": false, "error": format!("failed to load graph edges: {e}")}),
1115            )
1116        }
1117    };
1118
1119    let edges_loaded = edges.len();
1120    let scorer = DiscordScorer::with_defaults();
1121    let discord_hits = scorer.score(&direct_ids, &edges);
1122
1123    // Filter out items already in direct_ids
1124    let existing: std::collections::HashSet<String> = direct_ids.iter().cloned().collect();
1125    let filtered_hits: Vec<serde_json::Value> = discord_hits
1126        .iter()
1127        .filter(|hit| !existing.contains(&hit.item_id))
1128        .map(|hit| {
1129            // Fetch the fact's content directly from the DB.
1130            let bare_id = hit.item_id.strip_prefix("fact:").unwrap_or(&hit.item_id);
1131            let (content, namespace) = {
1132                let fact_result = handle.block_on(store.get_fact(bare_id));
1133                match fact_result {
1134                    Ok(Some(fact)) => (fact.content, fact.namespace),
1135                    _ => (String::new(), String::new()),
1136                }
1137            };
1138            serde_json::json!({
1139                "result_id": hit.item_id,
1140                "content": content,
1141                "namespace": namespace,
1142                "discord_score": hit.discord_score,
1143                "anchor_ids": hit.anchor_ids,
1144                "relationship_types": hit.relationship_types,
1145            })
1146        })
1147        .collect();
1148
1149    (
1150        "200 OK",
1151        serde_json::json!({
1152            "ok": true,
1153            "discord_results": filtered_hits,
1154            "count": filtered_hits.len(),
1155            "edges_loaded": edges_loaded,
1156            "direct_ids": direct_ids,
1157        }),
1158    )
1159}
1160
1161// ---------------------------------------------------------------------------
1162// Maintenance endpoints — for hooks and cron jobs to trigger auto-management
1163// without needing MCP tools to be visible (they're hidden in lean profile).
1164// ---------------------------------------------------------------------------
1165
1166/// Handle POST /maintenance/check: returns embeddings_are_dirty + verify_integrity(Quick)
1167/// in one call. This is the "health check" for auto-management.
1168fn handle_maintenance_check(
1169    bridge: &MemoryBridge,
1170    handle: &Handle,
1171) -> (&'static str, serde_json::Value) {
1172    let store = &bridge.store;
1173    let embeddings_dirty = block_in_place(|| handle.block_on(store.embeddings_are_dirty()));
1174    let embeddings_dirty = match embeddings_dirty {
1175        Ok(v) => v,
1176        Err(e) => {
1177            return (
1178                "500 Internal Server Error",
1179                serde_json::json!({"ok": false, "error": format!("embeddings_are_dirty error: {e}")}),
1180            )
1181        }
1182    };
1183    let integrity_result = block_in_place(|| {
1184        handle.block_on(store.verify_integrity(semantic_memory::VerifyMode::Quick))
1185    });
1186
1187    match integrity_result {
1188        Ok(report) => (
1189            "200 OK",
1190            serde_json::json!({
1191                "ok": report.ok,
1192                "embeddings_are_dirty": embeddings_dirty,
1193                "integrity": {
1194                    "ok": report.ok,
1195                    "schema_version": report.schema_version,
1196                    "fact_count": report.fact_count,
1197                    "chunk_count": report.chunk_count,
1198                    "message_count": report.message_count,
1199                    "facts_missing_embeddings": report.facts_missing_embeddings,
1200                    "chunks_missing_embeddings": report.chunks_missing_embeddings,
1201                    "issues": report.issues,
1202                    "issue_count": report.issues.len(),
1203                },
1204                "message": if report.ok && !embeddings_dirty {
1205                    "All checks passed".to_string()
1206                } else if report.ok && embeddings_dirty {
1207                    "Integrity OK but embeddings need re-embedding".to_string()
1208                } else {
1209                    format!("{} integrity issues found", report.issues.len())
1210                },
1211            }),
1212        ),
1213        Err(e) => (
1214            "500 Internal Server Error",
1215            serde_json::json!({
1216                "ok": false,
1217                "embeddings_are_dirty": embeddings_dirty,
1218                "error": format!("verify_integrity error: {e}"),
1219            }),
1220        ),
1221    }
1222}
1223
1224/// Handle POST /maintenance/vacuum: calls store.vacuum(). Returns ok.
1225fn handle_maintenance_vacuum(
1226    bridge: &MemoryBridge,
1227    handle: &Handle,
1228) -> (&'static str, serde_json::Value) {
1229    let store = &bridge.store;
1230    let result = block_in_place(|| handle.block_on(store.vacuum()));
1231
1232    match result {
1233        Ok(()) => (
1234            "200 OK",
1235            serde_json::json!({"ok": true, "action": "vacuum", "message": "Database vacuumed successfully"}),
1236        ),
1237        Err(e) => (
1238            "500 Internal Server Error",
1239            serde_json::json!({"ok": false, "error": format!("vacuum error: {e}")}),
1240        ),
1241    }
1242}
1243
1244/// Handle POST /maintenance/reembed: calls store.reembed_all(). Returns count.
1245/// This is expensive so the handler just calls it and returns the count.
1246fn handle_maintenance_reembed(
1247    bridge: &MemoryBridge,
1248    handle: &Handle,
1249) -> (&'static str, serde_json::Value) {
1250    let store = &bridge.store;
1251    let result = block_in_place(|| handle.block_on(store.reembed_all()));
1252
1253    match result {
1254        Ok(count) => (
1255            "200 OK",
1256            serde_json::json!({"ok": true, "action": "reembed", "reembedded_count": count, "message": format!("Re-embedded {count} items")}),
1257        ),
1258        Err(e) => (
1259            "500 Internal Server Error",
1260            serde_json::json!({"ok": false, "error": format!("reembed_all error: {e}")}),
1261        ),
1262    }
1263}
1264
1265/// Handle POST /maintenance/reconcile: accepts {"action": "ReportOnly"|"RebuildFts"|"ReEmbed"}.
1266/// Calls store.reconcile(action). Returns the IntegrityReport.
1267fn handle_maintenance_reconcile(
1268    body: &str,
1269    bridge: &MemoryBridge,
1270    handle: &Handle,
1271) -> (&'static str, serde_json::Value) {
1272    let params: serde_json::Value = match serde_json::from_str(body) {
1273        Ok(v) => v,
1274        Err(e) => {
1275            return (
1276                "400 Bad Request",
1277                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
1278            )
1279        }
1280    };
1281
1282    let action_str = params
1283        .get("action")
1284        .and_then(|v| v.as_str())
1285        .unwrap_or("ReportOnly");
1286
1287    let action = match action_str {
1288        "RebuildFts" => semantic_memory::ReconcileAction::RebuildFts,
1289        "ReEmbed" => semantic_memory::ReconcileAction::ReEmbed,
1290        _ => semantic_memory::ReconcileAction::ReportOnly,
1291    };
1292
1293    let store = &bridge.store;
1294    let result = block_in_place(|| handle.block_on(store.reconcile(action)));
1295
1296    match result {
1297        Ok(report) => (
1298            "200 OK",
1299            serde_json::json!({
1300                "ok": report.ok,
1301                "action": "reconcile",
1302                "reconcile_action": action_str,
1303                "integrity": {
1304                    "ok": report.ok,
1305                    "schema_version": report.schema_version,
1306                    "fact_count": report.fact_count,
1307                    "chunk_count": report.chunk_count,
1308                    "message_count": report.message_count,
1309                    "facts_missing_embeddings": report.facts_missing_embeddings,
1310                    "chunks_missing_embeddings": report.chunks_missing_embeddings,
1311                    "issues": report.issues,
1312                    "issue_count": report.issues.len(),
1313                },
1314                "message": if report.ok {
1315                    "Reconciliation completed, no issues found".to_string()
1316                } else {
1317                    format!("Reconciliation completed with {} issues", report.issues.len())
1318                },
1319            }),
1320        ),
1321        Err(e) => (
1322            "500 Internal Server Error",
1323            serde_json::json!({"ok": false, "error": format!("reconcile error: {e}")}),
1324        ),
1325    }
1326}
1327
1328/// Handle POST /maintenance/compact-hnsw: calls store.compact_hnsw(). Returns ok.
1329///
1330/// Only available when the `hnsw` feature is enabled. The default backend is
1331/// usearch, so this endpoint returns a not-applicable response without the feature.
1332fn handle_maintenance_compact_hnsw(
1333    bridge: &MemoryBridge,
1334    handle: &Handle,
1335) -> (&'static str, serde_json::Value) {
1336    #[cfg(feature = "hnsw")]
1337    {
1338        let store = &bridge.store;
1339        let result = block_in_place(|| handle.block_on(store.compact_hnsw()));
1340
1341        return match result {
1342            Ok(()) => (
1343                "200 OK",
1344                serde_json::json!({"ok": true, "action": "compact-hnsw", "message": "HNSW index compacted successfully"}),
1345            ),
1346            Err(e) => (
1347                "500 Internal Server Error",
1348                serde_json::json!({"ok": false, "error": format!("compact_hnsw error: {e}")}),
1349            ),
1350        };
1351    }
1352
1353    #[cfg(not(feature = "hnsw"))]
1354    {
1355        let _ = bridge;
1356        let _ = handle;
1357        (
1358            "200 OK",
1359            serde_json::json!({
1360                "ok": true,
1361                "action": "compact-hnsw",
1362                "message": "HNSW compaction not applicable — usearch backend does not require compaction",
1363                "skipped": true,
1364            }),
1365        )
1366    }
1367}
1368
1369/// Handle POST /maintenance/auto-edge: automatically create entity edges
1370/// between facts in DIFFERENT namespaces that share 2+ meaningful terms.
1371///
1372/// Accepts optional JSON body:
1373///   - batch_size (default 500): facts per page when iterating
1374///   - max_edges_per_fact (default 20): cap edges per source fact to prevent explosion
1375///   - dry_run (default false): if true, report what would be created without creating edges
1376///
1377/// Returns a report with facts_processed, edges_created, edges_skipped, time_elapsed.
1378fn handle_maintenance_auto_edge(
1379    body: &str,
1380    bridge: &MemoryBridge,
1381    handle: &Handle,
1382) -> (&'static str, serde_json::Value) {
1383    let start = std::time::Instant::now();
1384
1385    let params: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::json!({}));
1386    let batch_size = params.get("batch_size").and_then(|v| v.as_u64()).unwrap_or(500) as usize;
1387    let max_edges_per_fact =
1388        params.get("max_edges_per_fact").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
1389    let dry_run = params.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false);
1390    let rebuild = params.get("rebuild").and_then(|v| v.as_bool()).unwrap_or(false);
1391
1392    let store = &bridge.store;
1393
1394    // If rebuild mode, invalidate ALL existing entity edges first.
1395    let mut edges_invalidated: u64 = 0;
1396    if rebuild && !dry_run {
1397        let all_edges = block_in_place(|| handle.block_on(store.list_all_graph_edges()));
1398        if let Ok(edges) = all_edges {
1399            for edge in &edges {
1400                let parsed = edge.edge_type_parsed.clone().or_else(|| {
1401                    serde_json::from_str::<semantic_memory::GraphEdgeType>(&edge.edge_type).ok()
1402                });
1403                if matches!(parsed, Some(semantic_memory::GraphEdgeType::Entity { .. })) {
1404                    let _ = block_in_place(|| {
1405                        handle.block_on(store.invalidate_graph_edge(&edge.id, "auto-edge rebuild"))
1406                    });
1407                    edges_invalidated += 1;
1408                }
1409            }
1410        }
1411        eprintln!("[auto-edge] rebuild: invalidated {edges_invalidated} existing entity edges");
1412    }
1413
1414    // Namespaces to SKIP entirely — social media noise, not technical knowledge.
1415    const SKIP_NAMESPACES: &[&str] = &[
1416        "mixed", "chatgpt", "twitter", "tool-receipts", "agentguard",
1417    ];
1418    let skip_ns: std::collections::HashSet<&str> = SKIP_NAMESPACES.iter().copied().collect();
1419
1420    // Large stopword list — common English words that should never create edges.
1421    const STOPWORDS: &[&str] = &[
1422        "the", "and", "for", "are", "but", "not", "you", "all", "can", "her", "was", "one",
1423        "our", "out", "has", "have", "had", "his", "how", "its", "may", "new", "now", "old",
1424        "see", "him", "way", "who", "did", "yes", "yet", "say", "she", "too", "use", "via",
1425        "any", "few", "get", "got", "let", "put", "run", "set", "try", "two", "bad", "big",
1426        "far", "off", "own", "per", "sub", "top", "end", "add", "also", "been",
1427        "from", "into", "that", "this", "with", "will", "your", "they", "them", "were",
1428        "what", "when", "where", "which", "while", "there", "their", "about", "after",
1429        "before", "between", "because", "being", "would", "could", "should", "than",
1430        "then", "these", "those", "only", "over", "under", "again", "more", "most", "some",
1431        "such", "very", "just", "like", "even", "back", "both", "down", "here", "make",
1432        "made", "each", "want", "need", "know", "same", "other", "many", "much", "last",
1433        "first", "third", "next", "best", "main", "full", "upon", "within",
1434        "without", "through", "during", "above", "below", "against", "among",
1435        "across", "behind", "beside", "beyond",
1436        // Common technical/process words that create false connections
1437        "project", "work", "thing", "things", "fix", "fixed", "build", "built", "code",
1438        "data", "system", "update", "updated", "check", "checked", "test", "tested",
1439        "error", "issue", "problem", "result", "results", "status", "state", "info",
1440        "note", "notes", "list", "item", "items", "type", "types", "field", "fields",
1441        "name", "names", "value", "values", "line", "lines", "file", "files",
1442        "part", "parts", "section", "sections", "step", "steps", "task", "tasks",
1443        "goal", "goals", "plan", "plans", "done", "open", "close", "closed",
1444        "start", "started", "stop", "stopped", "change", "changed", "changing",
1445        "good", "bad", "right", "wrong", "true", "false", "yes", "no", "ok",
1446        "lots", "lot", "really", "actually", "basically", "probably", "maybe",
1447        "going", "getting", "looking", "trying", "working", "something", "anything",
1448        "everything", "nothing", "someone", "anyone", "everyone",
1449        "still", "always", "never", "sometimes", "usually", "often",
1450        "since", "until", "though", "although", "however", "therefore",
1451        "either", "neither", "both", "all", "any", "some", "none",
1452        "version", "config", "setup", "install", "installed", "running",
1453        "report", "reports", "summary", "detail", "details",
1454        "feature", "features", "function", "functions", "method", "methods",
1455        "class", "classes", "module", "modules", "crate", "crates",
1456        "package", "packages", "library", "libraries", "app", "apps",
1457        "web", "page", "pages", "site", "sites", "link", "links",
1458        "post", "posts", "reply", "replies", "comment", "comments",
1459        "read", "write", "call", "called", "calling", "return", "returns",
1460        "input", "output", "source", "target", "source_id", "target_id",
1461        "create", "created", "creating", "delete", "deleted", "removing",
1462        "find", "found", "finding", "search", "searching", "replace", "replaced",
1463        "show", "shown", "showing", "hide", "hidden", "display", "rendered",
1464        "enable", "enabled", "disable", "disabled", "allow", "allowed",
1465        "require", "required", "requiring", "support", "supported",
1466        "default", "custom", "general", "specific", "standard",
1467        "current", "latest", "previous", "old", "new", "future",
1468        "high", "low", "medium", "critical", "normal", "minor", "major",
1469        "single", "multiple", "total", "count", "number",
1470        "description", "summary", "overview", "introduction", "conclusion",
1471        "todo", "fixme", "wip", "draft", "final", "complete", "completed",
1472        "include", "includes", "included", "exclude", "excludes", "excluded",
1473        "require", "requires", "required", "optional",
1474        "important", "urgent", "priority", "blocking",
1475        "question", "answer", "response", "request",
1476    ];
1477    let stopword_set: std::collections::HashSet<&str> = STOPWORDS.iter().copied().collect();
1478
1479    /// Extract HIGH-QUALITY terms from text: only proper nouns, camelCase/snake_case
1480    /// identifiers, and words 5+ chars that aren't stopwords. This filters out
1481    /// common English words that create false connections.
1482    fn extract_terms(text: &str, stopwords: &std::collections::HashSet<&str>) -> std::collections::HashSet<String> {
1483        let mut terms = std::collections::HashSet::new();
1484        for word in text.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') {
1485            let w = word.trim_matches(|c: char| c == '-' || c == '_');
1486            if w.len() < 3 {
1487                continue;
1488            }
1489            let lower = w.to_lowercase();
1490            if stopwords.contains(lower.as_str()) {
1491                continue;
1492            }
1493            // Skip pure numbers
1494            if lower.chars().all(|c| c.is_ascii_digit()) {
1495                continue;
1496            }
1497            // Include if:
1498            // - camelCase or snake_case (has internal uppercase or _ or -)
1499            // - all lowercase and 5+ chars (filters short common words)
1500            // - starts with uppercase (proper noun)
1501            let has_separator = w.contains('_') || w.contains('-');
1502            let has_upper = w.chars().any(|c| c.is_uppercase());
1503            let starts_upper = w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false);
1504            let long_enough = lower.len() >= 5;
1505
1506            if has_separator || has_upper || starts_upper || long_enough {
1507                terms.insert(lower);
1508            }
1509        }
1510        terms
1511    }
1512
1513    // 1. Get all namespaces that contain facts
1514    let namespaces = match block_in_place(|| handle.block_on(store.list_fact_namespaces())) {
1515        Ok(ns) => ns,
1516        Err(e) => {
1517            return (
1518                "500 Internal Server Error",
1519                serde_json::json!({"ok": false, "error": format!("list_fact_namespaces error: {e}")}),
1520            )
1521        }
1522    };
1523
1524    // Filter out skip namespaces
1525    let namespaces: Vec<String> = namespaces
1526        .into_iter()
1527        .filter(|ns| !skip_ns.contains(ns.as_str()))
1528        .collect();
1529
1530    // 2. Page through all facts in each namespace, extract terms, store (id, namespace, terms)
1531    struct FactInfo {
1532        id: String,
1533        namespace: String,
1534        terms: std::collections::HashSet<String>,
1535    }
1536
1537    let mut all_facts: Vec<FactInfo> = Vec::new();
1538    for ns in &namespaces {
1539        let mut offset = 0usize;
1540        loop {
1541            let batch = match block_in_place(|| {
1542                handle.block_on(store.list_facts(ns, batch_size, offset))
1543            }) {
1544                Ok(facts) => facts,
1545                Err(e) => {
1546                    eprintln!("[auto-edge] list_facts error for ns={ns} offset={offset}: {e}");
1547                    break;
1548                }
1549            };
1550            if batch.is_empty() {
1551                break;
1552            }
1553            for fact in &batch {
1554                let terms = extract_terms(&fact.content, &stopword_set);
1555                // Skip facts with fewer than 3 quality terms — they can't form meaningful edges
1556                if terms.len() >= 3 {
1557                    all_facts.push(FactInfo {
1558                        id: fact.id.clone(),
1559                        namespace: fact.namespace.clone(),
1560                        terms,
1561                    });
1562                }
1563            }
1564            offset += batch.len();
1565            if batch.len() < batch_size {
1566                break;
1567            }
1568        }
1569    }
1570
1571    let facts_processed = all_facts.len();
1572
1573    // 3. Load existing edges to skip pairs that already have edges.
1574    let existing_edges: std::collections::HashSet<(String, String)> =
1575        match block_in_place(|| handle.block_on(store.list_all_graph_edges())) {
1576            Ok(edges) => edges
1577                .iter()
1578                .filter_map(|e| {
1579                    let parsed = e.edge_type_parsed.clone().or_else(|| {
1580                        serde_json::from_str::<semantic_memory::GraphEdgeType>(&e.edge_type)
1581                            .ok()
1582                    });
1583                    match parsed {
1584                        Some(semantic_memory::GraphEdgeType::Entity { .. }) => {
1585                            Some((e.source.clone(), e.target.clone()))
1586                        }
1587                        _ => None,
1588                    }
1589                })
1590                .collect(),
1591            Err(_) => std::collections::HashSet::new(),
1592        };
1593
1594    // 4. For each pair of facts in DIFFERENT namespaces sharing 3+ terms, create entity edge.
1595    // Higher threshold (3 instead of 2) + quality term extraction = much fewer, better edges.
1596    let mut edges_created: u64 = 0;
1597    let mut edges_skipped: u64 = 0;
1598    let mut edge_counts: std::collections::HashMap<String, u64> =
1599        std::collections::HashMap::new();
1600
1601    for i in 0..all_facts.len() {
1602        let fact_i = &all_facts[i];
1603        let src_id = format!("fact:{}", fact_i.id);
1604
1605        // Check if this fact has already hit its edge cap
1606        if *edge_counts.get(&src_id).unwrap_or(&0) >= max_edges_per_fact as u64 {
1607            continue;
1608        }
1609
1610        for j in (i + 1)..all_facts.len() {
1611            let fact_j = &all_facts[j];
1612
1613            // Only create edges between DIFFERENT namespaces
1614            if fact_i.namespace == fact_j.namespace {
1615                continue;
1616            }
1617
1618            // Check shared terms (need 3+ for high quality)
1619            let shared: usize = fact_i.terms.intersection(&fact_j.terms).count();
1620            if shared < 3 {
1621                continue;
1622            }
1623
1624            let tgt_id = format!("fact:{}", fact_j.id);
1625
1626            // Determine direction (smaller id first for consistency)
1627            let (edge_source, edge_target) = if src_id <= tgt_id {
1628                (src_id.clone(), tgt_id.clone())
1629            } else {
1630                (tgt_id.clone(), src_id.clone())
1631            };
1632
1633            // Skip if edge already exists
1634            if existing_edges.contains(&(edge_source.clone(), edge_target.clone())) {
1635                edges_skipped += 1;
1636                continue;
1637            }
1638
1639            // Check edge cap for both facts
1640            let count_src = *edge_counts.get(&src_id).unwrap_or(&0);
1641            let count_tgt = *edge_counts.get(&tgt_id).unwrap_or(&0);
1642            if count_src >= max_edges_per_fact as u64
1643                || count_tgt >= max_edges_per_fact as u64
1644            {
1645                continue;
1646            }
1647
1648            if dry_run {
1649                edges_created += 1;
1650                continue;
1651            }
1652
1653            let relation = format!("shared_terms:{}", shared);
1654            let edge_type = semantic_memory::GraphEdgeType::Entity {
1655                relation: relation.clone(),
1656            };
1657            // Weight based on overlap quality: 3 terms = 0.3, up to 1.0 for 10+ terms
1658            let weight = (shared.min(10) as f64) / 10.0;
1659
1660            let result = block_in_place(|| {
1661                handle.block_on(store.add_graph_edge(
1662                    &edge_source,
1663                    &edge_target,
1664                    edge_type,
1665                    weight,
1666                    None,
1667                ))
1668            });
1669
1670            match result {
1671                Ok(_) => {
1672                    edges_created += 1;
1673                    *edge_counts.entry(src_id.clone()).or_insert(0) += 1;
1674                    *edge_counts.entry(tgt_id.clone()).or_insert(0) += 1;
1675                }
1676                Err(e) => {
1677                    eprintln!(
1678                        "[auto-edge] add_graph_edge error: {e} for {edge_source} -> {edge_target}"
1679                    );
1680                }
1681            }
1682
1683            // Re-check cap after creating edge
1684            if *edge_counts.get(&src_id).unwrap_or(&0) >= max_edges_per_fact as u64 {
1685                break;
1686            }
1687        }
1688    }
1689
1690    let elapsed = start.elapsed();
1691
1692    (
1693        "200 OK",
1694        serde_json::json!({
1695            "ok": true,
1696            "action": "auto-edge",
1697            "dry_run": dry_run,
1698            "facts_processed": facts_processed,
1699            "namespaces_scanned": namespaces.len(),
1700            "edges_created": edges_created,
1701            "edges_skipped": edges_skipped,
1702            "max_edges_per_fact": max_edges_per_fact,
1703            "time_elapsed_ms": elapsed.as_millis(),
1704            "time_elapsed_secs": (elapsed.as_millis() as f64) / 1000.0,
1705        }),
1706    )
1707}