veza/veza-backend-api/internal/services/playback_abtest_service.go
2025-12-03 20:29:37 +01:00

475 lines
17 KiB
Go

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."
}