Skip to content
Open
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
16 changes: 9 additions & 7 deletions core/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,18 +302,20 @@ pub fn get_recent_chat_history_with_compaction(
.replace('\n', " "),
);
} else if msg.role == "assistant" {
let assistant_a: String = msg
.content
.chars()
.take(80)
.collect::<String>()
.trim()
.replace('\n', " ");
if let Some(user_q) = current_user.take() {
let assistant_a: String = msg
.content
.chars()
.take(80)
.collect::<String>()
.trim()
.replace('\n', " ");
turn_summaries.push(format!(
"- User: \"{}\" ➔ Assistant: \"{}\"",
user_q, assistant_a
));
} else {
turn_summaries.push(format!("- Assistant: \"{}\"", assistant_a));
}
}
}
Expand Down
48 changes: 6 additions & 42 deletions core/src/embed/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,14 @@ pub fn embed_node_with_config(
EmbedError::InferenceFailed(format!("database failed loading node {node_id}: {err}"))
})?;

let Some((title, mut summary, mut detail, vault_id, sub_vault_id, privacy_tier)) = node else {
let Some((title, summary, detail, vault_id, sub_vault_id, privacy_tier)) = node else {
return Ok(false);
};

let settings = config::get_embedding_settings(conn).map_err(|err| {
EmbedError::InferenceFailed(format!("embedding settings read failed: {err}"))
})?;

let mut title = title;

let effective_tier = crate::resolve_node_effective_privacy(
conn,
&vault_id,
Expand Down Expand Up @@ -132,13 +130,6 @@ pub fn embed_node_with_config(
}
}

if crate::privacy::embedding_uses_stub(&effective_tier) {
let stub = crate::privacy::generate_pointer_stub(&title, node_id);
title = stub;
summary = String::new();
detail = None;
}

let chunks = chunk_node_text(&title, &summary, detail.as_deref(), chunk_config);

let computed_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
Expand Down Expand Up @@ -845,29 +836,29 @@ mod tests {
Some("Changed Detail")
));

// Scenario 6: Privacy tier changes (open -> locked), text remains same
// Scenario 6: Privacy tier changes (open -> redacted), text remains same
assert!(stored_text_columns_changed(
"Title",
"Summary",
Some("Detail"),
false,
false,
"open",
"locked",
"redacted",
"Title",
"Summary",
Some("Detail")
));

