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