|
1 | | -import { z } from 'zod'; |
2 | | - |
3 | | -const ContentBlockYamlSchema = z.discriminatedUnion('type', [ |
4 | | - z.object({ |
5 | | - type: z.literal('image'), |
6 | | - url: z.string().min(1), |
7 | | - alt: z.string().min(1), |
8 | | - caption: z.string().optional(), |
9 | | - width: z.number().int().positive().optional(), |
10 | | - }), |
11 | | - z.object({ |
12 | | - type: z.literal('video'), |
13 | | - url: z.string().min(1), |
14 | | - title: z.string().min(1), |
15 | | - caption: z.string().optional(), |
16 | | - }), |
17 | | - z.object({ |
18 | | - type: z.literal('link'), |
19 | | - url: z.string().min(1), |
20 | | - title: z.string().min(1), |
21 | | - description: z.string().optional(), |
22 | | - }), |
23 | | - z.object({ |
24 | | - type: z.literal('callout'), |
25 | | - title: z.string().min(1), |
26 | | - body: z.string().min(1), |
27 | | - }), |
28 | | -]); |
29 | | - |
30 | | -const ProblemYamlSchema = z.object({ |
31 | | - id: z.string(), |
32 | | - type: z.enum([ |
33 | | - 'multiple_choice', |
34 | | - 'fill_blank', |
35 | | - 'true_false', |
36 | | - 'ordering', |
37 | | - 'matching', |
38 | | - 'scenario', |
39 | | - ]), |
40 | | - question: z.string(), |
41 | | - options: z.array(z.string()).optional(), |
42 | | - correct: z.union([z.number(), z.string()]), |
43 | | - explanation: z.string().optional(), |
44 | | - difficulty: z.number().int().min(1).max(5).optional(), |
45 | | -}); |
46 | | - |
47 | | -const KnowledgePointYamlSchema = z.object({ |
48 | | - id: z.string(), |
49 | | - instruction: z.string().optional(), |
50 | | - instructionContent: z.array(ContentBlockYamlSchema).optional().default([]), |
51 | | - workedExample: z.string().optional(), |
52 | | - workedExampleContent: z.array(ContentBlockYamlSchema).optional().default([]), |
53 | | - problems: z.array(ProblemYamlSchema).optional().default([]), |
54 | | -}); |
55 | | - |
56 | | -const EncompassingRefSchema = z.object({ |
57 | | - concept: z.string(), |
58 | | - weight: z.number().min(0).max(1), |
59 | | -}); |
60 | | - |
61 | | -const ConceptYamlSchema = z.object({ |
62 | | - id: z.string(), |
63 | | - name: z.string(), |
64 | | - section: z.string().optional(), |
65 | | - difficulty: z.number().int().min(1).max(10), |
66 | | - estimatedMinutes: z.number().int().positive(), |
67 | | - tags: z.array(z.string()).default([]), |
68 | | - sourceRef: z.string().optional(), |
69 | | - prerequisites: z.array(z.string()).optional().default([]), |
70 | | - encompassing: z.array(EncompassingRefSchema).optional().default([]), |
71 | | - knowledgePoints: z.array(KnowledgePointYamlSchema).optional().default([]), |
72 | | -}); |
73 | | - |
74 | | -const SectionExamBlueprintYamlSchema = z.object({ |
75 | | - conceptId: z.string(), |
76 | | - minQuestions: z.number().int().positive(), |
77 | | -}); |
78 | | - |
79 | | -const SectionExamYamlSchema = z.object({ |
80 | | - enabled: z.boolean().default(true), |
81 | | - passingScore: z.number().min(0).max(1).default(0.75), |
82 | | - timeLimitMinutes: z.number().int().positive().optional(), |
83 | | - questionCount: z.number().int().positive().default(10), |
84 | | - blueprint: z.array(SectionExamBlueprintYamlSchema).default([]), |
85 | | - instructions: z.string().optional(), |
86 | | -}); |
87 | | - |
88 | | -const SectionYamlSchema = z.object({ |
89 | | - id: z.string(), |
90 | | - name: z.string(), |
91 | | - description: z.string().optional(), |
92 | | - sectionExam: SectionExamYamlSchema.optional(), |
93 | | -}); |
94 | | - |
95 | | -const CourseMetaSchema = z.object({ |
96 | | - id: z.string(), |
97 | | - name: z.string(), |
98 | | - description: z.string().optional(), |
99 | | - estimatedHours: z.number().positive(), |
100 | | - version: z.string(), |
101 | | - sourceDocument: z.string().optional(), |
102 | | -}); |
103 | | - |
104 | | -export const CourseYamlSchema = z |
105 | | - .object({ |
106 | | - course: CourseMetaSchema, |
107 | | - sections: z.array(SectionYamlSchema).optional().default([]), |
108 | | - concepts: z.array(ConceptYamlSchema), |
109 | | - }) |
110 | | - .superRefine((data, ctx) => { |
111 | | - const conceptIds = new Set(data.concepts.map((concept) => concept.id)); |
112 | | - const sectionIds = new Set(data.sections.map((section) => section.id)); |
113 | | - const conceptToSection = new Map( |
114 | | - data.concepts |
115 | | - .filter((concept) => concept.section) |
116 | | - .map((concept) => [concept.id, concept.section as string]), |
117 | | - ); |
118 | | - |
119 | | - for (const concept of data.concepts) { |
120 | | - if (concept.section && !sectionIds.has(concept.section)) { |
121 | | - ctx.addIssue({ |
122 | | - code: z.ZodIssueCode.custom, |
123 | | - message: `Concept "${concept.id}" references unknown section "${concept.section}"`, |
124 | | - }); |
125 | | - } |
126 | | - } |
127 | | - |
128 | | - for (const section of data.sections) { |
129 | | - const exam = section.sectionExam; |
130 | | - if (!exam?.enabled) { |
131 | | - continue; |
132 | | - } |
133 | | - |
134 | | - const sectionConcepts = data.concepts.filter( |
135 | | - (concept) => concept.section === section.id, |
136 | | - ); |
137 | | - |
138 | | - if (sectionConcepts.length < 2) { |
139 | | - ctx.addIssue({ |
140 | | - code: z.ZodIssueCode.custom, |
141 | | - message: `Section "${section.id}" needs at least two concepts when sectionExam is enabled`, |
142 | | - }); |
143 | | - } |
144 | | - |
145 | | - const minQuestions = exam.blueprint.reduce( |
146 | | - (sum, item) => sum + item.minQuestions, |
147 | | - 0, |
148 | | - ); |
149 | | - if (exam.questionCount < minQuestions) { |
150 | | - ctx.addIssue({ |
151 | | - code: z.ZodIssueCode.custom, |
152 | | - message: `Section "${section.id}" questionCount must be at least the sum of blueprint minQuestions`, |
153 | | - }); |
154 | | - } |
155 | | - |
156 | | - const availableProblems = sectionConcepts.reduce( |
157 | | - (sum, concept) => |
158 | | - sum + |
159 | | - concept.knowledgePoints.reduce( |
160 | | - (kpSum, kp) => kpSum + kp.problems.length, |
161 | | - 0, |
162 | | - ), |
163 | | - 0, |
164 | | - ); |
165 | | - |
166 | | - if (availableProblems < exam.questionCount) { |
167 | | - ctx.addIssue({ |
168 | | - code: z.ZodIssueCode.custom, |
169 | | - message: `Section "${section.id}" does not have enough eligible problems for its section exam`, |
170 | | - }); |
171 | | - } |
172 | | - |
173 | | - for (const item of exam.blueprint) { |
174 | | - if (!conceptIds.has(item.conceptId)) { |
175 | | - ctx.addIssue({ |
176 | | - code: z.ZodIssueCode.custom, |
177 | | - message: `Section "${section.id}" blueprint references unknown concept "${item.conceptId}"`, |
178 | | - }); |
179 | | - continue; |
180 | | - } |
181 | | - |
182 | | - if (conceptToSection.get(item.conceptId) !== section.id) { |
183 | | - ctx.addIssue({ |
184 | | - code: z.ZodIssueCode.custom, |
185 | | - message: `Section "${section.id}" blueprint concept "${item.conceptId}" must belong to the same section`, |
186 | | - }); |
187 | | - } |
188 | | - } |
189 | | - } |
190 | | - }); |
191 | | - |
192 | | -export type CourseYaml = z.infer<typeof CourseYamlSchema>; |
193 | | -export type SectionYaml = z.infer<typeof SectionYamlSchema>; |
194 | | -export type SectionExamYaml = z.infer<typeof SectionExamYamlSchema>; |
195 | | -export type SectionExamBlueprintYaml = z.infer< |
196 | | - typeof SectionExamBlueprintYamlSchema |
197 | | ->; |
198 | | -export type ConceptYaml = z.infer<typeof ConceptYamlSchema>; |
199 | | -export type KnowledgePointYaml = z.infer<typeof KnowledgePointYamlSchema>; |
200 | | -export type ProblemYaml = z.infer<typeof ProblemYamlSchema>; |
201 | | -export type ContentBlockYaml = z.infer<typeof ContentBlockYamlSchema>; |
| 1 | +export { |
| 2 | + CourseYamlSchema, |
| 3 | + type CourseYaml, |
| 4 | + type SectionYaml, |
| 5 | + type SectionExamYaml, |
| 6 | + type SectionExamBlueprintYaml, |
| 7 | + type ConceptYaml, |
| 8 | + type KnowledgePointYaml, |
| 9 | + type ProblemYaml, |
| 10 | + type ContentBlockYaml, |
| 11 | +} from '@graspful/shared'; |
0 commit comments