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

@@ -7,7 +7,9 @@ import (
"slices"
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/festival"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -48,6 +50,15 @@ type Service struct {
// Grace is how long after a test's deadline an answer is still
// accepted; it never applies to learning quizzes. See TestGraceFrom.
Grace time.Duration
// Graders is the grader registry; nil knows only manual, so every open
// answer waits for the owner.
Graders *graders.Registry
// Jobs enqueues grading jobs; nil enqueues nothing (tests and apps
// without a queue keep working, the answers stay pending).
Jobs Enqueuer
// Events is the bus the grading and review events fire on; nil fires
// nothing.
Events *festival.Bus
}
func (s *Service) now() time.Time {
@@ -548,8 +559,9 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
// the row goes back to pending with the next grading_seq, and every grade,
// error, review and point of the old text is cleared, so a learning
// re-answer replaces an earlier grade or review. A first answer creates the
// row as pending with seq 1. A new or changed text is handed to
// enqueueGrading.
// row as pending with seq 1. In a learning quiz a new or changed text is
// handed to enqueueGrading at once; a test enqueues its open answers when
// it closes (UD-2).
func (s *Service) answerOpen(ctx context.Context, tx *gorm.DB, attempt *models.Attempt, question models.Question, text string) (*models.AttemptAnswer, error) {
tx = tx.WithContext(ctx)
now := s.now().UTC()
@@ -586,8 +598,10 @@ func (s *Service) answerOpen(ctx context.Context, tx *gorm.DB, attempt *models.A
if err := tx.Where("id = ?", existing.ID).Take(&existing).Error; err != nil {
return nil, err
}
if err := s.enqueueGrading(ctx, tx, existing.ID, seq, attempt); err != nil {
return nil, err
if !IsTest(attempt.Quiz) {
if err := s.enqueueGrading(ctx, tx, existing.ID, seq, attempt); err != nil {
return nil, err
}
}
return &existing, nil
case errors.Is(err, gorm.ErrRecordNotFound):
@@ -603,17 +617,14 @@ func (s *Service) answerOpen(ctx context.Context, tx *gorm.DB, attempt *models.A
if err := tx.Create(row).Error; err != nil {
return nil, err
}
if err := s.enqueueGrading(ctx, tx, row.ID, row.GradingSeq, attempt); err != nil {
return nil, err
if !IsTest(attempt.Quiz) {
if err := s.enqueueGrading(ctx, tx, row.ID, row.GradingSeq, attempt); err != nil {
return nil, err
}
}
return row, nil
}
// enqueueGrading hands a new or changed open answer to the quiz's grader.
func (s *Service) enqueueGrading(ctx context.Context, tx *gorm.DB, answerID uint, seq int, attempt *models.Attempt) error {
return nil
}
// CorrectAnswerIDs lists the IDs of a question's correct answers in sort
// order; it is empty for a self_check question.
func CorrectAnswerIDs(q models.Question) []uint {
@@ -649,7 +660,9 @@ func (s *Service) Finish(ctx context.Context, userID, attemptID uint) (*AttemptR
// close completes an attempt at the given time: it sums the recorded
// answers, floors the total at 0 and stores the score. Finish closes at the
// current time, an expired test closes at its deadline.
// current time, an expired test closes at its deadline. A test then
// enqueues one grading job per pending open answer (UD-2); every caller
// sets attempt.Quiz.
func (s *Service) close(ctx context.Context, tx *gorm.DB, attempt *models.Attempt, at time.Time) error {
tx = tx.WithContext(ctx)
var points []float64
@@ -663,13 +676,16 @@ func (s *Service) close(ctx context.Context, tx *gorm.DB, attempt *models.Attemp
score, percent := Totals(points, maxPoints)
at = at.UTC()
now := s.now().UTC()
return tx.Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{
if err := tx.Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{
"completed_at": at,
"score_points": score,
"max_points": maxPoints,
"score_percent": percent,
"updated_at": now,
}).Error
}).Error; err != nil {
return err
}
return s.enqueuePending(ctx, tx, attempt)
}
// AttemptSummary is an attempt with its quiz and answered question count.