@intellectif/lk-core 1.6.0 → 1.8.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 (86) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/{ai-i3jwXnpX.d.ts → ai-Cnm0qE8v.d.cts} +334 -16
  3. package/dist/ai-Cnm0qE8v.d.cts.map +1 -0
  4. package/dist/{ai-i3jwXnpX.d.cts → ai-Cnm0qE8v.d.ts} +334 -16
  5. package/dist/ai-Cnm0qE8v.d.ts.map +1 -0
  6. package/dist/ai-check.cjs +88 -7
  7. package/dist/ai-check.cjs.map +1 -1
  8. package/dist/ai-check.d.cts +11 -7
  9. package/dist/ai-check.d.cts.map +1 -1
  10. package/dist/ai-check.d.ts +11 -7
  11. package/dist/ai-check.d.ts.map +1 -1
  12. package/dist/ai-check.js +88 -7
  13. package/dist/ai-check.js.map +1 -1
  14. package/dist/{ai-writing-C4YRVrFi.cjs → ai-writing-Bh2Y53cY.cjs} +2567 -203
  15. package/dist/ai-writing-Bh2Y53cY.cjs.map +1 -0
  16. package/dist/{ai-writing-OJ5m_1EB.js → ai-writing-DeIV-GfN.js} +2428 -172
  17. package/dist/ai-writing-DeIV-GfN.js.map +1 -0
  18. package/dist/{index-Dj92T76o.d.ts → index-CtoPhJAH.d.ts} +2 -8
  19. package/dist/index-CtoPhJAH.d.ts.map +1 -0
  20. package/dist/{index--U7hHV0s.d.cts → index-DMh3OYSb.d.cts} +2 -8
  21. package/dist/index-DMh3OYSb.d.cts.map +1 -0
  22. package/dist/{index-WBHSrIUU.d.ts → index-HXvg0YRQ.d.ts} +5 -12
  23. package/dist/index-HXvg0YRQ.d.ts.map +1 -0
  24. package/dist/{index-CFRgZLhH.d.cts → index-fZ67k-WR.d.cts} +5 -12
  25. package/dist/index-fZ67k-WR.d.cts.map +1 -0
  26. package/dist/{index-DeD8EI1D.d.ts → index-g-EWIugp.d.ts} +2 -42
  27. package/dist/index-g-EWIugp.d.ts.map +1 -0
  28. package/dist/{index-Uh1u57yq.d.cts → index-oST8GBFa.d.cts} +2 -42
  29. package/dist/index-oST8GBFa.d.cts.map +1 -0
  30. package/dist/index.cjs +287 -20
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +227 -71
  33. package/dist/index.d.cts.map +1 -1
  34. package/dist/index.d.ts +227 -71
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +276 -21
  37. package/dist/index.js.map +1 -1
  38. package/dist/{item-group-BbvvH_Ob.js → item-group-BceUoxoM.js} +4 -6
  39. package/dist/{item-group-BbvvH_Ob.js.map → item-group-BceUoxoM.js.map} +1 -1
  40. package/dist/{item-group-CQRbb8Rx.cjs → item-group-KV4hYu3a.cjs} +4 -6
  41. package/dist/{item-group-CQRbb8Rx.cjs.map → item-group-KV4hYu3a.cjs.map} +1 -1
  42. package/dist/{registry-Ih5n4PVZ.cjs → registry-Cymq7Ah0.cjs} +97 -92
  43. package/dist/registry-Cymq7Ah0.cjs.map +1 -0
  44. package/dist/{registry-B23o9UWh.js → registry-tkacvJUI.js} +79 -91
  45. package/dist/registry-tkacvJUI.js.map +1 -0
  46. package/dist/{schemas-yId-bJhD.js → schemas-DM8sN_f0.js} +5 -7
  47. package/dist/{schemas-yId-bJhD.js.map → schemas-DM8sN_f0.js.map} +1 -1
  48. package/dist/{schemas-Q2dyOgc2.cjs → schemas-wDzbKdKa.cjs} +5 -7
  49. package/dist/{schemas-Q2dyOgc2.cjs.map → schemas-wDzbKdKa.cjs.map} +1 -1
  50. package/dist/schemas.cjs +3 -3
  51. package/dist/schemas.d.cts +1 -1
  52. package/dist/schemas.d.ts +1 -1
  53. package/dist/schemas.js +3 -3
  54. package/dist/{scoring-CaPS6q8b.cjs → scoring-D9tFvPEG.cjs} +22 -42
  55. package/dist/{scoring-CaPS6q8b.cjs.map → scoring-D9tFvPEG.cjs.map} +1 -1
  56. package/dist/{scoring-CLMdk8i9.js → scoring-DIBmo_cy.js} +22 -42
  57. package/dist/{scoring-CLMdk8i9.js.map → scoring-DIBmo_cy.js.map} +1 -1
  58. package/dist/scoring.cjs +2 -2
  59. package/dist/scoring.d.cts +2 -2
  60. package/dist/scoring.d.ts +2 -2
  61. package/dist/scoring.js +2 -2
  62. package/dist/{xapi-BX0hvRRB.cjs → xapi-Dyqcl3JK.cjs} +5 -8
  63. package/dist/{xapi-BX0hvRRB.cjs.map → xapi-Dyqcl3JK.cjs.map} +1 -1
  64. package/dist/{xapi-Cdk9fTvV.js → xapi-gCAo549Q.js} +5 -8
  65. package/dist/{xapi-Cdk9fTvV.js.map → xapi-gCAo549Q.js.map} +1 -1
  66. package/dist/xapi.cjs +1 -1
  67. package/dist/xapi.d.cts +1 -1
  68. package/dist/xapi.d.ts +1 -1
  69. package/dist/xapi.js +1 -1
  70. package/package.json +2 -2
  71. package/vectors/README.md +23 -0
  72. package/vectors/variants.d.mts +20 -0
  73. package/vectors/variants.json +5753 -0
  74. package/vectors/variants.mjs +67 -0
  75. package/dist/ai-i3jwXnpX.d.cts.map +0 -1
  76. package/dist/ai-i3jwXnpX.d.ts.map +0 -1
  77. package/dist/ai-writing-C4YRVrFi.cjs.map +0 -1
  78. package/dist/ai-writing-OJ5m_1EB.js.map +0 -1
  79. package/dist/index--U7hHV0s.d.cts.map +0 -1
  80. package/dist/index-CFRgZLhH.d.cts.map +0 -1
  81. package/dist/index-DeD8EI1D.d.ts.map +0 -1
  82. package/dist/index-Dj92T76o.d.ts.map +0 -1
  83. package/dist/index-Uh1u57yq.d.cts.map +0 -1
  84. package/dist/index-WBHSrIUU.d.ts.map +0 -1
  85. package/dist/registry-B23o9UWh.js.map +0 -1
  86. package/dist/registry-Ih5n4PVZ.cjs.map +0 -1
@@ -1,7 +1,7 @@
1
- const require_registry = require("./registry-Ih5n4PVZ.cjs");
2
- const require_scoring = require("./scoring-CaPS6q8b.cjs");
3
- const require_item_group = require("./item-group-CQRbb8Rx.cjs");
4
- //#region src/ai-material.ts
1
+ const require_registry = require("./registry-Cymq7Ah0.cjs");
2
+ const require_scoring = require("./scoring-D9tFvPEG.cjs");
3
+ const require_item_group = require("./item-group-KV4hYu3a.cjs");
4
+ // src/ai-material.ts
5
5
  /**
6
6
  * What a model is told about the group a question sits in: the material the
7
7
  * learner reads and sees beside it.
@@ -28,7 +28,7 @@ const MEDIA_TYPES = {
28
28
  video: true,
29
29
  embed: true
30
30
  };
31
- const isRecord$5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
31
+ const isRecord$7 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
32
32
  /** A string with something in it, as written; otherwise nothing. */
33
33
  const filled = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
34
34
  /**
@@ -62,17 +62,17 @@ function cut(text) {
62
62
  * and a signed address must not end up in a prompt. Each field is read once.
63
63
  */
