feat(exercise): add open-ended thumbs up/down question type (#780)

* feat(ui): add THUMBS question type renderer

- Add seven new renderer files under renderers/thumbs/:
  thumbs-correct.ts, take.svelte, edit.svelte, preview.svelte,
  thumbs-review-toggle.svelte, review.svelte, submission.svelte
- Register THUMBS in renderer-contract.ts with edit/take/preview/submission/review
- Export ThumbsEdit, ThumbsTake, ThumbsPreview, ThumbsSubmission,
  ThumbsReview from exercise-question/index.ts
- Add THUMBS_FIXTURE, four thumbs label keys, and Thumbs story
  in storybook exercise-question files
- Add THUMBS case to question-type-modes.svelte submission builder

Co-authored-by: Rotimi Best <[email protected]>

* feat(question-types): add THUMBS contracts, scoring, and seed

Introduce the THUMBS question type (id 14) with boolean answer data,
codec, auto-grading, Zod validation, DB seed/schema docs, and binary
normalize support shared with TRUE_FALSE. Dashboard wiring is deferred.

Co-authored-by: Rotimi Best <[email protected]>

* feat(dashboard): wire THUMBS question type into exercise UI

Connect THUMBS option sync in edit/view modes, labels, picker icon,
and locale strings so the existing contracts surface in the dashboard.

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): polish thumbs question UI and move premium upgrade icon

Widen thumbs Yes/No toggles, inherit selected icon color via custom class,
replace badge-based review correctness with solid fill states and bare
Check/X icons, add a success pill in preview, and place the premium
upgrade icon at the end of the question type select row.

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): align thumbs review colors with prototypes

Tune correct/incorrect selected states so review buttons use dark
emerald/red content colors on solid fills, matching the design comps.

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): address remaining PR review comments

Use a shared timestamp local in thumbs edit fallback options.
Move border-beam animation-delay after the animation shorthand.
Alias --spacing to --ui-spacing for tw-animate compatibility.

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): sync question_type sequence before seeding new types

Avoid duplicate key errors when question_type_id_seq lags behind rows
inserted via restore or explicit ids (e.g. THUMBS on restored databases).

Co-authored-by: Rotimi Best <[email protected]>

* fix(exercise): resolve thumbs review label ICU crash

Remove {value} ICU placeholder from thumbs.review.correct_answer_label
since labels are resolved at map-build time without context. Append the
dynamic yes/no label in thumbs-review-toggle instead.

Also bind question points through the questionnaire store index to avoid
the Svelte non-reactive property binding warning in edit-mode.

Co-authored-by: Rotimi Best <[email protected]>

* docs(agent): add THUMBS guidance and sync question type docs

Add shared agent question-type guide helpers in @cio/question-types and
use them across the teacher prompt, add/update_questions tool schemas,
legacy ai-assistant schemas, and MCP exercise tools.

- Document when to use THUMBS (Yes/No sentiment) vs TRUE_FALSE (factual)
- Fix stale MCP question type ids and add THUMBS=14
- Exclude disabled MATCHING/HOTSPOT from enabled type lists

Co-authored-by: Rotimi Best <[email protected]>

* fix(thumbs): clarify auto-grading and add editable option labels

THUMBS is auto-graded like TRUE_FALSE; the difference is thumbs UI with
customizable Yes/No-style labels and the teacher selecting the correct
option. Render take/preview/review from question.options labels and add
label inputs in the thumbs edit renderer. Update agent and MCP docs to
stop implying THUMBS is manual or non-gradable.

Co-authored-by: Rotimi Best <[email protected]>

* chore(mcp): bump version to 0.0.10

Co-authored-by: Rotimi Best <[email protected]>

* fix(question-types): make THUMBS open-ended with no correct answer

THUMBS is a sentiment/poll question type, not an auto-graded binary like
TRUE_FALSE. Remove correct-answer UI, scoring, and validation; update agent
and MCP docs accordingly.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): stop treating THUMBS as auto-graded in editor validation

