Files
sm-quizzes-plugin/graders/registry.go
Jakub Zych 80ea79ee3e feat: grade open answers through a grader registry in the background
- graders registry collected at boot from every HasGraders plugin,
  manual first; a duplicate or reserved code fails boot
- grading job on queue quizzes_grading with three attempts; a stale
  or reviewed answer is never touched; the last failure marks it failed
- learning answers enqueue at once, tests at close; a grade
  recalculates a finished attempt and fires AnswerGradedEvent after
  commit
2026-11-09 17:51:55 +01:00

133 lines
3.6 KiB
Go

package graders
import (
"errors"
"fmt"
"regexp"
"sync"
)
// ErrDuplicateCode reports two graders published with the same code.
var ErrDuplicateCode = errors.New("quizzes: duplicate grader code")
// ErrReservedCode reports a grader that claims the built-in Manual code.
var ErrReservedCode = errors.New("quizzes: grader code manual is built in")
// codePattern is the shape of a grader code: it is stored in the quiz's
// grader column (VARCHAR(64)).
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
// Option is one choice of the admin grader dropdown.
type Option struct {
Code string
Label string
}
// Registry is the ordered set of graders collected at boot: Manual first,
// then every activated plugin's graders in activation order. golem15.quizzes
// publishes it on the app as *graders.Registry. Every method is safe on a
// nil *Registry, which knows only Manual.
type Registry struct {
mu sync.RWMutex
order []string
byCode map[string]entry
}
type entry struct {
grader Grader
pluginID string
}
// NewRegistry returns a registry that knows only Manual.
func NewRegistry() *Registry {
return &Registry{byCode: map[string]entry{}}
}
// Add registers g as published by pluginID. A nil grader, an empty or
// malformed code, Manual (ErrReservedCode) and a code already registered
// (ErrDuplicateCode, naming both plugins) are errors.
func (r *Registry) Add(pluginID string, g Grader) error {
if r == nil {
return fmt.Errorf("quizzes: grader registry is nil")
}
if g == nil {
return fmt.Errorf("quizzes: plugin %s published a nil grader", pluginID)
}
code := g.Code()
switch {
case code == "":
return fmt.Errorf("quizzes: plugin %s published a grader without a code", pluginID)
case code == Manual:
return fmt.Errorf("%w: published by %s", ErrReservedCode, pluginID)
case !codePattern.MatchString(code):
return fmt.Errorf("quizzes: plugin %s published grader code %q; want lowercase letters, digits and _ (at most 64)", pluginID, code)
}
r.mu.Lock()
defer r.mu.Unlock()
if r.byCode == nil {
r.byCode = map[string]entry{}
}
if existing, ok := r.byCode[code]; ok {
return fmt.Errorf("%w: %q is published by %s and %s", ErrDuplicateCode, code, existing.pluginID, pluginID)
}
r.byCode[code] = entry{grader: g, pluginID: pluginID}
r.order = append(r.order, code)
return nil
}
// Get returns the grader with code. Manual and unknown codes are false:
// there is nothing to call, the owner grades.
func (r *Registry) Get(code string) (Grader, bool) {
if r == nil || code == Manual {
return nil, false
}
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.byCode[code]
if !ok {
return nil, false
}
return e.grader, true
}
// Codes lists Manual and then every registered code in registration order.
func (r *Registry) Codes() []string {
out := []string{Manual}
if r == nil {
return out
}
r.mu.RLock()
defer r.mu.RUnlock()
return append(out, r.order...)
}
// Options lists the admin dropdown choices: Manual with ManualLabel, then
// every registered grader with its Label.
func (r *Registry) Options() []Option {
out := []Option{{Code: Manual, Label: ManualLabel}}
if r == nil {
return out
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, code := range r.order {
out = append(out, Option{Code: code, Label: r.byCode[code].grader.Label()})
}
return out
}
// PluginID returns the id of the plugin that published code. Manual is
// golem15.quizzes' own.
func (r *Registry) PluginID(code string) (string, bool) {
if code == Manual {
return "golem15.quizzes", true
}
if r == nil {
return "", false
}
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.byCode[code]
return e.pluginID, ok
}