Skip to main content

sqlite_graphrag/commands/deep_research/
mod.rs

1//! Handler for the `deep-research` CLI subcommand.
2//!
3//! Orchestrates parallel multi-hop GraphRAG search via query decomposition.
4//! The workload is I/O-bound (SQLite WAL reads), so tokio is used instead of
5//! rayon. Each sub-query opens its own read-only connection.
6//!
7//! [`args`] holds the CLI surface and [`envelope`] the serialisation shapes;
8//! [`pipeline`] decomposes the query and executes each sub-query. The fan-out
9//! orchestration itself stays here.
10
11use crate::errors::AppError;
12use crate::output;
13use crate::paths::AppPaths;
14use crate::storage::connection::open_ro;
15use crate::storage::{entities, memories};
16
17use serde::Serialize;
18
19use std::collections::HashSet;
20use std::sync::Arc;
21use tokio::sync::Semaphore;
22use tokio::task::JoinSet;
23
24mod args;
25mod envelope;
26mod pipeline;
27
28pub use args::DeepResearchArgs;
29use envelope::{
30    DeepResearchResponse, DeepResult, GraphContext, GraphContextEntity, GraphContextRel, MergedHit,
31    ResearchStats,
32};
33pub(super) use envelope::{EvidenceChain, EvidenceNode, SubQuery, SubQueryResult};
34use pipeline::{compute_sub_embeddings, execute_sub_query, resolve_sub_queries};
35
36#[cfg(test)]
37use pipeline::{decompose_query, decompose_query_with_sources};
38
39/// Sync entry point — builds a tokio runtime for the async fan-out.
40#[tracing::instrument(skip_all, level = "debug", name = "deep_research")]
41pub fn run(
42    args: DeepResearchArgs,
43    llm_backend: crate::cli::LlmBackendChoice,
44    embedding_backend: crate::cli::EmbeddingBackendChoice,
45    fail_on_degraded: bool,
46) -> Result<(), AppError> {
47    tracing::debug!(target: "deep_research", query = %args.query, k = args.k, "starting deep research");
48
49    // GAP-001 (v1.1.04): resolve embeddings for every sub-query BEFORE the
50    // multi-thread runtime is built. `compute_sub_embeddings` calls the
51    // OpenRouter REST path, which internally does
52    // `shared_runtime()?.block_on(...)`; running that inside the worker
53    // threads of the runtime created below panics with
54    // "Cannot start a runtime from within a runtime". Doing the work
55    // synchronously here removes the nesting entirely.
56    let paths = AppPaths::resolve(args.db.as_deref())?;
57    crate::storage::connection::ensure_db_ready(&paths)?;
58    // Resolve sub-queries once (shared by embedding precompute + fan-out).
59    let sub_query_plan = resolve_sub_queries(&args)?;
60    let sub_query_texts: Vec<String> = sub_query_plan.iter().map(|s| s.text.clone()).collect();
61    let (sub_embeddings, vec_degraded, degraded_reason_code) =
62        compute_sub_embeddings(&paths, &sub_query_texts, embedding_backend, llm_backend);
63    // Decide ANTES de construir o runtime e de gastar o fan-out inteiro: sem isto a
64    // pesquisa devolvia só-FTS com exit 0 e a flag era placebo. Falhar aqui também
65    // evita pagar as chamadas de rede de uma pesquisa que o operador já declarou
66    // não querer em modo degradado.
67    if let Some(err) = crate::query_embedding::degradation_failure(
68        fail_on_degraded,
69        vec_degraded,
70        degraded_reason_code,
71    ) {
72        return Err(err);
73    }
74
75    // GAP-SG-141 B2: no explicit worker count. Tokio already defaults to the
76    // number of cores available to the process, and the fan-out below scales
77    // with the sub-query plan, so a fixed width could only under-serve it. The
78    // embed runtime keeps its own knob because it sizes a DIFFERENT reactor.
79    let rt = tokio::runtime::Builder::new_multi_thread()
80        .enable_all()
81        .build()
82        .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build tokio runtime: {e}")))?;
83    rt.block_on(run_async(
84        args,
85        llm_backend,
86        embedding_backend,
87        sub_query_plan,
88        sub_embeddings,
89        vec_degraded,
90    ))
91}
92
93/// Main async logic: decompose, fan-out, assemble, emit JSON.
94///
95/// `sub_embeddings` and `vec_degraded` are computed synchronously in
96/// [`run`] before the tokio runtime is built (GAP-001, v1.1.04) to avoid
97/// a nested-runtime panic on the OpenRouter embedding path.
98/// `sub_queries` is also resolved in [`run`] so embedding precompute and
99/// fan-out share one plan (v1.1.05).
100async fn run_async(
101    args: DeepResearchArgs,
102    _llm_backend: crate::cli::LlmBackendChoice,
103    _embedding_backend: crate::cli::EmbeddingBackendChoice,
104    sub_queries: Vec<SubQuery>,
105    sub_embeddings: Vec<Option<Arc<Vec<f32>>>>,
106    vec_degraded: bool,
107) -> Result<(), AppError> {
108    let start = std::time::Instant::now();
109
110    if args.query.trim().is_empty() {
111        return Err(AppError::Validation(crate::i18n::validation::empty_query()));
112    }
113
114    if args.max_cost_usd.is_some() {
115        // `--mode` accepts only `none` since v1.2.0, so the guard on the mode
116        // was dead and the message pointed at two backends the product removed.
117        // The flag stays accepted and stays inert; saying so is the whole point.
118        tracing::warn!(
119            target: "deep_research",
120            "--max-cost-usd is inert: deep-research has no LLM mode, so nothing is billed"
121        );
122    }
123
124    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
125    let paths = AppPaths::resolve(args.db.as_deref())?;
126    crate::storage::connection::ensure_db_ready(&paths)?;
127
128    // Phase 1: sub-queries already resolved in `run` (heuristic / manual / aspects).
129    let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
130
131    // GAP-001 (v1.1.04): sub-query embeddings were already resolved in
132    // `run` before the tokio runtime was built. Using them here keeps the
133    // OpenRouter REST path out of the worker threads (nested-runtime panic).
134    // `vec_degraded` reflects per-sub-query FTS5 fallback (GAP-DEEPRESEARCH-001).
135    if vec_degraded {
136        tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
137    }
138
139    // Phase 2: Fan-out — parallel sub-query execution.
140    let cpu_count = std::thread::available_parallelism()
141        .map(|n| n.get())
142        .unwrap_or(4);
143    let permits = args
144        .max_concurrency
145        .unwrap_or_else(|| cpu_count.min(8))
146        .min(sub_queries.len())
147        .max(1);
148    let semaphore = Arc::new(Semaphore::new(permits));
149    let timeout_dur = std::time::Duration::from_secs(args.timeout);
150
151    let mut join_set: JoinSet<Result<SubQueryResult, (usize, String)>> = JoinSet::new();
152
153    for (idx, sq_text) in sub_query_texts.iter().enumerate() {
154        let sem = Arc::clone(&semaphore);
155        // GAP-DEEPRESEARCH-001 FIX: pass Optional embedding (None = FTS5-only).
156        let emb = sub_embeddings[idx].clone();
157        let ns = namespace.clone();
158        let db_path = paths.db.clone();
159        let query_text = sq_text.clone();
160        let k = args.k;
161        let max_hops = args.max_hops;
162        let min_weight = args.min_weight;
163        let rrf_k = args.rrf_k;
164        let graph_decay = args.graph_decay;
165        let graph_min_score = args.graph_min_score;
166        let max_neighbors_per_hop = args.max_neighbors_per_hop;
167
168        join_set.spawn(async move {
169            let _permit = sem
170                .acquire_owned()
171                .await
172                .map_err(|e| (idx, format!("semaphore closed: {e}")))?;
173
174            // Dereference the Arc to obtain a &[f32] slice for the sync function.
175            let result = tokio::time::timeout(timeout_dur, async move {
176                execute_sub_query(
177                    idx,
178                    &query_text,
179                    emb.as_ref().map(|v| v.as_slice()),
180                    &ns,
181                    &db_path,
182                    k,
183                    max_hops,
184                    min_weight,
185                    rrf_k,
186                    graph_decay,
187                    graph_min_score,
188                    max_neighbors_per_hop,
189                )
190            })
191            .await;
192
193            match result {
194                Ok(inner) => inner.map_err(|e| (idx, e)),
195                Err(_) => Err((idx, "timeout".to_string())),
196            }
197        });
198    }
199
200    // Collect results incrementally.
201    let mut sub_query_results: Vec<SubQueryResult> = Vec::with_capacity(sub_queries.len());
202    let mut failed_count = 0usize;
203    let mut timed_out_count = 0usize;
204
205    while let Some(join_result) = join_set.join_next().await {
206        match join_result {
207            Ok(Ok(sqr)) => sub_query_results.push(sqr),
208            Ok(Err((_idx, reason))) => {
209                if reason == "timeout" {
210                    timed_out_count += 1;
211                } else {
212                    failed_count += 1;
213                }
214                tracing::warn!(target: "deep_research", sub_query_id = _idx, reason = %reason, "sub-query failed");
215            }
216            Err(join_err) => {
217                failed_count += 1;
218                if join_err.is_panic() {
219                    tracing::error!(target: "deep_research", error = %join_err, "sub-query task panicked");
220                } else {
221                    tracing::warn!(target: "deep_research", error = %join_err, "sub-query task cancelled");
222                }
223            }
224        }
225    }
226
227    // Phase 3: Evidence assembly — merge, dedup, rank.
228    // Aggregate hits: memory_id -> (best_score, source, snippet, body, hop_distance, sub_query_ids)
229    let mut merged: crate::hash::AHashMap<i64, MergedHit> =
230        crate::hash::AHashMap::with_capacity_and_hasher(
231            sub_query_results.len() * args.k,
232            Default::default(),
233        );
234
235    for sqr in &sub_query_results {
236        for (mem_id, score, source, snippet, body, hop) in &sqr.hits {
237            let entry = merged.entry(*mem_id).or_insert_with(|| {
238                (
239                    *score,
240                    source.clone(),
241                    snippet.clone(),
242                    body.clone(),
243                    *hop,
244                    Vec::new(),
245                )
246            });
247            // Keep best score.
248            if *score > entry.0 {
249                entry.0 = *score;
250                entry.1 = source.clone();
251                entry.2 = snippet.clone();
252                entry.3 = body.clone();
253                entry.4 = *hop;
254            }
255            if !entry.5.contains(&sqr.sub_query_id) {
256                entry.5.push(sqr.sub_query_id);
257            }
258        }
259    }
260
261    // Resolve memory names for merged results.
262    let conn = open_ro(&paths.db)?;
263    let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
264
265    // Sort by score descending.
266    let mut ranked: Vec<(i64, MergedHit)> = merged.into_iter().collect();
267    ranked.sort_by(|a, b| {
268        b.1 .0
269            .partial_cmp(&a.1 .0)
270            .unwrap_or(std::cmp::Ordering::Equal)
271    });
272    ranked.truncate(args.max_results);
273
274    for (mem_id, (score, source, snippet, body, hop, sq_ids)) in ranked {
275        let name = match memories::read_full(&conn, mem_id)? {
276            Some(row) => row.name,
277            None => continue,
278        };
279        results.push(DeepResult {
280            name,
281            score,
282            source,
283            sub_query_ids: sq_ids,
284            snippet,
285            body: if args.with_bodies { Some(body) } else { None },
286            hop_distance: hop,
287        });
288    }
289
290    // GAP-09/10 FIX: Collect evidence chains from reconstructed BFS paths.
291    // The old code appended flat node pairs from a global SELECT; now each
292    // sub-query returns directed EvidenceChain structs (from, to, path).
293    let completed_count = sub_query_results.len();
294    let mut evidence_chains: Vec<EvidenceChain> = Vec::with_capacity(completed_count * 2);
295    let mut seen_chain_keys: HashSet<String> = HashSet::with_capacity(completed_count * 2);
296
297    for sqr in sub_query_results {
298        for chain in sqr.chains {
299            // Deduplicate chains by (from, to) pair.
300            let key = format!("{}->{}", chain.from, chain.to);
301            if seen_chain_keys.insert(key) {
302                evidence_chains.push(chain);
303            }
304        }
305    }
306
307    // Sort evidence chains by total_weight descending, discard single-hop trivial chains.
308    evidence_chains.retain(|c| c.depth >= 2);
309    evidence_chains.sort_by(|a, b| {
310        b.total_weight
311            .partial_cmp(&a.total_weight)
312            .unwrap_or(std::cmp::Ordering::Equal)
313    });
314
315    let unique_memories = results.len();
316    let evidence_count = evidence_chains.len();
317
318    // MEDIUM-01b: Build graph_context with entities and relationships from result memories.
319    let graph_context = if !results.is_empty() {
320        let result_names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
321        let mut ctx_entities: Vec<GraphContextEntity> = Vec::with_capacity(results.len());
322        let mut ctx_rels: Vec<GraphContextRel> = Vec::with_capacity(results.len() * 2);
323        let mut seen_entity_ids: crate::hash::AHashSet<i64> =
324            crate::hash::AHashSet::with_capacity_and_hasher(results.len(), Default::default());
325
326        for name in &result_names {
327            if let Ok(Some(eid)) = entities::find_entity_id(&conn, &namespace, name) {
328                if seen_entity_ids.insert(eid) {
329                    let etype: String = conn
330                        .query_row(
331                            "SELECT COALESCE(type,'concept') FROM entities WHERE id = ?1",
332                            rusqlite::params![eid],
333                            |r| r.get(0),
334                        )
335                        .unwrap_or_else(|_| "concept".to_string());
336                    let degree: u32 = conn
337                        .query_row(
338                            "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 OR target_id = ?1",
339                            rusqlite::params![eid],
340                            |r| r.get(0),
341                        )
342                        .unwrap_or(0);
343                    ctx_entities.push(GraphContextEntity {
344                        name: name.to_string(),
345                        entity_type: etype,
346                        degree,
347                    });
348                }
349            }
350        }
351
352        let entity_ids: Vec<i64> = seen_entity_ids.iter().copied().collect();
353        if entity_ids.len() >= 2 {
354            let placeholders: String = entity_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
355            let sql = format!(
356                "SELECT s.name, t.name, r.relation, r.weight \
357                 FROM relationships r \
358                 JOIN entities s ON s.id = r.source_id \
359                 JOIN entities t ON t.id = r.target_id \
360                 WHERE r.source_id IN ({placeholders}) AND r.target_id IN ({placeholders}) \
361                 LIMIT ?"
362            );
363            if let Ok(mut stmt) = conn.prepare(&sql) {
364                let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
365                    Vec::with_capacity(entity_ids.len() * 2 + 1);
366                for id in &entity_ids {
367                    params.push(Box::new(*id));
368                }
369                for id in &entity_ids {
370                    params.push(Box::new(*id));
371                }
372                params.push(Box::new(
373                    i64::try_from(crate::constants::K_DEEP_RESEARCH_GRAPH_EDGES_LIMIT)
374                        .unwrap_or(i64::MAX),
375                ));
376                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
377                    params.iter().map(|p| p.as_ref()).collect();
378                if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |r| {
379                    Ok((
380                        r.get::<_, String>(0)?,
381                        r.get::<_, String>(1)?,
382                        r.get::<_, String>(2)?,
383                        r.get::<_, f64>(3)?,
384                    ))
385                }) {
386                    for row in rows.flatten() {
387                        ctx_rels.push(GraphContextRel {
388                            from: row.0,
389                            to: row.1,
390                            relation: row.2,
391                            weight: row.3,
392                        });
393                    }
394                }
395            }
396        }
397
398        if ctx_entities.is_empty() {
399            None
400        } else {
401            Some(GraphContext {
402                entities: ctx_entities,
403                relationships: ctx_rels,
404            })
405        }
406    } else {
407        None
408    };
409
410    tracing::debug!(target: "deep_research",
411        total_results = results.len(),
412        total_chains = evidence_chains.len(),
413        "assembly complete"
414    );
415
416    // Phase 4: JSON output (stdout and/or atomic --output).
417    let response = DeepResearchResponse {
418        query: args.query,
419        sub_queries,
420        results,
421        evidence_chains,
422        graph_context,
423        stats: ResearchStats {
424            sub_queries_total: sub_query_texts.len(),
425            sub_queries_completed: completed_count,
426            sub_queries_failed: failed_count,
427            sub_queries_timed_out: timed_out_count,
428            unique_memories_found: unique_memories,
429            evidence_chains_found: evidence_count,
430            elapsed_ms: start.elapsed().as_millis() as u64,
431            vec_degraded,
432        },
433    };
434
435    if let Some(path) = args.output.as_ref() {
436        // v1.1.05 Bug 2: atomic write avoids truncated envelopes under SIGTERM /
437        // shell redirect races. Full envelope goes to the file; stdout gets a
438        // small confirmation so pipelines can still check exit 0 + path.
439        // v1.1.8 GAP-CLI-DR-01..03: short `-o` is registered; fail-fast if the
440        // path was requested and the final file is missing or empty.
441        crate::atomic_io::write_json_atomic(path, &response)?;
442        if !path.exists() {
443            return Err(AppError::Validation(
444                crate::i18n::validation::deep_research_output_missing(&path.display().to_string()),
445            ));
446        }
447        let meta = std::fs::metadata(path).map_err(AppError::Io)?;
448        if meta.len() == 0 {
449            return Err(AppError::Validation(
450                crate::i18n::validation::deep_research_output_empty(&path.display().to_string()),
451            ));
452        }
453        let file_bytes = std::fs::read(path).map_err(AppError::Io)?;
454        let digest = blake3::hash(&file_bytes).to_hex().to_string();
455        #[derive(Serialize)]
456        struct WrittenAck {
457            written: String,
458            bytes: u64,
459            blake3: String,
460            sub_queries_total: usize,
461            unique_memories_found: usize,
462            elapsed_ms: u64,
463        }
464        output::emit_json(&WrittenAck {
465            written: path.display().to_string(),
466            bytes: meta.len(),
467            blake3: digest,
468            sub_queries_total: response.stats.sub_queries_total,
469            unique_memories_found: response.stats.unique_memories_found,
470            elapsed_ms: response.stats.elapsed_ms,
471        })?;
472    } else {
473        output::emit_json(&response)?;
474    }
475
476    Ok(())
477}
478
479#[cfg(test)]
480mod tests;