package classes import ( "math" "strconv" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/compass" ) // ScoringConfig holds the golem15.quizzes.scoring.* settings. type ScoringConfig struct { // MultiplePartial gives partial credit on a multiple question: (correct // chosen - wrong chosen) / correct answers, floored at 0. MultiplePartial bool // PenaltyWrong is the fraction of a question's points subtracted for a // wrong answer, from 0 (no penalty) to 1. PenaltyWrong float64 } // ScoringConfigFrom reads golem15.quizzes.scoring.multiple_partial and // golem15.quizzes.scoring.penalty_wrong. A missing config gives the defaults // (no partial credit, no penalty); the penalty is clamped to [0, 1]. func ScoringConfigFrom(cfg *compass.Config) ScoringConfig { if cfg == nil { return ScoringConfig{} } out := ScoringConfig{MultiplePartial: cfg.Bool("golem15.quizzes.scoring.multiple_partial")} if v, ok := cfg.Lookup("golem15.quizzes.scoring.penalty_wrong"); ok { out.PenaltyWrong = toFloat(v) } out.PenaltyWrong = math.Min(math.Max(out.PenaltyWrong, 0), 1) return out } func toFloat(v any) float64 { switch n := v.(type) { case float64: return n case float32: return float64(n) case int: return float64(n) case int64: return float64(n) case uint64: return float64(n) case string: f, err := strconv.ParseFloat(n, 64) if err == nil { return f } } return 0 } // ScoreInput is one answered question, as the scorer sees it. type ScoreInput struct { // Type is models.TypeSingle, models.TypeMultiple or models.TypeSelfCheck. Type string // Points is the question's full value. Points int // CorrectIDs are the IDs of the question's correct answers. CorrectIDs []uint // ChosenIDs are the answer IDs the user chose (single and multiple). ChosenIDs []uint // Known is the user's own mark on a self_check question. Known *bool } // ScoreResult is the outcome of one question. type ScoreResult struct { // IsCorrect is true only for a fully correct answer. IsCorrect bool // Points earned; negative when a penalty applies. Totals are floored at // 0 by Totals, never per question. Points float64 } // Score grades one answer. It is a pure function: // // - single: correct when exactly one answer is chosen and it is a correct // one. // - multiple: correct when the chosen set equals the correct set. With // MultiplePartial a partly right answer earns (correct chosen - wrong // chosen) / correct answers of the points, floored at 0. // - self_check: correct when the user marked it as known. // // An answer that earns nothing and is not empty is wrong: with PenaltyWrong // it loses that fraction of the question's points. An empty answer and a // self_check marked as unknown are never penalised. func Score(in ScoreInput, cfg ScoringConfig) ScoreResult { full := float64(max(in.Points, 0)) switch in.Type { case models.TypeSelfCheck: if in.Known != nil && *in.Known { return ScoreResult{IsCorrect: true, Points: full} } return ScoreResult{} case models.TypeSingle, models.TypeMultiple: default: return ScoreResult{} } chosen := uniqueIDs(in.ChosenIDs) if len(chosen) == 0 { return ScoreResult{} } correct := make(map[uint]bool, len(in.CorrectIDs)) for _, id := range in.CorrectIDs { correct[id] = true } right, wrong := 0, 0 for _, id := range chosen { if correct[id] { right++ } else { wrong++ } } var res ScoreResult switch in.Type { case models.TypeSingle: if len(chosen) == 1 && right == 1 { res = ScoreResult{IsCorrect: true, Points: full} } case models.TypeMultiple: switch { case len(correct) > 0 && wrong == 0 && right == len(correct): res = ScoreResult{IsCorrect: true, Points: full} case cfg.MultiplePartial && len(correct) > 0: res.Points = round(full * math.Max(float64(right-wrong), 0) / float64(len(correct))) } } if !res.IsCorrect && res.Points == 0 && cfg.PenaltyWrong > 0 { res.Points = round(-full * cfg.PenaltyWrong) } return res } // Totals sums the earned points of an attempt, floored at 0, and the // percentage of maxPoints it is. With no points to earn the percentage is 0. func Totals(points []float64, maxPoints int) (score, percent float64) { for _, p := range points { score += p } score = round(math.Max(score, 0)) if maxPoints <= 0 { return score, 0 } return score, round(score / float64(maxPoints) * 100) } // round keeps two decimals. func round(f float64) float64 { r := math.Round(f*100) / 100 if r == 0 { return 0 // no negative zero in JSON } return r } func uniqueIDs(ids []uint) []uint { seen := make(map[uint]bool, len(ids)) out := make([]uint, 0, len(ids)) for _, id := range ids { if !seen[id] { seen[id] = true out = append(out, id) } } return out }