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 if node.id == crate::hubs::HUB_CHANGELOG
155 || node
156 .file_path
157 .as_deref()
158 .is_some_and(|p| p.eq_ignore_ascii_case("CHANGELOG.md"))
159 {
160 score *= 1.15;
161 reasons.push("hub:changelog".into());
162 }
163 if node.id == crate::hubs::HUB_ROADMAP || node.id == crate::hubs::HUB_BACKLOG {
164 score *= 1.1;
165 reasons.push(format!("hub:{}", node.id));
166 }
167
168 let title_l = node.title.to_lowercase();
170 let id_l = node.id.to_lowercase();
171 let fts_body = self
172 .get_fts_content(&node.id)
173 .ok()
174 .flatten()
175 .unwrap_or_default()
176 .to_lowercase();
177 let mut coverage = 0usize;
178 for t in tokens {
179 let mut hit_tok = false;
180 if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t.as_str()) {
181 score += 2.0;
182 reasons.push(format!("title:{t}"));
183 hit_tok = true;
184 }
185 if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.contains(t.as_str()) {
186 score += 1.5;
187 reasons.push(format!("id:{t}"));
188 hit_tok = true;
189 }
190 if !fts_body.is_empty() && fts_body.contains(t.as_str()) {
191 score += 0.75;
192 hit_tok = true;
193 }
194 if hit_tok {
195 coverage += 1;
196 }
197 }
198 if tokens.len() > 1 && coverage > 1 {
199 let bonus = 1.5 * (coverage as f32);
200 score += bonus;
201 reasons.push(format!("coverage:{coverage}/{}", tokens.len()));
202 }
203
204 let tags = self.get_tags_for(&node.id)?;
206 for tag in &tags {
207 let tag_l = tag.to_lowercase();
208 for t in tokens {
209 if tag_l == *t || tag_l.contains(t.as_str()) {
210 score += 3.0;
211 reasons.push(format!("tag:{tag}"));
212 }
213 }
214 }
215
216 let aliases = self.get_aliases_for(&node.id)?;
218 for alias in &aliases {
219 let a = alias.to_lowercase();
220 for t in tokens {
221 if a == *t || a.contains(t.as_str()) {
222 score += 2.5;
223 reasons.push(format!("alias:{alias}"));
224 }
225 }
226 }
227
228 for (ty, mult) in &opts.type_boosts {
230 if node.node_type == *ty {
231 score *= mult;
232 reasons.push(format!("type:{}×{mult}", ty.as_str()));
233 }
234 }
235
236 hits.push(RankedHit {
237 node,
238 score,
239 reasons,
240 });
241 }
242
243 self.augment_with_tag_alias_hits(tokens, &mut hits, opts)?;
245
246 hits.retain(|h| opts.allows(&h.node.node_type));
247
248 hits.sort_by(|a, b| {
249 b.score
250 .partial_cmp(&a.score)
251 .unwrap_or(std::cmp::Ordering::Equal)
252 });
253 let mut seen = std::collections::HashSet::new();
255 hits.retain(|h| seen.insert(h.node.id.clone()));
256 hits.truncate(opts.limit);
257 Ok(hits)
258 }
259
260 pub fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
262 let mut stmt = self
263 .conn
264 .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
265 let rows = stmt.query_map([node_id], |row| row.get(0))?;
266 let mut v = Vec::new();
267 for r in rows {
268 v.push(r?);
269 }
270 Ok(v)
271 }
272
273 fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
274 let mut stmt = self
275 .conn
276 .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
277 let rows = stmt.query_map([node_id], |row| row.get(0))?;
278 let mut v = Vec::new();
279 for r in rows {
280 v.push(r?);
281 }
282 Ok(v)
283 }
284
285 fn augment_with_tag_alias_hits(
286 &self,
287 tokens: &[String],
288 hits: &mut Vec<RankedHit>,
289 opts: &QueryOptions,
290 ) -> Result<()> {
291 let existing: std::collections::HashSet<String> =
292 hits.iter().map(|h| h.node.id.clone()).collect();
293
294 for t in tokens {
295 let mut stmt = self.conn.prepare(
297 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
298 n.content_hash, n.created_at, n.updated_at
299 FROM node_tags t
300 JOIN nodes n ON n.id = t.node_id
301 WHERE lower(t.tag) = ?1
302 LIMIT 20",
303 )?;
304 let rows = stmt.query_map([t.as_str()], |row| {
305 let type_str: String = row.get(1)?;
306 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
307 let symbol_hash_raw: Option<i64> = row.get(4)?;
308 Ok(Node {
309 id: row.get(0)?,
310 node_type,
311 title: row.get(2)?,
312 file_path: row.get(3)?,
313 symbol_hash: symbol_hash_raw.map(|h| h as u64),
314 summary: row.get(5)?,
315 content_hash: row.get(6)?,
316 created_at: row.get(7)?,
317 updated_at: row.get(8)?,
318 })
319 })?;
320 for r in rows {
321 let node = r?;
322 if existing.contains(&node.id) || !opts.allows(&node.node_type) {
323 continue;
324 }
325 hits.push(RankedHit {
326 node,
327 score: 3.5,
328 reasons: vec![format!("tag-exact:{t}")],
329 });
330 }
331
332 let mut stmt = self.conn.prepare(
334 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
335 n.content_hash, n.created_at, n.updated_at
336 FROM node_aliases a
337 JOIN nodes n ON n.id = a.node_id
338 WHERE a.alias = ?1
339 LIMIT 20",
340 )?;
341 let rows = stmt.query_map([t.as_str()], |row| {
342 let type_str: String = row.get(1)?;
343 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
344 let symbol_hash_raw: Option<i64> = row.get(4)?;
345 Ok(Node {
346 id: row.get(0)?,
347 node_type,
348 title: row.get(2)?,
349 file_path: row.get(3)?,
350 symbol_hash: symbol_hash_raw.map(|h| h as u64),
351 summary: row.get(5)?,
352 content_hash: row.get(6)?,
353 created_at: row.get(7)?,
354 updated_at: row.get(8)?,
355 })
356 })?;
357 for r in rows {
358 let node = r?;
359 if hits.iter().any(|h| h.node.id == node.id) || !opts.allows(&node.node_type)
360 {
361 continue;
362 }
363 hits.push(RankedHit {
364 node,
365 score: 3.0,
366 reasons: vec![format!("alias-exact:{t}")],
367 });
368 }
369 }
370 Ok(())
371 }
372
373 pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
375 let mut stmt = self.conn.prepare(
376 "SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
377 )?;
378 let rows = stmt.query_map([], |row| {
379 Ok(PendingLink {
380 source_id: row.get(0)?,
381 raw_target: row.get(1)?,
382 relation_type: row.get(2)?,
383 created_at: row.get(3)?,
384 })
385 })?;
386 let mut out = Vec::new();
387 for r in rows {
388 out.push(r?);
389 }
390 Ok(out)
391 }
392
393 pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
395 let mut stmt = self
396 .conn
397 .prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
398 let rows = stmt.query_map([], |row| {
399 Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
400 })?;
401 let mut out = Vec::new();
402 for r in rows {
403 out.push(r?);
404 }
405 Ok(out)
406 }
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct PendingLink {
412 pub source_id: String,
414 pub raw_target: String,
416 pub relation_type: String,
418 pub created_at: i64,
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crate::types::NodeType;
426
427 #[test]
428 fn tag_boost_ranks_higher() {
429 let db = Database::open_in_memory().unwrap();
430 let n1 = Node {
431 id: "docs/a".into(),
432 node_type: NodeType::Concept,
433 title: "Something Else".into(),
434 file_path: None,
435 symbol_hash: None,
436 summary: Some("mentions raft in body".into()),
437 content_hash: None,
438 created_at: 1,
439 updated_at: 1,
440 };
441 let n2 = Node {
442 id: "docs/b".into(),
443 node_type: NodeType::Concept,
444 title: "Protocol".into(),
445 file_path: None,
446 symbol_hash: None,
447 summary: Some("tagged note".into()),
448 content_hash: None,
449 created_at: 1,
450 updated_at: 1,
451 };
452 db.insert_node(&n1).unwrap();
453 db.insert_node(&n2).unwrap();
454 db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
455 db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
456 db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
457
458 let hits = db
459 .search_ranked("raft", &QueryOptions::default())
460 .unwrap();
461 assert!(hits.len() >= 2);
462 assert!(hits.iter().any(|h| h.node.id == "docs/b"));
464 let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
465 assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
466 }
467}