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
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,
|
|
)
|
|
} |