Normalize thumbs questions on load/save, show an Open-ended badge instead
of Auto, and only enforce points/correct-answer rules on auto-gradable types.

Co-authored-by: Rotimi Best <[email protected]>

* revert(dashboard): remove unsolicited open-ended grading badge

THUMBS uses the existing Manual badge like other non-auto-gradable types.

Co-authored-by: Rotimi Best <[email protected]>

* fix(exercise): show question title validation errors in editor

Display mapped title errors beneath the question title input instead of
only highlighting the question card border. Pass the exercise entity to
mapZodErrorsToTranslations so messages resolve to validations.exercise.*
keys, and use a translated snackbar key for save validation failures.

Co-authored-by: Rotimi Best <[email protected]>

* fix(mcp): build workspace deps before MCP publish bundle

The MCP esbuild step imports @cio/question-types and @cio/utils dist
outputs. CI failed on a clean checkout because mcp:build only built the
MCP package itself. Build those dependencies first so 0.0.10 can publish.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-31 14:59:38 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 0abdfe2102
commit 20e34e29dd
69 changed files with 1022 additions and 166 deletions
@@ -0,0 +1,38 @@
import { ENABLED_QUESTION_TYPE_REGISTRY } from './question-type-registry';
/** Compact id=typename (label) list for Zod `.describe()` on questionTypeId fields. */
export const ENABLED_QUESTION_TYPE_ID_DESCRIPTION = ENABLED_QUESTION_TYPE_REGISTRY.map(
(type) => `${type.id}=${type.typename} (${type.label})`
).join(', ');
/** Shared hint for `settings` on create/add/update question tool schemas. */
export const QUESTION_SETTINGS_SCHEMA_HINT =
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean } with exactly two options labeled True and False (factual statements). THUMBS: open-ended sentiment/poll — no correct answer; omit settings or use {}. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.';
/** Shared hint for `options` on create/add question tool schemas. */
export const QUESTION_OPTIONS_SCHEMA_HINT =
'Answer options. RADIO and CHECKBOX must have at least 4 plausible options. TRUE_FALSE: exactly 2 options labeled True and False; teacher sets correct answer in settings.correctValue. THUMBS: exactly 2 options (default Yes/No labels, customizable); no correct answer — not auto-graded. Omit or leave empty for NUMERIC, STAR, and WORD_BANK.';
/** Tool-level description fragment for update_questions (dashboard agent + legacy schemas). */
export const UPDATE_QUESTIONS_BINARY_TYPES_HINT =
'For TRUE_FALSE use settings.correctValue (boolean) with exactly two options labeled True and False. For THUMBS use two customizable options (default Yes/No) with no correct answer — open-ended, not auto-graded.';
/** Tool-level description for add_questions. */
export const ADD_QUESTIONS_TOOL_DESCRIPTION =
'Add questions to an existing exercise in this course. When get_exercise_details lists in-exercise sections, pass exerciseSectionId so new questions are added to the correct block. For THUMBS (14), use two options (default Yes/No labels, customizable) with no correct answer — open-ended sentiment/poll, not auto-graded. For TRUE_FALSE (4), use True/False labels and settings.correctValue.';
/** Full question-type id guide for MCP exercise tool descriptions. */
export function formatEnabledQuestionTypesGuide(): string {
const entries = ENABLED_QUESTION_TYPE_REGISTRY.map((type) => `${type.id}=${type.typename} (${type.label})`).join(
', '
);
return `Supported questionTypeId values: ${entries}. TRUE_FALSE (4) is auto-graded with settings.correctValue (boolean) and True/False labels for factual statements. THUMBS (14) is open-ended (no correct answer, not auto-graded): two customizable Yes/No-style options with thumbs icons for sentiment or poll-style questions. MATCHING and HOTSPOT are disabled and must not be used.`;
}
/** Markdown bullet list of enabled question types (for docs). */
export function formatEnabledQuestionTypesMarkdownList(): string {
return ENABLED_QUESTION_TYPE_REGISTRY.map((type) => `- \`${type.id}\` \`${type.typename}\` - ${type.label}`).join(
'\n'
);
}
@@ -5,6 +5,7 @@ import type {
RadioAnswerData,
CheckboxAnswerData,
TrueFalseAnswerData,
ThumbsAnswerData,
TextareaAnswerData,
ShortAnswerData,
NumericAnswerData,
@@ -86,6 +87,19 @@ const TRUE_FALSE_CODEC: AnswerCodec<TrueFalseAnswerData> = {
}
};
const THUMBS_CODEC: AnswerCodec<ThumbsAnswerData> = {
type: 'THUMBS',
toApiPayload(data, questionId) {
return { questionId, answer: String(data.value) };
},
fromApiPayload(payload) {
if (payload.answer === undefined) return null;
const resolved = labelToBoolean(payload.answer);
if (resolved === undefined) return null;
return { type: 'THUMBS', value: resolved };
}
};
const TEXTAREA_CODEC: AnswerCodec<TextareaAnswerData> = {
type: 'TEXTAREA',
toApiPayload(data, questionId) {
@@ -308,6 +322,7 @@ export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
[QUESTION_TYPE_KEY.RADIO]: RADIO_CODEC,
[QUESTION_TYPE_KEY.CHECKBOX]: CHECKBOX_CODEC,
[QUESTION_TYPE_KEY.TRUE_FALSE]: TRUE_FALSE_CODEC,
[QUESTION_TYPE_KEY.THUMBS]: THUMBS_CODEC,
[QUESTION_TYPE_KEY.TEXTAREA]: TEXTAREA_CODEC,
[QUESTION_TYPE_KEY.SHORT_ANSWER]: SHORT_ANSWER_CODEC,
[QUESTION_TYPE_KEY.NUMERIC]: NUMERIC_CODEC,
@@ -357,6 +372,7 @@ export function extractAnswerDisplayValues(data: AnswerData): {
selectedValues: data.optionIds
};
case 'TRUE_FALSE':
case 'THUMBS':
return {
selectedIds: [String(data.value)],
selectedValues: [data.value]
@@ -1,6 +1,7 @@
export type RadioAnswerData = { type: 'RADIO'; optionId: number };
export type CheckboxAnswerData = { type: 'CHECKBOX'; optionIds: number[] };
export type TrueFalseAnswerData = { type: 'TRUE_FALSE'; value: boolean };
export type ThumbsAnswerData = { type: 'THUMBS'; value: boolean };
export type TextareaAnswerData = { type: 'TEXTAREA'; text: string };
export type ShortAnswerData = { type: 'SHORT_ANSWER'; text: string };
export type NumericAnswerData = { type: 'NUMERIC'; value: number };
@@ -37,6 +38,7 @@ export type AnswerData =
| RadioAnswerData
| CheckboxAnswerData
| TrueFalseAnswerData
| ThumbsAnswerData
| TextareaAnswerData
| ShortAnswerData
| NumericAnswerData
@@ -49,6 +49,11 @@ export type ExerciseQuestionLabelKey =
| 'true_false.false_label'
| 'true_false.edit.correct_answer_label'
| 'true_false.preview.correct_value_label'
| 'thumbs.yes_label'
| 'thumbs.no_label'
| 'thumbs.edit.positive_option_label'
| 'thumbs.edit.negative_option_label'
| 'thumbs.preview.helper'
| 'short_answer.edit.instructions_placeholder'
| 'short_answer.take.placeholder'
| 'short_answer.preview.helper'
+2
View File
@@ -19,3 +19,5 @@ export * from './textarea-validation';
export * from './star-rating-settings';
export * from './video-recording-settings';
export * from './true-false-answer-key';
export * from './thumbs-question';
export * from './agent-question-type-guide';
@@ -5,6 +5,7 @@ export type QuestionAnswerPayload =
| CheckboxAnswerPayload
| TextareaAnswerPayload
| TrueFalseAnswerPayload
| ThumbsAnswerPayload
| ShortAnswerPayload
| NumericAnswerPayload
| FillBlankAnswerPayload
@@ -41,6 +42,11 @@ export interface TrueFalseAnswerPayload extends BaseAnswerPayload {
value: boolean;
}
export interface ThumbsAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.THUMBS;
value: boolean;
}
export interface ShortAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.SHORT_ANSWER;
text: string;
@@ -13,7 +13,8 @@ export const QUESTION_TYPE_KEY = {
LINK: 'LINK',
WORD_BANK: 'WORD_BANK',
STAR: 'STAR',
VIDEO_RECORDING: 'VIDEO_RECORDING'
VIDEO_RECORDING: 'VIDEO_RECORDING',
THUMBS: 'THUMBS'
} as const;
export type QuestionTypeKey = (typeof QUESTION_TYPE_KEY)[keyof typeof QUESTION_TYPE_KEY];
@@ -119,6 +119,15 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
supportsPartialCredit: false,
manualGradingRequired: true
},
{
key: QUESTION_TYPE_KEY.THUMBS,
typename: 'THUMBS',
label: 'Thumbs up/down',
id: 14,
autoGradable: false,
supportsPartialCredit: false,
manualGradingRequired: false
},
// Built but not offered in the product. Kept at the END of the id sequence
// (append-only) so they never shift the ids of types that exist in the DB.
// They have no `question_type` row, so `disabled` keeps them out of pickers,
@@ -127,7 +136,7 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
key: QUESTION_TYPE_KEY.MATCHING,
typename: 'MATCHING',
label: 'Matching',
id: 14,
id: 15,
autoGradable: true,
supportsPartialCredit: true,
manualGradingRequired: false,
@@ -137,7 +146,7 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
key: QUESTION_TYPE_KEY.HOTSPOT,
typename: 'HOTSPOT',
label: 'Hotspot',
id: 15,
id: 16,
autoGradable: true,
supportsPartialCredit: true,
manualGradingRequired: false,
@@ -0,0 +1,23 @@
import type { ExerciseQuestionOption } from './exercise-types';
type ThumbsQuestionShape = {
settings?: Record<string, unknown>;
options?: ExerciseQuestionOption[];
};
/** Strips answer-key fields from THUMBS questions (open-ended — no correct answer). */
export function normalizeThumbsQuestion<T extends ThumbsQuestionShape>(question: T): T {
const settings = { ...(question.settings ?? {}) };
delete settings.correctValue;
const options = (question.options ?? []).map((option) => ({
...option,
isCorrect: false
}));
return {
...question,
settings,
options
};
}
@@ -13,12 +13,12 @@ function normalizeBooleanText(value: unknown): string {
export function getTrueFalseOptionValue(option: TrueFalseAnswerOption): boolean | undefined {
const normalizedValue = normalizeBooleanText(option.value);
if (normalizedValue === 'true' || normalizedValue === '1') return true;
if (normalizedValue === 'false' || normalizedValue === '0') return false;
if (normalizedValue === 'true' || normalizedValue === '1' || normalizedValue === 'yes') return true;
if (normalizedValue === 'false' || normalizedValue === '0' || normalizedValue === 'no') return false;
const normalizedLabel = normalizeBooleanText(option.label);
if (normalizedLabel === 'true' || normalizedLabel === '1') return true;
if (normalizedLabel === 'false' || normalizedLabel === '0') return false;
if (normalizedLabel === 'true' || normalizedLabel === '1' || normalizedLabel === 'yes') return true;
if (normalizedLabel === 'false' || normalizedLabel === '0' || normalizedLabel === 'no') return false;
return undefined;
}