@intellectif/lk-core 0.22.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +5 -5
  3. package/dist/{activity-CYc7SD3d.d.cts → activity-DgpBJeBc.d.cts} +11 -15
  4. package/dist/{activity-CYc7SD3d.d.ts → activity-DgpBJeBc.d.ts} +11 -15
  5. package/dist/ai-check.cjs +8 -8
  6. package/dist/ai-check.d.cts +1 -1
  7. package/dist/ai-check.d.ts +1 -1
  8. package/dist/ai-check.js +3 -3
  9. package/dist/{chunk-2CV6NU32.cjs → chunk-2JT2WUQ2.cjs} +77 -39
  10. package/dist/chunk-2JT2WUQ2.cjs.map +1 -0
  11. package/dist/{chunk-D4TOPSHV.cjs → chunk-DSDXL3JO.cjs} +1216 -1105
  12. package/dist/chunk-DSDXL3JO.cjs.map +1 -0
  13. package/dist/{chunk-D3GP5MET.js → chunk-FBKTION6.js} +1093 -982
  14. package/dist/chunk-FBKTION6.js.map +1 -0
  15. package/dist/{chunk-Q7MV3IXJ.js → chunk-IARLJ5MW.js} +15 -16
  16. package/dist/chunk-IARLJ5MW.js.map +1 -0
  17. package/dist/{chunk-5UCR4UNR.js → chunk-RCZCP5OE.js} +12 -7
  18. package/dist/chunk-RCZCP5OE.js.map +1 -0
  19. package/dist/{chunk-X5DAXBJI.js → chunk-SJVRUPJT.js} +2 -2
  20. package/dist/{chunk-EOFWSGLU.cjs → chunk-TJJFKKH2.cjs} +4 -4
  21. package/dist/{chunk-EOFWSGLU.cjs.map → chunk-TJJFKKH2.cjs.map} +1 -1
  22. package/dist/{chunk-WQVZPIN7.cjs → chunk-VSH7ECQL.cjs} +44 -45
  23. package/dist/chunk-VSH7ECQL.cjs.map +1 -0
  24. package/dist/{chunk-OPMY2MNS.cjs → chunk-W5KB3DNT.cjs} +19 -14
  25. package/dist/chunk-W5KB3DNT.cjs.map +1 -0
  26. package/dist/{chunk-Z7QQZ53L.js → chunk-XS6U2ARZ.js} +70 -32
  27. package/dist/chunk-XS6U2ARZ.js.map +1 -0
  28. package/dist/index-BVNeSCWC.d.ts +548 -0
  29. package/dist/index-BlOLlwOi.d.cts +548 -0
  30. package/dist/index.cjs +129 -206
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +115 -15
  33. package/dist/index.d.ts +115 -15
  34. package/dist/index.js +26 -103
  35. package/dist/index.js.map +1 -1
  36. package/dist/schemas.cjs +3 -94
  37. package/dist/schemas.cjs.map +1 -1
  38. package/dist/schemas.d.cts +2 -3
  39. package/dist/schemas.d.ts +2 -3
  40. package/dist/schemas.js +6 -97
  41. package/dist/scoring.cjs +3 -3
  42. package/dist/scoring.d.cts +2 -2
  43. package/dist/scoring.d.ts +2 -2
  44. package/dist/scoring.js +2 -2
  45. package/dist/xapi.cjs +3 -3
  46. package/dist/xapi.d.cts +1 -1
  47. package/dist/xapi.d.ts +1 -1
  48. package/dist/xapi.js +2 -2
  49. package/package.json +4 -4
  50. package/vectors/README.md +32 -2
  51. package/vectors/scoring.json +1 -498
  52. package/vectors/validation.json +6742 -0
  53. package/vectors/validation.mjs +195 -0
  54. package/dist/chunk-2CV6NU32.cjs.map +0 -1
  55. package/dist/chunk-5UCR4UNR.js.map +0 -1
  56. package/dist/chunk-D3GP5MET.js.map +0 -1
  57. package/dist/chunk-D4TOPSHV.cjs.map +0 -1
  58. package/dist/chunk-OPMY2MNS.cjs.map +0 -1
  59. package/dist/chunk-Q7MV3IXJ.js.map +0 -1
  60. package/dist/chunk-WQVZPIN7.cjs.map +0 -1
  61. package/dist/chunk-Z7QQZ53L.js.map +0 -1
  62. package/dist/index-0Z3-nnAP.d.ts +0 -2303
  63. package/dist/index-CxxiNSSH.d.cts +0 -2303
  64. /package/dist/{chunk-X5DAXBJI.js.map → chunk-SJVRUPJT.js.map} +0 -0
@@ -1,29 +1,32 @@
1
- // src/schemas/ai.ts
1
+ // src/schemas/media.ts
2
2
  import { z } from "zod/v4";
3
- var AiPermissionsSchema = z.looseObject({
4
- explanations: z.boolean().optional(),
5
- hints: z.boolean().optional()
6
- });
7
3
 
8
- // src/schemas/feedback.ts
9
- import { z as z2 } from "zod/v4";
10
- var FeedbackSchema = z2.looseObject({
11
- correct: z2.string().min(1).optional(),
12
- incorrect: z2.string().min(1).optional()
13
- });
4
+ // src/schemas/text-length.ts
5
+ function maxUnits(schema, max) {
6
+ return schema.check((payload) => {
7
+ if (payload.value.length > max) {
8
+ payload.issues.push({
9
+ code: "too_big",
10
+ origin: "string",
11
+ maximum: max,
12
+ inclusive: true,
13
+ input: payload.value
14
+ });
15
+ }
16
+ }).meta({ maxLength: max });
17
+ }
14
18
 
15
19
  // src/schemas/media.ts
