1use 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#[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 let paths = AppPaths::resolve(args.db.as_deref())?;
57 crate::storage::connection::ensure_db_ready(&paths)?;
58 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 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 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
93async 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 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 let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
130
131 if vec_degraded {
136 tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
137 }
138
139 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 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 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 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 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 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 let conn = open_ro(&paths.db)?;
263 let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
264
265 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 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 let key = format!("{}->{}", chain.from, chain.to);
301 if seen_chain_keys.insert(key) {
302 evidence_chains.push(chain);
303 }
304 }
305 }
306
307 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 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 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 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;