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(
81 crate::i18n::validation::entity_name_too_short(name),
82 ));
83 }
84 if name.contains('\n') || name.contains('\r') {
85 return Err(AppError::Validation(
86 "entity name must not contain newline characters".to_string(),
87 ));
88 }
89 if name.chars().all(|c| c.is_ascii_digit()) {
93 return Err(AppError::Validation(
94 crate::i18n::validation::entity_name_purely_numeric(name),
95 ));
96 }
97 if name.len() <= 4
98 && name
99 .chars()
100 .all(|c| c.is_ascii_uppercase() || c == '_' || c == '-')
101 {
102 return Err(AppError::Validation(
103 crate::i18n::validation::entity_name_all_caps_noise(name),
104 ));
105 }
106 Ok(())
107}
108
109#[derive(Debug, Clone)]
111pub struct FuzzyEntityMatch {
112 pub id: i64,
114 pub name: String,
116 pub score: f64,
118}
119
120pub fn entity_name_similarity(query: &str, name: &str) -> f64 {
126 let q = query.trim().to_ascii_lowercase();
127 let n = name.trim().to_ascii_lowercase();
128 if q.is_empty() || n.is_empty() {
129 return 0.0;
130 }
131 if q == n {
132 return 1.0;
133 }
134 if n.starts_with(&q) {
136 let rest = &n[q.len()..];
137 if rest.is_empty()
138 || rest.starts_with('-')
139 || rest.starts_with('_')
140 || rest.starts_with(' ')
141 {
142 return 0.95;
143 }
144 return 0.88;
146 }
147 if q.starts_with(&n) && n.len() >= 3 {
148 return 0.80;
149 }
150 let first_token = n
151 .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
152 .next()
153 .unwrap_or(n.as_str());
154 if first_token == q {
155 return 0.92;
156 }
157 if n.contains(&q) && q.len() >= 3 {
158 return 0.82;
159 }
160 rapidfuzz::distance::jaro_winkler::normalized_similarity(q.chars(), n.chars())
161}
162
163pub fn suggest_entity_names(
168 conn: &Connection,
169 namespace: &str,
170 query: &str,
171 limit: usize,
172 min_score: f64,
173) -> Result<Vec<FuzzyEntityMatch>, AppError> {
174 let entities = list_entities(conn, Some(namespace))?;
175 let mut scored: Vec<FuzzyEntityMatch> = entities
176 .into_iter()
177 .filter_map(|e| {
178 let score = entity_name_similarity(query, &e.name);
179 if score >= min_score {
180 Some(FuzzyEntityMatch {
181 id: e.id,
182 name: e.name,
183 score,
184 })
185 } else {
186 None
187 }
188 })
189 .collect();
190 scored.sort_by(|a, b| {
191 b.score
192 .partial_cmp(&a.score)
193 .unwrap_or(std::cmp::Ordering::Equal)
194 .then_with(|| a.name.cmp(&b.name))
195 });
196 scored.truncate(limit.max(1));
197 Ok(scored)
198}
199
200pub fn resolve_entity_fuzzy(
209 conn: &Connection,
210 namespace: &str,
211 name: &str,
212 auto_fuzzy: bool,
213) -> Result<Option<(i64, String, bool)>, AppError> {
214 if let Some(id) = find_entity_id(conn, namespace, name)? {
215 return Ok(Some((id, name.to_string(), false)));
216 }
217 let normalized = crate::parsers::normalize_entity_name(name);
220 if normalized != name {
221 if let Some(id) = find_entity_id(conn, namespace, &normalized)? {
222 return Ok(Some((id, normalized, false)));
223 }
224 }
225 if !auto_fuzzy {
226 return Ok(None);
227 }
228 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.75)?;
229 if suggestions.is_empty() {
230 return Ok(None);
231 }
232 let top = &suggestions[0];
233 let clear_winner =
234 top.score >= 0.90 && (suggestions.len() == 1 || top.score - suggestions[1].score >= 0.05);
235 let single_strong = suggestions.len() == 1 && top.score >= 0.85;
236 if clear_winner || single_strong {
237 tracing::warn!(
238 target: "entities",
239 query = %name,
240 resolved = %top.name,
241 score = top.score,
242 "fuzzy entity resolution: exact match failed; using best candidate"
243 );
244 return Ok(Some((top.id, top.name.clone(), true)));
245 }
246 Ok(None)
247}
248
249pub fn entity_not_found_with_suggestions(
251 conn: &Connection,
252 namespace: &str,
253 name: &str,
254) -> AppError {
255 let suggestions = suggest_entity_names(conn, namespace, name, 5, 0.70).unwrap_or_default();
256 if suggestions.is_empty() {
257 return AppError::NotFound(format!(
258 "entity '{name}' not found in namespace '{namespace}'"
259 ));
260 }
261 let list: Vec<String> = suggestions
262 .iter()
263 .map(|s| format!("{} (score={:.2})", s.name, s.score))
264 .collect();
265 AppError::NotFound(format!(
266 "entity '{name}' not found in namespace '{namespace}'. Did you mean: {}? \
267 Re-run with --fuzzy to auto-resolve a clear match, or pass the canonical name.",
268 list.join(", ")
269 ))
270}
271
272pub fn upsert_entity(conn: &Connection, namespace: &str, e: &NewEntity) -> Result<i64, AppError> {
281 validate_entity_name(&e.name)?;
284 let normalized_name = normalize_entity_name(&e.name);
286 if normalized_name.chars().count() < 2 {
289 return Err(AppError::Validation(
290 crate::i18n::validation::entity_name_normalizes_too_short(&e.name, &normalized_name),
291 ));
292 }
293 conn.execute(
294 "INSERT INTO entities (namespace, name, type, description)
295 VALUES (?1, ?2, ?3, ?4)
296 ON CONFLICT(namespace, name) DO UPDATE SET
297 type = excluded.type,
298 description = COALESCE(excluded.description, entities.description),
299 updated_at = unixepoch()",
300 params![namespace, normalized_name, e.entity_type, e.description],
301 )?;
302 let id: i64 = conn.query_row(
303 "SELECT id FROM entities WHERE namespace = ?1 AND name = ?2",
304 params![namespace, normalized_name],
305 |r| r.get(0),
306 )?;
307 Ok(id)
308}
309
310pub fn upsert_entity_vec(
321 conn: &Connection,
322 entity_id: i64,
323 namespace: &str,
324 _entity_type: EntityType,
325 embedding: &[f32],
326 _name: &str,
327) -> Result<(), AppError> {
328 if embedding.is_empty() {
333 tracing::debug!(
334 entity_id,
335 "empty entity embedding: skipping entity_embeddings row (backfill via enrich re-embed --target entities)"
336 );
337 return Ok(());
338 }
339 let embedding_bytes = f32_to_bytes(embedding);
340 with_busy_retry(|| {
341 conn.execute(
342 "DELETE FROM entity_embeddings WHERE entity_id = ?1",
343 params![entity_id],
344 )?;
345 conn.execute(
346 "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
347 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
348 params![
349 entity_id,
350 namespace,
351 &embedding_bytes,
352 "llm-headless",
353 crate::constants::SQLITE_GRAPHRAG_VERSION,
354 crate::constants::embedding_dim() as i64,
355 ],
356 )?;
357 Ok(())
358 })
359}
360
361pub fn upsert_relationship(
370 conn: &Connection,
371 namespace: &str,
372 source_id: i64,
373 target_id: i64,
374 rel: &NewRelationship,
375) -> Result<i64, AppError> {
376 conn.execute(
377 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight, description)
378 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
379 ON CONFLICT(source_id, target_id, relation) DO UPDATE SET
380 weight = excluded.weight,
381 description = COALESCE(excluded.description, relationships.description)",
382 params![
383 namespace,
384 source_id,
385 target_id,
386 rel.relation,
387 rel.strength,
388 rel.description
389 ],
390 )?;
391 let id: i64 = conn.query_row(
392 "SELECT id FROM relationships WHERE source_id=?1 AND target_id=?2 AND relation=?3",
393 params![source_id, target_id, rel.relation],
394 |r| r.get(0),
395 )?;
396 Ok(id)
397}
398
399#[derive(Debug, Serialize, Clone)]
401pub struct EntityNode {
402 pub id: i64,
404 pub name: String,
406 pub namespace: String,
408 pub kind: String,
410}
411
412pub fn list_entities(
418 conn: &Connection,
419 namespace: Option<&str>,
420) -> Result<Vec<EntityNode>, AppError> {
421 if let Some(ns) = namespace {
422 let mut stmt = conn.prepare_cached(
423 "SELECT id, name, namespace, type FROM entities WHERE namespace = ?1 ORDER BY id",
424 )?;
425 let rows = stmt
426 .query_map(params![ns], |r| {
427 Ok(EntityNode {
428 id: r.get(0)?,
429 name: r.get(1)?,
430 namespace: r.get(2)?,
431 kind: r.get(3)?,
432 })
433 })?
434 .collect::<Result<Vec<_>, _>>()?;
435 Ok(rows)
436 } else {
437 let mut stmt = conn.prepare_cached(
438 "SELECT id, name, namespace, type FROM entities ORDER BY namespace, id",
439 )?;
440 let rows = stmt
441 .query_map([], |r| {
442 Ok(EntityNode {
443 id: r.get(0)?,
444 name: r.get(1)?,
445 namespace: r.get(2)?,
446 kind: r.get(3)?,
447 })
448 })?
449 .collect::<Result<Vec<_>, _>>()?;
450 Ok(rows)
451 }
452}
453
454pub fn list_relationships_by_namespace(
460 conn: &Connection,
461 namespace: Option<&str>,
462) -> Result<Vec<RelationshipRow>, AppError> {
463 if let Some(ns) = namespace {
464 let mut stmt = conn.prepare_cached(
465 "SELECT r.id, r.namespace, r.source_id, r.target_id, r.relation, r.weight, r.description
466 FROM relationships r
467 JOIN entities se ON se.id = r.source_id AND se.namespace = ?1
468 JOIN entities te ON te.id = r.target_id AND te.namespace = ?1
469 ORDER BY r.id",
470 )?;
471 let rows = stmt
472 .query_map(params![ns], |r| {
473 Ok(RelationshipRow {
474 id: r.get(0)?,
475 namespace: r.get(1)?,
476 source_id: r.get(2)?,
477 target_id: r.get(3)?,
478 relation: r.get(4)?,
479 weight: r.get(5)?,
480 description: r.get(6)?,
481 })
482 })?
483 .collect::<Result<Vec<_>, _>>()?;
484 Ok(rows)
485 } else {
486 let mut stmt = conn.prepare_cached(
487 "SELECT id, namespace, source_id, target_id, relation, weight, description
488 FROM relationships ORDER BY id",
489 )?;
490 let rows = stmt
491 .query_map([], |r| {
492 Ok(RelationshipRow {
493 id: r.get(0)?,
494 namespace: r.get(1)?,
495 source_id: r.get(2)?,
496 target_id: r.get(3)?,
497 relation: r.get(4)?,
498 weight: r.get(5)?,
499 description: r.get(6)?,
500 })
501 })?
502 .collect::<Result<Vec<_>, _>>()?;
503 Ok(rows)
504 }
505}
506
507pub fn knn_search(
520 conn: &Connection,
521 embedding: &[f32],
522 namespace: &str,
523 k: usize,
524) -> Result<Vec<(i64, f32)>, AppError> {
525 if embedding.len() != crate::constants::embedding_dim() {
526 return Err(AppError::Embedding(
527 crate::i18n::validation::embedding_knn_search_dim_mismatch(
528 embedding.len(),
529 crate::constants::embedding_dim(),
530 ),
531 ));
532 }
533 let mut stmt = conn.prepare_cached(
534 "SELECT entity_id, embedding FROM entity_embeddings WHERE namespace = ?1",
535 )?;
536 let mut scored: Vec<(i64, f32)> = stmt
537 .query_map(params![namespace], |r| {
538 let id: i64 = r.get(0)?;
539 let bytes: Vec<u8> = r.get(1)?;
540 Ok((id, bytes))
541 })?
542 .filter_map(|row| {
543 row.ok().and_then(|(id, bytes)| {
544 let stored = crate::embedder::bytes_to_f32(&bytes);
545 if stored.len() != embedding.len() {
546 return None;
547 }
548 let score = crate::similarity::cosine_similarity(embedding, &stored);
549 Some((id, score))
550 })
551 })
552 .collect();
553 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
556 scored.truncate(k);
557 Ok(scored)
558}
559
560#[cfg(test)]
561mod tests_a;
562#[cfg(test)]
563mod tests_b;