1use std::fmt::Write as _;
14
15use super::error::GraphError;
16use super::llm::LlmProvider;
17use super::types::*;
18use super::GraphMemory;
19use crate::config::DedupBand;
20
21const DEDUP_SYSTEM_PROMPT: &str = r#"You are a deduplication system for a knowledge graph. Given a candidate entity and existing similar entities, decide:
22
231. "skip" — The candidate is a duplicate. It adds no new information.
242. "create" — The candidate is genuinely new despite surface similarity.
253. "merge" — The candidate adds new information to an existing entity. Specify which one.
26
27Return EXACTLY this JSON (no markdown fencing, no explanation):
28
29{
30 "decision": "skip" | "create" | "merge",
31 "target": "Name of existing entity to merge into (only if merge)",
32 "reason": "Brief explanation"
33}
34
35Rules:
36- Same entity with minor name variations (e.g., "ElevenLabs" vs "Eleven Labs"): merge
37- Same concept but genuinely different instances: create
38- Candidate adds meaningful new detail to an existing entity: merge
39- Candidate is less detailed than existing: skip
40- When in doubt between create and merge: prefer create (avoid data loss)"#;
41
42pub enum ResolvedEntity {
44 Created(Entity),
45 Merged(Entity),
46 Skipped,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ResolutionPath {
52 NameMatch,
54 SameEntityBand,
56 NewEntityBand,
58 LlmDecision,
60}
61
62impl ResolutionPath {
63 #[must_use]
65 pub fn used_llm(self) -> bool {
66 matches!(self, Self::LlmDecision)
67 }
68}
69
70pub struct Resolution {
72 pub entity: ResolvedEntity,
73 pub path: ResolutionPath,
74}
75
76impl Resolution {
77 fn new(entity: ResolvedEntity, path: ResolutionPath) -> Self {
78 Self { entity, path }
79 }
80}
81
82pub async fn resolve_entity(
95 gm: &GraphMemory,
96 llm: &dyn LlmProvider,
97 candidate: &ExtractedEntity,
98 session_id: &str,
99) -> Result<Resolution, GraphError> {
100 let config = gm.dedup_config();
101
102 if let Some(existing) = same_named_entity(gm, candidate).await? {
103 let resolved = absorb(gm, &existing, candidate, session_id).await?;
104 return Ok(Resolution::new(resolved, ResolutionPath::NameMatch));
105 }
106
107 let nearest = gm
110 .search(&candidate.abstract_text, config.candidate_limit())
111 .await?;
112
113 let Some(closest) = nearest.first() else {
114 return Ok(Resolution::new(
115 create(gm, candidate, session_id).await?,
116 ResolutionPath::NewEntityBand,
117 ));
118 };
119
120 match config.band(closest.similarity()) {
121 DedupBand::SameEntity if closest.entity.entity_type == candidate.entity_type => {
123 let resolved = absorb(gm, &closest.entity, candidate, session_id).await?;
124 Ok(Resolution::new(resolved, ResolutionPath::SameEntityBand))
125 }
126 DedupBand::NewEntity => Ok(Resolution::new(
127 create(gm, candidate, session_id).await?,
128 ResolutionPath::NewEntityBand,
129 )),
130 _ => {
133 let resolved = resolve_with_llm(gm, llm, candidate, session_id, &nearest).await?;
134 Ok(Resolution::new(resolved, ResolutionPath::LlmDecision))
135 }
136 }
137}
138
139async fn resolve_with_llm(
141 gm: &GraphMemory,
142 llm: &dyn LlmProvider,
143 candidate: &ExtractedEntity,
144 session_id: &str,
145 nearest: &[SearchResult],
146) -> Result<ResolvedEntity, GraphError> {
147 let config = gm.dedup_config();
148 let comparable: Vec<&SearchResult> = nearest
149 .iter()
150 .filter(|r| config.band(r.similarity()) != DedupBand::NewEntity)
151 .collect();
152
153 let user_message = build_dedup_message(candidate, &comparable);
154 let response = llm
155 .complete(DEDUP_SYSTEM_PROMPT, &user_message, 300)
156 .await?;
157
158 match parse_dedup_response(&response)? {
159 DedupDecision::Skip => Ok(ResolvedEntity::Skipped),
160
161 DedupDecision::Create => create(gm, candidate, session_id).await,
162
163 DedupDecision::Merge { target } => match gm.get_entity(&target).await? {
164 Some(target_entity) => absorb(gm, &target_entity, candidate, session_id).await,
165 None => create(gm, candidate, session_id).await,
167 },
168 }
169}
170
171async fn same_named_entity(
181 gm: &GraphMemory,
182 candidate: &ExtractedEntity,
183) -> Result<Option<Entity>, GraphError> {
184 let Some(existing) = gm.get_entity(candidate.name.trim()).await? else {
185 return Ok(None);
186 };
187 Ok((existing.entity_type == candidate.entity_type).then_some(existing))
188}
189
190async fn absorb(
198 gm: &GraphMemory,
199 target: &Entity,
200 candidate: &ExtractedEntity,
201 session_id: &str,
202) -> Result<ResolvedEntity, GraphError> {
203 if !target.mutable {
204 return create(gm, candidate, session_id).await;
205 }
206 if !adds_information(target, candidate) {
207 return Ok(ResolvedEntity::Skipped);
208 }
209 Ok(ResolvedEntity::Merged(
210 merge_entity(gm, target, candidate).await?,
211 ))
212}
213
214fn adds_information(target: &Entity, candidate: &ExtractedEntity) -> bool {
217 let longer_abstract = candidate.abstract_text.len() > target.abstract_text.len();
218 let new_overview = candidate
219 .overview
220 .as_ref()
221 .is_some_and(|overview| !target.overview.contains(overview.as_str()));
222 let new_content = candidate.content.as_ref().is_some_and(|content| {
223 target
224 .content
225 .as_ref()
226 .is_none_or(|held| !held.contains(content.as_str()))
227 });
228 let new_attributes = candidate
229 .attributes
230 .as_ref()
231 .is_some_and(|attrs| target.attributes.as_ref() != Some(attrs));
232
233 longer_abstract || new_overview || new_content || new_attributes
234}
235
236async fn create(
237 gm: &GraphMemory,
238 candidate: &ExtractedEntity,
239 session_id: &str,
240) -> Result<ResolvedEntity, GraphError> {
241 let entity = gm.add_entity(candidate.to_new_entity(session_id)).await?;
242 Ok(ResolvedEntity::Created(entity))
243}
244
245async fn merge_entity(
253 gm: &GraphMemory,
254 target: &Entity,
255 candidate: &ExtractedEntity,
256) -> Result<Entity, GraphError> {
257 let new_abstract = if candidate.abstract_text.len() > target.abstract_text.len() {
258 Some(candidate.abstract_text.clone())
259 } else {
260 None
261 };
262
263 let new_overview = candidate.overview.as_ref().map(|co| {
264 if target.overview.is_empty() {
265 co.clone()
266 } else {
267 format!("{}\n\n{}", target.overview, co)
268 }
269 });
270
271 let new_content = candidate.content.as_ref().map(|cc| match &target.content {
272 Some(tc) => format!("{tc}\n\n{cc}"),
273 None => cc.clone(),
274 });
275
276 let new_attributes = candidate
277 .attributes
278 .as_ref()
279 .map(|ca| match &target.attributes {
280 Some(ta) => merge_json_objects(ta, ca),
281 None => ca.clone(),
282 });
283
284 let updates = EntityUpdate {
285 abstract_text: new_abstract,
286 overview: new_overview,
287 content: new_content,
288 attributes: new_attributes,
289 };
290
291 gm.update_entity(&target.id_string(), updates).await
292}
293
294fn build_dedup_message(candidate: &ExtractedEntity, similar: &[&SearchResult]) -> String {
295 let mut msg = format!(
296 "CANDIDATE:\n Name: {}\n Type: {}\n Abstract: {}\n\nEXISTING SIMILAR ENTITIES:\n",
297 candidate.name, candidate.entity_type, candidate.abstract_text
298 );
299 for (i, r) in similar.iter().enumerate() {
300 let _ = write!(
303 msg,
304 "\n{}. Name: {} (similarity: {:.3})\n Type: {}\n Abstract: {}\n",
305 i + 1,
306 r.entity.name,
307 r.similarity(),
308 r.entity.entity_type,
309 r.entity.abstract_text
310 );
311 }
312 msg
313}
314
315pub fn parse_dedup_response(text: &str) -> Result<DedupDecision, GraphError> {
317 let cleaned = strip_markdown_fencing(text);
318
319 let v: serde_json::Value = serde_json::from_str(&cleaned).map_err(|e| {
320 if let Some(json_str) = extract_json_object(&cleaned) {
322 if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
323 return parse_decision_value(&v)
324 .err()
325 .unwrap_or_else(|| GraphError::Parse(e.to_string()));
326 }
327 }
328 GraphError::Parse(format!("dedup response not valid JSON: {e}"))
329 })?;
330
331 parse_decision_value(&v)
332}
333
334fn parse_decision_value(v: &serde_json::Value) -> Result<DedupDecision, GraphError> {
335 let decision = v
336 .get("decision")
337 .and_then(|d| d.as_str())
338 .ok_or_else(|| GraphError::Parse("missing 'decision' field".into()))?;
339
340 match decision {
341 "skip" => Ok(DedupDecision::Skip),
342 "create" => Ok(DedupDecision::Create),
343 "merge" => {
344 let target = v
345 .get("target")
346 .and_then(|t| t.as_str())
347 .ok_or_else(|| GraphError::Parse("merge decision missing 'target' field".into()))?;
348 Ok(DedupDecision::Merge {
349 target: target.to_string(),
350 })
351 }
352 other => Err(GraphError::Parse(format!("unknown decision: {other}"))),
353 }
354}
355
356use super::util::{extract_json_object, merge_json_objects, strip_markdown_fencing};
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn parse_skip_decision() {
364 let json = r#"{"decision": "skip", "reason": "duplicate"}"#;
365 let decision = parse_dedup_response(json).unwrap();
366 assert_eq!(decision, DedupDecision::Skip);
367 }
368
369 #[test]
370 fn parse_create_decision() {
371 let json = r#"{"decision": "create", "reason": "genuinely new"}"#;
372 let decision = parse_dedup_response(json).unwrap();
373 assert_eq!(decision, DedupDecision::Create);
374 }
375
376 #[test]
377 fn parse_merge_decision() {
378 let json = r#"{"decision": "merge", "target": "Rust", "reason": "same entity"}"#;
379 let decision = parse_dedup_response(json).unwrap();
380 assert_eq!(
381 decision,
382 DedupDecision::Merge {
383 target: "Rust".into()
384 }
385 );
386 }
387
388 #[test]
389 fn parse_with_fencing() {
390 let json = "```json\n{\"decision\": \"skip\", \"reason\": \"dup\"}\n```";
391 let decision = parse_dedup_response(json).unwrap();
392 assert_eq!(decision, DedupDecision::Skip);
393 }
394
395 fn stored(abstract_text: &str, overview: &str, content: Option<&str>) -> Entity {
396 Entity {
397 id: serde_json::json!("entity:one"),
398 name: "Biscuit".into(),
399 entity_type: EntityType::Person,
400 abstract_text: abstract_text.into(),
401 overview: overview.into(),
402 content: content.map(String::from),
403 attributes: None,
404 embedding: None,
405 mutable: true,
406 access_count: 0,
407 utility_score: 0.5,
408 utility_updates: 0,
409 created_at: serde_json::json!("2026-01-01T00:00:00Z"),
410 updated_at: serde_json::json!("2026-01-01T00:00:00Z"),
411 source: None,
412 }
413 }
414
415 fn extracted(abstract_text: &str, overview: Option<&str>) -> ExtractedEntity {
416 ExtractedEntity {
417 name: "Biscuit".into(),
418 entity_type: EntityType::Person,
419 abstract_text: abstract_text.into(),
420 overview: overview.map(String::from),
421 content: None,
422 attributes: None,
423 }
424 }
425
426 #[test]
428 fn a_candidate_repeating_what_is_stored_adds_nothing() {
429 let target = stored("A golden retriever", "Adopted in May 2023", None);
430 let candidate = extracted("A golden retriever", Some("Adopted in May 2023"));
431 assert!(!adds_information(&target, &candidate));
432 }
433
434 #[test]
435 fn a_longer_abstract_is_information() {
436 let target = stored("A dog", "", None);
437 let candidate = extracted("A golden retriever named Biscuit", None);
438 assert!(adds_information(&target, &candidate));
439 }
440
441 #[test]
442 fn an_unseen_overview_is_information() {
443 let target = stored("A golden retriever", "Adopted in May 2023", None);
444 let candidate = extracted("A golden retriever", Some("Finished puppy class"));
445 assert!(adds_information(&target, &candidate));
446 }
447
448 #[test]
449 fn content_the_target_already_holds_is_not_information() {
450 let target = stored("A golden retriever", "", Some("Session 1\n\nSession 2"));
451 let mut candidate = extracted("A golden retriever", None);
452 candidate.content = Some("Session 2".into());
453 assert!(!adds_information(&target, &candidate));
454
455 candidate.content = Some("Session 3".into());
456 assert!(adds_information(&target, &candidate));
457 }
458
459 #[test]
460 fn only_the_llm_path_counts_as_a_model_call() {
461 assert!(ResolutionPath::LlmDecision.used_llm());
462 assert!(!ResolutionPath::NameMatch.used_llm());
463 assert!(!ResolutionPath::SameEntityBand.used_llm());
464 assert!(!ResolutionPath::NewEntityBand.used_llm());
465 }
466
467 #[test]
470 fn the_dedup_prompt_shows_similarity_not_the_blended_score() {
471 let result = SearchResult {
472 entity: stored("A golden retriever", "", None),
473 score: 0.71,
474 distance: 0.2,
475 };
476 let msg = build_dedup_message(&extracted("A dog", None), &[&result]);
477 assert!(msg.contains("similarity: 0.800"), "{msg}");
478 assert!(!msg.contains("0.710"), "{msg}");
479 }
480
481 #[test]
482 fn merge_json_objects_test() {
483 let base = serde_json::json!({"a": 1, "b": 2});
484 let overlay = serde_json::json!({"b": 3, "c": 4});
485 let merged = merge_json_objects(&base, &overlay);
486 assert_eq!(merged, serde_json::json!({"a": 1, "b": 3, "c": 4}));
487 }
488}