@intellectif/lk-core 1.2.0 → 1.4.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 (105) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +2 -2
  3. package/dist/ai-CnmLPMzd.d.cts +2417 -0
  4. package/dist/ai-CnmLPMzd.d.cts.map +1 -0
  5. package/dist/ai-CnmLPMzd.d.ts +2417 -0
  6. package/dist/ai-CnmLPMzd.d.ts.map +1 -0
  7. package/dist/ai-check.cjs +845 -620
  8. package/dist/ai-check.cjs.map +1 -1
  9. package/dist/ai-check.d.cts +82 -80
  10. package/dist/ai-check.d.cts.map +1 -0
  11. package/dist/ai-check.d.ts +82 -80
  12. package/dist/ai-check.d.ts.map +1 -0
  13. package/dist/ai-check.js +843 -621
  14. package/dist/ai-check.js.map +1 -1
  15. package/dist/ai-writing-CPpH9Qxq.js +2601 -0
  16. package/dist/ai-writing-CPpH9Qxq.js.map +1 -0
  17. package/dist/ai-writing-G6tnCGzw.cjs +2846 -0
  18. package/dist/ai-writing-G6tnCGzw.cjs.map +1 -0
  19. package/dist/index-2nigSf4N.d.cts +339 -0
  20. package/dist/index-2nigSf4N.d.cts.map +1 -0
  21. package/dist/index-B54baFUA.d.ts +339 -0
  22. package/dist/index-B54baFUA.d.ts.map +1 -0
  23. package/dist/index-BAfEHCnD.d.cts +1071 -0
  24. package/dist/index-BAfEHCnD.d.cts.map +1 -0
  25. package/dist/index-BqMXWuNC.d.cts +153 -0
  26. package/dist/index-BqMXWuNC.d.cts.map +1 -0
  27. package/dist/index-CVWvymVb.d.ts +1071 -0
  28. package/dist/index-CVWvymVb.d.ts.map +1 -0
  29. package/dist/index-CcHDTxIZ.d.ts +153 -0
  30. package/dist/index-CcHDTxIZ.d.ts.map +1 -0
  31. package/dist/index.cjs +1211 -1146
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +941 -861
  34. package/dist/index.d.cts.map +1 -0
  35. package/dist/index.d.ts +941 -861
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +1063 -1147
  38. package/dist/index.js.map +1 -1
  39. package/dist/item-group-DXiR8nOr.js +445 -0
  40. package/dist/item-group-DXiR8nOr.js.map +1 -0
  41. package/dist/item-group-DroFXgLb.cjs +505 -0
  42. package/dist/item-group-DroFXgLb.cjs.map +1 -0
  43. package/dist/registry-DVi0LR_i.cjs +5915 -0
  44. package/dist/registry-DVi0LR_i.cjs.map +1 -0
  45. package/dist/registry-Dsx9mNsS.js +5449 -0
  46. package/dist/registry-Dsx9mNsS.js.map +1 -0
  47. package/dist/schemas-Bdw0nGPo.js +152 -0
  48. package/dist/schemas-Bdw0nGPo.js.map +1 -0
  49. package/dist/schemas-DVbAOrtd.cjs +224 -0
  50. package/dist/schemas-DVbAOrtd.cjs.map +1 -0
  51. package/dist/schemas.cjs +16 -34
  52. package/dist/schemas.d.cts +2 -2
  53. package/dist/schemas.d.ts +2 -2
  54. package/dist/schemas.js +3 -34
  55. package/dist/scoring-B2_HEhMT.cjs +1748 -0
  56. package/dist/scoring-B2_HEhMT.cjs.map +1 -0
  57. package/dist/scoring-BZqUkuDF.js +1604 -0
  58. package/dist/scoring-BZqUkuDF.js.map +1 -0
  59. package/dist/scoring.cjs +37 -75
  60. package/dist/scoring.d.cts +3 -1103
  61. package/dist/scoring.d.ts +3 -1103
  62. package/dist/scoring.js +3 -75
  63. package/dist/xapi-CMTph7wG.js +332 -0
  64. package/dist/xapi-CMTph7wG.js.map +1 -0
  65. package/dist/xapi-DNzkSJ0S.cjs +362 -0
  66. package/dist/xapi-DNzkSJ0S.cjs.map +1 -0
  67. package/dist/xapi.cjs +7 -16
  68. package/dist/xapi.d.cts +2 -150
  69. package/dist/xapi.d.ts +2 -150
  70. package/dist/xapi.js +2 -16
  71. package/package.json +4 -4
  72. package/dist/activity-D705CXng.d.cts +0 -2162
  73. package/dist/activity-D705CXng.d.ts +0 -2162
  74. package/dist/chunk-46BXIGLG.cjs +0 -272
  75. package/dist/chunk-46BXIGLG.cjs.map +0 -1
  76. package/dist/chunk-4YANYTFN.cjs +0 -1256
  77. package/dist/chunk-4YANYTFN.cjs.map +0 -1
  78. package/dist/chunk-7KOWU4RO.js +0 -2207
  79. package/dist/chunk-7KOWU4RO.js.map +0 -1
  80. package/dist/chunk-7THXD7AN.cjs +0 -5057
  81. package/dist/chunk-7THXD7AN.cjs.map +0 -1
  82. package/dist/chunk-CYCTIXUN.cjs +0 -118
  83. package/dist/chunk-CYCTIXUN.cjs.map +0 -1
  84. package/dist/chunk-CYPA5BW5.js +0 -118
  85. package/dist/chunk-CYPA5BW5.js.map +0 -1
  86. package/dist/chunk-JTNDIBZO.js +0 -272
  87. package/dist/chunk-JTNDIBZO.js.map +0 -1
  88. package/dist/chunk-MV2N2WNS.js +0 -352
  89. package/dist/chunk-MV2N2WNS.js.map +0 -1
  90. package/dist/chunk-MXI26H62.cjs +0 -2207
  91. package/dist/chunk-MXI26H62.cjs.map +0 -1
  92. package/dist/chunk-QS7B42ZR.js +0 -5057
  93. package/dist/chunk-QS7B42ZR.js.map +0 -1
  94. package/dist/chunk-QX2ERL72.js +0 -1256
  95. package/dist/chunk-QX2ERL72.js.map +0 -1
  96. package/dist/chunk-ZWXQSKCX.cjs +0 -352
  97. package/dist/chunk-ZWXQSKCX.cjs.map +0 -1
  98. package/dist/index-BChYtrEe.d.ts +0 -548
  99. package/dist/index-VXt_v3YI.d.cts +0 -548
  100. package/dist/schemas.cjs.map +0 -1
  101. package/dist/schemas.js.map +0 -1
  102. package/dist/scoring.cjs.map +0 -1
  103. package/dist/scoring.js.map +0 -1
  104. package/dist/xapi.cjs.map +0 -1
  105. package/dist/xapi.js.map +0 -1
