Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions crates/grasp-core/src/embed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,36 @@ impl Embedder {
}

/// Cosine similarity of two equal-length, L2-normalized vectors (== dot product).
///
/// Returns 0.0 when the dimensions differ rather than silently computing a
/// truncated dot product over the shorter length — which `zip` would otherwise
/// do. Mismatched dimensions are reachable: the `embeddings` table stores a
/// per-row `dim`, so a future model/`DIM` change can leave stale vectors of a
/// different length that must not corrupt ranking or semantic edges.
pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() {
return 0.0;
}
a.iter().zip(b).map(|(x, y)| x * y).sum()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn cosine_of_mismatched_dimensions_is_zero() {
// A stale embedding of a different length must not yield a bogus
// (truncated) similarity that could outrank correctly-sized vectors.
assert_eq!(cosine(&[1.0, 0.0, 0.0], &[1.0, 0.0]), 0.0);
}

#[test]
fn cosine_of_identical_unit_vectors_is_one() {
let v = [0.6_f32, 0.8];
assert!((cosine(&v, &v) - 1.0).abs() < 1e-6);
}

/// Sanity check that the candle MiniLM port produces *meaningful* vectors:
/// a query must be closer to a related sentence than an unrelated one, and
/// vectors must be the right shape and L2-normalized.
Expand Down
36 changes: 35 additions & 1 deletion crates/grasp-core/src/extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,31 @@ fn is_question(text: &str) -> bool {
return true;
}
let lower = t.to_lowercase();
// Match the interrogative only at a word boundary, so substrings like
// "somewhat" (contains "what ") or "showhow" don't masquerade as questions.
["why ", "how ", "should ", "what ", "when should"]
.iter()
.any(|k| lower.contains(k))
.any(|k| contains_at_word_boundary(&lower, k))
}

/// Does `needle` occur in `haystack` at a word boundary — i.e. at the start of
/// the string or immediately after a non-alphanumeric character? Avoids the
/// false positives a bare `contains` produces on embedded substrings.
fn contains_at_word_boundary(haystack: &str, needle: &str) -> bool {
let mut from = 0;
while let Some(rel) = haystack[from..].find(needle) {
let abs = from + rel;
let boundary = abs == 0
|| !haystack[..abs]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric());
if boundary {
return true;
}
from = abs + needle.len();
}
false
}

/// Leading sentences of a block of text, up to `max_chars` — a compact summary
Expand Down Expand Up @@ -436,6 +458,18 @@ mod tests {
assert!(chunks.iter().any(|c| c.chunk_type == ChunkType::ErrorResolution));
}

#[test]
fn question_detection_respects_word_boundaries() {
// Real interrogatives at a word boundary are questions.
assert!(is_question("how do we cache the tokens"));
assert!(is_question("what database are we using"));
assert!(is_question("when should we embed the chunks"));
// Embedded substrings must NOT count (this was a false positive: the
// word "somewhat" contains "what ").
assert!(!is_question("this loop is somewhat slow today"));
assert!(!is_question("the showhow demo build finished cleanly"));
}

#[test]
fn extracts_user_question_as_context() {
let entries = vec![Entry::User {
Expand Down
58 changes: 55 additions & 3 deletions crates/grasp-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,21 @@ pub use import::{import_all, import_file, ImportReport};
pub use model::{Chunk, ChunkType, Entry, SearchHit};

use anyhow::Result;
use rusqlite::Connection;
use rusqlite::{Connection, OptionalExtension};

/// Escape SQL `LIKE` wildcards (`%`, `_`) and the escape character (`\`) so a
/// string can be used as a literal prefix in a `LIKE ? ESCAPE '\'` clause —
/// notably so backslashes in Windows transcript paths aren't misread.
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '\\' | '%' | '_') {
out.push('\\');
}
out.push(c);
}
out
}

/// Convenience handle bundling the resolved config with an open DB connection.
pub struct Grasp {
Expand Down Expand Up @@ -161,11 +175,35 @@ impl Grasp {
let removed = self
.conn
.execute("DELETE FROM chunks WHERE project = ?1", [project])?;
// The real import directory, if recorded — transcripts can be imported
// from a custom path (`grasp import --path …`), so `processed_files`
// rows may live outside `claude_projects_dir` (issue #26). Read it
// *before* deleting the project row.
let stored_path: Option<String> = self
.conn
.query_row(
"SELECT path FROM projects WHERE slug = ?1",
[project],
|row| row.get(0),
)
.optional()?;
self.conn
.execute("DELETE FROM projects WHERE slug = ?1", [project])?;
// Remove processed-file records for *this* project only. Match the
// project's transcript directory as an anchored prefix. A bare
// `%slug%` substring also matched sibling projects (e.g. forgetting
// `…-grasp` cleared `…-grasp-core`) and treated `_`/`%` in a slug as
// wildcards — both forced needless re-imports of other projects.
let mut prefix = stored_path
.map(std::path::PathBuf::from)
.unwrap_or_else(|| self.config.claude_projects_dir.join(project))
.to_string_lossy()
.into_owned();
prefix.push(std::path::MAIN_SEPARATOR);
let pattern = format!("{}%", escape_like(&prefix));
self.conn.execute(
"DELETE FROM processed_files WHERE file_path LIKE ?1",
[format!("%{project}%")],
"DELETE FROM processed_files WHERE file_path LIKE ?1 ESCAPE '\\'",
[pattern],
)?;
let dir = self.config.memory_project_dir(project);
if dir.exists() {
Expand Down Expand Up @@ -286,3 +324,17 @@ impl Grasp {
Ok(added.unwrap_or(0))
}
}

#[cfg(test)]
mod tests {
use super::escape_like;

#[test]
fn escape_like_escapes_wildcards_and_backslashes() {
assert_eq!(escape_like("a_b%c"), r"a\_b\%c");
// Windows path separators must be escaped so they stay literal.
assert_eq!(escape_like(r"C:\projects\grasp"), r"C:\\projects\\grasp");
// Ordinary text is untouched.
assert_eq!(escape_like("c--projects-grasp"), "c--projects-grasp");
}
}
Loading