chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
+22 -5
View File
@@ -1,5 +1,8 @@
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import type { AgentContext } from '../types';
const QUESTION_TYPE_LIST = QUESTION_TYPE_REGISTRY.map((t) => `- ${t.id} = ${t.typename} — ${t.label}`).join('\n');
export function buildTeacherSystemPrompt(context: AgentContext): string {
const contextLines: string[] = [];
@@ -43,7 +46,13 @@ export function buildTeacherSystemPrompt(context: AgentContext): string {
## Your Capabilities
You have access to specific tools listed below. Use them to read course content, create sections and lessons, update existing section metadata via update_section, update existing lesson metadata via update_lesson, write lesson content, create exercises with questions, edit exercise metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts) via update_exercise, add or update questions inside an existing exercise, 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, 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.
## Question Types
These are the only question type IDs supported by this platform. Always use these IDs when creating or updating questions — never infer type IDs from exercise data, which only shows which types happen to be in use:
${QUESTION_TYPE_LIST}
## Plan Mode vs Agent Mode
@@ -68,6 +77,13 @@ You have access to specific tools listed below. Use them to read course content,
4. Report progress as you go
5. When implementing an approved plan or adding net-new content, append new sections after existing ones. Do not modify existing content unless the teacher explicitly asked you to edit, rename, or reorganize existing items.
**Resuming / Continuing Plan Execution** — When the teacher sends a continuation signal (e.g. "continue", "keep going", "proceed", "resume") after a previous step-limit pause:
1. Call get_course_structure FIRST before creating anything.
2. Compare the existing sections, lessons, and exercises against the approved plan — match by title.
3. Skip every item that already exists. Do NOT recreate a section, lesson, or exercise that is already present in the course.
4. If a section already exists but some of its items (lessons or exercises) are missing, resume inside that existing section — do NOT create a duplicate section.
5. Begin creating only from the first item in the plan that is not yet present, then continue through the rest of the plan in order.
**Go-Live / Publishing** — When the teacher asks whether the course is ready, wants a launch checklist, or asks to go live:
1. Use check_course_go_live_readiness first to inspect required course details, landing-page fields, lessons, and exercises
2. If blockers are returned, explain the blockers clearly and use available tools to fix only the items the teacher asks you to fix
@@ -93,26 +109,27 @@ 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, section, 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, or multiple-attempt behavior, 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.
## Editing Existing Questions
To change an existing question (its text, points, order, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Where the correct answer lives depends on the question type:
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.
- NUMERIC: on \`settings.correctValue\` (a number). Optional \`settings.tolerance\`. Do NOT add options to NUMERIC questions.
- STAR: on \`settings.correctValue\` (1..max stars).
- WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`.
Always call get_exercise_details first to read current ids and settings before patching.
Always call get_exercise_details first to read current question ids, in-exercise section ids (for update_exercise_section), and settings before patching.
## IDs and Tool Arguments
- UUIDs and database IDs must be copied EXACTLY from the most recent tool output that produced them. Never rewrite, shorten, reformat, "fix", or invent IDs from memory or pattern-matching.
- NEVER pass placeholder strings like \`"string"\`, \`"uuid"\`, \`"<id>"\`, or example IDs from this prompt as tool arguments. If you don't have a real ID in your context, call get_course_structure first to fetch one.
- Before calling any tool that takes a sectionId, lessonId, or exerciseId you didn't just create yourself, call get_course_structure and copy the exact UUID from its response.
- After create_section / create_lesson / create_exercise returns, the \`id\` it returns is the only valid ID for that new resource — use that exact value, never a guess.
- After create_section / create_lesson / create_exercise / create_exercise_section returns, the \`id\` it returns is the only valid ID for that new resource — use that exact value, never a guess.
- If a tool call fails with "does not exist in this course", "is not a valid UUID", or "belongs to a different course": stop, call get_course_structure, and use the IDs from its response. Do NOT retry with another guessed ID.
- Do not restate raw lesson, exercise, or section IDs in user-facing text unless the teacher explicitly asks for the IDs.
- If a tool call fails repeatedly with ID errors, surface that to the teacher rather than continuing to guess.
+10 -3
View File
@@ -1,18 +1,21 @@
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createMoonshotAI } from '@ai-sdk/moonshotai';
import { AIProvider, type AIProviderConfig } from '../types';
const DEFAULT_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o',
[AIProvider.OPENAI]: 'gpt-5.4-mini',
[AIProvider.ANTHROPIC]: 'claude-sonnet-4-20250514',
[AIProvider.GOOGLE]: 'gemini-2.5-flash'
[AIProvider.GOOGLE]: 'gemini-2.5-flash',
[AIProvider.MOONSHOT]: 'kimi-k2.6'
};
const PROVIDER_API_KEY_ENV: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'OPENAI_API_KEY',
[AIProvider.ANTHROPIC]: 'ANTHROPIC_API_KEY',
[AIProvider.GOOGLE]: 'GOOGLE_API_KEY'
[AIProvider.GOOGLE]: 'GOOGLE_API_KEY',
[AIProvider.MOONSHOT]: 'MOONSHOT_API_KEY'
};
/**
@@ -35,6 +38,10 @@ export function createModel(config: AIProviderConfig) {
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
return google(modelName);
}
case AIProvider.MOONSHOT: {
const moonshot = createMoonshotAI({ apiKey: config.apiKey });
return moonshot(modelName);
}
default:
throw new Error(`Unsupported AI provider: ${config.provider}`);
}
+6
View File
@@ -8,7 +8,9 @@ import {
updateLessonSchema,
updateLessonContentSchema,
createExerciseSchema,
createExerciseSectionSchema,
updateExerciseSchema,
updateExerciseSectionSchema,
addQuestionsSchema,
updateQuestionsSchema,
updateCourseLandingPageSchema,
@@ -37,7 +39,9 @@ const teacherTools: ToolRegistry = {
[ToolName.UPDATE_LESSON]: updateLessonSchema,
[ToolName.UPDATE_LESSON_CONTENT]: updateLessonContentSchema,
[ToolName.CREATE_EXERCISE]: createExerciseSchema,
[ToolName.CREATE_EXERCISE_SECTION]: createExerciseSectionSchema,
[ToolName.UPDATE_EXERCISE]: updateExerciseSchema,
[ToolName.UPDATE_EXERCISE_SECTION]: updateExerciseSectionSchema,
[ToolName.ADD_QUESTIONS]: addQuestionsSchema,
[ToolName.UPDATE_QUESTIONS]: updateQuestionsSchema,
[ToolName.UPDATE_COURSE_LANDING_PAGE]: updateCourseLandingPageSchema,
@@ -76,7 +80,9 @@ export {
updateLessonSchema,
updateLessonContentSchema,
createExerciseSchema,
createExerciseSectionSchema,
updateExerciseSchema,
updateExerciseSectionSchema,
addQuestionsSchema,
updateQuestionsSchema,
updateCourseLandingPageSchema,
+67 -7
View File
@@ -1,6 +1,9 @@
import { z } from 'zod';
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import { CoursePlanSchema } from '../types';
const QUESTION_TYPE_ID_DESCRIPTION = QUESTION_TYPE_REGISTRY.map((t) => `${t.id}=${t.typename} (${t.label})`).join(', ');
/**
* Mutation tool schemas available only to teachers.
*/
@@ -15,6 +18,12 @@ export const createSectionSchema = {
})
};
const exerciseSectionAfterBehaviorSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('continue') }),
z.object({ action: z.literal('submit') }),
z.object({ action: z.literal('go_to_section'), exerciseSectionId: z.string().uuid() })
]);
export const updateSectionSchema = {
description:
'Update metadata for an existing section in the course. Use this to rename a section or change its display order instead of creating a new section.',
@@ -117,9 +126,9 @@ export const createExerciseSchema = {
.number()
.int()
.min(1)
.max(14)
.max(QUESTION_TYPE_REGISTRY.length)
.default(1)
.describe('Question type: 1=RADIO (single answer), 2=CHECKBOX (multiple), 4=TRUE_FALSE, 3=TEXTAREA'),
.describe(`Question type ID. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}`),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
options: z
@@ -141,6 +150,36 @@ export const createExerciseSchema = {
})
};
export const createExerciseSectionSchema = {
description:
'Create a new section inside an existing exercise. Use this to add a new question block before adding questions into it.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The exercise that will contain this section'),
title: z.string().min(1).describe('Section heading shown above questions in this block'),
description: z.string().optional().describe('Optional intro HTML for this block'),
order: z.number().int().min(0).describe('Display order within the exercise (0-based)'),
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).optional(),
afterBehavior: exerciseSectionAfterBehaviorSchema.optional()
})
};
export const updateExerciseSectionSchema = {
description:
'Update the title and/or description of one question block inside an exercise (from get_exercise_details sections). Not the same as update_section (course outline).',
parameters: z
.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The exercise containing this block'),
exerciseSectionId: z.string().describe('Section id from get_exercise_details sections[].id'),
title: z.string().min(1).optional().describe('Heading shown above questions in this block'),
description: z.string().optional().describe('Optional intro HTML for this block; empty string clears it')
})
.refine((data) => data.title !== undefined || data.description !== undefined, {
message: 'Provide at least title or description'
})
};
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.",
@@ -181,10 +220,17 @@ 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.',
'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).',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
exerciseSectionId: z
.string()
.uuid()
.optional()
.describe(
'In-exercise section id from get_exercise_details `sections[].id` (not a course outline section id). Use when the exercise has question blocks/sections; omit only when questions are not grouped into sections.'
),
questions: z.array(
z.object({
question: z.string().min(1).describe('The question text'),
@@ -192,9 +238,9 @@ export const addQuestionsSchema = {
.number()
.int()
.min(1)
.max(14)
.max(QUESTION_TYPE_REGISTRY.length)
.default(1)
.describe('Question type: 1=RADIO, 2=CHECKBOX, 4=TRUE_FALSE, 3=TEXTAREA'),
.describe(`Question type ID. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}`),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
options: z.array(
@@ -210,7 +256,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. 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 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.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
@@ -218,9 +264,23 @@ export const updateQuestionsSchema = {
z.object({
id: z.number().int().describe('Existing question id. Required.'),
question: z.string().min(1).optional().describe('New question text. Omit to keep unchanged.'),
questionTypeId: z.number().int().min(1).max(14).optional(),
questionTypeId: z
.number()
.int()
.min(1)
.max(QUESTION_TYPE_REGISTRY.length)
.optional()
.describe(`Question type ID. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}`),
points: z.number().min(0).optional(),
order: z.number().int().min(0).optional(),
exerciseSectionId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
'In-exercise section id from get_exercise_details (not course outline). Omit to leave unchanged; pass null to unassign from a section.'
),
settings: z
.record(z.string(), z.unknown())
.optional()
+4 -1
View File
@@ -14,7 +14,8 @@ export type AgentRole = (typeof AgentRole)[keyof typeof AgentRole];
export const AIProvider = {
OPENAI: 'openai',
ANTHROPIC: 'anthropic',
GOOGLE: 'google'
GOOGLE: 'google',
MOONSHOT: 'moonshot'
} as const;
export type AIProvider = (typeof AIProvider)[keyof typeof AIProvider];
@@ -92,7 +93,9 @@ export const ToolName = {
UPDATE_LESSON: 'update_lesson',
UPDATE_LESSON_CONTENT: 'update_lesson_content',
CREATE_EXERCISE: 'create_exercise',
CREATE_EXERCISE_SECTION: 'create_exercise_section',
UPDATE_EXERCISE: 'update_exercise',
UPDATE_EXERCISE_SECTION: 'update_exercise_section',
ADD_QUESTIONS: 'add_questions',
UPDATE_QUESTIONS: 'update_questions',
UPDATE_COURSE_LANDING_PAGE: 'update_course_landing_page',