subcog/hooks/pre_compact/
orchestrator.rs1use 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
13pub struct CaptureOrchestrator {
15 capture: Option<CaptureService>,
17 dedup: Option<Arc<dyn Deduplicator>>,
19}
20
21impl CaptureOrchestrator {
22 #[must_use]
24 pub fn new() -> Self {
25 Self {
26 capture: None,
27 dedup: None,
28 }
29 }
30
31 #[must_use]
33 pub fn with_capture(mut self, capture: CaptureService) -> Self {
34 self.capture = Some(capture);
35 self
36 }
37
38 #[must_use]
40 pub fn with_deduplication(mut self, dedup: Arc<dyn Deduplicator>) -> Self {
41 self.dedup = Some(dedup);
42 self
43 }
44
45 #[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 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 if let Some(skip_info) = self.check_for_duplicate(&candidate) {
86 skipped.push(skip_info);
87 continue;
88 }
89
90 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, #[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 }
115
116 (captured, skipped)
117 }
118
119 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, Err(e) => {
155 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 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#[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 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}