You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

617 lines
18 KiB
Go

package valueobjects
import (
"fmt"
"time"
"knowfoolery/backend/shared/types"
"knowfoolery/backend/shared/errors"
)
// QuestionStatistics tracks usage and performance statistics for a question
type QuestionStatistics struct {
// Usage counts
totalAttempts int64
correctAttempts int64
incorrectAttempts int64
timeoutAttempts int64
// Hint usage
hintsRequested int64
// Difficulty tracking
difficultyVotes map[types.DifficultyLevel]int64
averageDifficulty float64
// Performance metrics
averageTimeToAnswer time.Duration
fastestAnswer time.Duration
slowestAnswer time.Duration
// Session tracking
uniqueSessions int64
uniqueUsers int64
// Time tracking
firstUsed *types.Timestamp
lastUsed *types.Timestamp
// Trends (last 30 days)
recentAttempts int64
recentCorrectRate float64
// Quality indicators
skipRate float64 // How often players skip this question
reportCount int64 // How many times question was reported
// Calculated fields
correctRate float64
popularityScore float64
// Update tracking
lastCalculated types.Timestamp
}
// NewQuestionStatistics creates new question statistics
func NewQuestionStatistics() *QuestionStatistics {
now := types.NewTimestamp()
return &QuestionStatistics{
totalAttempts: 0,
correctAttempts: 0,
incorrectAttempts: 0,
timeoutAttempts: 0,
hintsRequested: 0,
difficultyVotes: make(map[types.DifficultyLevel]int64),
averageDifficulty: 0.0,
averageTimeToAnswer: 0,
fastestAnswer: time.Duration(0),
slowestAnswer: time.Duration(0),
uniqueSessions: 0,
uniqueUsers: 0,
firstUsed: nil,
lastUsed: nil,
recentAttempts: 0,
recentCorrectRate: 0.0,
skipRate: 0.0,
reportCount: 0,
correctRate: 0.0,
popularityScore: 0.0,
lastCalculated: now,
}
}
// NewQuestionStatisticsWithData creates statistics with existing data
func NewQuestionStatisticsWithData(
totalAttempts int64,
correctAttempts int64,
incorrectAttempts int64,
timeoutAttempts int64,
hintsRequested int64,
uniqueSessions int64,
uniqueUsers int64,
firstUsed *types.Timestamp,
lastUsed *types.Timestamp,
) *QuestionStatistics {
stats := NewQuestionStatistics()
stats.totalAttempts = totalAttempts
stats.correctAttempts = correctAttempts
stats.incorrectAttempts = incorrectAttempts
stats.timeoutAttempts = timeoutAttempts
stats.hintsRequested = hintsRequested
stats.uniqueSessions = uniqueSessions
stats.uniqueUsers = uniqueUsers
stats.firstUsed = firstUsed
stats.lastUsed = lastUsed
stats.recalculate()
return stats
}
// Getters
// TotalAttempts returns the total number of attempts
func (qs *QuestionStatistics) TotalAttempts() int64 {
return qs.totalAttempts
}
// CorrectAttempts returns the number of correct attempts
func (qs *QuestionStatistics) CorrectAttempts() int64 {
return qs.correctAttempts
}
// IncorrectAttempts returns the number of incorrect attempts
func (qs *QuestionStatistics) IncorrectAttempts() int64 {
return qs.incorrectAttempts
}
// TimeoutAttempts returns the number of timeout attempts
func (qs *QuestionStatistics) TimeoutAttempts() int64 {
return qs.timeoutAttempts
}
// HintsRequested returns the number of hints requested
func (qs *QuestionStatistics) HintsRequested() int64 {
return qs.hintsRequested
}
// DifficultyVotes returns the difficulty votes
func (qs *QuestionStatistics) DifficultyVotes() map[types.DifficultyLevel]int64 {
result := make(map[types.DifficultyLevel]int64)
for k, v := range qs.difficultyVotes {
result[k] = v
}
return result
}
// AverageDifficulty returns the average perceived difficulty
func (qs *QuestionStatistics) AverageDifficulty() float64 {
return qs.averageDifficulty
}
// AverageTimeToAnswer returns the average time to answer
func (qs *QuestionStatistics) AverageTimeToAnswer() time.Duration {
return qs.averageTimeToAnswer
}
// FastestAnswer returns the fastest answer time
func (qs *QuestionStatistics) FastestAnswer() time.Duration {
return qs.fastestAnswer
}
// SlowestAnswer returns the slowest answer time
func (qs *QuestionStatistics) SlowestAnswer() time.Duration {
return qs.slowestAnswer
}
// UniqueSessions returns the number of unique sessions
func (qs *QuestionStatistics) UniqueSessions() int64 {
return qs.uniqueSessions
}
// UniqueUsers returns the number of unique users
func (qs *QuestionStatistics) UniqueUsers() int64 {
return qs.uniqueUsers
}
// FirstUsed returns when the question was first used
func (qs *QuestionStatistics) FirstUsed() *types.Timestamp {
return qs.firstUsed
}
// LastUsed returns when the question was last used
func (qs *QuestionStatistics) LastUsed() *types.Timestamp {
return qs.lastUsed
}
// RecentAttempts returns recent attempts count
func (qs *QuestionStatistics) RecentAttempts() int64 {
return qs.recentAttempts
}
// RecentCorrectRate returns recent correct rate
func (qs *QuestionStatistics) RecentCorrectRate() float64 {
return qs.recentCorrectRate
}
// SkipRate returns the skip rate
func (qs *QuestionStatistics) SkipRate() float64 {
return qs.skipRate
}
// ReportCount returns the number of reports
func (qs *QuestionStatistics) ReportCount() int64 {
return qs.reportCount
}
// CorrectRate returns the overall correct rate
func (qs *QuestionStatistics) CorrectRate() float64 {
return qs.correctRate
}
// PopularityScore returns the popularity score
func (qs *QuestionStatistics) PopularityScore() float64 {
return qs.popularityScore
}
// LastCalculated returns when statistics were last calculated
func (qs *QuestionStatistics) LastCalculated() types.Timestamp {
return qs.lastCalculated
}
// Business methods
// RecordUsage records a usage event for the question
func (qs *QuestionStatistics) RecordUsage(wasCorrect bool, difficulty types.DifficultyLevel) error {
now := types.NewTimestamp()
// Update usage counts
qs.totalAttempts++
if wasCorrect {
qs.correctAttempts++
} else {
qs.incorrectAttempts++
}
// Record difficulty vote
if difficulty != "" {
qs.difficultyVotes[difficulty]++
}
// Update timestamps
if qs.firstUsed == nil {
qs.firstUsed = &now
}
qs.lastUsed = &now
// Recalculate derived statistics
qs.recalculate()
return nil
}
// RecordTimeout records a timeout event
func (qs *QuestionStatistics) RecordTimeout() {
qs.totalAttempts++
qs.timeoutAttempts++
now := types.NewTimestamp()
if qs.firstUsed == nil {
qs.firstUsed = &now
}
qs.lastUsed = &now
qs.recalculate()
}
// RecordHintRequest records a hint request
func (qs *QuestionStatistics) RecordHintRequest() {
qs.hintsRequested++
qs.recalculate()
}
// RecordAnswerTime records time taken to answer
func (qs *QuestionStatistics) RecordAnswerTime(duration time.Duration) error {
if duration < 0 {
return errors.ErrValidationFailed("duration", "duration cannot be negative")
}
// Update fastest/slowest if this is first record or a new extreme
if qs.fastestAnswer == 0 || duration < qs.fastestAnswer {
qs.fastestAnswer = duration
}
if duration > qs.slowestAnswer {
qs.slowestAnswer = duration
}
// Recalculate average (simplified - in real implementation would track all times)
if qs.totalAttempts > 0 {
currentTotal := qs.averageTimeToAnswer * time.Duration(qs.totalAttempts-1)
qs.averageTimeToAnswer = (currentTotal + duration) / time.Duration(qs.totalAttempts)
} else {
qs.averageTimeToAnswer = duration
}
qs.recalculate()
return nil
}
// RecordSessionUsage records usage by a new session
func (qs *QuestionStatistics) RecordSessionUsage() {
qs.uniqueSessions++
qs.recalculate()
}
// RecordUserUsage records usage by a new user
func (qs *QuestionStatistics) RecordUserUsage() {
qs.uniqueUsers++
qs.recalculate()
}
// RecordSkip records that the question was skipped
func (qs *QuestionStatistics) RecordSkip() {
// This would affect skip rate calculation
qs.recalculate()
}
// RecordReport records a report for inappropriate content
func (qs *QuestionStatistics) RecordReport() {
qs.reportCount++
qs.recalculate()
}
// UpdateRecentStatistics updates recent (30-day) statistics
func (qs *QuestionStatistics) UpdateRecentStatistics(attempts int64, correctRate float64) {
qs.recentAttempts = attempts
qs.recentCorrectRate = correctRate
qs.recalculate()
}
// Analysis methods
// IsPopular returns true if the question is popular
func (qs *QuestionStatistics) IsPopular() bool {
return qs.popularityScore >= types.PopularityThreshold
}
// IsDifficult returns true if the question is difficult based on statistics
func (qs *QuestionStatistics) IsDifficult() bool {
return qs.correctRate < types.DifficultQuestionThreshold
}
// IsEasy returns true if the question is easy based on statistics
func (qs *QuestionStatistics) IsEasy() bool {
return qs.correctRate > types.EasyQuestionThreshold
}
// IsProblemQuestion returns true if the question has quality issues
func (qs *QuestionStatistics) IsProblemQuestion() bool {
return qs.reportCount >= types.ProblemQuestionReportThreshold ||
qs.skipRate >= types.ProblemQuestionSkipThreshold
}
// GetPerformanceCategory returns the performance category
func (qs *QuestionStatistics) GetPerformanceCategory() string {
switch {
case qs.correctRate >= 0.8:
return "excellent"
case qs.correctRate >= 0.6:
return "good"
case qs.correctRate >= 0.4:
return "average"
case qs.correctRate >= 0.2:
return "challenging"
default:
return "very_difficult"
}
}
// GetUsageCategory returns the usage category
func (qs *QuestionStatistics) GetUsageCategory() string {
switch {
case qs.totalAttempts >= 1000:
return "heavily_used"
case qs.totalAttempts >= 500:
return "frequently_used"
case qs.totalAttempts >= 100:
return "moderately_used"
case qs.totalAttempts >= 10:
return "lightly_used"
default:
return "rarely_used"
}
}
// GetHintUsageRate returns the hint usage rate
func (qs *QuestionStatistics) GetHintUsageRate() float64 {
if qs.totalAttempts == 0 {
return 0.0
}
return float64(qs.hintsRequested) / float64(qs.totalAttempts)
}
// GetTimeoutRate returns the timeout rate
func (qs *QuestionStatistics) GetTimeoutRate() float64 {
if qs.totalAttempts == 0 {
return 0.0
}
return float64(qs.timeoutAttempts) / float64(qs.totalAttempts)
}
// GetEngagementScore returns an engagement score
func (qs *QuestionStatistics) GetEngagementScore() float64 {
// Combination of popularity, correct rate, and hint usage
baseScore := qs.popularityScore * 0.4
correctRateBonus := qs.correctRate * 0.3
hintUsageBonus := (1.0 - qs.GetHintUsageRate()) * 0.2 // Less hints = more engaging
timeoutPenalty := qs.GetTimeoutRate() * -0.1
return baseScore + correctRateBonus + hintUsageBonus + timeoutPenalty
}
// Validation
// Validate performs validation on the statistics
func (qs *QuestionStatistics) Validate() *types.ValidationResult {
result := types.NewValidationResult()
// Check consistency
calculatedTotal := qs.correctAttempts + qs.incorrectAttempts + qs.timeoutAttempts
if calculatedTotal != qs.totalAttempts {
result.AddError("total attempts does not match sum of individual attempt types")
}
// Check bounds
if qs.correctRate < 0.0 || qs.correctRate > 1.0 {
result.AddError("correct rate must be between 0.0 and 1.0")
}
if qs.skipRate < 0.0 || qs.skipRate > 1.0 {
result.AddError("skip rate must be between 0.0 and 1.0")
}
if qs.averageDifficulty < 0.0 || qs.averageDifficulty > 5.0 {
result.AddError("average difficulty must be between 0.0 and 5.0")
}
// Check negative values
if qs.totalAttempts < 0 || qs.correctAttempts < 0 || qs.incorrectAttempts < 0 ||
qs.timeoutAttempts < 0 || qs.hintsRequested < 0 || qs.uniqueSessions < 0 ||
qs.uniqueUsers < 0 || qs.reportCount < 0 {
result.AddError("counts cannot be negative")
}
// Check time consistency
if qs.firstUsed != nil && qs.lastUsed != nil {
if qs.lastUsed.Before(*qs.firstUsed) {
result.AddError("last used cannot be before first used")
}
}
return result
}
// Helper methods
// recalculate recalculates derived statistics
func (qs *QuestionStatistics) recalculate() {
// Calculate correct rate
if qs.totalAttempts > 0 {
qs.correctRate = float64(qs.correctAttempts) / float64(qs.totalAttempts)
} else {
qs.correctRate = 0.0
}
// Calculate average difficulty from votes
if len(qs.difficultyVotes) > 0 {
var totalWeightedScore float64
var totalVotes int64
for difficulty, votes := range qs.difficultyVotes {
weight := difficultyToWeight(difficulty)
totalWeightedScore += weight * float64(votes)
totalVotes += votes
}
if totalVotes > 0 {
qs.averageDifficulty = totalWeightedScore / float64(totalVotes)
}
}
// Calculate popularity score
qs.popularityScore = calculatePopularityScore(
qs.totalAttempts,
qs.uniqueSessions,
qs.uniqueUsers,
qs.correctRate,
qs.recentAttempts,
)
qs.lastCalculated = types.NewTimestamp()
}
// difficultyToWeight converts difficulty level to numerical weight
func difficultyToWeight(difficulty types.DifficultyLevel) float64 {
switch difficulty {
case types.DifficultyEasy:
return 1.0
case types.DifficultyMedium:
return 2.0
case types.DifficultyHard:
return 3.0
case types.DifficultyExpert:
return 4.0
case types.DifficultyImpossible:
return 5.0
default:
return 2.5 // Default to medium
}
}
// calculatePopularityScore calculates a popularity score based on various metrics
func calculatePopularityScore(totalAttempts, uniqueSessions, uniqueUsers int64, correctRate float64, recentAttempts int64) float64 {
// Base score from usage
usageScore := float64(totalAttempts) * 0.1
// Bonus for unique sessions and users
uniquenessScore := float64(uniqueSessions)*0.2 + float64(uniqueUsers)*0.3
// Bonus for good correct rate (not too easy, not too hard)
correctRateScore := 0.0
if correctRate >= 0.3 && correctRate <= 0.8 {
correctRateScore = 100.0 * (1.0 - abs(correctRate-0.55)) // Peak at 55% correct
}
// Recent activity bonus
recentActivityScore := float64(recentAttempts) * 0.5
return usageScore + uniquenessScore + correctRateScore + recentActivityScore
}
// abs returns absolute value of float64
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
// ToSnapshot creates a read-only snapshot of the statistics
func (qs *QuestionStatistics) ToSnapshot() *QuestionStatisticsSnapshot {
difficultyVotesCopy := make(map[types.DifficultyLevel]int64)
for k, v := range qs.difficultyVotes {
difficultyVotesCopy[k] = v
}
return &QuestionStatisticsSnapshot{
TotalAttempts: qs.totalAttempts,
CorrectAttempts: qs.correctAttempts,
IncorrectAttempts: qs.incorrectAttempts,
TimeoutAttempts: qs.timeoutAttempts,
HintsRequested: qs.hintsRequested,
DifficultyVotes: difficultyVotesCopy,
AverageDifficulty: qs.averageDifficulty,
AverageTimeToAnswer: qs.averageTimeToAnswer,
FastestAnswer: qs.fastestAnswer,
SlowestAnswer: qs.slowestAnswer,
UniqueSessions: qs.uniqueSessions,
UniqueUsers: qs.uniqueUsers,
FirstUsed: qs.firstUsed,
LastUsed: qs.lastUsed,
RecentAttempts: qs.recentAttempts,
RecentCorrectRate: qs.recentCorrectRate,
SkipRate: qs.skipRate,
ReportCount: qs.reportCount,
CorrectRate: qs.correctRate,
PopularityScore: qs.popularityScore,
LastCalculated: qs.lastCalculated,
}
}
// GetSummary returns a human-readable summary
func (qs *QuestionStatistics) GetSummary() string {
return fmt.Sprintf("Usage: %d attempts (%.1f%% correct) | Popularity: %.0f | Category: %s",
qs.totalAttempts,
qs.correctRate*100,
qs.popularityScore,
qs.GetPerformanceCategory(),
)
}
// QuestionStatisticsSnapshot represents a read-only view of question statistics
type QuestionStatisticsSnapshot struct {
TotalAttempts int64 `json:"total_attempts"`
CorrectAttempts int64 `json:"correct_attempts"`
IncorrectAttempts int64 `json:"incorrect_attempts"`
TimeoutAttempts int64 `json:"timeout_attempts"`
HintsRequested int64 `json:"hints_requested"`
DifficultyVotes map[types.DifficultyLevel]int64 `json:"difficulty_votes"`
AverageDifficulty float64 `json:"average_difficulty"`
AverageTimeToAnswer time.Duration `json:"average_time_to_answer"`
FastestAnswer time.Duration `json:"fastest_answer"`
SlowestAnswer time.Duration `json:"slowest_answer"`
UniqueSessions int64 `json:"unique_sessions"`
UniqueUsers int64 `json:"unique_users"`
FirstUsed *types.Timestamp `json:"first_used,omitempty"`
LastUsed *types.Timestamp `json:"last_used,omitempty"`
RecentAttempts int64 `json:"recent_attempts"`
RecentCorrectRate float64 `json:"recent_correct_rate"`
SkipRate float64 `json:"skip_rate"`
ReportCount int64 `json:"report_count"`
CorrectRate float64 `json:"correct_rate"`
PopularityScore float64 `json:"popularity_score"`
LastCalculated types.Timestamp `json:"last_calculated"`
}
// GetSummary returns a summary of the snapshot
func (qss *QuestionStatisticsSnapshot) GetSummary() string {
return fmt.Sprintf("Usage: %d attempts (%.1f%% correct) | Popularity: %.0f",
qss.TotalAttempts,
qss.CorrectRate*100,
qss.PopularityScore,
)
}