package services import ( "context" "fmt" "math" "time" "veza-backend-api/internal/models" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" ) // PlaybackABTestService gère le support A/B testing pour les analytics de lecture // T0379: Create Playback Analytics A/B Testing Support type PlaybackABTestService struct { db *gorm.DB logger *zap.Logger } // NewPlaybackABTestService crée un nouveau service A/B testing func NewPlaybackABTestService(db *gorm.DB, logger *zap.Logger) *PlaybackABTestService { if logger == nil { logger = zap.NewNop() } return &PlaybackABTestService{ db: db, logger: logger, } } // VariantFilter représente les critères de filtrage pour un variant // GO-004: Migré vers UUID pour TrackID et UserIDs type VariantFilter struct { TrackID *uuid.UUID `json:"track_id,omitempty"` StartDate *time.Time `json:"start_date,omitempty"` EndDate *time.Time `json:"end_date,omitempty"` UserIDs []uuid.UUID `json:"user_ids,omitempty"` // Liste d'IDs utilisateurs spécifiques MinPlayTime *int `json:"min_play_time,omitempty"` // Filtre optionnel par temps de lecture minimum } // VariantStats représente les statistiques d'un variant type VariantStats struct { VariantName string `json:"variant_name"` TotalSessions int64 `json:"total_sessions"` TotalPlayTime int64 `json:"total_play_time"` // seconds AveragePlayTime float64 `json:"average_play_time"` // seconds AverageCompletion float64 `json:"average_completion"` // percentage CompletionRate float64 `json:"completion_rate"` // percentage of sessions with >90% completion AveragePauses float64 `json:"average_pauses"` AverageSeeks float64 `json:"average_seeks"` } // StatisticalSignificance représente la significativité statistique type StatisticalSignificance struct { PValue float64 `json:"p_value"` // P-value (0-1) IsSignificant bool `json:"is_significant"` // True si p-value < 0.05 ConfidenceLevel float64 `json:"confidence_level"` // Niveau de confiance (95%, 99%, etc.) ConfidenceIntervalLower float64 `json:"confidence_interval_lower"` // Borne inférieure de l'intervalle de confiance ConfidenceIntervalUpper float64 `json:"confidence_interval_upper"` // Borne supérieure de l'intervalle de confiance EffectSize float64 `json:"effect_size"` // Taille de l'effet (Cohen's d) } // ABTestStatsDifference représente la différence absolue entre deux variants type ABTestStatsDifference struct { TotalSessions int64 `json:"total_sessions"` TotalPlayTime int64 `json:"total_play_time"` // seconds AveragePlayTime float64 `json:"average_play_time"` // seconds TotalPauses int64 `json:"total_pauses"` AveragePauses float64 `json:"average_pauses"` TotalSeeks int64 `json:"total_seeks"` AverageSeeks float64 `json:"average_seeks"` AverageCompletion float64 `json:"average_completion"` // percentage CompletionRate float64 `json:"completion_rate"` // percentage } // ABTestPercentageChange représente le changement en pourcentage entre deux variants type ABTestPercentageChange struct { TotalSessions float64 `json:"total_sessions"` TotalPlayTime float64 `json:"total_play_time"` AveragePlayTime float64 `json:"average_play_time"` TotalPauses float64 `json:"total_pauses"` AveragePauses float64 `json:"average_pauses"` TotalSeeks float64 `json:"total_seeks"` AverageSeeks float64 `json:"average_seeks"` AverageCompletion float64 `json:"average_completion"` CompletionRate float64 `json:"completion_rate"` } // ABTestResult représente le résultat d'un test A/B type ABTestResult struct { VariantA *VariantStats `json:"variant_a"` VariantB *VariantStats `json:"variant_b"` Difference *ABTestStatsDifference `json:"difference"` PercentageChange *ABTestPercentageChange `json:"percentage_change"` Significance *StatisticalSignificance `json:"significance"` Winner string `json:"winner,omitempty"` // "A", "B", ou "inconclusive" Recommendation string `json:"recommendation,omitempty"` // Recommandation basée sur les résultats AnalyzedAt time.Time `json:"analyzed_at"` } // CompareVariants compare deux variants et calcule la significativité statistique // T0379: Create Playback Analytics A/B Testing Support func (s *PlaybackABTestService) CompareVariants(ctx context.Context, variantA, variantB string, filterA, filterB VariantFilter) (*ABTestResult, error) { if variantA == "" || variantB == "" { return nil, fmt.Errorf("variant names cannot be empty") } // Récupérer les analytics pour le variant A analyticsA, err := s.getAnalyticsForVariant(ctx, filterA) if err != nil { return nil, fmt.Errorf("failed to get analytics for variant A: %w", err) } // Récupérer les analytics pour le variant B analyticsB, err := s.getAnalyticsForVariant(ctx, filterB) if err != nil { return nil, fmt.Errorf("failed to get analytics for variant B: %w", err) } // Calculer les statistiques pour chaque variant statsA := s.calculateVariantStats(variantA, analyticsA) statsB := s.calculateVariantStats(variantB, analyticsB) // Calculer les différences difference := s.calculateDifference(statsA, statsB) percentageChange := s.calculatePercentageChange(statsA, statsB) // Calculer la significativité statistique significance := s.calculateStatisticalSignificance(analyticsA, analyticsB) // Déterminer le gagnant winner := s.determineWinner(statsA, statsB, significance) recommendation := s.generateRecommendation(statsA, statsB, significance) result := &ABTestResult{ VariantA: statsA, VariantB: statsB, Difference: difference, PercentageChange: percentageChange, Significance: significance, Winner: winner, Recommendation: recommendation, AnalyzedAt: time.Now(), } s.logger.Info("Compared A/B test variants", zap.String("variant_a", variantA), zap.String("variant_b", variantB), zap.Int64("sessions_a", statsA.TotalSessions), zap.Int64("sessions_b", statsB.TotalSessions), zap.Bool("significant", significance.IsSignificant), zap.String("winner", winner)) return result, nil } // getAnalyticsForVariant récupère les analytics pour un variant selon les filtres func (s *PlaybackABTestService) getAnalyticsForVariant(ctx context.Context, filter VariantFilter) ([]models.PlaybackAnalytics, error) { query := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}) if filter.TrackID != nil && *filter.TrackID != uuid.Nil { query = query.Where("track_id = ?", *filter.TrackID) } if filter.StartDate != nil { query = query.Where("created_at >= ?", *filter.StartDate) } if filter.EndDate != nil { query = query.Where("created_at <= ?", *filter.EndDate) } if len(filter.UserIDs) > 0 { query = query.Where("user_id IN ?", filter.UserIDs) } if filter.MinPlayTime != nil && *filter.MinPlayTime > 0 { query = query.Where("play_time >= ?", *filter.MinPlayTime) } var analytics []models.PlaybackAnalytics if err := query.Find(&analytics).Error; err != nil { return nil, fmt.Errorf("failed to query analytics: %w", err) } return analytics, nil } // calculateVariantStats calcule les statistiques pour un variant func (s *PlaybackABTestService) calculateVariantStats(variantName string, analytics []models.PlaybackAnalytics) *VariantStats { if len(analytics) == 0 { return &VariantStats{ VariantName: variantName, } } var totalSessions int64 var totalPlayTime int64 var totalCompletion float64 var totalPauses int64 var totalSeeks int64 var completedSessions int64 for _, a := range analytics { totalSessions++ totalPlayTime += int64(a.PlayTime) totalCompletion += a.CompletionRate totalPauses += int64(a.PauseCount) totalSeeks += int64(a.SeekCount) if a.CompletionRate >= 90.0 { completedSessions++ } } sessionCount := float64(totalSessions) stats := &VariantStats{ VariantName: variantName, TotalSessions: totalSessions, TotalPlayTime: totalPlayTime, AveragePlayTime: float64(totalPlayTime) / sessionCount, AverageCompletion: totalCompletion / sessionCount, CompletionRate: float64(completedSessions) / sessionCount * 100.0, AveragePauses: float64(totalPauses) / sessionCount, AverageSeeks: float64(totalSeeks) / sessionCount, } return stats } // calculateDifference calcule la différence absolue entre deux variants func (s *PlaybackABTestService) calculateDifference(statsA, statsB *VariantStats) *ABTestStatsDifference { return &ABTestStatsDifference{ TotalSessions: statsB.TotalSessions - statsA.TotalSessions, TotalPlayTime: statsB.TotalPlayTime - statsA.TotalPlayTime, AveragePlayTime: statsB.AveragePlayTime - statsA.AveragePlayTime, TotalPauses: int64(statsB.AveragePauses*float64(statsB.TotalSessions)) - int64(statsA.AveragePauses*float64(statsA.TotalSessions)), AveragePauses: statsB.AveragePauses - statsA.AveragePauses, TotalSeeks: int64(statsB.AverageSeeks*float64(statsB.TotalSessions)) - int64(statsA.AverageSeeks*float64(statsA.TotalSessions)), AverageSeeks: statsB.AverageSeeks - statsA.AverageSeeks, AverageCompletion: statsB.AverageCompletion - statsA.AverageCompletion, CompletionRate: statsB.CompletionRate - statsA.CompletionRate, } } // calculatePercentageChange calcule le changement en pourcentage entre deux variants func (s *PlaybackABTestService) calculatePercentageChange(statsA, statsB *VariantStats) *ABTestPercentageChange { return &ABTestPercentageChange{ TotalSessions: s.safePercentageChange(float64(statsA.TotalSessions), float64(statsB.TotalSessions)), TotalPlayTime: s.safePercentageChange(float64(statsA.TotalPlayTime), float64(statsB.TotalPlayTime)), AveragePlayTime: s.safePercentageChange(statsA.AveragePlayTime, statsB.AveragePlayTime), TotalPauses: s.safePercentageChange(statsA.AveragePauses*float64(statsA.TotalSessions), statsB.AveragePauses*float64(statsB.TotalSessions)), AveragePauses: s.safePercentageChange(statsA.AveragePauses, statsB.AveragePauses), TotalSeeks: s.safePercentageChange(statsA.AverageSeeks*float64(statsA.TotalSessions), statsB.AverageSeeks*float64(statsB.TotalSessions)), AverageSeeks: s.safePercentageChange(statsA.AverageSeeks, statsB.AverageSeeks), AverageCompletion: s.safePercentageChange(statsA.AverageCompletion, statsB.AverageCompletion), CompletionRate: s.safePercentageChange(statsA.CompletionRate, statsB.CompletionRate), } } // safePercentageChange calcule le changement en pourcentage en gérant la division par zéro func (s *PlaybackABTestService) safePercentageChange(base, current float64) float64 { if base == 0 { if current == 0 { return 0.0 } return math.Inf(1) // Infini si la base est zéro et le courant est non-zéro } return ((current - base) / base) * 100.0 } // calculateStatisticalSignificance calcule la significativité statistique entre deux variants // Utilise un test t de Student pour comparer les moyennes de completion rate func (s *PlaybackABTestService) calculateStatisticalSignificance(analyticsA, analyticsB []models.PlaybackAnalytics) *StatisticalSignificance { if len(analyticsA) == 0 || len(analyticsB) == 0 { return &StatisticalSignificance{ PValue: 1.0, IsSignificant: false, ConfidenceLevel: 95.0, EffectSize: 0.0, } } // Extraire les completion rates completionRatesA := make([]float64, len(analyticsA)) for i, a := range analyticsA { completionRatesA[i] = a.CompletionRate } completionRatesB := make([]float64, len(analyticsB)) for i, a := range analyticsB { completionRatesB[i] = a.CompletionRate } // Calculer les moyennes et écarts-types meanA, stdDevA := s.calculateMeanAndStdDev(completionRatesA) meanB, stdDevB := s.calculateMeanAndStdDev(completionRatesB) // Calculer le test t de Student pValue := s.calculateTTest(completionRatesA, completionRatesB, meanA, meanB, stdDevA, stdDevB) // Calculer l'intervalle de confiance à 95% confidenceLevel := 95.0 seA := stdDevA / math.Sqrt(float64(len(completionRatesA))) seB := stdDevB / math.Sqrt(float64(len(completionRatesB))) tValue := 1.96 // Pour un intervalle de confiance à 95% diff := meanB - meanA seDiff := math.Sqrt(seA*seA + seB*seB) confidenceIntervalLower := diff - tValue*seDiff confidenceIntervalUpper := diff + tValue*seDiff // Calculer la taille de l'effet (Cohen's d) pooledStdDev := math.Sqrt((stdDevA*stdDevA + stdDevB*stdDevB) / 2.0) effectSize := 0.0 if pooledStdDev > 0 { effectSize = (meanB - meanA) / pooledStdDev } return &StatisticalSignificance{ PValue: pValue, IsSignificant: pValue < 0.05, ConfidenceLevel: confidenceLevel, ConfidenceIntervalLower: confidenceIntervalLower, ConfidenceIntervalUpper: confidenceIntervalUpper, EffectSize: effectSize, } } // calculateMeanAndStdDev calcule la moyenne et l'écart-type func (s *PlaybackABTestService) calculateMeanAndStdDev(data []float64) (mean, stdDev float64) { if len(data) == 0 { return 0, 0 } // Calcul de la moyenne var sum float64 for _, v := range data { sum += v } mean = sum / float64(len(data)) // Calcul de l'écart-type var sumSqDiff float64 for _, v := range data { diff := v - mean sumSqDiff += diff * diff } if len(data) > 1 { stdDev = math.Sqrt(sumSqDiff / float64(len(data)-1)) // Échantillon } else { stdDev = 0 } return mean, stdDev } // calculateTTest calcule la p-value d'un test t de Student // Approximation simplifiée pour deux échantillons indépendants func (s *PlaybackABTestService) calculateTTest(dataA, dataB []float64, meanA, meanB, stdDevA, stdDevB float64) float64 { nA := float64(len(dataA)) nB := float64(len(dataB)) if nA < 2 || nB < 2 { return 1.0 // Pas assez de données pour un test significatif } // Calcul de l'erreur standard de la différence seA := stdDevA / math.Sqrt(nA) seB := stdDevB / math.Sqrt(nB) seDiff := math.Sqrt(seA*seA + seB*seB) if seDiff == 0 { return 1.0 } // Calcul de la statistique t tStat := (meanB - meanA) / seDiff // Calcul des degrés de liberté (approximation de Welch) _ = s.calculateWelchDF(seA, seB, nA, nB) // Calculé mais non utilisé dans l'approximation normale // Approximation de la p-value (test bilatéral) // Utilisation d'une approximation normale pour simplifier // En production, on utiliserait une table t ou une fonction de distribution pValue := 2.0 * (1.0 - s.normalCDF(math.Abs(tStat))) return pValue } // calculateWelchDF calcule les degrés de liberté pour le test t de Welch func (s *PlaybackABTestService) calculateWelchDF(seA, seB, nA, nB float64) float64 { if seA == 0 && seB == 0 { return nA + nB - 2 } if seA == 0 { return nB - 1 } if seB == 0 { return nA - 1 } numerator := math.Pow(seA*seA+seB*seB, 2) denominator := math.Pow(seA*seA, 2)/(nA-1) + math.Pow(seB*seB, 2)/(nB-1) if denominator == 0 { return nA + nB - 2 } return numerator / denominator } // normalCDF calcule la fonction de répartition cumulative de la distribution normale standard // Approximation utilisant la fonction d'erreur func (s *PlaybackABTestService) normalCDF(x float64) float64 { return 0.5 * (1.0 + s.erf(x/math.Sqrt2)) } // erf calcule la fonction d'erreur (approximation) func (s *PlaybackABTestService) erf(x float64) float64 { // Approximation de la fonction d'erreur // Formule d'Abramowitz et Stegun a1 := 0.254829592 a2 := -0.284496736 a3 := 1.421413741 a4 := -1.453152027 a5 := 1.061405429 p := 0.3275911 sign := 1.0 if x < 0 { sign = -1.0 x = -x } t := 1.0 / (1.0 + p*x) y := 1.0 - (((((a5*t+a4)*t)+a3)*t+a2)*t+a1)*t*math.Exp(-x*x) return sign * y } // determineWinner détermine le gagnant du test A/B func (s *PlaybackABTestService) determineWinner(statsA, statsB *VariantStats, significance *StatisticalSignificance) string { if !significance.IsSignificant { return "inconclusive" } // Le gagnant est déterminé par le completion rate le plus élevé if statsB.CompletionRate > statsA.CompletionRate { return "B" } else if statsA.CompletionRate > statsB.CompletionRate { return "A" } return "inconclusive" } // generateRecommendation génère une recommandation basée sur les résultats func (s *PlaybackABTestService) generateRecommendation(statsA, statsB *VariantStats, significance *StatisticalSignificance) string { if !significance.IsSignificant { return "Les résultats ne sont pas statistiquement significatifs. Continuer le test ou augmenter la taille de l'échantillon." } if statsB.CompletionRate > statsA.CompletionRate { improvement := ((statsB.CompletionRate - statsA.CompletionRate) / statsA.CompletionRate) * 100.0 return fmt.Sprintf("Le variant B est significativement meilleur avec une amélioration de %.2f%% du taux de complétion.", improvement) } else if statsA.CompletionRate > statsB.CompletionRate { improvement := ((statsA.CompletionRate - statsB.CompletionRate) / statsB.CompletionRate) * 100.0 return fmt.Sprintf("Le variant A est significativement meilleur avec une amélioration de %.2f%% du taux de complétion.", improvement) } return "Aucune différence significative entre les variants." }