// Scenario 7: Privacy tier stays locked, text remains same
// Scenario 7: Privacy tier stays redacted, text remains same
assert!(!stored_text_columns_changed(
"Title",
"Summary",
Some("Detail"),
false,
false,
"locked",
"locked",
"redacted",
"redacted",
"Title",
"Summary",
Some("Detail")
Expand Down Expand Up @@ -947,33 +938,6 @@ mod tests {
)?
.is_none());

// 2. Set up a remote destination and a locked node, with is_unlocked = false.
conn.execute(
"INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_locked', 'Locked Vault', 'locked');",
[],
)?;
conn.execute(
"INSERT INTO nodes (id, vault_id, node_type, title, summary, detail, source, source_type, priority, meta)
VALUES ('n_locked', 'v_locked', 'concept', 'Secret Title', 'Secret Summary', 'Secret Detail', 'test', 'manual', '{}', '{}');",
[],
)?;

// Try embedding n_locked when is_unlocked = false.
// It should embed the stub.
let is_embedded = embed_node(&mut conn, "n_locked", &engine, &cancel, false)?;
assert!(is_embedded);
assert_eq!(engine.calls(), 1);

let inputs = match engine.inputs.lock() {
Ok(guard) => guard,
Err(_) => panic!("Failed to lock inputs"),
};
assert_eq!(inputs.len(), 1);
let stub = crate::privacy::generate_pointer_stub("Secret Title", "n_locked");
assert!(inputs[0].contains(&stub));
assert!(!inputs[0].contains("Secret Summary"));
assert!(!inputs[0].contains("Secret Detail"));

// 3. Set up a redacted node and verify it is skipped and deletes stale vectors.
conn.execute(
"INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_redacted', 'Redacted Vault', 'redacted');",
Expand Down
108 changes: 103 additions & 5 deletions core/src/embed/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ pub fn expand_vault_scope(
})
.map_err(|err| format!("Failed expanding vault scope: {err}"))?;

let mut expanded = HashSet::new();
let mut expanded = vaults.clone();
for r in rows {
let v_id = r.map_err(|err| format!("Failed reading expanded vault ID: {err}"))?;
expanded.insert(v_id);
Expand Down Expand Up @@ -140,9 +140,12 @@ pub fn find_top_n_similar(
let (query_str, params_vec) = if let Some(vaults) = target_vaults {
let placeholders = vec!["?"; vaults.len()].join(", ");
let query = format!(
"SELECT n.id, n.vault_id, n.title, n.summary, n.node_type, ne.embedding
"SELECT n.id, n.vault_id, n.title, n.summary, n.node_type, ne.embedding,
n.sub_vault_id,
COALESCE(o.privacy_tier, n.privacy_tier) AS node_privacy_tier
FROM node_embeddings ne
JOIN nodes n ON ne.node_id = n.id
LEFT JOIN privacy_overrides o ON n.id = o.node_id
LEFT JOIN vaults v ON n.vault_id = v.id
LEFT JOIN vaults sv ON n.sub_vault_id = sv.id
WHERE ne.chunk_type = 'primary'
Expand All @@ -159,9 +162,12 @@ pub fn find_top_n_similar(
p.extend(vaults.iter().cloned());
(query, p)
} else {
let query = "SELECT n.id, n.vault_id, n.title, n.summary, n.node_type, ne.embedding
let query = "SELECT n.id, n.vault_id, n.title, n.summary, n.node_type, ne.embedding,
n.sub_vault_id,
COALESCE(o.privacy_tier, n.privacy_tier) AS node_privacy_tier
FROM node_embeddings ne
JOIN nodes n ON ne.node_id = n.id
LEFT JOIN privacy_overrides o ON n.id = o.node_id
LEFT JOIN vaults v ON n.vault_id = v.id
LEFT JOIN vaults sv ON n.sub_vault_id = sv.id
WHERE ne.chunk_type = 'primary'
Expand Down Expand Up @@ -194,13 +200,27 @@ pub fn find_top_n_similar(
node_type: row.get(4)?,
};
let embedding_bytes: Vec<u8> = row.get(5)?;
Ok((node, embedding_bytes))
let sub_vault_id: Option<String> = row.get(6)?;
let node_privacy_tier: Option<String> = row.get(7)?;
Ok((node, embedding_bytes, sub_vault_id, node_privacy_tier))
})
.map_err(|err| format!("Failed to execute search query: {}", err))?;

let mut candidates = Vec::new();
for row_res in rows {
let (node, bytes) = row_res.map_err(|err| format!("Failed to read row: {}", err))?;
let (node, bytes, sub_vault_id, node_privacy_tier) =
row_res.map_err(|err| format!("Failed to read row: {}", err))?;

let effective = crate::resolve_node_effective_privacy(
conn,
&node.vault_id,
sub_vault_id.as_deref(),
node_privacy_tier.as_deref(),
)?;

if crate::privacy::embedding_should_skip(&effective) {
continue;
}

match deserialize_f32_vec(&bytes) {
Ok(vec) => {
Expand Down Expand Up @@ -614,4 +634,82 @@ mod tests {

Ok(())
}

#[test]
fn test_find_top_n_similar_nested_vault_waterfall_privacy_filtering(
) -> Result<(), Box<dyn std::error::Error>> {
let conn = setup_test_db()?;
let model = "test-model";
let query = vec![1.0, 0.0, 0.0];

// 1. Create a 5-level nested vault hierarchy
// v_lvl0 (open) -> v_lvl1 (open) -> v_lvl2 (redacted) -> v_lvl3 (open) -> v_lvl4 (open)
conn.execute(
"INSERT INTO vaults (id, name, privacy_tier) VALUES ('v_lvl0', 'Level 0', 'open');",
[],
)?;
conn.execute("INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_lvl1', 'v_lvl0', 'Level 1', 'open');", [])?;
conn.execute("INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_lvl2', 'v_lvl1', 'Level 2', 'redacted');", [])?;
conn.execute("INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_lvl3', 'v_lvl2', 'Level 3', 'open');", [])?;
conn.execute("INSERT INTO vaults (id, parent_vault_id, name, privacy_tier) VALUES ('v_lvl4', 'v_lvl3', 'Level 4', 'open');", [])?;

// 2. Insert nodes at various depths
conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl0', 'v_lvl0', 'concept', 'L0 Note', 'Sum');", [])?;
conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl1', 'v_lvl1', 'concept', 'L1 Note', 'Sum');", [])?;
conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl3', 'v_lvl3', 'concept', 'L3 Note under Redacted Parent', 'Sum');", [])?;
conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl4', 'v_lvl4', 'concept', 'L4 Note under Redacted Ancestor', 'Sum');", [])?;

// Insert a node in v_lvl1 with a direct privacy_overrides record setting it to redacted
conn.execute("INSERT INTO nodes (id, vault_id, node_type, title, summary) VALUES ('n_lvl1_override', 'v_lvl1', 'concept', 'L1 Overridden Note', 'Sum');", [])?;
conn.execute("INSERT INTO privacy_overrides (node_id, privacy_tier) VALUES ('n_lvl1_override', 'redacted');", [])?;

// Upsert embeddings for all nodes
for n_id in ["n_lvl0", "n_lvl1", "n_lvl3", "n_lvl4", "n_lvl1_override"] {
upsert_embedding(
&conn,
&EmbeddingRow {
node_id: n_id.to_string(),
chunk_index: 0,
chunk_type: "primary".to_string(),
model: model.to_string(),
embedding: vec![1.0, 0.0, 0.0],
computed_at: "time".to_string(),
},
)?;
}

// Test: Vector search scoped to root vault v_lvl0
let root_scope = HashSet::from(["v_lvl0".to_string()]);
let results = find_top_n_similar(&conn, &query, model, 10, Some(&root_scope))?;

let result_ids: HashSet<String> = results.into_iter().map(|(n, _)| n.id).collect();

// Open nodes in open vaults MUST be present
assert!(
result_ids.contains("n_lvl0"),
"L0 open node must be returned"
);
assert!(
result_ids.contains("n_lvl1"),
"L1 open node must be returned"
);

// Nodes in/under v_lvl2 (redacted) MUST be excluded by waterfall privacy
assert!(
!result_ids.contains("n_lvl3"),
"L3 node under redacted parent must be excluded"
);
assert!(
!result_ids.contains("n_lvl4"),
"L4 node under redacted ancestor must be excluded"
);

// Node with privacy override = redacted MUST be excluded
assert!(
!result_ids.contains("n_lvl1_override"),
"Node with redacted override must be excluded"
);

Ok(())
}
}
20 changes: 20 additions & 0 deletions core/src/ephemeral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,26 @@ pub fn compute_fallback_text_vector(text: &str) -> Vec<f32> {
vec
}

/// Compute a 384-dimensional embedding vector for an ephemeral query string.
pub fn compute_ephemeral_query_vector(query: &str) -> Vec<f32> {
use crate::embed::engine::EmbedEngine;

if query.trim().is_empty() {
return vec![0.0f32; 384];
}

if let Ok(engine) =
crate::embed::BundledEmbedEngine::new(crate::embed::bundled::DEFAULT_BUNDLED_MODEL_ID, 384)
{
if let Ok(vectors) = engine.embed(&[query.to_string()]) {
if let Some(vec) = vectors.into_iter().next() {
return vec;
}
}
}
compute_fallback_text_vector(query)
}

/// Compute 384-dimensional query embedding vector using bundled GIST ONNX model (or normalized fallback).
pub fn embed_query(query: &str) -> Vec<f32> {
use crate::embed::engine::EmbedEngine;
Expand Down
Loading
Loading