1use crate::types::Memory;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct QualityMetrics {
12 pub completeness: f32,
14 pub clarity: f32,
16 pub relevance: f32,
18 pub freshness: f32,
20 pub connectivity: f32,
22 pub consistency: f32,
24}
25
26impl Default for QualityMetrics {
27 fn default() -> Self {
28 Self {
29 completeness: 0.5,
30 clarity: 0.5,
31 relevance: 0.5,
32 freshness: 0.5,
33 connectivity: 0.0,
34 consistency: 0.5,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct QualityScore {
42 pub overall: f32,
44 pub grade: char,
46 pub metrics: QualityMetrics,
48 pub suggestions: Vec<String>,
50 pub calculated_at: DateTime<Utc>,
52}
53
54impl QualityScore {
55 fn grade_from_score(score: f32) -> char {
57 match score {
58 s if s >= 0.9 => 'A',
59 s if s >= 0.8 => 'B',
60 s if s >= 0.7 => 'C',
61 s if s >= 0.6 => 'D',
62 _ => 'F',
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct QualityScorerConfig {
70 pub completeness_weight: f32,
72 pub clarity_weight: f32,
74 pub relevance_weight: f32,
76 pub freshness_weight: f32,
78 pub connectivity_weight: f32,
80 pub consistency_weight: f32,
82 pub min_content_length: usize,
84 pub ideal_content_length: usize,
86 pub staleness_days: i64,
88}
89
90impl Default for QualityScorerConfig {
91 fn default() -> Self {
92 Self {
93 completeness_weight: 0.2,
94 clarity_weight: 0.2,
95 relevance_weight: 0.2,
96 freshness_weight: 0.15,
97 connectivity_weight: 0.15,
98 consistency_weight: 0.1,
99 min_content_length: 20,
100 ideal_content_length: 200,
101 staleness_days: 90,
102 }
103 }
104}
105
106pub struct QualityScorer {
108 config: QualityScorerConfig,
109}
110
111impl Default for QualityScorer {
112 fn default() -> Self {
113 Self::new(QualityScorerConfig::default())
114 }
115}
116
117impl QualityScorer {
118 pub fn new(config: QualityScorerConfig) -> Self {
120 Self { config }
121 }
122
123 pub fn score(&self, memory: &Memory, connection_count: usize) -> QualityScore {
125 let metrics = QualityMetrics {
126 completeness: self.score_completeness(memory),
127 clarity: self.score_clarity(memory),
128 relevance: self.score_relevance(memory),
129 freshness: self.score_freshness(memory),
130 connectivity: self.score_connectivity(connection_count),
131 consistency: 0.5, };
133
134 let overall = self.calculate_overall(&metrics);
135 let suggestions = self.generate_suggestions(memory, &metrics);
136
137 QualityScore {
138 overall,
139 grade: QualityScore::grade_from_score(overall),
140 metrics,
141 suggestions,
142 calculated_at: Utc::now(),
143 }
144 }
145
146 fn score_completeness(&self, memory: &Memory) -> f32 {
148 let len = memory.content.len();
149
150 if len < self.config.min_content_length {
152 return 0.3;
153 }
154
155 if len >= self.config.ideal_content_length {
157 return 1.0;
158 }
159
160 let range = (self.config.ideal_content_length - self.config.min_content_length) as f32;
162 let progress = (len - self.config.min_content_length) as f32;
163 0.3 + 0.7 * (progress / range)
164 }
165
166 fn score_clarity(&self, memory: &Memory) -> f32 {
168 let content = &memory.content;
169 let mut score: f32 = 0.5;
170
171 let sentence_count = content.matches('.').count()
173 + content.matches('!').count()
174 + content.matches('?').count();
175 if sentence_count > 0 {
176 score += 0.15;
177 }
178
179 let word_count = content.split_whitespace().count();
181 if word_count > 0 {
182 let avg_word_len: f32 = content
183 .split_whitespace()
184 .map(|w| w.len() as f32)
185 .sum::<f32>()
186 / word_count as f32;
187
188 if (3.0..=10.0).contains(&avg_word_len) {
190 score += 0.2;
191 }
192 }
193
194 if !memory.tags.is_empty() {
196 score += 0.15;
197 }
198
199 score.min(1.0_f32)
200 }
201
202 fn score_relevance(&self, memory: &Memory) -> f32 {
204 let access_score = (memory.access_count as f32 / 50.0).min(1.0);
206
207 let recency_score = memory
208 .last_accessed_at
209 .map(|dt| {
210 let days_ago = (Utc::now() - dt).num_days() as f32;
211 (1.0 - days_ago / 30.0).max(0.0)
212 })
213 .unwrap_or(0.3);
214
215 (access_score * 0.6 + recency_score * 0.4).min(1.0)
216 }
217
218 fn score_freshness(&self, memory: &Memory) -> f32 {
220 let age_days = (Utc::now() - memory.updated_at).num_days() as f32;
221 let staleness = self.config.staleness_days as f32;
222
223 if age_days <= 0.0 {
224 1.0
225 } else if age_days >= staleness {
226 0.2
227 } else {
228 1.0 - 0.8 * (age_days / staleness)
229 }
230 }
231
232 fn score_connectivity(&self, connection_count: usize) -> f32 {
234 match connection_count {
236 0 => 0.2,
237 1..=2 => 0.5,
238 3..=5 => 0.8,
239 _ => 1.0,
240 }
241 }
242
243 fn calculate_overall(&self, metrics: &QualityMetrics) -> f32 {
245 let c = &self.config;
246 metrics.completeness * c.completeness_weight
247 + metrics.clarity * c.clarity_weight
248 + metrics.relevance * c.relevance_weight
249 + metrics.freshness * c.freshness_weight
250 + metrics.connectivity * c.connectivity_weight
251 + metrics.consistency * c.consistency_weight
252 }
253
254 fn generate_suggestions(&self, memory: &Memory, metrics: &QualityMetrics) -> Vec<String> {
256 let mut suggestions = Vec::new();
257
258 if metrics.completeness < 0.5 {
259 suggestions.push("Add more detail to make this memory more useful".to_string());
260 }
261
262 if metrics.clarity < 0.5 {
263 suggestions.push("Consider adding structure with clear sentences".to_string());
264 }
265
266 if memory.tags.is_empty() {
267 suggestions.push("Add tags to improve organization and searchability".to_string());
268 }
269
270 if metrics.freshness < 0.3 {
271 suggestions.push("This memory may be outdated - consider reviewing".to_string());
272 }
273
274 if metrics.connectivity < 0.3 {
275 suggestions.push("Link this to related memories to build connections".to_string());
276 }
277
278 if metrics.relevance < 0.3 && memory.access_count == 0 {
279 suggestions
280 .push("This memory has never been accessed - is it still relevant?".to_string());
281 }
282
283 suggestions
284 }
285
286 pub fn score_batch(&self, memories: &[Memory]) -> Vec<(Memory, QualityScore)> {
288 let mut scored: Vec<_> = memories
289 .iter()
290 .map(|m| (m.clone(), self.score(m, 0)))
291 .collect();
292
293 scored.sort_by(|a, b| {
294 b.1.overall
295 .partial_cmp(&a.1.overall)
296 .unwrap_or(std::cmp::Ordering::Equal)
297 });
298
299 scored
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::types::{MemoryType, Visibility};
307 use std::collections::HashMap;
308
309 fn create_test_memory(content: &str, tags: Vec<&str>, access_count: i32) -> Memory {
310 Memory {
311 id: 1,
312 content: content.to_string(),
313 memory_type: MemoryType::Note,
314 tags: tags.into_iter().map(String::from).collect(),
315 metadata: HashMap::new(),
316 importance: 0.5,
317 access_count,
318 created_at: Utc::now() - chrono::Duration::days(10),
319 updated_at: Utc::now() - chrono::Duration::days(5),
320 last_accessed_at: Some(Utc::now() - chrono::Duration::days(1)),
321 owner_id: None,
322 visibility: Visibility::Private,
323 scope: crate::types::MemoryScope::Global,
324 workspace: "default".to_string(),
325 tier: crate::types::MemoryTier::Permanent,
326 version: 1,
327 has_embedding: false,
328 expires_at: None,
329 content_hash: None,
330 event_time: None,
331 event_duration_seconds: None,
332 trigger_pattern: None,
333 procedure_success_count: 0,
334 procedure_failure_count: 0,
335 summary_of_id: None,
336 lifecycle_state: crate::types::LifecycleState::Active,
337 }
338 }
339
340 #[test]
341 fn test_score_completeness() {
342 let scorer = QualityScorer::default();
343
344 let short = create_test_memory("Hi", vec![], 0);
345 let medium = create_test_memory(
346 "This is a medium length note with some useful content.",
347 vec![],
348 0,
349 );
350 let long = create_test_memory(&"This is a detailed note. ".repeat(20), vec![], 0);
351
352 let short_score = scorer.score_completeness(&short);
353 let medium_score = scorer.score_completeness(&medium);
354 let long_score = scorer.score_completeness(&long);
355
356 assert!(short_score < medium_score);
357 assert!(medium_score < long_score);
358 }
359
360 #[test]
361 fn test_quality_grade() {
362 assert_eq!(QualityScore::grade_from_score(0.95), 'A');
363 assert_eq!(QualityScore::grade_from_score(0.85), 'B');
364 assert_eq!(QualityScore::grade_from_score(0.75), 'C');
365 assert_eq!(QualityScore::grade_from_score(0.65), 'D');
366 assert_eq!(QualityScore::grade_from_score(0.5), 'F');
367 }
368
369 #[test]
370 fn test_suggestions_generation() {
371 let scorer = QualityScorer::default();
372
373 let poor_memory = create_test_memory("X", vec![], 0);
374 let score = scorer.score(&poor_memory, 0);
375
376 assert!(!score.suggestions.is_empty());
377 assert!(score.suggestions.iter().any(|s| s.contains("detail")));
378 assert!(score.suggestions.iter().any(|s| s.contains("tags")));
379 }
380
381 #[test]
382 fn test_overall_score() {
383 let scorer = QualityScorer::default();
384
385 let good_memory = create_test_memory(
386 "This is a well-written note about an important topic. It has good structure and clear sentences. The content is detailed enough to be useful.",
387 vec!["important", "well-written"],
388 20,
389 );
390
391 let score = scorer.score(&good_memory, 3);
392 assert!(score.overall > 0.6);
393 assert!(score.grade == 'A' || score.grade == 'B' || score.grade == 'C');
394 }
395}