1mod merge;
8
9pub use merge::{
10 clear_memory_graph_bindings, count_relationships_by_relation, create_or_fetch_relationship,
11 delete_entities_by_ids, delete_relationship_by_id, delete_relationships_by_relation,
12 find_entity_id, find_orphan_entity_ids, find_relationship, increment_degree,
13 link_memory_entity, link_memory_relationship, list_entity_names_by_relation,
14 recalculate_degree, unlink_memory_entity, RelationshipRow,
15};
16
17use crate::embedder::f32_to_bytes;
18use crate::entity_type::EntityType;
19use crate::errors::AppError;
20use crate::parsers::normalize_entity_name;
21use crate::storage::utils::with_busy_retry;
22use rusqlite::{params, Connection};
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Serialize, Deserialize, Clone)]
30#[serde(deny_unknown_fields)]
31pub struct NewEntity {
32 pub name: String,
34 #[serde(alias = "type")]
36 pub entity_type: EntityType,
37 pub description: Option<String>,
39}
40
41#[derive(Debug, Serialize, Deserialize, Clone)]
46#[serde(deny_unknown_fields)]
47pub struct NewRelationship {
48 #[serde(alias = "from")]
50 pub source: String,
51 #[serde(alias = "to")]
53 pub target: String,
54 #[serde(alias = "type")]
56 pub relation: String,
57 #[serde(alias = "weight", default = "default_relationship_strength")]
61 pub strength: f64,
62 pub description: Option<String>,
64}
65
66fn default_relationship_strength() -> f64 {
67 crate::constants::DEFAULT_RELATION_WEIGHT
68}
69
70pub fn validate_entity_name(name: &str) -> Result<(), AppError> {
79 if name.len() < 2 {
80 return Err(AppError::Validation(crate::i18n::validation::entity_name_too_short(name)));
81 }
82 if name.contains('\n') || name.contains('\r') {
83 return Err(AppError::Validation(
84 "entity name must not contain newline characters".to_string(),
85 ));
86 }
87 if name.chars().all(|c| c.is_ascii_digit()) {
91 return Err(AppError::Validation(crate::i18n::validation::entity_name_purely_numeric(name)));
92 }
93 if name.len() <= 4
94 && name
95 .chars()
96 .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
97 {
98 return Err(AppError::Validation(crate::i18n::validation::entity_name_all_caps_noise(name)));
99 }
100 Ok(())
101}
102
103#[derive(Debug, Clone)]
105pub struct FuzzyEntityMatch {
106 pub id: i64,
108 pub name: String,
110 pub score: f64,
112}
113
114pub fn entity_name_similarity(query: &str, name: &str) -> f64 {
120 let q = query.trim().to_ascii_lowercase();
121 let n = name.trim().to_ascii_lowercase();
122 if q.is_empty() || n.is_empty() {
123 return 0.0;
124 }
125 if q == n {
126 return 1.0;
127 }
128 if n.starts_with(&q) {
130 let rest = &n[q.len()..];
131 if rest.is_empty()
132 || rest.starts_with('-')
133 || rest.starts_with('_')
134 || rest.starts_with(' ')
135 {
136 return 0.95;
137 }
138 return 0.88;
140 }
141 if q.starts_with(&n) && n.len() >= 3 {
142 return 0.80;
143 }
144 let first_token = n
145 .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
146 .next()
147 .unwrap_or(n.as_str());
148 if first_token == q {
149 return 0.92;
150 }
151 if n.contains(&q) && q.len() >= 3 {
152 return 0.82;
153 }
154 rapidfuzz::distance::jaro_winkler::normalized_similarity(q.chars(), n.chars())
155}
156
157pub fn suggest_entity_names(
162 conn: &Connection,
163 namespace: &str,
164 query: &str,
165 limit: usize,
166 min_score: f64,
167) -> Result<Vec<FuzzyEntityMatch>, AppError> {
168 let entities = list_entities(conn, Some(namespace))?;
169 let mut scored: Vec<FuzzyEntityMatch> = entities
170 .into_iter()
171 .filter_map(|e| {
172 let score = entity_name_similarity(query, &e.name);
173 if score >= min_score {
174 Some(FuzzyEntityMatch {
175 id: e.id,
176 name: e.name,
177 score,
178 })
179 } else {
180 None
181 }
182 })
183 .collect();
184 scored.sort_by(|a, b| {
185 b.score
186 .partial_cmp(&a.score)
187 .unwrap_or(std::cmp::Ordering::Equal)
188 .then_with(|| a.name.cmp(&b.name))
189 });
190 scored.truncate(limit.max(1));
191 Ok(scored)
192}
193
194pub fn resolve_entity_fuzzy(
203 conn: &Connection,
204 namespace: &str,
205 name: &str,
206 auto_fuzzy: bool,
207) -> Result<Option<(i64, String, bool)>, AppError> {
208 if let Some(id) = find_entity_id(conn, namespace, name)? {
209 return Ok(Some((id, name.to_string(), false)));
210 }
211 let normalized = crate::parsers::normalize_entity_name(name);
214 if normalized != name {
215 if let Some(id) = find_entity_id(conn, namespace, &normalized)? {
216 return Ok(Some((id, normalized, false)));
217 }
218 }
219 if !auto_fuzzy {
220 return Ok(None);
221 }
222 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.75)?;
223 if suggestions.is_empty() {
224 return Ok(None);
225 }
226 let top = &suggestions[0];
227 let clear_winner =
228 top.score >= 0.90 && (suggestions.len() == 1 || top.score - suggestions[1].score >= 0.05);
229 let single_strong = suggestions.len() == 1 && top.score >= 0.85;
230 if clear_winner || single_strong {
231 tracing::warn!(
232 target: "entities",
233 query = %name,
234 resolved = %top.name,
235 score = top.score,
236 "fuzzy entity resolution: exact match failed; using best candidate"
237 );
238 return Ok(Some((top.id, top.name.clone(), true)));
239 }
240 Ok(None)
241}
242
243pub fn entity_not_found_with_suggestions(
245 conn: &Connection,
246 namespace: &str,
247 name: &str,
248) -> AppError {
249 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.70).unwrap_or_default();
250 if suggestions.is_empty() {
251 return AppError::NotFound(format!(
252 "entity '{name}' not found in namespace '{namespace}'"
253 ));
254 }
255 let list: Vec<String> = suggestions
256 .iter()
257 .map(|s| format!("{} (score={:.2})", s.name, s.score))
258 .collect();
259 AppError::NotFound(format!(
260 "entity '{name}' not found in namespace '{namespace}'. Did you mean: {}? \
261 Re-run with --fuzzy to auto-resolve a clear match, or pass the canonical name.",
262 list.join(", ")
263 ))
264}
265
266pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
275 validate_entity_name(&e.name)?;
278 let normalized_name = normalize_entity_name(&e.name);
280 if normalized_name.chars().count() < 2 {
283 return Err(AppError::Validation(crate::i18n::validation::entity_name_normalizes_too_short(&e.name, &normalized_name)));
284 }
285 conn.execute(
286 "INSERT INTO entities (namespace, name, type, description)
287 VALUES (?1, ?2, ?3, ?4)
288 ON CONFLICT(namespace, name) DO UPDATE SET
289 type = excluded.type,
290 description = COALESCE(excluded.description, entities.description),
291 updated_at = unixepoch()",
292 params![namespace, normalized_name, e.entity_type, e.description],
293 )?;
294 let id: i64 = conn.query_row(
295 "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
296 params![namespace, normalized_name],
297 |r| r.get(0),
298 )?;
299 Ok(id)
300}
301
302pub fn upsert_entity_vec(
313 conn: &Connection,
314 entity_id: i64,
315 namespace: &str,
316 _entity_type: EntityType,
317 embedding: &[f32],
318 _name: &str,
319) -> Result<(), AppError> {
320 if embedding.is_empty() {
325 tracing::debug!(
326 entity_id,
327 "empty entity embedding: skipping entity_embeddings row (backfill via enrich re-embed --target entities)"
328 );
329 return Ok(());
330 }
331 let embedding_bytes = f32_to_bytes(embedding);
332 with_busy_retry(|| {
333 conn.execute(
334 "DELETE FROM entity_embeddings WHERE entity_id = ?1",
335 params![entity_id],
336 )?;
337 conn.execute(
338 "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
339 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
340 params![
341 entity_id,
342 namespace,
343 &embedding_bytes,
344 "llm-headless",
345 crate::constants::SQLITE_GRAPHRAG_VERSION,
346 crate::constants::embedding_dim() as i64,
347 ],
348 )?;
349 Ok(())
350 })
351}
352
353pub fn upsert_relationship(
362 conn: &Connection,
363 namespace: &str,
364 source_id: i64,
365 target_id: i64,
366 rel: &NewRelationship,
367) -> Result<i64, AppError> {
368 conn.execute(
369 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
370 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
371 ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
372 weight = excluded.weight,
373 description = COALESCE(excluded.description, relationships.description)",
374 params![
375 namespace,
376 source_id,
377 target_id,
378 rel.relation,
379 rel.strength,
380 rel.description
381 ],
382 )?;
383 let id: i64 = conn.query_row(
384 "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
385 params![source_id, target_id, rel.relation],
386 |r| r.get(0),
387 )?;
388 Ok(id)
389}
390
391#[derive(Debug, Serialize, Clone)]
393pub struct EntityNode {
394 pub id: i64,
396 pub name: String,
398 pub namespace: String,
400 pub kind: String,
402}
403
404pub fn list_entities(
410 conn: &Connection,
411 namespace: Option<&str>,
412) -> Result<Vec<EntityNode>, AppError> {
413 if let Some(ns) = namespace {
414 let mut stmt = conn.prepare_cached(
415 "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
416 )?;
417 let rows = stmt
418 .query_map(params![ns], |r| {
419 Ok(EntityNode {
420 id: r.get(0)?,
421 name: r.get(1)?,
422 namespace: r.get(2)?,
423 kind: r.get(3)?,
424 })
425 })?
426 .collect::<Result<Vec<_>, _>>()?;
427 Ok(rows)
428 } else {
429 let mut stmt = conn.prepare_cached(
430 "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
431 )?;
432 let rows = stmt
433 .query_map([], |r| {
434 Ok(EntityNode {
435 id: r.get(0)?,
436 name: r.get(1)?,
437 namespace: r.get(2)?,
438 kind: r.get(3)?,
439 })
440 })?
441 .collect::<Result<Vec<_>, _>>()?;
442 Ok(rows)
443 }
444}
445
446pub fn list_relationships_by_namespace(
452 conn: &Connection,
453 namespace: Option<&str>,
454) -> Result<Vec<RelationshipRow>, AppError> {
455 if let Some(ns) = namespace {
456 let mut stmt = conn.prepare_cached(
457 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
458 FROM relationships r
459 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
460 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
461 ORDER BY r.id",
462 )?;
463 let rows = stmt
464 .query_map(params![ns], |r| {
465 Ok(RelationshipRow {
466 id: r.get(0)?,
467 namespace: r.get(1)?,
468 source_id: r.get(2)?,
469 target_id: r.get(3)?,
470 relation: r.get(4)?,
471 weight: r.get(5)?,
472 description: r.get(6)?,
473 })
474 })?
475 .collect::<Result<Vec<_>, _>>()?;
476 Ok(rows)
477 } else {
478 let mut stmt = conn.prepare_cached(
479 "SELECT id, namespace, source_id, target_id, relation, weight, description
480 FROM relationships ORDER BY id",
481 )?;
482 let rows = stmt
483 .query_map([], |r| {
484 Ok(RelationshipRow {
485 id: r.get(0)?,
486 namespace: r.get(1)?,
487 source_id: r.get(2)?,
488 target_id: r.get(3)?,
489 relation: r.get(4)?,
490 weight: r.get(5)?,
491 description: r.get(6)?,
492 })
493 })?
494 .collect::<Result<Vec<_>, _>>()?;
495 Ok(rows)
496 }
497}
498
499pub fn knn_search(
512 conn: &Connection,
513 embedding: &[f32],
514 namespace: &str,
515 k: usize,
516) -> Result<Vec<(i64, f32)>, AppError> {
517 if embedding.len() != crate::constants::embedding_dim() {
518 return Err(AppError::Embedding(
519 crate::i18n::validation::embedding_knn_search_dim_mismatch(
520 embedding.len(),
521 crate::constants::embedding_dim(),
522 ),
523 ));
524 }
525 let mut stmt = conn.prepare_cached(
526 "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
527 )?;
528 let mut scored: Vec<(i64, f32)> = stmt
529 .query_map(params![namespace], |r| {
530 let id: i64 = r.get(0)?;
531 let bytes: Vec<u8> = r.get(1)?;
532 Ok((id, bytes))
533 })?
534 .filter_map(|row| {
535 row.ok().and_then(|(id, bytes)| {
536 let stored = crate::embedder::bytes_to_f32(&bytes);
537 if stored.len() != embedding.len() {
538 return None;
539 }
540 let score = crate::similarity::cosine_similarity(embedding, &stored);
541 Some((id, score))
542 })
543 })
544 .collect();
545 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
548 scored.truncate(k);
549 Ok(scored)
550}
551
552#[cfg(test)]
553mod tests_a;
554#[cfg(test)]
555mod tests_b;