Files
sm-quizzes-plugin/mcp_reviews.go
Jakub Zych 692a647650 feat: set scores, confirm in bulk and regrade open answers over MCP
- SetScore (0..points in 0.5 steps, comment shown to the student),
  ConfirmBulk per quiz or attempt and Regrade through the quiz's grader
- close() announces an attempt reviewed while it was open
- quizzes_review_set_score, quizzes_review_confirm_bulk,
  quizzes_review_regrade; 409 already_reviewed and grader_manual
2026-11-09 18:06:02 +01:00

109 lines
7.8 KiB
Go

package quizzes
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
)
// reviewListInput is the input of quizzes_review_list.
type reviewListInput struct {
Quiz string `json:"quiz,omitempty" query:"quiz" jsonschema:"only answers to this quiz, by slug"`
Status string `json:"status,omitempty" query:"status" jsonschema:"open (the default: pending, graded and failed, everything not reviewed yet), pending, graded, failed, reviewed or all"`
UserID uint `json:"user_id,omitempty" query:"user_id" jsonschema:"only answers of this student (user id)"`
AttemptID uint `json:"attempt_id,omitempty" query:"attempt_id" jsonschema:"only answers of this attempt"`
Page int `json:"page,omitempty" query:"page" jsonschema:"page number, from 1"`
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, 1-100; default 20"`
}
// reviewAnswerInput is the input of the tools that act on one answer.
type reviewAnswerInput struct {
ID uint `json:"id" path:"id" jsonschema:"the answer_id, as listed by quizzes_review_list"`
}
// reviewSetScoreInput is the input of quizzes_review_set_score.
type reviewSetScoreInput struct {
ID uint `json:"id" path:"id" jsonschema:"the answer_id, as listed by quizzes_review_list"`
Points float64 `json:"points" jsonschema:"0 up to the question's points, in steps of 0.5"`
Comment *string `json:"comment,omitempty" jsonschema:"optional comment the student sees, at most 2000 characters; leaving it out clears an earlier comment"`
}
// reviewConfirmBulkInput is the input of quizzes_review_confirm_bulk.
type reviewConfirmBulkInput struct {
Quiz string `json:"quiz,omitempty" jsonschema:"confirm the graded answers of this quiz (its slug); give either quiz or attempt_id"`
AttemptID uint `json:"attempt_id,omitempty" jsonschema:"confirm the graded answers of this attempt; give either quiz or attempt_id"`
}
// reviewRegradeInput is the input of quizzes_review_regrade.
type reviewRegradeInput struct {
AnswerID uint `json:"answer_id,omitempty" jsonschema:"grade this one answer again; give exactly one of answer_id, attempt_id and quiz"`
AttemptID uint `json:"attempt_id,omitempty" jsonschema:"grade the unreviewed answers of this attempt again"`
Quiz string `json:"quiz,omitempty" jsonschema:"grade the unreviewed answers of this quiz (its slug) again"`
}
// reviewTools are the owner's tools over the review queue of open answers
// (D-11). They bind the routes under ReviewPrefix, which check the same
// permission as the quiz tools (D-13).
func reviewTools() []pact.MCPTool {
return []pact.MCPTool{
{
Name: "review_list",
Title: "List open answers to review",
Description: "Lists the answers students wrote to open questions, oldest change first, for you to review. status: open (default: pending, graded and failed, everything not reviewed yet), pending (waiting for the grader or for you), graded (the grader gave a score you have not confirmed), failed (the grader could not score it; set the score yourself), reviewed or all. Filters: quiz (slug), user_id, attempt_id; page and per_page (1-100, default 20). Each row: answer_id, status, quiz {id, slug, title, mode}, question {id, text, points}, attempt {id, completed, completed_at}, student {user_id, email, name, surname}, text (the first 300 characters, text_truncated when cut), grader_points, points, graded_at, reviewed_at, updated_at. Read one answer in full with quizzes_review_show. It shows student emails, names and answers." + needsRead,
Method: http.MethodGet,
Path: ReviewPrefix,
Input: reviewListInput{},
Permissions: quizPermissions,
},
{
Name: "review_show",
Title: "Show one open answer for review",
Description: "Shows one open answer for review, owner view with the solution: question {id, text, points, answer_text (the model answer), rubric (the grading rules), max_length}, the student's full text, quiz, attempt, student, status, grader (the grader code), grader_points and grader_feedback (the grader's score and note, final only once status is reviewed), grading_error (why the grader failed), grading_attempts, points (what counts in the attempt's score now), is_correct, review_comment (shown to the student), reviewed_at and reviewed_by {user_id, admin_id}. Never pass the rubric or the model answer to a student." + needsRead,
Method: http.MethodGet,
Path: ReviewPrefix + "/{id}",
Input: reviewAnswerInput{},
Permissions: quizPermissions,
},
{
Name: "review_confirm",
Title: "Confirm the grader's score",
Description: "Confirms the grader's score of one graded answer: status becomes reviewed and points become grader_points, final over any later grading, and a finished attempt's score is recalculated. A pending or failed answer has no score to confirm and is refused with 409 code no_grader_score (set the score with quizzes_review_set_score instead); confirming a reviewed answer changes nothing. Returns the answer as quizzes_review_show does." + needsWrite,
Method: http.MethodPost,
Path: ReviewPrefix + "/{id}/confirm",
Input: reviewAnswerInput{},
Permissions: quizPermissions,
Idempotent: &yes,
},
{
Name: "review_set_score",
Title: "Set the score of an open answer",
Description: "Sets your own final score on one open answer, whatever its status (pending, graded, failed or reviewed): points from 0 up to the question's points in steps of 0.5 (anything else is refused with 422), and an optional comment of at most 2000 characters that the student sees with the answer; leaving the comment out clears an earlier one. status becomes reviewed and the score is final over any later grading; the grader's score and feedback stay for the record, and a finished attempt's score is recalculated. Returns the answer as quizzes_review_show does." + needsWrite,
Method: http.MethodPost,
Path: ReviewPrefix + "/{id}/score",
Input: reviewSetScoreInput{},
Permissions: quizPermissions,
Idempotent: &yes,
},
{
Name: "review_confirm_bulk",
Title: "Confirm grader scores in bulk",
Description: "Confirms the grader's score of every graded answer of one quiz (quiz, its slug) or of one attempt (attempt_id); give exactly one of the two. Each becomes reviewed with points = grader_points, as quizzes_review_confirm does; pending, failed and reviewed answers are left alone, so check failed ones with quizzes_review_list first. Finished attempts are recalculated. Returns {confirmed: the number of answers confirmed}; a second call confirms nothing new." + needsWrite,
Method: http.MethodPost,
Path: ReviewPrefix + "/confirm",
Input: reviewConfirmBulkInput{},
Permissions: quizPermissions,
Idempotent: &yes,
},
{
Name: "review_regrade",
Title: "Grade open answers again",
Description: "Re-runs the quiz's grader on one answer (answer_id), one attempt (attempt_id) or a whole quiz (quiz, its slug); give exactly one. Use it after changing a rubric or when the grader failed. Every pending, graded or failed answer in scope goes back to pending: the current grader scores, feedback and errors are discarded and the points drop to 0 until the grader scores it again (a finished attempt's score is recalculated). Answers of a test attempt that is still open are graded when it ends. Reviewed answers are skipped, and a single reviewed answer is refused with 409 code already_reviewed. Refused with 409 code grader_manual when the quiz's grader is manual or not available. Returns {regraded: the number of answers sent back}." + needsWrite,
Method: http.MethodPost,
Path: ReviewPrefix + "/regrade",
Input: reviewRegradeInput{},
Permissions: quizPermissions,
Destructive: &yes,
},
}
}