From 6ae101fc4c02b55b90946e97526991f95a4786b1 Mon Sep 17 00:00:00 2001 From: mahruschow Date: Sun, 4 Oct 2026 03:50:44 -0400 Subject: [PATCH 1/3] fix(quizzes): strengthen question and distractor authoring guidance --- docs/concepts/items.mdx | 3 +++ .../components/study/CreateStudyItemDialog.tsx | 2 +- src/features/workspaces/quizzes/quiz-edits.ts | 10 ++++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/concepts/items.mdx b/docs/concepts/items.mdx index fb2cd787..d14a6c00 100644 --- a/docs/concepts/items.mdx +++ b/docs/concepts/items.mdx @@ -17,6 +17,9 @@ Every piece of workspace content is a workspace item. Items can be opened in tab A container for organizing related items. Folders are part of the workspace item tree and can be opened like any other workspace location. + + A multiple-choice study set with saved answer history, immediate feedback, and explanations. Quiz creation asks the AI to emphasize application and reasoning, use plausible distractors based on misconceptions, and keep options comparable in wording and length. Correct answers are shuffled into a stable display order when questions are created. + ## Supported Uploads diff --git a/src/features/workspaces/components/study/CreateStudyItemDialog.tsx b/src/features/workspaces/components/study/CreateStudyItemDialog.tsx index 549d21a9..8ff67ab1 100644 --- a/src/features/workspaces/components/study/CreateStudyItemDialog.tsx +++ b/src/features/workspaces/components/study/CreateStudyItemDialog.tsx @@ -32,7 +32,7 @@ const studyItemDialogConfigs = { title: "Create a quiz", countLabel: "Number of questions", prompt: (count: number, locationPhrase: string, topic: string) => - `Create a quiz with exactly ${count} multiple-choice questions ${locationPhrase}. Cover: ${topic}`, + `Create a quiz with exactly ${count} multiple-choice questions ${locationPhrase}. Cover: ${topic}. Use the relevant workspace source material. Prefer application and reasoning questions, with three plausible distractors based on common misconceptions. Keep all options comparable in length, specificity, and wording so the correct answer does not stand out. Check that exactly one option is correct and explain why each distractor is wrong.`, }, } as const satisfies Record; diff --git a/src/features/workspaces/quizzes/quiz-edits.ts b/src/features/workspaces/quizzes/quiz-edits.ts index d111162e..00c082db 100644 --- a/src/features/workspaces/quizzes/quiz-edits.ts +++ b/src/features/workspaces/quizzes/quiz-edits.ts @@ -22,19 +22,21 @@ import { workspaceUnitRefInputSchema } from "#/features/workspaces/locations/wor const editTextSchema = z.string().max(8_000); const authoredQuestionFields = { - question: entryRichTextHtmlSchema.describe("HTML question stem."), + question: entryRichTextHtmlSchema.describe( + "HTML question stem testing one learning objective. Prefer applying, comparing, or reasoning about the source material over verbatim recall. Include enough context for exactly one defensible answer; do not reveal the answer through wording or quote it from the source.", + ), correctAnswer: entryRichTextHtmlSchema.describe( - "HTML for the single correct option. Never mark it in the stem; its final position is shuffled server-side.", + "HTML for the single correct option. Match the distractors in length, specificity, grammar, and formatting so it does not stand out. Never mark it in the stem; its final position is shuffled server-side.", ), distractors: z .array(entryRichTextHtmlSchema) .min(QUIZ_QUESTION_MIN_DISTRACTORS) .max(QUIZ_QUESTION_MAX_DISTRACTORS) .describe( - "HTML for each incorrect option: strictly wrong, plausible, and grounded in a specific misconception. Use 3 for a standard question, 1 for true/false.", + "HTML for each incorrect option: plausible to a learner who has a specific misconception, but unambiguously wrong in the stated context. Use nearby concepts, reversed relationships, or realistic calculation errors; avoid absurd or unrelated fillers, overlapping answers, all/none of the above, and giveaways such as absolute words used only in wrong options. Match the correct answer in length, specificity, grammar, and formatting. Use 3 for a standard question, 1 for true/false.", ), explanation: entryRichTextHtmlSchema.describe( - "Short HTML explanation of why the correct answer is right, touching on why the others are not.", + "Short HTML explanation of why the correct answer is right and the specific misconception behind each distractor. Check every option against the source material and revise any ambiguous or obviously implausible option before submitting. Refer to option content, not letters or positions, because options are shuffled.", ), }; From 357b0edcb785da50364b076735ec2df667975644 Mon Sep 17 00:00:00 2001 From: mahruschow Date: Sun, 4 Oct 2026 04:06:37 -0400 Subject: [PATCH 2/3] feat(quizzes): add AI-graded short-answer questions --- docs/concepts/items.mdx | 4 +- .../components/quizzes/QuizViewer.tsx | 196 ++++++++++++------ .../study/CreateStudyItemDialog.tsx | 20 +- .../content/workspace-content-contract.ts | 46 ++-- .../content/workspace-content-reader.test.ts | 54 +++++ .../content/workspace-content-reader.ts | 36 +++- .../workspaces/export/workspace-export.ts | 19 +- .../workspaces/quizzes/quiz-content.ts | 127 ++++++++++-- .../workspaces/quizzes/quiz-edits.test.ts | 2 + src/features/workspaces/quizzes/quiz-edits.ts | 54 ++++- .../workspaces/quizzes/quiz-functions.ts | 12 ++ .../workspaces/quizzes/quiz-grading.test.ts | 99 +++++++++ .../workspaces/quizzes/quiz-grading.ts | 80 +++++++ .../workspaces/quizzes/quiz-queries.ts | 27 +++ .../quizzes/quiz-short-answer.test.ts | 155 ++++++++++++++ .../quizzes/quiz-study-persistence.test.ts | 157 ++++++++++++++ .../quizzes/quiz-study-persistence.ts | 78 ++++++- .../workspaces/quizzes/quiz-study-session.ts | 3 +- .../workspaces/quizzes/quiz-study-state.ts | 36 +++- .../search/workspace-search-text.ts | 4 +- 20 files changed, 1077 insertions(+), 132 deletions(-) create mode 100644 src/features/workspaces/quizzes/quiz-grading.test.ts create mode 100644 src/features/workspaces/quizzes/quiz-grading.ts create mode 100644 src/features/workspaces/quizzes/quiz-short-answer.test.ts create mode 100644 src/features/workspaces/quizzes/quiz-study-persistence.test.ts diff --git a/docs/concepts/items.mdx b/docs/concepts/items.mdx index d14a6c00..c4f2371b 100644 --- a/docs/concepts/items.mdx +++ b/docs/concepts/items.mdx @@ -18,7 +18,9 @@ Every piece of workspace content is a workspace item. Items can be opened in tab A container for organizing related items. Folders are part of the workspace item tree and can be opened like any other workspace location. - A multiple-choice study set with saved answer history, immediate feedback, and explanations. Quiz creation asks the AI to emphasize application and reasoning, use plausible distractors based on misconceptions, and keep options comparable in wording and length. Correct answers are shuffled into a stable display order when questions are created. + A study set with multiple-choice or short-answer questions, saved answer history, feedback, and explanations. Quiz creation asks the AI to emphasize application and reasoning, use plausible distractors based on misconceptions, and keep options comparable in wording and length. Correct multiple-choice answers are shuffled into a stable display order when questions are created. + + Choose Short answer when creating a quiz to write responses and receive AI feedback against a model answer and grading criteria. Each successful grading request uses an AI message from your plan. Answers lock after grading; reset quiz progress for a fresh attempt. If grading fails, the response stays in the current tab so it can be retried. Editing a short-answer question invalidates its previous grade. diff --git a/src/features/workspaces/components/quizzes/QuizViewer.tsx b/src/features/workspaces/components/quizzes/QuizViewer.tsx index 3ac924e8..4fe47636 100644 --- a/src/features/workspaces/components/quizzes/QuizViewer.tsx +++ b/src/features/workspaces/components/quizzes/QuizViewer.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "#/components/ui/button"; import { Skeleton } from "#/components/ui/skeleton"; +import { Textarea } from "#/components/ui/textarea"; import { sendComposerPrompt } from "#/features/workspaces/composer/workspace-composer-actions"; import { StudyProgressStrip } from "#/features/workspaces/components/study/StudyProgressStrip"; import { StudyRichText } from "#/features/workspaces/components/study/StudyRichText"; @@ -21,6 +22,7 @@ import type { QuizQuestion } from "#/features/workspaces/quizzes/quiz-content"; import { quizViewerQueryOptions, useRecordQuizAnswer, + useRecordQuizShortAnswer, useResetQuizStudyProgress, } from "#/features/workspaces/quizzes/quiz-queries"; import { @@ -30,6 +32,7 @@ import { } from "#/features/workspaces/quizzes/quiz-study-session"; import { getQuizAnswer, + isQuizAnswerCorrect, summarizeQuizStudyProgress, type QuizStudyState, } from "#/features/workspaces/quizzes/quiz-study-state"; @@ -137,6 +140,8 @@ function QuizStudySession({ workspaceId: item.workspaceId, }; const recordAnswer = useRecordQuizAnswer(queryInput); + const recordShortAnswer = useRecordQuizShortAnswer(queryInput); + const [responses, setResponses] = useState>({}); const { isPending: isResetting, mutate: resetProgress } = useResetQuizStudyProgress(queryInput); const startSession = useCallback( (nextMode: QuizStudyMode, nextShuffled: boolean) => { @@ -196,7 +201,7 @@ function QuizStudySession({ itemId: item.id, ...getQuizStudyViewState({ answered: answer !== undefined, - ...(answer ? { correct: answer.selectedOptionId === currentQuestion.correctOptionId } : {}), + ...(answer ? { correct: isQuizAnswerCorrect(currentQuestion, answer) } : {}), mode, progress: quizProgress, sessionPosition: currentIndex + 1, @@ -286,18 +291,21 @@ function QuizStudySession({ if (!currentQuestion) return null; const graded = answer !== undefined; - const isCorrect = answer?.selectedOptionId === currentQuestion.correctOptionId; - const selectedOptionId = graded ? answer.selectedOptionId : pendingOptionId; + const isCorrect = isQuizAnswerCorrect(currentQuestion, answer); + const selectedOptionId = + answer && "selectedOptionId" in answer ? answer.selectedOptionId : pendingOptionId; + const options = currentQuestion.kind === "multiple_choice" ? currentQuestion.options : []; + const correctOptionId = + currentQuestion.kind === "multiple_choice" ? currentQuestion.correctOptionId : null; + const response = responses[currentQuestion.id] ?? ""; const isLastQuestion = currentIndex >= studyQuestions.length - 1; // Khan-style: the pending pick, and the correct option once graded, get an // outline. Wrong picks keep only the filled X — no row chrome — so they // stay in the hairline list. const outlinedOptionIds = new Set( - [!graded ? selectedOptionId : null, graded ? currentQuestion.correctOptionId : null].filter( - Boolean, - ), + [!graded ? selectedOptionId : null, graded ? correctOptionId : null].filter(Boolean), ); - const lastOption = currentQuestion.options.at(-1); + const lastOption = options.at(-1); return (
-
- {currentQuestion.options.map((option, optionIndex) => { - const isSelected = option.id === selectedOptionId; - const isCorrectOption = option.id === currentQuestion.correctOptionId; - const previousOption = currentQuestion.options[optionIndex - 1]; - return ( -
- -