fix(exercise): persist completion policy and improve dev build workflow (#754)

* fix(exercise): persist completion policy and pass threshold on save

buildExerciseUpdateFields accepted completionPolicy and passThreshold in
the validation schema but never mapped them to the database update, so
exercise settings appeared to save while reverting on refresh.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(db): build workspace deps before compiling

@cio/db imports @cio/utils/notifications, which only exists after
@cio/utils is built. Building db in isolation failed when utils dist
was missing or stale.

- Add a db prebuild step for question-types, utils, and email
- Clean dist and tsbuildinfo before utils and email builds so
  incremental tsc cannot skip emission after dist was removed

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* chore(dev): build api workspace deps before api:dev

Run turbo build for @cio/api and @cio/jobs-worker dependencies before
starting the dev servers, so missing dist/ folders do not break startup.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(course): improve exercise progression grading UX

---------

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-15 03:26:56 +01:00
committed by GitHub
co-authored by Rotimi Best
parent fdc19aac41
commit bfafc685e0
53 changed files with 2026 additions and 285 deletions
+13 -3
View File
@@ -41,7 +41,7 @@ export function buildTeacherSystemPrompt(context: AgentContext, options?: BuildT
## Your Capabilities
You have access to specific tools listed below. Use them to read course content, read a lesson's uploaded video transcript via get_lesson_transcript (the video's spoken content is not in the lesson body — use this whenever a question is about what the video says or explains), create sections and lessons, update existing course-outline section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, create exercise question blocks via create_exercise_section, edit exercise-level metadata (title, description, linked lesson, course section placement, order, due date, lock state, allow-multiple-attempts) via update_exercise, edit each existing in-exercise question block heading and intro via update_exercise_section (ids from get_exercise_details sections array — not course section ids), add or update questions inside an existing exercise (use add_questions with exerciseSectionId from that sections array when the exercise groups questions into blocks), update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions.
You have access to specific tools listed below. Use them to read course content, read a lesson's uploaded video transcript via get_lesson_transcript (the video's spoken content is not in the lesson body — use this whenever a question is about what the video says or explains), create sections and lessons, update existing course-outline section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, create exercise question blocks via create_exercise_section, edit exercise-level metadata (title, description, linked lesson, course section placement, order, due date, lock state, allow-multiple-attempts, completion policy, passing threshold) via update_exercise, edit each existing in-exercise question block heading and intro via update_exercise_section (ids from get_exercise_details sections array — not course section ids), add or update questions inside an existing exercise (use add_questions with exerciseSectionId from that sections array when the exercise groups questions into blocks), update course landing-page copy/settings, check go-live readiness, and publish the course when it is ready. To change a question's text, points, options, or correct answer, use update_questions; to add new questions, use add_questions.
## Question Types
@@ -215,15 +215,25 @@ If a lesson has \`hasExercise: true\`, the linked exercise takes the next order
- If the teacher asks to change a section name or order, first call get_course_structure, then use update_section with the existing section ID. Do NOT create a replacement section.
- If the teacher asks to change a lesson name, move a lesson to another section, reorder it, schedule it, or change its visibility/unlock settings, first call get_course_structure, then use update_lesson with the existing lesson ID. Do NOT create a replacement lesson.
- If the teacher asks to change exercise metadata such as title, description, linked lesson, course section placement, order, due date, lock state, or multiple-attempt behavior, use update_exercise. Do NOT create a replacement exercise.
- If the teacher asks to change exercise metadata such as title, description, linked lesson, course section placement, order, due date, lock state, multiple-attempt behavior, completion policy, or passing threshold, use update_exercise. Do NOT create a replacement exercise.
- If the teacher asks to create a new **block of questions inside an exercise**, use create_exercise_section. If they ask to rename or add intro text for an existing block, call get_exercise_details first, then use update_exercise_section with the exercise id and the section id from the sections array. Do NOT use update_section for that — update_section only changes course outline sections.
- Only use create_section or create_lesson when the teacher clearly wants a brand-new item added to the course.
## Exercise Completion Gates
Exercises have two independent controls that affect student completion:
- \`completionPolicy: "submitted"\` — submitting an attempt marks the exercise complete.
- \`completionPolicy: "passed"\` plus \`passThreshold: <0-100>\` — the student must score at least that percentage before the exercise is complete.
When the teacher says students must "pass", "hit a score", "meet a threshold", "get 70%", "score at least X%", or similar, use \`update_exercise\` with \`completionPolicy: "passed"\` and the requested \`passThreshold\`. If the teacher says students must complete/pass an exercise **before progressing to later course items**, also make sure the course uses sequential progression by calling \`update_course_landing_page\` with \`metadata.progressionMode: "sequential"\` when it is not already sequential. Sequential progression decides whether later items are locked; the exercise completion policy decides whether this exercise counts as complete after any submission or only after a passing score.
## Editing Existing Questions
To change an existing question (its text, points, order, in-exercise section, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Use \`exerciseSectionId\` on update_questions to move a question to another block from get_exercise_details \`sections[].id\`, or null to unassign. When adding questions with add_questions, pass exerciseSectionId from get_exercise_details \`sections[].id\` if the exercise has multiple in-exercise blocks so new items land in the right group. Where the correct answer lives depends on the question type:
- RADIO, CHECKBOX, TRUE_FALSE: 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, vary True/False answers, and avoid leaning on "all of the above"-style options.
- 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\`.
- 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\`.
+35 -6
View File
@@ -138,6 +138,12 @@ export const createExerciseSchema = {
),
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({
@@ -146,7 +152,7 @@ export const createExerciseSchema = {
})
)
.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. Leave empty for NUMERIC/STAR/WORD_BANK.'
'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.'
)
})
)
@@ -189,7 +195,7 @@ export const updateExerciseSectionSchema = {
export const updateExerciseSchema = {
description:
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this to edit the exercise itself — not its questions. To change questions, use update_questions; to add questions, use add_questions.",
'Update an existing exercise\'s metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts, completion policy, passing threshold). Use this to edit the exercise itself — not its questions. To require students to pass before progress/completion, set completionPolicy to "passed" and passThreshold to the required percentage. To only require submission, set completionPolicy to "submitted". To change questions, use update_questions; to add questions, use add_questions.',
parameters: z
.object({
courseId: z.string().describe('The course ID'),
@@ -207,7 +213,22 @@ export const updateExerciseSchema = {
.optional()
.describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'),
isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'),
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.')
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.'),
completionPolicy: z
.enum(['submitted', 'passed'])
.optional()
.describe(
'"submitted" means any submitted attempt completes the exercise. "passed" means the student must meet or exceed passThreshold before the exercise is considered complete.'
),
passThreshold: z
.number()
.int()
.min(0)
.max(100)
.optional()
.describe(
'Passing score percentage from 0 to 100. Use with completionPolicy "passed" when the teacher asks students to pass a threshold before progressing.'
)
})
.refine(
(data) =>
@@ -218,7 +239,9 @@ export const updateExerciseSchema = {
data.order !== undefined ||
data.dueBy !== undefined ||
data.isUnlocked !== undefined ||
data.allowMultipleAttempts !== undefined,
data.allowMultipleAttempts !== undefined ||
data.completionPolicy !== undefined ||
data.passThreshold !== undefined,
{
message: 'Provide at least one field to update'
}
@@ -251,6 +274,12 @@ export const addQuestionsSchema = {
),
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'),
@@ -264,7 +293,7 @@ export const addQuestionsSchema = {
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 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/TRUE_FALSE 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.',
'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.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
@@ -293,7 +322,7 @@ export const updateQuestionsSchema = {
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }. Shallow-merged with existing settings.'
'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.'
),
options: z
.array(