@intellectif/lk-core 1.7.0 → 1.9.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.
- package/CHANGELOG.md +33 -0
- package/dist/ai-check.cjs +77 -3
- package/dist/ai-check.cjs.map +1 -1
- package/dist/ai-check.d.cts +11 -3
- package/dist/ai-check.d.cts.map +1 -1
- package/dist/ai-check.d.ts +11 -3
- package/dist/ai-check.d.ts.map +1 -1
- package/dist/ai-check.js +77 -3
- package/dist/ai-check.js.map +1 -1
- package/dist/{ai-writing-B7oMCt66.js → ai-writing-DQjI9EFN.js} +2514 -136
- package/dist/ai-writing-DQjI9EFN.js.map +1 -0
- package/dist/{ai-writing-DrPTV3R2.cjs → ai-writing-DbpZpGRk.cjs} +2614 -122
- package/dist/ai-writing-DbpZpGRk.cjs.map +1 -0
- package/dist/{ai-B1wYrqcB.d.ts → ai-zhc9yhRj.d.cts} +454 -2
- package/dist/ai-zhc9yhRj.d.cts.map +1 -0
- package/dist/{ai-B1wYrqcB.d.cts → ai-zhc9yhRj.d.ts} +454 -2
- package/dist/ai-zhc9yhRj.d.ts.map +1 -0
- package/dist/{index-XCCoPoTU.d.cts → index-BeC8tAWf.d.cts} +2 -2
- package/dist/{index-XCCoPoTU.d.cts.map → index-BeC8tAWf.d.cts.map} +1 -1
- package/dist/{index-BU99GFTl.d.ts → index-Byar5VVs.d.ts} +2 -2
- package/dist/{index-BU99GFTl.d.ts.map → index-Byar5VVs.d.ts.map} +1 -1
- package/dist/{index-DP9rvyOz.d.cts → index-Ccj8oL_E.d.cts} +2 -2
- package/dist/{index-DP9rvyOz.d.cts.map → index-Ccj8oL_E.d.cts.map} +1 -1
- package/dist/{index-BTkMQtws.d.ts → index-CxkqS62X.d.ts} +89 -7
- package/dist/index-CxkqS62X.d.ts.map +1 -0
- package/dist/{index-BM8LeDWn.d.cts → index-DLsUnGHt.d.cts} +89 -7
- package/dist/index-DLsUnGHt.d.cts.map +1 -0
- package/dist/{index-BdXs4-en.d.ts → index-f4YuMgFb.d.ts} +2 -2
- package/dist/{index-BdXs4-en.d.ts.map → index-f4YuMgFb.d.ts.map} +1 -1
- package/dist/index.cjs +282 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +262 -7
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +262 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +269 -7
- package/dist/index.js.map +1 -1
- package/dist/{item-group-CeDk7mfM.js → item-group-CKZk3txt.js} +2 -2
- package/dist/{item-group-CeDk7mfM.js.map → item-group-CKZk3txt.js.map} +1 -1
- package/dist/{item-group-8P3WFuUK.cjs → item-group-COP5OwEC.cjs} +2 -2
- package/dist/{item-group-8P3WFuUK.cjs.map → item-group-COP5OwEC.cjs.map} +1 -1
- package/dist/{registry-D92hz9iA.js → registry-Bw9grsCN.js} +339 -12
- package/dist/registry-Bw9grsCN.js.map +1 -0
- package/dist/{registry-hzrkTeIT.cjs → registry-C9O9txy5.cjs} +368 -11
- package/dist/registry-C9O9txy5.cjs.map +1 -0
- package/dist/{schemas-C7TViWCg.js → schemas-BJe3W6Xb.js} +3 -3
- package/dist/{schemas-C7TViWCg.js.map → schemas-BJe3W6Xb.js.map} +1 -1
- package/dist/{schemas-BWeskONy.cjs → schemas-CXlemRvB.cjs} +3 -3
- package/dist/{schemas-BWeskONy.cjs.map → schemas-CXlemRvB.cjs.map} +1 -1
- package/dist/schemas.cjs +3 -3
- package/dist/schemas.d.cts +1 -1
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +3 -3
- package/dist/{scoring-DD1GHcLI.js → scoring-BGKdff2i.js} +214 -26
- package/dist/scoring-BGKdff2i.js.map +1 -0
- package/dist/{scoring-CtkCFyFT.cjs → scoring-Db1CZeAn.cjs} +219 -25
- package/dist/scoring-Db1CZeAn.cjs.map +1 -0
- package/dist/scoring.cjs +3 -2
- package/dist/scoring.d.cts +3 -3
- package/dist/scoring.d.ts +3 -3
- package/dist/scoring.js +3 -3
- package/dist/{xapi-Cgicg-zU.js → xapi-BtBm8AWZ.js} +2 -2
- package/dist/{xapi-Cgicg-zU.js.map → xapi-BtBm8AWZ.js.map} +1 -1
- package/dist/{xapi-7btSpx6v.cjs → xapi-DOAqH9__.cjs} +2 -2
- package/dist/{xapi-7btSpx6v.cjs.map → xapi-DOAqH9__.cjs.map} +1 -1
- package/dist/xapi.cjs +1 -1
- package/dist/xapi.d.cts +1 -1
- package/dist/xapi.d.ts +1 -1
- package/dist/xapi.js +1 -1
- package/package.json +2 -2
- package/vectors/README.md +23 -0
- package/vectors/scoring.json +11967 -1
- package/vectors/validation.json +1298 -1
- package/vectors/variants.d.mts +20 -0
- package/vectors/variants.json +5753 -0
- package/vectors/variants.mjs +67 -0
- package/dist/ai-B1wYrqcB.d.cts.map +0 -1
- package/dist/ai-B1wYrqcB.d.ts.map +0 -1
- package/dist/ai-writing-B7oMCt66.js.map +0 -1
- package/dist/ai-writing-DrPTV3R2.cjs.map +0 -1
- package/dist/index-BM8LeDWn.d.cts.map +0 -1
- package/dist/index-BTkMQtws.d.ts.map +0 -1
- package/dist/registry-D92hz9iA.js.map +0 -1
- package/dist/registry-hzrkTeIT.cjs.map +0 -1
- package/dist/scoring-CtkCFyFT.cjs.map +0 -1
- package/dist/scoring-DD1GHcLI.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import {
|
|
3
|
-
import { a as INTERACTIVE_VIDEO_ITEM_TYPES, i as validateItemGroup, t as ItemGroupSchema, u as isInteractiveVideoItemType } from "./item-group-
|
|
1
|
+
import { $ as GROUP_CAPTIONS_REVEAL_DICTATION, A as keyPositionFinding, B as countWords, Ct as passageWithoutPlaceholders, Dt as PLACEHOLDER_RE, G as isMissingId, H as checkMedia, I as LEVEL_PROBABILITY_TOLERANCE, J as isUnwritten, K as isRecord$8, L as levelDistribution, M as normaliseFinding, N as policyFor, R as levelKey, St as numberedPassage, Tt as revealsAnswer, U as coveredPathsOf, V as DRAFT_ISSUE_SEVERITY, W as covers, X as pathKey, Y as issue, Z as refusesEmpty, a as readSchema, bt as containsWords, et as groupCaptionsField, gt as withValidationScope, it as UnknownActivityTypeError, j as finding, ot as alignDictation, q as isUnset, t as getActivityTypeDescriptor, v as SPEAKING_MEASURES, wt as placeholderOrder, xt as fold, z as readRubricLevels } from "./registry-Bw9grsCN.js";
|
|
2
|
+
import { C as canonicalJson, I as isGradeInRange, c as alignReadAloud, g as seededShuffle, l as validateSpeechAssessment, r as score, v as gradeFromRubric } from "./scoring-BGKdff2i.js";
|
|
3
|
+
import { a as INTERACTIVE_VIDEO_ITEM_TYPES, i as validateItemGroup, t as ItemGroupSchema, u as isInteractiveVideoItemType } from "./item-group-CKZk3txt.js";
|
|
4
4
|
// 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$
|
|
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$
|
|
65
|
+
if (!isRecord$7(group)) return null;
|
|
66
66
|
const stimulus = group.stimulus;
|
|
67
|
-
if (!isRecord$
|
|
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$
|
|
75
|
-
const alt = isRecord$
|
|
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 } : {}
|
|
@@ -254,7 +254,7 @@ function isAnswerOption(detail, chosen) {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
const firstString = (value) => Array.isArray(value) ? value[0] : value;
|
|
257
|
-
const listOf$
|
|
257
|
+
const listOf$4 = (value) => value === void 0 ? null : Array.isArray(value) ? [...value] : [value];
|
|
258
258
|
/** The response a learner has before touching anything. */
|
|
259
259
|
function emptyResponse(type) {
|
|
260
260
|
switch (type) {
|
|
@@ -337,7 +337,7 @@ function buildAiFacts(data, response, details) {
|
|
|
337
337
|
blanks: placeholderOrder(item.passage).map((id, index) => {
|
|
338
338
|
const blank = blanks.get(id);
|
|
339
339
|
const detail = byId.get(id);
|
|
340
|
-
const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf$
|
|
340
|
+
const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf$4(detail?.correctResponse);
|
|
341
341
|
return {
|
|
342
342
|
id,
|
|
343
343
|
position: index + 1,
|
|
@@ -747,7 +747,7 @@ function aiCoachingRequest(input) {
|
|
|
747
747
|
...learnerLocale !== void 0 ? { learnerLocale } : {}
|
|
748
748
|
};
|
|
749
749
|
}
|
|
750
|
-
const isRecord$
|
|
750
|
+
const isRecord$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
751
751
|
/** A symbol as it is compared: composed, and without the spaces around it. */
|
|
752
752
|
const symbolKey = (symbol) => symbol.normalize("NFC").trim();
|
|
753
753
|
/**
|
|
@@ -796,7 +796,7 @@ function checkAiCoaching(raw, request) {
|
|
|
796
796
|
});
|
|
797
797
|
const seen = /* @__PURE__ */ new Set();
|
|
798
798
|
for (const entry of entries) {
|
|
799
|
-
if (!isRecord$
|
|
799
|
+
if (!isRecord$6(entry) || typeof entry.itemId !== "string" || typeof entry.tip !== "string") return {
|
|
800
800
|
ok: false,
|
|
801
801
|
refusal: "malformed"
|
|
802
802
|
};
|
|
@@ -844,7 +844,7 @@ function checkAiCoaching(raw, request) {
|
|
|
844
844
|
/** A sound a model names, checked against the sounds the engine reported for the word. */
|
|
845
845
|
function checkSound(raw, fact) {
|
|
846
846
|
if (raw === void 0 || raw === null) return;
|
|
847
|
-
if (!isRecord$
|
|
847
|
+
if (!isRecord$6(raw) || typeof raw.expected !== "string" || raw.heard !== void 0 && raw.heard !== null && typeof raw.heard !== "string") return {
|
|
848
848
|
ok: false,
|
|
849
849
|
refusal: "malformed"
|
|
850
850
|
};
|
|
@@ -877,13 +877,13 @@ const MAIN_KINDS = [
|
|
|
877
877
|
"image",
|
|
878
878
|
"embed"
|
|
879
879
|
];
|
|
880
|
-
function isRecord$
|
|
880
|
+
function isRecord$5(value) {
|
|
881
881
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
882
882
|
}
|
|
883
883
|
function valueAt(root, path) {
|
|
884
884
|
let at = root;
|
|
885
885
|
for (const key of path) {
|
|
886
|
-
if (typeof key === "number" ? !Array.isArray(at) : !isRecord$
|
|
886
|
+
if (typeof key === "number" ? !Array.isArray(at) : !isRecord$5(at)) return;
|
|
887
887
|
at = at[key];
|
|
888
888
|
}
|
|
889
889
|
return at;
|
|
@@ -896,7 +896,7 @@ function slotsOf(item) {
|
|
|
896
896
|
return Array.isArray(slots) ? slots : [];
|
|
897
897
|
}
|
|
898
898
|
function holderRefs(holder, path, kindOfSlot, owner) {
|
|
899
|
-
if (!isRecord$
|
|
899
|
+
if (!isRecord$5(holder)) return [];
|
|
900
900
|
const main = typeof holder.type === "string" && MAIN_KINDS.includes(holder.type) ? holder.type : kindOfSlot;
|
|
901
901
|
const refs = [];
|
|
902
902
|
const add = (field, kind, url) => {
|
|
@@ -911,7 +911,7 @@ function holderRefs(holder, path, kindOfSlot, owner) {
|
|
|
911
911
|
if (main !== void 0) add(["url"], main, typeof holder.url === "string" ? holder.url : null);
|
|
912
912
|
if (typeof holder.captionsUrl === "string") add(["captionsUrl"], "captions", holder.captionsUrl);
|
|
913
913
|
if (Array.isArray(holder.tracks)) holder.tracks.forEach((track, index) => {
|
|
914
|
-
if (isRecord$
|
|
914
|
+
if (isRecord$5(track) && typeof track.src === "string") refs.push({
|
|
915
915
|
path: [
|
|
916
916
|
...path,
|
|
917
917
|
"tracks",
|
|
@@ -929,7 +929,7 @@ function holderRefs(holder, path, kindOfSlot, owner) {
|
|
|
929
929
|
return refs;
|
|
930
930
|
}
|
|
931
931
|
function activityRefs(item, base) {
|
|
932
|
-
if (!isRecord$
|
|
932
|
+
if (!isRecord$5(item)) return [];
|
|
933
933
|
const owner = typeof item.id === "string" ? { activityId: item.id } : {};
|
|
934
934
|
return slotsOf(item).flatMap((slot) => holderRefs(valueAt(item, slot.path), [...base, ...slot.path], slot.kind, owner));
|
|
935
935
|
}
|
|
@@ -954,11 +954,11 @@ function activityRefs(item, base) {
|
|
|
954
954
|
* ```
|
|
955
955
|
*/
|
|
956
956
|
function listMediaRefs(entry) {
|
|
957
|
-
if (!isRecord$
|
|
957
|
+
if (!isRecord$5(entry)) return [];
|
|
958
958
|
if (entry.type !== "item-group") return activityRefs(entry, []);
|
|
959
959
|
const refs = [];
|
|
960
960
|
const stimulus = entry.stimulus;
|
|
961
|
-
if (isRecord$
|
|
961
|
+
if (isRecord$5(stimulus)) {
|
|
962
962
|
const owner = typeof stimulus.id === "string" ? { stimulusId: stimulus.id } : {};
|
|
963
963
|
refs.push(...holderRefs(stimulus.media, ["stimulus", "media"], void 0, owner));
|
|
964
964
|
}
|
|
@@ -1018,7 +1018,7 @@ function mapMediaRefs(entry, fn) {
|
|
|
1018
1018
|
/** A holder's own text: its transcript, else its script as "Speaker: text" lines. */
|
|
1019
1019
|
function ownText(holder) {
|
|
1020
1020
|
if (typeof holder.transcript === "string" && holder.transcript.trim() !== "") return holder.transcript;
|
|
1021
|
-
const turns = Array.isArray(holder.script) ? holder.script.flatMap((turn) => isRecord$
|
|
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()}`] : []) : [];
|
|
1022
1022
|
return turns.length > 0 ? turns.join("\n") : null;
|
|
1023
1023
|
}
|
|
1024
1024
|
/**
|
|
@@ -1036,14 +1036,14 @@ function ownText(holder) {
|
|
|
1036
1036
|
* ```
|
|
1037
1037
|
*/
|
|
1038
1038
|
function transcriptOf(holder) {
|
|
1039
|
-
if (!isRecord$
|
|
1040
|
-
return ownText(holder) ?? (isRecord$
|
|
1039
|
+
if (!isRecord$5(holder)) return null;
|
|
1040
|
+
return ownText(holder) ?? (isRecord$5(holder.media) ? ownText(holder.media) : null);
|
|
1041
1041
|
}
|
|
1042
1042
|
// src/authoring/recordings.ts
|
|
1043
|
-
const written$
|
|
1043
|
+
const written$2 = (value) => typeof value === "string" && value.trim() !== "";
|
|
1044
1044
|
/** The script's turns that have a speaker and words, or none. */
|
|
1045
|
-
function turnsOf(script) {
|
|
1046
|
-
return Array.isArray(script) ? script.flatMap((turn) => isRecord$
|
|
1045
|
+
function turnsOf$1(script) {
|
|
1046
|
+
return Array.isArray(script) ? script.flatMap((turn) => isRecord$8(turn) && typeof turn.speaker === "string" && written$2(turn.text) ? [{
|
|
1047
1047
|
speaker: turn.speaker,
|
|
1048
1048
|
text: turn.text
|
|
1049
1049
|
}] : []) : [];
|
|
@@ -1051,24 +1051,24 @@ function turnsOf(script) {
|
|
|
1051
1051
|
const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1052
1052
|
/** A transcript's words, with each `Speaker:` label of the script's speakers taken out. */
|
|
1053
1053
|
function transcriptWords(transcript, speakers) {
|
|
1054
|
-
const labels = [...new Set(speakers.map((speaker) => speaker.trim()).filter(written$
|
|
1054
|
+
const labels = [...new Set(speakers.map((speaker) => speaker.trim()).filter(written$2))];
|
|
1055
1055
|
const unlabelled = labels.length === 0 ? transcript : transcript.replace(new RegExp(`(^|\\s)(?:${labels.map(escaped).join("|")})\\s*:`, "giu"), "$1");
|
|
1056
1056
|
return fold(unlabelled);
|
|
1057
1057
|
}
|
|
1058
1058
|
/** What a recording says, as words: its transcript, or else its script's turns. */
|
|
1059
1059
|
function spokenWords(holder) {
|
|
1060
|
-
const turns = turnsOf(holder.script);
|
|
1061
|
-
if (written$
|
|
1060
|
+
const turns = turnsOf$1(holder.script);
|
|
1061
|
+
if (written$2(holder.transcript)) return transcriptWords(holder.transcript, turns.map((turn) => turn.speaker));
|
|
1062
1062
|
return turns.length > 0 ? fold(turns.map((turn) => turn.text).join(" ")) : null;
|
|
1063
1063
|
}
|
|
1064
1064
|
const sameWords = (a, b) => a.length === b.length && a.every((word, index) => word === b[index]);
|
|
1065
1065
|
/** One object's own rules: text on media with nothing to say, and a transcript its script contradicts. */
|
|
1066
1066
|
function holderFindings(holder, path) {
|
|
1067
|
-
if (!isRecord$
|
|
1067
|
+
if (!isRecord$8(holder)) return [];
|
|
1068
1068
|
const findings = [];
|
|
1069
|
-
const turns = turnsOf(holder.script);
|
|
1070
|
-
if ((written$
|
|
1071
|
-
if (written$
|
|
1069
|
+
const turns = turnsOf$1(holder.script);
|
|
1070
|
+
if ((written$2(holder.transcript) || turns.length > 0) && (holder.type === "image" || holder.type === "embed")) findings.push(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) {
|
|
1072
1072
|
const fromTranscript = transcriptWords(holder.transcript, turns.map((turn) => turn.speaker));
|
|
1073
1073
|
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."));
|
|
1074
1074
|
}
|
|
@@ -1088,20 +1088,777 @@ function recordingFindings(item) {
|
|
|
1088
1088
|
const findings = listMediaRefs(item).filter((ref) => ref.path[ref.path.length - 1] === "url").flatMap((ref) => holderFindings(ref.holder, ref.path.slice(0, -1)));
|
|
1089
1089
|
const field = typeof item.type === "string" ? SPOKEN_FIELD[item.type] : void 0;
|
|
1090
1090
|
const media = item.media;
|
|
1091
|
-
if (field === void 0 || !isRecord$
|
|
1091
|
+
if (field === void 0 || !isRecord$8(media)) return findings;
|
|
1092
1092
|
const said = spokenWords(media);
|
|
1093
|
-
const accepted = [item[field], ...item.type === "dictation" && Array.isArray(item.acceptedTranscripts) ? item.acceptedTranscripts : []].filter(written$
|
|
1094
|
-
if (said !== null && accepted.length > 0 && !accepted.some((text) => sameWords(said, fold(text)))) findings.push(finding("media_transcript_differs", ["media", written$
|
|
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, fold(text)))) findings.push(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.`));
|
|
1095
1095
|
return findings;
|
|
1096
1096
|
}
|
|
1097
1097
|
/** A group's material: its own transcript against its script, and its media's rules. */
|
|
1098
1098
|
function stimulusFindings(stimulus) {
|
|
1099
|
-
if (!isRecord$
|
|
1099
|
+
if (!isRecord$8(stimulus)) return [];
|
|
1100
1100
|
return [...holderFindings({
|
|
1101
1101
|
transcript: stimulus.transcript,
|
|
1102
1102
|
script: stimulus.script
|
|
1103
1103
|
}, ["stimulus"]), ...holderFindings(stimulus.media, ["stimulus", "media"])];
|
|
1104
1104
|
}
|
|
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) => canonicalJson(a ?? null) === 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" ? 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 fold(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 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"), 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) ? 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 (!isRecord$8(entry)) return [];
|
|
1662
|
+
if (entry.type === "item-group") {
|
|
1663
|
+
const group = [String(index)];
|
|
1664
|
+
return listOf$2(entry.items).flatMap((item, at) => isRecord$8(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) => isRecord$8(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
|
+
...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
|
+
...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) => isRecord$8(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
|
+
...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
|
+
...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) => isRecord$8(option) ? stringOf$2(option.text) : ""),
|
|
1783
|
+
...listOf$2(item.gaps).flatMap((gap) => isRecord$8(gap) ? listOf$2(gap.choices).map((choice) => isRecord$8(choice) ? stringOf$2(choice.text) : "") : []),
|
|
1784
|
+
...listOf$2(item.banks).flatMap((bank) => isRecord$8(bank) ? listOf$2(bank.choices).map((choice) => isRecord$8(choice) ? stringOf$2(choice.text) : "") : [])
|
|
1785
|
+
];
|
|
1786
|
+
return {
|
|
1787
|
+
path: one.path,
|
|
1788
|
+
owner: key(one.path),
|
|
1789
|
+
words: fold(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 fold(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 (!isRecord$8(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: 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 = fold(answer);
|
|
1816
|
+
if (!distinctive(words) || 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 (containsWords(place.words, words)) {
|
|
1820
|
+
reported.add(place.owner);
|
|
1821
|
+
findings.push({
|
|
1822
|
+
...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
|
+
...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
|
+
}
|
|
1105
1862
|
// src/authoring/item-group.ts
|
|
1106
1863
|
/**
|
|
1107
1864
|
* A new, empty reading or listening testlet for an editor to start from.
|
|
@@ -1197,7 +1954,7 @@ function createInteractiveVideoDraft(context) {
|
|
|
1197
1954
|
* {@link validateItemGroupDraft} already reports it.
|
|
1198
1955
|
*/
|
|
1199
1956
|
function critiqueItemGroupDraft(draft) {
|
|
1200
|
-
if (!isRecord$
|
|
1957
|
+
if (!isRecord$8(draft)) return [];
|
|
1201
1958
|
const items = Array.isArray(draft.items) ? draft.items : [];
|
|
1202
1959
|
return withValidationScope(() => [
|
|
1203
1960
|
...stimulusFindings(draft.stimulus),
|
|
@@ -1213,11 +1970,15 @@ function critiqueItemGroupDraft(draft) {
|
|
|
1213
1970
|
* skipped.
|
|
1214
1971
|
*/
|
|
1215
1972
|
function critiqueDrafts(entries) {
|
|
1216
|
-
return withValidationScope(() => [
|
|
1973
|
+
return withValidationScope(() => [
|
|
1974
|
+
...entries.flatMap((entry, index) => repathed(isRecord$8(entry) && entry.type === "item-group" ? critiqueItemGroupDraft(entry) : critiqueItem(entry), [index])),
|
|
1975
|
+
...keyPositionFinding(entries, []),
|
|
1976
|
+
...paperFindings(entries)
|
|
1977
|
+
]);
|
|
1217
1978
|
}
|
|
1218
1979
|
/** One item's findings, or none for an item the critic cannot place. */
|
|
1219
1980
|
function critiqueItem(item) {
|
|
1220
|
-
if (!isRecord$
|
|
1981
|
+
if (!isRecord$8(item) || typeof item.type !== "string") return [];
|
|
1221
1982
|
if (getActivityTypeDescriptor(item.type) === void 0) return [];
|
|
1222
1983
|
return critiqueDraft(item.type, item);
|
|
1223
1984
|
}
|
|
@@ -1230,7 +1991,7 @@ function validateItemGroupDraft(draft) {
|
|
|
1230
1991
|
}
|
|
1231
1992
|
function validateItemGroupDraftInScope(draft) {
|
|
1232
1993
|
const issues = [];
|
|
1233
|
-
if (!isRecord$
|
|
1994
|
+
if (!isRecord$8(draft)) {
|
|
1234
1995
|
issues.push(issue("ig_not_an_object", [], "A group must be an object with a stimulus and its items."));
|
|
1235
1996
|
return {
|
|
1236
1997
|
status: "invalid",
|
|
@@ -1238,7 +1999,7 @@ function validateItemGroupDraftInScope(draft) {
|
|
|
1238
1999
|
};
|
|
1239
2000
|
}
|
|
1240
2001
|
issues.push(...checkGroupIdentity(draft));
|
|
1241
|
-
if (isRecord$
|
|
2002
|
+
if (isRecord$8(draft.stimulus)) issues.push(...checkStimulus(draft.stimulus));
|
|
1242
2003
|
else if (isUnset(draft.stimulus)) issues.push(issue("ig_stimulus_required", ["stimulus"], "Add the passage, recording or image the questions are about."));
|
|
1243
2004
|
issues.push(...checkItems(draft));
|
|
1244
2005
|
if (draft.timeline !== void 0 && draft.timeline !== null) issues.push(...checkTimeline(draft));
|
|
@@ -1288,10 +2049,10 @@ function validateItemGroupDraftInScope(draft) {
|
|
|
1288
2049
|
function checkTimeline(draft) {
|
|
1289
2050
|
const issues = [];
|
|
1290
2051
|
const timeline = draft.timeline;
|
|
1291
|
-
if (!isRecord$
|
|
1292
|
-
const stimulus = isRecord$
|
|
2052
|
+
if (!isRecord$8(timeline)) return issues;
|
|
2053
|
+
const stimulus = isRecord$8(draft.stimulus) ? draft.stimulus : void 0;
|
|
1293
2054
|
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."));
|
|
1294
|
-
else if (stimulus !== void 0 && isRecord$
|
|
2055
|
+
else if (stimulus !== void 0 && isRecord$8(stimulus.media) && !isUnwritten(stimulus.media.type) && stimulus.media.type !== "video") issues.push(issue("ig_timeline_stimulus_kind", [
|
|
1295
2056
|
"stimulus",
|
|
1296
2057
|
"media",
|
|
1297
2058
|
"type"
|
|
@@ -1300,7 +2061,7 @@ function checkTimeline(draft) {
|
|
|
1300
2061
|
const items = Array.isArray(draft.items) ? draft.items : [];
|
|
1301
2062
|
const itemIndexById = /* @__PURE__ */ new Map();
|
|
1302
2063
|
items.forEach((item, index) => {
|
|
1303
|
-
if (!isRecord$
|
|
2064
|
+
if (!isRecord$8(item)) return;
|
|
1304
2065
|
if (typeof item.id === "string" && item.id !== "") itemIndexById.set(item.id, index);
|
|
1305
2066
|
const type = item.type;
|
|
1306
2067
|
if (typeof type === "string" && type !== "" && type !== "item-group" && !isInteractiveVideoItemType(type) && getActivityTypeDescriptor(type) !== void 0) issues.push(issue("ig_timeline_item_type", [
|
|
@@ -1318,7 +2079,7 @@ function checkTimeline(draft) {
|
|
|
1318
2079
|
const quizIds = /* @__PURE__ */ new Set();
|
|
1319
2080
|
const placed = /* @__PURE__ */ new Set();
|
|
1320
2081
|
cues.forEach((cue, cueIndex) => {
|
|
1321
|
-
if (!isRecord$
|
|
2082
|
+
if (!isRecord$8(cue)) return;
|
|
1322
2083
|
const at = (field, ...rest) => [
|
|
1323
2084
|
"timeline",
|
|
1324
2085
|
"cues",
|
|
@@ -1349,7 +2110,7 @@ function checkTimeline(draft) {
|
|
|
1349
2110
|
const chapters = Array.isArray(timeline.chapters) ? timeline.chapters : [];
|
|
1350
2111
|
let previousAt;
|
|
1351
2112
|
chapters.forEach((chapter, index) => {
|
|
1352
|
-
if (!isRecord$
|
|
2113
|
+
if (!isRecord$8(chapter)) return;
|
|
1353
2114
|
if (isUnwritten(chapter.title)) issues.push(issue("ig_timeline_chapter_title", [
|
|
1354
2115
|
"timeline",
|
|
1355
2116
|
"chapters",
|
|
@@ -1410,14 +2171,14 @@ function checkStimulus(stimulus) {
|
|
|
1410
2171
|
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."));
|
|
1411
2172
|
else if (typeof kind !== "string" || !KINDS$1.has(kind)) {
|
|
1412
2173
|
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.`));
|
|
1413
|
-
if (isRecord$
|
|
2174
|
+
if (isRecord$8(stimulus.media)) issues.push(...checkMedia(stimulus.media, at("media")));
|
|
1414
2175
|
return issues;
|
|
1415
2176
|
}
|
|
1416
2177
|
const hasBody = typeof stimulus.body === "string" && stimulus.body.trim() !== "";
|
|
1417
2178
|
const htmlPresent = !isUnset(stimulus.bodyHtml);
|
|
1418
2179
|
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."));
|
|
1419
2180
|
if (kind !== "text" && isUnset(stimulus.media)) issues.push(issue("ig_stimulus_media_required", at("media"), "Add the recording, video or image."));
|
|
1420
|
-
else if (isRecord$
|
|
2181
|
+
else if (isRecord$8(stimulus.media)) {
|
|
1421
2182
|
const allowed = MEDIA_FOR_KIND[kind];
|
|
1422
2183
|
const mediaType = stimulus.media.type;
|
|
1423
2184
|
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 ")}.`));
|
|
@@ -1444,7 +2205,7 @@ function checkItems(draft) {
|
|
|
1444
2205
|
const seen = /* @__PURE__ */ new Set();
|
|
1445
2206
|
const repeated = /* @__PURE__ */ new Set();
|
|
1446
2207
|
for (const [index, item] of items.entries()) {
|
|
1447
|
-
if (!isRecord$
|
|
2208
|
+
if (!isRecord$8(item)) continue;
|
|
1448
2209
|
const ordinal = index + 1;
|
|
1449
2210
|
if (isMissingId(item.id)) issues.push(issue("ig_item_id_required", [
|
|
1450
2211
|
"items",
|
|
@@ -1540,7 +2301,7 @@ function validateDraft(type, draft) {
|
|
|
1540
2301
|
function validateDraftInScope(type, draft) {
|
|
1541
2302
|
const descriptor = getActivityTypeDescriptor(type);
|
|
1542
2303
|
if (descriptor === void 0) throw new UnknownActivityTypeError(String(type));
|
|
1543
|
-
const reported = isRecord$
|
|
2304
|
+
const reported = isRecord$8(draft) && descriptor.authoring?.checkDraft !== void 0 ? descriptor.authoring.checkDraft(draft).map(normalise) : [];
|
|
1544
2305
|
const issues = [...reported];
|
|
1545
2306
|
const parsed = readSchema(descriptor.schema, draft, `Activity type "${descriptor.type}"`, { reportInput: true });
|
|
1546
2307
|
if (!parsed.success) {
|
|
@@ -1649,7 +2410,7 @@ function normalise(found) {
|
|
|
1649
2410
|
function critiqueDraft(type, draft) {
|
|
1650
2411
|
const descriptor = getActivityTypeDescriptor(type);
|
|
1651
2412
|
if (descriptor === void 0) throw new UnknownActivityTypeError(String(type));
|
|
1652
|
-
if (!isRecord$
|
|
2413
|
+
if (!isRecord$8(draft)) return [];
|
|
1653
2414
|
const critique = descriptor.authoring?.critique;
|
|
1654
2415
|
return withValidationScope(() => [...critique === void 0 ? [] : critique(draft), ...recordingFindings(draft)]).map(normaliseFinding);
|
|
1655
2416
|
}
|
|
@@ -1752,7 +2513,7 @@ function aiCritiqueRequest(input) {
|
|
|
1752
2513
|
...authorLocale !== void 0 ? { authorLocale } : {}
|
|
1753
2514
|
};
|
|
1754
2515
|
}
|
|
1755
|
-
const isRecord$
|
|
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$
|
|
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$
|
|
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
|
};
|
|
@@ -1838,8 +2599,8 @@ const DRAFT_TYPES = [
|
|
|
1838
2599
|
"read-aloud",
|
|
1839
2600
|
"written-response"
|
|
1840
2601
|
];
|
|
1841
|
-
const isRecord$
|
|
1842
|
-
const text = (description) => ({
|
|
2602
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2603
|
+
const text$1 = (description) => ({
|
|
1843
2604
|
type: "string",
|
|
1844
2605
|
description
|
|
1845
2606
|
});
|
|
@@ -1847,14 +2608,14 @@ const FEEDBACK = {
|
|
|
1847
2608
|
type: "object",
|
|
1848
2609
|
description: "Optional overall feedback, shown after answering.",
|
|
1849
2610
|
properties: {
|
|
1850
|
-
correct: text("Shown when the learner passes."),
|
|
1851
|
-
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.")
|
|
1852
2613
|
}
|
|
1853
2614
|
};
|
|
1854
2615
|
/** Every field a model writes, and the shape of each. A name means one thing in every type that uses it. */
|
|
1855
2616
|
const FIELDS = {
|
|
1856
|
-
title: text("A short name for the item, shown to the learner above it. Never the answer, or any part of it."),
|
|
1857
|
-
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."),
|
|
1858
2619
|
mode: {
|
|
1859
2620
|
type: "string",
|
|
1860
2621
|
enum: ["single", "multi"],
|
|
@@ -1868,13 +2629,13 @@ const FIELDS = {
|
|
|
1868
2629
|
type: "object",
|
|
1869
2630
|
required: ["text", "isCorrect"],
|
|
1870
2631
|
properties: {
|
|
1871
|
-
text: text("The option as the learner reads it."),
|
|
2632
|
+
text: text$1("The option as the learner reads it."),
|
|
1872
2633
|
isCorrect: { type: "boolean" },
|
|
1873
|
-
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.")
|
|
1874
2635
|
}
|
|
1875
2636
|
}
|
|
1876
2637
|
},
|
|
1877
|
-
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."),
|
|
1878
2639
|
blanks: {
|
|
1879
2640
|
type: "array",
|
|
1880
2641
|
minItems: 1,
|
|
@@ -1889,7 +2650,7 @@ const FIELDS = {
|
|
|
1889
2650
|
items: { type: "string" },
|
|
1890
2651
|
description: "Every answer that is right, spelled as a learner would type it."
|
|
1891
2652
|
},
|
|
1892
|
-
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.")
|
|
1893
2654
|
}
|
|
1894
2655
|
}
|
|
1895
2656
|
},
|
|
@@ -1908,17 +2669,17 @@ const FIELDS = {
|
|
|
1908
2669
|
type: "object",
|
|
1909
2670
|
required: ["text", "isCorrect"],
|
|
1910
2671
|
properties: {
|
|
1911
|
-
text: text("A choice."),
|
|
2672
|
+
text: text$1("A choice."),
|
|
1912
2673
|
isCorrect: { type: "boolean" }
|
|
1913
2674
|
}
|
|
1914
2675
|
}
|
|
1915
2676
|
} }
|
|
1916
2677
|
}
|
|
1917
2678
|
},
|
|
1918
|
-
transcript: text("What the learner hears and types: words from the source, as they are said there."),
|
|
1919
|
-
referenceText: text("What the learner reads aloud: a sentence or two from the source."),
|
|
1920
|
-
instructions: text("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."),
|
|
1921
|
-
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."),
|
|
1922
2683
|
minWords: {
|
|
1923
2684
|
type: "integer",
|
|
1924
2685
|
minimum: 0
|
|
@@ -1937,8 +2698,8 @@ const FIELDS = {
|
|
|
1937
2698
|
type: "object",
|
|
1938
2699
|
required: ["name"],
|
|
1939
2700
|
properties: {
|
|
1940
|
-
name: text("A short name."),
|
|
1941
|
-
description: text("What it looks for.")
|
|
2701
|
+
name: text$1("A short name."),
|
|
2702
|
+
description: text$1("What it looks for.")
|
|
1942
2703
|
}
|
|
1943
2704
|
}
|
|
1944
2705
|
} }
|
|
@@ -1946,7 +2707,7 @@ const FIELDS = {
|
|
|
1946
2707
|
feedback: FEEDBACK
|
|
1947
2708
|
};
|
|
1948
2709
|
/** The fields a model writes for each type: a draft's own names, never an id or a setting. */
|
|
1949
|
-
const WRITES = {
|
|
2710
|
+
const WRITES$1 = {
|
|
1950
2711
|
"multiple-choice": [
|
|
1951
2712
|
"title",
|
|
1952
2713
|
"instructions",
|
|
@@ -1997,9 +2758,9 @@ function itemShape(types, captions) {
|
|
|
1997
2758
|
const properties = { type: {
|
|
1998
2759
|
type: "string",
|
|
1999
2760
|
enum: [...types],
|
|
2000
|
-
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(". ")}.`
|
|
2001
2762
|
} };
|
|
2002
|
-
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];
|
|
2003
2764
|
if (captions) properties.caption = {
|
|
2004
2765
|
type: "integer",
|
|
2005
2766
|
minimum: 0,
|
|
@@ -2016,7 +2777,7 @@ function itemShape(types, captions) {
|
|
|
2016
2777
|
};
|
|
2017
2778
|
}
|
|
2018
2779
|
/** A reply of up to `max` drafts — exactly `exactly` on a repair. */
|
|
2019
|
-
function replyShape(types, captions, max, exactly) {
|
|
2780
|
+
function replyShape$1(types, captions, max, exactly) {
|
|
2020
2781
|
return {
|
|
2021
2782
|
type: "object",
|
|
2022
2783
|
required: ["drafts"],
|
|
@@ -2058,7 +2819,7 @@ function aiDraftsRequest(input) {
|
|
|
2058
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)}.`);
|
|
2059
2820
|
let facts;
|
|
2060
2821
|
if (source.kind === "captions") {
|
|
2061
|
-
const captions = (Array.isArray(source.cues) ? source.cues : []).flatMap((cue, index) => isRecord$
|
|
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) ? [{
|
|
2062
2823
|
index,
|
|
2063
2824
|
start: cue.start,
|
|
2064
2825
|
end: cue.end,
|
|
@@ -2086,17 +2847,17 @@ function aiDraftsRequest(input) {
|
|
|
2086
2847
|
...level !== void 0 ? { level } : {},
|
|
2087
2848
|
...instructions !== void 0 ? { instructions } : {}
|
|
2088
2849
|
},
|
|
2089
|
-
shape: replyShape(types, facts.kind === "captions", count ?? 50),
|
|
2850
|
+
shape: replyShape$1(types, facts.kind === "captions", count ?? 50),
|
|
2090
2851
|
...settings !== void 0 ? { settings } : {}
|
|
2091
2852
|
};
|
|
2092
2853
|
}
|
|
2093
|
-
const stringOf = (value) => typeof value === "string" ? value : "";
|
|
2094
|
-
const listOf = (value) => Array.isArray(value) ? value : [];
|
|
2095
|
-
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;
|
|
2096
2857
|
function feedbackOf(entry) {
|
|
2097
|
-
if (!isRecord$
|
|
2098
|
-
const correct = written(entry.feedback.correct);
|
|
2099
|
-
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);
|
|
2100
2861
|
return correct === void 0 && incorrect === void 0 ? {} : { feedback: {
|
|
2101
2862
|
...correct !== void 0 ? { correct } : {},
|
|
2102
2863
|
...incorrect !== void 0 ? { incorrect } : {}
|
|
@@ -2104,7 +2865,7 @@ function feedbackOf(entry) {
|
|
|
2104
2865
|
}
|
|
2105
2866
|
/** The instructions a model wrote for the learner, when it wrote any. */
|
|
2106
2867
|
function instructionsOf(entry) {
|
|
2107
|
-
const instructions = written(entry.instructions);
|
|
2868
|
+
const instructions = written$1(entry.instructions);
|
|
2108
2869
|
return instructions !== void 0 ? { instructions } : {};
|
|
2109
2870
|
}
|
|
2110
2871
|
/** A type's content, read from what a model wrote. */
|
|
@@ -2112,14 +2873,14 @@ function contentOf(type, entry, newId) {
|
|
|
2112
2873
|
switch (type) {
|
|
2113
2874
|
case "multiple-choice": return {
|
|
2114
2875
|
...instructionsOf(entry),
|
|
2115
|
-
question: stringOf(entry.question),
|
|
2876
|
+
question: stringOf$1(entry.question),
|
|
2116
2877
|
...typeof entry.mode === "string" ? { mode: entry.mode } : {},
|
|
2117
|
-
options: listOf(entry.options).map((option) => {
|
|
2118
|
-
const fields = isRecord$
|
|
2119
|
-
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);
|
|
2120
2881
|
return {
|
|
2121
2882
|
id: newId(),
|
|
2122
|
-
text: stringOf(fields.text),
|
|
2883
|
+
text: stringOf$1(fields.text),
|
|
2123
2884
|
isCorrect: fields.isCorrect === true,
|
|
2124
2885
|
...feedback !== void 0 ? { feedback } : {}
|
|
2125
2886
|
};
|
|
@@ -2128,13 +2889,13 @@ function contentOf(type, entry, newId) {
|
|
|
2128
2889
|
};
|
|
2129
2890
|
case "fill-in-the-blanks": return {
|
|
2130
2891
|
...instructionsOf(entry),
|
|
2131
|
-
passage: stringOf(entry.passage),
|
|
2132
|
-
blanks: listOf(entry.blanks).map((blank, index) => {
|
|
2133
|
-
const fields = isRecord$
|
|
2134
|
-
const hint = written(fields.hint);
|
|
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);
|
|
2135
2896
|
return {
|
|
2136
2897
|
id: String(index + 1),
|
|
2137
|
-
acceptedAnswers: listOf(fields.acceptedAnswers).filter((answer) => typeof answer === "string"),
|
|
2898
|
+
acceptedAnswers: listOf$1(fields.acceptedAnswers).filter((answer) => typeof answer === "string"),
|
|
2138
2899
|
...hint !== void 0 ? { hint } : {}
|
|
2139
2900
|
};
|
|
2140
2901
|
}),
|
|
@@ -2142,14 +2903,14 @@ function contentOf(type, entry, newId) {
|
|
|
2142
2903
|
};
|
|
2143
2904
|
case "gap-select": return {
|
|
2144
2905
|
...instructionsOf(entry),
|
|
2145
|
-
passage: stringOf(entry.passage),
|
|
2146
|
-
gaps: listOf(entry.gaps).map((gap, index) => {
|
|
2147
|
-
const marked = listOf(isRecord$
|
|
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);
|
|
2148
2909
|
const choices = marked.map((choice) => ({
|
|
2149
2910
|
id: newId(),
|
|
2150
|
-
text: stringOf(isRecord$
|
|
2911
|
+
text: stringOf$1(isRecord$2(choice) ? choice.text : void 0)
|
|
2151
2912
|
}));
|
|
2152
|
-
const right = marked.flatMap((choice, at) => isRecord$
|
|
2913
|
+
const right = marked.flatMap((choice, at) => isRecord$2(choice) && choice.isCorrect === true ? [at] : []);
|
|
2153
2914
|
return {
|
|
2154
2915
|
id: String(index + 1),
|
|
2155
2916
|
choices,
|
|
@@ -2159,25 +2920,25 @@ function contentOf(type, entry, newId) {
|
|
|
2159
2920
|
...feedbackOf(entry)
|
|
2160
2921
|
};
|
|
2161
2922
|
case "dictation": return {
|
|
2162
|
-
transcript: stringOf(entry.transcript),
|
|
2923
|
+
transcript: stringOf$1(entry.transcript),
|
|
2163
2924
|
...feedbackOf(entry)
|
|
2164
2925
|
};
|
|
2165
2926
|
case "read-aloud": return {
|
|
2166
|
-
referenceText: stringOf(entry.referenceText),
|
|
2927
|
+
referenceText: stringOf$1(entry.referenceText),
|
|
2167
2928
|
...instructionsOf(entry),
|
|
2168
2929
|
...feedbackOf(entry)
|
|
2169
2930
|
};
|
|
2170
2931
|
default: {
|
|
2171
|
-
const criteria = listOf(isRecord$
|
|
2932
|
+
const criteria = listOf$1(isRecord$2(entry.rubric) ? entry.rubric.criteria : void 0);
|
|
2172
2933
|
return {
|
|
2173
|
-
prompt: stringOf(entry.prompt),
|
|
2934
|
+
prompt: stringOf$1(entry.prompt),
|
|
2174
2935
|
...entry.minWords !== void 0 ? { minWords: entry.minWords } : {},
|
|
2175
2936
|
...entry.maxWords !== void 0 ? { maxWords: entry.maxWords } : {},
|
|
2176
2937
|
...criteria.length > 0 ? { rubric: { criteria: criteria.map((criterion) => {
|
|
2177
|
-
const fields = isRecord$
|
|
2178
|
-
const description = written(fields.description);
|
|
2938
|
+
const fields = isRecord$2(criterion) ? criterion : {};
|
|
2939
|
+
const description = written$1(fields.description);
|
|
2179
2940
|
return {
|
|
2180
|
-
name: stringOf(fields.name),
|
|
2941
|
+
name: stringOf$1(fields.name),
|
|
2181
2942
|
...description !== void 0 ? { description } : {},
|
|
2182
2943
|
weight: 1
|
|
2183
2944
|
};
|
|
@@ -2219,7 +2980,7 @@ function draftFrom(type, entry, id, newId, request) {
|
|
|
2219
2980
|
...startOf(type),
|
|
2220
2981
|
...locale !== void 0 ? { locale } : {},
|
|
2221
2982
|
...request.settings?.[type],
|
|
2222
|
-
title: stringOf(entry.title),
|
|
2983
|
+
title: stringOf$1(entry.title),
|
|
2223
2984
|
...contentOf(type, entry, newId),
|
|
2224
2985
|
schemaVersion: "1.0",
|
|
2225
2986
|
type,
|
|
@@ -2227,7 +2988,7 @@ function draftFrom(type, entry, id, newId, request) {
|
|
|
2227
2988
|
};
|
|
2228
2989
|
}
|
|
2229
2990
|
/** Ids from the host, checked as `createDraft` checks them. */
|
|
2230
|
-
function idSource(newId, caller) {
|
|
2991
|
+
function idSource$1(newId, caller) {
|
|
2231
2992
|
const issued = /* @__PURE__ */ new Set();
|
|
2232
2993
|
return () => {
|
|
2233
2994
|
const id = newId();
|
|
@@ -2243,7 +3004,7 @@ function inAuthorsOrder(drafts, types) {
|
|
|
2243
3004
|
}
|
|
2244
3005
|
function check(raw, request, newId, keep = []) {
|
|
2245
3006
|
const types = request.facts.activityTypes;
|
|
2246
|
-
if (!isRecord$
|
|
3007
|
+
if (!isRecord$2(raw) || !Array.isArray(raw.drafts) || !raw.drafts.every((entry) => isRecord$2(entry) && types.includes(entry.type))) return {
|
|
2247
3008
|
ok: false,
|
|
2248
3009
|
refusal: "malformed"
|
|
2249
3010
|
};
|
|
@@ -2259,7 +3020,7 @@ function check(raw, request, newId, keep = []) {
|
|
|
2259
3020
|
const type = entry.type;
|
|
2260
3021
|
const draft = draftFrom(type, entry, keep[position] ?? newId(), newId, request);
|
|
2261
3022
|
const caption = source.kind === "captions" && Number.isInteger(entry.caption) ? source.captions.find((one) => one.index === entry.caption) : void 0;
|
|
2262
|
-
const said = fold(stringOf(entry.transcript));
|
|
3023
|
+
const said = fold(stringOf$1(entry.transcript));
|
|
2263
3024
|
const clip = type === "dictation" && caption !== void 0 && containsWords(fold(caption.text), said) ? {
|
|
2264
3025
|
start: caption.start,
|
|
2265
3026
|
end: caption.end
|
|
@@ -2311,14 +3072,14 @@ function check(raw, request, newId, keep = []) {
|
|
|
2311
3072
|
* @throws Error when `newId` returns an empty or repeated id.
|
|
2312
3073
|
*/
|
|
2313
3074
|
function checkAiDrafts(raw, request, options) {
|
|
2314
|
-
return check(raw, request, idSource(options.newId, "checkAiDrafts"));
|
|
3075
|
+
return check(raw, request, idSource$1(options.newId, "checkAiDrafts"));
|
|
2315
3076
|
}
|
|
2316
3077
|
/**
|
|
2317
3078
|
* Where a model wrote what a problem names: the draft's paths, but a gap's key
|
|
2318
3079
|
* is its choices, and a reading too long for the host's recording time is the
|
|
2319
3080
|
* text the model chose.
|
|
2320
3081
|
*/
|
|
2321
|
-
function pathForModel(type, problem) {
|
|
3082
|
+
function pathForModel$1(type, problem) {
|
|
2322
3083
|
const { path, code } = problem;
|
|
2323
3084
|
if (type === "gap-select" && path.length === 3 && path[0] === "gaps" && path[2] === "correctChoiceId") return [
|
|
2324
3085
|
"gaps",
|
|
@@ -2333,8 +3094,8 @@ function pathForModel(type, problem) {
|
|
|
2333
3094
|
* dictation in a video with no recording, is the host's to finish, and is not
|
|
2334
3095
|
* sent back.
|
|
2335
3096
|
*/
|
|
2336
|
-
function problemsOf(one) {
|
|
2337
|
-
const writes = WRITES[one.type];
|
|
3097
|
+
function problemsOf$1(one) {
|
|
3098
|
+
const writes = WRITES$1[one.type];
|
|
2338
3099
|
return [...one.validation.issues.map((found) => ({
|
|
2339
3100
|
path: found.path,
|
|
2340
3101
|
code: found.code,
|
|
@@ -2347,7 +3108,7 @@ function problemsOf(one) {
|
|
|
2347
3108
|
severity: "warning"
|
|
2348
3109
|
}))].map((problem) => ({
|
|
2349
3110
|
...problem,
|
|
2350
|
-
path: pathForModel(one.type, problem)
|
|
3111
|
+
path: pathForModel$1(one.type, problem)
|
|
2351
3112
|
})).filter((problem) => writes.includes(problem.path[0]));
|
|
2352
3113
|
}
|
|
2353
3114
|
/**
|
|
@@ -2361,7 +3122,7 @@ function problemsOf(one) {
|
|
|
2361
3122
|
*/
|
|
2362
3123
|
function aiDraftsRepairRequest(request, drafts) {
|
|
2363
3124
|
const repair = ("drafts" in drafts ? drafts.drafts : drafts).flatMap((one) => {
|
|
2364
|
-
const problems = problemsOf(one);
|
|
3125
|
+
const problems = problemsOf$1(one);
|
|
2365
3126
|
return problems.length === 0 ? [] : [{
|
|
2366
3127
|
index: one.index,
|
|
2367
3128
|
draft: one.generated,
|
|
@@ -2372,20 +3133,20 @@ function aiDraftsRepairRequest(request, drafts) {
|
|
|
2372
3133
|
const { repair: _previous, ...first } = request;
|
|
2373
3134
|
return {
|
|
2374
3135
|
...first,
|
|
2375
|
-
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),
|
|
2376
3137
|
repair
|
|
2377
3138
|
};
|
|
2378
3139
|
}
|
|
2379
|
-
const RANK = {
|
|
3140
|
+
const RANK$1 = {
|
|
2380
3141
|
complete: 2,
|
|
2381
3142
|
incomplete: 1,
|
|
2382
3143
|
invalid: 0
|
|
2383
3144
|
};
|
|
2384
3145
|
/** Whether a repaired draft is at least as good as the one it would replace. */
|
|
2385
|
-
function noWorse(repaired, before) {
|
|
3146
|
+
function noWorse$1(repaired, before) {
|
|
2386
3147
|
const warnings = (one) => one.findings.filter((found) => found.severity === "warning").length;
|
|
2387
|
-
const now = RANK[repaired.validation.status];
|
|
2388
|
-
const was = RANK[before.validation.status];
|
|
3148
|
+
const now = RANK$1[repaired.validation.status];
|
|
3149
|
+
const was = RANK$1[before.validation.status];
|
|
2389
3150
|
return now > was || now === was && warnings(repaired) <= warnings(before);
|
|
2390
3151
|
}
|
|
2391
3152
|
/**
|
|
@@ -2418,7 +3179,7 @@ async function generateDrafts(input) {
|
|
|
2418
3179
|
const repairs = input.repairs ?? 1;
|
|
2419
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)}.`);
|
|
2420
3181
|
const calls = [];
|
|
2421
|
-
const ids = idSource(newId, "generateDrafts");
|
|
3182
|
+
const ids = idSource$1(newId, "generateDrafts");
|
|
2422
3183
|
const ask = async (asked, keep) => {
|
|
2423
3184
|
let raw;
|
|
2424
3185
|
try {
|
|
@@ -2463,7 +3224,7 @@ async function generateDrafts(input) {
|
|
|
2463
3224
|
if (repaired === void 0) break;
|
|
2464
3225
|
current = current.map((before) => {
|
|
2465
3226
|
const after = repaired.drafts.find((one) => one.index === before.index);
|
|
2466
|
-
return after !== void 0 && noWorse(after, before) ? after : before;
|
|
3227
|
+
return after !== void 0 && noWorse$1(after, before) ? after : before;
|
|
2467
3228
|
});
|
|
2468
3229
|
}
|
|
2469
3230
|
return {
|
|
@@ -2498,7 +3259,7 @@ function sourceText(request) {
|
|
|
2498
3259
|
*/
|
|
2499
3260
|
function interactiveVideoFromDrafts(input) {
|
|
2500
3261
|
const { request, drafts, video, durationSeconds, title } = input;
|
|
2501
|
-
const ids = idSource(input.newId, "interactiveVideoFromDrafts");
|
|
3262
|
+
const ids = idSource$1(input.newId, "interactiveVideoFromDrafts");
|
|
2502
3263
|
const types = request.facts.activityTypes;
|
|
2503
3264
|
const list = "drafts" in drafts ? drafts.drafts : drafts;
|
|
2504
3265
|
const end = typeof durationSeconds === "number" && Number.isFinite(durationSeconds) && durationSeconds >= 0 ? durationSeconds : void 0;
|
|
@@ -2609,7 +3370,7 @@ function textField(value, max) {
|
|
|
2609
3370
|
value: cleaned === "" ? void 0 : cleaned
|
|
2610
3371
|
};
|
|
2611
3372
|
}
|
|
2612
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3373
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2613
3374
|
/** The corrections, each anchored in the draft, or why the reply is refused. */
|
|
2614
3375
|
function checkCorrections(raw, text) {
|
|
2615
3376
|
if (raw === void 0 || raw === null) return {
|
|
@@ -2631,7 +3392,7 @@ function checkCorrections(raw, text) {
|
|
|
2631
3392
|
const taken = /* @__PURE__ */ new Set();
|
|
2632
3393
|
const corrections = [];
|
|
2633
3394
|
for (const entry of raw) {
|
|
2634
|
-
if (!isRecord(entry) || typeof entry.original !== "string") return {
|
|
3395
|
+
if (!isRecord$1(entry) || typeof entry.original !== "string") return {
|
|
2635
3396
|
ok: false,
|
|
2636
3397
|
refusal: "malformed"
|
|
2637
3398
|
};
|
|
@@ -2653,7 +3414,7 @@ function checkCorrections(raw, text) {
|
|
|
2653
3414
|
if (!category.ok) return category;
|
|
2654
3415
|
let claimed;
|
|
2655
3416
|
if (entry.range !== void 0 && entry.range !== null) {
|
|
2656
|
-
if (!isRecord(entry.range)) return {
|
|
3417
|
+
if (!isRecord$1(entry.range)) return {
|
|
2657
3418
|
ok: false,
|
|
2658
3419
|
refusal: "malformed"
|
|
2659
3420
|
};
|
|
@@ -2747,9 +3508,134 @@ function aiGradingRequest(input) {
|
|
|
2747
3508
|
}
|
|
2748
3509
|
};
|
|
2749
3510
|
}
|
|
3511
|
+
const MEASURES = SPEAKING_MEASURES;
|
|
3512
|
+
/**
|
|
3513
|
+
* The request for a model's grade of a spoken answer (1.9), or why there is
|
|
3514
|
+
* none to make.
|
|
3515
|
+
*
|
|
3516
|
+
* Built as a written response's is, on the server and from the whole item, with
|
|
3517
|
+
* the take's evidence: the model reads `assessment.transcript` — the
|
|
3518
|
+
* transcriber's verbatim text, the same text the engine measured the take
|
|
3519
|
+
* against — and scores the criteria a judge scores: those that name no
|
|
3520
|
+
* `measure`, or every criterion when `judgeMeasures` is set. The measured
|
|
3521
|
+
* criteria go with it as context, with the engine's scores, so feedback can
|
|
3522
|
+
* speak of them; the model does not score them.
|
|
3523
|
+
*
|
|
3524
|
+
* The evidence is checked as `gradeSpeakingResponse` checks it — valid,
|
|
3525
|
+
* unscripted, for this item's locale and this response's recording — so a model
|
|
3526
|
+
* is never paid to judge a take the grade would refuse. Read the reply with
|
|
3527
|
+
* `checkAiGrading`, and grade with `gradeSpeakingResponse` under the same
|
|
3528
|
+
* `judgeMeasures`.
|
|
3529
|
+
*/
|
|
3530
|
+
function aiSpeakingGradingRequest(input) {
|
|
3531
|
+
const { data, response, learnerLocale } = input;
|
|
3532
|
+
if (data.type !== "speaking-response") return {
|
|
3533
|
+
ok: false,
|
|
3534
|
+
problem: "not-speaking-response"
|
|
3535
|
+
};
|
|
3536
|
+
if (data.redacted === true) return {
|
|
3537
|
+
ok: false,
|
|
3538
|
+
problem: "redacted"
|
|
3539
|
+
};
|
|
3540
|
+
const item = data;
|
|
3541
|
+
const rubric = rubricFacts(item.rubric);
|
|
3542
|
+
if (!rubric.ok) return rubric;
|
|
3543
|
+
const authored = item.rubric.criteria;
|
|
3544
|
+
const judged = [];
|
|
3545
|
+
const measured = [];
|
|
3546
|
+
for (const [index, fact] of rubric.criteria.entries()) {
|
|
3547
|
+
const measure = authored[index].measure;
|
|
3548
|
+
if (measure !== void 0 && !MEASURES.includes(measure)) return {
|
|
3549
|
+
ok: false,
|
|
3550
|
+
problem: "no-rubric",
|
|
3551
|
+
path: [
|
|
3552
|
+
"rubric",
|
|
3553
|
+
"criteria",
|
|
3554
|
+
index,
|
|
3555
|
+
"measure"
|
|
3556
|
+
]
|
|
3557
|
+
};
|
|
3558
|
+
if (measure === void 0 || input.judgeMeasures === true) judged.push({
|
|
3559
|
+
...fact,
|
|
3560
|
+
...measure !== void 0 ? { measure } : {}
|
|
3561
|
+
});
|
|
3562
|
+
else measured.push({
|
|
3563
|
+
name: fact.name,
|
|
3564
|
+
measure,
|
|
3565
|
+
weight: fact.weight
|
|
3566
|
+
});
|
|
3567
|
+
}
|
|
3568
|
+
if (judged.length === 0) return {
|
|
3569
|
+
ok: false,
|
|
3570
|
+
problem: "nothing-to-judge"
|
|
3571
|
+
};
|
|
3572
|
+
const recording = response?.type === "speaking-response" ? response.recording : null;
|
|
3573
|
+
if (recording === null || recording === void 0) return {
|
|
3574
|
+
ok: false,
|
|
3575
|
+
problem: "empty-response"
|
|
3576
|
+
};
|
|
3577
|
+
const checked = input.assessment === null || input.assessment === void 0 ? void 0 : validateSpeechAssessment(input.assessment);
|
|
3578
|
+
if (checked === void 0 || !checked.success) return {
|
|
3579
|
+
ok: false,
|
|
3580
|
+
problem: "invalid-assessment"
|
|
3581
|
+
};
|
|
3582
|
+
const evidence = checked.data;
|
|
3583
|
+
if (evidence.status === "no_speech") return {
|
|
3584
|
+
ok: false,
|
|
3585
|
+
problem: "empty-response"
|
|
3586
|
+
};
|
|
3587
|
+
if (evidence.task !== "unscripted" || evidence.locale !== item.locale || evidence.recordingKey !== recording.key) return {
|
|
3588
|
+
ok: false,
|
|
3589
|
+
problem: "evidence-mismatch"
|
|
3590
|
+
};
|
|
3591
|
+
if (evidence.transcript === void 0) return {
|
|
3592
|
+
ok: false,
|
|
3593
|
+
problem: "no-transcript"
|
|
3594
|
+
};
|
|
3595
|
+
const text = evidence.transcript.text;
|
|
3596
|
+
const scores = evidence.scores;
|
|
3597
|
+
const scoreOf = {
|
|
3598
|
+
pronunciation: scores.accuracy,
|
|
3599
|
+
fluency: scores.fluency,
|
|
3600
|
+
prosody: scores.prosody
|
|
3601
|
+
};
|
|
3602
|
+
const media = isRecord$1(item.media) && typeof item.media.type === "string" ? item.media : void 0;
|
|
3603
|
+
const material = materialFacts(input.group);
|
|
3604
|
+
return {
|
|
3605
|
+
ok: true,
|
|
3606
|
+
request: {
|
|
3607
|
+
feature: "speaking-grading",
|
|
3608
|
+
facts: {
|
|
3609
|
+
activityType: "speaking-response",
|
|
3610
|
+
activityId: data.id,
|
|
3611
|
+
title: data.title,
|
|
3612
|
+
locale: item.locale,
|
|
3613
|
+
...typeof item.instructions === "string" ? { instructions: item.instructions } : {},
|
|
3614
|
+
prompt: typeof item.prompt === "string" ? item.prompt : "",
|
|
3615
|
+
...media !== void 0 ? { media: {
|
|
3616
|
+
type: media.type,
|
|
3617
|
+
...typeof media.alt === "string" ? { alt: media.alt } : {}
|
|
3618
|
+
} } : {},
|
|
3619
|
+
text,
|
|
3620
|
+
wordCount: countWords(text),
|
|
3621
|
+
rubric: judged,
|
|
3622
|
+
measured: measured.map((fact) => {
|
|
3623
|
+
const score = scoreOf[fact.measure];
|
|
3624
|
+
return score !== void 0 ? {
|
|
3625
|
+
...fact,
|
|
3626
|
+
score
|
|
3627
|
+
} : fact;
|
|
3628
|
+
}),
|
|
3629
|
+
...typeof item.languageTarget === "string" ? { languageTarget: item.languageTarget } : {}
|
|
3630
|
+
},
|
|
3631
|
+
...material !== null ? { material } : {},
|
|
3632
|
+
...learnerLocale !== void 0 ? { learnerLocale } : {}
|
|
3633
|
+
}
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
2750
3636
|
/** The rubric as a model is given it, each criterion's levels read. */
|
|
2751
3637
|
function rubricFacts(rubric) {
|
|
2752
|
-
const listed = isRecord(rubric) ? rubric.criteria : void 0;
|
|
3638
|
+
const listed = isRecord$1(rubric) ? rubric.criteria : void 0;
|
|
2753
3639
|
if (!Array.isArray(listed) || listed.length === 0) return {
|
|
2754
3640
|
ok: false,
|
|
2755
3641
|
problem: "no-rubric"
|
|
@@ -2757,7 +3643,7 @@ function rubricFacts(rubric) {
|
|
|
2757
3643
|
const criteria = [];
|
|
2758
3644
|
const names = /* @__PURE__ */ new Set();
|
|
2759
3645
|
for (const [index, criterion] of listed.entries()) {
|
|
2760
|
-
if (!isRecord(criterion)) return {
|
|
3646
|
+
if (!isRecord$1(criterion)) return {
|
|
2761
3647
|
ok: false,
|
|
2762
3648
|
problem: "no-rubric",
|
|
2763
3649
|
path: [
|
|
@@ -2926,7 +3812,7 @@ function judgeCriterion(entry, criterion) {
|
|
|
2926
3812
|
* `gradeFromRubric` computes every grade in the SDK.
|
|
2927
3813
|
*/
|
|
2928
3814
|
function checkAiGrading(raw, request) {
|
|
2929
|
-
if (!isRecord(raw)) return {
|
|
3815
|
+
if (!isRecord$1(raw)) return {
|
|
2930
3816
|
ok: false,
|
|
2931
3817
|
refusal: "malformed"
|
|
2932
3818
|
};
|
|
@@ -2943,7 +3829,7 @@ function checkAiGrading(raw, request) {
|
|
|
2943
3829
|
const indexOf = criterionIndex(rubric);
|
|
2944
3830
|
const judged = rubric.map(() => void 0);
|
|
2945
3831
|
for (const entry of listed) {
|
|
2946
|
-
if (!isRecord(entry)) return {
|
|
3832
|
+
if (!isRecord$1(entry)) return {
|
|
2947
3833
|
ok: false,
|
|
2948
3834
|
refusal: "malformed"
|
|
2949
3835
|
};
|
|
@@ -3004,6 +3890,1498 @@ function checkAiGrading(raw, request) {
|
|
|
3004
3890
|
}
|
|
3005
3891
|
};
|
|
3006
3892
|
}
|
|
3893
|
+
// src/ai-variants.ts
|
|
3894
|
+
/** The most exemplars one request may vary: a long section is sent in parts. */
|
|
3895
|
+
const AI_VARIANTS_MAX_EXEMPLARS = 20;
|
|
3896
|
+
/** The most repair passes `generateVariants` makes. */
|
|
3897
|
+
const AI_VARIANTS_MAX_REPAIRS = 3;
|
|
3898
|
+
/** The longest passage or script, in characters, an exemplar may carry into the facts. */
|
|
3899
|
+
const AI_VARIANTS_MAX_MATERIAL_LENGTH = AI_MATERIAL_MAX_LENGTH;
|
|
3900
|
+
/**
|
|
3901
|
+
* Every code a variant can be reported with, and its severity: a contract,
|
|
3902
|
+
* as the critic's codes are. `docs/ai.md` documents this table and a test
|
|
3903
|
+
* holds the two together.
|
|
3904
|
+
*/
|
|
3905
|
+
const AI_VARIANT_CODE_SEVERITY = {
|
|
3906
|
+
variant_missing: "invalid",
|
|
3907
|
+
variant_type: "invalid",
|
|
3908
|
+
variant_structure: "invalid",
|
|
3909
|
+
variant_length: "warning",
|
|
3910
|
+
variant_near_copy: "warning",
|
|
3911
|
+
variant_same_key: "warning"
|
|
3912
|
+
};
|
|
3913
|
+
/** The activity types the SDK writes variants of: the ones whose every field it knows. */
|
|
3914
|
+
const VARIANT_TYPES = [
|
|
3915
|
+
"multiple-choice",
|
|
3916
|
+
"fill-in-the-blanks",
|
|
3917
|
+
"gap-select",
|
|
3918
|
+
"dictation",
|
|
3919
|
+
"read-aloud",
|
|
3920
|
+
"written-response"
|
|
3921
|
+
];
|
|
3922
|
+
const DIFFICULTIES = [
|
|
3923
|
+
"easier",
|
|
3924
|
+
"same",
|
|
3925
|
+
"harder"
|
|
3926
|
+
];
|
|
3927
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3928
|
+
const listOf = (value) => Array.isArray(value) ? value : [];
|
|
3929
|
+
const stringOf = (value) => typeof value === "string" ? value : "";
|
|
3930
|
+
const written = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
3931
|
+
const textList = (value) => listOf(value).filter((one) => typeof one === "string");
|
|
3932
|
+
/** A copy of plain content, so a variant never shares an object with its exemplar. */
|
|
3933
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
3934
|
+
const isRecording = (media) => isRecord(media) && [
|
|
3935
|
+
"audio",
|
|
3936
|
+
"video",
|
|
3937
|
+
"embed"
|
|
3938
|
+
].includes(stringOf(media.type));
|
|
3939
|
+
const isImage = (media) => isRecord(media) && media.type === "image";
|
|
3940
|
+
const RULES = [
|
|
3941
|
+
"Write one variant for each exemplar, naming its exemplar index and its type.",
|
|
3942
|
+
"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.",
|
|
3943
|
+
"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.",
|
|
3944
|
+
"Keep each text within its targets.lengths, counted in words.",
|
|
3945
|
+
"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.",
|
|
3946
|
+
"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.",
|
|
3947
|
+
"Keep the task in instructions as the exemplar's; change its wording only where the new item needs it.",
|
|
3948
|
+
"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.",
|
|
3949
|
+
"Do not repeat the flaws listed in targets.exemplarFindings.",
|
|
3950
|
+
"A listening script keeps the number of speakers, with new names, and about as many turns."
|
|
3951
|
+
];
|
|
3952
|
+
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.";
|
|
3953
|
+
const REUSE_RULE = "Where an exemplar shows a picture, keep it: write new questions about the same picture, which its alt describes.";
|
|
3954
|
+
const DIFFICULTY_RULE = "Where targets.difficulty is easier or harder, move one step, inside the level, using targets.levers. Never write for another level.";
|
|
3955
|
+
const LEVEL_FACTS_RULE = "Use only grammar and vocabulary that levelFacts lists as taught, and keep away from the topics it lists.";
|
|
3956
|
+
/** The ways a type is made easier or harder: one step inside a level. */
|
|
3957
|
+
const LEVERS = {
|
|
3958
|
+
"multiple-choice": {
|
|
3959
|
+
easier: [
|
|
3960
|
+
"Make the wrong options clearly wrong to a learner who understood, though still plausible to one who did not.",
|
|
3961
|
+
"Ask about what is said directly rather than what is implied.",
|
|
3962
|
+
"Word the right option closer to the words of the text or recording."
|
|
3963
|
+
],
|
|
3964
|
+
harder: [
|
|
3965
|
+
"Make the wrong options closer to the right one, each wrong for a reason the text or recording makes clear.",
|
|
3966
|
+
"Ask for an inference rather than a fact stated directly.",
|
|
3967
|
+
"Paraphrase the right option rather than repeat the words of the text or recording."
|
|
3968
|
+
]
|
|
3969
|
+
},
|
|
3970
|
+
"fill-in-the-blanks": {
|
|
3971
|
+
easier: ["Give more context around each blank that points to the answer."],
|
|
3972
|
+
harder: ["Give less context around each blank, so the learner must know the form rather than infer it."]
|
|
3973
|
+
},
|
|
3974
|
+
"gap-select": {
|
|
3975
|
+
easier: ["Give more context around each gap that points to the answer.", "Make the wrong choices clearly wrong in context."],
|
|
3976
|
+
harder: ["Give less context around each gap.", "Make the wrong choices closer to the right one."]
|
|
3977
|
+
},
|
|
3978
|
+
dictation: {
|
|
3979
|
+
easier: ["Use shorter sentences and more frequent words of the level, inside the length band."],
|
|
3980
|
+
harder: ["Use longer sentences and less frequent words of the level, inside the length band.", "Use more connected-speech features: contractions, linking, weak forms."]
|
|
3981
|
+
},
|
|
3982
|
+
"read-aloud": {
|
|
3983
|
+
easier: ["Use shorter sentences and more frequent words of the level, inside the length band."],
|
|
3984
|
+
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."]
|
|
3985
|
+
},
|
|
3986
|
+
"written-response": {
|
|
3987
|
+
easier: ["Make the task more specific, and supply more of the content to write about."],
|
|
3988
|
+
harder: ["Make the task more open, so the learner supplies more of the content."]
|
|
3989
|
+
},
|
|
3990
|
+
reading: {
|
|
3991
|
+
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."],
|
|
3992
|
+
harder: ["Ask for inferences rather than facts stated directly.", "Use less frequent words of the level, and longer sentences."]
|
|
3993
|
+
},
|
|
3994
|
+
listening: {
|
|
3995
|
+
easier: ["Describe a slower pace in the script, and have a speaker repeat the key information.", "Use more frequent words of the level."],
|
|
3996
|
+
harder: ["Describe a natural pace in the script, and give the key information once.", "Use less frequent words of the level."]
|
|
3997
|
+
}
|
|
3998
|
+
};
|
|
3999
|
+
function leversFor(entry, difficulty) {
|
|
4000
|
+
if (difficulty === "same") return;
|
|
4001
|
+
let kind = stringOf(entry.type);
|
|
4002
|
+
if (kind === "item-group") {
|
|
4003
|
+
const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
4004
|
+
kind = stimulus.kind === "audio" || stimulus.kind === "video" ? "listening" : "reading";
|
|
4005
|
+
}
|
|
4006
|
+
const levers = LEVERS[kind];
|
|
4007
|
+
return levers !== void 0 ? [...levers[difficulty]] : void 0;
|
|
4008
|
+
}
|
|
4009
|
+
/** A passage with its placeholders numbered in order: `{{1}}`, `{{2}}`… — how a variant writes them. */
|
|
4010
|
+
function renumbered(passage) {
|
|
4011
|
+
let position = 0;
|
|
4012
|
+
return passage.replace(PLACEHOLDER_RE, () => {
|
|
4013
|
+
position += 1;
|
|
4014
|
+
return `{{${position}}}`;
|
|
4015
|
+
});
|
|
4016
|
+
}
|
|
4017
|
+
const turnsOf = (holder) => listOf(holder.script).flatMap((turn) => isRecord(turn) ? [{
|
|
4018
|
+
speaker: stringOf(turn.speaker),
|
|
4019
|
+
text: stringOf(turn.text)
|
|
4020
|
+
}] : []);
|
|
4021
|
+
const hasScript = (holder) => listOf(holder.script).some(isRecord);
|
|
4022
|
+
/** What a model is shown of a media object: a picture's description, or what a recording says. */
|
|
4023
|
+
function mediaContent(media, ownText, ref) {
|
|
4024
|
+
if (!isRecord(media)) return;
|
|
4025
|
+
if (isImage(media)) return {
|
|
4026
|
+
alt: stringOf(media.alt),
|
|
4027
|
+
...ref !== void 0 ? { ref } : {}
|
|
4028
|
+
};
|
|
4029
|
+
if (ownText || !isRecording(media)) return;
|
|
4030
|
+
if (hasScript(media)) return { script: turnsOf(media) };
|
|
4031
|
+
return typeof media.transcript === "string" ? { transcript: media.transcript } : void 0;
|
|
4032
|
+
}
|
|
4033
|
+
function feedbackContent(holder) {
|
|
4034
|
+
if (!isRecord(holder.feedback)) return {};
|
|
4035
|
+
const { correct, incorrect } = holder.feedback;
|
|
4036
|
+
return { feedback: {
|
|
4037
|
+
...typeof correct === "string" ? { correct } : {},
|
|
4038
|
+
...typeof incorrect === "string" ? { incorrect } : {}
|
|
4039
|
+
} };
|
|
4040
|
+
}
|
|
4041
|
+
function itemContent(item, refOf, at) {
|
|
4042
|
+
const type = stringOf(item.type);
|
|
4043
|
+
const ownText = type === "dictation" || type === "read-aloud";
|
|
4044
|
+
const media = mediaContent(item.media, ownText, refOf([...at, "media"]));
|
|
4045
|
+
const content = {
|
|
4046
|
+
type,
|
|
4047
|
+
title: stringOf(item.title),
|
|
4048
|
+
...typeof item.instructions === "string" ? { instructions: item.instructions } : {}
|
|
4049
|
+
};
|
|
4050
|
+
switch (type) {
|
|
4051
|
+
case "multiple-choice":
|
|
4052
|
+
Object.assign(content, {
|
|
4053
|
+
question: stringOf(item.question),
|
|
4054
|
+
options: listOf(item.options).map((option, index) => {
|
|
4055
|
+
const fields = isRecord(option) ? option : {};
|
|
4056
|
+
const optionMedia = mediaContent(fields.media, false, refOf([
|
|
4057
|
+
...at,
|
|
4058
|
+
"options",
|
|
4059
|
+
index,
|
|
4060
|
+
"media"
|
|
4061
|
+
]));
|
|
4062
|
+
return {
|
|
4063
|
+
text: stringOf(fields.text),
|
|
4064
|
+
isCorrect: fields.isCorrect === true,
|
|
4065
|
+
...typeof fields.feedback === "string" ? { feedback: fields.feedback } : {},
|
|
4066
|
+
...optionMedia !== void 0 ? { media: optionMedia } : {}
|
|
4067
|
+
};
|
|
4068
|
+
})
|
|
4069
|
+
});
|
|
4070
|
+
break;
|
|
4071
|
+
case "fill-in-the-blanks":
|
|
4072
|
+
Object.assign(content, {
|
|
4073
|
+
passage: renumbered(stringOf(item.passage)),
|
|
4074
|
+
blanks: inPassageOrder(item, "blanks").map(({ value }) => {
|
|
4075
|
+
const blank = isRecord(value) ? value : {};
|
|
4076
|
+
return {
|
|
4077
|
+
acceptedAnswers: textList(blank.acceptedAnswers),
|
|
4078
|
+
...typeof blank.hint === "string" ? { hint: blank.hint } : {},
|
|
4079
|
+
...typeof blank.feedback === "string" ? { feedback: blank.feedback } : {}
|
|
4080
|
+
};
|
|
4081
|
+
})
|
|
4082
|
+
});
|
|
4083
|
+
break;
|
|
4084
|
+
case "gap-select": {
|
|
4085
|
+
const banks = listOf(item.banks).filter(isRecord);
|
|
4086
|
+
Object.assign(content, {
|
|
4087
|
+
passage: renumbered(stringOf(item.passage)),
|
|
4088
|
+
gaps: inPassageOrder(item, "gaps").map(({ value }) => {
|
|
4089
|
+
const gap = isRecord(value) ? value : {};
|
|
4090
|
+
const feedback = typeof gap.feedback === "string" ? { feedback: gap.feedback } : {};
|
|
4091
|
+
const bank = banks.find((one) => one.id === gap.bankId);
|
|
4092
|
+
if (listOf(gap.choices).length === 0 && bank !== void 0) {
|
|
4093
|
+
const right = listOf(bank.choices).find((choice) => isRecord(choice) && choice.id === gap.correctChoiceId);
|
|
4094
|
+
return {
|
|
4095
|
+
answer: isRecord(right) ? stringOf(right.text) : "",
|
|
4096
|
+
...feedback
|
|
4097
|
+
};
|
|
4098
|
+
}
|
|
4099
|
+
return {
|
|
4100
|
+
choices: listOf(gap.choices).map((choice) => ({
|
|
4101
|
+
text: isRecord(choice) ? stringOf(choice.text) : "",
|
|
4102
|
+
isCorrect: isRecord(choice) && choice.id === gap.correctChoiceId
|
|
4103
|
+
})),
|
|
4104
|
+
...feedback
|
|
4105
|
+
};
|
|
4106
|
+
}),
|
|
4107
|
+
...banks.length > 0 ? { banks: banks.map((bank) => ({ choices: listOf(bank.choices).map((choice) => isRecord(choice) ? stringOf(choice.text) : "") })) } : {}
|
|
4108
|
+
});
|
|
4109
|
+
break;
|
|
4110
|
+
}
|
|
4111
|
+
case "dictation":
|
|
4112
|
+
Object.assign(content, {
|
|
4113
|
+
transcript: stringOf(item.transcript),
|
|
4114
|
+
...Array.isArray(item.acceptedTranscripts) ? { acceptedTranscripts: textList(item.acceptedTranscripts) } : {}
|
|
4115
|
+
});
|
|
4116
|
+
break;
|
|
4117
|
+
case "read-aloud":
|
|
4118
|
+
content.referenceText = stringOf(item.referenceText);
|
|
4119
|
+
break;
|
|
4120
|
+
default: content.prompt = stringOf(item.prompt);
|
|
4121
|
+
}
|
|
4122
|
+
return {
|
|
4123
|
+
...content,
|
|
4124
|
+
...media !== void 0 ? { media } : {},
|
|
4125
|
+
...feedbackContent(item)
|
|
4126
|
+
};
|
|
4127
|
+
}
|
|
4128
|
+
function entryContent(entry, refOf) {
|
|
4129
|
+
if (entry.type !== "item-group") return itemContent(entry, refOf, []);
|
|
4130
|
+
const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
4131
|
+
const ownText = hasScript(stimulus) || typeof stimulus.transcript === "string";
|
|
4132
|
+
const media = mediaContent(stimulus.media, ownText, refOf(["stimulus", "media"]));
|
|
4133
|
+
return {
|
|
4134
|
+
type: "item-group",
|
|
4135
|
+
...typeof entry.title === "string" ? { title: entry.title } : {},
|
|
4136
|
+
stimulus: {
|
|
4137
|
+
kind: stringOf(stimulus.kind),
|
|
4138
|
+
...typeof stimulus.title === "string" ? { title: stimulus.title } : {},
|
|
4139
|
+
...typeof stimulus.body === "string" ? { body: stimulus.body } : {},
|
|
4140
|
+
...hasScript(stimulus) ? { script: turnsOf(stimulus) } : typeof stimulus.transcript === "string" ? { transcript: stimulus.transcript } : {},
|
|
4141
|
+
...media ?? {}
|
|
4142
|
+
},
|
|
4143
|
+
items: listOf(entry.items).map((item, index) => isRecord(item) ? itemContent(item, refOf, ["items", index]) : {})
|
|
4144
|
+
};
|
|
4145
|
+
}
|
|
4146
|
+
/** Every picture an entry shows: an item's, an option's, and a group's material's. */
|
|
4147
|
+
function imageSlots(entry) {
|
|
4148
|
+
const ofItem = (item, at) => {
|
|
4149
|
+
if (!isRecord(item)) return [];
|
|
4150
|
+
return [...isImage(item.media) ? [{
|
|
4151
|
+
path: [...at, "media"],
|
|
4152
|
+
media: item.media
|
|
4153
|
+
}] : [], ...listOf(item.options).flatMap((option, index) => isRecord(option) && isImage(option.media) ? [{
|
|
4154
|
+
path: [
|
|
4155
|
+
...at,
|
|
4156
|
+
"options",
|
|
4157
|
+
String(index),
|
|
4158
|
+
"media"
|
|
4159
|
+
],
|
|
4160
|
+
media: option.media
|
|
4161
|
+
}] : [])];
|
|
4162
|
+
};
|
|
4163
|
+
if (entry.type !== "item-group") return ofItem(entry, []);
|
|
4164
|
+
const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
4165
|
+
return [...isImage(stimulus.media) ? [{
|
|
4166
|
+
path: ["stimulus", "media"],
|
|
4167
|
+
media: stimulus.media
|
|
4168
|
+
}] : [], ...listOf(entry.items).flatMap((item, index) => ofItem(item, ["items", String(index)]))];
|
|
4169
|
+
}
|
|
4170
|
+
/** Whether a recording the item's meaning depends on says nothing the facts can carry. */
|
|
4171
|
+
function unheard(entry) {
|
|
4172
|
+
const itemUnheard = (item) => {
|
|
4173
|
+
if (!isRecord(item) || item.type === "dictation" || item.type === "read-aloud") return false;
|
|
4174
|
+
return isRecording(item.media) && transcriptOf(item.media) === null || listOf(item.options).some((option) => isRecord(option) && isRecording(option.media) && transcriptOf(option.media) === null);
|
|
4175
|
+
};
|
|
4176
|
+
if (entry.type !== "item-group") return itemUnheard(entry);
|
|
4177
|
+
const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
4178
|
+
return (stimulus.kind === "audio" || stimulus.kind === "video" || isRecording(stimulus.media)) && transcriptOf(stimulus) === null || listOf(entry.items).some(itemUnheard);
|
|
4179
|
+
}
|
|
4180
|
+
function skipOf(entry, exemplarIndex, imagePolicy) {
|
|
4181
|
+
const skip = (code, message) => ({
|
|
4182
|
+
exemplarIndex,
|
|
4183
|
+
code,
|
|
4184
|
+
message
|
|
4185
|
+
});
|
|
4186
|
+
const group = entry.type === "item-group";
|
|
4187
|
+
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.");
|
|
4188
|
+
const unsupported = (group ? listOf(entry.items).map((item) => isRecord(item) ? stringOf(item.type) : "") : [stringOf(entry.type)]).find((type) => getActivityTypeDescriptor(type) !== void 0 && !VARIANT_TYPES.includes(type));
|
|
4189
|
+
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.`);
|
|
4190
|
+
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.");
|
|
4191
|
+
const validation = group ? validateItemGroupDraft(entry) : validateDraft(entry.type, entry);
|
|
4192
|
+
if (validation.status !== "complete") {
|
|
4193
|
+
const first = validation.issues[0];
|
|
4194
|
+
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.`);
|
|
4195
|
+
}
|
|
4196
|
+
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.");
|
|
4197
|
+
const long = textsOf(entry).find((text) => text.text.length > AI_VARIANTS_MAX_MATERIAL_LENGTH);
|
|
4198
|
+
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.`);
|
|
4199
|
+
}
|
|
4200
|
+
const text = (description) => ({
|
|
4201
|
+
type: "string",
|
|
4202
|
+
description
|
|
4203
|
+
});
|
|
4204
|
+
const SCRIPT_SHAPE = {
|
|
4205
|
+
type: "array",
|
|
4206
|
+
minItems: 1,
|
|
4207
|
+
description: "The recording as turns, in order: a new name for each speaker, as many speakers as the exemplar.",
|
|
4208
|
+
items: {
|
|
4209
|
+
type: "object",
|
|
4210
|
+
required: ["speaker", "text"],
|
|
4211
|
+
properties: {
|
|
4212
|
+
speaker: text("Who speaks."),
|
|
4213
|
+
text: text("What they say.")
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
};
|
|
4217
|
+
const MEDIA_SHAPE = {
|
|
4218
|
+
type: "object",
|
|
4219
|
+
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.",
|
|
4220
|
+
properties: {
|
|
4221
|
+
alt: text("What the picture shows."),
|
|
4222
|
+
transcript: text("What the recording says."),
|
|
4223
|
+
script: SCRIPT_SHAPE
|
|
4224
|
+
}
|
|
4225
|
+
};
|
|
4226
|
+
const FEEDBACK_SHAPE = {
|
|
4227
|
+
type: "object",
|
|
4228
|
+
description: "Only where the exemplar has feedback: shown after answering.",
|
|
4229
|
+
properties: {
|
|
4230
|
+
correct: text("Shown when the learner passes."),
|
|
4231
|
+
incorrect: text("Shown when the learner does not.")
|
|
4232
|
+
}
|
|
4233
|
+
};
|
|
4234
|
+
const FIELD_SHAPES = {
|
|
4235
|
+
title: text("A short name for the item, shown above it. Never the answer, or any part of it."),
|
|
4236
|
+
instructions: text("Only where the exemplar has instructions: the same task, worded for the new item."),
|
|
4237
|
+
question: text("The question, as plain text."),
|
|
4238
|
+
options: {
|
|
4239
|
+
type: "array",
|
|
4240
|
+
description: "As many as the exemplar's, as many of them right.",
|
|
4241
|
+
items: {
|
|
4242
|
+
type: "object",
|
|
4243
|
+
required: ["text", "isCorrect"],
|
|
4244
|
+
properties: {
|
|
4245
|
+
text: text("The option as the learner reads it."),
|
|
4246
|
+
isCorrect: { type: "boolean" },
|
|
4247
|
+
feedback: text("Only where the exemplar's options have feedback."),
|
|
4248
|
+
media: MEDIA_SHAPE
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
},
|
|
4252
|
+
passage: text("The passage as plain text, with {{1}}, {{2}}, … where each gap goes, in order."),
|
|
4253
|
+
blanks: {
|
|
4254
|
+
type: "array",
|
|
4255
|
+
description: "One per gap, in the order the gaps appear: the first is {{1}}.",
|
|
4256
|
+
items: {
|
|
4257
|
+
type: "object",
|
|
4258
|
+
required: ["acceptedAnswers"],
|
|
4259
|
+
properties: {
|
|
4260
|
+
acceptedAnswers: {
|
|
4261
|
+
type: "array",
|
|
4262
|
+
minItems: 1,
|
|
4263
|
+
items: { type: "string" },
|
|
4264
|
+
description: "Every answer that is right, spelled as a learner would type it."
|
|
4265
|
+
},
|
|
4266
|
+
hint: text("Only where the exemplar's blank has a hint. Never gives the answer away."),
|
|
4267
|
+
feedback: text("Only where the exemplar's blank has feedback.")
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
},
|
|
4271
|
+
gaps: {
|
|
4272
|
+
type: "array",
|
|
4273
|
+
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.",
|
|
4274
|
+
items: {
|
|
4275
|
+
type: "object",
|
|
4276
|
+
properties: {
|
|
4277
|
+
choices: {
|
|
4278
|
+
type: "array",
|
|
4279
|
+
items: {
|
|
4280
|
+
type: "object",
|
|
4281
|
+
required: ["text", "isCorrect"],
|
|
4282
|
+
properties: {
|
|
4283
|
+
text: text("A choice."),
|
|
4284
|
+
isCorrect: { type: "boolean" }
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
},
|
|
4288
|
+
answer: text("The right choice's text, exactly as its bank writes it."),
|
|
4289
|
+
feedback: text("Only where the exemplar's gap has feedback.")
|
|
4290
|
+
}
|
|
4291
|
+
}
|
|
4292
|
+
},
|
|
4293
|
+
banks: {
|
|
4294
|
+
type: "array",
|
|
4295
|
+
description: "The shared choice lists, as many as the exemplar's, each with as many choices.",
|
|
4296
|
+
items: {
|
|
4297
|
+
type: "object",
|
|
4298
|
+
required: ["choices"],
|
|
4299
|
+
properties: { choices: {
|
|
4300
|
+
type: "array",
|
|
4301
|
+
items: { type: "string" }
|
|
4302
|
+
} }
|
|
4303
|
+
}
|
|
4304
|
+
},
|
|
4305
|
+
transcript: text("What the learner hears and types."),
|
|
4306
|
+
acceptedTranscripts: {
|
|
4307
|
+
type: "array",
|
|
4308
|
+
items: { type: "string" },
|
|
4309
|
+
description: "Only where the exemplar has them: other writings of the transcript that are right, of the same kind."
|
|
4310
|
+
},
|
|
4311
|
+
referenceText: text("What the learner reads aloud."),
|
|
4312
|
+
prompt: text("The writing task, of the same genre and purpose as the exemplar's, about something new."),
|
|
4313
|
+
media: MEDIA_SHAPE,
|
|
4314
|
+
feedback: FEEDBACK_SHAPE
|
|
4315
|
+
};
|
|
4316
|
+
const WRITES = {
|
|
4317
|
+
"multiple-choice": [
|
|
4318
|
+
"title",
|
|
4319
|
+
"instructions",
|
|
4320
|
+
"question",
|
|
4321
|
+
"options",
|
|
4322
|
+
"media",
|
|
4323
|
+
"feedback"
|
|
4324
|
+
],
|
|
4325
|
+
"fill-in-the-blanks": [
|
|
4326
|
+
"title",
|
|
4327
|
+
"instructions",
|
|
4328
|
+
"passage",
|
|
4329
|
+
"blanks",
|
|
4330
|
+
"media",
|
|
4331
|
+
"feedback"
|
|
4332
|
+
],
|
|
4333
|
+
"gap-select": [
|
|
4334
|
+
"title",
|
|
4335
|
+
"instructions",
|
|
4336
|
+
"passage",
|
|
4337
|
+
"gaps",
|
|
4338
|
+
"banks",
|
|
4339
|
+
"media",
|
|
4340
|
+
"feedback"
|
|
4341
|
+
],
|
|
4342
|
+
dictation: [
|
|
4343
|
+
"title",
|
|
4344
|
+
"transcript",
|
|
4345
|
+
"acceptedTranscripts",
|
|
4346
|
+
"feedback"
|
|
4347
|
+
],
|
|
4348
|
+
"read-aloud": [
|
|
4349
|
+
"title",
|
|
4350
|
+
"instructions",
|
|
4351
|
+
"referenceText",
|
|
4352
|
+
"feedback"
|
|
4353
|
+
],
|
|
4354
|
+
"written-response": [
|
|
4355
|
+
"title",
|
|
4356
|
+
"prompt",
|
|
4357
|
+
"media",
|
|
4358
|
+
"feedback"
|
|
4359
|
+
]
|
|
4360
|
+
};
|
|
4361
|
+
function itemProperties(types) {
|
|
4362
|
+
const properties = {};
|
|
4363
|
+
for (const type of types) for (const name of WRITES[type] ?? []) properties[name] = FIELD_SHAPES[name];
|
|
4364
|
+
return properties;
|
|
4365
|
+
}
|
|
4366
|
+
/** The types an entry holds, a group's items included. */
|
|
4367
|
+
const typesIn = (entry) => entry.type === "item-group" ? listOf(entry.items).map((item) => isRecord(item) ? stringOf(item.type) : "") : [stringOf(entry.type)];
|
|
4368
|
+
function replyShape(entries, exactly) {
|
|
4369
|
+
const top = [...new Set(entries.map((entry) => stringOf(entry.type)))];
|
|
4370
|
+
const itemTypes = [...new Set(entries.filter((entry) => entry.type === "item-group").flatMap(typesIn))].filter((type) => VARIANT_TYPES.includes(type));
|
|
4371
|
+
const properties = {
|
|
4372
|
+
exemplar: {
|
|
4373
|
+
type: "integer",
|
|
4374
|
+
minimum: 0,
|
|
4375
|
+
description: "The exemplar's index, as the facts give it."
|
|
4376
|
+
},
|
|
4377
|
+
type: {
|
|
4378
|
+
type: "string",
|
|
4379
|
+
enum: top,
|
|
4380
|
+
description: "The exemplar's type."
|
|
4381
|
+
},
|
|
4382
|
+
...itemProperties(top.filter((type) => type !== "item-group"))
|
|
4383
|
+
};
|
|
4384
|
+
if (top.includes("item-group")) {
|
|
4385
|
+
properties.title = FIELD_SHAPES.title;
|
|
4386
|
+
properties.stimulus = {
|
|
4387
|
+
type: "object",
|
|
4388
|
+
description: "The new material: a passage, or what a recording says.",
|
|
4389
|
+
properties: {
|
|
4390
|
+
title: text("Only where the exemplar's material has a title."),
|
|
4391
|
+
body: text("The passage, as plain text."),
|
|
4392
|
+
transcript: text("What the recording says, where the exemplar has no script."),
|
|
4393
|
+
script: SCRIPT_SHAPE,
|
|
4394
|
+
alt: text("For a picture: what it shows.")
|
|
4395
|
+
}
|
|
4396
|
+
};
|
|
4397
|
+
properties.items = {
|
|
4398
|
+
type: "array",
|
|
4399
|
+
description: "The questions about the new material: the exemplar's types, in its order.",
|
|
4400
|
+
items: {
|
|
4401
|
+
type: "object",
|
|
4402
|
+
required: ["type"],
|
|
4403
|
+
properties: {
|
|
4404
|
+
type: {
|
|
4405
|
+
type: "string",
|
|
4406
|
+
enum: itemTypes
|
|
4407
|
+
},
|
|
4408
|
+
...itemProperties(itemTypes)
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
};
|
|
4412
|
+
}
|
|
4413
|
+
return {
|
|
4414
|
+
type: "object",
|
|
4415
|
+
required: ["variants"],
|
|
4416
|
+
properties: { variants: {
|
|
4417
|
+
type: "array",
|
|
4418
|
+
minItems: exactly,
|
|
4419
|
+
maxItems: exactly,
|
|
4420
|
+
items: {
|
|
4421
|
+
type: "object",
|
|
4422
|
+
required: ["exemplar", "type"],
|
|
4423
|
+
properties
|
|
4424
|
+
}
|
|
4425
|
+
} }
|
|
4426
|
+
};
|
|
4427
|
+
}
|
|
4428
|
+
const facing = (path) => path.join(".").replace(/^stimulus\.media\./, "stimulus.");
|
|
4429
|
+
function targetsOf(entry, difficulty, reason, itemDifficulty, tolerance) {
|
|
4430
|
+
const group = entry.type === "item-group";
|
|
4431
|
+
const levers = leversFor(entry, difficulty);
|
|
4432
|
+
const findings = group ? critiqueItemGroupDraft(entry) : critiqueDraft(entry.type, entry);
|
|
4433
|
+
return {
|
|
4434
|
+
type: stringOf(entry.type),
|
|
4435
|
+
structure: structureTargets(entry),
|
|
4436
|
+
lengths: Object.fromEntries(lengthTargets(entry, tolerance).map((target) => [facing(target.path), target.bounds])),
|
|
4437
|
+
difficulty,
|
|
4438
|
+
...difficulty !== "same" && reason !== void 0 ? { difficultyReason: reason } : {},
|
|
4439
|
+
...levers !== void 0 ? { levers } : {},
|
|
4440
|
+
...itemDifficulty !== void 0 ? { itemDifficulty } : {},
|
|
4441
|
+
exemplarFindings: findings.map((found) => ({
|
|
4442
|
+
code: found.code,
|
|
4443
|
+
message: found.message
|
|
4444
|
+
}))
|
|
4445
|
+
};
|
|
4446
|
+
}
|
|
4447
|
+
const idOf = (value) => isRecord(value) && typeof value.id === "string" ? value.id : void 0;
|
|
4448
|
+
/**
|
|
4449
|
+
* What a model is given to write a variant of each exemplar — a parallel item:
|
|
4450
|
+
* the exemplar's type, structure, scoring and settings, with every word a
|
|
4451
|
+
* learner reads or hears new — and what `checkAiVariants` needs to check the
|
|
4452
|
+
* reply against.
|
|
4453
|
+
*
|
|
4454
|
+
* `exemplars` are loose activities and item groups as stored, answer keys and
|
|
4455
|
+
* author-only transcripts included, in paper order; one variant is written for
|
|
4456
|
+
* each that can be varied. An exemplar that cannot is left out of the facts
|
|
4457
|
+
* and listed in `skipped`: a recording with nothing to say what it says
|
|
4458
|
+
* (`variant_exemplar_unheard`), a picture with no description to reuse
|
|
4459
|
+
* (`variant_exemplar_unseen`), an unfinished item
|
|
4460
|
+
* (`variant_exemplar_incomplete`), an interactive video
|
|
4461
|
+
* (`variant_exemplar_timeline`), a type the SDK does not vary
|
|
4462
|
+
* (`variant_exemplar_unsupported`) or material over
|
|
4463
|
+
* {@link AI_VARIANTS_MAX_MATERIAL_LENGTH} characters
|
|
4464
|
+
* (`variant_exemplar_too_long`). When every exemplar is skipped the request has
|
|
4465
|
+
* none, and `generateVariants` makes no call.
|
|
4466
|
+
*
|
|
4467
|
+
* The facts give each exemplar's content in the shape a variant is written in,
|
|
4468
|
+
* and its `targets`: the structure to keep, the length of each text in words,
|
|
4469
|
+
* its difficulty with the levers to move it, and the critic's findings on it
|
|
4470
|
+
* not to repeat. `rules` are what every variant must respect, as sentences a
|
|
4471
|
+
* prompt can quote. Pictures are listed in `images`, for a host that attaches
|
|
4472
|
+
* them; no recording's address reaches the facts. `avoid` is the other forms'
|
|
4473
|
+
* items: compared with every variant, never varied.
|
|
4474
|
+
*
|
|
4475
|
+
* Nothing passed is changed.
|
|
4476
|
+
*
|
|
4477
|
+
* @throws RangeError when `exemplars` is empty or longer than
|
|
4478
|
+
* {@link AI_VARIANTS_MAX_EXEMPLARS}, an exemplar is neither an item group nor
|
|
4479
|
+
* a registered activity type, `level` is empty, a difficulty is not `easier`,
|
|
4480
|
+
* `same` or `harder`, `images` is neither `describe` nor `reuse`, or
|
|
4481
|
+
* `difficultyFor` names an id no exemplar has.
|
|
4482
|
+
*/
|
|
4483
|
+
function aiVariantsRequest(input) {
|
|
4484
|
+
const { exemplars, level, locale, levelFacts, avoid, instructions, settings } = input;
|
|
4485
|
+
const difficulty = input.difficulty ?? "same";
|
|
4486
|
+
const imagePolicy = input.images ?? "describe";
|
|
4487
|
+
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.`);
|
|
4488
|
+
if (typeof level !== "string" || level.trim() === "") throw new RangeError("aiVariantsRequest: level is required: a variant must stay inside it.");
|
|
4489
|
+
if (!DIFFICULTIES.includes(difficulty)) throw new RangeError(`aiVariantsRequest: difficulty must be easier, same or harder, not ${String(difficulty)}.`);
|
|
4490
|
+
if (imagePolicy !== "describe" && imagePolicy !== "reuse") throw new RangeError(`aiVariantsRequest: images must be describe or reuse, not ${String(imagePolicy)}.`);
|
|
4491
|
+
exemplars.forEach((entry, index) => {
|
|
4492
|
+
const type = isRecord(entry) ? entry.type : void 0;
|
|
4493
|
+
if (typeof type !== "string" || type !== "item-group" && getActivityTypeDescriptor(type) === void 0) throw new RangeError(`aiVariantsRequest: exemplar ${index} is neither an item group nor a registered activity type.`);
|
|
4494
|
+
});
|
|
4495
|
+
const entries = exemplars;
|
|
4496
|
+
const ids = new Set(entries.flatMap((entry) => [idOf(entry), ...listOf(entry.items).map(idOf)]).filter((id) => id !== void 0));
|
|
4497
|
+
for (const [id, wanted] of Object.entries(input.difficultyFor ?? {})) {
|
|
4498
|
+
if (!ids.has(id)) throw new RangeError(`aiVariantsRequest: difficultyFor names "${id}", which no exemplar has.`);
|
|
4499
|
+
if (!isRecord(wanted) || !DIFFICULTIES.includes(wanted.difficulty)) throw new RangeError(`aiVariantsRequest: difficultyFor["${id}"] must be easier, same or harder.`);
|
|
4500
|
+
}
|
|
4501
|
+
const moved = (id) => id !== void 0 ? input.difficultyFor?.[id] : void 0;
|
|
4502
|
+
const skipped = [];
|
|
4503
|
+
const images = [];
|
|
4504
|
+
const facts = [];
|
|
4505
|
+
entries.forEach((entry, exemplarIndex) => {
|
|
4506
|
+
const skip = skipOf(entry, exemplarIndex, imagePolicy);
|
|
4507
|
+
if (skip !== void 0) {
|
|
4508
|
+
skipped.push(skip);
|
|
4509
|
+
return;
|
|
4510
|
+
}
|
|
4511
|
+
const refs = /* @__PURE__ */ new Map();
|
|
4512
|
+
for (const slot of imageSlots(entry)) {
|
|
4513
|
+
const ref = `img-${images.length + 1}`;
|
|
4514
|
+
refs.set(slot.path.join("."), ref);
|
|
4515
|
+
images.push({
|
|
4516
|
+
ref,
|
|
4517
|
+
exemplarIndex,
|
|
4518
|
+
path: slot.path,
|
|
4519
|
+
...typeof slot.media.alt === "string" ? { alt: slot.media.alt } : {},
|
|
4520
|
+
url: stringOf(slot.media.url)
|
|
4521
|
+
});
|
|
4522
|
+
}
|
|
4523
|
+
const own = moved(idOf(entry));
|
|
4524
|
+
const entryDifficulty = own?.difficulty ?? difficulty;
|
|
4525
|
+
const items = entry.type === "item-group" ? listOf(entry.items) : [];
|
|
4526
|
+
const perItem = items.map((item, at) => {
|
|
4527
|
+
const wanted = moved(idOf(item));
|
|
4528
|
+
return {
|
|
4529
|
+
item: at,
|
|
4530
|
+
difficulty: wanted?.difficulty ?? entryDifficulty,
|
|
4531
|
+
...wanted?.reason !== void 0 ? { difficultyReason: wanted.reason } : {}
|
|
4532
|
+
};
|
|
4533
|
+
});
|
|
4534
|
+
const itemDifficulty = perItem.some((one) => one.difficulty !== entryDifficulty || one.difficultyReason !== void 0) ? perItem.map((one) => {
|
|
4535
|
+
const levers = leversFor(items[one.item], one.difficulty);
|
|
4536
|
+
return {
|
|
4537
|
+
...one,
|
|
4538
|
+
...levers !== void 0 ? { levers } : {}
|
|
4539
|
+
};
|
|
4540
|
+
}) : void 0;
|
|
4541
|
+
facts.push({
|
|
4542
|
+
exemplar: exemplarIndex,
|
|
4543
|
+
type: stringOf(entry.type),
|
|
4544
|
+
content: entryContent(entry, (path) => refs.get(path.map(String).join("."))),
|
|
4545
|
+
...Array.isArray(entry.learningObjectives) ? { learningObjectives: textList(entry.learningObjectives) } : {},
|
|
4546
|
+
targets: targetsOf(entry, entryDifficulty, own?.reason, itemDifficulty, input.lengthTolerance)
|
|
4547
|
+
});
|
|
4548
|
+
});
|
|
4549
|
+
const rules = [
|
|
4550
|
+
...RULES,
|
|
4551
|
+
...images.length > 0 ? [imagePolicy === "describe" ? DESCRIBE_RULE : REUSE_RULE] : [],
|
|
4552
|
+
...facts.some((one) => one.targets.difficulty !== "same" || (one.targets.itemDifficulty ?? []).some((item) => item.difficulty !== "same")) ? [DIFFICULTY_RULE] : [],
|
|
4553
|
+
...levelFacts !== void 0 ? [LEVEL_FACTS_RULE] : []
|
|
4554
|
+
];
|
|
4555
|
+
const varied = facts.map((one) => entries[one.exemplar]);
|
|
4556
|
+
return {
|
|
4557
|
+
feature: "variant-generation",
|
|
4558
|
+
facts: {
|
|
4559
|
+
exemplars: facts,
|
|
4560
|
+
level,
|
|
4561
|
+
...locale !== void 0 ? { locale } : typeof entries[0]?.locale === "string" ? { locale: entries[0].locale } : {},
|
|
4562
|
+
difficulty,
|
|
4563
|
+
...levelFacts !== void 0 ? { levelFacts: clone(levelFacts) } : {},
|
|
4564
|
+
imagePolicy,
|
|
4565
|
+
...images.length > 0 ? { images } : {},
|
|
4566
|
+
...avoid !== void 0 && avoid.length > 0 ? { avoid: avoid.map((entry) => ({
|
|
4567
|
+
texts: textsOf(entry).map((one) => one.text),
|
|
4568
|
+
keys: (isRecord(entry) && entry.type === "item-group" ? listOf(entry.items) : [entry]).flatMap((item) => keysOf(item).map((slot) => slot.shown))
|
|
4569
|
+
})) } : {},
|
|
4570
|
+
...instructions !== void 0 ? { instructions } : {},
|
|
4571
|
+
rules
|
|
4572
|
+
},
|
|
4573
|
+
shape: replyShape(varied, varied.length),
|
|
4574
|
+
exemplars,
|
|
4575
|
+
...avoid !== void 0 ? { avoid } : {},
|
|
4576
|
+
...settings !== void 0 ? { settings } : {},
|
|
4577
|
+
...input.lengthTolerance !== void 0 ? { lengthTolerance: { ...input.lengthTolerance } } : {},
|
|
4578
|
+
...input.balanceKeyPositions !== void 0 ? { balanceKeyPositions: { seed: input.balanceKeyPositions.seed } } : {},
|
|
4579
|
+
skipped
|
|
4580
|
+
};
|
|
4581
|
+
}
|
|
4582
|
+
/** Fields every activity copies from its exemplar: what scores it, and what it tests. */
|
|
4583
|
+
const COPIED_COMMON = [
|
|
4584
|
+
"scoringStrategy",
|
|
4585
|
+
"shuffle",
|
|
4586
|
+
"passThreshold",
|
|
4587
|
+
"locale",
|
|
4588
|
+
"ai",
|
|
4589
|
+
"learningObjectives",
|
|
4590
|
+
"slotKey"
|
|
4591
|
+
];
|
|
4592
|
+
const COPIED = {
|
|
4593
|
+
"multiple-choice": ["mode"],
|
|
4594
|
+
"fill-in-the-blanks": [],
|
|
4595
|
+
"gap-select": ["presentation", "shuffleChoices"],
|
|
4596
|
+
dictation: ["tolerance", "hints"],
|
|
4597
|
+
"read-aloud": ["recording", "scoring"],
|
|
4598
|
+
"written-response": [
|
|
4599
|
+
"rubric",
|
|
4600
|
+
"minWords",
|
|
4601
|
+
"maxWords",
|
|
4602
|
+
"languageTarget"
|
|
4603
|
+
]
|
|
4604
|
+
};
|
|
4605
|
+
function pick(from, names) {
|
|
4606
|
+
const out = {};
|
|
4607
|
+
for (const name of names) if (from[name] !== void 0) out[name] = clone(from[name]);
|
|
4608
|
+
return out;
|
|
4609
|
+
}
|
|
4610
|
+
/** The author's 1–5 difficulty, one step along with the variant's, inside the scale. */
|
|
4611
|
+
function steppedLevel(level, difficulty) {
|
|
4612
|
+
if (typeof level !== "number") return {};
|
|
4613
|
+
return { difficultyLevel: Math.min(5, Math.max(1, level + (difficulty === "easier" ? -1 : difficulty === "harder" ? 1 : 0))) };
|
|
4614
|
+
}
|
|
4615
|
+
const scriptFrom = (value) => listOf(value).flatMap((turn) => isRecord(turn) ? [{
|
|
4616
|
+
speaker: stringOf(turn.speaker),
|
|
4617
|
+
text: stringOf(turn.text)
|
|
4618
|
+
}] : []);
|
|
4619
|
+
/**
|
|
4620
|
+
* A variant's media in place of the exemplar's: a picture described anew, or
|
|
4621
|
+
* kept with `reuse`; a recording with no file yet — the host records it — its
|
|
4622
|
+
* playback policy, and the words the model wrote for it. An address, captions,
|
|
4623
|
+
* tracks and a poster belong to the old recording and are not copied.
|
|
4624
|
+
*/
|
|
4625
|
+
function variantMedia(exemplar, wrote, ownText, imagePolicy) {
|
|
4626
|
+
const model = isRecord(wrote) ? wrote : {};
|
|
4627
|
+
if (isImage(exemplar)) return imagePolicy === "reuse" ? pick(exemplar, [
|
|
4628
|
+
"type",
|
|
4629
|
+
"url",
|
|
4630
|
+
"alt"
|
|
4631
|
+
]) : {
|
|
4632
|
+
type: "image",
|
|
4633
|
+
url: null,
|
|
4634
|
+
alt: stringOf(model.alt)
|
|
4635
|
+
};
|
|
4636
|
+
const media = {
|
|
4637
|
+
type: exemplar.type,
|
|
4638
|
+
url: null,
|
|
4639
|
+
...pick(exemplar, ["playback"])
|
|
4640
|
+
};
|
|
4641
|
+
if (!ownText) {
|
|
4642
|
+
if (hasScript(exemplar)) media.script = scriptFrom(model.script);
|
|
4643
|
+
else if (typeof exemplar.transcript === "string") media.transcript = stringOf(model.transcript);
|
|
4644
|
+
}
|
|
4645
|
+
return media;
|
|
4646
|
+
}
|
|
4647
|
+
/** The feedback the model wrote, on the keys the exemplar's feedback has. */
|
|
4648
|
+
function feedbackFrom(exemplar, wrote) {
|
|
4649
|
+
if (!isRecord(exemplar.feedback) || !isRecord(wrote.feedback)) return {};
|
|
4650
|
+
const out = {};
|
|
4651
|
+
for (const name of ["correct", "incorrect"]) {
|
|
4652
|
+
const said = written(wrote.feedback[name]);
|
|
4653
|
+
if (exemplar.feedback[name] !== void 0 && said !== void 0) out[name] = said;
|
|
4654
|
+
}
|
|
4655
|
+
return Object.keys(out).length > 0 ? { feedback: out } : {};
|
|
4656
|
+
}
|
|
4657
|
+
function difficultyFor(request, exemplarIndex, item) {
|
|
4658
|
+
const targets = request.facts.exemplars.find((one) => one.exemplar === exemplarIndex)?.targets;
|
|
4659
|
+
return (item !== void 0 ? targets?.itemDifficulty?.find((one) => one.item === item) : void 0)?.difficulty ?? targets?.difficulty ?? request.facts.difficulty;
|
|
4660
|
+
}
|
|
4661
|
+
function buildActivity(exemplar, wrote, type, build, difficulty, id) {
|
|
4662
|
+
const { request, ids } = build;
|
|
4663
|
+
const imagePolicy = request.facts.imagePolicy;
|
|
4664
|
+
const alike = exemplar.type === type;
|
|
4665
|
+
const content = { title: stringOf(wrote.title) };
|
|
4666
|
+
if (typeof exemplar.instructions === "string") content.instructions = written(wrote.instructions) ?? exemplar.instructions;
|
|
4667
|
+
switch (type) {
|
|
4668
|
+
case "multiple-choice": {
|
|
4669
|
+
const before = listOf(exemplar.options);
|
|
4670
|
+
const withFeedback = before.some((option) => isRecord(option) && option.feedback !== void 0);
|
|
4671
|
+
content.question = stringOf(wrote.question);
|
|
4672
|
+
content.options = listOf(wrote.options).map((option, index) => {
|
|
4673
|
+
const fields = isRecord(option) ? option : {};
|
|
4674
|
+
const was = before[index];
|
|
4675
|
+
const feedback = withFeedback ? written(fields.feedback) : void 0;
|
|
4676
|
+
return {
|
|
4677
|
+
id: ids(),
|
|
4678
|
+
text: stringOf(fields.text),
|
|
4679
|
+
isCorrect: fields.isCorrect === true,
|
|
4680
|
+
...feedback !== void 0 ? { feedback } : {},
|
|
4681
|
+
...isRecord(was) && isRecord(was.media) ? { media: variantMedia(was.media, fields.media, false, imagePolicy) } : {}
|
|
4682
|
+
};
|
|
4683
|
+
});
|
|
4684
|
+
break;
|
|
4685
|
+
}
|
|
4686
|
+
case "fill-in-the-blanks": {
|
|
4687
|
+
const before = alike ? inPassageOrder(exemplar, "blanks").map((one) => one.value) : [];
|
|
4688
|
+
content.passage = stringOf(wrote.passage);
|
|
4689
|
+
content.blanks = listOf(wrote.blanks).map((blank, index) => {
|
|
4690
|
+
const fields = isRecord(blank) ? blank : {};
|
|
4691
|
+
const was = isRecord(before[index]) ? before[index] : {};
|
|
4692
|
+
const hint = was.hint !== void 0 ? written(fields.hint) : void 0;
|
|
4693
|
+
const feedback = was.feedback !== void 0 ? written(fields.feedback) : void 0;
|
|
4694
|
+
return {
|
|
4695
|
+
id: String(index + 1),
|
|
4696
|
+
acceptedAnswers: textList(fields.acceptedAnswers),
|
|
4697
|
+
...hint !== void 0 ? { hint } : {},
|
|
4698
|
+
...feedback !== void 0 ? { feedback } : {},
|
|
4699
|
+
...pick(was, [
|
|
4700
|
+
"match",
|
|
4701
|
+
"caseSensitive",
|
|
4702
|
+
"trimWhitespace"
|
|
4703
|
+
])
|
|
4704
|
+
};
|
|
4705
|
+
});
|
|
4706
|
+
break;
|
|
4707
|
+
}
|
|
4708
|
+
case "gap-select": {
|
|
4709
|
+
const beforeGaps = alike ? inPassageOrder(exemplar, "gaps").map((one) => one.value) : [];
|
|
4710
|
+
const beforeBanks = alike ? listOf(exemplar.banks) : [];
|
|
4711
|
+
const banks = beforeBanks.length ? listOf(wrote.banks).map((bank) => ({
|
|
4712
|
+
id: ids(),
|
|
4713
|
+
choices: listOf(isRecord(bank) ? bank.choices : void 0).map((choice) => ({
|
|
4714
|
+
id: ids(),
|
|
4715
|
+
text: typeof choice === "string" ? choice : isRecord(choice) ? stringOf(choice.text) : ""
|
|
4716
|
+
}))
|
|
4717
|
+
})) : [];
|
|
4718
|
+
content.passage = stringOf(wrote.passage);
|
|
4719
|
+
content.gaps = listOf(wrote.gaps).map((gap, index) => {
|
|
4720
|
+
const fields = isRecord(gap) ? gap : {};
|
|
4721
|
+
const was = isRecord(beforeGaps[index]) ? beforeGaps[index] : {};
|
|
4722
|
+
const feedback = was.feedback !== void 0 ? written(fields.feedback) : void 0;
|
|
4723
|
+
const drawsOn = listOf(was.choices).length === 0 ? beforeBanks.findIndex((bank) => isRecord(bank) && bank.id === was.bankId) : -1;
|
|
4724
|
+
const bank = drawsOn !== -1 ? banks[drawsOn] : void 0;
|
|
4725
|
+
if (bank !== void 0) {
|
|
4726
|
+
const answer = stringOf(fields.answer).trim().toLowerCase();
|
|
4727
|
+
const right = bank.choices.find((choice) => choice.text.trim().toLowerCase() === answer);
|
|
4728
|
+
return {
|
|
4729
|
+
id: String(index + 1),
|
|
4730
|
+
bankId: bank.id,
|
|
4731
|
+
correctChoiceId: answer !== "" && right !== void 0 ? right.id : "",
|
|
4732
|
+
...feedback !== void 0 ? { feedback } : {}
|
|
4733
|
+
};
|
|
4734
|
+
}
|
|
4735
|
+
const marked = listOf(fields.choices);
|
|
4736
|
+
const choices = marked.map((choice) => ({
|
|
4737
|
+
id: ids(),
|
|
4738
|
+
text: isRecord(choice) ? stringOf(choice.text) : ""
|
|
4739
|
+
}));
|
|
4740
|
+
const right = marked.flatMap((choice, at) => isRecord(choice) && choice.isCorrect === true ? [at] : []);
|
|
4741
|
+
return {
|
|
4742
|
+
id: String(index + 1),
|
|
4743
|
+
choices,
|
|
4744
|
+
correctChoiceId: right.length === 1 ? choices[right[0]]?.id : "",
|
|
4745
|
+
...feedback !== void 0 ? { feedback } : {}
|
|
4746
|
+
};
|
|
4747
|
+
});
|
|
4748
|
+
if (banks.length > 0) content.banks = banks;
|
|
4749
|
+
break;
|
|
4750
|
+
}
|
|
4751
|
+
case "dictation":
|
|
4752
|
+
content.transcript = stringOf(wrote.transcript);
|
|
4753
|
+
if (Array.isArray(exemplar.acceptedTranscripts) && Array.isArray(wrote.acceptedTranscripts)) {
|
|
4754
|
+
const accepted = textList(wrote.acceptedTranscripts).filter((one) => one.trim() !== "");
|
|
4755
|
+
if (accepted.length > 0) content.acceptedTranscripts = accepted;
|
|
4756
|
+
}
|
|
4757
|
+
break;
|
|
4758
|
+
case "read-aloud":
|
|
4759
|
+
content.referenceText = stringOf(wrote.referenceText);
|
|
4760
|
+
break;
|
|
4761
|
+
default: content.prompt = stringOf(wrote.prompt);
|
|
4762
|
+
}
|
|
4763
|
+
const ownText = type === "dictation" || type === "read-aloud";
|
|
4764
|
+
return {
|
|
4765
|
+
...pick(exemplar, alike ? [...COPIED_COMMON, ...COPIED[type] ?? []] : COPIED_COMMON),
|
|
4766
|
+
...steppedLevel(exemplar.difficultyLevel, difficulty),
|
|
4767
|
+
...request.settings?.[type],
|
|
4768
|
+
...content,
|
|
4769
|
+
...isRecord(exemplar.media) ? { media: variantMedia(exemplar.media, wrote.media, ownText, imagePolicy) } : {},
|
|
4770
|
+
...ownText && isRecord(exemplar.slowMedia) ? { slowMedia: {
|
|
4771
|
+
type: "audio",
|
|
4772
|
+
url: null
|
|
4773
|
+
} } : {},
|
|
4774
|
+
...feedbackFrom(exemplar, wrote),
|
|
4775
|
+
schemaVersion: typeof exemplar.schemaVersion === "string" ? exemplar.schemaVersion : "1.0",
|
|
4776
|
+
type,
|
|
4777
|
+
id
|
|
4778
|
+
};
|
|
4779
|
+
}
|
|
4780
|
+
function buildStimulus(exemplar, wrote, build) {
|
|
4781
|
+
const model = isRecord(wrote) ? wrote : {};
|
|
4782
|
+
const ownText = hasScript(exemplar) || typeof exemplar.transcript === "string";
|
|
4783
|
+
const title = typeof exemplar.title === "string" ? written(model.title) : void 0;
|
|
4784
|
+
return {
|
|
4785
|
+
id: build.ids(),
|
|
4786
|
+
kind: exemplar.kind,
|
|
4787
|
+
...title !== void 0 ? { title } : {},
|
|
4788
|
+
...typeof exemplar.body === "string" ? { body: stringOf(model.body) } : {},
|
|
4789
|
+
...hasScript(exemplar) ? { script: scriptFrom(model.script) } : typeof exemplar.transcript === "string" ? { transcript: stringOf(model.transcript) } : {},
|
|
4790
|
+
...isRecord(exemplar.media) ? { media: variantMedia(exemplar.media, model, ownText, build.request.facts.imagePolicy) } : {},
|
|
4791
|
+
...pick(exemplar, ["locale"])
|
|
4792
|
+
};
|
|
4793
|
+
}
|
|
4794
|
+
function buildEntry(exemplar, exemplarIndex, wrote, build, id) {
|
|
4795
|
+
const difficulty = difficultyFor(build.request, exemplarIndex);
|
|
4796
|
+
if (exemplar.type !== "item-group") return buildActivity(exemplar, wrote, stringOf(exemplar.type), build, difficulty, id);
|
|
4797
|
+
const before = listOf(exemplar.items);
|
|
4798
|
+
const title = typeof exemplar.title === "string" ? written(wrote.title) ?? exemplar.title : void 0;
|
|
4799
|
+
return {
|
|
4800
|
+
...pick(exemplar, ["slotKey", "shuffle"]),
|
|
4801
|
+
...title !== void 0 ? { title } : {},
|
|
4802
|
+
stimulus: buildStimulus(isRecord(exemplar.stimulus) ? exemplar.stimulus : {}, wrote.stimulus, build),
|
|
4803
|
+
items: listOf(wrote.items).map((item, index) => {
|
|
4804
|
+
const fields = isRecord(item) ? item : {};
|
|
4805
|
+
const was = isRecord(before[index]) ? before[index] : {};
|
|
4806
|
+
return buildActivity(was, fields, VARIANT_TYPES.includes(stringOf(fields.type)) ? stringOf(fields.type) : stringOf(was.type), build, difficultyFor(build.request, exemplarIndex, index), build.ids());
|
|
4807
|
+
}),
|
|
4808
|
+
schemaVersion: typeof exemplar.schemaVersion === "string" ? exemplar.schemaVersion : "1.0",
|
|
4809
|
+
type: "item-group",
|
|
4810
|
+
id
|
|
4811
|
+
};
|
|
4812
|
+
}
|
|
4813
|
+
function variantFinding(code, path, message) {
|
|
4814
|
+
return {
|
|
4815
|
+
path: [...path],
|
|
4816
|
+
message,
|
|
4817
|
+
code,
|
|
4818
|
+
severity: AI_VARIANT_CODE_SEVERITY[code]
|
|
4819
|
+
};
|
|
4820
|
+
}
|
|
4821
|
+
/** The items of an entry with their paths: the entry itself, or a group's items. */
|
|
4822
|
+
const itemsWithPaths = (entry) => isRecord(entry) && entry.type === "item-group" ? listOf(entry.items).map((item, index) => ({
|
|
4823
|
+
path: ["items", String(index)],
|
|
4824
|
+
item
|
|
4825
|
+
})) : [{
|
|
4826
|
+
path: [],
|
|
4827
|
+
item: entry
|
|
4828
|
+
}];
|
|
4829
|
+
/** The other forms' entries in this exemplar's place: the same `slotKey`, or the same position in each form. */
|
|
4830
|
+
function avoidedFor(request, exemplarIndex) {
|
|
4831
|
+
const avoid = request.avoid ?? [];
|
|
4832
|
+
const exemplar = request.exemplars[exemplarIndex];
|
|
4833
|
+
const slotKey = isRecord(exemplar) ? exemplar.slotKey : void 0;
|
|
4834
|
+
const forms = avoid.length % request.exemplars.length === 0;
|
|
4835
|
+
return avoid.flatMap((entry, index) => {
|
|
4836
|
+
const theirs = isRecord(entry) ? entry.slotKey : void 0;
|
|
4837
|
+
return (slotKey !== void 0 && theirs !== void 0 ? slotKey === theirs : slotKey === void 0 && theirs === void 0 && forms && index % request.exemplars.length === exemplarIndex) ? [{
|
|
4838
|
+
entry,
|
|
4839
|
+
label: `avoid[${index}]`
|
|
4840
|
+
}] : [];
|
|
4841
|
+
});
|
|
4842
|
+
}
|
|
4843
|
+
function nearCopies(entry, exemplar, request) {
|
|
4844
|
+
const mine = new Map(textsOf(exemplar).map((one) => [one.path.join("."), one]));
|
|
4845
|
+
const avoided = (request.avoid ?? []).flatMap((other, index) => textsOf(other).map((one) => ({
|
|
4846
|
+
...one,
|
|
4847
|
+
label: `avoid[${index}]`
|
|
4848
|
+
})));
|
|
4849
|
+
const findings = [];
|
|
4850
|
+
for (const one of textsOf(entry)) {
|
|
4851
|
+
let best;
|
|
4852
|
+
const consider = (score, what) => {
|
|
4853
|
+
if (score >= .5 && (best === void 0 || score > best.score)) best = {
|
|
4854
|
+
score,
|
|
4855
|
+
what
|
|
4856
|
+
};
|
|
4857
|
+
};
|
|
4858
|
+
const theirs = mine.get(one.path.join("."));
|
|
4859
|
+
if (theirs !== void 0) consider(textSimilarity(theirs.text, one.text), `the exemplar's ${theirs.path.join(".")}`);
|
|
4860
|
+
for (const other of avoided) if (other.kind === one.kind) consider(textSimilarity(other.text, one.text), `${other.label} ${other.path.join(".") || "text"}`);
|
|
4861
|
+
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.`));
|
|
4862
|
+
}
|
|
4863
|
+
const exemplarItems = new Map(itemsWithPaths(exemplar).map((one) => [one.path.join("."), one.item]));
|
|
4864
|
+
const avoidedOptions = (request.avoid ?? []).flatMap((other, index) => itemsWithPaths(other).map((one) => ({
|
|
4865
|
+
set: optionSet(one.item),
|
|
4866
|
+
label: `avoid[${index}]`
|
|
4867
|
+
})));
|
|
4868
|
+
for (const { path, item } of itemsWithPaths(entry)) {
|
|
4869
|
+
const options = optionSet(item);
|
|
4870
|
+
if (options === null) continue;
|
|
4871
|
+
const sameAs = (other) => other !== null && other.join("\n") === options.join("\n");
|
|
4872
|
+
const source = sameAs(optionSet(exemplarItems.get(path.join(".")))) ? "the exemplar's" : avoidedOptions.find((other) => sameAs(other.set))?.label;
|
|
4873
|
+
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.`));
|
|
4874
|
+
}
|
|
4875
|
+
return findings;
|
|
4876
|
+
}
|
|
4877
|
+
function sameKeys(entry, exemplar, exemplarIndex, request) {
|
|
4878
|
+
const against = [{
|
|
4879
|
+
entry: exemplar,
|
|
4880
|
+
label: "the exemplar"
|
|
4881
|
+
}, ...avoidedFor(request, exemplarIndex)];
|
|
4882
|
+
const findings = [];
|
|
4883
|
+
for (const { path, item } of itemsWithPaths(entry)) {
|
|
4884
|
+
const slots = keysOf(item);
|
|
4885
|
+
const reported = /* @__PURE__ */ new Set();
|
|
4886
|
+
for (const other of against) {
|
|
4887
|
+
const theirSlots = keysOf(itemsWithPaths(other.entry).find((one) => one.path.join(".") === path.join("."))?.item);
|
|
4888
|
+
for (const slot of slots) {
|
|
4889
|
+
const match = theirSlots.find((one) => one.slot === slot.slot);
|
|
4890
|
+
const shared = match !== void 0 ? sharedAnswers(slot, match) : [];
|
|
4891
|
+
if (shared.length > 0 && !reported.has(slot.slot)) {
|
|
4892
|
+
reported.add(slot.slot);
|
|
4893
|
+
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.`));
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
return findings;
|
|
4899
|
+
}
|
|
4900
|
+
/** Whether a recording the exemplar's item has words for came back with none. */
|
|
4901
|
+
function wordlessRecordings(exemplar, entry) {
|
|
4902
|
+
const mismatch = (path, message) => ({
|
|
4903
|
+
path,
|
|
4904
|
+
message,
|
|
4905
|
+
code: "variant_structure",
|
|
4906
|
+
severity: "invalid"
|
|
4907
|
+
});
|
|
4908
|
+
const out = [];
|
|
4909
|
+
const pairsOf = (a, b, at) => {
|
|
4910
|
+
if (!isRecord(a) || !isRecord(b) || a.type === "dictation" || a.type === "read-aloud") return;
|
|
4911
|
+
if (isRecording(a.media) && transcriptOf(a.media) !== null && transcriptOf(b.media) === null) out.push(mismatch([
|
|
4912
|
+
...at,
|
|
4913
|
+
"media",
|
|
4914
|
+
hasScript(a.media) ? "script" : "transcript"
|
|
4915
|
+
], "The recording has no words to record: write what it says."));
|
|
4916
|
+
};
|
|
4917
|
+
if (exemplar.type === "item-group") {
|
|
4918
|
+
const before = isRecord(exemplar.stimulus) ? exemplar.stimulus : {};
|
|
4919
|
+
const after = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
4920
|
+
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."));
|
|
4921
|
+
listOf(exemplar.items).forEach((item, index) => {
|
|
4922
|
+
pairsOf(item, listOf(entry.items)[index], ["items", String(index)]);
|
|
4923
|
+
});
|
|
4924
|
+
} else pairsOf(exemplar, entry, []);
|
|
4925
|
+
return out;
|
|
4926
|
+
}
|
|
4927
|
+
function pairsOf(exemplar, entry) {
|
|
4928
|
+
const top = {
|
|
4929
|
+
exemplarPath: [],
|
|
4930
|
+
variantPath: [],
|
|
4931
|
+
exemplarId: stringOf(exemplar.id),
|
|
4932
|
+
variantId: stringOf(entry.id)
|
|
4933
|
+
};
|
|
4934
|
+
if (exemplar.type !== "item-group") return [top];
|
|
4935
|
+
const theirs = listOf(entry.items);
|
|
4936
|
+
return [top, ...listOf(exemplar.items).flatMap((item, index) => index < theirs.length ? [{
|
|
4937
|
+
exemplarPath: ["items", String(index)],
|
|
4938
|
+
variantPath: ["items", String(index)],
|
|
4939
|
+
exemplarId: idOf(item) ?? "",
|
|
4940
|
+
variantId: idOf(theirs[index]) ?? ""
|
|
4941
|
+
}] : [])];
|
|
4942
|
+
}
|
|
4943
|
+
function evaluate(built, request) {
|
|
4944
|
+
const exemplar = request.exemplars[built.exemplarIndex];
|
|
4945
|
+
const type = stringOf(exemplar.type);
|
|
4946
|
+
const { entry } = built;
|
|
4947
|
+
const group = type === "item-group";
|
|
4948
|
+
const validation = group ? validateItemGroupDraft(entry) : validateDraft(type, entry);
|
|
4949
|
+
const mismatches = built.wroteType !== type ? [{
|
|
4950
|
+
path: ["type"],
|
|
4951
|
+
message: `This was written as ${built.wroteType || "no type"}; the exemplar is ${type}.`,
|
|
4952
|
+
code: "variant_type",
|
|
4953
|
+
severity: "invalid"
|
|
4954
|
+
}] : [...structureMisses(exemplar, entry).map((miss) => ({
|
|
4955
|
+
...miss,
|
|
4956
|
+
code: "variant_structure",
|
|
4957
|
+
severity: "invalid"
|
|
4958
|
+
})), ...wordlessRecordings(exemplar, entry)];
|
|
4959
|
+
const findings = [
|
|
4960
|
+
...group ? critiqueItemGroupDraft(entry) : critiqueDraft(type, entry),
|
|
4961
|
+
...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]}.`)),
|
|
4962
|
+
...nearCopies(entry, exemplar, request),
|
|
4963
|
+
...sameKeys(entry, exemplar, built.exemplarIndex, request)
|
|
4964
|
+
];
|
|
4965
|
+
const status = validation.status === "invalid" || mismatches.length > 0 ? "invalid" : validation.status === "incomplete" ? "incomplete" : "complete";
|
|
4966
|
+
return {
|
|
4967
|
+
exemplarIndex: built.exemplarIndex,
|
|
4968
|
+
type,
|
|
4969
|
+
entry,
|
|
4970
|
+
status,
|
|
4971
|
+
validation,
|
|
4972
|
+
mismatches,
|
|
4973
|
+
findings,
|
|
4974
|
+
pairs: pairsOf(exemplar, entry),
|
|
4975
|
+
generated: built.generated
|
|
4976
|
+
};
|
|
4977
|
+
}
|
|
4978
|
+
/**
|
|
4979
|
+
* Moves each single-answer, unshuffled multiple choice's right option to the
|
|
4980
|
+
* position used least so far across the variants, ties broken by an order
|
|
4981
|
+
* drawn from `seed`: as even as the counts allow, and the same every time.
|
|
4982
|
+
* An option moves with its id, text, feedback and media.
|
|
4983
|
+
*/
|
|
4984
|
+
function balanceKeys(built, seed) {
|
|
4985
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4986
|
+
let slot = 0;
|
|
4987
|
+
const visit = (item) => {
|
|
4988
|
+
if (!isRecord(item) || item.type !== "multiple-choice" || item.mode !== "single" || item.shuffle === true) return item;
|
|
4989
|
+
const options = listOf(item.options);
|
|
4990
|
+
const keys = options.flatMap((option, index) => isRecord(option) && option.isCorrect === true ? [index] : []);
|
|
4991
|
+
if (keys.length !== 1) return item;
|
|
4992
|
+
const order = seededShuffle(options.map((_, index) => index), `${seed}:${slot}`, { version: 2 });
|
|
4993
|
+
slot += 1;
|
|
4994
|
+
let target = order[0];
|
|
4995
|
+
for (const position of order) if ((counts.get(position) ?? 0) < (counts.get(target) ?? 0)) target = position;
|
|
4996
|
+
counts.set(target, (counts.get(target) ?? 0) + 1);
|
|
4997
|
+
const key = options[keys[0]];
|
|
4998
|
+
const rest = options.filter((_, index) => index !== keys[0]);
|
|
4999
|
+
return {
|
|
5000
|
+
...item,
|
|
5001
|
+
options: [
|
|
5002
|
+
...rest.slice(0, target),
|
|
5003
|
+
key,
|
|
5004
|
+
...rest.slice(target)
|
|
5005
|
+
]
|
|
5006
|
+
};
|
|
5007
|
+
};
|
|
5008
|
+
return [...built].sort((a, b) => a.exemplarIndex - b.exemplarIndex).map((one) => ({
|
|
5009
|
+
...one,
|
|
5010
|
+
entry: one.entry.type === "item-group" ? {
|
|
5011
|
+
...one.entry,
|
|
5012
|
+
items: listOf(one.entry.items).map(visit)
|
|
5013
|
+
} : visit(one.entry)
|
|
5014
|
+
}));
|
|
5015
|
+
}
|
|
5016
|
+
/** Ids from the host, checked as `createDraft` checks them. */
|
|
5017
|
+
function idSource(newId, caller) {
|
|
5018
|
+
const issued = /* @__PURE__ */ new Set();
|
|
5019
|
+
return () => {
|
|
5020
|
+
const id = newId();
|
|
5021
|
+
if (typeof id !== "string" || id === "") throw new Error(`${caller}: newId() must return a non-empty string.`);
|
|
5022
|
+
if (issued.has(id)) throw new Error(`${caller}: newId() returned "${id}" twice. Ids must differ — two options sharing an id cannot be scored apart.`);
|
|
5023
|
+
issued.add(id);
|
|
5024
|
+
return id;
|
|
5025
|
+
};
|
|
5026
|
+
}
|
|
5027
|
+
function missingOf(exemplarIndex) {
|
|
5028
|
+
return {
|
|
5029
|
+
exemplarIndex,
|
|
5030
|
+
code: "variant_missing",
|
|
5031
|
+
message: `The reply has no variant of exemplar ${exemplarIndex}.`,
|
|
5032
|
+
severity: "invalid"
|
|
5033
|
+
};
|
|
5034
|
+
}
|
|
5035
|
+
function read(raw, request, ids, keep) {
|
|
5036
|
+
if (!isRecord(raw) || !Array.isArray(raw.variants) || !raw.variants.every(isRecord)) return {
|
|
5037
|
+
ok: false,
|
|
5038
|
+
refusal: "malformed"
|
|
5039
|
+
};
|
|
5040
|
+
const asked = request.repair ? request.repair.map((one) => one.exemplar) : request.facts.exemplars.map((one) => one.exemplar);
|
|
5041
|
+
if (raw.variants.length > asked.length) return {
|
|
5042
|
+
ok: false,
|
|
5043
|
+
refusal: "too-long"
|
|
5044
|
+
};
|
|
5045
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5046
|
+
for (const variant of raw.variants) {
|
|
5047
|
+
const index = variant.exemplar;
|
|
5048
|
+
if (typeof index !== "number" || !asked.includes(index) || seen.has(index)) return {
|
|
5049
|
+
ok: false,
|
|
5050
|
+
refusal: "malformed"
|
|
5051
|
+
};
|
|
5052
|
+
seen.add(index);
|
|
5053
|
+
}
|
|
5054
|
+
const build = {
|
|
5055
|
+
request,
|
|
5056
|
+
ids
|
|
5057
|
+
};
|
|
5058
|
+
return {
|
|
5059
|
+
ok: true,
|
|
5060
|
+
built: raw.variants.map((variant) => {
|
|
5061
|
+
const exemplarIndex = variant.exemplar;
|
|
5062
|
+
const exemplar = request.exemplars[exemplarIndex];
|
|
5063
|
+
return {
|
|
5064
|
+
exemplarIndex,
|
|
5065
|
+
wroteType: stringOf(variant.type),
|
|
5066
|
+
entry: buildEntry(exemplar, exemplarIndex, variant, build, keep.get(exemplarIndex) ?? ids()),
|
|
5067
|
+
generated: variant
|
|
5068
|
+
};
|
|
5069
|
+
}).sort((a, b) => a.exemplarIndex - b.exemplarIndex),
|
|
5070
|
+
missing: asked.filter((index) => !seen.has(index)).sort((a, b) => a - b).map(missingOf),
|
|
5071
|
+
raw
|
|
5072
|
+
};
|
|
5073
|
+
}
|
|
5074
|
+
function setFindings(variants) {
|
|
5075
|
+
const entries = variants.map((one) => one.entry);
|
|
5076
|
+
return [...keyPositionFinding(entries, []), ...paperFindings(entries)];
|
|
5077
|
+
}
|
|
5078
|
+
/**
|
|
5079
|
+
* Reads a model's reply to {@link aiVariantsRequest} into variants an editor
|
|
5080
|
+
* can show for approval: each made from what the model wrote and what its
|
|
5081
|
+
* exemplar is — type, structure, scoring and settings copied from the
|
|
5082
|
+
* exemplar, whatever the reply says of them — with ids from `newId`, checked
|
|
5083
|
+
* by `validateDraft` (or `validateItemGroupDraft`), compared with its exemplar
|
|
5084
|
+
* (`mismatches`: `variant_type`, `variant_structure`), critiqued, and checked
|
|
5085
|
+
* for length, near copies and repeated keys against the exemplar and every
|
|
5086
|
+
* entry in `avoid` (`findings`: `variant_length`, `variant_near_copy`,
|
|
5087
|
+
* `variant_same_key`). `pairs` line each exemplar item up with its variant's.
|
|
5088
|
+
*
|
|
5089
|
+
* A recording comes back with no file — `url: null`, its playback policy, and
|
|
5090
|
+
* a new script or transcript — and a picture to describe with no file and a
|
|
5091
|
+
* new `alt`: `media_url_required` until the host attaches one.
|
|
5092
|
+
*
|
|
5093
|
+
* Variants are independent: one that is wrong comes back as such, beside the
|
|
5094
|
+
* others. An exemplar the reply wrote nothing for is listed in `missing`. The
|
|
5095
|
+
* whole reply is refused only when it is not `{ variants: [...] }` of objects
|
|
5096
|
+
* each naming an exemplar of the facts once (`malformed`), or holds more
|
|
5097
|
+
* variants than exemplars (`too-long`).
|
|
5098
|
+
*
|
|
5099
|
+
* With `balanceKeyPositions` the single-answer keys are spread across
|
|
5100
|
+
* positions, from its seed.
|
|
5101
|
+
*
|
|
5102
|
+
* @throws Error when `newId` returns an empty or repeated id.
|
|
5103
|
+
*/
|
|
5104
|
+
function checkAiVariants(raw, request, options) {
|
|
5105
|
+
const result = read(raw, request, idSource(options.newId, "checkAiVariants"), /* @__PURE__ */ new Map());
|
|
5106
|
+
if (!result.ok) return result;
|
|
5107
|
+
const seed = request.balanceKeyPositions?.seed;
|
|
5108
|
+
const variants = (seed !== void 0 ? balanceKeys(result.built, seed) : result.built).map((one) => evaluate(one, request));
|
|
5109
|
+
const provenance = provenanceOf(result.raw.provenance);
|
|
5110
|
+
const usage = usageOf(result.raw.usage);
|
|
5111
|
+
return {
|
|
5112
|
+
ok: true,
|
|
5113
|
+
variants: {
|
|
5114
|
+
variants,
|
|
5115
|
+
missing: result.missing,
|
|
5116
|
+
findings: setFindings(variants),
|
|
5117
|
+
...provenance !== void 0 ? { provenance } : {},
|
|
5118
|
+
...usage !== void 0 ? { usage } : {}
|
|
5119
|
+
}
|
|
5120
|
+
};
|
|
5121
|
+
}
|
|
5122
|
+
/** The fields a model writes, and inside media and material, the ones it writes there. */
|
|
5123
|
+
const WRITTEN_FIELDS = /* @__PURE__ */ new Set([
|
|
5124
|
+
"type",
|
|
5125
|
+
"title",
|
|
5126
|
+
"instructions",
|
|
5127
|
+
"question",
|
|
5128
|
+
"options",
|
|
5129
|
+
"passage",
|
|
5130
|
+
"blanks",
|
|
5131
|
+
"gaps",
|
|
5132
|
+
"banks",
|
|
5133
|
+
"transcript",
|
|
5134
|
+
"acceptedTranscripts",
|
|
5135
|
+
"referenceText",
|
|
5136
|
+
"prompt",
|
|
5137
|
+
"feedback",
|
|
5138
|
+
"media",
|
|
5139
|
+
"stimulus",
|
|
5140
|
+
"items"
|
|
5141
|
+
]);
|
|
5142
|
+
const WRITTEN_IN_MEDIA = /* @__PURE__ */ new Set([
|
|
5143
|
+
"alt",
|
|
5144
|
+
"transcript",
|
|
5145
|
+
"script"
|
|
5146
|
+
]);
|
|
5147
|
+
const WRITTEN_IN_STIMULUS = /* @__PURE__ */ new Set([
|
|
5148
|
+
"title",
|
|
5149
|
+
"body",
|
|
5150
|
+
"transcript",
|
|
5151
|
+
"script",
|
|
5152
|
+
"media",
|
|
5153
|
+
"alt"
|
|
5154
|
+
]);
|
|
5155
|
+
function writable(path) {
|
|
5156
|
+
const rest = path[0] === "items" && path.length > 2 ? path.slice(2) : path;
|
|
5157
|
+
const [first, second, third] = rest;
|
|
5158
|
+
if (first === void 0 || !WRITTEN_FIELDS.has(first)) return false;
|
|
5159
|
+
if (first === "media") return second !== void 0 && WRITTEN_IN_MEDIA.has(second);
|
|
5160
|
+
if (first === "stimulus") {
|
|
5161
|
+
if (second === "media") return third !== void 0 && WRITTEN_IN_MEDIA.has(third);
|
|
5162
|
+
return second !== void 0 && WRITTEN_IN_STIMULUS.has(second);
|
|
5163
|
+
}
|
|
5164
|
+
if (first === "options" && rest[2] === "media") return rest[3] !== void 0 && WRITTEN_IN_MEDIA.has(rest[3]);
|
|
5165
|
+
return true;
|
|
5166
|
+
}
|
|
5167
|
+
/** Where the model wrote what a problem names. */
|
|
5168
|
+
function pathForModel(variant, problem) {
|
|
5169
|
+
const { path, code } = problem;
|
|
5170
|
+
const prefix = path[0] === "items" && path.length > 2 ? path.slice(0, 2) : [];
|
|
5171
|
+
const rest = path.slice(prefix.length);
|
|
5172
|
+
if (code === "ra_text_long_for_time") return [...prefix, "referenceText"];
|
|
5173
|
+
if (rest[0] === "gaps" && rest.length === 3 && rest[2] === "correctChoiceId") {
|
|
5174
|
+
const item = prefix.length > 0 ? listOf(variant.entry.items)[Number(prefix[1])] : variant.entry;
|
|
5175
|
+
const gap = isRecord(item) ? listOf(item.gaps)[Number(rest[1])] : void 0;
|
|
5176
|
+
return [
|
|
5177
|
+
...prefix,
|
|
5178
|
+
"gaps",
|
|
5179
|
+
rest[1],
|
|
5180
|
+
isRecord(gap) && gap.bankId !== void 0 ? "answer" : "choices"
|
|
5181
|
+
];
|
|
5182
|
+
}
|
|
5183
|
+
if (rest[0] === "stimulus" && rest[1] === "media" && (rest[2] === "script" || rest[2] === "transcript")) return ["stimulus", rest[2]];
|
|
5184
|
+
return [...path];
|
|
5185
|
+
}
|
|
5186
|
+
function problemsOf(variant) {
|
|
5187
|
+
return [
|
|
5188
|
+
...variant.mismatches.map((found) => ({
|
|
5189
|
+
path: found.path,
|
|
5190
|
+
code: found.code,
|
|
5191
|
+
message: found.message,
|
|
5192
|
+
severity: "invalid"
|
|
5193
|
+
})),
|
|
5194
|
+
...variant.validation.issues.filter((found) => found.code !== "media_url_required").map((found) => ({
|
|
5195
|
+
path: found.path,
|
|
5196
|
+
code: found.code,
|
|
5197
|
+
message: found.message,
|
|
5198
|
+
severity: found.severity
|
|
5199
|
+
})),
|
|
5200
|
+
...variant.findings.filter((found) => found.severity === "warning").map((found) => ({
|
|
5201
|
+
path: found.path,
|
|
5202
|
+
code: found.code,
|
|
5203
|
+
message: found.message,
|
|
5204
|
+
severity: "warning"
|
|
5205
|
+
}))
|
|
5206
|
+
].map((problem) => ({
|
|
5207
|
+
...problem,
|
|
5208
|
+
path: pathForModel(variant, problem)
|
|
5209
|
+
})).filter((problem) => writable(problem.path));
|
|
5210
|
+
}
|
|
5211
|
+
/**
|
|
5212
|
+
* A second request, for the variants with something the model can fix —
|
|
5213
|
+
* a wrong type or structure, an issue other than a missing file, or a warning
|
|
5214
|
+
* at a field the model writes — and the exemplars the reply wrote nothing for
|
|
5215
|
+
* (`variant_missing`). Each comes with its problems at the paths the model
|
|
5216
|
+
* wrote; the exemplar and its targets stay in the facts. `null` when there is
|
|
5217
|
+
* nothing to repair.
|
|
5218
|
+
*
|
|
5219
|
+
* The reply holds one variant for each, naming its exemplar;
|
|
5220
|
+
* {@link checkAiVariants} reads it.
|
|
5221
|
+
*/
|
|
5222
|
+
function aiVariantsRepairRequest(request, variants) {
|
|
5223
|
+
const list = Array.isArray(variants) ? variants : variants.variants;
|
|
5224
|
+
const missing = Array.isArray(variants) ? [] : variants.missing;
|
|
5225
|
+
const repair = [...list.flatMap((one) => {
|
|
5226
|
+
const problems = problemsOf(one);
|
|
5227
|
+
return problems.length === 0 ? [] : [{
|
|
5228
|
+
exemplar: one.exemplarIndex,
|
|
5229
|
+
variant: one.generated,
|
|
5230
|
+
problems
|
|
5231
|
+
}];
|
|
5232
|
+
}), ...missing.map((one) => ({
|
|
5233
|
+
exemplar: one.exemplarIndex,
|
|
5234
|
+
problems: [{
|
|
5235
|
+
path: [],
|
|
5236
|
+
code: one.code,
|
|
5237
|
+
message: one.message,
|
|
5238
|
+
severity: "invalid"
|
|
5239
|
+
}]
|
|
5240
|
+
}))].sort((a, b) => a.exemplar - b.exemplar);
|
|
5241
|
+
if (repair.length === 0) return null;
|
|
5242
|
+
const { repair: _previous, ...first } = request;
|
|
5243
|
+
return {
|
|
5244
|
+
...first,
|
|
5245
|
+
shape: replyShape(repair.map((one) => request.exemplars[one.exemplar]), repair.length),
|
|
5246
|
+
repair
|
|
5247
|
+
};
|
|
5248
|
+
}
|
|
5249
|
+
const RANK = {
|
|
5250
|
+
complete: 2,
|
|
5251
|
+
incomplete: 1,
|
|
5252
|
+
invalid: 0
|
|
5253
|
+
};
|
|
5254
|
+
const invalidCount = (one) => one.mismatches.length + one.validation.issues.filter((found) => found.severity === "invalid").length;
|
|
5255
|
+
const warningCount = (one) => one.findings.filter((found) => found.severity === "warning").length;
|
|
5256
|
+
/** Whether a repaired variant is at least as good as the one it would replace. */
|
|
5257
|
+
function noWorse(repaired, before) {
|
|
5258
|
+
const now = RANK[repaired.status];
|
|
5259
|
+
const was = RANK[before.status];
|
|
5260
|
+
if (now !== was) return now > was;
|
|
5261
|
+
if (invalidCount(repaired) !== invalidCount(before)) return invalidCount(repaired) < invalidCount(before);
|
|
5262
|
+
return warningCount(repaired) <= warningCount(before);
|
|
5263
|
+
}
|
|
5264
|
+
/**
|
|
5265
|
+
* Writes variants of existing items, with a bounded repair loop: asks the port
|
|
5266
|
+
* with `request`, reads the reply as {@link checkAiVariants} does, then — up
|
|
5267
|
+
* to `repairs` times — sends back what the model can fix and what it did not
|
|
5268
|
+
* write ({@link aiVariantsRepairRequest}), keeping each variant's best pass:
|
|
5269
|
+
* by status, then fewer invalid problems, then fewer warnings. A repaired
|
|
5270
|
+
* variant keeps its id. With `balanceKeyPositions`, the keys are spread once,
|
|
5271
|
+
* over the variants as they end.
|
|
5272
|
+
*
|
|
5273
|
+
* Never throws for a model's failure: a port that throws or a reply the SDK
|
|
5274
|
+
* refuses ends the loop, and is recorded in `calls` beside the provenance and
|
|
5275
|
+
* usage of every call. A first call that fails leaves no variants. A request
|
|
5276
|
+
* with no exemplar to vary makes no call.
|
|
5277
|
+
*
|
|
5278
|
+
* ```ts
|
|
5279
|
+
* const run = await generateVariants({
|
|
5280
|
+
* request: aiVariantsRequest({ exemplars: section, level: 'CEFR B1', avoid: otherForms }),
|
|
5281
|
+
* port: (request) => callMyModel(VARIANTS_PROMPT, request),
|
|
5282
|
+
* newId: () => crypto.randomUUID(),
|
|
5283
|
+
* });
|
|
5284
|
+
* // run.variants: store each as proposed, for a person to approve.
|
|
5285
|
+
* ```
|
|
5286
|
+
*
|
|
5287
|
+
* @throws RangeError when `repairs` is not a whole number from 0 to
|
|
5288
|
+
* {@link AI_VARIANTS_MAX_REPAIRS} (default 1), and Error when `newId` returns
|
|
5289
|
+
* an empty or repeated id.
|
|
5290
|
+
*/
|
|
5291
|
+
async function generateVariants(input) {
|
|
5292
|
+
const { request, port, newId } = input;
|
|
5293
|
+
const repairs = input.repairs ?? 1;
|
|
5294
|
+
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)}.`);
|
|
5295
|
+
const calls = [];
|
|
5296
|
+
const skipped = request.skipped;
|
|
5297
|
+
if (request.facts.exemplars.length === 0) return {
|
|
5298
|
+
variants: [],
|
|
5299
|
+
missing: [],
|
|
5300
|
+
findings: [],
|
|
5301
|
+
skipped,
|
|
5302
|
+
calls
|
|
5303
|
+
};
|
|
5304
|
+
const ids = idSource(newId, "generateVariants");
|
|
5305
|
+
const ask = async (asked, keep) => {
|
|
5306
|
+
let raw;
|
|
5307
|
+
try {
|
|
5308
|
+
raw = await port(asked);
|
|
5309
|
+
} catch (error) {
|
|
5310
|
+
calls.push({
|
|
5311
|
+
ok: false,
|
|
5312
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5313
|
+
});
|
|
5314
|
+
return;
|
|
5315
|
+
}
|
|
5316
|
+
const result = read(raw, asked, ids, keep);
|
|
5317
|
+
if (!result.ok) {
|
|
5318
|
+
calls.push({
|
|
5319
|
+
ok: false,
|
|
5320
|
+
refusal: result.refusal
|
|
5321
|
+
});
|
|
5322
|
+
return;
|
|
5323
|
+
}
|
|
5324
|
+
const provenance = provenanceOf(result.raw.provenance);
|
|
5325
|
+
const usage = usageOf(result.raw.usage);
|
|
5326
|
+
calls.push({
|
|
5327
|
+
ok: true,
|
|
5328
|
+
...provenance !== void 0 ? { provenance } : {},
|
|
5329
|
+
...usage !== void 0 ? { usage } : {}
|
|
5330
|
+
});
|
|
5331
|
+
return {
|
|
5332
|
+
built: result.built,
|
|
5333
|
+
variants: result.built.map((one) => evaluate(one, request)),
|
|
5334
|
+
missing: result.missing
|
|
5335
|
+
};
|
|
5336
|
+
};
|
|
5337
|
+
const first = await ask(request, /* @__PURE__ */ new Map());
|
|
5338
|
+
if (first === void 0) return {
|
|
5339
|
+
variants: [],
|
|
5340
|
+
missing: [],
|
|
5341
|
+
findings: [],
|
|
5342
|
+
skipped,
|
|
5343
|
+
calls
|
|
5344
|
+
};
|
|
5345
|
+
const best = /* @__PURE__ */ new Map();
|
|
5346
|
+
first.variants.forEach((variant, index) => {
|
|
5347
|
+
best.set(variant.exemplarIndex, {
|
|
5348
|
+
built: first.built[index],
|
|
5349
|
+
variant
|
|
5350
|
+
});
|
|
5351
|
+
});
|
|
5352
|
+
const wanted = request.facts.exemplars.map((one) => one.exemplar);
|
|
5353
|
+
const missingNow = () => wanted.filter((index) => !best.has(index)).map(missingOf);
|
|
5354
|
+
for (let pass = 0; pass < repairs; pass += 1) {
|
|
5355
|
+
const repairRequest = aiVariantsRepairRequest(request, {
|
|
5356
|
+
variants: [...best.values()].map((one) => one.variant),
|
|
5357
|
+
missing: missingNow(),
|
|
5358
|
+
findings: []
|
|
5359
|
+
});
|
|
5360
|
+
if (repairRequest === null) break;
|
|
5361
|
+
const repaired = await ask(repairRequest, new Map((repairRequest.repair ?? []).flatMap((one) => {
|
|
5362
|
+
const before = best.get(one.exemplar);
|
|
5363
|
+
return before !== void 0 ? [[one.exemplar, stringOf(before.variant.entry.id)]] : [];
|
|
5364
|
+
})));
|
|
5365
|
+
if (repaired === void 0) break;
|
|
5366
|
+
repaired.variants.forEach((variant, index) => {
|
|
5367
|
+
const before = best.get(variant.exemplarIndex);
|
|
5368
|
+
if (before === void 0 || noWorse(variant, before.variant)) best.set(variant.exemplarIndex, {
|
|
5369
|
+
built: repaired.built[index],
|
|
5370
|
+
variant
|
|
5371
|
+
});
|
|
5372
|
+
});
|
|
5373
|
+
}
|
|
5374
|
+
const ended = [...best.values()].sort((a, b) => a.variant.exemplarIndex - b.variant.exemplarIndex);
|
|
5375
|
+
const seed = request.balanceKeyPositions?.seed;
|
|
5376
|
+
const variants = seed !== void 0 ? balanceKeys(ended.map((one) => one.built), seed).map((one) => evaluate(one, request)) : ended.map((one) => one.variant);
|
|
5377
|
+
return {
|
|
5378
|
+
variants,
|
|
5379
|
+
missing: missingNow(),
|
|
5380
|
+
findings: setFindings(variants),
|
|
5381
|
+
skipped,
|
|
5382
|
+
calls
|
|
5383
|
+
};
|
|
5384
|
+
}
|
|
3007
5385
|
// src/ai-writing.ts
|
|
3008
5386
|
/**
|
|
3009
5387
|
* The request for feedback on a draft, or `null` when there is none to give:
|
|
@@ -3085,7 +5463,7 @@ function checkCriteria(raw, rubric) {
|
|
|
3085
5463
|
const seen = /* @__PURE__ */ new Set();
|
|
3086
5464
|
const criteria = [];
|
|
3087
5465
|
for (const entry of raw) {
|
|
3088
|
-
if (!isRecord(entry) || typeof entry.name !== "string") return {
|
|
5466
|
+
if (!isRecord$1(entry) || typeof entry.name !== "string") return {
|
|
3089
5467
|
ok: false,
|
|
3090
5468
|
refusal: "malformed"
|
|
3091
5469
|
};
|
|
@@ -3174,6 +5552,6 @@ function checkAiWritingFeedback(raw, request) {
|
|
|
3174
5552
|
}
|
|
3175
5553
|
};
|
|
3176
5554
|
}
|
|
3177
|
-
export {
|
|
5555
|
+
export { AI_EXPLANATION_TYPES as $, validateDraft as A, optionSet as B, interactiveVideoFromDrafts as C, checkAiCritique as D, aiCritiqueRequest as E, validateItemGroupDraft as F, textsOf as G, shownScore as H, AI_VARIANT_LENGTH_BANDS as I, transcriptOf as J, listMediaRefs as K, AI_VARIANT_NEAR_COPY_THRESHOLD as L, createItemGroupDraft as M, critiqueDrafts as N, createDraft as O, critiqueItemGroupDraft as P, checkAiCoaching as Q, keysOf as R, generateDrafts as S, AI_CRITIQUE_MAX_FINDINGS as T, structureMisses as U, sharedAnswers as V, textSimilarity as W, AI_COACHING_MAX_WORDS as X, AI_COACHING_MAX_TIP_LENGTH as Y, aiCoachingRequest as Z, AI_DRAFTS_MAX_COUNT as _, AI_VARIANTS_MAX_REPAIRS as a, aiHintRequest as at, aiDraftsRequest as b, aiVariantsRequest as c, checkAiExplanation as ct, AI_GRADING_PROBABILITY_TOLERANCE as d, readAiTextResult as dt, AI_HINT_TYPES as et, aiGradingRequest as f, AI_MATERIAL_MAX_LENGTH as ft, AI_WRITING_MAX_FIELD_LENGTH as g, AI_WRITING_MAX_CORRECTIONS as h, AI_VARIANTS_MAX_MATERIAL_LENGTH as i, aiGradeOf as it, createInteractiveVideoDraft as j, critiqueDraft as k, checkAiVariants as l, checkAiHint as lt, checkAiGrading as m, checkAiWritingFeedback as n, aiAllowedByContent as nt, AI_VARIANT_CODE_SEVERITY as o, aiSupports as ot, aiSpeakingGradingRequest as p, mapMediaRefs as q, AI_VARIANTS_MAX_EXEMPLARS as r, aiExplanationRequest as rt, aiVariantsRepairRequest as s, buildAiFacts as st, aiWritingFeedbackRequest as t, AI_TEXT_MAX_LENGTH as tt, generateVariants as u, hintRevealsAnswer as ut, AI_DRAFTS_MAX_REPAIRS as v, AI_CRITIQUE_MAX_FIELD_LENGTH as w, checkAiDrafts as x, aiDraftsRepairRequest as y, lengthMisses as z };
|
|
3178
5556
|
|
|
3179
|
-
//# sourceMappingURL=ai-writing-
|
|
5557
|
+
//# sourceMappingURL=ai-writing-DQjI9EFN.js.map
|