- import document, manage routes and MCP question tools take rubric and max_length; open questions refuse answers, max_length is 1..20000 and both apply to open questions only - quizzes take a grader; the manage routes refuse a code the registry does not know, the import accepts any well-formed code - quiz stats report unreviewed open answers and their average points - import_quiz warns that replace deletes the grades and reviews
138 lines
3.9 KiB
Go
138 lines
3.9 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}$`)
|
|
|
|
// ValidCode reports whether code has the shape of a grader code: lowercase
|
|
// letters, digits and _, starting with a letter, at most 64 characters.
|
|
// It says nothing about whether a plugin registered it.
|
|
func ValidCode(code string) bool { return codePattern.MatchString(code) }
|
|
|
|
// 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
|
|
}
|