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