use crate::error::Result;
use crate::fts::escape_fts5_query;
use crate::storage::Database;
use crate::types::{Node, NodeType};
use rusqlite::params;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RankedHit {
pub node: Node,
pub score: f32,
pub reasons: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct QueryOptions {
pub limit: usize,
pub type_boosts: Vec<(NodeType, f32)>,
pub include_types: Vec<NodeType>,
pub exclude_types: Vec<NodeType>,
pub no_symbols: bool,
}
impl Default for QueryOptions {
fn default() -> Self {
Self {
limit: 50,
type_boosts: vec![
(NodeType::Goal, 1.15),
(NodeType::Adr, 1.1),
(NodeType::EdgeCase, 1.1),
(NodeType::Concept, 1.05),
(NodeType::Symbol, 0.95),
],
include_types: Vec::new(),
exclude_types: Vec::new(),
no_symbols: false,
}
}
}
impl QueryOptions {
pub fn human() -> Self {
Self {
no_symbols: true,
..Self::default()
}
}
fn allows(&self, ty: &NodeType) -> bool {
if self.no_symbols && *ty == NodeType::Symbol {
return false;
}
if !self.include_types.is_empty() && !self.include_types.contains(ty) {
return false;
}
if self.exclude_types.contains(ty) {
return false;
}
true
}
}
impl Database {
pub fn search_ranked(&self, query: &str, opts: &QueryOptions) -> Result<Vec<RankedHit>> {
let tokens: Vec<String> = query
.split_whitespace()
.filter(|t| !t.is_empty())
.map(|t| t.to_lowercase())
.collect();
if tokens.is_empty() {
return Err(crate::error::BrainError::FtsQuery(
"query must contain at least one non-whitespace token".into(),
));
}
let escaped = escape_fts5_query(query)?;
let mut stmt = self.conn.prepare(
"SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
n.content_hash, n.created_at, n.updated_at,
bm25(node_fts) AS bm25_score
FROM node_fts f
JOIN nodes n ON n.id = f.node_id
WHERE node_fts MATCH ?1
ORDER BY bm25_score
LIMIT ?2",
)?;
let rows = stmt.query_map(params![escaped, opts.limit as i64 * 2], |row| {
let type_str: String = row.get(1)?;
let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
let symbol_hash_raw: Option<i64> = row.get(4)?;
let bm25: f64 = row.get(9)?;
Ok((
Node {
id: row.get(0)?,
node_type,
title: row.get(2)?,
file_path: row.get(3)?,
symbol_hash: symbol_hash_raw.map(|h| h as u64),
summary: row.get(5)?,
content_hash: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
},
bm25 as f32,
))
})?;
let mut hits: Vec<RankedHit> = Vec::new();
for r in rows {
let (node, bm25) = r?;
if !opts.allows(&node.node_type) {
continue;
}
let fts_score = (-bm25).max(0.01);
let mut score = fts_score;
let mut reasons = vec![format!("fts:{fts_score:.3}")];
if node.id == "readme" || node.file_path.as_deref() == Some("README.md") {
score *= 1.2;
reasons.push("hub:readme".into());
}
let title_l = node.title.to_lowercase();
let id_l = node.id.to_lowercase();
for t in &tokens {
if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t) {
score += 2.0;
reasons.push(format!("title:{t}"));
}
if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.ends_with(t) {
score += 1.5;
reasons.push(format!("id:{t}"));
}
}
let tags = self.get_tags_for(&node.id)?;
for tag in &tags {
let tag_l = tag.to_lowercase();
for t in &tokens {
if tag_l == *t || tag_l.contains(t) {
score += 3.0;
reasons.push(format!("tag:{tag}"));
}
}
}
let aliases = self.get_aliases_for(&node.id)?;
for alias in &aliases {
let a = alias.to_lowercase();
for t in &tokens {
if a == *t || a.contains(t) {
score += 2.5;
reasons.push(format!("alias:{alias}"));
}
}
}
for (ty, mult) in &opts.type_boosts {
if node.node_type == *ty {
score *= mult;
reasons.push(format!("type:{}×{mult}", ty.as_str()));
}
}
hits.push(RankedHit {
node,
score,
reasons,
});
}
self.augment_with_tag_alias_hits(&tokens, &mut hits, opts)?;
hits.retain(|h| opts.allows(&h.node.node_type));
hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut seen = std::collections::HashSet::new();
hits.retain(|h| seen.insert(h.node.id.clone()));
hits.truncate(opts.limit);
Ok(hits)
}
fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
let mut stmt = self
.conn
.prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
let rows = stmt.query_map([node_id], |row| row.get(0))?;
let mut v = Vec::new();
for r in rows {
v.push(r?);
}
Ok(v)
}
fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
let mut stmt = self
.conn
.prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
let rows = stmt.query_map([node_id], |row| row.get(0))?;
let mut v = Vec::new();
for r in rows {
v.push(r?);
}
Ok(v)
}
fn augment_with_tag_alias_hits(
&self,
tokens: &[String],
hits: &mut Vec<RankedHit>,
opts: &QueryOptions,
) -> Result<()> {
let existing: std::collections::HashSet<String> =
hits.iter().map(|h| h.node.id.clone()).collect();
for t in tokens {
let mut stmt = self.conn.prepare(
"SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
n.content_hash, n.created_at, n.updated_at
FROM node_tags t
JOIN nodes n ON n.id = t.node_id
WHERE lower(t.tag) = ?1
LIMIT 20",
)?;
let rows = stmt.query_map([t.as_str()], |row| {
let type_str: String = row.get(1)?;
let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
let symbol_hash_raw: Option<i64> = row.get(4)?;
Ok(Node {
id: row.get(0)?,
node_type,
title: row.get(2)?,
file_path: row.get(3)?,
symbol_hash: symbol_hash_raw.map(|h| h as u64),
summary: row.get(5)?,
content_hash: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
})?;
for r in rows {
let node = r?;
if existing.contains(&node.id) || !opts.allows(&node.node_type) {
continue;
}
hits.push(RankedHit {
node,
score: 3.5,
reasons: vec![format!("tag-exact:{t}")],
});
}
let mut stmt = self.conn.prepare(
"SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
n.content_hash, n.created_at, n.updated_at
FROM node_aliases a
JOIN nodes n ON n.id = a.node_id
WHERE a.alias = ?1
LIMIT 20",
)?;
let rows = stmt.query_map([t.as_str()], |row| {
let type_str: String = row.get(1)?;
let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
let symbol_hash_raw: Option<i64> = row.get(4)?;
Ok(Node {
id: row.get(0)?,
node_type,
title: row.get(2)?,
file_path: row.get(3)?,
symbol_hash: symbol_hash_raw.map(|h| h as u64),
summary: row.get(5)?,
content_hash: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
})?;
for r in rows {
let node = r?;
if hits.iter().any(|h| h.node.id == node.id) || !opts.allows(&node.node_type)
{
continue;
}
hits.push(RankedHit {
node,
score: 3.0,
reasons: vec![format!("alias-exact:{t}")],
});
}
}
Ok(())
}
pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
let mut stmt = self.conn.prepare(
"SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
)?;
let rows = stmt.query_map([], |row| {
Ok(PendingLink {
source_id: row.get(0)?,
raw_target: row.get(1)?,
relation_type: row.get(2)?,
created_at: row.get(3)?,
})
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
let mut stmt = self
.conn
.prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingLink {
pub source_id: String,
pub raw_target: String,
pub relation_type: String,
pub created_at: i64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::NodeType;
#[test]
fn tag_boost_ranks_higher() {
let db = Database::open_in_memory().unwrap();
let n1 = Node {
id: "docs/a".into(),
node_type: NodeType::Concept,
title: "Something Else".into(),
file_path: None,
symbol_hash: None,
summary: Some("mentions raft in body".into()),
content_hash: None,
created_at: 1,
updated_at: 1,
};
let n2 = Node {
id: "docs/b".into(),
node_type: NodeType::Concept,
title: "Protocol".into(),
file_path: None,
symbol_hash: None,
summary: Some("tagged note".into()),
content_hash: None,
created_at: 1,
updated_at: 1,
};
db.insert_node(&n1).unwrap();
db.insert_node(&n2).unwrap();
db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
let hits = db
.search_ranked("raft", &QueryOptions::default())
.unwrap();
assert!(hits.len() >= 2);
assert!(hits.iter().any(|h| h.node.id == "docs/b"));
let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
}
}