1use std::sync::Arc;
5
6use dashmap::DashMap;
7use futures::stream::{self, StreamExt as _};
8use schemars::JsonSchema;
9use serde::Deserialize;
10use tokio::sync::Mutex;
11use zeph_common::sanitize::strip_control_chars;
12use zeph_common::text::truncate_to_bytes_ref;
13use zeph_llm::any::AnyProvider;
14use zeph_llm::provider::{LlmProvider as _, Message, Role};
15
16use super::store::GraphStore;
17use super::types::{EntityType, GraphProvenance};
18use crate::embedding_store::EmbeddingStore;
19use crate::error::MemoryError;
20use crate::graph::extractor::ExtractedEntity;
21use crate::types::MessageId;
22use crate::vector_store::{FieldCondition, FieldValue, VectorFilter};
23
24const MIN_ENTITY_NAME_BYTES: usize = 3;
26const MAX_ENTITY_NAME_BYTES: usize = 512;
28pub(crate) const MAX_RELATION_BYTES: usize = 256;
32pub(crate) const MAX_FACT_BYTES: usize = 2048;
36
37pub(crate) fn sanitize_relation(relation: &str) -> String {
40 let cleaned = strip_control_chars(&relation.trim().to_lowercase());
41 truncate_to_bytes_ref(&cleaned, MAX_RELATION_BYTES).to_owned()
42}
43
44pub(crate) fn sanitize_fact(fact: &str) -> String {
46 let cleaned = strip_control_chars(fact.trim());
47 truncate_to_bytes_ref(&cleaned, MAX_FACT_BYTES).to_owned()
48}
49
50const ENTITY_COLLECTION: &str = "zeph_graph_entities";
52
53const EMBED_TIMEOUT_SECS: u64 = 30;
55
56#[derive(Debug, Clone, PartialEq)]
58#[non_exhaustive]
59pub enum ResolutionOutcome {
60 ExactMatch,
62 EmbeddingMatch { score: f32 },
64 LlmDisambiguated,
66 Created,
68}
69
70#[derive(Debug, Deserialize, JsonSchema)]
72struct DisambiguationResponse {
73 same_entity: bool,
74}
75
76type NameLockMap = Arc<DashMap<String, Arc<Mutex<()>>>>;
85
86pub struct EntityResolver<'a> {
87 store: &'a GraphStore,
88 embedding_store: Option<&'a Arc<EmbeddingStore>>,
89 provider: Option<&'a AnyProvider>,
90 similarity_threshold: f32,
91 ambiguous_threshold: f32,
92 name_locks: NameLockMap,
93 fallback_count: Arc<std::sync::atomic::AtomicU64>,
95 collection_ensured: Arc<tokio::sync::OnceCell<()>>,
99 embed_timeout: std::time::Duration,
101 llm_timeout: std::time::Duration,
107}
108
109impl<'a> EntityResolver<'a> {
110 #[must_use]
112 pub fn graph_store(&self) -> &GraphStore {
113 self.store
114 }
115
116 #[must_use]
117 pub fn new(store: &'a GraphStore) -> Self {
118 Self {
119 store,
120 embedding_store: None,
121 provider: None,
122 similarity_threshold: 0.85,
123 ambiguous_threshold: 0.70,
124 name_locks: Arc::new(DashMap::new()),
125 fallback_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
126 collection_ensured: Arc::new(tokio::sync::OnceCell::new()),
127 embed_timeout: std::time::Duration::from_secs(5),
128 llm_timeout: std::time::Duration::from_secs(30),
129 }
130 }
131
132 #[must_use]
136 pub fn with_embed_timeout(mut self, timeout_secs: u64) -> Self {
137 self.embed_timeout = std::time::Duration::from_secs(timeout_secs);
138 self
139 }
140
141 #[must_use]
146 pub fn with_llm_timeout(mut self, timeout_secs: u64) -> Self {
147 self.llm_timeout = std::time::Duration::from_secs(timeout_secs);
148 self
149 }
150
151 #[must_use]
152 pub fn with_embedding_store(mut self, store: &'a Arc<EmbeddingStore>) -> Self {
153 self.embedding_store = Some(store);
154 self
155 }
156
157 #[must_use]
158 pub fn with_provider(mut self, provider: &'a AnyProvider) -> Self {
159 self.provider = Some(provider);
160 self
161 }
162
163 #[must_use]
164 pub fn with_thresholds(mut self, similarity: f32, ambiguous: f32) -> Self {
165 self.similarity_threshold = similarity;
166 self.ambiguous_threshold = ambiguous;
167 self
168 }
169
170 #[must_use]
172 pub fn fallback_count(&self) -> Arc<std::sync::atomic::AtomicU64> {
173 Arc::clone(&self.fallback_count)
174 }
175
176 fn normalize_name(name: &str) -> String {
178 let lowered = name.trim().to_lowercase();
179 let cleaned = strip_control_chars(&lowered);
180 let normalized = truncate_to_bytes_ref(&cleaned, MAX_ENTITY_NAME_BYTES).to_owned();
181 if normalized.len() < cleaned.len() {
182 tracing::debug!(
183 "graph resolver: entity name truncated to {} bytes",
184 MAX_ENTITY_NAME_BYTES
185 );
186 }
187 normalized
188 }
189
190 pub(crate) fn parse_entity_type(entity_type: &str) -> EntityType {
197 entity_type
198 .trim()
199 .to_lowercase()
200 .parse::<EntityType>()
201 .unwrap_or_else(|_| {
202 tracing::debug!(
203 "graph resolver: unknown entity type {:?}, falling back to Concept",
204 entity_type
205 );
206 EntityType::Concept
207 })
208 }
209
210 async fn lock_name(&self, normalized: &str) -> tokio::sync::OwnedMutexGuard<()> {
212 let lock = self
213 .name_locks
214 .entry(normalized.to_owned())
215 .or_insert_with(|| Arc::new(Mutex::new(())))
216 .clone();
217 lock.lock_owned().await
218 }
219
220 pub async fn resolve(
239 &self,
240 name: &str,
241 entity_type: &str,
242 summary: Option<&str>,
243 provenance: Option<&GraphProvenance>,
244 ) -> Result<(i64, ResolutionOutcome), MemoryError> {
245 let normalized = Self::normalize_name(name);
246
247 if normalized.is_empty() {
248 return Err(MemoryError::GraphStore("empty entity name".into()));
249 }
250
251 if normalized.len() < MIN_ENTITY_NAME_BYTES {
252 return Err(MemoryError::GraphStore(format!(
253 "entity name too short: {normalized:?} ({} bytes, min {MIN_ENTITY_NAME_BYTES})",
254 normalized.len()
255 )));
256 }
257
258 let et = Self::parse_entity_type(entity_type);
259
260 let surface_name = name.trim().to_owned();
262
263 let _guard = self.lock_name(&normalized).await;
265
266 if let Some(entity) = self.store.find_entity_by_alias(&normalized, et).await? {
268 self.store
269 .upsert_entity(
270 &surface_name,
271 &entity.canonical_name,
272 et,
273 summary,
274 provenance,
275 )
276 .await?;
277 return Ok((entity.id.0, ResolutionOutcome::ExactMatch));
278 }
279
280 if let Some(entity) = self.store.find_entity(&normalized, et).await? {
282 self.store
283 .upsert_entity(
284 &surface_name,
285 &entity.canonical_name,
286 et,
287 summary,
288 provenance,
289 )
290 .await?;
291 return Ok((entity.id.0, ResolutionOutcome::ExactMatch));
292 }
293
294 if let Some(outcome) = self
296 .resolve_via_embedding(&normalized, name, &surface_name, et, summary, provenance)
297 .await?
298 {
299 return Ok(outcome);
300 }
301
302 let entity_id = self
304 .store
305 .upsert_entity(&surface_name, &normalized, et, summary, provenance)
306 .await?;
307
308 self.register_aliases(entity_id.0, &normalized, name)
309 .await?;
310
311 Ok((entity_id.0, ResolutionOutcome::Created))
312 }
313
314 async fn embed_entity_text(
317 &self,
318 provider: &AnyProvider,
319 normalized: &str,
320 summary: Option<&str>,
321 ) -> Option<Vec<f32>> {
322 let safe_summary = truncate_to_bytes_ref(summary.unwrap_or(""), MAX_FACT_BYTES);
323 let embed_text = format!("{normalized}: {safe_summary}");
324 let embed_result = tokio::time::timeout(
325 std::time::Duration::from_secs(EMBED_TIMEOUT_SECS),
326 provider.embed(&embed_text),
327 )
328 .await;
329 match embed_result {
330 Ok(Ok(v)) => Some(v),
331 Ok(Err(err)) => {
332 self.fallback_count
333 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
334 tracing::warn!(entity_name = %normalized, error = %err,
335 "embed() failed; falling back to exact-match-only entity creation");
336 None
337 }
338 Err(_timeout) => {
339 self.fallback_count
340 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
341 tracing::warn!(entity_name = %normalized,
342 "embed() timed out after {}s; falling back to create new entity",
343 EMBED_TIMEOUT_SECS);
344 None
345 }
346 }
347 }
348
349 #[allow(clippy::too_many_arguments)] async fn handle_ambiguous_candidate(
353 &self,
354 emb_store: &EmbeddingStore,
355 provider: &AnyProvider,
356 payload: &std::collections::HashMap<String, serde_json::Value>,
357 point_id: &str,
358 score: f32,
359 surface_name: &str,
360 normalized: &str,
361 et: EntityType,
362 summary: Option<&str>,
363 _provenance: Option<&GraphProvenance>,
364 ) -> Result<Option<(i64, ResolutionOutcome)>, MemoryError> {
365 let payload_entity_id = payload
366 .get("entity_id")
367 .and_then(serde_json::Value::as_i64)
368 .ok_or_else(|| MemoryError::GraphStore("missing entity_id in payload".into()))?;
369 let existing_name = payload
370 .get("name")
371 .and_then(|v| v.as_str())
372 .unwrap_or("")
373 .to_owned();
374 let existing_summary = payload
375 .get("summary")
376 .and_then(|v| v.as_str())
377 .unwrap_or("")
378 .to_owned();
379 let existing_type = payload
381 .get("entity_type")
382 .and_then(|v| v.as_str())
383 .unwrap_or(et.as_str())
384 .to_owned();
385 let existing_canonical = payload.get("canonical_name").and_then(|v| v.as_str());
386 let existing_summary_str = payload.get("summary").and_then(|v| v.as_str());
387 match self
388 .llm_disambiguate(
389 provider,
390 normalized,
391 et.as_str(),
392 summary.unwrap_or(""),
393 &existing_name,
394 &existing_type,
395 &existing_summary,
396 score,
397 )
398 .await
399 {
400 Some(true) => {
401 let resolved_entity_id = self
402 .merge_entity(
403 emb_store,
404 provider,
405 payload_entity_id,
406 surface_name,
407 normalized,
408 et,
409 summary,
410 existing_canonical,
411 existing_summary_str,
412 Some(point_id),
413 )
414 .await?;
415 Ok(Some((
416 resolved_entity_id,
417 ResolutionOutcome::LlmDisambiguated,
418 )))
419 }
420 Some(false) => Ok(None),
421 None => {
422 tracing::warn!(entity_name = %normalized,
425 "LLM disambiguation failed; falling back to create new entity");
426 Ok(None)
427 }
428 }
429 }
430
431 #[allow(clippy::too_many_lines)]
434 async fn resolve_via_embedding(
435 &self,
436 normalized: &str,
437 original_name: &str,
438 surface_name: &str,
439 et: EntityType,
440 summary: Option<&str>,
441 provenance: Option<&GraphProvenance>,
442 ) -> Result<Option<(i64, ResolutionOutcome)>, MemoryError> {
443 let (Some(emb_store), Some(provider)) = (self.embedding_store, self.provider) else {
444 return Ok(None);
445 };
446
447 let Some(query_vec) = self.embed_entity_text(provider, normalized, summary).await else {
448 return Ok(None);
449 };
450
451 let type_filter = VectorFilter {
452 must: vec![FieldCondition {
453 field: "entity_type".into(),
454 value: FieldValue::Text(et.as_str().to_owned()),
455 }],
456 must_not: vec![],
457 };
458 let candidates = match emb_store
459 .search_collection(ENTITY_COLLECTION, &query_vec, 5, Some(type_filter))
460 .await
461 {
462 Ok(c) => c,
463 Err(err) => {
464 self.fallback_count
465 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
466 tracing::warn!(entity_name = %normalized, error = %err,
467 "Qdrant search failed; falling back to create new entity");
468 return self
469 .create_with_embedding(
470 emb_store,
471 surface_name,
472 normalized,
473 original_name,
474 et,
475 summary,
476 &query_vec,
477 provenance,
478 )
479 .await
480 .map(Some);
481 }
482 };
483
484 if let Some(best) = candidates.first() {
485 let score = best.score;
486 if score >= self.similarity_threshold {
487 let payload_entity_id = best
488 .payload
489 .get("entity_id")
490 .and_then(serde_json::Value::as_i64)
491 .ok_or_else(|| {
492 MemoryError::GraphStore("missing entity_id in payload".into())
493 })?;
494 let existing_canonical =
495 best.payload.get("canonical_name").and_then(|v| v.as_str());
496 let existing_summary = best.payload.get("summary").and_then(|v| v.as_str());
497 let existing_pid = Some(best.id.as_str());
498 let resolved_entity_id = self
499 .merge_entity(
500 emb_store,
501 provider,
502 payload_entity_id,
503 surface_name,
504 normalized,
505 et,
506 summary,
507 existing_canonical,
508 existing_summary,
509 existing_pid,
510 )
511 .await?;
512 return Ok(Some((
513 resolved_entity_id,
514 ResolutionOutcome::EmbeddingMatch { score },
515 )));
516 } else if score >= self.ambiguous_threshold
517 && let Some(result) = self
518 .handle_ambiguous_candidate(
519 emb_store,
520 provider,
521 &best.payload,
522 &best.id,
523 score,
524 surface_name,
525 normalized,
526 et,
527 summary,
528 provenance,
529 )
530 .await?
531 {
532 return Ok(Some(result));
533 }
534 }
536
537 self.create_with_embedding(
539 emb_store,
540 surface_name,
541 normalized,
542 original_name,
543 et,
544 summary,
545 &query_vec,
546 provenance,
547 )
548 .await
549 .map(Some)
550 }
551
552 #[allow(clippy::too_many_arguments)] async fn create_with_embedding(
555 &self,
556 emb_store: &EmbeddingStore,
557 surface_name: &str,
558 normalized: &str,
559 original_name: &str,
560 et: EntityType,
561 summary: Option<&str>,
562 query_vec: &[f32],
563 provenance: Option<&GraphProvenance>,
564 ) -> Result<(i64, ResolutionOutcome), MemoryError> {
565 let entity_id = self
566 .store
567 .upsert_entity(surface_name, normalized, et, summary, provenance)
568 .await?;
569 self.register_aliases(entity_id.0, normalized, original_name)
570 .await?;
571 self.store_entity_embedding(
572 emb_store,
573 entity_id.0,
574 None,
575 normalized,
576 et,
577 summary.unwrap_or(""),
578 query_vec,
579 )
580 .await;
581 Ok((entity_id.0, ResolutionOutcome::Created))
582 }
583
584 async fn register_aliases(
586 &self,
587 entity_id: i64,
588 normalized: &str,
589 original_name: &str,
590 ) -> Result<(), MemoryError> {
591 self.store.add_alias(entity_id, normalized).await?;
592
593 let original_trimmed = original_name.trim().to_lowercase();
596 let original_clean_str = strip_control_chars(&original_trimmed);
597 let original_clean = truncate_to_bytes_ref(&original_clean_str, MAX_ENTITY_NAME_BYTES);
598 if original_clean != normalized {
599 self.store.add_alias(entity_id, original_clean).await?;
600 }
601
602 Ok(())
603 }
604
605 #[allow(clippy::too_many_arguments)]
620 #[allow(clippy::too_many_lines)] async fn merge_entity(
623 &self,
624 emb_store: &EmbeddingStore,
625 provider: &AnyProvider,
626 payload_entity_id: i64,
627 new_surface_name: &str,
628 new_canonical_name: &str,
629 entity_type: EntityType,
630 new_summary: Option<&str>,
631 existing_canonical_name: Option<&str>,
632 existing_summary_payload: Option<&str>,
633 existing_point_id: Option<&str>,
634 ) -> Result<i64, MemoryError> {
635 let (existing_canonical, existing_summary, existing_point_id_owned) =
640 if existing_canonical_name.is_some() && existing_summary_payload.is_some() {
641 (
642 existing_canonical_name
643 .unwrap_or(new_canonical_name)
644 .to_owned(),
645 existing_summary_payload.unwrap_or("").to_owned(),
646 existing_point_id.map(ToOwned::to_owned),
647 )
648 } else {
649 let existing = self.store.find_entity_by_id(payload_entity_id).await?;
654 let canonical = existing_canonical_name.map_or_else(
655 || {
656 existing.as_ref().map_or_else(
657 || new_canonical_name.to_owned(),
658 |e| e.canonical_name.clone(),
659 )
660 },
661 ToOwned::to_owned,
662 );
663 let summary = existing
664 .as_ref()
665 .and_then(|e| e.summary.as_deref())
666 .unwrap_or("")
667 .to_owned();
668 let pid = existing_point_id.map(ToOwned::to_owned).or_else(|| {
669 existing
670 .as_ref()
671 .and_then(|e| e.qdrant_point_id.as_deref())
672 .map(ToOwned::to_owned)
673 });
674 (canonical, summary, pid)
675 };
676
677 let merged_summary = if let Some(new) = new_summary {
678 if !new.is_empty() && !existing_summary.is_empty() {
679 let combined = format!("{existing_summary}; {new}");
680 truncate_to_bytes_ref(&combined, MAX_FACT_BYTES).to_owned()
682 } else if !new.is_empty() {
683 new.to_owned()
684 } else {
685 existing_summary.clone()
686 }
687 } else {
688 existing_summary.clone()
689 };
690
691 let summary_opt = if merged_summary.is_empty() {
692 None
693 } else {
694 Some(merged_summary.as_str())
695 };
696
697 let entity_id = self
704 .store
705 .upsert_entity(
706 new_surface_name,
707 &existing_canonical,
708 entity_type,
709 summary_opt,
710 None,
711 )
712 .await?
713 .0;
714
715 if entity_id != payload_entity_id {
716 tracing::warn!(
722 payload_entity_id,
723 resolved_entity_id = entity_id,
724 canonical_name = %existing_canonical,
725 "graph: Qdrant entity_id payload did not match local SQLite row \
726 (cross-DB or stale resolution) — corrected to the locally authoritative id"
727 );
728 }
729
730 let embed_text = format!("{new_surface_name}: {merged_summary}");
732 let embed_result = tokio::time::timeout(
733 std::time::Duration::from_secs(EMBED_TIMEOUT_SECS),
734 provider.embed(&embed_text),
735 )
736 .await;
737
738 match embed_result {
739 Ok(Ok(vec)) => {
740 self.store_entity_embedding(
741 emb_store,
742 entity_id,
743 existing_point_id_owned.as_deref(),
744 new_surface_name,
745 entity_type,
746 &merged_summary,
747 &vec,
748 )
749 .await;
750 }
751 Ok(Err(err)) => {
752 tracing::warn!(
753 entity_id,
754 error = %err,
755 "merge re-embed failed; Qdrant entry may be stale"
756 );
757 }
758 Err(_) => {
759 tracing::warn!(
760 entity_id,
761 "merge re-embed timed out; Qdrant entry may be stale"
762 );
763 }
764 }
765
766 Ok(entity_id)
767 }
768
769 #[allow(clippy::too_many_arguments)] async fn store_entity_embedding(
778 &self,
779 emb_store: &EmbeddingStore,
780 entity_id: i64,
781 existing_point_id: Option<&str>,
782 name: &str,
783 entity_type: EntityType,
784 summary: &str,
785 vector: &[f32],
786 ) {
787 let collection_ensured = Arc::clone(&self.collection_ensured);
793 if let Err(err) = collection_ensured
794 .get_or_try_init(|| async {
795 emb_store
796 .ensure_named_collection_for_vector(ENTITY_COLLECTION, vector)
797 .await
798 })
799 .await
800 {
801 tracing::error!(
802 error = %err,
803 "failed to ensure entity embedding collection; skipping Qdrant upsert"
804 );
805 return;
806 }
807
808 let payload = serde_json::json!({
809 "entity_id": entity_id,
810 "entity_id_str": entity_id.to_string(),
814 "canonical_name": name,
815 "name": name,
816 "entity_type": entity_type.as_str(),
817 "summary": summary,
818 });
819
820 if let Some(point_id) = existing_point_id {
821 if let Err(err) = emb_store
823 .upsert_to_collection(ENTITY_COLLECTION, point_id, payload, vector.to_vec())
824 .await
825 {
826 tracing::warn!(
827 entity_id,
828 error = %err,
829 "Qdrant upsert (existing point) failed; Qdrant entry may be stale"
830 );
831 }
832 } else {
833 match emb_store
834 .store_to_collection(ENTITY_COLLECTION, payload, vector.to_vec())
835 .await
836 {
837 Ok(point_id) => {
838 if let Err(err) = self
839 .store
840 .set_entity_qdrant_point_id(entity_id, &point_id)
841 .await
842 {
843 tracing::warn!(
844 entity_id,
845 error = %err,
846 "failed to store qdrant_point_id in SQLite"
847 );
848 }
849 }
850 Err(err) => {
851 tracing::warn!(
852 entity_id,
853 error = %err,
854 "Qdrant upsert failed; entity created in SQLite, qdrant_point_id remains NULL"
855 );
856 }
857 }
858 }
859 }
860
861 #[allow(clippy::too_many_arguments)] async fn llm_disambiguate(
866 &self,
867 provider: &AnyProvider,
868 new_name: &str,
869 new_type: &str,
870 new_summary: &str,
871 existing_name: &str,
872 existing_type: &str,
873 existing_summary: &str,
874 score: f32,
875 ) -> Option<bool> {
876 let prompt = format!(
877 "New entity:\n\
878 - Name: <external-data>{new_name}</external-data>\n\
879 - Type: <external-data>{new_type}</external-data>\n\
880 - Summary: <external-data>{new_summary}</external-data>\n\
881 \n\
882 Existing entity:\n\
883 - Name: <external-data>{existing_name}</external-data>\n\
884 - Type: <external-data>{existing_type}</external-data>\n\
885 - Summary: <external-data>{existing_summary}</external-data>\n\
886 \n\
887 Cosine similarity: {score:.2}\n\
888 \n\
889 Are these the same entity? Respond with JSON: {{\"same_entity\": true}} or {{\"same_entity\": false}}"
890 );
891
892 let messages = [
893 Message::from_legacy(
894 Role::System,
895 "You are an entity disambiguation assistant. Given a new entity mention and \
896 an existing entity from the knowledge graph, determine if they refer to the same \
897 real-world entity. Respond only with JSON.",
898 ),
899 Message::from_legacy(Role::User, prompt),
900 ];
901
902 let response = match tokio::time::timeout(self.llm_timeout, provider.chat(&messages)).await
903 {
904 Ok(Ok(r)) => r,
905 Ok(Err(err)) => {
906 self.fallback_count
907 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
908 tracing::warn!(error = %err, "LLM disambiguation chat failed");
909 return None;
910 }
911 Err(_timeout) => {
912 self.fallback_count
913 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
914 tracing::warn!(
915 timeout = ?self.llm_timeout,
916 "LLM disambiguation chat timed out"
917 );
918 return None;
919 }
920 };
921
922 let json_str = extract_json(&response);
924 match serde_json::from_str::<DisambiguationResponse>(json_str) {
925 Ok(parsed) => Some(parsed.same_entity),
926 Err(err) => {
927 self.fallback_count
928 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
929 tracing::warn!(error = %err, response = %response, "failed to parse LLM disambiguation response");
930 None
931 }
932 }
933 }
934
935 pub async fn resolve_batch(
948 &self,
949 entities: &[ExtractedEntity],
950 ) -> Result<Vec<(i64, ResolutionOutcome)>, MemoryError> {
951 if entities.is_empty() {
952 return Ok(Vec::new());
953 }
954
955 let mut results: Vec<Option<(i64, ResolutionOutcome)>> = vec![None; entities.len()];
957
958 let mut stream = stream::iter(entities.iter().enumerate().map(|(i, e)| {
959 let name = e.name.clone();
960 let entity_type = e.entity_type.clone();
961 let summary = e.summary.clone();
962 async move {
963 let result = self
964 .resolve(&name, &entity_type, summary.as_deref(), None)
965 .await;
966 (i, result)
967 }
968 }))
969 .buffer_unordered(4);
970
971 while let Some((i, result)) = stream.next().await {
972 match result {
973 Ok(outcome) => results[i] = Some(outcome),
974 Err(err) => return Err(err),
975 }
976 }
977
978 Ok(results
979 .into_iter()
980 .enumerate()
981 .map(|(i, r)| {
982 r.unwrap_or_else(|| {
983 tracing::warn!(
984 index = i,
985 "resolve_batch: missing result at index — bug in stream collection"
986 );
987 panic!("resolve_batch: missing result at index {i}")
988 })
989 })
990 .collect())
991 }
992
993 pub async fn resolve_edge(
1007 &self,
1008 source_id: i64,
1009 target_id: i64,
1010 relation: &str,
1011 fact: &str,
1012 confidence: f32,
1013 episode_id: Option<MessageId>,
1014 ) -> Result<Option<i64>, MemoryError> {
1015 let normalized_relation = sanitize_relation(relation);
1016 let normalized_fact = sanitize_fact(fact);
1017
1018 let existing_edges = self.store.edges_exact(source_id, target_id).await?;
1020
1021 let matching = existing_edges
1022 .iter()
1023 .find(|e| e.relation == normalized_relation);
1024
1025 if let Some(old) = matching {
1026 if old.fact == normalized_fact {
1027 return Ok(None);
1029 }
1030 self.store.invalidate_edge(old.id).await?;
1032 }
1033
1034 let new_id = self
1035 .store
1036 .insert_edge(
1037 source_id,
1038 target_id,
1039 &normalized_relation,
1040 &normalized_fact,
1041 confidence,
1042 episode_id,
1043 None,
1044 )
1045 .await?;
1046 Ok(Some(new_id))
1047 }
1048
1049 #[allow(clippy::too_many_arguments)] pub async fn resolve_edge_typed(
1071 &self,
1072 source_id: i64,
1073 target_id: i64,
1074 relation: &str,
1075 fact: &str,
1076 confidence: f32,
1077 episode_id: Option<crate::types::MessageId>,
1078 edge_type: crate::graph::EdgeType,
1079 belief_revision: Option<&crate::graph::BeliefRevisionConfig>,
1080 turn_index: Option<u32>,
1081 provenance: Option<&GraphProvenance>,
1082 ) -> Result<Option<i64>, MemoryError> {
1083 let normalized_relation = sanitize_relation(relation);
1084 let normalized_fact = sanitize_fact(fact);
1085
1086 let existing_edges = self.store.edges_exact(source_id, target_id).await?;
1087
1088 let matching = existing_edges
1090 .iter()
1091 .find(|e| e.relation == normalized_relation && e.edge_type == edge_type);
1092
1093 if matching.is_some_and(|old| old.fact == normalized_fact) {
1094 return Ok(None);
1095 }
1096
1097 let superseded_ids: Vec<i64> = if let (Some(cfg), Some(provider)) =
1099 (belief_revision, self.provider)
1100 {
1101 match tokio::time::timeout(self.embed_timeout, provider.embed(&normalized_fact)).await {
1103 Ok(Ok(new_emb)) => {
1104 match crate::graph::belief_revision::find_superseded_edges(
1105 &existing_edges,
1106 &new_emb,
1107 &normalized_relation,
1108 edge_type,
1109 provider,
1110 cfg,
1111 self.embed_timeout,
1112 )
1113 .await
1114 {
1115 Ok(ids) => ids,
1116 Err(err) => {
1117 tracing::warn!(error = %err,
1118 "belief_revision: find_superseded_edges failed, falling back to exact match");
1119 matching.map(|e| vec![e.id]).unwrap_or_default()
1120 }
1121 }
1122 }
1123 Ok(Err(err)) => {
1124 tracing::warn!(error = %err,
1125 "belief_revision: embed new fact failed, falling back to exact match");
1126 matching.map(|e| vec![e.id]).unwrap_or_default()
1127 }
1128 Err(_) => {
1129 tracing::warn!(
1130 "belief_revision: embed new fact timed out, falling back to exact match"
1131 );
1132 matching.map(|e| vec![e.id]).unwrap_or_default()
1133 }
1134 }
1135 } else {
1136 matching.map(|e| vec![e.id]).unwrap_or_default()
1138 };
1139
1140 let new_id = self
1141 .store
1142 .insert_edge_typed(
1143 source_id,
1144 target_id,
1145 &normalized_relation,
1146 &normalized_fact,
1147 confidence,
1148 episode_id,
1149 edge_type,
1150 turn_index,
1151 provenance,
1152 )
1153 .await?;
1154
1155 for old_id in superseded_ids {
1157 if belief_revision.is_some() {
1158 self.store
1159 .invalidate_edge_with_supersession(old_id, new_id)
1160 .await?;
1161 } else {
1162 self.store.invalidate_edge(old_id).await?;
1163 }
1164 }
1165
1166 Ok(Some(new_id))
1167 }
1168}
1169
1170fn extract_json(s: &str) -> &str {
1172 let trimmed = s.trim();
1173 if let Some(inner) = trimmed.strip_prefix("```json")
1175 && let Some(end) = inner.rfind("```")
1176 {
1177 return inner[..end].trim();
1178 }
1179 if let Some(inner) = trimmed.strip_prefix("```")
1180 && let Some(end) = inner.rfind("```")
1181 {
1182 return inner[..end].trim();
1183 }
1184 if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}'))
1186 && start <= end
1187 {
1188 return &trimmed[start..=end];
1189 }
1190 trimmed
1191}
1192
1193#[cfg(test)]
1194mod tests;