Skip to main content

graphrag_cli/handlers/
graphrag.rs

1//! GraphRAG operations handler
2//!
3//! Provides a thread-safe wrapper around GraphRAG instance with async operations.
4
5use color_eyre::eyre::{eyre, Result};
6use graphrag_core::{persistence::WorkspaceManager, Config, Entity, GraphRAG};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use std::sync::Arc;
10use tokio::sync::Mutex;
11
12/// Statistics about the knowledge graph
13#[derive(Debug, Clone, Default)]
14pub struct GraphStats {
15    pub entities: usize,
16    pub relationships: usize,
17    pub documents: usize,
18    pub chunks: usize,
19}
20
21/// Source reference from query results
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct SourceReference {
24    pub id: String,
25    pub excerpt: String,
26    pub relevance_score: f32,
27}
28
29/// Reasoning step from query decomposition
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ReasoningStep {
32    pub step_number: u8,
33    pub description: String,
34}
35
36/// Explained query result with detailed information
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct QueryExplainedResult {
39    pub answer: String,
40    pub confidence: f32,
41    pub sources: Vec<SourceReference>,
42    pub reasoning_steps: Vec<ReasoningStep>,
43}
44
45/// Thread-safe GraphRAG handler
46#[derive(Clone)]
47pub struct GraphRAGHandler {
48    graphrag: Arc<Mutex<Option<GraphRAG>>>,
49}
50
51impl GraphRAGHandler {
52    /// Create a new GraphRAG handler
53    pub fn new() -> Self {
54        Self {
55            graphrag: Arc::new(Mutex::new(None)),
56        }
57    }
58
59    /// Check if GraphRAG is initialized
60    pub async fn is_initialized(&self) -> bool {
61        let guard = self.graphrag.lock().await;
62        guard.is_some()
63    }
64
65    /// Initialize GraphRAG with configuration
66    pub async fn initialize(&self, config: Config) -> Result<()> {
67        tracing::info!("Initializing GraphRAG with config");
68
69        let mut config = config;
70        // Suppress indicatif progress bars when running inside the TUI
71        // to avoid corrupting ratatui's raw-mode terminal.
72        config.suppress_progress_bars = true;
73
74        let mut graphrag = GraphRAG::new(config)?;
75        graphrag.initialize()?;
76
77        let mut guard = self.graphrag.lock().await;
78        *guard = Some(graphrag);
79
80        tracing::info!("GraphRAG initialized successfully");
81        Ok(())
82    }
83
84    /// Pre-flight check: verify required Ollama models are available.
85    ///
86    /// Queries Ollama's `/api/tags` (3-second timeout) and checks that every
87    /// model the current configuration needs is present. Returns a descriptive
88    /// error with `ollama pull` commands if any are missing, preventing the TUI
89    /// from freezing inside `build_graph()`.
90    async fn check_ollama_models(&self) -> Result<()> {
91        // Read the config under the lock, then drop it before doing IO.
92        let (needs_ollama, host, port, embedding_backend, embedding_model, chat_model) = {
93            let guard = self.graphrag.lock().await;
94            match guard.as_ref() {
95                Some(g) => {
96                    let c = g.config();
97                    (
98                        c.ollama.enabled,
99                        c.ollama.host.clone(),
100                        c.ollama.port,
101                        c.embeddings.backend.clone(),
102                        c.ollama.embedding_model.clone(),
103                        c.ollama.chat_model.clone(),
104                    )
105                },
106                None => return Ok(()), // not initialised – other code will handle this
107            }
108        };
109
110        // Determine which models we actually need.
111        let mut required: Vec<String> = Vec::new();
112        if embedding_backend == "ollama" {
113            required.push(embedding_model);
114        }
115        if needs_ollama {
116            required.push(chat_model);
117        }
118        // De-duplicate (e.g. if both fields point to the same model).
119        required.sort();
120        required.dedup();
121
122        if required.is_empty() {
123            return Ok(()); // purely algorithmic config, no Ollama needed
124        }
125
126        // Quick HTTP check with a short timeout.
127        let url = format!("{}:{}/api/tags", host, port);
128        let agent = ureq::AgentBuilder::new()
129            .timeout(std::time::Duration::from_secs(3))
130            .build();
131
132        let available: Vec<String> = match agent.get(&url).call() {
133            Ok(resp) => {
134                let body: serde_json::Value = resp
135                    .into_json()
136                    .unwrap_or_else(|_| serde_json::json!({"models": []}));
137                body["models"]
138                    .as_array()
139                    .map(|arr| {
140                        arr.iter()
141                            .filter_map(|m| m["name"].as_str().map(|s| s.to_string()))
142                            .collect()
143                    })
144                    .unwrap_or_default()
145            },
146            Err(e) => {
147                return Err(eyre!(
148                    "Cannot reach Ollama at {} — is it running?\nError: {}",
149                    url,
150                    e
151                ));
152            },
153        };
154
155        // Compare required vs available (Ollama names may include `:latest`).
156        let missing: Vec<&String> = required
157            .iter()
158            .filter(|req| {
159                !available.iter().any(|avail| {
160                    avail == req.as_str()
161                        || avail.starts_with(&format!("{}:", req))
162                        || req.ends_with(":latest") && avail == req.trim_end_matches(":latest")
163                })
164            })
165            .collect();
166
167        if !missing.is_empty() {
168            let pull_cmds: Vec<String> = missing
169                .iter()
170                .map(|m| format!("ollama pull {}", m))
171                .collect();
172            return Err(eyre!(
173                "Missing Ollama models: {}\n\nPull them first:\n  {}",
174                missing
175                    .iter()
176                    .map(|s| s.as_str())
177                    .collect::<Vec<_>>()
178                    .join(", "),
179                pull_cmds.join("\n  ")
180            ));
181        }
182
183        Ok(())
184    }
185
186    /// Load a document into the knowledge graph
187    ///
188    /// # Arguments
189    /// * `path` - Path to the document to load
190    /// * `rebuild` - If true, clears existing graph AND documents before loading (forces complete rebuild)
191    pub async fn load_document_with_options(&self, path: &Path, rebuild: bool) -> Result<String> {
192        // Pre-flight: check Ollama models BEFORE acquiring the long-held lock.
193        self.check_ollama_models().await?;
194
195        tracing::info!("Loading document: {:?} (rebuild: {})", path, rebuild);
196
197        // Read file asynchronously
198        let content = tokio::fs::read_to_string(path)
199            .await
200            .map_err(|e| eyre!("Failed to read file: {}", e))?;
201
202        let filename = path
203            .file_name()
204            .and_then(|n| n.to_str())
205            .unwrap_or("unknown")
206            .to_string();
207
208        // Add document and build graph with tokio::sync::Mutex
209        let mut guard = self.graphrag.lock().await;
210        if let Some(ref mut graphrag) = *guard {
211            // Clear graph AND documents if rebuild is requested (BEFORE adding new document)
212            if rebuild {
213                tracing::info!("Clearing existing graph and documents for rebuild");
214                // Re-initialize to clear everything including documents and chunks
215                graphrag.initialize()?;
216            }
217
218            // Add the document
219            graphrag.add_document_from_text(&content)?;
220
221            // Build graph asynchronously (async feature is always enabled in CLI)
222            graphrag.build_graph().await?;
223
224            let message = if rebuild {
225                format!(
226                    "Document '{}' loaded successfully (complete rebuild from scratch)",
227                    filename
228                )
229            } else {
230                format!("Document '{}' loaded successfully", filename)
231            };
232
233            Ok(message)
234        } else {
235            Err(eyre!("GraphRAG not initialized"))
236        }
237    }
238
239    /// Clear the knowledge graph (preserves documents and chunks)
240    pub async fn clear_graph(&self) -> Result<String> {
241        tracing::info!("Clearing knowledge graph");
242
243        let mut guard = self.graphrag.lock().await;
244        if let Some(ref mut graphrag) = *guard {
245            graphrag.clear_graph()?;
246            Ok("Knowledge graph cleared successfully. Entities and relationships removed, documents preserved.".to_string())
247        } else {
248            Err(eyre!("GraphRAG not initialized"))
249        }
250    }
251
252    /// Rebuild the knowledge graph from existing documents
253    ///
254    /// This clears the graph and re-extracts entities and relationships from all loaded documents.
255    /// Useful after changing configuration or to fix issues with the graph.
256    pub async fn rebuild_graph(&self) -> Result<String> {
257        tracing::info!("Rebuilding knowledge graph from existing documents");
258
259        let mut guard = self.graphrag.lock().await;
260        if let Some(ref mut graphrag) = *guard {
261            // Clear the existing graph
262            graphrag.clear_graph()?;
263
264            // Check if there are documents to rebuild from
265            if !graphrag.has_documents() {
266                return Err(eyre!(
267                    "No documents loaded. Use /load <file> to load a document first."
268                ));
269            }
270
271            // Rebuild the graph from existing documents
272            graphrag.build_graph().await?;
273
274            let stats = graphrag
275                .knowledge_graph()
276                .map(|kg| (kg.entities().count(), kg.relationships().count()))
277                .unwrap_or((0, 0));
278
279            Ok(format!(
280                "Knowledge graph rebuilt successfully. Extracted {} entities and {} relationships.",
281                stats.0, stats.1
282            ))
283        } else {
284            Err(eyre!("GraphRAG not initialized"))
285        }
286    }
287
288    /// Execute a query and return both LLM answer and raw search results
289    ///
290    /// Returns a tuple of (llm_answer, raw_results)
291    pub async fn query_with_raw(&self, query_text: &str) -> Result<(String, Vec<String>)> {
292        tracing::info!("Executing query with raw results: {}", query_text);
293
294        let mut guard = self.graphrag.lock().await;
295        if let Some(ref mut graphrag) = *guard {
296            // Get raw search results first
297            let raw_results = graphrag.query_internal(query_text).await?;
298
299            // Then get the LLM-processed answer
300            let answer = graphrag.ask(query_text).await?;
301
302            Ok((answer, raw_results))
303        } else {
304            Err(eyre!(
305                "GraphRAG not initialized. Use /config to load a configuration first."
306            ))
307        }
308    }
309
310    /// Execute a query and return explained answer with sources and confidence
311    ///
312    /// Returns detailed information including:
313    /// - Answer text
314    /// - Confidence score
315    /// - Source references with excerpts
316    /// - Reasoning steps
317    pub async fn query_explained(&self, query_text: &str) -> Result<QueryExplainedResult> {
318        tracing::info!("Executing explained query: {}", query_text);
319
320        let mut guard = self.graphrag.lock().await;
321        if let Some(ref mut graphrag) = *guard {
322            let explained = graphrag.ask_explained(query_text).await?;
323
324            Ok(QueryExplainedResult {
325                answer: explained.answer,
326                confidence: explained.confidence,
327                sources: explained
328                    .sources
329                    .into_iter()
330                    .map(|s| SourceReference {
331                        id: s.id,
332                        excerpt: s.excerpt,
333                        relevance_score: s.relevance_score,
334                    })
335                    .collect(),
336                reasoning_steps: explained
337                    .reasoning_steps
338                    .into_iter()
339                    .map(|s| ReasoningStep {
340                        step_number: s.step_number,
341                        description: s.description,
342                    })
343                    .collect(),
344            })
345        } else {
346            Err(eyre!(
347                "GraphRAG not initialized. Use /config to load a configuration first."
348            ))
349        }
350    }
351
352    /// Execute a query with reasoning (query decomposition)
353    ///
354    /// This splits complex queries into sub-queries, gathers context for all of them,
355    /// and synthesizes a comprehensive answer.
356    pub async fn query_with_reasoning(&self, query_text: &str) -> Result<String> {
357        tracing::info!("Executing query with reasoning: {}", query_text);
358
359        let mut guard = self.graphrag.lock().await;
360        if let Some(ref mut graphrag) = *guard {
361            let answer = graphrag.ask_with_reasoning(query_text).await?;
362            Ok(answer)
363        } else {
364            Err(eyre!(
365                "GraphRAG not initialized. Use /config to load a configuration first."
366            ))
367        }
368    }
369
370    /// Check if knowledge graph has documents loaded
371    #[allow(dead_code)]
372    pub async fn has_documents(&self) -> bool {
373        let guard = self.graphrag.lock().await;
374        guard.as_ref().map_or(false, |g| g.has_documents())
375    }
376
377    /// Check if knowledge graph is built
378    #[allow(dead_code)]
379    pub async fn has_graph(&self) -> bool {
380        let guard = self.graphrag.lock().await;
381        guard.as_ref().map_or(false, |g| g.has_graph())
382    }
383
384    /// Get knowledge graph statistics
385    pub async fn get_stats(&self) -> Option<GraphStats> {
386        let guard = self.graphrag.lock().await;
387        guard.as_ref().and_then(|g| {
388            g.knowledge_graph().map(|kg| GraphStats {
389                entities: kg.entities().count(),
390                relationships: kg.relationships().count(),
391                documents: kg.documents().count(),
392                chunks: kg.chunks().count(),
393            })
394        })
395    }
396
397    /// Get all entities, optionally filtered
398    pub async fn get_entities(&self, filter: Option<&str>) -> Result<Vec<Entity>> {
399        let guard = self.graphrag.lock().await;
400        if let Some(ref graphrag) = *guard {
401            if let Some(kg) = graphrag.knowledge_graph() {
402                let entities: Vec<Entity> = match filter {
403                    Some(f) => kg
404                        .entities()
405                        .filter(|e| {
406                            e.name.to_lowercase().contains(&f.to_lowercase())
407                                || e.entity_type.to_lowercase().contains(&f.to_lowercase())
408                        })
409                        .cloned()
410                        .collect(),
411                    None => kg.entities().cloned().collect(),
412                };
413                Ok(entities)
414            } else {
415                Err(eyre!("Knowledge graph not built yet"))
416            }
417        } else {
418            Err(eyre!("GraphRAG not initialized"))
419        }
420    }
421
422    /// Check if knowledge graph exists
423    #[allow(dead_code)]
424    pub async fn has_knowledge_graph(&self) -> bool {
425        let guard = self.graphrag.lock().await;
426        if let Some(ref graphrag) = *guard {
427            graphrag.knowledge_graph().is_some()
428        } else {
429            false
430        }
431    }
432
433    // ========= Workspace Operations =========
434
435    /// List all available workspaces
436    pub async fn list_workspaces(&self, workspace_dir: &str) -> Result<String> {
437        let workspace_manager = WorkspaceManager::new(workspace_dir)?;
438        let workspaces = workspace_manager.list_workspaces()?;
439
440        if workspaces.is_empty() {
441            return Ok(
442                "No workspaces found. Use /workspace save <name> to create one.".to_string(),
443            );
444        }
445
446        let mut output = format!("📁 Available Workspaces ({} total):\n\n", workspaces.len());
447
448        for (i, ws) in workspaces.iter().enumerate() {
449            output.push_str(&format!(
450                "{}. {} ({:.2} KB)\n",
451                i + 1,
452                ws.name,
453                ws.size_bytes as f64 / 1024.0
454            ));
455            output.push_str(&format!(
456                "   Entities: {}, Relationships: {}, Documents: {}, Chunks: {}\n",
457                ws.metadata.entity_count,
458                ws.metadata.relationship_count,
459                ws.metadata.document_count,
460                ws.metadata.chunk_count
461            ));
462            output.push_str(&format!(
463                "   Created: {}\n",
464                ws.metadata.created_at.format("%Y-%m-%d %H:%M:%S")
465            ));
466            if let Some(desc) = &ws.metadata.description {
467                output.push_str(&format!("   Description: {}\n", desc));
468            }
469            output.push('\n');
470        }
471
472        Ok(output)
473    }
474
475    /// Save current knowledge graph to workspace
476    pub async fn save_workspace(&self, workspace_dir: &str, name: &str) -> Result<String> {
477        let guard = self.graphrag.lock().await;
478        if let Some(ref graphrag) = *guard {
479            if let Some(kg) = graphrag.knowledge_graph() {
480                let workspace_manager = WorkspaceManager::new(workspace_dir)?;
481                workspace_manager.save_graph(kg, name)?;
482
483                let stats = (
484                    kg.entities().count(),
485                    kg.relationships().count(),
486                    kg.documents().count(),
487                    kg.chunks().count(),
488                );
489
490                Ok(format!(
491                    "✅ Workspace '{}' saved successfully!\n\n\
492                     Saved: {} entities, {} relationships, {} documents, {} chunks",
493                    name, stats.0, stats.1, stats.2, stats.3
494                ))
495            } else {
496                Err(eyre!(
497                    "No knowledge graph to save. Build a graph first with /load <file>"
498                ))
499            }
500        } else {
501            Err(eyre!("GraphRAG not initialized"))
502        }
503    }
504
505    /// Load knowledge graph from workspace
506    pub async fn load_workspace(&self, workspace_dir: &str, name: &str) -> Result<String> {
507        let workspace_manager = WorkspaceManager::new(workspace_dir)?;
508        let loaded_kg = workspace_manager.load_graph(name)?;
509
510        let stats = (
511            loaded_kg.entities().count(),
512            loaded_kg.relationships().count(),
513            loaded_kg.documents().count(),
514            loaded_kg.chunks().count(),
515        );
516
517        // Replace the current knowledge graph
518        let mut guard = self.graphrag.lock().await;
519        if let Some(ref mut graphrag) = *guard {
520            // Replace the knowledge graph using the mutable accessor
521            if let Some(kg_mut) = graphrag.knowledge_graph_mut() {
522                *kg_mut = loaded_kg;
523            } else {
524                return Err(eyre!("Knowledge graph not initialized. Use /config first."));
525            }
526
527            Ok(format!(
528                "✅ Workspace '{}' loaded successfully!\n\n\
529                 Loaded: {} entities, {} relationships, {} documents, {} chunks",
530                name, stats.0, stats.1, stats.2, stats.3
531            ))
532        } else {
533            Err(eyre!(
534                "GraphRAG not initialized. Use /config to load configuration first."
535            ))
536        }
537    }
538
539    /// Delete a workspace
540    pub async fn delete_workspace(&self, workspace_dir: &str, name: &str) -> Result<String> {
541        let workspace_manager = WorkspaceManager::new(workspace_dir)?;
542
543        // Confirm deletion (in TUI this would be a confirmation dialog)
544        workspace_manager.delete_workspace(name)?;
545
546        Ok(format!("✅ Workspace '{}' deleted successfully.", name))
547    }
548}
549
550impl Default for GraphRAGHandler {
551    fn default() -> Self {
552        Self::new()
553    }
554}