1use crate::error::Result;
7use crate::fts::escape_fts5_query;
8use crate::storage::Database;
9use crate::types::{Node, NodeType};
10use rusqlite::params;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RankedHit {
19 pub node: Node,
21 pub score: f32,
23 pub reasons: Vec<String>,
25}
26
27#[derive(Debug, Clone)]
29pub struct QueryOptions {
30 pub limit: usize,
32 pub type_boosts: Vec<(NodeType, f32)>,
36 pub include_types: Vec<NodeType>,
38 pub exclude_types: Vec<NodeType>,
40 pub no_symbols: bool,
42}
43
44impl Default for QueryOptions {
45 fn default() -> Self {
46 Self {
47 limit: 50,
48 type_boosts: vec![
49 (NodeType::Goal, 1.15),
50 (NodeType::Adr, 1.1),
51 (NodeType::EdgeCase, 1.1),
52 (NodeType::Concept, 1.05),
53 (NodeType::Symbol, 0.95),
54 ],
55 include_types: Vec::new(),
56 exclude_types: Vec::new(),
57 no_symbols: false,
58 }
59 }
60}
61
62impl QueryOptions {
63 pub fn human() -> Self {
65 Self {
66 no_symbols: true,
67 ..Self::default()
68 }
69 }
70
71 fn allows(&self, ty: &NodeType) -> bool {
72 if self.no_symbols && *ty == NodeType::Symbol {
73 return false;
74 }
75 if !self.include_types.is_empty() && !self.include_types.contains(ty) {
76 return false;
77 }
78 if self.exclude_types.contains(ty) {
79 return false;
80 }
81 true
82 }
83}
84
85impl Database {
86 pub fn search_ranked(&self, query: &str, opts: &QueryOptions) -> Result<Vec<RankedHit>> {
100 let tokens: Vec<String> = query
101 .split_whitespace()
102 .filter(|t| !t.is_empty())
103 .map(|t| t.to_lowercase())
104 .collect();
105 if tokens.is_empty() {
106 return Err(crate::error::BrainError::FtsQuery(
107 "query must contain at least one non-whitespace token".into(),
108 ));
109 }
110
111 let escaped = escape_fts5_query(query)?;
112 let mut stmt = self.conn.prepare(
113 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
114 n.content_hash, n.created_at, n.updated_at,
115 bm25(node_fts) AS bm25_score
116 FROM node_fts f
117 JOIN nodes n ON n.id = f.node_id
118 WHERE node_fts MATCH ?1
119 ORDER BY bm25_score
120 LIMIT ?2",
121 )?;
122
123 let rows = stmt.query_map(params![escaped, opts.limit as i64 * 2], |row| {
124 let type_str: String = row.get(1)?;
125 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
126 let symbol_hash_raw: Option<i64> = row.get(4)?;
127 let bm25: f64 = row.get(9)?;
128 Ok((
129 Node {
130 id: row.get(0)?,
131 node_type,
132 title: row.get(2)?,
133 file_path: row.get(3)?,
134 symbol_hash: symbol_hash_raw.map(|h| h as u64),
135 summary: row.get(5)?,
136 content_hash: row.get(6)?,
137 created_at: row.get(7)?,
138 updated_at: row.get(8)?,
139 },
140 bm25 as f32,
141 ))
142 })?;
143
144 let mut hits: Vec<RankedHit> = Vec::new();
145 for r in rows {
146 let (node, bm25) = r?;
147 if !opts.allows(&node.node_type) {
148 continue;
149 }
150 let fts_score = (-bm25).max(0.01);
152 let mut score = fts_score;
153 let mut reasons = vec![format!("fts:{fts_score:.3}")];
154 if node.id == "readme" || node.file_path.as_deref() == Some("README.md") {
156 score *= 1.2;
157 reasons.push("hub:readme".into());
158 }
159
160 let title_l = node.title.to_lowercase();
162 let id_l = node.id.to_lowercase();
163 for t in &tokens {
164 if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t) {
165 score += 2.0;
166 reasons.push(format!("title:{t}"));
167 }
168 if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.ends_with(t) {
169 score += 1.5;
170 reasons.push(format!("id:{t}"));
171 }
172 }
173
174 let tags = self.get_tags_for(&node.id)?;
176 for tag in &tags {
177 let tag_l = tag.to_lowercase();
178 for t in &tokens {
179 if tag_l == *t || tag_l.contains(t) {
180 score += 3.0;
181 reasons.push(format!("tag:{tag}"));
182 }
183 }
184 }
185
186 let aliases = self.get_aliases_for(&node.id)?;
188 for alias in &aliases {
189 let a = alias.to_lowercase();
190 for t in &tokens {
191 if a == *t || a.contains(t) {
192 score += 2.5;
193 reasons.push(format!("alias:{alias}"));
194 }
195 }
196 }
197
198 for (ty, mult) in &opts.type_boosts {
200 if node.node_type == *ty {
201 score *= mult;
202 reasons.push(format!("type:{}×{mult}", ty.as_str()));
203 }
204 }
205
206 hits.push(RankedHit {
207 node,
208 score,
209 reasons,
210 });
211 }
212
213 self.augment_with_tag_alias_hits(&tokens, &mut hits, opts)?;
215
216 hits.retain(|h| opts.allows(&h.node.node_type));
217
218 hits.sort_by(|a, b| {
219 b.score
220 .partial_cmp(&a.score)
221 .unwrap_or(std::cmp::Ordering::Equal)
222 });
223 let mut seen = std::collections::HashSet::new();
225 hits.retain(|h| seen.insert(h.node.id.clone()));
226 hits.truncate(opts.limit);
227 Ok(hits)
228 }
229
230 fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
231 let mut stmt = self
232 .conn
233 .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
234 let rows = stmt.query_map([node_id], |row| row.get(0))?;
235 let mut v = Vec::new();
236 for r in rows {
237 v.push(r?);
238 }
239 Ok(v)
240 }
241
242 fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
243 let mut stmt = self
244 .conn
245 .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
246 let rows = stmt.query_map([node_id], |row| row.get(0))?;
247 let mut v = Vec::new();
248 for r in rows {
249 v.push(r?);
250 }
251 Ok(v)
252 }
253
254 fn augment_with_tag_alias_hits(
255 &self,
256 tokens: &[String],
257 hits: &mut Vec<RankedHit>,
258 opts: &QueryOptions,
259 ) -> Result<()> {
260 let existing: std::collections::HashSet<String> =
261 hits.iter().map(|h| h.node.id.clone()).collect();
262
263 for t in tokens {
264 let mut stmt = self.conn.prepare(
266 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
267 n.content_hash, n.created_at, n.updated_at
268 FROM node_tags t
269 JOIN nodes n ON n.id = t.node_id
270 WHERE lower(t.tag) = ?1
271 LIMIT 20",
272 )?;
273 let rows = stmt.query_map([t.as_str()], |row| {
274 let type_str: String = row.get(1)?;
275 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
276 let symbol_hash_raw: Option<i64> = row.get(4)?;
277 Ok(Node {
278 id: row.get(0)?,
279 node_type,
280 title: row.get(2)?,
281 file_path: row.get(3)?,
282 symbol_hash: symbol_hash_raw.map(|h| h as u64),
283 summary: row.get(5)?,
284 content_hash: row.get(6)?,
285 created_at: row.get(7)?,
286 updated_at: row.get(8)?,
287 })
288 })?;
289 for r in rows {
290 let node = r?;
291 if existing.contains(&node.id) || !opts.allows(&node.node_type) {
292 continue;
293 }
294 hits.push(RankedHit {
295 node,
296 score: 3.5,
297 reasons: vec![format!("tag-exact:{t}")],
298 });
299 }
300
301 let mut stmt = self.conn.prepare(
303 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
304 n.content_hash, n.created_at, n.updated_at
305 FROM node_aliases a
306 JOIN nodes n ON n.id = a.node_id
307 WHERE a.alias = ?1
308 LIMIT 20",
309 )?;
310 let rows = stmt.query_map([t.as_str()], |row| {
311 let type_str: String = row.get(1)?;
312 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
313 let symbol_hash_raw: Option<i64> = row.get(4)?;
314 Ok(Node {
315 id: row.get(0)?,
316 node_type,
317 title: row.get(2)?,
318 file_path: row.get(3)?,
319 symbol_hash: symbol_hash_raw.map(|h| h as u64),
320 summary: row.get(5)?,
321 content_hash: row.get(6)?,
322 created_at: row.get(7)?,
323 updated_at: row.get(8)?,
324 })
325 })?;
326 for r in rows {
327 let node = r?;
328 if hits.iter().any(|h| h.node.id == node.id) || !opts.allows(&node.node_type)
329 {
330 continue;
331 }
332 hits.push(RankedHit {
333 node,
334 score: 3.0,
335 reasons: vec![format!("alias-exact:{t}")],
336 });
337 }
338 }
339 Ok(())
340 }
341
342 pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
344 let mut stmt = self.conn.prepare(
345 "SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
346 )?;
347 let rows = stmt.query_map([], |row| {
348 Ok(PendingLink {
349 source_id: row.get(0)?,
350 raw_target: row.get(1)?,
351 relation_type: row.get(2)?,
352 created_at: row.get(3)?,
353 })
354 })?;
355 let mut out = Vec::new();
356 for r in rows {
357 out.push(r?);
358 }
359 Ok(out)
360 }
361
362 pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
364 let mut stmt = self
365 .conn
366 .prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
367 let rows = stmt.query_map([], |row| {
368 Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
369 })?;
370 let mut out = Vec::new();
371 for r in rows {
372 out.push(r?);
373 }
374 Ok(out)
375 }
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct PendingLink {
381 pub source_id: String,
383 pub raw_target: String,
385 pub relation_type: String,
387 pub created_at: i64,
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::types::NodeType;
395
396 #[test]
397 fn tag_boost_ranks_higher() {
398 let db = Database::open_in_memory().unwrap();
399 let n1 = Node {
400 id: "docs/a".into(),
401 node_type: NodeType::Concept,
402 title: "Something Else".into(),
403 file_path: None,
404 symbol_hash: None,
405 summary: Some("mentions raft in body".into()),
406 content_hash: None,
407 created_at: 1,
408 updated_at: 1,
409 };
410 let n2 = Node {
411 id: "docs/b".into(),
412 node_type: NodeType::Concept,
413 title: "Protocol".into(),
414 file_path: None,
415 symbol_hash: None,
416 summary: Some("tagged note".into()),
417 content_hash: None,
418 created_at: 1,
419 updated_at: 1,
420 };
421 db.insert_node(&n1).unwrap();
422 db.insert_node(&n2).unwrap();
423 db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
424 db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
425 db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
426
427 let hits = db
428 .search_ranked("raft", &QueryOptions::default())
429 .unwrap();
430 assert!(hits.len() >= 2);
431 assert!(hits.iter().any(|h| h.node.id == "docs/b"));
433 let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
434 assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
435 }
436}