@@ -0,0 +1,2846 @@
1
+ const require_registry = require("./registry-DVi0LR_i.cjs");
2
+ const require_scoring = require("./scoring-B2_HEhMT.cjs");
3
+ const require_item_group = require("./item-group-DroFXgLb.cjs");
4
+ //#region src/ai-material.ts
5
+ /**
6
+ * What a model is told about the group a question sits in: the material the
7
+ * learner reads and sees beside it.
8
+ *
9
+ * Internal: the request builders in `ai.ts` and `ai-writing.ts` are its public
10
+ * face, through their `group` input.
11
+ */
12
+ /**
13
+ * The longest material text a model is given, in code points. A longer one is
14
+ * cut and marked `truncated`: a long reading passage on an exam paper runs to
15
+ * 4,000–7,000.
16
+ */
17
+ const AI_MATERIAL_MAX_LENGTH = 12e3;
18
+ const KINDS$2 = {
19
+ text: true,
20
+ audio: true,
21
+ video: true,
22
+ image: true,
23
+ mixed: true
24
+ };
25
+ const MEDIA_TYPES = {
26
+ image: true,
27
+ audio: true,
28
+ video: true,
29
+ embed: true
30
+ };
31
+ const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
32
+ /** A string with something in it, as written; otherwise nothing. */
33
+ const filled = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
34
+ /**
35
+ * `text` cut to {@link AI_MATERIAL_MAX_LENGTH} code points. The cut falls at
36
+ * the last space in its second half, so no word is broken; in a script written
37
+ * without spaces, at the limit itself.
38
+ */
39
+ function cut(text) {
40
+ const points = [...text];
41
+ if (points.length <= 12e3) return {
42
+ text,
43
+ truncated: false
44
+ };
45
+ let end = AI_MATERIAL_MAX_LENGTH;
46
+ for (let at = AI_MATERIAL_MAX_LENGTH; at >= AI_MATERIAL_MAX_LENGTH / 2; at -= 1) if (/\s/u.test(points[at])) {
47
+ end = at;
48
+ break;
49
+ }
50
+ return {
51
+ text: points.slice(0, end).join("").trimEnd(),
52
+ truncated: true
53
+ };
54
+ }
55
+ /**
56
+ * The facts of `group`'s material, or `null` without a group or a stimulus it
57
+ * can read: not an object, or of a kind the SDK does not know.
58
+ *
59
+ * Only what the learner sees reaches a model. The `transcript` is author-only
60
+ * and never read, so the facts are the same for a whole group and a redacted
61
+ * one; a media address is never read either, since a model cannot open one
62
+ * and a signed address must not end up in a prompt. Each field is read once.
63
+ */
64
+ function materialFacts(group) {
65
+ if (!isRecord$4(group)) return null;
66
+ const stimulus = group.stimulus;
67
+ if (!isRecord$4(stimulus)) return null;
68
+ const kind = stimulus.kind;
69
+ if (typeof kind !== "string" || !Object.hasOwn(KINDS$2, kind)) return null;
70
+ const title = filled(stimulus.title) ?? filled(group.title);
71
+ const body = filled(stimulus.body);
72
+ const text = body === void 0 ? void 0 : cut(body);
73
+ const rawMedia = stimulus.media;
74
+ const mediaType = isRecord$4(rawMedia) ? rawMedia.type : void 0;
75
+ const alt = isRecord$4(rawMedia) ? filled(rawMedia.alt) : void 0;
76
+ const media = typeof mediaType === "string" && Object.hasOwn(MEDIA_TYPES, mediaType) ? {
77
+ type: mediaType,
78
+ ...alt !== void 0 ? { alt } : {}
79
+ } : void 0;
80
+ const attribution = filled(stimulus.attribution);
81
+ const locale = filled(stimulus.locale);
82
+ return {
83
+ kind,
84
+ ...title !== void 0 ? { title } : {},
85
+ ...text !== void 0 ? { text: text.text } : {},
86
+ ...text?.truncated === true ? { truncated: true } : {},
87
+ ...media !== void 0 ? { media } : {},
88
+ ...attribution !== void 0 ? { attribution } : {},
89
+ ...locale !== void 0 ? { locale } : {}
90
+ };
91
+ }
92
+ //#endregion
93
+ //#region src/ai-text.ts
94
+ /**
95
+ * How the SDK reads text a model wrote, before a learner does. Internal: shared
96
+ * by the AI checks, and exported by none of the package's entry points.
97
+ */
98
+ /**
99
+ * Plain text as the learner will read it: line endings unified, control
100
+ * characters other than line breaks and tabs removed, runs of blank lines
101
+ * collapsed, the ends trimmed.
102
+ */
103
+ function cleanText(text) {
104
+ return text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u2028\u2029]/g, "").replace(/\n{3,}/g, "\n\n").trim();
105
+ }
106
+ /** A provenance field a host may send, kept only when it is a short string. */
107
+ function provenanceOf(raw) {
108
+ if (typeof raw !== "object" || raw === null) return;
109
+ const kept = {};
110
+ for (const key of [
111
+ "model",
112
+ "promptHash",
113
+ "generatedAt"
114
+ ]) {
115
+ const value = raw[key];
116
+ if (typeof value === "string" && value.length > 0 && value.length <= 200) kept[key] = value;
117
+ }
118
+ return Object.keys(kept).length > 0 ? kept : void 0;
119
+ }
120
+ /**
121
+ * What the call cost, as a port reported it. Read on the same terms as the
122
+ * provenance: a number that is not finite and zero or more says nothing about
123
+ * a cost, so it is dropped rather than carried into a host's totals.
124
+ */
125
+ function usageOf(raw) {
126
+ if (typeof raw !== "object" || raw === null) return;
127
+ const kept = {};
128
+ for (const key of [
129
+ "promptTokens",
130
+ "completionTokens",
131
+ "costUsd"
132
+ ]) {
133
+ const value = raw[key];
134
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) kept[key] = value;
135
+ }
136
+ return Object.keys(kept).length > 0 ? kept : void 0;
137
+ }
138
+ /** Typographic quotes a model writes for the plain ones a learner typed, and back. */
139
+ const QUOTE_FOLD = {
140
+ "‘": "'",
141
+ "’": "'",
142
+ "‛": "'",
143
+ "′": "'",
144
+ "“": "\"",
145
+ "”": "\"",
146
+ "„": "\"",
147
+ "″": "\""
148
+ };
149
+ /**
150
+ * Text as a quote is matched against it: typographic quotes folded to plain
151
+ * ones and every run of whitespace one space — the two liberties a model takes
152
+ * when it copies words out — with, for each folded character, where it came
153
+ * from, so a match maps back to the learner's own text. Case is kept: a
154
+ * correction of "i" to "I" is about case.
155
+ */
156
+ function folded(text) {
157
+ let chars = "";
158
+ const from = [];
159
+ let space = false;
160
+ for (let at = 0; at < text.length; at += 1) {
161
+ const char = text[at];
162
+ if (/\s/u.test(char)) {
163
+ if (!space) {
164
+ chars += " ";
165
+ from.push(at);
166
+ space = true;
167
+ }
168
+ continue;
169
+ }
170
+ space = false;
171
+ chars += QUOTE_FOLD[char] ?? char;
172
+ from.push(at);
173
+ }
174
+ return {
175
+ chars,
176
+ from
177
+ };
178
+ }
179
+ /** A quote as it is searched for: folded, and without the spaces around it. */
180
+ const needle = (quote) => folded(quote).chars.trim();
181
+ //#endregion
182
+ //#region src/ai.ts
183
+ /**
184
+ * The activity types the SDK builds facts for, and which of them take hints.
185
+ * A dictation takes none: its answer is the whole sentence, and it has
186
+ * word-by-word hints of its own.
187
+ */
188
+ const AI_EXPLANATION_TYPES = [
189
+ "multiple-choice",
190
+ "fill-in-the-blanks",
191
+ "gap-select",
192
+ "dictation"
193
+ ];
194
+ const AI_HINT_TYPES = [
195
+ "multiple-choice",
196
+ "fill-in-the-blanks",
197
+ "gap-select"
198
+ ];
199
+ /** The longest AI text shown, in code points. Longer is refused, not cut. */
200
+ const AI_TEXT_MAX_LENGTH = 2e3;
201
+ /**
202
+ * Whether the SDK supports `feature` for this activity type: explanations and
203
+ * hints for the types it builds facts for, and writing feedback for a written
204
+ * response, and coaching for a read-aloud. A consumer's own type has none.
205
+ */
206
+ function aiSupports(activityType, feature) {
207
+ if (feature === "writing-feedback") return activityType === "written-response";
208
+ if (feature === "pronunciation-coaching") return activityType === "read-aloud";
209
+ return (feature === "hint" ? AI_HINT_TYPES : AI_EXPLANATION_TYPES).includes(activityType);
210
+ }
211
+ /**
212
+ * Whether the item's author allows `feature`. Only an explicit `false`
213
+ * switches it off: an author can refuse a feature, never force one on.
214
+ *
215
+ * Writing feedback and pronunciation coaching read `explanations`: each is a
216
+ * model's words about the learner's own answer, and an author who switched one
217
+ * off has switched off the others.
218
+ */
219
+ function aiAllowedByContent(data, feature) {
220
+ const permissions = data.ai;
221
+ if (typeof permissions !== "object" || permissions === null) return true;
222
+ return permissions[feature === "hint" ? "hints" : "explanations"] !== false;
223
+ }
224
+ /** A grade as an explanation must speak to it: all marks, some, or none. */
225
+ function aiGradeOf(result) {
226
+ const ratio = result.maxScore > 0 ? result.score / result.maxScore : 0;
227
+ let category = "partly-correct";
228
+ if (ratio >= 1 - 1e-9) category = "correct";
229
+ else if (ratio <= 1e-9) category = "incorrect";
230
+ return {
231
+ score: result.score,
232
+ maxScore: result.maxScore,
233
+ passed: result.passed,
234
+ category
235
+ };
236
+ }
237
+ /**
238
+ * The `correct` flag a detail stored before 0.3 carries instead of an
239
+ * `outcome`. On a blank, a gap or a word it meant the part was right; on a
240
+ * multiple-choice option, that the learner acted rightly on it.
241
+ */
242
+ const legacyCorrect = (detail) => detail.correct === true;
243
+ /** Whether a blank, a gap or a word was right, read from a server's detail. */
244
+ function detailRight(detail) {
245
+ const outcome = detail.outcome;
246
+ return outcome === void 0 ? legacyCorrect(detail) : outcome === "correct";
247
+ }
248
+ /** Whether an option is part of the answer, read from a server's detail (as the renderer reads it). */
249
+ function isAnswerOption(detail, chosen) {
250
+ switch (detail.outcome) {
251
+ case "correct":
252
+ case "incorrect-omission": return true;
253
+ case "incorrect":
254
+ case "correct-omission": return false;
255
+ default: return chosen ? legacyCorrect(detail) : !legacyCorrect(detail);
256
+ }
257
+ }
258
+ const firstString = (value) => Array.isArray(value) ? value[0] : value;
259
+ const listOf$1 = (value) => value === void 0 ? null : Array.isArray(value) ? [...value] : [value];
260
+ /** The response a learner has before touching anything. */
261
+ function emptyResponse(type) {
262
+ switch (type) {
263
+ case "multiple-choice": return {
264
+ type: "multiple-choice",
265
+ selectedOptionIds: []
266
+ };
267
+ case "fill-in-the-blanks": return {
268
+ type: "fill-in-the-blanks",
269
+ answers: {}
270
+ };
271
+ case "gap-select": return {
272
+ type: "gap-select",
273
+ selections: {}
274
+ };
275
+ case "dictation": return {
276
+ type: "dictation",
277
+ text: ""
278
+ };
279
+ default: return null;
280
+ }
281
+ }
282
+ const base = (data) => ({
283
+ activityId: data.id,
284
+ title: data.title,
285
+ ...typeof data.locale === "string" ? { locale: data.locale } : {}
286
+ });
287
+ /**
288
+ * The facts a model is given about one item and one answer. `details` are the
289
+ * SDK's per-part verdicts — from its own scorer, or from a server's outcome in
290
+ * review — and every `correct` is `null` without them. A redacted item yields
291
+ * facts without a key, which is what the learner's side holds; a port that
292
+ * needs the key loads the item on its server.
293
+ *
294
+ * `null` for a type the SDK builds no facts for.
295
+ */
296
+ function buildAiFacts(data, response, details) {
297
+ const byId = new Map((details ?? []).map((detail) => [detail.itemId, detail]));
298
+ const full = data.redacted !== true;
299
+ switch (data.type) {
300
+ case "multiple-choice": {
301
+ const item = data;
302
+ const chosen = new Set(response?.type === "multiple-choice" ? response.selectedOptionIds : []);
303
+ return {
304
+ ...base(data),
305
+ activityType: "multiple-choice",
306
+ question: item.question,
307
+ mode: item.mode,
308
+ options: item.options.map((option) => {
309
+ const detail = byId.get(option.id);
310
+ const wasChosen = chosen.has(option.id);
311
+ let correct = null;
312
+ if (full && typeof option.isCorrect === "boolean") correct = option.isCorrect;
313
+ else if (detail !== void 0) correct = isAnswerOption(detail, wasChosen);
314
+ return {
315
+ id: option.id,
316
+ text: option.text,
317
+ chosen: wasChosen,
318
+ correct,
319
+ ...typeof option.feedback === "string" ? { feedback: option.feedback } : {}
320
+ };
321
+ })
322
+ };
323
+ }
324
+ case "fill-in-the-blanks": {
325
+ const item = data;
326
+ const answers = response?.type === "fill-in-the-blanks" ? response.answers : {};
327
+ const blanks = new Map(item.blanks.map((blank) => [blank.id, blank]));
328
+ return {
329
+ ...base(data),
330
+ activityType: "fill-in-the-blanks",
331
+ passage: require_registry.numberedPassage(item.passage),
332
+ blanks: require_registry.placeholderOrder(item.passage).map((id, index) => {
333
+ const blank = blanks.get(id);
334
+ const detail = byId.get(id);
335
+ const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf$1(detail?.correctResponse);
336
+ return {
337
+ id,
338
+ position: index + 1,
339
+ typed: typeof answers[id] === "string" ? answers[id] : "",
340
+ accepted,
341
+ correct: detail === void 0 ? null : detailRight(detail),
342
+ ...typeof blank?.hint === "string" ? { hint: blank.hint } : {},
343
+ ...typeof blank?.feedback === "string" ? { feedback: blank.feedback } : {}
344
+ };
345
+ })
346
+ };
347
+ }
348
+ case "gap-select": {
349
+ const item = data;
350
+ const selections = response?.type === "gap-select" ? response.selections : {};
351
+ const gaps = new Map(item.gaps.map((gap) => [gap.id, gap]));
352
+ const banks = new Map((item.banks ?? []).map((bank) => [bank.id, bank]));
353
+ return {
354
+ ...base(data),
355
+ activityType: "gap-select",
356
+ passage: require_registry.numberedPassage(item.passage),
357
+ gaps: require_registry.placeholderOrder(item.passage).map((id, index) => {
358
+ const gap = gaps.get(id);
359
+ const choices = gap?.choices ?? (gap?.bankId === void 0 ? [] : banks.get(gap.bankId)?.choices ?? []);
360
+ const textOf = (choiceId) => choiceId === void 0 ? null : choices.find((choice) => choice.id === choiceId)?.text ?? null;
361
+ const detail = byId.get(id);
362
+ const answerId = full && typeof gap?.correctChoiceId === "string" ? gap.correctChoiceId : firstString(detail?.correctResponse);
363
+ return {
364
+ id,
365
+ position: index + 1,
366
+ choices: choices.map((choice) => choice.text),
367
+ chosen: textOf(selections[id]),
368
+ answer: textOf(answerId),
369
+ correct: detail === void 0 ? null : detailRight(detail),
370
+ ...typeof gap?.feedback === "string" ? { feedback: gap.feedback } : {}
371
+ };
372
+ })
373
+ };
374
+ }
375
+ case "dictation": {
376
+ const item = data;
377
+ const typed = response?.type === "dictation" ? response.text : "";
378
+ const hasKey = full && typeof item.transcript === "string";
379
+ let words = null;
380
+ if (hasKey) words = require_registry.alignDictation(item, typed).words.map((word) => ({
381
+ expected: word.reference,
382
+ typed: word.attempt,
383
+ status: word.status
384
+ }));
385
+ else if (details !== null && details.length > 0) words = details.map((detail) => {
386
+ const expected = firstString(detail.correctResponse) ?? "";
387
+ const attempt = firstString(detail.learnerResponse) ?? "";
388
+ let status = "incorrect";
389
+ if (detailRight(detail)) status = "correct";
390
+ else if (attempt === "") status = "missing";
391
+ else if (expected === "") status = "extra";
392
+ return {
393
+ expected,
394
+ typed: attempt,
395
+ status
396
+ };
397
+ });
398
+ return {
399
+ ...base(data),
400
+ activityType: "dictation",
401
+ transcript: hasKey ? item.transcript : null,
402
+ typed,
403
+ words
404
+ };
405
+ }
406
+ default: return null;
407
+ }
408
+ }
409
+ /** The SDK's own grade of `response`, with its per-part details; `null` when it cannot grade. */
410
+ function gradeLocally(data, response) {
411
+ if (data.redacted === true) return null;
412
+ try {
413
+ const result = require_scoring.score(data.type, data, response);
414
+ return {
415
+ details: result.details,
416
+ grade: aiGradeOf(result)
417
+ };
418
+ } catch {
419
+ return null;
420
+ }
421
+ }
422
+ /**
423
+ * The request for an explanation of `response`, or `null` when there is none
424
+ * to give: an unsupported type, an author who switched explanations off,
425
+ * nothing graded to explain, or a grade of record that cannot be a grade.
426
+ *
427
+ * A scored `outcome` — the grade of record a review shows — wins over grading
428
+ * locally, so the explanation speaks to the grade on the screen. One whose
429
+ * numbers cannot be a grade (the rule `outcomeFromGrade` applies: `maxScore`
430
+ * positive and finite, `score` finite from 0 to it) gets no explanation at all:
431
+ * explaining 85 "out of 1" would tell the model a wrong answer was correct, and
432
+ * grading locally instead would speak to a grade the screen does not show.
433
+ *
434
+ * `group` is the item group the question sits in: its material — the passage,
435
+ * or the instructions the group's questions share — goes with the request as
436
+ * `material`, as the learner sees it.
437
+ */
438
+ function aiExplanationRequest(input) {
439
+ const { data, response, outcome, learnerLocale } = input;
440
+ if (!aiSupports(data.type, "explanation") || !aiAllowedByContent(data, "explanation")) return null;
441
+ let details = null;
442
+ let grade = null;
443
+ if (outcome?.status === "scored") {
444
+ const { score: recorded, maxScore, passed } = outcome;
445
+ if (!require_scoring.isGradeInRange(recorded, maxScore)) return null;
446
+ details = outcome.details;
447
+ grade = aiGradeOf({
448
+ score: recorded,
449
+ maxScore,
450
+ passed
451
+ });
452
+ } else if (response !== null) {
453
+ const local = gradeLocally(data, response);
454
+ if (local !== null) {
455
+ details = local.details;
456
+ grade = local.grade;
457
+ }
458
+ }
459
+ if (grade === null) return null;
460
+ const facts = buildAiFacts(data, response, details);
461
+ if (facts === null) return null;
462
+ const material = materialFacts(input.group);
463
+ return {
464
+ feature: "explanation",
465
+ facts,
466
+ grade,
467
+ ...material !== null ? { material } : {},
468
+ ...learnerLocale !== void 0 ? { learnerLocale } : {}
469
+ };
470
+ }
471
+ /**
472
+ * The request for the next hint on an unsubmitted answer, or `null` when
473
+ * there is none to give: an unsupported type, an author who switched hints
474
+ * off, or a redacted item — hints are for practice, where the item is whole.
475
+ *
476
+ * The facts carry the key, and which parts the learner has right so far, so a
477
+ * model can hint where it helps. The learner never sees them; a hint that
478
+ * contains an answer is refused by {@link checkAiHint}, whatever it quotes —
479
+ * the group's material included.
480
+ *
481
+ * `group` is the item group the question sits in, as for
482
+ * {@link aiExplanationRequest}.
483
+ */
484
+ function aiHintRequest(input) {
485
+ const { data, previousHints, learnerLocale } = input;
486
+ if (!aiSupports(data.type, "hint") || !aiAllowedByContent(data, "hint") || data.redacted === true) return null;
487
+ const response = input.response ?? emptyResponse(data.type);
488
+ const facts = buildAiFacts(data, response, (response === null ? null : gradeLocally(data, response))?.details ?? null);
489
+ if (facts === null) return null;
490
+ const material = materialFacts(input.group);
491
+ return {
492
+ feature: "hint",
493
+ facts,
494
+ hintNumber: previousHints.length + 1,
495
+ previousHints: [...previousHints],
496
+ ...material !== null ? { material } : {},
497
+ ...learnerLocale !== void 0 ? { learnerLocale } : {}
498
+ };
499
+ }
500
+ const VERDICTS = [
501
+ "correct",
502
+ "partly-correct",
503
+ "incorrect"
504
+ ];
505
+ /**
506
+ * Reads what a port returned. Anything but an object with a string `text` is
507
+ * `malformed`; text that is empty once cleaned, or longer than
508
+ * {@link AI_TEXT_MAX_LENGTH}, is refused rather than shown or cut.
509
+ */
510
+ function readAiTextResult(raw) {
511
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
512
+ ok: false,
513
+ refusal: "malformed"
514
+ };
515
+ const record = raw;
516
+ if (typeof record.text !== "string") return {
517
+ ok: false,
518
+ refusal: "malformed"
519
+ };
520
+ if (record.verdict !== void 0 && !VERDICTS.includes(record.verdict)) return {
521
+ ok: false,
522
+ refusal: "malformed"
523
+ };
524
+ const text = cleanText(record.text);
525
+ if (text === "") return {
526
+ ok: false,
527
+ refusal: "empty"
528
+ };
529
+ if ([...text].length > 2e3) return {
530
+ ok: false,
531
+ refusal: "too-long"
532
+ };
533
+ const provenance = provenanceOf(record.provenance);
534
+ const usage = usageOf(record.usage);
535
+ return {
536
+ ok: true,
537
+ result: {
538
+ text,
539
+ ...record.verdict !== void 0 ? { verdict: record.verdict } : {},
540
+ ...provenance !== void 0 ? { provenance } : {},
541
+ ...usage !== void 0 ? { usage } : {}
542
+ }
543
+ };
544
+ }
545
+ /**
546
+ * Checks an explanation a port returned for `request`. On top of
547
+ * {@link readAiTextResult}: an explanation that states a verdict other than
548
+ * the SDK's grade is refused (`contradicts-grade`) — it explains an answer
549
+ * the learner did not get.
550
+ */
551
+ function checkAiExplanation(raw, request) {
552
+ const read = readAiTextResult(raw);
553
+ if (!read.ok) return read;
554
+ if (read.result.verdict !== void 0 && read.result.verdict !== request.grade.category) return {
555
+ ok: false,
556
+ refusal: "contradicts-grade"
557
+ };
558
+ return read;
559
+ }
560
+ /**
561
+ * Checks a hint a port returned for `request`. On top of
562
+ * {@link readAiTextResult}: a hint that contains an answer is refused
563
+ * (`reveals-answer`) — see {@link hintRevealsAnswer}.
564
+ */
565
+ function checkAiHint(raw, request) {
566
+ const read = readAiTextResult(raw);
567
+ if (!read.ok) return read;
568
+ if (hintRevealsAnswer(request.facts, read.result.text)) return {
569
+ ok: false,
570
+ refusal: "reveals-answer"
571
+ };
572
+ return read;
573
+ }
574
+ /**
575
+ * Whether a hint gives away an answer, judged against the facts it was asked
576
+ * for. It is a floor, not a proof: it finds an answer written out, not one
577
+ * spelled letter by letter or described.
578
+ *
579
+ * - **Multiple choice:** the text of any correct option, as whole words.
580
+ * - **Fill-in-the-blanks and gap select:** any accepted answer, as whole words.
581
+ * An answer of one short word ("is", "the") counts only where the hint
582
+ * writes it beside a neighbour it has in the passage — "name is", "is
583
+ * Rossi" — so a hint may still use the word on its own.
584
+ * - **Facts of any other kind:** revealing. A hint the check cannot read is
585
+ * withheld, not shown unchecked.
586
+ *
587
+ * Case, accents and punctuation are ignored on both sides.
588
+ */
589
+ function hintRevealsAnswer(facts, hint) {
590
+ const reveals = (answer, passage, position) => require_registry.revealsAnswer(hint, answer, passage, position);
591
+ switch (facts.activityType) {
592
+ case "multiple-choice": return facts.options.some((option) => option.correct === true && reveals(option.text));
593
+ case "fill-in-the-blanks": return facts.blanks.some((blank) => (blank.accepted ?? []).some((answer) => reveals(answer, facts.passage, blank.position)));
594
+ case "gap-select": return facts.gaps.some((gap) => gap.answer !== null && reveals(gap.answer, facts.passage, gap.position));
595
+ case "dictation": return false;
596
+ default: return true;
597
+ }
598
+ }
599
+ //#endregion
600
+ //#region src/ai-coaching.ts
601
+ /** The most words one coaching may work on. More is refused, not cut. */
602
+ const AI_COACHING_MAX_WORDS = 20;
603
+ /** The longest tip for one word, in code points. */
604
+ const AI_COACHING_MAX_TIP_LENGTH = 500;
605
+ /** The longest sound symbol: a phoneme, not a sentence. */
606
+ const MAX_SYMBOL_LENGTH = 16;
607
+ /** The dimensions a reading is scored on, as the facts name them. */
608
+ const DIMENSIONS = [
609
+ "accuracy",
610
+ "fluency",
611
+ "completeness",
612
+ "prosody"
613
+ ];
614
+ /** Two decimals: what a stored score out of 1 was, out of 100, without float noise. */
615
+ const hundredths = (value) => Math.round(value * 1e4) / 100;
616
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
617
+ /** A word's sounds as the engine reported them: only those it names. */
618
+ function soundsOf(word) {
619
+ const sounds = [];
620
+ for (const phoneme of word?.phonemes ?? []) {
621
+ if (typeof phoneme.symbol !== "string") continue;
622
+ sounds.push({
623
+ symbol: phoneme.symbol,
624
+ ...phoneme.accuracy !== void 0 ? { accuracy: phoneme.accuracy } : {},
625
+ ...phoneme.heardAs !== void 0 && phoneme.heardAs.length > 0 ? { heardAs: phoneme.heardAs.map(({ symbol, score }) => ({
626
+ symbol,
627
+ score
628
+ })) } : {}
629
+ });
630
+ }
631
+ return sounds.length > 0 ? sounds : void 0;
632
+ }
633
+ /** The facts from the engine's full assessment: every word's mark, and its sounds. */
634
+ function fromAssessment(item, assessment) {
635
+ const words = require_scoring.alignReadAloud(item, assessment).map((entry) => {
636
+ const sounds = entry.itemId !== void 0 && entry.wordIndex !== void 0 ? soundsOf(assessment.words[entry.wordIndex]) : void 0;
637
+ return {
638
+ ...entry.itemId !== void 0 ? { itemId: entry.itemId } : {},
639
+ word: entry.reference,
640
+ heard: entry.heard,
641
+ state: entry.state,
642
+ ...entry.accuracy !== void 0 ? { accuracy: entry.accuracy } : {},
643
+ ...sounds !== void 0 ? { sounds } : {}
644
+ };
645
+ });
646
+ const scores = {};
647
+ for (const dimension of DIMENSIONS) {
648
+ const value = assessment.scores[dimension];
649
+ if (value !== void 0) scores[dimension] = value;
650
+ }
651
+ return {
652
+ assessor: assessment.assessor.kind,
653
+ ...assessment.phonemeAlphabet !== void 0 ? { phonemeAlphabet: assessment.phonemeAlphabet } : {},
654
+ scores,
655
+ words
656
+ };
657
+ }
658
+ /**
659
+ * Whether an assessment holds marks on this text: one `validateSpeechAssessment`
660
+ * accepts, of speech heard, made against the item's own text in its own locale
661
+ * — the checks `gradeReadAloud` makes before it reads a mark. Anything else is
662
+ * not marks on this reading, and would tell a model every word was left out.
663
+ */
664
+ const marksThisText = (item, assessment) => require_scoring.validateSpeechAssessment(assessment).success && assessment.status === "assessed" && assessment.task === "scripted" && assessment.locale === item.locale && assessment.referenceText === item.referenceText;
665
+ const STATE_OF_OUTCOME = {
666
+ correct: "correct",
667
+ incorrect: "mispronounced",
668
+ "incorrect-omission": "omitted"
669
+ };
670
+ /**
671
+ * The facts from a stored grade alone: each word's mark and score, and no
672
+ * sounds — a grade's details do not keep them.
673
+ */
674
+ function fromGrade(grade) {
675
+ if (!Array.isArray(grade.details) || grade.details.length === 0) return null;
676
+ const words = [];
677
+ for (const detail of grade.details) {
678
+ const state = STATE_OF_OUTCOME[detail.outcome];
679
+ if (state === void 0 || typeof detail.itemId !== "string") continue;
680
+ const text = (value) => Array.isArray(value) ? value.join(" ") : value;
681
+ words.push({
682
+ itemId: detail.itemId,
683
+ word: text(detail.correctResponse),
684
+ heard: text(detail.learnerResponse),
685
+ state,
686
+ ...state !== "omitted" && isFiniteNumber(detail.score) ? { accuracy: hundredths(detail.score) } : {}
687
+ });
688
+ }
689
+ if (words.length === 0) return null;
690
+ const scores = {};
691
+ for (const criterion of grade.criteria ?? []) {
692
+ const dimension = DIMENSIONS.find((name) => name === criterion.name);
693
+ if (dimension !== void 0 && isFiniteNumber(criterion.score) && isFiniteNumber(criterion.maxScore) && criterion.maxScore > 0) scores[dimension] = hundredths(criterion.score / criterion.maxScore);
694
+ }
695
+ const kind = grade.grader?.kind;
696
+ return {
697
+ assessor: kind === "auto" || kind === "ai" || kind === "human" ? kind : "unknown",
698
+ scores,
699
+ words
700
+ };
701
+ }
702
+ /**
703
+ * The request for coaching on a graded reading, or `null` when there is none
704
+ * to give: not a read-aloud, an author who switched explanations off (coaching
705
+ * reads `ai.explanations`), or no marks.
706
+ *
707
+ * The marks come from the `assessment` when one is given — with each word's
708
+ * sounds and what each was heard as — and then only from it: an assessment
709
+ * `gradeReadAloud` would not read (no speech heard, unscripted, or made against
710
+ * another text or locale) gives none, rather than a stored grade's marks the
711
+ * learner is not looking at. With no assessment, they come from
712
+ * `grade.details`, without sounds.
713
+ *
714
+ * The facts are the text as the learner saw it, those marks word by word, and
715
+ * the engine's dimension scores. The marks are the engine's; the model
716
+ * explains them.
717
+ */
718
+ function aiCoachingRequest(input) {
719
+ const { data, assessment, grade, learnerLocale } = input;
720
+ if (!aiSupports(data.type, "pronunciation-coaching") || !aiAllowedByContent(data, "pronunciation-coaching")) return null;
721
+ const item = data;
722
+ if (typeof item.referenceText !== "string" || typeof item.locale !== "string") return null;
723
+ const marks = assessment !== void 0 && assessment !== null ? marksThisText(item, assessment) ? fromAssessment(item, assessment) : null : grade !== void 0 && grade !== null ? fromGrade(grade) : null;
724
+ if (marks === null) return null;
725
+ const graded = grade !== void 0 && grade !== null && isFiniteNumber(grade.score) && isFiniteNumber(grade.maxScore) && typeof grade.passed === "boolean" ? {
726
+ score: grade.score,
727
+ maxScore: grade.maxScore,
728
+ passed: grade.passed
729
+ } : void 0;
730
+ return {
731
+ feature: "pronunciation-coaching",
732
+ facts: {
733
+ activityType: "read-aloud",
734
+ activityId: data.id,
735
+ title: data.title,
736
+ locale: item.locale,
737
+ referenceText: item.referenceText,
738
+ ...typeof item.instructions === "string" ? { instructions: item.instructions } : {},
739
+ ...marks
740
+ },
741
+ ...graded !== void 0 ? { grade: graded } : {},
742
+ ...learnerLocale !== void 0 ? { learnerLocale } : {}
743
+ };
744
+ }
745
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
746
+ /** A symbol as it is compared: composed, and without the spaces around it. */
747
+ const symbolKey = (symbol) => symbol.normalize("NFC").trim();
748
+ /**
749
+ * Checks coaching a port returned for `request`, and turns it into what a
750
+ * learner is shown. As every AI text, it is shown whole or not at all.
751
+ *
752
+ * - **The text** is read as every AI text is (`readAiTextResult`).
753
+ * - **Every word coached is one the engine marked.** Each `itemId` names a word
754
+ * of the text marked `mispronounced` or `omitted`, once; coaching on a word
755
+ * read correctly, or one the text does not have, refuses the whole reply as
756
+ * `contradicts-marks`. Praise belongs in `text`.
757
+ * - **Every sound named is one the engine reported** for that word, and a sound
758
+ * it was heard as is one the engine heard it as. Where the facts carry no
759
+ * sounds — marks from a stored grade, or an assessor that reports none — any
760
+ * sound is `contradicts-marks`: nothing stands behind it.
761
+ * - **Nothing numeric is read.** A score in the reply is ignored: the marks and
762
+ * the grade are the engine's.
763
+ * - **Limits:** {@link AI_COACHING_MAX_WORDS} words, a tip of
764
+ * {@link AI_COACHING_MAX_TIP_LENGTH} code points; over them `too-long`. A
765
+ * shape that is not the one documented is `malformed`.
766
+ *
767
+ * The words come back in reading order, each with the word as the marks show it
768
+ * (the SDK's reading of the text, never the model's), and each sound spelt as
769
+ * the engine spelt it.
770
+ */
771
+ function checkAiCoaching(raw, request) {
772
+ const read = readAiTextResult(raw);
773
+ if (!read.ok) return read;
774
+ const entries = raw.words;
775
+ const words = [];
776
+ if (entries !== void 0 && entries !== null) {
777
+ if (!Array.isArray(entries)) return {
778
+ ok: false,
779
+ refusal: "malformed"
780
+ };
781
+ if (entries.length > 20) return {
782
+ ok: false,
783
+ refusal: "too-long"
784
+ };
785
+ const facts = /* @__PURE__ */ new Map();
786
+ request.facts.words.forEach((fact, order) => {
787
+ if (fact.itemId !== void 0) facts.set(fact.itemId, {
788
+ fact,
789
+ order
790
+ });
791
+ });
792
+ const seen = /* @__PURE__ */ new Set();
793
+ for (const entry of entries) {
794
+ if (!isRecord$3(entry) || typeof entry.itemId !== "string" || typeof entry.tip !== "string") return {
795
+ ok: false,
796
+ refusal: "malformed"
797
+ };
798
+ const tip = cleanText(entry.tip);
799
+ if (tip === "") return {
800
+ ok: false,
801
+ refusal: "malformed"
802
+ };
803
+ if ([...tip].length > 500) return {
804
+ ok: false,
805
+ refusal: "too-long"
806
+ };
807
+ if (seen.has(entry.itemId)) return {
808
+ ok: false,
809
+ refusal: "malformed"
810
+ };
811
+ seen.add(entry.itemId);
812
+ const found = facts.get(entry.itemId);
813
+ if (found === void 0 || found.fact.state !== "mispronounced" && found.fact.state !== "omitted") return {
814
+ ok: false,
815
+ refusal: "contradicts-marks"
816
+ };
817
+ const sound = checkSound(entry.sound, found.fact);
818
+ if (sound !== void 0 && "refusal" in sound) return sound;
819
+ words.push({
820
+ itemId: entry.itemId,
821
+ word: found.fact.word,
822
+ tip,
823
+ ...sound !== void 0 ? { sound } : {},
824
+ order: found.order
825
+ });
826
+ }
827
+ }
828
+ const { text, provenance, usage } = read.result;
829
+ return {
830
+ ok: true,
831
+ coaching: {
832
+ text,
833
+ words: words.sort((a, b) => a.order - b.order).map(({ order: _order, ...word }) => word),
834
+ ...provenance !== void 0 ? { provenance } : {},
835
+ ...usage !== void 0 ? { usage } : {}
836
+ }
837
+ };
838
+ }
839
+ /** A sound a model names, checked against the sounds the engine reported for the word. */
840
+ function checkSound(raw, fact) {
841
+ if (raw === void 0 || raw === null) return;
842
+ if (!isRecord$3(raw) || typeof raw.expected !== "string" || raw.heard !== void 0 && raw.heard !== null && typeof raw.heard !== "string") return {
843
+ ok: false,
844
+ refusal: "malformed"
845
+ };
846
+ const heard = typeof raw.heard === "string" ? raw.heard : void 0;
847
+ if ([...raw.expected].length > MAX_SYMBOL_LENGTH || heard !== void 0 && [...heard].length > MAX_SYMBOL_LENGTH) return {
848
+ ok: false,
849
+ refusal: "too-long"
850
+ };
851
+ const expected = symbolKey(raw.expected);
852
+ const reported = fact.sounds?.find((sound) => symbolKey(sound.symbol) === expected);
853
+ if (reported === void 0) return {
854
+ ok: false,
855
+ refusal: "contradicts-marks"
856
+ };
857
+ if (heard === void 0) return { expected: reported.symbol };
858
+ const instead = reported.heardAs?.find((candidate) => symbolKey(candidate.symbol) === symbolKey(heard));
859
+ if (instead === void 0) return {
860
+ ok: false,
861
+ refusal: "contradicts-marks"
862
+ };
863
+ return {
864
+ expected: reported.symbol,
865
+ heard: instead.symbol
866
+ };
867
+ }
868
+ //#endregion
869
+ //#region src/authoring/item-group.ts
870
+ /**
871
+ * A new, empty reading or listening testlet for an editor to start from.
872
+ *
873
+ * The stimulus starts as `text`, the only kind that needs no media file to be
874
+ * a valid draft — an author who is writing a passage can begin typing, and one
875
+ * who is building a listening item changes `kind` before uploading. `items`
876
+ * starts empty, exactly as a new fill-in-the-blanks draft starts with no
877
+ * blanks: the group is `incomplete` until somebody writes a question into it.
878
+ *
879
+ * @throws Error when `newId` returns an empty or repeated id.
880
+ */
881
+ function createItemGroupDraft(context) {
882
+ const issued = /* @__PURE__ */ new Set();
883
+ const newId = () => {
884
+ const id = context.newId();
885
+ if (typeof id !== "string" || id === "") throw new Error("createItemGroupDraft: newId() must return a non-empty string.");
886
+ if (issued.has(id)) throw new Error(`createItemGroupDraft: newId() returned "${id}" twice. Ids within one draft must differ.`);
887
+ issued.add(id);
888
+ return id;
889
+ };
890
+ return {
891
+ schemaVersion: "1.0",
892
+ type: "item-group",
893
+ id: newId(),
894
+ stimulus: {
895
+ id: newId(),
896
+ kind: "text",
897
+ body: ""
898
+ },
899
+ items: []
900
+ };
901
+ }
902
+ /**
903
+ * A new, empty interactive video for an editor to start from: an item group
904
+ * whose stimulus is a video with no file yet, no questions, and a timeline with
905
+ * no quizzes.
906
+ *
907
+ * It is an ordinary item group — {@link validateItemGroupDraft} checks it, and
908
+ * everything that reads groups reads it — because that is what keeps each
909
+ * question its own slot. Like every new draft it is `incomplete`: an untouched
910
+ * draft must never read as finished.
911
+ *
912
+ * @throws Error when `newId` returns an empty or repeated id.
913
+ */
914
+ function createInteractiveVideoDraft(context) {
915
+ const draft = createItemGroupDraft(context);
916
+ return {
917
+ ...draft,
918
+ stimulus: {
919
+ id: draft.stimulus.id,
920
+ kind: "video",
921
+ media: {
922
+ type: "video",
923
+ url: ""
924
+ }
925
+ },
926
+ timeline: { cues: [] }
927
+ };
928
+ }
929
+ /**
930
+ * Tells an unfinished testlet from a broken one — {@link validateDraft} for the
931
+ * container that holds several questions around one passage or recording.
932
+ *
933
+ * It is a **separate entry point rather than a `'item-group'` type**, because
934
+ * `validateDraft('item-group', …)` throws by design: `item-group` is reserved
935
+ * in the registry precisely so it can never be registered as an activity, and
936
+ * that guard is what keeps `isItemGroup` and `flattenSequence` able to trust
937
+ * their own container. Widening `validateDraft` to accept it would have meant
938
+ * weakening that.
939
+ *
940
+ * Each item is checked by `validateDraft` for its own type and its issues are
941
+ * re-pathed under `items.N.…`, so an editor gets one list for the whole group
942
+ * and every code in it is the code that type already documents. An item whose
943
+ * type nobody registered is REPORTED, never thrown — an author fixing a
944
+ * six-item group wants all six problems, not the first one that blew up.
945
+ *
946
+ * The group is `complete` only when the container passes, every item passes its
947
+ * own schema, and every item is itself `complete`.
948
+ *
949
+ * @throws the error a registered item type's `checkDraft` or schema throws, as
950
+ * `validateDraft` does for that item on its own. Only an unregistered type is
951
+ * turned into an issue: a fault in a type's own code is not a problem with the
952
+ * draft, and reporting it as an unknown type would hide it.
953
+ */
954
+ /**
955
+ * The item critic over a testlet: each item's findings, under `items.N.…`, and
956
+ * what only the set shows — every single-answer question keeping its right
957
+ * option in one place. The stimulus is not searched for answers: a reading
958
+ * group's answers are meant to be in its passage.
959
+ *
960
+ * An item of a type that is not registered, or without a `type`, is skipped:
961
+ * {@link validateItemGroupDraft} already reports it.
962
+ */
963
+ function critiqueItemGroupDraft(draft) {
964
+ if (!require_registry.isRecord(draft)) return [];
965
+ const items = Array.isArray(draft.items) ? draft.items : [];
966
+ return require_registry.withValidationScope(() => [...items.flatMap((item, index) => repathed(critiqueItem(item), ["items", index])), ...require_registry.keyPositionFinding(items, ["items"])]);
967
+ }
968
+ /**
969
+ * The item critic over a question set, as `<ActivitySequence>` takes one: each
970
+ * entry's findings under its index — an item group's under `N.items.M.…` — and,
971
+ * over the entries that are items, what only the set shows (see
972
+ * {@link critiqueItemGroupDraft}). An entry of a type that is not registered is
973
+ * skipped.
974
+ */
975
+ function critiqueDrafts(entries) {
976
+ return require_registry.withValidationScope(() => [...entries.flatMap((entry, index) => repathed(require_registry.isRecord(entry) && entry.type === "item-group" ? critiqueItemGroupDraft(entry) : critiqueItem(entry), [index])), ...require_registry.keyPositionFinding(entries, [])]);
977
+ }
978
+ /** One item's findings, or none for an item the critic cannot place. */
979
+ function critiqueItem(item) {
980
+ if (!require_registry.isRecord(item) || typeof item.type !== "string") return [];
981
+ if (require_registry.getActivityTypeDescriptor(item.type) === void 0) return [];
982
+ return critiqueDraft(item.type, item);
983
+ }
984
+ const repathed = (findings, prefix) => findings.map((found) => ({
985
+ ...found,
986
+ path: [...prefix.map(String), ...found.path]
987
+ }));
988
+ function validateItemGroupDraft(draft) {
989
+ return require_registry.withValidationScope(() => validateItemGroupDraftInScope(draft));
990
+ }
991
+ function validateItemGroupDraftInScope(draft) {
992
+ const issues = [];
993
+ if (!require_registry.isRecord(draft)) {
994
+ issues.push(require_registry.issue("ig_not_an_object", [], "A group must be an object with a stimulus and its items."));
995
+ return {
996
+ status: "invalid",
997
+ issues
998
+ };
999
+ }
1000
+ issues.push(...checkGroupIdentity(draft));
1001
+ if (require_registry.isRecord(draft.stimulus)) issues.push(...checkStimulus(draft.stimulus));
1002
+ else if (require_registry.isUnset(draft.stimulus)) issues.push(require_registry.issue("ig_stimulus_required", ["stimulus"], "Add the passage, recording or image the questions are about."));
1003
+ issues.push(...checkItems(draft));
1004
+ if (draft.timeline !== void 0 && draft.timeline !== null) issues.push(...checkTimeline(draft));
1005
+ const captions = require_registry.groupCaptionsField(draft, (captionsUrl) => !require_registry.isUnwritten(captionsUrl));
1006
+ if (captions !== void 0) issues.push(require_registry.issue("dc_captions_not_allowed", [
1007
+ "stimulus",
1008
+ "media",
1009
+ captions
1010
+ ], require_registry.GROUP_CAPTIONS_REVEAL_DICTATION));
1011
+ const parsed = require_item_group.ItemGroupSchema.safeParse(draft, { reportInput: true });
1012
+ if (!parsed.success) for (const schemaIssue of parsed.error.issues) {
1013
+ const path = schemaIssue.path.map(String);
1014
+ if (issues.some((known) => require_registry.covers(known.path, path))) continue;
1015
+ if (path.length > 0 && require_registry.refusesEmpty(draft, schemaIssue)) {
1016
+ issues.push(require_registry.issue("null_not_allowed", path, "This field cannot be null. Leave it out if it is optional, or give it a value."));
1017
+ continue;
1018
+ }
1019
+ issues.push({
1020
+ path,
1021
+ message: schemaIssue.message,
1022
+ code: schemaIssue.code,
1023
+ severity: "invalid"
1024
+ });
1025
+ }
1026
+ const stored = require_item_group.validateItemGroup(draft);
1027
+ if (issues.length === 0 && stored.success) return {
1028
+ status: "complete",
1029
+ data: stored.data,
1030
+ issues
1031
+ };
1032
+ if (issues.length === 0) for (const error of stored.success ? [] : stored.errors) issues.push({
1033
+ ...error,
1034
+ severity: "invalid"
1035
+ });
1036
+ return {
1037
+ status: issues.some((found) => found.severity !== "incomplete") ? "invalid" : "incomplete",
1038
+ issues
1039
+ };
1040
+ }
1041
+ /**
1042
+ * An interactive video: every question in exactly one quiz, quizzes at real
1043
+ * moments, a video to hang them on, and only the five question types a video
1044
+ * may hold. Each check stands aside where another already names the problem —
1045
+ * an unset item type is `ig_item_type_required`, an unregistered one
1046
+ * `ig_item_type_unknown` — so an author sees one issue per mistake.
1047
+ */
1048
+ function checkTimeline(draft) {
1049
+ const issues = [];
1050
+ const timeline = draft.timeline;
1051
+ if (!require_registry.isRecord(timeline)) return issues;
1052
+ const stimulus = require_registry.isRecord(draft.stimulus) ? draft.stimulus : void 0;
1053
+ if (stimulus !== void 0 && !require_registry.isUnwritten(stimulus.kind) && stimulus.kind !== "video") issues.push(require_registry.issue("ig_timeline_stimulus_kind", ["stimulus", "kind"], "An interactive video needs a video: quizzes open at moments of it."));
1054
+ else if (stimulus !== void 0 && require_registry.isRecord(stimulus.media) && !require_registry.isUnwritten(stimulus.media.type) && stimulus.media.type !== "video") issues.push(require_registry.issue("ig_timeline_stimulus_kind", [
1055
+ "stimulus",
1056
+ "media",
1057
+ "type"
1058
+ ], "Upload a video file. An embedded player from another site cannot be paused when a quiz opens."));
1059
+ if (draft.shuffle === "within-group") issues.push(require_registry.issue("ig_timeline_shuffle", ["shuffle"], "The questions of an interactive video appear in the order their quizzes open, so they cannot be shuffled."));
1060
+ const items = Array.isArray(draft.items) ? draft.items : [];
1061
+ const itemIndexById = /* @__PURE__ */ new Map();
1062
+ items.forEach((item, index) => {
1063
+ if (!require_registry.isRecord(item)) return;
1064
+ if (typeof item.id === "string" && item.id !== "") itemIndexById.set(item.id, index);
1065
+ const type = item.type;
1066
+ if (typeof type === "string" && type !== "" && type !== "item-group" && !require_item_group.isInteractiveVideoItemType(type) && require_registry.getActivityTypeDescriptor(type) !== void 0) issues.push(require_registry.issue("ig_timeline_item_type", [
1067
+ "items",
1068
+ index,
1069
+ "type"
1070
+ ], `An interactive video can hold ${require_item_group.INTERACTIVE_VIDEO_ITEM_TYPES.join(", ")} questions only.`));
1071
+ if (type === "dictation" && require_registry.isUnset(item.media)) issues.push(require_registry.issue("ig_timeline_dictation_media", [
1072
+ "items",
1073
+ index,
1074
+ "media"
1075
+ ], "Add the recording this dictation plays: inside a video it cannot play the video."));
1076
+ });
1077
+ const cues = Array.isArray(timeline.cues) ? timeline.cues : [];
1078
+ const quizIds = /* @__PURE__ */ new Set();
1079
+ const placed = /* @__PURE__ */ new Set();
1080
+ cues.forEach((cue, cueIndex) => {
1081
+ if (!require_registry.isRecord(cue)) return;
1082
+ const at = (field, ...rest) => [
1083
+ "timeline",
1084
+ "cues",
1085
+ cueIndex,
1086
+ field,
1087
+ ...rest
1088
+ ];
1089
+ if (require_registry.isMissingId(cue.id)) issues.push(require_registry.issue("ig_timeline_quiz_id_required", at("id"), "This quiz has no id."));
1090
+ else if (typeof cue.id === "string") {
1091
+ if (quizIds.has(cue.id)) issues.push(require_registry.issue("ig_timeline_quiz_id_duplicate", at("id"), `Two quizzes share the id "${cue.id}".`));
1092
+ quizIds.add(cue.id);
1093
+ }
1094
+ if (require_registry.isUnset(cue.at)) issues.push(require_registry.issue("ig_timeline_quiz_time_required", at("at"), "Choose when in the video this quiz opens."));
1095
+ else if (typeof cue.at !== "number" || !Number.isFinite(cue.at) || cue.at < 0) issues.push(require_registry.issue("ig_timeline_quiz_time_invalid", at("at"), "A quiz opens at a number of seconds, 0 or more."));
1096
+ if (require_registry.isUnset(cue.itemIds) || Array.isArray(cue.itemIds) && cue.itemIds.length === 0) {
1097
+ issues.push(require_registry.issue("ig_timeline_quiz_empty", at("itemIds"), "Add at least one question to this quiz."));
1098
+ return;
1099
+ }
1100
+ if (!Array.isArray(cue.itemIds)) return;
1101
+ cue.itemIds.forEach((itemId, position) => {
1102
+ if (typeof itemId !== "string") return;
1103
+ if (!itemIndexById.has(itemId)) issues.push(require_registry.issue("ig_timeline_quiz_unknown_item", at("itemIds", position), `This quiz names a question that is not in the video ("${itemId}").`));
1104
+ else if (placed.has(itemId)) issues.push(require_registry.issue("ig_timeline_item_duplicate", at("itemIds", position), "This question is already in a quiz: each question belongs to one quiz."));
1105
+ placed.add(itemId);
1106
+ });
1107
+ });
1108
+ for (const [itemId, index] of itemIndexById) if (!placed.has(itemId)) issues.push(require_registry.issue("ig_timeline_item_unplaced", ["items", index], "Add this question to a quiz, or the video never shows it."));
1109
+ const chapters = Array.isArray(timeline.chapters) ? timeline.chapters : [];
1110
+ let previousAt;
1111
+ chapters.forEach((chapter, index) => {
1112
+ if (!require_registry.isRecord(chapter)) return;
1113
+ if (require_registry.isUnwritten(chapter.title)) issues.push(require_registry.issue("ig_timeline_chapter_title", [
1114
+ "timeline",
1115
+ "chapters",
1116
+ index,
1117
+ "title"
1118
+ ], "Give this chapter a title."));
1119
+ if (typeof chapter.at === "number" && Number.isFinite(chapter.at)) {
1120
+ if (previousAt !== void 0 && chapter.at <= previousAt) issues.push(require_registry.issue("ig_timeline_chapter_order", [
1121
+ "timeline",
1122
+ "chapters",
1123
+ index,
1124
+ "at"
1125
+ ], "Chapters must start in order, each after the one before it."));
1126
+ previousAt = chapter.at;
1127
+ }
1128
+ });
1129
+ return issues;
1130
+ }
1131
+ /** The group's own envelope. `title` is optional on a group, unlike an activity. */
1132
+ function checkGroupIdentity(draft) {
1133
+ const issues = [];
1134
+ if (draft.schemaVersion !== "1.0") issues.push(require_registry.issue("schema_version_invalid", ["schemaVersion"], "The draft must have schemaVersion \"1.0\". A draft from createItemGroupDraft has it."));
1135
+ if (draft.type !== "item-group") issues.push(require_registry.issue("type_mismatch", ["type"], "The draft's type must be \"item-group\"."));
1136
+ if (require_registry.isMissingId(draft.id)) issues.push(require_registry.issue("id_required", ["id"], "The group has no id."));
1137
+ if (!require_registry.isUnset(draft.shuffle) && draft.shuffle !== "none" && draft.shuffle !== "within-group") issues.push(require_registry.issue("ig_shuffle_invalid", ["shuffle"], "Shuffling is either \"none\" or \"within-group\". Leave it out to keep the authored order."));
1138
+ return issues;
1139
+ }
1140
+ const KINDS$1 = /* @__PURE__ */ new Set([
1141
+ "text",
1142
+ "audio",
1143
+ "video",
1144
+ "image",
1145
+ "mixed"
1146
+ ]);
1147
+ /** Which media type each stimulus kind accepts — mirrors `StimulusSchema`'s refinement. */
1148
+ const MEDIA_FOR_KIND = {
1149
+ audio: ["audio"],
1150
+ video: ["video", "embed"],
1151
+ image: ["image"],
1152
+ mixed: [
1153
+ "audio",
1154
+ "video",
1155
+ "image",
1156
+ "embed"
1157
+ ]
1158
+ };
1159
+ /**
1160
+ * The passage, recording or image. The split this makes is the one the whole
1161
+ * draft contract exists for: a kind nobody has chosen and a body nobody has
1162
+ * typed are `incomplete`, while a recording attached to an image stimulus is
1163
+ * `invalid` — writing more will not reconcile them.
1164
+ */
1165
+ function checkStimulus(stimulus) {
1166
+ const issues = [];
1167
+ const at = (...rest) => ["stimulus", ...rest];
1168
+ if (require_registry.isMissingId(stimulus.id)) issues.push(require_registry.issue("ig_stimulus_id_required", at("id"), "The stimulus has no id."));
1169
+ const kind = stimulus.kind;
1170
+ if (require_registry.isUnwritten(kind)) issues.push(require_registry.issue("ig_stimulus_kind_required", at("kind"), "Choose what the questions are about: a text, a recording, a video, an image, or a mix."));
1171
+ else if (typeof kind !== "string" || !KINDS$1.has(kind)) {
1172
+ if (typeof kind === "string") issues.push(require_registry.issue("ig_stimulus_kind_invalid", at("kind"), `"${kind}" is not a stimulus kind. Use text, audio, video, image or mixed.`));
1173
+ if (require_registry.isRecord(stimulus.media)) issues.push(...require_registry.checkMedia(stimulus.media, at("media")));
1174
+ return issues;
1175
+ }
1176
+ const hasBody = typeof stimulus.body === "string" && stimulus.body.trim() !== "";
1177
+ const htmlPresent = !require_registry.isUnset(stimulus.bodyHtml);
1178
+ if (!hasBody && (kind === "text" || kind === "mixed" || htmlPresent)) issues.push(require_registry.issue("ig_stimulus_body_required", at("body"), require_registry.isUnwritten(stimulus.bodyHtml) ? "Write the passage." : "Write the passage as plain text too. The rich-text version is shown only where a sanitiser is supplied."));
1179
+ if (kind !== "text" && require_registry.isUnset(stimulus.media)) issues.push(require_registry.issue("ig_stimulus_media_required", at("media"), "Add the recording, video or image."));
1180
+ else if (require_registry.isRecord(stimulus.media)) {
1181
+ const allowed = MEDIA_FOR_KIND[kind];
1182
+ const mediaType = stimulus.media.type;
1183
+ if (allowed !== void 0 && typeof mediaType === "string" && !allowed.includes(mediaType)) issues.push(require_registry.issue("ig_stimulus_media_kind", at("media", "type"), `A "${kind}" stimulus cannot carry ${mediaType} media. Use ${allowed.join(" or ")}.`));
1184
+ issues.push(...require_registry.checkMedia(stimulus.media, at("media")));
1185
+ }
1186
+ return issues;
1187
+ }
1188
+ /**
1189
+ * Every item, checked by `validateDraft` for its own type.
1190
+ *
1191
+ * Re-pathing rather than re-implementing is the point: a multiple-choice
1192
+ * question inside a testlet reports exactly the codes a standalone one does,
1193
+ * so a consumer's translation table covers both and a rule can never drift
1194
+ * between them.
1195
+ */
1196
+ function checkItems(draft) {
1197
+ const issues = [];
1198
+ const items = require_registry.isUnset(draft.items) ? [] : draft.items;
1199
+ if (!Array.isArray(items)) return issues;
1200
+ if (items.length === 0) {
1201
+ issues.push(require_registry.issue("ig_items_required", ["items"], "Add at least one question about this material."));
1202
+ return issues;
1203
+ }
1204
+ const seen = /* @__PURE__ */ new Set();
1205
+ const repeated = /* @__PURE__ */ new Set();
1206
+ for (const [index, item] of items.entries()) {
1207
+ if (!require_registry.isRecord(item)) continue;
1208
+ const ordinal = index + 1;
1209
+ if (require_registry.isMissingId(item.id)) issues.push(require_registry.issue("ig_item_id_required", [
1210
+ "items",
1211
+ index,
1212
+ "id"
1213
+ ], `Question ${ordinal} has no id.`));
1214
+ else if (typeof item.id === "string") {
1215
+ if (seen.has(item.id)) repeated.add(item.id);
1216
+ seen.add(item.id);
1217
+ }
1218
+ if (item.type === "item-group") {
1219
+ issues.push(require_registry.issue("ig_item_nested_group", [
1220
+ "items",
1221
+ index,
1222
+ "type"
1223
+ ], "Groups do not nest: every item must be an activity."));
1224
+ continue;
1225
+ }
1226
+ if (require_registry.isUnwritten(item.type)) {
1227
+ issues.push(require_registry.issue("ig_item_type_required", [
1228
+ "items",
1229
+ index,
1230
+ "type"
1231
+ ], `Choose what kind of question ${ordinal} is.`));
1232
+ continue;
1233
+ }
1234
+ if (typeof item.type !== "string") continue;
1235
+ let result;
1236
+ try {
1237
+ result = validateDraft(item.type, item);
1238
+ } catch (error) {
1239
+ if (!(error instanceof require_registry.UnknownActivityTypeError) || error.activityType !== item.type) throw error;
1240
+ issues.push(require_registry.issue("ig_item_type_unknown", [
1241
+ "items",
1242
+ index,
1243
+ "type"
1244
+ ], `"${item.type}" is not a registered activity type.`));
1245
+ continue;
1246
+ }
1247
+ for (const found of result.issues) issues.push({
1248
+ ...found,
1249
+ path: [
1250
+ "items",
1251
+ String(index),
1252
+ ...found.path
1253
+ ]
1254
+ });
1255
+ }
1256
+ for (const id of repeated) issues.push(require_registry.issue("ig_item_id_duplicate", ["items"], `More than one question has the id "${id}".`));
1257
+ return issues;
1258
+ }
1259
+ //#endregion
1260
+ //#region src/authoring/index.ts
1261
+ /**
1262
+ * Tells a draft that is not finished from a draft that is wrong.
1263
+ *
1264
+ * `validateActivity` answers "may this be stored?", and to that question an
1265
+ * empty new question and a broken one are the same answer: no. An editor needs
1266
+ * the difference — "Add a title" is a to-do, "Only one option can be marked
1267
+ * correct" is an error — so a freshly added question does not read as a
1268
+ * failure, disable a whole form, or blank a preview.
1269
+ *
1270
+ * - `complete` — the draft is a valid activity with nothing left to write.
1271
+ * `data` is exactly what `validateActivity` returns for it.
1272
+ * - `incomplete` — every problem is something not yet written.
1273
+ * - `invalid` — at least one problem is wrong in a way writing more cannot fix.
1274
+ *
1275
+ * It is stricter than `validateActivity`, never looser: `complete` requires the
1276
+ * activity's schema to pass, and a draft can fail here that the schema accepts
1277
+ * (a title that is only whitespace). Content stored by an older build can
1278
+ * therefore be valid and still not `complete`. `validateActivity` stays the
1279
+ * check at your write boundary.
1280
+ *
1281
+ * Issues come from the type's `authoring.checkDraft`, plus every schema failure
1282
+ * it did not already report at that path or inside it. (A failure at the root of
1283
+ * the draft counts as reported only by an issue at the root, since every path is
1284
+ * inside the root.) Such a failure is added as `invalid`: under the code
1285
+ * `null_not_allowed` when the schema refused a `null` there, or an `undefined`
1286
+ * entry in a list, and otherwise under the schema's own code and message. A type
1287
+ * with no `checkDraft` therefore reports every schema failure as `invalid`.
1288
+ *
1289
+ * The codes documented in `docs/authoring.md` are a contract, and each is
1290
+ * reported with its documented severity, whichever check reports it. A code
1291
+ * passed through from the schema is not a contract: the code and its English
1292
+ * message belong to the schema library, so give a code you do not recognise a
1293
+ * generic message of your own.
1294
+ *
1295
+ * @throws UnknownActivityTypeError when `type` has no registered descriptor —
1296
+ * including `'item-group'`, which is a container, not an activity type.
1297
+ */
1298
+ function validateDraft(type, draft) {
1299
+ return require_registry.withValidationScope(() => validateDraftInScope(type, draft));
1300
+ }
1301
+ function validateDraftInScope(type, draft) {
1302
+ const descriptor = require_registry.getActivityTypeDescriptor(type);
1303
+ if (descriptor === void 0) throw new require_registry.UnknownActivityTypeError(String(type));
1304
+ const reported = require_registry.isRecord(draft) && descriptor.authoring?.checkDraft !== void 0 ? descriptor.authoring.checkDraft(draft).map(normalise) : [];
1305
+ const issues = [...reported];
1306
+ const parsed = require_registry.readSchema(descriptor.schema, draft, `Activity type "${descriptor.type}"`, { reportInput: true });
1307
+ if (!parsed.success) {
1308
+ const covered = require_registry.coveredPathsOf(reported);
1309
+ const reportedEmpty = /* @__PURE__ */ new Set();
1310
+ for (const schemaIssue of parsed.issues) {
1311
+ const path = schemaIssue.path.map(String);
1312
+ if (covered.has(require_registry.pathKey(path))) continue;
1313
+ if (path.length > 0 && require_registry.refusesEmpty(draft, schemaIssue)) {
1314
+ const key = JSON.stringify(path);
1315
+ if (!reportedEmpty.has(key)) {
1316
+ reportedEmpty.add(key);
1317
+ issues.push(require_registry.issue("null_not_allowed", path, typeof schemaIssue.path.at(-1) === "number" ? "This entry has no value. Remove it, or fill it in." : "This field cannot be null. Leave it out if it is optional, or give it a value."));
1318
+ }
1319
+ continue;
1320
+ }
1321
+ issues.push({
1322
+ path,
1323
+ message: schemaIssue.message,
1324
+ code: schemaIssue.code,
1325
+ severity: "invalid"
1326
+ });
1327
+ }
1328
+ }
1329
+ if (parsed.success && issues.length === 0) return {
1330
+ status: "complete",
1331
+ data: parsed.data,
1332
+ issues
1333
+ };
1334
+ return {
1335
+ status: issues.some((found) => found.severity !== "incomplete") ? "invalid" : "incomplete",
1336
+ issues
1337
+ };
1338
+ }
1339
+ /**
1340
+ * A new, empty draft of an activity type, for an editor to start from.
1341
+ *
1342
+ * Every required field is present and nothing is authored, so the draft comes
1343
+ * back from {@link validateDraft} as `incomplete`, and `validateActivity`
1344
+ * rejects it until someone writes it. The SDK invents no ids: `newId` is called
1345
+ * once per id the draft needs, and must return a different non-empty string each
1346
+ * time.
1347
+ *
1348
+ * ```ts
1349
+ * const draft = createDraft('multiple-choice', { newId: () => crypto.randomUUID() });
1350
+ * ```
1351
+ *
1352
+ * @throws UnknownActivityTypeError when `type` has no registered descriptor.
1353
+ * @throws Error when the type declares no `authoring.createDraft`, or when
1354
+ * `newId` returns an empty or repeated id.
1355
+ */
1356
+ function createDraft(type, context) {
1357
+ const descriptor = require_registry.getActivityTypeDescriptor(type);
1358
+ if (descriptor === void 0) throw new require_registry.UnknownActivityTypeError(String(type));
1359
+ const create = descriptor.authoring?.createDraft;
1360
+ if (create === void 0) throw new Error(`Activity type "${String(type)}" declares no authoring.createDraft, so there is no empty draft to create. Build the draft yourself; validateDraft works for every registered type.`);
1361
+ const issued = /* @__PURE__ */ new Set();
1362
+ const newId = () => {
1363
+ const id = context.newId();
1364
+ if (typeof id !== "string" || id === "") throw new Error("createDraft: newId() must return a non-empty string.");
1365
+ if (issued.has(id)) throw new Error(`createDraft: newId() returned "${id}" twice. Ids within one draft must differ — two options sharing an id cannot be scored apart.`);
1366
+ issued.add(id);
1367
+ return id;
1368
+ };
1369
+ return create({ newId });
1370
+ }
1371
+ /**
1372
+ * A check's issue as `validateDraft` reports it.
1373
+ *
1374
+ * A documented code carries its documented severity, whichever check reports
1375
+ * it. Any other code keeps the check's severity, and one that is not
1376
+ * `'incomplete'` — misspelled, or missing — is reported as `invalid`, so the
1377
+ * mistake fails closed. Path segments become strings, as the schema's are, so a
1378
+ * list index given as a number still covers the schema's failure there.
1379
+ */
1380
+ function normalise(found) {
1381
+ const documented = Object.hasOwn(require_registry.DRAFT_ISSUE_SEVERITY, found.code) ? require_registry.DRAFT_ISSUE_SEVERITY[found.code] : void 0;
1382
+ return {
1383
+ ...found,
1384
+ path: found.path.map(String),
1385
+ severity: documented ?? (found.severity === "incomplete" ? "incomplete" : "invalid")
1386
+ };
1387
+ }
1388
+ /**
1389
+ * What an experienced item writer would point out on review: flaws a learner
1390
+ * can exploit or trip over (`warning`) and item-writing guidelines the item
1391
+ * departs from (`advice`) — the right option much longer than the rest, an
1392
+ * answer printed in the passage, a hint that gives the answer away, two
1393
+ * options that read the same, "all of the above" under shuffle.
1394
+ *
1395
+ * A finding is never a reason to refuse an item. It does not change what
1396
+ * {@link validateDraft} says: a valid item with findings is still valid. The
1397
+ * shape is a `DraftIssue`'s, so an editor shows both in one list.
1398
+ *
1399
+ * It reads a draft, finished or not, and skips any rule whose fields are not
1400
+ * written yet. The codes are documented in `docs/authoring.md`, each with its
1401
+ * severity; the rule behind a code is advice and may be refined in a minor.
1402
+ *
1403
+ * ```ts
1404
+ * const list = [...validateDraft(type, draft).issues, ...critiqueDraft(type, draft)];
1405
+ * ```
1406
+ *
1407
+ * @throws UnknownActivityTypeError when `type` has no registered descriptor —
1408
+ * including `'item-group'`: use {@link critiqueItemGroupDraft}.
1409
+ */
1410
+ function critiqueDraft(type, draft) {
1411
+ const descriptor = require_registry.getActivityTypeDescriptor(type);
1412
+ if (descriptor === void 0) throw new require_registry.UnknownActivityTypeError(String(type));
1413
+ const critique = descriptor.authoring?.critique;
1414
+ if (!require_registry.isRecord(draft) || critique === void 0) return [];
1415
+ return require_registry.withValidationScope(() => critique(draft)).map(require_registry.normaliseFinding);
1416
+ }
1417
+ //#endregion
1418
+ //#region src/ai-critique.ts
1419
+ /** The most findings one critique may carry. More is refused, not cut. */
1420
+ const AI_CRITIQUE_MAX_FINDINGS = 20;
1421
+ /** The longest message or quote in a finding, in code points. */
1422
+ const AI_CRITIQUE_MAX_FIELD_LENGTH = 500;
1423
+ const KINDS = [
1424
+ "ambiguous",
1425
+ "second-answer",
1426
+ "wrong-key",
1427
+ "implausible-distractor",
1428
+ "cue",
1429
+ "level",
1430
+ "language",
1431
+ "sensitivity",
1432
+ "other"
1433
+ ];
1434
+ /**
1435
+ * Keys whose values are settings, ids or addresses — nothing a critique of the
1436
+ * item's words is about. A string under any of them is not a field to point at.
1437
+ */
1438
+ const NOT_WORDS = /* @__PURE__ */ new Set([
1439
+ "id",
1440
+ "type",
1441
+ "schemaVersion",
1442
+ "mode",
1443
+ "scoringStrategy",
1444
+ "presentation",
1445
+ "locale",
1446
+ "languageTarget",
1447
+ "correctChoiceId",
1448
+ "bankId",
1449
+ "url",
1450
+ "captionsUrl",
1451
+ "match",
1452
+ "playback",
1453
+ "recording",
1454
+ "scoring",
1455
+ "ai",
1456
+ "learningObjectives",
1457
+ "hints",
1458
+ "kind",
1459
+ "timeline"
1460
+ ]);
1461
+ /**
1462
+ * Every string an author wrote into an item, with its path: the question, the
1463
+ * options, the passage, the answers, the hints, the feedback, a picture's
1464
+ * `alt`. Settings, ids and addresses are left out, and so is any `…Html`
1465
+ * sidecar: the plain field beside it holds the same words.
1466
+ */
1467
+ function fieldsOf(value, path = []) {
1468
+ if (typeof value === "string") return value.trim() === "" ? [] : [{
1469
+ path,
1470
+ text: value
1471
+ }];
1472
+ if (Array.isArray(value)) return value.flatMap((entry, index) => fieldsOf(entry, [...path, String(index)]));
1473
+ if (typeof value === "object" && value !== null) return Object.entries(value).flatMap(([key, entry]) => NOT_WORDS.has(key) || key.endsWith("Html") ? [] : fieldsOf(entry, [...path, key]));
1474
+ return [];
1475
+ }
1476
+ /**
1477
+ * What a model is given to review an item, or `null` when there is nothing to
1478
+ * review: a draft that is not an object, or one with no words in it yet.
1479
+ *
1480
+ * The facts are the item as the author wrote it — the answer key included; this
1481
+ * is for an author, never a learner — every text field a finding may point at,
1482
+ * and what the SDK's own critic already found there, so a model spends its
1483
+ * words on what a rule cannot see: a stem that reads two ways, a distractor
1484
+ * that is defensibly right, a key that is wrong, language above the level.
1485
+ *
1486
+ * Works for every registered type, the SDK's own and yours: the fields are read
1487
+ * from the draft itself.
1488
+ *
1489
+ * @throws UnknownActivityTypeError when `type` has no registered descriptor.
1490
+ */
1491
+ function aiCritiqueRequest(input) {
1492
+ const { type, draft, level, authorLocale } = input;
1493
+ if (require_registry.getActivityTypeDescriptor(type) === void 0) throw new require_registry.UnknownActivityTypeError(String(type));
1494
+ if (typeof draft !== "object" || draft === null || Array.isArray(draft)) return null;
1495
+ const fields = fieldsOf(draft);
1496
+ if (fields.length === 0) return null;
1497
+ return {
1498
+ feature: "item-critique",
1499
+ facts: {
1500
+ activityType: String(type),
1501
+ item: draft,
1502
+ fields,
1503
+ findings: critiqueDraft(type, draft).map(({ path, code, message }) => ({
1504
+ path,
1505
+ code,
1506
+ message
1507
+ })),
1508
+ ...level !== void 0 ? { level } : {}
1509
+ },
1510
+ ...authorLocale !== void 0 ? { authorLocale } : {}
1511
+ };
1512
+ }
1513
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1514
+ const tooLong = (text) => [...text].length > 500;
1515
+ /**
1516
+ * Checks a critique a port returned for `request`, and turns it into findings
1517
+ * an editor lists beside the SDK's own. As every AI answer, it is shown whole
1518
+ * or not at all.
1519
+ *
1520
+ * - **Every finding points at a field the item has.** Its `path` is one of
1521
+ * `facts.fields`, exactly; a path to a field the item does not have refuses
1522
+ * the whole reply as `contradicts-item`.
1523
+ * - **A quote is words that field contains**, typographic quotes and runs of
1524
+ * spacing aside, case kept; one it does not refuses the reply the same way.
1525
+ * - **Every finding is `advice`.** It is a model's opinion about the item, and
1526
+ * nothing in the SDK acts on it; its code is `ai_` and the kind
1527
+ * (`ai_second_answer`).
1528
+ * - **Limits:** {@link AI_CRITIQUE_MAX_FINDINGS} findings; a message or quote of
1529
+ * {@link AI_CRITIQUE_MAX_FIELD_LENGTH} code points; over them `too-long`. A
1530
+ * kind the SDK does not name, or any other shape, is `malformed`.
1531
+ */
1532
+ function checkAiCritique(raw, request) {
1533
+ if (!isRecord$2(raw) || !Array.isArray(raw.findings)) return {
1534
+ ok: false,
1535
+ refusal: "malformed"
1536
+ };
1537
+ if (raw.findings.length > 20) return {
1538
+ ok: false,
1539
+ refusal: "too-long"
1540
+ };
1541
+ const fields = new Map(request.facts.fields.map((field) => [JSON.stringify(field.path), field]));
1542
+ const findings = [];
1543
+ for (const entry of raw.findings) {
1544
+ if (!isRecord$2(entry) || !Array.isArray(entry.path) || !entry.path.every((segment) => typeof segment === "string" || typeof segment === "number") || typeof entry.kind !== "string" || !KINDS.includes(entry.kind) || typeof entry.message !== "string" || entry.quote !== void 0 && entry.quote !== null && typeof entry.quote !== "string") return {
1545
+ ok: false,
1546
+ refusal: "malformed"
1547
+ };
1548
+ const message = cleanText(entry.message);
1549
+ const quote = typeof entry.quote === "string" ? cleanText(entry.quote) : void 0;
1550
+ if (message === "" || quote === "") return {
1551
+ ok: false,
1552
+ refusal: "malformed"
1553
+ };
1554
+ if (tooLong(message) || quote !== void 0 && tooLong(quote)) return {
1555
+ ok: false,
1556
+ refusal: "too-long"
1557
+ };
1558
+ const field = fields.get(JSON.stringify(entry.path.map(String)));
1559
+ if (field === void 0) return {
1560
+ ok: false,
1561
+ refusal: "contradicts-item"
1562
+ };
1563
+ if (quote !== void 0 && !folded(field.text).chars.includes(needle(quote))) return {
1564
+ ok: false,
1565
+ refusal: "contradicts-item"
1566
+ };
1567
+ findings.push({
1568
+ path: field.path,
1569
+ code: `ai_${entry.kind.replace(/-/g, "_")}`,
1570
+ message,
1571
+ severity: "advice",
1572
+ ...quote !== void 0 ? { quote } : {}
1573
+ });
1574
+ }
1575
+ const provenance = provenanceOf(raw.provenance);
1576
+ const usage = usageOf(raw.usage);
1577
+ return {
1578
+ ok: true,
1579
+ critique: {
1580
+ findings,
1581
+ ...provenance !== void 0 ? { provenance } : {},
1582
+ ...usage !== void 0 ? { usage } : {}
1583
+ }
1584
+ };
1585
+ }
1586
+ //#endregion
1587
+ //#region src/ai-drafts.ts
1588
+ /** The most drafts one request may ask for, and the most a reply may hold when it names no count. */
1589
+ const AI_DRAFTS_MAX_COUNT = 50;
1590
+ /** The most repair passes `generateDrafts` makes. */
1591
+ const AI_DRAFTS_MAX_REPAIRS = 3;
1592
+ const DRAFT_TYPES = [
1593
+ "multiple-choice",
1594
+ "fill-in-the-blanks",
1595
+ "gap-select",
1596
+ "dictation",
1597
+ "read-aloud",
1598
+ "written-response"
1599
+ ];
1600
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1601
+ const text = (description) => ({
1602
+ type: "string",
1603
+ description
1604
+ });
1605
+ const FEEDBACK = {
1606
+ type: "object",
1607
+ description: "Optional overall feedback, shown after answering.",
1608
+ properties: {
1609
+ correct: text("Shown when the learner passes."),
1610
+ incorrect: text("Shown when the learner does not.")
1611
+ }
1612
+ };
1613
+ /** Every field a model writes, and the shape of each. A name means one thing in every type that uses it. */
1614
+ const FIELDS = {
1615
+ title: text("A short name for the item, shown to the learner above it. Never the answer, or any part of it."),
1616
+ question: text("The question, as plain text."),
1617
+ mode: {
1618
+ type: "string",
1619
+ enum: ["single", "multi"],
1620
+ description: "`single`: exactly one option is right. `multi`: one or more are."
1621
+ },
1622
+ options: {
1623
+ type: "array",
1624
+ minItems: 2,
1625
+ maxItems: 26,
1626
+ items: {
1627
+ type: "object",
1628
+ required: ["text", "isCorrect"],
1629
+ properties: {
1630
+ text: text("The option as the learner reads it."),
1631
+ isCorrect: { type: "boolean" },
1632
+ feedback: text("Why this option is right or wrong, shown after answering.")
1633
+ }
1634
+ }
1635
+ },
1636
+ passage: text("The passage as plain text, with {{1}}, {{2}}, … where each gap goes, numbered in the order they appear."),
1637
+ blanks: {
1638
+ type: "array",
1639
+ minItems: 1,
1640
+ description: "One per gap, in the order the gaps appear: the first is {{1}}.",
1641
+ items: {
1642
+ type: "object",
1643
+ required: ["acceptedAnswers"],
1644
+ properties: {
1645
+ acceptedAnswers: {
1646
+ type: "array",
1647
+ minItems: 1,
1648
+ items: { type: "string" },
1649
+ description: "Every answer that is right, spelled as a learner would type it."
1650
+ },
1651
+ hint: text("Optional. Helps without giving the answer or any of its words.")
1652
+ }
1653
+ }
1654
+ },
1655
+ gaps: {
1656
+ type: "array",
1657
+ minItems: 1,
1658
+ description: "One per gap, in the order the gaps appear: the first is {{1}}.",
1659
+ items: {
1660
+ type: "object",
1661
+ required: ["choices"],
1662
+ properties: { choices: {
1663
+ type: "array",
1664
+ minItems: 2,
1665
+ description: "What the learner chooses from. Exactly one is right.",
1666
+ items: {
1667
+ type: "object",
1668
+ required: ["text", "isCorrect"],
1669
+ properties: {
1670
+ text: text("A choice."),
1671
+ isCorrect: { type: "boolean" }
1672
+ }
1673
+ }
1674
+ } }
1675
+ }
1676
+ },
1677
+ transcript: text("What the learner hears and types: words from the source, as they are said there."),
1678
+ referenceText: text("What the learner reads aloud: a sentence or two from the source."),
1679
+ instructions: text("Optional. How to read it, shown to the learner."),
1680
+ prompt: text("The writing task, as plain text."),
1681
+ minWords: {
1682
+ type: "integer",
1683
+ minimum: 0
1684
+ },
1685
+ maxWords: {
1686
+ type: "integer",
1687
+ minimum: 1
1688
+ },
1689
+ rubric: {
1690
+ type: "object",
1691
+ description: "Optional. What the writing is assessed on; every criterion is weighted equally.",
1692
+ properties: { criteria: {
1693
+ type: "array",
1694
+ minItems: 1,
1695
+ items: {
1696
+ type: "object",
1697
+ required: ["name"],
1698
+ properties: {
1699
+ name: text("A short name."),
1700
+ description: text("What it looks for.")
1701
+ }
1702
+ }
1703
+ } }
1704
+ },
1705
+ feedback: FEEDBACK
1706
+ };
1707
+ /** The fields a model writes for each type: a draft's own names, never an id or a setting. */
1708
+ const WRITES = {
1709
+ "multiple-choice": [
1710
+ "title",
1711
+ "question",
1712
+ "mode",
1713
+ "options",
1714
+ "feedback"
1715
+ ],
1716
+ "fill-in-the-blanks": [
1717
+ "title",
1718
+ "passage",
1719
+ "blanks",
1720
+ "feedback"
1721
+ ],
1722
+ "gap-select": [
1723
+ "title",
1724
+ "passage",
1725
+ "gaps",
1726
+ "feedback"
1727
+ ],
1728
+ dictation: [
1729
+ "title",
1730
+ "transcript",
1731
+ "feedback"
1732
+ ],
1733
+ "read-aloud": [
1734
+ "title",
1735
+ "referenceText",
1736
+ "instructions",
1737
+ "feedback"
1738
+ ],
1739
+ "written-response": [
1740
+ "title",
1741
+ "prompt",
1742
+ "minWords",
1743
+ "maxWords",
1744
+ "rubric"
1745
+ ]
1746
+ };
1747
+ /**
1748
+ * One draft, as a model writes it: its `type`, and the fields that type uses —
1749
+ * one object for every chosen type, since a structured output that must pick
1750
+ * between schemas is the thing providers support least.
1751
+ */
1752
+ function itemShape(types, captions) {
1753
+ const properties = { type: {
1754
+ type: "string",
1755
+ enum: [...types],
1756
+ description: `The item's type, and the fields it fills. ${types.map((type) => `${type}: ${WRITES[type].filter((name) => name !== "title").join(", ")}`).join(". ")}.`
1757
+ } };
1758
+ for (const type of types) for (const name of WRITES[type]) properties[name] = FIELDS[name];
1759
+ if (captions) properties.caption = {
1760
+ type: "integer",
1761
+ minimum: 0,
1762
+ description: "The index of the caption the item is about. It opens when that caption ends."
1763
+ };
1764
+ return {
1765
+ type: "object",
1766
+ required: captions ? [
1767
+ "type",
1768
+ "title",
1769
+ "caption"
1770
+ ] : ["type", "title"],
1771
+ properties
1772
+ };
1773
+ }
1774
+ /** A reply of up to `max` drafts — exactly `exactly` on a repair. */
1775
+ function replyShape(types, captions, max, exactly) {
1776
+ return {
1777
+ type: "object",
1778
+ required: ["drafts"],
1779
+ properties: { drafts: {
1780
+ type: "array",
1781
+ ...exactly !== void 0 ? {
1782
+ minItems: exactly,
1783
+ maxItems: exactly
1784
+ } : { maxItems: max },
1785
+ items: itemShape(types, captions)
1786
+ } }
1787
+ };
1788
+ }
1789
+ /**
1790
+ * What a model is given to draft items from a source, or `null` when there is
1791
+ * no source to draft from: an empty text, or no caption with words in it.
1792
+ *
1793
+ * `types` are the ones the author chose, in the author's order: the model
1794
+ * leans on the first, and where several drafts share a moment of a video — or
1795
+ * the end of one — they are shown in this order. `count` is how many to write
1796
+ * at most; left out, the model writes as many as the source is worth, up to
1797
+ * {@link AI_DRAFTS_MAX_COUNT}.
1798
+ *
1799
+ * The facts are the source — a caption list numbered, with its times in
1800
+ * seconds — the types, the count, and the language, level and instructions
1801
+ * the author gave. `shape` is the JSON Schema of the reply: each draft names
1802
+ * its type and writes that type's own fields, with no ids, no HTML, no media
1803
+ * and no settings. Those are not a model's to write. `settings` are yours, put
1804
+ * on every draft of a type: a read-aloud's `recording` and `scoring`, say, which
1805
+ * the SDK has no default for.
1806
+ *
1807
+ * @throws RangeError when `types` is empty, repeats a type or names one a model
1808
+ * does not draft, or `count` is not a whole number from 1 to
1809
+ * {@link AI_DRAFTS_MAX_COUNT}.
1810
+ */
1811
+ function aiDraftsRequest(input) {
1812
+ const { types, source, count, locale, level, instructions, settings } = input;
1813
+ if (!Array.isArray(types) || types.length === 0 || new Set(types).size !== types.length || !types.every((type) => DRAFT_TYPES.includes(type))) throw new RangeError(`aiDraftsRequest: types must be one or more of ${DRAFT_TYPES.join(", ")}, each once, in the author's order.`);
1814
+ if (count !== void 0 && (!Number.isInteger(count) || count < 1 || count > 50)) throw new RangeError(`aiDraftsRequest: count must be a whole number from 1 to 50, or left out, not ${String(count)}.`);
1815
+ let facts;
1816
+ if (source.kind === "captions") {
1817
+ const captions = (Array.isArray(source.cues) ? source.cues : []).flatMap((cue, index) => isRecord$1(cue) && typeof cue.text === "string" && cue.text.trim() !== "" && Number.isFinite(cue.start) && Number.isFinite(cue.end) ? [{
1818
+ index,
1819
+ start: cue.start,
1820
+ end: cue.end,
1821
+ text: cue.text
1822
+ }] : []);
1823
+ if (captions.length === 0) return null;
1824
+ facts = {
1825
+ kind: "captions",
1826
+ captions
1827
+ };
1828
+ } else {
1829
+ if (typeof source.text !== "string" || source.text.trim() === "") return null;
1830
+ facts = {
1831
+ kind: source.kind,
1832
+ text: source.text
1833
+ };
1834
+ }
1835
+ return {
1836
+ feature: "draft-generation",
1837
+ facts: {
1838
+ activityTypes: [...types],
1839
+ source: facts,
1840
+ ...count !== void 0 ? { count } : {},
1841
+ ...locale !== void 0 ? { locale } : {},
1842
+ ...level !== void 0 ? { level } : {},
1843
+ ...instructions !== void 0 ? { instructions } : {}
1844
+ },
1845
+ shape: replyShape(types, facts.kind === "captions", count ?? 50),
1846
+ ...settings !== void 0 ? { settings } : {}
1847
+ };
1848
+ }
1849
+ const stringOf = (value) => typeof value === "string" ? value : "";
1850
+ const listOf = (value) => Array.isArray(value) ? value : [];
1851
+ const written = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
1852
+ function feedbackOf(entry) {
1853
+ if (!isRecord$1(entry.feedback)) return {};
1854
+ const correct = written(entry.feedback.correct);
1855
+ const incorrect = written(entry.feedback.incorrect);
1856
+ return correct === void 0 && incorrect === void 0 ? {} : { feedback: {
1857
+ ...correct !== void 0 ? { correct } : {},
1858
+ ...incorrect !== void 0 ? { incorrect } : {}
1859
+ } };
1860
+ }
1861
+ /** A type's content, read from what a model wrote. */
1862
+ function contentOf(type, entry, newId) {
1863
+ switch (type) {
1864
+ case "multiple-choice": return {
1865
+ question: stringOf(entry.question),
1866
+ ...typeof entry.mode === "string" ? { mode: entry.mode } : {},
1867
+ options: listOf(entry.options).map((option) => {
1868
+ const fields = isRecord$1(option) ? option : {};
1869
+ const feedback = written(fields.feedback);
1870
+ return {
1871
+ id: newId(),
1872
+ text: stringOf(fields.text),
1873
+ isCorrect: fields.isCorrect === true,
1874
+ ...feedback !== void 0 ? { feedback } : {}
1875
+ };
1876
+ }),
1877
+ ...feedbackOf(entry)
1878
+ };
1879
+ case "fill-in-the-blanks": return {
1880
+ passage: stringOf(entry.passage),
1881
+ blanks: listOf(entry.blanks).map((blank, index) => {
1882
+ const fields = isRecord$1(blank) ? blank : {};
1883
+ const hint = written(fields.hint);
1884
+ return {
1885
+ id: String(index + 1),
1886
+ acceptedAnswers: listOf(fields.acceptedAnswers).filter((answer) => typeof answer === "string"),
1887
+ ...hint !== void 0 ? { hint } : {}
1888
+ };
1889
+ }),
1890
+ ...feedbackOf(entry)
1891
+ };
1892
+ case "gap-select": return {
1893
+ passage: stringOf(entry.passage),
1894
+ gaps: listOf(entry.gaps).map((gap, index) => {
1895
+ const marked = listOf(isRecord$1(gap) ? gap.choices : void 0);
1896
+ const choices = marked.map((choice) => ({
1897
+ id: newId(),
1898
+ text: stringOf(isRecord$1(choice) ? choice.text : void 0)
1899
+ }));
1900
+ const right = marked.flatMap((choice, at) => isRecord$1(choice) && choice.isCorrect === true ? [at] : []);
1901
+ return {
1902
+ id: String(index + 1),
1903
+ choices,
1904
+ correctChoiceId: right.length === 1 ? choices[right[0]]?.id : ""
1905
+ };
1906
+ }),
1907
+ ...feedbackOf(entry)
1908
+ };
1909
+ case "dictation": return {
1910
+ transcript: stringOf(entry.transcript),
1911
+ ...feedbackOf(entry)
1912
+ };
1913
+ case "read-aloud": {
1914
+ const instructions = written(entry.instructions);
1915
+ return {
1916
+ referenceText: stringOf(entry.referenceText),
1917
+ ...instructions !== void 0 ? { instructions } : {},
1918
+ ...feedbackOf(entry)
1919
+ };
1920
+ }
1921
+ default: {
1922
+ const criteria = listOf(isRecord$1(entry.rubric) ? entry.rubric.criteria : void 0);
1923
+ return {
1924
+ prompt: stringOf(entry.prompt),
1925
+ ...entry.minWords !== void 0 ? { minWords: entry.minWords } : {},
1926
+ ...entry.maxWords !== void 0 ? { maxWords: entry.maxWords } : {},
1927
+ ...criteria.length > 0 ? { rubric: { criteria: criteria.map((criterion) => {
1928
+ const fields = isRecord$1(criterion) ? criterion : {};
1929
+ const description = written(fields.description);
1930
+ return {
1931
+ name: stringOf(fields.name),
1932
+ ...description !== void 0 ? { description } : {},
1933
+ weight: 1
1934
+ };
1935
+ }) } } : {}
1936
+ };
1937
+ }
1938
+ }
1939
+ }
1940
+ /**
1941
+ * What a new draft of a type starts with, where the model writes nothing: a
1942
+ * scoring strategy on the three that have one — `all-or-nothing`, as a new
1943
+ * draft has — and a read-aloud's recording and scoring left unset, as a new
1944
+ * draft leaves them, for the host's settings or the author to fill.
1945
+ */
1946
+ function startOf(type) {
1947
+ switch (type) {
1948
+ case "multiple-choice":
1949
+ case "fill-in-the-blanks":
1950
+ case "gap-select": return { scoringStrategy: "all-or-nothing" };
1951
+ case "read-aloud": return {
1952
+ recording: { maxSeconds: 0 },
1953
+ scoring: { dimensions: [] }
1954
+ };
1955
+ default: return {};
1956
+ }
1957
+ }
1958
+ /**
1959
+ * A draft from what a model wrote. Lenient: every field it can read is kept as
1960
+ * written, and anything it cannot is left for `validateDraft` to name — which
1961
+ * is what a repair tells the model. Ids are the host's; a blank's and a gap's
1962
+ * are the numbers of their placeholders, as the passage names them. In order:
1963
+ * what a new draft starts with, the language asked for, the host's settings,
1964
+ * then what the model wrote — so a setting never overrides the content, and
1965
+ * nothing overrides what the draft is.
1966
+ */
1967
+ function draftFrom(type, entry, id, newId, request) {
1968
+ const locale = request.facts.locale;
1969
+ return {
1970
+ ...startOf(type),
1971
+ ...locale !== void 0 ? { locale } : {},
1972
+ ...request.settings?.[type],
1973
+ title: stringOf(entry.title),
1974
+ ...contentOf(type, entry, newId),
1975
+ schemaVersion: "1.0",
1976
+ type,
1977
+ id
1978
+ };
1979
+ }
1980
+ /** Ids from the host, checked as `createDraft` checks them. */
1981
+ function idSource(newId, caller) {
1982
+ const issued = /* @__PURE__ */ new Set();
1983
+ return () => {
1984
+ const id = newId();
1985
+ if (typeof id !== "string" || id === "") throw new Error(`${caller}: newId() must return a non-empty string.`);
1986
+ if (issued.has(id)) throw new Error(`${caller}: newId() returned "${id}" twice. Ids must differ — two options sharing an id cannot be scored apart.`);
1987
+ issued.add(id);
1988
+ return id;
1989
+ };
1990
+ }
1991
+ /** Drafts in the author's order of types, and within a type in the order the model wrote them. */
1992
+ function inAuthorsOrder(drafts, types) {
1993
+ return [...drafts].sort((a, b) => types.indexOf(a.type) - types.indexOf(b.type) || a.index - b.index);
1994
+ }
1995
+ function check(raw, request, newId, keep = []) {
1996
+ const types = request.facts.activityTypes;
1997
+ if (!isRecord$1(raw) || !Array.isArray(raw.drafts) || !raw.drafts.every((entry) => isRecord$1(entry) && types.includes(entry.type))) return {
1998
+ ok: false,
1999
+ refusal: "malformed"
2000
+ };
2001
+ const entries = raw.drafts;
2002
+ const repair = request.repair;
2003
+ const most = request.facts.count ?? 50;
2004
+ if (repair !== void 0 ? entries.length !== repair.length : entries.length > most) return {
2005
+ ok: false,
2006
+ refusal: repair !== void 0 ? "malformed" : "too-long"
2007
+ };
2008
+ const source = request.facts.source;
2009
+ const ordered = inAuthorsOrder(entries.map((entry, position) => {
2010
+ const type = entry.type;
2011
+ const draft = draftFrom(type, entry, keep[position] ?? newId(), newId, request);
2012
+ const caption = source.kind === "captions" && Number.isInteger(entry.caption) ? source.captions.find((one) => one.index === entry.caption) : void 0;
2013
+ const said = require_registry.fold(stringOf(entry.transcript));
2014
+ const clip = type === "dictation" && caption !== void 0 && require_registry.containsWords(require_registry.fold(caption.text), said) ? {
2015
+ start: caption.start,
2016
+ end: caption.end
2017
+ } : void 0;
2018
+ return {
2019
+ index: repair !== void 0 ? repair[position]?.index : position,
2020
+ type,
2021
+ draft,
2022
+ validation: validateDraft(type, draft),
2023
+ findings: critiqueDraft(type, draft),
2024
+ ...caption !== void 0 ? { at: caption.end } : {},
2025
+ ...clip !== void 0 ? { clip } : {},
2026
+ generated: entry
2027
+ };
2028
+ }), types);
2029
+ const provenance = provenanceOf(raw.provenance);
2030
+ const usage = usageOf(raw.usage);
2031
+ return {
2032
+ ok: true,
2033
+ drafts: {
2034
+ drafts: ordered,
2035
+ findings: require_registry.keyPositionFinding(ordered.map((one) => one.draft), []),
2036
+ ...provenance !== void 0 ? { provenance } : {},
2037
+ ...usage !== void 0 ? { usage } : {}
2038
+ }
2039
+ };
2040
+ }
2041
+ /**
2042
+ * Reads a model's reply to {@link aiDraftsRequest} into drafts an editor can
2043
+ * show for approval: each with ids from `newId`, your settings for its type,
2044
+ * what `validateDraft` says of it, what the item critic finds in it, and — from
2045
+ * captions — where it goes, and for a dictation the stretch of the video that
2046
+ * says its words. They come back in the author's order of types.
2047
+ *
2048
+ * Drafts are independent: one that is unfinished or wrong is returned as such,
2049
+ * for an author or {@link aiDraftsRepairRequest} to fix, beside the ones that
2050
+ * are complete. `complete` means valid, never approved: a model wrote it, and a
2051
+ * person decides whether learners see it. The whole reply is refused only when
2052
+ * it is not `{ drafts: [...] }` of objects of the chosen types (`malformed`), or
2053
+ * holds more drafts than asked for (`too-long`) — on a repair, not exactly one
2054
+ * per draft to fix (`malformed`).
2055
+ *
2056
+ * Only the fields a type's shape names are read: a model cannot set an id,
2057
+ * HTML, media, a switch or a scoring setting through this. A question is scored
2058
+ * `all-or-nothing` unless your settings say otherwise, a written response's
2059
+ * criteria are weighted equally, and a dictation carries no recording — attach
2060
+ * one, or cut it from the video at `clip`.
2061
+ *
2062
+ * @throws Error when `newId` returns an empty or repeated id.
2063
+ */
2064
+ function checkAiDrafts(raw, request, options) {
2065
+ return check(raw, request, idSource(options.newId, "checkAiDrafts"));
2066
+ }
2067
+ /**
2068
+ * Where a model wrote what a problem names: the draft's paths, but a gap's key
2069
+ * is its choices, and a reading too long for the host's recording time is the
2070
+ * text the model chose.
2071
+ */
2072
+ function pathForModel(type, problem) {
2073
+ const { path, code } = problem;
2074
+ if (type === "gap-select" && path.length === 3 && path[0] === "gaps" && path[2] === "correctChoiceId") return [
2075
+ "gaps",
2076
+ path[1],
2077
+ "choices"
2078
+ ];
2079
+ return code === "ra_text_long_for_time" ? ["referenceText"] : [...path];
2080
+ }
2081
+ /**
2082
+ * What is wrong with a draft that the model can fix: every issue and warning
2083
+ * at a field the model writes. A read-aloud with no recording time, or a
2084
+ * dictation in a video with no recording, is the host's to finish, and is not
2085
+ * sent back.
2086
+ */
2087
+ function problemsOf(one) {
2088
+ const writes = WRITES[one.type];
2089
+ return [...one.validation.issues.map((found) => ({
2090
+ path: found.path,
2091
+ code: found.code,
2092
+ message: found.message,
2093
+ severity: found.severity
2094
+ })), ...one.findings.filter((found) => found.severity === "warning").map((found) => ({
2095
+ path: found.path,
2096
+ code: found.code,
2097
+ message: found.message,
2098
+ severity: "warning"
2099
+ }))].map((problem) => ({
2100
+ ...problem,
2101
+ path: pathForModel(one.type, problem)
2102
+ })).filter((problem) => writes.includes(problem.path[0]));
2103
+ }
2104
+ /**
2105
+ * A second request, for the drafts with something wrong the model can fix —
2106
+ * unfinished, wrong, or a flaw the critic warns about, at a field the model
2107
+ * writes — each as the model wrote it, with those problems at the paths the
2108
+ * model wrote. `null` when there is none.
2109
+ *
2110
+ * The reply holds one draft for each, in order; {@link checkAiDrafts} reads it
2111
+ * and gives each the `index` of the draft it replaces.
2112
+ */
2113
+ function aiDraftsRepairRequest(request, drafts) {
2114
+ const repair = ("drafts" in drafts ? drafts.drafts : drafts).flatMap((one) => {
2115
+ const problems = problemsOf(one);
2116
+ return problems.length === 0 ? [] : [{
2117
+ index: one.index,
2118
+ draft: one.generated,
2119
+ problems
2120
+ }];
2121
+ });
2122
+ if (repair.length === 0) return null;
2123
+ const { repair: _previous, ...first } = request;
2124
+ return {
2125
+ ...first,
2126
+ shape: replyShape(request.facts.activityTypes, request.facts.source.kind === "captions", repair.length, repair.length),
2127
+ repair
2128
+ };
2129
+ }
2130
+ const RANK = {
2131
+ complete: 2,
2132
+ incomplete: 1,
2133
+ invalid: 0
2134
+ };
2135
+ /** Whether a repaired draft is at least as good as the one it would replace. */
2136
+ function noWorse(repaired, before) {
2137
+ const warnings = (one) => one.findings.filter((found) => found.severity === "warning").length;
2138
+ const now = RANK[repaired.validation.status];
2139
+ const was = RANK[before.validation.status];
2140
+ return now > was || now === was && warnings(repaired) <= warnings(before);
2141
+ }
2142
+ /**
2143
+ * Drafts items from a source, with a bounded repair loop: asks the port with
2144
+ * `request`, reads the reply with {@link checkAiDrafts}, then — up to `repairs`
2145
+ * times — sends back the drafts with something wrong the model can fix
2146
+ * ({@link aiDraftsRepairRequest}), and keeps a repaired draft only when it is
2147
+ * no worse than the one it replaces. A repaired draft keeps its id.
2148
+ *
2149
+ * Never throws for a model's failure: a port that throws or a reply the SDK
2150
+ * refuses ends the loop, and is recorded in `calls` beside the provenance and
2151
+ * usage of every call — which the SDK never adds up. A first call that fails
2152
+ * leaves no drafts.
2153
+ *
2154
+ * ```ts
2155
+ * const run = await generateDrafts({
2156
+ * request: aiDraftsRequest({ types: ['multiple-choice', 'dictation'], source: { kind: 'transcript', text } }),
2157
+ * port: (request) => callMyModel(DRAFTS_PROMPT, request),
2158
+ * newId: () => crypto.randomUUID(),
2159
+ * });
2160
+ * // run.drafts: store each as proposed, for a person to approve.
2161
+ * ```
2162
+ *
2163
+ * @throws RangeError when `repairs` is not a whole number from 0 to
2164
+ * {@link AI_DRAFTS_MAX_REPAIRS} (default 1), and Error when `newId` returns an
2165
+ * empty or repeated id.
2166
+ */
2167
+ async function generateDrafts(input) {
2168
+ const { request, port, newId } = input;
2169
+ const repairs = input.repairs ?? 1;
2170
+ if (!Number.isInteger(repairs) || repairs < 0 || repairs > 3) throw new RangeError(`generateDrafts: repairs must be a whole number from 0 to 3, not ${String(repairs)}.`);
2171
+ const calls = [];
2172
+ const ids = idSource(newId, "generateDrafts");
2173
+ const ask = async (asked, keep) => {
2174
+ let raw;
2175
+ try {
2176
+ raw = await port(asked);
2177
+ } catch (error) {
2178
+ calls.push({
2179
+ ok: false,
2180
+ error: error instanceof Error ? error.message : String(error)
2181
+ });
2182
+ return;
2183
+ }
2184
+ const checked = check(raw, asked, ids, keep);
2185
+ if (!checked.ok) {
2186
+ calls.push({
2187
+ ok: false,
2188
+ refusal: checked.refusal
2189
+ });
2190
+ return;
2191
+ }
2192
+ const { provenance, usage } = checked.drafts;
2193
+ calls.push({
2194
+ ok: true,
2195
+ ...provenance !== void 0 ? { provenance } : {},
2196
+ ...usage !== void 0 ? { usage } : {}
2197
+ });
2198
+ return checked.drafts;
2199
+ };
2200
+ const first = await ask(request, []);
2201
+ if (first === void 0) return {
2202
+ drafts: [],
2203
+ findings: [],
2204
+ calls
2205
+ };
2206
+ let current = first.drafts;
2207
+ for (let pass = 0; pass < repairs; pass += 1) {
2208
+ const repairRequest = aiDraftsRepairRequest(request, current);
2209
+ if (repairRequest === null) break;
2210
+ const repaired = await ask(repairRequest, (repairRequest.repair ?? []).map((wanted) => {
2211
+ const before = current.find((one) => one.index === wanted.index);
2212
+ return typeof before?.draft.id === "string" ? before.draft.id : void 0;
2213
+ }));
2214
+ if (repaired === void 0) break;
2215
+ current = current.map((before) => {
2216
+ const after = repaired.drafts.find((one) => one.index === before.index);
2217
+ return after !== void 0 && noWorse(after, before) ? after : before;
2218
+ });
2219
+ }
2220
+ return {
2221
+ drafts: current,
2222
+ findings: require_registry.keyPositionFinding(current.map((one) => one.draft), []),
2223
+ calls
2224
+ };
2225
+ }
2226
+ /** The source as one text, kept with the video as its author-only transcript. */
2227
+ function sourceText(request) {
2228
+ const source = request.facts.source;
2229
+ return source.kind === "captions" ? source.captions.map((caption) => caption.text).join("\n") : source.text;
2230
+ }
2231
+ /**
2232
+ * An interactive video made of drafts: an item group whose stimulus is
2233
+ * `video`, whose items are the drafts, and whose timeline opens a quiz at each
2234
+ * moment a draft was placed — drafts placed at one moment share its quiz, in
2235
+ * the author's order of types — and one more at `durationSeconds` for every
2236
+ * draft placed nowhere: all of them when the source had no times.
2237
+ *
2238
+ * The SDK never reads the video, so it cannot know where the end is: without
2239
+ * `durationSeconds` that last quiz has no time yet, which
2240
+ * `validateItemGroupDraft` reports for the author to set. The source is kept on
2241
+ * the stimulus as its author-only `transcript`.
2242
+ *
2243
+ * Like every draft it is checked (`validateItemGroupDraft`) and critiqued
2244
+ * (`critiqueItemGroupDraft`), never approved. A dictation still needs its own
2245
+ * recording; a type a video does not take — a written response — is reported,
2246
+ * not dropped.
2247
+ *
2248
+ * @throws Error when `newId` returns an empty or repeated id.
2249
+ */
2250
+ function interactiveVideoFromDrafts(input) {
2251
+ const { request, drafts, video, durationSeconds, title } = input;
2252
+ const ids = idSource(input.newId, "interactiveVideoFromDrafts");
2253
+ const types = request.facts.activityTypes;
2254
+ const list = "drafts" in drafts ? drafts.drafts : drafts;
2255
+ const end = typeof durationSeconds === "number" && Number.isFinite(durationSeconds) && durationSeconds >= 0 ? durationSeconds : void 0;
2256
+ const ordered = [...list.map((one) => ({
2257
+ one,
2258
+ at: one.at ?? end
2259
+ }))].sort((a, b) => (a.at ?? Number.POSITIVE_INFINITY) - (b.at ?? Number.POSITIVE_INFINITY) || types.indexOf(a.one.type) - types.indexOf(b.one.type) || a.one.index - b.one.index);
2260
+ const cues = [];
2261
+ for (const { one, at } of ordered) {
2262
+ const itemId = String(one.draft.id);
2263
+ const last = cues[cues.length - 1];
2264
+ if (last !== void 0 && last.at === at) last.itemIds.push(itemId);
2265
+ else cues.push({
2266
+ id: ids(),
2267
+ ...at !== void 0 ? { at } : {},
2268
+ itemIds: [itemId]
2269
+ });
2270
+ }
2271
+ const group = {
2272
+ schemaVersion: "1.0",
2273
+ type: "item-group",
2274
+ id: ids(),
2275
+ ...title !== void 0 ? { title } : {},
2276
+ stimulus: {
2277
+ id: ids(),
2278
+ kind: "video",
2279
+ media: video,
2280
+ transcript: sourceText(request)
2281
+ },
2282
+ items: ordered.map(({ one }) => one.draft),
2283
+ timeline: { cues }
2284
+ };
2285
+ return {
2286
+ group,
2287
+ validation: validateItemGroupDraft(group),
2288
+ findings: critiqueItemGroupDraft(group)
2289
+ };
2290
+ }
2291
+ //#endregion
2292
+ //#region src/ai-writing.ts
2293
+ /** The most corrections one piece of writing feedback may carry. More is refused, not cut. */
2294
+ const AI_WRITING_MAX_CORRECTIONS = 20;
2295
+ /** The longest quote, correction, explanation or criterion comment, in code points. */
2296
+ const AI_WRITING_MAX_FIELD_LENGTH = 500;
2297
+ /** The longest `category` or `band`: a tag, not a sentence. */
2298
+ const MAX_TAG_LENGTH = 40;
2299
+ /**
2300
+ * The request for feedback on a draft, or `null` when there is none to give:
2301
+ * not a written response, an author who switched explanations off (writing
2302
+ * feedback reads `ai.explanations`), a redacted item — feedback is for
2303
+ * practice, where the item is whole — or a draft with nothing in it.
2304
+ *
2305
+ * The facts are the task as the learner saw it, the draft verbatim with its
2306
+ * word count recomputed as the grader will count it, and the rubric the answer
2307
+ * will be graded on. `previousFeedback` is the text of the feedback already
2308
+ * shown on earlier drafts, so a model can speak to what changed. `group` is the
2309
+ * item group the task sits in: its material, the text a summary is of, goes
2310
+ * with the request as `material`.
2311
+ */
2312
+ function aiWritingFeedbackRequest(input) {
2313
+ const { data, response, learnerLocale } = input;
2314
+ if (!aiSupports(data.type, "writing-feedback") || !aiAllowedByContent(data, "writing-feedback") || data.redacted === true) return null;
2315
+ const text = response?.type === "written-response" ? response.text : "";
2316
+ if (typeof text !== "string" || text.trim() === "") return null;
2317
+ const item = data;
2318
+ const wordCount = require_registry.countWords(text);
2319
+ const minWords = typeof item.minWords === "number" ? item.minWords : 0;
2320
+ const maxWords = typeof item.maxWords === "number" ? item.maxWords : Number.POSITIVE_INFINITY;
2321
+ const criteria = Array.isArray(item.rubric?.criteria) ? item.rubric.criteria : null;
2322
+ const rubric = criteria === null || criteria.length === 0 ? null : criteria.map((criterion) => ({
2323
+ name: criterion.name,
2324
+ ...typeof criterion.description === "string" ? { description: criterion.description } : {},
2325
+ weight: criterion.weight
2326
+ }));
2327
+ const previousFeedback = [...input.previousFeedback ?? []];
2328
+ const material = materialFacts(input.group);
2329
+ return {
2330
+ feature: "writing-feedback",
2331
+ facts: {
2332
+ activityType: "written-response",
2333
+ activityId: data.id,
2334
+ title: data.title,
2335
+ ...typeof data.locale === "string" ? { locale: data.locale } : {},
2336
+ prompt: typeof item.prompt === "string" ? item.prompt : "",
2337
+ text,
2338
+ wordCount,
2339
+ minWords,
2340
+ maxWords,
2341
+ withinWordBounds: wordCount >= minWords && wordCount <= maxWords,
2342
+ rubric,
2343
+ ...typeof item.languageTarget === "string" ? { languageTarget: item.languageTarget } : {}
2344
+ },
2345
+ draftNumber: previousFeedback.length + 1,
2346
+ previousFeedback,
2347
+ ...material !== null ? { material } : {},
2348
+ ...learnerLocale !== void 0 ? { learnerLocale } : {}
2349
+ };
2350
+ }
2351
+ /**
2352
+ * Where `quote` sits in the draft: at `claimed`, when the model named a range —
2353
+ * which must then hold the quote — or else at its first occurrence not already
2354
+ * taken by an identical quote, so a mistake made twice is anchored twice. A
2355
+ * quote corrected more often than it occurs takes its first occurrence again.
2356
+ * `null` when the draft does not contain it where claimed, or at all.
2357
+ */
2358
+ function anchor(draft, quote, claimed, taken) {
2359
+ const sought = needle(quote);
2360
+ if (sought === "") return null;
2361
+ if (claimed !== void 0) {
2362
+ const { start, end } = claimed;
2363
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end > draft.text.length || start >= end) return null;
2364
+ const span = draft.text.slice(start, end);
2365
+ if (needle(span) !== sought) return null;
2366
+ const lead = span.length - span.trimStart().length;
2367
+ const trail = span.length - span.trimEnd().length;
2368
+ return {
2369
+ start: start + lead,
2370
+ end: end - trail
2371
+ };
2372
+ }
2373
+ let first = null;
2374
+ for (let at = draft.chars.indexOf(sought); at !== -1; at = draft.chars.indexOf(sought, at + 1)) {
2375
+ const start = draft.from[at];
2376
+ const end = draft.from[at + sought.length - 1] + 1;
2377
+ const found = {
2378
+ start,
2379
+ end
2380
+ };
2381
+ first ??= found;
2382
+ const key = `${start}:${end}`;
2383
+ if (!taken.has(key)) {
2384
+ taken.add(key);
2385
+ return found;
2386
+ }
2387
+ }
2388
+ return first;
2389
+ }
2390
+ /** A field that must be a string, at most `max` code points once cleaned; `undefined` when absent. */
2391
+ function textField(value, max) {
2392
+ if (value === void 0 || value === null) return {
2393
+ ok: true,
2394
+ value: void 0
2395
+ };
2396
+ if (typeof value !== "string") return {
2397
+ ok: false,
2398
+ refusal: "malformed"
2399
+ };
2400
+ const cleaned = cleanText(value);
2401
+ if ([...cleaned].length > max) return {
2402
+ ok: false,
2403
+ refusal: "too-long"
2404
+ };
2405
+ return {
2406
+ ok: true,
2407
+ value: cleaned === "" ? void 0 : cleaned
2408
+ };
2409
+ }
2410
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2411
+ /** The corrections, each anchored in the draft, or why the reply is refused. */
2412
+ function checkCorrections(raw, text) {
2413
+ if (raw === void 0 || raw === null) return {
2414
+ ok: true,
2415
+ corrections: []
2416
+ };
2417
+ if (!Array.isArray(raw)) return {
2418
+ ok: false,
2419
+ refusal: "malformed"
2420
+ };
2421
+ if (raw.length > 20) return {
2422
+ ok: false,
2423
+ refusal: "too-long"
2424
+ };
2425
+ const draft = {
2426
+ text,
2427
+ ...folded(text)
2428
+ };
2429
+ const taken = /* @__PURE__ */ new Set();
2430
+ const corrections = [];
2431
+ for (const entry of raw) {
2432
+ if (!isRecord(entry) || typeof entry.original !== "string") return {
2433
+ ok: false,
2434
+ refusal: "malformed"
2435
+ };
2436
+ if (typeof entry.corrected !== "string") return {
2437
+ ok: false,
2438
+ refusal: "malformed"
2439
+ };
2440
+ if ([...entry.original].length > 500 || [...entry.corrected].length > 500) return {
2441
+ ok: false,
2442
+ refusal: "too-long"
2443
+ };
2444
+ if (needle(entry.original) === needle(entry.corrected)) return {
2445
+ ok: false,
2446
+ refusal: "malformed"
2447
+ };
2448
+ const explanation = textField(entry.explanation, 500);
2449
+ const category = textField(entry.category, MAX_TAG_LENGTH);
2450
+ if (!explanation.ok) return explanation;
2451
+ if (!category.ok) return category;
2452
+ let claimed;
2453
+ if (entry.range !== void 0 && entry.range !== null) {
2454
+ if (!isRecord(entry.range)) return {
2455
+ ok: false,
2456
+ refusal: "malformed"
2457
+ };
2458
+ claimed = {
2459
+ start: entry.range.start,
2460
+ end: entry.range.end
2461
+ };
2462
+ }
2463
+ const at = anchor(draft, entry.original, claimed, taken);
2464
+ if (at === null) return {
2465
+ ok: false,
2466
+ refusal: "misquotes-answer"
2467
+ };
2468
+ corrections.push({
2469
+ original: text.slice(at.start, at.end),
2470
+ corrected: cleanText(entry.corrected),
2471
+ ...explanation.value !== void 0 ? { explanation: explanation.value } : {},
2472
+ ...category.value !== void 0 ? { category: category.value } : {},
2473
+ range: at
2474
+ });
2475
+ }
2476
+ return {
2477
+ ok: true,
2478
+ corrections
2479
+ };
2480
+ }
2481
+ /** A criterion score a grade could be computed from: finite, 0 or more, and not above its maximum. */
2482
+ function isJudgedScore(score, maxScore) {
2483
+ if (score === void 0) return maxScore === void 0 || typeof maxScore === "number" && maxScore > 0;
2484
+ const max = maxScore ?? 1;
2485
+ return typeof score === "number" && typeof max === "number" && Number.isFinite(score) && Number.isFinite(max) && max > 0 && score >= 0 && score - max <= max * 1e-9;
2486
+ }
2487
+ /** The judgements, each on a criterion the author wrote and weighted as the author weighted it. */
2488
+ function checkCriteria(raw, rubric) {
2489
+ if (raw === void 0 || raw === null) return {
2490
+ ok: true,
2491
+ criteria: []
2492
+ };
2493
+ if (!Array.isArray(raw)) return {
2494
+ ok: false,
2495
+ refusal: "malformed"
2496
+ };
2497
+ if (raw.length === 0) return {
2498
+ ok: true,
2499
+ criteria: []
2500
+ };
2501
+ if (rubric === null) return {
2502
+ ok: false,
2503
+ refusal: "malformed"
2504
+ };
2505
+ const weights = new Map(rubric.map((criterion) => [criterion.name, criterion.weight]));
2506
+ const seen = /* @__PURE__ */ new Set();
2507
+ const criteria = [];
2508
+ for (const entry of raw) {
2509
+ if (!isRecord(entry) || typeof entry.name !== "string") return {
2510
+ ok: false,
2511
+ refusal: "malformed"
2512
+ };
2513
+ const weight = weights.get(entry.name);
2514
+ if (weight === void 0 || seen.has(entry.name)) return {
2515
+ ok: false,
2516
+ refusal: "malformed"
2517
+ };
2518
+ seen.add(entry.name);
2519
+ if (!isJudgedScore(entry.score, entry.maxScore)) return {
2520
+ ok: false,
2521
+ refusal: "malformed"
2522
+ };
2523
+ if (entry.notApplicable !== void 0 && typeof entry.notApplicable !== "boolean") return {
2524
+ ok: false,
2525
+ refusal: "malformed"
2526
+ };
2527
+ const band = textField(entry.band, MAX_TAG_LENGTH);
2528
+ const comment = textField(entry.comment, 500);
2529
+ if (!band.ok) return band;
2530
+ if (!comment.ok) return comment;
2531
+ criteria.push({
2532
+ name: entry.name,
2533
+ ...typeof entry.score === "number" ? { score: entry.score } : {},
2534
+ ...typeof entry.maxScore === "number" ? { maxScore: entry.maxScore } : {},
2535
+ ...band.value !== void 0 ? { band: band.value } : {},
2536
+ ...comment.value !== void 0 ? { comment: comment.value } : {},
2537
+ ...entry.notApplicable === true ? { notApplicable: true } : {},
2538
+ weight
2539
+ });
2540
+ }
2541
+ return {
2542
+ ok: true,
2543
+ criteria
2544
+ };
2545
+ }
2546
+ /**
2547
+ * Checks writing feedback a port returned for `request`, and turns it into what
2548
+ * a learner is shown. The SDK shows a model's feedback whole or not at all: it
2549
+ * never drops the part it doubts and shows the rest.
2550
+ *
2551
+ * - **The text** is read as every AI text is (`readAiTextResult`): plain text,
2552
+ * not empty, at most {@link AI_TEXT_MAX_LENGTH} code points.
2553
+ * - **Every correction must quote the draft** — the check only this SDK can run.
2554
+ * The SDK finds each quote in the draft itself, typographic quotes and runs of
2555
+ * whitespace aside, and anchors it there; a range the model claims must hold
2556
+ * the quote. A correction of words the learner did not write refuses the
2557
+ * whole reply as `misquotes-answer`, as a hint that gives the answer away
2558
+ * refuses the hint. The `original` shown is the draft's own text at the range.
2559
+ * At most {@link AI_WRITING_MAX_CORRECTIONS}; a correction that changes
2560
+ * nothing is `malformed`.
2561
+ * - **Criteria are judgements; the arithmetic is the SDK's.** Each names a
2562
+ * criterion of the item's rubric, once; its score, when it has one, is a
2563
+ * score out of its `maxScore`. The rubric's weights are attached, whatever
2564
+ * the model sent. Anything else is `malformed`, as is any criterion on an
2565
+ * item with no rubric.
2566
+ * - **`indicativeScore`** is the rubric's weighted total, computed as
2567
+ * `gradeFromRubric` computes a grade, when every criterion of the rubric was
2568
+ * judged; `null` otherwise. An indication, never a grade.
2569
+ */
2570
+ function checkAiWritingFeedback(raw, request) {
2571
+ const read = readAiTextResult(raw);
2572
+ if (!read.ok) return read;
2573
+ const record = raw;
2574
+ const corrections = checkCorrections(record.corrections, request.facts.text);
2575
+ if (!corrections.ok) return corrections;
2576
+ const rubric = request.facts.rubric;
2577
+ const criteria = checkCriteria(record.criteria, rubric);
2578
+ if (!criteria.ok) return criteria;
2579
+ let indicativeScore = null;
2580
+ const judged = new Set(criteria.criteria.map((criterion) => criterion.name));
2581
+ if (rubric?.every((criterion) => judged.has(criterion.name))) {
2582
+ const grade = require_scoring.gradeFromRubric(criteria.criteria);
2583
+ indicativeScore = "unscorable" in grade ? null : grade.score;
2584
+ }
2585
+ const { text, provenance, usage } = read.result;
2586
+ return {
2587
+ ok: true,
2588
+ feedback: {
2589
+ text,
2590
+ corrections: corrections.corrections,
2591
+ criteria: criteria.criteria,
2592
+ indicativeScore,
2593
+ ...provenance !== void 0 ? { provenance } : {},
2594
+ ...usage !== void 0 ? { usage } : {}
2595
+ }
2596
+ };
2597
+ }
2598
+ //#endregion
2599
+ Object.defineProperty(exports, "AI_COACHING_MAX_TIP_LENGTH", {
2600
+ enumerable: true,
2601
+ get: function() {
2602
+ return AI_COACHING_MAX_TIP_LENGTH;
2603
+ }
2604
+ });
2605
+ Object.defineProperty(exports, "AI_COACHING_MAX_WORDS", {
2606
+ enumerable: true,
2607
+ get: function() {
2608
+ return AI_COACHING_MAX_WORDS;
2609
+ }
2610
+ });
2611
+ Object.defineProperty(exports, "AI_CRITIQUE_MAX_FIELD_LENGTH", {
2612
+ enumerable: true,
2613
+ get: function() {
2614
+ return AI_CRITIQUE_MAX_FIELD_LENGTH;
2615
+ }
2616
+ });
2617
+ Object.defineProperty(exports, "AI_CRITIQUE_MAX_FINDINGS", {
2618
+ enumerable: true,
2619
+ get: function() {
2620
+ return AI_CRITIQUE_MAX_FINDINGS;
2621
+ }
2622
+ });
2623
+ Object.defineProperty(exports, "AI_DRAFTS_MAX_COUNT", {
2624
+ enumerable: true,
2625
+ get: function() {
2626
+ return AI_DRAFTS_MAX_COUNT;
2627
+ }
2628
+ });
2629
+ Object.defineProperty(exports, "AI_DRAFTS_MAX_REPAIRS", {
2630
+ enumerable: true,
2631
+ get: function() {
2632
+ return AI_DRAFTS_MAX_REPAIRS;
2633
+ }
2634
+ });
2635
+ Object.defineProperty(exports, "AI_EXPLANATION_TYPES", {
2636
+ enumerable: true,
2637
+ get: function() {
2638
+ return AI_EXPLANATION_TYPES;
2639
+ }
2640
+ });
2641
+ Object.defineProperty(exports, "AI_HINT_TYPES", {
2642
+ enumerable: true,
2643
+ get: function() {
2644
+ return AI_HINT_TYPES;
2645
+ }
2646
+ });
2647
+ Object.defineProperty(exports, "AI_MATERIAL_MAX_LENGTH", {
2648
+ enumerable: true,
2649
+ get: function() {
2650
+ return AI_MATERIAL_MAX_LENGTH;
2651
+ }
2652
+ });
2653
+ Object.defineProperty(exports, "AI_TEXT_MAX_LENGTH", {
2654
+ enumerable: true,
2655
+ get: function() {
2656
+ return AI_TEXT_MAX_LENGTH;
2657
+ }
2658
+ });
2659
+ Object.defineProperty(exports, "AI_WRITING_MAX_CORRECTIONS", {
2660
+ enumerable: true,
2661
+ get: function() {
2662
+ return AI_WRITING_MAX_CORRECTIONS;
2663
+ }
2664
+ });
2665
+ Object.defineProperty(exports, "AI_WRITING_MAX_FIELD_LENGTH", {
2666
+ enumerable: true,
2667
+ get: function() {
2668
+ return AI_WRITING_MAX_FIELD_LENGTH;
2669
+ }
2670
+ });
2671
+ Object.defineProperty(exports, "aiAllowedByContent", {
2672
+ enumerable: true,
2673
+ get: function() {
2674
+ return aiAllowedByContent;
2675
+ }
2676
+ });
2677
+ Object.defineProperty(exports, "aiCoachingRequest", {
2678
+ enumerable: true,
2679
+ get: function() {
2680
+ return aiCoachingRequest;
2681
+ }
2682
+ });
2683
+ Object.defineProperty(exports, "aiCritiqueRequest", {
2684
+ enumerable: true,
2685
+ get: function() {
2686
+ return aiCritiqueRequest;
2687
+ }
2688
+ });
2689
+ Object.defineProperty(exports, "aiDraftsRepairRequest", {
2690
+ enumerable: true,
2691
+ get: function() {
2692
+ return aiDraftsRepairRequest;
2693
+ }
2694
+ });
2695
+ Object.defineProperty(exports, "aiDraftsRequest", {
2696
+ enumerable: true,
2697
+ get: function() {
2698
+ return aiDraftsRequest;
2699
+ }
2700
+ });
2701
+ Object.defineProperty(exports, "aiExplanationRequest", {
2702
+ enumerable: true,
2703
+ get: function() {
2704
+ return aiExplanationRequest;
2705
+ }
2706
+ });
2707
+ Object.defineProperty(exports, "aiGradeOf", {
2708
+ enumerable: true,
2709
+ get: function() {
2710
+ return aiGradeOf;
2711
+ }
2712
+ });
2713
+ Object.defineProperty(exports, "aiHintRequest", {
2714
+ enumerable: true,
2715
+ get: function() {
2716
+ return aiHintRequest;
2717
+ }
2718
+ });
2719
+ Object.defineProperty(exports, "aiSupports", {
2720
+ enumerable: true,
2721
+ get: function() {
2722
+ return aiSupports;
2723
+ }
2724
+ });
2725
+ Object.defineProperty(exports, "aiWritingFeedbackRequest", {
2726
+ enumerable: true,
2727
+ get: function() {
2728
+ return aiWritingFeedbackRequest;
2729
+ }
2730
+ });
2731
+ Object.defineProperty(exports, "buildAiFacts", {
2732
+ enumerable: true,
2733
+ get: function() {
2734
+ return buildAiFacts;
2735
+ }
2736
+ });
2737
+ Object.defineProperty(exports, "checkAiCoaching", {
2738
+ enumerable: true,
2739
+ get: function() {
2740
+ return checkAiCoaching;
2741
+ }
2742
+ });
2743
+ Object.defineProperty(exports, "checkAiCritique", {
2744
+ enumerable: true,
2745
+ get: function() {
2746
+ return checkAiCritique;
2747
+ }
2748
+ });
2749
+ Object.defineProperty(exports, "checkAiDrafts", {
2750
+ enumerable: true,
2751
+ get: function() {
2752
+ return checkAiDrafts;
2753
+ }
2754
+ });
2755
+ Object.defineProperty(exports, "checkAiExplanation", {
2756
+ enumerable: true,
2757
+ get: function() {
2758
+ return checkAiExplanation;
2759
+ }
2760
+ });
2761
+ Object.defineProperty(exports, "checkAiHint", {
2762
+ enumerable: true,
2763
+ get: function() {
2764
+ return checkAiHint;
2765
+ }
2766
+ });
2767
+ Object.defineProperty(exports, "checkAiWritingFeedback", {
2768
+ enumerable: true,
2769
+ get: function() {
2770
+ return checkAiWritingFeedback;
2771
+ }
2772
+ });
2773
+ Object.defineProperty(exports, "createDraft", {
2774
+ enumerable: true,
2775
+ get: function() {
2776
+ return createDraft;
2777
+ }
2778
+ });
2779
+ Object.defineProperty(exports, "createInteractiveVideoDraft", {
2780
+ enumerable: true,
2781
+ get: function() {
2782
+ return createInteractiveVideoDraft;
2783
+ }
2784
+ });
2785
+ Object.defineProperty(exports, "createItemGroupDraft", {
2786
+ enumerable: true,
2787
+ get: function() {
2788
+ return createItemGroupDraft;
2789
+ }
2790
+ });
2791
+ Object.defineProperty(exports, "critiqueDraft", {
2792
+ enumerable: true,
2793
+ get: function() {
2794
+ return critiqueDraft;
2795
+ }
2796
+ });
2797
+ Object.defineProperty(exports, "critiqueDrafts", {
2798
+ enumerable: true,
2799
+ get: function() {
2800
+ return critiqueDrafts;
2801
+ }
2802
+ });
2803
+ Object.defineProperty(exports, "critiqueItemGroupDraft", {
2804
+ enumerable: true,
2805
+ get: function() {
2806
+ return critiqueItemGroupDraft;
2807
+ }
2808
+ });
2809
+ Object.defineProperty(exports, "generateDrafts", {
2810
+ enumerable: true,
2811
+ get: function() {
2812
+ return generateDrafts;
2813
+ }
2814
+ });
2815
+ Object.defineProperty(exports, "hintRevealsAnswer", {
2816
+ enumerable: true,
2817
+ get: function() {
2818
+ return hintRevealsAnswer;
2819
+ }
2820
+ });
2821
+ Object.defineProperty(exports, "interactiveVideoFromDrafts", {
2822
+ enumerable: true,
2823
+ get: function() {
2824
+ return interactiveVideoFromDrafts;
2825
+ }
2826
+ });
2827
+ Object.defineProperty(exports, "readAiTextResult", {
2828
+ enumerable: true,
2829
+ get: function() {
2830
+ return readAiTextResult;
2831
+ }
2832
+ });
2833
+ Object.defineProperty(exports, "validateDraft", {
2834
+ enumerable: true,
2835
+ get: function() {
2836
+ return validateDraft;
2837
+ }
2838
+ });
2839
+ Object.defineProperty(exports, "validateItemGroupDraft", {
2840
+ enumerable: true,
2841
+ get: function() {
2842
+ return validateItemGroupDraft;
2843
+ }
2844
+ });
2845
+
2846
+ //# sourceMappingURL=ai-writing-G6tnCGzw.cjs.map