475 lines
17 KiB
Go
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."
|
|
}
|