veza/veza-backend-api/internal/services/playback_alerts_service.go

374 lines
12 KiB
Go

package services
import (
"context"
"fmt"
"math"
"time"
"github.com/google/uuid"
"veza-backend-api/internal/models"
"go.uber.org/zap"
"gorm.io/gorm"
)
// PlaybackAlertsService gère la détection d'alertes pour les analytics de lecture
// T0374: Create Playback Analytics Alerts Service
type PlaybackAlertsService struct {
db *gorm.DB
logger *zap.Logger
}
// Alert représente une alerte détectée
type Alert struct {
Type string `json:"type"` // "anomaly", "low_completion_rate", "drop_off_point"
Severity string `json:"severity"` // "low", "medium", "high"
Message string `json:"message"` // Message descriptif
Value float64 `json:"value"` // Valeur qui a déclenché l'alerte
Threshold float64 `json:"threshold"` // Seuil utilisé
DetectedAt time.Time `json:"detected_at"` // Date de détection
Metadata map[string]interface{} `json:"metadata,omitempty"` // Métadonnées supplémentaires
}
// AlertConfig représente la configuration des seuils d'alerte
type AlertConfig struct {
LowCompletionRateThreshold float64 // Seuil pour completion rate bas (défaut: 30%)
AnomalyDeviationThreshold float64 // Nombre d'écarts-types pour détecter une anomalie (défaut: 2.0)
DropOffPointThreshold float64 // Seuil de drop-off en pourcentage de la durée (défaut: 25%)
}
// NewPlaybackAlertsService crée un nouveau service d'alertes d'analytics
func NewPlaybackAlertsService(db *gorm.DB, logger *zap.Logger) *PlaybackAlertsService {
if logger == nil {
logger = zap.NewNop()
}
return &PlaybackAlertsService{
db: db,
logger: logger,
}
}
// CheckAlerts vérifie les alertes pour un track donné
// T0374: Create Playback Analytics Alerts Service
func (s *PlaybackAlertsService) CheckAlerts(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) {
if trackID == uuid.Nil {
return nil, fmt.Errorf("invalid track ID: %s", trackID)
}
// Utiliser la configuration par défaut si non fournie
if config == nil {
config = &AlertConfig{
LowCompletionRateThreshold: 30.0,
AnomalyDeviationThreshold: 2.0,
DropOffPointThreshold: 25.0,
}
}
// Vérifier que le track existe
var track models.Track
if err := s.db.WithContext(ctx).First(&track, trackID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("track not found: %d", trackID)
}
return nil, fmt.Errorf("failed to get track: %w", err)
}
alerts := make([]Alert, 0)
// Détecter les anomalies
anomalyAlerts, err := s.detectAnomalies(ctx, trackID, config)
if err != nil {
s.logger.Warn("Failed to detect anomalies", zap.Error(err), zap.String("track_id", trackID.String()))
} else {
alerts = append(alerts, anomalyAlerts...)
}
// Détecter les completion rates bas
completionAlerts, err := s.detectLowCompletionRate(ctx, trackID, config)
if err != nil {
s.logger.Warn("Failed to detect low completion rates", zap.Error(err), zap.String("track_id", trackID.String()))
} else {
alerts = append(alerts, completionAlerts...)
}
// Détecter les drop-off points
dropOffAlerts, err := s.detectDropOffPoints(ctx, trackID, config)
if err != nil {
s.logger.Warn("Failed to detect drop-off points", zap.Error(err), zap.String("track_id", trackID.String()))
} else {
alerts = append(alerts, dropOffAlerts...)
}
s.logger.Info("Checked playback alerts",
zap.String("track_id", trackID.String()),
zap.Int("alerts_count", len(alerts)))
return alerts, nil
}
// detectAnomalies détecte les anomalies dans les statistiques de lecture
func (s *PlaybackAlertsService) detectAnomalies(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) {
var alerts []Alert
// Récupérer toutes les analytics récentes (30 derniers jours)
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
var analytics []models.PlaybackAnalytics
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
Where("track_id = ? AND created_at >= ?", trackID, thirtyDaysAgo).
Find(&analytics).Error; err != nil {
return nil, fmt.Errorf("failed to get analytics: %w", err)
}
if len(analytics) < 10 {
// Pas assez de données pour détecter des anomalies
return alerts, nil
}
// Calculer la moyenne et l'écart-type pour le play_time
var playTimes []float64
var completionRates []float64
for _, a := range analytics {
playTimes = append(playTimes, float64(a.PlayTime))
completionRates = append(completionRates, a.CompletionRate)
}
// Détecter les anomalies dans le play_time
playTimeMean, playTimeStdDev := s.calculateMeanAndStdDev(playTimes)
for _, a := range analytics {
playTime := float64(a.PlayTime)
deviation := math.Abs(playTime - playTimeMean)
if playTimeStdDev > 0 && deviation > config.AnomalyDeviationThreshold*playTimeStdDev {
severity := "medium"
if deviation > config.AnomalyDeviationThreshold*2*playTimeStdDev {
severity = "high"
}
alerts = append(alerts, Alert{
Type: "anomaly",
Severity: severity,
Message: fmt.Sprintf("Anomalous play time detected: %.0f seconds (mean: %.0f, std dev: %.0f)", playTime, playTimeMean, playTimeStdDev),
Value: playTime,
Threshold: playTimeMean + config.AnomalyDeviationThreshold*playTimeStdDev,
DetectedAt: time.Now(),
Metadata: map[string]interface{}{
"analytics_id": a.ID,
"user_id": a.UserID,
"mean": playTimeMean,
"std_dev": playTimeStdDev,
"deviation": deviation,
},
})
}
}
// Détecter les anomalies dans le completion rate
completionMean, completionStdDev := s.calculateMeanAndStdDev(completionRates)
for _, a := range analytics {
deviation := math.Abs(a.CompletionRate - completionMean)
if completionStdDev > 0 && deviation > config.AnomalyDeviationThreshold*completionStdDev {
severity := "medium"
if deviation > config.AnomalyDeviationThreshold*2*completionStdDev {
severity = "high"
}
alerts = append(alerts, Alert{
Type: "anomaly",
Severity: severity,
Message: fmt.Sprintf("Anomalous completion rate detected: %.2f%% (mean: %.2f%%, std dev: %.2f%%)", a.CompletionRate, completionMean, completionStdDev),
Value: a.CompletionRate,
Threshold: completionMean + config.AnomalyDeviationThreshold*completionStdDev,
DetectedAt: time.Now(),
Metadata: map[string]interface{}{
"analytics_id": a.ID,
"user_id": a.UserID,
"mean": completionMean,
"std_dev": completionStdDev,
"deviation": deviation,
},
})
}
}
return alerts, nil
}
// detectLowCompletionRate détecte les completion rates bas
func (s *PlaybackAlertsService) detectLowCompletionRate(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) {
var alerts []Alert
// Récupérer les statistiques récentes (7 derniers jours)
sevenDaysAgo := time.Now().AddDate(0, 0, -7)
var analytics []models.PlaybackAnalytics
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
Where("track_id = ? AND created_at >= ?", trackID, sevenDaysAgo).
Find(&analytics).Error; err != nil {
return nil, fmt.Errorf("failed to get analytics: %w", err)
}
if len(analytics) == 0 {
return alerts, nil
}
// Calculer le taux de completion moyen
var totalCompletion float64
var lowCompletionCount int
for _, a := range analytics {
totalCompletion += a.CompletionRate
if a.CompletionRate < config.LowCompletionRateThreshold {
lowCompletionCount++
}
}
averageCompletion := totalCompletion / float64(len(analytics))
// Si le taux moyen est bas, créer une alerte
if averageCompletion < config.LowCompletionRateThreshold {
severity := "medium"
if averageCompletion < config.LowCompletionRateThreshold/2 {
severity = "high"
}
alerts = append(alerts, Alert{
Type: "low_completion_rate",
Severity: severity,
Message: fmt.Sprintf("Low average completion rate: %.2f%% (threshold: %.2f%%)", averageCompletion, config.LowCompletionRateThreshold),
Value: averageCompletion,
Threshold: config.LowCompletionRateThreshold,
DetectedAt: time.Now(),
Metadata: map[string]interface{}{
"total_sessions": len(analytics),
"low_completion_count": lowCompletionCount,
"percentage_low": float64(lowCompletionCount) / float64(len(analytics)) * 100.0,
},
})
}
// Si un pourcentage élevé de sessions a un completion rate bas, créer une alerte
lowCompletionPercentage := float64(lowCompletionCount) / float64(len(analytics)) * 100.0
if lowCompletionPercentage > 50.0 {
severity := "medium"
if lowCompletionPercentage > 75.0 {
severity = "high"
}
alerts = append(alerts, Alert{
Type: "low_completion_rate",
Severity: severity,
Message: fmt.Sprintf("High percentage of sessions with low completion rate: %.2f%%", lowCompletionPercentage),
Value: lowCompletionPercentage,
Threshold: 50.0,
DetectedAt: time.Now(),
Metadata: map[string]interface{}{
"total_sessions": len(analytics),
"low_completion_count": lowCompletionCount,
"average_completion": averageCompletion,
},
})
}
return alerts, nil
}
// detectDropOffPoints détecte les points de drop-off (moments où les utilisateurs arrêtent de regarder)
func (s *PlaybackAlertsService) detectDropOffPoints(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) {
var alerts []Alert
// Récupérer le track pour connaître sa durée
var track models.Track
if err := s.db.WithContext(ctx).First(&track, trackID).Error; err != nil {
return nil, fmt.Errorf("failed to get track: %w", err)
}
if track.Duration <= 0 {
return alerts, nil
}
// Récupérer les analytics récentes (7 derniers jours)
sevenDaysAgo := time.Now().AddDate(0, 0, -7)
var analytics []models.PlaybackAnalytics
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
Where("track_id = ? AND created_at >= ?", trackID, sevenDaysAgo).
Find(&analytics).Error; err != nil {
return nil, fmt.Errorf("failed to get analytics: %w", err)
}
if len(analytics) == 0 {
return alerts, nil
}
// Calculer le pourcentage de la durée où les utilisateurs arrêtent
dropOffThresholdSeconds := float64(track.Duration) * (config.DropOffPointThreshold / 100.0)
var dropOffCount int
var dropOffTimes []float64
for _, a := range analytics {
// Si le play_time est inférieur au seuil de drop-off, c'est un drop-off
if float64(a.PlayTime) < dropOffThresholdSeconds {
dropOffCount++
dropOffTimes = append(dropOffTimes, float64(a.PlayTime))
}
}
dropOffPercentage := float64(dropOffCount) / float64(len(analytics)) * 100.0
// Si un pourcentage significatif de sessions s'arrête tôt, créer une alerte
if dropOffPercentage > 30.0 {
// Calculer le temps moyen de drop-off
var avgDropOffTime float64
if len(dropOffTimes) > 0 {
var sum float64
for _, t := range dropOffTimes {
sum += t
}
avgDropOffTime = sum / float64(len(dropOffTimes))
}
severity := "medium"
if dropOffPercentage > 50.0 {
severity = "high"
}
dropOffPointPercentage := (avgDropOffTime / float64(track.Duration)) * 100.0
alerts = append(alerts, Alert{
Type: "drop_off_point",
Severity: severity,
Message: fmt.Sprintf("Drop-off detected: %.2f%% of sessions stop before %.2f%% of track duration (avg drop-off at %.2f%%)", dropOffPercentage, config.DropOffPointThreshold, dropOffPointPercentage),
Value: dropOffPercentage,
Threshold: 30.0,
DetectedAt: time.Now(),
Metadata: map[string]interface{}{
"total_sessions": len(analytics),
"drop_off_count": dropOffCount,
"drop_off_threshold": config.DropOffPointThreshold,
"average_drop_off_time": avgDropOffTime,
"drop_off_point_percentage": dropOffPointPercentage,
"track_duration": track.Duration,
},
})
}
return alerts, nil
}
// calculateMeanAndStdDev calcule la moyenne et l'écart-type d'une série de valeurs
func (s *PlaybackAlertsService) calculateMeanAndStdDev(values []float64) (mean, stdDev float64) {
if len(values) == 0 {
return 0, 0
}
// Calculer la moyenne
var sum float64
for _, v := range values {
sum += v
}
mean = sum / float64(len(values))
// Calculer l'écart-type
var variance float64
for _, v := range values {
diff := v - mean
variance += diff * diff
}
variance = variance / float64(len(values))
stdDev = math.Sqrt(variance)
return mean, stdDev
}