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"` 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 // 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)"` 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. 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"` 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), 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, 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"` } // 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"` } // answerInput is one answer of quizzes_update_question's answers list. type answerInput struct { ID *uint `json:"id,omitempty" jsonschema:"the id of an existing answer to keep; leave out to create one"` 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; left out keeps a kept answer's explanation, an empty string clears it"` } // createQuestionInput is the input of quizzes_create_question. 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), 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, 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"` } // updateQuestionInput is the input of quizzes_update_question. 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, 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 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"` } // deleteQuestionInput is the input of quizzes_delete_question. type deleteQuestionInput struct { Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"` ID uint `json:"id" path:"id" jsonschema:"the question id"` } // reorderInput is the input of quizzes_reorder_questions. type reorderInput struct { Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"` QuestionIDs []uint `json:"question_ids" jsonschema:"every question id of the quiz exactly once, in the new order"` } 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, 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{}, 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, 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{}, 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, 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{}, 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, 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{}, 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, 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{}, 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, 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{}, Permissions: quizPermissions, }, { Name: "create_question", Title: "Add a question", 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{}, Permissions: quizPermissions, }, { 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, 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{}, Permissions: quizPermissions, }, { Name: "delete_question", Title: "Delete a question", Description: "Deletes one question for good, with its answers and the attempt answers students gave to it. Finished attempts keep their scores, but their reviews lose the question. Returns id and quiz_slug." + needsWrite, Method: http.MethodDelete, Path: ManagePrefix + "/{slug}/questions/{id}", Input: deleteQuestionInput{}, Permissions: quizPermissions, Destructive: &yes, }, { Name: "reorder_questions", Title: "Reorder questions", Description: "Sets the question order of a quiz: question_ids must list every question of the quiz exactly once, or the call is refused; the first id gets sort_order 1. Returns the quiz with its questions as quizzes_show_quiz does." + needsWrite, Method: http.MethodPut, Path: ManagePrefix + "/{slug}/questions/order", Input: reorderInput{}, Permissions: quizPermissions, }, } }