1use 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
17fn 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, _ => Some(30),
25 }
26}
27
28#[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#[derive(Debug, Deserialize)]
42pub struct PruneRequest {
43 #[serde(default)]
45 pub max_age_days: Option<i64>,
46 #[serde(default)]
48 pub source_type: Option<String>,
49 #[serde(default)]
51 pub dry_run: bool,
52}
53
54pub 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 let stale_ids = find_stale(&entries, &now, req);
80 report.stale_removed = stale_ids.len();
81 to_delete.extend(stale_ids);
82
83 let dedup_ids = find_content_duplicates(&entries, &to_delete);
85 report.duplicates_removed = dedup_ids.len();
86 to_delete.extend(dedup_ids);
87
88 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 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 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), make_entry("e2", "task", "t2", "recent task", 5), make_entry("e3", "doc", "d1", "old doc", 365), make_entry("e4", "commit", "c1", "old commit", 90), ];
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), 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())); }
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), make_entry("e3", "commit", "def456", "other", 5), ];
254 let dedup = find_source_duplicates(&entries, &[]);
255 assert_eq!(dedup.len(), 1);
256 assert!(dedup.contains(&"e1".to_string()));
257 }
258}