1use crate::lesson::{Difficulty, Lesson, LessonLibrary};
8use crate::stats::UserStats;
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct LessonRecommendation {
15 pub lesson: Lesson,
16 pub reason: RecommendationReason,
17 pub priority: u8, }
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum RecommendationReason {
23 NextInSequence,
25 PrerequisiteSatisfied,
27 SameDifficulty,
29 PopularNext,
31 FillGap,
33 Review,
35 SkillLevelMatch,
37 RelatedTopic,
39}
40
41pub struct RecommendationEngine {
43 library: LessonLibrary,
44}
45
46impl RecommendationEngine {
47 pub fn new() -> Self {
49 Self {
50 library: LessonLibrary::new(),
51 }
52 }
53
54 pub fn with_library(library: LessonLibrary) -> Self {
57 Self { library }
58 }
59
60 pub fn get_recommendations(
64 &self,
65 completed_lessons: &HashSet<String>,
66 stats: &UserStats,
67 max_recommendations: usize,
68 ) -> Vec<LessonRecommendation> {
69 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
70 let mut recommendations = Vec::new();
71
72 for lesson in all_lessons {
73 if completed_lessons.contains(&lesson.id) {
75 continue;
76 }
77
78 if !self.check_prerequisites(&lesson, completed_lessons) {
80 continue;
81 }
82
83 let (priority, reason) =
85 self.calculate_priority_and_reason(&lesson, completed_lessons, stats);
86
87 if priority > 0 {
89 recommendations.push(LessonRecommendation {
90 lesson,
91 reason,
92 priority,
93 });
94 }
95 }
96
97 recommendations.sort_by(|a, b| b.priority.cmp(&a.priority));
99
100 recommendations.truncate(max_recommendations);
102 recommendations
103 }
104
105 fn calculate_priority_and_reason(
107 &self,
108 lesson: &Lesson,
109 completed: &HashSet<String>,
110 stats: &UserStats,
111 ) -> (u8, RecommendationReason) {
112 let mut priority = 0u8;
113 let mut reason = RecommendationReason::SkillLevelMatch;
114
115 if !lesson.prerequisites.is_empty() {
117 let all_prereqs_done = lesson
118 .prerequisites
119 .iter()
120 .all(|prereq| completed.contains(prereq));
121
122 if all_prereqs_done {
123 priority += 8;
124 reason = RecommendationReason::PrerequisiteSatisfied;
125 }
126 }
127
128 if completed.is_empty() && lesson.difficulty == Difficulty::Beginner {
130 priority = 10;
131 reason = RecommendationReason::SkillLevelMatch;
132 }
133
134 let user_skill_level = self.estimate_user_skill_level(completed, stats);
136 if lesson.difficulty == user_skill_level {
137 priority += 6;
138 if priority > 6 {
139 } else {
141 reason = RecommendationReason::SameDifficulty;
142 }
143 } else if self.is_next_difficulty_level(user_skill_level, lesson.difficulty) {
144 priority += 5;
145 reason = RecommendationReason::NextInSequence;
146 }
147
148 if self.has_related_topics(lesson, completed) {
150 priority += 4;
151 if priority <= 4 {
152 reason = RecommendationReason::RelatedTopic;
153 }
154 }
155
156 if self.fills_knowledge_gap(lesson, completed) {
158 priority += 3;
159 if priority <= 3 {
160 reason = RecommendationReason::FillGap;
161 }
162 }
163
164 if lesson.estimated_minutes <= 10 && stats.total_time_seconds < 1800 {
166 priority += 2; }
168
169 if completed.len() < 3 && lesson.difficulty == Difficulty::Advanced {
171 priority = priority.saturating_sub(8);
172 }
173 if completed.len() < 5 && lesson.difficulty == Difficulty::Expert {
174 priority = priority.saturating_sub(10);
175 }
176
177 if self.is_too_difficult(user_skill_level, lesson.difficulty) {
179 priority = priority.saturating_sub(7);
180 }
181
182 (priority.min(10), reason)
183 }
184
185 fn check_prerequisites(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
187 lesson.prerequisites.iter().all(|prereq| completed.contains(prereq))
188 }
189
190 fn estimate_user_skill_level(
192 &self,
193 completed: &HashSet<String>,
194 stats: &UserStats,
195 ) -> Difficulty {
196 if completed.is_empty() {
197 return Difficulty::Beginner;
198 }
199
200 let beginner_count = stats.lessons_by_difficulty.get(&Difficulty::Beginner).unwrap_or(&0);
202 let intermediate_count = stats.lessons_by_difficulty.get(&Difficulty::Intermediate).unwrap_or(&0);
203 let advanced_count = stats.lessons_by_difficulty.get(&Difficulty::Advanced).unwrap_or(&0);
204 let expert_count = stats.lessons_by_difficulty.get(&Difficulty::Expert).unwrap_or(&0);
205
206 if *expert_count >= 3 {
208 Difficulty::Expert
209 } else if *advanced_count >= 3 || (*intermediate_count >= 5 && *advanced_count >= 1) {
210 Difficulty::Advanced
211 } else if *intermediate_count >= 2 || (*beginner_count >= 5 && *intermediate_count >= 1) {
212 Difficulty::Intermediate
213 } else {
214 Difficulty::Beginner
215 }
216 }
217
218 fn is_next_difficulty_level(&self, current: Difficulty, lesson: Difficulty) -> bool {
220 matches!(
221 (current, lesson),
222 (Difficulty::Beginner, Difficulty::Intermediate)
223 | (Difficulty::Intermediate, Difficulty::Advanced)
224 | (Difficulty::Advanced, Difficulty::Expert)
225 )
226 }
227
228 fn is_too_difficult(&self, current: Difficulty, lesson: Difficulty) -> bool {
230 match (current, lesson) {
231 (Difficulty::Beginner, Difficulty::Advanced) => true,
232 (Difficulty::Beginner, Difficulty::Expert) => true,
233 (Difficulty::Intermediate, Difficulty::Expert) => true,
234 _ => false,
235 }
236 }
237
238 fn has_related_topics(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
240 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
241
242 let completed_tags: HashSet<String> = all_lessons
244 .iter()
245 .filter(|l| completed.contains(&l.id))
246 .flat_map(|l| l.tags.clone())
247 .collect();
248
249 lesson.tags.iter().any(|tag| completed_tags.contains(tag))
251 }
252
253 fn fills_knowledge_gap(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
255 if lesson.prerequisites.is_empty() && !lesson.tags.is_empty() {
260 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
261 let completed_tags: HashSet<String> = all_lessons
262 .iter()
263 .filter(|l| completed.contains(&l.id))
264 .flat_map(|l| l.tags.clone())
265 .collect();
266
267 lesson.tags.iter().any(|tag| !completed_tags.contains(tag))
269 } else {
270 false
271 }
272 }
273
274 pub fn get_recommendations_by_difficulty(
276 &self,
277 completed_lessons: &HashSet<String>,
278 difficulty: Difficulty,
279 max_recommendations: usize,
280 ) -> Vec<LessonRecommendation> {
281 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
282 let mut recommendations = Vec::new();
283
284 for lesson in all_lessons {
285 if completed_lessons.contains(&lesson.id) {
286 continue;
287 }
288
289 if lesson.difficulty != difficulty {
290 continue;
291 }
292
293 if !self.check_prerequisites(&lesson, completed_lessons) {
294 continue;
295 }
296
297 recommendations.push(LessonRecommendation {
298 lesson,
299 reason: RecommendationReason::SameDifficulty,
300 priority: 7,
301 });
302 }
303
304 recommendations.truncate(max_recommendations);
305 recommendations
306 }
307
308 pub fn get_next_lesson(
310 &self,
311 completed_lessons: &HashSet<String>,
312 stats: &UserStats,
313 ) -> Option<LessonRecommendation> {
314 self.get_recommendations(completed_lessons, stats, 1).into_iter().next()
315 }
316
317 pub fn get_available_lessons(
319 &self,
320 completed_lessons: &HashSet<String>,
321 ) -> Vec<Lesson> {
322 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
323
324 all_lessons
325 .into_iter()
326 .filter(|lesson| {
327 !completed_lessons.contains(&lesson.id)
328 && self.check_prerequisites(lesson, completed_lessons)
329 })
330 .collect()
331 }
332}
333
334impl Default for RecommendationEngine {
335 fn default() -> Self {
336 Self::new()
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn test_recommendation_engine_creation() {
346 let engine = RecommendationEngine::new();
347 let completed = HashSet::new();
348 let stats = UserStats::new();
349 let recommendations = engine.get_recommendations(&completed, &stats, 5);
350
351 assert!(!recommendations.is_empty());
353 }
354
355 #[test]
356 fn test_beginner_recommendations() {
357 let engine = RecommendationEngine::new();
358 let completed = HashSet::new();
359 let stats = UserStats::new();
360 let recommendations = engine.get_recommendations(&completed, &stats, 5);
361
362 for rec in recommendations {
364 assert_eq!(rec.lesson.difficulty, Difficulty::Beginner);
365 }
366 }
367
368 #[test]
369 fn test_skill_level_estimation() {
370 let engine = RecommendationEngine::new();
371 let mut stats = UserStats::new();
372 let completed = HashSet::new();
373
374 let level = engine.estimate_user_skill_level(&completed, &stats);
376 assert_eq!(level, Difficulty::Beginner);
377
378 stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
380 stats.record_lesson_completion("lesson2".to_string(), Difficulty::Beginner);
381 let level = engine.estimate_user_skill_level(&completed, &stats);
382 assert_eq!(level, Difficulty::Beginner);
383 }
384
385 #[test]
386 fn test_prerequisite_checking() {
387 let engine = RecommendationEngine::new();
388 let library = LessonLibrary::new();
389 let lessons: Vec<Lesson> = library.all().into_iter().cloned().collect();
390
391 if let Some(lesson_with_prereq) = lessons.iter().find(|l| !l.prerequisites.is_empty()) {
392 let completed = HashSet::new();
393 assert!(!engine.check_prerequisites(lesson_with_prereq, &completed));
394
395 let mut completed_with_prereq = HashSet::new();
396 for prereq in &lesson_with_prereq.prerequisites {
397 completed_with_prereq.insert(prereq.clone());
398 }
399 assert!(engine.check_prerequisites(lesson_with_prereq, &completed_with_prereq));
400 }
401 }
402
403 #[test]
404 fn test_difficulty_progression() {
405 let engine = RecommendationEngine::new();
406
407 assert!(engine.is_next_difficulty_level(Difficulty::Beginner, Difficulty::Intermediate));
408 assert!(engine.is_next_difficulty_level(Difficulty::Intermediate, Difficulty::Advanced));
409 assert!(!engine.is_next_difficulty_level(Difficulty::Beginner, Difficulty::Expert));
410 }
411
412 #[test]
413 fn test_too_difficult_check() {
414 let engine = RecommendationEngine::new();
415
416 assert!(engine.is_too_difficult(Difficulty::Beginner, Difficulty::Advanced));
417 assert!(engine.is_too_difficult(Difficulty::Beginner, Difficulty::Expert));
418 assert!(!engine.is_too_difficult(Difficulty::Beginner, Difficulty::Intermediate));
419 assert!(!engine.is_too_difficult(Difficulty::Intermediate, Difficulty::Advanced));
420 }
421
422 #[test]
423 fn test_get_next_lesson() {
424 let engine = RecommendationEngine::new();
425 let completed = HashSet::new();
426 let stats = UserStats::new();
427
428 let next = engine.get_next_lesson(&completed, &stats);
429 assert!(next.is_some());
430
431 if let Some(recommendation) = next {
432 assert_eq!(recommendation.lesson.difficulty, Difficulty::Beginner);
433 assert!(recommendation.priority > 0);
434 }
435 }
436
437 #[test]
438 fn test_available_lessons() {
439 let engine = RecommendationEngine::new();
440 let completed = HashSet::new();
441
442 let available = engine.get_available_lessons(&completed);
443 assert!(!available.is_empty());
444
445 for lesson in available {
447 assert!(lesson.prerequisites.is_empty() ||
448 lesson.prerequisites.iter().all(|p| completed.contains(p)));
449 }
450 }
451
452 #[test]
453 fn test_recommendations_by_difficulty() {
454 let engine = RecommendationEngine::new();
455 let completed = HashSet::new();
456
457 let beginner_recs = engine.get_recommendations_by_difficulty(
458 &completed,
459 Difficulty::Beginner,
460 5,
461 );
462
463 for rec in beginner_recs {
464 assert_eq!(rec.lesson.difficulty, Difficulty::Beginner);
465 }
466 }
467
468 #[test]
469 fn test_exclude_completed_lessons() {
470 let engine = RecommendationEngine::new();
471 let mut completed = HashSet::new();
472 let stats = UserStats::new();
473
474 let first_rec = engine.get_next_lesson(&completed, &stats);
475 assert!(first_rec.is_some());
476
477 if let Some(rec) = first_rec {
478 completed.insert(rec.lesson.id.clone());
479 let next_rec = engine.get_next_lesson(&completed, &stats);
480
481 if let Some(next) = next_rec {
482 assert_ne!(next.lesson.id, rec.lesson.id);
483 }
484 }
485 }
486}