@intellectif/lk-core 1.4.0 → 1.6.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 (89) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +2 -0
  3. package/dist/ai-check.cjs +175 -17
  4. package/dist/ai-check.cjs.map +1 -1
  5. package/dist/ai-check.d.cts +13 -5
  6. package/dist/ai-check.d.cts.map +1 -1
  7. package/dist/ai-check.d.ts +13 -5
  8. package/dist/ai-check.d.ts.map +1 -1
  9. package/dist/ai-check.js +175 -17
  10. package/dist/ai-check.js.map +1 -1
  11. package/dist/{ai-CnmLPMzd.d.ts → ai-i3jwXnpX.d.cts} +242 -7
  12. package/dist/ai-i3jwXnpX.d.cts.map +1 -0
  13. package/dist/{ai-CnmLPMzd.d.cts → ai-i3jwXnpX.d.ts} +242 -7
  14. package/dist/ai-i3jwXnpX.d.ts.map +1 -0
  15. package/dist/{ai-writing-G6tnCGzw.cjs → ai-writing-C4YRVrFi.cjs} +683 -72
  16. package/dist/ai-writing-C4YRVrFi.cjs.map +1 -0
  17. package/dist/{ai-writing-CPpH9Qxq.js → ai-writing-OJ5m_1EB.js} +663 -88
  18. package/dist/ai-writing-OJ5m_1EB.js.map +1 -0
  19. package/dist/{index-BqMXWuNC.d.cts → index--U7hHV0s.d.cts} +2 -2
  20. package/dist/{index-BqMXWuNC.d.cts.map → index--U7hHV0s.d.cts.map} +1 -1
  21. package/dist/{index-B54baFUA.d.ts → index-CFRgZLhH.d.cts} +45 -12
  22. package/dist/index-CFRgZLhH.d.cts.map +1 -0
  23. package/dist/{index-CVWvymVb.d.ts → index-DeD8EI1D.d.ts} +327 -3
  24. package/dist/index-DeD8EI1D.d.ts.map +1 -0
  25. package/dist/{index-CcHDTxIZ.d.ts → index-Dj92T76o.d.ts} +2 -2
  26. package/dist/{index-CcHDTxIZ.d.ts.map → index-Dj92T76o.d.ts.map} +1 -1
  27. package/dist/{index-BAfEHCnD.d.cts → index-Uh1u57yq.d.cts} +327 -3
  28. package/dist/index-Uh1u57yq.d.cts.map +1 -0
  29. package/dist/{index-2nigSf4N.d.cts → index-WBHSrIUU.d.ts} +45 -12
  30. package/dist/index-WBHSrIUU.d.ts.map +1 -0
  31. package/dist/index.cjs +88 -131
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +217 -95
  34. package/dist/index.d.cts.map +1 -1
  35. package/dist/index.d.ts +217 -95
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +60 -118
  38. package/dist/index.js.map +1 -1
  39. package/dist/{item-group-DXiR8nOr.js → item-group-BbvvH_Ob.js} +3 -2
  40. package/dist/item-group-BbvvH_Ob.js.map +1 -0
  41. package/dist/{item-group-DroFXgLb.cjs → item-group-CQRbb8Rx.cjs} +3 -2
  42. package/dist/item-group-CQRbb8Rx.cjs.map +1 -0
  43. package/dist/{registry-Dsx9mNsS.js → registry-B23o9UWh.js} +218 -21
  44. package/dist/registry-B23o9UWh.js.map +1 -0
  45. package/dist/{registry-DVi0LR_i.cjs → registry-Ih5n4PVZ.cjs} +265 -20
  46. package/dist/registry-Ih5n4PVZ.cjs.map +1 -0
  47. package/dist/{schemas-DVbAOrtd.cjs → schemas-Q2dyOgc2.cjs} +77 -14
  48. package/dist/schemas-Q2dyOgc2.cjs.map +1 -0
  49. package/dist/{schemas-Bdw0nGPo.js → schemas-yId-bJhD.js} +72 -15
  50. package/dist/schemas-yId-bJhD.js.map +1 -0
  51. package/dist/schemas.cjs +5 -2
  52. package/dist/schemas.d.cts +2 -2
  53. package/dist/schemas.d.ts +2 -2
  54. package/dist/schemas.js +4 -3
  55. package/dist/{scoring-BZqUkuDF.js → scoring-CLMdk8i9.js} +657 -3
  56. package/dist/scoring-CLMdk8i9.js.map +1 -0
  57. package/dist/{scoring-B2_HEhMT.cjs → scoring-CaPS6q8b.cjs} +722 -2
  58. package/dist/scoring-CaPS6q8b.cjs.map +1 -0
  59. package/dist/scoring.cjs +8 -2
  60. package/dist/scoring.d.cts +3 -3
  61. package/dist/scoring.d.ts +3 -3
  62. package/dist/scoring.js +3 -3
  63. package/dist/{xapi-DNzkSJ0S.cjs → xapi-BX0hvRRB.cjs} +2 -2
  64. package/dist/{xapi-DNzkSJ0S.cjs.map → xapi-BX0hvRRB.cjs.map} +1 -1
  65. package/dist/{xapi-CMTph7wG.js → xapi-Cdk9fTvV.js} +2 -2
  66. package/dist/{xapi-CMTph7wG.js.map → xapi-Cdk9fTvV.js.map} +1 -1
  67. package/dist/xapi.cjs +1 -1
  68. package/dist/xapi.d.cts +1 -1
  69. package/dist/xapi.d.ts +1 -1
  70. package/dist/xapi.js +1 -2
  71. package/package.json +2 -2
  72. package/vectors/README.md +24 -1
  73. package/vectors/scoring.json +18182 -1
  74. package/dist/ai-CnmLPMzd.d.cts.map +0 -1
  75. package/dist/ai-CnmLPMzd.d.ts.map +0 -1
  76. package/dist/ai-writing-CPpH9Qxq.js.map +0 -1
  77. package/dist/ai-writing-G6tnCGzw.cjs.map +0 -1
  78. package/dist/index-2nigSf4N.d.cts.map +0 -1
  79. package/dist/index-B54baFUA.d.ts.map +0 -1
  80. package/dist/index-BAfEHCnD.d.cts.map +0 -1
  81. package/dist/index-CVWvymVb.d.ts.map +0 -1
  82. package/dist/item-group-DXiR8nOr.js.map +0 -1
  83. package/dist/item-group-DroFXgLb.cjs.map +0 -1
  84. package/dist/registry-DVi0LR_i.cjs.map +0 -1
  85. package/dist/registry-Dsx9mNsS.js.map +0 -1
  86. package/dist/schemas-Bdw0nGPo.js.map +0 -1
  87. package/dist/schemas-DVbAOrtd.cjs.map +0 -1
  88. package/dist/scoring-B2_HEhMT.cjs.map +0 -1
  89. package/dist/scoring-BZqUkuDF.js.map +0 -1
