2026-05-09 06:05:03 +01:00
import { z } from 'zod' ;
import { CoursePlanSchema } from '@cio/ai-assistant' ;
2026-05-18 03:47:22 +01:00
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' ;
2026-05-09 06:05:03 +01:00
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 ),
2026-05-18 03:47:22 +01:00
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.'
),
2026-05-09 06:05:03 +01:00
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 )
});
2026-05-18 03:47:22 +01:00
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 }.'
);
2026-05-09 06:05:03 +01:00
export const updateOptionSchema = z . object ({
id : z.number (). int (). optional (),
label : z.string (). min ( 1 ). optional (),
isCorrect : z.boolean (). optional (),
2026-05-18 03:47:22 +01:00
settings : updateQuestionSettingsSchema.optional ()
2026-05-09 06:05:03 +01:00
});
2026-05-18 03:47:22 +01:00
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'
}
);
2026-05-09 06:05:03 +01:00
export const updateQuestionsParam = z . object ({
exerciseId : z.string (),
questions : z.array ( updateQuestionPatchSchema ). min ( 1 )
});
export const coursePlanParam = z . object ({ plan : CoursePlanSchema });
2026-05-18 03:47:22 +01:00
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 ()
});
2026-05-09 06:05:03 +01:00
// 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 ({
2026-05-18 03:47:22 +01:00
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 ()
2026-05-09 06:05:03 +01:00
});
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate . extend ({
2026-05-18 03:47:22 +01:00
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.' ),
2026-05-09 06:05:03 +01:00
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' )
});