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