use chrono::{DateTime, Utc, Duration};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionFeatures {
pub session_id: Uuid,
pub title: String,
pub provider: String,
pub model: Option<String>,
pub tags: Vec<String>,
pub topics: Vec<String>,
pub message_count: usize,
pub token_count: usize,
pub quality_score: u8,
pub created_at: DateTime<Utc>,
pub last_accessed: DateTime<Utc>,
pub access_count: usize,
pub bookmarked: bool,
pub archived: bool,
pub embedding: Option<Vec<f32>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInteraction {
pub user_id: Uuid,
pub session_id: Uuid,
pub interaction_type: InteractionType,
pub duration_seconds: Option<u32>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InteractionType {
View,
Search,
Export,
Share,
Bookmark,
Continue,
Archive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecommendationReason {
SimilarContent,
RelatedTopics,
SameTags,
FrequentlyAccessed,
RecentlyActive,
HighQuality,
SearchRelevant,
Collaborative,
ContinueSuggestion,
Trending,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRecommendation {
pub session_id: Uuid,
pub title: String,
pub provider: String,
pub score: f64,
pub reason: RecommendationReason,
pub additional_reasons: Vec<RecommendationReason>,
pub explanation: String,
pub preview: Option<String>,
pub tags: Vec<String>,
pub message_count: usize,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendationRequest {
pub user_id: Uuid,
pub context: RecommendationContext,
pub limit: usize,
pub exclude: Vec<Uuid>,
pub provider_filter: Option<Vec<String>>,
pub tag_filter: Option<Vec<String>>,
pub include_archived: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RecommendationContext {
ViewingSession { session_id: Uuid },
Searching { query: String },
Dashboard,
Workspace { workspace_id: Uuid },
Provider { provider: String },
Custom { topics: Vec<String>, tags: Vec<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendationResponse {
pub recommendations: Vec<SessionRecommendation>,
pub context: RecommendationContext,
pub generated_at: DateTime<Utc>,
pub algorithm: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfile {
pub user_id: Uuid,
pub preferred_providers: Vec<String>,
pub preferred_topics: Vec<String>,
pub tag_weights: HashMap<String, f64>,
pub recent_interactions: Vec<SessionInteraction>,
pub view_history: Vec<Uuid>,
pub bookmarked: HashSet<Uuid>,
pub updated_at: DateTime<Utc>,
}
impl UserProfile {
pub fn new(user_id: Uuid) -> Self {
Self {
user_id,
preferred_providers: vec![],
preferred_topics: vec![],
tag_weights: HashMap::new(),
recent_interactions: vec![],
view_history: vec![],
bookmarked: HashSet::new(),
updated_at: Utc::now(),
}
}
pub fn record_interaction(&mut self, session_id: Uuid, interaction_type: InteractionType) {
self.recent_interactions.push(SessionInteraction {
user_id: self.user_id,
session_id,
interaction_type,
duration_seconds: None,
timestamp: Utc::now(),
});
let cutoff = Utc::now() - Duration::days(30);
self.recent_interactions.retain(|i| i.timestamp > cutoff);
if interaction_type == InteractionType::View {
self.view_history.push(session_id);
if self.view_history.len() > 100 {
self.view_history.remove(0);
}
}
if interaction_type == InteractionType::Bookmark {
self.bookmarked.insert(session_id);
}
self.updated_at = Utc::now();
}
pub fn infer_provider_preferences(&self, sessions: &[SessionFeatures]) -> HashMap<String, f64> {
let mut counts: HashMap<String, usize> = HashMap::new();
for session_id in &self.view_history {
if let Some(session) = sessions.iter().find(|s| s.session_id == *session_id) {
*counts.entry(session.provider.clone()).or_insert(0) += 1;
}
}
let total = counts.values().sum::<usize>().max(1) as f64;
counts.into_iter().map(|(k, v)| (k, v as f64 / total)).collect()
}
pub fn infer_topic_preferences(&self, sessions: &[SessionFeatures]) -> HashMap<String, f64> {
let mut counts: HashMap<String, usize> = HashMap::new();
for session_id in &self.view_history {
if let Some(session) = sessions.iter().find(|s| s.session_id == *session_id) {
for topic in &session.topics {
*counts.entry(topic.clone()).or_insert(0) += 1;
}
}
}
let total = counts.values().sum::<usize>().max(1) as f64;
counts.into_iter().map(|(k, v)| (k, v as f64 / total)).collect()
}
}
pub struct RecommendationEngine {
sessions: Vec<SessionFeatures>,
profiles: HashMap<Uuid, UserProfile>,
topic_frequencies: HashMap<String, usize>,
tag_frequencies: HashMap<String, usize>,
}
impl RecommendationEngine {
pub fn new() -> Self {
Self {
sessions: vec![],
profiles: HashMap::new(),
topic_frequencies: HashMap::new(),
tag_frequencies: HashMap::new(),
}
}
pub fn index_session(&mut self, session: SessionFeatures) {
for topic in &session.topics {
*self.topic_frequencies.entry(topic.clone()).or_insert(0) += 1;
}
for tag in &session.tags {
*self.tag_frequencies.entry(tag.clone()).or_insert(0) += 1;
}
if let Some(existing) = self.sessions.iter_mut().find(|s| s.session_id == session.session_id) {
*existing = session;
} else {
self.sessions.push(session);
}
}
pub fn get_or_create_profile(&mut self, user_id: Uuid) -> &mut UserProfile {
self.profiles.entry(user_id).or_insert_with(|| UserProfile::new(user_id))
}
pub fn record_interaction(&mut self, user_id: Uuid, session_id: Uuid, interaction_type: InteractionType) {
let profile = self.get_or_create_profile(user_id);
profile.record_interaction(session_id, interaction_type);
}
pub fn recommend(&self, request: &RecommendationRequest) -> RecommendationResponse {
let profile = self.profiles.get(&request.user_id);
let candidates: Vec<&SessionFeatures> = self.sessions.iter()
.filter(|s| !request.exclude.contains(&s.session_id))
.filter(|s| request.include_archived || !s.archived)
.filter(|s| {
request.provider_filter.as_ref()
.map(|p| p.contains(&s.provider))
.unwrap_or(true)
})
.filter(|s| {
request.tag_filter.as_ref()
.map(|t| s.tags.iter().any(|st| t.contains(st)))
.unwrap_or(true)
})
.collect();
let mut scored: Vec<(SessionRecommendation, f64)> = candidates.iter()
.map(|s| self.score_session(s, &request.context, profile))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
let recommendations: Vec<SessionRecommendation> = scored.into_iter()
.take(request.limit)
.map(|(r, _)| r)
.collect();
RecommendationResponse {
recommendations,
context: request.context.clone(),
generated_at: Utc::now(),
algorithm: "hybrid_scoring_v1".to_string(),
}
}
fn score_session(
&self,
session: &SessionFeatures,
context: &RecommendationContext,
profile: Option<&UserProfile>,
) -> (SessionRecommendation, f64) {
let mut score = 0.0;
let mut reasons: Vec<(RecommendationReason, f64)> = vec![];
match context {
RecommendationContext::ViewingSession { session_id } => {
if let Some(current) = self.sessions.iter().find(|s| s.session_id == *session_id) {
let topic_sim = self.topic_similarity(¤t.topics, &session.topics);
if topic_sim > 0.3 {
reasons.push((RecommendationReason::RelatedTopics, topic_sim));
}
let tag_sim = self.tag_similarity(¤t.tags, &session.tags);
if tag_sim > 0.3 {
reasons.push((RecommendationReason::SameTags, tag_sim));
}
if current.provider == session.provider {
reasons.push((RecommendationReason::SimilarContent, 0.2));
}
}
}
RecommendationContext::Searching { query } => {
let query_lower = query.to_lowercase();
if session.title.to_lowercase().contains(&query_lower) {
reasons.push((RecommendationReason::SearchRelevant, 0.8));
}
let topic_match = session.topics.iter()
.any(|t| t.to_lowercase().contains(&query_lower));
if topic_match {
reasons.push((RecommendationReason::SearchRelevant, 0.6));
}
let tag_match = session.tags.iter()
.any(|t| t.to_lowercase().contains(&query_lower));
if tag_match {
reasons.push((RecommendationReason::SameTags, 0.5));
}
}
RecommendationContext::Dashboard => {
let age_days = (Utc::now() - session.last_accessed).num_days() as f64;
let recency = 1.0 / (1.0 + age_days / 7.0);
reasons.push((RecommendationReason::RecentlyActive, recency));
if session.quality_score > 70 {
reasons.push((RecommendationReason::HighQuality, session.quality_score as f64 / 100.0));
}
if session.access_count > 5 {
reasons.push((RecommendationReason::FrequentlyAccessed, (session.access_count as f64).ln() / 10.0));
}
}
RecommendationContext::Workspace { .. } => {
let age_days = (Utc::now() - session.created_at).num_days() as f64;
let recency = 1.0 / (1.0 + age_days / 30.0);
reasons.push((RecommendationReason::RecentlyActive, recency * 0.5));
}
RecommendationContext::Provider { provider } => {
if &session.provider == provider {
reasons.push((RecommendationReason::SimilarContent, 0.5));
}
}
RecommendationContext::Custom { topics, tags } => {
let topic_sim = self.topic_similarity(topics, &session.topics);
if topic_sim > 0.2 {
reasons.push((RecommendationReason::RelatedTopics, topic_sim));
}
let tag_sim = self.tag_similarity(tags, &session.tags);
if tag_sim > 0.2 {
reasons.push((RecommendationReason::SameTags, tag_sim));
}
}
}
if let Some(profile) = profile {
let view_count = profile.view_history.iter()
.filter(|id| {
self.sessions.iter()
.find(|s| s.session_id == **id)
.map(|viewed| self.topic_similarity(&viewed.topics, &session.topics) > 0.5)
.unwrap_or(false)
})
.count();
if view_count > 0 {
reasons.push((RecommendationReason::Collaborative, (view_count as f64).ln() / 5.0));
}
if profile.bookmarked.contains(&session.session_id) {
reasons.push((RecommendationReason::FrequentlyAccessed, 0.3));
}
}
for (_, reason_score) in &reasons {
score += reason_score;
}
score = (score / (reasons.len().max(1) as f64)).min(1.0);
reasons.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
let primary_reason = reasons.first().map(|(r, _)| *r).unwrap_or(RecommendationReason::RecentlyActive);
let additional_reasons: Vec<RecommendationReason> = reasons.iter().skip(1).take(2).map(|(r, _)| *r).collect();
let recommendation = SessionRecommendation {
session_id: session.session_id,
title: session.title.clone(),
provider: session.provider.clone(),
score,
reason: primary_reason,
additional_reasons,
explanation: self.generate_explanation(primary_reason, session),
preview: None,
tags: session.tags.clone(),
message_count: session.message_count,
created_at: session.created_at,
};
(recommendation, score)
}
fn topic_similarity(&self, a: &[String], b: &[String]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let set_a: HashSet<&String> = a.iter().collect();
let set_b: HashSet<&String> = b.iter().collect();
let intersection = set_a.intersection(&set_b).count();
let union = set_a.union(&set_b).count();
intersection as f64 / union as f64
}
fn tag_similarity(&self, a: &[String], b: &[String]) -> f64 {
self.topic_similarity(a, b)
}
fn generate_explanation(&self, reason: RecommendationReason, session: &SessionFeatures) -> String {
match reason {
RecommendationReason::SimilarContent => {
"Similar to what you're viewing".to_string()
}
RecommendationReason::RelatedTopics => {
let topics = session.topics.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
format!("Related topics: {}", topics)
}
RecommendationReason::SameTags => {
let tags = session.tags.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
format!("Tagged with: {}", tags)
}
RecommendationReason::FrequentlyAccessed => {
"Frequently accessed session".to_string()
}
RecommendationReason::RecentlyActive => {
"Recently active".to_string()
}
RecommendationReason::HighQuality => {
format!("High quality session ({}% score)", session.quality_score)
}
RecommendationReason::SearchRelevant => {
"Matches your search".to_string()
}
RecommendationReason::Collaborative => {
"Popular with similar users".to_string()
}
RecommendationReason::ContinueSuggestion => {
"You might want to continue this".to_string()
}
RecommendationReason::Trending => {
"Trending in your team".to_string()
}
}
}
pub fn get_trending(&self, limit: usize, days: i64) -> Vec<SessionRecommendation> {
let cutoff = Utc::now() - Duration::days(days);
let mut trending: Vec<&SessionFeatures> = self.sessions.iter()
.filter(|s| s.last_accessed > cutoff)
.filter(|s| !s.archived)
.collect();
trending.sort_by(|a, b| b.access_count.cmp(&a.access_count));
trending.into_iter()
.take(limit)
.map(|s| SessionRecommendation {
session_id: s.session_id,
title: s.title.clone(),
provider: s.provider.clone(),
score: s.access_count as f64 / 100.0,
reason: RecommendationReason::Trending,
additional_reasons: vec![],
explanation: format!("Viewed {} times recently", s.access_count),
preview: None,
tags: s.tags.clone(),
message_count: s.message_count,
created_at: s.created_at,
})
.collect()
}
}
impl Default for RecommendationEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_session(id: Uuid, title: &str, topics: Vec<&str>, tags: Vec<&str>) -> SessionFeatures {
SessionFeatures {
session_id: id,
title: title.to_string(),
provider: "copilot".to_string(),
model: Some("gpt-4".to_string()),
tags: tags.into_iter().map(String::from).collect(),
topics: topics.into_iter().map(String::from).collect(),
message_count: 10,
token_count: 1000,
quality_score: 80,
created_at: Utc::now(),
last_accessed: Utc::now(),
access_count: 5,
bookmarked: false,
archived: false,
embedding: None,
}
}
#[test]
fn test_recommendations() {
let mut engine = RecommendationEngine::new();
let session1 = create_test_session(
Uuid::new_v4(),
"Rust async programming",
vec!["rust", "async", "tokio"],
vec!["programming", "rust"],
);
let session2 = create_test_session(
Uuid::new_v4(),
"Python web development",
vec!["python", "web", "flask"],
vec!["programming", "python"],
);
let session3 = create_test_session(
Uuid::new_v4(),
"Rust error handling",
vec!["rust", "errors", "result"],
vec!["programming", "rust"],
);
engine.index_session(session1.clone());
engine.index_session(session2);
engine.index_session(session3.clone());
let request = RecommendationRequest {
user_id: Uuid::new_v4(),
context: RecommendationContext::ViewingSession { session_id: session1.session_id },
limit: 5,
exclude: vec![session1.session_id],
provider_filter: None,
tag_filter: None,
include_archived: false,
};
let response = engine.recommend(&request);
assert!(!response.recommendations.is_empty());
let first = &response.recommendations[0];
assert_eq!(first.session_id, session3.session_id);
}
#[test]
fn test_user_profile() {
let mut profile = UserProfile::new(Uuid::new_v4());
let session_id = Uuid::new_v4();
profile.record_interaction(session_id, InteractionType::View);
profile.record_interaction(session_id, InteractionType::Bookmark);
assert_eq!(profile.view_history.len(), 1);
assert!(profile.bookmarked.contains(&session_id));
}
}