Skip to main content

chasm/routing/
recommendations.rs

1// Copyright (c) 2024-2027 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! AI-powered session recommendations
4//!
5//! Recommends relevant sessions based on context, history, and user behavior.
6
7use chrono::{DateTime, Utc, Duration};
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use uuid::Uuid;
11
12// ============================================================================
13// Session Features
14// ============================================================================
15
16/// Session features for recommendation
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SessionFeatures {
19    /// Session ID
20    pub session_id: Uuid,
21    /// Session title
22    pub title: String,
23    /// Provider
24    pub provider: String,
25    /// Model used
26    pub model: Option<String>,
27    /// Tags
28    pub tags: Vec<String>,
29    /// Topics extracted
30    pub topics: Vec<String>,
31    /// Message count
32    pub message_count: usize,
33    /// Token count
34    pub token_count: usize,
35    /// Quality score (0-100)
36    pub quality_score: u8,
37    /// Creation timestamp
38    pub created_at: DateTime<Utc>,
39    /// Last accessed
40    pub last_accessed: DateTime<Utc>,
41    /// Access count
42    pub access_count: usize,
43    /// Whether bookmarked
44    pub bookmarked: bool,
45    /// Whether archived
46    pub archived: bool,
47    /// Content embedding (if available)
48    pub embedding: Option<Vec<f32>>,
49}
50
51/// User interaction with a session
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SessionInteraction {
54    /// User ID
55    pub user_id: Uuid,
56    /// Session ID
57    pub session_id: Uuid,
58    /// Interaction type
59    pub interaction_type: InteractionType,
60    /// Duration (if view)
61    pub duration_seconds: Option<u32>,
62    /// Timestamp
63    pub timestamp: DateTime<Utc>,
64}
65
66/// Interaction type
67#[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// ============================================================================
80// Recommendation Types
81// ============================================================================
82
83/// Recommendation reason
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum RecommendationReason {
87    /// Similar to current context
88    SimilarContent,
89    /// Related topics
90    RelatedTopics,
91    /// Same tags
92    SameTags,
93    /// Frequently accessed
94    FrequentlyAccessed,
95    /// Recently active
96    RecentlyActive,
97    /// High quality
98    HighQuality,
99    /// Related to search query
100    SearchRelevant,
101    /// Collaborative (others viewed)
102    Collaborative,
103    /// Continuation suggestion
104    ContinueSuggestion,
105    /// Trending
106    Trending,
107}
108
109/// Session recommendation
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct SessionRecommendation {
112    /// Session ID
113    pub session_id: Uuid,
114    /// Session title
115    pub title: String,
116    /// Provider
117    pub provider: String,
118    /// Relevance score (0.0 - 1.0)
119    pub score: f64,
120    /// Primary reason for recommendation
121    pub reason: RecommendationReason,
122    /// Additional reasons
123    pub additional_reasons: Vec<RecommendationReason>,
124    /// Explanation text
125    pub explanation: String,
126    /// Preview snippet
127    pub preview: Option<String>,
128    /// Tags
129    pub tags: Vec<String>,
130    /// Message count
131    pub message_count: usize,
132    /// Created at
133    pub created_at: DateTime<Utc>,
134}
135
136/// Recommendation request
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct RecommendationRequest {
139    /// User ID
140    pub user_id: Uuid,
141    /// Current context (what user is looking at)
142    pub context: RecommendationContext,
143    /// Number of recommendations to return
144    pub limit: usize,
145    /// Exclude session IDs
146    pub exclude: Vec<Uuid>,
147    /// Filter by provider
148    pub provider_filter: Option<Vec<String>>,
149    /// Filter by tags
150    pub tag_filter: Option<Vec<String>>,
151    /// Include archived sessions
152    pub include_archived: bool,
153}
154
155/// Context for generating recommendations
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub enum RecommendationContext {
158    /// Currently viewing a session
159    ViewingSession { session_id: Uuid },
160    /// Searching for sessions
161    Searching { query: String },
162    /// On dashboard/home
163    Dashboard,
164    /// In a workspace
165    Workspace { workspace_id: Uuid },
166    /// Working with a provider
167    Provider { provider: String },
168    /// Custom context
169    Custom { topics: Vec<String>, tags: Vec<String> },
170}
171
172/// Recommendation response
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct RecommendationResponse {
175    /// Recommendations
176    pub recommendations: Vec<SessionRecommendation>,
177    /// Context used
178    pub context: RecommendationContext,
179    /// Generation timestamp
180    pub generated_at: DateTime<Utc>,
181    /// Model/algorithm used
182    pub algorithm: String,
183}
184
185// ============================================================================
186// User Profile
187// ============================================================================
188
189/// User preference profile for recommendations
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct UserProfile {
192    /// User ID
193    pub user_id: Uuid,
194    /// Preferred providers
195    pub preferred_providers: Vec<String>,
196    /// Preferred topics
197    pub preferred_topics: Vec<String>,
198    /// Tag preferences (tag -> weight)
199    pub tag_weights: HashMap<String, f64>,
200    /// Recent interactions
201    pub recent_interactions: Vec<SessionInteraction>,
202    /// Session view history
203    pub view_history: Vec<Uuid>,
204    /// Bookmarked sessions
205    pub bookmarked: HashSet<Uuid>,
206    /// Last updated
207    pub updated_at: DateTime<Utc>,
208}
209
210impl UserProfile {
211    /// Create a new user profile
212    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    /// Record an interaction
226    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        // Keep only recent interactions (last 30 days)
236        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    /// Get provider preferences based on history
254    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    /// Get topic preferences based on history
268    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
284// ============================================================================
285// Recommendation Engine
286// ============================================================================
287
288/// AI-powered session recommendation engine
289pub struct RecommendationEngine {
290    /// All session features
291    sessions: Vec<SessionFeatures>,
292    /// User profiles
293    profiles: HashMap<Uuid, UserProfile>,
294    /// Global topic frequencies
295    topic_frequencies: HashMap<String, usize>,
296    /// Global tag frequencies
297    tag_frequencies: HashMap<String, usize>,
298}
299
300impl RecommendationEngine {
301    /// Create a new recommendation engine
302    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    /// Index a session for recommendations
312    pub fn index_session(&mut self, session: SessionFeatures) {
313        // Update frequencies
314        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        // Add or update session
322        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    /// Get or create user profile
330    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    /// Record a user interaction
335    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    /// Generate recommendations
341    pub fn recommend(&self, request: &RecommendationRequest) -> RecommendationResponse {
342        let profile = self.profiles.get(&request.user_id);
343        
344        // Filter sessions
345        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        // Score sessions based on context
361        let mut scored: Vec<(SessionRecommendation, f64)> = candidates.iter()
362            .map(|s| self.score_session(s, &request.context, profile))
363            .collect();
364
365        // Sort by score descending
366        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
367
368        // Take top N
369        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    /// Score a session for recommendation
383    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        // Context-based scoring
393        match context {
394            RecommendationContext::ViewingSession { session_id } => {
395                if let Some(current) = self.sessions.iter().find(|s| s.session_id == *session_id) {
396                    // Topic similarity
397                    let topic_sim = self.topic_similarity(&current.topics, &session.topics);
398                    if topic_sim > 0.3 {
399                        reasons.push((RecommendationReason::RelatedTopics, topic_sim));
400                    }
401
402                    // Tag similarity
403                    let tag_sim = self.tag_similarity(&current.tags, &session.tags);
404                    if tag_sim > 0.3 {
405                        reasons.push((RecommendationReason::SameTags, tag_sim));
406                    }
407
408                    // Same provider bonus
409                    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                // Title match
418                if session.title.to_lowercase().contains(&query_lower) {
419                    reasons.push((RecommendationReason::SearchRelevant, 0.8));
420                }
421
422                // Topic match
423                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                // Tag match
430                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                // Recency score
438                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                // Quality score
443                if session.quality_score > 70 {
444                    reasons.push((RecommendationReason::HighQuality, session.quality_score as f64 / 100.0));
445                }
446
447                // Frequency score
448                if session.access_count > 5 {
449                    reasons.push((RecommendationReason::FrequentlyAccessed, (session.access_count as f64).ln() / 10.0));
450                }
451            }
452            RecommendationContext::Workspace { .. } => {
453                // Recency within workspace
454                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        // User profile-based scoring
477        if let Some(profile) = profile {
478            // Viewed similar sessions
479            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            // Bookmarked boost
493            if profile.bookmarked.contains(&session.session_id) {
494                reasons.push((RecommendationReason::FrequentlyAccessed, 0.3));
495            }
496        }
497
498        // Calculate total score
499        for (_, reason_score) in &reasons {
500            score += reason_score;
501        }
502
503        // Normalize score to 0-1
504        score = (score / (reasons.len().max(1) as f64)).min(1.0);
505
506        // Sort reasons by score
507        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    /// Calculate topic similarity (Jaccard)
530    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    /// Calculate tag similarity (Jaccard)
545    fn tag_similarity(&self, a: &[String], b: &[String]) -> f64 {
546        self.topic_similarity(a, b)
547    }
548
549    /// Generate explanation text
550    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    /// Get trending sessions (most accessed recently)
588    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        // Session3 should score higher than session2 due to topic similarity
688        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}