Files
sm-quizzes-plugin/mcp_quizzes.go
Jakub Zych 4a60eda425 feat: create, publish, import and report on quizzes over MCP
- quizzes_create_quiz, update_quiz, delete_quiz (confirm repeats the
  slug), publish, unpublish, import_quiz (the existing import route) and
  quiz_stats on new manage routes
- stats close expired open test attempts first, then report per student
  over completed whole-quiz attempts and per question over every
  completed attempt
- input is checked before the database; TestManageAPIAgainstDatabase is
  written and skipped under -short
2026-11-09 14:30:31 +01:00

206 lines
14 KiB
Go

package quizzes
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
)
// The sentences every quiz tool description ends with (D-13).
const (
needsRead = " Needs scope read and the golem15.quizzes.access_quizzes permission."
needsWrite = " Needs scope write and the golem15.quizzes.access_quizzes permission."
)
// quizPermissions filters the listing: only users whose groups grant the
// quizzes permission see the quiz tools.
var quizPermissions = []string{permQuizzes}
// yes is the true the tool annotations point at.
var yes = true
// listQuizzesInput is the input of quizzes_list_quizzes.
type listQuizzesInput struct {
Context string `json:"context,omitempty" query:"context" jsonschema:"only quizzes whose context label equals this value exactly; leave out for every quiz"`
}
// showQuizInput is the input of quizzes_show_quiz.
type showQuizInput struct {
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug (lowercase letters, digits and hyphens), as listed by quizzes_list_quizzes"`
}
// The quiz fields a create or an update may send.
type createQuizInput struct {
Title string `json:"title" jsonschema:"the quiz title, 1-255 characters"`
Slug string `json:"slug,omitempty" jsonschema:"the slug (lowercase letters, digits and hyphens; normalised); default: made from the title; 409 when taken"`
Description *string `json:"description,omitempty" jsonschema:"a description shown above the quiz"`
Context *string `json:"context,omitempty" jsonschema:"a free context label (at most 255 characters) the student app groups quizzes by, such as a course code"`
Mode *string `json:"mode,omitempty" jsonschema:"learning (default: solutions after each answer) or test (solutions only after finishing)"`
Navigation *string `json:"navigation,omitempty" jsonschema:"for a test: free (default; answers can change) or one_way (an answer is final)"`
MaxAttempts *int `json:"max_attempts,omitempty" jsonschema:"for a test: attempts per student, at least 1; leave out for unlimited"`
TimeLimitSeconds *int `json:"time_limit_seconds,omitempty" jsonschema:"time limit of an attempt in seconds, at least 1; leave out for none"`
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"`
}
// updateQuizInput is the input of quizzes_update_quiz: only sent fields
// change.
type updateQuizInput struct {
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug, as listed by quizzes_list_quizzes; it cannot be changed"`
Title *string `json:"title,omitempty" jsonschema:"the new title, 1-255 characters"`
Description *string `json:"description,omitempty" jsonschema:"the new description; an empty string clears it"`
Context *string `json:"context,omitempty" jsonschema:"the new context label (at most 255 characters); an empty string clears it"`
Mode *string `json:"mode,omitempty" jsonschema:"learning or test"`
Navigation *string `json:"navigation,omitempty" jsonschema:"free or one_way (tests only)"`
MaxAttempts *int `json:"max_attempts,omitempty" jsonschema:"attempts per student in a test; 0 clears it (unlimited)"`
TimeLimitSeconds *int `json:"time_limit_seconds,omitempty" jsonschema:"time limit in seconds; 0 clears it (none)"`
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)"`
}
// deleteQuizInput is the input of quizzes_delete_quiz.
type deleteQuizInput struct {
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"`
Confirm string `json:"confirm" query:"confirm" jsonschema:"must repeat the slug exactly, or the delete is refused"`
}
// publishInput is the input of quizzes_publish and quizzes_unpublish.
type publishInput struct {
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"`
}
// statsInput is the input of quizzes_quiz_stats.
type statsInput struct {
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"`
}
// importQuizInput is the input of quizzes_import_quiz: the quizzes:import
// document. The keys match classes.ImportQuiz exactly, because the
// importer refuses unknown keys.
type importQuizInput struct {
Replace bool `json:"replace,omitempty" query:"replace" jsonschema:"true to replace an existing quiz with this slug (its fields, and all its questions are deleted and recreated); without it a taken slug is a 409"`
Title string `json:"title" jsonschema:"the quiz title"`
Slug string `json:"slug,omitempty" jsonschema:"the slug; default: made from the title"`
Description *string `json:"description,omitempty" jsonschema:"a description"`
Context *string `json:"context,omitempty" jsonschema:"a free context label, such as a course code"`
IsPublished *bool `json:"is_published,omitempty" jsonschema:"true to publish the quiz at once"`
Mode *string `json:"mode,omitempty" jsonschema:"learning or test"`
Navigation *string `json:"navigation,omitempty" jsonschema:"free or one_way (tests only)"`
MaxAttempts *int `json:"max_attempts,omitempty" jsonschema:"attempts per student in a test, at least 1"`
TimeLimitSeconds *int `json:"time_limit_seconds,omitempty" jsonschema:"time limit in seconds, at least 1"`
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"`
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)"`
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"`
Explanation *string `json:"explanation,omitempty" jsonschema:"shown with the solution"`
Points *int `json:"points,omitempty" jsonschema:"points, zero or more; default 1"`
Answers []importAnswerInput `json:"answers,omitempty" jsonschema:"the choices of a single or multiple question, at least two"`
}
// importAnswerInput is one answer of an import question.
type importAnswerInput struct {
Text string `json:"text" jsonschema:"the answer text"`
IsCorrect bool `json:"is_correct,omitempty" jsonschema:"true for a correct answer"`
Explanation *string `json:"explanation,omitempty" jsonschema:"shown with the solution"`
}
func quizTools() []pact.MCPTool {
return []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,
Method: http.MethodGet,
Path: ManagePrefix,
Input: listQuizzesInput{},
Permissions: quizPermissions,
},
{
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,
Method: http.MethodGet,
Path: ManagePrefix + "/{slug}",
Input: showQuizInput{},
Permissions: quizPermissions,
},
{
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,
Method: http.MethodPost,
Path: ManagePrefix,
Input: createQuizInput{},
Permissions: quizPermissions,
},
{
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,
Method: http.MethodPatch,
Path: ManagePrefix + "/{slug}",
Input: updateQuizInput{},
Permissions: quizPermissions,
},
{
Name: "delete_quiz",
Title: "Delete a quiz",
Description: "Deletes a quiz for good, with every question, answer and student attempt. Refused unless confirm repeats the slug. Returns id, slug, title and the number of questions and attempts removed. To hide a quiz without deleting it use quizzes_unpublish." + needsWrite,
Method: http.MethodDelete,
Path: ManagePrefix + "/{slug}",
Input: deleteQuizInput{},
Permissions: quizPermissions,
Destructive: &yes,
},
{
Name: "publish",
Title: "Publish a quiz",
Description: "Publishes the quiz so students see it in their quiz list; publishing a published quiz changes nothing. Returns the quiz with the fields of quizzes_list_quizzes." + needsWrite,
Method: http.MethodPost,
Path: ManagePrefix + "/{slug}/publish",
Input: publishInput{},
Permissions: quizPermissions,
Idempotent: &yes,
},
{
Name: "unpublish",
Title: "Unpublish a quiz",
Description: "Hides the quiz from students; their finished attempts and scores stay. Unpublishing an unpublished quiz changes nothing. Returns the quiz with the fields of quizzes_list_quizzes." + needsWrite,
Method: http.MethodPost,
Path: ManagePrefix + "/{slug}/unpublish",
Input: publishInput{},
Permissions: quizPermissions,
Idempotent: &yes,
},
{
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,
Method: http.MethodPost,
Path: ManagePrefix + "/import",
Input: importQuizInput{},
Permissions: quizPermissions,
Destructive: &yes,
},
{
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,
Method: http.MethodGet,
Path: ManagePrefix + "/{slug}/stats",
Input: statsInput{},
Permissions: quizPermissions,
},
}
}