175 lines
4.7 KiB
Go
175 lines
4.7 KiB
Go
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
|
|
}
|