go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/projects/kana-server/pkg/types/quiz.go (about)

     1  /*
     2  
     3  Copyright (c) 2023 - Present. Will Charczuk. All rights reserved.
     4  Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository.
     5  
     6  */
     7  
     8  package types
     9  
    10  import (
    11  	"sort"
    12  	"time"
    13  
    14  	"go.charczuk.com/sdk/mathutil"
    15  	"go.charczuk.com/sdk/uuid"
    16  
    17  	"go.charczuk.com/projects/kana-server/pkg/kana"
    18  )
    19  
    20  // Quiz is a quiz.
    21  type Quiz struct {
    22  	// ID is a unique identifier for the quiz.
    23  	ID uuid.UUID `db:"id,pk"`
    24  	// UserID is the user the quiz corresponds to.
    25  	UserID uuid.UUID `db:"user_id"`
    26  	// CreatedUTC is the time the quiz was created.
    27  	CreatedUTC time.Time `db:"created_utc"`
    28  	// LastAnsweredUTC is the last time the quiz was answered.
    29  	LastAnsweredUTC time.Time `db:"last_answered_utc"`
    30  	// Hiragana indicates if we should include prompts from the hiragana set.
    31  	Hiragana bool `db:"hiragana"`
    32  	// Katakana indicates if we should include prompts from the katakana set.
    33  	Katakana bool `db:"katakana"`
    34  	// MaxQuestions is the maximum number of questions to ask per quiz.
    35  	MaxQuestions int `db:"max_questions"`
    36  	// MaxPrompts is the maximum number of prompts to pull from either prompt set (or in total)
    37  	MaxPrompts int `db:"max_prompts"`
    38  	// MaxRepeatHistory is the debounce history list length.
    39  	MaxRepeatHistory int `db:"max_repeat_history"`
    40  	// Results are the individual prompts and answers.
    41  	Results []QuizResult `db:"-"`
    42  	// Prompts are the individual mappings between kana and roman to quiz.
    43  	Prompts map[string]string `db:"prompts,json"`
    44  	// PromptWeights are used for selection bias based on incorrect answers.
    45  	PromptWeights map[string]float64 `db:"prompt_weights,json"`
    46  	// PromptHistory are the recent prompts used to debounce them.
    47  	PromptHistory []string `db:"prompt_history,json"`
    48  }
    49  
    50  // TableName returns the database tablename for the type.
    51  func (q Quiz) TableName() string { return "quiz" }
    52  
    53  // IsZero returns if the quiz is set or not.
    54  func (q Quiz) IsZero() bool {
    55  	return q.ID.IsZero()
    56  }
    57  
    58  // LatestResult returns the latest result.
    59  func (q Quiz) LatestResult() *QuizResult {
    60  	if len(q.Results) > 0 {
    61  		return &q.Results[0]
    62  	}
    63  	return nil
    64  }
    65  
    66  // Stats returns the stats for the quiz.
    67  func (q Quiz) Stats() (stats QuizStats) {
    68  	var elapsedTimes []time.Duration
    69  
    70  	for _, qr := range q.Results {
    71  		elapsedTimes = append(elapsedTimes, qr.Elapsed())
    72  		if qr.Correct() {
    73  			stats.Correct++
    74  		}
    75  		stats.Total++
    76  	}
    77  
    78  	sortedElapsedTimes := mathutil.CopySort(elapsedTimes)
    79  	stats.ElapsedAverage = mathutil.Mean(sortedElapsedTimes)
    80  	stats.ElapsedP90 = mathutil.PercentileSorted(sortedElapsedTimes, 90.0)
    81  	stats.ElapsedP95 = mathutil.PercentileSorted(sortedElapsedTimes, 95.0)
    82  	stats.ElapsedMin, stats.ElapsedMax = mathutil.MinMax(sortedElapsedTimes)
    83  	return
    84  }
    85  
    86  // PromptStats returns stats for each prompt.
    87  func (q Quiz) PromptStats() (output []*PromptStats) {
    88  	lookup := make(map[string]*PromptStats)
    89  
    90  	for _, res := range q.Results {
    91  		stats, ok := lookup[res.Prompt]
    92  		if ok {
    93  			stats.Total++
    94  			if res.Correct() {
    95  				stats.Correct++
    96  			}
    97  			stats.ElapsedTimes = append(stats.ElapsedTimes, res.Elapsed())
    98  		} else {
    99  			var newStats PromptStats
   100  			newStats.Prompt = res.Prompt
   101  			newStats.Weight = q.PromptWeights[res.Prompt]
   102  			newStats.Total = 1
   103  			if res.Correct() {
   104  				newStats.Correct = 1
   105  			}
   106  			newStats.ElapsedTimes = append(newStats.ElapsedTimes, res.Elapsed())
   107  			output = append(output, &newStats)
   108  			lookup[res.Prompt] = &newStats
   109  		}
   110  	}
   111  
   112  	for _, stats := range output {
   113  		stats.ElapsedAverage = mathutil.Mean(stats.ElapsedTimes)
   114  		stats.ElapsedP90 = mathutil.Percentile(stats.ElapsedTimes, 90.0)
   115  		stats.ElapsedP95 = mathutil.Percentile(stats.ElapsedTimes, 95.0)
   116  		stats.ElapsedMin, stats.ElapsedMax = mathutil.MinMax(stats.ElapsedTimes)
   117  	}
   118  	sort.Slice(output, func(i, j int) bool {
   119  		return output[i].Prompt < output[j].Prompt
   120  	})
   121  	return
   122  }
   123  
   124  // NewTestQuiz returns a new test quiz.
   125  func NewTestQuiz(userID uuid.UUID) *Quiz {
   126  	prompts := kana.SelectCount(kana.Merge(kana.Hiragana, kana.Katakana), 10)
   127  	return &Quiz{
   128  		ID:               uuid.V4(),
   129  		UserID:           userID,
   130  		CreatedUTC:       time.Now().UTC(),
   131  		Hiragana:         true,
   132  		Katakana:         true,
   133  		MaxPrompts:       10,
   134  		MaxQuestions:     0,
   135  		MaxRepeatHistory: 5,
   136  		Prompts:          prompts,
   137  		PromptWeights:    kana.CreateWeights(prompts),
   138  	}
   139  }