Skip to main content

convergio_knowledge/
pruning.rs

1//! Auto-pruning + dedup for the knowledge vector store (#688).
2//!
3//! Strategies:
4//! - **Stale pruning**: remove entries older than a threshold by source_type
5//! - **Content dedup**: merge entries with near-identical content (prefix match)
6//! - **Source dedup**: if same source_type+source_id has multiple entries, keep newest
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use serde::{Deserialize, Serialize};
12use tracing::{info, warn};
13
14use crate::store::LanceVectorStore;
15use crate::types::KnowledgeEntry;
16
17/// Default max age in days per source_type before pruning.
18fn default_max_age_days(source_type: &str) -> Option<i64> {
19    match source_type {
20        "task" => Some(30),
21        "commit" => Some(60),
22        "agent_memory" => Some(14),
23        "doc" | "kb" => None, // evergreen, never auto-prune
24        _ => Some(30),
25    }
26}
27
28/// Prune report returned by the pruning endpoint.
29#[derive(Debug, Serialize, Deserialize)]
30pub struct PruneReport {
31    pub stale_removed: usize,
32    pub duplicates_removed: usize,
33    pub source_dedup_removed: usize,
34    pub total_removed: usize,
35    pub total_before: usize,
36    pub total_after: usize,
37    pub errors: Vec<String>,
38}
39
40/// Request for the prune endpoint.
41#[derive(Debug, Deserialize)]
42pub struct PruneRequest {
43    /// Override max age in days (applies to all source_types).
44    #[serde(default)]
45    pub max_age_days: Option<i64>,
46    /// Only prune entries matching this source_type.
47    #[serde(default)]
48    pub source_type: Option<String>,
49    /// Dry run — report what would be pruned without deleting.
50    #[serde(default)]
51    pub dry_run: bool,
52}
53
54/// Run the full prune pipeline.
55pub async fn prune(store: &Arc<LanceVectorStore>, req: &PruneRequest) -> PruneReport {
56    let mut report = PruneReport {
57        stale_removed: 0,
58        duplicates_removed: 0,
59        source_dedup_removed: 0,
60        total_removed: 0,
61        total_before: 0,
62        total_after: 0,
63        errors: vec![],
64    };
65
66    let entries = match store.list_all_metadata().await {
67        Ok(e) => e,
68        Err(e) => {
69            report.errors.push(format!("list failed: {e}"));
70            return report;
71        }
72    };
73    report.total_before = entries.len();
74
75    let now = chrono::Utc::now();
76    let mut to_delete: Vec<String> = Vec::new();
77
78    // 1. Stale pruning — remove entries older than max_age_days
79    let stale_ids = find_stale(&entries, &now, req);
80    report.stale_removed = stale_ids.len();
81    to_delete.extend(stale_ids);
82
83    // 2. Content dedup — entries with identical first 200 chars, keep newest
84    let dedup_ids = find_content_duplicates(&entries, &to_delete);
85    report.duplicates_removed = dedup_ids.len();
86    to_delete.extend(dedup_ids);
87
88    // 3. Source dedup — same source_type+source_id, keep newest
89    let source_dedup_ids = find_source_duplicates(&entries, &to_delete);
90    report.source_dedup_removed = source_dedup_ids.len();
91    to_delete.extend(source_dedup_ids);
92
93    report.total_removed = to_delete.len();
94    report.total_after = report.total_before.saturating_sub(report.total_removed);
95
96    if req.dry_run || to_delete.is_empty() {
97        return report;
98    }
99
100    // Batch delete in chunks of 50
101    for chunk in to_delete.chunks(50) {
102        let ids: Vec<String> = chunk.to_vec();
103        match store.delete_batch(&ids).await {
104            Ok(n) => info!(deleted = n, "pruned knowledge entries"),
105            Err(e) => {
106                warn!(error = %e, "prune batch delete failed");
107                report.errors.push(e);
108            }
109        }
110    }
111    report
112}
113
114fn find_stale(
115    entries: &[KnowledgeEntry],
116    now: &chrono::DateTime<chrono::Utc>,
117    req: &PruneRequest,
118) -> Vec<String> {
119    entries
120        .iter()
121        .filter(|e| {
122            if let Some(ref st) = req.source_type {
123                if e.source_type != *st {
124                    return false;
125                }
126            }
127            let max_days = req
128                .max_age_days
129                .or_else(|| default_max_age_days(&e.source_type));
130            let Some(max_days) = max_days else {
131                return false;
132            };
133            let Ok(created) = chrono::DateTime::parse_from_rfc3339(&e.created_at) else {
134                return false;
135            };
136            let age = *now - created.with_timezone(&chrono::Utc);
137            age.num_days() > max_days
138        })
139        .map(|e| e.id.clone())
140        .collect()
141}
142
143fn find_content_duplicates(entries: &[KnowledgeEntry], already_marked: &[String]) -> Vec<String> {
144    let mut prefix_map: HashMap<String, Vec<&KnowledgeEntry>> = HashMap::new();
145    for e in entries {
146        if already_marked.contains(&e.id) {
147            continue;
148        }
149        let key: String = e.content.chars().take(200).collect();
150        prefix_map.entry(key).or_default().push(e);
151    }
152
153    let mut to_remove = Vec::new();
154    for (_key, mut group) in prefix_map {
155        if group.len() <= 1 {
156            continue;
157        }
158        // Sort by created_at desc, keep newest
159        group.sort_by(|a, b| b.created_at.cmp(&a.created_at));
160        for e in &group[1..] {
161            to_remove.push(e.id.clone());
162        }
163    }
164    to_remove
165}
166
167fn find_source_duplicates(entries: &[KnowledgeEntry], already_marked: &[String]) -> Vec<String> {
168    let mut source_map: HashMap<(String, String), Vec<&KnowledgeEntry>> = HashMap::new();
169    for e in entries {
170        if already_marked.contains(&e.id) {
171            continue;
172        }
173        let key = (e.source_type.clone(), e.source_id.clone());
174        source_map.entry(key).or_default().push(e);
175    }
176
177    let mut to_remove = Vec::new();
178    for (_key, mut group) in source_map {
179        if group.len() <= 1 {
180            continue;
181        }
182        group.sort_by(|a, b| b.created_at.cmp(&a.created_at));
183        for e in &group[1..] {
184            to_remove.push(e.id.clone());
185        }
186    }
187    to_remove
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn make_entry(
195        id: &str,
196        source_type: &str,
197        source_id: &str,
198        content: &str,
199        age_days: i64,
200    ) -> KnowledgeEntry {
201        let created = chrono::Utc::now() - chrono::Duration::days(age_days);
202        KnowledgeEntry {
203            id: id.to_string(),
204            source_type: source_type.to_string(),
205            source_id: source_id.to_string(),
206            content: content.to_string(),
207            org_id: None,
208            agent_id: None,
209            project_id: None,
210            visibility: "org".to_string(),
211            created_at: created.to_rfc3339(),
212        }
213    }
214
215    #[test]
216    fn stale_pruning_respects_source_type_defaults() {
217        let now = chrono::Utc::now();
218        let entries = vec![
219            make_entry("e1", "task", "t1", "old task", 45), // >30 days = stale
220            make_entry("e2", "task", "t2", "recent task", 5), // <30 days = keep
221            make_entry("e3", "doc", "d1", "old doc", 365),  // docs never stale
222            make_entry("e4", "commit", "c1", "old commit", 90), // >60 days = stale
223        ];
224        let req = PruneRequest {
225            max_age_days: None,
226            source_type: None,
227            dry_run: false,
228        };
229        let stale = find_stale(&entries, &now, &req);
230        assert_eq!(stale.len(), 2);
231        assert!(stale.contains(&"e1".to_string()));
232        assert!(stale.contains(&"e4".to_string()));
233    }
234
235    #[test]
236    fn content_dedup_keeps_newest() {
237        let entries = vec![
238            make_entry("e1", "task", "t1", "same content here", 10),
239            make_entry("e2", "task", "t2", "same content here", 5), // newer
240            make_entry("e3", "task", "t3", "different content", 3),
241        ];
242        let dedup = find_content_duplicates(&entries, &[]);
243        assert_eq!(dedup.len(), 1);
244        assert!(dedup.contains(&"e1".to_string())); // older one removed
245    }
246
247    #[test]
248    fn source_dedup_keeps_newest() {
249        let entries = vec![
250            make_entry("e1", "commit", "abc123", "first", 10),
251            make_entry("e2", "commit", "abc123", "updated", 2), // newer, same source
252            make_entry("e3", "commit", "def456", "other", 5),   // different source_id
253        ];
254        let dedup = find_source_duplicates(&entries, &[]);
255        assert_eq!(dedup.len(), 1);
256        assert!(dedup.contains(&"e1".to_string()));
257    }
258}