veza/veza-backend-api/internal/services/playback_segmentation_service.go
2026-03-05 23:03:43 +01:00

373 lines
13 KiB
Go

package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"veza-backend-api/internal/models"
"go.uber.org/zap"
"gorm.io/gorm"
)
// UserSegment représente un segment d'utilisateur
// T0378: Create Playback Analytics User Segmentation
type UserSegment string
const (
// Segments par engagement
SegmentHighEngagement UserSegment = "high_engagement"
SegmentMediumEngagement UserSegment = "medium_engagement"
SegmentLowEngagement UserSegment = "low_engagement"
// Segments par completion rate
SegmentHighCompletion UserSegment = "high_completion"
SegmentMediumCompletion UserSegment = "medium_completion"
SegmentLowCompletion UserSegment = "low_completion"
// Segments par comportement
SegmentActiveListener UserSegment = "active_listener" // Beaucoup de sessions
SegmentCasualListener UserSegment = "casual_listener" // Peu de sessions
SegmentFrequentSkipper UserSegment = "frequent_skipper" // Beaucoup de skips
SegmentFocusedListener UserSegment = "focused_listener" // Peu de skips, beaucoup d'écoute
)
// PlaybackSegmentationService gère la segmentation des utilisateurs pour les analytics de lecture
// T0378: Create Playback Analytics User Segmentation
type PlaybackSegmentationService struct {
db *gorm.DB
logger *zap.Logger
}
// NewPlaybackSegmentationService crée un nouveau service de segmentation d'utilisateurs
func NewPlaybackSegmentationService(db *gorm.DB, logger *zap.Logger) *PlaybackSegmentationService {
if logger == nil {
logger = zap.NewNop()
}
return &PlaybackSegmentationService{
db: db,
logger: logger,
}
}
// UserMetrics représente les métriques agrégées pour un utilisateur
// MIGRATION UUID: UserID migré vers uuid.UUID
type UserMetrics struct {
UserID uuid.UUID `json:"user_id"`
SessionCount int64 `json:"session_count"`
AverageCompletion float64 `json:"average_completion"` // Taux de complétion moyen (%)
AveragePlayTime float64 `json:"average_play_time"` // Temps de lecture moyen (secondes)
TotalPlayTime int64 `json:"total_play_time"` // Temps de lecture total (secondes)
AveragePauses float64 `json:"average_pauses"` // Nombre moyen de pauses
AverageSeeks float64 `json:"average_seeks"` // Nombre moyen de seeks
EngagementScore float64 `json:"engagement_score"` // Score d'engagement (0-100)
CompletionRate float64 `json:"completion_rate"` // Pourcentage de sessions complétées (>90%)
SkipRate float64 `json:"skip_rate"` // Taux de skips (seeks par session)
}
// SegmentationResult représente le résultat de la segmentation
type SegmentationResult struct {
TrackID uuid.UUID `json:"track_id"`
TotalUsers int64 `json:"total_users"`
Segments map[UserSegment][]uuid.UUID `json:"segments"` // Map de segment -> liste d'user UUIDs
UserMetrics map[uuid.UUID]*UserMetrics `json:"user_metrics,omitempty"` // Métriques par utilisateur
SegmentCounts map[UserSegment]int64 `json:"segment_counts"` // Nombre d'utilisateurs par segment
AnalyzedAt time.Time `json:"analyzed_at"`
}
// SegmentUsers segmente les utilisateurs pour un track donné
// T0378: Create Playback Analytics User Segmentation
func (s *PlaybackSegmentationService) SegmentUsers(ctx context.Context, trackID uuid.UUID) (*SegmentationResult, error) {
if trackID == uuid.Nil {
return nil, fmt.Errorf("invalid track ID: %s", trackID)
}
// Vérifier que le track existe
var track models.Track
if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("track not found: %s", trackID)
}
return nil, fmt.Errorf("failed to get track: %w", err)
}
// Récupérer toutes les analytics pour ce track
var analytics []models.PlaybackAnalytics
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
Where("track_id = ?", trackID).
Find(&analytics).Error; err != nil {
return nil, fmt.Errorf("failed to get analytics: %w", err)
}
if len(analytics) == 0 {
// Retourner un résultat vide
return &SegmentationResult{
TrackID: trackID,
TotalUsers: 0,
Segments: make(map[UserSegment][]uuid.UUID),
UserMetrics: make(map[uuid.UUID]*UserMetrics),
SegmentCounts: make(map[UserSegment]int64),
AnalyzedAt: time.Now(),
}, nil
}
// Calculer les métriques par utilisateur
userMetrics := s.calculateUserMetrics(analytics)
// Segmenter par engagement
engagementSegments := s.segmentByEngagement(userMetrics)
// Segmenter par completion rate
completionSegments := s.segmentByCompletionRate(userMetrics)
// Segmenter par comportement
behaviorSegments := s.segmentByBehavior(userMetrics)
// Combiner tous les segments
allSegments := make(map[UserSegment][]uuid.UUID)
for segment, userIDs := range engagementSegments {
allSegments[segment] = userIDs
}
for segment, userIDs := range completionSegments {
allSegments[segment] = userIDs
}
for segment, userIDs := range behaviorSegments {
allSegments[segment] = userIDs
}
// Calculer les compteurs par segment
segmentCounts := make(map[UserSegment]int64)
for segment, userIDs := range allSegments {
segmentCounts[segment] = int64(len(userIDs))
}
result := &SegmentationResult{
TrackID: trackID,
TotalUsers: int64(len(userMetrics)),
Segments: allSegments,
UserMetrics: userMetrics,
SegmentCounts: segmentCounts,
AnalyzedAt: time.Now(),
}
s.logger.Info("Segmented users for track",
zap.String("track_id", trackID.String()),
zap.Int64("total_users", result.TotalUsers),
zap.Int("total_segments", len(allSegments)))
return result, nil
}
// calculateUserMetrics calcule les métriques agrégées pour chaque utilisateur
// MIGRATION UUID: retourne map[uuid.UUID]*UserMetrics
func (s *PlaybackSegmentationService) calculateUserMetrics(analytics []models.PlaybackAnalytics) map[uuid.UUID]*UserMetrics {
userMetricsMap := make(map[uuid.UUID]*UserMetrics)
// Grouper les analytics par utilisateur
userAnalytics := make(map[uuid.UUID][]models.PlaybackAnalytics)
for _, a := range analytics {
userAnalytics[a.UserID] = append(userAnalytics[a.UserID], a)
}
// Calculer les métriques pour chaque utilisateur
for userID, userSessions := range userAnalytics {
if len(userSessions) == 0 {
continue
}
var totalCompletion float64
var totalPlayTime int64
var totalPauses int64
var totalSeeks int64
var completedSessions int64
for _, session := range userSessions {
totalCompletion += session.CompletionRate
totalPlayTime += int64(session.PlayTime)
totalPauses += int64(session.PauseCount)
totalSeeks += int64(session.SeekCount)
if session.CompletionRate >= 90.0 {
completedSessions++
}
}
sessionCount := int64(len(userSessions))
averageCompletion := totalCompletion / float64(sessionCount)
averagePlayTime := float64(totalPlayTime) / float64(sessionCount)
averagePauses := float64(totalPauses) / float64(sessionCount)
averageSeeks := float64(totalSeeks) / float64(sessionCount)
completionRate := float64(completedSessions) / float64(sessionCount) * 100.0
skipRate := averageSeeks // Taux de skips = nombre moyen de seeks
// Calculer le score d'engagement (0-100)
// Basé sur: completion rate (50%), pauses (25%), seeks (25%)
engagementScore := averageCompletion * 0.5
// Normaliser les pauses (0-10 pauses = 0-25 points)
pauseScore := 25.0
if averagePauses > 0 {
pauseScore = 25.0 - (averagePauses / 10.0 * 25.0)
if pauseScore < 0 {
pauseScore = 0
}
}
engagementScore += pauseScore
// Normaliser les seeks (0-5 seeks = 0-25 points)
seekScore := 25.0
if averageSeeks > 0 {
seekScore = 25.0 - (averageSeeks / 5.0 * 25.0)
if seekScore < 0 {
seekScore = 0
}
}
engagementScore += seekScore
// S'assurer que le score est entre 0 et 100
if engagementScore > 100.0 {
engagementScore = 100.0
}
if engagementScore < 0.0 {
engagementScore = 0.0
}
userMetricsMap[userID] = &UserMetrics{
UserID: userID, // UUID
SessionCount: sessionCount,
AverageCompletion: averageCompletion,
AveragePlayTime: averagePlayTime,
TotalPlayTime: totalPlayTime,
AveragePauses: averagePauses,
AverageSeeks: averageSeeks,
EngagementScore: engagementScore,
CompletionRate: completionRate,
SkipRate: skipRate,
}
}
return userMetricsMap
}
// segmentByEngagement segmente les utilisateurs par niveau d'engagement
// MIGRATION UUID: paramètre et retour utilisent uuid.UUID
func (s *PlaybackSegmentationService) segmentByEngagement(userMetrics map[uuid.UUID]*UserMetrics) map[UserSegment][]uuid.UUID {
segments := make(map[UserSegment][]uuid.UUID)
segments[SegmentHighEngagement] = []uuid.UUID{}
segments[SegmentMediumEngagement] = []uuid.UUID{}
segments[SegmentLowEngagement] = []uuid.UUID{}
for userID, metrics := range userMetrics {
if metrics.EngagementScore >= 75.0 {
segments[SegmentHighEngagement] = append(segments[SegmentHighEngagement], userID)
} else if metrics.EngagementScore >= 50.0 {
segments[SegmentMediumEngagement] = append(segments[SegmentMediumEngagement], userID)
} else {
segments[SegmentLowEngagement] = append(segments[SegmentLowEngagement], userID)
}
}
return segments
}
// segmentByCompletionRate segmente les utilisateurs par taux de complétion
// MIGRATION UUID: paramètre et retour utilisent uuid.UUID
func (s *PlaybackSegmentationService) segmentByCompletionRate(userMetrics map[uuid.UUID]*UserMetrics) map[UserSegment][]uuid.UUID {
segments := make(map[UserSegment][]uuid.UUID)
segments[SegmentHighCompletion] = []uuid.UUID{}
segments[SegmentMediumCompletion] = []uuid.UUID{}
segments[SegmentLowCompletion] = []uuid.UUID{}
for userID, metrics := range userMetrics {
if metrics.AverageCompletion >= 75.0 {
segments[SegmentHighCompletion] = append(segments[SegmentHighCompletion], userID)
} else if metrics.AverageCompletion >= 50.0 {
segments[SegmentMediumCompletion] = append(segments[SegmentMediumCompletion], userID)
} else {
segments[SegmentLowCompletion] = append(segments[SegmentLowCompletion], userID)
}
}
return segments
}
// segmentByBehavior segmente les utilisateurs par comportement d'écoute
// MIGRATION UUID: paramètre et retour utilisent uuid.UUID
func (s *PlaybackSegmentationService) segmentByBehavior(userMetrics map[uuid.UUID]*UserMetrics) map[UserSegment][]uuid.UUID {
segments := make(map[UserSegment][]uuid.UUID)
segments[SegmentActiveListener] = []uuid.UUID{}
segments[SegmentCasualListener] = []uuid.UUID{}
segments[SegmentFrequentSkipper] = []uuid.UUID{}
segments[SegmentFocusedListener] = []uuid.UUID{}
// Calculer les seuils basés sur les données
var totalSessions int64
var totalSeeks float64
var maxSessions int64
for _, metrics := range userMetrics {
totalSessions += metrics.SessionCount
totalSeeks += metrics.AverageSeeks
if metrics.SessionCount > maxSessions {
maxSessions = metrics.SessionCount
}
}
avgSessions := float64(totalSessions) / float64(len(userMetrics))
avgSeeks := totalSeeks / float64(len(userMetrics))
// Seuils pour la segmentation
activeThreshold := avgSessions * 1.5 // 50% au-dessus de la moyenne
casualThreshold := avgSessions * 0.5 // 50% en dessous de la moyenne
skipThreshold := avgSeeks * 1.5 // 50% au-dessus de la moyenne des seeks
focusedThreshold := avgSeeks * 0.5 // 50% en dessous de la moyenne des seeks
for userID, metrics := range userMetrics {
// Segmentation par nombre de sessions
if float64(metrics.SessionCount) >= activeThreshold {
segments[SegmentActiveListener] = append(segments[SegmentActiveListener], userID)
} else if float64(metrics.SessionCount) <= casualThreshold {
segments[SegmentCasualListener] = append(segments[SegmentCasualListener], userID)
}
// Segmentation par comportement de skip
if metrics.AverageSeeks >= skipThreshold {
segments[SegmentFrequentSkipper] = append(segments[SegmentFrequentSkipper], userID)
} else if metrics.AverageSeeks <= focusedThreshold && metrics.AverageCompletion >= 70.0 {
// Focused listener: peu de skips ET bonne complétion
segments[SegmentFocusedListener] = append(segments[SegmentFocusedListener], userID)
}
}
return segments
}
// GetUserSegment retourne le segment principal d'un utilisateur pour un track
// MIGRATION UUID: userID migré vers uuid.UUID, trackID reste int64
func (s *PlaybackSegmentationService) GetUserSegment(ctx context.Context, trackID uuid.UUID, userID uuid.UUID) (UserSegment, error) {
if trackID == uuid.Nil || userID == uuid.Nil {
return "", fmt.Errorf("invalid track ID or user ID: trackID=%s, userID=%s", trackID, userID)
}
result, err := s.SegmentUsers(ctx, trackID)
if err != nil {
return "", err
}
// Trouver le segment principal de l'utilisateur (priorité: engagement > completion > behavior)
userMetrics, exists := result.UserMetrics[userID]
if !exists {
return "", fmt.Errorf("user %s not found in analytics for track %s", userID, trackID)
}
// Déterminer le segment principal basé sur l'engagement
if userMetrics.EngagementScore >= 75.0 {
return SegmentHighEngagement, nil
} else if userMetrics.EngagementScore >= 50.0 {
return SegmentMediumEngagement, nil
} else {
return SegmentLowEngagement, nil
}
}