1use std::path::PathBuf;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use terraphim_types::Document;
6
7#[derive(Debug, Clone)]
8pub struct GrepOptions {
9 pub haystack: Haystack,
10 pub context_lines: usize,
11 pub max_results: usize,
12 pub force_rlm: bool,
13 pub include_answer: bool,
14}
15
16impl Default for GrepOptions {
17 fn default() -> Self {
18 Self {
19 haystack: Haystack::All,
20 context_lines: 0,
21 max_results: 50,
22 force_rlm: false,
23 include_answer: false,
24 }
25 }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum Haystack {
30 #[default]
31 Code,
32 Docs,
33 All,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RetrievedChunk {
38 pub content: String,
39 pub source: String,
40 pub line_start: Option<usize>,
41 pub line_end: Option<usize>,
42 pub relevance_score: f64,
43 pub haystack: &'static str,
44}
45
46impl From<Document> for RetrievedChunk {
47 fn from(doc: Document) -> Self {
48 Self {
49 content: doc.body,
50 source: doc.url,
51 line_start: None,
52 line_end: None,
53 relevance_score: doc.rank.unwrap_or(0) as f64,
54 haystack: "code",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct KgConcept {
61 pub id: u64,
62 pub name: String,
63 pub display_value: Option<String>,
64 pub score: f64,
65}
66
67#[derive(Debug, Clone)]
68pub struct HybridResults {
69 pub code_results: Vec<RetrievedChunk>,
70 pub doc_results: Vec<RetrievedChunk>,
71 pub kg_concepts: Vec<KgConcept>,
72}
73
74impl HybridResults {
75 pub fn to_chunks(&self) -> Vec<RetrievedChunk> {
76 let mut chunks = Vec::with_capacity(self.code_results.len() + self.doc_results.len());
77 chunks.extend(self.code_results.clone());
78 chunks.extend(self.doc_results.clone());
79 chunks
80 }
81
82 pub fn total_results(&self) -> usize {
83 self.code_results.len() + self.doc_results.len() + self.kg_concepts.len()
84 }
85
86 pub fn is_empty(&self) -> bool {
87 self.code_results.is_empty() && self.doc_results.is_empty() && self.kg_concepts.is_empty()
88 }
89}
90
91pub const DEFAULT_KG_BOOST_WEIGHT: f64 = 1.0;
95
96pub fn score_kg_boost(chunk: &RetrievedChunk, concepts: &[KgConcept], weight: f64) -> f64 {
107 if concepts.is_empty() || weight <= 0.0 {
108 return 0.0;
109 }
110 let max_concept_score: f64 = concepts.iter().map(|c| c.score).fold(0.0, f64::max);
111 if max_concept_score <= 0.0 {
112 return 0.0;
113 }
114 let source_lower = chunk.source.to_lowercase();
115 let content_lower = chunk.content.to_lowercase();
116
117 let mut boost = 0.0;
118 for c in concepts {
119 let needle = c
120 .display_value
121 .as_deref()
122 .unwrap_or(c.name.as_str())
123 .to_lowercase();
124 if needle.is_empty() {
125 continue;
126 }
127 if source_lower.contains(&needle) || content_lower.contains(&needle) {
128 boost += c.score / max_concept_score;
129 }
130 }
131 (boost * weight).min(weight * concepts.len() as f64)
132}
133
134pub fn boost_chunks_with_kg(
138 mut chunks: Vec<RetrievedChunk>,
139 concepts: &[KgConcept],
140) -> Vec<RetrievedChunk> {
141 for chunk in chunks.iter_mut() {
142 let boost = score_kg_boost(chunk, concepts, DEFAULT_KG_BOOST_WEIGHT);
143 chunk.relevance_score += boost;
144 }
145 chunks.sort_by(|a, b| {
146 b.relevance_score
147 .partial_cmp(&a.relevance_score)
148 .unwrap_or(std::cmp::Ordering::Equal)
149 });
150 chunks
151}
152
153pub struct HybridSearcher {
154 role_graph: Arc<tokio::sync::RwLock<terraphim_rolegraph::RoleGraph>>,
155 thesaurus: terraphim_types::Thesaurus,
160 search_paths: Vec<PathBuf>,
161}
162
163impl HybridSearcher {
164 pub fn new(
165 role_name: String,
166 thesaurus: terraphim_types::Thesaurus,
167 ) -> Result<Self, terraphim_rolegraph::Error> {
168 let rolegraph = terraphim_rolegraph::RoleGraph::new_sync(
169 terraphim_types::RoleName::new(&role_name),
170 thesaurus.clone(),
171 )?;
172
173 Ok(Self {
174 role_graph: Arc::new(tokio::sync::RwLock::new(rolegraph)),
175 thesaurus,
176 search_paths: vec![PathBuf::from(".")],
177 })
178 }
179
180 pub fn with_search_path(mut self, path: PathBuf) -> Self {
181 self.search_paths = vec![path];
182 self
183 }
184
185 pub fn with_search_paths(mut self, paths: Vec<PathBuf>) -> Self {
186 self.search_paths = paths;
187 self
188 }
189
190 pub async fn search(
191 &self,
192 query: &str,
193 options: &GrepOptions,
194 ) -> Result<HybridResults, String> {
195 let max_results = options.max_results;
196 let search_paths = self.search_paths.clone();
197 let role_graph = self.role_graph.clone();
198 let query_owned = query.to_string();
199
200 let thesaurus = self.thesaurus.clone();
201
202 let (kg_concepts, code_results) = match options.haystack {
203 Haystack::All | Haystack::Code => {
204 let kg_handle = tokio::spawn({
205 let query = query_owned.clone();
206 let graph = role_graph.clone();
207 let thes = thesaurus.clone();
208 async move { Self::search_kg(&query, max_results, graph, &thes).await }
209 });
210
211 let code_handle = tokio::spawn({
212 let query = query_owned.clone();
213 let paths = search_paths.clone();
214 async move {
215 let mut all_results = Vec::new();
216 for path in paths {
217 let mut results = Self::search_code(&query, max_results, path).await?;
218 all_results.append(&mut results);
219 }
220 Ok::<Vec<RetrievedChunk>, String>(all_results)
221 }
222 });
223
224 let kg_concepts = kg_handle
225 .await
226 .map_err(|e| format!("KG search join error: {}", e))??;
227 let code_results = code_handle
228 .await
229 .map_err(|e| format!("Code search join error: {}", e))??;
230 (kg_concepts, code_results)
231 }
232 Haystack::Docs => {
233 let kg_concepts =
234 Self::search_kg(&query_owned, max_results, role_graph.clone(), &thesaurus)
235 .await?;
236 (kg_concepts, vec![])
237 }
238 };
239
240 let code_results = boost_chunks_with_kg(code_results, &kg_concepts);
246 let code_results: Vec<RetrievedChunk> =
247 code_results.into_iter().take(max_results).collect();
248
249 Ok(HybridResults {
250 code_results,
251 doc_results: vec![],
252 kg_concepts,
253 })
254 }
255
256 async fn search_kg(
257 query: &str,
258 limit: usize,
259 graph: Arc<tokio::sync::RwLock<terraphim_rolegraph::RoleGraph>>,
260 thesaurus: &terraphim_types::Thesaurus,
261 ) -> Result<Vec<KgConcept>, String> {
262 let graph_guard = graph.read().await;
263
264 let matches = graph_guard
265 .query_graph_with_trigger_fallback(query, None, Some(limit), false)
266 .map_err(|e| e.to_string())?;
267
268 if !matches.is_empty() {
269 let concepts = matches
270 .into_iter()
271 .map(|(doc_id, indexed_doc)| KgConcept {
272 id: 0,
273 name: doc_id,
274 display_value: None,
275 score: indexed_doc.rank as f64,
276 })
277 .collect();
278 return Ok(concepts);
279 }
280
281 let query_lower = query.to_lowercase();
286 let mut concepts: Vec<KgConcept> = thesaurus
287 .keys()
288 .filter_map(|key| {
289 let key_str = key.as_str();
290 let key_lower = key_str.to_lowercase();
291 if query_lower.contains(&key_lower) || key_lower.contains(&query_lower) {
292 Some(KgConcept {
293 id: 0,
294 name: key_str.to_string(),
295 display_value: None,
296 score: 1.0,
297 })
298 } else {
299 None
300 }
301 })
302 .take(limit)
303 .collect();
304 concepts.sort_by(|a, b| a.name.cmp(&b.name));
306 Ok(concepts)
307 }
308
309 async fn search_code(
310 query: &str,
311 limit: usize,
312 search_path: PathBuf,
313 ) -> Result<Vec<RetrievedChunk>, String> {
314 #[cfg(feature = "code-search")]
315 {
316 use fff_search::{
317 FFFMode, FilePicker, FilePickerOptions, GrepMode, GrepSearchOptions,
318 parse_grep_query,
319 };
320
321 let mut picker = FilePicker::new(FilePickerOptions {
322 base_path: search_path.to_string_lossy().to_string(),
323 mode: FFFMode::Ai,
324 watch: false,
325 cache_budget: None,
326 ..FilePickerOptions::default()
327 })
328 .map_err(|e| format!("FilePicker init failed: {}", e))?;
329
330 picker
331 .collect_files()
332 .map_err(|e| format!("File scan failed: {}", e))?;
333
334 if picker.get_files().is_empty() {
335 return Ok(vec![]);
336 }
337
338 let fff_query = parse_grep_query(query);
339 let options = GrepSearchOptions {
340 max_file_size: 10 * 1024 * 1024,
341 max_matches_per_file: 200,
342 smart_case: true,
343 file_offset: 0,
344 page_limit: limit,
345 mode: GrepMode::PlainText,
346 time_budget_ms: 0,
347 before_context: 0,
348 after_context: 0,
349 classify_definitions: false,
350 ..GrepSearchOptions::default()
351 };
352
353 let result = picker.grep(&fff_query, &options);
354
355 let chunks = result
356 .matches
357 .iter()
358 .take(limit)
359 .filter_map(|m| {
360 let file = result.files.get(m.file_index)?;
361 Some(RetrievedChunk {
362 content: m.line_content.clone(),
363 source: file.relative_path(&picker),
364 line_start: Some(m.line_number as usize),
365 line_end: Some(m.line_number as usize),
366 relevance_score: 1.0,
367 haystack: "code",
368 })
369 })
370 .collect();
371
372 Ok(chunks)
373 }
374
375 #[cfg(not(feature = "code-search"))]
376 {
377 let _ = (query, limit, search_path);
378 static WARNED: std::sync::Once = std::sync::Once::new();
383 WARNED.call_once(|| {
384 tracing::warn!(
385 "terraphim-grep was built without the `code-search` feature; \
386 file-content search is disabled and every query returns no matches. \
387 Rebuild with `--features code-search` (the default) to enable grep."
388 );
389 });
390 Ok(vec![])
391 }
392 }
393
394 pub fn fuse_and_rank(&self, mut results: Vec<RetrievedChunk>) -> Vec<RetrievedChunk> {
395 results.sort_by(|a, b| {
396 b.relevance_score
397 .partial_cmp(&a.relevance_score)
398 .unwrap_or(std::cmp::Ordering::Equal)
399 });
400 results
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[tokio::test]
409 async fn test_hybrid_results_empty() {
410 let results = HybridResults {
411 code_results: vec![],
412 doc_results: vec![],
413 kg_concepts: vec![],
414 };
415 assert!(results.is_empty());
416 assert_eq!(results.total_results(), 0);
417 }
418
419 #[tokio::test]
420 async fn test_hybrid_results_to_chunks() {
421 let results = HybridResults {
422 code_results: vec![RetrievedChunk {
423 content: "test".to_string(),
424 source: "file1.rs".to_string(),
425 line_start: Some(1),
426 line_end: Some(1),
427 relevance_score: 0.9,
428 haystack: "code",
429 }],
430 doc_results: vec![RetrievedChunk {
431 content: "test doc".to_string(),
432 source: "file2.md".to_string(),
433 line_start: Some(5),
434 line_end: Some(5),
435 relevance_score: 0.8,
436 haystack: "docs",
437 }],
438 kg_concepts: vec![],
439 };
440
441 let chunks = results.to_chunks();
442 assert_eq!(chunks.len(), 2);
443 assert_eq!(results.total_results(), 2);
444 }
445
446 #[cfg(feature = "code-search")]
451 #[tokio::test]
452 async fn default_build_greps_populated_directory() {
453 let tmp = tempfile::TempDir::new().expect("tempdir");
454 std::fs::write(
455 tmp.path().join("alpha.rs"),
456 "fn configure_pipeline() { /* pipeline setup */ }\n",
457 )
458 .unwrap();
459 std::fs::write(
460 tmp.path().join("beta.rs"),
461 "fn run_pipeline() { configure_pipeline(); }\n",
462 )
463 .unwrap();
464
465 let searcher = HybridSearcher::new(
466 "test-role".to_string(),
467 terraphim_types::Thesaurus::new("t".to_string()),
468 )
469 .expect("build hybrid searcher")
470 .with_search_path(tmp.path().to_path_buf());
471
472 let results = searcher
473 .search(
474 "pipeline",
475 &GrepOptions {
476 haystack: Haystack::Code,
477 max_results: 50,
478 ..GrepOptions::default()
479 },
480 )
481 .await
482 .expect("search should succeed");
483
484 assert!(
485 !results.code_results.is_empty(),
486 "default build must return file-content matches over {:?}, got none",
487 tmp.path()
488 );
489 }
490
491 #[cfg(feature = "code-search")]
493 #[tokio::test]
494 async fn multiple_search_paths_merge_results() {
495 let tmp = tempfile::TempDir::new().expect("tempdir");
496 let dir_a = tmp.path().join("a");
497 let dir_b = tmp.path().join("b");
498 std::fs::create_dir(&dir_a).unwrap();
499 std::fs::create_dir(&dir_b).unwrap();
500 std::fs::write(dir_a.join("alpha.rs"), "fn shared_term() {}\n").unwrap();
501 std::fs::write(dir_b.join("beta.rs"), "fn shared_term() {}\n").unwrap();
502
503 let searcher = HybridSearcher::new(
504 "test-role".to_string(),
505 terraphim_types::Thesaurus::new("t".to_string()),
506 )
507 .expect("build hybrid searcher")
508 .with_search_paths(vec![dir_a.clone(), dir_b.clone()]);
509
510 let results = searcher
511 .search(
512 "shared_term",
513 &GrepOptions {
514 haystack: Haystack::Code,
515 max_results: 50,
516 ..GrepOptions::default()
517 },
518 )
519 .await
520 .expect("search should succeed");
521
522 assert_eq!(
523 results.code_results.len(),
524 2,
525 "expected one match from each of {:?} and {:?}",
526 dir_a,
527 dir_b
528 );
529 }
530
531 fn chunk(source: &str, content: &str, score: f64) -> RetrievedChunk {
532 RetrievedChunk {
533 content: content.to_string(),
534 source: source.to_string(),
535 line_start: Some(1),
536 line_end: Some(1),
537 relevance_score: score,
538 haystack: "code",
539 }
540 }
541
542 fn concept(name: &str, score: f64) -> KgConcept {
543 KgConcept {
544 id: 0,
545 name: name.to_string(),
546 display_value: None,
547 score,
548 }
549 }
550
551 #[test]
552 fn kg_boost_promotes_matching_chunks_to_top() {
553 let chunks = vec![
557 chunk("src/parse_csv.rs", "fn parse_csv() {}", 1.0),
558 chunk("src/retry_policy.rs", "pub struct RetryPolicy {}", 1.0),
559 ];
560 let concepts = vec![concept("retry_policy", 0.9)];
561
562 let ranked = boost_chunks_with_kg(chunks, &concepts);
563 assert_eq!(ranked[0].source, "src/retry_policy.rs");
564 assert!(
565 ranked[0].relevance_score > ranked[1].relevance_score,
566 "KG-matched chunk must outscore the unmatched chunk: {:?} vs {:?}",
567 ranked[0].relevance_score,
568 ranked[1].relevance_score,
569 );
570 }
571
572 #[test]
573 fn kg_boost_no_concepts_is_a_noop() {
574 let chunks = vec![chunk("a.rs", "alpha", 0.5), chunk("b.rs", "beta", 0.9)];
577 let ranked = boost_chunks_with_kg(chunks, &[]);
578 assert_eq!(ranked[0].source, "b.rs");
579 assert!((ranked[0].relevance_score - 0.9).abs() < f64::EPSILON);
580 }
581
582 #[test]
583 fn kg_boost_stacks_when_multiple_concepts_match() {
584 let one_match = chunk("src/retry.rs", "fn retry() {}", 1.0);
587 let two_matches = chunk("src/retry.rs", "fn retry() -> backoff::Result<()>", 1.0);
588 let concepts = vec![concept("retry", 1.0), concept("backoff", 1.0)];
589
590 let b1 = score_kg_boost(&one_match, &concepts, 1.0);
591 let b2 = score_kg_boost(&two_matches, &concepts, 1.0);
592 assert!(
593 b2 > b1,
594 "two-concept match must score higher than one-concept match: {b2} vs {b1}"
595 );
596 }
597
598 #[test]
599 fn test_grep_options_default() {
600 let options = GrepOptions::default();
601 assert_eq!(options.haystack, Haystack::All);
602 assert_eq!(options.context_lines, 0);
603 assert_eq!(options.max_results, 50);
604 assert!(!options.force_rlm);
605 assert!(!options.include_answer);
606 }
607}