64
64
  function materialFacts(group) {
65
- if (!isRecord$5(group)) return null;
65
+ if (!isRecord$7(group)) return null;
66
66
  const stimulus = group.stimulus;
67
- if (!isRecord$5(stimulus)) return null;
67
+ if (!isRecord$7(stimulus)) return null;
68
68
  const kind = stimulus.kind;
69
69
  if (typeof kind !== "string" || !Object.hasOwn(KINDS$2, kind)) return null;
70
70
  const title = filled(stimulus.title) ?? filled(group.title);
71
71
  const body = filled(stimulus.body);
72
72
  const text = body === void 0 ? void 0 : cut(body);
73
73
  const rawMedia = stimulus.media;
74
- const mediaType = isRecord$5(rawMedia) ? rawMedia.type : void 0;
75
- const alt = isRecord$5(rawMedia) ? filled(rawMedia.alt) : void 0;
74
+ const mediaType = isRecord$7(rawMedia) ? rawMedia.type : void 0;
75
+ const alt = isRecord$7(rawMedia) ? filled(rawMedia.alt) : void 0;
76
76
  const media = typeof mediaType === "string" && Object.hasOwn(MEDIA_TYPES, mediaType) ? {
77
77
  type: mediaType,
78
78
  ...alt !== void 0 ? { alt } : {}
@@ -89,8 +89,7 @@ function materialFacts(group) {
89
89
  ...locale !== void 0 ? { locale } : {}
90
90
  };
91
91
  }
92
- //#endregion
93
- //#region src/ai-text.ts
92
+ // src/ai-text.ts
94
93
  /**
95
94
  * How the SDK reads text a model wrote, before a learner does. Internal: shared
96
95
  * by the AI checks, and exported by none of the package's entry points.
@@ -178,8 +177,7 @@ function folded(text) {
178
177
  }
179
178
  /** A quote as it is searched for: folded, and without the spaces around it. */
180
179
  const needle = (quote) => folded(quote).chars.trim();
181
- //#endregion
182
- //#region src/ai.ts
180
+ // src/ai.ts
183
181
  /**
184
182
  * The activity types the SDK builds facts for, and which of them take hints.
185
183
  * A dictation takes none: its answer is the whole sentence, and it has
@@ -256,7 +254,7 @@ function isAnswerOption(detail, chosen) {
256
254
  }
257
255
  }
258
256
  const firstString = (value) => Array.isArray(value) ? value[0] : value;
259
- const listOf$1 = (value) => value === void 0 ? null : Array.isArray(value) ? [...value] : [value];
257
+ const listOf$4 = (value) => value === void 0 ? null : Array.isArray(value) ? [...value] : [value];
260
258
  /** The response a learner has before touching anything. */
261
259
  function emptyResponse(type) {
262
260
  switch (type) {
@@ -284,6 +282,11 @@ const base = (data) => ({
284
282
  title: data.title,
285
283
  ...typeof data.locale === "string" ? { locale: data.locale } : {}
286
284
  });
285
+ /** The item's instructions, trimmed; nothing for none, or for blank ones the learner never sees. */
286
+ function instructionsOf$1(data) {
287
+ const text = data.instructions;
288
+ return typeof text === "string" && text.trim() !== "" ? { instructions: text.trim() } : {};
289
+ }
287
290
  /**
288
291
  * The facts a model is given about one item and one answer. `details` are the
289
292
  * SDK's per-part verdicts — from its own scorer, or from a server's outcome in
@@ -303,6 +306,7 @@ function buildAiFacts(data, response, details) {
303
306
  return {
304
307
  ...base(data),
305
308
  activityType: "multiple-choice",
309
+ ...instructionsOf$1(data),
306
310
  question: item.question,
307
311
  mode: item.mode,
308
312
  options: item.options.map((option) => {
@@ -328,11 +332,12 @@ function buildAiFacts(data, response, details) {
328
332
  return {
329
333
  ...base(data),
330
334
  activityType: "fill-in-the-blanks",
335
+ ...instructionsOf$1(data),
331
336
  passage: require_registry.numberedPassage(item.passage),
332
337
  blanks: require_registry.placeholderOrder(item.passage).map((id, index) => {
333
338
  const blank = blanks.get(id);
334
339
  const detail = byId.get(id);
335
- const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf$1(detail?.correctResponse);
340
+ const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf$4(detail?.correctResponse);
336
341
  return {
337
342
  id,
338
343
  position: index + 1,
@@ -353,6 +358,7 @@ function buildAiFacts(data, response, details) {
353
358
  return {
354
359
  ...base(data),
355
360
  activityType: "gap-select",
361
+ ...instructionsOf$1(data),
356
362
  passage: require_registry.numberedPassage(item.passage),
357
363
  gaps: require_registry.placeholderOrder(item.passage).map((id, index) => {
358
364
  const gap = gaps.get(id);
@@ -596,8 +602,7 @@ function hintRevealsAnswer(facts, hint) {
596
602
  default: return true;
597
603
  }
598
604
  }
599
- //#endregion
600
- //#region src/ai-coaching.ts
605
+ // src/ai-coaching.ts
601
606
  /** The most words one coaching may work on. More is refused, not cut. */
602
607
  const AI_COACHING_MAX_WORDS = 20;
603
608
  /** The longest tip for one word, in code points. */
@@ -742,7 +747,7 @@ function aiCoachingRequest(input) {
742
747
  ...learnerLocale !== void 0 ? { learnerLocale } : {}
743
748
  };
744
749
  }
745
- const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
750
+ const isRecord$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
746
751
  /** A symbol as it is compared: composed, and without the spaces around it. */
747
752
  const symbolKey = (symbol) => symbol.normalize("NFC").trim();
748
753
  /**
@@ -791,7 +796,7 @@ function checkAiCoaching(raw, request) {
791
796
  });
792
797
  const seen = /* @__PURE__ */ new Set();
793
798
  for (const entry of entries) {
794
- if (!isRecord$4(entry) || typeof entry.itemId !== "string" || typeof entry.tip !== "string") return {
799
+ if (!isRecord$6(entry) || typeof entry.itemId !== "string" || typeof entry.tip !== "string") return {
795
800
  ok: false,
796
801
  refusal: "malformed"
797
802
  };
@@ -839,7 +844,7 @@ function checkAiCoaching(raw, request) {
839
844
  /** A sound a model names, checked against the sounds the engine reported for the word. */
840
845
  function checkSound(raw, fact) {
841
846
  if (raw === void 0 || raw === null) return;
842
- if (!isRecord$4(raw) || typeof raw.expected !== "string" || raw.heard !== void 0 && raw.heard !== null && typeof raw.heard !== "string") return {
847
+ if (!isRecord$6(raw) || typeof raw.expected !== "string" || raw.heard !== void 0 && raw.heard !== null && typeof raw.heard !== "string") return {
843
848
  ok: false,
844
849
  refusal: "malformed"
845
850
  };
@@ -865,21 +870,20 @@ function checkSound(raw, fact) {
865
870
  heard: instead.symbol
866
871
  };
867
872
  }
868
- //#endregion
869
- //#region src/media-refs.ts
873
+ // src/media-refs.ts
870
874
  const MAIN_KINDS = [
871
875
  "audio",
872
876
  "video",
873
877
  "image",
874
878
  "embed"
875
879
  ];
876
- function isRecord$3(value) {
880
+ function isRecord$5(value) {
877
881
  return typeof value === "object" && value !== null && !Array.isArray(value);
878
882
  }
879
883
  function valueAt(root, path) {
880
884
  let at = root;
881
885
  for (const key of path) {
882
- if (typeof key === "number" ? !Array.isArray(at) : !isRecord$3(at)) return;
886
+ if (typeof key === "number" ? !Array.isArray(at) : !isRecord$5(at)) return;
883
887
  at = at[key];
884
888
  }
885
889
  return at;
@@ -892,7 +896,7 @@ function slotsOf(item) {
892
896
  return Array.isArray(slots) ? slots : [];
893
897
  }
894
898
  function holderRefs(holder, path, kindOfSlot, owner) {
895
- if (!isRecord$3(holder)) return [];
899
+ if (!isRecord$5(holder)) return [];
896
900
  const main = typeof holder.type === "string" && MAIN_KINDS.includes(holder.type) ? holder.type : kindOfSlot;
897
901
  const refs = [];
898
902
  const add = (field, kind, url) => {
@@ -907,7 +911,7 @@ function holderRefs(holder, path, kindOfSlot, owner) {
907
911
  if (main !== void 0) add(["url"], main, typeof holder.url === "string" ? holder.url : null);
908
912
  if (typeof holder.captionsUrl === "string") add(["captionsUrl"], "captions", holder.captionsUrl);
909
913
  if (Array.isArray(holder.tracks)) holder.tracks.forEach((track, index) => {
910
- if (isRecord$3(track) && typeof track.src === "string") refs.push({
914
+ if (isRecord$5(track) && typeof track.src === "string") refs.push({
911
915
  path: [
912
916
  ...path,
913
917
  "tracks",
@@ -925,7 +929,7 @@ function holderRefs(holder, path, kindOfSlot, owner) {
925
929
  return refs;
926
930
  }
927
931
  function activityRefs(item, base) {
928
- if (!isRecord$3(item)) return [];
932
+ if (!isRecord$5(item)) return [];
929
933
  const owner = typeof item.id === "string" ? { activityId: item.id } : {};
930
934
  return slotsOf(item).flatMap((slot) => holderRefs(valueAt(item, slot.path), [...base, ...slot.path], slot.kind, owner));
931
935
  }
@@ -950,11 +954,11 @@ function activityRefs(item, base) {
950
954
  * ```
951
955
  */
952
956
  function listMediaRefs(entry) {
953
- if (!isRecord$3(entry)) return [];
957
+ if (!isRecord$5(entry)) return [];
954
958
  if (entry.type !== "item-group") return activityRefs(entry, []);
955
959
  const refs = [];
956
960
  const stimulus = entry.stimulus;
957
- if (isRecord$3(stimulus)) {
961
+ if (isRecord$5(stimulus)) {
958
962
  const owner = typeof stimulus.id === "string" ? { stimulusId: stimulus.id } : {};
959
963
  refs.push(...holderRefs(stimulus.media, ["stimulus", "media"], void 0, owner));
960
964
  }
@@ -1014,7 +1018,7 @@ function mapMediaRefs(entry, fn) {
1014
1018
  /** A holder's own text: its transcript, else its script as "Speaker: text" lines. */
1015
1019
  function ownText(holder) {
1016
1020
  if (typeof holder.transcript === "string" && holder.transcript.trim() !== "") return holder.transcript;
1017
- const turns = Array.isArray(holder.script) ? holder.script.flatMap((turn) => isRecord$3(turn) && typeof turn.speaker === "string" && typeof turn.text === "string" && turn.text.trim() !== "" ? [`${turn.speaker.trim()}: ${turn.text.trim()}`] : []) : [];
1021
+ const turns = Array.isArray(holder.script) ? holder.script.flatMap((turn) => isRecord$5(turn) && typeof turn.speaker === "string" && typeof turn.text === "string" && turn.text.trim() !== "" ? [`${turn.speaker.trim()}: ${turn.text.trim()}`] : []) : [];
1018
1022
  return turns.length > 0 ? turns.join("\n") : null;
1019
1023
  }
1020
1024
  /**
@@ -1032,15 +1036,14 @@ function ownText(holder) {
1032
1036
  * ```
1033
1037
  */
1034
1038
  function transcriptOf(holder) {
1035
- if (!isRecord$3(holder)) return null;
1036
- return ownText(holder) ?? (isRecord$3(holder.media) ? ownText(holder.media) : null);
1039
+ if (!isRecord$5(holder)) return null;
1040
+ return ownText(holder) ?? (isRecord$5(holder.media) ? ownText(holder.media) : null);
1037
1041
  }
1038
- //#endregion
1039
- //#region src/authoring/recordings.ts
1040
- const written$1 = (value) => typeof value === "string" && value.trim() !== "";
1042
+ // src/authoring/recordings.ts
1043
+ const written$2 = (value) => typeof value === "string" && value.trim() !== "";
1041
1044
  /** The script's turns that have a speaker and words, or none. */
1042
- function turnsOf(script) {
1043
- return Array.isArray(script) ? script.flatMap((turn) => require_registry.isRecord(turn) && typeof turn.speaker === "string" && written$1(turn.text) ? [{
1045
+ function turnsOf$1(script) {
1046
+ return Array.isArray(script) ? script.flatMap((turn) => require_registry.isRecord(turn) && typeof turn.speaker === "string" && written$2(turn.text) ? [{
1044
1047
  speaker: turn.speaker,
1045
1048
  text: turn.text
1046
1049
  }] : []) : [];
@@ -1048,14 +1051,14 @@ function turnsOf(script) {
1048
1051
  const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1049
1052
  /** A transcript's words, with each `Speaker:` label of the script's speakers taken out. */
1050
1053
  function transcriptWords(transcript, speakers) {
1051
- const labels = [...new Set(speakers.map((speaker) => speaker.trim()).filter(written$1))];
1054
+ const labels = [...new Set(speakers.map((speaker) => speaker.trim()).filter(written$2))];
1052
1055
  const unlabelled = labels.length === 0 ? transcript : transcript.replace(new RegExp(`(^|\\s)(?:${labels.map(escaped).join("|")})\\s*:`, "giu"), "$1");
1053
1056
  return require_registry.fold(unlabelled);
1054
1057
  }
1055
1058
  /** What a recording says, as words: its transcript, or else its script's turns. */
1056
1059
  function spokenWords(holder) {
1057
- const turns = turnsOf(holder.script);
1058
- if (written$1(holder.transcript)) return transcriptWords(holder.transcript, turns.map((turn) => turn.speaker));
1060
+ const turns = turnsOf$1(holder.script);
1061
+ if (written$2(holder.transcript)) return transcriptWords(holder.transcript, turns.map((turn) => turn.speaker));
1059
1062
  return turns.length > 0 ? require_registry.fold(turns.map((turn) => turn.text).join(" ")) : null;
1060
1063
  }
1061
1064
  const sameWords = (a, b) => a.length === b.length && a.every((word, index) => word === b[index]);
@@ -1063,9 +1066,9 @@ const sameWords = (a, b) => a.length === b.length && a.every((word, index) => wo
1063
1066
  function holderFindings(holder, path) {
1064
1067
  if (!require_registry.isRecord(holder)) return [];
1065
1068
  const findings = [];
1066
- const turns = turnsOf(holder.script);
1067
- if ((written$1(holder.transcript) || turns.length > 0) && (holder.type === "image" || holder.type === "embed")) findings.push(require_registry.finding("media_transcript_unused", [...path, written$1(holder.transcript) ? "transcript" : "script"], `A transcript or a script on ${holder.type === "image" ? "a picture" : "an embedded player"} has nothing to describe, and nothing reads it. Put it on the recording it belongs to.`));
1068
- if (written$1(holder.transcript) && turns.length > 0) {
1069
+ const turns = turnsOf$1(holder.script);
1070
+ if ((written$2(holder.transcript) || turns.length > 0) && (holder.type === "image" || holder.type === "embed")) findings.push(require_registry.finding("media_transcript_unused", [...path, written$2(holder.transcript) ? "transcript" : "script"], `A transcript or a script on ${holder.type === "image" ? "a picture" : "an embedded player"} has nothing to describe, and nothing reads it. Put it on the recording it belongs to.`));
1071
+ if (written$2(holder.transcript) && turns.length > 0) {
1069
1072
  const fromTranscript = transcriptWords(holder.transcript, turns.map((turn) => turn.speaker));
1070
1073
  if (!sameWords(fromTranscript, require_registry.fold(turns.map((turn) => turn.text).join(" ")))) findings.push(require_registry.finding("media_script_differs", [...path, "script"], "The script and the transcript say different words. AI facts read the transcript, and a voice reads the script: make them say the same."));
1071
1074
  }
@@ -1087,8 +1090,8 @@ function recordingFindings(item) {
1087
1090
  const media = item.media;
1088
1091
  if (field === void 0 || !require_registry.isRecord(media)) return findings;
1089
1092
  const said = spokenWords(media);
1090
- const accepted = [item[field], ...item.type === "dictation" && Array.isArray(item.acceptedTranscripts) ? item.acceptedTranscripts : []].filter(written$1);
1091
- if (said !== null && accepted.length > 0 && !accepted.some((text) => sameWords(said, require_registry.fold(text)))) findings.push(require_registry.finding("media_transcript_differs", ["media", written$1(media.transcript) ? "transcript" : "script"], `The recording's transcript says different words from the item's \`${field}\`, which is what the recording is meant to say.`));
1093
+ const accepted = [item[field], ...item.type === "dictation" && Array.isArray(item.acceptedTranscripts) ? item.acceptedTranscripts : []].filter(written$2);
1094
+ if (said !== null && accepted.length > 0 && !accepted.some((text) => sameWords(said, require_registry.fold(text)))) findings.push(require_registry.finding("media_transcript_differs", ["media", written$2(media.transcript) ? "transcript" : "script"], `The recording's transcript says different words from the item's \`${field}\`, which is what the recording is meant to say.`));
1092
1095
  return findings;
1093
1096
  }
1094
1097
  /** A group's material: its own transcript against its script, and its media's rules. */
@@ -1099,8 +1102,764 @@ function stimulusFindings(stimulus) {
1099
1102
  script: stimulus.script
1100
1103
  }, ["stimulus"]), ...holderFindings(stimulus.media, ["stimulus", "media"])];
1101
1104
  }
1102
- //#endregion
1103
- //#region src/authoring/item-group.ts
1105
+ // src/variant-rules.ts
1106
+ const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1107
+ const listOf$3 = (value) => Array.isArray(value) ? value : [];
1108
+ const stringOf$3 = (value) => typeof value === "string" ? value : "";
1109
+ const same = (a, b) => require_scoring.canonicalJson(a ?? null) === require_scoring.canonicalJson(b ?? null);
1110
+ /**
1111
+ * A passage's blanks or gaps in the order its placeholders appear, each with
1112
+ * its index in the list: the order a learner meets them, which is how two
1113
+ * items are compared and how a model writes them. The list's own order where
1114
+ * the passage does not name each of them once.
1115
+ */
1116
+ function inPassageOrder(item, field) {
1117
+ const list = listOf$3(item[field]);
1118
+ const found = (typeof item.passage === "string" ? require_registry.placeholderOrder(item.passage) : []).map((id) => list.findIndex((one) => isRecord$4(one) && one.id === id));
1119
+ if (found.length === list.length && found.every((at) => at !== -1) && new Set(found).size === found.length) return found.map((at) => ({
1120
+ at,
1121
+ value: list[at]
1122
+ }));
1123
+ return list.map((value, at) => ({
1124
+ at,
1125
+ value
1126
+ }));
1127
+ }
1128
+ /**
1129
+ * How alike two texts must be to count as one copying the other: the share of
1130
+ * their word triples (3-word shingles) they have in common, as Jaccard
1131
+ * similarity. 0.5 means half of everything either text says, three words at a
1132
+ * time, is in the other.
1133
+ */
1134
+ const AI_VARIANT_NEAR_COPY_THRESHOLD = .5;
1135
+ /** Texts shorter than this, in words, are compared whole: word triples say nothing about them. */
1136
+ const SHORT_TEXT_WORDS = 6;
1137
+ /**
1138
+ * The words of a text as every comparison here reads them: placeholders
1139
+ * (`{{1}}`) taken out, compatibility forms read as what they stand for, accents
1140
+ * and case ignored, and punctuation — typographic quotes included — gone.
1141
+ */
1142
+ function wordsOf(text) {
1143
+ return require_registry.fold(require_registry.passageWithoutPlaceholders(text));
1144
+ }
1145
+ function triples(words) {
1146
+ const found = /* @__PURE__ */ new Set();
1147
+ for (let at = 0; at + 3 <= words.length; at += 1) found.add(`${words[at]} ${words[at + 1]} ${words[at + 2]}`);
1148
+ return found;
1149
+ }
1150
+ /**
1151
+ * How alike two texts are, from 0 to 1: the Jaccard similarity of their word
1152
+ * triples, or — when either has fewer than six words — 1 if they read the same
1153
+ * and 0 if not. A text with no words is like nothing.
1154
+ */
1155
+ function textSimilarity(a, b) {
1156
+ const left = wordsOf(a);
1157
+ const right = wordsOf(b);
1158
+ if (left.length === 0 || right.length === 0) return 0;
1159
+ if (left.length < SHORT_TEXT_WORDS || right.length < SHORT_TEXT_WORDS) return left.join(" ") === right.join(" ") ? 1 : 0;
1160
+ const one = triples(left);
1161
+ const two = triples(right);
1162
+ let shared = 0;
1163
+ for (const triple of one) if (two.has(triple)) shared += 1;
1164
+ return shared / (one.size + two.size - shared);
1165
+ }
1166
+ /** A similarity as a message shows it: two decimals. */
1167
+ const shownScore = (score) => score.toFixed(2);
1168
+ const RECORDING_TYPES = [
1169
+ "audio",
1170
+ "video",
1171
+ "embed"
1172
+ ];
1173
+ /** A recording's words: its script's turns, speakers left out, else its transcript. */
1174
+ function recordingText(holder) {
1175
+ const turns = listOf$3(holder.script).flatMap((turn) => isRecord$4(turn) && typeof turn.text === "string" && turn.text.trim() !== "" ? [turn.text] : []);
1176
+ if (turns.length > 0) return {
1177
+ field: "script",
1178
+ text: turns.join("\n")
1179
+ };
1180
+ return typeof holder.transcript === "string" && holder.transcript.trim() !== "" ? {
1181
+ field: "transcript",
1182
+ text: holder.transcript
1183
+ } : null;
1184
+ }
1185
+ function mediaTexts(media, at) {
1186
+ if (!isRecord$4(media) || !RECORDING_TYPES.includes(stringOf$3(media.type))) return [];
1187
+ const said = recordingText(media);
1188
+ return said === null ? [] : [{
1189
+ path: [...at, said.field],
1190
+ kind: "material",
1191
+ band: "material",
1192
+ text: said.text
1193
+ }];
1194
+ }
1195
+ /** The fields an activity type keeps its own text in, and what each is. */
1196
+ const OWN_TEXT = {
1197
+ "multiple-choice": {
1198
+ field: "question",
1199
+ kind: "stem"
1200
+ },
1201
+ "fill-in-the-blanks": {
1202
+ field: "passage",
1203
+ kind: "stem"
1204
+ },
1205
+ "gap-select": {
1206
+ field: "passage",
1207
+ kind: "stem"
1208
+ },
1209
+ "written-response": {
1210
+ field: "prompt",
1211
+ kind: "stem"
1212
+ },
1213
+ dictation: {
1214
+ field: "transcript",
1215
+ kind: "spoken"
1216
+ },
1217
+ "read-aloud": {
1218
+ field: "referenceText",
1219
+ kind: "spoken"
1220
+ }
1221
+ };
1222
+ /**
1223
+ * Every text of an entry a near copy or a length is judged on: an item's own
1224
+ * text and what its recording says; for an item group, its stimulus's passage
1225
+ * and recording, then each item's under `items.N`. A dictation's or a
1226
+ * read-aloud's recording says the item's own text, so it is not read twice.
1227
+ */
1228
+ function textsOf(entry) {
1229
+ if (!isRecord$4(entry)) return [];
1230
+ if (entry.type === "item-group") {
1231
+ const stimulus = isRecord$4(entry.stimulus) ? entry.stimulus : {};
1232
+ const own = recordingText(stimulus);
1233
+ return [
1234
+ ...typeof stimulus.body === "string" && stimulus.body.trim() !== "" ? [{
1235
+ path: ["stimulus", "body"],
1236
+ kind: "material",
1237
+ band: "material",
1238
+ text: stimulus.body
1239
+ }] : [],
1240
+ ...own !== null ? [{
1241
+ path: ["stimulus", own.field],
1242
+ kind: "material",
1243
+ band: "material",
1244
+ text: own.text
1245
+ }] : mediaTexts(stimulus.media, ["stimulus", "media"]),
1246
+ ...listOf$3(entry.items).flatMap((item, index) => textsOf(item).map((found) => ({
1247
+ ...found,
1248
+ path: [
1249
+ "items",
1250
+ String(index),
1251
+ ...found.path
1252
+ ]
1253
+ })))
1254
+ ];
1255
+ }
1256
+ const own = OWN_TEXT[stringOf$3(entry.type)];
1257
+ if (own === void 0) return [];
1258
+ const text = entry[own.field];
1259
+ return [...typeof text === "string" && text.trim() !== "" ? [{
1260
+ path: [own.field],
1261
+ kind: own.kind,
1262
+ band: "item",
1263
+ text
1264
+ }] : [], ...own.kind === "spoken" ? [] : mediaTexts(entry.media, ["media"])];
1265
+ }
1266
+ /** A key as compared: folded, and kept in its case when its blank matches case. */
1267
+ function keyOf(text, caseSensitive = false) {
1268
+ if (!caseSensitive) return require_registry.fold(text).join(" ");
1269
+ return text.normalize("NFKD").replace(/\p{M}/gu, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
1270
+ }
1271
+ function choiceText(item, gap) {
1272
+ const own = listOf$3(gap.choices);
1273
+ const bank = listOf$3(item.banks).find((one) => isRecord$4(one) && one.id === gap.bankId);
1274
+ const right = (own.length > 0 ? own : isRecord$4(bank) ? listOf$3(bank.choices) : []).find((choice) => isRecord$4(choice) && choice.id === gap.correctChoiceId);
1275
+ return isRecord$4(right) && typeof right.text === "string" ? right.text : void 0;
1276
+ }
1277
+ /** The right answers of one item (not a group), slot by slot. */
1278
+ function keysOf(item) {
1279
+ if (!isRecord$4(item)) return [];
1280
+ const slots = [];
1281
+ const add = (slot, path, written, caseSensitive = false) => {
1282
+ const answers = [...new Set(written.map((one) => keyOf(one, caseSensitive)))].filter((one) => one !== "");
1283
+ if (answers.length > 0) slots.push({
1284
+ slot,
1285
+ path,
1286
+ answers,
1287
+ shown: written[0].trim()
1288
+ });
1289
+ };
1290
+ if (item.type === "multiple-choice") {
1291
+ const options = listOf$3(item.options);
1292
+ const first = options.findIndex((option) => isRecord$4(option) && option.isCorrect === true);
1293
+ add("options", [
1294
+ "options",
1295
+ String(first),
1296
+ "text"
1297
+ ], options.flatMap((option) => isRecord$4(option) && option.isCorrect === true && typeof option.text === "string" ? [option.text] : []));
1298
+ } else if (item.type === "fill-in-the-blanks") inPassageOrder(item, "blanks").forEach(({ at, value: blank }, position) => {
1299
+ if (isRecord$4(blank)) add(`blanks.${position}`, [
1300
+ "blanks",
1301
+ String(at),
1302
+ "acceptedAnswers"
1303
+ ], listOf$3(blank.acceptedAnswers).filter((one) => typeof one === "string"), require_registry.policyFor(blank).caseSensitive === true);
1304
+ });
1305
+ else if (item.type === "gap-select") inPassageOrder(item, "gaps").forEach(({ at, value: gap }, position) => {
1306
+ const text = isRecord$4(gap) ? choiceText(item, gap) : void 0;
1307
+ if (text !== void 0) add(`gaps.${position}`, [
1308
+ "gaps",
1309
+ String(at),
1310
+ "correctChoiceId"
1311
+ ], [text]);
1312
+ });
1313
+ return slots;
1314
+ }
1315
+ /** The answers two key slots share, as the first slot writes them. */
1316
+ function sharedAnswers(a, b) {
1317
+ return a.answers.filter((answer) => b.answers.includes(answer));
1318
+ }
1319
+ /** A multiple choice's options as a set of texts, for "the same options in another order". */
1320
+ function optionSet(item) {
1321
+ if (!isRecord$4(item) || item.type !== "multiple-choice") return null;
1322
+ const texts = listOf$3(item.options).map((option) => isRecord$4(option) ? require_registry.fold(stringOf$3(option.text)).join(" ") : "");
1323
+ return texts.length === 0 || texts.some((one) => one === "") ? null : [...texts].sort();
1324
+ }
1325
+ /**
1326
+ * How far a parallel text may stray from its exemplar's length, in words:
1327
+ * `ratio` of the exemplar's words either way, and never less than `minWords`.
1328
+ * Material — a passage, a listening script, what a recording says — is held
1329
+ * closer than an item's own text, because how much a candidate reads or hears
1330
+ * is what makes one form of a part harder than another.
1331
+ */
1332
+ const AI_VARIANT_LENGTH_BANDS = {
1333
+ material: {
1334
+ ratio: .1,
1335
+ minWords: 5
1336
+ },
1337
+ item: {
1338
+ ratio: .25,
1339
+ minWords: 2
1340
+ }
1341
+ };
1342
+ /** Inclusive bounds, in words, for a text the exemplar writes in `words` words. */
1343
+ function lengthBand(words, band, tolerance = {}) {
1344
+ const rule = AI_VARIANT_LENGTH_BANDS[band];
1345
+ const asked = tolerance[band];
1346
+ const ratio = typeof asked === "number" && Number.isFinite(asked) && asked >= 0 ? asked : rule.ratio;
1347
+ const leeway = Math.max(words * ratio, rule.minWords);
1348
+ return [Math.max(0, Math.ceil(words - leeway - 1e-9)), Math.floor(words + leeway + 1e-9)];
1349
+ }
1350
+ /** Every text of an entry with its band: path, kind and bounds. */
1351
+ function lengthTargets(entry, tolerance) {
1352
+ return textsOf(entry).map((found) => {
1353
+ const words = wordsOf(found.text).length;
1354
+ return {
1355
+ path: found.path,
1356
+ band: found.band,
1357
+ words,
1358
+ bounds: lengthBand(words, found.band, tolerance)
1359
+ };
1360
+ });
1361
+ }
1362
+ /** The texts of `candidate` whose length falls outside the band of the same text in `exemplar`. */
1363
+ function lengthMisses(exemplar, candidate, tolerance) {
1364
+ const theirs = new Map(textsOf(candidate).map((found) => [found.path.join("."), found]));
1365
+ return lengthTargets(exemplar, tolerance).flatMap((target) => {
1366
+ const found = theirs.get(target.path.join("."));
1367
+ if (found === void 0) return [];
1368
+ const words = wordsOf(found.text).length;
1369
+ return words < target.bounds[0] || words > target.bounds[1] ? [{
1370
+ path: target.path,
1371
+ band: target.band,
1372
+ words,
1373
+ bounds: target.bounds,
1374
+ exemplarWords: target.words
1375
+ }] : [];
1376
+ });
1377
+ }
1378
+ const count = (n, what) => `${n} ${what}${n === 1 ? "" : "s"}`;
1379
+ /** A script's speakers, by name, and its turns. */
1380
+ function scriptShape(holder) {
1381
+ const turns = listOf$3(holder.script).filter(isRecord$4);
1382
+ if (turns.length === 0) return null;
1383
+ return {
1384
+ speakers: new Set(turns.map((turn) => stringOf$3(turn.speaker).trim().toLowerCase())).size,
1385
+ turns: turns.length
1386
+ };
1387
+ }
1388
+ /** The script a stimulus carries, on itself or on its media. */
1389
+ function stimulusScript(stimulus) {
1390
+ return scriptShape(stimulus) ?? (isRecord$4(stimulus.media) ? scriptShape(stimulus.media) : null);
1391
+ }
1392
+ /** How many turns a script of `turns` turns may have in a variant: within a quarter, at least one either way. */
1393
+ function turnBand(turns) {
1394
+ const leeway = Math.max(turns * .25, 1);
1395
+ return [Math.max(1, Math.ceil(turns - leeway - 1e-9)), Math.floor(turns + leeway + 1e-9)];
1396
+ }
1397
+ const mediaKind = (media) => isRecord$4(media) && typeof media.type === "string" ? media.type : null;
1398
+ function isOneWord(answer) {
1399
+ const trimmed = answer.trim();
1400
+ return trimmed !== "" && !/\s/.test(trimmed);
1401
+ }
1402
+ /**
1403
+ * Which bank each gap draws on, by the bank's position, or `null` for a gap
1404
+ * with its own choices: in passage order.
1405
+ */
1406
+ function gapBanks(item) {
1407
+ const banks = listOf$3(item.banks);
1408
+ return inPassageOrder(item, "gaps").map(({ value: gap }) => {
1409
+ if (!isRecord$4(gap) || listOf$3(gap.choices).length > 0) return null;
1410
+ const at = banks.findIndex((bank) => isRecord$4(bank) && bank.id === gap.bankId);
1411
+ return at === -1 ? null : at;
1412
+ });
1413
+ }
1414
+ /**
1415
+ * The structure a variant must keep, as plain facts a model can be told: the
1416
+ * counts it must hit. Read from the exemplar; nothing here is checked.
1417
+ */
1418
+ function structureTargets(entry) {
1419
+ if (!isRecord$4(entry)) return {};
1420
+ switch (entry.type) {
1421
+ case "item-group": {
1422
+ const stimulus = isRecord$4(entry.stimulus) ? entry.stimulus : {};
1423
+ const script = stimulusScript(stimulus);
1424
+ return {
1425
+ stimulus: stringOf$3(stimulus.kind),
1426
+ items: listOf$3(entry.items).map((item) => ({
1427
+ type: isRecord$4(item) ? stringOf$3(item.type) : "",
1428
+ ...structureTargets(item)
1429
+ })),
1430
+ ...script !== null ? {
1431
+ speakers: script.speakers,
1432
+ turns: turnBand(script.turns)
1433
+ } : {}
1434
+ };
1435
+ }
1436
+ case "multiple-choice": {
1437
+ const options = listOf$3(entry.options);
1438
+ const media = options.map((option) => isRecord$4(option) ? mediaKind(option.media) : null);
1439
+ return {
1440
+ mode: stringOf$3(entry.mode),
1441
+ options: options.length,
1442
+ correct: options.filter((option) => isRecord$4(option) && option.isCorrect === true).length,
1443
+ ...media.some((one) => one !== null) ? { optionMedia: media } : {}
1444
+ };
1445
+ }
1446
+ case "fill-in-the-blanks": return { blanks: inPassageOrder(entry, "blanks").map(({ value: blank }) => {
1447
+ const answers = isRecord$4(blank) ? listOf$3(blank.acceptedAnswers).filter((one) => typeof one === "string") : [];
1448
+ return {
1449
+ oneWord: answers.length > 0 && answers.every(isOneWord),
1450
+ acceptedAnswers: [Math.max(1, answers.length - 2), answers.length + 2]
1451
+ };
1452
+ }) };
1453
+ case "gap-select": {
1454
+ const banks = listOf$3(entry.banks);
1455
+ const drawn = gapBanks(entry);
1456
+ return {
1457
+ gaps: inPassageOrder(entry, "gaps").map(({ value: gap }, index) => drawn[index] !== null ? { bank: drawn[index] } : { choices: isRecord$4(gap) ? listOf$3(gap.choices).length : 0 }),
1458
+ ...banks.length > 0 ? { banks: banks.map((bank) => ({ choices: isRecord$4(bank) ? listOf$3(bank.choices).length : 0 })) } : {}
1459
+ };
1460
+ }
1461
+ case "written-response": return {
1462
+ minWords: entry.minWords,
1463
+ maxWords: entry.maxWords,
1464
+ ...isRecord$4(entry.rubric) ? { criteria: listOf$3(entry.rubric.criteria).map((criterion) => isRecord$4(criterion) ? stringOf$3(criterion.name) : "") } : {}
1465
+ };
1466
+ default: return {};
1467
+ }
1468
+ }
1469
+ /** The settings two items must share to be scored alike, per type. */
1470
+ const SCORED_ALIKE = {
1471
+ "multiple-choice": ["mode", "scoringStrategy"],
1472
+ "fill-in-the-blanks": ["scoringStrategy"],
1473
+ "gap-select": ["scoringStrategy", "presentation"],
1474
+ dictation: ["tolerance", "hints"],
1475
+ "read-aloud": [
1476
+ "recording",
1477
+ "scoring",
1478
+ "locale"
1479
+ ],
1480
+ "written-response": [
1481
+ "rubric",
1482
+ "minWords",
1483
+ "maxWords",
1484
+ "languageTarget"
1485
+ ]
1486
+ };
1487
+ const DIFFERS = {
1488
+ mode: "The mode differs",
1489
+ scoringStrategy: "The scoring strategy differs",
1490
+ presentation: "The presentation differs",
1491
+ tolerance: "The tolerance differs",
1492
+ hints: "The hints differ",
1493
+ recording: "The recording bounds differ",
1494
+ scoring: "The scored dimensions differ",
1495
+ locale: "The locale differs",
1496
+ rubric: "The rubric differs",
1497
+ minWords: "The minimum word count differs",
1498
+ maxWords: "The maximum word count differs",
1499
+ languageTarget: "The language target differs"
1500
+ };
1501
+ function itemMisses(exemplar, candidate) {
1502
+ const misses = [];
1503
+ const miss = (path, message) => misses.push({
1504
+ path: path.map(String),
1505
+ message
1506
+ });
1507
+ const type = stringOf$3(exemplar.type);
1508
+ for (const field of SCORED_ALIKE[type] ?? []) if (!same(exemplar[field], candidate[field])) miss([field], `${DIFFERS[field]} from the exemplar's.`);
1509
+ if (exemplar.shuffle === true !== (candidate.shuffle === true)) miss(["shuffle"], "Shuffling differs from the exemplar.");
1510
+ if (mediaKind(exemplar.media) !== mediaKind(candidate.media)) miss(["media"], `The exemplar has ${mediaKind(exemplar.media) ?? "no"} media; this has ${mediaKind(candidate.media) ?? "none"}.`);
1511
+ if (type === "dictation" || type === "read-aloud") {
1512
+ if (isRecord$4(exemplar.slowMedia) !== isRecord$4(candidate.slowMedia)) miss(["slowMedia"], isRecord$4(exemplar.slowMedia) ? "The exemplar has a slow recording; this has none." : "This has a slow recording; the exemplar has none.");
1513
+ }
1514
+ if (type === "multiple-choice") {
1515
+ const mine = listOf$3(exemplar.options);
1516
+ const theirs = listOf$3(candidate.options);
1517
+ if (mine.length !== theirs.length) miss(["options"], `${count(theirs.length, "option")}; the exemplar has ${mine.length}.`);
1518
+ const right = (list) => list.filter((option) => isRecord$4(option) && option.isCorrect === true).length;
1519
+ if (right(mine) !== right(theirs)) miss(["options"], `${count(right(theirs), "correct option")}; the exemplar has ${right(mine)}.`);
1520
+ const media = (list) => list.map((option) => isRecord$4(option) ? mediaKind(option.media) : null).filter((one) => one !== null).sort().join(",");
1521
+ if (media(mine) !== media(theirs)) miss(["options"], "The options with media differ from the exemplar's.");
1522
+ } else if (type === "fill-in-the-blanks") {
1523
+ const mine = inPassageOrder(exemplar, "blanks");
1524
+ const theirs = inPassageOrder(candidate, "blanks");
1525
+ if (mine.length !== theirs.length) miss(["blanks"], `${count(theirs.length, "blank")}; the exemplar has ${mine.length}.`);
1526
+ mine.forEach(({ value: blank }, index) => {
1527
+ const at = theirs[index]?.at;
1528
+ const other = theirs[index]?.value;
1529
+ if (!isRecord$4(blank) || !isRecord$4(other)) return;
1530
+ const policy = (one) => ({
1531
+ match: one.match,
1532
+ caseSensitive: one.caseSensitive,
1533
+ trimWhitespace: one.trimWhitespace
1534
+ });
1535
+ if (!same(policy(blank), policy(other))) miss(["blanks", at], `Blank ${index + 1} matches answers differently from the exemplar's.`);
1536
+ const answers = (one) => listOf$3(one.acceptedAnswers).filter((answer) => typeof answer === "string");
1537
+ const exemplarAnswers = answers(blank);
1538
+ const candidateAnswers = answers(other);
1539
+ if (exemplarAnswers.length > 0 && exemplarAnswers.every(isOneWord) && !candidateAnswers.every(isOneWord)) miss([
1540
+ "blanks",
1541
+ at,
1542
+ "acceptedAnswers"
1543
+ ], `Blank ${index + 1} takes one word in the exemplar; every answer here must be one word.`);
1544
+ if (Math.abs(candidateAnswers.length - exemplarAnswers.length) > 2) miss([
1545
+ "blanks",
1546
+ at,
1547
+ "acceptedAnswers"
1548
+ ], `Blank ${index + 1} accepts ${count(candidateAnswers.length, "answer")}; the exemplar accepts ${exemplarAnswers.length}, and two more or fewer is the most.`);
1549
+ });
1550
+ } else if (type === "gap-select") {
1551
+ const mine = inPassageOrder(exemplar, "gaps");
1552
+ const theirs = inPassageOrder(candidate, "gaps");
1553
+ if (mine.length !== theirs.length) miss(["gaps"], `${count(theirs.length, "gap")}; the exemplar has ${mine.length}.`);
1554
+ const myBanks = listOf$3(exemplar.banks);
1555
+ const theirBanks = listOf$3(candidate.banks);
1556
+ if (myBanks.length !== theirBanks.length) miss(["banks"], `${count(theirBanks.length, "bank")}; the exemplar has ${myBanks.length}.`);
1557
+ myBanks.forEach((bank, index) => {
1558
+ const other = theirBanks[index];
1559
+ const size = (one) => isRecord$4(one) ? listOf$3(one.choices).length : 0;
1560
+ if (other !== void 0 && size(bank) !== size(other)) miss([
1561
+ "banks",
1562
+ index,
1563
+ "choices"
1564
+ ], `Bank ${index + 1} offers ${count(size(other), "choice")}; the exemplar's offers ${size(bank)}.`);
1565
+ });
1566
+ const myDraws = gapBanks(exemplar);
1567
+ const theirDraws = gapBanks(candidate);
1568
+ mine.forEach(({ value: gap }, index) => {
1569
+ const at = theirs[index]?.at;
1570
+ const other = theirs[index]?.value;
1571
+ if (at === void 0) return;
1572
+ if (myDraws[index] !== theirDraws[index]) miss(["gaps", at], `Gap ${index + 1} draws on its choices differently from the exemplar's.`);
1573
+ else if (myDraws[index] === null) {
1574
+ const size = (one) => isRecord$4(one) ? listOf$3(one.choices).length : 0;
1575
+ if (size(gap) !== size(other)) miss([
1576
+ "gaps",
1577
+ at,
1578
+ "choices"
1579
+ ], `Gap ${index + 1} offers ${count(size(other), "choice")}; the exemplar's offers ${size(gap)}.`);
1580
+ }
1581
+ });
1582
+ if (exemplar.shuffleChoices === true !== (candidate.shuffleChoices === true)) miss(["shuffleChoices"], "Shuffling the choices differs from the exemplar.");
1583
+ }
1584
+ return misses;
1585
+ }
1586
+ /**
1587
+ * How `candidate`'s structure differs from `exemplar`'s: what makes the two
1588
+ * no parallel items — the type, the counts a learner answers (options and how
1589
+ * many are right, blanks, gaps, banks, a part's items and speakers), and the
1590
+ * settings that score them. Empty when they are parallel. Lengths, copies and
1591
+ * keys are not structure; see {@link lengthMisses}, {@link textSimilarity} and
1592
+ * {@link keysOf}.
1593
+ */
1594
+ function structureMisses(exemplar, candidate) {
1595
+ if (!isRecord$4(exemplar) || !isRecord$4(candidate)) return [];
1596
+ if (exemplar.type !== candidate.type) return [{
1597
+ path: ["type"],
1598
+ message: `This is ${stringOf$3(candidate.type) || "no type"}; the exemplar is ${stringOf$3(exemplar.type)}.`
1599
+ }];
1600
+ if (exemplar.type !== "item-group") return itemMisses(exemplar, candidate);
1601
+ const misses = [];
1602
+ const mine = isRecord$4(exemplar.stimulus) ? exemplar.stimulus : {};
1603
+ const theirs = isRecord$4(candidate.stimulus) ? candidate.stimulus : {};
1604
+ if (mine.kind !== theirs.kind) misses.push({
1605
+ path: ["stimulus", "kind"],
1606
+ message: `The material is ${stringOf$3(theirs.kind) || "of no kind"}; the exemplar's is ${stringOf$3(mine.kind)}.`
1607
+ });
1608
+ const myScript = stimulusScript(mine);
1609
+ const theirScript = stimulusScript(theirs);
1610
+ if (myScript !== null) {
1611
+ if (theirScript === null) misses.push({
1612
+ path: ["stimulus", "script"],
1613
+ message: "The exemplar's recording has a script; this has none."
1614
+ });
1615
+ else {
1616
+ if (theirScript.speakers !== myScript.speakers) misses.push({
1617
+ path: ["stimulus", "script"],
1618
+ message: `${count(theirScript.speakers, "speaker")}; the exemplar has ${myScript.speakers}.`
1619
+ });
1620
+ const [low, high] = turnBand(myScript.turns);
1621
+ if (theirScript.turns < low || theirScript.turns > high) misses.push({
1622
+ path: ["stimulus", "script"],
1623
+ message: `${count(theirScript.turns, "turn")}; the exemplar has ${myScript.turns}, so ${low} to ${high}.`
1624
+ });
1625
+ }
1626
+ }
1627
+ if ((exemplar.shuffle ?? "none") !== (candidate.shuffle ?? "none")) misses.push({
1628
+ path: ["shuffle"],
1629
+ message: "Shuffling differs from the exemplar."
1630
+ });
1631
+ const myItems = listOf$3(exemplar.items);
1632
+ const theirItems = listOf$3(candidate.items);
1633
+ if (myItems.length !== theirItems.length) misses.push({
1634
+ path: ["items"],
1635
+ message: `${count(theirItems.length, "item")}; the exemplar has ${myItems.length}.`
1636
+ });
1637
+ myItems.forEach((item, index) => {
1638
+ const other = theirItems[index];
1639
+ if (other !== void 0) for (const found of structureMisses(item, other)) misses.push({
1640
+ path: [
1641
+ "items",
1642
+ String(index),
1643
+ ...found.path
1644
+ ],
1645
+ message: found.message
1646
+ });
1647
+ });
1648
+ return misses;
1649
+ }
1650
+ // src/authoring/paper.ts
1651
+ /**
1652
+ * The paper critic: what only a whole paper shows, over every item of a set —
1653
+ * loose questions and the items of groups alike. Each finding sits at one of
1654
+ * the items involved and names the others in `related`.
1655
+ */
1656
+ const listOf$2 = (value) => Array.isArray(value) ? value : [];
1657
+ const stringOf$2 = (value) => typeof value === "string" ? value : "";
1658
+ const key = (path) => path.join(".");
1659
+ function itemsOf(entries) {
1660
+ return entries.flatMap((entry, index) => {
1661
+ if (!require_registry.isRecord(entry)) return [];
1662
+ if (entry.type === "item-group") {
1663
+ const group = [String(index)];
1664
+ return listOf$2(entry.items).flatMap((item, at) => require_registry.isRecord(item) ? [{
1665
+ path: [
1666
+ ...group,
1667
+ "items",
1668
+ String(at)
1669
+ ],
1670
+ item,
1671
+ group
1672
+ }] : []);
1673
+ }
1674
+ return typeof entry.type === "string" ? [{
1675
+ path: [String(index)],
1676
+ item: entry
1677
+ }] : [];
1678
+ });
1679
+ }
1680
+ /** The single-answer multiple choices a learner can see the key position of: unshuffled. */
1681
+ function singleAnswer(item) {
1682
+ if (item.type !== "multiple-choice" || item.mode !== "single") return null;
1683
+ const options = listOf$2(item.options);
1684
+ const keys = options.flatMap((option, index) => require_registry.isRecord(option) && option.isCorrect === true ? [index] : []);
1685
+ return keys.length === 1 ? {
1686
+ key: keys[0],
1687
+ options
1688
+ } : null;
1689
+ }
1690
+ /** Questions a position needs before "more than half" reads as a habit, not chance. */
1691
+ const POSITION_BIAS_MINIMUM = 6;
1692
+ /** Keys a set needs before "never here" reads as a habit. */
1693
+ const POSITION_NEVER_MINIMUM = 8;
1694
+ const letter = (position) => position < 26 ? String.fromCharCode(65 + position) : String(position + 1);
1695
+ function keyPositionBias(items) {
1696
+ const placed = items.flatMap((one) => {
1697
+ if (one.item.shuffle === true) return [];
1698
+ const single = singleAnswer(one.item);
1699
+ return single === null ? [] : [{
1700
+ one,
1701
+ key: single.key,
1702
+ size: single.options.length
1703
+ }];
1704
+ });
1705
+ if (placed.length < POSITION_BIAS_MINIMUM || placed.every((at) => at.key === placed[0]?.key)) return [];
1706
+ const counts = /* @__PURE__ */ new Map();
1707
+ for (const at of placed) counts.set(at.key, [...counts.get(at.key) ?? [], at.one]);
1708
+ for (const [position, holders] of counts) if (holders.length * 2 > placed.length) return [{
1709
+ ...require_registry.finding("set_key_position_bias", [], `${holders.length} of ${placed.length} single-answer questions put the right option at ${letter(position)}. Spread the keys across positions, or shuffle the options.`),
1710
+ related: holders.map((one) => one.path)
1711
+ }];
1712
+ if (placed.length >= POSITION_NEVER_MINIMUM) {
1713
+ const everywhere = Math.min(...placed.map((at) => at.size));
1714
+ for (let position = 0; position < everywhere; position += 1) if (!counts.has(position)) return [{
1715
+ ...require_registry.finding("set_key_position_bias", [], `None of ${placed.length} single-answer questions puts the right option at ${letter(position)}, which every one of them has. A learner can rule it out.`),
1716
+ related: placed.map((at) => at.one.path)
1717
+ }];
1718
+ }
1719
+ return [];
1720
+ }
1721
+ const LONGEST_MINIMUM = 5;
1722
+ const LONGEST_SHARE = .6;
1723
+ function keyLongestBias(items) {
1724
+ const judged = items.flatMap((one) => {
1725
+ const single = singleAnswer(one.item);
1726
+ if (single === null || single.options.length < 3 || !single.options.every((option) => require_registry.isRecord(option) && option.media === void 0 && typeof option.text === "string" && option.text.trim() !== "")) return [];
1727
+ const lengths = single.options.map((option) => [...stringOf$2(option.text).trim()].length);
1728
+ const keyLength = lengths[single.key];
1729
+ return [{
1730
+ one,
1731
+ longest: lengths.every((length, index) => index === single.key || length < keyLength)
1732
+ }];
1733
+ });
1734
+ const longest = judged.filter((at) => at.longest);
1735
+ if (judged.length < LONGEST_MINIMUM || longest.length < judged.length * LONGEST_SHARE) return [];
1736
+ return [{
1737
+ ...require_registry.finding("set_key_longest_bias", [], `In ${longest.length} of ${judged.length} single-answer questions the right option is the longest. A learner who picks the longest option scores well without knowing the answers.`),
1738
+ related: longest.map((at) => at.one.path)
1739
+ }];
1740
+ }
1741
+ /** Stems shorter than this are not compared: "Choose the correct answer." repeats by design. */
1742
+ const STEM_MINIMUM_WORDS = 6;
1743
+ function stemDuplicates(items) {
1744
+ const stems = items.flatMap((one) => textsOf(one.item).filter((text) => text.kind !== "material" && wordsOf(text.text).length >= STEM_MINIMUM_WORDS).map((text) => ({
1745
+ one,
1746
+ path: [...one.path, ...text.path],
1747
+ text: text.text
1748
+ })));
1749
+ const findings = [];
1750
+ stems.forEach((later, at) => {
1751
+ let best;
1752
+ for (const earlier of stems.slice(0, at)) {
1753
+ if (earlier.one === later.one) continue;
1754
+ const score = textSimilarity(earlier.text, later.text);
1755
+ if (score >= .5 && (best === void 0 || score > best.score)) best = {
1756
+ path: earlier.path,
1757
+ score
1758
+ };
1759
+ }
1760
+ if (best !== void 0) findings.push({
1761
+ ...require_registry.finding("set_stem_duplicate", later.path, best.score === 1 ? "This reads the same as another item of the set. One piece of knowledge answers both." : `This reads like another item of the set (similarity ${shownScore(best.score)}). One piece of knowledge may answer both.`),
1762
+ related: [best.path]
1763
+ });
1764
+ });
1765
+ return findings;
1766
+ }
1767
+ /**
1768
+ * The 100 most common English words (Oxford English Corpus), with the forms
1769
+ * of be, have and do: a key that is one of them is everywhere, so finding it
1770
+ * in another item says nothing.
1771
+ */
1772
+ const COMMON_WORDS = new Set(`the be to of and a in that have i it for not on with he as you do at this but his by from they we say her she or an will my one all would there their what so up out if about who get which go me when make can like time no just him know take people into year your good some could them see other than then now look only come its over think also back after use two how our work first well way even new want because any these give day most us is are was were been being am has had having does did done`.split(" "));
1773
+ /** Whether an answer is worth looking for in other items: more than two characters, and not a common word alone. */
1774
+ function distinctive(words) {
1775
+ return words.join(" ").length > 2 && !(words.length === 1 && COMMON_WORDS.has(words[0]));
1776
+ }
1777
+ /** What a learner reads or hears of an item: its own text, and its options' and choices'. */
1778
+ function placeOf(one) {
1779
+ const item = one.item;
1780
+ const texts = [
1781
+ ...textsOf(item).map((text) => text.text),
1782
+ ...listOf$2(item.options).map((option) => require_registry.isRecord(option) ? stringOf$2(option.text) : ""),
1783
+ ...listOf$2(item.gaps).flatMap((gap) => require_registry.isRecord(gap) ? listOf$2(gap.choices).map((choice) => require_registry.isRecord(choice) ? stringOf$2(choice.text) : "") : []),
1784
+ ...listOf$2(item.banks).flatMap((bank) => require_registry.isRecord(bank) ? listOf$2(bank.choices).map((choice) => require_registry.isRecord(choice) ? stringOf$2(choice.text) : "") : [])
1785
+ ];
1786
+ return {
1787
+ path: one.path,
1788
+ owner: key(one.path),
1789
+ words: require_registry.fold(require_registry.passageWithoutPlaceholders(texts.join("\n")))
1790
+ };
1791
+ }
1792
+ /** An item's own words, where its answer must not already be: its stem, not its options. */
1793
+ function ownWords(item) {
1794
+ return require_registry.fold(require_registry.passageWithoutPlaceholders(textsOf(item).filter((text) => text.kind !== "material").map((text) => text.text).join("\n")));
1795
+ }
1796
+ function answersInOtherItems(entries, items) {
1797
+ const places = [...items.map(placeOf), ...entries.flatMap((entry, index) => {
1798
+ if (!require_registry.isRecord(entry) || entry.type !== "item-group") return [];
1799
+ const material = textsOf({
1800
+ type: "item-group",
1801
+ stimulus: entry.stimulus
1802
+ }).map((text) => text.text).join("\n");
1803
+ return material === "" ? [] : [{
1804
+ path: [String(index), "stimulus"],
1805
+ owner: `group:${index}`,
1806
+ words: require_registry.fold(material)
1807
+ }];
1808
+ })];
1809
+ const findings = [];
1810
+ for (const one of items) {
1811
+ const own = ownWords(one.item);
1812
+ const ownGroup = one.group !== void 0 ? `group:${one.group[0]}` : void 0;
1813
+ const reported = /* @__PURE__ */ new Set();
1814
+ for (const slot of keysOf(one.item)) for (const answer of slot.answers) {
1815
+ const words = require_registry.fold(answer);
1816
+ if (!distinctive(words) || require_registry.containsWords(own, words)) continue;
1817
+ for (const place of places) {
1818
+ if (place.owner === key(one.path) || place.owner === ownGroup || reported.has(place.owner)) continue;
1819
+ if (require_registry.containsWords(place.words, words)) {
1820
+ reported.add(place.owner);
1821
+ findings.push({
1822
+ ...require_registry.finding("set_item_answers_item", [...one.path, ...slot.path], `The answer "${answer}" is printed elsewhere in the set: a learner can find it there.`),
1823
+ related: [place.path]
1824
+ });
1825
+ }
1826
+ }
1827
+ }
1828
+ }
1829
+ return findings;
1830
+ }
1831
+ const REPEATED_KEY_MINIMUM = 3;
1832
+ function repeatedKeys(items) {
1833
+ const holders = /* @__PURE__ */ new Map();
1834
+ for (const one of items) {
1835
+ const seen = /* @__PURE__ */ new Set();
1836
+ for (const slot of keysOf(one.item)) for (const answer of slot.answers) if (!seen.has(answer)) {
1837
+ seen.add(answer);
1838
+ holders.set(answer, [...holders.get(answer) ?? [], [...one.path, ...slot.path]]);
1839
+ }
1840
+ }
1841
+ return [...holders].flatMap(([answer, paths]) => paths.length >= REPEATED_KEY_MINIMUM ? [{
1842
+ ...require_registry.finding("set_key_repeated", paths[0], `"${answer}" is the answer to ${paths.length} items. The set tests less than its length suggests.`),
1843
+ related: paths.slice(1)
1844
+ }] : []);
1845
+ }
1846
+ /**
1847
+ * What only a whole paper shows, over every item of `entries` — loose
1848
+ * questions and the items of groups: keys clustered in one position, keys
1849
+ * that are the longest option, stems that repeat, an answer printed in another
1850
+ * item, and one answer to many items. Paths are from the set's root.
1851
+ */
1852
+ function paperFindings(entries) {
1853
+ const items = itemsOf(entries);
1854
+ return [
1855
+ ...keyPositionBias(items),
1856
+ ...keyLongestBias(items),
1857
+ ...stemDuplicates(items),
1858
+ ...answersInOtherItems(entries, items),
1859
+ ...repeatedKeys(items)
1860
+ ];
1861
+ }
1862
+ // src/authoring/item-group.ts
1104
1863
  /**
1105
1864
  * A new, empty reading or listening testlet for an editor to start from.
1106
1865
  *
@@ -1211,7 +1970,11 @@ function critiqueItemGroupDraft(draft) {
1211
1970
  * skipped.
1212
1971
  */
1213
1972
  function critiqueDrafts(entries) {
1214
- return require_registry.withValidationScope(() => [...entries.flatMap((entry, index) => repathed(require_registry.isRecord(entry) && entry.type === "item-group" ? critiqueItemGroupDraft(entry) : critiqueItem(entry), [index])), ...require_registry.keyPositionFinding(entries, [])]);
1973
+ return require_registry.withValidationScope(() => [
1974
+ ...entries.flatMap((entry, index) => repathed(require_registry.isRecord(entry) && entry.type === "item-group" ? critiqueItemGroupDraft(entry) : critiqueItem(entry), [index])),
1975
+ ...require_registry.keyPositionFinding(entries, []),
1976
+ ...paperFindings(entries)
1977
+ ]);
1215
1978
  }
1216
1979
  /** One item's findings, or none for an item the critic cannot place. */
1217
1980
  function critiqueItem(item) {
@@ -1494,8 +2257,7 @@ function checkItems(draft) {
1494
2257
  for (const id of repeated) issues.push(require_registry.issue("ig_item_id_duplicate", ["items"], `More than one question has the id "${id}".`));
1495
2258
  return issues;
1496
2259
  }
1497
- //#endregion
1498
- //#region src/authoring/index.ts
2260
+ // src/authoring/index.ts
1499
2261
  /**
1500
2262
  * Tells a draft that is not finished from a draft that is wrong.
1501
2263
  *
@@ -1652,8 +2414,7 @@ function critiqueDraft(type, draft) {
1652
2414
  const critique = descriptor.authoring?.critique;
1653
2415
  return require_registry.withValidationScope(() => [...critique === void 0 ? [] : critique(draft), ...recordingFindings(draft)]).map(require_registry.normaliseFinding);
1654
2416
  }
1655
- //#endregion
1656
- //#region src/ai-critique.ts
2417
+ // src/ai-critique.ts
1657
2418
  /** The most findings one critique may carry. More is refused, not cut. */
1658
2419
  const AI_CRITIQUE_MAX_FINDINGS = 20;
1659
2420
  /** The longest message or quote in a finding, in code points. */
@@ -1701,10 +2462,10 @@ const NOT_WORDS = /* @__PURE__ */ new Set([
1701
2462
  "timeline"
1702
2463
  ]);
1703
2464
  /**
1704
- * Every string an author wrote into an item, with its path: the question, the
1705
- * options, the passage, the answers, the hints, the feedback, a picture's
1706
- * `alt`. Settings, ids and addresses are left out, and so is any `…Html`
1707
- * sidecar: the plain field beside it holds the same words.
2465
+ * Every string an author wrote into an item, with its path: the instructions,
2466
+ * the question, the options, the passage, the answers, the hints, the
2467
+ * feedback, a picture's `alt`. Settings, ids and addresses are left out, and
2468
+ * so is any `…Html` sidecar: the plain field beside it holds the same words.
1708
2469
  */
1709
2470
  function fieldsOf(value, path = []) {
1710
2471
  if (typeof value === "string") return value.trim() === "" ? [] : [{
@@ -1752,7 +2513,7 @@ function aiCritiqueRequest(input) {
1752
2513
  ...authorLocale !== void 0 ? { authorLocale } : {}
1753
2514
  };
1754
2515
  }
1755
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2516
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1756
2517
  const tooLong = (text) => [...text].length > 500;
1757
2518
  /**
1758
2519
  * Checks a critique a port returned for `request`, and turns it into findings
@@ -1772,7 +2533,7 @@ const tooLong = (text) => [...text].length > 500;
1772
2533
  * kind the SDK does not name, or any other shape, is `malformed`.
1773
2534
  */
1774
2535
  function checkAiCritique(raw, request) {
1775
- if (!isRecord$2(raw) || !Array.isArray(raw.findings)) return {
2536
+ if (!isRecord$3(raw) || !Array.isArray(raw.findings)) return {
1776
2537
  ok: false,
1777
2538
  refusal: "malformed"
1778
2539
  };
@@ -1783,7 +2544,7 @@ function checkAiCritique(raw, request) {
1783
2544
  const fields = new Map(request.facts.fields.map((field) => [JSON.stringify(field.path), field]));
1784
2545
  const findings = [];
1785
2546
  for (const entry of raw.findings) {
1786
- 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 {
2547
+ if (!isRecord$3(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 {
1787
2548
  ok: false,
1788
2549
  refusal: "malformed"
1789
2550
  };
@@ -1825,8 +2586,7 @@ function checkAiCritique(raw, request) {
1825
2586
  }
1826
2587
  };
1827
2588
  }
1828
- //#endregion
1829
- //#region src/ai-drafts.ts
2589
+ // src/ai-drafts.ts
1830
2590
  /** The most drafts one request may ask for, and the most a reply may hold when it names no count. */
1831
2591
  const AI_DRAFTS_MAX_COUNT = 50;
1832
2592
  /** The most repair passes `generateDrafts` makes. */
@@ -1839,8 +2599,8 @@ const DRAFT_TYPES = [
1839
2599
  "read-aloud",
1840
2600
  "written-response"
1841
2601
  ];
1842
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1843
- const text = (description) => ({
2602
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2603
+ const text$1 = (description) => ({
1844
2604
  type: "string",
1845
2605
  description
1846
2606
  });
@@ -1848,14 +2608,14 @@ const FEEDBACK = {
1848
2608
  type: "object",
1849
2609
  description: "Optional overall feedback, shown after answering.",
1850
2610
  properties: {
1851
- correct: text("Shown when the learner passes."),
1852
- incorrect: text("Shown when the learner does not.")
2611
+ correct: text$1("Shown when the learner passes."),
2612
+ incorrect: text$1("Shown when the learner does not.")
1853
2613
  }
1854
2614
  };
1855
2615
  /** Every field a model writes, and the shape of each. A name means one thing in every type that uses it. */
1856
2616
  const FIELDS = {
1857
- title: text("A short name for the item, shown to the learner above it. Never the answer, or any part of it."),
1858
- question: text("The question, as plain text."),
2617
+ title: text$1("A short name for the item, shown to the learner above it. Never the answer, or any part of it."),
2618
+ question: text$1("The question, as plain text."),
1859
2619
  mode: {
1860
2620
  type: "string",
1861
2621
  enum: ["single", "multi"],
@@ -1869,13 +2629,13 @@ const FIELDS = {
1869
2629
  type: "object",
1870
2630
  required: ["text", "isCorrect"],
1871
2631
  properties: {
1872
- text: text("The option as the learner reads it."),
2632
+ text: text$1("The option as the learner reads it."),
1873
2633
  isCorrect: { type: "boolean" },
1874
- feedback: text("Why this option is right or wrong, shown after answering.")
2634
+ feedback: text$1("Why this option is right or wrong, shown after answering.")
1875
2635
  }
1876
2636
  }
1877
2637
  },
1878
- passage: text("The passage as plain text, with {{1}}, {{2}}, … where each gap goes, numbered in the order they appear."),
2638
+ passage: text$1("The passage as plain text, with {{1}}, {{2}}, … where each gap goes, numbered in the order they appear."),
1879
2639
  blanks: {
1880
2640
  type: "array",
1881
2641
  minItems: 1,
@@ -1890,7 +2650,7 @@ const FIELDS = {
1890
2650
  items: { type: "string" },
1891
2651
  description: "Every answer that is right, spelled as a learner would type it."
1892
2652
  },
1893
- hint: text("Optional. Helps without giving the answer or any of its words.")
2653
+ hint: text$1("Optional. Helps without giving the answer or any of its words.")
1894
2654
  }
1895
2655
  }
1896
2656
  },
@@ -1909,17 +2669,17 @@ const FIELDS = {
1909
2669
  type: "object",
1910
2670
  required: ["text", "isCorrect"],
1911
2671
  properties: {
1912
- text: text("A choice."),
2672
+ text: text$1("A choice."),
1913
2673
  isCorrect: { type: "boolean" }
1914
2674
  }
1915
2675
  }
1916
2676
  } }
1917
2677
  }
1918
2678
  },
1919
- transcript: text("What the learner hears and types: words from the source, as they are said there."),
1920
- referenceText: text("What the learner reads aloud: a sentence or two from the source."),
1921
- instructions: text("Optional. How to read it, shown to the learner."),
1922
- prompt: text("The writing task, as plain text."),
2679
+ transcript: text$1("What the learner hears and types: words from the source, as they are said there."),
2680
+ referenceText: text$1("What the learner reads aloud: a sentence or two from the source."),
2681
+ instructions: text$1("Optional. The task, shown to the learner before the item: what to do (\"Complete the sentence. Use ONE word.\"), or how to read it aloud. Never part of the text the learner answers."),
2682
+ prompt: text$1("The writing task, as plain text."),
1923
2683
  minWords: {
1924
2684
  type: "integer",
1925
2685
  minimum: 0
@@ -1938,8 +2698,8 @@ const FIELDS = {
1938
2698
  type: "object",
1939
2699
  required: ["name"],
1940
2700
  properties: {
1941
- name: text("A short name."),
1942
- description: text("What it looks for.")
2701
+ name: text$1("A short name."),
2702
+ description: text$1("What it looks for.")
1943
2703
  }
1944
2704
  }
1945
2705
  } }
@@ -1947,9 +2707,10 @@ const FIELDS = {
1947
2707
  feedback: FEEDBACK
1948
2708
  };
1949
2709
  /** The fields a model writes for each type: a draft's own names, never an id or a setting. */
1950
- const WRITES = {
2710
+ const WRITES$1 = {
1951
2711
  "multiple-choice": [
1952
2712
  "title",
2713
+ "instructions",
1953
2714
  "question",
1954
2715
  "mode",
1955
2716
  "options",
@@ -1957,12 +2718,14 @@ const WRITES = {
1957
2718
  ],
1958
2719
  "fill-in-the-blanks": [
1959
2720
  "title",
2721
+ "instructions",
1960
2722
  "passage",
1961
2723
  "blanks",
1962
2724
  "feedback"
1963
2725
  ],
1964
2726
  "gap-select": [
1965
2727
  "title",
2728
+ "instructions",
1966
2729
  "passage",
1967
2730
  "gaps",
1968
2731
  "feedback"
@@ -1995,9 +2758,9 @@ function itemShape(types, captions) {
1995
2758
  const properties = { type: {
1996
2759
  type: "string",
1997
2760
  enum: [...types],
1998
- description: `The item's type, and the fields it fills. ${types.map((type) => `${type}: ${WRITES[type].filter((name) => name !== "title").join(", ")}`).join(". ")}.`
2761
+ description: `The item's type, and the fields it fills. ${types.map((type) => `${type}: ${WRITES$1[type].filter((name) => name !== "title").join(", ")}`).join(". ")}.`
1999
2762
  } };
2000
- for (const type of types) for (const name of WRITES[type]) properties[name] = FIELDS[name];
2763
+ for (const type of types) for (const name of WRITES$1[type]) properties[name] = FIELDS[name];
2001
2764
  if (captions) properties.caption = {
2002
2765
  type: "integer",
2003
2766
  minimum: 0,
@@ -2014,7 +2777,7 @@ function itemShape(types, captions) {
2014
2777
  };
2015
2778
  }
2016
2779
  /** A reply of up to `max` drafts — exactly `exactly` on a repair. */
2017
- function replyShape(types, captions, max, exactly) {
2780
+ function replyShape$1(types, captions, max, exactly) {
2018
2781
  return {
2019
2782
  type: "object",
2020
2783
  required: ["drafts"],
@@ -2056,7 +2819,7 @@ function aiDraftsRequest(input) {
2056
2819
  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)}.`);
2057
2820
  let facts;
2058
2821
  if (source.kind === "captions") {
2059
- 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) ? [{
2822
+ const captions = (Array.isArray(source.cues) ? source.cues : []).flatMap((cue, index) => isRecord$2(cue) && typeof cue.text === "string" && cue.text.trim() !== "" && Number.isFinite(cue.start) && Number.isFinite(cue.end) ? [{
2060
2823
  index,
2061
2824
  start: cue.start,
2062
2825
  end: cue.end,
@@ -2084,34 +2847,40 @@ function aiDraftsRequest(input) {
2084
2847
  ...level !== void 0 ? { level } : {},
2085
2848
  ...instructions !== void 0 ? { instructions } : {}
2086
2849
  },
2087
- shape: replyShape(types, facts.kind === "captions", count ?? 50),
2850
+ shape: replyShape$1(types, facts.kind === "captions", count ?? 50),
2088
2851
  ...settings !== void 0 ? { settings } : {}
2089
2852
  };
2090
2853
  }
2091
- const stringOf = (value) => typeof value === "string" ? value : "";
2092
- const listOf = (value) => Array.isArray(value) ? value : [];
2093
- const written = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
2854
+ const stringOf$1 = (value) => typeof value === "string" ? value : "";
2855
+ const listOf$1 = (value) => Array.isArray(value) ? value : [];
2856
+ const written$1 = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
2094
2857
  function feedbackOf(entry) {
2095
- if (!isRecord$1(entry.feedback)) return {};
2096
- const correct = written(entry.feedback.correct);
2097
- const incorrect = written(entry.feedback.incorrect);
2858
+ if (!isRecord$2(entry.feedback)) return {};
2859
+ const correct = written$1(entry.feedback.correct);
2860
+ const incorrect = written$1(entry.feedback.incorrect);
2098
2861
  return correct === void 0 && incorrect === void 0 ? {} : { feedback: {
2099
2862
  ...correct !== void 0 ? { correct } : {},
2100
2863
  ...incorrect !== void 0 ? { incorrect } : {}
2101
2864
  } };
2102
2865
  }
2866
+ /** The instructions a model wrote for the learner, when it wrote any. */
2867
+ function instructionsOf(entry) {
2868
+ const instructions = written$1(entry.instructions);
2869
+ return instructions !== void 0 ? { instructions } : {};
2870
+ }
2103
2871
  /** A type's content, read from what a model wrote. */
2104
2872
  function contentOf(type, entry, newId) {
2105
2873
  switch (type) {
2106
2874
  case "multiple-choice": return {
2107
- question: stringOf(entry.question),
2875
+ ...instructionsOf(entry),
2876
+ question: stringOf$1(entry.question),
2108
2877
  ...typeof entry.mode === "string" ? { mode: entry.mode } : {},
2109
- options: listOf(entry.options).map((option) => {
2110
- const fields = isRecord$1(option) ? option : {};
2111
- const feedback = written(fields.feedback);
2878
+ options: listOf$1(entry.options).map((option) => {
2879
+ const fields = isRecord$2(option) ? option : {};
2880
+ const feedback = written$1(fields.feedback);
2112
2881
  return {
2113
2882
  id: newId(),
2114
- text: stringOf(fields.text),
2883
+ text: stringOf$1(fields.text),
2115
2884
  isCorrect: fields.isCorrect === true,
2116
2885
  ...feedback !== void 0 ? { feedback } : {}
2117
2886
  };
@@ -2119,27 +2888,29 @@ function contentOf(type, entry, newId) {
2119
2888
  ...feedbackOf(entry)
2120
2889
  };
2121
2890
  case "fill-in-the-blanks": return {
2122
- passage: stringOf(entry.passage),
2123
- blanks: listOf(entry.blanks).map((blank, index) => {
2124
- const fields = isRecord$1(blank) ? blank : {};
2125
- const hint = written(fields.hint);
2891
+ ...instructionsOf(entry),
2892
+ passage: stringOf$1(entry.passage),
2893
+ blanks: listOf$1(entry.blanks).map((blank, index) => {
2894
+ const fields = isRecord$2(blank) ? blank : {};
2895
+ const hint = written$1(fields.hint);
2126
2896
  return {
2127
2897
  id: String(index + 1),
2128
- acceptedAnswers: listOf(fields.acceptedAnswers).filter((answer) => typeof answer === "string"),
2898
+ acceptedAnswers: listOf$1(fields.acceptedAnswers).filter((answer) => typeof answer === "string"),
2129
2899
  ...hint !== void 0 ? { hint } : {}
2130
2900
  };
2131
2901
  }),
2132
2902
  ...feedbackOf(entry)
2133
2903
  };
2134
2904
  case "gap-select": return {
2135
- passage: stringOf(entry.passage),
2136
- gaps: listOf(entry.gaps).map((gap, index) => {
2137
- const marked = listOf(isRecord$1(gap) ? gap.choices : void 0);
2905
+ ...instructionsOf(entry),
2906
+ passage: stringOf$1(entry.passage),
2907
+ gaps: listOf$1(entry.gaps).map((gap, index) => {
2908
+ const marked = listOf$1(isRecord$2(gap) ? gap.choices : void 0);
2138
2909
  const choices = marked.map((choice) => ({
2139
2910
  id: newId(),
2140
- text: stringOf(isRecord$1(choice) ? choice.text : void 0)
2911
+ text: stringOf$1(isRecord$2(choice) ? choice.text : void 0)
2141
2912
  }));
2142
- const right = marked.flatMap((choice, at) => isRecord$1(choice) && choice.isCorrect === true ? [at] : []);
2913
+ const right = marked.flatMap((choice, at) => isRecord$2(choice) && choice.isCorrect === true ? [at] : []);
2143
2914
  return {
2144
2915
  id: String(index + 1),
2145
2916
  choices,
@@ -2149,28 +2920,25 @@ function contentOf(type, entry, newId) {
2149
2920
  ...feedbackOf(entry)
2150
2921
  };
2151
2922
  case "dictation": return {
2152
- transcript: stringOf(entry.transcript),
2923
+ transcript: stringOf$1(entry.transcript),
2924
+ ...feedbackOf(entry)
2925
+ };
2926
+ case "read-aloud": return {
2927
+ referenceText: stringOf$1(entry.referenceText),
2928
+ ...instructionsOf(entry),
2153
2929
  ...feedbackOf(entry)
2154
2930
  };
2155
- case "read-aloud": {
2156
- const instructions = written(entry.instructions);
2157
- return {
2158
- referenceText: stringOf(entry.referenceText),
2159
- ...instructions !== void 0 ? { instructions } : {},
2160
- ...feedbackOf(entry)
2161
- };
2162
- }
2163
2931
  default: {
2164
- const criteria = listOf(isRecord$1(entry.rubric) ? entry.rubric.criteria : void 0);
2932
+ const criteria = listOf$1(isRecord$2(entry.rubric) ? entry.rubric.criteria : void 0);
2165
2933
  return {
2166
- prompt: stringOf(entry.prompt),
2934
+ prompt: stringOf$1(entry.prompt),
2167
2935
  ...entry.minWords !== void 0 ? { minWords: entry.minWords } : {},
2168
2936
  ...entry.maxWords !== void 0 ? { maxWords: entry.maxWords } : {},
2169
2937
  ...criteria.length > 0 ? { rubric: { criteria: criteria.map((criterion) => {
2170
- const fields = isRecord$1(criterion) ? criterion : {};
2171
- const description = written(fields.description);
2938
+ const fields = isRecord$2(criterion) ? criterion : {};
2939
+ const description = written$1(fields.description);
2172
2940
  return {
2173
- name: stringOf(fields.name),
2941
+ name: stringOf$1(fields.name),
2174
2942
  ...description !== void 0 ? { description } : {},
2175
2943
  weight: 1
2176
2944
  };
@@ -2212,7 +2980,7 @@ function draftFrom(type, entry, id, newId, request) {
2212
2980
  ...startOf(type),
2213
2981
  ...locale !== void 0 ? { locale } : {},
2214
2982
  ...request.settings?.[type],
2215
- title: stringOf(entry.title),
2983
+ title: stringOf$1(entry.title),
2216
2984
  ...contentOf(type, entry, newId),
2217
2985
  schemaVersion: "1.0",
2218
2986
  type,
@@ -2220,7 +2988,7 @@ function draftFrom(type, entry, id, newId, request) {
2220
2988
  };
2221
2989
  }
2222
2990
  /** Ids from the host, checked as `createDraft` checks them. */
2223
- function idSource(newId, caller) {
2991
+ function idSource$1(newId, caller) {
2224
2992
  const issued = /* @__PURE__ */ new Set();
2225
2993
  return () => {
2226
2994
  const id = newId();
@@ -2236,7 +3004,7 @@ function inAuthorsOrder(drafts, types) {
2236
3004
  }
2237
3005
  function check(raw, request, newId, keep = []) {
2238
3006
  const types = request.facts.activityTypes;
2239
- if (!isRecord$1(raw) || !Array.isArray(raw.drafts) || !raw.drafts.every((entry) => isRecord$1(entry) && types.includes(entry.type))) return {
3007
+ if (!isRecord$2(raw) || !Array.isArray(raw.drafts) || !raw.drafts.every((entry) => isRecord$2(entry) && types.includes(entry.type))) return {
2240
3008
  ok: false,
2241
3009
  refusal: "malformed"
2242
3010
  };
@@ -2252,7 +3020,7 @@ function check(raw, request, newId, keep = []) {
2252
3020
  const type = entry.type;
2253
3021
  const draft = draftFrom(type, entry, keep[position] ?? newId(), newId, request);
2254
3022
  const caption = source.kind === "captions" && Number.isInteger(entry.caption) ? source.captions.find((one) => one.index === entry.caption) : void 0;
2255
- const said = require_registry.fold(stringOf(entry.transcript));
3023
+ const said = require_registry.fold(stringOf$1(entry.transcript));
2256
3024
  const clip = type === "dictation" && caption !== void 0 && require_registry.containsWords(require_registry.fold(caption.text), said) ? {
2257
3025
  start: caption.start,
2258
3026
  end: caption.end
@@ -2304,14 +3072,14 @@ function check(raw, request, newId, keep = []) {
2304
3072
  * @throws Error when `newId` returns an empty or repeated id.
2305
3073
  */
2306
3074
  function checkAiDrafts(raw, request, options) {
2307
- return check(raw, request, idSource(options.newId, "checkAiDrafts"));
3075
+ return check(raw, request, idSource$1(options.newId, "checkAiDrafts"));
2308
3076
  }
2309
3077
  /**
2310
3078
  * Where a model wrote what a problem names: the draft's paths, but a gap's key
2311
3079
  * is its choices, and a reading too long for the host's recording time is the
2312
3080
  * text the model chose.
2313
3081
  */
2314
- function pathForModel(type, problem) {
3082
+ function pathForModel$1(type, problem) {
2315
3083
  const { path, code } = problem;
2316
3084
  if (type === "gap-select" && path.length === 3 && path[0] === "gaps" && path[2] === "correctChoiceId") return [
2317
3085
  "gaps",
@@ -2326,8 +3094,8 @@ function pathForModel(type, problem) {
2326
3094
  * dictation in a video with no recording, is the host's to finish, and is not
2327
3095
  * sent back.
2328
3096
  */
2329
- function problemsOf(one) {
2330
- const writes = WRITES[one.type];
3097
+ function problemsOf$1(one) {
3098
+ const writes = WRITES$1[one.type];
2331
3099
  return [...one.validation.issues.map((found) => ({
2332
3100
  path: found.path,
2333
3101
  code: found.code,
@@ -2340,7 +3108,7 @@ function problemsOf(one) {
2340
3108
  severity: "warning"
2341
3109
  }))].map((problem) => ({
2342
3110
  ...problem,
2343
- path: pathForModel(one.type, problem)
3111
+ path: pathForModel$1(one.type, problem)
2344
3112
  })).filter((problem) => writes.includes(problem.path[0]));
2345
3113
  }
2346
3114
  /**
@@ -2354,7 +3122,7 @@ function problemsOf(one) {
2354
3122
  */
2355
3123
  function aiDraftsRepairRequest(request, drafts) {
2356
3124
  const repair = ("drafts" in drafts ? drafts.drafts : drafts).flatMap((one) => {
2357
- const problems = problemsOf(one);
3125
+ const problems = problemsOf$1(one);
2358
3126
  return problems.length === 0 ? [] : [{
2359
3127
  index: one.index,
2360
3128
  draft: one.generated,
@@ -2365,20 +3133,20 @@ function aiDraftsRepairRequest(request, drafts) {
2365
3133
  const { repair: _previous, ...first } = request;
2366
3134
  return {
2367
3135
  ...first,
2368
- shape: replyShape(request.facts.activityTypes, request.facts.source.kind === "captions", repair.length, repair.length),
3136
+ shape: replyShape$1(request.facts.activityTypes, request.facts.source.kind === "captions", repair.length, repair.length),
2369
3137
  repair
2370
3138
  };
2371
3139
  }
2372
- const RANK = {
3140
+ const RANK$1 = {
2373
3141
  complete: 2,
2374
3142
  incomplete: 1,
2375
3143
  invalid: 0
2376
3144
  };
2377
3145
  /** Whether a repaired draft is at least as good as the one it would replace. */
2378
- function noWorse(repaired, before) {
3146
+ function noWorse$1(repaired, before) {
2379
3147
  const warnings = (one) => one.findings.filter((found) => found.severity === "warning").length;
2380
- const now = RANK[repaired.validation.status];
2381
- const was = RANK[before.validation.status];
3148
+ const now = RANK$1[repaired.validation.status];
3149
+ const was = RANK$1[before.validation.status];
2382
3150
  return now > was || now === was && warnings(repaired) <= warnings(before);
2383
3151
  }
2384
3152
  /**
@@ -2411,7 +3179,7 @@ async function generateDrafts(input) {
2411
3179
  const repairs = input.repairs ?? 1;
2412
3180
  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)}.`);
2413
3181
  const calls = [];
2414
- const ids = idSource(newId, "generateDrafts");
3182
+ const ids = idSource$1(newId, "generateDrafts");
2415
3183
  const ask = async (asked, keep) => {
2416
3184
  let raw;
2417
3185
  try {
@@ -2456,7 +3224,7 @@ async function generateDrafts(input) {
2456
3224
  if (repaired === void 0) break;
2457
3225
  current = current.map((before) => {
2458
3226
  const after = repaired.drafts.find((one) => one.index === before.index);
2459
- return after !== void 0 && noWorse(after, before) ? after : before;
3227
+ return after !== void 0 && noWorse$1(after, before) ? after : before;
2460
3228
  });
2461
3229
  }
2462
3230
  return {
@@ -2491,7 +3259,7 @@ function sourceText(request) {
2491
3259
  */
2492
3260
  function interactiveVideoFromDrafts(input) {
2493
3261
  const { request, drafts, video, durationSeconds, title } = input;
2494
- const ids = idSource(input.newId, "interactiveVideoFromDrafts");
3262
+ const ids = idSource$1(input.newId, "interactiveVideoFromDrafts");
2495
3263
  const types = request.facts.activityTypes;
2496
3264
  const list = "drafts" in drafts ? drafts.drafts : drafts;
2497
3265
  const end = typeof durationSeconds === "number" && Number.isFinite(durationSeconds) && durationSeconds >= 0 ? durationSeconds : void 0;
@@ -2530,8 +3298,7 @@ function interactiveVideoFromDrafts(input) {
2530
3298
  findings: critiqueItemGroupDraft(group)
2531
3299
  };
2532
3300
  }
2533
- //#endregion
2534
- //#region src/ai-written.ts
3301
+ // src/ai-written.ts
2535
3302
  /**
2536
3303
  * What the AI features about a written response share: reading a model's text
2537
3304
  * fields, and finding each correction's quote in the learner's text.
@@ -2603,7 +3370,7 @@ function textField(value, max) {
2603
3370
  value: cleaned === "" ? void 0 : cleaned
2604
3371
  };
2605
3372
  }
2606
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3373
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2607
3374
  /** The corrections, each anchored in the draft, or why the reply is refused. */
2608
3375
  function checkCorrections(raw, text) {
2609
3376
  if (raw === void 0 || raw === null) return {
@@ -2625,7 +3392,7 @@ function checkCorrections(raw, text) {
2625
3392
  const taken = /* @__PURE__ */ new Set();
2626
3393
  const corrections = [];
2627
3394
  for (const entry of raw) {
2628
- if (!isRecord(entry) || typeof entry.original !== "string") return {
3395
+ if (!isRecord$1(entry) || typeof entry.original !== "string") return {
2629
3396
  ok: false,
2630
3397
  refusal: "malformed"
2631
3398
  };
@@ -2647,7 +3414,7 @@ function checkCorrections(raw, text) {
2647
3414
  if (!category.ok) return category;
2648
3415
  let claimed;
2649
3416
  if (entry.range !== void 0 && entry.range !== null) {
2650
- if (!isRecord(entry.range)) return {
3417
+ if (!isRecord$1(entry.range)) return {
2651
3418
  ok: false,
2652
3419
  refusal: "malformed"
2653
3420
  };
@@ -2674,8 +3441,7 @@ function checkCorrections(raw, text) {
2674
3441
  corrections
2675
3442
  };
2676
3443
  }
2677
- //#endregion
2678
- //#region src/ai-grading.ts
3444
+ // src/ai-grading.ts
2679
3445
  /**
2680
3446
  * How far a model's level probabilities may sum from 1 and still be a
2681
3447
  * distribution: rounding in a model's output, not a different judgement.
@@ -2744,7 +3510,7 @@ function aiGradingRequest(input) {
2744
3510
  }
2745
3511
  /** The rubric as a model is given it, each criterion's levels read. */
2746
3512
  function rubricFacts(rubric) {
2747
- const listed = isRecord(rubric) ? rubric.criteria : void 0;
3513
+ const listed = isRecord$1(rubric) ? rubric.criteria : void 0;
2748
3514
  if (!Array.isArray(listed) || listed.length === 0) return {
2749
3515
  ok: false,
2750
3516
  problem: "no-rubric"
@@ -2752,7 +3518,7 @@ function rubricFacts(rubric) {
2752
3518
  const criteria = [];
2753
3519
  const names = /* @__PURE__ */ new Set();
2754
3520
  for (const [index, criterion] of listed.entries()) {
2755
- if (!isRecord(criterion)) return {
3521
+ if (!isRecord$1(criterion)) return {
2756
3522
  ok: false,
2757
3523
  problem: "no-rubric",
2758
3524
  path: [
@@ -2921,7 +3687,7 @@ function judgeCriterion(entry, criterion) {
2921
3687
  * `gradeFromRubric` computes every grade in the SDK.
2922
3688
  */
2923
3689
  function checkAiGrading(raw, request) {
2924
- if (!isRecord(raw)) return {
3690
+ if (!isRecord$1(raw)) return {
2925
3691
  ok: false,
2926
3692
  refusal: "malformed"
2927
3693
  };
@@ -2938,7 +3704,7 @@ function checkAiGrading(raw, request) {
2938
3704
  const indexOf = criterionIndex(rubric);
2939
3705
  const judged = rubric.map(() => void 0);
2940
3706
  for (const entry of listed) {
2941
- if (!isRecord(entry)) return {
3707
+ if (!isRecord$1(entry)) return {
2942
3708
  ok: false,
2943
3709
  refusal: "malformed"
2944
3710
  };
@@ -2999,60 +3765,1551 @@ function checkAiGrading(raw, request) {
2999
3765
  }
3000
3766
  };
3001
3767
  }
3002
- //#endregion
3003
- //#region src/ai-writing.ts
3768
+ // src/ai-variants.ts
3769
+ /** The most exemplars one request may vary: a long section is sent in parts. */
3770
+ const AI_VARIANTS_MAX_EXEMPLARS = 20;
3771
+ /** The most repair passes `generateVariants` makes. */
3772
+ const AI_VARIANTS_MAX_REPAIRS = 3;
3773
+ /** The longest passage or script, in characters, an exemplar may carry into the facts. */
3774
+ const AI_VARIANTS_MAX_MATERIAL_LENGTH = AI_MATERIAL_MAX_LENGTH;
3004
3775
  /**
3005
- * The request for feedback on a draft, or `null` when there is none to give:
3006
- * not a written response, an author who switched explanations off (writing
3007
- * feedback reads `ai.explanations`), a redacted item — feedback is for
3008
- * practice, where the item is whole — or a draft with nothing in it.
3009
- *
3010
- * The facts are the task as the learner saw it, the draft verbatim with its
3011
- * word count recomputed as the grader will count it, and the rubric the answer
3012
- * will be graded on. `previousFeedback` is the text of the feedback already
3013
- * shown on earlier drafts, so a model can speak to what changed. `group` is the
3014
- * item group the task sits in: its material, the text a summary is of, goes
3015
- * with the request as `material`.
3776
+ * Every code a variant can be reported with, and its severity: a contract,
3777
+ * as the critic's codes are. `docs/ai.md` documents this table and a test
3778
+ * holds the two together.
3016
3779
  */
3017
- function aiWritingFeedbackRequest(input) {
3018
- const { data, response, learnerLocale } = input;
3019
- if (!aiSupports(data.type, "writing-feedback") || !aiAllowedByContent(data, "writing-feedback") || data.redacted === true) return null;
3020
- const text = response?.type === "written-response" ? response.text : "";
3021
- if (typeof text !== "string" || text.trim() === "") return null;
3022
- const item = data;
3023
- const wordCount = require_registry.countWords(text);
3024
- const minWords = typeof item.minWords === "number" ? item.minWords : 0;
3025
- const maxWords = typeof item.maxWords === "number" ? item.maxWords : Number.POSITIVE_INFINITY;
3026
- const criteria = Array.isArray(item.rubric?.criteria) ? item.rubric.criteria : null;
3027
- const rubric = criteria === null || criteria.length === 0 ? null : criteria.map((criterion) => ({
3028
- name: criterion.name,
3029
- ...typeof criterion.description === "string" ? { description: criterion.description } : {},
3030
- weight: criterion.weight
3031
- }));
3032
- const previousFeedback = [...input.previousFeedback ?? []];
3033
- const material = materialFacts(input.group);
3034
- return {
3035
- feature: "writing-feedback",
3036
- facts: {
3037
- activityType: "written-response",
3038
- activityId: data.id,
3039
- title: data.title,
3040
- ...typeof data.locale === "string" ? { locale: data.locale } : {},
3041
- prompt: typeof item.prompt === "string" ? item.prompt : "",
3042
- text,
3043
- wordCount,
3044
- minWords,
3045
- maxWords,
3046
- withinWordBounds: wordCount >= minWords && wordCount <= maxWords,
3047
- rubric,
3048
- ...typeof item.languageTarget === "string" ? { languageTarget: item.languageTarget } : {}
3049
- },
3050
- draftNumber: previousFeedback.length + 1,
3051
- previousFeedback,
3052
- ...material !== null ? { material } : {},
3053
- ...learnerLocale !== void 0 ? { learnerLocale } : {}
3054
- };
3055
- }
3780
+ const AI_VARIANT_CODE_SEVERITY = {
3781
+ variant_missing: "invalid",
3782
+ variant_type: "invalid",
3783
+ variant_structure: "invalid",
3784
+ variant_length: "warning",
3785
+ variant_near_copy: "warning",
3786
+ variant_same_key: "warning"
3787
+ };
3788
+ /** The activity types the SDK writes variants of: the ones whose every field it knows. */
3789
+ const VARIANT_TYPES = [
3790
+ "multiple-choice",
3791
+ "fill-in-the-blanks",
3792
+ "gap-select",
3793
+ "dictation",
3794
+ "read-aloud",
3795
+ "written-response"
3796
+ ];
3797
+ const DIFFICULTIES = [
3798
+ "easier",
3799
+ "same",
3800
+ "harder"
3801
+ ];
3802
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3803
+ const listOf = (value) => Array.isArray(value) ? value : [];
3804
+ const stringOf = (value) => typeof value === "string" ? value : "";
3805
+ const written = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
3806
+ const textList = (value) => listOf(value).filter((one) => typeof one === "string");
3807
+ /** A copy of plain content, so a variant never shares an object with its exemplar. */
3808
+ const clone = (value) => JSON.parse(JSON.stringify(value));
3809
+ const isRecording = (media) => isRecord(media) && [
3810
+ "audio",
3811
+ "video",
3812
+ "embed"
3813
+ ].includes(stringOf(media.type));
3814
+ const isImage = (media) => isRecord(media) && media.type === "image";
3815
+ const RULES = [
3816
+ "Write one variant for each exemplar, naming its exemplar index and its type.",
3817
+ "Keep each exemplar's targets.structure exactly: the same number of options and of right options, blanks, gaps, choices and banks, items in the same order, and speakers.",
3818
+ "Write every word a learner reads or hears, in new words: never the exemplar's sentences with a few words changed. Write nothing else: no ids, HTML, addresses or settings.",
3819
+ "Keep each text within its targets.lengths, counted in words.",
3820
+ "Test the same skill with the same kind of thinking at the same level: a literal question stays literal, an inference stays an inference, and wrong options stay as plausible as the exemplar's.",
3821
+ "Give each item a right answer other than its exemplar's, and other than the answers in avoid, unless the skill tested leaves only one.",
3822
+ "Keep the task in instructions as the exemplar's; change its wording only where the new item needs it.",
3823
+ "Keep a title that is a label (Part 1) as it is; give a title that names the topic a new one that gives no answer away.",
3824
+ "Do not repeat the flaws listed in targets.exemplarFindings.",
3825
+ "A listening script keeps the number of speakers, with new names, and about as many turns."
3826
+ ];
3827
+ const DESCRIBE_RULE = "Where an exemplar shows a picture, describe a new picture in its media alt, specific enough for a person to find or draw it, and ask about that picture.";
3828
+ const REUSE_RULE = "Where an exemplar shows a picture, keep it: write new questions about the same picture, which its alt describes.";
3829
+ const DIFFICULTY_RULE = "Where targets.difficulty is easier or harder, move one step, inside the level, using targets.levers. Never write for another level.";
3830
+ const LEVEL_FACTS_RULE = "Use only grammar and vocabulary that levelFacts lists as taught, and keep away from the topics it lists.";
3831
+ /** The ways a type is made easier or harder: one step inside a level. */
3832
+ const LEVERS = {
3833
+ "multiple-choice": {
3834
+ easier: [
3835
+ "Make the wrong options clearly wrong to a learner who understood, though still plausible to one who did not.",
3836
+ "Ask about what is said directly rather than what is implied.",
3837
+ "Word the right option closer to the words of the text or recording."
3838
+ ],
3839
+ harder: [
3840
+ "Make the wrong options closer to the right one, each wrong for a reason the text or recording makes clear.",
3841
+ "Ask for an inference rather than a fact stated directly.",
3842
+ "Paraphrase the right option rather than repeat the words of the text or recording."
3843
+ ]
3844
+ },
3845
+ "fill-in-the-blanks": {
3846
+ easier: ["Give more context around each blank that points to the answer."],
3847
+ harder: ["Give less context around each blank, so the learner must know the form rather than infer it."]
3848
+ },
3849
+ "gap-select": {
3850
+ easier: ["Give more context around each gap that points to the answer.", "Make the wrong choices clearly wrong in context."],
3851
+ harder: ["Give less context around each gap.", "Make the wrong choices closer to the right one."]
3852
+ },
3853
+ dictation: {
3854
+ easier: ["Use shorter sentences and more frequent words of the level, inside the length band."],
3855
+ harder: ["Use longer sentences and less frequent words of the level, inside the length band.", "Use more connected-speech features: contractions, linking, weak forms."]
3856
+ },
3857
+ "read-aloud": {
3858
+ easier: ["Use shorter sentences and more frequent words of the level, inside the length band."],
3859
+ harder: ["Use longer sentences and less frequent words of the level, inside the length band.", "Use more words with sounds and stress the level finds hard."]
3860
+ },
3861
+ "written-response": {
3862
+ easier: ["Make the task more specific, and supply more of the content to write about."],
3863
+ harder: ["Make the task more open, so the learner supplies more of the content."]
3864
+ },
3865
+ reading: {
3866
+ easier: ["Ask about what the passage says directly rather than what it implies.", "Use more frequent words of the level, and shorter sentences with simpler syntax."],
3867
+ harder: ["Ask for inferences rather than facts stated directly.", "Use less frequent words of the level, and longer sentences."]
3868
+ },
3869
+ listening: {
3870
+ easier: ["Describe a slower pace in the script, and have a speaker repeat the key information.", "Use more frequent words of the level."],
3871
+ harder: ["Describe a natural pace in the script, and give the key information once.", "Use less frequent words of the level."]
3872
+ }
3873
+ };
3874
+ function leversFor(entry, difficulty) {
3875
+ if (difficulty === "same") return;
3876
+ let kind = stringOf(entry.type);
3877
+ if (kind === "item-group") {
3878
+ const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
3879
+ kind = stimulus.kind === "audio" || stimulus.kind === "video" ? "listening" : "reading";
3880
+ }
3881
+ const levers = LEVERS[kind];
3882
+ return levers !== void 0 ? [...levers[difficulty]] : void 0;
3883
+ }
3884
+ /** A passage with its placeholders numbered in order: `{{1}}`, `{{2}}`… — how a variant writes them. */
3885
+ function renumbered(passage) {
3886
+ let position = 0;
3887
+ return passage.replace(require_registry.PLACEHOLDER_RE, () => {
3888
+ position += 1;
3889
+ return `{{${position}}}`;
3890
+ });
3891
+ }
3892
+ const turnsOf = (holder) => listOf(holder.script).flatMap((turn) => isRecord(turn) ? [{
3893
+ speaker: stringOf(turn.speaker),
3894
+ text: stringOf(turn.text)
3895
+ }] : []);
3896
+ const hasScript = (holder) => listOf(holder.script).some(isRecord);
3897
+ /** What a model is shown of a media object: a picture's description, or what a recording says. */
3898
+ function mediaContent(media, ownText, ref) {
3899
+ if (!isRecord(media)) return;
3900
+ if (isImage(media)) return {
3901
+ alt: stringOf(media.alt),
3902
+ ...ref !== void 0 ? { ref } : {}
3903
+ };
3904
+ if (ownText || !isRecording(media)) return;
3905
+ if (hasScript(media)) return { script: turnsOf(media) };
3906
+ return typeof media.transcript === "string" ? { transcript: media.transcript } : void 0;
3907
+ }
3908
+ function feedbackContent(holder) {
3909
+ if (!isRecord(holder.feedback)) return {};
3910
+ const { correct, incorrect } = holder.feedback;
3911
+ return { feedback: {
3912
+ ...typeof correct === "string" ? { correct } : {},
3913
+ ...typeof incorrect === "string" ? { incorrect } : {}
3914
+ } };
3915
+ }
3916
+ function itemContent(item, refOf, at) {
3917
+ const type = stringOf(item.type);
3918
+ const ownText = type === "dictation" || type === "read-aloud";
3919
+ const media = mediaContent(item.media, ownText, refOf([...at, "media"]));
3920
+ const content = {
3921
+ type,
3922
+ title: stringOf(item.title),
3923
+ ...typeof item.instructions === "string" ? { instructions: item.instructions } : {}
3924
+ };
3925
+ switch (type) {
3926
+ case "multiple-choice":
3927
+ Object.assign(content, {
3928
+ question: stringOf(item.question),
3929
+ options: listOf(item.options).map((option, index) => {
3930
+ const fields = isRecord(option) ? option : {};
3931
+ const optionMedia = mediaContent(fields.media, false, refOf([
3932
+ ...at,
3933
+ "options",
3934
+ index,
3935
+ "media"
3936
+ ]));
3937
+ return {
3938
+ text: stringOf(fields.text),
3939
+ isCorrect: fields.isCorrect === true,
3940
+ ...typeof fields.feedback === "string" ? { feedback: fields.feedback } : {},
3941
+ ...optionMedia !== void 0 ? { media: optionMedia } : {}
3942
+ };
3943
+ })
3944
+ });
3945
+ break;
3946
+ case "fill-in-the-blanks":
3947
+ Object.assign(content, {
3948
+ passage: renumbered(stringOf(item.passage)),
3949
+ blanks: inPassageOrder(item, "blanks").map(({ value }) => {
3950
+ const blank = isRecord(value) ? value : {};
3951
+ return {
3952
+ acceptedAnswers: textList(blank.acceptedAnswers),
3953
+ ...typeof blank.hint === "string" ? { hint: blank.hint } : {},
3954
+ ...typeof blank.feedback === "string" ? { feedback: blank.feedback } : {}
3955
+ };
3956
+ })
3957
+ });
3958
+ break;
3959
+ case "gap-select": {
3960
+ const banks = listOf(item.banks).filter(isRecord);
3961
+ Object.assign(content, {
3962
+ passage: renumbered(stringOf(item.passage)),
3963
+ gaps: inPassageOrder(item, "gaps").map(({ value }) => {
3964
+ const gap = isRecord(value) ? value : {};
3965
+ const feedback = typeof gap.feedback === "string" ? { feedback: gap.feedback } : {};
3966
+ const bank = banks.find((one) => one.id === gap.bankId);
3967
+ if (listOf(gap.choices).length === 0 && bank !== void 0) {
3968
+ const right = listOf(bank.choices).find((choice) => isRecord(choice) && choice.id === gap.correctChoiceId);
3969
+ return {
3970
+ answer: isRecord(right) ? stringOf(right.text) : "",
3971
+ ...feedback
3972
+ };
3973
+ }
3974
+ return {
3975
+ choices: listOf(gap.choices).map((choice) => ({
3976
+ text: isRecord(choice) ? stringOf(choice.text) : "",
3977
+ isCorrect: isRecord(choice) && choice.id === gap.correctChoiceId
3978
+ })),
3979
+ ...feedback
3980
+ };
3981
+ }),
3982
+ ...banks.length > 0 ? { banks: banks.map((bank) => ({ choices: listOf(bank.choices).map((choice) => isRecord(choice) ? stringOf(choice.text) : "") })) } : {}
3983
+ });
3984
+ break;
3985
+ }
3986
+ case "dictation":
3987
+ Object.assign(content, {
3988
+ transcript: stringOf(item.transcript),
3989
+ ...Array.isArray(item.acceptedTranscripts) ? { acceptedTranscripts: textList(item.acceptedTranscripts) } : {}
3990
+ });
3991
+ break;
3992
+ case "read-aloud":
3993
+ content.referenceText = stringOf(item.referenceText);
3994
+ break;
3995
+ default: content.prompt = stringOf(item.prompt);
3996
+ }
3997
+ return {
3998
+ ...content,
3999
+ ...media !== void 0 ? { media } : {},
4000
+ ...feedbackContent(item)
4001
+ };
4002
+ }
4003
+ function entryContent(entry, refOf) {
4004
+ if (entry.type !== "item-group") return itemContent(entry, refOf, []);
4005
+ const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
4006
+ const ownText = hasScript(stimulus) || typeof stimulus.transcript === "string";
4007
+ const media = mediaContent(stimulus.media, ownText, refOf(["stimulus", "media"]));
4008
+ return {
4009
+ type: "item-group",
4010
+ ...typeof entry.title === "string" ? { title: entry.title } : {},
4011
+ stimulus: {
4012
+ kind: stringOf(stimulus.kind),
4013
+ ...typeof stimulus.title === "string" ? { title: stimulus.title } : {},
4014
+ ...typeof stimulus.body === "string" ? { body: stimulus.body } : {},
4015
+ ...hasScript(stimulus) ? { script: turnsOf(stimulus) } : typeof stimulus.transcript === "string" ? { transcript: stimulus.transcript } : {},
4016
+ ...media ?? {}
4017
+ },
4018
+ items: listOf(entry.items).map((item, index) => isRecord(item) ? itemContent(item, refOf, ["items", index]) : {})
4019
+ };
4020
+ }
4021
+ /** Every picture an entry shows: an item's, an option's, and a group's material's. */
4022
+ function imageSlots(entry) {
4023
+ const ofItem = (item, at) => {
4024
+ if (!isRecord(item)) return [];
4025
+ return [...isImage(item.media) ? [{
4026
+ path: [...at, "media"],
4027
+ media: item.media
4028
+ }] : [], ...listOf(item.options).flatMap((option, index) => isRecord(option) && isImage(option.media) ? [{
4029
+ path: [
4030
+ ...at,
4031
+ "options",
4032
+ String(index),
4033
+ "media"
4034
+ ],
4035
+ media: option.media
4036
+ }] : [])];
4037
+ };
4038
+ if (entry.type !== "item-group") return ofItem(entry, []);
4039
+ const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
4040
+ return [...isImage(stimulus.media) ? [{
4041
+ path: ["stimulus", "media"],
4042
+ media: stimulus.media
4043
+ }] : [], ...listOf(entry.items).flatMap((item, index) => ofItem(item, ["items", String(index)]))];
4044
+ }
4045
+ /** Whether a recording the item's meaning depends on says nothing the facts can carry. */
4046
+ function unheard(entry) {
4047
+ const itemUnheard = (item) => {
4048
+ if (!isRecord(item) || item.type === "dictation" || item.type === "read-aloud") return false;
4049
+ return isRecording(item.media) && transcriptOf(item.media) === null || listOf(item.options).some((option) => isRecord(option) && isRecording(option.media) && transcriptOf(option.media) === null);
4050
+ };
4051
+ if (entry.type !== "item-group") return itemUnheard(entry);
4052
+ const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
4053
+ return (stimulus.kind === "audio" || stimulus.kind === "video" || isRecording(stimulus.media)) && transcriptOf(stimulus) === null || listOf(entry.items).some(itemUnheard);
4054
+ }
4055
+ function skipOf(entry, exemplarIndex, imagePolicy) {
4056
+ const skip = (code, message) => ({
4057
+ exemplarIndex,
4058
+ code,
4059
+ message
4060
+ });
4061
+ const group = entry.type === "item-group";
4062
+ if (group && entry.timeline !== void 0 && entry.timeline !== null) return skip("variant_exemplar_timeline", "An interactive video is not varied: its questions are timed to its video.");
4063
+ const unsupported = (group ? listOf(entry.items).map((item) => isRecord(item) ? stringOf(item.type) : "") : [stringOf(entry.type)]).find((type) => require_registry.getActivityTypeDescriptor(type) !== void 0 && !VARIANT_TYPES.includes(type));
4064
+ if (unsupported !== void 0) return skip("variant_exemplar_unsupported", `The SDK does not write variants of ${unsupported}: it does not know which of its fields are words and which are settings.`);
4065
+ if (imagePolicy === "reuse" && imageSlots(entry).some((slot) => written(slot.media.alt) === void 0)) return skip("variant_exemplar_unseen", "A picture this depends on has no description (alt), so a model cannot write questions about it.");
4066
+ const validation = group ? validateItemGroupDraft(entry) : validateDraft(entry.type, entry);
4067
+ if (validation.status !== "complete") {
4068
+ const first = validation.issues[0];
4069
+ return skip("variant_exemplar_incomplete", `This is not a finished item (${first !== void 0 ? `${first.code} at ${first.path.join(".") || "its root"}` : validation.status}), so it has no structure to copy.`);
4070
+ }
4071
+ if (unheard(entry)) return skip("variant_exemplar_unheard", "A recording this depends on has no transcript or script, so a model cannot know what it says.");
4072
+ const long = textsOf(entry).find((text) => text.text.length > AI_VARIANTS_MAX_MATERIAL_LENGTH);
4073
+ if (long !== void 0) return skip("variant_exemplar_too_long", `${long.path.join(".")} is longer than ${AI_VARIANTS_MAX_MATERIAL_LENGTH} characters. Send a shorter part.`);
4074
+ }
4075
+ const text = (description) => ({
4076
+ type: "string",
4077
+ description
4078
+ });
4079
+ const SCRIPT_SHAPE = {
4080
+ type: "array",
4081
+ minItems: 1,
4082
+ description: "The recording as turns, in order: a new name for each speaker, as many speakers as the exemplar.",
4083
+ items: {
4084
+ type: "object",
4085
+ required: ["speaker", "text"],
4086
+ properties: {
4087
+ speaker: text("Who speaks."),
4088
+ text: text("What they say.")
4089
+ }
4090
+ }
4091
+ };
4092
+ const MEDIA_SHAPE = {
4093
+ type: "object",
4094
+ description: "Only where the exemplar's content has media: for a picture, its alt; for a recording, its script — or transcript where the exemplar has no script.",
4095
+ properties: {
4096
+ alt: text("What the picture shows."),
4097
+ transcript: text("What the recording says."),
4098
+ script: SCRIPT_SHAPE
4099
+ }
4100
+ };
4101
+ const FEEDBACK_SHAPE = {
4102
+ type: "object",
4103
+ description: "Only where the exemplar has feedback: shown after answering.",
4104
+ properties: {
4105
+ correct: text("Shown when the learner passes."),
4106
+ incorrect: text("Shown when the learner does not.")
4107
+ }
4108
+ };
4109
+ const FIELD_SHAPES = {
4110
+ title: text("A short name for the item, shown above it. Never the answer, or any part of it."),
4111
+ instructions: text("Only where the exemplar has instructions: the same task, worded for the new item."),
4112
+ question: text("The question, as plain text."),
4113
+ options: {
4114
+ type: "array",
4115
+ description: "As many as the exemplar's, as many of them right.",
4116
+ items: {
4117
+ type: "object",
4118
+ required: ["text", "isCorrect"],
4119
+ properties: {
4120
+ text: text("The option as the learner reads it."),
4121
+ isCorrect: { type: "boolean" },
4122
+ feedback: text("Only where the exemplar's options have feedback."),
4123
+ media: MEDIA_SHAPE
4124
+ }
4125
+ }
4126
+ },
4127
+ passage: text("The passage as plain text, with {{1}}, {{2}}, … where each gap goes, in order."),
4128
+ blanks: {
4129
+ type: "array",
4130
+ description: "One per gap, in the order the gaps appear: the first is {{1}}.",
4131
+ items: {
4132
+ type: "object",
4133
+ required: ["acceptedAnswers"],
4134
+ properties: {
4135
+ acceptedAnswers: {
4136
+ type: "array",
4137
+ minItems: 1,
4138
+ items: { type: "string" },
4139
+ description: "Every answer that is right, spelled as a learner would type it."
4140
+ },
4141
+ hint: text("Only where the exemplar's blank has a hint. Never gives the answer away."),
4142
+ feedback: text("Only where the exemplar's blank has feedback.")
4143
+ }
4144
+ }
4145
+ },
4146
+ gaps: {
4147
+ type: "array",
4148
+ description: "One per gap, in the order the gaps appear. A gap with its own choices writes `choices`, exactly one right; a gap drawing on a bank writes `answer`, the text of its right choice in that bank.",
4149
+ items: {
4150
+ type: "object",
4151
+ properties: {
4152
+ choices: {
4153
+ type: "array",
4154
+ items: {
4155
+ type: "object",
4156
+ required: ["text", "isCorrect"],
4157
+ properties: {
4158
+ text: text("A choice."),
4159
+ isCorrect: { type: "boolean" }
4160
+ }
4161
+ }
4162
+ },
4163
+ answer: text("The right choice's text, exactly as its bank writes it."),
4164
+ feedback: text("Only where the exemplar's gap has feedback.")
4165
+ }
4166
+ }
4167
+ },
4168
+ banks: {
4169
+ type: "array",
4170
+ description: "The shared choice lists, as many as the exemplar's, each with as many choices.",
4171
+ items: {
4172
+ type: "object",
4173
+ required: ["choices"],
4174
+ properties: { choices: {
4175
+ type: "array",
4176
+ items: { type: "string" }
4177
+ } }
4178
+ }
4179
+ },
4180
+ transcript: text("What the learner hears and types."),
4181
+ acceptedTranscripts: {
4182
+ type: "array",
4183
+ items: { type: "string" },
4184
+ description: "Only where the exemplar has them: other writings of the transcript that are right, of the same kind."
4185
+ },
4186
+ referenceText: text("What the learner reads aloud."),
4187
+ prompt: text("The writing task, of the same genre and purpose as the exemplar's, about something new."),
4188
+ media: MEDIA_SHAPE,
4189
+ feedback: FEEDBACK_SHAPE
4190
+ };
4191
+ const WRITES = {
4192
+ "multiple-choice": [
4193
+ "title",
4194
+ "instructions",
4195
+ "question",
4196
+ "options",
4197
+ "media",
4198
+ "feedback"
4199
+ ],
4200
+ "fill-in-the-blanks": [
4201
+ "title",
4202
+ "instructions",
4203
+ "passage",
4204
+ "blanks",
4205
+ "media",
4206
+ "feedback"
4207
+ ],
4208
+ "gap-select": [
4209
+ "title",
4210
+ "instructions",
4211
+ "passage",
4212
+ "gaps",
4213
+ "banks",
4214
+ "media",
4215
+ "feedback"
4216
+ ],
4217
+ dictation: [
4218
+ "title",
4219
+ "transcript",
4220
+ "acceptedTranscripts",
4221
+ "feedback"
4222
+ ],
4223
+ "read-aloud": [
4224
+ "title",
4225
+ "instructions",
4226
+ "referenceText",
4227
+ "feedback"
4228
+ ],
4229
+ "written-response": [
4230
+ "title",
4231
+ "prompt",
4232
+ "media",
4233
+ "feedback"
4234
+ ]
4235
+ };
4236
+ function itemProperties(types) {
4237
+ const properties = {};
4238
+ for (const type of types) for (const name of WRITES[type] ?? []) properties[name] = FIELD_SHAPES[name];
4239
+ return properties;
4240
+ }
4241
+ /** The types an entry holds, a group's items included. */
4242
+ const typesIn = (entry) => entry.type === "item-group" ? listOf(entry.items).map((item) => isRecord(item) ? stringOf(item.type) : "") : [stringOf(entry.type)];
4243
+ function replyShape(entries, exactly) {
4244
+ const top = [...new Set(entries.map((entry) => stringOf(entry.type)))];
4245
+ const itemTypes = [...new Set(entries.filter((entry) => entry.type === "item-group").flatMap(typesIn))].filter((type) => VARIANT_TYPES.includes(type));
4246
+ const properties = {
4247
+ exemplar: {
4248
+ type: "integer",
4249
+ minimum: 0,
4250
+ description: "The exemplar's index, as the facts give it."
4251
+ },
4252
+ type: {
4253
+ type: "string",
4254
+ enum: top,
4255
+ description: "The exemplar's type."
4256
+ },
4257
+ ...itemProperties(top.filter((type) => type !== "item-group"))
4258
+ };
4259
+ if (top.includes("item-group")) {
4260
+ properties.title = FIELD_SHAPES.title;
4261
+ properties.stimulus = {
4262
+ type: "object",
4263
+ description: "The new material: a passage, or what a recording says.",
4264
+ properties: {
4265
+ title: text("Only where the exemplar's material has a title."),
4266
+ body: text("The passage, as plain text."),
4267
+ transcript: text("What the recording says, where the exemplar has no script."),
4268
+ script: SCRIPT_SHAPE,
4269
+ alt: text("For a picture: what it shows.")
4270
+ }
4271
+ };
4272
+ properties.items = {
4273
+ type: "array",
4274
+ description: "The questions about the new material: the exemplar's types, in its order.",
4275
+ items: {
4276
+ type: "object",
4277
+ required: ["type"],
4278
+ properties: {
4279
+ type: {
4280
+ type: "string",
4281
+ enum: itemTypes
4282
+ },
4283
+ ...itemProperties(itemTypes)
4284
+ }
4285
+ }
4286
+ };
4287
+ }
4288
+ return {
4289
+ type: "object",
4290
+ required: ["variants"],
4291
+ properties: { variants: {
4292
+ type: "array",
4293
+ minItems: exactly,
4294
+ maxItems: exactly,
4295
+ items: {
4296
+ type: "object",
4297
+ required: ["exemplar", "type"],
4298
+ properties
4299
+ }
4300
+ } }
4301
+ };
4302
+ }
4303
+ const facing = (path) => path.join(".").replace(/^stimulus\.media\./, "stimulus.");
4304
+ function targetsOf(entry, difficulty, reason, itemDifficulty, tolerance) {
4305
+ const group = entry.type === "item-group";
4306
+ const levers = leversFor(entry, difficulty);
4307
+ const findings = group ? critiqueItemGroupDraft(entry) : critiqueDraft(entry.type, entry);
4308
+ return {
4309
+ type: stringOf(entry.type),
4310
+ structure: structureTargets(entry),
4311
+ lengths: Object.fromEntries(lengthTargets(entry, tolerance).map((target) => [facing(target.path), target.bounds])),
4312
+ difficulty,
4313
+ ...difficulty !== "same" && reason !== void 0 ? { difficultyReason: reason } : {},
4314
+ ...levers !== void 0 ? { levers } : {},
4315
+ ...itemDifficulty !== void 0 ? { itemDifficulty } : {},
4316
+ exemplarFindings: findings.map((found) => ({
4317
+ code: found.code,
4318
+ message: found.message
4319
+ }))
4320
+ };
4321
+ }
4322
+ const idOf = (value) => isRecord(value) && typeof value.id === "string" ? value.id : void 0;
4323
+ /**
4324
+ * What a model is given to write a variant of each exemplar — a parallel item:
4325
+ * the exemplar's type, structure, scoring and settings, with every word a
4326
+ * learner reads or hears new — and what `checkAiVariants` needs to check the
4327
+ * reply against.
4328
+ *
4329
+ * `exemplars` are loose activities and item groups as stored, answer keys and
4330
+ * author-only transcripts included, in paper order; one variant is written for
4331
+ * each that can be varied. An exemplar that cannot is left out of the facts
4332
+ * and listed in `skipped`: a recording with nothing to say what it says
4333
+ * (`variant_exemplar_unheard`), a picture with no description to reuse
4334
+ * (`variant_exemplar_unseen`), an unfinished item
4335
+ * (`variant_exemplar_incomplete`), an interactive video
4336
+ * (`variant_exemplar_timeline`), a type the SDK does not vary
4337
+ * (`variant_exemplar_unsupported`) or material over
4338
+ * {@link AI_VARIANTS_MAX_MATERIAL_LENGTH} characters
4339
+ * (`variant_exemplar_too_long`). When every exemplar is skipped the request has
4340
+ * none, and `generateVariants` makes no call.
4341
+ *
4342
+ * The facts give each exemplar's content in the shape a variant is written in,
4343
+ * and its `targets`: the structure to keep, the length of each text in words,
4344
+ * its difficulty with the levers to move it, and the critic's findings on it
4345
+ * not to repeat. `rules` are what every variant must respect, as sentences a
4346
+ * prompt can quote. Pictures are listed in `images`, for a host that attaches
4347
+ * them; no recording's address reaches the facts. `avoid` is the other forms'
4348
+ * items: compared with every variant, never varied.
4349
+ *
4350
+ * Nothing passed is changed.
4351
+ *
4352
+ * @throws RangeError when `exemplars` is empty or longer than
4353
+ * {@link AI_VARIANTS_MAX_EXEMPLARS}, an exemplar is neither an item group nor
4354
+ * a registered activity type, `level` is empty, a difficulty is not `easier`,
4355
+ * `same` or `harder`, `images` is neither `describe` nor `reuse`, or
4356
+ * `difficultyFor` names an id no exemplar has.
4357
+ */
4358
+ function aiVariantsRequest(input) {
4359
+ const { exemplars, level, locale, levelFacts, avoid, instructions, settings } = input;
4360
+ const difficulty = input.difficulty ?? "same";
4361
+ const imagePolicy = input.images ?? "describe";
4362
+ if (!Array.isArray(exemplars) || exemplars.length === 0 || exemplars.length > 20) throw new RangeError(`aiVariantsRequest: exemplars must hold 1 to 20 entries; send a longer section in parts.`);
4363
+ if (typeof level !== "string" || level.trim() === "") throw new RangeError("aiVariantsRequest: level is required: a variant must stay inside it.");
4364
+ if (!DIFFICULTIES.includes(difficulty)) throw new RangeError(`aiVariantsRequest: difficulty must be easier, same or harder, not ${String(difficulty)}.`);
4365
+ if (imagePolicy !== "describe" && imagePolicy !== "reuse") throw new RangeError(`aiVariantsRequest: images must be describe or reuse, not ${String(imagePolicy)}.`);
4366
+ exemplars.forEach((entry, index) => {
4367
+ const type = isRecord(entry) ? entry.type : void 0;
4368
+ if (typeof type !== "string" || type !== "item-group" && require_registry.getActivityTypeDescriptor(type) === void 0) throw new RangeError(`aiVariantsRequest: exemplar ${index} is neither an item group nor a registered activity type.`);
4369
+ });
4370
+ const entries = exemplars;
4371
+ const ids = new Set(entries.flatMap((entry) => [idOf(entry), ...listOf(entry.items).map(idOf)]).filter((id) => id !== void 0));
4372
+ for (const [id, wanted] of Object.entries(input.difficultyFor ?? {})) {
4373
+ if (!ids.has(id)) throw new RangeError(`aiVariantsRequest: difficultyFor names "${id}", which no exemplar has.`);
4374
+ if (!isRecord(wanted) || !DIFFICULTIES.includes(wanted.difficulty)) throw new RangeError(`aiVariantsRequest: difficultyFor["${id}"] must be easier, same or harder.`);
4375
+ }
4376
+ const moved = (id) => id !== void 0 ? input.difficultyFor?.[id] : void 0;
4377
+ const skipped = [];
4378
+ const images = [];
4379
+ const facts = [];
4380
+ entries.forEach((entry, exemplarIndex) => {
4381
+ const skip = skipOf(entry, exemplarIndex, imagePolicy);
4382
+ if (skip !== void 0) {
4383
+ skipped.push(skip);
4384
+ return;
4385
+ }
4386
+ const refs = /* @__PURE__ */ new Map();
4387
+ for (const slot of imageSlots(entry)) {
4388
+ const ref = `img-${images.length + 1}`;
4389
+ refs.set(slot.path.join("."), ref);
4390
+ images.push({
4391
+ ref,
4392
+ exemplarIndex,
4393
+ path: slot.path,
4394
+ ...typeof slot.media.alt === "string" ? { alt: slot.media.alt } : {},
4395
+ url: stringOf(slot.media.url)
4396
+ });
4397
+ }
4398
+ const own = moved(idOf(entry));
4399
+ const entryDifficulty = own?.difficulty ?? difficulty;
4400
+ const items = entry.type === "item-group" ? listOf(entry.items) : [];
4401
+ const perItem = items.map((item, at) => {
4402
+ const wanted = moved(idOf(item));
4403
+ return {
4404
+ item: at,
4405
+ difficulty: wanted?.difficulty ?? entryDifficulty,
4406
+ ...wanted?.reason !== void 0 ? { difficultyReason: wanted.reason } : {}
4407
+ };
4408
+ });
4409
+ const itemDifficulty = perItem.some((one) => one.difficulty !== entryDifficulty || one.difficultyReason !== void 0) ? perItem.map((one) => {
4410
+ const levers = leversFor(items[one.item], one.difficulty);
4411
+ return {
4412
+ ...one,
4413
+ ...levers !== void 0 ? { levers } : {}
4414
+ };
4415
+ }) : void 0;
4416
+ facts.push({
4417
+ exemplar: exemplarIndex,
4418
+ type: stringOf(entry.type),
4419
+ content: entryContent(entry, (path) => refs.get(path.map(String).join("."))),
4420
+ ...Array.isArray(entry.learningObjectives) ? { learningObjectives: textList(entry.learningObjectives) } : {},
4421
+ targets: targetsOf(entry, entryDifficulty, own?.reason, itemDifficulty, input.lengthTolerance)
4422
+ });
4423
+ });
4424
+ const rules = [
4425
+ ...RULES,
4426
+ ...images.length > 0 ? [imagePolicy === "describe" ? DESCRIBE_RULE : REUSE_RULE] : [],
4427
+ ...facts.some((one) => one.targets.difficulty !== "same" || (one.targets.itemDifficulty ?? []).some((item) => item.difficulty !== "same")) ? [DIFFICULTY_RULE] : [],
4428
+ ...levelFacts !== void 0 ? [LEVEL_FACTS_RULE] : []
4429
+ ];
4430
+ const varied = facts.map((one) => entries[one.exemplar]);
4431
+ return {
4432
+ feature: "variant-generation",
4433
+ facts: {
4434
+ exemplars: facts,
4435
+ level,
4436
+ ...locale !== void 0 ? { locale } : typeof entries[0]?.locale === "string" ? { locale: entries[0].locale } : {},
4437
+ difficulty,
4438
+ ...levelFacts !== void 0 ? { levelFacts: clone(levelFacts) } : {},
4439
+ imagePolicy,
4440
+ ...images.length > 0 ? { images } : {},
4441
+ ...avoid !== void 0 && avoid.length > 0 ? { avoid: avoid.map((entry) => ({
4442
+ texts: textsOf(entry).map((one) => one.text),
4443
+ keys: (isRecord(entry) && entry.type === "item-group" ? listOf(entry.items) : [entry]).flatMap((item) => keysOf(item).map((slot) => slot.shown))
4444
+ })) } : {},
4445
+ ...instructions !== void 0 ? { instructions } : {},
4446
+ rules
4447
+ },
4448
+ shape: replyShape(varied, varied.length),
4449
+ exemplars,
4450
+ ...avoid !== void 0 ? { avoid } : {},
4451
+ ...settings !== void 0 ? { settings } : {},
4452
+ ...input.lengthTolerance !== void 0 ? { lengthTolerance: { ...input.lengthTolerance } } : {},
4453
+ ...input.balanceKeyPositions !== void 0 ? { balanceKeyPositions: { seed: input.balanceKeyPositions.seed } } : {},
4454
+ skipped
4455
+ };
4456
+ }
4457
+ /** Fields every activity copies from its exemplar: what scores it, and what it tests. */
4458
+ const COPIED_COMMON = [
4459
+ "scoringStrategy",
4460
+ "shuffle",
4461
+ "passThreshold",
4462
+ "locale",
4463
+ "ai",
4464
+ "learningObjectives",
4465
+ "slotKey"
4466
+ ];
4467
+ const COPIED = {
4468
+ "multiple-choice": ["mode"],
4469
+ "fill-in-the-blanks": [],
4470
+ "gap-select": ["presentation", "shuffleChoices"],
4471
+ dictation: ["tolerance", "hints"],
4472
+ "read-aloud": ["recording", "scoring"],
4473
+ "written-response": [
4474
+ "rubric",
4475
+ "minWords",
4476
+ "maxWords",
4477
+ "languageTarget"
4478
+ ]
4479
+ };
4480
+ function pick(from, names) {
4481
+ const out = {};
4482
+ for (const name of names) if (from[name] !== void 0) out[name] = clone(from[name]);
4483
+ return out;
4484
+ }
4485
+ /** The author's 1–5 difficulty, one step along with the variant's, inside the scale. */
4486
+ function steppedLevel(level, difficulty) {
4487
+ if (typeof level !== "number") return {};
4488
+ return { difficultyLevel: Math.min(5, Math.max(1, level + (difficulty === "easier" ? -1 : difficulty === "harder" ? 1 : 0))) };
4489
+ }
4490
+ const scriptFrom = (value) => listOf(value).flatMap((turn) => isRecord(turn) ? [{
4491
+ speaker: stringOf(turn.speaker),
4492
+ text: stringOf(turn.text)
4493
+ }] : []);
4494
+ /**
4495
+ * A variant's media in place of the exemplar's: a picture described anew, or
4496
+ * kept with `reuse`; a recording with no file yet — the host records it — its
4497
+ * playback policy, and the words the model wrote for it. An address, captions,
4498
+ * tracks and a poster belong to the old recording and are not copied.
4499
+ */
4500
+ function variantMedia(exemplar, wrote, ownText, imagePolicy) {
4501
+ const model = isRecord(wrote) ? wrote : {};
4502
+ if (isImage(exemplar)) return imagePolicy === "reuse" ? pick(exemplar, [
4503
+ "type",
4504
+ "url",
4505
+ "alt"
4506
+ ]) : {
4507
+ type: "image",
4508
+ url: null,
4509
+ alt: stringOf(model.alt)
4510
+ };
4511
+ const media = {
4512
+ type: exemplar.type,
4513
+ url: null,
4514
+ ...pick(exemplar, ["playback"])
4515
+ };
4516
+ if (!ownText) {
4517
+ if (hasScript(exemplar)) media.script = scriptFrom(model.script);
4518
+ else if (typeof exemplar.transcript === "string") media.transcript = stringOf(model.transcript);
4519
+ }
4520
+ return media;
4521
+ }
4522
+ /** The feedback the model wrote, on the keys the exemplar's feedback has. */
4523
+ function feedbackFrom(exemplar, wrote) {
4524
+ if (!isRecord(exemplar.feedback) || !isRecord(wrote.feedback)) return {};
4525
+ const out = {};
4526
+ for (const name of ["correct", "incorrect"]) {
4527
+ const said = written(wrote.feedback[name]);
4528
+ if (exemplar.feedback[name] !== void 0 && said !== void 0) out[name] = said;
4529
+ }
4530
+ return Object.keys(out).length > 0 ? { feedback: out } : {};
4531
+ }
4532
+ function difficultyFor(request, exemplarIndex, item) {
4533
+ const targets = request.facts.exemplars.find((one) => one.exemplar === exemplarIndex)?.targets;
4534
+ return (item !== void 0 ? targets?.itemDifficulty?.find((one) => one.item === item) : void 0)?.difficulty ?? targets?.difficulty ?? request.facts.difficulty;
4535
+ }
4536
+ function buildActivity(exemplar, wrote, type, build, difficulty, id) {
4537
+ const { request, ids } = build;
4538
+ const imagePolicy = request.facts.imagePolicy;
4539
+ const alike = exemplar.type === type;
4540
+ const content = { title: stringOf(wrote.title) };
4541
+ if (typeof exemplar.instructions === "string") content.instructions = written(wrote.instructions) ?? exemplar.instructions;
4542
+ switch (type) {
4543
+ case "multiple-choice": {
4544
+ const before = listOf(exemplar.options);
4545
+ const withFeedback = before.some((option) => isRecord(option) && option.feedback !== void 0);
4546
+ content.question = stringOf(wrote.question);
4547
+ content.options = listOf(wrote.options).map((option, index) => {
4548
+ const fields = isRecord(option) ? option : {};
4549
+ const was = before[index];
4550
+ const feedback = withFeedback ? written(fields.feedback) : void 0;
4551
+ return {
4552
+ id: ids(),
4553
+ text: stringOf(fields.text),
4554
+ isCorrect: fields.isCorrect === true,
4555
+ ...feedback !== void 0 ? { feedback } : {},
4556
+ ...isRecord(was) && isRecord(was.media) ? { media: variantMedia(was.media, fields.media, false, imagePolicy) } : {}
4557
+ };
4558
+ });
4559
+ break;
4560
+ }
4561
+ case "fill-in-the-blanks": {
4562
+ const before = alike ? inPassageOrder(exemplar, "blanks").map((one) => one.value) : [];
4563
+ content.passage = stringOf(wrote.passage);
4564
+ content.blanks = listOf(wrote.blanks).map((blank, index) => {
4565
+ const fields = isRecord(blank) ? blank : {};
4566
+ const was = isRecord(before[index]) ? before[index] : {};
4567
+ const hint = was.hint !== void 0 ? written(fields.hint) : void 0;
4568
+ const feedback = was.feedback !== void 0 ? written(fields.feedback) : void 0;
4569
+ return {
4570
+ id: String(index + 1),
4571
+ acceptedAnswers: textList(fields.acceptedAnswers),
4572
+ ...hint !== void 0 ? { hint } : {},
4573
+ ...feedback !== void 0 ? { feedback } : {},
4574
+ ...pick(was, [
4575
+ "match",
4576
+ "caseSensitive",
4577
+ "trimWhitespace"
4578
+ ])
4579
+ };
4580
+ });
4581
+ break;
4582
+ }
4583
+ case "gap-select": {
4584
+ const beforeGaps = alike ? inPassageOrder(exemplar, "gaps").map((one) => one.value) : [];
4585
+ const beforeBanks = alike ? listOf(exemplar.banks) : [];
4586
+ const banks = beforeBanks.length ? listOf(wrote.banks).map((bank) => ({
4587
+ id: ids(),
4588
+ choices: listOf(isRecord(bank) ? bank.choices : void 0).map((choice) => ({
4589
+ id: ids(),
4590
+ text: typeof choice === "string" ? choice : isRecord(choice) ? stringOf(choice.text) : ""
4591
+ }))
4592
+ })) : [];
4593
+ content.passage = stringOf(wrote.passage);
4594
+ content.gaps = listOf(wrote.gaps).map((gap, index) => {
4595
+ const fields = isRecord(gap) ? gap : {};
4596
+ const was = isRecord(beforeGaps[index]) ? beforeGaps[index] : {};
4597
+ const feedback = was.feedback !== void 0 ? written(fields.feedback) : void 0;
4598
+ const drawsOn = listOf(was.choices).length === 0 ? beforeBanks.findIndex((bank) => isRecord(bank) && bank.id === was.bankId) : -1;
4599
+ const bank = drawsOn !== -1 ? banks[drawsOn] : void 0;
4600
+ if (bank !== void 0) {
4601
+ const answer = stringOf(fields.answer).trim().toLowerCase();
4602
+ const right = bank.choices.find((choice) => choice.text.trim().toLowerCase() === answer);
4603
+ return {
4604
+ id: String(index + 1),
4605
+ bankId: bank.id,
4606
+ correctChoiceId: answer !== "" && right !== void 0 ? right.id : "",
4607
+ ...feedback !== void 0 ? { feedback } : {}
4608
+ };
4609
+ }
4610
+ const marked = listOf(fields.choices);
4611
+ const choices = marked.map((choice) => ({
4612
+ id: ids(),
4613
+ text: isRecord(choice) ? stringOf(choice.text) : ""
4614
+ }));
4615
+ const right = marked.flatMap((choice, at) => isRecord(choice) && choice.isCorrect === true ? [at] : []);
4616
+ return {
4617
+ id: String(index + 1),
4618
+ choices,
4619
+ correctChoiceId: right.length === 1 ? choices[right[0]]?.id : "",
4620
+ ...feedback !== void 0 ? { feedback } : {}
4621
+ };
4622
+ });
4623
+ if (banks.length > 0) content.banks = banks;
4624
+ break;
4625
+ }
4626
+ case "dictation":
4627
+ content.transcript = stringOf(wrote.transcript);
4628
+ if (Array.isArray(exemplar.acceptedTranscripts) && Array.isArray(wrote.acceptedTranscripts)) {
4629
+ const accepted = textList(wrote.acceptedTranscripts).filter((one) => one.trim() !== "");
4630
+ if (accepted.length > 0) content.acceptedTranscripts = accepted;
4631
+ }
4632
+ break;
4633
+ case "read-aloud":
4634
+ content.referenceText = stringOf(wrote.referenceText);
4635
+ break;
4636
+ default: content.prompt = stringOf(wrote.prompt);
4637
+ }
4638
+ const ownText = type === "dictation" || type === "read-aloud";
4639
+ return {
4640
+ ...pick(exemplar, alike ? [...COPIED_COMMON, ...COPIED[type] ?? []] : COPIED_COMMON),
4641
+ ...steppedLevel(exemplar.difficultyLevel, difficulty),
4642
+ ...request.settings?.[type],
4643
+ ...content,
4644
+ ...isRecord(exemplar.media) ? { media: variantMedia(exemplar.media, wrote.media, ownText, imagePolicy) } : {},
4645
+ ...ownText && isRecord(exemplar.slowMedia) ? { slowMedia: {
4646
+ type: "audio",
4647
+ url: null
4648
+ } } : {},
4649
+ ...feedbackFrom(exemplar, wrote),
4650
+ schemaVersion: typeof exemplar.schemaVersion === "string" ? exemplar.schemaVersion : "1.0",
4651
+ type,
4652
+ id
4653
+ };
4654
+ }
4655
+ function buildStimulus(exemplar, wrote, build) {
4656
+ const model = isRecord(wrote) ? wrote : {};
4657
+ const ownText = hasScript(exemplar) || typeof exemplar.transcript === "string";
4658
+ const title = typeof exemplar.title === "string" ? written(model.title) : void 0;
4659
+ return {
4660
+ id: build.ids(),
4661
+ kind: exemplar.kind,
4662
+ ...title !== void 0 ? { title } : {},
4663
+ ...typeof exemplar.body === "string" ? { body: stringOf(model.body) } : {},
4664
+ ...hasScript(exemplar) ? { script: scriptFrom(model.script) } : typeof exemplar.transcript === "string" ? { transcript: stringOf(model.transcript) } : {},
4665
+ ...isRecord(exemplar.media) ? { media: variantMedia(exemplar.media, model, ownText, build.request.facts.imagePolicy) } : {},
4666
+ ...pick(exemplar, ["locale"])
4667
+ };
4668
+ }
4669
+ function buildEntry(exemplar, exemplarIndex, wrote, build, id) {
4670
+ const difficulty = difficultyFor(build.request, exemplarIndex);
4671
+ if (exemplar.type !== "item-group") return buildActivity(exemplar, wrote, stringOf(exemplar.type), build, difficulty, id);
4672
+ const before = listOf(exemplar.items);
4673
+ const title = typeof exemplar.title === "string" ? written(wrote.title) ?? exemplar.title : void 0;
4674
+ return {
4675
+ ...pick(exemplar, ["slotKey", "shuffle"]),
4676
+ ...title !== void 0 ? { title } : {},
4677
+ stimulus: buildStimulus(isRecord(exemplar.stimulus) ? exemplar.stimulus : {}, wrote.stimulus, build),
4678
+ items: listOf(wrote.items).map((item, index) => {
4679
+ const fields = isRecord(item) ? item : {};
4680
+ const was = isRecord(before[index]) ? before[index] : {};
4681
+ return buildActivity(was, fields, VARIANT_TYPES.includes(stringOf(fields.type)) ? stringOf(fields.type) : stringOf(was.type), build, difficultyFor(build.request, exemplarIndex, index), build.ids());
4682
+ }),
4683
+ schemaVersion: typeof exemplar.schemaVersion === "string" ? exemplar.schemaVersion : "1.0",
4684
+ type: "item-group",
4685
+ id
4686
+ };
4687
+ }
4688
+ function variantFinding(code, path, message) {
4689
+ return {
4690
+ path: [...path],
4691
+ message,
4692
+ code,
4693
+ severity: AI_VARIANT_CODE_SEVERITY[code]
4694
+ };
4695
+ }
4696
+ /** The items of an entry with their paths: the entry itself, or a group's items. */
4697
+ const itemsWithPaths = (entry) => isRecord(entry) && entry.type === "item-group" ? listOf(entry.items).map((item, index) => ({
4698
+ path: ["items", String(index)],
4699
+ item
4700
+ })) : [{
4701
+ path: [],
4702
+ item: entry
4703
+ }];
4704
+ /** The other forms' entries in this exemplar's place: the same `slotKey`, or the same position in each form. */
4705
+ function avoidedFor(request, exemplarIndex) {
4706
+ const avoid = request.avoid ?? [];
4707
+ const exemplar = request.exemplars[exemplarIndex];
4708
+ const slotKey = isRecord(exemplar) ? exemplar.slotKey : void 0;
4709
+ const forms = avoid.length % request.exemplars.length === 0;
4710
+ return avoid.flatMap((entry, index) => {
4711
+ const theirs = isRecord(entry) ? entry.slotKey : void 0;
4712
+ return (slotKey !== void 0 && theirs !== void 0 ? slotKey === theirs : slotKey === void 0 && theirs === void 0 && forms && index % request.exemplars.length === exemplarIndex) ? [{
4713
+ entry,
4714
+ label: `avoid[${index}]`
4715
+ }] : [];
4716
+ });
4717
+ }
4718
+ function nearCopies(entry, exemplar, request) {
4719
+ const mine = new Map(textsOf(exemplar).map((one) => [one.path.join("."), one]));
4720
+ const avoided = (request.avoid ?? []).flatMap((other, index) => textsOf(other).map((one) => ({
4721
+ ...one,
4722
+ label: `avoid[${index}]`
4723
+ })));
4724
+ const findings = [];
4725
+ for (const one of textsOf(entry)) {
4726
+ let best;
4727
+ const consider = (score, what) => {
4728
+ if (score >= .5 && (best === void 0 || score > best.score)) best = {
4729
+ score,
4730
+ what
4731
+ };
4732
+ };
4733
+ const theirs = mine.get(one.path.join("."));
4734
+ if (theirs !== void 0) consider(textSimilarity(theirs.text, one.text), `the exemplar's ${theirs.path.join(".")}`);
4735
+ for (const other of avoided) if (other.kind === one.kind) consider(textSimilarity(other.text, one.text), `${other.label} ${other.path.join(".") || "text"}`);
4736
+ if (best !== void 0) findings.push(variantFinding("variant_near_copy", one.path, `This reads like ${best.what} (similarity ${shownScore(best.score)}). Write it in new words.`));
4737
+ }
4738
+ const exemplarItems = new Map(itemsWithPaths(exemplar).map((one) => [one.path.join("."), one.item]));
4739
+ const avoidedOptions = (request.avoid ?? []).flatMap((other, index) => itemsWithPaths(other).map((one) => ({
4740
+ set: optionSet(one.item),
4741
+ label: `avoid[${index}]`
4742
+ })));
4743
+ for (const { path, item } of itemsWithPaths(entry)) {
4744
+ const options = optionSet(item);
4745
+ if (options === null) continue;
4746
+ const sameAs = (other) => other !== null && other.join("\n") === options.join("\n");
4747
+ const source = sameAs(optionSet(exemplarItems.get(path.join(".")))) ? "the exemplar's" : avoidedOptions.find((other) => sameAs(other.set))?.label;
4748
+ if (source !== void 0) findings.push(variantFinding("variant_near_copy", [...path, "options"], `These are ${source === "the exemplar's" ? "the exemplar's options" : `${source}'s options`}, in another order. Write new options.`));
4749
+ }
4750
+ return findings;
4751
+ }
4752
+ function sameKeys(entry, exemplar, exemplarIndex, request) {
4753
+ const against = [{
4754
+ entry: exemplar,
4755
+ label: "the exemplar"
4756
+ }, ...avoidedFor(request, exemplarIndex)];
4757
+ const findings = [];
4758
+ for (const { path, item } of itemsWithPaths(entry)) {
4759
+ const slots = keysOf(item);
4760
+ const reported = /* @__PURE__ */ new Set();
4761
+ for (const other of against) {
4762
+ const theirSlots = keysOf(itemsWithPaths(other.entry).find((one) => one.path.join(".") === path.join("."))?.item);
4763
+ for (const slot of slots) {
4764
+ const match = theirSlots.find((one) => one.slot === slot.slot);
4765
+ const shared = match !== void 0 ? sharedAnswers(slot, match) : [];
4766
+ if (shared.length > 0 && !reported.has(slot.slot)) {
4767
+ reported.add(slot.slot);
4768
+ findings.push(variantFinding("variant_same_key", [...path, ...slot.path], `The right answer "${shared[0]}" is ${other.label === "the exemplar" ? "the exemplar's" : `${other.label}'s`} too: a learner who sat that form scores again by memory.`));
4769
+ }
4770
+ }
4771
+ }
4772
+ }
4773
+ return findings;
4774
+ }
4775
+ /** Whether a recording the exemplar's item has words for came back with none. */
4776
+ function wordlessRecordings(exemplar, entry) {
4777
+ const mismatch = (path, message) => ({
4778
+ path,
4779
+ message,
4780
+ code: "variant_structure",
4781
+ severity: "invalid"
4782
+ });
4783
+ const out = [];
4784
+ const pairsOf = (a, b, at) => {
4785
+ if (!isRecord(a) || !isRecord(b) || a.type === "dictation" || a.type === "read-aloud") return;
4786
+ if (isRecording(a.media) && transcriptOf(a.media) !== null && transcriptOf(b.media) === null) out.push(mismatch([
4787
+ ...at,
4788
+ "media",
4789
+ hasScript(a.media) ? "script" : "transcript"
4790
+ ], "The recording has no words to record: write what it says."));
4791
+ };
4792
+ if (exemplar.type === "item-group") {
4793
+ const before = isRecord(exemplar.stimulus) ? exemplar.stimulus : {};
4794
+ const after = isRecord(entry.stimulus) ? entry.stimulus : {};
4795
+ if (transcriptOf(before) !== null && transcriptOf(after) === null) out.push(mismatch(["stimulus", hasScript(before) || isRecord(before.media) && hasScript(before.media) ? "script" : "transcript"], "The recording has no words to record: write what it says."));
4796
+ listOf(exemplar.items).forEach((item, index) => {
4797
+ pairsOf(item, listOf(entry.items)[index], ["items", String(index)]);
4798
+ });
4799
+ } else pairsOf(exemplar, entry, []);
4800
+ return out;
4801
+ }
4802
+ function pairsOf(exemplar, entry) {
4803
+ const top = {
4804
+ exemplarPath: [],
4805
+ variantPath: [],
4806
+ exemplarId: stringOf(exemplar.id),
4807
+ variantId: stringOf(entry.id)
4808
+ };
4809
+ if (exemplar.type !== "item-group") return [top];
4810
+ const theirs = listOf(entry.items);
4811
+ return [top, ...listOf(exemplar.items).flatMap((item, index) => index < theirs.length ? [{
4812
+ exemplarPath: ["items", String(index)],
4813
+ variantPath: ["items", String(index)],
4814
+ exemplarId: idOf(item) ?? "",
4815
+ variantId: idOf(theirs[index]) ?? ""
4816
+ }] : [])];
4817
+ }
4818
+ function evaluate(built, request) {
4819
+ const exemplar = request.exemplars[built.exemplarIndex];
4820
+ const type = stringOf(exemplar.type);
4821
+ const { entry } = built;
4822
+ const group = type === "item-group";
4823
+ const validation = group ? validateItemGroupDraft(entry) : validateDraft(type, entry);
4824
+ const mismatches = built.wroteType !== type ? [{
4825
+ path: ["type"],
4826
+ message: `This was written as ${built.wroteType || "no type"}; the exemplar is ${type}.`,
4827
+ code: "variant_type",
4828
+ severity: "invalid"
4829
+ }] : [...structureMisses(exemplar, entry).map((miss) => ({
4830
+ ...miss,
4831
+ code: "variant_structure",
4832
+ severity: "invalid"
4833
+ })), ...wordlessRecordings(exemplar, entry)];
4834
+ const findings = [
4835
+ ...group ? critiqueItemGroupDraft(entry) : critiqueDraft(type, entry),
4836
+ ...lengthMisses(exemplar, entry, request.lengthTolerance).map((miss) => variantFinding("variant_length", miss.path, `${miss.words} words; the exemplar's has ${miss.exemplarWords}, so ${miss.bounds[0]} to ${miss.bounds[1]}.`)),
4837
+ ...nearCopies(entry, exemplar, request),
4838
+ ...sameKeys(entry, exemplar, built.exemplarIndex, request)
4839
+ ];
4840
+ const status = validation.status === "invalid" || mismatches.length > 0 ? "invalid" : validation.status === "incomplete" ? "incomplete" : "complete";
4841
+ return {
4842
+ exemplarIndex: built.exemplarIndex,
4843
+ type,
4844
+ entry,
4845
+ status,
4846
+ validation,
4847
+ mismatches,
4848
+ findings,
4849
+ pairs: pairsOf(exemplar, entry),
4850
+ generated: built.generated
4851
+ };
4852
+ }
4853
+ /**
4854
+ * Moves each single-answer, unshuffled multiple choice's right option to the
4855
+ * position used least so far across the variants, ties broken by an order
4856
+ * drawn from `seed`: as even as the counts allow, and the same every time.
4857
+ * An option moves with its id, text, feedback and media.
4858
+ */
4859
+ function balanceKeys(built, seed) {
4860
+ const counts = /* @__PURE__ */ new Map();
4861
+ let slot = 0;
4862
+ const visit = (item) => {
4863
+ if (!isRecord(item) || item.type !== "multiple-choice" || item.mode !== "single" || item.shuffle === true) return item;
4864
+ const options = listOf(item.options);
4865
+ const keys = options.flatMap((option, index) => isRecord(option) && option.isCorrect === true ? [index] : []);
4866
+ if (keys.length !== 1) return item;
4867
+ const order = require_scoring.seededShuffle(options.map((_, index) => index), `${seed}:${slot}`, { version: 2 });
4868
+ slot += 1;
4869
+ let target = order[0];
4870
+ for (const position of order) if ((counts.get(position) ?? 0) < (counts.get(target) ?? 0)) target = position;
4871
+ counts.set(target, (counts.get(target) ?? 0) + 1);
4872
+ const key = options[keys[0]];
4873
+ const rest = options.filter((_, index) => index !== keys[0]);
4874
+ return {
4875
+ ...item,
4876
+ options: [
4877
+ ...rest.slice(0, target),
4878
+ key,
4879
+ ...rest.slice(target)
4880
+ ]
4881
+ };
4882
+ };
4883
+ return [...built].sort((a, b) => a.exemplarIndex - b.exemplarIndex).map((one) => ({
4884
+ ...one,
4885
+ entry: one.entry.type === "item-group" ? {
4886
+ ...one.entry,
4887
+ items: listOf(one.entry.items).map(visit)
4888
+ } : visit(one.entry)
4889
+ }));
4890
+ }
4891
+ /** Ids from the host, checked as `createDraft` checks them. */
4892
+ function idSource(newId, caller) {
4893
+ const issued = /* @__PURE__ */ new Set();
4894
+ return () => {
4895
+ const id = newId();
4896
+ if (typeof id !== "string" || id === "") throw new Error(`${caller}: newId() must return a non-empty string.`);
4897
+ if (issued.has(id)) throw new Error(`${caller}: newId() returned "${id}" twice. Ids must differ — two options sharing an id cannot be scored apart.`);
4898
+ issued.add(id);
4899
+ return id;
4900
+ };
4901
+ }
4902
+ function missingOf(exemplarIndex) {
4903
+ return {
4904
+ exemplarIndex,
4905
+ code: "variant_missing",
4906
+ message: `The reply has no variant of exemplar ${exemplarIndex}.`,
4907
+ severity: "invalid"
4908
+ };
4909
+ }
4910
+ function read(raw, request, ids, keep) {
4911
+ if (!isRecord(raw) || !Array.isArray(raw.variants) || !raw.variants.every(isRecord)) return {
4912
+ ok: false,
4913
+ refusal: "malformed"
4914
+ };
4915
+ const asked = request.repair ? request.repair.map((one) => one.exemplar) : request.facts.exemplars.map((one) => one.exemplar);
4916
+ if (raw.variants.length > asked.length) return {
4917
+ ok: false,
4918
+ refusal: "too-long"
4919
+ };
4920
+ const seen = /* @__PURE__ */ new Set();
4921
+ for (const variant of raw.variants) {
4922
+ const index = variant.exemplar;
4923
+ if (typeof index !== "number" || !asked.includes(index) || seen.has(index)) return {
4924
+ ok: false,
4925
+ refusal: "malformed"
4926
+ };
4927
+ seen.add(index);
4928
+ }
4929
+ const build = {
4930
+ request,
4931
+ ids
4932
+ };
4933
+ return {
4934
+ ok: true,
4935
+ built: raw.variants.map((variant) => {
4936
+ const exemplarIndex = variant.exemplar;
4937
+ const exemplar = request.exemplars[exemplarIndex];
4938
+ return {
4939
+ exemplarIndex,
4940
+ wroteType: stringOf(variant.type),
4941
+ entry: buildEntry(exemplar, exemplarIndex, variant, build, keep.get(exemplarIndex) ?? ids()),
4942
+ generated: variant
4943
+ };
4944
+ }).sort((a, b) => a.exemplarIndex - b.exemplarIndex),
4945
+ missing: asked.filter((index) => !seen.has(index)).sort((a, b) => a - b).map(missingOf),
4946
+ raw
4947
+ };
4948
+ }
4949
+ function setFindings(variants) {
4950
+ const entries = variants.map((one) => one.entry);
4951
+ return [...require_registry.keyPositionFinding(entries, []), ...paperFindings(entries)];
4952
+ }
4953
+ /**
4954
+ * Reads a model's reply to {@link aiVariantsRequest} into variants an editor
4955
+ * can show for approval: each made from what the model wrote and what its
4956
+ * exemplar is — type, structure, scoring and settings copied from the
4957
+ * exemplar, whatever the reply says of them — with ids from `newId`, checked
4958
+ * by `validateDraft` (or `validateItemGroupDraft`), compared with its exemplar
4959
+ * (`mismatches`: `variant_type`, `variant_structure`), critiqued, and checked
4960
+ * for length, near copies and repeated keys against the exemplar and every
4961
+ * entry in `avoid` (`findings`: `variant_length`, `variant_near_copy`,
4962
+ * `variant_same_key`). `pairs` line each exemplar item up with its variant's.
4963
+ *
4964
+ * A recording comes back with no file — `url: null`, its playback policy, and
4965
+ * a new script or transcript — and a picture to describe with no file and a
4966
+ * new `alt`: `media_url_required` until the host attaches one.
4967
+ *
4968
+ * Variants are independent: one that is wrong comes back as such, beside the
4969
+ * others. An exemplar the reply wrote nothing for is listed in `missing`. The
4970
+ * whole reply is refused only when it is not `{ variants: [...] }` of objects
4971
+ * each naming an exemplar of the facts once (`malformed`), or holds more
4972
+ * variants than exemplars (`too-long`).
4973
+ *
4974
+ * With `balanceKeyPositions` the single-answer keys are spread across
4975
+ * positions, from its seed.
4976
+ *
4977
+ * @throws Error when `newId` returns an empty or repeated id.
4978
+ */
4979
+ function checkAiVariants(raw, request, options) {
4980
+ const result = read(raw, request, idSource(options.newId, "checkAiVariants"), /* @__PURE__ */ new Map());
4981
+ if (!result.ok) return result;
4982
+ const seed = request.balanceKeyPositions?.seed;
4983
+ const variants = (seed !== void 0 ? balanceKeys(result.built, seed) : result.built).map((one) => evaluate(one, request));
4984
+ const provenance = provenanceOf(result.raw.provenance);
4985
+ const usage = usageOf(result.raw.usage);
4986
+ return {
4987
+ ok: true,
4988
+ variants: {
4989
+ variants,
4990
+ missing: result.missing,
4991
+ findings: setFindings(variants),
4992
+ ...provenance !== void 0 ? { provenance } : {},
4993
+ ...usage !== void 0 ? { usage } : {}
4994
+ }
4995
+ };
4996
+ }
4997
+ /** The fields a model writes, and inside media and material, the ones it writes there. */
4998
+ const WRITTEN_FIELDS = /* @__PURE__ */ new Set([
4999
+ "type",
5000
+ "title",
5001
+ "instructions",
5002
+ "question",
5003
+ "options",
5004
+ "passage",
5005
+ "blanks",
5006
+ "gaps",
5007
+ "banks",
5008
+ "transcript",
5009
+ "acceptedTranscripts",
5010
+ "referenceText",
5011
+ "prompt",
5012
+ "feedback",
5013
+ "media",
5014
+ "stimulus",
5015
+ "items"
5016
+ ]);
5017
+ const WRITTEN_IN_MEDIA = /* @__PURE__ */ new Set([
5018
+ "alt",
5019
+ "transcript",
5020
+ "script"
5021
+ ]);
5022
+ const WRITTEN_IN_STIMULUS = /* @__PURE__ */ new Set([
5023
+ "title",
5024
+ "body",
5025
+ "transcript",
5026
+ "script",
5027
+ "media",
5028
+ "alt"
5029
+ ]);
5030
+ function writable(path) {
5031
+ const rest = path[0] === "items" && path.length > 2 ? path.slice(2) : path;
5032
+ const [first, second, third] = rest;
5033
+ if (first === void 0 || !WRITTEN_FIELDS.has(first)) return false;
5034
+ if (first === "media") return second !== void 0 && WRITTEN_IN_MEDIA.has(second);
5035
+ if (first === "stimulus") {
5036
+ if (second === "media") return third !== void 0 && WRITTEN_IN_MEDIA.has(third);
5037
+ return second !== void 0 && WRITTEN_IN_STIMULUS.has(second);
5038
+ }
5039
+ if (first === "options" && rest[2] === "media") return rest[3] !== void 0 && WRITTEN_IN_MEDIA.has(rest[3]);
5040
+ return true;
5041
+ }
5042
+ /** Where the model wrote what a problem names. */
5043
+ function pathForModel(variant, problem) {
5044
+ const { path, code } = problem;
5045
+ const prefix = path[0] === "items" && path.length > 2 ? path.slice(0, 2) : [];
5046
+ const rest = path.slice(prefix.length);
5047
+ if (code === "ra_text_long_for_time") return [...prefix, "referenceText"];
5048
+ if (rest[0] === "gaps" && rest.length === 3 && rest[2] === "correctChoiceId") {
5049
+ const item = prefix.length > 0 ? listOf(variant.entry.items)[Number(prefix[1])] : variant.entry;
5050
+ const gap = isRecord(item) ? listOf(item.gaps)[Number(rest[1])] : void 0;
5051
+ return [
5052
+ ...prefix,
5053
+ "gaps",
5054
+ rest[1],
5055
+ isRecord(gap) && gap.bankId !== void 0 ? "answer" : "choices"
5056
+ ];
5057
+ }
5058
+ if (rest[0] === "stimulus" && rest[1] === "media" && (rest[2] === "script" || rest[2] === "transcript")) return ["stimulus", rest[2]];
5059
+ return [...path];
5060
+ }
5061
+ function problemsOf(variant) {
5062
+ return [
5063
+ ...variant.mismatches.map((found) => ({
5064
+ path: found.path,
5065
+ code: found.code,
5066
+ message: found.message,
5067
+ severity: "invalid"
5068
+ })),
5069
+ ...variant.validation.issues.filter((found) => found.code !== "media_url_required").map((found) => ({
5070
+ path: found.path,
5071
+ code: found.code,
5072
+ message: found.message,
5073
+ severity: found.severity
5074
+ })),
5075
+ ...variant.findings.filter((found) => found.severity === "warning").map((found) => ({
5076
+ path: found.path,
5077
+ code: found.code,
5078
+ message: found.message,
5079
+ severity: "warning"
5080
+ }))
5081
+ ].map((problem) => ({
5082
+ ...problem,
5083
+ path: pathForModel(variant, problem)
5084
+ })).filter((problem) => writable(problem.path));
5085
+ }
5086
+ /**
5087
+ * A second request, for the variants with something the model can fix —
5088
+ * a wrong type or structure, an issue other than a missing file, or a warning
5089
+ * at a field the model writes — and the exemplars the reply wrote nothing for
5090
+ * (`variant_missing`). Each comes with its problems at the paths the model
5091
+ * wrote; the exemplar and its targets stay in the facts. `null` when there is
5092
+ * nothing to repair.
5093
+ *
5094
+ * The reply holds one variant for each, naming its exemplar;
5095
+ * {@link checkAiVariants} reads it.
5096
+ */
5097
+ function aiVariantsRepairRequest(request, variants) {
5098
+ const list = Array.isArray(variants) ? variants : variants.variants;
5099
+ const missing = Array.isArray(variants) ? [] : variants.missing;
5100
+ const repair = [...list.flatMap((one) => {
5101
+ const problems = problemsOf(one);
5102
+ return problems.length === 0 ? [] : [{
5103
+ exemplar: one.exemplarIndex,
5104
+ variant: one.generated,
5105
+ problems
5106
+ }];
5107
+ }), ...missing.map((one) => ({
5108
+ exemplar: one.exemplarIndex,
5109
+ problems: [{
5110
+ path: [],
5111
+ code: one.code,
5112
+ message: one.message,
5113
+ severity: "invalid"
5114
+ }]
5115
+ }))].sort((a, b) => a.exemplar - b.exemplar);
5116
+ if (repair.length === 0) return null;
5117
+ const { repair: _previous, ...first } = request;
5118
+ return {
5119
+ ...first,
5120
+ shape: replyShape(repair.map((one) => request.exemplars[one.exemplar]), repair.length),
5121
+ repair
5122
+ };
5123
+ }
5124
+ const RANK = {
5125
+ complete: 2,
5126
+ incomplete: 1,
5127
+ invalid: 0
5128
+ };
5129
+ const invalidCount = (one) => one.mismatches.length + one.validation.issues.filter((found) => found.severity === "invalid").length;
5130
+ const warningCount = (one) => one.findings.filter((found) => found.severity === "warning").length;
5131
+ /** Whether a repaired variant is at least as good as the one it would replace. */
5132
+ function noWorse(repaired, before) {
5133
+ const now = RANK[repaired.status];
5134
+ const was = RANK[before.status];
5135
+ if (now !== was) return now > was;
5136
+ if (invalidCount(repaired) !== invalidCount(before)) return invalidCount(repaired) < invalidCount(before);
5137
+ return warningCount(repaired) <= warningCount(before);
5138
+ }
5139
+ /**
5140
+ * Writes variants of existing items, with a bounded repair loop: asks the port
5141
+ * with `request`, reads the reply as {@link checkAiVariants} does, then — up
5142
+ * to `repairs` times — sends back what the model can fix and what it did not
5143
+ * write ({@link aiVariantsRepairRequest}), keeping each variant's best pass:
5144
+ * by status, then fewer invalid problems, then fewer warnings. A repaired
5145
+ * variant keeps its id. With `balanceKeyPositions`, the keys are spread once,
5146
+ * over the variants as they end.
5147
+ *
5148
+ * Never throws for a model's failure: a port that throws or a reply the SDK
5149
+ * refuses ends the loop, and is recorded in `calls` beside the provenance and
5150
+ * usage of every call. A first call that fails leaves no variants. A request
5151
+ * with no exemplar to vary makes no call.
5152
+ *
5153
+ * ```ts
5154
+ * const run = await generateVariants({
5155
+ * request: aiVariantsRequest({ exemplars: section, level: 'CEFR B1', avoid: otherForms }),
5156
+ * port: (request) => callMyModel(VARIANTS_PROMPT, request),
5157
+ * newId: () => crypto.randomUUID(),
5158
+ * });
5159
+ * // run.variants: store each as proposed, for a person to approve.
5160
+ * ```
5161
+ *
5162
+ * @throws RangeError when `repairs` is not a whole number from 0 to
5163
+ * {@link AI_VARIANTS_MAX_REPAIRS} (default 1), and Error when `newId` returns
5164
+ * an empty or repeated id.
5165
+ */
5166
+ async function generateVariants(input) {
5167
+ const { request, port, newId } = input;
5168
+ const repairs = input.repairs ?? 1;
5169
+ if (!Number.isInteger(repairs) || repairs < 0 || repairs > 3) throw new RangeError(`generateVariants: repairs must be a whole number from 0 to 3, not ${String(repairs)}.`);
5170
+ const calls = [];
5171
+ const skipped = request.skipped;
5172
+ if (request.facts.exemplars.length === 0) return {
5173
+ variants: [],
5174
+ missing: [],
5175
+ findings: [],
5176
+ skipped,
5177
+ calls
5178
+ };
5179
+ const ids = idSource(newId, "generateVariants");
5180
+ const ask = async (asked, keep) => {
5181
+ let raw;
5182
+ try {
5183
+ raw = await port(asked);
5184
+ } catch (error) {
5185
+ calls.push({
5186
+ ok: false,
5187
+ error: error instanceof Error ? error.message : String(error)
5188
+ });
5189
+ return;
5190
+ }
5191
+ const result = read(raw, asked, ids, keep);
5192
+ if (!result.ok) {
5193
+ calls.push({
5194
+ ok: false,
5195
+ refusal: result.refusal
5196
+ });
5197
+ return;
5198
+ }
5199
+ const provenance = provenanceOf(result.raw.provenance);
5200
+ const usage = usageOf(result.raw.usage);
5201
+ calls.push({
5202
+ ok: true,
5203
+ ...provenance !== void 0 ? { provenance } : {},
5204
+ ...usage !== void 0 ? { usage } : {}
5205
+ });
5206
+ return {
5207
+ built: result.built,
5208
+ variants: result.built.map((one) => evaluate(one, request)),
5209
+ missing: result.missing
5210
+ };
5211
+ };
5212
+ const first = await ask(request, /* @__PURE__ */ new Map());
5213
+ if (first === void 0) return {
5214
+ variants: [],
5215
+ missing: [],
5216
+ findings: [],
5217
+ skipped,
5218
+ calls
5219
+ };
5220
+ const best = /* @__PURE__ */ new Map();
5221
+ first.variants.forEach((variant, index) => {
5222
+ best.set(variant.exemplarIndex, {
5223
+ built: first.built[index],
5224
+ variant
5225
+ });
5226
+ });
5227
+ const wanted = request.facts.exemplars.map((one) => one.exemplar);
5228
+ const missingNow = () => wanted.filter((index) => !best.has(index)).map(missingOf);
5229
+ for (let pass = 0; pass < repairs; pass += 1) {
5230
+ const repairRequest = aiVariantsRepairRequest(request, {
5231
+ variants: [...best.values()].map((one) => one.variant),
5232
+ missing: missingNow(),
5233
+ findings: []
5234
+ });
5235
+ if (repairRequest === null) break;
5236
+ const repaired = await ask(repairRequest, new Map((repairRequest.repair ?? []).flatMap((one) => {
5237
+ const before = best.get(one.exemplar);
5238
+ return before !== void 0 ? [[one.exemplar, stringOf(before.variant.entry.id)]] : [];
5239
+ })));
5240
+ if (repaired === void 0) break;
5241
+ repaired.variants.forEach((variant, index) => {
5242
+ const before = best.get(variant.exemplarIndex);
5243
+ if (before === void 0 || noWorse(variant, before.variant)) best.set(variant.exemplarIndex, {
5244
+ built: repaired.built[index],
5245
+ variant
5246
+ });
5247
+ });
5248
+ }
5249
+ const ended = [...best.values()].sort((a, b) => a.variant.exemplarIndex - b.variant.exemplarIndex);
5250
+ const seed = request.balanceKeyPositions?.seed;
5251
+ const variants = seed !== void 0 ? balanceKeys(ended.map((one) => one.built), seed).map((one) => evaluate(one, request)) : ended.map((one) => one.variant);
5252
+ return {
5253
+ variants,
5254
+ missing: missingNow(),
5255
+ findings: setFindings(variants),
5256
+ skipped,
5257
+ calls
5258
+ };
5259
+ }
5260
+ // src/ai-writing.ts
5261
+ /**
5262
+ * The request for feedback on a draft, or `null` when there is none to give:
5263
+ * not a written response, an author who switched explanations off (writing
5264
+ * feedback reads `ai.explanations`), a redacted item — feedback is for
5265
+ * practice, where the item is whole — or a draft with nothing in it.
5266
+ *
5267
+ * The facts are the task as the learner saw it, the draft verbatim with its
5268
+ * word count recomputed as the grader will count it, and the rubric the answer
5269
+ * will be graded on. `previousFeedback` is the text of the feedback already
5270
+ * shown on earlier drafts, so a model can speak to what changed. `group` is the
5271
+ * item group the task sits in: its material, the text a summary is of, goes
5272
+ * with the request as `material`.
5273
+ */
5274
+ function aiWritingFeedbackRequest(input) {
5275
+ const { data, response, learnerLocale } = input;
5276
+ if (!aiSupports(data.type, "writing-feedback") || !aiAllowedByContent(data, "writing-feedback") || data.redacted === true) return null;
5277
+ const text = response?.type === "written-response" ? response.text : "";
5278
+ if (typeof text !== "string" || text.trim() === "") return null;
5279
+ const item = data;
5280
+ const wordCount = require_registry.countWords(text);
5281
+ const minWords = typeof item.minWords === "number" ? item.minWords : 0;
5282
+ const maxWords = typeof item.maxWords === "number" ? item.maxWords : Number.POSITIVE_INFINITY;
5283
+ const criteria = Array.isArray(item.rubric?.criteria) ? item.rubric.criteria : null;
5284
+ const rubric = criteria === null || criteria.length === 0 ? null : criteria.map((criterion) => ({
5285
+ name: criterion.name,
5286
+ ...typeof criterion.description === "string" ? { description: criterion.description } : {},
5287
+ weight: criterion.weight
5288
+ }));
5289
+ const previousFeedback = [...input.previousFeedback ?? []];
5290
+ const material = materialFacts(input.group);
5291
+ return {
5292
+ feature: "writing-feedback",
5293
+ facts: {
5294
+ activityType: "written-response",
5295
+ activityId: data.id,
5296
+ title: data.title,
5297
+ ...typeof data.locale === "string" ? { locale: data.locale } : {},
5298
+ prompt: typeof item.prompt === "string" ? item.prompt : "",
5299
+ text,
5300
+ wordCount,
5301
+ minWords,
5302
+ maxWords,
5303
+ withinWordBounds: wordCount >= minWords && wordCount <= maxWords,
5304
+ rubric,
5305
+ ...typeof item.languageTarget === "string" ? { languageTarget: item.languageTarget } : {}
5306
+ },
5307
+ draftNumber: previousFeedback.length + 1,
5308
+ previousFeedback,
5309
+ ...material !== null ? { material } : {},
5310
+ ...learnerLocale !== void 0 ? { learnerLocale } : {}
5311
+ };
5312
+ }
3056
5313
  /** A criterion score a grade could be computed from: finite, 0 or more, and not above its maximum. */
3057
5314
  function isJudgedScore(score, maxScore) {
3058
5315
  if (score === void 0) return maxScore === void 0 || typeof maxScore === "number" && maxScore > 0;
@@ -3081,7 +5338,7 @@ function checkCriteria(raw, rubric) {
3081
5338
  const seen = /* @__PURE__ */ new Set();
3082
5339
  const criteria = [];
3083
5340
  for (const entry of raw) {
3084
- if (!isRecord(entry) || typeof entry.name !== "string") return {
5341
+ if (!isRecord$1(entry) || typeof entry.name !== "string") return {
3085
5342
  ok: false,
3086
5343
  refusal: "malformed"
3087
5344
  };
@@ -3170,7 +5427,6 @@ function checkAiWritingFeedback(raw, request) {
3170
5427
  }
3171
5428
  };
3172
5429
  }
3173
- //#endregion
3174
5430
  Object.defineProperty(exports, "AI_COACHING_MAX_TIP_LENGTH", {
3175
5431
  enumerable: true,
3176
5432
  get: function() {
@@ -3237,6 +5493,42 @@ Object.defineProperty(exports, "AI_TEXT_MAX_LENGTH", {
3237
5493
  return AI_TEXT_MAX_LENGTH;
3238
5494
  }
3239
5495
  });
5496
+ Object.defineProperty(exports, "AI_VARIANTS_MAX_EXEMPLARS", {
5497
+ enumerable: true,
5498
+ get: function() {
5499
+ return AI_VARIANTS_MAX_EXEMPLARS;
5500
+ }
5501
+ });
5502
+ Object.defineProperty(exports, "AI_VARIANTS_MAX_MATERIAL_LENGTH", {
5503
+ enumerable: true,
5504
+ get: function() {
5505
+ return AI_VARIANTS_MAX_MATERIAL_LENGTH;
5506
+ }
5507
+ });
5508
+ Object.defineProperty(exports, "AI_VARIANTS_MAX_REPAIRS", {
5509
+ enumerable: true,
5510
+ get: function() {
5511
+ return AI_VARIANTS_MAX_REPAIRS;
5512
+ }
5513
+ });
5514
+ Object.defineProperty(exports, "AI_VARIANT_CODE_SEVERITY", {
5515
+ enumerable: true,
5516
+ get: function() {
5517
+ return AI_VARIANT_CODE_SEVERITY;
5518
+ }
5519
+ });
5520
+ Object.defineProperty(exports, "AI_VARIANT_LENGTH_BANDS", {
5521
+ enumerable: true,
5522
+ get: function() {
5523
+ return AI_VARIANT_LENGTH_BANDS;
5524
+ }
5525
+ });
5526
+ Object.defineProperty(exports, "AI_VARIANT_NEAR_COPY_THRESHOLD", {
5527
+ enumerable: true,
5528
+ get: function() {
5529
+ return AI_VARIANT_NEAR_COPY_THRESHOLD;
5530
+ }
5531
+ });
3240
5532
  Object.defineProperty(exports, "AI_WRITING_MAX_CORRECTIONS", {
3241
5533
  enumerable: true,
3242
5534
  get: function() {
@@ -3309,6 +5601,18 @@ Object.defineProperty(exports, "aiSupports", {
3309
5601
  return aiSupports;
3310
5602
  }
3311
5603
  });
5604
+ Object.defineProperty(exports, "aiVariantsRepairRequest", {
5605
+ enumerable: true,
5606
+ get: function() {
5607
+ return aiVariantsRepairRequest;
5608
+ }
5609
+ });
5610
+ Object.defineProperty(exports, "aiVariantsRequest", {
5611
+ enumerable: true,
5612
+ get: function() {
5613
+ return aiVariantsRequest;
5614
+ }
5615
+ });
3312
5616
  Object.defineProperty(exports, "aiWritingFeedbackRequest", {
3313
5617
  enumerable: true,
3314
5618
  get: function() {
@@ -3357,6 +5661,12 @@ Object.defineProperty(exports, "checkAiHint", {
3357
5661
  return checkAiHint;
3358
5662
  }
3359
5663
  });
5664
+ Object.defineProperty(exports, "checkAiVariants", {
5665
+ enumerable: true,
5666
+ get: function() {
5667
+ return checkAiVariants;
5668
+ }
5669
+ });
3360
5670
  Object.defineProperty(exports, "checkAiWritingFeedback", {
3361
5671
  enumerable: true,
3362
5672
  get: function() {
@@ -3405,6 +5715,12 @@ Object.defineProperty(exports, "generateDrafts", {
3405
5715
  return generateDrafts;
3406
5716
  }
3407
5717
  });
5718
+ Object.defineProperty(exports, "generateVariants", {
5719
+ enumerable: true,
5720
+ get: function() {
5721
+ return generateVariants;
5722
+ }
5723
+ });
3408
5724
  Object.defineProperty(exports, "hintRevealsAnswer", {
3409
5725
  enumerable: true,
3410
5726
  get: function() {
@@ -3417,6 +5733,18 @@ Object.defineProperty(exports, "interactiveVideoFromDrafts", {
3417
5733
  return interactiveVideoFromDrafts;
3418
5734
  }
3419
5735
  });
5736
+ Object.defineProperty(exports, "keysOf", {
5737
+ enumerable: true,
5738
+ get: function() {
5739
+ return keysOf;
5740
+ }
5741
+ });
5742
+ Object.defineProperty(exports, "lengthMisses", {
5743
+ enumerable: true,
5744
+ get: function() {
5745
+ return lengthMisses;
5746
+ }
5747
+ });
3420
5748
  Object.defineProperty(exports, "listMediaRefs", {
3421
5749
  enumerable: true,
3422
5750
  get: function() {
@@ -3429,12 +5757,48 @@ Object.defineProperty(exports, "mapMediaRefs", {
3429
5757
  return mapMediaRefs;
3430
5758
  }
3431
5759
  });
5760
+ Object.defineProperty(exports, "optionSet", {
5761
+ enumerable: true,
5762
+ get: function() {
5763
+ return optionSet;
5764
+ }
5765
+ });
3432
5766
  Object.defineProperty(exports, "readAiTextResult", {
3433
5767
  enumerable: true,
3434
5768
  get: function() {
3435
5769
  return readAiTextResult;
3436
5770
  }
3437
5771
  });
5772
+ Object.defineProperty(exports, "sharedAnswers", {
5773
+ enumerable: true,
5774
+ get: function() {
5775
+ return sharedAnswers;
5776
+ }
5777
+ });
5778
+ Object.defineProperty(exports, "shownScore", {
5779
+ enumerable: true,
5780
+ get: function() {
5781
+ return shownScore;
5782
+ }
5783
+ });
5784
+ Object.defineProperty(exports, "structureMisses", {
5785
+ enumerable: true,
5786
+ get: function() {
5787
+ return structureMisses;
5788
+ }
5789
+ });
5790
+ Object.defineProperty(exports, "textSimilarity", {
5791
+ enumerable: true,
5792
+ get: function() {
5793
+ return textSimilarity;
5794
+ }
5795
+ });
5796
+ Object.defineProperty(exports, "textsOf", {
5797
+ enumerable: true,
5798
+ get: function() {
5799
+ return textsOf;
5800
+ }
5801
+ });
3438
5802
  Object.defineProperty(exports, "transcriptOf", {
3439
5803
  enumerable: true,
3440
5804
  get: function() {
@@ -3454,4 +5818,4 @@ Object.defineProperty(exports, "validateItemGroupDraft", {
3454
5818
  }
3455
5819
  });
3456
5820
 
3457
- //# sourceMappingURL=ai-writing-C4YRVrFi.cjs.map
5821
+ //# sourceMappingURL=ai-writing-Bh2Y53cY.cjs.map