@@ -1,6 +1,6 @@
1
- import { B as issue, D as keyPositionFinding, F as covers, G as groupCaptionsField, H as refusesEmpty, I as isMissingId, L as isRecord$5, M as DRAFT_ISSUE_SEVERITY, N as checkMedia, O as normaliseFinding, P as coveredPathsOf, R as isUnset, V as pathKey, W as GROUP_CAPTIONS_REVEAL_DICTATION, Y as UnknownActivityTypeError, Z as alignDictation, a as readSchema, dt as containsWords, ft as fold, ht as revealsAnswer, j as countWords, mt as placeholderOrder, pt as numberedPassage, st as withValidationScope, t as getActivityTypeDescriptor, z as isUnwritten } from "./registry-Dsx9mNsS.js";
2
- import { c as validateSpeechAssessment, d as gradeFromRubric, r as score, s as alignReadAloud, w as isGradeInRange } from "./scoring-BZqUkuDF.js";
3
- import { a as INTERACTIVE_VIDEO_ITEM_TYPES, i as validateItemGroup, t as ItemGroupSchema, u as isInteractiveVideoItemType } from "./item-group-DXiR8nOr.js";
1
+ import { A as normaliseFinding, B as coveredPathsOf, F as levelKey, G as isUnwritten, H as isMissingId, I as readRubricLevels, J as refusesEmpty, K as issue, L as countWords, N as LEVEL_PROBABILITY_TOLERANCE, O as keyPositionFinding, P as levelDistribution, R as DRAFT_ISSUE_SEVERITY, U as isRecord$6, V as covers, W as isUnset, X as GROUP_CAPTIONS_REVEAL_DICTATION, Z as groupCaptionsField, _t as containsWords, a as readSchema, bt as placeholderOrder, k as finding, pt as withValidationScope, q as pathKey, rt as alignDictation, t as getActivityTypeDescriptor, tt as UnknownActivityTypeError, vt as fold, xt as revealsAnswer, yt as numberedPassage, z as checkMedia } from "./registry-B23o9UWh.js";
2
+ import { F as isGradeInRange, _ as gradeFromRubric, c as validateSpeechAssessment, r as score, s as alignReadAloud } from "./scoring-CLMdk8i9.js";
3
+ import { a as INTERACTIVE_VIDEO_ITEM_TYPES, i as validateItemGroup, t as ItemGroupSchema, u as isInteractiveVideoItemType } from "./item-group-BbvvH_Ob.js";
4
4
  //#region src/ai-material.ts
5
5
  /**
6
6
  * What a model is told about the group a question sits in: the material the
@@ -28,7 +28,7 @@ const MEDIA_TYPES = {
28
28
  video: true,
29
29
  embed: true
30
30
  };
31
- const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
31
+ const isRecord$5 = (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$4(group)) return null;
65
+ if (!isRecord$5(group)) return null;
66
66
  const stimulus = group.stimulus;
67
- if (!isRecord$4(stimulus)) return null;
67
+ if (!isRecord$5(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$4(rawMedia) ? rawMedia.type : void 0;
75
- const alt = isRecord$4(rawMedia) ? filled(rawMedia.alt) : void 0;
74
+ const mediaType = isRecord$5(rawMedia) ? rawMedia.type : void 0;
75
+ const alt = isRecord$5(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 } : {}
@@ -742,7 +742,7 @@ function aiCoachingRequest(input) {
742
742
  ...learnerLocale !== void 0 ? { learnerLocale } : {}
743
743
  };
744
744
  }
745
- const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
745
+ const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
746
746
  /** A symbol as it is compared: composed, and without the spaces around it. */
747
747
  const symbolKey = (symbol) => symbol.normalize("NFC").trim();
748
748
  /**
@@ -791,7 +791,7 @@ function checkAiCoaching(raw, request) {
791
791
  });
792
792
  const seen = /* @__PURE__ */ new Set();
