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
This commit is contained in:
Jakub Zych
2026-11-09 17:51:55 +01:00
parent 297780cb8e
commit 80ea79ee3e
12 changed files with 942 additions and 24 deletions

View File

@@ -3,16 +3,21 @@ package quizzes
import (
"context"
"embed"
"errors"
"io/fs"
"net/http"
"strconv"
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/console"
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
"git.golem15.com/golem15/sm-quizzes-plugin/jobs"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
@@ -53,6 +58,8 @@ var adminFS embed.FS
// Plugin is the golem15.quizzes plugin.
type Plugin struct {
app *backpack.App
// graders is the grader registry built at Boot.
graders *graders.Registry
}
func (p *Plugin) ID() string { return "golem15.quizzes" }
@@ -68,10 +75,17 @@ func (p *Plugin) Register(app *backpack.App) error {
return nil
}
// Boot keeps the application and points the admin filter choices at its
// database.
// Boot keeps the application, collects the grader registry and points the
// admin filter choices at its database.
//
// Grading plugins never register themselves: golem15.quizzes pulls their
// graders through graders.HasGraders, as golem15.paymentgateway pulls
// payment operators.
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
if err := bootGraders(p, app); err != nil {
return err
}
models.FilterDB = func() *gorm.DB {
if app == nil {
return nil
@@ -82,6 +96,35 @@ func (p *Plugin) Boot(app *backpack.App) error {
return nil
}
// bootGraders builds the grader registry: manual first, then the graders of
// every other activated plugin that implements graders.HasGraders, in
// activation order. A duplicate or reserved code fails boot. The registry
// is kept on p and published on the app as *graders.Registry.
func bootGraders(p *Plugin, app *backpack.App) error {
reg := graders.NewRegistry()
if app == nil {
p.graders = reg
return nil
}
activated, _ := app.Lookup[party.Activated]()
for _, plugin := range activated {
if plugin == nil || plugin.ID() == p.ID() {
continue
}
hg, ok := plugin.(graders.HasGraders)
if !ok {
continue
}
for _, g := range hg.QuizGraders() {
if err := reg.Add(plugin.ID(), g); err != nil {
return err
}
}
}
p.graders = reg
return app.Publish(reg)
}
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) LangFS() fs.FS { return langFS }
@@ -91,7 +134,27 @@ func (p *Plugin) MailLayouts() map[string]string { return nil }
func (p *Plugin) Models() []any { return generatedModels() }
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
// Jobs returns the generated jobs plus the grading job: one open answer per
// job on its own queue, GradeMaxAttempts attempts, each with GradeTimeout.
func (p *Plugin) Jobs() []pact.Job {
return append(generatedJobs(), conga.Job(p.gradeAnswer,
conga.OnQueue(jobs.Queue),
conga.MaxAttempts(classes.GradeMaxAttempts),
conga.Timeout(classes.GradeTimeout)))
}
// gradeAnswer runs one grading job on the application's database.
func (p *Plugin) gradeAnswer(ctx context.Context, args jobs.GradeAnswerArgs) error {
var gdb *gorm.DB
if p.app != nil {
gdb, _ = p.app.Lookup[*gorm.DB]()
}
if gdb == nil {
return errors.New("quizzes: database unavailable")
}
return classes.ForApp(p.app, gdb).GradeAnswer(ctx, args.AnswerID, args.Seq)
}
func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}