fix(question-types): realign registry ids with the question_type table

The registry hardcoded numeric ids that drifted from the database
`question_type` table: MATCHING and HOTSPOT were inserted mid-list
(ids 9 and 11) but never seeded, shifting ORDERING/LINK/WORD_BANK/
STAR/VIDEO_RECORDING one or two ids past their real rows. Every id
emitter inherited the wrong values — the exercise editor, the AI
question generator, and the grading/scoring gate — so e.g. an ORDERING
question was stored as id 10 (LINK in the DB), came back as a manual
type, and never auto-graded.

- Move MATCHING/HOTSPOT to the end (ids 14/15) and flag them
  `disabled`, restoring the append-only id convention and realigning
  ORDERING=9, LINK=10, WORD_BANK=11, STAR=12, VIDEO_RECORDING=13 with
  the DB.
- Add a registry test asserting every enabled type's id matches the
  canonical `question_type` rows and disabled types stay past the live
  range, so ids can't silently drift again.
- Constrain the agent tool schema to enabled, DB-backed ids so the
  model can't emit a disabled id with no row.
- Drop disabled types from the teacher prompt's type list and guidance.
- Centralize the dashboard question-type picker on the `disabled` flag.

This also fixes video-recording detection, which compared against the
stale id 15 instead of the real 13.
This commit is contained in:
Rotimi Best
2026-06-29 01:07:02 +01:00
parent eb4cd22ac5
commit 55abf982ed
6 changed files with 131 additions and 72 deletions
@@ -5,11 +5,22 @@ import {
LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT,
LANDING_PAGE_SECTION_HTML_AGENT_HINT
} from '@cio/ai-assistant/tools';
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import { ENABLED_QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdateFields } from '@cio/utils/validation/course';
// courseId is NOT a parameter — it's injected from the authenticated request context.
// Only question types backed by a `question_type` DB row may be generated.
// Disabled types (e.g. MATCHING/HOTSPOT) have no row and would fail the FK, so
// the model is constrained to the enabled ids rather than the whole registry.
const ENABLED_QUESTION_TYPE_IDS = ENABLED_QUESTION_TYPE_REGISTRY.map((type) => type.id);
const zEnabledQuestionTypeId = z
.number()
.int()
.refine((id) => ENABLED_QUESTION_TYPE_IDS.includes(id), {
message: `questionTypeId must be one of: ${ENABLED_QUESTION_TYPE_IDS.join(', ')}`
});
// This prevents prompt injection from tricking the LLM into targeting another course.
export const emptyParam = z.object({});
@@ -63,14 +74,9 @@ export const updateContentParam = z.object({
export const questionSchema = z.object({
question: z.string().min(1),
questionTypeId: z
.number()
.int()
.min(1)
.max(QUESTION_TYPE_REGISTRY.length)
.describe(
'Required. Use the numeric question type IDs from the teacher system prompt (Question Types). Omitting this field is invalid — set an explicit type on every question and vary types within each exercise.'
),
questionTypeId: zEnabledQuestionTypeId.describe(
'Required. Use the numeric question type IDs from the teacher system prompt (Question Types). Omitting this field is invalid — set an explicit type on every question and vary types within each exercise.'
),
points: z.number().min(0).default(1),
order: z.number().int().min(0),
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
@@ -193,7 +199,7 @@ export const updateQuestionPatchSchema = z
.object({
id: z.number().int(),
question: z.string().min(1).optional(),
questionTypeId: z.number().int().min(1).max(QUESTION_TYPE_REGISTRY.length).optional(),
questionTypeId: zEnabledQuestionTypeId.optional(),
points: z.number().min(0).optional(),
order: z.number().int().min(0).optional(),
exerciseSectionId: z