793
793
  for (const entry of entries) {
794
- if (!isRecord$3(entry) || typeof entry.itemId !== "string" || typeof entry.tip !== "string") return {
794
+ if (!isRecord$4(entry) || typeof entry.itemId !== "string" || typeof entry.tip !== "string") return {
795
795
  ok: false,
796
796
  refusal: "malformed"
797
797
  };
@@ -839,7 +839,7 @@ function checkAiCoaching(raw, request) {
839
839
  /** A sound a model names, checked against the sounds the engine reported for the word. */
840
840
  function checkSound(raw, fact) {
841
841
  if (raw === void 0 || raw === null) return;
842
- if (!isRecord$3(raw) || typeof raw.expected !== "string" || raw.heard !== void 0 && raw.heard !== null && typeof raw.heard !== "string") return {
842
+ if (!isRecord$4(raw) || typeof raw.expected !== "string" || raw.heard !== void 0 && raw.heard !== null && typeof raw.heard !== "string") return {
843
843
  ok: false,
844
844
  refusal: "malformed"
845
845
  };
@@ -866,6 +866,240 @@ function checkSound(raw, fact) {
866
866
  };
867
867
  }
868
868
  //#endregion
869
+ //#region src/media-refs.ts
870
+ const MAIN_KINDS = [
871
+ "audio",
872
+ "video",
873
+ "image",
874
+ "embed"
875
+ ];
876
+ function isRecord$3(value) {
877
+ return typeof value === "object" && value !== null && !Array.isArray(value);
878
+ }
879
+ function valueAt(root, path) {
880
+ let at = root;
881
+ for (const key of path) {
882
+ if (typeof key === "number" ? !Array.isArray(at) : !isRecord$3(at)) return;
883
+ at = at[key];
884
+ }
885
+ return at;
886
+ }
887
+ /** Where `item` keeps its files: what its type declares, or its `media` alone. */
888
+ function slotsOf(item) {
889
+ const declared = typeof item.type === "string" ? getActivityTypeDescriptor(item.type)?.mediaSlots : void 0;
890
+ if (declared === void 0) return [{ path: ["media"] }];
891
+ const slots = declared(item);
892
+ return Array.isArray(slots) ? slots : [];
893
+ }
894
+ function holderRefs(holder, path, kindOfSlot, owner) {
895
+ if (!isRecord$3(holder)) return [];
896
+ const main = typeof holder.type === "string" && MAIN_KINDS.includes(holder.type) ? holder.type : kindOfSlot;
897
+ const refs = [];
898
+ const add = (field, kind, url) => {
899
+ refs.push({
900
+ path: [...path, ...field],
901
+ kind,
902
+ url,
903
+ holder,
904
+ ...owner
905
+ });
906
+ };
907
+ if (main !== void 0) add(["url"], main, typeof holder.url === "string" ? holder.url : null);
908
+ if (typeof holder.captionsUrl === "string") add(["captionsUrl"], "captions", holder.captionsUrl);
909
+ if (Array.isArray(holder.tracks)) holder.tracks.forEach((track, index) => {
910
+ if (isRecord$3(track) && typeof track.src === "string") refs.push({
911
+ path: [
912
+ ...path,
913
+ "tracks",
914
+ index,
915
+ "src"
916
+ ],
917
+ kind: "track",
918
+ url: track.src,
919
+ holder: track,
920
+ ...owner,
921
+ ...typeof track.srclang === "string" ? { srclang: track.srclang } : {}
922
+ });
923
+ });
924
+ if (typeof holder.poster === "string") add(["poster"], "poster", holder.poster);
925
+ return refs;
926
+ }
927
+ function activityRefs(item, base) {
928
+ if (!isRecord$3(item)) return [];
929
+ const owner = typeof item.id === "string" ? { activityId: item.id } : {};
930
+ return slotsOf(item).flatMap((slot) => holderRefs(valueAt(item, slot.path), [...base, ...slot.path], slot.kind, owner));
931
+ }
932
+ /**
933
+ * Every media address in one piece of content: a loose activity or an item
934
+ * group (its stimulus first, then each item in authored order), as a
935
+ * `SequenceEntry` holds them. Drafts are read too.
936
+ *
937
+ * For each activity the walker reads the places its type keeps files: an
938
+ * item's `media`, each multiple-choice option's `media`, and dictation's and
939
+ * read-aloud's `slowMedia`. In each it lists the main file, `captionsUrl`,
940
+ * every track's `src` and `poster`. A type you register declares its own with
941
+ * `mediaSlots`; without that, its `media` is read.
942
+ *
943
+ * A main file with no address yet is listed with `url: null`, so a recording
944
+ * still to be made has a place in the list. Not read: addresses inside an
945
+ * `…Html` sidecar (the SDK does not parse HTML) and a learner's recordings,
946
+ * which are responses rather than content.
947
+ *
948
+ * ```ts
949
+ * const files = listMediaRefs(entry).filter((ref) => ref.url !== null);
950
+ * ```
951
+ */
952
+ function listMediaRefs(entry) {
953
+ if (!isRecord$3(entry)) return [];
954
+ if (entry.type !== "item-group") return activityRefs(entry, []);
955
+ const refs = [];
956
+ const stimulus = entry.stimulus;
957
+ if (isRecord$3(stimulus)) {
958
+ const owner = typeof stimulus.id === "string" ? { stimulusId: stimulus.id } : {};
959
+ refs.push(...holderRefs(stimulus.media, ["stimulus", "media"], void 0, owner));
960
+ }
961
+ if (Array.isArray(entry.items)) entry.items.forEach((item, index) => {
962
+ refs.push(...activityRefs(item, ["items", index]));
963
+ });
964
+ return refs;
965
+ }
966
+ /** A deep copy of a JSON value. Content is plain data; a cycle cannot be copied. */
967
+ function copyOf(value, seen) {
968
+ if (typeof value !== "object" || value === null) return value;
969
+ if (seen.has(value)) throw new TypeError("mapMediaRefs: the content contains a circular reference.");
970
+ seen.add(value);
971
+ try {
972
+ if (Array.isArray(value)) return value.map((element) => copyOf(element, seen));
973
+ const copy = {};
974
+ for (const [key, field] of Object.entries(value)) copy[key] = copyOf(field, seen);
975
+ return copy;
976
+ } finally {
977
+ seen.delete(value);
978
+ }
979
+ }
980
+ /**
981
+ * A copy of `entry` with each address {@link listMediaRefs} finds replaced
982
+ * by what `fn` returns for it: files moved into a new folder structure, or an
983
+ * export's file names pointed at where an import stored them. Nothing else
984
+ * changes, and `entry` is never modified: with `(ref) => ref.url`, the copy is
985
+ * deep-equal to it, with the same `canonicalJson`.
986
+ *
987
+ * For a main file with no address yet (`url: null`), a string attaches one
988
+ * and `null` leaves it empty. For an address that is there, `fn` must return
989
+ * a string: removing a file is not mapping.
990
+ *
991
+ * The copy validates as the original did when every address `fn` returns is
992
+ * one the media rule accepts: `https:`, `http:`, `data:`, `blob:`, or
993
+ * root-relative (`/…`). A bundle-relative `media/part1.mp3` is refused.
994
+ *
995
+ * @throws TypeError when `fn` returns something other than a string or
996
+ * `null`, or `null` for an address that is there, or when `entry` is circular.
997
+ */
998
+ function mapMediaRefs(entry, fn) {
999
+ const refs = listMediaRefs(entry);
1000
+ const copy = copyOf(entry, /* @__PURE__ */ new Set());
1001
+ for (const ref of refs) {
1002
+ const next = fn(ref);
1003
+ if (next === null) {
1004
+ if (ref.url !== null) throw new TypeError(`mapMediaRefs: ${ref.path.join(".")} has a file; returning null would remove it, which is not mapping.`);
1005
+ continue;
1006
+ }
1007
+ if (typeof next !== "string") throw new TypeError(`mapMediaRefs: the function must return a string or null for ${ref.path.join(".")}, not ${typeof next}.`);
1008
+ if (next === ref.url) continue;
1009
+ const holder = valueAt(copy, ref.path.slice(0, -1));
1010
+ holder[ref.path[ref.path.length - 1]] = next;
1011
+ }
1012
+ return copy;
1013
+ }
1014
+ /** A holder's own text: its transcript, else its script as "Speaker: text" lines. */
1015
+ function ownText(holder) {
1016
+ 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()}`] : []) : [];
1018
+ return turns.length > 0 ? turns.join("\n") : null;
1019
+ }
1020
+ /**
1021
+ * The author-side text of a recording: its `transcript`, or else its
1022
+ * `script` as one "Speaker: text" line per turn. `null` when it has neither.
1023
+ *
1024
+ * `holder` is a media object, a stimulus, or an activity. A stimulus or an
1025
+ * activity is read for its own text first and then for its `media`'s; a
1026
+ * stimulus keeps both at its own level. Author-only: pass it to the author's
1027
+ * requests (`aiDraftsRequest`'s `source`, a critique), never to a learner's.
1028
+ *
1029
+ * ```ts
1030
+ * const text = transcriptOf(item.media);
1031
+ * if (text !== null) aiDraftsRequest({ types: ['multiple-choice'], source: { kind: 'transcript', text } });
1032
+ * ```
1033
+ */
1034
+ function transcriptOf(holder) {
1035
+ if (!isRecord$3(holder)) return null;
1036
+ return ownText(holder) ?? (isRecord$3(holder.media) ? ownText(holder.media) : null);
1037
+ }
1038
+ //#endregion
1039
+ //#region src/authoring/recordings.ts
1040
+ const written$1 = (value) => typeof value === "string" && value.trim() !== "";
1041
+ /** The script's turns that have a speaker and words, or none. */
1042
+ function turnsOf(script) {
1043
+ return Array.isArray(script) ? script.flatMap((turn) => isRecord$6(turn) && typeof turn.speaker === "string" && written$1(turn.text) ? [{
1044
+ speaker: turn.speaker,
1045
+ text: turn.text
1046
+ }] : []) : [];
1047
+ }
1048
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1049
+ /** A transcript's words, with each `Speaker:` label of the script's speakers taken out. */
1050
+ function transcriptWords(transcript, speakers) {
1051
+ const labels = [...new Set(speakers.map((speaker) => speaker.trim()).filter(written$1))];
1052
+ const unlabelled = labels.length === 0 ? transcript : transcript.replace(new RegExp(`(^|\\s)(?:${labels.map(escaped).join("|")})\\s*:`, "giu"), "$1");
1053
+ return fold(unlabelled);
1054
+ }
1055
+ /** What a recording says, as words: its transcript, or else its script's turns. */
1056
+ function spokenWords(holder) {
1057
+ const turns = turnsOf(holder.script);
1058
+ if (written$1(holder.transcript)) return transcriptWords(holder.transcript, turns.map((turn) => turn.speaker));
1059
+ return turns.length > 0 ? fold(turns.map((turn) => turn.text).join(" ")) : null;
1060
+ }
1061
+ const sameWords = (a, b) => a.length === b.length && a.every((word, index) => word === b[index]);
1062
+ /** One object's own rules: text on media with nothing to say, and a transcript its script contradicts. */
1063
+ function holderFindings(holder, path) {
1064
+ if (!isRecord$6(holder)) return [];
1065
+ 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(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 fromTranscript = transcriptWords(holder.transcript, turns.map((turn) => turn.speaker));
1070
+ if (!sameWords(fromTranscript, fold(turns.map((turn) => turn.text).join(" ")))) findings.push(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
+ }
1072
+ return findings;
1073
+ }
1074
+ /** The field a type's own recording reads out, for the types that have one. */
1075
+ const SPOKEN_FIELD = {
1076
+ dictation: "transcript",
1077
+ "read-aloud": "referenceText"
1078
+ };
1079
+ /**
1080
+ * An item's recording rules, at every place its type keeps media. A
1081
+ * dictation's and a read-aloud's own text is what their recording says, so a
1082
+ * recording that says other words is reported too.
1083
+ */
1084
+ function recordingFindings(item) {
1085
+ const findings = listMediaRefs(item).filter((ref) => ref.path[ref.path.length - 1] === "url").flatMap((ref) => holderFindings(ref.holder, ref.path.slice(0, -1)));
1086
+ const field = typeof item.type === "string" ? SPOKEN_FIELD[item.type] : void 0;
1087
+ const media = item.media;
1088
+ if (field === void 0 || !isRecord$6(media)) return findings;
1089
+ 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, fold(text)))) findings.push(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.`));
1092
+ return findings;
1093
+ }
1094
+ /** A group's material: its own transcript against its script, and its media's rules. */
1095
+ function stimulusFindings(stimulus) {
1096
+ if (!isRecord$6(stimulus)) return [];
1097
+ return [...holderFindings({
1098
+ transcript: stimulus.transcript,
1099
+ script: stimulus.script
1100
+ }, ["stimulus"]), ...holderFindings(stimulus.media, ["stimulus", "media"])];
1101
+ }
1102
+ //#endregion
869
1103
  //#region src/authoring/item-group.ts
870
1104
  /**
871
1105
  * A new, empty reading or listening testlet for an editor to start from.
@@ -961,9 +1195,13 @@ function createInteractiveVideoDraft(context) {
961
1195
  * {@link validateItemGroupDraft} already reports it.
962
1196
  */
963
1197
  function critiqueItemGroupDraft(draft) {
964
- if (!isRecord$5(draft)) return [];
1198
+ if (!isRecord$6(draft)) return [];
965
1199
  const items = Array.isArray(draft.items) ? draft.items : [];
966
- return withValidationScope(() => [...items.flatMap((item, index) => repathed(critiqueItem(item), ["items", index])), ...keyPositionFinding(items, ["items"])]);
1200
+ return withValidationScope(() => [
1201
+ ...stimulusFindings(draft.stimulus),
1202
+ ...items.flatMap((item, index) => repathed(critiqueItem(item), ["items", index])),
1203
+ ...keyPositionFinding(items, ["items"])
1204
+ ]);
967
1205
  }
968
1206
  /**
969
1207
  * The item critic over a question set, as `<ActivitySequence>` takes one: each
@@ -973,11 +1211,11 @@ function critiqueItemGroupDraft(draft) {
973
1211
  * skipped.
974
1212
  */
975
1213
  function critiqueDrafts(entries) {
976
- return withValidationScope(() => [...entries.flatMap((entry, index) => repathed(isRecord$5(entry) && entry.type === "item-group" ? critiqueItemGroupDraft(entry) : critiqueItem(entry), [index])), ...keyPositionFinding(entries, [])]);
1214
+ return withValidationScope(() => [...entries.flatMap((entry, index) => repathed(isRecord$6(entry) && entry.type === "item-group" ? critiqueItemGroupDraft(entry) : critiqueItem(entry), [index])), ...keyPositionFinding(entries, [])]);
977
1215
  }
978
1216
  /** One item's findings, or none for an item the critic cannot place. */
979
1217
  function critiqueItem(item) {
980
- if (!isRecord$5(item) || typeof item.type !== "string") return [];
1218
+ if (!isRecord$6(item) || typeof item.type !== "string") return [];
981
1219
  if (getActivityTypeDescriptor(item.type) === void 0) return [];
982
1220
  return critiqueDraft(item.type, item);
983
1221
  }
@@ -990,7 +1228,7 @@ function validateItemGroupDraft(draft) {
990
1228
  }
991
1229
  function validateItemGroupDraftInScope(draft) {
992
1230
  const issues = [];
993
- if (!isRecord$5(draft)) {
1231
+ if (!isRecord$6(draft)) {
994
1232
  issues.push(issue("ig_not_an_object", [], "A group must be an object with a stimulus and its items."));
995
1233
  return {
996
1234
  status: "invalid",
@@ -998,7 +1236,7 @@ function validateItemGroupDraftInScope(draft) {
998
1236
  };
999
1237
  }
1000
1238
  issues.push(...checkGroupIdentity(draft));
1001
- if (isRecord$5(draft.stimulus)) issues.push(...checkStimulus(draft.stimulus));
1239
+ if (isRecord$6(draft.stimulus)) issues.push(...checkStimulus(draft.stimulus));
1002
1240
  else if (isUnset(draft.stimulus)) issues.push(issue("ig_stimulus_required", ["stimulus"], "Add the passage, recording or image the questions are about."));
1003
1241
  issues.push(...checkItems(draft));
1004
1242
  if (draft.timeline !== void 0 && draft.timeline !== null) issues.push(...checkTimeline(draft));
@@ -1048,10 +1286,10 @@ function validateItemGroupDraftInScope(draft) {
1048
1286
  function checkTimeline(draft) {
1049
1287
  const issues = [];
1050
1288
  const timeline = draft.timeline;
1051
- if (!isRecord$5(timeline)) return issues;
1052
- const stimulus = isRecord$5(draft.stimulus) ? draft.stimulus : void 0;
1289
+ if (!isRecord$6(timeline)) return issues;
1290
+ const stimulus = isRecord$6(draft.stimulus) ? draft.stimulus : void 0;
1053
1291
  if (stimulus !== void 0 && !isUnwritten(stimulus.kind) && stimulus.kind !== "video") issues.push(issue("ig_timeline_stimulus_kind", ["stimulus", "kind"], "An interactive video needs a video: quizzes open at moments of it."));
1054
- else if (stimulus !== void 0 && isRecord$5(stimulus.media) && !isUnwritten(stimulus.media.type) && stimulus.media.type !== "video") issues.push(issue("ig_timeline_stimulus_kind", [
1292
+ else if (stimulus !== void 0 && isRecord$6(stimulus.media) && !isUnwritten(stimulus.media.type) && stimulus.media.type !== "video") issues.push(issue("ig_timeline_stimulus_kind", [
1055
1293
  "stimulus",
1056
1294
  "media",
1057
1295
  "type"
@@ -1060,7 +1298,7 @@ function checkTimeline(draft) {
1060
1298
  const items = Array.isArray(draft.items) ? draft.items : [];
1061
1299
  const itemIndexById = /* @__PURE__ */ new Map();
1062
1300
  items.forEach((item, index) => {
1063
- if (!isRecord$5(item)) return;
1301
+ if (!isRecord$6(item)) return;
1064
1302
  if (typeof item.id === "string" && item.id !== "") itemIndexById.set(item.id, index);
1065
1303
  const type = item.type;
1066
1304
  if (typeof type === "string" && type !== "" && type !== "item-group" && !isInteractiveVideoItemType(type) && getActivityTypeDescriptor(type) !== void 0) issues.push(issue("ig_timeline_item_type", [
@@ -1078,7 +1316,7 @@ function checkTimeline(draft) {
1078
1316
  const quizIds = /* @__PURE__ */ new Set();
1079
1317
  const placed = /* @__PURE__ */ new Set();
1080
1318
  cues.forEach((cue, cueIndex) => {
1081
- if (!isRecord$5(cue)) return;
1319
+ if (!isRecord$6(cue)) return;
1082
1320
  const at = (field, ...rest) => [
1083
1321
  "timeline",
1084
1322
  "cues",
@@ -1109,7 +1347,7 @@ function checkTimeline(draft) {
1109
1347
  const chapters = Array.isArray(timeline.chapters) ? timeline.chapters : [];
1110
1348
  let previousAt;
1111
1349
  chapters.forEach((chapter, index) => {
1112
- if (!isRecord$5(chapter)) return;
1350
+ if (!isRecord$6(chapter)) return;
1113
1351
  if (isUnwritten(chapter.title)) issues.push(issue("ig_timeline_chapter_title", [
1114
1352
  "timeline",
1115
1353
  "chapters",
@@ -1170,14 +1408,14 @@ function checkStimulus(stimulus) {
1170
1408
  if (isUnwritten(kind)) issues.push(issue("ig_stimulus_kind_required", at("kind"), "Choose what the questions are about: a text, a recording, a video, an image, or a mix."));
1171
1409
  else if (typeof kind !== "string" || !KINDS$1.has(kind)) {
1172
1410
  if (typeof kind === "string") issues.push(issue("ig_stimulus_kind_invalid", at("kind"), `"${kind}" is not a stimulus kind. Use text, audio, video, image or mixed.`));
1173
- if (isRecord$5(stimulus.media)) issues.push(...checkMedia(stimulus.media, at("media")));
1411
+ if (isRecord$6(stimulus.media)) issues.push(...checkMedia(stimulus.media, at("media")));
1174
1412
  return issues;
1175
1413
  }
1176
1414
  const hasBody = typeof stimulus.body === "string" && stimulus.body.trim() !== "";
1177
1415
  const htmlPresent = !isUnset(stimulus.bodyHtml);
1178
1416
  if (!hasBody && (kind === "text" || kind === "mixed" || htmlPresent)) issues.push(issue("ig_stimulus_body_required", at("body"), isUnwritten(stimulus.bodyHtml) ? "Write the passage." : "Write the passage as plain text too. The rich-text version is shown only where a sanitiser is supplied."));
1179
1417
  if (kind !== "text" && isUnset(stimulus.media)) issues.push(issue("ig_stimulus_media_required", at("media"), "Add the recording, video or image."));
1180
- else if (isRecord$5(stimulus.media)) {
1418
+ else if (isRecord$6(stimulus.media)) {
1181
1419
  const allowed = MEDIA_FOR_KIND[kind];
1182
1420
  const mediaType = stimulus.media.type;
1183
1421
  if (allowed !== void 0 && typeof mediaType === "string" && !allowed.includes(mediaType)) issues.push(issue("ig_stimulus_media_kind", at("media", "type"), `A "${kind}" stimulus cannot carry ${mediaType} media. Use ${allowed.join(" or ")}.`));
@@ -1204,7 +1442,7 @@ function checkItems(draft) {
1204
1442
  const seen = /* @__PURE__ */ new Set();
1205
1443
  const repeated = /* @__PURE__ */ new Set();
1206
1444
  for (const [index, item] of items.entries()) {
1207
- if (!isRecord$5(item)) continue;
1445
+ if (!isRecord$6(item)) continue;
1208
1446
  const ordinal = index + 1;
1209
1447
  if (isMissingId(item.id)) issues.push(issue("ig_item_id_required", [
1210
1448
  "items",
@@ -1301,7 +1539,7 @@ function validateDraft(type, draft) {
1301
1539
  function validateDraftInScope(type, draft) {
1302
1540
  const descriptor = getActivityTypeDescriptor(type);
1303
1541
  if (descriptor === void 0) throw new UnknownActivityTypeError(String(type));
1304
- const reported = isRecord$5(draft) && descriptor.authoring?.checkDraft !== void 0 ? descriptor.authoring.checkDraft(draft).map(normalise) : [];
1542
+ const reported = isRecord$6(draft) && descriptor.authoring?.checkDraft !== void 0 ? descriptor.authoring.checkDraft(draft).map(normalise) : [];
1305
1543
  const issues = [...reported];
1306
1544
  const parsed = readSchema(descriptor.schema, draft, `Activity type "${descriptor.type}"`, { reportInput: true });
1307
1545
  if (!parsed.success) {
@@ -1410,9 +1648,9 @@ function normalise(found) {
1410
1648
  function critiqueDraft(type, draft) {
1411
1649
  const descriptor = getActivityTypeDescriptor(type);
1412
1650
  if (descriptor === void 0) throw new UnknownActivityTypeError(String(type));
1651
+ if (!isRecord$6(draft)) return [];
1413
1652
  const critique = descriptor.authoring?.critique;
1414
- if (!isRecord$5(draft) || critique === void 0) return [];
1415
- return withValidationScope(() => critique(draft)).map(normaliseFinding);
1653
+ return withValidationScope(() => [...critique === void 0 ? [] : critique(draft), ...recordingFindings(draft)]).map(normaliseFinding);
1416
1654
  }
1417
1655
  //#endregion
1418
1656
  //#region src/ai-critique.ts
@@ -1448,6 +1686,10 @@ const NOT_WORDS = /* @__PURE__ */ new Set([
1448
1686
  "bankId",
1449
1687
  "url",
1450
1688
  "captionsUrl",
1689
+ "src",
1690
+ "srclang",
1691
+ "poster",
1692
+ "speaker",
1451
1693
  "match",
1452
1694
  "playback",
1453
1695
  "recording",
@@ -2289,65 +2531,19 @@ function interactiveVideoFromDrafts(input) {
2289
2531
  };
2290
2532
  }
2291
2533
  //#endregion
2292
- //#region src/ai-writing.ts
2534
+ //#region src/ai-written.ts
2535
+ /**
2536
+ * What the AI features about a written response share: reading a model's text
2537
+ * fields, and finding each correction's quote in the learner's text.
2538
+ *
2539
+ * Internal. `ai-writing.ts` (feedback on a draft) and `ai-grading.ts` (a
2540
+ * grade) are their public faces, and hold every correction to the same
2541
+ * anchoring.
2542
+ */
2293
2543
  /** The most corrections one piece of writing feedback may carry. More is refused, not cut. */
2294
2544
  const AI_WRITING_MAX_CORRECTIONS = 20;
2295
2545
  /** The longest quote, correction, explanation or criterion comment, in code points. */
2296
2546
  const AI_WRITING_MAX_FIELD_LENGTH = 500;
2297
- /** The longest `category` or `band`: a tag, not a sentence. */
2298
- const MAX_TAG_LENGTH = 40;
2299
- /**
2300
- * The request for feedback on a draft, or `null` when there is none to give:
2301
- * not a written response, an author who switched explanations off (writing
2302
- * feedback reads `ai.explanations`), a redacted item — feedback is for
2303
- * practice, where the item is whole — or a draft with nothing in it.
2304
- *
2305
- * The facts are the task as the learner saw it, the draft verbatim with its
2306
- * word count recomputed as the grader will count it, and the rubric the answer
2307
- * will be graded on. `previousFeedback` is the text of the feedback already
2308
- * shown on earlier drafts, so a model can speak to what changed. `group` is the
2309
- * item group the task sits in: its material, the text a summary is of, goes
2310
- * with the request as `material`.
2311
- */
2312
- function aiWritingFeedbackRequest(input) {
2313
- const { data, response, learnerLocale } = input;
2314
- if (!aiSupports(data.type, "writing-feedback") || !aiAllowedByContent(data, "writing-feedback") || data.redacted === true) return null;
2315
- const text = response?.type === "written-response" ? response.text : "";
2316
- if (typeof text !== "string" || text.trim() === "") return null;
2317
- const item = data;
2318
- const wordCount = countWords(text);
2319
- const minWords = typeof item.minWords === "number" ? item.minWords : 0;
2320
- const maxWords = typeof item.maxWords === "number" ? item.maxWords : Number.POSITIVE_INFINITY;
2321
- const criteria = Array.isArray(item.rubric?.criteria) ? item.rubric.criteria : null;
2322
- const rubric = criteria === null || criteria.length === 0 ? null : criteria.map((criterion) => ({
2323
- name: criterion.name,
2324
- ...typeof criterion.description === "string" ? { description: criterion.description } : {},
2325
- weight: criterion.weight
2326
- }));
2327
- const previousFeedback = [...input.previousFeedback ?? []];
2328
- const material = materialFacts(input.group);
2329
- return {
2330
- feature: "writing-feedback",
2331
- facts: {
2332
- activityType: "written-response",
2333
- activityId: data.id,
2334
- title: data.title,
2335
- ...typeof data.locale === "string" ? { locale: data.locale } : {},
2336
- prompt: typeof item.prompt === "string" ? item.prompt : "",
2337
- text,
2338
- wordCount,
2339
- minWords,
2340
- maxWords,
2341
- withinWordBounds: wordCount >= minWords && wordCount <= maxWords,
2342
- rubric,
2343
- ...typeof item.languageTarget === "string" ? { languageTarget: item.languageTarget } : {}
2344
- },
2345
- draftNumber: previousFeedback.length + 1,
2346
- previousFeedback,
2347
- ...material !== null ? { material } : {},
2348
- ...learnerLocale !== void 0 ? { learnerLocale } : {}
2349
- };
2350
- }
2351
2547
  /**
2352
2548
  * Where `quote` sits in the draft: at `claimed`, when the model named a range —
2353
2549
  * which must then hold the quote — or else at its first occurrence not already
@@ -2446,7 +2642,7 @@ function checkCorrections(raw, text) {
2446
2642
  refusal: "malformed"
2447
2643
  };
2448
2644
  const explanation = textField(entry.explanation, 500);
2449
- const category = textField(entry.category, MAX_TAG_LENGTH);
2645
+ const category = textField(entry.category, 40);
2450
2646
  if (!explanation.ok) return explanation;
2451
2647
  if (!category.ok) return category;
2452
2648
  let claimed;
@@ -2478,6 +2674,385 @@ function checkCorrections(raw, text) {
2478
2674
  corrections
2479
2675
  };
2480
2676
  }
2677
+ //#endregion
2678
+ //#region src/ai-grading.ts
2679
+ /**
2680
+ * How far a model's level probabilities may sum from 1 and still be a
2681
+ * distribution: rounding in a model's output, not a different judgement.
2682
+ */
2683
+ const AI_GRADING_PROBABILITY_TOLERANCE = LEVEL_PROBABILITY_TOLERANCE;
2684
+ /**
2685
+ * The request for a model's grade of a submitted written response, or why there
2686
+ * is none to make.
2687
+ *
2688
+ * Grading happens on the server, from the whole item: the rubric is what a
2689
+ * model is held to, so an item without one — or one whose criteria are not each
2690
+ * a name and a weight — gives no request, nor does a `redact()` projection or
2691
+ * an empty response. A criterion's `levels`, when there are any, must be ones
2692
+ * the grading functions can read; levels they cannot read give no request
2693
+ * rather than a grade on a scale the author did not mean. `group` is the item
2694
+ * group the task sits in: its material goes with the request as `material`, as
2695
+ * on feedback on writing.
2696
+ *
2697
+ * The author's AI switch (`ai.explanations`) is not read: grading is the
2698
+ * deployment's decision, made on the server, not help a learner asks for.
2699
+ */
2700
+ function aiGradingRequest(input) {
2701
+ const { data, response, learnerLocale } = input;
2702
+ if (data.type !== "written-response") return {
2703
+ ok: false,
2704
+ problem: "not-written-response"
2705
+ };
2706
+ if (data.redacted === true) return {
2707
+ ok: false,
2708
+ problem: "redacted"
2709
+ };
2710
+ const item = data;
2711
+ const rubric = rubricFacts(item.rubric);
2712
+ if (!rubric.ok) return rubric;
2713
+ const text = response?.type === "written-response" ? response.text : void 0;
2714
+ if (typeof text !== "string" || text.trim() === "") return {
2715
+ ok: false,
2716
+ problem: "empty-response"
2717
+ };
2718
+ const wordCount = countWords(text);
2719
+ const minWords = typeof item.minWords === "number" ? item.minWords : 0;
2720
+ const maxWords = typeof item.maxWords === "number" ? item.maxWords : Number.POSITIVE_INFINITY;
2721
+ const material = materialFacts(input.group);
2722
+ return {
2723
+ ok: true,
2724
+ request: {
2725
+ feature: "grading",
2726
+ facts: {
2727
+ activityType: "written-response",
2728
+ activityId: data.id,
2729
+ title: data.title,
2730
+ ...typeof data.locale === "string" ? { locale: data.locale } : {},
2731
+ prompt: typeof item.prompt === "string" ? item.prompt : "",
2732
+ text,
2733
+ wordCount,
2734
+ minWords,
2735
+ maxWords,
2736
+ withinWordBounds: wordCount >= minWords && wordCount <= maxWords,
2737
+ rubric: rubric.criteria,
2738
+ ...typeof item.languageTarget === "string" ? { languageTarget: item.languageTarget } : {}
2739
+ },
2740
+ ...material !== null ? { material } : {},
2741
+ ...learnerLocale !== void 0 ? { learnerLocale } : {}
2742
+ }
2743
+ };
2744
+ }
2745
+ /** The rubric as a model is given it, each criterion's levels read. */
2746
+ function rubricFacts(rubric) {
2747
+ const listed = isRecord(rubric) ? rubric.criteria : void 0;
2748
+ if (!Array.isArray(listed) || listed.length === 0) return {
2749
+ ok: false,
2750
+ problem: "no-rubric"
2751
+ };
2752
+ const criteria = [];
2753
+ const names = /* @__PURE__ */ new Set();
2754
+ for (const [index, criterion] of listed.entries()) {
2755
+ if (!isRecord(criterion)) return {
2756
+ ok: false,
2757
+ problem: "no-rubric",
2758
+ path: [
2759
+ "rubric",
2760
+ "criteria",
2761
+ index
2762
+ ]
2763
+ };
2764
+ const { name, description, weight } = criterion;
2765
+ if (typeof name !== "string" || name.trim() === "" || typeof weight !== "number") return {
2766
+ ok: false,
2767
+ problem: "no-rubric",
2768
+ path: [
2769
+ "rubric",
2770
+ "criteria",
2771
+ index
2772
+ ]
2773
+ };
2774
+ if (names.has(levelKey(name))) return {
2775
+ ok: false,
2776
+ problem: "duplicate-criteria",
2777
+ path: [
2778
+ "rubric",
2779
+ "criteria",
2780
+ index,
2781
+ "name"
2782
+ ]
2783
+ };
2784
+ names.add(levelKey(name));
2785
+ const levels = readRubricLevels(criterion.levels);
2786
+ if (!levels.ok) return {
2787
+ ok: false,
2788
+ problem: "unreadable-levels",
2789
+ path: [
2790
+ "rubric",
2791
+ "criteria",
2792
+ index,
2793
+ "levels"
2794
+ ]
2795
+ };
2796
+ criteria.push({
2797
+ name,
2798
+ ...typeof description === "string" ? { description } : {},
2799
+ weight,
2800
+ levels: levels.levels
2801
+ });
2802
+ }
2803
+ return {
2804
+ ok: true,
2805
+ criteria
2806
+ };
2807
+ }
2808
+ /**
2809
+ * The rubric's criteria by name, folded as level labels are: composed, trimmed,
2810
+ * spaced once, lower-cased. A model that writes "task achievement" for "Task
2811
+ * achievement" judged that criterion. The request refused a rubric with two
2812
+ * names alike once folded, so a folded name is one criterion's alone.
2813
+ */
2814
+ function criterionIndex(rubric) {
2815
+ const folded = new Map(rubric.map((criterion, index) => [levelKey(criterion.name), index]));
2816
+ return (name) => folded.get(levelKey(name));
2817
+ }
2818
+ /** A finite number. */
2819
+ const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
2820
+ /**
2821
+ * One criterion's judgement, on that criterion's scale: a level it has, with
2822
+ * any probabilities a distribution over its levels whose likeliest is the
2823
+ * level chosen; or, without levels, a score from 0 to its `maxScore` (1 by
2824
+ * default).
2825
+ */
2826
+ function judgeCriterion(entry, criterion) {
2827
+ const { level, score, maxScore, probabilities } = entry;
2828
+ const levels = criterion.levels;
2829
+ if (levels === null) {
2830
+ if (level !== void 0 || probabilities !== void 0) return {
2831
+ ok: false,
2832
+ refusal: "out-of-scale"
2833
+ };
2834
+ if (score === void 0) return {
2835
+ ok: false,
2836
+ refusal: "misses-criteria"
2837
+ };
2838
+ if (!isNumber(score) || maxScore !== void 0 && !isNumber(maxScore)) return {
2839
+ ok: false,
2840
+ refusal: "malformed"
2841
+ };
2842
+ const max = maxScore ?? 1;
2843
+ if (!(max > 0) || score < 0 || score - max > max * 1e-9) return {
2844
+ ok: false,
2845
+ refusal: "out-of-scale"
2846
+ };
2847
+ return {
2848
+ ok: true,
2849
+ judged: {
2850
+ name: criterion.name,
2851
+ score: Math.min(1, score / max)
2852
+ }
2853
+ };
2854
+ }
2855
+ if (score !== void 0 || maxScore !== void 0) return {
2856
+ ok: false,
2857
+ refusal: "out-of-scale"
2858
+ };
2859
+ if (level === void 0) return {
2860
+ ok: false,
2861
+ refusal: "misses-criteria"
2862
+ };
2863
+ if (typeof level !== "string") return {
2864
+ ok: false,
2865
+ refusal: "malformed"
2866
+ };
2867
+ const chosen = levels.findIndex((one) => levelKey(one.label) === levelKey(level));
2868
+ if (chosen === -1) return {
2869
+ ok: false,
2870
+ refusal: "out-of-scale"
2871
+ };
2872
+ const judged = {
2873
+ name: criterion.name,
2874
+ level: levels[chosen].label,
2875
+ score: chosen / (levels.length - 1)
2876
+ };
2877
+ if (probabilities === void 0) return {
2878
+ ok: true,
2879
+ judged
2880
+ };
2881
+ if (!Array.isArray(probabilities)) return {
2882
+ ok: false,
2883
+ refusal: "malformed"
2884
+ };
2885
+ const values = levelDistribution(probabilities, levels.length, chosen);
2886
+ if (values === null) return {
2887
+ ok: false,
2888
+ refusal: "out-of-scale"
2889
+ };
2890
+ return {
2891
+ ok: true,
2892
+ judged: {
2893
+ ...judged,
2894
+ probabilities: values
2895
+ }
2896
+ };
2897
+ }
2898
+ /**
2899
+ * Checks a grade a port returned for `request`. Whole or nothing, as every AI
2900
+ * text is: a reply the SDK doubts in any part is refused, and is no grade —
2901
+ * re-ask the model or send the response to a person; never record a 0.
2902
+ *
2903
+ * - **Every criterion of the rubric, once** (`misses-criteria`). Names match as
2904
+ * written, or folded when the folded name is one criterion's alone. Leaving
2905
+ * one out, inventing or renaming one, judging one twice or calling one not
2906
+ * applicable refuses the reply: a total renormalised over fewer criteria
2907
+ * is a grade of a different task.
2908
+ * - **On each criterion's own scale** (`out-of-scale`). With levels: a `level`
2909
+ * the criterion has, as labelled (case and spacing aside), and any
2910
+ * `probabilities` a distribution over its levels, lowest first, whose
2911
+ * likeliest is the level chosen. Without levels: a `score` from 0 to its
2912
+ * `maxScore`, 1 by default.
2913
+ * - **Every correction quotes the response** (`misquotes-answer`), anchored as
2914
+ * feedback on writing anchors it.
2915
+ * - **Text fields** — a criterion's `comment` (at most
2916
+ * `AI_WRITING_MAX_FIELD_LENGTH` code points), `feedback` and `rationale`
2917
+ * (at most `AI_TEXT_MAX_LENGTH`) — are plain text, cleaned; longer is
2918
+ * `too-long`. `requiresHumanReview` is a boolean.
2919
+ *
2920
+ * The total is not computed here: `gradeFromJudgement` computes it, as
2921
+ * `gradeFromRubric` computes every grade in the SDK.
2922
+ */
2923
+ function checkAiGrading(raw, request) {
2924
+ if (!isRecord(raw)) return {
2925
+ ok: false,
2926
+ refusal: "malformed"
2927
+ };
2928
+ const { criteria: listed, feedback, corrections, rationale, requiresHumanReview } = raw;
2929
+ if (listed === void 0) return {
2930
+ ok: false,
2931
+ refusal: "misses-criteria"
2932
+ };
2933
+ if (!Array.isArray(listed)) return {
2934
+ ok: false,
2935
+ refusal: "malformed"
2936
+ };
2937
+ const rubric = request.facts.rubric;
2938
+ const indexOf = criterionIndex(rubric);
2939
+ const judged = rubric.map(() => void 0);
2940
+ for (const entry of listed) {
2941
+ if (!isRecord(entry)) return {
2942
+ ok: false,
2943
+ refusal: "malformed"
2944
+ };
2945
+ const name = entry.name;
2946
+ const notApplicable = entry.notApplicable;
2947
+ const written = entry.comment;
2948
+ const scale = {
2949
+ level: entry.level,
2950
+ score: entry.score,
2951
+ maxScore: entry.maxScore,
2952
+ probabilities: entry.probabilities
2953
+ };
2954
+ if (typeof name !== "string" || notApplicable !== void 0 && typeof notApplicable !== "boolean") return {
2955
+ ok: false,
2956
+ refusal: "malformed"
2957
+ };
2958
+ const index = indexOf(name);
2959
+ if (index === void 0 || judged[index] !== void 0 || notApplicable === true) return {
2960
+ ok: false,
2961
+ refusal: "misses-criteria"
2962
+ };
2963
+ const criterion = rubric[index];
2964
+ const scaled = judgeCriterion(scale, criterion);
2965
+ if (!scaled.ok) return scaled;
2966
+ const comment = textField(written, 500);
2967
+ if (!comment.ok) return comment;
2968
+ judged[index] = {
2969
+ ...scaled.judged,
2970
+ ...comment.value !== void 0 ? { comment: comment.value } : {}
2971
+ };
2972
+ }
2973
+ if (judged.some((one) => one === void 0)) return {
2974
+ ok: false,
2975
+ refusal: "misses-criteria"
2976
+ };
2977
+ const anchored = checkCorrections(corrections, request.facts.text);
2978
+ if (!anchored.ok) return anchored;
2979
+ const said = textField(feedback, AI_TEXT_MAX_LENGTH);
2980
+ if (!said.ok) return said;
2981
+ const why = textField(rationale, AI_TEXT_MAX_LENGTH);
2982
+ if (!why.ok) return why;
2983
+ if (requiresHumanReview !== void 0 && typeof requiresHumanReview !== "boolean") return {
2984
+ ok: false,
2985
+ refusal: "malformed"
2986
+ };
2987
+ const provenance = provenanceOf(raw.provenance);
2988
+ const usage = usageOf(raw.usage);
2989
+ return {
2990
+ ok: true,
2991
+ judgement: {
2992
+ criteria: judged,
2993
+ feedback: said.value ?? null,
2994
+ corrections: anchored.corrections,
2995
+ ...why.value !== void 0 ? { rationale: why.value } : {},
2996
+ requiresHumanReview: requiresHumanReview === true,
2997
+ ...provenance !== void 0 ? { provenance } : {},
2998
+ ...usage !== void 0 ? { usage } : {}
2999
+ }
3000
+ };
3001
+ }
3002
+ //#endregion
3003
+ //#region src/ai-writing.ts
3004
+ /**
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`.
3016
+ */
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 = 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
+ }
2481
3056
  /** A criterion score a grade could be computed from: finite, 0 or more, and not above its maximum. */
2482
3057
  function isJudgedScore(score, maxScore) {
2483
3058
  if (score === void 0) return maxScore === void 0 || typeof maxScore === "number" && maxScore > 0;
@@ -2524,7 +3099,7 @@ function checkCriteria(raw, rubric) {
2524
3099
  ok: false,
2525
3100
  refusal: "malformed"
2526
3101
  };
2527
- const band = textField(entry.band, MAX_TAG_LENGTH);
3102
+ const band = textField(entry.band, 40);
2528
3103
  const comment = textField(entry.comment, 500);
2529
3104
  if (!band.ok) return band;
2530
3105
  if (!comment.ok) return comment;
@@ -2596,6 +3171,6 @@ function checkAiWritingFeedback(raw, request) {
2596
3171
  };
2597
3172
  }
2598
3173
  //#endregion
2599
- export { AI_TEXT_MAX_LENGTH as A, readAiTextResult as B, validateItemGroupDraft as C, checkAiCoaching as D, aiCoachingRequest as E, aiSupports as F, buildAiFacts as I, checkAiExplanation as L, aiExplanationRequest as M, aiGradeOf as N, AI_EXPLANATION_TYPES as O, aiHintRequest as P, checkAiHint as R, critiqueItemGroupDraft as S, AI_COACHING_MAX_WORDS as T, AI_MATERIAL_MAX_LENGTH as V, critiqueDraft as _, AI_DRAFTS_MAX_COUNT as a, createItemGroupDraft as b, aiDraftsRequest as c, interactiveVideoFromDrafts as d, AI_CRITIQUE_MAX_FIELD_LENGTH as f, createDraft as g, checkAiCritique as h, checkAiWritingFeedback as i, aiAllowedByContent as j, AI_HINT_TYPES as k, checkAiDrafts as l, aiCritiqueRequest as m, AI_WRITING_MAX_FIELD_LENGTH as n, AI_DRAFTS_MAX_REPAIRS as o, AI_CRITIQUE_MAX_FINDINGS as p, aiWritingFeedbackRequest as r, aiDraftsRepairRequest as s, AI_WRITING_MAX_CORRECTIONS as t, generateDrafts as u, validateDraft as v, AI_COACHING_MAX_TIP_LENGTH as w, critiqueDrafts as x, createInteractiveVideoDraft as y, hintRevealsAnswer as z };
3174
+ export { AI_COACHING_MAX_TIP_LENGTH as A, aiHintRequest as B, createItemGroupDraft as C, listMediaRefs as D, validateItemGroupDraft as E, AI_HINT_TYPES as F, hintRevealsAnswer as G, buildAiFacts as H, AI_TEXT_MAX_LENGTH as I, readAiTextResult as K, aiAllowedByContent as L, aiCoachingRequest as M, checkAiCoaching as N, mapMediaRefs as O, AI_EXPLANATION_TYPES as P, aiExplanationRequest as R, createInteractiveVideoDraft as S, critiqueItemGroupDraft as T, checkAiExplanation as U, aiSupports as V, checkAiHint as W, aiCritiqueRequest as _, checkAiGrading as a, critiqueDraft as b, AI_DRAFTS_MAX_COUNT as c, aiDraftsRequest as d, checkAiDrafts as f, AI_CRITIQUE_MAX_FINDINGS as g, AI_CRITIQUE_MAX_FIELD_LENGTH as h, aiGradingRequest as i, AI_COACHING_MAX_WORDS as j, transcriptOf as k, AI_DRAFTS_MAX_REPAIRS as l, interactiveVideoFromDrafts as m, checkAiWritingFeedback as n, AI_WRITING_MAX_CORRECTIONS as o, generateDrafts as p, AI_MATERIAL_MAX_LENGTH as q, AI_GRADING_PROBABILITY_TOLERANCE as r, AI_WRITING_MAX_FIELD_LENGTH as s, aiWritingFeedbackRequest as t, aiDraftsRepairRequest as u, checkAiCritique as v, critiqueDrafts as w, validateDraft as x, createDraft as y, aiGradeOf as z };
2600
3175
 
2601
- //# sourceMappingURL=ai-writing-CPpH9Qxq.js.map
3176
+ //# sourceMappingURL=ai-writing-OJ5m_1EB.js.map