343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
import { z } from 'zod';
|
||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
||
import {
|
||
LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT,
|
||
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 { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
|
||
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
|
||
|
||
// courseId is NOT a parameter — it's injected from the authenticated request context.
|
||
// This prevents prompt injection from tricking the LLM into targeting another course.
|
||
|
||
export const emptyParam = z.object({});
|
||
export const lessonReadParam = z.object({ lessonId: z.string(), locale: z.string().default('en') });
|
||
export const exerciseReadParam = z.object({ exerciseId: z.string() });
|
||
export const createSectionParam = z.object({ title: z.string().min(1), order: z.number().int().min(0) });
|
||
export const updateSectionParam = z
|
||
.object({
|
||
sectionId: z.string(),
|
||
title: z.string().min(1).optional(),
|
||
order: z.number().int().min(0).optional()
|
||
})
|
||
.refine((data) => data.title !== undefined || data.order !== undefined, {
|
||
message: 'Provide at least one field to update'
|
||
});
|
||
export const createLessonParam = z.object({
|
||
sectionId: z.string(),
|
||
title: z.string().min(1),
|
||
order: z.number().int().min(0)
|
||
});
|
||
export const updateLessonParam = z
|
||
.object({
|
||
lessonId: z.string(),
|
||
title: z.string().min(1).optional(),
|
||
sectionId: z.string().optional(),
|
||
order: z.number().int().min(0).optional(),
|
||
lessonAt: z.string().optional(),
|
||
callUrl: z.string().optional(),
|
||
isUnlocked: z.boolean().optional(),
|
||
public: z.boolean().optional()
|
||
})
|
||
.refine(
|
||
(data) =>
|
||
data.title !== undefined ||
|
||
data.sectionId !== undefined ||
|
||
data.order !== undefined ||
|
||
data.lessonAt !== undefined ||
|
||
data.callUrl !== undefined ||
|
||
data.isUnlocked !== undefined ||
|
||
data.public !== undefined,
|
||
{
|
||
message: 'Provide at least one field to update'
|
||
}
|
||
);
|
||
export const updateContentParam = z.object({
|
||
lessonId: z.string(),
|
||
locale: z.string().default('en'),
|
||
content: z.string().min(1)
|
||
});
|
||
|
||
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.'
|
||
),
|
||
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() }))
|
||
});
|
||
|
||
export const createExerciseParam = z.object({
|
||
lessonId: z.string().optional(),
|
||
sectionId: z.string().optional(),
|
||
title: z.string().min(1),
|
||
description: z.string().optional().describe('Optional short description shown to students above the questions.'),
|
||
order: z
|
||
.number()
|
||
.int()
|
||
.min(0)
|
||
.optional()
|
||
.describe(
|
||
'Display order within the section (0-based). Required when sectionId is provided. Lessons and exercises share the same order space within a section.'
|
||
),
|
||
questions: z.array(questionSchema)
|
||
});
|
||
|
||
export const updateExerciseParam = z
|
||
.object({
|
||
exerciseId: z.string().describe('The exercise to update. Must be a real UUID returned by a prior tool call.'),
|
||
title: z.string().min(1).optional().describe('New exercise title. Omit to keep unchanged.'),
|
||
description: z
|
||
.string()
|
||
.optional()
|
||
.describe('New short description shown to students. Pass an empty string to clear it.'),
|
||
lessonId: z.string().optional().describe('Link the exercise to a lesson in this course.'),
|
||
sectionId: z.string().optional().describe('Move the exercise to a section in this course.'),
|
||
order: z.number().int().min(0).optional().describe('New order within the section (0-based).'),
|
||
dueBy: z
|
||
.string()
|
||
.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.')
|
||
})
|
||
.refine(
|
||
(data) =>
|
||
data.title !== undefined ||
|
||
data.description !== undefined ||
|
||
data.lessonId !== undefined ||
|
||
data.sectionId !== undefined ||
|
||
data.order !== undefined ||
|
||
data.dueBy !== undefined ||
|
||
data.isUnlocked !== undefined ||
|
||
data.allowMultipleAttempts !== undefined,
|
||
{
|
||
message: 'Provide at least one field to update'
|
||
}
|
||
);
|
||
|
||
export const updateExerciseSectionParam = z
|
||
.object({
|
||
exerciseId: z
|
||
.string()
|
||
.describe('The exercise that contains this section. Must match the exercise you read with get_exercise_details.'),
|
||
exerciseSectionId: z
|
||
.string()
|
||
.describe('The exercise section id from get_exercise_details `sections[].id` — not a course section id.'),
|
||
title: z.string().min(1).optional().describe('New section heading shown above questions in this block.'),
|
||
description: z.string().optional().describe('Optional intro HTML for this block. Pass an empty string to clear it.')
|
||
})
|
||
.refine((data) => data.title !== undefined || data.description !== undefined, {
|
||
message: 'Provide at least title or description to update'
|
||
});
|
||
|
||
export const createExerciseSectionParam = z.object({
|
||
exerciseId: z.string().describe('The exercise that will contain this section.'),
|
||
title: z.string().min(1).describe('Section heading shown above the questions in this block.'),
|
||
description: z
|
||
.string()
|
||
.optional()
|
||
.describe('Optional intro HTML for this block. Pass an empty string to leave it blank.'),
|
||
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: ZExerciseSectionAfterBehavior.optional()
|
||
});
|
||
|
||
export const addQuestionsParam = z.object({
|
||
exerciseId: z.string(),
|
||
exerciseSectionId: z
|
||
.string()
|
||
.uuid()
|
||
.optional()
|
||
.describe(
|
||
'In-exercise section id from get_exercise_details `sections[].id` where the new questions should appear (not a course outline section id).'
|
||
),
|
||
questions: z.array(questionSchema)
|
||
});
|
||
|
||
export const updateQuestionSettingsSchema = z
|
||
.object({
|
||
correctValue: z.union([z.number(), z.string(), z.boolean()]).optional(),
|
||
tolerance: z.number().min(0).optional(),
|
||
correctAnswers: z.array(z.string().min(1)).optional(),
|
||
template: z.string().min(1).optional(),
|
||
maxStars: z.number().int().min(1).optional(),
|
||
caseSensitive: z.boolean().optional(),
|
||
distractors: z.array(z.string().min(1)).optional()
|
||
})
|
||
.passthrough()
|
||
.refine((settings) => Object.keys(settings).length > 0, {
|
||
message: 'Provide at least one settings field'
|
||
})
|
||
.describe(
|
||
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
|
||
);
|
||
|
||
export const updateOptionSchema = z.object({
|
||
id: z.number().int().optional(),
|
||
label: z.string().min(1).optional(),
|
||
isCorrect: z.boolean().optional(),
|
||
settings: updateQuestionSettingsSchema.optional()
|
||
});
|
||
|
||
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(),
|
||
points: z.number().min(0).optional(),
|
||
order: z.number().int().min(0).optional(),
|
||
exerciseSectionId: z
|
||
.string()
|
||
.uuid()
|
||
.nullable()
|
||
.optional()
|
||
.describe(
|
||
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
|
||
),
|
||
settings: updateQuestionSettingsSchema.optional(),
|
||
options: z.array(updateOptionSchema).optional()
|
||
})
|
||
.refine(
|
||
(patch) =>
|
||
patch.question !== undefined ||
|
||
patch.questionTypeId !== undefined ||
|
||
patch.points !== undefined ||
|
||
patch.order !== undefined ||
|
||
patch.exerciseSectionId !== undefined ||
|
||
patch.settings !== undefined ||
|
||
patch.options !== undefined,
|
||
{
|
||
message: 'Provide at least one field to update besides id'
|
||
}
|
||
);
|
||
|
||
export const updateQuestionsParam = z.object({
|
||
exerciseId: z.string(),
|
||
questions: z.array(updateQuestionPatchSchema).min(1)
|
||
});
|
||
export const coursePlanParam = z.object({ plan: CoursePlanSchema });
|
||
|
||
const courseTemplateIdParam = z.enum(['product_101', 'product_onboarding', 'expert_on_x']);
|
||
|
||
const agentTemplateFormFieldBase = z.object({
|
||
id: z.string().min(1),
|
||
label: z.string().min(1),
|
||
description: z.string().optional(),
|
||
required: z.boolean().optional(),
|
||
placeholder: z.string().optional()
|
||
});
|
||
|
||
const agentTemplateFormFieldParam = z.discriminatedUnion('type', [
|
||
agentTemplateFormFieldBase.extend({ type: z.literal('text') }),
|
||
agentTemplateFormFieldBase.extend({ type: z.literal('textarea') }),
|
||
agentTemplateFormFieldBase.extend({ type: z.literal('url') }),
|
||
agentTemplateFormFieldBase.extend({
|
||
type: z.literal('select'),
|
||
options: z.array(z.object({ value: z.string().min(1), label: z.string().min(1) })).min(1)
|
||
})
|
||
]);
|
||
|
||
export const askTemplateQuestionsParam = z.object({
|
||
templateId: courseTemplateIdParam,
|
||
title: z.string().min(1),
|
||
description: z.string().optional(),
|
||
fields: z.array(agentTemplateFormFieldParam).min(1)
|
||
});
|
||
|
||
export const fetchDocumentationUrlParam = z.object({
|
||
url: z.string().url()
|
||
});
|
||
|
||
// Gemini's tool-schema validator only accepts string enums, so numeric/boolean
|
||
// `z.literal` values must be relaxed to plain types in the schemas the model sees.
|
||
export const agentLessonTabsOrder = z.array(
|
||
z.object({
|
||
id: z.number().int().min(1).max(4),
|
||
name: z.string()
|
||
})
|
||
);
|
||
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
|
||
lessonTabsOrder: agentLessonTabsOrder.optional(),
|
||
requirements: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||
description: z.string().optional().describe(LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT),
|
||
goals: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||
instructor: z
|
||
.object({
|
||
name: z.string().optional(),
|
||
role: z.string().optional(),
|
||
coursesNo: z.number().optional(),
|
||
description: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||
imgUrl: z.string().optional()
|
||
})
|
||
.optional()
|
||
});
|
||
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({
|
||
title: z.string().min(1).optional().describe('Plain-text public course title (no HTML).'),
|
||
description: z.string().min(1).optional().describe(LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT),
|
||
overview: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||
generateImage: z
|
||
.boolean()
|
||
.optional()
|
||
.describe(
|
||
'Set true to auto-resolve a banner image from Unsplash. The server searches Unsplash using imageQuery if provided, otherwise the course title. Use this to fix a missing-banner blocker — do NOT ask the teacher to describe an image.'
|
||
),
|
||
imageQuery: z
|
||
.string()
|
||
.min(1)
|
||
.max(120)
|
||
.optional()
|
||
.describe('Optional Unsplash search query (1–120 chars). Omit to let the server use the course title.'),
|
||
metadata: agentLandingPageMetadataUpdate.optional()
|
||
}).refine(
|
||
(data) =>
|
||
data.title !== undefined ||
|
||
data.description !== undefined ||
|
||
data.overview !== undefined ||
|
||
data.cost !== undefined ||
|
||
data.currency !== undefined ||
|
||
data.imageUrl !== undefined ||
|
||
data.generateImage !== undefined ||
|
||
data.imageQuery !== undefined ||
|
||
data.metadata !== undefined,
|
||
{
|
||
message: 'Provide at least one landing-page field to update'
|
||
}
|
||
);
|
||
export const goLiveParam = z.object({
|
||
confirmPublish: z
|
||
.boolean()
|
||
.describe('Must be true. Only use this after the teacher explicitly asks to publish or go live.')
|
||
});
|
||
|
||
export const reorderContentParam = z.object({
|
||
sections: z
|
||
.array(
|
||
z.object({
|
||
id: z.string().min(1),
|
||
order: z.number().int().min(0)
|
||
})
|
||
)
|
||
.optional()
|
||
.describe('Reorder sections by setting new order values'),
|
||
items: z
|
||
.array(
|
||
z.object({
|
||
id: z.string().min(1),
|
||
type: z.enum(['LESSON', 'EXERCISE']),
|
||
order: z.number().int().min(0).optional().describe('New order within the section'),
|
||
sectionId: z.string().nullable().optional().describe('Move item to a different section')
|
||
})
|
||
)
|
||
.optional()
|
||
.describe('Reorder or move lessons/exercises')
|
||
});
|