@intellectif/lk-core 1.5.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 (78) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/ai-check.cjs +2 -2
  3. package/dist/ai-check.d.cts +1 -1
  4. package/dist/ai-check.d.ts +1 -1
  5. package/dist/ai-check.js +2 -2
  6. package/dist/{ai-BYryFkF9.d.ts → ai-i3jwXnpX.d.cts} +45 -4
  7. package/dist/ai-i3jwXnpX.d.cts.map +1 -0
  8. package/dist/{ai-BYryFkF9.d.cts → ai-i3jwXnpX.d.ts} +45 -4
  9. package/dist/ai-i3jwXnpX.d.ts.map +1 -0
  10. package/dist/{ai-writing-Cen7-3qs.cjs → ai-writing-C4YRVrFi.cjs} +275 -15
  11. package/dist/ai-writing-C4YRVrFi.cjs.map +1 -0
  12. package/dist/{ai-writing-D8PdhJJz.js → ai-writing-OJ5m_1EB.js} +273 -31
  13. package/dist/ai-writing-OJ5m_1EB.js.map +1 -0
  14. package/dist/{index-D7R3Ll8R.d.cts → index--U7hHV0s.d.cts} +2 -2
  15. package/dist/{index-D7R3Ll8R.d.cts.map → index--U7hHV0s.d.cts.map} +1 -1
  16. package/dist/{index-qMbxed1B.d.cts → index-CFRgZLhH.d.cts} +41 -12
  17. package/dist/index-CFRgZLhH.d.cts.map +1 -0
  18. package/dist/{index-DUaW1Tj5.d.ts → index-DeD8EI1D.d.ts} +2 -2
  19. package/dist/{index-DUaW1Tj5.d.ts.map → index-DeD8EI1D.d.ts.map} +1 -1
  20. package/dist/{index-wMc1E_aM.d.ts → index-Dj92T76o.d.ts} +2 -2
  21. package/dist/{index-wMc1E_aM.d.ts.map → index-Dj92T76o.d.ts.map} +1 -1
  22. package/dist/{index-Bg0aeWtM.d.cts → index-Uh1u57yq.d.cts} +2 -2
  23. package/dist/{index-Bg0aeWtM.d.cts.map → index-Uh1u57yq.d.cts.map} +1 -1
  24. package/dist/{index-CxpiWedW.d.ts → index-WBHSrIUU.d.ts} +41 -12
  25. package/dist/index-WBHSrIUU.d.ts.map +1 -0
  26. package/dist/index.cjs +63 -7
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +137 -5
  29. package/dist/index.d.cts.map +1 -1
  30. package/dist/index.d.ts +137 -5
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +58 -8
  33. package/dist/index.js.map +1 -1
  34. package/dist/{item-group-DdSUCUjM.js → item-group-BbvvH_Ob.js} +3 -2
  35. package/dist/item-group-BbvvH_Ob.js.map +1 -0
  36. package/dist/{item-group-DEB7nLID.cjs → item-group-CQRbb8Rx.cjs} +3 -2
  37. package/dist/item-group-CQRbb8Rx.cjs.map +1 -0
  38. package/dist/{registry-DaZRpqjW.js → registry-B23o9UWh.js} +65 -18
  39. package/dist/registry-B23o9UWh.js.map +1 -0
  40. package/dist/{registry-Ce00A_OW.cjs → registry-Ih5n4PVZ.cjs} +82 -17
  41. package/dist/registry-Ih5n4PVZ.cjs.map +1 -0
  42. package/dist/{schemas-DksueYsu.cjs → schemas-Q2dyOgc2.cjs} +77 -14
  43. package/dist/schemas-Q2dyOgc2.cjs.map +1 -0
  44. package/dist/{schemas-BioKFf3F.js → schemas-yId-bJhD.js} +72 -15
  45. package/dist/schemas-yId-bJhD.js.map +1 -0
  46. package/dist/schemas.cjs +5 -2
  47. package/dist/schemas.d.cts +2 -2
  48. package/dist/schemas.d.ts +2 -2
  49. package/dist/schemas.js +4 -3
  50. package/dist/{scoring-BKNNTz-s.js → scoring-CLMdk8i9.js} +2 -2
  51. package/dist/{scoring-BKNNTz-s.js.map → scoring-CLMdk8i9.js.map} +1 -1
  52. package/dist/{scoring-lKQVYsxy.cjs → scoring-CaPS6q8b.cjs} +2 -2
  53. package/dist/{scoring-lKQVYsxy.cjs.map → scoring-CaPS6q8b.cjs.map} +1 -1
  54. package/dist/scoring.cjs +2 -2
  55. package/dist/scoring.d.cts +2 -2
  56. package/dist/scoring.d.ts +2 -2
  57. package/dist/scoring.js +2 -2
  58. package/dist/{xapi-NYrTlpID.cjs → xapi-BX0hvRRB.cjs} +2 -2
  59. package/dist/{xapi-NYrTlpID.cjs.map → xapi-BX0hvRRB.cjs.map} +1 -1
  60. package/dist/{xapi-wR6gu4qS.js → xapi-Cdk9fTvV.js} +2 -2
  61. package/dist/{xapi-wR6gu4qS.js.map → xapi-Cdk9fTvV.js.map} +1 -1
  62. package/dist/xapi.cjs +1 -1
  63. package/dist/xapi.d.cts +1 -1
  64. package/dist/xapi.d.ts +1 -1
  65. package/dist/xapi.js +1 -1
  66. package/package.json +1 -1
  67. package/dist/ai-BYryFkF9.d.cts.map +0 -1
  68. package/dist/ai-BYryFkF9.d.ts.map +0 -1
  69. package/dist/ai-writing-Cen7-3qs.cjs.map +0 -1
  70. package/dist/ai-writing-D8PdhJJz.js.map +0 -1
  71. package/dist/index-CxpiWedW.d.ts.map +0 -1
  72. package/dist/index-qMbxed1B.d.cts.map +0 -1
  73. package/dist/item-group-DEB7nLID.cjs.map +0 -1
  74. package/dist/item-group-DdSUCUjM.js.map +0 -1
  75. package/dist/registry-Ce00A_OW.cjs.map +0 -1
  76. package/dist/registry-DaZRpqjW.js.map +0 -1
  77. package/dist/schemas-BioKFf3F.js.map +0 -1
  78. package/dist/schemas-DksueYsu.cjs.map +0 -1
