use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info, instrument};
use uuid::Uuid;
use super::types::{
RecommendationFeedback, RecommendationSession, RecommendationStats, SessionWithFeedback,
};
use crate::error::Result;
#[derive(Clone)]
pub struct RecommendationTracker {
sessions: Arc<RwLock<HashMap<Uuid, RecommendationSession>>>,
feedback: Arc<RwLock<HashMap<Uuid, RecommendationFeedback>>>,
episode_sessions: Arc<RwLock<HashMap<Uuid, Uuid>>>,
}
impl Default for RecommendationTracker {
fn default() -> Self {
Self::new()
}
}
impl RecommendationTracker {
#[must_use]
pub fn new() -> Self {
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
feedback: Arc::new(RwLock::new(HashMap::new())),
episode_sessions: Arc::new(RwLock::new(HashMap::new())),
}
}
#[instrument(skip(self, session), fields(session_id = %session.session_id, episode_id = %session.episode_id))]
pub async fn record_session(&self, session: RecommendationSession) {
let session_id = session.session_id;
let episode_id = session.episode_id;
{
let mut sessions = self.sessions.write().await;
sessions.insert(session_id, session);
}
{
let mut episode_sessions = self.episode_sessions.write().await;
episode_sessions.insert(episode_id, session_id);
}
info!(
session_id = %session_id,
episode_id = %episode_id,
"Recorded recommendation session"
);
}
#[instrument(skip(self, feedback), fields(session_id = %feedback.session_id))]
pub async fn record_feedback(&self, feedback: RecommendationFeedback) -> Result<()> {
let session_id = feedback.session_id;
{
let sessions = self.sessions.read().await;
if !sessions.contains_key(&session_id) {
debug!(session_id = %session_id, "Session not found for feedback");
}
}
{
let mut feedback_map = self.feedback.write().await;
feedback_map.insert(session_id, feedback);
}
info!(
session_id = %session_id,
"Recorded recommendation feedback"
);
Ok(())
}
#[instrument(skip(self))]
pub async fn get_session(&self, session_id: Uuid) -> Option<RecommendationSession> {
let sessions = self.sessions.read().await;
sessions.get(&session_id).cloned()
}
#[instrument(skip(self))]
pub async fn get_feedback(&self, session_id: Uuid) -> Option<RecommendationFeedback> {
let feedback = self.feedback.read().await;
feedback.get(&session_id).cloned()
}
#[instrument(skip(self))]
pub async fn get_session_for_episode(&self, episode_id: Uuid) -> Option<RecommendationSession> {
let episode_sessions = self.episode_sessions.read().await;
let session_id = episode_sessions.get(&episode_id)?;
let sessions = self.sessions.read().await;
sessions.get(session_id).cloned()
}
#[instrument(skip(self))]
pub async fn get_session_with_feedback(&self, session_id: Uuid) -> Option<SessionWithFeedback> {
let session = self.get_session(session_id).await?;
let feedback = self.get_feedback(session_id).await;
Some(SessionWithFeedback { session, feedback })
}
#[instrument(skip(self))]
pub async fn get_feedback_for_episode(
&self,
episode_id: Uuid,
) -> Option<RecommendationFeedback> {
let session = self.get_session_for_episode(episode_id).await?;
self.get_feedback(session.session_id).await
}
#[instrument(skip(self))]
pub async fn get_stats(&self) -> RecommendationStats {
let sessions = self.sessions.read().await;
let feedback = self.feedback.read().await;
let total_sessions = sessions.len();
let total_feedback = feedback.len();
let mut total_recommended: usize = 0;
let mut total_applied: usize = 0;
let mut successful_applications: usize = 0;
let mut total_ratings: f32 = 0.0;
let mut rating_count: usize = 0;
for session in sessions.values() {
total_recommended += session.recommended_pattern_ids.len();
}
for fb in feedback.values() {
total_applied += fb.applied_pattern_ids.len();
if matches!(
fb.outcome,
crate::types::TaskOutcome::Success { .. }
| crate::types::TaskOutcome::PartialSuccess { .. }
) {
successful_applications += fb.applied_pattern_ids.len();
}
if let Some(rating) = fb.agent_rating {
total_ratings += rating;
rating_count += 1;
}
}
let patterns_ignored = total_recommended.saturating_sub(total_applied);
let adoption_rate = if total_recommended > 0 {
total_applied as f32 / total_recommended as f32
} else {
0.0
};
let success_after_adoption_rate = if total_applied > 0 {
successful_applications as f32 / total_applied as f32
} else {
0.0
};
let avg_agent_rating = if rating_count > 0 {
Some(total_ratings / rating_count as f32)
} else {
None
};
RecommendationStats {
total_sessions,
total_feedback,
patterns_applied: total_applied,
patterns_ignored,
successful_applications,
adoption_rate,
success_after_adoption_rate,
avg_agent_rating,
}
}
#[instrument(skip(self))]
pub async fn clear(&self) {
let mut sessions = self.sessions.write().await;
let mut feedback = self.feedback.write().await;
let mut episode_sessions = self.episode_sessions.write().await;
sessions.clear();
feedback.clear();
episode_sessions.clear();
debug!("Cleared all recommendation data");
}
pub async fn get_all_sessions(&self) -> Vec<RecommendationSession> {
let sessions = self.sessions.read().await;
sessions.values().cloned().collect()
}
pub async fn get_all_feedback(&self) -> Vec<RecommendationFeedback> {
let feedback = self.feedback.read().await;
feedback.values().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::TaskOutcome;
fn create_test_session() -> RecommendationSession {
RecommendationSession {
session_id: Uuid::new_v4(),
episode_id: Uuid::new_v4(),
timestamp: chrono::Utc::now(),
recommended_pattern_ids: vec!["p1".to_string(), "p2".to_string()],
recommended_playbook_ids: vec![],
}
}
fn create_test_feedback(session_id: Uuid) -> RecommendationFeedback {
RecommendationFeedback {
session_id,
applied_pattern_ids: vec!["p1".to_string()],
consulted_episode_ids: vec![],
outcome: TaskOutcome::Success {
verdict: "Done".to_string(),
artifacts: vec![],
},
agent_rating: Some(0.9),
}
}
#[tokio::test]
async fn test_record_and_get_session() {
let tracker = RecommendationTracker::new();
let session = create_test_session();
let session_id = session.session_id;
tracker.record_session(session.clone()).await;
let retrieved = tracker.get_session(session_id).await;
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().session_id, session_id);
}
#[tokio::test]
async fn test_record_and_get_feedback() {
let tracker = RecommendationTracker::new();
let session = create_test_session();
let session_id = session.session_id;
tracker.record_session(session).await;
let feedback = create_test_feedback(session_id);
tracker.record_feedback(feedback.clone()).await.unwrap();
let retrieved = tracker.get_feedback(session_id).await;
assert!(retrieved.is_some());
}
#[tokio::test]
async fn test_get_session_for_episode() {
let tracker = RecommendationTracker::new();
let session = create_test_session();
let episode_id = session.episode_id;
tracker.record_session(session).await;
let retrieved = tracker.get_session_for_episode(episode_id).await;
assert!(retrieved.is_some());
}
#[tokio::test]
async fn test_get_stats() {
let tracker = RecommendationTracker::new();
let session = create_test_session();
let session_id = session.session_id;
tracker.record_session(session).await;
let feedback = create_test_feedback(session_id);
tracker.record_feedback(feedback).await.unwrap();
let stats = tracker.get_stats().await;
assert_eq!(stats.total_sessions, 1);
assert_eq!(stats.total_feedback, 1);
assert_eq!(stats.patterns_applied, 1);
assert_eq!(stats.patterns_ignored, 1); assert!((stats.adoption_rate - 0.5).abs() < 0.01);
}
#[tokio::test]
async fn test_clear() {
let tracker = RecommendationTracker::new();
let session = create_test_session();
tracker.record_session(session).await;
tracker.clear().await;
let stats = tracker.get_stats().await;
assert_eq!(stats.total_sessions, 0);
}
}