use super::*;
pub(super) fn persist_memory_bindings(
conn: &Connection,
namespace: &str,
memory_id: i64,
entities_json: &serde_json::Value,
rels_json: &serde_json::Value,
) -> Result<(usize, usize), AppError> {
#[derive(Deserialize)]
struct EntityItem {
name: String,
entity_type: String,
}
#[derive(Deserialize)]
struct RelItem {
source: String,
target: String,
relation: String,
strength: f64,
}
let extracted_entities: Vec<EntityItem> = serde_json::from_value(entities_json.clone())
.map_err(|e| AppError::Validation(format!("failed to parse entities array: {e}")))?;
let extracted_rels: Vec<RelItem> = serde_json::from_value(rels_json.clone())
.map_err(|e| AppError::Validation(format!("failed to parse relationships array: {e}")))?;
let mut ent_count = 0usize;
let mut rel_count = 0usize;
for item in &extracted_entities {
let entity_type = EntityType::map_to_canonical(&item.entity_type);
match entities::upsert_entity(
conn,
namespace,
&NewEntity {
name: item.name.clone(),
entity_type,
description: None,
},
) {
Ok(eid) => {
let _ = entities::link_memory_entity(conn, memory_id, eid);
ent_count += 1;
}
Err(e) => {
tracing::warn!(
target: "enrich",
entity = %item.name,
error = %e,
"entity upsert skipped"
);
}
}
}
for rel in &extracted_rels {
let normalized = crate::parsers::map_to_canonical_relation(&rel.relation);
let src_name = crate::parsers::normalize_entity_name(&rel.source);
let tgt_name = crate::parsers::normalize_entity_name(&rel.target);
let src_id = entities::find_entity_id(conn, namespace, &src_name);
let tgt_id = entities::find_entity_id(conn, namespace, &tgt_name);
if let (Ok(Some(sid)), Ok(Some(tid))) = (src_id, tgt_id) {
let new_rel = NewRelationship {
source: rel.source.clone(),
target: rel.target.clone(),
relation: normalized,
strength: rel.strength,
description: None,
};
if entities::upsert_relationship(conn, namespace, sid, tid, &new_rel).is_ok() {
rel_count += 1;
}
}
}
Ok((ent_count, rel_count))
}
pub(super) fn persist_entity_description(
conn: &Connection,
entity_id: i64,
description: &str,
) -> Result<(), AppError> {
conn.execute(
"UPDATE entities SET description = ?1, updated_at = unixepoch() WHERE id = ?2",
rusqlite::params![description, entity_id],
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) fn reembed_memory_vector(
conn: &Connection,
namespace: &str,
memory_id: i64,
memory_name: &str,
memory_type: &str,
body: &str,
paths: &crate::paths::AppPaths,
llm_backend: crate::cli::LlmBackendChoice,
embedding_backend: crate::cli::EmbeddingBackendChoice,
) -> Result<(), AppError> {
let snippet: String = body.chars().take(200).collect();
let (embedding, backend_kind) = crate::embedder::embed_passage_with_embedding_choice(
&paths.models,
body,
embedding_backend,
llm_backend,
)?;
record_enrich_backend(backend_kind.as_str());
memories::upsert_vec(
conn,
memory_id,
namespace,
memory_type,
&embedding,
memory_name,
&snippet,
)?;
Ok(())
}
pub(super) fn record_enrich_backend(backend: &'static str) {
if let Ok(mut guard) = ENRICH_LAST_BACKEND.lock() {
*guard = Some(backend);
}
}
pub(super) fn take_enrich_backend() -> Option<&'static str> {
ENRICH_LAST_BACKEND.lock().ok().and_then(|mut g| g.take())
}
pub(super) static ENRICH_LAST_BACKEND: std::sync::Mutex<Option<&'static str>> =
std::sync::Mutex::new(None);
#[allow(clippy::too_many_arguments)]
pub(super) fn persist_enriched_body(
conn: &Connection,
namespace: &str,
memory_id: i64,
memory_name: &str,
new_body: &str,
paths: &crate::paths::AppPaths,
llm_backend: crate::cli::LlmBackendChoice,
embedding_backend: crate::cli::EmbeddingBackendChoice,
) -> Result<(), AppError> {
let (old_name, old_desc, old_body): (String, String, String) = conn.query_row(
"SELECT name, COALESCE(description,''), COALESCE(body,'') FROM memories WHERE id=?1",
rusqlite::params![memory_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
let memory_type: String = conn.query_row(
"SELECT type FROM memories WHERE id=?1",
rusqlite::params![memory_id],
|r| r.get(0),
)?;
let description: String = conn.query_row(
"SELECT COALESCE(description,'') FROM memories WHERE id=?1",
rusqlite::params![memory_id],
|r| r.get(0),
)?;
let body_hash = blake3::hash(new_body.as_bytes()).to_hex().to_string();
let new_memory = memories::NewMemory {
namespace: namespace.to_string(),
name: memory_name.to_string(),
memory_type: memory_type.clone(),
description: description.clone(),
body: new_body.to_string(),
body_hash,
session_id: None,
source: "agent".to_string(),
metadata: serde_json::json!({
"operation": "body-enrich",
"orig_chars": old_body.chars().count(),
"new_chars": new_body.chars().count(),
}),
};
let next_version = crate::storage::versions::next_version(conn, memory_id)?;
let version_metadata = serde_json::json!({
"operation": "body-enrich",
"orig_chars": old_body.chars().count(),
"new_chars": new_body.chars().count(),
})
.to_string();
crate::storage::versions::insert_version(
conn,
memory_id,
next_version,
memory_name,
&memory_type,
&description,
new_body,
&version_metadata,
Some("enrich"),
"edit",
)?;
memories::update(conn, memory_id, &new_memory, None)?;
memories::sync_fts_after_update(
conn,
memory_id,
&old_name,
&old_desc,
&old_body,
&new_memory.name,
&new_memory.description,
&new_memory.body,
)?;
if let Err(e) = reembed_memory_vector(
conn,
namespace,
memory_id,
memory_name,
&memory_type,
new_body,
paths,
llm_backend,
embedding_backend,
) {
tracing::warn!(target: "enrich", memory = %memory_name, error = %e, "vec upsert failed after body-enrich");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn open_test_db() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db");
conn.execute_batch(
"CREATE TABLE memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
namespace TEXT NOT NULL DEFAULT 'global',
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'note',
description TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
body_hash TEXT NOT NULL DEFAULT '',
session_id TEXT,
source TEXT NOT NULL DEFAULT 'agent',
metadata TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
deleted_at INTEGER,
UNIQUE(namespace, name)
);
CREATE TABLE entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
namespace TEXT NOT NULL DEFAULT 'global',
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'concept',
description TEXT,
degree INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(namespace, name)
);
CREATE TABLE memory_entities (
memory_id INTEGER NOT NULL,
entity_id INTEGER NOT NULL,
PRIMARY KEY (memory_id, entity_id)
);
CREATE TABLE relationships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
namespace TEXT NOT NULL DEFAULT 'global',
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relation TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 0.5,
description TEXT,
UNIQUE(source_id, target_id, relation)
);
CREATE TABLE memory_embeddings (
memory_id INTEGER PRIMARY KEY,
namespace TEXT NOT NULL,
embedding BLOB NOT NULL,
source TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
dim INTEGER NOT NULL DEFAULT 384,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);",
)
.expect("schema creation must succeed");
conn
}
#[test]
fn persist_entity_description_updates_db() {
let conn = open_test_db();
conn.execute(
"INSERT INTO entities (namespace, name, type) VALUES ('global', 'tokio-runtime', 'tool')",
[],
)
.unwrap();
let eid: i64 = conn
.query_row(
"SELECT id FROM entities WHERE name='tokio-runtime'",
[],
|r| r.get(0),
)
.unwrap();
persist_entity_description(&conn, eid, "Async runtime for Rust applications").unwrap();
let desc: String = conn
.query_row(
"SELECT description FROM entities WHERE id=?1",
rusqlite::params![eid],
|r| r.get(0),
)
.unwrap();
assert_eq!(desc, "Async runtime for Rust applications");
}
}