diff --git a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte index b53d8453..227de99f 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte @@ -200,6 +200,14 @@ ]; } + function makeThumbsOptions() { + const ts = Date.now(); + return [ + { id: `${ts}-yes-form`, label: 'Yes', value: 'true', isCorrect: false }, + { id: `${ts}-no-form`, label: 'No', value: 'false', isCorrect: false } + ]; + } + function onQuestionTypeChange(questionId: string | number, value: string) { if (!value) return; @@ -218,6 +226,9 @@ const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true; nextSettings = { ...nextSettings, correctValue }; nextOptions = makeTrueFalseOptions(correctValue); + } else if (nextQuestionTypeKey === QUESTION_TYPE_KEY.THUMBS) { + nextSettings = { ...nextSettings }; + nextOptions = makeThumbsOptions(); } else if (questionTypeSupportsOptions(nextQuestionTypeKey)) { const hasActiveOptions = nextOptions.some((option) => !option.deletedAt); if (!hasActiveOptions) { @@ -276,7 +287,8 @@ let nextSettings = nextQuestion.settings ?? {}; let nextOptions = mappedOptions; - if (getQuestionTypeKey(current) === QUESTION_TYPE_KEY.TRUE_FALSE) { + const currentQuestionTypeKey = getQuestionTypeKey(current); + if (currentQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) { const correctValue = resolveTrueFalseCorrectValue(nextSettings, nextOptions); nextSettings = { ...nextSettings, correctValue }; nextOptions = syncTrueFalseOptions(nextOptions, correctValue); @@ -411,15 +423,16 @@ {/snippet} {#snippet questionEditor(question, index)} + {@const questionStoreIndex = $questionnaire.questions.findIndex((item) => item.id === question.id)} { @@ -444,6 +457,7 @@
{ clickEvent.preventDefault(); clickEvent.stopPropagation(); @@ -47,8 +47,8 @@ }} title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')} > - {getExerciseEditorQuestionTypeLabel(typeEntry)} + {/snippet} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts b/apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts index 83d402bc..2f64e998 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts +++ b/apps/dashboard/src/lib/features/course/components/exercise/question-type-utils.ts @@ -24,7 +24,8 @@ export function getExerciseEditorQuestionTypeLabel( const OPTION_QUESTION_TYPE_KEYS = new Set([ QUESTION_TYPE_KEY.RADIO, QUESTION_TYPE_KEY.CHECKBOX, - QUESTION_TYPE_KEY.TRUE_FALSE + QUESTION_TYPE_KEY.TRUE_FALSE, + QUESTION_TYPE_KEY.THUMBS ]); export function getQuestionTypeId(question: Partial): number { diff --git a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte index 3816c9d7..559a952d 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte @@ -405,6 +405,7 @@ switch (answerValue.type) { case 'RADIO': case 'TRUE_FALSE': + case 'THUMBS': case 'NUMERIC': case 'STAR': return true; diff --git a/apps/dashboard/src/lib/features/course/pages/exercise.svelte b/apps/dashboard/src/lib/features/course/pages/exercise.svelte index 8b0ead78..339c06d9 100644 --- a/apps/dashboard/src/lib/features/course/pages/exercise.svelte +++ b/apps/dashboard/src/lib/features/course/pages/exercise.svelte @@ -62,7 +62,7 @@ import * as Alert from '@cio/ui/base/alert'; import * as RadioGroup from '@cio/ui/base/radio-group'; import InfoIcon from '@lucide/svelte/icons/info'; - import { isAutoGradableQuestionType } from '@cio/question-types'; + import { isAutoGradableQuestionType, isAutoGradableQuestionTypeId } from '@cio/question-types'; import * as Dialog from '@cio/ui/base/dialog'; import type { ExerciseSectionState } from '$features/course/components/exercise/store'; import { @@ -368,18 +368,23 @@ }); if (!zodResult.success || Object.keys(sectionValidationErrors).length > 0) { - const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error) : {}; + const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error, 'exercise') : {}; const questionErrors = mapZodErrorsToQuestionErrors(zodErrors, $questionnaire.questions); questionnaireValidation.set({ ...questionErrors, ...sectionValidationErrors }); - snackbar.error('Please fix all validation errors before saving'); + snackbar.error('snackbar.exercise.validation_errors'); return; } if (requiresPositivePointsForAutoGrade) { - const zeroPointQuestions = ($questionnaire.questions ?? []).filter((q) => !q.deletedAt && Number(q.points) === 0); + const zeroPointQuestions = ($questionnaire.questions ?? []).filter( + (question) => + !question.deletedAt && + Number(question.points) === 0 && + isAutoGradableQuestionTypeId(getQuestionTypeId(question)) + ); if (zeroPointQuestions.length > 0) { const pointErrors: Record = {}; @@ -430,7 +435,7 @@ // Check if there are validation errors from the API if (Object.keys(exerciseApi.errors || {}).length > 0) { console.log('Validation errors from API:', exerciseApi.errors); - snackbar.error('Please fix all validation errors before saving'); + snackbar.error('snackbar.exercise.validation_errors'); isSaving = false; return; } diff --git a/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts b/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts index 8097caff..a7b6f201 100644 --- a/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts +++ b/apps/dashboard/src/lib/features/course/utils/exercise-page-utils.ts @@ -1,10 +1,15 @@ import type { ExerciseSectionAfterBehavior, ExerciseSectionColorTheme } from '@cio/question-types'; +import { QUESTION_TYPE_KEY, normalizeThumbsQuestion } from '@cio/question-types'; import { clearQuestionnaireValidation, questionnaire, questionnaireMetaData } from '$features/course/components/exercise/store'; -import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils'; +import { + getQuestionTypeId, + getQuestionTypeKey, + getQuestionTypeOptionById +} from '$features/course/components/exercise/question-type-utils'; import type { Exercise } from '$features/course/utils/types'; import type { ExerciseSectionState } from '$features/course/components/exercise/store'; @@ -34,13 +39,27 @@ export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) if (exerciseQuestions.length > 0) { const mappedQuestions: Question[] = exerciseQuestions.map((question) => { const questionType = getQuestionTypeOptionById(getQuestionTypeId(question)); - - return { + const baseQuestion = { ...question, exerciseSectionId: question.exerciseSectionId ?? null, questionTypeId: questionType.id, questionType }; + + if (getQuestionTypeKey(baseQuestion) !== QUESTION_TYPE_KEY.THUMBS) { + return baseQuestion; + } + + const normalized = normalizeThumbsQuestion({ + settings: (baseQuestion as Question & { settings?: Record }).settings, + options: baseQuestion.options + }); + + return { + ...baseQuestion, + settings: normalized.settings, + options: normalized.options + }; }); if (sections.length > 0) { diff --git a/apps/dashboard/src/lib/features/ui/question/constants.ts b/apps/dashboard/src/lib/features/ui/question/constants.ts index 94b68da3..bf6a4af8 100644 --- a/apps/dashboard/src/lib/features/ui/question/constants.ts +++ b/apps/dashboard/src/lib/features/ui/question/constants.ts @@ -7,6 +7,7 @@ const QUESTION_TYPE_LABEL_KEYS: Partial> = { [QUESTION_TYPE_KEY.CHECKBOX]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.multiple', [QUESTION_TYPE_KEY.TEXTAREA]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.paragraph', [QUESTION_TYPE_KEY.TRUE_FALSE]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.true_false', + [QUESTION_TYPE_KEY.THUMBS]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.thumbs', [QUESTION_TYPE_KEY.SHORT_ANSWER]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.short_answer', [QUESTION_TYPE_KEY.NUMERIC]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.numeric', diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index c68e694a..d264cd64 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -321,6 +321,7 @@ "creating_new": "Oprettelse af nye oversættelser mislykkedes" }, "exercise": { + "validation_errors": "Ret alle valideringsfejl, før du gemmer.", "points_required_auto_grade": "Angiv en positiv pointværdi for hvert spørgsmål, før du gemmer.", "success": "Gemt", "submission_updated": "Indsendelse opdateret til", @@ -1517,6 +1518,7 @@ "multiple": "Flere svar", "paragraph": "Afsnit", "true_false": "Sandt/falsk", + "thumbs": "Tommel op/ned", "short_answer": "Kort svar", "numeric": "Numerisk svar", "fill_blank": "Udfyld det tomme felt", @@ -1705,6 +1707,17 @@ "correct_answer_label": "Korrekt svar" } }, + "thumbs": { + "yes_label": "Ja", + "no_label": "Nej", + "edit": { + "positive_option_label": "Tommel op-etiket", + "negative_option_label": "Tommel ned-etiket" + }, + "preview": { + "helper": "Åben — elever vælger tommel op eller ned." + } + }, "checkbox": { "edit": { "helper": "Marker en eller flere muligheder som korrekte.", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 23f9bf3a..b154b3d0 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -321,6 +321,7 @@ "creating_new": "Das Erstellen neuer Übersetzungen ist fehlgeschlagen" }, "exercise": { + "validation_errors": "Bitte beheben Sie alle Validierungsfehler, bevor Sie speichern.", "points_required_auto_grade": "Legen Sie für jede Frage einen positiven Punktwert fest, bevor Sie speichern.", "success": "Erfolgreich gespeichert", "submission_updated": "Einreichung aktualisiert auf", @@ -1517,6 +1518,7 @@ "multiple": "Mehrere Antworten", "paragraph": "Absatz", "true_false": "Richtig/Falsch", + "thumbs": "Daumen hoch/runter", "short_answer": "Kurze Antwort", "numeric": "Numerische Antwort", "fill_blank": "Füllen Sie die Lücke aus", @@ -1705,6 +1707,17 @@ "correct_answer_label": "Richtige Antwort" } }, + "thumbs": { + "yes_label": "Ja", + "no_label": "Nein", + "edit": { + "positive_option_label": "Daumen-hoch-Label", + "negative_option_label": "Daumen-runter-Label" + }, + "preview": { + "helper": "Offen — Lernende wählen Daumen hoch oder runter." + } + }, "checkbox": { "edit": { "helper": "Markieren Sie eine oder mehrere Optionen als richtig.", diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index b50c035d..73e1e574 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -2627,6 +2627,7 @@ "multiple": "Multiple answers", "paragraph": "Paragraph", "true_false": "True/False", + "thumbs": "Thumbs up/down", "short_answer": "Short answer", "numeric": "Numeric answer", "fill_blank": "Fill in the blank", @@ -2772,6 +2773,17 @@ "correct_value_label": "Correct value" } }, + "thumbs": { + "yes_label": "Yes", + "no_label": "No", + "edit": { + "positive_option_label": "Thumbs up label", + "negative_option_label": "Thumbs down label" + }, + "preview": { + "helper": "Open-ended — students choose thumbs up or down." + } + }, "short_answer": { "edit": { "instructions_placeholder": "Optional instructions" @@ -4181,6 +4193,7 @@ } }, "exercise": { + "validation_errors": "Please fix all validation errors before saving.", "points_required_auto_grade": "Set a positive point value for every question before saving.", "passed_requires_points": "Passed completion needs at least one point available.", "success": "Saved Successfully", diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 4967d08c..3918483f 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -321,6 +321,7 @@ "creating_new": "Error al crear nuevas traducciones" }, "exercise": { + "validation_errors": "Corrija todos los errores de validación antes de guardar.", "points_required_auto_grade": "Asigne un valor de puntos positivo a cada pregunta antes de guardar.", "success": "Guardado exitosamente", "submission_updated": "Envío actualizado a", @@ -1517,6 +1518,7 @@ "multiple": "Múltiples respuestas", "paragraph": "Párrafo", "true_false": "Verdadero/Falso", + "thumbs": "Pulgares arriba/abajo", "short_answer": "respuesta corta", "numeric": "respuesta numérica", "fill_blank": "Completa el espacio en blanco", @@ -1705,6 +1707,17 @@ "correct_answer_label": "respuesta correcta" } }, + "thumbs": { + "yes_label": "Sí", + "no_label": "No", + "edit": { + "positive_option_label": "Etiqueta de pulgar arriba", + "negative_option_label": "Etiqueta de pulgar abajo" + }, + "preview": { + "helper": "Abierta — los estudiantes eligen pulgar arriba o abajo." + } + }, "checkbox": { "edit": { "helper": "Marque una o más opciones como correctas.", diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 5a02b60f..9ac1280c 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -321,6 +321,7 @@ "creating_new": "La création de nouvelles traductions a échoué" }, "exercise": { + "validation_errors": "Veuillez corriger toutes les erreurs de validation avant d'enregistrer.", "points_required_auto_grade": "Définissez un nombre de points positif pour chaque question avant d'enregistrer.", "success": "Enregistré avec succès", "submission_updated": "Soumission mise à jour pour", @@ -1517,6 +1518,7 @@ "multiple": "Plusieurs réponses", "paragraph": "Paragraphe", "true_false": "Vrai/Faux", + "thumbs": "Pouce vers le haut/bas", "short_answer": "Réponse courte", "numeric": "Réponse numérique", "fill_blank": "Remplissez le vide", @@ -1705,6 +1707,17 @@ "correct_answer_label": "Bonne réponse" } }, + "thumbs": { + "yes_label": "Oui", + "no_label": "Non", + "edit": { + "positive_option_label": "Libellé pouce vers le haut", + "negative_option_label": "Libellé pouce vers le bas" + }, + "preview": { + "helper": "Ouverte — les apprenants choisissent pouce vers le haut ou vers le bas." + } + }, "checkbox": { "edit": { "helper": "Marquez une ou plusieurs options comme correctes.", diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 5cb3aee9..68630aec 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -321,6 +321,7 @@ "creating_new": "नए अनुवाद बनाना विफल रहा" }, "exercise": { + "validation_errors": "सहेजने से पहले सभी सत्यापन त्रुटियों को ठीक करें।", "points_required_auto_grade": "सहेजने से पहले प्रत्येक प्रश्न के लिए धनात्मक अंक निर्धारित करें।", "success": "सफलतापूर्वक सहेजा गया", "submission_updated": "सबमिशन को अद्यतन किया गया", @@ -1517,6 +1518,7 @@ "multiple": "एकाधिक उत्तर", "paragraph": "अनुच्छेद", "true_false": "सत्य/असत्य", + "thumbs": "अंगूठा ऊपर/नीचे", "short_answer": "संक्षिप्त उत्तर", "numeric": "संख्यात्मक उत्तर", "fill_blank": "रिक्त स्थान भरें", @@ -1705,6 +1707,17 @@ "correct_answer_label": "सही उत्तर" } }, + "thumbs": { + "yes_label": "हाँ", + "no_label": "नहीं", + "edit": { + "positive_option_label": "अंगूठा ऊपर लेबल", + "negative_option_label": "अंगूठा नीचे लेबल" + }, + "preview": { + "helper": "खुला प्रश्न — छात्र अंगूठा ऊपर या नीचे चुनते हैं।" + } + }, "checkbox": { "edit": { "helper": "एक या अधिक विकल्पों को सही के रूप में चिह्नित करें.", diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 562d3af6..7bc31618 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -321,6 +321,7 @@ "creating_new": "Tworzenie nowych tłumaczeń nie powiodło się" }, "exercise": { + "validation_errors": "Popraw wszystkie błędy walidacji przed zapisaniem.", "points_required_auto_grade": "Ustaw dodatnią liczbę punktów dla każdego pytania przed zapisaniem.", "success": "Zapisano pomyślnie", "submission_updated": "Zgłoszenie zaktualizowane do", @@ -1517,6 +1518,7 @@ "multiple": "Wiele odpowiedzi", "paragraph": "Akapit", "true_false": "Prawda/Fałsz", + "thumbs": "Kciuk w górę/w dół", "short_answer": "Krótka odpowiedź", "numeric": "Odpowiedź numeryczna", "fill_blank": "Wypełnij puste miejsce", @@ -1705,6 +1707,17 @@ "correct_answer_label": "Prawidłowa odpowiedź" } }, + "thumbs": { + "yes_label": "Tak", + "no_label": "Nie", + "edit": { + "positive_option_label": "Etykieta kciuka w górę", + "negative_option_label": "Etykieta kciuka w dół" + }, + "preview": { + "helper": "Otwarte — uczniowie wybierają kciuk w górę lub w dół." + } + }, "checkbox": { "edit": { "helper": "Zaznacz jedną lub więcej opcji jako poprawną.", diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index d4173933..de3b54dc 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -321,6 +321,7 @@ "creating_new": "Falha ao criar novas traduções" }, "exercise": { + "validation_errors": "Corrija todos os erros de validação antes de salvar.", "points_required_auto_grade": "Defina um valor de pontos positivo para cada pergunta antes de salvar.", "success": "Salvo com sucesso", "submission_updated": "Envio atualizado para", @@ -1517,6 +1518,7 @@ "multiple": "Múltiplas respostas", "paragraph": "Parágrafo", "true_false": "Verdadeiro/Falso", + "thumbs": "Polegar para cima/baixo", "short_answer": "Resposta curta", "numeric": "Resposta numérica", "fill_blank": "Preencha o espaço em branco", @@ -1705,6 +1707,17 @@ "correct_answer_label": "Resposta correta" } }, + "thumbs": { + "yes_label": "Sim", + "no_label": "Não", + "edit": { + "positive_option_label": "Rótulo de polegar para cima", + "negative_option_label": "Rótulo de polegar para baixo" + }, + "preview": { + "helper": "Aberta — os alunos escolhem polegar para cima ou para baixo." + } + }, "checkbox": { "edit": { "helper": "Marque uma ou mais opções como corretas.", diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 4404a0e5..f3903a9e 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -321,6 +321,7 @@ "creating_new": "Не удалось создать новые переводы." }, "exercise": { + "validation_errors": "Исправьте все ошибки проверки перед сохранением.", "points_required_auto_grade": "Установите положительное количество баллов для каждого вопроса перед сохранением.", "success": "Сохранено успешно", "submission_updated": "Представление обновлено до", @@ -1517,6 +1518,7 @@ "multiple": "Несколько ответов", "paragraph": "Параграф", "true_false": "Верно/Неверно", + "thumbs": "Большой палец вверх/вниз", "short_answer": "Короткий ответ", "numeric": "Числовой ответ", "fill_blank": "Заполните пробел", @@ -1705,6 +1707,17 @@ "correct_answer_label": "Правильный ответ" } }, + "thumbs": { + "yes_label": "Да", + "no_label": "Нет", + "edit": { + "positive_option_label": "Подпись «палец вверх»", + "negative_option_label": "Подпись «палец вниз»" + }, + "preview": { + "helper": "Открытый вопрос — студенты выбирают палец вверх или вниз." + } + }, "checkbox": { "edit": { "helper": "Отметьте один или несколько вариантов как правильные.", diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 52984065..93634665 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -321,6 +321,7 @@ "creating_new": "Tạo bản dịch mới không thành công" }, "exercise": { + "validation_errors": "Vui lòng sửa tất cả lỗi xác thực trước khi lưu.", "points_required_auto_grade": "Đặt điểm dương cho mỗi câu hỏi trước khi lưu.", "success": "Đã lưu thành công", "submission_updated": "Đã cập nhật bài nộp vào", @@ -1517,6 +1518,7 @@ "multiple": "Nhiều câu trả lời", "paragraph": "Đoạn", "true_false": "Đúng/Sai", + "thumbs": "Thích/Không thích", "short_answer": "Câu trả lời ngắn", "numeric": "Câu trả lời bằng số", "fill_blank": "Điền vào chỗ trống", @@ -1705,6 +1707,17 @@ "correct_answer_label": "Câu trả lời đúng" } }, + "thumbs": { + "yes_label": "Có", + "no_label": "Không", + "edit": { + "positive_option_label": "Nhãn thích", + "negative_option_label": "Nhãn không thích" + }, + "preview": { + "helper": "Mở — học viên chọn thích hoặc không thích." + } + }, "checkbox": { "edit": { "helper": "Đánh dấu một hoặc nhiều lựa chọn là đúng.", diff --git a/apps/docs/content/docs/mcp.mdx b/apps/docs/content/docs/mcp.mdx index bd24378e..8243ca48 100644 --- a/apps/docs/content/docs/mcp.mdx +++ b/apps/docs/content/docs/mcp.mdx @@ -444,15 +444,19 @@ Supported `questionTypeId` values for exercise payloads: - `1` `RADIO` - Single answer - `2` `CHECKBOX` - Multiple answers - `3` `TEXTAREA` - Paragraph -- `4` `TRUE_FALSE` - True/False +- `4` `TRUE_FALSE` - True/False (factual statements; options: True, False; auto-graded) - `5` `SHORT_ANSWER` - Short answer - `6` `NUMERIC` - Numeric answer - `7` `FILL_BLANK` - Fill in the blank - `8` `FILE_UPLOAD` - File upload -- `9` `MATCHING` - Matching -- `10` `ORDERING` - Ordering -- `11` `HOTSPOT` - Hotspot -- `12` `LINK` - Links +- `9` `ORDERING` - Ordering +- `10` `LINK` - Links +- `11` `WORD_BANK` - Word bank +- `12` `STAR` - Star rating +- `13` `VIDEO_RECORDING` - Video recording +- `14` `THUMBS` - Thumbs up/down (open-ended poll with customizable Yes/No labels; no correct answer) + +`TRUE_FALSE` is auto-graded with `settings.correctValue` (boolean) and fixed True/False labels for factual statements. `THUMBS` is open-ended (not auto-graded): two customizable Yes/No-style options with thumbs icons for sentiment or poll-style questions — do not set `settings.correctValue`. `MATCHING` and `HOTSPOT` are disabled and must not be used. ## Operational Notes diff --git a/package.json b/package.json index 9b704f82..19089bc0 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "website:dev": "pnpm --filter=@cio/website run dev", "embeds:dev": "pnpm --filter=@cio/embeds --filter=@cio/ui run dev", "storybook:dev": "pnpm --filter=@cio/storybook --filter=@cio/ui --filter=@cio/question-types run dev", - "mcp:build": "pnpm --filter=@classroomio/mcp run build", + "mcp:build": "pnpm --filter @cio/question-types build && pnpm --filter @cio/utils build && pnpm --filter @classroomio/mcp build", "db:push": "pnpm --filter @cio/db db push" }, "license": "MIT", diff --git a/packages/ai-assistant/src/prompt/teacher.ts b/packages/ai-assistant/src/prompt/teacher.ts index de213d3f..b0403fb8 100644 --- a/packages/ai-assistant/src/prompt/teacher.ts +++ b/packages/ai-assistant/src/prompt/teacher.ts @@ -59,7 +59,8 @@ Every question you pass to \`create_exercise\` or \`add_questions\` MUST include **Pick the type that matches the cognitive skill being tested, not the type that's easiest to write:** -- **TRUE_FALSE** — testing a single misconception. Cheap and high-signal when the statement targets a *common* wrong belief, not a trivia detail. +- **TRUE_FALSE** — testing a single factual misconception. Cheap and high-signal when the statement targets a *common* wrong belief, not a trivia detail. Options must be labeled **True** and **False**. Auto-graded against the teacher's chosen correct answer. +- **THUMBS** — open-ended sentiment/poll with **thumbs up/down icons** and **customizable option labels** (defaults: Yes / No). There is **no correct answer** and it is **not auto-graded**. Use for recommendations, agreement, "would you…?", or other poll-style questions. Do **not** use THUMBS for factual true/false statements — use TRUE_FALSE instead. - **SHORT_ANSWER** — recalling a specific term, command, value, or short phrase. Use when there's a small set of right answers you can list. - **NUMERIC** — anything quantitative: math, calculations, counts, sizes, rates. Default for any "how many / what value / calculate X" question. - **FILL_BLANK** — testing syntax, sequence completion, or code/sentence patterns where position matters ("\`SELECT \_\_ FROM users\`"). @@ -234,6 +235,7 @@ To change an existing question (its text, points, order, in-exercise section, co - RADIO, CHECKBOX: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option. The system automatically balances which position holds the correct answer across an exercise, so you don't need to vary it yourself — but DO keep the correct option from being an obvious tell: don't always make it the longest or most detailed choice, and avoid leaning on "all of the above"-style options. - TRUE_FALSE: on \`settings.correctValue\` as a boolean. Use \`true\` when True is correct and \`false\` when False is correct. Keep exactly two options labeled True and False; their \`isCorrect\` values are synchronized from \`settings.correctValue\`. +- THUMBS: open-ended — no correct answer, not auto-graded. Keep exactly two options (default labels Yes and No; teachers may customize labels). Do not set \`settings.correctValue\`. - NUMERIC: see the dedicated NUMERIC block below — \`settings.correctValue\` is required and \`settings.tolerance\` should almost always be set. \`options\` MUST be empty/absent. - STAR: on \`settings.correctValue\` (1..max stars). - WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`. @@ -349,7 +351,8 @@ When you create an exercise (especially during plan implementation), it must act ### Question writing - Each question must reference something specific the lesson taught (a concept, a worked example, a definition, a step in a procedure). Do not write questions whose answer cannot be derived from the lesson. - Prefer scenario / "what would happen if…" / "why does X work this way?" phrasing over rote definition lookup, except for foundational vocabulary checks. -- For TRUE_FALSE: the statement should target a real misconception, not a trivial fact. Use sparingly. +- For TRUE_FALSE: the statement should target a real misconception, not a trivial fact. Use sparingly. Options: True / False. Auto-graded. +- For THUMBS: use when Yes/No framing fits ("Would you recommend…?", "Do you agree…?"). Open-ended — no correct answer, not auto-graded. Labels default to Yes/No but may be customized. Use sparingly. - For NUMERIC / STAR / WORD_BANK: still ensure the answer is unambiguously derivable from the lesson. ### Per-exercise structure diff --git a/packages/ai-assistant/src/tools/teacher.ts b/packages/ai-assistant/src/tools/teacher.ts index 901f70fe..3edc147c 100644 --- a/packages/ai-assistant/src/tools/teacher.ts +++ b/packages/ai-assistant/src/tools/teacher.ts @@ -1,5 +1,11 @@ import { z } from 'zod'; -import { QUESTION_TYPE_REGISTRY } from '@cio/question-types'; +import { + ENABLED_QUESTION_TYPE_REGISTRY, + ENABLED_QUESTION_TYPE_ID_DESCRIPTION, + QUESTION_OPTIONS_SCHEMA_HINT, + QUESTION_SETTINGS_SCHEMA_HINT, + UPDATE_QUESTIONS_BINARY_TYPES_HINT +} from '@cio/question-types'; import { CoursePlanSchema } from '../types'; import { CourseTemplateIdSchema, TemplateFormFieldSchema } from '../templates'; import { @@ -8,7 +14,7 @@ import { LANDING_PAGE_SECTION_HTML_AGENT_HINT } from './landing-page-html-hint'; -const QUESTION_TYPE_ID_DESCRIPTION = QUESTION_TYPE_REGISTRY.map((t) => `${t.id}=${t.typename} (${t.label})`).join(', '); +const ENABLED_QUESTION_TYPE_MAX_ID = Math.max(...ENABLED_QUESTION_TYPE_REGISTRY.map((type) => type.id)); /** * Mutation tool schemas available only to teachers. @@ -132,18 +138,13 @@ export const createExerciseSchema = { .number() .int() .min(1) - .max(QUESTION_TYPE_REGISTRY.length) + .max(ENABLED_QUESTION_TYPE_MAX_ID) .describe( - `Required on every question — never omit. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}. Vary types across questions (do not default everything to RADIO/1).` + `Required on every question — never omit. Supported types: ${ENABLED_QUESTION_TYPE_ID_DESCRIPTION}. Vary types across questions (do not default everything to RADIO/1).` ), points: z.number().min(0).default(1).describe('Points for this question'), order: z.number().int().min(0).describe('Display order'), - settings: z - .record(z.string(), z.unknown()) - .optional() - .describe( - 'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.' - ), + settings: z.record(z.string(), z.unknown()).optional().describe(QUESTION_SETTINGS_SCHEMA_HINT), options: z .array( z.object({ @@ -151,9 +152,7 @@ export const createExerciseSchema = { isCorrect: z.boolean().describe('Whether this is a correct answer') }) ) - .describe( - 'Answer options. RADIO and CHECKBOX questions MUST have at least 4 options with plausible distractors (not obvious filler). RADIO needs exactly one correct option; CHECKBOX needs at least 2 correct and at least 1 incorrect. TRUE_FALSE uses exactly 2 options labeled True and False; set the correct answer in settings.correctValue. Leave empty for NUMERIC/STAR/WORD_BANK.' - ) + .describe(QUESTION_OPTIONS_SCHEMA_HINT) }) ) .min(1) @@ -250,7 +249,7 @@ export const updateExerciseSchema = { export const addQuestionsSchema = { description: - 'Add questions to an existing exercise. Use this when the exercise already exists and you want to add more questions. When get_exercise_details returns a non-empty sections array, pass exerciseSectionId so new questions are placed in the right in-exercise block (same ids as update_exercise_section).', + 'Add questions to an existing exercise. Use this when the exercise already exists and you want to add more questions. When get_exercise_details returns a non-empty sections array, pass exerciseSectionId so new questions are placed in the right in-exercise block (same ids as update_exercise_section). For THUMBS (14), use two options (default Yes/No labels, customizable) with no correct answer — open-ended, not auto-graded. For TRUE_FALSE (4), use True/False labels and settings.correctValue.', parameters: z.object({ courseId: z.string().describe('The course ID'), exerciseId: z.string().describe('The existing exercise ID'), @@ -268,32 +267,28 @@ export const addQuestionsSchema = { .number() .int() .min(1) - .max(QUESTION_TYPE_REGISTRY.length) + .max(ENABLED_QUESTION_TYPE_MAX_ID) .describe( - `Required on every question — never omit. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}. Vary types across questions (do not default everything to RADIO/1).` + `Required on every question — never omit. Supported types: ${ENABLED_QUESTION_TYPE_ID_DESCRIPTION}. Vary types across questions (do not default everything to RADIO/1).` ), points: z.number().min(0).default(1).describe('Points for this question'), order: z.number().int().min(0).describe('Display order'), - settings: z - .record(z.string(), z.unknown()) - .optional() - .describe( - 'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.' - ), - options: z.array( - z.object({ - label: z.string().min(1).describe('Option text'), - isCorrect: z.boolean().describe('Whether this is a correct answer') - }) - ) + settings: z.record(z.string(), z.unknown()).optional().describe(QUESTION_SETTINGS_SCHEMA_HINT), + options: z + .array( + z.object({ + label: z.string().min(1).describe('Option text'), + isCorrect: z.boolean().describe('Whether this is a correct answer') + }) + ) + .describe(QUESTION_OPTIONS_SCHEMA_HINT) }) ) }) }; export const updateQuestionsSchema = { - description: - 'Update existing questions in an exercise. Pass only the fields you want to change; `id` identifies the question. Optional `exerciseSectionId` (from get_exercise_details sections) moves the question to another in-exercise block; use null to clear section assignment. For TRUE_FALSE use `settings.correctValue` (boolean) and keep exactly two options labeled True and False. For NUMERIC the correct answer is `settings.correctValue` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue` (1..max). For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX use `options[].isCorrect`: include an option `id` to edit it, omit `id` to add a new one. `settings` is shallow-merged with the existing settings. Omit `options` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.', + description: `Update existing questions in an exercise. Pass only the fields you want to change; \`id\` identifies the question. Optional \`exerciseSectionId\` (from get_exercise_details sections) moves the question to another in-exercise block; use null to clear section assignment. ${UPDATE_QUESTIONS_BINARY_TYPES_HINT} For NUMERIC the correct answer is \`settings.correctValue\` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use \`settings.correctValue\` (1..max). For WORD_BANK use \`settings.correctAnswers\` and \`settings.template\`. RADIO/CHECKBOX use \`options[].isCorrect\`: include an option \`id\` to edit it, omit \`id\` to add a new one. \`settings\` is shallow-merged with the existing settings. Omit \`options\` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.`, parameters: z.object({ courseId: z.string().describe('The course ID'), exerciseId: z.string().describe('The existing exercise ID'), @@ -305,9 +300,9 @@ export const updateQuestionsSchema = { .number() .int() .min(1) - .max(QUESTION_TYPE_REGISTRY.length) + .max(ENABLED_QUESTION_TYPE_MAX_ID) .optional() - .describe(`Question type ID. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}`), + .describe(`Question type ID. Supported types: ${ENABLED_QUESTION_TYPE_ID_DESCRIPTION}`), points: z.number().min(0).optional(), order: z.number().int().min(0).optional(), exerciseSectionId: z @@ -321,9 +316,7 @@ export const updateQuestionsSchema = { settings: z .record(z.string(), z.unknown()) .optional() - .describe( - 'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }. Shallow-merged with existing settings.' - ), + .describe(`${QUESTION_SETTINGS_SCHEMA_HINT} Shallow-merged with existing settings.`), options: z .array( z.object({ diff --git a/packages/core/src/services/agent/agent-tool-schemas.ts b/packages/core/src/services/agent/agent-tool-schemas.ts index dcae7789..9833e32d 100644 --- a/packages/core/src/services/agent/agent-tool-schemas.ts +++ b/packages/core/src/services/agent/agent-tool-schemas.ts @@ -5,7 +5,12 @@ import { LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT, LANDING_PAGE_SECTION_HTML_AGENT_HINT } from '@cio/ai-assistant/tools'; -import { ENABLED_QUESTION_TYPE_REGISTRY } from '@cio/question-types'; +import { + ADD_QUESTIONS_TOOL_DESCRIPTION, + ENABLED_QUESTION_TYPE_REGISTRY, + QUESTION_SETTINGS_SCHEMA_HINT, + UPDATE_QUESTIONS_BINARY_TYPES_HINT +} from '@cio/question-types'; import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise'; import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdateFields } from '@cio/utils/validation/course'; @@ -83,12 +88,7 @@ export const questionSchema = z.object({ ), points: z.number().min(0).default(1), order: z.number().int().min(0), - settings: z - .record(z.string(), z.unknown()) - .optional() - .describe( - 'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.' - ), + settings: z.record(z.string(), z.unknown()).optional().describe(QUESTION_SETTINGS_SCHEMA_HINT), options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() })) }); @@ -211,9 +211,7 @@ export const updateQuestionSettingsSchema = z .refine((settings) => Object.keys(settings).length > 0, { message: 'Provide at least one settings field' }) - .describe( - 'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.' - ); + .describe(QUESTION_SETTINGS_SCHEMA_HINT); export const updateOptionSchema = z.object({ id: z.number().int().optional(), diff --git a/packages/core/src/services/agent/chat-tools.ts b/packages/core/src/services/agent/chat-tools.ts index 788da95a..6ae67708 100644 --- a/packages/core/src/services/agent/chat-tools.ts +++ b/packages/core/src/services/agent/chat-tools.ts @@ -2,7 +2,13 @@ import { createHash } from 'node:crypto'; import { tool } from 'ai'; import { CoursePlanSchema, getCourseTemplate } from '@cio/ai-assistant'; -import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPE_ID_TO_KEY, QUESTION_TYPE_REGISTRY } from '@cio/question-types'; +import { + ADD_QUESTIONS_TOOL_DESCRIPTION, + PREMIUM_QUESTION_TYPE_KEYS, + QUESTION_TYPE_ID_TO_KEY, + QUESTION_TYPE_REGISTRY, + UPDATE_QUESTIONS_BINARY_TYPES_HINT +} from '@cio/question-types'; import { AppError } from '@cio/utils/errors'; import { trackAgentEvent, AgentEvent } from '../../utils/tinybird'; import { @@ -781,8 +787,7 @@ export function buildAgentTools( }), 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.', + description: ADD_QUESTIONS_TOOL_DESCRIPTION, inputSchema: addQuestionsParam, execute: async (args) => { return executeAgentTool('add_questions', { orgId, userId, courseId, args }, async () => { @@ -847,8 +852,7 @@ export function buildAgentTools( }), update_questions: tool({ - description: - 'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For TRUE_FALSE use `settings.correctValue` (boolean) and keep exactly two options labeled True and False. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.', + description: `Update existing questions in an exercise. Pass only fields you want to change; \`id\` identifies the question. Optional \`exerciseSectionId\` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. ${UPDATE_QUESTIONS_BINARY_TYPES_HINT} For NUMERIC, the correct answer is \`settings.correctValue\` (number) — do NOT add options to NUMERIC questions. For STAR use \`settings.correctValue\`. For WORD_BANK use \`settings.correctAnswers\` and \`settings.template\`. RADIO/CHECKBOX use \`options[].isCorrect\` (include option \`id\` to edit, omit \`id\` to add). \`settings\` is shallow-merged with existing settings. Omit \`options\` entirely to leave existing options untouched.`, inputSchema: updateQuestionsParam, execute: async (args) => { return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => { diff --git a/packages/core/src/services/exercise/utils.ts b/packages/core/src/services/exercise/utils.ts index d01aeec5..1ed7a4ba 100644 --- a/packages/core/src/services/exercise/utils.ts +++ b/packages/core/src/services/exercise/utils.ts @@ -76,14 +76,14 @@ function areSettingsEqual(left: unknown, right: unknown): boolean { return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); } -const TRUE_FALSE_QUESTION_TYPE_ID = QUESTION_TYPE_IDS[QUESTION_TYPE_KEY.TRUE_FALSE]; +const BINARY_BOOLEAN_QUESTION_TYPE_IDS = new Set([QUESTION_TYPE_IDS[QUESTION_TYPE_KEY.TRUE_FALSE]]); -function normalizeTrueFalseQuestion( +function normalizeBinaryBooleanQuestion( question: ExerciseUpdateQuestion, currentQuestion?: CurrentQuestion ): ExerciseUpdateQuestion { const questionTypeId = question.questionTypeId ?? currentQuestion?.questionTypeId; - if (questionTypeId !== TRUE_FALSE_QUESTION_TYPE_ID) return question; + if (questionTypeId == null || !BINARY_BOOLEAN_QUESTION_TYPE_IDS.has(questionTypeId)) return question; const settings = normalizeSettings(question.settings ?? currentQuestion?.settings); const options = question.options ?? currentQuestion?.options ?? []; @@ -126,7 +126,7 @@ export function categorizeQuestions(questions: NonNullable }> = []; for (const rawQuestion of questions) { - const q = normalizeTrueFalseQuestion(rawQuestion); + const q = normalizeBinaryBooleanQuestion(rawQuestion); if (q.deletedAt && q.id) { deletedIds.push(q.id); @@ -263,7 +263,7 @@ export function computeExerciseDiff( ) { const questionMap = new Map(currentQuestions.map((q) => [q.id, q])); const normalizedIncomingQuestions = incomingQuestions.map((incoming) => - normalizeTrueFalseQuestion(incoming, incoming.id ? questionMap.get(incoming.id) : undefined) + normalizeBinaryBooleanQuestion(incoming, incoming.id ? questionMap.get(incoming.id) : undefined) ); const deletedQuestionIds: number[] = []; @@ -371,7 +371,7 @@ export async function createNewQuestionsWithOptions( newQuestions: NonNullable, txClient: DbOrTxClient ): Promise { - const normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question)); + const normalizedQuestions = newQuestions.map((question) => normalizeBinaryBooleanQuestion(question)); const createdQuestions = await createQuestions( normalizedQuestions.map((q) => ({ exerciseId, diff --git a/packages/db/src/queries/exercise/exercise.ts b/packages/db/src/queries/exercise/exercise.ts index ce20a692..87b0c322 100644 --- a/packages/db/src/queries/exercise/exercise.ts +++ b/packages/db/src/queries/exercise/exercise.ts @@ -545,3 +545,19 @@ export async function syncQuestionIdSequence(dbClient: DbOrTxClient = db): Promi ` ); } + +/** + * Syncs the question_type table's identity sequence to the current max(id). + * Call before inserting question types to avoid duplicate key on question_type_pkey when the sequence is behind. + */ +export async function syncQuestionTypeIdSequence(dbClient: DbOrTxClient = db): Promise { + await dbClient.execute( + sql` + SELECT setval( + pg_get_serial_sequence('question_type', 'id'), + COALESCE((SELECT MAX(id) FROM question_type), 1), + EXISTS(SELECT 1 FROM question_type) + ) + ` + ); +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 9f43c444..447cecdf 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1968,8 +1968,8 @@ export const questionAnswer = pgTable( /** * Exercise question kinds (`typename` matches `@cio/question-types` keys). Expected ids: * 1 RADIO, 2 CHECKBOX, 3 TEXTAREA, 4 TRUE_FALSE, 5 SHORT_ANSWER, 6 NUMERIC, 7 FILL_BLANK, - * 8 FILE_UPLOAD, 9 MATCHING, 10 ORDERING, 11 HOTSPOT, 12 LINK, 13 WORD_BANK, 14 STAR, - * 15 VIDEO_RECORDING. + * 8 FILE_UPLOAD, 9 ORDERING, 10 LINK, 11 WORD_BANK, 12 STAR, 13 VIDEO_RECORDING, 14 THUMBS. + * Disabled (no DB row): 15 MATCHING, 16 HOTSPOT. */ export const questionType = pgTable( 'question_type', diff --git a/packages/db/src/utils/seed/questionType.ts b/packages/db/src/utils/seed/questionType.ts index 695e01d9..289ebba3 100644 --- a/packages/db/src/utils/seed/questionType.ts +++ b/packages/db/src/utils/seed/questionType.ts @@ -1,4 +1,5 @@ import { db, questionType } from '@db/drizzle'; +import { syncQuestionTypeIdSequence } from '@db/queries/exercise'; export async function seedQuestionTypes() { const existingQuestionTypes = await db.select().from(questionType); @@ -82,10 +83,17 @@ export async function seedQuestionTypes() { typename: 'VIDEO_RECORDING', createdAt: '2026-04-30 00:00:00+00', updatedAt: '2026-04-30 00:00:00+00' + }, + { + label: 'Thumbs up/down', + typename: 'THUMBS', + createdAt: '2026-07-23 00:00:00+00', + updatedAt: '2026-07-23 00:00:00+00' } ].filter((q) => !questionTypeNames.includes(q.typename)); if (questionTypesToInsert.length > 0) { + await syncQuestionTypeIdSequence(); await db.insert(questionType).values(questionTypesToInsert); console.log(` ✓ Inserted ${questionTypesToInsert.length} question type(s)`); } else { diff --git a/packages/mcp/README.md b/packages/mcp/README.md index e7c4743b..c87121b8 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -430,15 +430,19 @@ Supported `questionTypeId` values for exercise payloads: - `1` `RADIO` - Single answer - `2` `CHECKBOX` - Multiple answers - `3` `TEXTAREA` - Paragraph -- `4` `TRUE_FALSE` - True/False +- `4` `TRUE_FALSE` - True/False (factual statements; options: True, False; auto-graded) - `5` `SHORT_ANSWER` - Short answer - `6` `NUMERIC` - Numeric answer - `7` `FILL_BLANK` - Fill in the blank - `8` `FILE_UPLOAD` - File upload -- `9` `MATCHING` - Matching -- `10` `ORDERING` - Ordering -- `11` `HOTSPOT` - Hotspot -- `12` `LINK` - Links +- `9` `ORDERING` - Ordering +- `10` `LINK` - Links +- `11` `WORD_BANK` - Word bank +- `12` `STAR` - Star rating +- `13` `VIDEO_RECORDING` - Video recording +- `14` `THUMBS` - Thumbs up/down (open-ended poll with customizable Yes/No labels; no correct answer) + +`TRUE_FALSE` is auto-graded with `settings.correctValue` (boolean) and fixed True/False labels for factual statements. `THUMBS` is open-ended (not auto-graded): two customizable Yes/No-style options with thumbs icons for sentiment or poll-style questions — do not set `settings.correctValue`. `MATCHING` and `HOTSPOT` are disabled and must not be used. ## Operational Notes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index ecc154cd..ce618dc8 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@classroomio/mcp", - "version": "0.0.9", + "version": "0.0.10", "private": false, "type": "module", "main": "./dist/index.js", @@ -35,6 +35,7 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { + "@cio/question-types": "workspace:*", "@cio/utils": "workspace:*", "@modelcontextprotocol/sdk": "^1.27.1" }, diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts index 97481b45..bcadae20 100644 --- a/packages/mcp/src/config.ts +++ b/packages/mcp/src/config.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; const ZConfig = z.object({ CLASSROOMIO_API_URL: z.url().default('https://api.classroomio.com'), CLASSROOMIO_API_KEY: z.string().min(1), - CLASSROOMIO_USER_AGENT: z.string().min(1).default('classroomio-mcp/0.0.9') + CLASSROOMIO_USER_AGENT: z.string().min(1).default('classroomio-mcp/0.0.10') }); export type McpServerConfig = z.infer; diff --git a/packages/mcp/src/tools/course-drafts.ts b/packages/mcp/src/tools/course-drafts.ts index 3bee2cb5..1b51814b 100644 --- a/packages/mcp/src/tools/course-drafts.ts +++ b/packages/mcp/src/tools/course-drafts.ts @@ -28,6 +28,7 @@ import { import type { ClassroomIoApiClient } from '../api-client'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { formatEnabledQuestionTypesGuide } from '@cio/question-types'; import { ZGetOrganizationCoursesQuery } from '@cio/utils/validation/organization'; import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js'; @@ -104,8 +105,7 @@ const publishCourseDraftToExistingCourseShape = ZPublishCourseDraftToExistingCourseToolInput.shape as unknown as ZodRawShapeCompat; const tagCoursesShape = ZAutomationCourseTagAssignment.shape as unknown as ZodRawShapeCompat; -const SUPPORTED_QUESTION_TYPES_GUIDE = - 'Supported questionTypeId values: RADIO=1 (Single answer), CHECKBOX=2 (Multiple answers), TEXTAREA=3 (Paragraph), TRUE_FALSE=4 (True/False), SHORT_ANSWER=5 (Short answer), NUMERIC=6 (Numeric answer), FILL_BLANK=7 (Fill in the blank), FILE_UPLOAD=8 (File upload), MATCHING=9 (Matching), ORDERING=10 (Ordering), HOTSPOT=11 (Hotspot), LINK=12 (Links), WORD_BANK=13 (Word bank). Use the numeric ids in exercise payloads.'; +const SUPPORTED_QUESTION_TYPES_GUIDE = formatEnabledQuestionTypesGuide(); const LESSON_HTML_GUIDE = 'For draft lesson HTML, put only the lesson body in lessonLanguages[].content. Do not include the lesson title. Do not use h1 or h2 anywhere in lesson HTML. Start headings at h3 because that is the highest heading level allowed in lesson content.'; diff --git a/packages/question-types/src/agent-question-type-guide.ts b/packages/question-types/src/agent-question-type-guide.ts new file mode 100644 index 00000000..42362c61 --- /dev/null +++ b/packages/question-types/src/agent-question-type-guide.ts @@ -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' + ); +} diff --git a/packages/question-types/src/answer-codecs.ts b/packages/question-types/src/answer-codecs.ts index 13a1a69f..2225db58 100644 --- a/packages/question-types/src/answer-codecs.ts +++ b/packages/question-types/src/answer-codecs.ts @@ -5,6 +5,7 @@ import type { RadioAnswerData, CheckboxAnswerData, TrueFalseAnswerData, + ThumbsAnswerData, TextareaAnswerData, ShortAnswerData, NumericAnswerData, @@ -86,6 +87,19 @@ const TRUE_FALSE_CODEC: AnswerCodec = { } }; +const THUMBS_CODEC: AnswerCodec = { + 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 = { type: 'TEXTAREA', toApiPayload(data, questionId) { @@ -308,6 +322,7 @@ export const ANSWER_CODECS: Record = { [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] diff --git a/packages/question-types/src/answer-data.ts b/packages/question-types/src/answer-data.ts index df9ad10b..60ef710d 100644 --- a/packages/question-types/src/answer-data.ts +++ b/packages/question-types/src/answer-data.ts @@ -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 diff --git a/packages/question-types/src/exercise-types.ts b/packages/question-types/src/exercise-types.ts index b368d3ab..6893c80c 100644 --- a/packages/question-types/src/exercise-types.ts +++ b/packages/question-types/src/exercise-types.ts @@ -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' diff --git a/packages/question-types/src/index.ts b/packages/question-types/src/index.ts index 1097579c..24a86a66 100644 --- a/packages/question-types/src/index.ts +++ b/packages/question-types/src/index.ts @@ -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'; diff --git a/packages/question-types/src/question-answer-payload.ts b/packages/question-types/src/question-answer-payload.ts index ef5eaf79..de18935c 100644 --- a/packages/question-types/src/question-answer-payload.ts +++ b/packages/question-types/src/question-answer-payload.ts @@ -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; diff --git a/packages/question-types/src/question-type-keys.ts b/packages/question-types/src/question-type-keys.ts index 1fe18c27..2ed1aa63 100644 --- a/packages/question-types/src/question-type-keys.ts +++ b/packages/question-types/src/question-type-keys.ts @@ -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]; diff --git a/packages/question-types/src/question-type-registry.ts b/packages/question-types/src/question-type-registry.ts index dee9b167..1fc42e5f 100644 --- a/packages/question-types/src/question-type-registry.ts +++ b/packages/question-types/src/question-type-registry.ts @@ -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, diff --git a/packages/question-types/src/thumbs-question.ts b/packages/question-types/src/thumbs-question.ts new file mode 100644 index 00000000..6b4e79cd --- /dev/null +++ b/packages/question-types/src/thumbs-question.ts @@ -0,0 +1,23 @@ +import type { ExerciseQuestionOption } from './exercise-types'; + +type ThumbsQuestionShape = { + settings?: Record; + options?: ExerciseQuestionOption[]; +}; + +/** Strips answer-key fields from THUMBS questions (open-ended — no correct answer). */ +export function normalizeThumbsQuestion(question: T): T { + const settings = { ...(question.settings ?? {}) }; + delete settings.correctValue; + + const options = (question.options ?? []).map((option) => ({ + ...option, + isCorrect: false + })); + + return { + ...question, + settings, + options + }; +} diff --git a/packages/question-types/src/true-false-answer-key.ts b/packages/question-types/src/true-false-answer-key.ts index 27a3da9b..a2ca7ac1 100644 --- a/packages/question-types/src/true-false-answer-key.ts +++ b/packages/question-types/src/true-false-answer-key.ts @@ -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; } diff --git a/packages/question-types/tests/question-type-registry.test.ts b/packages/question-types/tests/question-type-registry.test.ts index f4e4c707..f438e482 100644 --- a/packages/question-types/tests/question-type-registry.test.ts +++ b/packages/question-types/tests/question-type-registry.test.ts @@ -122,10 +122,18 @@ const EXPECTED_REGISTRY = [ supportsPartialCredit: false, manualGradingRequired: true }, + { + key: QUESTION_TYPE_KEY.THUMBS, + typename: 'THUMBS', + id: 14, + autoGradable: false, + supportsPartialCredit: false, + manualGradingRequired: false + }, { key: QUESTION_TYPE_KEY.MATCHING, typename: 'MATCHING', - id: 14, + id: 15, autoGradable: true, supportsPartialCredit: true, manualGradingRequired: false, @@ -134,7 +142,7 @@ const EXPECTED_REGISTRY = [ { key: QUESTION_TYPE_KEY.HOTSPOT, typename: 'HOTSPOT', - id: 15, + id: 16, autoGradable: true, supportsPartialCredit: true, manualGradingRequired: false, @@ -143,7 +151,7 @@ const EXPECTED_REGISTRY = [ ] as const; // Canonical `question_type` rows in the database (the source of truth ids are -// resolved against). Ids 1-13 are the live rows in insertion order; MATCHING and +// resolved against). Ids 1-14 are the live rows in insertion order; MATCHING and // HOTSPOT are intentionally absent (built-but-disabled). This list guards against // the registry id ever drifting from the DB again. const CANONICAL_DB_QUESTION_TYPES: ReadonlyArray<{ id: number; typename: string }> = [ @@ -159,7 +167,8 @@ const CANONICAL_DB_QUESTION_TYPES: ReadonlyArray<{ id: number; typename: string { id: 10, typename: 'LINK' }, { id: 11, typename: 'WORD_BANK' }, { id: 12, typename: 'STAR' }, - { id: 13, typename: 'VIDEO_RECORDING' } + { id: 13, typename: 'VIDEO_RECORDING' }, + { id: 14, typename: 'THUMBS' } ]; describe('question type registry', () => { diff --git a/packages/question-types/tests/thumbs-codec-scoring.test.ts b/packages/question-types/tests/thumbs-codec-scoring.test.ts new file mode 100644 index 00000000..117ef14f --- /dev/null +++ b/packages/question-types/tests/thumbs-codec-scoring.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + QUESTION_TYPE_KEY, + fromApiPayload, + isAutoGradableQuestionType, + scoreAnswerForQuestion, + toApiPayload, + type ExerciseQuestionModel +} from '../src'; + +function buildThumbsQuestion(): ExerciseQuestionModel { + return { + id: 1, + title: 'Was this lesson helpful?', + questionType: QUESTION_TYPE_KEY.THUMBS, + points: 5, + settings: {}, + options: [ + { id: 1, label: 'Yes', value: 'true', isCorrect: false }, + { id: 2, label: 'No', value: 'false', isCorrect: false } + ] + }; +} + +describe('thumbs codec and scoring', () => { + it('round-trips boolean answers through the API codec', () => { + const question = buildThumbsQuestion(); + const payload = toApiPayload({ type: 'THUMBS', value: true }, 1); + + expect(payload).toEqual({ questionId: 1, answer: 'true' }); + expect(fromApiPayload(QUESTION_TYPE_KEY.THUMBS, payload, question)).toEqual({ + type: 'THUMBS', + value: true + }); + expect(fromApiPayload(QUESTION_TYPE_KEY.THUMBS, { questionId: 1, answer: 'yes' }, question)).toEqual({ + type: 'THUMBS', + value: true + }); + expect(fromApiPayload(QUESTION_TYPE_KEY.THUMBS, { questionId: 1, answer: 'no' }, question)).toEqual({ + type: 'THUMBS', + value: false + }); + }); + + it('is not auto-graded and always scores zero', () => { + const question = buildThumbsQuestion(); + + expect(isAutoGradableQuestionType(QUESTION_TYPE_KEY.THUMBS)).toBe(false); + expect(scoreAnswerForQuestion(question, { type: 'THUMBS', value: true })).toBe(0); + expect(scoreAnswerForQuestion(question, { type: 'THUMBS', value: false })).toBe(0); + expect(scoreAnswerForQuestion(question, { type: 'TRUE_FALSE', value: true })).toBe(0); + }); +}); diff --git a/packages/question-types/tests/thumbs-question.test.ts b/packages/question-types/tests/thumbs-question.test.ts new file mode 100644 index 00000000..db9d7044 --- /dev/null +++ b/packages/question-types/tests/thumbs-question.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeThumbsQuestion } from '../src'; + +describe('thumbs question helpers', () => { + it('normalizes answer-key fields out of thumbs questions', () => { + expect( + normalizeThumbsQuestion({ + settings: { correctValue: true }, + options: [ + { id: 1, label: 'Yes', value: 'true', isCorrect: true }, + { id: 2, label: 'No', value: 'false', isCorrect: false } + ] + }) + ).toEqual({ + settings: {}, + options: [ + { id: 1, label: 'Yes', value: 'true', isCorrect: false }, + { id: 2, label: 'No', value: 'false', isCorrect: false } + ] + }); + }); +}); diff --git a/packages/question-types/tests/true-false-answer-key.test.ts b/packages/question-types/tests/true-false-answer-key.test.ts index e19849f8..d5065144 100644 --- a/packages/question-types/tests/true-false-answer-key.test.ts +++ b/packages/question-types/tests/true-false-answer-key.test.ts @@ -31,4 +31,17 @@ describe('true/false answer key helpers', () => { { label: 'False', value: 'false', isCorrect: true } ]); }); + + it('recognizes yes/no option labels used by thumbs questions', () => { + const options = [ + { label: 'Yes', value: 'yes', isCorrect: false }, + { label: 'No', value: 'no', isCorrect: true } + ]; + + expect(resolveTrueFalseCorrectValue({}, options)).toBe(false); + expect(syncTrueFalseOptions(options, true)).toEqual([ + { label: 'Yes', value: 'yes', isCorrect: true }, + { label: 'No', value: 'no', isCorrect: false } + ]); + }); }); diff --git a/packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte b/packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte index 619f9226..d7525cb8 100644 --- a/packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte +++ b/packages/storybook/src/molecules/exercise-question/exercise-question.stories.svelte @@ -16,6 +16,7 @@ SHORT_ANSWER_FIXTURE, STAR_FIXTURE, TEXTAREA_FIXTURE, + THUMBS_FIXTURE, TRUE_FALSE_FIXTURE, VIDEO_RECORDING_FIXTURE, WORD_BANK_FIXTURE @@ -353,6 +354,16 @@ {/snippet} + + {#snippet template()} + + {/snippet} + + {#snippet template()} Specific feedback helps students improve on the next attempt quickly.

' }; +export const THUMBS_FIXTURE: QuestionStoryFixture = { + question: { + id: 'q-thumbs', + key: 'q-thumbs', + title: 'Thumbs Up or Down: Would you recommend ClassroomIO to a colleague? [THUMBS]', + questionType: 'THUMBS', + options: [ + { id: 5001, label: 'Yes', value: 'true', isCorrect: false }, + { id: 5002, label: 'No', value: 'false', isCorrect: false } + ], + settings: {} + }, + answer: { type: 'THUMBS', value: true }, + wrongAnswer: { type: 'THUMBS', value: false } +}; + export const TRUE_FALSE_FIXTURE: QuestionStoryFixture = { question: { id: 'q-true-false', diff --git a/packages/storybook/src/molecules/exercise-question/question-labels.ts b/packages/storybook/src/molecules/exercise-question/question-labels.ts index 7e1e4334..8a33a7fd 100644 --- a/packages/storybook/src/molecules/exercise-question/question-labels.ts +++ b/packages/storybook/src/molecules/exercise-question/question-labels.ts @@ -49,6 +49,11 @@ export const QUESTION_LABELS = { 'true_false.false_label': 'False', 'true_false.edit.correct_answer_label': 'Correct answer', 'true_false.preview.correct_value_label': 'Correct value', + 'thumbs.yes_label': 'Yes', + 'thumbs.no_label': 'No', + 'thumbs.edit.positive_option_label': 'Thumbs up label', + 'thumbs.edit.negative_option_label': 'Thumbs down label', + 'thumbs.preview.helper': 'Open-ended — students choose thumbs up or down.', 'short_answer.edit.instructions_placeholder': 'Optional instructions', 'short_answer.take.placeholder': 'Type your answer', 'short_answer.preview.helper': 'Free-text answer question.', diff --git a/packages/storybook/src/molecules/exercise-question/question-type-modes.svelte b/packages/storybook/src/molecules/exercise-question/question-type-modes.svelte index 13bbefbe..00db840a 100644 --- a/packages/storybook/src/molecules/exercise-question/question-type-modes.svelte +++ b/packages/storybook/src/molecules/exercise-question/question-type-modes.svelte @@ -50,6 +50,8 @@ }; case 'TRUE_FALSE': return { type: 'TRUE_FALSE', value: index % 3 !== 0 }; + case 'THUMBS': + return { type: 'THUMBS', value: index % 3 !== 0 }; case 'TEXTAREA': return { type: 'TEXTAREA', diff --git a/packages/ui/src/base/alert/alert.svelte b/packages/ui/src/base/alert/alert.svelte index de47928d..a4b4408c 100644 --- a/packages/ui/src/base/alert/alert.svelte +++ b/packages/ui/src/base/alert/alert.svelte @@ -2,7 +2,7 @@ import { type VariantProps, tv } from 'tailwind-variants'; export const alertVariants = tv({ - base: 'ui:relative ui:grid ui:w-full ui:grid-cols-[0_1fr] ui:items-start ui:gap-y-0.5 ui:rounded-lg ui:border ui:px-4 ui:py-3 ui:text-sm ui:has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] ui:has-[>svg]:gap-x-3 ui:[&>svg]:size-4 ui:[&>svg]:translate-y-0.5 ui:[&>svg]:text-current', + base: 'ui:relative ui:grid ui:w-full ui:grid-cols-[0_1fr] ui:items-start ui:gap-y-0.5 ui:rounded-lg ui:border ui:px-4 ui:py-3 ui:text-sm ui:has-[>svg]:grid-cols-[calc(var(--ui-spacing)*4)_1fr] ui:has-[>svg]:gap-x-3 ui:[&>svg]:size-4 ui:[&>svg]:translate-y-0.5 ui:[&>svg]:text-current', variants: { variant: { default: 'ui:bg-card ui:text-card-foreground', diff --git a/packages/ui/src/custom/animation/marketing-layers.css b/packages/ui/src/custom/animation/marketing-layers.css index c57b96fb..c0932882 100644 --- a/packages/ui/src/custom/animation/marketing-layers.css +++ b/packages/ui/src/custom/animation/marketing-layers.css @@ -72,10 +72,10 @@ position: absolute; aspect-ratio: 1; width: calc(var(--size) * 1px); - animation-delay: var(--delay); background: linear-gradient(to left, var(--color-from), var(--color-to), transparent); offset-anchor: calc(var(--anchor) * 1%) 50%; offset-path: rect(0 auto auto 0 round calc(var(--size) * 1px)); animation: border-beam calc(var(--duration) * 1s) infinite linear; + animation-delay: var(--delay); } } diff --git a/packages/ui/src/custom/exercise-question/index.ts b/packages/ui/src/custom/exercise-question/index.ts index f72fc6d7..0b9d3373 100644 --- a/packages/ui/src/custom/exercise-question/index.ts +++ b/packages/ui/src/custom/exercise-question/index.ts @@ -10,6 +10,7 @@ export { default as SectionNavigationSidebar } from './section-navigation-sideba export { default as SectionOverview } from './section-overview.svelte'; export * from './renderer-contract'; +export { default as ThumbsEdit } from './renderers/thumbs/edit.svelte'; export { default as TrueFalseEdit } from './renderers/true-false/edit.svelte'; export { default as ShortAnswerEdit } from './renderers/short-answer/edit.svelte'; export { default as NumericEdit } from './renderers/numeric/edit.svelte'; @@ -23,6 +24,7 @@ export { default as OrderingEdit } from './renderers/ordering/edit.svelte'; export { default as HotspotEdit } from './renderers/hotspot/edit.svelte'; export { default as VideoRecordingEdit } from './renderers/video-recording/edit.svelte'; +export { default as ThumbsTake } from './renderers/thumbs/take.svelte'; export { default as TrueFalseTake } from './renderers/true-false/take.svelte'; export { default as ShortAnswerTake } from './renderers/short-answer/take.svelte'; export { default as NumericTake } from './renderers/numeric/take.svelte'; @@ -36,6 +38,7 @@ export { default as OrderingTake } from './renderers/ordering/take.svelte'; export { default as HotspotTake } from './renderers/hotspot/take.svelte'; export { default as VideoRecordingTake } from './renderers/video-recording/take.svelte'; +export { default as ThumbsPreview } from './renderers/thumbs/preview.svelte'; export { default as TrueFalsePreview } from './renderers/true-false/preview.svelte'; export { default as ShortAnswerPreview } from './renderers/short-answer/preview.svelte'; export { default as NumericPreview } from './renderers/numeric/preview.svelte'; @@ -50,6 +53,7 @@ export { default as OrderingDisplay } from './renderers/ordering/ordering-displa export { default as HotspotPreview } from './renderers/hotspot/preview.svelte'; export { default as VideoRecordingPreview } from './renderers/video-recording/preview.svelte'; +export { default as ThumbsSubmission } from './renderers/thumbs/submission.svelte'; export { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte'; export { default as ShortAnswerSubmission } from './renderers/short-answer/submission.svelte'; export { default as NumericSubmission } from './renderers/numeric/submission.svelte'; @@ -63,3 +67,5 @@ export { default as OrderingSubmission } from './renderers/ordering/submission.s export { default as HotspotSubmission } from './renderers/hotspot/submission.svelte'; export { default as LinkSubmission } from './renderers/link/submission.svelte'; export { default as VideoRecordingSubmission } from './renderers/video-recording/submission.svelte'; + +export { default as ThumbsReview } from './renderers/thumbs/review.svelte'; diff --git a/packages/ui/src/custom/exercise-question/question-renderer.svelte b/packages/ui/src/custom/exercise-question/question-renderer.svelte index 9fc04ff0..565a8349 100644 --- a/packages/ui/src/custom/exercise-question/question-renderer.svelte +++ b/packages/ui/src/custom/exercise-question/question-renderer.svelte @@ -31,6 +31,8 @@ questionNumber?: number; /** When false, the badge uses muted styling (e.g. question lists). Defaults to true. */ questionNumberActive?: boolean; + /** Shown beneath the title input in edit mode when validation fails. */ + titleError?: string | null; } let { @@ -40,7 +42,8 @@ showContainer = true, questionTypeSelect, questionNumber, - questionNumberActive = true + questionNumberActive = true, + titleError = null }: Props = $props(); const Renderer = $derived(getExerciseQuestionRenderer(contract.question.questionType, contract.mode)); @@ -280,8 +283,12 @@ value={contract.question.title} placeholder={label('question.edit.title_placeholder')} disabled={contract.disabled} + aria-invalid={titleError ? true : undefined} onchange={(event) => patchQuestion({ title: event.currentTarget.value })} /> + {#if titleError} +

{titleError}

+ {/if}
diff --git a/packages/ui/src/custom/exercise-question/renderer-contract.ts b/packages/ui/src/custom/exercise-question/renderer-contract.ts index d3e4a279..b92bb330 100644 --- a/packages/ui/src/custom/exercise-question/renderer-contract.ts +++ b/packages/ui/src/custom/exercise-question/renderer-contract.ts @@ -22,6 +22,7 @@ import { default as WordBankEdit } from './renderers/word-bank/edit.svelte'; import { default as ShortAnswerEdit } from './renderers/short-answer/edit.svelte'; import { default as StarEdit } from './renderers/star/edit.svelte'; import { default as TextareaEdit } from './renderers/textarea/edit.svelte'; +import { default as ThumbsEdit } from './renderers/thumbs/edit.svelte'; import { default as TrueFalseEdit } from './renderers/true-false/edit.svelte'; import { default as VideoRecordingEdit } from './renderers/video-recording/edit.svelte'; @@ -38,6 +39,7 @@ import { default as WordBankTake } from './renderers/word-bank/take.svelte'; import { default as ShortAnswerTake } from './renderers/short-answer/take.svelte'; import { default as StarTake } from './renderers/star/take.svelte'; import { default as TextareaTake } from './renderers/textarea/take.svelte'; +import { default as ThumbsTake } from './renderers/thumbs/take.svelte'; import { default as TrueFalseTake } from './renderers/true-false/take.svelte'; import { default as VideoRecordingTake } from './renderers/video-recording/take.svelte'; @@ -54,6 +56,7 @@ import { default as WordBankPreview } from './renderers/word-bank/preview.svelte import { default as ShortAnswerPreview } from './renderers/short-answer/preview.svelte'; import { default as StarPreview } from './renderers/star/preview.svelte'; import { default as TextareaPreview } from './renderers/textarea/preview.svelte'; +import { default as ThumbsPreview } from './renderers/thumbs/preview.svelte'; import { default as TrueFalsePreview } from './renderers/true-false/preview.svelte'; import { default as VideoRecordingPreview } from './renderers/video-recording/preview.svelte'; @@ -70,6 +73,7 @@ import { default as WordBankSubmission } from './renderers/word-bank/submission. import { default as ShortAnswerSubmission } from './renderers/short-answer/submission.svelte'; import { default as StarSubmission } from './renderers/star/submission.svelte'; import { default as TextareaSubmission } from './renderers/textarea/submission.svelte'; +import { default as ThumbsSubmission } from './renderers/thumbs/submission.svelte'; import { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte'; import { default as VideoRecordingSubmission } from './renderers/video-recording/submission.svelte'; @@ -80,6 +84,7 @@ import { default as ShortAnswerReview } from './renderers/short-answer/review.sv import { default as FillBlankReview } from './renderers/fill-blank/review.svelte'; import { default as WordBankReview } from './renderers/word-bank/review.svelte'; import { default as OrderingReview } from './renderers/ordering/review.svelte'; +import { default as ThumbsReview } from './renderers/thumbs/review.svelte'; import { default as TrueFalseReview } from './renderers/true-false/review.svelte'; type SharedRendererComponent = Component; @@ -112,6 +117,13 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry + import { + getExerciseQuestionLabel, + type ExerciseQuestionModel, + type ExerciseQuestionRendererProps + } from '@cio/question-types'; + import { Input } from '../../../../base/input'; + import { ensureThumbsOptions, getThumbsOptionLabel } from './thumbs-options'; + + let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props(); + const label = (key: Parameters[1], fallback = '') => + getExerciseQuestionLabel(labels, key, fallback); + + const defaultYesLabel = $derived(label('thumbs.yes_label', 'Yes')); + const defaultNoLabel = $derived(label('thumbs.no_label', 'No')); + + const yesLabel = $derived(getThumbsOptionLabel(question.options, true, defaultYesLabel, defaultNoLabel)); + const noLabel = $derived(getThumbsOptionLabel(question.options, false, defaultYesLabel, defaultNoLabel)); + + function patchQuestion(partial: Partial) { + onQuestionChange({ ...question, ...partial }); + } + + function handleOptionLabelChange(value: boolean, nextLabel: string) { + const options = ensureThumbsOptions(question.options, defaultYesLabel, defaultNoLabel).map((option) => { + const optionValue = + String(option.value ?? '') + .trim() + .toLowerCase() === 'true'; + + if (optionValue !== value) return option; + + return { ...option, label: nextLabel }; + }); + + patchQuestion({ options }); + } + + +
+
+ + handleOptionLabelChange(true, event.currentTarget.value)} + /> +
+
+ + handleOptionLabelChange(false, event.currentTarget.value)} + /> +
+
diff --git a/packages/ui/src/custom/exercise-question/renderers/thumbs/preview.svelte b/packages/ui/src/custom/exercise-question/renderers/thumbs/preview.svelte new file mode 100644 index 00000000..6758f5b7 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/thumbs/preview.svelte @@ -0,0 +1,11 @@ + + +
+

{label('thumbs.preview.helper')}

+
diff --git a/packages/ui/src/custom/exercise-question/renderers/thumbs/review.svelte b/packages/ui/src/custom/exercise-question/renderers/thumbs/review.svelte new file mode 100644 index 00000000..d3d9d830 --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/thumbs/review.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/ui/src/custom/exercise-question/renderers/thumbs/submission.svelte b/packages/ui/src/custom/exercise-question/renderers/thumbs/submission.svelte new file mode 100644 index 00000000..35736baf --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/thumbs/submission.svelte @@ -0,0 +1,93 @@ + + +{#if chartData.length > 0} + + + ({ label: d.label, color: d.color, value: d.responses }))} /> + +{:else} +

+ {getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')} +

+{/if} diff --git a/packages/ui/src/custom/exercise-question/renderers/thumbs/take.svelte b/packages/ui/src/custom/exercise-question/renderers/thumbs/take.svelte new file mode 100644 index 00000000..03ba8a2d --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/thumbs/take.svelte @@ -0,0 +1,47 @@ + + +
+ + + + + + +
diff --git a/packages/ui/src/custom/exercise-question/renderers/thumbs/thumbs-options.ts b/packages/ui/src/custom/exercise-question/renderers/thumbs/thumbs-options.ts new file mode 100644 index 00000000..9f11f0fa --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/thumbs/thumbs-options.ts @@ -0,0 +1,56 @@ +import type { ExerciseQuestionOption } from '@cio/question-types'; + +export function findThumbsOption( + options: ExerciseQuestionOption[] | undefined, + value: boolean +): ExerciseQuestionOption | undefined { + const wanted = value ? 'true' : 'false'; + + return (options ?? []).find((item) => { + if (item.deletedAt) return false; + + return ( + String(item.value ?? '') + .trim() + .toLowerCase() === wanted + ); + }); +} + +export function getThumbsOptionLabel( + options: ExerciseQuestionOption[] | undefined, + value: boolean, + fallbackYes: string, + fallbackNo: string +): string { + const option = findThumbsOption(options, value); + const fallback = value ? fallbackYes : fallbackNo; + const label = option?.label?.trim(); + + return label || fallback; +} + +export function ensureThumbsOptions( + existing: ExerciseQuestionOption[] | undefined, + defaultYesLabel: string, + defaultNoLabel: string +): ExerciseQuestionOption[] { + const timestamp = Date.now(); + const yesOption = findThumbsOption(existing, true); + const noOption = findThumbsOption(existing, false); + + return [ + { + ...(yesOption ?? { id: `${timestamp}-yes` }), + label: yesOption?.label?.trim() || defaultYesLabel, + value: 'true', + isCorrect: false + }, + { + ...(noOption ?? { id: `${timestamp}-no` }), + label: noOption?.label?.trim() || defaultNoLabel, + value: 'false', + isCorrect: false + } + ]; +} diff --git a/packages/ui/src/custom/exercise-question/renderers/thumbs/thumbs-review-toggle.svelte b/packages/ui/src/custom/exercise-question/renderers/thumbs/thumbs-review-toggle.svelte new file mode 100644 index 00000000..0e957d5f --- /dev/null +++ b/packages/ui/src/custom/exercise-question/renderers/thumbs/thumbs-review-toggle.svelte @@ -0,0 +1,40 @@ + + +
+ + + + + + +
diff --git a/packages/ui/src/custom/question-type-picker/exercise-labels.ts b/packages/ui/src/custom/question-type-picker/exercise-labels.ts index 6650d8dc..a2ec3083 100644 --- a/packages/ui/src/custom/question-type-picker/exercise-labels.ts +++ b/packages/ui/src/custom/question-type-picker/exercise-labels.ts @@ -48,6 +48,11 @@ export const QUESTION_TYPE_PICKER_EXERCISE_LABELS: ExerciseQuestionLabels = { 'true_false.false_label': 'False', 'true_false.edit.correct_answer_label': 'Correct answer', 'true_false.preview.correct_value_label': 'Correct value', + 'thumbs.yes_label': 'Yes', + 'thumbs.no_label': 'No', + 'thumbs.edit.positive_option_label': 'Thumbs up label', + 'thumbs.edit.negative_option_label': 'Thumbs down label', + 'thumbs.preview.helper': 'Open-ended — students choose thumbs up or down.', 'short_answer.edit.instructions_placeholder': 'Optional instructions', 'short_answer.take.placeholder': 'Type your answer', 'short_answer.preview.helper': 'Free-text answer question.', diff --git a/packages/ui/src/custom/question-type-picker/picker-data.ts b/packages/ui/src/custom/question-type-picker/picker-data.ts index eb720e34..d9294850 100644 --- a/packages/ui/src/custom/question-type-picker/picker-data.ts +++ b/packages/ui/src/custom/question-type-picker/picker-data.ts @@ -152,6 +152,22 @@ const QUESTION_TYPE_PICKER_ENTRIES: QuestionTypePickerEntry[] = [ } } }, + { + key: QUESTION_TYPE_KEY.THUMBS, + displayLabel: 'Thumbs up/down', + description: 'Quick yes/no with thumbs icons — open-ended poll with customizable labels, no correct answer.', + demoQuestion: { + id: 'picker-thumbs', + key: 'picker-thumbs', + title: 'Would you recommend this lesson to a colleague who is new to the topic?', + questionType: 'THUMBS', + options: [ + { id: 14001, label: 'Yes', value: 'true', isCorrect: false }, + { id: 14002, label: 'No', value: 'false', isCorrect: false } + ], + settings: {} + } + }, { key: QUESTION_TYPE_KEY.SHORT_ANSWER, displayLabel: 'Short answer', diff --git a/packages/ui/src/custom/question-type-picker/question-type-picker.svelte b/packages/ui/src/custom/question-type-picker/question-type-picker.svelte index 2c27d223..efb5f43c 100644 --- a/packages/ui/src/custom/question-type-picker/question-type-picker.svelte +++ b/packages/ui/src/custom/question-type-picker/question-type-picker.svelte @@ -9,6 +9,7 @@ import Link2Icon from '@lucide/svelte/icons/link-2'; import SquareCheckIcon from '@lucide/svelte/icons/square-check'; import StarIcon from '@lucide/svelte/icons/star'; + import ThumbsUpIcon from '@lucide/svelte/icons/thumbs-up'; import UploadIcon from '@lucide/svelte/icons/upload'; import VideoIcon from '@lucide/svelte/icons/video'; import { QuestionRenderer } from '../exercise-question'; @@ -33,6 +34,8 @@ return Link2Icon; case QUESTION_TYPE_KEY.STAR: return StarIcon; + case QUESTION_TYPE_KEY.THUMBS: + return ThumbsUpIcon; case QUESTION_TYPE_KEY.VIDEO_RECORDING: return VideoIcon; default: diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 7cc65079..f5762453 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -56,6 +56,7 @@ @source inline('ui:z-menu-elevated{,!}'); :root { + --spacing: var(--ui-spacing); --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); diff --git a/packages/utils/src/validation/exercise/exercise.ts b/packages/utils/src/validation/exercise/exercise.ts index eeeb042f..3b68c8a8 100644 --- a/packages/utils/src/validation/exercise/exercise.ts +++ b/packages/utils/src/validation/exercise/exercise.ts @@ -25,7 +25,8 @@ const EXERCISE_QUESTION_TYPE_ID_LITERALS = [ z.literal(QUESTION_TYPE.LINK), z.literal(QUESTION_TYPE.WORD_BANK), z.literal(QUESTION_TYPE.STAR), - z.literal(QUESTION_TYPE.VIDEO_RECORDING) + z.literal(QUESTION_TYPE.VIDEO_RECORDING), + z.literal(QUESTION_TYPE.THUMBS) ] as const; export const ZExerciseQuestionTypeId = z.union(EXERCISE_QUESTION_TYPE_ID_LITERALS); @@ -36,6 +37,24 @@ type QuestionRuleInput = { settings?: Record; }; +function requireAtLeastTwoOptions(message: string) { + return (question: QuestionRuleInput): string | null => { + const options = question.options || []; + if (options.length > 0 && options.length < 2) { + return message; + } + return null; + }; +} + +function requireMarkedCorrectOption(question: QuestionRuleInput): string | null { + const options = question.options || []; + if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) { + return 'Please mark an option as the correct answer'; + } + return null; +} + const QUESTION_VALIDATION_RULES: Record string | null>> = { [QUESTION_TYPE.RADIO]: [ (question) => { @@ -74,21 +93,10 @@ const QUESTION_VALIDATION_RULES: Record { - const options = question.options || []; - if (options.length > 0 && options.length < 2) { - return 'True/False questions need both True and False options'; - } - return null; - }, - (question) => { - const options = question.options || []; - if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) { - return 'Please mark an option as the correct answer'; - } - return null; - } + requireAtLeastTwoOptions('True/False questions need both True and False options'), + requireMarkedCorrectOption ], + [QUESTION_TYPE.THUMBS]: [requireAtLeastTwoOptions('Thumbs up/down questions need both Yes and No options')], [QUESTION_TYPE.TEXTAREA]: [], [QUESTION_TYPE.WORD_BANK]: [ (question) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08d53356..9d17edf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -624,7 +624,7 @@ importers: version: 0.4.9(astro@7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) '@shikijs/twoslash': specifier: 4.3.1 - version: 4.3.1(typescript@5.9.3) + version: 4.3.1(typescript@6.0.3) '@tailwindcss/typography': specifier: 0.5.19 version: 0.5.19(tailwindcss@4.1.18) @@ -636,7 +636,7 @@ importers: version: 7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0) blume: specifier: 1.1.2 - version: 1.1.2(4833f745f9fd8f626b4a7791f185a353) + version: 1.1.2(864af55737aed5e342af6da019f32ece) mermaid: specifier: ^11.16.0 version: 11.16.0 @@ -1256,6 +1256,9 @@ importers: packages/mcp: dependencies: + '@cio/question-types': + specifier: workspace:* + version: link:../question-types '@cio/utils': specifier: workspace:* version: link:../utils @@ -1348,7 +1351,7 @@ importers: version: 10.1.10(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(esbuild@0.25.12)(rollup@4.54.0)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/sveltekit': specifier: ^8.4.2 - version: 8.6.15(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.19)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 8.6.15(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@6.0.3)))(postcss@8.5.19)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/test': specifier: ^8.4.2 version: 8.6.14(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6)) @@ -1387,7 +1390,7 @@ importers: version: 4.21.0 typescript-svelte-plugin: specifier: ^0.3.50 - version: 0.3.50(svelte@5.46.0)(typescript@5.9.3) + version: 0.3.50(svelte@5.46.0)(typescript@6.0.3) vite: specifier: ^7.1.4 version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0) @@ -16049,10 +16052,10 @@ snapshots: is-docker: 4.0.0 package-manager-detector: 1.7.0 - '@astrojs/vercel@11.0.3(@aws-sdk/credential-provider-web-identity@3.958.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(astro@7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0))(encoding@0.1.13)(react@19.2.3)(rollup@4.54.0)(svelte@5.46.0)(vue@3.5.26(typescript@5.9.3))(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@astrojs/vercel@11.0.3(@aws-sdk/credential-provider-web-identity@3.958.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(astro@7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0))(encoding@0.1.13)(react@19.2.3)(rollup@4.54.0)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3))(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@astrojs/internal-helpers': 0.10.1 - '@vercel/analytics': 1.6.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(react@19.2.3)(svelte@5.46.0)(vue@3.5.26(typescript@5.9.3)) + '@vercel/analytics': 1.6.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(react@19.2.3)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3)) '@vercel/functions': 3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@vercel/nft': 1.10.2(encoding@0.1.13)(rollup@4.54.0) '@vercel/routing-utils': 5.3.3 @@ -20551,15 +20554,6 @@ snapshots: '@shikijs/core': 4.3.1 '@shikijs/types': 4.3.1 - '@shikijs/twoslash@4.3.1(typescript@5.9.3)': - dependencies: - '@shikijs/core': 4.3.1 - '@shikijs/types': 4.3.1 - twoslash: 0.3.9(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@shikijs/twoslash@4.3.1(typescript@6.0.3)': dependencies: '@shikijs/core': 4.3.1 @@ -21219,7 +21213,7 @@ snapshots: - rollup - webpack - '@storybook/svelte-vite@8.6.15(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.19)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/svelte-vite@8.6.15(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@6.0.3)))(postcss@8.5.19)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@storybook/builder-vite': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/svelte': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0) @@ -21227,7 +21221,7 @@ snapshots: magic-string: 0.30.21 storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6) svelte: 5.46.0 - svelte-preprocess: 5.1.4(@babel/core@7.29.0)(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.19)(sass@1.97.1)(svelte@5.46.0)(typescript@5.9.3) + svelte-preprocess: 5.1.4(@babel/core@7.29.0)(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@6.0.3)))(postcss@8.5.19)(sass@1.97.1)(svelte@5.46.0)(typescript@5.9.3) svelte2tsx: 0.7.46(svelte@5.46.0)(typescript@5.9.3) sveltedoc-parser: 4.2.1 ts-dedent: 2.2.0 @@ -21268,12 +21262,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/sveltekit@8.6.15(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.19)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/sveltekit@8.6.15(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@6.0.3)))(postcss@8.5.19)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@storybook/addon-actions': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6)) '@storybook/builder-vite': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/svelte': 8.6.15(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0) - '@storybook/svelte-vite': 8.6.15(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.19)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) + '@storybook/svelte-vite': 8.6.15(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@6.0.3)))(postcss@8.5.19)(sass@1.97.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))(svelte@5.46.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) storybook: 8.6.15(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6) svelte: 5.46.0 vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0) @@ -23135,13 +23129,6 @@ snapshots: '@typescript-eslint/types': 8.50.1 eslint-visitor-keys: 4.2.1 - '@typescript/vfs@1.6.4(typescript@5.9.3)': - dependencies: - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript/vfs@1.6.4(typescript@6.0.3)': dependencies: debug: 4.4.3 @@ -23156,19 +23143,19 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vercel/analytics@1.6.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(react@19.2.3)(svelte@5.46.0)(vue@3.5.26(typescript@5.9.3))': + '@vercel/analytics@1.6.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(react@19.2.3)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3))': optionalDependencies: '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) react: 19.2.3 svelte: 5.46.0 - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.26(typescript@6.0.3) - '@vercel/analytics@2.0.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(react@19.2.3)(svelte@5.46.0)(vue@3.5.26(typescript@5.9.3))': + '@vercel/analytics@2.0.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(react@19.2.3)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3))': optionalDependencies: '@sveltejs/kit': 2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) react: 19.2.3 svelte: 5.46.0 - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.26(typescript@6.0.3) '@vercel/cli-config@0.2.0': dependencies: @@ -24089,14 +24076,14 @@ snapshots: bluebird@3.4.7: {} - blume@1.1.2(4833f745f9fd8f626b4a7791f185a353): + blume@1.1.2(864af55737aed5e342af6da019f32ece): dependencies: '@astrojs/check': 0.9.9(prettier@3.8.2)(typescript@6.0.3) '@astrojs/markdown-satteri': 0.3.4 '@astrojs/mdx': 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) '@astrojs/node': 11.0.2(astro@7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) '@astrojs/react': 6.0.1(@types/node@24.10.4)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(esbuild@0.28.1)(jiti@2.6.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0) - '@astrojs/vercel': 11.0.3(@aws-sdk/credential-provider-web-identity@3.958.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(astro@7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0))(encoding@0.1.13)(react@19.2.3)(rollup@4.54.0)(svelte@5.46.0)(vue@3.5.26(typescript@5.9.3))(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@astrojs/vercel': 11.0.3(@aws-sdk/credential-provider-web-identity@3.958.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(astro@7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0))(encoding@0.1.13)(react@19.2.3)(rollup@4.54.0)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3))(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@clack/prompts': 1.7.0 '@iconify-json/lucide': 1.2.117 '@iconify/types': 2.0.0 @@ -24111,7 +24098,7 @@ snapshots: '@shikijs/twoslash': 4.3.1(typescript@6.0.3) '@tailwindcss/typography': 0.5.20(tailwindcss@4.2.2) '@tailwindcss/vite': 4.1.18(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)) - '@vercel/analytics': 2.0.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(react@19.2.3)(svelte@5.46.0)(vue@3.5.26(typescript@5.9.3)) + '@vercel/analytics': 2.0.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(svelte@5.46.0)(vite@8.1.4(@types/node@24.10.4)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)))(react@19.2.3)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3)) ai: 5.0.212(zod@3.25.76) astro: 7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0) babel-plugin-react-compiler: 1.0.0 @@ -29174,13 +29161,13 @@ snapshots: postcss: 8.5.6 ts-node: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) - postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)): + postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@6.0.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.2 optionalDependencies: postcss: 8.5.19 - ts-node: 10.9.2(@types/node@24.10.4)(typescript@5.9.3) + ts-node: 10.9.2(@types/node@24.10.4)(typescript@6.0.3) optional: true postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)): @@ -30658,7 +30645,7 @@ snapshots: svelte: 5.46.0 tslib: 2.8.1 - svelte-preprocess@5.1.4(@babel/core@7.29.0)(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)))(postcss@8.5.19)(sass@1.97.1)(svelte@5.46.0)(typescript@5.9.3): + svelte-preprocess@5.1.4(@babel/core@7.29.0)(postcss-load-config@4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@6.0.3)))(postcss@8.5.19)(sass@1.97.1)(svelte@5.46.0)(typescript@5.9.3): dependencies: '@types/pug': 2.0.10 detect-indent: 6.1.0 @@ -30669,7 +30656,7 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 postcss: 8.5.19 - postcss-load-config: 4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@5.9.3)) + postcss-load-config: 4.0.2(postcss@8.5.19)(ts-node@10.9.2(@types/node@24.10.4)(typescript@6.0.3)) sass: 1.97.1 typescript: 5.9.3 @@ -30745,6 +30732,13 @@ snapshots: svelte: 5.46.0 typescript: 5.9.3 + svelte2tsx@0.7.46(svelte@5.46.0)(typescript@6.0.3): + dependencies: + dedent-js: 1.0.1 + scule: 1.3.0 + svelte: 5.46.0 + typescript: 6.0.3 + svelte@5.46.0: dependencies: '@jridgewell/remapping': 2.3.5 @@ -31110,6 +31104,25 @@ snapshots: yn: 3.1.1 optional: true + ts-node@10.9.2(@types/node@24.10.4)(typescript@6.0.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.10.4 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 6.0.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + tsc-alias@1.8.16: dependencies: chokidar: 3.6.0 @@ -31162,14 +31175,6 @@ snapshots: twoslash-protocol@0.3.9: {} - twoslash@0.3.9(typescript@5.9.3): - dependencies: - '@typescript/vfs': 1.6.4(typescript@5.9.3) - twoslash-protocol: 0.3.9 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - twoslash@0.3.9(typescript@6.0.3): dependencies: '@typescript/vfs': 1.6.4(typescript@6.0.3) @@ -31238,10 +31243,10 @@ snapshots: transitivePeerDependencies: - supports-color - typescript-svelte-plugin@0.3.50(svelte@5.46.0)(typescript@5.9.3): + typescript-svelte-plugin@0.3.50(svelte@5.46.0)(typescript@6.0.3): dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - svelte2tsx: 0.7.46(svelte@5.46.0)(typescript@5.9.3) + svelte2tsx: 0.7.46(svelte@5.46.0)(typescript@6.0.3) transitivePeerDependencies: - svelte - typescript