Skip to main content

subcog/hooks/pre_compact/
orchestrator.rs

1//! Capture orchestration for pre-compact hook.
2//!
3//! This module coordinates the capture of candidates with deduplication,
4//! handling the interaction between capture service and deduplication service.
5
6use super::analyzer::CaptureCandidate;
7use super::formatter::{CapturedMemory, SkippedDuplicate};
8use crate::models::{CaptureRequest, Domain, MemoryId};
9use crate::services::CaptureService;
10use crate::services::deduplication::{ContentHasher, Deduplicator, DuplicateReason};
11use std::sync::Arc;
12
13/// Orchestrates capture operations with deduplication support.
14pub struct CaptureOrchestrator {
15    /// Capture service instance.
16    capture: Option<CaptureService>,
17    /// Deduplication service instance (trait object for flexibility).
18    dedup: Option<Arc<dyn Deduplicator>>,
19}
20
21impl CaptureOrchestrator {
22    /// Creates a new orchestrator.
23    #[must_use]
24    pub fn new() -> Self {
25        Self {
26            capture: None,
27            dedup: None,
28        }
29    }
30
31    /// Sets the capture service.
32    #[must_use]
33    pub fn with_capture(mut self, capture: CaptureService) -> Self {
34        self.capture = Some(capture);
35        self
36    }
37
38    /// Sets the deduplication service.
39    #[must_use]
40    pub fn with_deduplication(mut self, dedup: Arc<dyn Deduplicator>) -> Self {
41        self.dedup = Some(dedup);
42        self
43    }
44
45    /// Returns whether deduplication is configured.
46    ///
47    /// This method is primarily used in tests to verify builder configuration.
48    #[must_use]
49    #[cfg_attr(not(test), allow(dead_code))]
50    pub fn has_deduplication(&self) -> bool {
51        self.dedup.is_some()
52    }
53
54    /// Performs the actual capture of candidates.
55    ///
56    /// If a deduplication service is configured, checks each candidate for
57    /// duplicates before capture. Returns both captured memories and skipped duplicates.
58    ///
59    /// **Note**: If no capture service is configured, this method returns empty results
60    /// and logs a debug message. Configure a capture service using [`with_capture`].
61    pub fn capture_candidates(
62        &self,
63        candidates: Vec<CaptureCandidate>,
64    ) -> (Vec<CapturedMemory>, Vec<SkippedDuplicate>) {
65        let Some(capture) = &self.capture else {
66            if !candidates.is_empty() {
67                tracing::debug!(
68                    candidate_count = candidates.len(),
69                    "CaptureOrchestrator: No capture service configured, skipping {} candidates",
70                    candidates.len()
71                );
72            }
73            return (Vec::new(), Vec::new());
74        };
75
76        let mut captured = Vec::new();
77        let mut skipped = Vec::new();
78
79        for candidate in candidates {
80            if candidate.confidence < 0.6 {
81                continue;
82            }
83
84            // Check for duplicates
85            if let Some(skip_info) = self.check_for_duplicate(&candidate) {
86                skipped.push(skip_info);
87                continue;
88            }
89
90            // Capture the candidate
91            let request = CaptureRequest {
92                content: candidate.content.clone(),
93                namespace: candidate.namespace,
94                domain: Domain::default(),
95                tags: vec!["auto-captured".to_string(), "pre-compact".to_string()],
96                source: Some("PreCompactHandler".to_string()),
97                skip_security_check: false,
98                ttl_seconds: None,
99                scope: None, // Use default scope
100                #[cfg(feature = "group-scope")]
101                group_id: None,
102            };
103
104            if let Ok(result) = capture.capture(request.clone()) {
105                self.record_capture_for_dedup(&request.content, &result.memory_id);
106
107                captured.push(CapturedMemory {
108                    memory_id: result.memory_id.to_string(),
109                    namespace: candidate.namespace.as_str().to_string(),
110                    confidence: candidate.confidence,
111                });
112            }
113            // Errors are silently ignored, continue with other candidates
114        }
115
116        (captured, skipped)
117    }
118
119    /// Checks if a candidate is a duplicate and returns skip info if so.
120    ///
121    /// Returns `Some(SkippedDuplicate)` if the candidate should be skipped,
122    /// `None` if it should be captured.
123    fn check_for_duplicate(&self, candidate: &CaptureCandidate) -> Option<SkippedDuplicate> {
124        let dedup = self.dedup.as_ref()?;
125
126        match dedup.check_duplicate(&candidate.content, candidate.namespace) {
127            Ok(result) if result.is_duplicate => {
128                let reason_str = reason_to_str(result.reason);
129                let matched_urn = result.matched_urn.unwrap_or_default();
130
131                tracing::debug!(
132                    namespace = %candidate.namespace.as_str(),
133                    matched_urn = %matched_urn,
134                    reason = reason_str,
135                    "Skipping duplicate candidate"
136                );
137
138                metrics::counter!(
139                    "hook_deduplication_skipped_total",
140                    "hook_type" => "PreCompact",
141                    "namespace" => candidate.namespace.as_str().to_string(),
142                    "reason" => reason_str.to_string()
143                )
144                .increment(1);
145
146                Some(SkippedDuplicate {
147                    reason: reason_str.to_string(),
148                    matched_urn,
149                    similarity_score: result.similarity_score,
150                    namespace: candidate.namespace.as_str().to_string(),
151                })
152            },
153            Ok(_) => None, // Not a duplicate
154            Err(e) => {
155                // Graceful degradation: log error and proceed with capture
156                tracing::warn!(
157                    error = %e,
158                    namespace = %candidate.namespace.as_str(),
159                    "Deduplication check failed, proceeding with capture"
160                );
161                None
162            },
163        }
164    }
165
166    /// Records a successful capture in the deduplication service.
167    fn record_capture_for_dedup(&self, content: &str, memory_id: &MemoryId) {
168        if let Some(dedup) = &self.dedup {
169            let hash = ContentHasher::hash(content);
170            dedup.record_capture(&hash, memory_id);
171        }
172    }
173}
174
175impl Default for CaptureOrchestrator {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181/// Converts a `DuplicateReason` to a string.
182#[must_use]
183pub fn reason_to_str(reason: Option<DuplicateReason>) -> &'static str {
184    reason.map_or("unknown", |r| match r {
185        DuplicateReason::ExactMatch => "exact_match",
186        DuplicateReason::SemanticSimilar => "semantic_similar",
187        DuplicateReason::RecentCapture => "recent_capture",
188    })
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::models::Namespace;
195    use crate::services::deduplication::DuplicateCheckResult;
196
197    #[test]
198    fn test_orchestrator_creation() {
199        let orchestrator = CaptureOrchestrator::new();
200        assert!(!orchestrator.has_deduplication());
201    }
202
203    #[test]
204    fn test_with_deduplication() {
205        struct MockDedup;
206        impl Deduplicator for MockDedup {
207            fn check_duplicate(
208                &self,
209                _content: &str,
210                _namespace: Namespace,
211            ) -> crate::Result<DuplicateCheckResult> {
212                Ok(DuplicateCheckResult::not_duplicate(0))
213            }
214            fn record_capture(&self, _hash: &str, _memory_id: &MemoryId) {}
215        }
216
217        let orchestrator = CaptureOrchestrator::new().with_deduplication(Arc::new(MockDedup));
218        assert!(orchestrator.has_deduplication());
219    }
220
221    #[test]
222    fn test_check_for_duplicate_skips() {
223        struct MockDedupAlwaysDup;
224        impl Deduplicator for MockDedupAlwaysDup {
225            fn check_duplicate(
226                &self,
227                _content: &str,
228                _namespace: Namespace,
229            ) -> crate::Result<DuplicateCheckResult> {
230                Ok(DuplicateCheckResult::exact_match(
231                    MemoryId::new("123"),
232                    "subcog://test/decisions/123".to_string(),
233                    0,
234                ))
235            }
236            fn record_capture(&self, _hash: &str, _memory_id: &MemoryId) {}
237        }
238
239        let orchestrator =
240            CaptureOrchestrator::new().with_deduplication(Arc::new(MockDedupAlwaysDup));
241
242        let candidate = CaptureCandidate {
243            content: "Test content".to_string(),
244            namespace: Namespace::Decisions,
245            confidence: 0.8,
246        };
247
248        let result = orchestrator.check_for_duplicate(&candidate);
249        assert!(result.is_some());
250        let skip = result.unwrap();
251        assert_eq!(skip.reason, "exact_match");
252        assert_eq!(skip.matched_urn, "subcog://test/decisions/123");
253    }
254
255    #[test]
256    fn test_check_for_duplicate_passes() {
257        struct MockDedupNoDup;
258        impl Deduplicator for MockDedupNoDup {
259            fn check_duplicate(
260                &self,
261                _content: &str,
262                _namespace: Namespace,
263            ) -> crate::Result<DuplicateCheckResult> {
264                Ok(DuplicateCheckResult::not_duplicate(0))
265            }
266            fn record_capture(&self, _hash: &str, _memory_id: &MemoryId) {}
267        }
268
269        let orchestrator = CaptureOrchestrator::new().with_deduplication(Arc::new(MockDedupNoDup));
270
271        let candidate = CaptureCandidate {
272            content: "Test content".to_string(),
273            namespace: Namespace::Decisions,
274            confidence: 0.8,
275        };
276
277        let result = orchestrator.check_for_duplicate(&candidate);
278        assert!(result.is_none());
279    }
280
281    #[test]
282    fn test_check_for_duplicate_graceful_degradation() {
283        struct MockDedupError;
284        impl Deduplicator for MockDedupError {
285            fn check_duplicate(
286                &self,
287                _content: &str,
288                _namespace: Namespace,
289            ) -> crate::Result<DuplicateCheckResult> {
290                Err(crate::Error::OperationFailed {
291                    operation: "test".to_string(),
292                    cause: "simulated error".to_string(),
293                })
294            }
295            fn record_capture(&self, _hash: &str, _memory_id: &MemoryId) {}
296        }
297
298        let orchestrator = CaptureOrchestrator::new().with_deduplication(Arc::new(MockDedupError));
299
300        let candidate = CaptureCandidate {
301            content: "Test content".to_string(),
302            namespace: Namespace::Decisions,
303            confidence: 0.8,
304        };
305
306        // Error should result in None (proceed with capture)
307        let result = orchestrator.check_for_duplicate(&candidate);
308        assert!(result.is_none());
309    }
310
311    #[test]
312    fn test_reason_to_str() {
313        assert_eq!(
314            reason_to_str(Some(DuplicateReason::ExactMatch)),
315            "exact_match"
316        );
317        assert_eq!(
318            reason_to_str(Some(DuplicateReason::SemanticSimilar)),
319            "semantic_similar"
320        );
321        assert_eq!(
322            reason_to_str(Some(DuplicateReason::RecentCapture)),
323            "recent_capture"
324        );
325        assert_eq!(reason_to_str(None), "unknown");
326    }
327}