Skip to main content

code_repo_wiki/search/
agent.rs

1use std::collections::HashMap;
2
3use super::text::TextEngine;
4use super::semantic::SemanticSearch;
5use super::hybrid::{self, SearchHit, rrf_merge};
6
7/// 调用链索引:符号名 → (调用者列表, 被调用者列表)
8type CallIndex = HashMap<String, (Vec<String>, Vec<String>)>;
9
10/// 搜索 Agent:自动回溯的多轮搜索策略
11///
12/// 优先执行 FTS5(<50ms)→ 结果不足时自动启动语义搜索(~200ms)
13/// → 需要精确定位时 AST 查询(~100ms)。
14/// 各级结果经 RRF 合并后返回;若提供了调用链索引,还会对命中结果
15/// 做调用者/被调用者补全。
16pub struct SearchAgent {
17    text: TextEngine,
18    /// 语义引擎抽象(v6:trait object 可注入 mock,语义分支可测试)
19    semantic: Option<Box<dyn SemanticSearch>>,
20    rrf_k: f64,
21    /// 符号名 → (调用者列表, 被调用者列表) 预计算表,None 表示不做调用链补全
22    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    /// 注入调用链索引(由 CallGraph::build_call_index 预计算),启用调用链补全
31    pub fn with_call_index(mut self, index: CallIndex) -> Self {
32        self.call_index = Some(index);
33        self
34    }
35
36    /// 执行分层搜索。auto_backtrack 控制是否自动回退到语义引擎。
37    pub fn search(&self, query: &str, top_k: usize, auto_backtrack: bool) -> Vec<SearchHit> {
38        // 第一层:FTS5 全文搜索
39        let text_results = match self.text.search(query, top_k) {
40            Ok(r) => r,
41            // U04/P3:text 索引损坏/不可读时不再静默返回空(此前空结果
42            // 被当作"无命中",掩盖索引损坏事实)——告警后仍返回空,
43            // 调用方按无命中处理,但日志暴露真实原因。
44            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                // U04/P3:语义搜索失败同样告警(此前 if let Ok 吞掉 Err,
56                // 语义引擎故障时静默退化为纯 text 结果,无任何日志)。
57                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        // 调用链补全:对命中结果填充调用者/被调用者
71        self.enrich_call_chain(&mut hits);
72        hits
73    }
74
75    /// 调用链补全:按命中符号名查 call_index,填充 callers/callees;无索引时直接跳过
76    ///
77    /// v36 C2 语义约定:**展示双向、扩展单向**——
78    /// - 展示:callers/callees 双向填充(用户在结果里看到调用者与被
79    ///   调用者,理解符号上下文)
80    /// - 扩展:本函数不做候选扩展(结果集不变);若未来引入「调用链
81    ///   候选扩展」(把被调用者追加进检索候选),**只允许使用 callees
82    ///   方向**——CodeRAG 实证(callee 扩展 +15% MRR,callers 扩展
83    ///   -17%):调用者是「谁在用我」,对查询相关性是噪声;被调用者
84    ///   「我调用谁」携带实现细节,与代码检索意图同向。
85    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    /// 可编程 mock 语义引擎(v6:SemanticSearch trait 抽象使语义分支可测试)
135    ///
136    /// 固定返回预置结果;无需真实 embedding 与向量库。
137    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    /// 语义回溯分支(v5 报告 P1 缺口补测):FTS 结果不足 3 条且语义引擎在场时,
171    /// 语义结果经 RRF 合并后返回;无语义引擎时仅返回 FTS 结果。
172    #[test]
173    fn test_agent_auto_backtrack_with_semantic() {
174        // 文本引擎空(无 FTS 命中)→ 触发回溯,语义结果应进入最终结果
175        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    /// auto_backtrack=false 时不触发语义回溯(即使语义引擎在场)
186    #[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    /// FTS 命中足够(≥3 条)时不触发语义回溯(分层搜索的成本控制语义)
198    #[test]
199    fn test_agent_skips_semantic_when_text_sufficient() {
200        // 构造 3 条使 FTS 命中 ≥3,验证不触发回溯
201        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        // 查询 "user":FTS 命中 3 条 → 不回溯,语义结果不应混入
211        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    /// 构造 a→b→c 迷你调用图,验证调用链补全:查 b 得调用者 a、被调用者 c
243    #[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    /// 无 call_index 时搜索行为不变,不报错
282    #[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}