1use std::collections::HashMap;
2
3use super::text::TextEngine;
4use super::semantic::SemanticSearch;
5use super::hybrid::{self, SearchHit, rrf_merge};
6
7type CallIndex = HashMap<String, (Vec<String>, Vec<String>)>;
9
10pub struct SearchAgent {
17 text: TextEngine,
18 semantic: Option<Box<dyn SemanticSearch>>,
20 rrf_k: f64,
21 call_index: Option<CallIndex>,
23}
24
25impl SearchAgent {
26 pub fn new(text: TextEngine, semantic: Option<Box<dyn SemanticSearch>>, rrf_k: f64) -> Self {
27 Self { text, semantic, rrf_k, call_index: None }
28 }
29
30 pub fn with_call_index(mut self, index: CallIndex) -> Self {
32 self.call_index = Some(index);
33 self
34 }
35
36 pub fn search(&self, query: &str, top_k: usize, auto_backtrack: bool) -> Vec<SearchHit> {
38 let text_results = match self.text.search(query, top_k) {
40 Ok(r) => r,
41 Err(e) => {
45 tracing::warn!("text 索引搜索失败(按无命中处理): {e}");
46 return Vec::new();
47 }
48 };
49 let mut hits = if auto_backtrack && text_results.len() < 3 && self.semantic.is_some() {
50 let mut all = Vec::new();
51 if !text_results.is_empty() {
52 all.push(hybrid::text_results_to_hits(text_results));
53 }
54 if let Some(ref sem) = self.semantic {
55 match sem.search(query, top_k * 2) {
58 Ok(sem_results) => {
59 all.push(hybrid::semantic_results_to_hits(sem_results));
60 }
61 Err(e) => {
62 tracing::warn!("语义搜索失败(跳过语义回溯): {e}");
63 }
64 }
65 }
66 rrf_merge(&all, top_k, self.rrf_k)
67 } else {
68 hybrid::text_results_to_hits(text_results)
69 };
70 self.enrich_call_chain(&mut hits);
72 hits
73 }
74
75 fn enrich_call_chain(&self, hits: &mut [SearchHit]) {
86 let Some(index) = &self.call_index else { return };
87 for hit in hits.iter_mut() {
88 if let Some((callers, callees)) = index.get(&hit.node.name) {
89 hit.callers = callers.clone();
90 hit.callees = callees.clone();
91 }
92 }
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::model::{CodeNode, NodeKind, NodeId};
100
101 use std::sync::atomic::{AtomicU64, Ordering};
102 static AGENT_COUNTER: AtomicU64 = AtomicU64::new(0);
103
104 fn unique_db_path(prefix: &str) -> std::path::PathBuf {
105 let id = AGENT_COUNTER.fetch_add(1, Ordering::Relaxed);
106 let mut p = std::env::temp_dir();
107 p.push(format!("{}_{}_{}.db", prefix, std::process::id(), id));
108 let _ = std::fs::remove_file(&p);
109 p
110 }
111
112 fn make_text_engine() -> TextEngine {
113 let path = unique_db_path("agent_text");
114 let (mut t, _) = TextEngine::open(&path).unwrap();
115 let _ = t.index(&CodeNode {
116 id: NodeId::new(0), kind: NodeKind::Function,
117 name: "add_user".into(), file_path: None, line_range: None,
118 doc_comment: None, signature: Some("fn add_user(name: &str)".into()), visibility: None,
119 module_path: vec![],
120 }, "fn add_user(name: &str)");
121 let _ = t.index(&CodeNode {
122 id: NodeId::new(1), kind: NodeKind::Function,
123 name: "delete_user".into(), file_path: None, line_range: None,
124 doc_comment: None, signature: None, visibility: None, module_path: vec![],
125 }, "");
126 t
127 }
128
129 fn make_text_empty() -> TextEngine {
130 let path = unique_db_path("agent_empty");
131 TextEngine::open(&path).unwrap().0
132 }
133
134 struct MockSemantic {
138 results: Vec<(CodeNode, f32)>,
139 }
140
141 impl SemanticSearch for MockSemantic {
142 fn index(&mut self, _node: &CodeNode, _source_code: &str) -> anyhow::Result<()> {
143 Ok(())
144 }
145 fn index_batch(&mut self, _items: &[(CodeNode, String)]) -> anyhow::Result<()> {
146 Ok(())
147 }
148 fn search(&self, _query: &str, _limit: usize) -> anyhow::Result<Vec<(CodeNode, f32)>> {
149 Ok(self.results.clone())
150 }
151 fn remove_by_file(&mut self, _file_path: &str) -> anyhow::Result<usize> {
152 Ok(0)
153 }
154 fn clear(&mut self) -> anyhow::Result<()> {
155 Ok(())
156 }
157 fn entry_count(&self) -> usize {
158 self.results.len()
159 }
160 }
161
162 fn mock_node(name: &str) -> CodeNode {
163 CodeNode {
164 id: NodeId::new(0), kind: NodeKind::Function, name: name.into(),
165 file_path: Some(format!("src/{name}.rs")), line_range: None,
166 doc_comment: None, signature: None, visibility: None, module_path: vec![],
167 }
168 }
169
170 #[test]
173 fn test_agent_auto_backtrack_with_semantic() {
174 let text = make_text_empty();
176 let semantic = Box::new(MockSemantic {
177 results: vec![(mock_node("sem_hit"), 0.95)],
178 });
179 let agent = SearchAgent::new(text, Some(semantic), 60.0);
180 let results = agent.search("zzz_not_in_fts", 5, true);
181 assert_eq!(results.len(), 1, "语义命中应经回溯进入结果");
182 assert_eq!(results[0].node.name, "sem_hit");
183 }
184
185 #[test]
187 fn test_agent_no_backtrack_when_disabled() {
188 let text = make_text_empty();
189 let semantic = Box::new(MockSemantic {
190 results: vec![(mock_node("sem_hit"), 0.95)],
191 });
192 let agent = SearchAgent::new(text, Some(semantic), 60.0);
193 let results = agent.search("zzz_not_in_fts", 5, false);
194 assert!(results.is_empty(), "回溯关闭时不应使用语义结果");
195 }
196
197 #[test]
199 fn test_agent_skips_semantic_when_text_sufficient() {
200 let path = unique_db_path("agent_text3");
202 let (mut t, _) = TextEngine::open(&path).unwrap();
203 let _ = t.index(&mock_node("add_user"), "fn add_user(name: &str)");
204 let _ = t.index(&mock_node("delete_user"), "fn delete_user(id: u64)");
205 let _ = t.index(&mock_node("update_user"), "fn update_user(id: u64)");
206 let semantic = Box::new(MockSemantic {
207 results: vec![(mock_node("sem_hit"), 0.95)],
208 });
209 let agent = SearchAgent::new(t, Some(semantic), 60.0);
210 let results = agent.search("user", 5, true);
212 assert!(
213 results.iter().all(|h| h.node.name != "sem_hit"),
214 "FTS 足够时不应触发语义回溯: {:?}",
215 results.iter().map(|h| h.node.name.clone()).collect::<Vec<_>>()
216 );
217 assert!(results.len() >= 3, "FTS 应有 3 条命中: {:?}", results.len());
218 }
219
220 #[test]
221 fn test_agent_text_search() {
222 let agent = SearchAgent::new(make_text_engine(), None, 60.0);
223 let results = agent.search("add", 5, false);
224 assert!(!results.is_empty());
225 assert!(results[0].node.name.contains("add"));
226 }
227
228 #[test]
229 fn test_agent_empty_text() {
230 let agent = SearchAgent::new(make_text_empty(), None, 60.0);
231 let results = agent.search("anything", 5, false);
232 assert!(results.is_empty());
233 }
234
235 #[test]
236 fn test_agent_auto_backtrack_no_semantic() {
237 let agent = SearchAgent::new(make_text_engine(), None, 60.0);
238 let results = agent.search("zzzz_not_found", 5, true);
239 assert!(results.is_empty());
240 }
241
242 #[test]
244 fn test_callgraph_enrichment() {
245 use crate::model::{CodeEdge, EdgeKind, KnowledgeGraph};
246 use crate::search::callgraph::CallGraph;
247 use petgraph::stable_graph::StableDiGraph;
248
249 let make_node = |id: u64, name: &str| CodeNode {
250 id: NodeId::new(id as usize), kind: NodeKind::Function, name: name.into(),
251 file_path: None, line_range: None, doc_comment: None,
252 signature: None, module_path: vec!["test".into()], visibility: None,
253 };
254 let make_edge = |source: _, target: _| CodeEdge {
255 id: petgraph::stable_graph::EdgeIndex::new(0),
256 kind: EdgeKind::Calls, source, target,
257 weight: 1.0, location: None,
258 };
259
260 let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
261 let a = g.add_node(make_node(0, "a"));
262 let b = g.add_node(make_node(1, "b"));
263 let c = g.add_node(make_node(2, "c"));
264 g.add_edge(a, b, make_edge(a, b));
265 g.add_edge(b, c, make_edge(b, c));
266
267 let kg = KnowledgeGraph { graph: g, modules: vec![], features: Vec::new() };
268 let index = CallGraph::new(&kg).build_call_index();
269
270 let (mut t, _) = TextEngine::open(unique_db_path("agent_callgraph")).unwrap();
271 let _ = t.index(&make_node(1, "b"), "fn b()");
272
273 let agent = SearchAgent::new(t, None, 60.0).with_call_index(index);
274 let results = agent.search("b", 5, false);
275 assert_eq!(results.len(), 1);
276 assert_eq!(results[0].node.name, "b");
277 assert!(results[0].callers.iter().any(|c| c == "a"));
278 assert!(results[0].callees.iter().any(|c| c == "c"));
279 }
280
281 #[test]
283 fn test_search_without_call_index() {
284 let agent = SearchAgent::new(make_text_engine(), None, 60.0);
285 let results = agent.search("add", 5, false);
286 assert!(!results.is_empty());
287 assert!(results[0].callers.is_empty());
288 assert!(results[0].callees.is_empty());
289 }
290}