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

62
events/events.go Normal file
View File

@@ -0,0 +1,62 @@
// Package events holds the plugin events of golem15.quizzes that other
// plugins listen to with app.Events.Listen. Every event fires after the
// write it announces has committed, so a listener never sees a score that
// was rolled back; a listener error is logged and never undoes the write.
// The package imports nothing from golem15.quizzes, so a listener can
// depend on it alone.
package events
// Event names, for logs and documentation; the bus itself is keyed by type.
const (
AnswerGradedEventName = "quizzes.AnswerGraded"
AnswerReviewedEventName = "quizzes.AnswerReviewed"
AttemptReviewedEventName = "quizzes.AttemptReviewed"
)
// AnswerGradedEvent is fired as *AnswerGradedEvent after a grader's score
// of an open answer is stored (grading_status graded). The owner has not
// confirmed it yet. MaxPoints is the question's points.
type AnswerGradedEvent struct {
QuizID uint
QuestionID uint
AttemptID uint
AnswerID uint
UserID uint
Grader string
Points float64
MaxPoints int
Feedback string
}
// AnswerReviewedEvent is fired as *AnswerReviewedEvent after the owner
// confirms or sets the score of an open answer (grading_status reviewed).
// GraderPoints is the grader's score when there was one; Comment is the
// owner's comment to the student. The reviewer is a frontend user
// (ReviewerUserID, over MCP) or a backend administrator (ReviewerAdminID).
type AnswerReviewedEvent struct {
QuizID uint
QuestionID uint
AttemptID uint
AnswerID uint
UserID uint
Points float64
MaxPoints int
GraderPoints *float64
Comment *string
ReviewerUserID *uint
ReviewerAdminID *uint
}
// AttemptReviewedEvent is fired as *AttemptReviewedEvent once every open
// answer of a completed attempt is reviewed, with the attempt's final
// score. MaxPoints is the attempt's stored maximum; Passed compares
// ScorePercent with the quiz's passing score.
type AttemptReviewedEvent struct {
QuizID uint
AttemptID uint
UserID uint
ScorePoints float64
MaxPoints int
ScorePercent *float64
Passed bool
}