1use chrono::{DateTime, Utc, Duration};
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SessionFeatures {
19 pub session_id: Uuid,
21 pub title: String,
23 pub provider: String,
25 pub model: Option<String>,
27 pub tags: Vec<String>,
29 pub topics: Vec<String>,
31 pub message_count: usize,
33 pub token_count: usize,
35 pub quality_score: u8,
37 pub created_at: DateTime<Utc>,
39 pub last_accessed: DateTime<Utc>,
41 pub access_count: usize,
43 pub bookmarked: bool,
45 pub archived: bool,
47 pub embedding: Option<Vec<f32>>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SessionInteraction {
54 pub user_id: Uuid,
56 pub session_id: Uuid,
58 pub interaction_type: InteractionType,
60 pub duration_seconds: Option<u32>,
62 pub timestamp: DateTime<Utc>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum InteractionType {
70 View,
71 Search,
72 Export,
73 Share,
74 Bookmark,
75 Continue,
76 Archive,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum RecommendationReason {
87 SimilarContent,
89 RelatedTopics,
91 SameTags,
93 FrequentlyAccessed,
95 RecentlyActive,
97 HighQuality,
99 SearchRelevant,
101 Collaborative,
103 ContinueSuggestion,
105 Trending,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct SessionRecommendation {
112 pub session_id: Uuid,
114 pub title: String,
116 pub provider: String,
118 pub score: f64,
120 pub reason: RecommendationReason,
122 pub additional_reasons: Vec<RecommendationReason>,
124 pub explanation: String,
126 pub preview: Option<String>,
128 pub tags: Vec<String>,
130 pub message_count: usize,
132 pub created_at: DateTime<Utc>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct RecommendationRequest {
139 pub user_id: Uuid,
141 pub context: RecommendationContext,
143 pub limit: usize,
145 pub exclude: Vec<Uuid>,
147 pub provider_filter: Option<Vec<String>>,
149 pub tag_filter: Option<Vec<String>>,
151 pub include_archived: bool,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub enum RecommendationContext {
158 ViewingSession { session_id: Uuid },
160 Searching { query: String },
162 Dashboard,
164 Workspace { workspace_id: Uuid },
166 Provider { provider: String },
168 Custom { topics: Vec<String>, tags: Vec<String> },
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct RecommendationResponse {
175 pub recommendations: Vec<SessionRecommendation>,
177 pub context: RecommendationContext,
179 pub generated_at: DateTime<Utc>,
181 pub algorithm: String,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct UserProfile {
192 pub user_id: Uuid,
194 pub preferred_providers: Vec<String>,
196 pub preferred_topics: Vec<String>,
198 pub tag_weights: HashMap<String, f64>,
200 pub recent_interactions: Vec<SessionInteraction>,
202 pub view_history: Vec<Uuid>,
204 pub bookmarked: HashSet<Uuid>,
206 pub updated_at: DateTime<Utc>,
208}
209
210impl UserProfile {
211 pub fn new(user_id: Uuid) -> Self {
213 Self {
214 user_id,
215 preferred_providers: vec![],
216 preferred_topics: vec![],
217 tag_weights: HashMap::new(),
218 recent_interactions: vec![],
219 view_history: vec![],
220 bookmarked: HashSet::new(),
221 updated_at: Utc::now(),
222 }
223 }
224
225 pub fn record_interaction(&mut self, session_id: Uuid, interaction_type: InteractionType) {
227 self.recent_interactions.push(SessionInteraction {
228 user_id: self.user_id,
229 session_id,
230 interaction_type,
231 duration_seconds: None,
232 timestamp: Utc::now(),
233 });
234
235 let cutoff = Utc::now() - Duration::days(30);
237 self.recent_interactions.retain(|i| i.timestamp > cutoff);
238
239 if interaction_type == InteractionType::View {
240 self.view_history.push(session_id);
241 if self.view_history.len() > 100 {
242 self.view_history.remove(0);
243 }
244 }
245
246 if interaction_type == InteractionType::Bookmark {
247 self.bookmarked.insert(session_id);
248 }
249
250 self.updated_at = Utc::now();
251 }
252
253 pub fn infer_provider_preferences(&self, sessions: &[SessionFeatures]) -> HashMap<String, f64> {
255 let mut counts: HashMap<String, usize> = HashMap::new();
256
257 for session_id in &self.view_history {
258 if let Some(session) = sessions.iter().find(|s| s.session_id == *session_id) {
259 *counts.entry(session.provider.clone()).or_insert(0) += 1;
260 }
261 }
262
263 let total = counts.values().sum::<usize>().max(1) as f64;
264 counts.into_iter().map(|(k, v)| (k, v as f64 / total)).collect()
265 }
266
267 pub fn infer_topic_preferences(&self, sessions: &[SessionFeatures]) -> HashMap<String, f64> {
269 let mut counts: HashMap<String, usize> = HashMap::new();
270
271 for session_id in &self.view_history {
272 if let Some(session) = sessions.iter().find(|s| s.session_id == *session_id) {
273 for topic in &session.topics {
274 *counts.entry(topic.clone()).or_insert(0) += 1;
275 }
276 }
277 }
278
279 let total = counts.values().sum::<usize>().max(1) as f64;
280 counts.into_iter().map(|(k, v)| (k, v as f64 / total)).collect()
281 }
282}
283
284pub struct RecommendationEngine {
290 sessions: Vec<SessionFeatures>,
292 profiles: HashMap<Uuid, UserProfile>,
294 topic_frequencies: HashMap<String, usize>,
296 tag_frequencies: HashMap<String, usize>,
298}
299
300impl RecommendationEngine {
301 pub fn new() -> Self {
303 Self {
304 sessions: vec![],
305 profiles: HashMap::new(),
306 topic_frequencies: HashMap::new(),
307 tag_frequencies: HashMap::new(),
308 }
309 }
310
311 pub fn index_session(&mut self, session: SessionFeatures) {
313 for topic in &session.topics {
315 *self.topic_frequencies.entry(topic.clone()).or_insert(0) += 1;
316 }
317 for tag in &session.tags {
318 *self.tag_frequencies.entry(tag.clone()).or_insert(0) += 1;
319 }
320
321 if let Some(existing) = self.sessions.iter_mut().find(|s| s.session_id == session.session_id) {
323 *existing = session;
324 } else {
325 self.sessions.push(session);
326 }
327 }
328
329 pub fn get_or_create_profile(&mut self, user_id: Uuid) -> &mut UserProfile {
331 self.profiles.entry(user_id).or_insert_with(|| UserProfile::new(user_id))
332 }
333
334 pub fn record_interaction(&mut self, user_id: Uuid, session_id: Uuid, interaction_type: InteractionType) {
336 let profile = self.get_or_create_profile(user_id);
337 profile.record_interaction(session_id, interaction_type);
338 }
339
340 pub fn recommend(&self, request: &RecommendationRequest) -> RecommendationResponse {
342 let profile = self.profiles.get(&request.user_id);
343
344 let candidates: Vec<&SessionFeatures> = self.sessions.iter()
346 .filter(|s| !request.exclude.contains(&s.session_id))
347 .filter(|s| request.include_archived || !s.archived)
348 .filter(|s| {
349 request.provider_filter.as_ref()
350 .map(|p| p.contains(&s.provider))
351 .unwrap_or(true)
352 })
353 .filter(|s| {
354 request.tag_filter.as_ref()
355 .map(|t| s.tags.iter().any(|st| t.contains(st)))
356 .unwrap_or(true)
357 })
358 .collect();
359
360 let mut scored: Vec<(SessionRecommendation, f64)> = candidates.iter()
362 .map(|s| self.score_session(s, &request.context, profile))
363 .collect();
364
365 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
367
368 let recommendations: Vec<SessionRecommendation> = scored.into_iter()
370 .take(request.limit)
371 .map(|(r, _)| r)
372 .collect();
373
374 RecommendationResponse {
375 recommendations,
376 context: request.context.clone(),
377 generated_at: Utc::now(),
378 algorithm: "hybrid_scoring_v1".to_string(),
379 }
380 }
381
382 fn score_session(
384 &self,
385 session: &SessionFeatures,
386 context: &RecommendationContext,
387 profile: Option<&UserProfile>,
388 ) -> (SessionRecommendation, f64) {
389 let mut score = 0.0;
390 let mut reasons: Vec<(RecommendationReason, f64)> = vec![];
391
392 match context {
394 RecommendationContext::ViewingSession { session_id } => {
395 if let Some(current) = self.sessions.iter().find(|s| s.session_id == *session_id) {
396 let topic_sim = self.topic_similarity(¤t.topics, &session.topics);
398 if topic_sim > 0.3 {
399 reasons.push((RecommendationReason::RelatedTopics, topic_sim));
400 }
401
402 let tag_sim = self.tag_similarity(¤t.tags, &session.tags);
404 if tag_sim > 0.3 {
405 reasons.push((RecommendationReason::SameTags, tag_sim));
406 }
407
408 if current.provider == session.provider {
410 reasons.push((RecommendationReason::SimilarContent, 0.2));
411 }
412 }
413 }
414 RecommendationContext::Searching { query } => {
415 let query_lower = query.to_lowercase();
416
417 if session.title.to_lowercase().contains(&query_lower) {
419 reasons.push((RecommendationReason::SearchRelevant, 0.8));
420 }
421
422 let topic_match = session.topics.iter()
424 .any(|t| t.to_lowercase().contains(&query_lower));
425 if topic_match {
426 reasons.push((RecommendationReason::SearchRelevant, 0.6));
427 }
428
429 let tag_match = session.tags.iter()
431 .any(|t| t.to_lowercase().contains(&query_lower));
432 if tag_match {
433 reasons.push((RecommendationReason::SameTags, 0.5));
434 }
435 }
436 RecommendationContext::Dashboard => {
437 let age_days = (Utc::now() - session.last_accessed).num_days() as f64;
439 let recency = 1.0 / (1.0 + age_days / 7.0);
440 reasons.push((RecommendationReason::RecentlyActive, recency));
441
442 if session.quality_score > 70 {
444 reasons.push((RecommendationReason::HighQuality, session.quality_score as f64 / 100.0));
445 }
446
447 if session.access_count > 5 {
449 reasons.push((RecommendationReason::FrequentlyAccessed, (session.access_count as f64).ln() / 10.0));
450 }
451 }
452 RecommendationContext::Workspace { .. } => {
453 let age_days = (Utc::now() - session.created_at).num_days() as f64;
455 let recency = 1.0 / (1.0 + age_days / 30.0);
456 reasons.push((RecommendationReason::RecentlyActive, recency * 0.5));
457 }
458 RecommendationContext::Provider { provider } => {
459 if &session.provider == provider {
460 reasons.push((RecommendationReason::SimilarContent, 0.5));
461 }
462 }
463 RecommendationContext::Custom { topics, tags } => {
464 let topic_sim = self.topic_similarity(topics, &session.topics);
465 if topic_sim > 0.2 {
466 reasons.push((RecommendationReason::RelatedTopics, topic_sim));
467 }
468
469 let tag_sim = self.tag_similarity(tags, &session.tags);
470 if tag_sim > 0.2 {
471 reasons.push((RecommendationReason::SameTags, tag_sim));
472 }
473 }
474 }
475
476 if let Some(profile) = profile {
478 let view_count = profile.view_history.iter()
480 .filter(|id| {
481 self.sessions.iter()
482 .find(|s| s.session_id == **id)
483 .map(|viewed| self.topic_similarity(&viewed.topics, &session.topics) > 0.5)
484 .unwrap_or(false)
485 })
486 .count();
487
488 if view_count > 0 {
489 reasons.push((RecommendationReason::Collaborative, (view_count as f64).ln() / 5.0));
490 }
491
492 if profile.bookmarked.contains(&session.session_id) {
494 reasons.push((RecommendationReason::FrequentlyAccessed, 0.3));
495 }
496 }
497
498 for (_, reason_score) in &reasons {
500 score += reason_score;
501 }
502
503 score = (score / (reasons.len().max(1) as f64)).min(1.0);
505
506 reasons.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
508
509 let primary_reason = reasons.first().map(|(r, _)| *r).unwrap_or(RecommendationReason::RecentlyActive);
510 let additional_reasons: Vec<RecommendationReason> = reasons.iter().skip(1).take(2).map(|(r, _)| *r).collect();
511
512 let recommendation = SessionRecommendation {
513 session_id: session.session_id,
514 title: session.title.clone(),
515 provider: session.provider.clone(),
516 score,
517 reason: primary_reason,
518 additional_reasons,
519 explanation: self.generate_explanation(primary_reason, session),
520 preview: None,
521 tags: session.tags.clone(),
522 message_count: session.message_count,
523 created_at: session.created_at,
524 };
525
526 (recommendation, score)
527 }
528
529 fn topic_similarity(&self, a: &[String], b: &[String]) -> f64 {
531 if a.is_empty() || b.is_empty() {
532 return 0.0;
533 }
534
535 let set_a: HashSet<&String> = a.iter().collect();
536 let set_b: HashSet<&String> = b.iter().collect();
537
538 let intersection = set_a.intersection(&set_b).count();
539 let union = set_a.union(&set_b).count();
540
541 intersection as f64 / union as f64
542 }
543
544 fn tag_similarity(&self, a: &[String], b: &[String]) -> f64 {
546 self.topic_similarity(a, b)
547 }
548
549 fn generate_explanation(&self, reason: RecommendationReason, session: &SessionFeatures) -> String {
551 match reason {
552 RecommendationReason::SimilarContent => {
553 "Similar to what you're viewing".to_string()
554 }
555 RecommendationReason::RelatedTopics => {
556 let topics = session.topics.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
557 format!("Related topics: {}", topics)
558 }
559 RecommendationReason::SameTags => {
560 let tags = session.tags.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
561 format!("Tagged with: {}", tags)
562 }
563 RecommendationReason::FrequentlyAccessed => {
564 "Frequently accessed session".to_string()
565 }
566 RecommendationReason::RecentlyActive => {
567 "Recently active".to_string()
568 }
569 RecommendationReason::HighQuality => {
570 format!("High quality session ({}% score)", session.quality_score)
571 }
572 RecommendationReason::SearchRelevant => {
573 "Matches your search".to_string()
574 }
575 RecommendationReason::Collaborative => {
576 "Popular with similar users".to_string()
577 }
578 RecommendationReason::ContinueSuggestion => {
579 "You might want to continue this".to_string()
580 }
581 RecommendationReason::Trending => {
582 "Trending in your team".to_string()
583 }
584 }
585 }
586
587 pub fn get_trending(&self, limit: usize, days: i64) -> Vec<SessionRecommendation> {
589 let cutoff = Utc::now() - Duration::days(days);
590
591 let mut trending: Vec<&SessionFeatures> = self.sessions.iter()
592 .filter(|s| s.last_accessed > cutoff)
593 .filter(|s| !s.archived)
594 .collect();
595
596 trending.sort_by(|a, b| b.access_count.cmp(&a.access_count));
597
598 trending.into_iter()
599 .take(limit)
600 .map(|s| SessionRecommendation {
601 session_id: s.session_id,
602 title: s.title.clone(),
603 provider: s.provider.clone(),
604 score: s.access_count as f64 / 100.0,
605 reason: RecommendationReason::Trending,
606 additional_reasons: vec![],
607 explanation: format!("Viewed {} times recently", s.access_count),
608 preview: None,
609 tags: s.tags.clone(),
610 message_count: s.message_count,
611 created_at: s.created_at,
612 })
613 .collect()
614 }
615}
616
617impl Default for RecommendationEngine {
618 fn default() -> Self {
619 Self::new()
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 fn create_test_session(id: Uuid, title: &str, topics: Vec<&str>, tags: Vec<&str>) -> SessionFeatures {
628 SessionFeatures {
629 session_id: id,
630 title: title.to_string(),
631 provider: "copilot".to_string(),
632 model: Some("gpt-4".to_string()),
633 tags: tags.into_iter().map(String::from).collect(),
634 topics: topics.into_iter().map(String::from).collect(),
635 message_count: 10,
636 token_count: 1000,
637 quality_score: 80,
638 created_at: Utc::now(),
639 last_accessed: Utc::now(),
640 access_count: 5,
641 bookmarked: false,
642 archived: false,
643 embedding: None,
644 }
645 }
646
647 #[test]
648 fn test_recommendations() {
649 let mut engine = RecommendationEngine::new();
650
651 let session1 = create_test_session(
652 Uuid::new_v4(),
653 "Rust async programming",
654 vec!["rust", "async", "tokio"],
655 vec!["programming", "rust"],
656 );
657 let session2 = create_test_session(
658 Uuid::new_v4(),
659 "Python web development",
660 vec!["python", "web", "flask"],
661 vec!["programming", "python"],
662 );
663 let session3 = create_test_session(
664 Uuid::new_v4(),
665 "Rust error handling",
666 vec!["rust", "errors", "result"],
667 vec!["programming", "rust"],
668 );
669
670 engine.index_session(session1.clone());
671 engine.index_session(session2);
672 engine.index_session(session3.clone());
673
674 let request = RecommendationRequest {
675 user_id: Uuid::new_v4(),
676 context: RecommendationContext::ViewingSession { session_id: session1.session_id },
677 limit: 5,
678 exclude: vec![session1.session_id],
679 provider_filter: None,
680 tag_filter: None,
681 include_archived: false,
682 };
683
684 let response = engine.recommend(&request);
685 assert!(!response.recommendations.is_empty());
686
687 let first = &response.recommendations[0];
689 assert_eq!(first.session_id, session3.session_id);
690 }
691
692 #[test]
693 fn test_user_profile() {
694 let mut profile = UserProfile::new(Uuid::new_v4());
695 let session_id = Uuid::new_v4();
696
697 profile.record_interaction(session_id, InteractionType::View);
698 profile.record_interaction(session_id, InteractionType::Bookmark);
699
700 assert_eq!(profile.view_history.len(), 1);
701 assert!(profile.bookmarked.contains(&session_id));
702 }
703}