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_ids,
12 delete_relationships_by_relation, find_dangling_relationship_ids, find_entity_id,
13 find_orphan_entity_ids, find_relationship, increment_degree, link_memory_entity,
14 link_memory_relationship, list_entity_names_by_relation, recalculate_degree,
15 unlink_memory_entity, RelationshipRow,
16};
17
18use crate::embedder::f32_to_bytes;
19use crate::entity_type::normalize_entity_type;
20use crate::errors::AppError;
21use crate::parsers::normalize_entity_name;
22use crate::storage::utils::with_busy_retry;
23use rusqlite::{params, Connection};
24use serde::{Deserialize, Serialize};
25
26#[derive(Debug, Serialize, Deserialize, Clone)]
31#[serde(deny_unknown_fields)]
32pub struct NewEntity {
33 pub name: String,
35 #[serde(alias = "type")]
44 pub entity_type: String,
45 pub description: Option<String>,
47}
48
49#[derive(Debug, Serialize, Deserialize, Clone)]
54#[serde(deny_unknown_fields)]
55pub struct NewRelationship {
56 #[serde(alias = "from")]
58 pub source: String,
59 #[serde(alias = "to")]
61 pub target: String,
62 #[serde(alias = "type")]
64 pub relation: String,
65 #[serde(alias = "weight", default = "default_relationship_strength")]
69 pub strength: f64,
70 pub description: Option<String>,
72}
73
74fn default_relationship_strength() -> f64 {
75 crate::constants::DEFAULT_RELATION_WEIGHT
76}
77
78pub fn validate_entity_name(name: &str) -> Result<(), AppError> {
87 if name.len() < 2 {
88 return Err(AppError::Validation(
89 crate::i18n::validation::entity_name_too_short(name),
90 ));
91 }
92 if name.contains('\n') || name.contains('\r') {
93 return Err(AppError::Validation(
94 "entity name must not contain newline characters".to_string(),
95 ));
96 }
97 if name.chars().all(|c| c.is_ascii_digit()) {
101 return Err(AppError::Validation(
102 crate::i18n::validation::entity_name_purely_numeric(name),
103 ));
104 }
105 if name.len() <= 4
106 && name
107 .chars()
108 .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
109 {
110 return Err(AppError::Validation(
111 crate::i18n::validation::entity_name_all_caps_noise(name),
112 ));
113 }
114 Ok(())
115}
116
117#[derive(Debug, Clone)]
119pub struct FuzzyEntityMatch {
120 pub id: i64,
122 pub name: String,
124 pub score: f64,
126}
127
128pub fn entity_name_similarity(query: &str, name: &str) -> f64 {
134 let q = query.trim().to_ascii_lowercase();
135 let n = name.trim().to_ascii_lowercase();
136 if q.is_empty() || n.is_empty() {
137 return 0.0;
138 }
139 if q == n {
140 return 1.0;
141 }
142 if n.starts_with(&q) {
144 let rest = &n[q.len()..];
145 if rest.is_empty()
146 || rest.starts_with('-')
147 || rest.starts_with('_')
148 || rest.starts_with(' ')
149 {
150 return 0.95;
151 }
152 return 0.88;
154 }
155 if q.starts_with(&n) && n.len() >= 3 {
156 return 0.80;
157 }
158 let first_token = n
159 .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
160 .next()
161 .unwrap_or(n.as_str());
162 if first_token == q {
163 return 0.92;
164 }
165 if n.contains(&q) && q.len() >= 3 {
166 return 0.82;
167 }
168 rapidfuzz::distance::jaro_winkler::normalized_similarity(q.chars(), n.chars())
169}
170
171pub fn suggest_entity_names(
176 conn: &Connection,
177 namespace: &str,
178 query: &str,
179 limit: usize,
180 min_score: f64,
181) -> Result<Vec<FuzzyEntityMatch>, AppError> {
182 let entities = list_entities(conn, Some(namespace))?;
183 let mut scored: Vec<FuzzyEntityMatch> = entities
184 .into_iter()
185 .filter_map(|e| {
186 let score = entity_name_similarity(query, &e.name);
187 if score >= min_score {
188 Some(FuzzyEntityMatch {
189 id: e.id,
190 name: e.name,
191 score,
192 })
193 } else {
194 None
195 }
196 })
197 .collect();
198 scored.sort_by(|a, b| {
199 b.score
200 .partial_cmp(&a.score)
201 .unwrap_or(std::cmp::Ordering::Equal)
202 .then_with(|| a.name.cmp(&b.name))
203 });
204 scored.truncate(limit.max(1));
205 Ok(scored)
206}
207
208pub fn resolve_entity_fuzzy(
217 conn: &Connection,
218 namespace: &str,
219 name: &str,
220 auto_fuzzy: bool,
221) -> Result<Option<(i64, String, bool)>, AppError> {
222 if let Some(id) = find_entity_id(conn, namespace, name)? {
223 return Ok(Some((id, name.to_string(), false)));
224 }
225 let normalized = crate::parsers::normalize_entity_name(name);
228 if normalized != name {
229 if let Some(id) = find_entity_id(conn, namespace, &normalized)? {
230 return Ok(Some((id, normalized, false)));
231 }
232 }
233 if !auto_fuzzy {
234 return Ok(None);
235 }
236 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.75)?;
237 if suggestions.is_empty() {
238 return Ok(None);
239 }
240 let top = &suggestions[0];
241 let clear_winner =
242 top.score >= 0.90 && (suggestions.len() == 1 || top.score - suggestions[1].score >= 0.05);
243 let single_strong = suggestions.len() == 1 && top.score >= 0.85;
244 if clear_winner || single_strong {
245 tracing::warn!(
246 target: "entities",
247 query = %name,
248 resolved = %top.name,
249 score = top.score,
250 "fuzzy entity resolution: exact match failed; using best candidate"
251 );
252 return Ok(Some((top.id, top.name.clone(), true)));
253 }
254 Ok(None)
255}
256
257pub fn entity_not_found_with_suggestions(
259 conn: &Connection,
260 namespace: &str,
261 name: &str,
262) -> AppError {
263 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.70).unwrap_or_default();
264 if suggestions.is_empty() {
265 return AppError::NotFound(
266 crate::i18n::validation::entity_named_not_found_in_namespace(name, namespace),
267 );
268 }
269 let list: Vec<String> = suggestions
270 .iter()
271 .map(|s| format!("{} (score={:.2})", s.name, s.score))
272 .collect();
273 AppError::NotFound(
274 crate::i18n::validation::entity_named_not_found_with_suggestions(
275 name,
276 namespace,
277 &list.join(", "),
278 ),
279 )
280}
281
282pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
291 validate_entity_name(&e.name)?;
294 let normalized_name = normalize_entity_name(&e.name);
296 if normalized_name.chars().count() < 2 {
299 return Err(AppError::Validation(
300 crate::i18n::validation::entity_name_normalizes_too_short(&e.name, &normalized_name),
301 ));
302 }
303 let normalized_type = normalize_entity_type(&e.entity_type)?;
306 conn.execute(
307 "INSERT INTO entities (namespace, name, type, description)
308 VALUES (?1, ?2, ?3, ?4)
309 ON CONFLICT(namespace, name) DO UPDATE SET
310 type = excluded.type,
311 description = COALESCE(excluded.description, entities.description),
312 updated_at = unixepoch()",
313 params![namespace, normalized_name, normalized_type, e.description],
314 )?;
315 let id: i64 = conn.query_row(
316 "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
317 params![namespace, normalized_name],
318 |r| r.get(0),
319 )?;
320 Ok(id)
321}
322
323pub fn upsert_entity_preserving_type(
348 conn: &Connection,
349 namespace: &str,
350 e: &NewEntity,
351) -> Result<i64, AppError> {
352 validate_entity_name(&e.name)?;
353 let normalized_name = normalize_entity_name(&e.name);
354 if normalized_name.chars().count() < 2 {
355 return Err(AppError::Validation(
356 crate::i18n::validation::entity_name_normalizes_too_short(&e.name, &normalized_name),
357 ));
358 }
359 let normalized_type = normalize_entity_type(&e.entity_type)?;
360 conn.execute(
361 "INSERT INTO entities (namespace, name, type, description)
362 VALUES (?1, ?2, ?3, ?4)
363 ON CONFLICT(namespace, name) DO UPDATE SET
364 type = CASE WHEN entities.type = 'concept'
365 THEN excluded.type
366 ELSE entities.type END,
367 description = COALESCE(excluded.description, entities.description),
368 updated_at = unixepoch()",
369 params![namespace, normalized_name, normalized_type, e.description],
370 )?;
371 let id: i64 = conn.query_row(
372 "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
373 params![namespace, normalized_name],
374 |r| r.get(0),
375 )?;
376 Ok(id)
377}
378
379pub fn upsert_entity_vec(
390 conn: &Connection,
391 entity_id: i64,
392 namespace: &str,
393 _entity_type: &str,
394 embedding: &[f32],
395 _name: &str,
396) -> Result<(), AppError> {
397 if embedding.is_empty() {
402 tracing::debug!(
403 entity_id,
404 "empty entity embedding: skipping entity_embeddings row (backfill via enrich re-embed --target entities)"
405 );
406 return Ok(());
407 }
408 let embedding_bytes = f32_to_bytes(embedding);
409 with_busy_retry(|| {
410 conn.execute(
411 "DELETE FROM entity_embeddings WHERE entity_id = ?1",
412 params![entity_id],
413 )?;
414 conn.execute(
415 "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
416 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
417 params![
418 entity_id,
419 namespace,
420 &embedding_bytes,
421 "llm-headless",
422 crate::constants::SQLITE_GRAPHRAG_VERSION,
423 crate::constants::embedding_dim() as i64,
424 ],
425 )?;
426 Ok(())
427 })
428}
429
430pub fn upsert_relationship(
439 conn: &Connection,
440 namespace: &str,
441 source_id: i64,
442 target_id: i64,
443 rel: &NewRelationship,
444) -> Result<i64, AppError> {
445 let relation = crate::parsers::map_to_canonical_relation(&rel.relation);
449 conn.execute(
450 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
451 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
452 ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
453 weight = excluded.weight,
454 description = COALESCE(excluded.description, relationships.description)",
455 params![
456 namespace,
457 source_id,
458 target_id,
459 relation,
460 rel.strength,
461 rel.description
462 ],
463 )?;
464 let id: i64 = conn.query_row(
465 "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
466 params![source_id, target_id, relation],
467 |r| r.get(0),
468 )?;
469 Ok(id)
470}
471
472#[derive(Debug, Serialize, Clone)]
474pub struct EntityNode {
475 pub id: i64,
477 pub name: String,
479 pub namespace: String,
481 pub kind: String,
483 pub description: Option<String>,
489}
490
491pub fn list_entities(
497 conn: &Connection,
498 namespace: Option<&str>,
499) -> Result<Vec<EntityNode>, AppError> {
500 if let Some(ns) = namespace {
501 let mut stmt = conn.prepare_cached(
502 "SELECT id, name, namespace, type, description FROM entities WHERE namespace = ?1 ORDER BY id",
503 )?;
504 let rows = stmt
505 .query_map(params![ns], |r| {
506 Ok(EntityNode {
507 id: r.get(0)?,
508 name: r.get(1)?,
509 namespace: r.get(2)?,
510 kind: r.get(3)?,
511 description: r
512 .get::<_, Option<String>>(4)?
513 .filter(|d| !d.trim().is_empty()),
514 })
515 })?
516 .collect::<Result<Vec<_>, _>>()?;
517 Ok(rows)
518 } else {
519 let mut stmt = conn.prepare_cached(
520 "SELECT id, name, namespace, type, description FROM entities ORDER BY namespace, id",
521 )?;
522 let rows = stmt
523 .query_map([], |r| {
524 Ok(EntityNode {
525 id: r.get(0)?,
526 name: r.get(1)?,
527 namespace: r.get(2)?,
528 kind: r.get(3)?,
529 description: r
530 .get::<_, Option<String>>(4)?
531 .filter(|d| !d.trim().is_empty()),
532 })
533 })?
534 .collect::<Result<Vec<_>, _>>()?;
535 Ok(rows)
536 }
537}
538
539pub fn list_relationships_by_namespace(
545 conn: &Connection,
546 namespace: Option<&str>,
547) -> Result<Vec<RelationshipRow>, AppError> {
548 if let Some(ns) = namespace {
549 let mut stmt = conn.prepare_cached(
550 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
551 FROM relationships r
552 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
553 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
554 ORDER BY r.id",
555 )?;
556 let rows = stmt
557 .query_map(params![ns], |r| {
558 Ok(RelationshipRow {
559 id: r.get(0)?,
560 namespace: r.get(1)?,
561 source_id: r.get(2)?,
562 target_id: r.get(3)?,
563 relation: r.get(4)?,
564 weight: r.get(5)?,
565 description: r.get(6)?,
566 })
567 })?
568 .collect::<Result<Vec<_>, _>>()?;
569 Ok(rows)
570 } else {
571 let mut stmt = conn.prepare_cached(
572 "SELECT id, namespace, source_id, target_id, relation, weight, description
573 FROM relationships ORDER BY id",
574 )?;
575 let rows = stmt
576 .query_map([], |r| {
577 Ok(RelationshipRow {
578 id: r.get(0)?,
579 namespace: r.get(1)?,
580 source_id: r.get(2)?,
581 target_id: r.get(3)?,
582 relation: r.get(4)?,
583 weight: r.get(5)?,
584 description: r.get(6)?,
585 })
586 })?
587 .collect::<Result<Vec<_>, _>>()?;
588 Ok(rows)
589 }
590}
591
592pub fn knn_search(
605 conn: &Connection,
606 embedding: &[f32],
607 namespace: &str,
608 k: usize,
609) -> Result<Vec<(i64, f32)>, AppError> {
610 if embedding.len() != crate::constants::embedding_dim() {
611 return Err(AppError::Embedding(
612 crate::i18n::validation::embedding_knn_search_dim_mismatch(
613 embedding.len(),
614 crate::constants::embedding_dim(),
615 ),
616 ));
617 }
618 let mut stmt = conn.prepare_cached(
619 "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
620 )?;
621 let mut scored: Vec<(i64, f32)> = stmt
622 .query_map(params![namespace], |r| {
623 let id: i64 = r.get(0)?;
624 let bytes: Vec<u8> = r.get(1)?;
625 Ok((id, bytes))
626 })?
627 .filter_map(|row| {
628 row.ok().and_then(|(id, bytes)| {
629 let stored = crate::embedder::bytes_to_f32(&bytes);
630 if stored.len() != embedding.len() {
631 return None;
632 }
633 let score = crate::similarity::cosine_similarity(embedding, &stored);
634 Some((id, score))
635 })
636 })
637 .collect();
638 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
641 scored.truncate(k);
642 Ok(scored)
643}
644
645#[cfg(test)]
648#[path = "entity_crud_tests.rs"]
649mod crud_tests;
650#[cfg(test)]
651#[path = "entity_name_validation_tests.rs"]
652mod name_validation_tests;
653#[cfg(test)]
654#[path = "entity_relationship_tests.rs"]
655mod relationship_tests;
656#[cfg(test)]
657#[path = "entity_test_fixtures.rs"]
658mod test_fixtures;
659#[cfg(test)]
660#[path = "entity_vector_tests.rs"]
661mod vector_tests;