diff --git a/classes/importer.go b/classes/importer.go index 396d9b9..1c74a23 100644 --- a/classes/importer.go +++ b/classes/importer.go @@ -8,14 +8,16 @@ import ( "slices" "strings" + "git.golem15.com/golem15/sm-quizzes-plugin/graders" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) -// ImportQuiz is the JSON document quizzes:import reads. Mode, Navigation -// and MaxAttempts keep the quiz's current value (the model default on -// create) when absent. +// ImportQuiz is the JSON document quizzes:import reads. Mode, Navigation, +// MaxAttempts and Grader keep the quiz's current value (the model default +// on create) when absent. Grader is any well-formed grader code: one that +// no activated plugin registers grades as manual (D-05). type ImportQuiz struct { Title string `json:"title"` Slug string `json:"slug"` @@ -29,10 +31,13 @@ type ImportQuiz struct { Mode *string `json:"mode"` Navigation *string `json:"navigation"` MaxAttempts *int `json:"max_attempts"` + Grader *string `json:"grader"` Questions []ImportQuestion `json:"questions"` } -// ImportQuestion is one question of an ImportQuiz. +// ImportQuestion is one question of an ImportQuiz. Rubric (owner-only +// grading notes) and MaxLength (the longest answer in characters) belong +// to open questions; AnswerText is an open question's model answer. type ImportQuestion struct { Text string `json:"text"` Type string `json:"type"` @@ -40,6 +45,8 @@ type ImportQuestion struct { AnswerText *string `json:"answer_text"` Explanation *string `json:"explanation"` Points *int `json:"points"` + Rubric *string `json:"rubric"` + MaxLength *int `json:"max_length"` Answers []ImportAnswer `json:"answers"` } @@ -93,11 +100,13 @@ func ParseImport(raw []byte) (*ImportQuiz, error) { if doc.MaxAttempts != nil && *doc.MaxAttempts < 1 { problems = append(problems, "max_attempts must be at least 1") } + problems = append(problems, checkGraderFormat(doc.Grader)...) if len(doc.Questions) == 0 { problems = append(problems, "questions must not be empty") } - for i, q := range doc.Questions { - problems = append(problems, CheckQuestion(fmt.Sprintf("questions[%d]", i), q)...) + for i := range doc.Questions { + doc.Questions[i].Rubric = nilIfBlank(deref(doc.Questions[i].Rubric), false) + problems = append(problems, CheckQuestion(fmt.Sprintf("questions[%d]", i), doc.Questions[i])...) } if len(problems) > 0 { return nil, fmt.Errorf("invalid quiz: %s", strings.Join(problems, "; ")) @@ -109,8 +118,11 @@ func ParseImport(raw []byte) (*ImportQuiz, error) { // at (such as "questions[0]"): the text is required, the type is one of // models.QuestionTypes, points are not negative, answer texts are // required, a single question has at least two answers and exactly one -// correct, a multiple question at least two and one or more correct, and a -// self_check question none. The importer and the manage routes share it. +// correct, a multiple question at least two and one or more correct, a +// self_check question none, and an open question none either (answer_text +// is its model answer). rubric and max_length (1..models.MaxMaxLength) +// apply to open questions only. The importer and the manage routes share +// it. func CheckQuestion(at string, q ImportQuestion) []string { var problems []string if strings.TrimSpace(q.Text) == "" { @@ -144,10 +156,34 @@ func CheckQuestion(at string, q ImportQuestion) []string { if len(q.Answers) > 0 { problems = append(problems, at+": a self_check question has no answers; use answer_text") } + case models.TypeOpen: + if len(q.Answers) > 0 { + problems = append(problems, at+": an open question has no answers; put the model answer in answer_text") + } + if q.MaxLength != nil && (*q.MaxLength < 1 || *q.MaxLength > models.MaxMaxLength) { + problems = append(problems, fmt.Sprintf("%s.max_length must be between 1 and %d", at, models.MaxMaxLength)) + } + } + if q.Type != models.TypeOpen { + if q.Rubric != nil && strings.TrimSpace(*q.Rubric) != "" { + problems = append(problems, at+".rubric applies to open questions only") + } + if q.MaxLength != nil { + problems = append(problems, at+".max_length applies to open questions only") + } } return problems } +// checkGraderFormat is the problem of a sent grader code that is not +// well-formed. Whether a plugin registers it is CheckGrader's question. +func checkGraderFormat(code *string) []string { + if code == nil || graders.ValidCode(*code) { + return nil + } + return []string{"grader must be lowercase letters, digits and underscores"} +} + // ImportResult reports what an import wrote. type ImportResult struct { Quiz models.Quiz @@ -198,11 +234,15 @@ func Import(ctx context.Context, db *gorm.DB, doc *ImportQuiz, replace bool) (*I limit := *doc.MaxAttempts quiz.MaxAttempts = &limit } + if doc.Grader != nil { + quiz.Grader = *doc.Grader + } if err := tx.Save(&quiz).Error; err != nil { return err } if !res.Created { - // The foreign keys cascade to answers and attempt answers. + // The foreign keys cascade to answers and attempt answers, + // with the grades and reviews of open answers. if err := tx.Where("quiz_id = ?", quiz.ID).Delete(&models.Question{}).Error; err != nil { return err } @@ -215,6 +255,7 @@ func Import(ctx context.Context, db *gorm.DB, doc *ImportQuiz, replace bool) (*I q := models.Question{ QuizID: quiz.ID, Text: strings.TrimSpace(in.Text), Type: in.Type, Category: trimmed(in.Category), AnswerText: in.AnswerText, Explanation: in.Explanation, SortOrder: i + 1, Points: points, + Rubric: in.Rubric, MaxLength: in.MaxLength, } if err := tx.Create(&q).Error; err != nil { return err diff --git a/classes/importer_test.go b/classes/importer_test.go index c65f914..7fe328b 100644 --- a/classes/importer_test.go +++ b/classes/importer_test.go @@ -88,6 +88,8 @@ func TestParseImportTestMode(t *testing.T) { // with the manage routes; ParseImport reports exactly these problems. func TestCheckQuestion(t *testing.T) { neg := -1 + zero, five, tooLong := 0, 500, 20001 + rubric := "Mentions sovereignty." for name, tc := range map[string]struct { q classes.ImportQuestion want []string @@ -100,6 +102,12 @@ func TestCheckQuestion(t *testing.T) { "self_check answer": {classes.ImportQuestion{Text: "Q", Type: "self_check", Answers: []classes.ImportAnswer{{Text: "a"}}}, []string{"questions[0]: a self_check question has no answers; use answer_text"}}, "blank answer": {classes.ImportQuestion{Text: "Q", Type: "single", Answers: []classes.ImportAnswer{{Text: " ", IsCorrect: true}, {Text: "b"}}}, []string{"questions[0].answers[0].text is required"}}, "valid single": {classes.ImportQuestion{Text: "Q", Type: "single", Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}, {Text: "b"}}}, nil}, + "valid open": {classes.ImportQuestion{Text: "Q", Type: "open", AnswerText: str("Model"), Rubric: &rubric, MaxLength: &five}, nil}, + "open with answer": {classes.ImportQuestion{Text: "Q", Type: "open", Answers: []classes.ImportAnswer{{Text: "a"}}}, []string{"questions[0]: an open question has no answers; put the model answer in answer_text"}}, + "open length 0": {classes.ImportQuestion{Text: "Q", Type: "open", MaxLength: &zero}, []string{"questions[0].max_length must be between 1 and 20000"}}, + "open length 20001": {classes.ImportQuestion{Text: "Q", Type: "open", MaxLength: &tooLong}, []string{"questions[0].max_length must be between 1 and 20000"}}, + "single rubric": {classes.ImportQuestion{Text: "Q", Type: "single", Rubric: &rubric, Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}, {Text: "b"}}}, []string{"questions[0].rubric applies to open questions only"}}, + "single max_length": {classes.ImportQuestion{Text: "Q", Type: "single", MaxLength: &five, Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}, {Text: "b"}}}, []string{"questions[0].max_length applies to open questions only"}}, } { got := classes.CheckQuestion("questions[0]", tc.q) if strings.Join(got, "; ") != strings.Join(tc.want, "; ") { @@ -115,3 +123,32 @@ func TestCheckQuestion(t *testing.T) { } } } + +func TestParseImportOpen(t *testing.T) { + for _, grader := range []string{"ai", "manual"} { + raw := `{"title":"O","grader":"` + grader + `","questions":[{"text":"Q","type":"open","answer_text":"M","rubric":"R","max_length":300}]}` + doc, err := classes.ParseImport([]byte(raw)) + if err != nil { + t.Fatalf("grader %s: %v", grader, err) + } + q := doc.Questions[0] + if doc.Grader == nil || *doc.Grader != grader || q.Rubric == nil || *q.Rubric != "R" || q.MaxLength == nil || *q.MaxLength != 300 { + t.Errorf("grader %s: doc = %+v, question = %+v", grader, doc, q) + } + } + if _, err := classes.ParseImport([]byte(`{"title":"O","grader":"Bad Grader","questions":[{"text":"Q","type":"open"}]}`)); err == nil || + !strings.Contains(err.Error(), "grader must be lowercase letters, digits and underscores") { + t.Errorf("bad grader = %v", err) + } + doc, err := classes.ParseImport([]byte(`{"title":"O","questions":[{"text":"Q","type":"open","rubric":" "}]}`)) + if err != nil || doc.Questions[0].Rubric != nil { + t.Errorf("blank rubric = %+v %v, want it dropped", doc, err) + } + raw, err := os.ReadFile("../testdata/quiz.json") + if err != nil { + t.Fatal(err) + } + if doc, err := classes.ParseImport(raw); err != nil || doc.Grader != nil { + t.Errorf("sample = %+v %v, want no grader", doc, err) + } +} diff --git a/classes/manage.go b/classes/manage.go index 6767f0a..260d189 100644 --- a/classes/manage.go +++ b/classes/manage.go @@ -56,6 +56,7 @@ type QuizInput struct { PassingScore *int `json:"passing_score"` RoundSize *int `json:"round_size"` ShuffleQuestions *bool `json:"shuffle_questions"` + Grader *string `json:"grader"` } // CheckQuizInput returns the rule problems of in. On create the title is @@ -92,6 +93,7 @@ func CheckQuizInput(in QuizInput, create bool) []string { if in.PassingScore != nil && (*in.PassingScore < 0 || *in.PassingScore > 100) { problems = append(problems, "passing_score must be between 0 and 100") } + problems = append(problems, checkGraderFormat(in.Grader)...) least, word := 1, "at least 1" if !create { least, word = 0, "0 (clears it) or more" @@ -107,6 +109,16 @@ func CheckQuizInput(in QuizInput, create bool) []string { return problems } +// CheckGrader is the problem of a sent grader code that is not one of +// known, the codes of the registry (manual first), naming the valid codes. +// A nil code is fine. +func CheckGrader(code *string, known []string) []string { + if code == nil || slices.Contains(known, *code) { + return nil + } + return []string{"grader must be one of " + strings.Join(known, ", ")} +} + // positive is v, or nil when v is nil or not positive (0 clears). func positive(v *int) *int { if v == nil || *v <= 0 { @@ -181,6 +193,9 @@ func applyQuizInput(quiz *models.Quiz, in QuizInput) { if in.ShuffleQuestions != nil { quiz.ShuffleQuestions = *in.ShuffleQuestions } + if in.Grader != nil { + quiz.Grader = *in.Grader + } } // nilIfBlank is nil for a blank s, else s (trimmed when trim is set). @@ -414,7 +429,11 @@ type WrongAnswerStat struct { // QuestionStat is one question's results over every completed attempt's // answers. CorrectPercent is nil when nobody answered it; MostChosenWrong -// is nil for a self_check question and when no wrong answer was chosen. +// is nil for a self_check or open question and when no wrong answer was +// chosen. Unreviewed counts the open answers the owner has not reviewed +// yet (pending, graded or failed); AveragePoints is the mean score of an +// open question's graded and reviewed answers, nil for other types and +// before any grade. type QuestionStat struct { ID uint Text string @@ -423,6 +442,8 @@ type QuestionStat struct { Correct int CorrectPercent *float64 MostChosenWrong *WrongAnswerStat + Unreviewed int + AveragePoints *float64 } // Stats is the owner's report on one quiz (D-15). @@ -431,6 +452,8 @@ type Stats struct { QuestionCount int Students []StudentStat Questions []QuestionStat + // Unreviewed is the sum of the questions' Unreviewed. + Unreviewed int } // StudentRow is one row of the per-student stats query. @@ -447,9 +470,11 @@ type StudentRow struct { // AnswerRow is one row of the per-question answered/correct query. type AnswerRow struct { - QuestionID uint - Answered int - Correct int + QuestionID uint + Answered int + Correct int + Unreviewed int + AveragePoints *float64 } // WrongRow is one wrong answer chosen N times for a question. @@ -470,7 +495,11 @@ WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL AND a.category IS NULL GROUP BY a.user_id, u.email, u.name, u.surname ORDER BY u.surname ASC NULLS LAST, u.name ASC NULLS LAST, u.email ASC, a.user_id ASC` - statsAnswersSQL = `SELECT aa.question_id, COUNT(*) AS answered, COUNT(*) FILTER (WHERE aa.is_correct) AS correct + // average_points counts only scored open answers: a pending or failed + // answer holds 0 points until it is graded. + statsAnswersSQL = `SELECT aa.question_id, COUNT(*) AS answered, COUNT(*) FILTER (WHERE aa.is_correct) AS correct, + COUNT(*) FILTER (WHERE aa.grading_status IN ('pending', 'graded', 'failed')) AS unreviewed, + AVG(aa.points) FILTER (WHERE aa.grading_status IN ('graded', 'reviewed')) AS average_points FROM golem15_quizzes_attempt_answers aa JOIN golem15_quizzes_attempts a ON a.id = aa.attempt_id WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL @@ -589,9 +618,18 @@ func BuildStats(quiz models.Quiz, questions []models.Question, students []Studen pct := math.Round(float64(a.Correct)*1000/float64(a.Answered)) / 10 st.CorrectPercent = &pct } - if q.Type != models.TypeSelfCheck { + switch q.Type { + case models.TypeSelfCheck: + case models.TypeOpen: + st.Unreviewed = a.Unreviewed + if a.AveragePoints != nil { + avg := math.Round(*a.AveragePoints*100) / 100 + st.AveragePoints = &avg + } + default: st.MostChosenWrong = wrongBy[q.ID] } + out.Unreviewed += st.Unreviewed out.Questions = append(out.Questions, st) } return out @@ -609,8 +647,9 @@ type ManagedAnswerInput struct { // QuestionInput is the fields of a question create or partial update; nil // means not sent. Answers, when sent, is the question's full new answer -// list in order. An empty string clears category, answer_text and -// explanation. +// list in order. An empty string clears category, answer_text, +// explanation and rubric; a max_length of 0 clears it (the default 2000 +// applies). type QuestionInput struct { Text *string `json:"text"` Type *string `json:"type"` @@ -619,6 +658,8 @@ type QuestionInput struct { Explanation *string `json:"explanation"` Points *int `json:"points"` SortOrder *int `json:"sort_order"` + Rubric *string `json:"rubric"` + MaxLength *int `json:"max_length"` Answers *[]ManagedAnswerInput `json:"answers"` } @@ -630,6 +671,7 @@ func MergeQuestion(current models.Question, in QuestionInput) (ImportQuestion, [ q := ImportQuestion{ Text: current.Text, Type: current.Type, Category: current.Category, AnswerText: current.AnswerText, Explanation: current.Explanation, + Rubric: current.Rubric, MaxLength: current.MaxLength, } points := current.Points if current.ID == 0 { @@ -653,6 +695,17 @@ func MergeQuestion(current models.Question, in QuestionInput) (ImportQuestion, [ if in.Points != nil { points = *in.Points } + if in.Rubric != nil { + q.Rubric = nilIfBlank(*in.Rubric, false) + } + if in.MaxLength != nil { + if *in.MaxLength == 0 { + q.MaxLength = nil + } else { + n := *in.MaxLength + q.MaxLength = &n + } + } q.Points = &points var problems []string if in.Category != nil && utf8.RuneCountInString(strings.TrimSpace(*in.Category)) > 255 { @@ -747,6 +800,7 @@ func CreateQuestion(ctx context.Context, db *gorm.DB, slug string, in QuestionIn q = models.Question{ QuizID: quizID, Text: merged.Text, Type: merged.Type, Category: merged.Category, AnswerText: merged.AnswerText, Explanation: merged.Explanation, SortOrder: order, Points: *merged.Points, + Rubric: merged.Rubric, MaxLength: merged.MaxLength, } if err := tx.Omit(clause.Associations).Create(&q).Error; err != nil { return err @@ -802,6 +856,7 @@ func UpdateQuestion(ctx context.Context, db *gorm.DB, slug string, id uint, in Q } q.Text, q.Type, q.Category = merged.Text, merged.Type, merged.Category q.AnswerText, q.Explanation, q.Points = merged.AnswerText, merged.Explanation, *merged.Points + q.Rubric, q.MaxLength = merged.Rubric, merged.MaxLength if in.SortOrder != nil { q.SortOrder = *in.SortOrder } diff --git a/classes/manage_test.go b/classes/manage_test.go index b1817e0..f14d683 100644 --- a/classes/manage_test.go +++ b/classes/manage_test.go @@ -29,6 +29,8 @@ func TestCheckQuizInput(t *testing.T) { "long context": {classes.QuizInput{Title: str("A"), Context: str(strings.Repeat("c", 256))}, true, "context must be at most 255"}, "update blank title": {classes.QuizInput{Title: str("")}, false, "title is required"}, "update negative limit": {classes.QuizInput{MaxAttempts: num(-1)}, false, "max_attempts must be 0"}, + "bad grader": {classes.QuizInput{Title: str("A"), Grader: str("Bad Grader")}, true, "grader must be lowercase"}, + "update empty grader": {classes.QuizInput{Grader: str("")}, false, "grader must be lowercase"}, } { got := strings.Join(classes.CheckQuizInput(tc.in, tc.create), "; ") if !strings.Contains(got, tc.want) { @@ -38,6 +40,9 @@ func TestCheckQuizInput(t *testing.T) { if got := classes.CheckQuizInput(classes.QuizInput{Title: str("Pojęcia")}, true); len(got) != 0 { t.Errorf("minimal create: %v", got) } + if got := classes.CheckQuizInput(classes.QuizInput{Title: str("A"), Grader: str("ai")}, true); len(got) != 0 { + t.Errorf("well-formed grader: %v", got) + } if got := classes.CheckQuizInput(classes.QuizInput{}, false); len(got) != 0 { t.Errorf("empty update: %v", got) } @@ -107,6 +112,9 @@ func TestBuildStats(t *testing.T) { if ok := s.Questions[3]; ok.CorrectPercent == nil || *ok.CorrectPercent != 100 || ok.MostChosenWrong != nil { t.Errorf("all-correct question = %+v", ok) } + if s.Unreviewed != 0 || s.Questions[0].AveragePoints != nil || s.Questions[0].Unreviewed != 0 { + t.Errorf("closed questions report unreviewed %d, average %v", s.Unreviewed, s.Questions[0].AveragePoints) + } more := append(wrong, classes.WrongRow{QuestionID: 10, AnswerID: 103, Text: "third", N: 2}) if w := classes.BuildStats(quiz, questions, nil, answers, more).Questions[0].MostChosenWrong; w == nil || w.AnswerID != 103 { t.Errorf("most_chosen_wrong by count = %+v, want 103", w) @@ -115,6 +123,52 @@ func TestBuildStats(t *testing.T) { func uid(n uint) *uint { return &n } +// TestBuildStatsOpen: an open question reports its unreviewed answers and +// the mean score of its scored answers; it has no most chosen wrong answer. +func TestBuildStatsOpen(t *testing.T) { + quiz := models.Quiz{ID: 1, Slug: "q", PassingScore: 60} + questions := []models.Question{ + {ID: 20, Text: "essay", Type: models.TypeOpen}, + {ID: 21, Text: "single", Type: models.TypeSingle}, + } + avg, ignored := 4.0/3.0, 1.5 + answers := []classes.AnswerRow{ + {QuestionID: 20, Answered: 3, Correct: 1, Unreviewed: 2, AveragePoints: &avg}, + {QuestionID: 21, Answered: 2, Correct: 1, Unreviewed: 5, AveragePoints: &ignored}, + } + wrong := []classes.WrongRow{{QuestionID: 20, AnswerID: 1, Text: "never", N: 3}} + s := classes.BuildStats(quiz, questions, nil, answers, wrong) + open := s.Questions[0] + if open.Unreviewed != 2 || open.AveragePoints == nil || *open.AveragePoints != 1.33 || open.MostChosenWrong != nil { + t.Errorf("open = %+v (average %v)", open, open.AveragePoints) + } + if single := s.Questions[1]; single.Unreviewed != 0 || single.AveragePoints != nil { + t.Errorf("single = %+v, want no open counts", single) + } + if s.Unreviewed != 2 { + t.Errorf("total unreviewed = %d, want 2", s.Unreviewed) + } + if none := classes.BuildStats(quiz, questions[:1], nil, nil, nil).Questions[0]; none.AveragePoints != nil || none.Unreviewed != 0 { + t.Errorf("unanswered open = %+v", none) + } +} + +func TestCheckGrader(t *testing.T) { + known := []string{"manual"} + if got := classes.CheckGrader(str("ai"), known); len(got) != 1 || !strings.Contains(got[0], "grader must be one of manual") { + t.Errorf("unknown grader = %v", got) + } + if got := classes.CheckGrader(str("manual"), known); got != nil { + t.Errorf("manual = %v", got) + } + if got := classes.CheckGrader(nil, known); got != nil { + t.Errorf("nil = %v", got) + } + if got := classes.CheckGrader(str("ai"), []string{"manual", "ai"}); got != nil { + t.Errorf("registered ai = %v", got) + } +} + // TestQuestionInputRules: MergeQuestion applies a partial update and the // answers list to the current question, and with CheckQuestion refuses // what the importer would refuse. @@ -168,6 +222,29 @@ func TestQuestionInputRules(t *testing.T) { if problems := classes.CheckNewQuestion(classes.QuestionInput{Text: str("Q"), Type: str("self_check")}); len(problems) != 0 { t.Errorf("minimal self_check create: %v", problems) } + + open := models.Question{ID: 3, Text: "Esej", Type: models.TypeOpen, Points: 2, Rubric: str("Kryteria"), MaxLength: num(500)} + merged, problems = check(open, classes.QuestionInput{Text: str("Esej?")}) + if len(problems) != 0 || merged.Rubric == nil || *merged.Rubric != "Kryteria" || merged.MaxLength == nil || *merged.MaxLength != 500 { + t.Errorf("open kept: %+v %v", merged, problems) + } + merged, problems = check(open, classes.QuestionInput{Rubric: str(""), MaxLength: num(0)}) + if len(problems) != 0 || merged.Rubric != nil || merged.MaxLength != nil { + t.Errorf("open cleared: %+v %v", merged, problems) + } + if _, problems := check(open, classes.QuestionInput{MaxLength: num(20001)}); !strings.Contains(strings.Join(problems, "; "), "max_length must be between 1 and 20000") { + t.Errorf("open max_length 20001: %v", problems) + } + withAnswers := []classes.ManagedAnswerInput{{Text: "a", IsCorrect: true}} + if problems := classes.CheckNewQuestion(classes.QuestionInput{Text: str("Q"), Type: str("open"), Answers: &withAnswers}); !strings.Contains(strings.Join(problems, "; "), "an open question has no answers") { + t.Errorf("open create with answers: %v", problems) + } + if problems := classes.CheckNewQuestion(classes.QuestionInput{Text: str("Q"), Type: str("single"), Rubric: str("R"), Answers: &toSingle}); !strings.Contains(strings.Join(problems, "; "), "rubric applies to open questions only") { + t.Errorf("single with rubric: %v", problems) + } + if _, problems := check(open, classes.QuestionInput{Type: str(models.TypeSingle), Answers: &toSingle}); !strings.Contains(strings.Join(problems, "; "), "rubric applies to open questions only") { + t.Errorf("open to single keeping the rubric: %v", problems) + } } // TestCheckQuestionOrder: an empty list and a repeated id are refused diff --git a/controllers/api/manage.go b/controllers/api/manage.go index a7d002d..d369083 100644 --- a/controllers/api/manage.go +++ b/controllers/api/manage.go @@ -145,16 +145,23 @@ type updateQuizBody struct { PassingScore *int `json:"passing_score"` RoundSize *int `json:"round_size"` ShuffleQuestions *bool `json:"shuffle_questions"` + Grader *string `json:"grader"` } func (b updateQuizBody) input() classes.QuizInput { return classes.QuizInput{ Title: b.Title, Description: b.Description, Context: b.Context, Mode: b.Mode, Navigation: b.Navigation, MaxAttempts: b.MaxAttempts, TimeLimitSeconds: b.TimeLimitSeconds, PassingScore: b.PassingScore, - RoundSize: b.RoundSize, ShuffleQuestions: b.ShuffleQuestions, + RoundSize: b.RoundSize, ShuffleQuestions: b.ShuffleQuestions, Grader: b.Grader, } } +// graderProblems checks a sent grader against the codes the application's +// grader registry knows (manual and every registered grader). +func (h *Handlers) graderProblems(in classes.QuizInput) []string { + return classes.CheckGrader(in.Grader, classes.ForApp(h.App, nil).Graders.Codes()) +} + // answerQuiz counts and answers a quiz a write returned, or maps err. func answerQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, quiz *models.Quiz, err error) { if err != nil { @@ -174,7 +181,7 @@ func answerQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, // database is touched. func (h *Handlers) ManageCreateQuiz(w http.ResponseWriter, r *http.Request) { var in classes.QuizInput - if !readManaged(w, r, &in) || refuse(w, classes.CheckQuizInput(in, true)) { + if !readManaged(w, r, &in) || refuse(w, classes.CheckQuizInput(in, true)) || refuse(w, h.graderProblems(in)) { return } db, ok := h.manageDB(w) @@ -189,7 +196,8 @@ func (h *Handlers) ManageCreateQuiz(w http.ResponseWriter, r *http.Request) { // fields change. func (h *Handlers) ManageUpdateQuiz(w http.ResponseWriter, r *http.Request) { var body updateQuizBody - if !readManaged(w, r, &body) || refuse(w, classes.CheckQuizInput(body.input(), false)) { + if !readManaged(w, r, &body) || refuse(w, classes.CheckQuizInput(body.input(), false)) || + refuse(w, h.graderProblems(body.input())) { return } db, ok := h.manageDB(w) diff --git a/controllers/api/manage_json.go b/controllers/api/manage_json.go index cdb8c73..d4e7aa6 100644 --- a/controllers/api/manage_json.go +++ b/controllers/api/manage_json.go @@ -7,6 +7,7 @@ package api import ( "git.golem15.com/golem15/sm-quizzes-plugin/classes" + "git.golem15.com/golem15/sm-quizzes-plugin/graders" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/wire" ) @@ -24,6 +25,7 @@ type managedQuizJSON struct { PassingScore int `json:"passing_score"` ShuffleQuestions bool `json:"shuffle_questions"` RoundSize *int `json:"round_size"` + Grader string `json:"grader"` IsPublished bool `json:"is_published"` QuestionCount int `json:"question_count"` AttemptsCount int `json:"attempts_count"` @@ -45,6 +47,8 @@ type managedQuestionJSON struct { Explanation *string `json:"explanation"` Points int `json:"points"` SortOrder int `json:"sort_order"` + Rubric *string `json:"rubric"` + MaxLength *int `json:"max_length"` Answers []managedAnswerJSON `json:"answers"` } @@ -70,6 +74,7 @@ func managedQuiz(q models.Quiz, questions, attempts int) managedQuizJSON { PassingScore: q.PassingScore, ShuffleQuestions: q.ShuffleQuestions, RoundSize: q.RoundSize, + Grader: quizGrader(&q), IsPublished: q.IsPublished, QuestionCount: questions, AttemptsCount: attempts, @@ -105,6 +110,8 @@ func managedQuestion(q models.Question) managedQuestionJSON { Explanation: q.Explanation, Points: q.Points, SortOrder: q.SortOrder, + Rubric: q.Rubric, + MaxLength: q.MaxLength, Answers: make([]managedAnswerJSON, 0, len(q.Answers)), } for _, a := range q.Answers { @@ -116,9 +123,10 @@ func managedQuestion(q models.Question) managedQuestionJSON { } type statsJSON struct { - Quiz statsQuizJSON `json:"quiz"` - Students []studentStatJSON `json:"students"` - Questions []questionStatJSON `json:"questions"` + Quiz statsQuizJSON `json:"quiz"` + Unreviewed int `json:"unreviewed"` + Students []studentStatJSON `json:"students"` + Questions []questionStatJSON `json:"questions"` } type statsQuizJSON struct { @@ -150,6 +158,8 @@ type questionStatJSON struct { Correct int `json:"correct"` CorrectPercent *float64 `json:"correct_percent"` MostChosenWrong *wrongAnswerJSON `json:"most_chosen_wrong"` + Unreviewed int `json:"unreviewed"` + AveragePoints *float64 `json:"average_points"` } type wrongAnswerJSON struct { @@ -164,8 +174,9 @@ func quizStats(s *classes.Stats) statsJSON { ID: s.Quiz.ID, Slug: s.Quiz.Slug, Title: s.Quiz.Title, Mode: quizMode(&s.Quiz), PassingScore: s.Quiz.PassingScore, QuestionCount: s.QuestionCount, }, - Students: make([]studentStatJSON, 0, len(s.Students)), - Questions: make([]questionStatJSON, 0, len(s.Questions)), + Unreviewed: s.Unreviewed, + Students: make([]studentStatJSON, 0, len(s.Students)), + Questions: make([]questionStatJSON, 0, len(s.Questions)), } for _, st := range s.Students { row := studentStatJSON{ @@ -181,6 +192,7 @@ func quizStats(s *classes.Stats) statsJSON { for _, q := range s.Questions { row := questionStatJSON{ ID: q.ID, Text: q.Text, Type: q.Type, Answered: q.Answered, Correct: q.Correct, CorrectPercent: q.CorrectPercent, + Unreviewed: q.Unreviewed, AveragePoints: q.AveragePoints, } if q.MostChosenWrong != nil { row.MostChosenWrong = &wrongAnswerJSON{AnswerID: q.MostChosenWrong.AnswerID, Text: q.MostChosenWrong.Text, Count: q.MostChosenWrong.Count} @@ -189,3 +201,12 @@ func quizStats(s *classes.Stats) statsJSON { } return out } + +// quizGrader is the quiz's grader code, manual for a quiz read before the +// column had a value. +func quizGrader(q *models.Quiz) string { + if q.Grader == "" { + return graders.Manual + } + return q.Grader +} diff --git a/controllers/api/manage_test.go b/controllers/api/manage_test.go index ea435a4..2a897fe 100644 --- a/controllers/api/manage_test.go +++ b/controllers/api/manage_test.go @@ -57,6 +57,11 @@ func TestManageWritesRefuseBadInputBeforeDatabase(t *testing.T) { "update slug": {h.ManageUpdateQuiz, "PATCH", "/", `{"slug":"b"}`, "slug"}, "update is_published": {h.ManageUpdateQuiz, "PATCH", "/", `{"is_published":true}`, "is_published"}, "update passing 101": {h.ManageUpdateQuiz, "PATCH", "/", `{"passing_score":101}`, "passing_score"}, + "create unknown grader": {h.ManageCreateQuiz, "POST", "/", `{"title":"A","grader":"zzz"}`, "grader must be one of manual"}, + "create bad grader": {h.ManageCreateQuiz, "POST", "/", `{"title":"A","grader":"Bad Grader"}`, "grader must be lowercase"}, + "update unknown grader": {h.ManageUpdateQuiz, "PATCH", "/", `{"grader":"zzz"}`, "grader must be one of manual"}, + "question open answers": {h.ManageCreateQuestion, "POST", "/", `{"text":"Q","type":"open","answers":[{"text":"a"}]}`, "an open question has no answers"}, + "question rubric single": {h.ManageCreateQuestion, "POST", "/", `{"text":"Q","type":"single","rubric":"R","answers":[{"text":"a","is_correct":true},{"text":"b"}]}`, "rubric applies to open questions only"}, "delete without confirm": {h.ManageDeleteQuiz, "DELETE", "/", "", "confirm must repeat the quiz slug"}, "delete wrong confirm": {h.ManageDeleteQuiz, "DELETE", "/?confirm=y", "", "confirm must repeat the quiz slug"}, } { diff --git a/graders/registry.go b/graders/registry.go index 7c40405..4c32ea5 100644 --- a/graders/registry.go +++ b/graders/registry.go @@ -17,6 +17,11 @@ var ErrReservedCode = errors.New("quizzes: grader code manual is built in") // 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 diff --git a/mcp_quizzes.go b/mcp_quizzes.go index 47617d6..cfbb427 100644 --- a/mcp_quizzes.go +++ b/mcp_quizzes.go @@ -42,6 +42,7 @@ type createQuizInput struct { PassingScore *int `json:"passing_score,omitempty" jsonschema:"percent needed to pass, 0-100; default 50"` ShuffleQuestions *bool `json:"shuffle_questions,omitempty" jsonschema:"true to shuffle the question order"` RoundSize *int `json:"round_size,omitempty" jsonschema:"for learning: questions per round, at least 1; leave out for all"` + Grader *string `json:"grader,omitempty" jsonschema:"how open answers are graded: manual (default; the owner grades) or a grader code an installed plugin registers; an unknown code is refused with the valid list"` } // updateQuizInput is the input of quizzes_update_quiz: only sent fields @@ -58,6 +59,7 @@ type updateQuizInput struct { PassingScore *int `json:"passing_score,omitempty" jsonschema:"percent needed to pass, 0-100"` ShuffleQuestions *bool `json:"shuffle_questions,omitempty" jsonschema:"true to shuffle the question order"` RoundSize *int `json:"round_size,omitempty" jsonschema:"questions per learning round; 0 clears it (all)"` + Grader *string `json:"grader,omitempty" jsonschema:"how open answers are graded: manual (default; the owner grades) or a grader code an installed plugin registers; an unknown code is refused with the valid list"` } // deleteQuizInput is the input of quizzes_delete_quiz. @@ -93,17 +95,20 @@ type importQuizInput struct { PassingScore *int `json:"passing_score,omitempty" jsonschema:"percent needed to pass, 0-100; default 50"` ShuffleQuestions *bool `json:"shuffle_questions,omitempty" jsonschema:"true to shuffle the question order"` RoundSize *int `json:"round_size,omitempty" jsonschema:"questions per learning round, at least 1"` + Grader *string `json:"grader,omitempty" jsonschema:"how open answers are graded: manual (default) or a grader code; a code no installed plugin registers grades as manual"` Questions []importQuestionInput `json:"questions" jsonschema:"the questions in order; at least one"` } // importQuestionInput is one question of an import document. type importQuestionInput struct { Text string `json:"text" jsonschema:"the question"` - Type string `json:"type" jsonschema:"single (exactly one correct answer), multiple (one or more correct) or self_check (a flashcard with answer_text and no answers)"` + Type string `json:"type" jsonschema:"single (exactly one correct answer), multiple (one or more correct), self_check (a flashcard with answer_text and no answers) or open (a free-text answer graded later; no answers, answer_text is the model answer)"` Category *string `json:"category,omitempty" jsonschema:"a category for learning rounds"` - AnswerText *string `json:"answer_text,omitempty" jsonschema:"the answer a self_check card reveals"` + AnswerText *string `json:"answer_text,omitempty" jsonschema:"the answer a self_check card reveals, or an open question's model answer"` Explanation *string `json:"explanation,omitempty" jsonschema:"shown with the solution"` Points *int `json:"points,omitempty" jsonschema:"points, zero or more; default 1"` + Rubric *string `json:"rubric,omitempty" jsonschema:"open questions: owner-only grading notes for the grader, never shown to students"` + MaxLength *int `json:"max_length,omitempty" jsonschema:"open questions: the longest answer in characters, 1-20000; default 2000"` Answers []importAnswerInput `json:"answers,omitempty" jsonschema:"the choices of a single or multiple question, at least two"` } @@ -126,12 +131,14 @@ type answerInput struct { type createQuestionInput struct { Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"` Text string `json:"text" jsonschema:"the question"` - Type string `json:"type" jsonschema:"single (exactly one correct answer), multiple (one or more correct) or self_check (a flashcard with answer_text and no answers)"` + Type string `json:"type" jsonschema:"single (exactly one correct answer), multiple (one or more correct), self_check (a flashcard with answer_text and no answers) or open (a free-text answer graded later; no answers, answer_text is the model answer)"` Category *string `json:"category,omitempty" jsonschema:"a category for learning rounds"` - AnswerText *string `json:"answer_text,omitempty" jsonschema:"the answer a self_check card reveals"` + AnswerText *string `json:"answer_text,omitempty" jsonschema:"the answer a self_check card reveals, or an open question's model answer"` Explanation *string `json:"explanation,omitempty" jsonschema:"shown with the solution"` Points *int `json:"points,omitempty" jsonschema:"points, zero or more; default 1"` SortOrder *int `json:"sort_order,omitempty" jsonschema:"position, at least 1; default: after the last question"` + Rubric *string `json:"rubric,omitempty" jsonschema:"open questions: owner-only grading notes for the grader, never shown to students"` + MaxLength *int `json:"max_length,omitempty" jsonschema:"open questions: the longest answer in characters, 1-20000; default 2000"` Answers []importAnswerInput `json:"answers,omitempty" jsonschema:"the choices of a single or multiple question in order, at least two"` } @@ -140,12 +147,14 @@ type updateQuestionInput struct { Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"` ID uint `json:"id" path:"id" jsonschema:"the question id, as listed by quizzes_show_quiz"` Text *string `json:"text,omitempty" jsonschema:"the new question text"` - Type *string `json:"type,omitempty" jsonschema:"single, multiple or self_check"` + Type *string `json:"type,omitempty" jsonschema:"single, multiple, self_check or open"` Category *string `json:"category,omitempty" jsonschema:"the new category; an empty string clears it"` - AnswerText *string `json:"answer_text,omitempty" jsonschema:"the new self_check answer; an empty string clears it"` + AnswerText *string `json:"answer_text,omitempty" jsonschema:"the new self_check answer or open model answer; an empty string clears it"` Explanation *string `json:"explanation,omitempty" jsonschema:"the new explanation; an empty string clears it"` Points *int `json:"points,omitempty" jsonschema:"points, zero or more"` SortOrder *int `json:"sort_order,omitempty" jsonschema:"position, at least 1"` + Rubric *string `json:"rubric,omitempty" jsonschema:"open questions: the new owner-only grading notes, never shown to students; an empty string clears them"` + MaxLength *int `json:"max_length,omitempty" jsonschema:"open questions: the longest answer in characters, 1-20000; 0 clears it (default 2000)"` Answers *[]answerInput `json:"answers,omitempty" jsonschema:"the full new answer list in order: answers with an id are kept and updated, answers without an id are created, answers missing from the list are deleted; leave out to keep the answers"` } @@ -166,7 +175,7 @@ func quizTools() []pact.MCPTool { { Name: "list_quizzes", Title: "List quizzes", - Description: "Lists every quiz, published or not, ordered by context (quizzes without one last) and title: id, slug, title, description, context, mode (learning or test), navigation (free or one_way), max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size, is_published, question_count, attempts_count (completed attempts of all students), created_at and updated_at. context filters by the exact context label." + needsRead, + Description: "Lists every quiz, published or not, ordered by context (quizzes without one last) and title: id, slug, title, description, context, mode (learning or test), navigation (free or one_way), max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size, grader (how open answers are graded), is_published, question_count, attempts_count (completed attempts of all students), created_at and updated_at. context filters by the exact context label." + needsRead, Method: http.MethodGet, Path: ManagePrefix, Input: listQuizzesInput{}, @@ -175,7 +184,7 @@ func quizTools() []pact.MCPTool { { Name: "show_quiz", Title: "Show a quiz with its solutions", - Description: "Returns one quiz with the fields of quizzes_list_quizzes and its questions in order: id, text, type (single, multiple or self_check), category, answer_text, explanation, points, sort_order and answers [{id, text, is_correct, explanation, sort_order}]. This is the owner view: it includes the solutions (is_correct, explanations and answer_text), which students only see after answering or finishing." + needsRead, + Description: "Returns one quiz with the fields of quizzes_list_quizzes and its questions in order: id, text, type (single, multiple, self_check or open), category, answer_text (an open question's model answer), explanation, points, sort_order, rubric and max_length (open questions; null otherwise) and answers [{id, text, is_correct, explanation, sort_order}]. This is the owner view: it includes the solutions (is_correct, explanations and answer_text), which students only see after answering or finishing, and the rubric, which students never see." + needsRead, Method: http.MethodGet, Path: ManagePrefix + "/{slug}", Input: showQuizInput{}, @@ -184,7 +193,7 @@ func quizTools() []pact.MCPTool { { Name: "create_quiz", Title: "Create a quiz", - Description: "Creates a quiz without questions and returns it with the fields of quizzes_list_quizzes. title is required; slug defaults to one made from the title, and a taken slug is refused with 409. Optional: description, context, mode (learning or test), navigation (free or one_way), max_attempts, time_limit_seconds, passing_score (default 50), shuffle_questions, round_size. The quiz starts unpublished; add questions with quizzes_create_question, then use quizzes_publish. To create a quiz with all its questions in one call use quizzes_import_quiz." + needsWrite, + Description: "Creates a quiz without questions and returns it with the fields of quizzes_list_quizzes. title is required; slug defaults to one made from the title, and a taken slug is refused with 409. Optional: description, context, mode (learning or test), navigation (free or one_way), max_attempts, time_limit_seconds, passing_score (default 50), shuffle_questions, round_size, grader (manual by default, or a code an installed grader plugin registers; an unknown code is refused with the valid list). The quiz starts unpublished; add questions with quizzes_create_question, then use quizzes_publish. To create a quiz with all its questions in one call use quizzes_import_quiz." + needsWrite, Method: http.MethodPost, Path: ManagePrefix, Input: createQuizInput{}, @@ -193,7 +202,7 @@ func quizTools() []pact.MCPTool { { Name: "update_quiz", Title: "Change quiz fields", - Description: "Changes only the quiz fields you send: title, description, context, mode, navigation, max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size. 0 clears time_limit_seconds, max_attempts and round_size; an empty string clears description and context. It cannot publish or unpublish (use quizzes_publish / quizzes_unpublish) and cannot change the slug. Returns the quiz with the fields of quizzes_list_quizzes. Finished attempts keep their scores." + needsWrite, + Description: "Changes only the quiz fields you send: title, description, context, mode, navigation, max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size, grader (refused unless manual or registered). 0 clears time_limit_seconds, max_attempts and round_size; an empty string clears description and context. It cannot publish or unpublish (use quizzes_publish / quizzes_unpublish) and cannot change the slug. Returns the quiz with the fields of quizzes_list_quizzes. Finished attempts keep their scores." + needsWrite, Method: http.MethodPatch, Path: ManagePrefix + "/{slug}", Input: updateQuizInput{}, @@ -232,7 +241,7 @@ func quizTools() []pact.MCPTool { { Name: "import_quiz", Title: "Import a whole quiz", - Description: "Creates a quiz with all its questions and answers from one quizzes:import document: title, slug, description, context, is_published, mode, navigation, max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size and questions [{text, type, category, answer_text, explanation, points, answers [{text, is_correct, explanation}]}]. A taken slug is a 409 unless replace is true: then the quiz fields are updated and all its questions are deleted and recreated, which deletes the attempt answers given to them. Finished attempts keep their scores, but their reviews lose those questions; on a quiz with attempts prefer the per-question tools. An invalid document is a 422 and writes nothing. Returns id, slug, created and the number of questions and answers." + needsWrite, + Description: "Creates a quiz with all its questions and answers from one quizzes:import document: title, slug, description, context, is_published, mode, navigation, max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size, grader and questions [{text, type, category, answer_text, explanation, points, rubric, max_length, answers [{text, is_correct, explanation}]}]. A taken slug is a 409 unless replace is true: then the quiz fields are updated and all its questions are deleted and recreated, which deletes the attempt answers given to them, open answers with their grades and reviews. Finished attempts keep their scores, but their reviews lose those questions; on a quiz with attempts prefer the per-question tools. An invalid document is a 422 and writes nothing. Returns id, slug, created and the number of questions and answers." + needsWrite, Method: http.MethodPost, Path: ManagePrefix + "/import", Input: importQuizInput{}, @@ -242,7 +251,7 @@ func quizTools() []pact.MCPTool { { Name: "quiz_stats", Title: "Quiz results", - Description: "Reports on one quiz. Expired open test attempts are closed at their deadline first. students [{user_id, email, name, surname, attempts, best_score_percent, last_score_percent, last_completed_at, passed}] counts each student's completed whole-quiz attempts (learning rounds of one category are left out); passed is best_score_percent >= passing_score. questions [{id, text, type, answered, correct, correct_percent, most_chosen_wrong {answer_id, text, count} or null}] counts the answers of every completed attempt; correct_percent is null when nobody answered, and for self_check correct counts the cards marked known. It shows student emails and names." + needsRead, + Description: "Reports on one quiz. Expired open test attempts are closed at their deadline first. students [{user_id, email, name, surname, attempts, best_score_percent, last_score_percent, last_completed_at, passed}] counts each student's completed whole-quiz attempts (learning rounds of one category are left out); passed is best_score_percent >= passing_score. questions [{id, text, type, answered, correct, correct_percent, most_chosen_wrong {answer_id, text, count} or null, unreviewed, average_points}] counts the answers of every completed attempt; correct_percent is null when nobody answered, and for self_check correct counts the cards marked known. For open questions unreviewed counts the answers still pending, graded or failed and average_points is the mean of the scored ones (null otherwise); the top-level unreviewed is their sum. It shows student emails and names." + needsRead, Method: http.MethodGet, Path: ManagePrefix + "/{slug}/stats", Input: statsInput{}, @@ -251,7 +260,7 @@ func quizTools() []pact.MCPTool { { Name: "create_question", Title: "Add a question", - Description: "Adds a question to a quiz: text and type (single, multiple or self_check) are required; category, answer_text (the self_check answer), explanation, points (default 1), sort_order (default: after the last question) and answers [{text, is_correct, explanation}] are optional. A single question needs at least two answers and exactly one correct, a multiple question at least two and one or more correct, a self_check question none. Returns the question with its answer ids." + needsWrite, + Description: "Adds a question to a quiz: text and type (single, multiple, self_check or open) are required; category, answer_text (the self_check answer or the open model answer), explanation, points (default 1), sort_order (default: after the last question), rubric and max_length (open only) and answers [{text, is_correct, explanation}] are optional. A single question needs at least two answers and exactly one correct, a multiple question at least two and one or more correct, a self_check and an open question none. An open question takes a free-text answer of at most max_length characters (default 2000) that the quiz's grader or the owner scores; its rubric guides grading and is never shown to students. Returns the question with its answer ids." + needsWrite, Method: http.MethodPost, Path: ManagePrefix + "/{slug}/questions", Input: createQuestionInput{}, @@ -260,7 +269,7 @@ func quizTools() []pact.MCPTool { { Name: "update_question", Title: "Change one question", - Description: "Changes only the fields you send on one question: text, type, category, answer_text, explanation, points, sort_order; an empty string clears category, answer_text and explanation. answers, when sent, is the full new list: answers with an id are kept and updated (they keep their ids), answers without an id are created, answers missing from the list are deleted; an id of another question is refused. The changed question must pass the same rules as quizzes_create_question. Editing a question of a quiz with attempts is allowed: finished attempts keep their scores and their reviews show the current question." + needsWrite, + Description: "Changes only the fields you send on one question: text, type, category, answer_text, explanation, points, sort_order, rubric, max_length; an empty string clears category, answer_text, explanation and rubric, max_length 0 clears it (default 2000), and rubric and max_length apply to open questions only. answers, when sent, is the full new list: answers with an id are kept and updated (they keep their ids), answers without an id are created, answers missing from the list are deleted; an id of another question is refused. The changed question must pass the same rules as quizzes_create_question. Editing a question of a quiz with attempts is allowed: finished attempts keep their scores and their reviews show the current question." + needsWrite, Method: http.MethodPatch, Path: ManagePrefix + "/{slug}/questions/{id}", Input: updateQuestionInput{}, diff --git a/mcp_test.go b/mcp_test.go index 8762143..2c3684f 100644 --- a/mcp_test.go +++ b/mcp_test.go @@ -13,6 +13,7 @@ import ( _ "git.golem15.com/golem15/sm-user-plugin" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/pier" "git.golem15.com/golem15/summercms/modules/surf" @@ -163,6 +164,42 @@ func TestQuizzesMCP(t *testing.T) { } }) + t.Run("open authoring", func(t *testing.T) { + byName := map[string]pact.MCPTool{} + for _, tool := range tools { + byName[tool.Name] = tool + } + for name, keys := range map[string][]string{ + "create_quiz": {"grader"}, + "update_quiz": {"grader"}, + "import_quiz": {"grader", "rubric", "max_length"}, + "create_question": {"rubric", "max_length"}, + "update_question": {"rubric", "max_length"}, + } { + tool, ok := byName[name] + if !ok { + t.Errorf("no tool %s", name) + continue + } + for _, key := range keys { + if _, found := jsonKeyIn(reflect.TypeOf(tool.Input), key); !found { + t.Errorf("%s has no input %s", name, key) + } + } + } + if d := byName["import_quiz"].Description; !strings.Contains(d, "grades and reviews") { + t.Errorf("import_quiz does not warn that replace deletes reviews: %q", d) + } + for _, name := range []string{"show_quiz", "list_quizzes"} { + if !strings.Contains(byName[name].Description, "grader") && !strings.Contains(byName[name].Description, "rubric") { + t.Errorf("%s does not name the open-question fields", name) + } + } + if d := byName["quiz_stats"].Description; !strings.Contains(d, "unreviewed") || !strings.Contains(d, "average_points") { + t.Errorf("quiz_stats does not name unreviewed and average_points: %q", d) + } + }) + t.Run("review annotations", func(t *testing.T) { destructive := map[string]bool{"review_regrade": true} idempotent := map[string]bool{"review_confirm": true, "review_set_score": true, "review_confirm_bulk": true}