@@ -1,6 +1,6 @@
1
- import { B as covers, F as readRubricLevels, G as issue, H as isRecord$5, I as countWords, K as pathKey, L as DRAFT_ISSUE_SEVERITY, M as LEVEL_PROBABILITY_TOLERANCE, N as levelDistribution, O as keyPositionFinding, P as levelKey, R as checkMedia, U as isUnset, V as isMissingId, W as isUnwritten, X as groupCaptionsField, Y as GROUP_CAPTIONS_REVEAL_DICTATION, _t as fold, a as readSchema, bt as revealsAnswer, et as UnknownActivityTypeError, ft as withValidationScope, gt as containsWords, k as normaliseFinding, nt as alignDictation, q as refusesEmpty, t as getActivityTypeDescriptor, vt as numberedPassage, yt as placeholderOrder, z as coveredPathsOf } from "./registry-DaZRpqjW.js";
2
- import { F as isGradeInRange, _ as gradeFromRubric, c as validateSpeechAssessment, r as score, s as alignReadAloud } from "./scoring-BKNNTz-s.js";
3
- import { a as INTERACTIVE_VIDEO_ITEM_TYPES, i as validateItemGroup, t as ItemGroupSchema, u as isInteractiveVideoItemType } from "./item-group-DdSUCUjM.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",
@@ -2929,6 +3171,6 @@ function checkAiWritingFeedback(raw, request) {
2929
3171
  };
2930
3172
  }
2931
3173
  //#endregion
2932
- export { checkAiCoaching as A, checkAiExplanation as B, createItemGroupDraft as C, AI_COACHING_MAX_TIP_LENGTH as D, validateItemGroupDraft as E, aiExplanationRequest as F, hintRevealsAnswer as H, aiGradeOf as I, aiHintRequest as L, AI_HINT_TYPES as M, AI_TEXT_MAX_LENGTH as N, AI_COACHING_MAX_WORDS as O, aiAllowedByContent as P, aiSupports as R, createInteractiveVideoDraft as S, critiqueItemGroupDraft as T, readAiTextResult as U, checkAiHint as V, AI_MATERIAL_MAX_LENGTH 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_EXPLANATION_TYPES as j, aiCoachingRequest as k, AI_DRAFTS_MAX_REPAIRS as l, interactiveVideoFromDrafts as m, checkAiWritingFeedback as n, AI_WRITING_MAX_CORRECTIONS as o, generateDrafts as p, 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, buildAiFacts 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 };
2933
3175
 
2934
- //# sourceMappingURL=ai-writing-D8PdhJJz.js.map
3176
+ //# sourceMappingURL=ai-writing-OJ5m_1EB.js.map