16
- import { z as z3 } from "zod/v4";
17
- var MediaUrlSchema = z3.union([
18
- z3.url().refine((value) => /^(https?|data|blob):/i.test(value), {
20
+ var MediaUrlSchema = z.union([
21
+ z.url().refine((value) => /^(https?|data|blob):/i.test(value), {
19
22
  error: "Absolute media URLs must use the https:, http:, data:, or blob: scheme."
20
23
  }),
21
- z3.string().regex(/^\/(?!\/)\S*$/, {
24
+ z.string().regex(/^\/(?!\/)\S*$/, {
22
25
  error: 'Relative media URLs must be root-relative (a single leading "/").'
23
26
  })
24
27
  ]);
25
- var NativeControlHintSchema = z3.enum(["hide-download", "hide-rate"]);
26
- var MediaPlaybackSchema = z3.strictObject({
28
+ var NativeControlHintSchema = z.enum(["hide-download", "hide-rate"]);
29
+ var MediaPlaybackSchema = z.strictObject({
27
30
  /**
28
31
  * `native` (the resolved default) renders today's `<audio controls>`
29
32
  * unchanged. `minimal` — resolved automatically whenever any enforcement
@@ -31,29 +34,31 @@ var MediaPlaybackSchema = z3.strictObject({
31
34
  * play/pause, elapsed/total, mute, volume, optional speed, optional
32
35
  * scrubber, and a live plays-remaining status.
33
36
  */
34
- controls: z3.enum(["native", "minimal"]).optional(),
37
+ controls: z.enum(["native", "minimal"]).optional(),
35
38
  /**
36
39
  * How many times the recording may be STARTED. A play is consumed when
37
40
  * playback begins from anywhere other than where it last stopped, so
38
41
  * pausing, resuming, and paging between the questions of one listening
39
42
  * group are all free. Enforced in `practice` and `exam`; never in `review`.
40
43
  */
41
- maxPlays: z3.number().int().min(1).max(20).optional(),
44
+ maxPlays: z.number().int().min(1).max(20).optional(),
42
45
  /** `none` renders no scrubber and reverts an out-of-band seek to the high-water mark. */
43
- seek: z3.enum(["allow", "none"]).optional(),
46
+ seek: z.enum(["allow", "none"]).optional(),
44
47
  /** `fixed` renders no speed control and snaps `playbackRate` back to 1. */
45
- rate: z3.enum(["allow", "fixed"]).optional(),
48
+ rate: z.enum(["allow", "fixed"]).optional(),
46
49
  /** Advisory only. See {@link NativeControlHintSchema}. */
47
- nativeControlHints: z3.array(NativeControlHintSchema).min(1).max(2).optional()
50
+ nativeControlHints: z.array(NativeControlHintSchema).min(1).max(2).optional()
48
51
  });
49
- var MediaTrackSchema = z3.strictObject({
50
- kind: z3.enum(["captions", "subtitles"]),
52
+ var MediaTrackSchema = z.strictObject({
53
+ kind: z.enum(["captions", "subtitles"]),
51
54
  src: MediaUrlSchema,
52
- srclang: z3.string().regex(/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, {
55
+ srclang: z.string().regex(/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, {
53
56
  error: 'srclang must be a BCP 47 language tag, such as "en", "es" or "pt-BR".'
54
57
  }),
55
- label: z3.string().max(60).refine((label) => label.trim().length > 0, { error: "A track label must not be empty." }),
56
- default: z3.boolean().optional()
58
+ label: maxUnits(z.string(), 60).refine((label) => label.trim().length > 0, {
59
+ error: "A track label must not be empty."
60
+ }),
61
+ default: z.boolean().optional()
57
62
  });
58
63
  var MAX_TRACKS = 12;
59
64
  function posterFits(m) {
@@ -76,12 +81,12 @@ var TRACKS_DISTINCT = {
76
81
  error: "tracks: at most one may be the default, and no two may share a kind and a language \u2014 a menu listing one language twice cannot say which one it plays.",
77
82
  path: ["tracks"]
78
83
  };
79
- var MediaSchema = z3.looseObject({
80
- type: z3.enum(["image", "audio", "video", "embed"]),
84
+ var MediaSchema = z.looseObject({
85
+ type: z.enum(["image", "audio", "video", "embed"]),
81
86
  url: MediaUrlSchema,
82
- alt: z3.string().min(1).optional(),
87
+ alt: z.string().min(1).optional(),
83
88
  captionsUrl: MediaUrlSchema.optional(),
84
- tracks: z3.array(MediaTrackSchema).min(1).max(MAX_TRACKS).optional(),
89
+ tracks: z.array(MediaTrackSchema).min(1).max(MAX_TRACKS).optional(),
85
90
  poster: MediaUrlSchema.optional(),
86
91
  playback: MediaPlaybackSchema.optional()
87
92
  }).refine(
@@ -118,12 +123,12 @@ var MediaSchema = z3.looseObject({
118
123
  path: ["playback", "nativeControlHints"]
119
124
  }
120
125
  );
121
- var RedactedMediaSchema = z3.strictObject({
122
- type: z3.enum(["image", "audio", "video", "embed"]),
126
+ var RedactedMediaSchema = z.strictObject({
127
+ type: z.enum(["image", "audio", "video", "embed"]),
123
128
  url: MediaUrlSchema,
124
- alt: z3.string().min(1).optional(),
129
+ alt: z.string().min(1).optional(),
125
130
  captionsUrl: MediaUrlSchema.optional(),
126
- tracks: z3.array(MediaTrackSchema).min(1).max(MAX_TRACKS).optional(),
131
+ tracks: z.array(MediaTrackSchema).min(1).max(MAX_TRACKS).optional(),
127
132
  poster: MediaUrlSchema.optional(),
128
133
  playback: MediaPlaybackSchema.optional()
129
134
  }).refine(
@@ -137,70 +142,6 @@ var RedactedMediaSchema = z3.strictObject({
137
142
  path: ["url"]
138
143
  }).refine(posterFits, POSTER_FITS).refine(tracksFit, TRACKS_FIT).refine(tracksAreDistinct, TRACKS_DISTINCT);
139
144
 
140
- // src/schemas/fill-in-the-blanks.ts
141
- import { z as z4 } from "zod/v4";
142
- var PLACEHOLDER_RE = /\{\{\s*([^{}]+?)\s*\}\}/g;
143
- var TextMatchPolicySchema = z4.looseObject({
144
- caseSensitive: z4.boolean().optional(),
145
- trim: z4.boolean().optional(),
146
- normalize: z4.enum(["none", "NFC", "NFKC"]).optional(),
147
- foldDiacritics: z4.boolean().optional(),
148
- collapseInnerWhitespace: z4.boolean().optional(),
149
- ignorePunctuation: z4.boolean().optional(),
150
- levenshtein: z4.number().int().min(0).optional(),
151
- locale: z4.string().optional()
152
- });
153
- var BlankConfigSchema = z4.looseObject({
154
- id: z4.string().min(1),
155
- acceptedAnswers: z4.array(
156
- z4.string().min(1).refine((answer) => answer.trim().length > 0, {
157
- error: "Accepted answers must contain non-whitespace characters."
158
- })
159
- ).min(1),
160
- caseSensitive: z4.boolean().optional(),
161
- trimWhitespace: z4.boolean().optional(),
162
- match: TextMatchPolicySchema.optional(),
163
- hint: z4.string().optional(),
164
- feedback: z4.string().optional()
165
- });
166
- var FillInTheBlanksDataSchema = z4.looseObject({
167
- schemaVersion: z4.literal("1.0"),
168
- type: z4.literal("fill-in-the-blanks"),
169
- id: z4.string().min(1),
170
- title: z4.string().min(1),
171
- passage: z4.string().min(1),
172
- passageHtml: z4.string().optional(),
173
- blanks: z4.array(BlankConfigSchema).min(1),
174
- scoringStrategy: z4.enum(["all-or-nothing", "partial"]),
175
- media: MediaSchema.optional(),
176
- feedback: FeedbackSchema.optional(),
177
- passThreshold: z4.number().min(0).max(1).optional(),
178
- locale: z4.string().optional(),
179
- learningObjectives: z4.array(z4.string()).optional(),
180
- difficultyLevel: z4.literal([1, 2, 3, 4, 5]).optional(),
181
- ai: AiPermissionsSchema.optional()
182
- }).refine(
183
- (data) => {
184
- const placeholderCounts2 = /* @__PURE__ */ new Map();
185
- for (const match of data.passage.matchAll(PLACEHOLDER_RE)) {
186
- const id = match[1];
187
- placeholderCounts2.set(id, (placeholderCounts2.get(id) ?? 0) + 1);
188
- }
189
- const blankIds = data.blanks.map((blank) => blank.id);
190
- if (new Set(blankIds).size !== blankIds.length) {
191
- return false;
192
- }
193
- if (placeholderCounts2.size !== blankIds.length) {
194
- return false;
195
- }
196
- return blankIds.every((id) => placeholderCounts2.get(id) === 1);
197
- },
198
- {
199
- error: "Each blank id must appear exactly once in blanks[] and have exactly one matching {{id}} placeholder in the passage, and vice versa.",
200
- path: ["passage"]
201
- }
202
- );
203
-
204
145
  // src/validation-scope.ts
205
146
  var active = null;
206
147
  function withValidationScope(run) {
@@ -1306,791 +1247,182 @@ var DeferredScoringError = class extends Error {
1306
1247
  activityType;
1307
1248
  };
1308
1249
 
1309
- // src/schemas/dictation.ts
1310
- import { z as z5 } from "zod/v4";
1311
- var DictationSlowMediaSchema = z5.looseObject({
1312
- type: z5.literal("audio"),
1250
+ // src/schemas/multiple-choice.ts
1251
+ import { z as z4 } from "zod/v4";
1252
+
1253
+ // src/schemas/ai.ts
1254
+ import { z as z2 } from "zod/v4";
1255
+ var AiPermissionsSchema = z2.looseObject({
1256
+ explanations: z2.boolean().optional(),
1257
+ hints: z2.boolean().optional()
1258
+ });
1259
+
1260
+ // src/schemas/feedback.ts
1261
+ import { z as z3 } from "zod/v4";
1262
+ var FeedbackSchema = z3.looseObject({
1263
+ correct: z3.string().min(1).optional(),
1264
+ incorrect: z3.string().min(1).optional()
1265
+ });
1266
+
1267
+ // src/schemas/multiple-choice.ts
1268
+ var MultipleChoiceOptionMediaSchema = z4.looseObject({
1269
+ // No `embed`: an iframe swallows the click that selects the option, so the
1270
+ // learner could not choose it. No `video`: a native control bar inside the
1271
+ // option's label eats the same click.
1272
+ type: z4.enum(["image", "audio"]),
1313
1273
  url: MediaUrlSchema,
1314
- alt: z5.string().min(1).optional()
1274
+ alt: z4.string().min(1).optional(),
1275
+ captionsUrl: MediaUrlSchema.optional()
1276
+ }).refine((media) => media.type !== "image" || media.alt !== void 0 && media.alt !== "", {
1277
+ error: "An image option requires a non-empty `alt`.",
1278
+ path: ["alt"]
1279
+ }).refine((media) => !("playback" in media), {
1280
+ error: "An option carries no playback policy. maxPlays, seek and rate are enforced only on the media above the question.",
1281
+ path: ["playback"]
1315
1282
  });
1316
- var RuleTextSchema = z5.string().min(1).meta({ maxLength: DICTATION_MAX_EQUIVALENCE_LENGTH });
1317
- var TranscriptTextSchema = z5.string().min(1).meta({ maxLength: DICTATION_MAX_TRANSCRIPT_LENGTH });
1318
- var DictationEquivalenceSchema = z5.looseObject({
1319
- from: RuleTextSchema,
1320
- to: RuleTextSchema
1321
- }).refine((rule) => codePointLength(rule.from) <= DICTATION_MAX_EQUIVALENCE_LENGTH, {
1322
- error: `A rule rewrites at most ${DICTATION_MAX_EQUIVALENCE_LENGTH} characters.`,
1323
- path: ["from"]
1324
- }).refine((rule) => codePointLength(rule.to) <= DICTATION_MAX_EQUIVALENCE_LENGTH, {
1325
- error: `A rewrite is at most ${DICTATION_MAX_EQUIVALENCE_LENGTH} characters.`,
1326
- path: ["to"]
1283
+ var MultipleChoiceOptionSchema = z4.looseObject({
1284
+ id: z4.string().min(1),
1285
+ text: z4.string().min(1),
1286
+ isCorrect: z4.boolean(),
1287
+ feedback: z4.string().optional(),
1288
+ media: MultipleChoiceOptionMediaSchema.optional()
1327
1289
  });
1328
- var DictationToleranceSchema = z5.looseObject({
1329
- equivalences: z5.array(DictationEquivalenceSchema).max(DICTATION_MAX_EQUIVALENCES, {
1330
- error: `A dictation carries at most ${DICTATION_MAX_EQUIVALENCES} equivalence rules.`
1331
- }).optional()
1290
+ var MultipleChoiceDataSchema = z4.looseObject({
1291
+ schemaVersion: z4.literal("1.0"),
1292
+ type: z4.literal("multiple-choice"),
1293
+ id: z4.string().min(1),
1294
+ title: z4.string().min(1),
1295
+ question: z4.string().min(1),
1296
+ questionHtml: z4.string().optional(),
1297
+ mode: z4.enum(["single", "multi"]),
1298
+ options: z4.array(MultipleChoiceOptionSchema).min(2).max(26),
1299
+ scoringStrategy: z4.enum(["all-or-nothing", "partial"]),
1300
+ media: MediaSchema.optional(),
1301
+ feedback: FeedbackSchema.optional(),
1302
+ passThreshold: z4.number().min(0).max(1).optional(),
1303
+ shuffle: z4.boolean().optional(),
1304
+ locale: z4.string().optional(),
1305
+ learningObjectives: z4.array(z4.string()).optional(),
1306
+ difficultyLevel: z4.literal([1, 2, 3, 4, 5]).optional(),
1307
+ ai: AiPermissionsSchema.optional()
1308
+ }).refine((data) => data.options.some((option) => option.isCorrect), {
1309
+ error: "At least one option must be marked correct.",
1310
+ path: ["options"]
1311
+ }).refine(
1312
+ (data) => data.mode !== "single" || data.options.filter((option) => option.isCorrect).length === 1,
1313
+ {
1314
+ error: 'Single-select activities (mode: "single") must have exactly one correct option.',
1315
+ path: ["options"]
1316
+ }
1317
+ ).refine((data) => new Set(data.options.map((option) => option.id)).size === data.options.length, {
1318
+ error: "Option ids must be unique within the activity.",
1319
+ path: ["options"]
1332
1320
  });
1333
- var normalizers = /* @__PURE__ */ new WeakMap();
1334
- function normalizerFor(data) {
1335
- let normalizer = normalizers.get(data);
1336
- if (normalizer === void 0) {
1337
- normalizer = dictationNormalizer(data.tolerance);
1338
- normalizers.set(data, normalizer);
1321
+
1322
+ // src/scoring/speech/limits.ts
1323
+ var READ_ALOUD_MAX_REFERENCE_LENGTH = DICTATION_MAX_TRANSCRIPT_LENGTH;
1324
+ var READ_ALOUD_MAX_SECONDS = 300;
1325
+ var READ_ALOUD_MAX_TAKES = 20;
1326
+ var READ_ALOUD_MAX_DIMENSION_WEIGHT = 1e3;
1327
+ var SPEECH_ASSESSMENT_MAX_WORDS = 1e3;
1328
+ var SPEECH_ASSESSMENT_MAX_TEXT_LENGTH = DICTATION_MAX_TEXT_LENGTH;
1329
+
1330
+ // src/count-words.ts
1331
+ function countWords(text) {
1332
+ if (typeof text !== "string") {
1333
+ return 0;
1339
1334
  }
1340
- return normalizer;
1335
+ const trimmed = text.trim();
1336
+ if (trimmed === "") {
1337
+ return 0;
1338
+ }
1339
+ return trimmed.split(/\s+/).length;
1341
1340
  }
1342
- function measure(data, text) {
1343
- return normalizerFor(data).measure(text);
1341
+
1342
+ // src/schemas/read-schema.ts
1343
+ var OWN_SCHEMAS = /* @__PURE__ */ new WeakSet();
1344
+ function ownSchemas(...schemas) {
1345
+ for (const schema of schemas) {
1346
+ OWN_SCHEMAS.add(schema);
1347
+ }
1344
1348
  }
1345
- function candidatesOf(data) {
1346
- return [data.transcript, ...data.acceptedTranscripts ?? []];
1349
+ function isOwnSchema(schema) {
1350
+ return OWN_SCHEMAS.has(schema);
1347
1351
  }
1348
- function isEmpty(measured) {
1349
- return measured.normalized === "";
1352
+ var GENERIC_CODE = "invalid";
1353
+ function valueAt(root, path) {
1354
+ let node = root;
1355
+ for (const segment of path) {
1356
+ if (node === null || typeof node !== "object") {
1357
+ return void 0;
1358
+ }
1359
+ node = node[segment];
1360
+ }
1361
+ return node;
1350
1362
  }
1351
- function checkEvenAfterIssues(check) {
1352
- const guard = new z5.core.$ZodCheck({ check: "custom", when: () => true });
1353
- guard._zod.check = check;
1354
- return guard;
1363
+ var isThenable = (value) => typeof value?.then === "function";
1364
+ function isZodSchema(schema) {
1365
+ return schema["~standard"].vendor === "zod" && typeof schema.safeParse === "function";
1355
1366
  }
1356
- var DictationDataShape = z5.looseObject({
1357
- schemaVersion: z5.literal("1.0"),
1358
- type: z5.literal("dictation"),
1359
- id: z5.string().min(1),
1360
- title: z5.string().min(1),
1361
- transcript: TranscriptTextSchema,
1362
- acceptedTranscripts: z5.array(TranscriptTextSchema).max(DICTATION_MAX_ACCEPTED_TRANSCRIPTS).optional(),
1363
- media: MediaSchema.optional(),
1364
- slowMedia: DictationSlowMediaSchema.optional(),
1365
- hints: z5.looseObject({ mode: z5.literal("progressive-words") }).optional(),
1366
- tolerance: DictationToleranceSchema.optional(),
1367
- feedback: FeedbackSchema.optional(),
1368
- passThreshold: z5.number().min(0).max(1).optional(),
1369
- locale: z5.string().optional(),
1370
- learningObjectives: z5.array(z5.string()).optional(),
1371
- difficultyLevel: z5.literal([1, 2, 3, 4, 5]).optional(),
1372
- ai: AiPermissionsSchema.optional()
1373
- });
1374
- var DictationDataSchema = DictationDataShape.check(
1375
- checkEvenAfterIssues((ctx) => {
1376
- if (ctx.issues.some((issue2) => (issue2.path?.length ?? 0) === 0)) {
1377
- return;
1367
+ function readSchema(schema, value, subject, options) {
1368
+ if (isZodSchema(schema)) {
1369
+ const parsed = schema.safeParse(
1370
+ value,
1371
+ options?.reportInput === true ? { reportInput: true } : void 0
1372
+ );
1373
+ if (parsed.success) {
1374
+ return { success: true, data: parsed.data };
1378
1375
  }
1379
- const data = ctx.value;
1380
- const refused = new Set(ctx.issues.map((issue2) => String(issue2.path?.[0])));
1381
- const report = (path, input, message) => {
1382
- ctx.issues.push({ code: "custom", input, message, path: [...path] });
1376
+ return {
1377
+ success: false,
1378
+ issues: parsed.error.issues.map((issue2) => ({
1379
+ path: issue2.path,
1380
+ message: issue2.message,
1381
+ code: issue2.code,
1382
+ ..."input" in issue2 ? { input: issue2.input } : {}
1383
+ }))
1383
1384
  };
1384
- const rulesParse = !refused.has("tolerance");
1385
- const transcriptParses = !refused.has("transcript");
1386
- if (rulesParse && transcriptParses) {
1387
- const transcript = measure(data, data.transcript);
1388
- if (isEmpty(transcript)) {
1389
- report(
1390
- ["transcript"],
1391
- data.transcript,
1392
- "The transcript must contain something to type: after ignoring case, punctuation and spacing nothing is left."
1393
- );
1394
- }
1395
- if (transcript.tooLong) {
1396
- report(
1397
- ["transcript"],
1398
- data.transcript,
1399
- `A transcript is at most ${DICTATION_MAX_TRANSCRIPT_LENGTH} characters, before and after its equivalences are applied, and its equivalences may not grow it past ${WORKING_LENGTH} characters on the way.`
1400
- );
1401
- }
1402
- if (!refused.has("acceptedTranscripts")) {
1403
- const accepted = data.acceptedTranscripts ?? [];
1404
- if (accepted.some((entry) => {
1405
- const measured = measure(data, entry);
1406
- return measured.tooLong || isEmpty(measured);
1407
- })) {
1408
- report(
1409
- ["acceptedTranscripts"],
1410
- data.acceptedTranscripts,
1411
- `An accepted transcript must contain something to type and be at most ${DICTATION_MAX_TRANSCRIPT_LENGTH} characters, before and after its equivalences are applied, and its equivalences may not grow it past ${WORKING_LENGTH} characters on the way.`
1412
- );
1413
- }
1414
- const normalized = candidatesOf(data).map((entry) => measure(data, entry).normalized).filter((entry) => entry !== null);
1415
- if (new Set(normalized).size !== normalized.length) {
1416
- report(
1417
- ["acceptedTranscripts"],
1418
- data.acceptedTranscripts,
1419
- "An accepted transcript must differ from the transcript and from the other accepted transcripts once case, punctuation and spacing are ignored."
1420
- );
1421
- }
1422
- }
1423
- }
1424
- const mediaParses = !refused.has("media");
1425
- const slowParses = !refused.has("slowMedia");
1426
- if (mediaParses && data.media !== void 0 && data.media.type !== "audio") {
1427
- report(
1428
- ["media", "type"],
1429
- data.media.type,
1430
- "A dictation is dictated: its recording must be audio."
1431
- );
1432
- }
1433
- if (slowParses && data.slowMedia?.playback !== void 0) {
1434
- report(
1435
- ["slowMedia", "playback"],
1436
- data.slowMedia.playback,
1437
- "The slow recording carries no playback policy of its own; put the policy on `media` \u2014 the slow recording follows it (except the play budget)."
1385
+ }
1386
+ const result = schema["~standard"].validate(value);
1387
+ if (isThenable(result)) {
1388
+ result.then(void 0, () => {
1389
+ });
1390
+ throw new TypeError(
1391
+ `${subject} has a schema whose validation returned a Promise: it validates asynchronously, or its library reported an error that way. lk-core validates synchronously: register a schema whose validation returns its result.`
1392
+ );
1393
+ }
1394
+ const settled = result;
1395
+ if (settled.issues === void 0) {
1396
+ return { success: true, data: settled.value };
1397
+ }
1398
+ return {
1399
+ success: false,
1400
+ issues: settled.issues.map((issue2) => {
1401
+ const path = (issue2.path ?? []).map(
1402
+ (segment) => typeof segment === "object" && segment !== null ? segment.key : segment
1438
1403
  );
1439
- }
1440
- if (mediaParses && slowParses && data.slowMedia !== void 0) {
1441
- if (data.media?.playback?.maxPlays !== void 0) {
1442
- report(
1443
- ["slowMedia"],
1444
- data.slowMedia,
1445
- "A play budget on `media` cannot coexist with an unbudgeted slow recording of the same content. Drop `slowMedia`, or drop `maxPlays` (a budgeted recording can still offer the transport's 0.75\xD7 speed when `rate` is `allow`)."
1446
- );
1447
- }
1448
- if (typeof data.slowMedia.url === "string" && typeof data.media?.url === "string" && data.slowMedia.url === data.media.url) {
1449
- report(
1450
- ["slowMedia", "url"],
1451
- data.slowMedia.url,
1452
- "The slow recording must be a different file from the recording."
1453
- );
1454
- }
1455
- if (data.media === void 0) {
1456
- report(
1457
- ["media"],
1458
- data.media,
1459
- "A slow recording accompanies a recording: add `media` first."
1460
- );
1461
- }
1462
- }
1463
- if (rulesParse && !(data.tolerance?.equivalences ?? []).every(
1464
- (rule) => preStripNormalize(rule.from) !== "" && normalizeDictationText(rule.to) !== ""
1465
- )) {
1466
- report(
1467
- ["tolerance", "equivalences"],
1468
- data.tolerance?.equivalences,
1469
- "An equivalence must name a word, phrase or symbol to rewrite, and rewrite it to something that survives normalisation (not only punctuation or spaces)."
1470
- );
1471
- }
1472
- for (const field of ["media", "slowMedia"]) {
1473
- const recording = data[field];
1474
- for (const captions of captionFieldsOf(recording)) {
1475
- report(
1476
- [field, captions],
1477
- recording?.[captions],
1478
- "A dictation recording cannot carry captions: the captions are the answer. Offer an accessible alternative as a different item."
1479
- );
1480
- }
1481
- }
1482
- if (!rulesParse || !transcriptParses) {
1483
- return;
1484
- }
1485
- const normalizer = normalizerFor(data);
1486
- const candidates = candidatesOf(data).map((entry) => normalizer.measure(entry));
1487
- const shown = [
1488
- [["title"], refused.has("title") ? void 0 : data.title],
1489
- [["media", "alt"], data.media?.alt],
1490
- [["slowMedia", "alt"], data.slowMedia?.alt]
1491
- ];
1492
- for (const [path, value] of shown) {
1493
- if (value === void 0) {
1494
- continue;
1495
- }
1496
- const { tooLong, forms } = normalizer.shown(value);
1497
- if (tooLong) {
1498
- report(
1499
- path,
1500
- value,
1501
- `The title and the recording descriptions are searched for the transcript before they are shown: each is at most ${WORKING_LENGTH} characters, before and after the equivalences are applied, and the equivalences may not grow it past that on the way.`
1502
- );
1503
- } else if (candidates.some((candidate) => revealsCandidate(forms, candidate))) {
1504
- report(
1505
- path,
1506
- value,
1507
- 'The title and the recording descriptions are shown to the learner before they type: they must not contain the transcript. Describe the recording ("Recording, normal speed"), never transcribe it.'
1508
- );
1509
- }
1510
- }
1511
- })
1512
- );
1513
- var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1514
- function captionFieldsOf(media, written = (captionsUrl) => captionsUrl !== void 0) {
1515
- if (!isObject(media)) {
1516
- return [];
1517
- }
1518
- const fields = [];
1519
- if (written(media.captionsUrl)) {
1520
- fields.push("captionsUrl");
1521
- }
1522
- if (Array.isArray(media.tracks) && media.tracks.length > 0) {
1523
- fields.push("tracks");
1524
- }
1525
- return fields;
1526
- }
1527
- function groupCaptionsField(group, written) {
1528
- if (!isObject(group) || !isObject(group.stimulus) || !Array.isArray(group.items)) {
1529
- return void 0;
1530
- }
1531
- const [field] = captionFieldsOf(group.stimulus.media, written);
1532
- if (field === void 0) {
1533
- return void 0;
1534
- }
1535
- const played = group.items.some(
1536
- (item) => isObject(item) && item.type === "dictation" && item.media === void 0
1537
- );
1538
- return played ? field : void 0;
1539
- }
1540
- var GROUP_CAPTIONS_REVEAL_DICTATION = "The stimulus recording carries captions, and a dictation in this group plays that recording: the captions are the answer. Remove the captions, or give the dictation its own recording.";
1541
-
1542
- // src/schemas/multiple-choice.ts
1543
- import { z as z6 } from "zod/v4";
1544
- var MultipleChoiceOptionMediaSchema = z6.looseObject({
1545
- // No `embed`: an iframe swallows the click that selects the option, so the
1546
- // learner could not choose it. No `video`: a native control bar inside the
1547
- // option's label eats the same click.
1548
- type: z6.enum(["image", "audio"]),
1549
- url: MediaUrlSchema,
1550
- alt: z6.string().min(1).optional(),
1551
- captionsUrl: MediaUrlSchema.optional()
1552
- }).refine((media) => media.type !== "image" || media.alt !== void 0 && media.alt !== "", {
1553
- error: "An image option requires a non-empty `alt`.",
1554
- path: ["alt"]
1555
- }).refine((media) => !("playback" in media), {
1556
- error: "An option carries no playback policy. maxPlays, seek and rate are enforced only on the media above the question.",
1557
- path: ["playback"]
1558
- });
1559
- var MultipleChoiceOptionSchema = z6.looseObject({
1560
- id: z6.string().min(1),
1561
- text: z6.string().min(1),
1562
- isCorrect: z6.boolean(),
1563
- feedback: z6.string().optional(),
1564
- media: MultipleChoiceOptionMediaSchema.optional()
1565
- });
1566
- var MultipleChoiceDataSchema = z6.looseObject({
1567
- schemaVersion: z6.literal("1.0"),
1568
- type: z6.literal("multiple-choice"),
1569
- id: z6.string().min(1),
1570
- title: z6.string().min(1),
1571
- question: z6.string().min(1),
1572
- questionHtml: z6.string().optional(),
1573
- mode: z6.enum(["single", "multi"]),
1574
- options: z6.array(MultipleChoiceOptionSchema).min(2).max(26),
1575
- scoringStrategy: z6.enum(["all-or-nothing", "partial"]),
1576
- media: MediaSchema.optional(),
1577
- feedback: FeedbackSchema.optional(),
1578
- passThreshold: z6.number().min(0).max(1).optional(),
1579
- shuffle: z6.boolean().optional(),
1580
- locale: z6.string().optional(),
1581
- learningObjectives: z6.array(z6.string()).optional(),
1582
- difficultyLevel: z6.literal([1, 2, 3, 4, 5]).optional(),
1583
- ai: AiPermissionsSchema.optional()
1584
- }).refine((data) => data.options.some((option) => option.isCorrect), {
1585
- error: "At least one option must be marked correct.",
1586
- path: ["options"]
1587
- }).refine(
1588
- (data) => data.mode !== "single" || data.options.filter((option) => option.isCorrect).length === 1,
1589
- {
1590
- error: 'Single-select activities (mode: "single") must have exactly one correct option.',
1591
- path: ["options"]
1592
- }
1593
- ).refine((data) => new Set(data.options.map((option) => option.id)).size === data.options.length, {
1594
- error: "Option ids must be unique within the activity.",
1595
- path: ["options"]
1596
- });
1597
-
1598
- // src/scoring/speech/limits.ts
1599
- var READ_ALOUD_MAX_REFERENCE_LENGTH = DICTATION_MAX_TRANSCRIPT_LENGTH;
1600
- var READ_ALOUD_MAX_SECONDS = 300;
1601
- var READ_ALOUD_MAX_TAKES = 20;
1602
- var READ_ALOUD_MAX_DIMENSION_WEIGHT = 1e3;
1603
- var SPEECH_ASSESSMENT_MAX_WORDS = 1e3;
1604
- var SPEECH_ASSESSMENT_MAX_TEXT_LENGTH = DICTATION_MAX_TEXT_LENGTH;
1605
-
1606
- // src/schemas/read-aloud.ts
1607
- import { z as z7 } from "zod/v4";
1608
-
1609
- // src/scoring/speech/locale.ts
1610
- var CANONICAL_LOCALE_RE = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?-(?:[A-Z]{2}|[0-9]{3})$/;
1611
-
1612
- // src/schemas/read-aloud.ts
1613
- var READ_ALOUD_DIMENSIONS = ["accuracy", "fluency", "completeness", "prosody"];
1614
- var READ_ALOUD_MAX_DIMENSIONS = READ_ALOUD_DIMENSIONS.length;
1615
- var ReadAloudSlowMediaSchema = z7.strictObject({
1616
- type: z7.literal("audio"),
1617
- url: MediaUrlSchema,
1618
- alt: z7.string().min(1).optional()
1619
- });
1620
- var ReferenceTextSchema = z7.string().min(1).refine((text) => text.trim() !== "", {
1621
- error: "The text to read aloud must contain something to read."
1622
- }).meta({ maxLength: READ_ALOUD_MAX_REFERENCE_LENGTH });
1623
- var RecordingBoundsSchema = z7.looseObject({
1624
- maxSeconds: z7.number().gt(0).max(READ_ALOUD_MAX_SECONDS),
1625
- minSeconds: z7.number().min(0).optional(),
1626
- maxTakes: z7.number().int().min(1).max(READ_ALOUD_MAX_TAKES).optional()
1627
- });
1628
- var ReadAloudDimensionWeightSchema = z7.looseObject({
1629
- name: z7.enum(READ_ALOUD_DIMENSIONS),
1630
- weight: z7.number().min(0).max(READ_ALOUD_MAX_DIMENSION_WEIGHT)
1631
- });
1632
- var ReadAloudScoringSchema = z7.looseObject({
1633
- dimensions: z7.array(ReadAloudDimensionWeightSchema).min(1).max(READ_ALOUD_MAX_DIMENSIONS)
1634
- });
1635
- function checkEvenAfterIssues2(check) {
1636
- const guard = new z7.core.$ZodCheck({ check: "custom", when: () => true });
1637
- guard._zod.check = check;
1638
- return guard;
1639
- }
1640
- var ReadAloudDataShape = z7.looseObject({
1641
- schemaVersion: z7.literal("1.0"),
1642
- type: z7.literal("read-aloud"),
1643
- id: z7.string().min(1),
1644
- title: z7.string().min(1),
1645
- instructions: z7.string().optional(),
1646
- referenceText: ReferenceTextSchema,
1647
- locale: z7.string().regex(CANONICAL_LOCALE_RE, {
1648
- error: `The locale must be a BCP 47 tag in canonical form, with a region: "en-US", "es-419", "zh-Hant-TW". Pronunciation is assessed against one locale's speech, and "en" does not say whose.`
1649
- }),
1650
- media: MediaSchema.optional(),
1651
- slowMedia: ReadAloudSlowMediaSchema.optional(),
1652
- recording: RecordingBoundsSchema,
1653
- scoring: ReadAloudScoringSchema,
1654
- passThreshold: z7.number().min(0).max(1).optional(),
1655
- feedback: FeedbackSchema.optional(),
1656
- learningObjectives: z7.array(z7.string()).optional(),
1657
- difficultyLevel: z7.literal([1, 2, 3, 4, 5]).optional(),
1658
- ai: AiPermissionsSchema.optional()
1659
- });
1660
- var ReadAloudDataSchema = ReadAloudDataShape.check(
1661
- checkEvenAfterIssues2((ctx) => {
1662
- if (ctx.issues.some((issue2) => (issue2.path?.length ?? 0) === 0)) {
1663
- return;
1664
- }
1665
- const data = ctx.value;
1666
- const refused = new Set(ctx.issues.map((issue2) => String(issue2.path?.[0])));
1667
- const report = (path, input, message) => {
1668
- ctx.issues.push({ code: "custom", input, message, path: [...path] });
1669
- };
1670
- if (!refused.has("referenceText")) {
1671
- const text = data.referenceText;
1672
- const measured = dictationNormalizer(void 0).measure(text);
1673
- if (measured.tooLong) {
1674
- report(
1675
- ["referenceText"],
1676
- text,
1677
- `The text to read aloud is at most ${READ_ALOUD_MAX_REFERENCE_LENGTH} characters, before and after normalisation.`
1678
- );
1679
- }
1680
- if (measured.normalized === "") {
1681
- report(
1682
- ["referenceText"],
1683
- text,
1684
- "The text to read aloud must contain a word: after ignoring punctuation and spacing nothing is left, so no word would be marked and every reading would align as one insertion after another."
1685
- );
1686
- }
1687
- if (containsUnspacedScript(text)) {
1688
- report(
1689
- ["referenceText"],
1690
- text,
1691
- "A read-aloud item needs a script written with spaces between its words. The text is compared word by word, and a run of Han, kana, Thai, Lao, Khmer or Myanmar letters is one word however many it holds."
1692
- );
1693
- }
1694
- }
1695
- const mediaParses = !refused.has("media");
1696
- const slowParses = !refused.has("slowMedia");
1697
- if (mediaParses && data.media !== void 0 && data.media.type !== "audio") {
1698
- report(
1699
- ["media", "type"],
1700
- data.media.type,
1701
- "A read-aloud model is read aloud: its recording must be audio."
1702
- );
1703
- }
1704
- if (mediaParses && slowParses && data.slowMedia !== void 0) {
1705
- if (data.media === void 0) {
1706
- report(
1707
- ["media"],
1708
- data.media,
1709
- "A slow model recording accompanies a recording: add `media` first."
1710
- );
1711
- }
1712
- if (data.media?.playback?.maxPlays !== void 0) {
1713
- report(
1714
- ["slowMedia"],
1715
- data.slowMedia,
1716
- "A play budget on `media` cannot coexist with an unbudgeted slow recording of the same content. Drop `slowMedia`, or drop `maxPlays`."
1717
- );
1718
- }
1719
- if (typeof data.slowMedia.url === "string" && typeof data.media?.url === "string" && data.slowMedia.url === data.media.url) {
1720
- report(
1721
- ["slowMedia", "url"],
1722
- data.slowMedia.url,
1723
- "The slow model recording must be a different file from the recording."
1724
- );
1725
- }
1726
- }
1727
- if (!refused.has("scoring")) {
1728
- const dimensions = data.scoring.dimensions;
1729
- const seen = /* @__PURE__ */ new Set();
1730
- for (const [index, dimension] of dimensions.entries()) {
1731
- if (seen.has(dimension.name)) {
1732
- report(
1733
- ["scoring", "dimensions", index, "name"],
1734
- dimension.name,
1735
- `"${dimension.name}" is already weighed: a dimension counted twice hides which weight the grade used.`
1736
- );
1737
- }
1738
- seen.add(dimension.name);
1739
- }
1740
- if (dimensions.every((dimension) => dimension.weight === 0)) {
1741
- report(
1742
- ["scoring", "dimensions"],
1743
- dimensions,
1744
- "At least one dimension must carry a weight above 0: with every weight at 0 there is no weighted total, and every take would be unscorable."
1745
- );
1746
- }
1747
- }
1748
- if (!refused.has("recording")) {
1749
- const { minSeconds, maxSeconds } = data.recording;
1750
- if (minSeconds !== void 0 && minSeconds >= maxSeconds) {
1751
- report(
1752
- ["recording", "minSeconds"],
1753
- minSeconds,
1754
- "The shortest take must be shorter than the longest."
1755
- );
1756
- }
1757
- }
1758
- })
1759
- );
1760
-
1761
- // src/count-words.ts
1762
- function countWords(text) {
1763
- if (typeof text !== "string") {
1764
- return 0;
1765
- }
1766
- const trimmed = text.trim();
1767
- if (trimmed === "") {
1768
- return 0;
1769
- }
1770
- return trimmed.split(/\s+/).length;
1771
- }
1772
-
1773
- // src/schemas/gap-select.ts
1774
- import { z as z8 } from "zod/v4";
1775
- var GapSelectChoiceSchema = z8.looseObject({
1776
- id: z8.string().min(1),
1777
- text: z8.string().min(1)
1778
- });
1779
- var GapSelectBankSchema = z8.looseObject({
1780
- id: z8.string().min(1),
1781
- choices: z8.array(GapSelectChoiceSchema).min(2)
1782
- });
1783
- var GapSelectGapSchema = z8.looseObject({
1784
- id: z8.string().min(1),
1785
- choices: z8.array(GapSelectChoiceSchema).min(2).optional(),
1786
- bankId: z8.string().min(1).optional(),
1787
- correctChoiceId: z8.string().min(1),
1788
- feedback: z8.string().optional()
1789
- });
1790
- function resolveChoices(gap, banks) {
1791
- if (gap.choices !== void 0) {
1792
- return gap.bankId === void 0 ? gap.choices : void 0;
1793
- }
1794
- if (gap.bankId === void 0) {
1795
- return void 0;
1796
- }
1797
- return banks.find((bank) => bank.id === gap.bankId)?.choices;
1798
- }
1799
- function placeholderCounts(passage) {
1800
- const counts = /* @__PURE__ */ new Map();
1801
- for (const match of passage.matchAll(PLACEHOLDER_RE)) {
1802
- const id = match[1];
1803
- counts.set(id, (counts.get(id) ?? 0) + 1);
1804
- }
1805
- return counts;
1806
- }
1807
- var GapSelectDataSchema = z8.looseObject({
1808
- schemaVersion: z8.literal("1.0"),
1809
- type: z8.literal("gap-select"),
1810
- id: z8.string().min(1),
1811
- title: z8.string().min(1),
1812
- passage: z8.string().min(1),
1813
- passageHtml: z8.string().optional(),
1814
- gaps: z8.array(GapSelectGapSchema).min(1),
1815
- banks: z8.array(GapSelectBankSchema).optional(),
1816
- scoringStrategy: z8.enum(["all-or-nothing", "partial"]),
1817
- presentation: z8.literal("dropdown").optional(),
1818
- shuffleChoices: z8.boolean().optional(),
1819
- media: MediaSchema.optional(),
1820
- feedback: FeedbackSchema.optional(),
1821
- passThreshold: z8.number().min(0).max(1).optional(),
1822
- locale: z8.string().optional(),
1823
- learningObjectives: z8.array(z8.string()).optional(),
1824
- difficultyLevel: z8.literal([1, 2, 3, 4, 5]).optional(),
1825
- ai: AiPermissionsSchema.optional()
1826
- }).refine(
1827
- (data) => {
1828
- const ids = (data.banks ?? []).map((bank) => bank.id);
1829
- return new Set(ids).size === ids.length;
1830
- },
1831
- { error: "Word bank ids must be unique.", path: ["banks"] }
1832
- ).refine(
1833
- (data) => {
1834
- const ids = data.gaps.map((gap) => gap.id);
1835
- return new Set(ids).size === ids.length;
1836
- },
1837
- { error: "Gap ids must be unique.", path: ["gaps"] }
1838
- ).refine(
1839
- (data) => {
1840
- const counts = placeholderCounts(data.passage);
1841
- const ids = data.gaps.map((gap) => gap.id);
1842
- return counts.size === new Set(ids).size && ids.every((id) => counts.get(id) === 1) && [...counts.values()].every((count) => count === 1);
1843
- },
1844
- {
1845
- error: "Every gap must appear exactly once in the passage as {{gap_id}}, and every {{gap_id}} must have a gap.",
1846
- path: ["passage"]
1847
- }
1848
- ).refine(
1849
- (data) => data.gaps.every((gap) => gap.choices === void 0 !== (gap.bankId === void 0)),
1850
- {
1851
- error: "Each gap needs exactly one choice source: its own `choices`, or a `bankId`.",
1852
- path: ["gaps"]
1853
- }
1854
- ).refine(
1855
- (data) => {
1856
- const bankIds = new Set((data.banks ?? []).map((bank) => bank.id));
1857
- return data.gaps.every((gap) => gap.bankId === void 0 || bankIds.has(gap.bankId));
1858
- },
1859
- { error: "A gap references a `bankId` that no word bank defines.", path: ["gaps"] }
1860
- ).refine(
1861
- (data) => {
1862
- const banks = data.banks ?? [];
1863
- return data.gaps.every((gap) => {
1864
- const choices = resolveChoices(gap, banks);
1865
- if (choices === void 0) {
1866
- return true;
1867
- }
1868
- const ids = choices.map((choice) => choice.id);
1869
- return new Set(ids).size === ids.length && ids.includes(gap.correctChoiceId);
1870
- });
1871
- },
1872
- {
1873
- error: "Each gap needs unique choice ids and a `correctChoiceId` that is one of the choices it offers.",
1874
- path: ["gaps"]
1875
- }
1876
- );
1877
-
1878
- // src/schemas/written-response.ts
1879
- import { z as z9 } from "zod/v4";
1880
- var WrittenResponseRubricCriterionSchema = z9.looseObject({
1881
- name: z9.string().min(1),
1882
- description: z9.string().optional(),
1883
- weight: z9.number().min(0)
1884
- });
1885
- var WrittenResponseRubricSchema = z9.looseObject({
1886
- label: z9.string().optional(),
1887
- criteria: z9.array(WrittenResponseRubricCriterionSchema).min(1)
1888
- });
1889
- var RedactedWrittenResponseRubricCriterionSchema = z9.strictObject({
1890
- name: z9.string().min(1),
1891
- description: z9.string().optional(),
1892
- weight: z9.number().min(0)
1893
- });
1894
- var RedactedWrittenResponseRubricSchema = z9.strictObject({
1895
- label: z9.string().optional(),
1896
- criteria: z9.array(RedactedWrittenResponseRubricCriterionSchema).min(1)
1897
- });
1898
- var WrittenResponseDataSchema = z9.looseObject({
1899
- schemaVersion: z9.literal("1.0"),
1900
- type: z9.literal("written-response"),
1901
- id: z9.string().min(1),
1902
- title: z9.string().min(1),
1903
- prompt: z9.string(),
1904
- promptHtml: z9.string().optional(),
1905
- minWords: z9.number().int().min(0),
1906
- maxWords: z9.number().int().min(1),
1907
- rubric: WrittenResponseRubricSchema.optional(),
1908
- languageTarget: z9.string().optional(),
1909
- media: MediaSchema.optional(),
1910
- feedback: FeedbackSchema.optional(),
1911
- passThreshold: z9.number().min(0).max(1).optional(),
1912
- locale: z9.string().optional(),
1913
- learningObjectives: z9.array(z9.string()).optional(),
1914
- difficultyLevel: z9.literal([1, 2, 3, 4, 5]).optional(),
1915
- ai: AiPermissionsSchema.optional()
1916
- }).refine((data) => data.maxWords >= data.minWords, {
1917
- error: "maxWords must be greater than or equal to minWords.",
1918
- path: ["maxWords"]
1919
- });
1920
-
1921
- // src/schemas/redacted.ts
1922
- import { z as z10 } from "zod/v4";
1923
- var redactedBase = {
1924
- /** Marker distinguishing a redacted projection from full activity data. */
1925
- redacted: z10.literal(true),
1926
- /** Slot identity, carried through redaction so the client and the plan agree. */
1927
- slotKey: z10.string().min(1).optional(),
1928
- schemaVersion: z10.literal("1.0"),
1929
- id: z10.string().min(1),
1930
- title: z10.string().min(1),
1931
- media: RedactedMediaSchema.optional(),
1932
- passThreshold: z10.number().min(0).max(1).optional(),
1933
- locale: z10.string().optional(),
1934
- learningObjectives: z10.array(z10.string()).optional(),
1935
- difficultyLevel: z10.literal([1, 2, 3, 4, 5]).optional(),
1936
- ai: z10.strictObject({ explanations: z10.boolean().optional(), hints: z10.boolean().optional() }).optional()
1937
- };
1938
- var RedactedMultipleChoiceOptionMediaSchema = z10.strictObject({
1939
- type: z10.enum(["image", "audio"]),
1940
- url: z10.string().min(1),
1941
- alt: z10.string().min(1).optional(),
1942
- captionsUrl: z10.string().min(1).optional()
1943
- });
1944
- var RedactedMultipleChoiceOptionSchema = z10.strictObject({
1945
- id: z10.string().min(1),
1946
- text: z10.string().min(1),
1947
- media: RedactedMultipleChoiceOptionMediaSchema.optional()
1948
- });
1949
- var RedactedMultipleChoiceDataSchema = z10.strictObject({
1950
- ...redactedBase,
1951
- type: z10.literal("multiple-choice"),
1952
- question: z10.string().min(1),
1953
- questionHtml: z10.string().optional(),
1954
- mode: z10.enum(["single", "multi"]),
1955
- options: z10.array(RedactedMultipleChoiceOptionSchema).min(2).max(26),
1956
- shuffle: z10.boolean().optional()
1957
- });
1958
- var RedactedBlankConfigSchema = z10.strictObject({
1959
- id: z10.string().min(1),
1960
- hint: z10.string().optional()
1961
- });
1962
- var RedactedFillInTheBlanksDataSchema = z10.strictObject({
1963
- ...redactedBase,
1964
- type: z10.literal("fill-in-the-blanks"),
1965
- passage: z10.string().min(1),
1966
- passageHtml: z10.string().optional(),
1967
- blanks: z10.array(RedactedBlankConfigSchema).min(1)
1968
- });
1969
- var RedactedGapSelectChoiceSchema = z10.strictObject({
1970
- id: z10.string().min(1),
1971
- text: z10.string().min(1)
1972
- });
1973
- var RedactedGapSelectBankSchema = z10.strictObject({
1974
- id: z10.string().min(1),
1975
- choices: z10.array(RedactedGapSelectChoiceSchema).min(2)
1976
- });
1977
- var RedactedGapSelectGapSchema = z10.strictObject({
1978
- id: z10.string().min(1),
1979
- choices: z10.array(RedactedGapSelectChoiceSchema).min(2).optional(),
1980
- bankId: z10.string().min(1).optional()
1981
- });
1982
- var RedactedGapSelectDataSchema = z10.strictObject({
1983
- ...redactedBase,
1984
- type: z10.literal("gap-select"),
1985
- passage: z10.string().min(1),
1986
- passageHtml: z10.string().optional(),
1987
- gaps: z10.array(RedactedGapSelectGapSchema).min(1),
1988
- banks: z10.array(RedactedGapSelectBankSchema).optional(),
1989
- presentation: z10.literal("dropdown").optional(),
1990
- shuffleChoices: z10.boolean().optional()
1991
- });
1992
- var RedactedWrittenResponseDataSchema = z10.strictObject({
1993
- ...redactedBase,
1994
- type: z10.literal("written-response"),
1995
- prompt: z10.string(),
1996
- promptHtml: z10.string().optional(),
1997
- minWords: z10.number().int().min(0),
1998
- maxWords: z10.number().int().min(1),
1999
- rubric: RedactedWrittenResponseRubricSchema.optional(),
2000
- languageTarget: z10.string().optional()
2001
- });
2002
- var RedactedDictationSlowMediaSchema = z10.strictObject({
2003
- type: z10.literal("audio"),
2004
- url: MediaUrlSchema,
2005
- alt: z10.string().min(1).optional()
2006
- });
2007
- var RedactedDictationDataSchema = z10.strictObject({
2008
- ...redactedBase,
2009
- type: z10.literal("dictation"),
2010
- slowMedia: RedactedDictationSlowMediaSchema.optional(),
2011
- hints: z10.strictObject({ mode: z10.literal("progressive-words") }).optional()
2012
- }).check((ctx) => {
2013
- const data = ctx.value;
2014
- for (const captions of captionFieldsOf(data.media)) {
2015
- ctx.issues.push({
2016
- code: "custom",
2017
- input: data.media?.[captions],
2018
- message: "A dictation recording cannot carry captions: the captions are the answer, so this payload is not learner-safe.",
2019
- path: ["media", captions]
2020
- });
2021
- }
2022
- if (data.slowMedia !== void 0 && data.media?.playback?.maxPlays !== void 0) {
2023
- ctx.issues.push({
2024
- code: "custom",
2025
- input: data.slowMedia,
2026
- message: "A play budget on `media` cannot coexist with an unbudgeted slow recording of the same content.",
2027
- path: ["slowMedia"]
2028
- });
2029
- }
2030
- if (data.slowMedia !== void 0 && data.media === void 0) {
2031
- ctx.issues.push({
2032
- code: "custom",
2033
- input: data.slowMedia,
2034
- message: "A slow recording accompanies a recording: this payload has no `media`.",
2035
- path: ["media"]
2036
- });
2037
- }
2038
- });
2039
- var RedactedReadAloudDataSchema = z10.strictObject({
2040
- ...redactedBase,
2041
- type: z10.literal("read-aloud"),
2042
- locale: z10.string().regex(CANONICAL_LOCALE_RE),
2043
- instructions: z10.string().optional(),
2044
- referenceText: z10.string().min(1),
2045
- slowMedia: ReadAloudSlowMediaSchema.optional(),
2046
- recording: z10.strictObject({
2047
- maxSeconds: z10.number().gt(0).max(READ_ALOUD_MAX_SECONDS),
2048
- minSeconds: z10.number().min(0).optional(),
2049
- maxTakes: z10.number().int().min(1).max(READ_ALOUD_MAX_TAKES).optional()
2050
- }),
2051
- scoring: z10.strictObject({
2052
- dimensions: z10.array(
2053
- z10.strictObject({
2054
- name: z10.enum(READ_ALOUD_DIMENSIONS),
2055
- weight: z10.number().min(0).max(READ_ALOUD_MAX_DIMENSION_WEIGHT)
2056
- })
2057
- ).min(1).max(READ_ALOUD_DIMENSIONS.length)
2058
- })
2059
- }).check((ctx) => {
2060
- const data = ctx.value;
2061
- if (data.media !== void 0 && data.media.type !== "audio") {
2062
- ctx.issues.push({
2063
- code: "custom",
2064
- input: data.media.type,
2065
- message: "A read-aloud model recording must be audio, so this payload is not renderable.",
2066
- path: ["media", "type"]
2067
- });
2068
- }
2069
- if (data.slowMedia !== void 0 && data.media === void 0) {
2070
- ctx.issues.push({
2071
- code: "custom",
2072
- input: data.slowMedia,
2073
- message: "A slow model recording accompanies a recording: this payload has no `media`.",
2074
- path: ["media"]
2075
- });
2076
- }
2077
- if (data.slowMedia !== void 0 && data.media?.playback?.maxPlays !== void 0) {
2078
- ctx.issues.push({
2079
- code: "custom",
2080
- input: data.slowMedia,
2081
- message: "A play budget on `media` cannot coexist with an unbudgeted slow recording of the same content.",
2082
- path: ["slowMedia"]
2083
- });
2084
- }
2085
- if (data.slowMedia !== void 0 && data.slowMedia.url === data.media?.url) {
2086
- ctx.issues.push({
2087
- code: "custom",
2088
- input: data.slowMedia.url,
2089
- message: "The slow model recording must be a different file from the recording.",
2090
- path: ["slowMedia", "url"]
2091
- });
1404
+ const code = issue2.code;
1405
+ return {
1406
+ path,
1407
+ message: issue2.message,
1408
+ code: typeof code === "string" && code !== "" ? code : GENERIC_CODE,
1409
+ input: valueAt(value, path)
1410
+ };
1411
+ })
1412
+ };
1413
+ }
1414
+ function isStandardSchema(value) {
1415
+ const props = value?.["~standard"];
1416
+ return props?.version === 1 && typeof props.validate === "function";
1417
+ }
1418
+ function standardJsonSchemaOf(schema) {
1419
+ const converter = schema["~standard"].jsonSchema;
1420
+ if (typeof converter?.output !== "function") {
1421
+ return void 0;
2092
1422
  }
2093
- });
1423
+ const generated = converter.output({ target: "draft-07" });
1424
+ return generated !== null && typeof generated === "object" && !Array.isArray(generated) ? generated : void 0;
1425
+ }
2094
1426
 
2095
1427
  // src/scoring/text-match.ts
2096
1428
  var COMBINING_MARKS_RE = /\p{M}/gu;
@@ -2143,66 +1475,326 @@ function levenshteinDistance(a, b, max) {
2143
1475
  current.push(value);
2144
1476
  if (value < rowMin) rowMin = value;
2145
1477
  }
2146
- if (rowMin > max) return max + 1;
2147
- previous = current;
2148
- }
2149
- return previous[b.length];
2150
- }
2151
- function matchText(input, accepted, policy = {}) {
2152
- const acceptedList = typeof accepted === "string" ? [accepted] : accepted;
2153
- const baselineInput = applyBaseline(input, policy);
2154
- const baselineAccepted = acceptedList.map((answer) => applyBaseline(answer, policy));
2155
- if (baselineAccepted.some((answer) => answer === baselineInput)) {
2156
- return { matched: true, via: "exact" };
2157
- }
2158
- const usesNormalization = policy.normalize === "NFC" || policy.normalize === "NFKC" || policy.ignorePunctuation === true || policy.collapseInnerWhitespace === true;
2159
- const normalizedInput = usesNormalization ? applyNormalization(baselineInput, policy) : baselineInput;
2160
- const normalizedAccepted = usesNormalization ? baselineAccepted.map((answer) => applyNormalization(answer, policy)) : baselineAccepted;
2161
- if (usesNormalization && normalizedAccepted.some((answer) => answer === normalizedInput)) {
2162
- return { matched: true, via: "normalized" };
1478
+ if (rowMin > max) return max + 1;
1479
+ previous = current;
1480
+ }
1481
+ return previous[b.length];
1482
+ }
1483
+ function matchText(input, accepted, policy = {}) {
1484
+ const acceptedList = typeof accepted === "string" ? [accepted] : accepted;
1485
+ const baselineInput = applyBaseline(input, policy);
1486
+ const baselineAccepted = acceptedList.map((answer) => applyBaseline(answer, policy));
1487
+ if (baselineAccepted.some((answer) => answer === baselineInput)) {
1488
+ return { matched: true, via: "exact" };
1489
+ }
1490
+ const usesNormalization = policy.normalize === "NFC" || policy.normalize === "NFKC" || policy.ignorePunctuation === true || policy.collapseInnerWhitespace === true;
1491
+ const normalizedInput = usesNormalization ? applyNormalization(baselineInput, policy) : baselineInput;
1492
+ const normalizedAccepted = usesNormalization ? baselineAccepted.map((answer) => applyNormalization(answer, policy)) : baselineAccepted;
1493
+ if (usesNormalization && normalizedAccepted.some((answer) => answer === normalizedInput)) {
1494
+ return { matched: true, via: "normalized" };
1495
+ }
1496
+ const foldedInput = policy.foldDiacritics === true ? applyDiacriticFold(normalizedInput) : normalizedInput;
1497
+ const foldedAccepted = policy.foldDiacritics === true ? normalizedAccepted.map((answer) => applyDiacriticFold(answer)) : normalizedAccepted;
1498
+ if (policy.foldDiacritics === true && foldedAccepted.some((answer) => answer === foldedInput)) {
1499
+ return { matched: true, via: "folded" };
1500
+ }
1501
+ const maxDistance = policy.levenshtein ?? 0;
1502
+ if (maxDistance > 0 && foldedInput.trim().length > 0 && foldedAccepted.some(
1503
+ (answer) => levenshteinDistance(foldedInput, answer, maxDistance) <= maxDistance
1504
+ )) {
1505
+ return { matched: true, via: "fuzzy" };
1506
+ }
1507
+ return { matched: false, via: "none" };
1508
+ }
1509
+
1510
+ // src/registry/registry.ts
1511
+ var registry = /* @__PURE__ */ new Map();
1512
+ function defineActivityType(descriptor) {
1513
+ return descriptor;
1514
+ }
1515
+ function registerActivityType(descriptor) {
1516
+ if (descriptor.type === "item-group") {
1517
+ throw new Error(
1518
+ `"item-group" is reserved for the SDK's item-group container (see ItemGroup) and cannot be registered as an activity type.`
1519
+ );
1520
+ }
1521
+ if (!isStandardSchema(descriptor.schema)) {
1522
+ throw new TypeError(
1523
+ `Activity type "${descriptor.type}": \`schema\` must implement Standard Schema v1 (https://standardschema.dev), as a zod 4, valibot or ArkType schema does.`
1524
+ );
1525
+ }
1526
+ if (descriptor.redactedSchema !== void 0 && !isStandardSchema(descriptor.redactedSchema)) {
1527
+ throw new TypeError(
1528
+ `Activity type "${descriptor.type}": \`redactedSchema\` must implement Standard Schema v1 (https://standardschema.dev), as a zod 4, valibot or ArkType schema does.`
1529
+ );
1530
+ }
1531
+ if (descriptor.jsonSchema !== void 0 && (descriptor.jsonSchema === null || typeof descriptor.jsonSchema !== "object" || Array.isArray(descriptor.jsonSchema))) {
1532
+ throw new TypeError(
1533
+ `Activity type "${descriptor.type}": \`jsonSchema\` must be a JSON Schema object.`
1534
+ );
1535
+ }
1536
+ const existing = registry.get(descriptor.type);
1537
+ if (existing !== void 0) {
1538
+ if (existing === descriptor) {
1539
+ return;
1540
+ }
1541
+ throw new Error(
1542
+ `Activity type "${descriptor.type}" is already registered. Registering a different descriptor for an existing type is not allowed.`
1543
+ );
1544
+ }
1545
+ registry.set(descriptor.type, descriptor);
1546
+ }
1547
+ function getActivityTypeDescriptor(type) {
1548
+ return registry.get(type);
1549
+ }
1550
+ function registeredActivityTypes() {
1551
+ return [...registry.keys()];
1552
+ }
1553
+
1554
+ // src/schemas/dictation.ts
1555
+ import { z as z6 } from "zod/v4";
1556
+
1557
+ // src/schemas/after-issues.ts
1558
+ import { z as z5 } from "zod/v4";
1559
+ function checkEvenAfterIssues(check) {
1560
+ const guard = new z5.core.$ZodCheck({ check: "custom", when: () => true });
1561
+ guard._zod.check = check;
1562
+ return guard;
1563
+ }
1564
+ function parsedFields(payload) {
1565
+ const refused = new Set(payload.issues.map((issue2) => String(issue2.path?.[0])));
1566
+ const data = { ...payload.value };
1567
+ for (const key of refused) {
1568
+ delete data[key];
1569
+ }
1570
+ return { data, refused };
1571
+ }
1572
+
1573
+ // src/schemas/dictation.ts
1574
+ var DictationSlowMediaSchema = z6.looseObject({
1575
+ type: z6.literal("audio"),
1576
+ url: MediaUrlSchema,
1577
+ alt: z6.string().min(1).optional()
1578
+ });
1579
+ var RuleTextSchema = z6.string().min(1).meta({ maxLength: DICTATION_MAX_EQUIVALENCE_LENGTH });
1580
+ var TranscriptTextSchema = z6.string().min(1).meta({ maxLength: DICTATION_MAX_TRANSCRIPT_LENGTH });
1581
+ var DictationEquivalenceSchema = z6.looseObject({
1582
+ from: RuleTextSchema,
1583
+ to: RuleTextSchema
1584
+ }).refine((rule) => codePointLength(rule.from) <= DICTATION_MAX_EQUIVALENCE_LENGTH, {
1585
+ error: `A rule rewrites at most ${DICTATION_MAX_EQUIVALENCE_LENGTH} characters.`,
1586
+ path: ["from"]
1587
+ }).refine((rule) => codePointLength(rule.to) <= DICTATION_MAX_EQUIVALENCE_LENGTH, {
1588
+ error: `A rewrite is at most ${DICTATION_MAX_EQUIVALENCE_LENGTH} characters.`,
1589
+ path: ["to"]
1590
+ });
1591
+ var DictationToleranceSchema = z6.looseObject({
1592
+ equivalences: z6.array(DictationEquivalenceSchema).max(DICTATION_MAX_EQUIVALENCES, {
1593
+ error: `A dictation carries at most ${DICTATION_MAX_EQUIVALENCES} equivalence rules.`
1594
+ }).optional()
1595
+ });
1596
+ var normalizers = /* @__PURE__ */ new WeakMap();
1597
+ function normalizerFor(data) {
1598
+ let normalizer = normalizers.get(data);
1599
+ if (normalizer === void 0) {
1600
+ normalizer = dictationNormalizer(data.tolerance);
1601
+ normalizers.set(data, normalizer);
1602
+ }
1603
+ return normalizer;
1604
+ }
1605
+ function measure(data, text) {
1606
+ return normalizerFor(data).measure(text);
1607
+ }
1608
+ function candidatesOf(data) {
1609
+ return [data.transcript, ...data.acceptedTranscripts ?? []];
1610
+ }
1611
+ function isEmpty(measured) {
1612
+ return measured.normalized === "";
1613
+ }
1614
+ var DictationDataShape = z6.looseObject({
1615
+ schemaVersion: z6.literal("1.0"),
1616
+ type: z6.literal("dictation"),
1617
+ id: z6.string().min(1),
1618
+ title: z6.string().min(1),
1619
+ transcript: TranscriptTextSchema,
1620
+ acceptedTranscripts: z6.array(TranscriptTextSchema).max(DICTATION_MAX_ACCEPTED_TRANSCRIPTS).optional(),
1621
+ media: MediaSchema.optional(),
1622
+ slowMedia: DictationSlowMediaSchema.optional(),
1623
+ hints: z6.looseObject({ mode: z6.literal("progressive-words") }).optional(),
1624
+ tolerance: DictationToleranceSchema.optional(),
1625
+ feedback: FeedbackSchema.optional(),
1626
+ passThreshold: z6.number().min(0).max(1).optional(),
1627
+ locale: z6.string().optional(),
1628
+ learningObjectives: z6.array(z6.string()).optional(),
1629
+ difficultyLevel: z6.literal([1, 2, 3, 4, 5]).optional(),
1630
+ ai: AiPermissionsSchema.optional()
1631
+ });
1632
+ var DictationDataSchema = DictationDataShape.check(
1633
+ checkEvenAfterIssues((ctx) => {
1634
+ if (ctx.issues.some((issue2) => (issue2.path?.length ?? 0) === 0)) {
1635
+ return;
1636
+ }
1637
+ const { data, refused } = parsedFields(ctx);
1638
+ const report = (path, input, message) => {
1639
+ ctx.issues.push({ code: "custom", input, message, path: [...path] });
1640
+ };
1641
+ const rulesParse = !refused.has("tolerance");
1642
+ const transcriptParses = !refused.has("transcript");
1643
+ if (rulesParse && transcriptParses) {
1644
+ const transcript = measure(data, data.transcript);
1645
+ if (isEmpty(transcript)) {
1646
+ report(
1647
+ ["transcript"],
1648
+ data.transcript,
1649
+ "The transcript must contain something to type: after ignoring case, punctuation and spacing nothing is left."
1650
+ );
1651
+ }
1652
+ if (transcript.tooLong) {
1653
+ report(
1654
+ ["transcript"],
1655
+ data.transcript,
1656
+ `A transcript is at most ${DICTATION_MAX_TRANSCRIPT_LENGTH} characters, before and after its equivalences are applied, and its equivalences may not grow it past ${WORKING_LENGTH} characters on the way.`
1657
+ );
1658
+ }
1659
+ if (!refused.has("acceptedTranscripts")) {
1660
+ const accepted = data.acceptedTranscripts ?? [];
1661
+ if (accepted.some((entry) => {
1662
+ const measured = measure(data, entry);
1663
+ return measured.tooLong || isEmpty(measured);
1664
+ })) {
1665
+ report(
1666
+ ["acceptedTranscripts"],
1667
+ data.acceptedTranscripts,
1668
+ `An accepted transcript must contain something to type and be at most ${DICTATION_MAX_TRANSCRIPT_LENGTH} characters, before and after its equivalences are applied, and its equivalences may not grow it past ${WORKING_LENGTH} characters on the way.`
1669
+ );
1670
+ }
1671
+ const normalized = candidatesOf(data).map((entry) => measure(data, entry).normalized).filter((entry) => entry !== null);
1672
+ if (new Set(normalized).size !== normalized.length) {
1673
+ report(
1674
+ ["acceptedTranscripts"],
1675
+ data.acceptedTranscripts,
1676
+ "An accepted transcript must differ from the transcript and from the other accepted transcripts once case, punctuation and spacing are ignored."
1677
+ );
1678
+ }
1679
+ }
1680
+ }
1681
+ const mediaParses = !refused.has("media");
1682
+ const slowParses = !refused.has("slowMedia");
1683
+ if (mediaParses && data.media !== void 0 && data.media.type !== "audio") {
1684
+ report(
1685
+ ["media", "type"],
1686
+ data.media.type,
1687
+ "A dictation is dictated: its recording must be audio."
1688
+ );
1689
+ }
1690
+ if (slowParses && data.slowMedia?.playback !== void 0) {
1691
+ report(
1692
+ ["slowMedia", "playback"],
1693
+ data.slowMedia.playback,
1694
+ "The slow recording carries no playback policy of its own; put the policy on `media` \u2014 the slow recording follows it (except the play budget)."
1695
+ );
1696
+ }
1697
+ if (mediaParses && slowParses && data.slowMedia !== void 0) {
1698
+ if (data.media?.playback?.maxPlays !== void 0) {
1699
+ report(
1700
+ ["slowMedia"],
1701
+ data.slowMedia,
1702
+ "A play budget on `media` cannot coexist with an unbudgeted slow recording of the same content. Drop `slowMedia`, or drop `maxPlays` (a budgeted recording can still offer the transport's 0.75\xD7 speed when `rate` is `allow`)."
1703
+ );
1704
+ }
1705
+ if (typeof data.slowMedia.url === "string" && typeof data.media?.url === "string" && data.slowMedia.url === data.media.url) {
1706
+ report(
1707
+ ["slowMedia", "url"],
1708
+ data.slowMedia.url,
1709
+ "The slow recording must be a different file from the recording."
1710
+ );
1711
+ }
1712
+ if (data.media === void 0) {
1713
+ report(
1714
+ ["media"],
1715
+ data.media,
1716
+ "A slow recording accompanies a recording: add `media` first."
1717
+ );
1718
+ }
1719
+ }
1720
+ if (rulesParse && !(data.tolerance?.equivalences ?? []).every(
1721
+ (rule) => preStripNormalize(rule.from) !== "" && normalizeDictationText(rule.to) !== ""
1722
+ )) {
1723
+ report(
1724
+ ["tolerance", "equivalences"],
1725
+ data.tolerance?.equivalences,
1726
+ "An equivalence must name a word, phrase or symbol to rewrite, and rewrite it to something that survives normalisation (not only punctuation or spaces)."
1727
+ );
1728
+ }
1729
+ for (const field of ["media", "slowMedia"]) {
1730
+ const recording = data[field];
1731
+ for (const captions of captionFieldsOf(recording)) {
1732
+ report(
1733
+ [field, captions],
1734
+ recording?.[captions],
1735
+ "A dictation recording cannot carry captions: the captions are the answer. Offer an accessible alternative as a different item."
1736
+ );
1737
+ }
1738
+ }
1739
+ if (!rulesParse || !transcriptParses) {
1740
+ return;
1741
+ }
1742
+ const normalizer = normalizerFor(data);
1743
+ const candidates = candidatesOf(data).map((entry) => normalizer.measure(entry));
1744
+ const shown = [
1745
+ [["title"], refused.has("title") ? void 0 : data.title],
1746
+ [["media", "alt"], data.media?.alt],
1747
+ [["slowMedia", "alt"], data.slowMedia?.alt]
1748
+ ];
1749
+ for (const [path, value] of shown) {
1750
+ if (value === void 0) {
1751
+ continue;
1752
+ }
1753
+ const { tooLong, forms } = normalizer.shown(value);
1754
+ if (tooLong) {
1755
+ report(
1756
+ path,
1757
+ value,
1758
+ `The title and the recording descriptions are searched for the transcript before they are shown: each is at most ${WORKING_LENGTH} characters, before and after the equivalences are applied, and the equivalences may not grow it past that on the way.`
1759
+ );
1760
+ } else if (candidates.some((candidate) => revealsCandidate(forms, candidate))) {
1761
+ report(
1762
+ path,
1763
+ value,
1764
+ 'The title and the recording descriptions are shown to the learner before they type: they must not contain the transcript. Describe the recording ("Recording, normal speed"), never transcribe it.'
1765
+ );
1766
+ }
1767
+ }
1768
+ })
1769
+ );
1770
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1771
+ function captionFieldsOf(media, written = (captionsUrl) => captionsUrl !== void 0) {
1772
+ if (!isObject(media)) {
1773
+ return [];
2163
1774
  }
2164
- const foldedInput = policy.foldDiacritics === true ? applyDiacriticFold(normalizedInput) : normalizedInput;
2165
- const foldedAccepted = policy.foldDiacritics === true ? normalizedAccepted.map((answer) => applyDiacriticFold(answer)) : normalizedAccepted;
2166
- if (policy.foldDiacritics === true && foldedAccepted.some((answer) => answer === foldedInput)) {
2167
- return { matched: true, via: "folded" };
1775
+ const fields = [];
1776
+ if (written(media.captionsUrl)) {
1777
+ fields.push("captionsUrl");
2168
1778
  }
2169
- const maxDistance = policy.levenshtein ?? 0;
2170
- if (maxDistance > 0 && foldedInput.trim().length > 0 && foldedAccepted.some(
2171
- (answer) => levenshteinDistance(foldedInput, answer, maxDistance) <= maxDistance
2172
- )) {
2173
- return { matched: true, via: "fuzzy" };
1779
+ if (Array.isArray(media.tracks) && media.tracks.length > 0) {
1780
+ fields.push("tracks");
2174
1781
  }
2175
- return { matched: false, via: "none" };
2176
- }
2177
-
2178
- // src/registry/registry.ts
2179
- var registry = /* @__PURE__ */ new Map();
2180
- function defineActivityType(descriptor) {
2181
- return descriptor;
1782
+ return fields;
2182
1783
  }
2183
- function registerActivityType(descriptor) {
2184
- if (descriptor.type === "item-group") {
2185
- throw new Error(
2186
- `"item-group" is reserved for the SDK's item-group container (see ItemGroup) and cannot be registered as an activity type.`
2187
- );
1784
+ function groupCaptionsField(group, written) {
1785
+ if (!isObject(group) || !isObject(group.stimulus) || !Array.isArray(group.items)) {
1786
+ return void 0;
2188
1787
  }
2189
- const existing = registry.get(descriptor.type);
2190
- if (existing !== void 0) {
2191
- if (existing === descriptor) {
2192
- return;
2193
- }
2194
- throw new Error(
2195
- `Activity type "${descriptor.type}" is already registered. Registering a different descriptor for an existing type is not allowed.`
2196
- );
1788
+ const [field] = captionFieldsOf(group.stimulus.media, written);
1789
+ if (field === void 0) {
1790
+ return void 0;
2197
1791
  }
2198
- registry.set(descriptor.type, descriptor);
2199
- }
2200
- function getActivityTypeDescriptor(type) {
2201
- return registry.get(type);
2202
- }
2203
- function registeredActivityTypes() {
2204
- return [...registry.keys()];
1792
+ const played = group.items.some(
1793
+ (item) => isObject(item) && item.type === "dictation" && item.media === void 0
1794
+ );
1795
+ return played ? field : void 0;
2205
1796
  }
1797
+ var GROUP_CAPTIONS_REVEAL_DICTATION = "The stimulus recording carries captions, and a dictation in this group plays that recording: the captions are the answer. Remove the captions, or give the dictation its own recording.";
2206
1798
 
2207
1799
  // src/authoring/issues.ts
2208
1800
  var DRAFT_ISSUE_SEVERITY = {
@@ -2365,7 +1957,7 @@ function issue(code, path, message) {
2365
1957
  function isRecord(value) {
2366
1958
  return typeof value === "object" && value !== null && !Array.isArray(value);
2367
1959
  }
2368
- function valueAt(root, path) {
1960
+ function valueAt2(root, path) {
2369
1961
  let node = root;
2370
1962
  for (const segment of path) {
2371
1963
  if (typeof node !== "object" || node === null) {
@@ -2377,9 +1969,9 @@ function valueAt(root, path) {
2377
1969
  }
2378
1970
  function refusesEmpty(root, schemaIssue) {
2379
1971
  const { path } = schemaIssue;
2380
- const value = valueAt(root, path);
1972
+ const value = valueAt2(root, path);
2381
1973
  const index = path.at(-1);
2382
- const list = valueAt(root, path.slice(0, -1));
1974
+ const list = valueAt2(root, path.slice(0, -1));
2383
1975
  const empty = value === null || value === void 0 && typeof index === "number" && Array.isArray(list) && index < list.length;
2384
1976
  return empty && (schemaIssue.input === value || schemaIssue.code === "invalid_union");
2385
1977
  }
@@ -2906,6 +2498,70 @@ function checkTolerance(tolerance) {
2906
2498
  return issues;
2907
2499
  }
2908
2500
 
2501
+ // src/schemas/fill-in-the-blanks.ts
2502
+ import { z as z7 } from "zod/v4";
2503
+ var PLACEHOLDER_RE = /\{\{\s*([^{}]+?)\s*\}\}/g;
2504
+ var TextMatchPolicySchema = z7.looseObject({
2505
+ caseSensitive: z7.boolean().optional(),
2506
+ trim: z7.boolean().optional(),
2507
+ normalize: z7.enum(["none", "NFC", "NFKC"]).optional(),
2508
+ foldDiacritics: z7.boolean().optional(),
2509
+ collapseInnerWhitespace: z7.boolean().optional(),
2510
+ ignorePunctuation: z7.boolean().optional(),
2511
+ levenshtein: z7.number().int().min(0).optional(),
2512
+ locale: z7.string().optional()
2513
+ });
2514
+ var BlankConfigSchema = z7.looseObject({
2515
+ id: z7.string().min(1),
2516
+ acceptedAnswers: z7.array(
2517
+ z7.string().min(1).refine((answer) => answer.trim().length > 0, {
2518
+ error: "Accepted answers must contain non-whitespace characters."
2519
+ })
2520
+ ).min(1),
2521
+ caseSensitive: z7.boolean().optional(),
2522
+ trimWhitespace: z7.boolean().optional(),
2523
+ match: TextMatchPolicySchema.optional(),
2524
+ hint: z7.string().optional(),
2525
+ feedback: z7.string().optional()
2526
+ });
2527
+ var FillInTheBlanksDataSchema = z7.looseObject({
2528
+ schemaVersion: z7.literal("1.0"),
2529
+ type: z7.literal("fill-in-the-blanks"),
2530
+ id: z7.string().min(1),
2531
+ title: z7.string().min(1),
2532
+ passage: z7.string().min(1),
2533
+ passageHtml: z7.string().optional(),
2534
+ blanks: z7.array(BlankConfigSchema).min(1),
2535
+ scoringStrategy: z7.enum(["all-or-nothing", "partial"]),
2536
+ media: MediaSchema.optional(),
2537
+ feedback: FeedbackSchema.optional(),
2538
+ passThreshold: z7.number().min(0).max(1).optional(),
2539
+ locale: z7.string().optional(),
2540
+ learningObjectives: z7.array(z7.string()).optional(),
2541
+ difficultyLevel: z7.literal([1, 2, 3, 4, 5]).optional(),
2542
+ ai: AiPermissionsSchema.optional()
2543
+ }).refine(
2544
+ (data) => {
2545
+ const placeholderCounts2 = /* @__PURE__ */ new Map();
2546
+ for (const match of data.passage.matchAll(PLACEHOLDER_RE)) {
2547
+ const id = match[1];
2548
+ placeholderCounts2.set(id, (placeholderCounts2.get(id) ?? 0) + 1);
2549
+ }
2550
+ const blankIds = data.blanks.map((blank) => blank.id);
2551
+ if (new Set(blankIds).size !== blankIds.length) {
2552
+ return false;
2553
+ }
2554
+ if (placeholderCounts2.size !== blankIds.length) {
2555
+ return false;
2556
+ }
2557
+ return blankIds.every((id) => placeholderCounts2.get(id) === 1);
2558
+ },
2559
+ {
2560
+ error: "Each blank id must appear exactly once in blanks[] and have exactly one matching {{id}} placeholder in the passage, and vice versa.",
2561
+ path: ["passage"]
2562
+ }
2563
+ );
2564
+
2909
2565
  // src/authoring/fill-in-the-blanks.ts
2910
2566
  var fillInTheBlanksAuthoring = {
2911
2567
  createDraft: ({ newId }) => ({
@@ -3262,7 +2918,7 @@ function checkAnswerKey(gap, index, name, banks, bankIds) {
3262
2918
  if (typeof key !== "string") {
3263
2919
  return [];
3264
2920
  }
3265
- const choices = resolveChoices2(gap, banks, bankIds);
2921
+ const choices = resolveChoices(gap, banks, bankIds);
3266
2922
  if (choices === void 0) {
3267
2923
  return [];
3268
2924
  }
@@ -3275,7 +2931,7 @@ function checkAnswerKey(gap, index, name, banks, bankIds) {
3275
2931
  )
3276
2932
  ];
3277
2933
  }
3278
- function resolveChoices2(gap, banks, bankIds) {
2934
+ function resolveChoices(gap, banks, bankIds) {
3279
2935
  const hasOwn = !isUnset(gap.choices);
3280
2936
  const hasBank = !isUnwritten(gap.bankId);
3281
2937
  if (hasOwn === hasBank) {
@@ -3499,24 +3155,173 @@ function checkMultipleChoiceDraft(draft) {
3499
3155
  )
3500
3156
  );
3501
3157
  }
3502
- }
3503
- issues.push(...checkSharedOptional(draft));
3504
- return issues;
3505
- }
3506
- function checkOptionMedia(media, index, ordinal) {
3507
- const path = ["options", index, "media"];
3508
- const kind = media.type;
3509
- if (kind === "video" || kind === "embed") {
3510
- return [
3511
- issue(
3512
- "mc_option_media_kind",
3513
- [...path, "type"],
3514
- `Option ${ordinal} carries ${kind === "embed" ? "an embedded player" : "a video"}, which cannot be an option: its controls swallow the click that selects the answer. Use a picture or a recording.`
3515
- )
3516
- ];
3517
- }
3518
- return checkMedia(media, path, MultipleChoiceOptionMediaSchema);
3519
- }
3158
+ }
3159
+ issues.push(...checkSharedOptional(draft));
3160
+ return issues;
3161
+ }
3162
+ function checkOptionMedia(media, index, ordinal) {
3163
+ const path = ["options", index, "media"];
3164
+ const kind = media.type;
3165
+ if (kind === "video" || kind === "embed") {
3166
+ return [
3167
+ issue(
3168
+ "mc_option_media_kind",
3169
+ [...path, "type"],
3170
+ `Option ${ordinal} carries ${kind === "embed" ? "an embedded player" : "a video"}, which cannot be an option: its controls swallow the click that selects the answer. Use a picture or a recording.`
3171
+ )
3172
+ ];
3173
+ }
3174
+ return checkMedia(media, path, MultipleChoiceOptionMediaSchema);
3175
+ }
3176
+
3177
+ // src/schemas/read-aloud.ts
3178
+ import { z as z8 } from "zod/v4";
3179
+
3180
+ // src/scoring/speech/locale.ts
3181
+ var CANONICAL_LOCALE_RE = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?-(?:[A-Z]{2}|[0-9]{3})$/;
3182
+
3183
+ // src/schemas/read-aloud.ts
3184
+ var READ_ALOUD_DIMENSIONS = ["accuracy", "fluency", "completeness", "prosody"];
3185
+ var READ_ALOUD_MAX_DIMENSIONS = READ_ALOUD_DIMENSIONS.length;
3186
+ var ReadAloudSlowMediaSchema = z8.strictObject({
3187
+ type: z8.literal("audio"),
3188
+ url: MediaUrlSchema,
3189
+ alt: z8.string().min(1).optional()
3190
+ });
3191
+ var ReferenceTextSchema = z8.string().min(1).refine((text) => text.trim() !== "", {
3192
+ error: "The text to read aloud must contain something to read."
3193
+ }).meta({ maxLength: READ_ALOUD_MAX_REFERENCE_LENGTH });
3194
+ var RecordingBoundsSchema = z8.looseObject({
3195
+ maxSeconds: z8.number().gt(0).max(READ_ALOUD_MAX_SECONDS),
3196
+ minSeconds: z8.number().min(0).optional(),
3197
+ maxTakes: z8.number().int().min(1).max(READ_ALOUD_MAX_TAKES).optional()
3198
+ });
3199
+ var ReadAloudDimensionWeightSchema = z8.looseObject({
3200
+ name: z8.enum(READ_ALOUD_DIMENSIONS),
3201
+ weight: z8.number().min(0).max(READ_ALOUD_MAX_DIMENSION_WEIGHT)
3202
+ });
3203
+ var ReadAloudScoringSchema = z8.looseObject({
3204
+ dimensions: z8.array(ReadAloudDimensionWeightSchema).min(1).max(READ_ALOUD_MAX_DIMENSIONS)
3205
+ });
3206
+ var ReadAloudDataShape = z8.looseObject({
3207
+ schemaVersion: z8.literal("1.0"),
3208
+ type: z8.literal("read-aloud"),
3209
+ id: z8.string().min(1),
3210
+ title: z8.string().min(1),
3211
+ instructions: z8.string().optional(),
3212
+ referenceText: ReferenceTextSchema,
3213
+ locale: z8.string().regex(CANONICAL_LOCALE_RE, {
3214
+ error: `The locale must be a BCP 47 tag in canonical form, with a region: "en-US", "es-419", "zh-Hant-TW". Pronunciation is assessed against one locale's speech, and "en" does not say whose.`
3215
+ }),
3216
+ media: MediaSchema.optional(),
3217
+ slowMedia: ReadAloudSlowMediaSchema.optional(),
3218
+ recording: RecordingBoundsSchema,
3219
+ scoring: ReadAloudScoringSchema,
3220
+ passThreshold: z8.number().min(0).max(1).optional(),
3221
+ feedback: FeedbackSchema.optional(),
3222
+ learningObjectives: z8.array(z8.string()).optional(),
3223
+ difficultyLevel: z8.literal([1, 2, 3, 4, 5]).optional(),
3224
+ ai: AiPermissionsSchema.optional()
3225
+ });
3226
+ var ReadAloudDataSchema = ReadAloudDataShape.check(
3227
+ checkEvenAfterIssues((ctx) => {
3228
+ if (ctx.issues.some((issue2) => (issue2.path?.length ?? 0) === 0)) {
3229
+ return;
3230
+ }
3231
+ const { data, refused } = parsedFields(ctx);
3232
+ const report = (path, input, message) => {
3233
+ ctx.issues.push({ code: "custom", input, message, path: [...path] });
3234
+ };
3235
+ if (!refused.has("referenceText")) {
3236
+ const text = data.referenceText;
3237
+ const measured = dictationNormalizer(void 0).measure(text);
3238
+ if (measured.tooLong) {
3239
+ report(
3240
+ ["referenceText"],
3241
+ text,
3242
+ `The text to read aloud is at most ${READ_ALOUD_MAX_REFERENCE_LENGTH} characters, before and after normalisation.`
3243
+ );
3244
+ }
3245
+ if (measured.normalized === "") {
3246
+ report(
3247
+ ["referenceText"],
3248
+ text,
3249
+ "The text to read aloud must contain a word: after ignoring punctuation and spacing nothing is left, so no word would be marked and every reading would align as one insertion after another."
3250
+ );
3251
+ }
3252
+ if (containsUnspacedScript(text)) {
3253
+ report(
3254
+ ["referenceText"],
3255
+ text,
3256
+ "A read-aloud item needs a script written with spaces between its words. The text is compared word by word, and a run of Han, kana, Thai, Lao, Khmer or Myanmar letters is one word however many it holds."
3257
+ );
3258
+ }
3259
+ }
3260
+ const mediaParses = !refused.has("media");
3261
+ const slowParses = !refused.has("slowMedia");
3262
+ if (mediaParses && data.media !== void 0 && data.media.type !== "audio") {
3263
+ report(
3264
+ ["media", "type"],
3265
+ data.media.type,
3266
+ "A read-aloud model is read aloud: its recording must be audio."
3267
+ );
3268
+ }
3269
+ if (mediaParses && slowParses && data.slowMedia !== void 0) {
3270
+ if (data.media === void 0) {
3271
+ report(
3272
+ ["media"],
3273
+ data.media,
3274
+ "A slow model recording accompanies a recording: add `media` first."
3275
+ );
3276
+ }
3277
+ if (data.media?.playback?.maxPlays !== void 0) {
3278
+ report(
3279
+ ["slowMedia"],
3280
+ data.slowMedia,
3281
+ "A play budget on `media` cannot coexist with an unbudgeted slow recording of the same content. Drop `slowMedia`, or drop `maxPlays`."
3282
+ );
3283
+ }
3284
+ if (typeof data.slowMedia.url === "string" && typeof data.media?.url === "string" && data.slowMedia.url === data.media.url) {
3285
+ report(
3286
+ ["slowMedia", "url"],
3287
+ data.slowMedia.url,
3288
+ "The slow model recording must be a different file from the recording."
3289
+ );
3290
+ }
3291
+ }
3292
+ if (!refused.has("scoring")) {
3293
+ const dimensions = data.scoring.dimensions;
3294
+ const seen = /* @__PURE__ */ new Set();
3295
+ for (const [index, dimension] of dimensions.entries()) {
3296
+ if (seen.has(dimension.name)) {
3297
+ report(
3298
+ ["scoring", "dimensions", index, "name"],
3299
+ dimension.name,
3300
+ `"${dimension.name}" is already weighed: a dimension counted twice hides which weight the grade used.`
3301
+ );
3302
+ }
3303
+ seen.add(dimension.name);
3304
+ }
3305
+ if (dimensions.every((dimension) => dimension.weight === 0)) {
3306
+ report(
3307
+ ["scoring", "dimensions"],
3308
+ dimensions,
3309
+ "At least one dimension must carry a weight above 0: with every weight at 0 there is no weighted total, and every take would be unscorable."
3310
+ );
3311
+ }
3312
+ }
3313
+ if (!refused.has("recording")) {
3314
+ const { minSeconds, maxSeconds } = data.recording;
3315
+ if (minSeconds !== void 0 && minSeconds >= maxSeconds) {
3316
+ report(
3317
+ ["recording", "minSeconds"],
3318
+ minSeconds,
3319
+ "The shortest take must be shorter than the longest."
3320
+ );
3321
+ }
3322
+ }
3323
+ })
3324
+ );
3520
3325
 
3521
3326
  // src/authoring/read-aloud.ts
3522
3327
  var readAloudAuthoring = {
@@ -3936,6 +3741,330 @@ function checkCriteria(criteria) {
3936
3741
  return issues;
3937
3742
  }
3938
3743
 
3744
+ // src/schemas/gap-select.ts
3745
+ import { z as z9 } from "zod/v4";
3746
+ var GapSelectChoiceSchema = z9.looseObject({
3747
+ id: z9.string().min(1),
3748
+ text: z9.string().min(1)
3749
+ });
3750
+ var GapSelectBankSchema = z9.looseObject({
3751
+ id: z9.string().min(1),
3752
+ choices: z9.array(GapSelectChoiceSchema).min(2)
3753
+ });
3754
+ var GapSelectGapSchema = z9.looseObject({
3755
+ id: z9.string().min(1),
3756
+ choices: z9.array(GapSelectChoiceSchema).min(2).optional(),
3757
+ bankId: z9.string().min(1).optional(),
3758
+ correctChoiceId: z9.string().min(1),
3759
+ feedback: z9.string().optional()
3760
+ });
3761
+ function resolveChoices2(gap, banks) {
3762
+ if (gap.choices !== void 0) {
3763
+ return gap.bankId === void 0 ? gap.choices : void 0;
3764
+ }
3765
+ if (gap.bankId === void 0) {
3766
+ return void 0;
3767
+ }
3768
+ return banks.find((bank) => bank.id === gap.bankId)?.choices;
3769
+ }
3770
+ function placeholderCounts(passage) {
3771
+ const counts = /* @__PURE__ */ new Map();
3772
+ for (const match of passage.matchAll(PLACEHOLDER_RE)) {
3773
+ const id = match[1];
3774
+ counts.set(id, (counts.get(id) ?? 0) + 1);
3775
+ }
3776
+ return counts;
3777
+ }
3778
+ var GapSelectDataSchema = z9.looseObject({
3779
+ schemaVersion: z9.literal("1.0"),
3780
+ type: z9.literal("gap-select"),
3781
+ id: z9.string().min(1),
3782
+ title: z9.string().min(1),
3783
+ passage: z9.string().min(1),
3784
+ passageHtml: z9.string().optional(),
3785
+ gaps: z9.array(GapSelectGapSchema).min(1),
3786
+ banks: z9.array(GapSelectBankSchema).optional(),
3787
+ scoringStrategy: z9.enum(["all-or-nothing", "partial"]),
3788
+ presentation: z9.literal("dropdown").optional(),
3789
+ shuffleChoices: z9.boolean().optional(),
3790
+ media: MediaSchema.optional(),
3791
+ feedback: FeedbackSchema.optional(),
3792
+ passThreshold: z9.number().min(0).max(1).optional(),
3793
+ locale: z9.string().optional(),
3794
+ learningObjectives: z9.array(z9.string()).optional(),
3795
+ difficultyLevel: z9.literal([1, 2, 3, 4, 5]).optional(),
3796
+ ai: AiPermissionsSchema.optional()
3797
+ }).refine(
3798
+ (data) => {
3799
+ const ids = (data.banks ?? []).map((bank) => bank.id);
3800
+ return new Set(ids).size === ids.length;
3801
+ },
3802
+ { error: "Word bank ids must be unique.", path: ["banks"] }
3803
+ ).refine(
3804
+ (data) => {
3805
+ const ids = data.gaps.map((gap) => gap.id);
3806
+ return new Set(ids).size === ids.length;
3807
+ },
3808
+ { error: "Gap ids must be unique.", path: ["gaps"] }
3809
+ ).refine(
3810
+ (data) => {
3811
+ const counts = placeholderCounts(data.passage);
3812
+ const ids = data.gaps.map((gap) => gap.id);
3813
+ return counts.size === new Set(ids).size && ids.every((id) => counts.get(id) === 1) && [...counts.values()].every((count) => count === 1);
3814
+ },
3815
+ {
3816
+ error: "Every gap must appear exactly once in the passage as {{gap_id}}, and every {{gap_id}} must have a gap.",
3817
+ path: ["passage"]
3818
+ }
3819
+ ).refine(
3820
+ (data) => data.gaps.every((gap) => gap.choices === void 0 !== (gap.bankId === void 0)),
3821
+ {
3822
+ error: "Each gap needs exactly one choice source: its own `choices`, or a `bankId`.",
3823
+ path: ["gaps"]
3824
+ }
3825
+ ).refine(
3826
+ (data) => {
3827
+ const bankIds = new Set((data.banks ?? []).map((bank) => bank.id));
3828
+ return data.gaps.every((gap) => gap.bankId === void 0 || bankIds.has(gap.bankId));
3829
+ },
3830
+ { error: "A gap references a `bankId` that no word bank defines.", path: ["gaps"] }
3831
+ ).refine(
3832
+ (data) => {
3833
+ const banks = data.banks ?? [];
3834
+ return data.gaps.every((gap) => {
3835
+ const choices = resolveChoices2(gap, banks);
3836
+ if (choices === void 0) {
3837
+ return true;
3838
+ }
3839
+ const ids = choices.map((choice) => choice.id);
3840
+ return new Set(ids).size === ids.length && ids.includes(gap.correctChoiceId);
3841
+ });
3842
+ },
3843
+ {
3844
+ error: "Each gap needs unique choice ids and a `correctChoiceId` that is one of the choices it offers.",
3845
+ path: ["gaps"]
3846
+ }
3847
+ );
3848
+
3849
+ // src/schemas/redacted.ts
3850
+ import { z as z11 } from "zod/v4";
3851
+
3852
+ // src/schemas/written-response.ts
3853
+ import { z as z10 } from "zod/v4";
3854
+ var WrittenResponseRubricCriterionSchema = z10.looseObject({
3855
+ name: z10.string().min(1),
3856
+ description: z10.string().optional(),
3857
+ weight: z10.number().min(0)
3858
+ });
3859
+ var WrittenResponseRubricSchema = z10.looseObject({
3860
+ label: z10.string().optional(),
3861
+ criteria: z10.array(WrittenResponseRubricCriterionSchema).min(1)
3862
+ });
3863
+ var RedactedWrittenResponseRubricCriterionSchema = z10.strictObject({
3864
+ name: z10.string().min(1),
3865
+ description: z10.string().optional(),
3866
+ weight: z10.number().min(0)
3867
+ });
3868
+ var RedactedWrittenResponseRubricSchema = z10.strictObject({
3869
+ label: z10.string().optional(),
3870
+ criteria: z10.array(RedactedWrittenResponseRubricCriterionSchema).min(1)
3871
+ });
3872
+ var WrittenResponseDataSchema = z10.looseObject({
3873
+ schemaVersion: z10.literal("1.0"),
3874
+ type: z10.literal("written-response"),
3875
+ id: z10.string().min(1),
3876
+ title: z10.string().min(1),
3877
+ prompt: z10.string(),
3878
+ promptHtml: z10.string().optional(),
3879
+ minWords: z10.number().int().min(0),
3880
+ maxWords: z10.number().int().min(1),
3881
+ rubric: WrittenResponseRubricSchema.optional(),
3882
+ languageTarget: z10.string().optional(),
3883
+ media: MediaSchema.optional(),
3884
+ feedback: FeedbackSchema.optional(),
3885
+ passThreshold: z10.number().min(0).max(1).optional(),
3886
+ locale: z10.string().optional(),
3887
+ learningObjectives: z10.array(z10.string()).optional(),
3888
+ difficultyLevel: z10.literal([1, 2, 3, 4, 5]).optional(),
3889
+ ai: AiPermissionsSchema.optional()
3890
+ }).refine((data) => data.maxWords >= data.minWords, {
3891
+ error: "maxWords must be greater than or equal to minWords.",
3892
+ path: ["maxWords"]
3893
+ });
3894
+
3895
+ // src/schemas/redacted.ts
3896
+ var redactedBase = {
3897
+ /** Marker distinguishing a redacted projection from full activity data. */
3898
+ redacted: z11.literal(true),
3899
+ /** Slot identity, carried through redaction so the client and the plan agree. */
3900
+ slotKey: z11.string().min(1).optional(),
3901
+ schemaVersion: z11.literal("1.0"),
3902
+ id: z11.string().min(1),
3903
+ title: z11.string().min(1),
3904
+ media: RedactedMediaSchema.optional(),
3905
+ passThreshold: z11.number().min(0).max(1).optional(),
3906
+ locale: z11.string().optional(),
3907
+ learningObjectives: z11.array(z11.string()).optional(),
3908
+ difficultyLevel: z11.literal([1, 2, 3, 4, 5]).optional(),
3909
+ ai: z11.strictObject({ explanations: z11.boolean().optional(), hints: z11.boolean().optional() }).optional()
3910
+ };
3911
+ var RedactedMultipleChoiceOptionMediaSchema = z11.strictObject({
3912
+ type: z11.enum(["image", "audio"]),
3913
+ url: z11.string().min(1),
3914
+ alt: z11.string().min(1).optional(),
3915
+ captionsUrl: z11.string().min(1).optional()
3916
+ });
3917
+ var RedactedMultipleChoiceOptionSchema = z11.strictObject({
3918
+ id: z11.string().min(1),
3919
+ text: z11.string().min(1),
3920
+ media: RedactedMultipleChoiceOptionMediaSchema.optional()
3921
+ });
3922
+ var RedactedMultipleChoiceDataSchema = z11.strictObject({
3923
+ ...redactedBase,
3924
+ type: z11.literal("multiple-choice"),
3925
+ question: z11.string().min(1),
3926
+ questionHtml: z11.string().optional(),
3927
+ mode: z11.enum(["single", "multi"]),
3928
+ options: z11.array(RedactedMultipleChoiceOptionSchema).min(2).max(26),
3929
+ shuffle: z11.boolean().optional()
3930
+ });
3931
+ var RedactedBlankConfigSchema = z11.strictObject({
3932
+ id: z11.string().min(1),
3933
+ hint: z11.string().optional()
3934
+ });
3935
+ var RedactedFillInTheBlanksDataSchema = z11.strictObject({
3936
+ ...redactedBase,
3937
+ type: z11.literal("fill-in-the-blanks"),
3938
+ passage: z11.string().min(1),
3939
+ passageHtml: z11.string().optional(),
3940
+ blanks: z11.array(RedactedBlankConfigSchema).min(1)
3941
+ });
3942
+ var RedactedGapSelectChoiceSchema = z11.strictObject({
3943
+ id: z11.string().min(1),
3944
+ text: z11.string().min(1)
3945
+ });
3946
+ var RedactedGapSelectBankSchema = z11.strictObject({
3947
+ id: z11.string().min(1),
3948
+ choices: z11.array(RedactedGapSelectChoiceSchema).min(2)
3949
+ });
3950
+ var RedactedGapSelectGapSchema = z11.strictObject({
3951
+ id: z11.string().min(1),
3952
+ choices: z11.array(RedactedGapSelectChoiceSchema).min(2).optional(),
3953
+ bankId: z11.string().min(1).optional()
3954
+ });
3955
+ var RedactedGapSelectDataSchema = z11.strictObject({
3956
+ ...redactedBase,
3957
+ type: z11.literal("gap-select"),
3958
+ passage: z11.string().min(1),
3959
+ passageHtml: z11.string().optional(),
3960
+ gaps: z11.array(RedactedGapSelectGapSchema).min(1),
3961
+ banks: z11.array(RedactedGapSelectBankSchema).optional(),
3962
+ presentation: z11.literal("dropdown").optional(),
3963
+ shuffleChoices: z11.boolean().optional()
3964
+ });
3965
+ var RedactedWrittenResponseDataSchema = z11.strictObject({
3966
+ ...redactedBase,
3967
+ type: z11.literal("written-response"),
3968
+ prompt: z11.string(),
3969
+ promptHtml: z11.string().optional(),
3970
+ minWords: z11.number().int().min(0),
3971
+ maxWords: z11.number().int().min(1),
3972
+ rubric: RedactedWrittenResponseRubricSchema.optional(),
3973
+ languageTarget: z11.string().optional()
3974
+ });
3975
+ var RedactedDictationSlowMediaSchema = z11.strictObject({
3976
+ type: z11.literal("audio"),
3977
+ url: MediaUrlSchema,
3978
+ alt: z11.string().min(1).optional()
3979
+ });
3980
+ var RedactedDictationDataSchema = z11.strictObject({
3981
+ ...redactedBase,
3982
+ type: z11.literal("dictation"),
3983
+ slowMedia: RedactedDictationSlowMediaSchema.optional(),
3984
+ hints: z11.strictObject({ mode: z11.literal("progressive-words") }).optional()
3985
+ }).check((ctx) => {
3986
+ const data = ctx.value;
3987
+ for (const captions of captionFieldsOf(data.media)) {
3988
+ ctx.issues.push({
3989
+ code: "custom",
3990
+ input: data.media?.[captions],
3991
+ message: "A dictation recording cannot carry captions: the captions are the answer, so this payload is not learner-safe.",
3992
+ path: ["media", captions]
3993
+ });
3994
+ }
3995
+ if (data.slowMedia !== void 0 && data.media?.playback?.maxPlays !== void 0) {
3996
+ ctx.issues.push({
3997
+ code: "custom",
3998
+ input: data.slowMedia,
3999
+ message: "A play budget on `media` cannot coexist with an unbudgeted slow recording of the same content.",
4000
+ path: ["slowMedia"]
4001
+ });
4002
+ }
4003
+ if (data.slowMedia !== void 0 && data.media === void 0) {
4004
+ ctx.issues.push({
4005
+ code: "custom",
4006
+ input: data.slowMedia,
4007
+ message: "A slow recording accompanies a recording: this payload has no `media`.",
4008
+ path: ["media"]
4009
+ });
4010
+ }
4011
+ });
4012
+ var RedactedReadAloudDataSchema = z11.strictObject({
4013
+ ...redactedBase,
4014
+ type: z11.literal("read-aloud"),
4015
+ locale: z11.string().regex(CANONICAL_LOCALE_RE),
4016
+ instructions: z11.string().optional(),
4017
+ referenceText: z11.string().min(1),
4018
+ slowMedia: ReadAloudSlowMediaSchema.optional(),
4019
+ recording: z11.strictObject({
4020
+ maxSeconds: z11.number().gt(0).max(READ_ALOUD_MAX_SECONDS),
4021
+ minSeconds: z11.number().min(0).optional(),
4022
+ maxTakes: z11.number().int().min(1).max(READ_ALOUD_MAX_TAKES).optional()
4023
+ }),
4024
+ scoring: z11.strictObject({
4025
+ dimensions: z11.array(
4026
+ z11.strictObject({
4027
+ name: z11.enum(READ_ALOUD_DIMENSIONS),
4028
+ weight: z11.number().min(0).max(READ_ALOUD_MAX_DIMENSION_WEIGHT)
4029
+ })
4030
+ ).min(1).max(READ_ALOUD_DIMENSIONS.length)
4031
+ })
4032
+ }).check((ctx) => {
4033
+ const data = ctx.value;
4034
+ if (data.media !== void 0 && data.media.type !== "audio") {
4035
+ ctx.issues.push({
4036
+ code: "custom",
4037
+ input: data.media.type,
4038
+ message: "A read-aloud model recording must be audio, so this payload is not renderable.",
4039
+ path: ["media", "type"]
4040
+ });
4041
+ }
4042
+ if (data.slowMedia !== void 0 && data.media === void 0) {
4043
+ ctx.issues.push({
4044
+ code: "custom",
4045
+ input: data.slowMedia,
4046
+ message: "A slow model recording accompanies a recording: this payload has no `media`.",
4047
+ path: ["media"]
4048
+ });
4049
+ }
4050
+ if (data.slowMedia !== void 0 && data.media?.playback?.maxPlays !== void 0) {
4051
+ ctx.issues.push({
4052
+ code: "custom",
4053
+ input: data.slowMedia,
4054
+ message: "A play budget on `media` cannot coexist with an unbudgeted slow recording of the same content.",
4055
+ path: ["slowMedia"]
4056
+ });
4057
+ }
4058
+ if (data.slowMedia !== void 0 && data.slowMedia.url === data.media?.url) {
4059
+ ctx.issues.push({
4060
+ code: "custom",
4061
+ input: data.slowMedia.url,
4062
+ message: "The slow model recording must be a different file from the recording.",
4063
+ path: ["slowMedia", "url"]
4064
+ });
4065
+ }
4066
+ });
4067
+
3939
4068
  // src/scoring/activity-scorers/dictation.ts
3940
4069
  function scoreDictation(data, response) {
3941
4070
  const alignment = alignDictation(data, typeof response?.text === "string" ? response.text : "");
@@ -3946,7 +4075,6 @@ function scoreDictation(data, response) {
3946
4075
  }
3947
4076
  details.push({
3948
4077
  itemId: word.itemId,
3949
- correct: word.status === "correct",
3950
4078
  outcome: word.status === "correct" ? "correct" : word.status === "incorrect" ? "incorrect" : "incorrect-omission",
3951
4079
  learnerResponse: word.attempt,
3952
4080
  correctResponse: word.reference,
@@ -3990,7 +4118,6 @@ function scoreFillInTheBlanks(data, response) {
3990
4118
  perBlankCorrect.push(matched);
3991
4119
  details.push({
3992
4120
  itemId: blank.id,
3993
- correct: matched,
3994
4121
  outcome: matched ? "correct" : "incorrect",
3995
4122
  learnerResponse: [input],
3996
4123
  correctResponse: [...blank.acceptedAnswers],
@@ -4022,7 +4149,6 @@ function scoreGapSelect(data, response) {
4022
4149
  perGapCorrect.push(correct);
4023
4150
  details.push({
4024
4151
  itemId: gap.id,
4025
- correct,
4026
4152
  outcome: correct ? "correct" : answered ? "incorrect" : "incorrect-omission",
4027
4153
  learnerResponse: [selected],
4028
4154
  correctResponse: [gap.correctChoiceId],
@@ -4067,7 +4193,6 @@ function scoreMultipleChoice(data, response) {
4067
4193
  const outcome = wasSelected ? option.isCorrect ? "correct" : "incorrect" : option.isCorrect ? "incorrect-omission" : "correct-omission";
4068
4194
  return {
4069
4195
  itemId: option.id,
4070
- correct: wasSelected === option.isCorrect,
4071
4196
  outcome,
4072
4197
  learnerResponse: [wasSelected ? "selected" : "not-selected"],
4073
4198
  correctResponse: [option.isCorrect ? "selected" : "not-selected"],
@@ -4383,6 +4508,19 @@ var readAloudType = defineActivityType({
4383
4508
  interactions: ["recording-started", "recording-stopped", "recording-uploaded", "submitted"],
4384
4509
  authoring: readAloudAuthoring
4385
4510
  });
4511
+ for (const descriptor of [
4512
+ multipleChoiceType,
4513
+ fillInTheBlanksType,
4514
+ writtenResponseType,
4515
+ gapSelectType,
4516
+ dictationType,
4517
+ readAloudType
4518
+ ]) {
4519
+ ownSchemas(
4520
+ descriptor.schema,
4521
+ ...descriptor.redactedSchema !== void 0 ? [descriptor.redactedSchema] : []
4522
+ );
4523
+ }
4386
4524
  registerActivityType(multipleChoiceType);
4387
4525
  registerActivityType(fillInTheBlanksType);
4388
4526
  registerActivityType(writtenResponseType);
@@ -4391,17 +4529,10 @@ registerActivityType(dictationType);
4391
4529
  registerActivityType(readAloudType);
4392
4530
 
4393
4531
  export {
4394
- AiPermissionsSchema,
4395
- FeedbackSchema,
4396
- MediaUrlSchema,
4397
- NativeControlHintSchema,
4398
- MediaPlaybackSchema,
4399
- MediaTrackSchema,
4532
+ maxUnits,
4400
4533
  MediaSchema,
4401
4534
  RedactedMediaSchema,
4402
4535
  PLACEHOLDER_RE,
4403
- TextMatchPolicySchema,
4404
- BlankConfigSchema,
4405
4536
  FillInTheBlanksDataSchema,
4406
4537
  withValidationScope,
4407
4538
  DICTATION_MAX_TRANSCRIPT_LENGTH,
@@ -4419,9 +4550,6 @@ export {
4419
4550
  UnknownActivityTypeError,
4420
4551
  RedactedScoringError,
4421
4552
  DeferredScoringError,
4422
- DictationSlowMediaSchema,
4423
- DictationEquivalenceSchema,
4424
- DictationToleranceSchema,
4425
4553
  DictationDataSchema,
4426
4554
  groupCaptionsField,
4427
4555
  GROUP_CAPTIONS_REVEAL_DICTATION,
@@ -4437,7 +4565,6 @@ export {
4437
4565
  pathKey,
4438
4566
  coveredPathsOf,
4439
4567
  MultipleChoiceOptionMediaSchema,
4440
- MultipleChoiceOptionSchema,
4441
4568
  MultipleChoiceDataSchema,
4442
4569
  READ_ALOUD_MAX_REFERENCE_LENGTH,
4443
4570
  READ_ALOUD_MAX_SECONDS,
@@ -4446,29 +4573,13 @@ export {
4446
4573
  SPEECH_ASSESSMENT_MAX_WORDS,
4447
4574
  SPEECH_ASSESSMENT_MAX_TEXT_LENGTH,
4448
4575
  CANONICAL_LOCALE_RE,
4449
- ReadAloudSlowMediaSchema,
4450
4576
  ReadAloudDataSchema,
4451
4577
  countWords,
4452
- GapSelectChoiceSchema,
4453
- GapSelectBankSchema,
4454
- GapSelectGapSchema,
4455
4578
  GapSelectDataSchema,
4456
- WrittenResponseRubricCriterionSchema,
4457
- WrittenResponseRubricSchema,
4579
+ isOwnSchema,
4580
+ readSchema,
4581
+ standardJsonSchemaOf,
4458
4582
  WrittenResponseDataSchema,
4459
- RedactedMultipleChoiceOptionMediaSchema,
4460
- RedactedMultipleChoiceOptionSchema,
4461
- RedactedMultipleChoiceDataSchema,
4462
- RedactedBlankConfigSchema,
4463
- RedactedFillInTheBlanksDataSchema,
4464
- RedactedGapSelectChoiceSchema,
4465
- RedactedGapSelectBankSchema,
4466
- RedactedGapSelectGapSchema,
4467
- RedactedGapSelectDataSchema,
4468
- RedactedWrittenResponseDataSchema,
4469
- RedactedDictationSlowMediaSchema,
4470
- RedactedDictationDataSchema,
4471
- RedactedReadAloudDataSchema,
4472
4583
  levenshteinDistance,
4473
4584
  matchText,
4474
4585
  defineActivityType,
@@ -4483,4 +4594,4 @@ export {
4483
4594
  dictationType,
4484
4595
  readAloudType
4485
4596
  };
4486
- //# sourceMappingURL=chunk-D3GP5MET.js.map
4597
+ //# sourceMappingURL=chunk-FBKTION6.js.map