1use std::fmt::Write as _;
14
15use super::error::GraphError;
16use super::llm::{LlmProvider, TokenUsage};
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 pub usage: Option<TokenUsage>,
78}
79
80impl Resolution {
81 fn free(entity: ResolvedEntity, path: ResolutionPath) -> Self {
83 Self {
84 entity,
85 path,
86 usage: None,
87 }
88 }
89}
90
91pub async fn resolve_entity(
104 gm: &GraphMemory,
105 llm: &dyn LlmProvider,
106 candidate: &ExtractedEntity,
107 session_id: &str,
108) -> Result<Resolution, GraphError> {
109 let config = gm.dedup_config();
110
111 if let Some(existing) = same_named_entity(gm, candidate).await? {
112 let resolved = absorb(gm, &existing, candidate, session_id).await?;
113 return Ok(Resolution::free(resolved, ResolutionPath::NameMatch));
114 }
115
116 let nearest = gm
119 .search(&candidate.abstract_text, config.candidate_limit())
120 .await?;
121
122 let Some(closest) = nearest.first() else {
123 return Ok(Resolution::free(
124 create(gm, candidate, session_id).await?,
125 ResolutionPath::NewEntityBand,
126 ));
127 };
128
129 match config.band(closest.similarity()) {
130 DedupBand::SameEntity if closest.entity.entity_type == candidate.entity_type => {
132 let resolved = absorb(gm, &closest.entity, candidate, session_id).await?;
133 Ok(Resolution::free(resolved, ResolutionPath::SameEntityBand))
134 }
135 DedupBand::NewEntity => Ok(Resolution::free(
136 create(gm, candidate, session_id).await?,
137 ResolutionPath::NewEntityBand,
138 )),
139 _ => {
142 let (entity, usage) =
143 resolve_with_llm(gm, llm, candidate, session_id, &nearest).await?;
144 Ok(Resolution {
145 entity,
146 path: ResolutionPath::LlmDecision,
147 usage,
148 })
149 }
150 }
151}
152
153async fn resolve_with_llm(
155 gm: &GraphMemory,
156 llm: &dyn LlmProvider,
157 candidate: &ExtractedEntity,
158 session_id: &str,
159 nearest: &[SearchResult],
160) -> Result<(ResolvedEntity, Option<TokenUsage>), GraphError> {
161 let config = gm.dedup_config();
162 let comparable: Vec<&SearchResult> = nearest
163 .iter()
164 .filter(|r| config.band(r.similarity()) != DedupBand::NewEntity)
165 .collect();
166
167 let user_message = build_dedup_message(candidate, &comparable);
168 let completion = llm
169 .complete_measured(DEDUP_SYSTEM_PROMPT, &user_message, 300)
170 .await?;
171
172 let resolved = match parse_dedup_response(&completion.text)? {
173 DedupDecision::Skip => ResolvedEntity::Skipped,
174
175 DedupDecision::Create => create(gm, candidate, session_id).await?,
176
177 DedupDecision::Merge { target } => match gm.get_entity(&target).await? {
178 Some(target_entity) => absorb(gm, &target_entity, candidate, session_id).await?,
179 None => create(gm, candidate, session_id).await?,
181 },
182 };
183 Ok((resolved, completion.usage))
184}
185
186async fn same_named_entity(
196 gm: &GraphMemory,
197 candidate: &ExtractedEntity,
198) -> Result<Option<Entity>, GraphError> {
199 let Some(existing) = gm.get_entity(candidate.name.trim()).await? else {
200 return Ok(None);
201 };
202 Ok((existing.entity_type == candidate.entity_type).then_some(existing))
203}
204
205async fn absorb(
213 gm: &GraphMemory,
214 target: &Entity,
215 candidate: &ExtractedEntity,
216 session_id: &str,
217) -> Result<ResolvedEntity, GraphError> {
218 if !target.mutable {
219 return create(gm, candidate, session_id).await;
220 }
221 if !adds_information(target, candidate) {
222 return Ok(ResolvedEntity::Skipped);
223 }
224 Ok(ResolvedEntity::Merged(
225 merge_entity(gm, target, candidate).await?,
226 ))
227}
228
229fn adds_information(target: &Entity, candidate: &ExtractedEntity) -> bool {
232 let longer_abstract = candidate.abstract_text.len() > target.abstract_text.len();
233 let new_overview = candidate
234 .overview
235 .as_ref()
236 .is_some_and(|overview| !target.overview.contains(overview.as_str()));
237 let new_content = candidate.content.as_ref().is_some_and(|content| {
238 target
239 .content
240 .as_ref()
241 .is_none_or(|held| !held.contains(content.as_str()))
242 });
243 let new_attributes = candidate
244 .attributes
245 .as_ref()
246 .is_some_and(|attrs| target.attributes.as_ref() != Some(attrs));
247
248 longer_abstract || new_overview || new_content || new_attributes
249}
250
251async fn create(
252 gm: &GraphMemory,
253 candidate: &ExtractedEntity,
254 session_id: &str,
255) -> Result<ResolvedEntity, GraphError> {
256 let entity = gm.add_entity(candidate.to_new_entity(session_id)).await?;
257 Ok(ResolvedEntity::Created(entity))
258}
259
260async fn merge_entity(
268 gm: &GraphMemory,
269 target: &Entity,
270 candidate: &ExtractedEntity,
271) -> Result<Entity, GraphError> {
272 let new_abstract = if candidate.abstract_text.len() > target.abstract_text.len() {
273 Some(candidate.abstract_text.clone())
274 } else {
275 None
276 };
277
278 let new_overview = candidate.overview.as_ref().map(|co| {
279 if target.overview.is_empty() {
280 co.clone()
281 } else {
282 format!("{}\n\n{}", target.overview, co)
283 }
284 });
285
286 let new_content = candidate.content.as_ref().map(|cc| match &target.content {
287 Some(tc) => format!("{tc}\n\n{cc}"),
288 None => cc.clone(),
289 });
290
291 let new_attributes = candidate
292 .attributes
293 .as_ref()
294 .map(|ca| match &target.attributes {
295 Some(ta) => merge_json_objects(ta, ca),
296 None => ca.clone(),
297 });
298
299 let updates = EntityUpdate {
300 abstract_text: new_abstract,
301 overview: new_overview,
302 content: new_content,
303 attributes: new_attributes,
304 };
305
306 gm.update_entity(&target.id_string(), updates).await
307}
308
309fn build_dedup_message(candidate: &ExtractedEntity, similar: &[&SearchResult]) -> String {
310 let mut msg = format!(
311 "CANDIDATE:\n Name: {}\n Type: {}\n Abstract: {}\n\nEXISTING SIMILAR ENTITIES:\n",
312 candidate.name, candidate.entity_type, candidate.abstract_text
313 );
314 for (i, r) in similar.iter().enumerate() {
315 let _ = write!(
318 msg,
319 "\n{}. Name: {} (similarity: {:.3})\n Type: {}\n Abstract: {}\n",
320 i + 1,
321 r.entity.name,
322 r.similarity(),
323 r.entity.entity_type,
324 r.entity.abstract_text
325 );
326 }
327 msg
328}
329
330pub fn parse_dedup_response(text: &str) -> Result<DedupDecision, GraphError> {
332 let cleaned = strip_markdown_fencing(text);
333
334 let v: serde_json::Value = serde_json::from_str(&cleaned).map_err(|e| {
335 if let Some(json_str) = extract_json_object(&cleaned) {
337 if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
338 return parse_decision_value(&v)
339 .err()
340 .unwrap_or_else(|| GraphError::Parse(e.to_string()));
341 }
342 }
343 GraphError::Parse(format!("dedup response not valid JSON: {e}"))
344 })?;
345
346 parse_decision_value(&v)
347}
348
349fn parse_decision_value(v: &serde_json::Value) -> Result<DedupDecision, GraphError> {
350 let decision = v
351 .get("decision")
352 .and_then(|d| d.as_str())
353 .ok_or_else(|| GraphError::Parse("missing 'decision' field".into()))?;
354
355 match decision {
356 "skip" => Ok(DedupDecision::Skip),
357 "create" => Ok(DedupDecision::Create),
358 "merge" => {
359 let target = v
360 .get("target")
361 .and_then(|t| t.as_str())
362 .ok_or_else(|| GraphError::Parse("merge decision missing 'target' field".into()))?;
363 Ok(DedupDecision::Merge {
364 target: target.to_string(),
365 })
366 }
367 other => Err(GraphError::Parse(format!("unknown decision: {other}"))),
368 }
369}
370
371use super::util::{extract_json_object, merge_json_objects, strip_markdown_fencing};
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn parse_skip_decision() {
379 let json = r#"{"decision": "skip", "reason": "duplicate"}"#;
380 let decision = parse_dedup_response(json).unwrap();
381 assert_eq!(decision, DedupDecision::Skip);
382 }
383
384 #[test]
385 fn parse_create_decision() {
386 let json = r#"{"decision": "create", "reason": "genuinely new"}"#;
387 let decision = parse_dedup_response(json).unwrap();
388 assert_eq!(decision, DedupDecision::Create);
389 }
390
391 #[test]
392 fn parse_merge_decision() {
393 let json = r#"{"decision": "merge", "target": "Rust", "reason": "same entity"}"#;
394 let decision = parse_dedup_response(json).unwrap();
395 assert_eq!(
396 decision,
397 DedupDecision::Merge {
398 target: "Rust".into()
399 }
400 );
401 }
402
403 #[test]
404 fn parse_with_fencing() {
405 let json = "```json\n{\"decision\": \"skip\", \"reason\": \"dup\"}\n```";
406 let decision = parse_dedup_response(json).unwrap();
407 assert_eq!(decision, DedupDecision::Skip);
408 }
409
410 fn stored(abstract_text: &str, overview: &str, content: Option<&str>) -> Entity {
411 Entity {
412 id: serde_json::json!("entity:one"),
413 name: "Biscuit".into(),
414 entity_type: EntityType::Person,
415 abstract_text: abstract_text.into(),
416 overview: overview.into(),
417 content: content.map(String::from),
418 attributes: None,
419 embedding: None,
420 mutable: true,
421 access_count: 0,
422 utility_score: 0.5,
423 utility_updates: 0,
424 created_at: serde_json::json!("2026-01-01T00:00:00Z"),
425 updated_at: serde_json::json!("2026-01-01T00:00:00Z"),
426 source: None,
427 }
428 }
429
430 fn extracted(abstract_text: &str, overview: Option<&str>) -> ExtractedEntity {
431 ExtractedEntity {
432 name: "Biscuit".into(),
433 entity_type: EntityType::Person,
434 abstract_text: abstract_text.into(),
435 overview: overview.map(String::from),
436 content: None,
437 attributes: None,
438 }
439 }
440
441 #[test]
443 fn a_candidate_repeating_what_is_stored_adds_nothing() {
444 let target = stored("A golden retriever", "Adopted in May 2023", None);
445 let candidate = extracted("A golden retriever", Some("Adopted in May 2023"));
446 assert!(!adds_information(&target, &candidate));
447 }
448
449 #[test]
450 fn a_longer_abstract_is_information() {
451 let target = stored("A dog", "", None);
452 let candidate = extracted("A golden retriever named Biscuit", None);
453 assert!(adds_information(&target, &candidate));
454 }
455
456 #[test]
457 fn an_unseen_overview_is_information() {
458 let target = stored("A golden retriever", "Adopted in May 2023", None);
459 let candidate = extracted("A golden retriever", Some("Finished puppy class"));
460 assert!(adds_information(&target, &candidate));
461 }
462
463 #[test]
464 fn content_the_target_already_holds_is_not_information() {
465 let target = stored("A golden retriever", "", Some("Session 1\n\nSession 2"));
466 let mut candidate = extracted("A golden retriever", None);
467 candidate.content = Some("Session 2".into());
468 assert!(!adds_information(&target, &candidate));
469
470 candidate.content = Some("Session 3".into());
471 assert!(adds_information(&target, &candidate));
472 }
473
474 #[test]
475 fn only_the_llm_path_counts_as_a_model_call() {
476 assert!(ResolutionPath::LlmDecision.used_llm());
477 assert!(!ResolutionPath::NameMatch.used_llm());
478 assert!(!ResolutionPath::SameEntityBand.used_llm());
479 assert!(!ResolutionPath::NewEntityBand.used_llm());
480 }
481
482 #[test]
485 fn the_dedup_prompt_shows_similarity_not_the_blended_score() {
486 let result = SearchResult {
487 entity: stored("A golden retriever", "", None),
488 score: 0.71,
489 distance: 0.2,
490 };
491 let msg = build_dedup_message(&extracted("A dog", None), &[&result]);
492 assert!(msg.contains("similarity: 0.800"), "{msg}");
493 assert!(!msg.contains("0.710"), "{msg}");
494 }
495
496 #[test]
497 fn merge_json_objects_test() {
498 let base = serde_json::json!({"a": 1, "b": 2});
499 let overlay = serde_json::json!({"b": 3, "c": 4});
500 let merged = merge_json_objects(&base, &overlay);
501 assert_eq!(merged, serde_json::json!({"a": 1, "b": 3, "c": 4}));
502 }
503}