@intellectif/lk-core 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/dist/{ai-i3jwXnpX.d.ts → ai-Cnm0qE8v.d.cts} +334 -16
- package/dist/ai-Cnm0qE8v.d.cts.map +1 -0
- package/dist/{ai-i3jwXnpX.d.cts → ai-Cnm0qE8v.d.ts} +334 -16
- package/dist/ai-Cnm0qE8v.d.ts.map +1 -0
- package/dist/ai-check.cjs +88 -7
- package/dist/ai-check.cjs.map +1 -1
- package/dist/ai-check.d.cts +11 -7
- package/dist/ai-check.d.cts.map +1 -1
- package/dist/ai-check.d.ts +11 -7
- package/dist/ai-check.d.ts.map +1 -1
- package/dist/ai-check.js +88 -7
- package/dist/ai-check.js.map +1 -1
- package/dist/{ai-writing-C4YRVrFi.cjs → ai-writing-Bh2Y53cY.cjs} +2567 -203
- package/dist/ai-writing-Bh2Y53cY.cjs.map +1 -0
- package/dist/{ai-writing-OJ5m_1EB.js → ai-writing-DeIV-GfN.js} +2428 -172
- package/dist/ai-writing-DeIV-GfN.js.map +1 -0
- package/dist/{index-Dj92T76o.d.ts → index-CtoPhJAH.d.ts} +2 -8
- package/dist/index-CtoPhJAH.d.ts.map +1 -0
- package/dist/{index--U7hHV0s.d.cts → index-DMh3OYSb.d.cts} +2 -8
- package/dist/index-DMh3OYSb.d.cts.map +1 -0
- package/dist/{index-WBHSrIUU.d.ts → index-HXvg0YRQ.d.ts} +5 -12
- package/dist/index-HXvg0YRQ.d.ts.map +1 -0
- package/dist/{index-CFRgZLhH.d.cts → index-fZ67k-WR.d.cts} +5 -12
- package/dist/index-fZ67k-WR.d.cts.map +1 -0
- package/dist/{index-DeD8EI1D.d.ts → index-g-EWIugp.d.ts} +2 -42
- package/dist/index-g-EWIugp.d.ts.map +1 -0
- package/dist/{index-Uh1u57yq.d.cts → index-oST8GBFa.d.cts} +2 -42
- package/dist/index-oST8GBFa.d.cts.map +1 -0
- package/dist/index.cjs +287 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +227 -71
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +227 -71
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +276 -21
- package/dist/index.js.map +1 -1
- package/dist/{item-group-BbvvH_Ob.js → item-group-BceUoxoM.js} +4 -6
- package/dist/{item-group-BbvvH_Ob.js.map → item-group-BceUoxoM.js.map} +1 -1
- package/dist/{item-group-CQRbb8Rx.cjs → item-group-KV4hYu3a.cjs} +4 -6
- package/dist/{item-group-CQRbb8Rx.cjs.map → item-group-KV4hYu3a.cjs.map} +1 -1
- package/dist/{registry-Ih5n4PVZ.cjs → registry-Cymq7Ah0.cjs} +97 -92
- package/dist/registry-Cymq7Ah0.cjs.map +1 -0
- package/dist/{registry-B23o9UWh.js → registry-tkacvJUI.js} +79 -91
- package/dist/registry-tkacvJUI.js.map +1 -0
- package/dist/{schemas-yId-bJhD.js → schemas-DM8sN_f0.js} +5 -7
- package/dist/{schemas-yId-bJhD.js.map → schemas-DM8sN_f0.js.map} +1 -1
- package/dist/{schemas-Q2dyOgc2.cjs → schemas-wDzbKdKa.cjs} +5 -7
- package/dist/{schemas-Q2dyOgc2.cjs.map → schemas-wDzbKdKa.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-CaPS6q8b.cjs → scoring-D9tFvPEG.cjs} +22 -42
- package/dist/{scoring-CaPS6q8b.cjs.map → scoring-D9tFvPEG.cjs.map} +1 -1
- package/dist/{scoring-CLMdk8i9.js → scoring-DIBmo_cy.js} +22 -42
- package/dist/{scoring-CLMdk8i9.js.map → scoring-DIBmo_cy.js.map} +1 -1
- package/dist/scoring.cjs +2 -2
- package/dist/scoring.d.cts +2 -2
- package/dist/scoring.d.ts +2 -2
- package/dist/scoring.js +2 -2
- package/dist/{xapi-BX0hvRRB.cjs → xapi-Dyqcl3JK.cjs} +5 -8
- package/dist/{xapi-BX0hvRRB.cjs.map → xapi-Dyqcl3JK.cjs.map} +1 -1
- package/dist/{xapi-Cdk9fTvV.js → xapi-gCAo549Q.js} +5 -8
- package/dist/{xapi-Cdk9fTvV.js.map → xapi-gCAo549Q.js.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/variants.d.mts +20 -0
- package/vectors/variants.json +5753 -0
- package/vectors/variants.mjs +67 -0
- package/dist/ai-i3jwXnpX.d.cts.map +0 -1
- package/dist/ai-i3jwXnpX.d.ts.map +0 -1
- package/dist/ai-writing-C4YRVrFi.cjs.map +0 -1
- package/dist/ai-writing-OJ5m_1EB.js.map +0 -1
- package/dist/index--U7hHV0s.d.cts.map +0 -1
- package/dist/index-CFRgZLhH.d.cts.map +0 -1
- package/dist/index-DeD8EI1D.d.ts.map +0 -1
- package/dist/index-Dj92T76o.d.ts.map +0 -1
- package/dist/index-Uh1u57yq.d.cts.map +0 -1
- package/dist/index-WBHSrIUU.d.ts.map +0 -1
- package/dist/registry-B23o9UWh.js.map +0 -1
- package/dist/registry-Ih5n4PVZ.cjs.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { A as normaliseFinding, B as
|
|
2
|
-
import { F as isGradeInRange, _ as gradeFromRubric, c as validateSpeechAssessment, r as score, s as alignReadAloud } from "./scoring-
|
|
3
|
-
import { a as INTERACTIVE_VIDEO_ITEM_TYPES, i as validateItemGroup, t as ItemGroupSchema, u as isInteractiveVideoItemType } from "./item-group-
|
|
4
|
-
|
|
1
|
+
import { A as normaliseFinding, B as checkMedia, Ct as revealsAnswer, F as levelDistribution, G as isUnset, H as covers, I as levelKey, J as pathKey, K as isUnwritten, L as readRubricLevels, O as keyPositionFinding, P as LEVEL_PROBABILITY_TOLERANCE, Q as groupCaptionsField, R as countWords, St as placeholderOrder, Tt as PLACEHOLDER_RE, U as isMissingId, V as coveredPathsOf, W as isRecord$8, Y as refusesEmpty, Z as GROUP_CAPTIONS_REVEAL_DICTATION, a as readSchema, bt as numberedPassage, it as alignDictation, j as policyFor, k as finding, mt as withValidationScope, nt as UnknownActivityTypeError, q as issue, t as getActivityTypeDescriptor, vt as containsWords, xt as passageWithoutPlaceholders, yt as fold, z as DRAFT_ISSUE_SEVERITY } from "./registry-tkacvJUI.js";
|
|
2
|
+
import { F as isGradeInRange, S as canonicalJson, _ as gradeFromRubric, c as validateSpeechAssessment, h as seededShuffle, r as score, s as alignReadAloud } from "./scoring-DIBmo_cy.js";
|
|
3
|
+
import { a as INTERACTIVE_VIDEO_ITEM_TYPES, i as validateItemGroup, t as ItemGroupSchema, u as isInteractiveVideoItemType } from "./item-group-BceUoxoM.js";
|
|
4
|
+
// src/ai-material.ts
|
|
5
5
|
/**
|
|
6
6
|
* What a model is told about the group a question sits in: the material the
|
|
7
7
|
* learner reads and sees beside it.
|
|
@@ -28,7 +28,7 @@ const MEDIA_TYPES = {
|
|
|
28
28
|
video: true,
|
|
29
29
|
embed: true
|
|
30
30
|
};
|
|
31
|
-
const isRecord$
|
|
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 } : {}
|
|
@@ -89,8 +89,7 @@ function materialFacts(group) {
|
|
|
89
89
|
...locale !== void 0 ? { locale } : {}
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
//#region src/ai-text.ts
|
|
92
|
+
// src/ai-text.ts
|
|
94
93
|
/**
|
|
95
94
|
* How the SDK reads text a model wrote, before a learner does. Internal: shared
|
|
96
95
|
* by the AI checks, and exported by none of the package's entry points.
|
|
@@ -178,8 +177,7 @@ function folded(text) {
|
|
|
178
177
|
}
|
|
179
178
|
/** A quote as it is searched for: folded, and without the spaces around it. */
|
|
180
179
|
const needle = (quote) => folded(quote).chars.trim();
|
|
181
|
-
|
|
182
|
-
//#region src/ai.ts
|
|
180
|
+
// src/ai.ts
|
|
183
181
|
/**
|
|
184
182
|
* The activity types the SDK builds facts for, and which of them take hints.
|
|
185
183
|
* A dictation takes none: its answer is the whole sentence, and it has
|
|
@@ -256,7 +254,7 @@ function isAnswerOption(detail, chosen) {
|
|
|
256
254
|
}
|
|
257
255
|
}
|
|
258
256
|
const firstString = (value) => Array.isArray(value) ? value[0] : value;
|
|
259
|
-
const listOf$
|
|
257
|
+
const listOf$4 = (value) => value === void 0 ? null : Array.isArray(value) ? [...value] : [value];
|
|
260
258
|
/** The response a learner has before touching anything. */
|
|
261
259
|
function emptyResponse(type) {
|
|
262
260
|
switch (type) {
|
|
@@ -284,6 +282,11 @@ const base = (data) => ({
|
|
|
284
282
|
title: data.title,
|
|
285
283
|
...typeof data.locale === "string" ? { locale: data.locale } : {}
|
|
286
284
|
});
|
|
285
|
+
/** The item's instructions, trimmed; nothing for none, or for blank ones the learner never sees. */
|
|
286
|
+
function instructionsOf$1(data) {
|
|
287
|
+
const text = data.instructions;
|
|
288
|
+
return typeof text === "string" && text.trim() !== "" ? { instructions: text.trim() } : {};
|
|
289
|
+
}
|
|
287
290
|
/**
|
|
288
291
|
* The facts a model is given about one item and one answer. `details` are the
|
|
289
292
|
* SDK's per-part verdicts — from its own scorer, or from a server's outcome in
|
|
@@ -303,6 +306,7 @@ function buildAiFacts(data, response, details) {
|
|
|
303
306
|
return {
|
|
304
307
|
...base(data),
|
|
305
308
|
activityType: "multiple-choice",
|
|
309
|
+
...instructionsOf$1(data),
|
|
306
310
|
question: item.question,
|
|
307
311
|
mode: item.mode,
|
|
308
312
|
options: item.options.map((option) => {
|
|
@@ -328,11 +332,12 @@ function buildAiFacts(data, response, details) {
|
|
|
328
332
|
return {
|
|
329
333
|
...base(data),
|
|
330
334
|
activityType: "fill-in-the-blanks",
|
|
335
|
+
...instructionsOf$1(data),
|
|
331
336
|
passage: numberedPassage(item.passage),
|
|
332
337
|
blanks: placeholderOrder(item.passage).map((id, index) => {
|
|
333
338
|
const blank = blanks.get(id);
|
|
334
339
|
const detail = byId.get(id);
|
|
335
|
-
const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf$
|
|
340
|
+
const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf$4(detail?.correctResponse);
|
|
336
341
|
return {
|
|
337
342
|
id,
|
|
338
343
|
position: index + 1,
|
|
@@ -353,6 +358,7 @@ function buildAiFacts(data, response, details) {
|
|
|
353
358
|
return {
|
|
354
359
|
...base(data),
|
|
355
360
|
activityType: "gap-select",
|
|
361
|
+
...instructionsOf$1(data),
|
|
356
362
|
passage: numberedPassage(item.passage),
|
|
357
363
|
gaps: placeholderOrder(item.passage).map((id, index) => {
|
|
358
364
|
const gap = gaps.get(id);
|
|
@@ -596,8 +602,7 @@ function hintRevealsAnswer(facts, hint) {
|
|
|
596
602
|
default: return true;
|
|
597
603
|
}
|
|
598
604
|
}
|
|
599
|
-
|
|
600
|
-
//#region src/ai-coaching.ts
|
|
605
|
+
// src/ai-coaching.ts
|
|
601
606
|
/** The most words one coaching may work on. More is refused, not cut. */
|
|
602
607
|
const AI_COACHING_MAX_WORDS = 20;
|
|
603
608
|
/** The longest tip for one word, in code points. */
|
|
@@ -742,7 +747,7 @@ function aiCoachingRequest(input) {
|
|
|
742
747
|
...learnerLocale !== void 0 ? { learnerLocale } : {}
|
|
743
748
|
};
|
|
744
749
|
}
|
|
745
|
-
const isRecord$
|
|
750
|
+
const isRecord$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
746
751
|
/** A symbol as it is compared: composed, and without the spaces around it. */
|
|
747
752
|
const symbolKey = (symbol) => symbol.normalize("NFC").trim();
|
|
748
753
|
/**
|
|
@@ -791,7 +796,7 @@ function checkAiCoaching(raw, request) {
|
|
|
791
796
|
});
|
|
792
797
|
const seen = /* @__PURE__ */ new Set();
|
|
793
798
|
for (const entry of entries) {
|
|
794
|
-
if (!isRecord$
|
|
799
|
+
if (!isRecord$6(entry) || typeof entry.itemId !== "string" || typeof entry.tip !== "string") return {
|
|
795
800
|
ok: false,
|
|
796
801
|
refusal: "malformed"
|
|
797
802
|
};
|
|
@@ -839,7 +844,7 @@ function checkAiCoaching(raw, request) {
|
|
|
839
844
|
/** A sound a model names, checked against the sounds the engine reported for the word. */
|
|
840
845
|
function checkSound(raw, fact) {
|
|
841
846
|
if (raw === void 0 || raw === null) return;
|
|
842
|
-
if (!isRecord$
|
|
847
|
+
if (!isRecord$6(raw) || typeof raw.expected !== "string" || raw.heard !== void 0 && raw.heard !== null && typeof raw.heard !== "string") return {
|
|
843
848
|
ok: false,
|
|
844
849
|
refusal: "malformed"
|
|
845
850
|
};
|
|
@@ -865,21 +870,20 @@ function checkSound(raw, fact) {
|
|
|
865
870
|
heard: instead.symbol
|
|
866
871
|
};
|
|
867
872
|
}
|
|
868
|
-
|
|
869
|
-
//#region src/media-refs.ts
|
|
873
|
+
// src/media-refs.ts
|
|
870
874
|
const MAIN_KINDS = [
|
|
871
875
|
"audio",
|
|
872
876
|
"video",
|
|
873
877
|
"image",
|
|
874
878
|
"embed"
|
|
875
879
|
];
|
|
876
|
-
function isRecord$
|
|
880
|
+
function isRecord$5(value) {
|
|
877
881
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
878
882
|
}
|
|
879
883
|
function valueAt(root, path) {
|
|
880
884
|
let at = root;
|
|
881
885
|
for (const key of path) {
|
|
882
|
-
if (typeof key === "number" ? !Array.isArray(at) : !isRecord$
|
|
886
|
+
if (typeof key === "number" ? !Array.isArray(at) : !isRecord$5(at)) return;
|
|
883
887
|
at = at[key];
|
|
884
888
|
}
|
|
885
889
|
return at;
|
|
@@ -892,7 +896,7 @@ function slotsOf(item) {
|
|
|
892
896
|
return Array.isArray(slots) ? slots : [];
|
|
893
897
|
}
|
|
894
898
|
function holderRefs(holder, path, kindOfSlot, owner) {
|
|
895
|
-
if (!isRecord$
|
|
899
|
+
if (!isRecord$5(holder)) return [];
|
|
896
900
|
const main = typeof holder.type === "string" && MAIN_KINDS.includes(holder.type) ? holder.type : kindOfSlot;
|
|
897
901
|
const refs = [];
|
|
898
902
|
const add = (field, kind, url) => {
|
|
@@ -907,7 +911,7 @@ function holderRefs(holder, path, kindOfSlot, owner) {
|
|
|
907
911
|
if (main !== void 0) add(["url"], main, typeof holder.url === "string" ? holder.url : null);
|
|
908
912
|
if (typeof holder.captionsUrl === "string") add(["captionsUrl"], "captions", holder.captionsUrl);
|
|
909
913
|
if (Array.isArray(holder.tracks)) holder.tracks.forEach((track, index) => {
|
|
910
|
-
if (isRecord$
|
|
914
|
+
if (isRecord$5(track) && typeof track.src === "string") refs.push({
|
|
911
915
|
path: [
|
|
912
916
|
...path,
|
|
913
917
|
"tracks",
|
|
@@ -925,7 +929,7 @@ function holderRefs(holder, path, kindOfSlot, owner) {
|
|
|
925
929
|
return refs;
|
|
926
930
|
}
|
|
927
931
|
function activityRefs(item, base) {
|
|
928
|
-
if (!isRecord$
|
|
932
|
+
if (!isRecord$5(item)) return [];
|
|
929
933
|
const owner = typeof item.id === "string" ? { activityId: item.id } : {};
|
|
930
934
|
return slotsOf(item).flatMap((slot) => holderRefs(valueAt(item, slot.path), [...base, ...slot.path], slot.kind, owner));
|
|
931
935
|
}
|
|
@@ -950,11 +954,11 @@ function activityRefs(item, base) {
|
|
|
950
954
|
* ```
|
|
951
955
|
*/
|
|
952
956
|
function listMediaRefs(entry) {
|
|
953
|
-
if (!isRecord$
|
|
957
|
+
if (!isRecord$5(entry)) return [];
|
|
954
958
|
if (entry.type !== "item-group") return activityRefs(entry, []);
|
|
955
959
|
const refs = [];
|
|
956
960
|
const stimulus = entry.stimulus;
|
|
957
|
-
if (isRecord$
|
|
961
|
+
if (isRecord$5(stimulus)) {
|
|
958
962
|
const owner = typeof stimulus.id === "string" ? { stimulusId: stimulus.id } : {};
|
|
959
963
|
refs.push(...holderRefs(stimulus.media, ["stimulus", "media"], void 0, owner));
|
|
960
964
|
}
|
|
@@ -1014,7 +1018,7 @@ function mapMediaRefs(entry, fn) {
|
|
|
1014
1018
|
/** A holder's own text: its transcript, else its script as "Speaker: text" lines. */
|
|
1015
1019
|
function ownText(holder) {
|
|
1016
1020
|
if (typeof holder.transcript === "string" && holder.transcript.trim() !== "") return holder.transcript;
|
|
1017
|
-
const turns = Array.isArray(holder.script) ? holder.script.flatMap((turn) => isRecord$
|
|
1021
|
+
const turns = Array.isArray(holder.script) ? holder.script.flatMap((turn) => isRecord$5(turn) && typeof turn.speaker === "string" && typeof turn.text === "string" && turn.text.trim() !== "" ? [`${turn.speaker.trim()}: ${turn.text.trim()}`] : []) : [];
|
|
1018
1022
|
return turns.length > 0 ? turns.join("\n") : null;
|
|
1019
1023
|
}
|
|
1020
1024
|
/**
|
|
@@ -1032,15 +1036,14 @@ function ownText(holder) {
|
|
|
1032
1036
|
* ```
|
|
1033
1037
|
*/
|
|
1034
1038
|
function transcriptOf(holder) {
|
|
1035
|
-
if (!isRecord$
|
|
1036
|
-
return ownText(holder) ?? (isRecord$
|
|
1039
|
+
if (!isRecord$5(holder)) return null;
|
|
1040
|
+
return ownText(holder) ?? (isRecord$5(holder.media) ? ownText(holder.media) : null);
|
|
1037
1041
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
const written$1 = (value) => typeof value === "string" && value.trim() !== "";
|
|
1042
|
+
// src/authoring/recordings.ts
|
|
1043
|
+
const written$2 = (value) => typeof value === "string" && value.trim() !== "";
|
|
1041
1044
|
/** The script's turns that have a speaker and words, or none. */
|
|
1042
|
-
function turnsOf(script) {
|
|
1043
|
-
return Array.isArray(script) ? script.flatMap((turn) => 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) ? [{
|
|
1044
1047
|
speaker: turn.speaker,
|
|
1045
1048
|
text: turn.text
|
|
1046
1049
|
}] : []) : [];
|
|
@@ -1048,24 +1051,24 @@ function turnsOf(script) {
|
|
|
1048
1051
|
const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1049
1052
|
/** A transcript's words, with each `Speaker:` label of the script's speakers taken out. */
|
|
1050
1053
|
function transcriptWords(transcript, speakers) {
|
|
1051
|
-
const labels = [...new Set(speakers.map((speaker) => speaker.trim()).filter(written$
|
|
1054
|
+
const labels = [...new Set(speakers.map((speaker) => speaker.trim()).filter(written$2))];
|
|
1052
1055
|
const unlabelled = labels.length === 0 ? transcript : transcript.replace(new RegExp(`(^|\\s)(?:${labels.map(escaped).join("|")})\\s*:`, "giu"), "$1");
|
|
1053
1056
|
return fold(unlabelled);
|
|
1054
1057
|
}
|
|
1055
1058
|
/** What a recording says, as words: its transcript, or else its script's turns. */
|
|
1056
1059
|
function spokenWords(holder) {
|
|
1057
|
-
const turns = turnsOf(holder.script);
|
|
1058
|
-
if (written$
|
|
1060
|
+
const turns = turnsOf$1(holder.script);
|
|
1061
|
+
if (written$2(holder.transcript)) return transcriptWords(holder.transcript, turns.map((turn) => turn.speaker));
|
|
1059
1062
|
return turns.length > 0 ? fold(turns.map((turn) => turn.text).join(" ")) : null;
|
|
1060
1063
|
}
|
|
1061
1064
|
const sameWords = (a, b) => a.length === b.length && a.every((word, index) => word === b[index]);
|
|
1062
1065
|
/** One object's own rules: text on media with nothing to say, and a transcript its script contradicts. */
|
|
1063
1066
|
function holderFindings(holder, path) {
|
|
1064
|
-
if (!isRecord$
|
|
1067
|
+
if (!isRecord$8(holder)) return [];
|
|
1065
1068
|
const findings = [];
|
|
1066
|
-
const turns = turnsOf(holder.script);
|
|
1067
|
-
if ((written$
|
|
1068
|
-
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) {
|
|
1069
1072
|
const fromTranscript = transcriptWords(holder.transcript, turns.map((turn) => turn.speaker));
|
|
1070
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."));
|
|
1071
1074
|
}
|
|
@@ -1085,22 +1088,778 @@ function recordingFindings(item) {
|
|
|
1085
1088
|
const findings = listMediaRefs(item).filter((ref) => ref.path[ref.path.length - 1] === "url").flatMap((ref) => holderFindings(ref.holder, ref.path.slice(0, -1)));
|
|
1086
1089
|
const field = typeof item.type === "string" ? SPOKEN_FIELD[item.type] : void 0;
|
|
1087
1090
|
const media = item.media;
|
|
1088
|
-
if (field === void 0 || !isRecord$
|
|
1091
|
+
if (field === void 0 || !isRecord$8(media)) return findings;
|
|
1089
1092
|
const said = spokenWords(media);
|
|
1090
|
-
const accepted = [item[field], ...item.type === "dictation" && Array.isArray(item.acceptedTranscripts) ? item.acceptedTranscripts : []].filter(written$
|
|
1091
|
-
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.`));
|
|
1092
1095
|
return findings;
|
|
1093
1096
|
}
|
|
1094
1097
|
/** A group's material: its own transcript against its script, and its media's rules. */
|
|
1095
1098
|
function stimulusFindings(stimulus) {
|
|
1096
|
-
if (!isRecord$
|
|
1099
|
+
if (!isRecord$8(stimulus)) return [];
|
|
1097
1100
|
return [...holderFindings({
|
|
1098
1101
|
transcript: stimulus.transcript,
|
|
1099
1102
|
script: stimulus.script
|
|
1100
1103
|
}, ["stimulus"]), ...holderFindings(stimulus.media, ["stimulus", "media"])];
|
|
1101
1104
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
+
}
|
|
1862
|
+
// src/authoring/item-group.ts
|
|
1104
1863
|
/**
|
|
1105
1864
|
* A new, empty reading or listening testlet for an editor to start from.
|
|
1106
1865
|
*
|
|
@@ -1195,7 +1954,7 @@ function createInteractiveVideoDraft(context) {
|
|
|
1195
1954
|
* {@link validateItemGroupDraft} already reports it.
|
|
1196
1955
|
*/
|
|
1197
1956
|
function critiqueItemGroupDraft(draft) {
|
|
1198
|
-
if (!isRecord$
|
|
1957
|
+
if (!isRecord$8(draft)) return [];
|
|
1199
1958
|
const items = Array.isArray(draft.items) ? draft.items : [];
|
|
1200
1959
|
return withValidationScope(() => [
|
|
1201
1960
|
...stimulusFindings(draft.stimulus),
|
|
@@ -1211,11 +1970,15 @@ function critiqueItemGroupDraft(draft) {
|
|
|
1211
1970
|
* skipped.
|
|
1212
1971
|
*/
|
|
1213
1972
|
function critiqueDrafts(entries) {
|
|
1214
|
-
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
|
+
]);
|
|
1215
1978
|
}
|
|
1216
1979
|
/** One item's findings, or none for an item the critic cannot place. */
|
|
1217
1980
|
function critiqueItem(item) {
|
|
1218
|
-
if (!isRecord$
|
|
1981
|
+
if (!isRecord$8(item) || typeof item.type !== "string") return [];
|
|
1219
1982
|
if (getActivityTypeDescriptor(item.type) === void 0) return [];
|
|
1220
1983
|
return critiqueDraft(item.type, item);
|
|
1221
1984
|
}
|
|
@@ -1228,7 +1991,7 @@ function validateItemGroupDraft(draft) {
|
|
|
1228
1991
|
}
|
|
1229
1992
|
function validateItemGroupDraftInScope(draft) {
|
|
1230
1993
|
const issues = [];
|
|
1231
|
-
if (!isRecord$
|
|
1994
|
+
if (!isRecord$8(draft)) {
|
|
1232
1995
|
issues.push(issue("ig_not_an_object", [], "A group must be an object with a stimulus and its items."));
|
|
1233
1996
|
return {
|
|
1234
1997
|
status: "invalid",
|
|
@@ -1236,7 +1999,7 @@ function validateItemGroupDraftInScope(draft) {
|
|
|
1236
1999
|
};
|
|
1237
2000
|
}
|
|
1238
2001
|
issues.push(...checkGroupIdentity(draft));
|
|
1239
|
-
if (isRecord$
|
|
2002
|
+
if (isRecord$8(draft.stimulus)) issues.push(...checkStimulus(draft.stimulus));
|
|
1240
2003
|
else if (isUnset(draft.stimulus)) issues.push(issue("ig_stimulus_required", ["stimulus"], "Add the passage, recording or image the questions are about."));
|
|
1241
2004
|
issues.push(...checkItems(draft));
|
|
1242
2005
|
if (draft.timeline !== void 0 && draft.timeline !== null) issues.push(...checkTimeline(draft));
|
|
@@ -1286,10 +2049,10 @@ function validateItemGroupDraftInScope(draft) {
|
|
|
1286
2049
|
function checkTimeline(draft) {
|
|
1287
2050
|
const issues = [];
|
|
1288
2051
|
const timeline = draft.timeline;
|
|
1289
|
-
if (!isRecord$
|
|
1290
|
-
const stimulus = isRecord$
|
|
2052
|
+
if (!isRecord$8(timeline)) return issues;
|
|
2053
|
+
const stimulus = isRecord$8(draft.stimulus) ? draft.stimulus : void 0;
|
|
1291
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."));
|
|
1292
|
-
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", [
|
|
1293
2056
|
"stimulus",
|
|
1294
2057
|
"media",
|
|
1295
2058
|
"type"
|
|
@@ -1298,7 +2061,7 @@ function checkTimeline(draft) {
|
|
|
1298
2061
|
const items = Array.isArray(draft.items) ? draft.items : [];
|
|
1299
2062
|
const itemIndexById = /* @__PURE__ */ new Map();
|
|
1300
2063
|
items.forEach((item, index) => {
|
|
1301
|
-
if (!isRecord$
|
|
2064
|
+
if (!isRecord$8(item)) return;
|
|
1302
2065
|
if (typeof item.id === "string" && item.id !== "") itemIndexById.set(item.id, index);
|
|
1303
2066
|
const type = item.type;
|
|
1304
2067
|
if (typeof type === "string" && type !== "" && type !== "item-group" && !isInteractiveVideoItemType(type) && getActivityTypeDescriptor(type) !== void 0) issues.push(issue("ig_timeline_item_type", [
|
|
@@ -1316,7 +2079,7 @@ function checkTimeline(draft) {
|
|
|
1316
2079
|
const quizIds = /* @__PURE__ */ new Set();
|
|
1317
2080
|
const placed = /* @__PURE__ */ new Set();
|
|
1318
2081
|
cues.forEach((cue, cueIndex) => {
|
|
1319
|
-
if (!isRecord$
|
|
2082
|
+
if (!isRecord$8(cue)) return;
|
|
1320
2083
|
const at = (field, ...rest) => [
|
|
1321
2084
|
"timeline",
|
|
1322
2085
|
"cues",
|
|
@@ -1347,7 +2110,7 @@ function checkTimeline(draft) {
|
|
|
1347
2110
|
const chapters = Array.isArray(timeline.chapters) ? timeline.chapters : [];
|
|
1348
2111
|
let previousAt;
|
|
1349
2112
|
chapters.forEach((chapter, index) => {
|
|
1350
|
-
if (!isRecord$
|
|
2113
|
+
if (!isRecord$8(chapter)) return;
|
|
1351
2114
|
if (isUnwritten(chapter.title)) issues.push(issue("ig_timeline_chapter_title", [
|
|
1352
2115
|
"timeline",
|
|
1353
2116
|
"chapters",
|
|
@@ -1408,14 +2171,14 @@ function checkStimulus(stimulus) {
|
|
|
1408
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."));
|
|
1409
2172
|
else if (typeof kind !== "string" || !KINDS$1.has(kind)) {
|
|
1410
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.`));
|
|
1411
|
-
if (isRecord$
|
|
2174
|
+
if (isRecord$8(stimulus.media)) issues.push(...checkMedia(stimulus.media, at("media")));
|
|
1412
2175
|
return issues;
|
|
1413
2176
|
}
|
|
1414
2177
|
const hasBody = typeof stimulus.body === "string" && stimulus.body.trim() !== "";
|
|
1415
2178
|
const htmlPresent = !isUnset(stimulus.bodyHtml);
|
|
1416
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."));
|
|
1417
2180
|
if (kind !== "text" && isUnset(stimulus.media)) issues.push(issue("ig_stimulus_media_required", at("media"), "Add the recording, video or image."));
|
|
1418
|
-
else if (isRecord$
|
|
2181
|
+
else if (isRecord$8(stimulus.media)) {
|
|
1419
2182
|
const allowed = MEDIA_FOR_KIND[kind];
|
|
1420
2183
|
const mediaType = stimulus.media.type;
|
|
1421
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 ")}.`));
|
|
@@ -1442,7 +2205,7 @@ function checkItems(draft) {
|
|
|
1442
2205
|
const seen = /* @__PURE__ */ new Set();
|
|
1443
2206
|
const repeated = /* @__PURE__ */ new Set();
|
|
1444
2207
|
for (const [index, item] of items.entries()) {
|
|
1445
|
-
if (!isRecord$
|
|
2208
|
+
if (!isRecord$8(item)) continue;
|
|
1446
2209
|
const ordinal = index + 1;
|
|
1447
2210
|
if (isMissingId(item.id)) issues.push(issue("ig_item_id_required", [
|
|
1448
2211
|
"items",
|
|
@@ -1494,8 +2257,7 @@ function checkItems(draft) {
|
|
|
1494
2257
|
for (const id of repeated) issues.push(issue("ig_item_id_duplicate", ["items"], `More than one question has the id "${id}".`));
|
|
1495
2258
|
return issues;
|
|
1496
2259
|
}
|
|
1497
|
-
|
|
1498
|
-
//#region src/authoring/index.ts
|
|
2260
|
+
// src/authoring/index.ts
|
|
1499
2261
|
/**
|
|
1500
2262
|
* Tells a draft that is not finished from a draft that is wrong.
|
|
1501
2263
|
*
|
|
@@ -1539,7 +2301,7 @@ function validateDraft(type, draft) {
|
|
|
1539
2301
|
function validateDraftInScope(type, draft) {
|
|
1540
2302
|
const descriptor = getActivityTypeDescriptor(type);
|
|
1541
2303
|
if (descriptor === void 0) throw new UnknownActivityTypeError(String(type));
|
|
1542
|
-
const reported = isRecord$
|
|
2304
|
+
const reported = isRecord$8(draft) && descriptor.authoring?.checkDraft !== void 0 ? descriptor.authoring.checkDraft(draft).map(normalise) : [];
|
|
1543
2305
|
const issues = [...reported];
|
|
1544
2306
|
const parsed = readSchema(descriptor.schema, draft, `Activity type "${descriptor.type}"`, { reportInput: true });
|
|
1545
2307
|
if (!parsed.success) {
|
|
@@ -1648,12 +2410,11 @@ function normalise(found) {
|
|
|
1648
2410
|
function critiqueDraft(type, draft) {
|
|
1649
2411
|
const descriptor = getActivityTypeDescriptor(type);
|
|
1650
2412
|
if (descriptor === void 0) throw new UnknownActivityTypeError(String(type));
|
|
1651
|
-
if (!isRecord$
|
|
2413
|
+
if (!isRecord$8(draft)) return [];
|
|
1652
2414
|
const critique = descriptor.authoring?.critique;
|
|
1653
2415
|
return withValidationScope(() => [...critique === void 0 ? [] : critique(draft), ...recordingFindings(draft)]).map(normaliseFinding);
|
|
1654
2416
|
}
|
|
1655
|
-
|
|
1656
|
-
//#region src/ai-critique.ts
|
|
2417
|
+
// src/ai-critique.ts
|
|
1657
2418
|
/** The most findings one critique may carry. More is refused, not cut. */
|
|
1658
2419
|
const AI_CRITIQUE_MAX_FINDINGS = 20;
|
|
1659
2420
|
/** The longest message or quote in a finding, in code points. */
|
|
@@ -1701,10 +2462,10 @@ const NOT_WORDS = /* @__PURE__ */ new Set([
|
|
|
1701
2462
|
"timeline"
|
|
1702
2463
|
]);
|
|
1703
2464
|
/**
|
|
1704
|
-
* Every string an author wrote into an item, with its path: the
|
|
1705
|
-
* options, the passage, the answers, the hints, the
|
|
1706
|
-
* `alt`. Settings, ids and addresses are left out, and
|
|
1707
|
-
* sidecar: the plain field beside it holds the same words.
|
|
2465
|
+
* Every string an author wrote into an item, with its path: the instructions,
|
|
2466
|
+
* the question, the options, the passage, the answers, the hints, the
|
|
2467
|
+
* feedback, a picture's `alt`. Settings, ids and addresses are left out, and
|
|
2468
|
+
* so is any `…Html` sidecar: the plain field beside it holds the same words.
|
|
1708
2469
|
*/
|
|
1709
2470
|
function fieldsOf(value, path = []) {
|
|
1710
2471
|
if (typeof value === "string") return value.trim() === "" ? [] : [{
|
|
@@ -1752,7 +2513,7 @@ function aiCritiqueRequest(input) {
|
|
|
1752
2513
|
...authorLocale !== void 0 ? { authorLocale } : {}
|
|
1753
2514
|
};
|
|
1754
2515
|
}
|
|
1755
|
-
const isRecord$
|
|
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
|
};
|
|
@@ -1825,8 +2586,7 @@ function checkAiCritique(raw, request) {
|
|
|
1825
2586
|
}
|
|
1826
2587
|
};
|
|
1827
2588
|
}
|
|
1828
|
-
|
|
1829
|
-
//#region src/ai-drafts.ts
|
|
2589
|
+
// src/ai-drafts.ts
|
|
1830
2590
|
/** The most drafts one request may ask for, and the most a reply may hold when it names no count. */
|
|
1831
2591
|
const AI_DRAFTS_MAX_COUNT = 50;
|
|
1832
2592
|
/** The most repair passes `generateDrafts` makes. */
|
|
@@ -1839,8 +2599,8 @@ const DRAFT_TYPES = [
|
|
|
1839
2599
|
"read-aloud",
|
|
1840
2600
|
"written-response"
|
|
1841
2601
|
];
|
|
1842
|
-
const isRecord$
|
|
1843
|
-
const text = (description) => ({
|
|
2602
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2603
|
+
const text$1 = (description) => ({
|
|
1844
2604
|
type: "string",
|
|
1845
2605
|
description
|
|
1846
2606
|
});
|
|
@@ -1848,14 +2608,14 @@ const FEEDBACK = {
|
|
|
1848
2608
|
type: "object",
|
|
1849
2609
|
description: "Optional overall feedback, shown after answering.",
|
|
1850
2610
|
properties: {
|
|
1851
|
-
correct: text("Shown when the learner passes."),
|
|
1852
|
-
incorrect: text("Shown when the learner does not.")
|
|
2611
|
+
correct: text$1("Shown when the learner passes."),
|
|
2612
|
+
incorrect: text$1("Shown when the learner does not.")
|
|
1853
2613
|
}
|
|
1854
2614
|
};
|
|
1855
2615
|
/** Every field a model writes, and the shape of each. A name means one thing in every type that uses it. */
|
|
1856
2616
|
const FIELDS = {
|
|
1857
|
-
title: text("A short name for the item, shown to the learner above it. Never the answer, or any part of it."),
|
|
1858
|
-
question: text("The question, as plain text."),
|
|
2617
|
+
title: text$1("A short name for the item, shown to the learner above it. Never the answer, or any part of it."),
|
|
2618
|
+
question: text$1("The question, as plain text."),
|
|
1859
2619
|
mode: {
|
|
1860
2620
|
type: "string",
|
|
1861
2621
|
enum: ["single", "multi"],
|
|
@@ -1869,13 +2629,13 @@ const FIELDS = {
|
|
|
1869
2629
|
type: "object",
|
|
1870
2630
|
required: ["text", "isCorrect"],
|
|
1871
2631
|
properties: {
|
|
1872
|
-
text: text("The option as the learner reads it."),
|
|
2632
|
+
text: text$1("The option as the learner reads it."),
|
|
1873
2633
|
isCorrect: { type: "boolean" },
|
|
1874
|
-
feedback: text("Why this option is right or wrong, shown after answering.")
|
|
2634
|
+
feedback: text$1("Why this option is right or wrong, shown after answering.")
|
|
1875
2635
|
}
|
|
1876
2636
|
}
|
|
1877
2637
|
},
|
|
1878
|
-
passage: text("The passage as plain text, with {{1}}, {{2}}, … where each gap goes, numbered in the order they appear."),
|
|
2638
|
+
passage: text$1("The passage as plain text, with {{1}}, {{2}}, … where each gap goes, numbered in the order they appear."),
|
|
1879
2639
|
blanks: {
|
|
1880
2640
|
type: "array",
|
|
1881
2641
|
minItems: 1,
|
|
@@ -1890,7 +2650,7 @@ const FIELDS = {
|
|
|
1890
2650
|
items: { type: "string" },
|
|
1891
2651
|
description: "Every answer that is right, spelled as a learner would type it."
|
|
1892
2652
|
},
|
|
1893
|
-
hint: text("Optional. Helps without giving the answer or any of its words.")
|
|
2653
|
+
hint: text$1("Optional. Helps without giving the answer or any of its words.")
|
|
1894
2654
|
}
|
|
1895
2655
|
}
|
|
1896
2656
|
},
|
|
@@ -1909,17 +2669,17 @@ const FIELDS = {
|
|
|
1909
2669
|
type: "object",
|
|
1910
2670
|
required: ["text", "isCorrect"],
|
|
1911
2671
|
properties: {
|
|
1912
|
-
text: text("A choice."),
|
|
2672
|
+
text: text$1("A choice."),
|
|
1913
2673
|
isCorrect: { type: "boolean" }
|
|
1914
2674
|
}
|
|
1915
2675
|
}
|
|
1916
2676
|
} }
|
|
1917
2677
|
}
|
|
1918
2678
|
},
|
|
1919
|
-
transcript: text("What the learner hears and types: words from the source, as they are said there."),
|
|
1920
|
-
referenceText: text("What the learner reads aloud: a sentence or two from the source."),
|
|
1921
|
-
instructions: text("Optional.
|
|
1922
|
-
prompt: text("The writing task, as plain text."),
|
|
2679
|
+
transcript: text$1("What the learner hears and types: words from the source, as they are said there."),
|
|
2680
|
+
referenceText: text$1("What the learner reads aloud: a sentence or two from the source."),
|
|
2681
|
+
instructions: text$1("Optional. The task, shown to the learner before the item: what to do (\"Complete the sentence. Use ONE word.\"), or how to read it aloud. Never part of the text the learner answers."),
|
|
2682
|
+
prompt: text$1("The writing task, as plain text."),
|
|
1923
2683
|
minWords: {
|
|
1924
2684
|
type: "integer",
|
|
1925
2685
|
minimum: 0
|
|
@@ -1938,8 +2698,8 @@ const FIELDS = {
|
|
|
1938
2698
|
type: "object",
|
|
1939
2699
|
required: ["name"],
|
|
1940
2700
|
properties: {
|
|
1941
|
-
name: text("A short name."),
|
|
1942
|
-
description: text("What it looks for.")
|
|
2701
|
+
name: text$1("A short name."),
|
|
2702
|
+
description: text$1("What it looks for.")
|
|
1943
2703
|
}
|
|
1944
2704
|
}
|
|
1945
2705
|
} }
|
|
@@ -1947,9 +2707,10 @@ const FIELDS = {
|
|
|
1947
2707
|
feedback: FEEDBACK
|
|
1948
2708
|
};
|
|
1949
2709
|
/** The fields a model writes for each type: a draft's own names, never an id or a setting. */
|
|
1950
|
-
const WRITES = {
|
|
2710
|
+
const WRITES$1 = {
|
|
1951
2711
|
"multiple-choice": [
|
|
1952
2712
|
"title",
|
|
2713
|
+
"instructions",
|
|
1953
2714
|
"question",
|
|
1954
2715
|
"mode",
|
|
1955
2716
|
"options",
|
|
@@ -1957,12 +2718,14 @@ const WRITES = {
|
|
|
1957
2718
|
],
|
|
1958
2719
|
"fill-in-the-blanks": [
|
|
1959
2720
|
"title",
|
|
2721
|
+
"instructions",
|
|
1960
2722
|
"passage",
|
|
1961
2723
|
"blanks",
|
|
1962
2724
|
"feedback"
|
|
1963
2725
|
],
|
|
1964
2726
|
"gap-select": [
|
|
1965
2727
|
"title",
|
|
2728
|
+
"instructions",
|
|
1966
2729
|
"passage",
|
|
1967
2730
|
"gaps",
|
|
1968
2731
|
"feedback"
|
|
@@ -1995,9 +2758,9 @@ function itemShape(types, captions) {
|
|
|
1995
2758
|
const properties = { type: {
|
|
1996
2759
|
type: "string",
|
|
1997
2760
|
enum: [...types],
|
|
1998
|
-
description: `The item's type, and the fields it fills. ${types.map((type) => `${type}: ${WRITES[type].filter((name) => name !== "title").join(", ")}`).join(". ")}.`
|
|
2761
|
+
description: `The item's type, and the fields it fills. ${types.map((type) => `${type}: ${WRITES$1[type].filter((name) => name !== "title").join(", ")}`).join(". ")}.`
|
|
1999
2762
|
} };
|
|
2000
|
-
for (const type of types) for (const name of WRITES[type]) properties[name] = FIELDS[name];
|
|
2763
|
+
for (const type of types) for (const name of WRITES$1[type]) properties[name] = FIELDS[name];
|
|
2001
2764
|
if (captions) properties.caption = {
|
|
2002
2765
|
type: "integer",
|
|
2003
2766
|
minimum: 0,
|
|
@@ -2014,7 +2777,7 @@ function itemShape(types, captions) {
|
|
|
2014
2777
|
};
|
|
2015
2778
|
}
|
|
2016
2779
|
/** A reply of up to `max` drafts — exactly `exactly` on a repair. */
|
|
2017
|
-
function replyShape(types, captions, max, exactly) {
|
|
2780
|
+
function replyShape$1(types, captions, max, exactly) {
|
|
2018
2781
|
return {
|
|
2019
2782
|
type: "object",
|
|
2020
2783
|
required: ["drafts"],
|
|
@@ -2056,7 +2819,7 @@ function aiDraftsRequest(input) {
|
|
|
2056
2819
|
if (count !== void 0 && (!Number.isInteger(count) || count < 1 || count > 50)) throw new RangeError(`aiDraftsRequest: count must be a whole number from 1 to 50, or left out, not ${String(count)}.`);
|
|
2057
2820
|
let facts;
|
|
2058
2821
|
if (source.kind === "captions") {
|
|
2059
|
-
const captions = (Array.isArray(source.cues) ? source.cues : []).flatMap((cue, index) => isRecord$
|
|
2822
|
+
const captions = (Array.isArray(source.cues) ? source.cues : []).flatMap((cue, index) => isRecord$2(cue) && typeof cue.text === "string" && cue.text.trim() !== "" && Number.isFinite(cue.start) && Number.isFinite(cue.end) ? [{
|
|
2060
2823
|
index,
|
|
2061
2824
|
start: cue.start,
|
|
2062
2825
|
end: cue.end,
|
|
@@ -2084,34 +2847,40 @@ function aiDraftsRequest(input) {
|
|
|
2084
2847
|
...level !== void 0 ? { level } : {},
|
|
2085
2848
|
...instructions !== void 0 ? { instructions } : {}
|
|
2086
2849
|
},
|
|
2087
|
-
shape: replyShape(types, facts.kind === "captions", count ?? 50),
|
|
2850
|
+
shape: replyShape$1(types, facts.kind === "captions", count ?? 50),
|
|
2088
2851
|
...settings !== void 0 ? { settings } : {}
|
|
2089
2852
|
};
|
|
2090
2853
|
}
|
|
2091
|
-
const stringOf = (value) => typeof value === "string" ? value : "";
|
|
2092
|
-
const listOf = (value) => Array.isArray(value) ? value : [];
|
|
2093
|
-
const written = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
2854
|
+
const stringOf$1 = (value) => typeof value === "string" ? value : "";
|
|
2855
|
+
const listOf$1 = (value) => Array.isArray(value) ? value : [];
|
|
2856
|
+
const written$1 = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
2094
2857
|
function feedbackOf(entry) {
|
|
2095
|
-
if (!isRecord$
|
|
2096
|
-
const correct = written(entry.feedback.correct);
|
|
2097
|
-
const incorrect = written(entry.feedback.incorrect);
|
|
2858
|
+
if (!isRecord$2(entry.feedback)) return {};
|
|
2859
|
+
const correct = written$1(entry.feedback.correct);
|
|
2860
|
+
const incorrect = written$1(entry.feedback.incorrect);
|
|
2098
2861
|
return correct === void 0 && incorrect === void 0 ? {} : { feedback: {
|
|
2099
2862
|
...correct !== void 0 ? { correct } : {},
|
|
2100
2863
|
...incorrect !== void 0 ? { incorrect } : {}
|
|
2101
2864
|
} };
|
|
2102
2865
|
}
|
|
2866
|
+
/** The instructions a model wrote for the learner, when it wrote any. */
|
|
2867
|
+
function instructionsOf(entry) {
|
|
2868
|
+
const instructions = written$1(entry.instructions);
|
|
2869
|
+
return instructions !== void 0 ? { instructions } : {};
|
|
2870
|
+
}
|
|
2103
2871
|
/** A type's content, read from what a model wrote. */
|
|
2104
2872
|
function contentOf(type, entry, newId) {
|
|
2105
2873
|
switch (type) {
|
|
2106
2874
|
case "multiple-choice": return {
|
|
2107
|
-
|
|
2875
|
+
...instructionsOf(entry),
|
|
2876
|
+
question: stringOf$1(entry.question),
|
|
2108
2877
|
...typeof entry.mode === "string" ? { mode: entry.mode } : {},
|
|
2109
|
-
options: listOf(entry.options).map((option) => {
|
|
2110
|
-
const fields = isRecord$
|
|
2111
|
-
const feedback = written(fields.feedback);
|
|
2878
|
+
options: listOf$1(entry.options).map((option) => {
|
|
2879
|
+
const fields = isRecord$2(option) ? option : {};
|
|
2880
|
+
const feedback = written$1(fields.feedback);
|
|
2112
2881
|
return {
|
|
2113
2882
|
id: newId(),
|
|
2114
|
-
text: stringOf(fields.text),
|
|
2883
|
+
text: stringOf$1(fields.text),
|
|
2115
2884
|
isCorrect: fields.isCorrect === true,
|
|
2116
2885
|
...feedback !== void 0 ? { feedback } : {}
|
|
2117
2886
|
};
|
|
@@ -2119,27 +2888,29 @@ function contentOf(type, entry, newId) {
|
|
|
2119
2888
|
...feedbackOf(entry)
|
|
2120
2889
|
};
|
|
2121
2890
|
case "fill-in-the-blanks": return {
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
const
|
|
2891
|
+
...instructionsOf(entry),
|
|
2892
|
+
passage: stringOf$1(entry.passage),
|
|
2893
|
+
blanks: listOf$1(entry.blanks).map((blank, index) => {
|
|
2894
|
+
const fields = isRecord$2(blank) ? blank : {};
|
|
2895
|
+
const hint = written$1(fields.hint);
|
|
2126
2896
|
return {
|
|
2127
2897
|
id: String(index + 1),
|
|
2128
|
-
acceptedAnswers: listOf(fields.acceptedAnswers).filter((answer) => typeof answer === "string"),
|
|
2898
|
+
acceptedAnswers: listOf$1(fields.acceptedAnswers).filter((answer) => typeof answer === "string"),
|
|
2129
2899
|
...hint !== void 0 ? { hint } : {}
|
|
2130
2900
|
};
|
|
2131
2901
|
}),
|
|
2132
2902
|
...feedbackOf(entry)
|
|
2133
2903
|
};
|
|
2134
2904
|
case "gap-select": return {
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2905
|
+
...instructionsOf(entry),
|
|
2906
|
+
passage: stringOf$1(entry.passage),
|
|
2907
|
+
gaps: listOf$1(entry.gaps).map((gap, index) => {
|
|
2908
|
+
const marked = listOf$1(isRecord$2(gap) ? gap.choices : void 0);
|
|
2138
2909
|
const choices = marked.map((choice) => ({
|
|
2139
2910
|
id: newId(),
|
|
2140
|
-
text: stringOf(isRecord$
|
|
2911
|
+
text: stringOf$1(isRecord$2(choice) ? choice.text : void 0)
|
|
2141
2912
|
}));
|
|
2142
|
-
const right = marked.flatMap((choice, at) => isRecord$
|
|
2913
|
+
const right = marked.flatMap((choice, at) => isRecord$2(choice) && choice.isCorrect === true ? [at] : []);
|
|
2143
2914
|
return {
|
|
2144
2915
|
id: String(index + 1),
|
|
2145
2916
|
choices,
|
|
@@ -2149,28 +2920,25 @@ function contentOf(type, entry, newId) {
|
|
|
2149
2920
|
...feedbackOf(entry)
|
|
2150
2921
|
};
|
|
2151
2922
|
case "dictation": return {
|
|
2152
|
-
transcript: stringOf(entry.transcript),
|
|
2923
|
+
transcript: stringOf$1(entry.transcript),
|
|
2924
|
+
...feedbackOf(entry)
|
|
2925
|
+
};
|
|
2926
|
+
case "read-aloud": return {
|
|
2927
|
+
referenceText: stringOf$1(entry.referenceText),
|
|
2928
|
+
...instructionsOf(entry),
|
|
2153
2929
|
...feedbackOf(entry)
|
|
2154
2930
|
};
|
|
2155
|
-
case "read-aloud": {
|
|
2156
|
-
const instructions = written(entry.instructions);
|
|
2157
|
-
return {
|
|
2158
|
-
referenceText: stringOf(entry.referenceText),
|
|
2159
|
-
...instructions !== void 0 ? { instructions } : {},
|
|
2160
|
-
...feedbackOf(entry)
|
|
2161
|
-
};
|
|
2162
|
-
}
|
|
2163
2931
|
default: {
|
|
2164
|
-
const criteria = listOf(isRecord$
|
|
2932
|
+
const criteria = listOf$1(isRecord$2(entry.rubric) ? entry.rubric.criteria : void 0);
|
|
2165
2933
|
return {
|
|
2166
|
-
prompt: stringOf(entry.prompt),
|
|
2934
|
+
prompt: stringOf$1(entry.prompt),
|
|
2167
2935
|
...entry.minWords !== void 0 ? { minWords: entry.minWords } : {},
|
|
2168
2936
|
...entry.maxWords !== void 0 ? { maxWords: entry.maxWords } : {},
|
|
2169
2937
|
...criteria.length > 0 ? { rubric: { criteria: criteria.map((criterion) => {
|
|
2170
|
-
const fields = isRecord$
|
|
2171
|
-
const description = written(fields.description);
|
|
2938
|
+
const fields = isRecord$2(criterion) ? criterion : {};
|
|
2939
|
+
const description = written$1(fields.description);
|
|
2172
2940
|
return {
|
|
2173
|
-
name: stringOf(fields.name),
|
|
2941
|
+
name: stringOf$1(fields.name),
|
|
2174
2942
|
...description !== void 0 ? { description } : {},
|
|
2175
2943
|
weight: 1
|
|
2176
2944
|
};
|
|
@@ -2212,7 +2980,7 @@ function draftFrom(type, entry, id, newId, request) {
|
|
|
2212
2980
|
...startOf(type),
|
|
2213
2981
|
...locale !== void 0 ? { locale } : {},
|
|
2214
2982
|
...request.settings?.[type],
|
|
2215
|
-
title: stringOf(entry.title),
|
|
2983
|
+
title: stringOf$1(entry.title),
|
|
2216
2984
|
...contentOf(type, entry, newId),
|
|
2217
2985
|
schemaVersion: "1.0",
|
|
2218
2986
|
type,
|
|
@@ -2220,7 +2988,7 @@ function draftFrom(type, entry, id, newId, request) {
|
|
|
2220
2988
|
};
|
|
2221
2989
|
}
|
|
2222
2990
|
/** Ids from the host, checked as `createDraft` checks them. */
|
|
2223
|
-
function idSource(newId, caller) {
|
|
2991
|
+
function idSource$1(newId, caller) {
|
|
2224
2992
|
const issued = /* @__PURE__ */ new Set();
|
|
2225
2993
|
return () => {
|
|
2226
2994
|
const id = newId();
|
|
@@ -2236,7 +3004,7 @@ function inAuthorsOrder(drafts, types) {
|
|
|
2236
3004
|
}
|
|
2237
3005
|
function check(raw, request, newId, keep = []) {
|
|
2238
3006
|
const types = request.facts.activityTypes;
|
|
2239
|
-
if (!isRecord$
|
|
3007
|
+
if (!isRecord$2(raw) || !Array.isArray(raw.drafts) || !raw.drafts.every((entry) => isRecord$2(entry) && types.includes(entry.type))) return {
|
|
2240
3008
|
ok: false,
|
|
2241
3009
|
refusal: "malformed"
|
|
2242
3010
|
};
|
|
@@ -2252,7 +3020,7 @@ function check(raw, request, newId, keep = []) {
|
|
|
2252
3020
|
const type = entry.type;
|
|
2253
3021
|
const draft = draftFrom(type, entry, keep[position] ?? newId(), newId, request);
|
|
2254
3022
|
const caption = source.kind === "captions" && Number.isInteger(entry.caption) ? source.captions.find((one) => one.index === entry.caption) : void 0;
|
|
2255
|
-
const said = fold(stringOf(entry.transcript));
|
|
3023
|
+
const said = fold(stringOf$1(entry.transcript));
|
|
2256
3024
|
const clip = type === "dictation" && caption !== void 0 && containsWords(fold(caption.text), said) ? {
|
|
2257
3025
|
start: caption.start,
|
|
2258
3026
|
end: caption.end
|
|
@@ -2304,14 +3072,14 @@ function check(raw, request, newId, keep = []) {
|
|
|
2304
3072
|
* @throws Error when `newId` returns an empty or repeated id.
|
|
2305
3073
|
*/
|
|
2306
3074
|
function checkAiDrafts(raw, request, options) {
|
|
2307
|
-
return check(raw, request, idSource(options.newId, "checkAiDrafts"));
|
|
3075
|
+
return check(raw, request, idSource$1(options.newId, "checkAiDrafts"));
|
|
2308
3076
|
}
|
|
2309
3077
|
/**
|
|
2310
3078
|
* Where a model wrote what a problem names: the draft's paths, but a gap's key
|
|
2311
3079
|
* is its choices, and a reading too long for the host's recording time is the
|
|
2312
3080
|
* text the model chose.
|
|
2313
3081
|
*/
|
|
2314
|
-
function pathForModel(type, problem) {
|
|
3082
|
+
function pathForModel$1(type, problem) {
|
|
2315
3083
|
const { path, code } = problem;
|
|
2316
3084
|
if (type === "gap-select" && path.length === 3 && path[0] === "gaps" && path[2] === "correctChoiceId") return [
|
|
2317
3085
|
"gaps",
|
|
@@ -2326,8 +3094,8 @@ function pathForModel(type, problem) {
|
|
|
2326
3094
|
* dictation in a video with no recording, is the host's to finish, and is not
|
|
2327
3095
|
* sent back.
|
|
2328
3096
|
*/
|
|
2329
|
-
function problemsOf(one) {
|
|
2330
|
-
const writes = WRITES[one.type];
|
|
3097
|
+
function problemsOf$1(one) {
|
|
3098
|
+
const writes = WRITES$1[one.type];
|
|
2331
3099
|
return [...one.validation.issues.map((found) => ({
|
|
2332
3100
|
path: found.path,
|
|
2333
3101
|
code: found.code,
|
|
@@ -2340,7 +3108,7 @@ function problemsOf(one) {
|
|
|
2340
3108
|
severity: "warning"
|
|
2341
3109
|
}))].map((problem) => ({
|
|
2342
3110
|
...problem,
|
|
2343
|
-
path: pathForModel(one.type, problem)
|
|
3111
|
+
path: pathForModel$1(one.type, problem)
|
|
2344
3112
|
})).filter((problem) => writes.includes(problem.path[0]));
|
|
2345
3113
|
}
|
|
2346
3114
|
/**
|
|
@@ -2354,7 +3122,7 @@ function problemsOf(one) {
|
|
|
2354
3122
|
*/
|
|
2355
3123
|
function aiDraftsRepairRequest(request, drafts) {
|
|
2356
3124
|
const repair = ("drafts" in drafts ? drafts.drafts : drafts).flatMap((one) => {
|
|
2357
|
-
const problems = problemsOf(one);
|
|
3125
|
+
const problems = problemsOf$1(one);
|
|
2358
3126
|
return problems.length === 0 ? [] : [{
|
|
2359
3127
|
index: one.index,
|
|
2360
3128
|
draft: one.generated,
|
|
@@ -2365,20 +3133,20 @@ function aiDraftsRepairRequest(request, drafts) {
|
|
|
2365
3133
|
const { repair: _previous, ...first } = request;
|
|
2366
3134
|
return {
|
|
2367
3135
|
...first,
|
|
2368
|
-
shape: replyShape(request.facts.activityTypes, request.facts.source.kind === "captions", repair.length, repair.length),
|
|
3136
|
+
shape: replyShape$1(request.facts.activityTypes, request.facts.source.kind === "captions", repair.length, repair.length),
|
|
2369
3137
|
repair
|
|
2370
3138
|
};
|
|
2371
3139
|
}
|
|
2372
|
-
const RANK = {
|
|
3140
|
+
const RANK$1 = {
|
|
2373
3141
|
complete: 2,
|
|
2374
3142
|
incomplete: 1,
|
|
2375
3143
|
invalid: 0
|
|
2376
3144
|
};
|
|
2377
3145
|
/** Whether a repaired draft is at least as good as the one it would replace. */
|
|
2378
|
-
function noWorse(repaired, before) {
|
|
3146
|
+
function noWorse$1(repaired, before) {
|
|
2379
3147
|
const warnings = (one) => one.findings.filter((found) => found.severity === "warning").length;
|
|
2380
|
-
const now = RANK[repaired.validation.status];
|
|
2381
|
-
const was = RANK[before.validation.status];
|
|
3148
|
+
const now = RANK$1[repaired.validation.status];
|
|
3149
|
+
const was = RANK$1[before.validation.status];
|
|
2382
3150
|
return now > was || now === was && warnings(repaired) <= warnings(before);
|
|
2383
3151
|
}
|
|
2384
3152
|
/**
|
|
@@ -2411,7 +3179,7 @@ async function generateDrafts(input) {
|
|
|
2411
3179
|
const repairs = input.repairs ?? 1;
|
|
2412
3180
|
if (!Number.isInteger(repairs) || repairs < 0 || repairs > 3) throw new RangeError(`generateDrafts: repairs must be a whole number from 0 to 3, not ${String(repairs)}.`);
|
|
2413
3181
|
const calls = [];
|
|
2414
|
-
const ids = idSource(newId, "generateDrafts");
|
|
3182
|
+
const ids = idSource$1(newId, "generateDrafts");
|
|
2415
3183
|
const ask = async (asked, keep) => {
|
|
2416
3184
|
let raw;
|
|
2417
3185
|
try {
|
|
@@ -2456,7 +3224,7 @@ async function generateDrafts(input) {
|
|
|
2456
3224
|
if (repaired === void 0) break;
|
|
2457
3225
|
current = current.map((before) => {
|
|
2458
3226
|
const after = repaired.drafts.find((one) => one.index === before.index);
|
|
2459
|
-
return after !== void 0 && noWorse(after, before) ? after : before;
|
|
3227
|
+
return after !== void 0 && noWorse$1(after, before) ? after : before;
|
|
2460
3228
|
});
|
|
2461
3229
|
}
|
|
2462
3230
|
return {
|
|
@@ -2491,7 +3259,7 @@ function sourceText(request) {
|
|
|
2491
3259
|
*/
|
|
2492
3260
|
function interactiveVideoFromDrafts(input) {
|
|
2493
3261
|
const { request, drafts, video, durationSeconds, title } = input;
|
|
2494
|
-
const ids = idSource(input.newId, "interactiveVideoFromDrafts");
|
|
3262
|
+
const ids = idSource$1(input.newId, "interactiveVideoFromDrafts");
|
|
2495
3263
|
const types = request.facts.activityTypes;
|
|
2496
3264
|
const list = "drafts" in drafts ? drafts.drafts : drafts;
|
|
2497
3265
|
const end = typeof durationSeconds === "number" && Number.isFinite(durationSeconds) && durationSeconds >= 0 ? durationSeconds : void 0;
|
|
@@ -2530,8 +3298,7 @@ function interactiveVideoFromDrafts(input) {
|
|
|
2530
3298
|
findings: critiqueItemGroupDraft(group)
|
|
2531
3299
|
};
|
|
2532
3300
|
}
|
|
2533
|
-
|
|
2534
|
-
//#region src/ai-written.ts
|
|
3301
|
+
// src/ai-written.ts
|
|
2535
3302
|
/**
|
|
2536
3303
|
* What the AI features about a written response share: reading a model's text
|
|
2537
3304
|
* fields, and finding each correction's quote in the learner's text.
|
|
@@ -2603,7 +3370,7 @@ function textField(value, max) {
|
|
|
2603
3370
|
value: cleaned === "" ? void 0 : cleaned
|
|
2604
3371
|
};
|
|
2605
3372
|
}
|
|
2606
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3373
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2607
3374
|
/** The corrections, each anchored in the draft, or why the reply is refused. */
|
|
2608
3375
|
function checkCorrections(raw, text) {
|
|
2609
3376
|
if (raw === void 0 || raw === null) return {
|
|
@@ -2625,7 +3392,7 @@ function checkCorrections(raw, text) {
|
|
|
2625
3392
|
const taken = /* @__PURE__ */ new Set();
|
|
2626
3393
|
const corrections = [];
|
|
2627
3394
|
for (const entry of raw) {
|
|
2628
|
-
if (!isRecord(entry) || typeof entry.original !== "string") return {
|
|
3395
|
+
if (!isRecord$1(entry) || typeof entry.original !== "string") return {
|
|
2629
3396
|
ok: false,
|
|
2630
3397
|
refusal: "malformed"
|
|
2631
3398
|
};
|
|
@@ -2647,7 +3414,7 @@ function checkCorrections(raw, text) {
|
|
|
2647
3414
|
if (!category.ok) return category;
|
|
2648
3415
|
let claimed;
|
|
2649
3416
|
if (entry.range !== void 0 && entry.range !== null) {
|
|
2650
|
-
if (!isRecord(entry.range)) return {
|
|
3417
|
+
if (!isRecord$1(entry.range)) return {
|
|
2651
3418
|
ok: false,
|
|
2652
3419
|
refusal: "malformed"
|
|
2653
3420
|
};
|
|
@@ -2674,8 +3441,7 @@ function checkCorrections(raw, text) {
|
|
|
2674
3441
|
corrections
|
|
2675
3442
|
};
|
|
2676
3443
|
}
|
|
2677
|
-
|
|
2678
|
-
//#region src/ai-grading.ts
|
|
3444
|
+
// src/ai-grading.ts
|
|
2679
3445
|
/**
|
|
2680
3446
|
* How far a model's level probabilities may sum from 1 and still be a
|
|
2681
3447
|
* distribution: rounding in a model's output, not a different judgement.
|
|
@@ -2744,7 +3510,7 @@ function aiGradingRequest(input) {
|
|
|
2744
3510
|
}
|
|
2745
3511
|
/** The rubric as a model is given it, each criterion's levels read. */
|
|
2746
3512
|
function rubricFacts(rubric) {
|
|
2747
|
-
const listed = isRecord(rubric) ? rubric.criteria : void 0;
|
|
3513
|
+
const listed = isRecord$1(rubric) ? rubric.criteria : void 0;
|
|
2748
3514
|
if (!Array.isArray(listed) || listed.length === 0) return {
|
|
2749
3515
|
ok: false,
|
|
2750
3516
|
problem: "no-rubric"
|
|
@@ -2752,7 +3518,7 @@ function rubricFacts(rubric) {
|
|
|
2752
3518
|
const criteria = [];
|
|
2753
3519
|
const names = /* @__PURE__ */ new Set();
|
|
2754
3520
|
for (const [index, criterion] of listed.entries()) {
|
|
2755
|
-
if (!isRecord(criterion)) return {
|
|
3521
|
+
if (!isRecord$1(criterion)) return {
|
|
2756
3522
|
ok: false,
|
|
2757
3523
|
problem: "no-rubric",
|
|
2758
3524
|
path: [
|
|
@@ -2921,7 +3687,7 @@ function judgeCriterion(entry, criterion) {
|
|
|
2921
3687
|
* `gradeFromRubric` computes every grade in the SDK.
|
|
2922
3688
|
*/
|
|
2923
3689
|
function checkAiGrading(raw, request) {
|
|
2924
|
-
if (!isRecord(raw)) return {
|
|
3690
|
+
if (!isRecord$1(raw)) return {
|
|
2925
3691
|
ok: false,
|
|
2926
3692
|
refusal: "malformed"
|
|
2927
3693
|
};
|
|
@@ -2938,7 +3704,7 @@ function checkAiGrading(raw, request) {
|
|
|
2938
3704
|
const indexOf = criterionIndex(rubric);
|
|
2939
3705
|
const judged = rubric.map(() => void 0);
|
|
2940
3706
|
for (const entry of listed) {
|
|
2941
|
-
if (!isRecord(entry)) return {
|
|
3707
|
+
if (!isRecord$1(entry)) return {
|
|
2942
3708
|
ok: false,
|
|
2943
3709
|
refusal: "malformed"
|
|
2944
3710
|
};
|
|
@@ -2999,8 +3765,1499 @@ function checkAiGrading(raw, request) {
|
|
|
2999
3765
|
}
|
|
3000
3766
|
};
|
|
3001
3767
|
}
|
|
3002
|
-
|
|
3003
|
-
|
|
3768
|
+
// src/ai-variants.ts
|
|
3769
|
+
/** The most exemplars one request may vary: a long section is sent in parts. */
|
|
3770
|
+
const AI_VARIANTS_MAX_EXEMPLARS = 20;
|
|
3771
|
+
/** The most repair passes `generateVariants` makes. */
|
|
3772
|
+
const AI_VARIANTS_MAX_REPAIRS = 3;
|
|
3773
|
+
/** The longest passage or script, in characters, an exemplar may carry into the facts. */
|
|
3774
|
+
const AI_VARIANTS_MAX_MATERIAL_LENGTH = AI_MATERIAL_MAX_LENGTH;
|
|
3775
|
+
/**
|
|
3776
|
+
* Every code a variant can be reported with, and its severity: a contract,
|
|
3777
|
+
* as the critic's codes are. `docs/ai.md` documents this table and a test
|
|
3778
|
+
* holds the two together.
|
|
3779
|
+
*/
|
|
3780
|
+
const AI_VARIANT_CODE_SEVERITY = {
|
|
3781
|
+
variant_missing: "invalid",
|
|
3782
|
+
variant_type: "invalid",
|
|
3783
|
+
variant_structure: "invalid",
|
|
3784
|
+
variant_length: "warning",
|
|
3785
|
+
variant_near_copy: "warning",
|
|
3786
|
+
variant_same_key: "warning"
|
|
3787
|
+
};
|
|
3788
|
+
/** The activity types the SDK writes variants of: the ones whose every field it knows. */
|
|
3789
|
+
const VARIANT_TYPES = [
|
|
3790
|
+
"multiple-choice",
|
|
3791
|
+
"fill-in-the-blanks",
|
|
3792
|
+
"gap-select",
|
|
3793
|
+
"dictation",
|
|
3794
|
+
"read-aloud",
|
|
3795
|
+
"written-response"
|
|
3796
|
+
];
|
|
3797
|
+
const DIFFICULTIES = [
|
|
3798
|
+
"easier",
|
|
3799
|
+
"same",
|
|
3800
|
+
"harder"
|
|
3801
|
+
];
|
|
3802
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3803
|
+
const listOf = (value) => Array.isArray(value) ? value : [];
|
|
3804
|
+
const stringOf = (value) => typeof value === "string" ? value : "";
|
|
3805
|
+
const written = (value) => typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
3806
|
+
const textList = (value) => listOf(value).filter((one) => typeof one === "string");
|
|
3807
|
+
/** A copy of plain content, so a variant never shares an object with its exemplar. */
|
|
3808
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
3809
|
+
const isRecording = (media) => isRecord(media) && [
|
|
3810
|
+
"audio",
|
|
3811
|
+
"video",
|
|
3812
|
+
"embed"
|
|
3813
|
+
].includes(stringOf(media.type));
|
|
3814
|
+
const isImage = (media) => isRecord(media) && media.type === "image";
|
|
3815
|
+
const RULES = [
|
|
3816
|
+
"Write one variant for each exemplar, naming its exemplar index and its type.",
|
|
3817
|
+
"Keep each exemplar's targets.structure exactly: the same number of options and of right options, blanks, gaps, choices and banks, items in the same order, and speakers.",
|
|
3818
|
+
"Write every word a learner reads or hears, in new words: never the exemplar's sentences with a few words changed. Write nothing else: no ids, HTML, addresses or settings.",
|
|
3819
|
+
"Keep each text within its targets.lengths, counted in words.",
|
|
3820
|
+
"Test the same skill with the same kind of thinking at the same level: a literal question stays literal, an inference stays an inference, and wrong options stay as plausible as the exemplar's.",
|
|
3821
|
+
"Give each item a right answer other than its exemplar's, and other than the answers in avoid, unless the skill tested leaves only one.",
|
|
3822
|
+
"Keep the task in instructions as the exemplar's; change its wording only where the new item needs it.",
|
|
3823
|
+
"Keep a title that is a label (Part 1) as it is; give a title that names the topic a new one that gives no answer away.",
|
|
3824
|
+
"Do not repeat the flaws listed in targets.exemplarFindings.",
|
|
3825
|
+
"A listening script keeps the number of speakers, with new names, and about as many turns."
|
|
3826
|
+
];
|
|
3827
|
+
const DESCRIBE_RULE = "Where an exemplar shows a picture, describe a new picture in its media alt, specific enough for a person to find or draw it, and ask about that picture.";
|
|
3828
|
+
const REUSE_RULE = "Where an exemplar shows a picture, keep it: write new questions about the same picture, which its alt describes.";
|
|
3829
|
+
const DIFFICULTY_RULE = "Where targets.difficulty is easier or harder, move one step, inside the level, using targets.levers. Never write for another level.";
|
|
3830
|
+
const LEVEL_FACTS_RULE = "Use only grammar and vocabulary that levelFacts lists as taught, and keep away from the topics it lists.";
|
|
3831
|
+
/** The ways a type is made easier or harder: one step inside a level. */
|
|
3832
|
+
const LEVERS = {
|
|
3833
|
+
"multiple-choice": {
|
|
3834
|
+
easier: [
|
|
3835
|
+
"Make the wrong options clearly wrong to a learner who understood, though still plausible to one who did not.",
|
|
3836
|
+
"Ask about what is said directly rather than what is implied.",
|
|
3837
|
+
"Word the right option closer to the words of the text or recording."
|
|
3838
|
+
],
|
|
3839
|
+
harder: [
|
|
3840
|
+
"Make the wrong options closer to the right one, each wrong for a reason the text or recording makes clear.",
|
|
3841
|
+
"Ask for an inference rather than a fact stated directly.",
|
|
3842
|
+
"Paraphrase the right option rather than repeat the words of the text or recording."
|
|
3843
|
+
]
|
|
3844
|
+
},
|
|
3845
|
+
"fill-in-the-blanks": {
|
|
3846
|
+
easier: ["Give more context around each blank that points to the answer."],
|
|
3847
|
+
harder: ["Give less context around each blank, so the learner must know the form rather than infer it."]
|
|
3848
|
+
},
|
|
3849
|
+
"gap-select": {
|
|
3850
|
+
easier: ["Give more context around each gap that points to the answer.", "Make the wrong choices clearly wrong in context."],
|
|
3851
|
+
harder: ["Give less context around each gap.", "Make the wrong choices closer to the right one."]
|
|
3852
|
+
},
|
|
3853
|
+
dictation: {
|
|
3854
|
+
easier: ["Use shorter sentences and more frequent words of the level, inside the length band."],
|
|
3855
|
+
harder: ["Use longer sentences and less frequent words of the level, inside the length band.", "Use more connected-speech features: contractions, linking, weak forms."]
|
|
3856
|
+
},
|
|
3857
|
+
"read-aloud": {
|
|
3858
|
+
easier: ["Use shorter sentences and more frequent words of the level, inside the length band."],
|
|
3859
|
+
harder: ["Use longer sentences and less frequent words of the level, inside the length band.", "Use more words with sounds and stress the level finds hard."]
|
|
3860
|
+
},
|
|
3861
|
+
"written-response": {
|
|
3862
|
+
easier: ["Make the task more specific, and supply more of the content to write about."],
|
|
3863
|
+
harder: ["Make the task more open, so the learner supplies more of the content."]
|
|
3864
|
+
},
|
|
3865
|
+
reading: {
|
|
3866
|
+
easier: ["Ask about what the passage says directly rather than what it implies.", "Use more frequent words of the level, and shorter sentences with simpler syntax."],
|
|
3867
|
+
harder: ["Ask for inferences rather than facts stated directly.", "Use less frequent words of the level, and longer sentences."]
|
|
3868
|
+
},
|
|
3869
|
+
listening: {
|
|
3870
|
+
easier: ["Describe a slower pace in the script, and have a speaker repeat the key information.", "Use more frequent words of the level."],
|
|
3871
|
+
harder: ["Describe a natural pace in the script, and give the key information once.", "Use less frequent words of the level."]
|
|
3872
|
+
}
|
|
3873
|
+
};
|
|
3874
|
+
function leversFor(entry, difficulty) {
|
|
3875
|
+
if (difficulty === "same") return;
|
|
3876
|
+
let kind = stringOf(entry.type);
|
|
3877
|
+
if (kind === "item-group") {
|
|
3878
|
+
const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
3879
|
+
kind = stimulus.kind === "audio" || stimulus.kind === "video" ? "listening" : "reading";
|
|
3880
|
+
}
|
|
3881
|
+
const levers = LEVERS[kind];
|
|
3882
|
+
return levers !== void 0 ? [...levers[difficulty]] : void 0;
|
|
3883
|
+
}
|
|
3884
|
+
/** A passage with its placeholders numbered in order: `{{1}}`, `{{2}}`… — how a variant writes them. */
|
|
3885
|
+
function renumbered(passage) {
|
|
3886
|
+
let position = 0;
|
|
3887
|
+
return passage.replace(PLACEHOLDER_RE, () => {
|
|
3888
|
+
position += 1;
|
|
3889
|
+
return `{{${position}}}`;
|
|
3890
|
+
});
|
|
3891
|
+
}
|
|
3892
|
+
const turnsOf = (holder) => listOf(holder.script).flatMap((turn) => isRecord(turn) ? [{
|
|
3893
|
+
speaker: stringOf(turn.speaker),
|
|
3894
|
+
text: stringOf(turn.text)
|
|
3895
|
+
}] : []);
|
|
3896
|
+
const hasScript = (holder) => listOf(holder.script).some(isRecord);
|
|
3897
|
+
/** What a model is shown of a media object: a picture's description, or what a recording says. */
|
|
3898
|
+
function mediaContent(media, ownText, ref) {
|
|
3899
|
+
if (!isRecord(media)) return;
|
|
3900
|
+
if (isImage(media)) return {
|
|
3901
|
+
alt: stringOf(media.alt),
|
|
3902
|
+
...ref !== void 0 ? { ref } : {}
|
|
3903
|
+
};
|
|
3904
|
+
if (ownText || !isRecording(media)) return;
|
|
3905
|
+
if (hasScript(media)) return { script: turnsOf(media) };
|
|
3906
|
+
return typeof media.transcript === "string" ? { transcript: media.transcript } : void 0;
|
|
3907
|
+
}
|
|
3908
|
+
function feedbackContent(holder) {
|
|
3909
|
+
if (!isRecord(holder.feedback)) return {};
|
|
3910
|
+
const { correct, incorrect } = holder.feedback;
|
|
3911
|
+
return { feedback: {
|
|
3912
|
+
...typeof correct === "string" ? { correct } : {},
|
|
3913
|
+
...typeof incorrect === "string" ? { incorrect } : {}
|
|
3914
|
+
} };
|
|
3915
|
+
}
|
|
3916
|
+
function itemContent(item, refOf, at) {
|
|
3917
|
+
const type = stringOf(item.type);
|
|
3918
|
+
const ownText = type === "dictation" || type === "read-aloud";
|
|
3919
|
+
const media = mediaContent(item.media, ownText, refOf([...at, "media"]));
|
|
3920
|
+
const content = {
|
|
3921
|
+
type,
|
|
3922
|
+
title: stringOf(item.title),
|
|
3923
|
+
...typeof item.instructions === "string" ? { instructions: item.instructions } : {}
|
|
3924
|
+
};
|
|
3925
|
+
switch (type) {
|
|
3926
|
+
case "multiple-choice":
|
|
3927
|
+
Object.assign(content, {
|
|
3928
|
+
question: stringOf(item.question),
|
|
3929
|
+
options: listOf(item.options).map((option, index) => {
|
|
3930
|
+
const fields = isRecord(option) ? option : {};
|
|
3931
|
+
const optionMedia = mediaContent(fields.media, false, refOf([
|
|
3932
|
+
...at,
|
|
3933
|
+
"options",
|
|
3934
|
+
index,
|
|
3935
|
+
"media"
|
|
3936
|
+
]));
|
|
3937
|
+
return {
|
|
3938
|
+
text: stringOf(fields.text),
|
|
3939
|
+
isCorrect: fields.isCorrect === true,
|
|
3940
|
+
...typeof fields.feedback === "string" ? { feedback: fields.feedback } : {},
|
|
3941
|
+
...optionMedia !== void 0 ? { media: optionMedia } : {}
|
|
3942
|
+
};
|
|
3943
|
+
})
|
|
3944
|
+
});
|
|
3945
|
+
break;
|
|
3946
|
+
case "fill-in-the-blanks":
|
|
3947
|
+
Object.assign(content, {
|
|
3948
|
+
passage: renumbered(stringOf(item.passage)),
|
|
3949
|
+
blanks: inPassageOrder(item, "blanks").map(({ value }) => {
|
|
3950
|
+
const blank = isRecord(value) ? value : {};
|
|
3951
|
+
return {
|
|
3952
|
+
acceptedAnswers: textList(blank.acceptedAnswers),
|
|
3953
|
+
...typeof blank.hint === "string" ? { hint: blank.hint } : {},
|
|
3954
|
+
...typeof blank.feedback === "string" ? { feedback: blank.feedback } : {}
|
|
3955
|
+
};
|
|
3956
|
+
})
|
|
3957
|
+
});
|
|
3958
|
+
break;
|
|
3959
|
+
case "gap-select": {
|
|
3960
|
+
const banks = listOf(item.banks).filter(isRecord);
|
|
3961
|
+
Object.assign(content, {
|
|
3962
|
+
passage: renumbered(stringOf(item.passage)),
|
|
3963
|
+
gaps: inPassageOrder(item, "gaps").map(({ value }) => {
|
|
3964
|
+
const gap = isRecord(value) ? value : {};
|
|
3965
|
+
const feedback = typeof gap.feedback === "string" ? { feedback: gap.feedback } : {};
|
|
3966
|
+
const bank = banks.find((one) => one.id === gap.bankId);
|
|
3967
|
+
if (listOf(gap.choices).length === 0 && bank !== void 0) {
|
|
3968
|
+
const right = listOf(bank.choices).find((choice) => isRecord(choice) && choice.id === gap.correctChoiceId);
|
|
3969
|
+
return {
|
|
3970
|
+
answer: isRecord(right) ? stringOf(right.text) : "",
|
|
3971
|
+
...feedback
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3974
|
+
return {
|
|
3975
|
+
choices: listOf(gap.choices).map((choice) => ({
|
|
3976
|
+
text: isRecord(choice) ? stringOf(choice.text) : "",
|
|
3977
|
+
isCorrect: isRecord(choice) && choice.id === gap.correctChoiceId
|
|
3978
|
+
})),
|
|
3979
|
+
...feedback
|
|
3980
|
+
};
|
|
3981
|
+
}),
|
|
3982
|
+
...banks.length > 0 ? { banks: banks.map((bank) => ({ choices: listOf(bank.choices).map((choice) => isRecord(choice) ? stringOf(choice.text) : "") })) } : {}
|
|
3983
|
+
});
|
|
3984
|
+
break;
|
|
3985
|
+
}
|
|
3986
|
+
case "dictation":
|
|
3987
|
+
Object.assign(content, {
|
|
3988
|
+
transcript: stringOf(item.transcript),
|
|
3989
|
+
...Array.isArray(item.acceptedTranscripts) ? { acceptedTranscripts: textList(item.acceptedTranscripts) } : {}
|
|
3990
|
+
});
|
|
3991
|
+
break;
|
|
3992
|
+
case "read-aloud":
|
|
3993
|
+
content.referenceText = stringOf(item.referenceText);
|
|
3994
|
+
break;
|
|
3995
|
+
default: content.prompt = stringOf(item.prompt);
|
|
3996
|
+
}
|
|
3997
|
+
return {
|
|
3998
|
+
...content,
|
|
3999
|
+
...media !== void 0 ? { media } : {},
|
|
4000
|
+
...feedbackContent(item)
|
|
4001
|
+
};
|
|
4002
|
+
}
|
|
4003
|
+
function entryContent(entry, refOf) {
|
|
4004
|
+
if (entry.type !== "item-group") return itemContent(entry, refOf, []);
|
|
4005
|
+
const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
4006
|
+
const ownText = hasScript(stimulus) || typeof stimulus.transcript === "string";
|
|
4007
|
+
const media = mediaContent(stimulus.media, ownText, refOf(["stimulus", "media"]));
|
|
4008
|
+
return {
|
|
4009
|
+
type: "item-group",
|
|
4010
|
+
...typeof entry.title === "string" ? { title: entry.title } : {},
|
|
4011
|
+
stimulus: {
|
|
4012
|
+
kind: stringOf(stimulus.kind),
|
|
4013
|
+
...typeof stimulus.title === "string" ? { title: stimulus.title } : {},
|
|
4014
|
+
...typeof stimulus.body === "string" ? { body: stimulus.body } : {},
|
|
4015
|
+
...hasScript(stimulus) ? { script: turnsOf(stimulus) } : typeof stimulus.transcript === "string" ? { transcript: stimulus.transcript } : {},
|
|
4016
|
+
...media ?? {}
|
|
4017
|
+
},
|
|
4018
|
+
items: listOf(entry.items).map((item, index) => isRecord(item) ? itemContent(item, refOf, ["items", index]) : {})
|
|
4019
|
+
};
|
|
4020
|
+
}
|
|
4021
|
+
/** Every picture an entry shows: an item's, an option's, and a group's material's. */
|
|
4022
|
+
function imageSlots(entry) {
|
|
4023
|
+
const ofItem = (item, at) => {
|
|
4024
|
+
if (!isRecord(item)) return [];
|
|
4025
|
+
return [...isImage(item.media) ? [{
|
|
4026
|
+
path: [...at, "media"],
|
|
4027
|
+
media: item.media
|
|
4028
|
+
}] : [], ...listOf(item.options).flatMap((option, index) => isRecord(option) && isImage(option.media) ? [{
|
|
4029
|
+
path: [
|
|
4030
|
+
...at,
|
|
4031
|
+
"options",
|
|
4032
|
+
String(index),
|
|
4033
|
+
"media"
|
|
4034
|
+
],
|
|
4035
|
+
media: option.media
|
|
4036
|
+
}] : [])];
|
|
4037
|
+
};
|
|
4038
|
+
if (entry.type !== "item-group") return ofItem(entry, []);
|
|
4039
|
+
const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
4040
|
+
return [...isImage(stimulus.media) ? [{
|
|
4041
|
+
path: ["stimulus", "media"],
|
|
4042
|
+
media: stimulus.media
|
|
4043
|
+
}] : [], ...listOf(entry.items).flatMap((item, index) => ofItem(item, ["items", String(index)]))];
|
|
4044
|
+
}
|
|
4045
|
+
/** Whether a recording the item's meaning depends on says nothing the facts can carry. */
|
|
4046
|
+
function unheard(entry) {
|
|
4047
|
+
const itemUnheard = (item) => {
|
|
4048
|
+
if (!isRecord(item) || item.type === "dictation" || item.type === "read-aloud") return false;
|
|
4049
|
+
return isRecording(item.media) && transcriptOf(item.media) === null || listOf(item.options).some((option) => isRecord(option) && isRecording(option.media) && transcriptOf(option.media) === null);
|
|
4050
|
+
};
|
|
4051
|
+
if (entry.type !== "item-group") return itemUnheard(entry);
|
|
4052
|
+
const stimulus = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
4053
|
+
return (stimulus.kind === "audio" || stimulus.kind === "video" || isRecording(stimulus.media)) && transcriptOf(stimulus) === null || listOf(entry.items).some(itemUnheard);
|
|
4054
|
+
}
|
|
4055
|
+
function skipOf(entry, exemplarIndex, imagePolicy) {
|
|
4056
|
+
const skip = (code, message) => ({
|
|
4057
|
+
exemplarIndex,
|
|
4058
|
+
code,
|
|
4059
|
+
message
|
|
4060
|
+
});
|
|
4061
|
+
const group = entry.type === "item-group";
|
|
4062
|
+
if (group && entry.timeline !== void 0 && entry.timeline !== null) return skip("variant_exemplar_timeline", "An interactive video is not varied: its questions are timed to its video.");
|
|
4063
|
+
const unsupported = (group ? listOf(entry.items).map((item) => isRecord(item) ? stringOf(item.type) : "") : [stringOf(entry.type)]).find((type) => getActivityTypeDescriptor(type) !== void 0 && !VARIANT_TYPES.includes(type));
|
|
4064
|
+
if (unsupported !== void 0) return skip("variant_exemplar_unsupported", `The SDK does not write variants of ${unsupported}: it does not know which of its fields are words and which are settings.`);
|
|
4065
|
+
if (imagePolicy === "reuse" && imageSlots(entry).some((slot) => written(slot.media.alt) === void 0)) return skip("variant_exemplar_unseen", "A picture this depends on has no description (alt), so a model cannot write questions about it.");
|
|
4066
|
+
const validation = group ? validateItemGroupDraft(entry) : validateDraft(entry.type, entry);
|
|
4067
|
+
if (validation.status !== "complete") {
|
|
4068
|
+
const first = validation.issues[0];
|
|
4069
|
+
return skip("variant_exemplar_incomplete", `This is not a finished item (${first !== void 0 ? `${first.code} at ${first.path.join(".") || "its root"}` : validation.status}), so it has no structure to copy.`);
|
|
4070
|
+
}
|
|
4071
|
+
if (unheard(entry)) return skip("variant_exemplar_unheard", "A recording this depends on has no transcript or script, so a model cannot know what it says.");
|
|
4072
|
+
const long = textsOf(entry).find((text) => text.text.length > AI_VARIANTS_MAX_MATERIAL_LENGTH);
|
|
4073
|
+
if (long !== void 0) return skip("variant_exemplar_too_long", `${long.path.join(".")} is longer than ${AI_VARIANTS_MAX_MATERIAL_LENGTH} characters. Send a shorter part.`);
|
|
4074
|
+
}
|
|
4075
|
+
const text = (description) => ({
|
|
4076
|
+
type: "string",
|
|
4077
|
+
description
|
|
4078
|
+
});
|
|
4079
|
+
const SCRIPT_SHAPE = {
|
|
4080
|
+
type: "array",
|
|
4081
|
+
minItems: 1,
|
|
4082
|
+
description: "The recording as turns, in order: a new name for each speaker, as many speakers as the exemplar.",
|
|
4083
|
+
items: {
|
|
4084
|
+
type: "object",
|
|
4085
|
+
required: ["speaker", "text"],
|
|
4086
|
+
properties: {
|
|
4087
|
+
speaker: text("Who speaks."),
|
|
4088
|
+
text: text("What they say.")
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
};
|
|
4092
|
+
const MEDIA_SHAPE = {
|
|
4093
|
+
type: "object",
|
|
4094
|
+
description: "Only where the exemplar's content has media: for a picture, its alt; for a recording, its script — or transcript where the exemplar has no script.",
|
|
4095
|
+
properties: {
|
|
4096
|
+
alt: text("What the picture shows."),
|
|
4097
|
+
transcript: text("What the recording says."),
|
|
4098
|
+
script: SCRIPT_SHAPE
|
|
4099
|
+
}
|
|
4100
|
+
};
|
|
4101
|
+
const FEEDBACK_SHAPE = {
|
|
4102
|
+
type: "object",
|
|
4103
|
+
description: "Only where the exemplar has feedback: shown after answering.",
|
|
4104
|
+
properties: {
|
|
4105
|
+
correct: text("Shown when the learner passes."),
|
|
4106
|
+
incorrect: text("Shown when the learner does not.")
|
|
4107
|
+
}
|
|
4108
|
+
};
|
|
4109
|
+
const FIELD_SHAPES = {
|
|
4110
|
+
title: text("A short name for the item, shown above it. Never the answer, or any part of it."),
|
|
4111
|
+
instructions: text("Only where the exemplar has instructions: the same task, worded for the new item."),
|
|
4112
|
+
question: text("The question, as plain text."),
|
|
4113
|
+
options: {
|
|
4114
|
+
type: "array",
|
|
4115
|
+
description: "As many as the exemplar's, as many of them right.",
|
|
4116
|
+
items: {
|
|
4117
|
+
type: "object",
|
|
4118
|
+
required: ["text", "isCorrect"],
|
|
4119
|
+
properties: {
|
|
4120
|
+
text: text("The option as the learner reads it."),
|
|
4121
|
+
isCorrect: { type: "boolean" },
|
|
4122
|
+
feedback: text("Only where the exemplar's options have feedback."),
|
|
4123
|
+
media: MEDIA_SHAPE
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
},
|
|
4127
|
+
passage: text("The passage as plain text, with {{1}}, {{2}}, … where each gap goes, in order."),
|
|
4128
|
+
blanks: {
|
|
4129
|
+
type: "array",
|
|
4130
|
+
description: "One per gap, in the order the gaps appear: the first is {{1}}.",
|
|
4131
|
+
items: {
|
|
4132
|
+
type: "object",
|
|
4133
|
+
required: ["acceptedAnswers"],
|
|
4134
|
+
properties: {
|
|
4135
|
+
acceptedAnswers: {
|
|
4136
|
+
type: "array",
|
|
4137
|
+
minItems: 1,
|
|
4138
|
+
items: { type: "string" },
|
|
4139
|
+
description: "Every answer that is right, spelled as a learner would type it."
|
|
4140
|
+
},
|
|
4141
|
+
hint: text("Only where the exemplar's blank has a hint. Never gives the answer away."),
|
|
4142
|
+
feedback: text("Only where the exemplar's blank has feedback.")
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
},
|
|
4146
|
+
gaps: {
|
|
4147
|
+
type: "array",
|
|
4148
|
+
description: "One per gap, in the order the gaps appear. A gap with its own choices writes `choices`, exactly one right; a gap drawing on a bank writes `answer`, the text of its right choice in that bank.",
|
|
4149
|
+
items: {
|
|
4150
|
+
type: "object",
|
|
4151
|
+
properties: {
|
|
4152
|
+
choices: {
|
|
4153
|
+
type: "array",
|
|
4154
|
+
items: {
|
|
4155
|
+
type: "object",
|
|
4156
|
+
required: ["text", "isCorrect"],
|
|
4157
|
+
properties: {
|
|
4158
|
+
text: text("A choice."),
|
|
4159
|
+
isCorrect: { type: "boolean" }
|
|
4160
|
+
}
|
|
4161
|
+
}
|
|
4162
|
+
},
|
|
4163
|
+
answer: text("The right choice's text, exactly as its bank writes it."),
|
|
4164
|
+
feedback: text("Only where the exemplar's gap has feedback.")
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
},
|
|
4168
|
+
banks: {
|
|
4169
|
+
type: "array",
|
|
4170
|
+
description: "The shared choice lists, as many as the exemplar's, each with as many choices.",
|
|
4171
|
+
items: {
|
|
4172
|
+
type: "object",
|
|
4173
|
+
required: ["choices"],
|
|
4174
|
+
properties: { choices: {
|
|
4175
|
+
type: "array",
|
|
4176
|
+
items: { type: "string" }
|
|
4177
|
+
} }
|
|
4178
|
+
}
|
|
4179
|
+
},
|
|
4180
|
+
transcript: text("What the learner hears and types."),
|
|
4181
|
+
acceptedTranscripts: {
|
|
4182
|
+
type: "array",
|
|
4183
|
+
items: { type: "string" },
|
|
4184
|
+
description: "Only where the exemplar has them: other writings of the transcript that are right, of the same kind."
|
|
4185
|
+
},
|
|
4186
|
+
referenceText: text("What the learner reads aloud."),
|
|
4187
|
+
prompt: text("The writing task, of the same genre and purpose as the exemplar's, about something new."),
|
|
4188
|
+
media: MEDIA_SHAPE,
|
|
4189
|
+
feedback: FEEDBACK_SHAPE
|
|
4190
|
+
};
|
|
4191
|
+
const WRITES = {
|
|
4192
|
+
"multiple-choice": [
|
|
4193
|
+
"title",
|
|
4194
|
+
"instructions",
|
|
4195
|
+
"question",
|
|
4196
|
+
"options",
|
|
4197
|
+
"media",
|
|
4198
|
+
"feedback"
|
|
4199
|
+
],
|
|
4200
|
+
"fill-in-the-blanks": [
|
|
4201
|
+
"title",
|
|
4202
|
+
"instructions",
|
|
4203
|
+
"passage",
|
|
4204
|
+
"blanks",
|
|
4205
|
+
"media",
|
|
4206
|
+
"feedback"
|
|
4207
|
+
],
|
|
4208
|
+
"gap-select": [
|
|
4209
|
+
"title",
|
|
4210
|
+
"instructions",
|
|
4211
|
+
"passage",
|
|
4212
|
+
"gaps",
|
|
4213
|
+
"banks",
|
|
4214
|
+
"media",
|
|
4215
|
+
"feedback"
|
|
4216
|
+
],
|
|
4217
|
+
dictation: [
|
|
4218
|
+
"title",
|
|
4219
|
+
"transcript",
|
|
4220
|
+
"acceptedTranscripts",
|
|
4221
|
+
"feedback"
|
|
4222
|
+
],
|
|
4223
|
+
"read-aloud": [
|
|
4224
|
+
"title",
|
|
4225
|
+
"instructions",
|
|
4226
|
+
"referenceText",
|
|
4227
|
+
"feedback"
|
|
4228
|
+
],
|
|
4229
|
+
"written-response": [
|
|
4230
|
+
"title",
|
|
4231
|
+
"prompt",
|
|
4232
|
+
"media",
|
|
4233
|
+
"feedback"
|
|
4234
|
+
]
|
|
4235
|
+
};
|
|
4236
|
+
function itemProperties(types) {
|
|
4237
|
+
const properties = {};
|
|
4238
|
+
for (const type of types) for (const name of WRITES[type] ?? []) properties[name] = FIELD_SHAPES[name];
|
|
4239
|
+
return properties;
|
|
4240
|
+
}
|
|
4241
|
+
/** The types an entry holds, a group's items included. */
|
|
4242
|
+
const typesIn = (entry) => entry.type === "item-group" ? listOf(entry.items).map((item) => isRecord(item) ? stringOf(item.type) : "") : [stringOf(entry.type)];
|
|
4243
|
+
function replyShape(entries, exactly) {
|
|
4244
|
+
const top = [...new Set(entries.map((entry) => stringOf(entry.type)))];
|
|
4245
|
+
const itemTypes = [...new Set(entries.filter((entry) => entry.type === "item-group").flatMap(typesIn))].filter((type) => VARIANT_TYPES.includes(type));
|
|
4246
|
+
const properties = {
|
|
4247
|
+
exemplar: {
|
|
4248
|
+
type: "integer",
|
|
4249
|
+
minimum: 0,
|
|
4250
|
+
description: "The exemplar's index, as the facts give it."
|
|
4251
|
+
},
|
|
4252
|
+
type: {
|
|
4253
|
+
type: "string",
|
|
4254
|
+
enum: top,
|
|
4255
|
+
description: "The exemplar's type."
|
|
4256
|
+
},
|
|
4257
|
+
...itemProperties(top.filter((type) => type !== "item-group"))
|
|
4258
|
+
};
|
|
4259
|
+
if (top.includes("item-group")) {
|
|
4260
|
+
properties.title = FIELD_SHAPES.title;
|
|
4261
|
+
properties.stimulus = {
|
|
4262
|
+
type: "object",
|
|
4263
|
+
description: "The new material: a passage, or what a recording says.",
|
|
4264
|
+
properties: {
|
|
4265
|
+
title: text("Only where the exemplar's material has a title."),
|
|
4266
|
+
body: text("The passage, as plain text."),
|
|
4267
|
+
transcript: text("What the recording says, where the exemplar has no script."),
|
|
4268
|
+
script: SCRIPT_SHAPE,
|
|
4269
|
+
alt: text("For a picture: what it shows.")
|
|
4270
|
+
}
|
|
4271
|
+
};
|
|
4272
|
+
properties.items = {
|
|
4273
|
+
type: "array",
|
|
4274
|
+
description: "The questions about the new material: the exemplar's types, in its order.",
|
|
4275
|
+
items: {
|
|
4276
|
+
type: "object",
|
|
4277
|
+
required: ["type"],
|
|
4278
|
+
properties: {
|
|
4279
|
+
type: {
|
|
4280
|
+
type: "string",
|
|
4281
|
+
enum: itemTypes
|
|
4282
|
+
},
|
|
4283
|
+
...itemProperties(itemTypes)
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
};
|
|
4287
|
+
}
|
|
4288
|
+
return {
|
|
4289
|
+
type: "object",
|
|
4290
|
+
required: ["variants"],
|
|
4291
|
+
properties: { variants: {
|
|
4292
|
+
type: "array",
|
|
4293
|
+
minItems: exactly,
|
|
4294
|
+
maxItems: exactly,
|
|
4295
|
+
items: {
|
|
4296
|
+
type: "object",
|
|
4297
|
+
required: ["exemplar", "type"],
|
|
4298
|
+
properties
|
|
4299
|
+
}
|
|
4300
|
+
} }
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4303
|
+
const facing = (path) => path.join(".").replace(/^stimulus\.media\./, "stimulus.");
|
|
4304
|
+
function targetsOf(entry, difficulty, reason, itemDifficulty, tolerance) {
|
|
4305
|
+
const group = entry.type === "item-group";
|
|
4306
|
+
const levers = leversFor(entry, difficulty);
|
|
4307
|
+
const findings = group ? critiqueItemGroupDraft(entry) : critiqueDraft(entry.type, entry);
|
|
4308
|
+
return {
|
|
4309
|
+
type: stringOf(entry.type),
|
|
4310
|
+
structure: structureTargets(entry),
|
|
4311
|
+
lengths: Object.fromEntries(lengthTargets(entry, tolerance).map((target) => [facing(target.path), target.bounds])),
|
|
4312
|
+
difficulty,
|
|
4313
|
+
...difficulty !== "same" && reason !== void 0 ? { difficultyReason: reason } : {},
|
|
4314
|
+
...levers !== void 0 ? { levers } : {},
|
|
4315
|
+
...itemDifficulty !== void 0 ? { itemDifficulty } : {},
|
|
4316
|
+
exemplarFindings: findings.map((found) => ({
|
|
4317
|
+
code: found.code,
|
|
4318
|
+
message: found.message
|
|
4319
|
+
}))
|
|
4320
|
+
};
|
|
4321
|
+
}
|
|
4322
|
+
const idOf = (value) => isRecord(value) && typeof value.id === "string" ? value.id : void 0;
|
|
4323
|
+
/**
|
|
4324
|
+
* What a model is given to write a variant of each exemplar — a parallel item:
|
|
4325
|
+
* the exemplar's type, structure, scoring and settings, with every word a
|
|
4326
|
+
* learner reads or hears new — and what `checkAiVariants` needs to check the
|
|
4327
|
+
* reply against.
|
|
4328
|
+
*
|
|
4329
|
+
* `exemplars` are loose activities and item groups as stored, answer keys and
|
|
4330
|
+
* author-only transcripts included, in paper order; one variant is written for
|
|
4331
|
+
* each that can be varied. An exemplar that cannot is left out of the facts
|
|
4332
|
+
* and listed in `skipped`: a recording with nothing to say what it says
|
|
4333
|
+
* (`variant_exemplar_unheard`), a picture with no description to reuse
|
|
4334
|
+
* (`variant_exemplar_unseen`), an unfinished item
|
|
4335
|
+
* (`variant_exemplar_incomplete`), an interactive video
|
|
4336
|
+
* (`variant_exemplar_timeline`), a type the SDK does not vary
|
|
4337
|
+
* (`variant_exemplar_unsupported`) or material over
|
|
4338
|
+
* {@link AI_VARIANTS_MAX_MATERIAL_LENGTH} characters
|
|
4339
|
+
* (`variant_exemplar_too_long`). When every exemplar is skipped the request has
|
|
4340
|
+
* none, and `generateVariants` makes no call.
|
|
4341
|
+
*
|
|
4342
|
+
* The facts give each exemplar's content in the shape a variant is written in,
|
|
4343
|
+
* and its `targets`: the structure to keep, the length of each text in words,
|
|
4344
|
+
* its difficulty with the levers to move it, and the critic's findings on it
|
|
4345
|
+
* not to repeat. `rules` are what every variant must respect, as sentences a
|
|
4346
|
+
* prompt can quote. Pictures are listed in `images`, for a host that attaches
|
|
4347
|
+
* them; no recording's address reaches the facts. `avoid` is the other forms'
|
|
4348
|
+
* items: compared with every variant, never varied.
|
|
4349
|
+
*
|
|
4350
|
+
* Nothing passed is changed.
|
|
4351
|
+
*
|
|
4352
|
+
* @throws RangeError when `exemplars` is empty or longer than
|
|
4353
|
+
* {@link AI_VARIANTS_MAX_EXEMPLARS}, an exemplar is neither an item group nor
|
|
4354
|
+
* a registered activity type, `level` is empty, a difficulty is not `easier`,
|
|
4355
|
+
* `same` or `harder`, `images` is neither `describe` nor `reuse`, or
|
|
4356
|
+
* `difficultyFor` names an id no exemplar has.
|
|
4357
|
+
*/
|
|
4358
|
+
function aiVariantsRequest(input) {
|
|
4359
|
+
const { exemplars, level, locale, levelFacts, avoid, instructions, settings } = input;
|
|
4360
|
+
const difficulty = input.difficulty ?? "same";
|
|
4361
|
+
const imagePolicy = input.images ?? "describe";
|
|
4362
|
+
if (!Array.isArray(exemplars) || exemplars.length === 0 || exemplars.length > 20) throw new RangeError(`aiVariantsRequest: exemplars must hold 1 to 20 entries; send a longer section in parts.`);
|
|
4363
|
+
if (typeof level !== "string" || level.trim() === "") throw new RangeError("aiVariantsRequest: level is required: a variant must stay inside it.");
|
|
4364
|
+
if (!DIFFICULTIES.includes(difficulty)) throw new RangeError(`aiVariantsRequest: difficulty must be easier, same or harder, not ${String(difficulty)}.`);
|
|
4365
|
+
if (imagePolicy !== "describe" && imagePolicy !== "reuse") throw new RangeError(`aiVariantsRequest: images must be describe or reuse, not ${String(imagePolicy)}.`);
|
|
4366
|
+
exemplars.forEach((entry, index) => {
|
|
4367
|
+
const type = isRecord(entry) ? entry.type : void 0;
|
|
4368
|
+
if (typeof type !== "string" || type !== "item-group" && getActivityTypeDescriptor(type) === void 0) throw new RangeError(`aiVariantsRequest: exemplar ${index} is neither an item group nor a registered activity type.`);
|
|
4369
|
+
});
|
|
4370
|
+
const entries = exemplars;
|
|
4371
|
+
const ids = new Set(entries.flatMap((entry) => [idOf(entry), ...listOf(entry.items).map(idOf)]).filter((id) => id !== void 0));
|
|
4372
|
+
for (const [id, wanted] of Object.entries(input.difficultyFor ?? {})) {
|
|
4373
|
+
if (!ids.has(id)) throw new RangeError(`aiVariantsRequest: difficultyFor names "${id}", which no exemplar has.`);
|
|
4374
|
+
if (!isRecord(wanted) || !DIFFICULTIES.includes(wanted.difficulty)) throw new RangeError(`aiVariantsRequest: difficultyFor["${id}"] must be easier, same or harder.`);
|
|
4375
|
+
}
|
|
4376
|
+
const moved = (id) => id !== void 0 ? input.difficultyFor?.[id] : void 0;
|
|
4377
|
+
const skipped = [];
|
|
4378
|
+
const images = [];
|
|
4379
|
+
const facts = [];
|
|
4380
|
+
entries.forEach((entry, exemplarIndex) => {
|
|
4381
|
+
const skip = skipOf(entry, exemplarIndex, imagePolicy);
|
|
4382
|
+
if (skip !== void 0) {
|
|
4383
|
+
skipped.push(skip);
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
const refs = /* @__PURE__ */ new Map();
|
|
4387
|
+
for (const slot of imageSlots(entry)) {
|
|
4388
|
+
const ref = `img-${images.length + 1}`;
|
|
4389
|
+
refs.set(slot.path.join("."), ref);
|
|
4390
|
+
images.push({
|
|
4391
|
+
ref,
|
|
4392
|
+
exemplarIndex,
|
|
4393
|
+
path: slot.path,
|
|
4394
|
+
...typeof slot.media.alt === "string" ? { alt: slot.media.alt } : {},
|
|
4395
|
+
url: stringOf(slot.media.url)
|
|
4396
|
+
});
|
|
4397
|
+
}
|
|
4398
|
+
const own = moved(idOf(entry));
|
|
4399
|
+
const entryDifficulty = own?.difficulty ?? difficulty;
|
|
4400
|
+
const items = entry.type === "item-group" ? listOf(entry.items) : [];
|
|
4401
|
+
const perItem = items.map((item, at) => {
|
|
4402
|
+
const wanted = moved(idOf(item));
|
|
4403
|
+
return {
|
|
4404
|
+
item: at,
|
|
4405
|
+
difficulty: wanted?.difficulty ?? entryDifficulty,
|
|
4406
|
+
...wanted?.reason !== void 0 ? { difficultyReason: wanted.reason } : {}
|
|
4407
|
+
};
|
|
4408
|
+
});
|
|
4409
|
+
const itemDifficulty = perItem.some((one) => one.difficulty !== entryDifficulty || one.difficultyReason !== void 0) ? perItem.map((one) => {
|
|
4410
|
+
const levers = leversFor(items[one.item], one.difficulty);
|
|
4411
|
+
return {
|
|
4412
|
+
...one,
|
|
4413
|
+
...levers !== void 0 ? { levers } : {}
|
|
4414
|
+
};
|
|
4415
|
+
}) : void 0;
|
|
4416
|
+
facts.push({
|
|
4417
|
+
exemplar: exemplarIndex,
|
|
4418
|
+
type: stringOf(entry.type),
|
|
4419
|
+
content: entryContent(entry, (path) => refs.get(path.map(String).join("."))),
|
|
4420
|
+
...Array.isArray(entry.learningObjectives) ? { learningObjectives: textList(entry.learningObjectives) } : {},
|
|
4421
|
+
targets: targetsOf(entry, entryDifficulty, own?.reason, itemDifficulty, input.lengthTolerance)
|
|
4422
|
+
});
|
|
4423
|
+
});
|
|
4424
|
+
const rules = [
|
|
4425
|
+
...RULES,
|
|
4426
|
+
...images.length > 0 ? [imagePolicy === "describe" ? DESCRIBE_RULE : REUSE_RULE] : [],
|
|
4427
|
+
...facts.some((one) => one.targets.difficulty !== "same" || (one.targets.itemDifficulty ?? []).some((item) => item.difficulty !== "same")) ? [DIFFICULTY_RULE] : [],
|
|
4428
|
+
...levelFacts !== void 0 ? [LEVEL_FACTS_RULE] : []
|
|
4429
|
+
];
|
|
4430
|
+
const varied = facts.map((one) => entries[one.exemplar]);
|
|
4431
|
+
return {
|
|
4432
|
+
feature: "variant-generation",
|
|
4433
|
+
facts: {
|
|
4434
|
+
exemplars: facts,
|
|
4435
|
+
level,
|
|
4436
|
+
...locale !== void 0 ? { locale } : typeof entries[0]?.locale === "string" ? { locale: entries[0].locale } : {},
|
|
4437
|
+
difficulty,
|
|
4438
|
+
...levelFacts !== void 0 ? { levelFacts: clone(levelFacts) } : {},
|
|
4439
|
+
imagePolicy,
|
|
4440
|
+
...images.length > 0 ? { images } : {},
|
|
4441
|
+
...avoid !== void 0 && avoid.length > 0 ? { avoid: avoid.map((entry) => ({
|
|
4442
|
+
texts: textsOf(entry).map((one) => one.text),
|
|
4443
|
+
keys: (isRecord(entry) && entry.type === "item-group" ? listOf(entry.items) : [entry]).flatMap((item) => keysOf(item).map((slot) => slot.shown))
|
|
4444
|
+
})) } : {},
|
|
4445
|
+
...instructions !== void 0 ? { instructions } : {},
|
|
4446
|
+
rules
|
|
4447
|
+
},
|
|
4448
|
+
shape: replyShape(varied, varied.length),
|
|
4449
|
+
exemplars,
|
|
4450
|
+
...avoid !== void 0 ? { avoid } : {},
|
|
4451
|
+
...settings !== void 0 ? { settings } : {},
|
|
4452
|
+
...input.lengthTolerance !== void 0 ? { lengthTolerance: { ...input.lengthTolerance } } : {},
|
|
4453
|
+
...input.balanceKeyPositions !== void 0 ? { balanceKeyPositions: { seed: input.balanceKeyPositions.seed } } : {},
|
|
4454
|
+
skipped
|
|
4455
|
+
};
|
|
4456
|
+
}
|
|
4457
|
+
/** Fields every activity copies from its exemplar: what scores it, and what it tests. */
|
|
4458
|
+
const COPIED_COMMON = [
|
|
4459
|
+
"scoringStrategy",
|
|
4460
|
+
"shuffle",
|
|
4461
|
+
"passThreshold",
|
|
4462
|
+
"locale",
|
|
4463
|
+
"ai",
|
|
4464
|
+
"learningObjectives",
|
|
4465
|
+
"slotKey"
|
|
4466
|
+
];
|
|
4467
|
+
const COPIED = {
|
|
4468
|
+
"multiple-choice": ["mode"],
|
|
4469
|
+
"fill-in-the-blanks": [],
|
|
4470
|
+
"gap-select": ["presentation", "shuffleChoices"],
|
|
4471
|
+
dictation: ["tolerance", "hints"],
|
|
4472
|
+
"read-aloud": ["recording", "scoring"],
|
|
4473
|
+
"written-response": [
|
|
4474
|
+
"rubric",
|
|
4475
|
+
"minWords",
|
|
4476
|
+
"maxWords",
|
|
4477
|
+
"languageTarget"
|
|
4478
|
+
]
|
|
4479
|
+
};
|
|
4480
|
+
function pick(from, names) {
|
|
4481
|
+
const out = {};
|
|
4482
|
+
for (const name of names) if (from[name] !== void 0) out[name] = clone(from[name]);
|
|
4483
|
+
return out;
|
|
4484
|
+
}
|
|
4485
|
+
/** The author's 1–5 difficulty, one step along with the variant's, inside the scale. */
|
|
4486
|
+
function steppedLevel(level, difficulty) {
|
|
4487
|
+
if (typeof level !== "number") return {};
|
|
4488
|
+
return { difficultyLevel: Math.min(5, Math.max(1, level + (difficulty === "easier" ? -1 : difficulty === "harder" ? 1 : 0))) };
|
|
4489
|
+
}
|
|
4490
|
+
const scriptFrom = (value) => listOf(value).flatMap((turn) => isRecord(turn) ? [{
|
|
4491
|
+
speaker: stringOf(turn.speaker),
|
|
4492
|
+
text: stringOf(turn.text)
|
|
4493
|
+
}] : []);
|
|
4494
|
+
/**
|
|
4495
|
+
* A variant's media in place of the exemplar's: a picture described anew, or
|
|
4496
|
+
* kept with `reuse`; a recording with no file yet — the host records it — its
|
|
4497
|
+
* playback policy, and the words the model wrote for it. An address, captions,
|
|
4498
|
+
* tracks and a poster belong to the old recording and are not copied.
|
|
4499
|
+
*/
|
|
4500
|
+
function variantMedia(exemplar, wrote, ownText, imagePolicy) {
|
|
4501
|
+
const model = isRecord(wrote) ? wrote : {};
|
|
4502
|
+
if (isImage(exemplar)) return imagePolicy === "reuse" ? pick(exemplar, [
|
|
4503
|
+
"type",
|
|
4504
|
+
"url",
|
|
4505
|
+
"alt"
|
|
4506
|
+
]) : {
|
|
4507
|
+
type: "image",
|
|
4508
|
+
url: null,
|
|
4509
|
+
alt: stringOf(model.alt)
|
|
4510
|
+
};
|
|
4511
|
+
const media = {
|
|
4512
|
+
type: exemplar.type,
|
|
4513
|
+
url: null,
|
|
4514
|
+
...pick(exemplar, ["playback"])
|
|
4515
|
+
};
|
|
4516
|
+
if (!ownText) {
|
|
4517
|
+
if (hasScript(exemplar)) media.script = scriptFrom(model.script);
|
|
4518
|
+
else if (typeof exemplar.transcript === "string") media.transcript = stringOf(model.transcript);
|
|
4519
|
+
}
|
|
4520
|
+
return media;
|
|
4521
|
+
}
|
|
4522
|
+
/** The feedback the model wrote, on the keys the exemplar's feedback has. */
|
|
4523
|
+
function feedbackFrom(exemplar, wrote) {
|
|
4524
|
+
if (!isRecord(exemplar.feedback) || !isRecord(wrote.feedback)) return {};
|
|
4525
|
+
const out = {};
|
|
4526
|
+
for (const name of ["correct", "incorrect"]) {
|
|
4527
|
+
const said = written(wrote.feedback[name]);
|
|
4528
|
+
if (exemplar.feedback[name] !== void 0 && said !== void 0) out[name] = said;
|
|
4529
|
+
}
|
|
4530
|
+
return Object.keys(out).length > 0 ? { feedback: out } : {};
|
|
4531
|
+
}
|
|
4532
|
+
function difficultyFor(request, exemplarIndex, item) {
|
|
4533
|
+
const targets = request.facts.exemplars.find((one) => one.exemplar === exemplarIndex)?.targets;
|
|
4534
|
+
return (item !== void 0 ? targets?.itemDifficulty?.find((one) => one.item === item) : void 0)?.difficulty ?? targets?.difficulty ?? request.facts.difficulty;
|
|
4535
|
+
}
|
|
4536
|
+
function buildActivity(exemplar, wrote, type, build, difficulty, id) {
|
|
4537
|
+
const { request, ids } = build;
|
|
4538
|
+
const imagePolicy = request.facts.imagePolicy;
|
|
4539
|
+
const alike = exemplar.type === type;
|
|
4540
|
+
const content = { title: stringOf(wrote.title) };
|
|
4541
|
+
if (typeof exemplar.instructions === "string") content.instructions = written(wrote.instructions) ?? exemplar.instructions;
|
|
4542
|
+
switch (type) {
|
|
4543
|
+
case "multiple-choice": {
|
|
4544
|
+
const before = listOf(exemplar.options);
|
|
4545
|
+
const withFeedback = before.some((option) => isRecord(option) && option.feedback !== void 0);
|
|
4546
|
+
content.question = stringOf(wrote.question);
|
|
4547
|
+
content.options = listOf(wrote.options).map((option, index) => {
|
|
4548
|
+
const fields = isRecord(option) ? option : {};
|
|
4549
|
+
const was = before[index];
|
|
4550
|
+
const feedback = withFeedback ? written(fields.feedback) : void 0;
|
|
4551
|
+
return {
|
|
4552
|
+
id: ids(),
|
|
4553
|
+
text: stringOf(fields.text),
|
|
4554
|
+
isCorrect: fields.isCorrect === true,
|
|
4555
|
+
...feedback !== void 0 ? { feedback } : {},
|
|
4556
|
+
...isRecord(was) && isRecord(was.media) ? { media: variantMedia(was.media, fields.media, false, imagePolicy) } : {}
|
|
4557
|
+
};
|
|
4558
|
+
});
|
|
4559
|
+
break;
|
|
4560
|
+
}
|
|
4561
|
+
case "fill-in-the-blanks": {
|
|
4562
|
+
const before = alike ? inPassageOrder(exemplar, "blanks").map((one) => one.value) : [];
|
|
4563
|
+
content.passage = stringOf(wrote.passage);
|
|
4564
|
+
content.blanks = listOf(wrote.blanks).map((blank, index) => {
|
|
4565
|
+
const fields = isRecord(blank) ? blank : {};
|
|
4566
|
+
const was = isRecord(before[index]) ? before[index] : {};
|
|
4567
|
+
const hint = was.hint !== void 0 ? written(fields.hint) : void 0;
|
|
4568
|
+
const feedback = was.feedback !== void 0 ? written(fields.feedback) : void 0;
|
|
4569
|
+
return {
|
|
4570
|
+
id: String(index + 1),
|
|
4571
|
+
acceptedAnswers: textList(fields.acceptedAnswers),
|
|
4572
|
+
...hint !== void 0 ? { hint } : {},
|
|
4573
|
+
...feedback !== void 0 ? { feedback } : {},
|
|
4574
|
+
...pick(was, [
|
|
4575
|
+
"match",
|
|
4576
|
+
"caseSensitive",
|
|
4577
|
+
"trimWhitespace"
|
|
4578
|
+
])
|
|
4579
|
+
};
|
|
4580
|
+
});
|
|
4581
|
+
break;
|
|
4582
|
+
}
|
|
4583
|
+
case "gap-select": {
|
|
4584
|
+
const beforeGaps = alike ? inPassageOrder(exemplar, "gaps").map((one) => one.value) : [];
|
|
4585
|
+
const beforeBanks = alike ? listOf(exemplar.banks) : [];
|
|
4586
|
+
const banks = beforeBanks.length ? listOf(wrote.banks).map((bank) => ({
|
|
4587
|
+
id: ids(),
|
|
4588
|
+
choices: listOf(isRecord(bank) ? bank.choices : void 0).map((choice) => ({
|
|
4589
|
+
id: ids(),
|
|
4590
|
+
text: typeof choice === "string" ? choice : isRecord(choice) ? stringOf(choice.text) : ""
|
|
4591
|
+
}))
|
|
4592
|
+
})) : [];
|
|
4593
|
+
content.passage = stringOf(wrote.passage);
|
|
4594
|
+
content.gaps = listOf(wrote.gaps).map((gap, index) => {
|
|
4595
|
+
const fields = isRecord(gap) ? gap : {};
|
|
4596
|
+
const was = isRecord(beforeGaps[index]) ? beforeGaps[index] : {};
|
|
4597
|
+
const feedback = was.feedback !== void 0 ? written(fields.feedback) : void 0;
|
|
4598
|
+
const drawsOn = listOf(was.choices).length === 0 ? beforeBanks.findIndex((bank) => isRecord(bank) && bank.id === was.bankId) : -1;
|
|
4599
|
+
const bank = drawsOn !== -1 ? banks[drawsOn] : void 0;
|
|
4600
|
+
if (bank !== void 0) {
|
|
4601
|
+
const answer = stringOf(fields.answer).trim().toLowerCase();
|
|
4602
|
+
const right = bank.choices.find((choice) => choice.text.trim().toLowerCase() === answer);
|
|
4603
|
+
return {
|
|
4604
|
+
id: String(index + 1),
|
|
4605
|
+
bankId: bank.id,
|
|
4606
|
+
correctChoiceId: answer !== "" && right !== void 0 ? right.id : "",
|
|
4607
|
+
...feedback !== void 0 ? { feedback } : {}
|
|
4608
|
+
};
|
|
4609
|
+
}
|
|
4610
|
+
const marked = listOf(fields.choices);
|
|
4611
|
+
const choices = marked.map((choice) => ({
|
|
4612
|
+
id: ids(),
|
|
4613
|
+
text: isRecord(choice) ? stringOf(choice.text) : ""
|
|
4614
|
+
}));
|
|
4615
|
+
const right = marked.flatMap((choice, at) => isRecord(choice) && choice.isCorrect === true ? [at] : []);
|
|
4616
|
+
return {
|
|
4617
|
+
id: String(index + 1),
|
|
4618
|
+
choices,
|
|
4619
|
+
correctChoiceId: right.length === 1 ? choices[right[0]]?.id : "",
|
|
4620
|
+
...feedback !== void 0 ? { feedback } : {}
|
|
4621
|
+
};
|
|
4622
|
+
});
|
|
4623
|
+
if (banks.length > 0) content.banks = banks;
|
|
4624
|
+
break;
|
|
4625
|
+
}
|
|
4626
|
+
case "dictation":
|
|
4627
|
+
content.transcript = stringOf(wrote.transcript);
|
|
4628
|
+
if (Array.isArray(exemplar.acceptedTranscripts) && Array.isArray(wrote.acceptedTranscripts)) {
|
|
4629
|
+
const accepted = textList(wrote.acceptedTranscripts).filter((one) => one.trim() !== "");
|
|
4630
|
+
if (accepted.length > 0) content.acceptedTranscripts = accepted;
|
|
4631
|
+
}
|
|
4632
|
+
break;
|
|
4633
|
+
case "read-aloud":
|
|
4634
|
+
content.referenceText = stringOf(wrote.referenceText);
|
|
4635
|
+
break;
|
|
4636
|
+
default: content.prompt = stringOf(wrote.prompt);
|
|
4637
|
+
}
|
|
4638
|
+
const ownText = type === "dictation" || type === "read-aloud";
|
|
4639
|
+
return {
|
|
4640
|
+
...pick(exemplar, alike ? [...COPIED_COMMON, ...COPIED[type] ?? []] : COPIED_COMMON),
|
|
4641
|
+
...steppedLevel(exemplar.difficultyLevel, difficulty),
|
|
4642
|
+
...request.settings?.[type],
|
|
4643
|
+
...content,
|
|
4644
|
+
...isRecord(exemplar.media) ? { media: variantMedia(exemplar.media, wrote.media, ownText, imagePolicy) } : {},
|
|
4645
|
+
...ownText && isRecord(exemplar.slowMedia) ? { slowMedia: {
|
|
4646
|
+
type: "audio",
|
|
4647
|
+
url: null
|
|
4648
|
+
} } : {},
|
|
4649
|
+
...feedbackFrom(exemplar, wrote),
|
|
4650
|
+
schemaVersion: typeof exemplar.schemaVersion === "string" ? exemplar.schemaVersion : "1.0",
|
|
4651
|
+
type,
|
|
4652
|
+
id
|
|
4653
|
+
};
|
|
4654
|
+
}
|
|
4655
|
+
function buildStimulus(exemplar, wrote, build) {
|
|
4656
|
+
const model = isRecord(wrote) ? wrote : {};
|
|
4657
|
+
const ownText = hasScript(exemplar) || typeof exemplar.transcript === "string";
|
|
4658
|
+
const title = typeof exemplar.title === "string" ? written(model.title) : void 0;
|
|
4659
|
+
return {
|
|
4660
|
+
id: build.ids(),
|
|
4661
|
+
kind: exemplar.kind,
|
|
4662
|
+
...title !== void 0 ? { title } : {},
|
|
4663
|
+
...typeof exemplar.body === "string" ? { body: stringOf(model.body) } : {},
|
|
4664
|
+
...hasScript(exemplar) ? { script: scriptFrom(model.script) } : typeof exemplar.transcript === "string" ? { transcript: stringOf(model.transcript) } : {},
|
|
4665
|
+
...isRecord(exemplar.media) ? { media: variantMedia(exemplar.media, model, ownText, build.request.facts.imagePolicy) } : {},
|
|
4666
|
+
...pick(exemplar, ["locale"])
|
|
4667
|
+
};
|
|
4668
|
+
}
|
|
4669
|
+
function buildEntry(exemplar, exemplarIndex, wrote, build, id) {
|
|
4670
|
+
const difficulty = difficultyFor(build.request, exemplarIndex);
|
|
4671
|
+
if (exemplar.type !== "item-group") return buildActivity(exemplar, wrote, stringOf(exemplar.type), build, difficulty, id);
|
|
4672
|
+
const before = listOf(exemplar.items);
|
|
4673
|
+
const title = typeof exemplar.title === "string" ? written(wrote.title) ?? exemplar.title : void 0;
|
|
4674
|
+
return {
|
|
4675
|
+
...pick(exemplar, ["slotKey", "shuffle"]),
|
|
4676
|
+
...title !== void 0 ? { title } : {},
|
|
4677
|
+
stimulus: buildStimulus(isRecord(exemplar.stimulus) ? exemplar.stimulus : {}, wrote.stimulus, build),
|
|
4678
|
+
items: listOf(wrote.items).map((item, index) => {
|
|
4679
|
+
const fields = isRecord(item) ? item : {};
|
|
4680
|
+
const was = isRecord(before[index]) ? before[index] : {};
|
|
4681
|
+
return buildActivity(was, fields, VARIANT_TYPES.includes(stringOf(fields.type)) ? stringOf(fields.type) : stringOf(was.type), build, difficultyFor(build.request, exemplarIndex, index), build.ids());
|
|
4682
|
+
}),
|
|
4683
|
+
schemaVersion: typeof exemplar.schemaVersion === "string" ? exemplar.schemaVersion : "1.0",
|
|
4684
|
+
type: "item-group",
|
|
4685
|
+
id
|
|
4686
|
+
};
|
|
4687
|
+
}
|
|
4688
|
+
function variantFinding(code, path, message) {
|
|
4689
|
+
return {
|
|
4690
|
+
path: [...path],
|
|
4691
|
+
message,
|
|
4692
|
+
code,
|
|
4693
|
+
severity: AI_VARIANT_CODE_SEVERITY[code]
|
|
4694
|
+
};
|
|
4695
|
+
}
|
|
4696
|
+
/** The items of an entry with their paths: the entry itself, or a group's items. */
|
|
4697
|
+
const itemsWithPaths = (entry) => isRecord(entry) && entry.type === "item-group" ? listOf(entry.items).map((item, index) => ({
|
|
4698
|
+
path: ["items", String(index)],
|
|
4699
|
+
item
|
|
4700
|
+
})) : [{
|
|
4701
|
+
path: [],
|
|
4702
|
+
item: entry
|
|
4703
|
+
}];
|
|
4704
|
+
/** The other forms' entries in this exemplar's place: the same `slotKey`, or the same position in each form. */
|
|
4705
|
+
function avoidedFor(request, exemplarIndex) {
|
|
4706
|
+
const avoid = request.avoid ?? [];
|
|
4707
|
+
const exemplar = request.exemplars[exemplarIndex];
|
|
4708
|
+
const slotKey = isRecord(exemplar) ? exemplar.slotKey : void 0;
|
|
4709
|
+
const forms = avoid.length % request.exemplars.length === 0;
|
|
4710
|
+
return avoid.flatMap((entry, index) => {
|
|
4711
|
+
const theirs = isRecord(entry) ? entry.slotKey : void 0;
|
|
4712
|
+
return (slotKey !== void 0 && theirs !== void 0 ? slotKey === theirs : slotKey === void 0 && theirs === void 0 && forms && index % request.exemplars.length === exemplarIndex) ? [{
|
|
4713
|
+
entry,
|
|
4714
|
+
label: `avoid[${index}]`
|
|
4715
|
+
}] : [];
|
|
4716
|
+
});
|
|
4717
|
+
}
|
|
4718
|
+
function nearCopies(entry, exemplar, request) {
|
|
4719
|
+
const mine = new Map(textsOf(exemplar).map((one) => [one.path.join("."), one]));
|
|
4720
|
+
const avoided = (request.avoid ?? []).flatMap((other, index) => textsOf(other).map((one) => ({
|
|
4721
|
+
...one,
|
|
4722
|
+
label: `avoid[${index}]`
|
|
4723
|
+
})));
|
|
4724
|
+
const findings = [];
|
|
4725
|
+
for (const one of textsOf(entry)) {
|
|
4726
|
+
let best;
|
|
4727
|
+
const consider = (score, what) => {
|
|
4728
|
+
if (score >= .5 && (best === void 0 || score > best.score)) best = {
|
|
4729
|
+
score,
|
|
4730
|
+
what
|
|
4731
|
+
};
|
|
4732
|
+
};
|
|
4733
|
+
const theirs = mine.get(one.path.join("."));
|
|
4734
|
+
if (theirs !== void 0) consider(textSimilarity(theirs.text, one.text), `the exemplar's ${theirs.path.join(".")}`);
|
|
4735
|
+
for (const other of avoided) if (other.kind === one.kind) consider(textSimilarity(other.text, one.text), `${other.label} ${other.path.join(".") || "text"}`);
|
|
4736
|
+
if (best !== void 0) findings.push(variantFinding("variant_near_copy", one.path, `This reads like ${best.what} (similarity ${shownScore(best.score)}). Write it in new words.`));
|
|
4737
|
+
}
|
|
4738
|
+
const exemplarItems = new Map(itemsWithPaths(exemplar).map((one) => [one.path.join("."), one.item]));
|
|
4739
|
+
const avoidedOptions = (request.avoid ?? []).flatMap((other, index) => itemsWithPaths(other).map((one) => ({
|
|
4740
|
+
set: optionSet(one.item),
|
|
4741
|
+
label: `avoid[${index}]`
|
|
4742
|
+
})));
|
|
4743
|
+
for (const { path, item } of itemsWithPaths(entry)) {
|
|
4744
|
+
const options = optionSet(item);
|
|
4745
|
+
if (options === null) continue;
|
|
4746
|
+
const sameAs = (other) => other !== null && other.join("\n") === options.join("\n");
|
|
4747
|
+
const source = sameAs(optionSet(exemplarItems.get(path.join(".")))) ? "the exemplar's" : avoidedOptions.find((other) => sameAs(other.set))?.label;
|
|
4748
|
+
if (source !== void 0) findings.push(variantFinding("variant_near_copy", [...path, "options"], `These are ${source === "the exemplar's" ? "the exemplar's options" : `${source}'s options`}, in another order. Write new options.`));
|
|
4749
|
+
}
|
|
4750
|
+
return findings;
|
|
4751
|
+
}
|
|
4752
|
+
function sameKeys(entry, exemplar, exemplarIndex, request) {
|
|
4753
|
+
const against = [{
|
|
4754
|
+
entry: exemplar,
|
|
4755
|
+
label: "the exemplar"
|
|
4756
|
+
}, ...avoidedFor(request, exemplarIndex)];
|
|
4757
|
+
const findings = [];
|
|
4758
|
+
for (const { path, item } of itemsWithPaths(entry)) {
|
|
4759
|
+
const slots = keysOf(item);
|
|
4760
|
+
const reported = /* @__PURE__ */ new Set();
|
|
4761
|
+
for (const other of against) {
|
|
4762
|
+
const theirSlots = keysOf(itemsWithPaths(other.entry).find((one) => one.path.join(".") === path.join("."))?.item);
|
|
4763
|
+
for (const slot of slots) {
|
|
4764
|
+
const match = theirSlots.find((one) => one.slot === slot.slot);
|
|
4765
|
+
const shared = match !== void 0 ? sharedAnswers(slot, match) : [];
|
|
4766
|
+
if (shared.length > 0 && !reported.has(slot.slot)) {
|
|
4767
|
+
reported.add(slot.slot);
|
|
4768
|
+
findings.push(variantFinding("variant_same_key", [...path, ...slot.path], `The right answer "${shared[0]}" is ${other.label === "the exemplar" ? "the exemplar's" : `${other.label}'s`} too: a learner who sat that form scores again by memory.`));
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
return findings;
|
|
4774
|
+
}
|
|
4775
|
+
/** Whether a recording the exemplar's item has words for came back with none. */
|
|
4776
|
+
function wordlessRecordings(exemplar, entry) {
|
|
4777
|
+
const mismatch = (path, message) => ({
|
|
4778
|
+
path,
|
|
4779
|
+
message,
|
|
4780
|
+
code: "variant_structure",
|
|
4781
|
+
severity: "invalid"
|
|
4782
|
+
});
|
|
4783
|
+
const out = [];
|
|
4784
|
+
const pairsOf = (a, b, at) => {
|
|
4785
|
+
if (!isRecord(a) || !isRecord(b) || a.type === "dictation" || a.type === "read-aloud") return;
|
|
4786
|
+
if (isRecording(a.media) && transcriptOf(a.media) !== null && transcriptOf(b.media) === null) out.push(mismatch([
|
|
4787
|
+
...at,
|
|
4788
|
+
"media",
|
|
4789
|
+
hasScript(a.media) ? "script" : "transcript"
|
|
4790
|
+
], "The recording has no words to record: write what it says."));
|
|
4791
|
+
};
|
|
4792
|
+
if (exemplar.type === "item-group") {
|
|
4793
|
+
const before = isRecord(exemplar.stimulus) ? exemplar.stimulus : {};
|
|
4794
|
+
const after = isRecord(entry.stimulus) ? entry.stimulus : {};
|
|
4795
|
+
if (transcriptOf(before) !== null && transcriptOf(after) === null) out.push(mismatch(["stimulus", hasScript(before) || isRecord(before.media) && hasScript(before.media) ? "script" : "transcript"], "The recording has no words to record: write what it says."));
|
|
4796
|
+
listOf(exemplar.items).forEach((item, index) => {
|
|
4797
|
+
pairsOf(item, listOf(entry.items)[index], ["items", String(index)]);
|
|
4798
|
+
});
|
|
4799
|
+
} else pairsOf(exemplar, entry, []);
|
|
4800
|
+
return out;
|
|
4801
|
+
}
|
|
4802
|
+
function pairsOf(exemplar, entry) {
|
|
4803
|
+
const top = {
|
|
4804
|
+
exemplarPath: [],
|
|
4805
|
+
variantPath: [],
|
|
4806
|
+
exemplarId: stringOf(exemplar.id),
|
|
4807
|
+
variantId: stringOf(entry.id)
|
|
4808
|
+
};
|
|
4809
|
+
if (exemplar.type !== "item-group") return [top];
|
|
4810
|
+
const theirs = listOf(entry.items);
|
|
4811
|
+
return [top, ...listOf(exemplar.items).flatMap((item, index) => index < theirs.length ? [{
|
|
4812
|
+
exemplarPath: ["items", String(index)],
|
|
4813
|
+
variantPath: ["items", String(index)],
|
|
4814
|
+
exemplarId: idOf(item) ?? "",
|
|
4815
|
+
variantId: idOf(theirs[index]) ?? ""
|
|
4816
|
+
}] : [])];
|
|
4817
|
+
}
|
|
4818
|
+
function evaluate(built, request) {
|
|
4819
|
+
const exemplar = request.exemplars[built.exemplarIndex];
|
|
4820
|
+
const type = stringOf(exemplar.type);
|
|
4821
|
+
const { entry } = built;
|
|
4822
|
+
const group = type === "item-group";
|
|
4823
|
+
const validation = group ? validateItemGroupDraft(entry) : validateDraft(type, entry);
|
|
4824
|
+
const mismatches = built.wroteType !== type ? [{
|
|
4825
|
+
path: ["type"],
|
|
4826
|
+
message: `This was written as ${built.wroteType || "no type"}; the exemplar is ${type}.`,
|
|
4827
|
+
code: "variant_type",
|
|
4828
|
+
severity: "invalid"
|
|
4829
|
+
}] : [...structureMisses(exemplar, entry).map((miss) => ({
|
|
4830
|
+
...miss,
|
|
4831
|
+
code: "variant_structure",
|
|
4832
|
+
severity: "invalid"
|
|
4833
|
+
})), ...wordlessRecordings(exemplar, entry)];
|
|
4834
|
+
const findings = [
|
|
4835
|
+
...group ? critiqueItemGroupDraft(entry) : critiqueDraft(type, entry),
|
|
4836
|
+
...lengthMisses(exemplar, entry, request.lengthTolerance).map((miss) => variantFinding("variant_length", miss.path, `${miss.words} words; the exemplar's has ${miss.exemplarWords}, so ${miss.bounds[0]} to ${miss.bounds[1]}.`)),
|
|
4837
|
+
...nearCopies(entry, exemplar, request),
|
|
4838
|
+
...sameKeys(entry, exemplar, built.exemplarIndex, request)
|
|
4839
|
+
];
|
|
4840
|
+
const status = validation.status === "invalid" || mismatches.length > 0 ? "invalid" : validation.status === "incomplete" ? "incomplete" : "complete";
|
|
4841
|
+
return {
|
|
4842
|
+
exemplarIndex: built.exemplarIndex,
|
|
4843
|
+
type,
|
|
4844
|
+
entry,
|
|
4845
|
+
status,
|
|
4846
|
+
validation,
|
|
4847
|
+
mismatches,
|
|
4848
|
+
findings,
|
|
4849
|
+
pairs: pairsOf(exemplar, entry),
|
|
4850
|
+
generated: built.generated
|
|
4851
|
+
};
|
|
4852
|
+
}
|
|
4853
|
+
/**
|
|
4854
|
+
* Moves each single-answer, unshuffled multiple choice's right option to the
|
|
4855
|
+
* position used least so far across the variants, ties broken by an order
|
|
4856
|
+
* drawn from `seed`: as even as the counts allow, and the same every time.
|
|
4857
|
+
* An option moves with its id, text, feedback and media.
|
|
4858
|
+
*/
|
|
4859
|
+
function balanceKeys(built, seed) {
|
|
4860
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4861
|
+
let slot = 0;
|
|
4862
|
+
const visit = (item) => {
|
|
4863
|
+
if (!isRecord(item) || item.type !== "multiple-choice" || item.mode !== "single" || item.shuffle === true) return item;
|
|
4864
|
+
const options = listOf(item.options);
|
|
4865
|
+
const keys = options.flatMap((option, index) => isRecord(option) && option.isCorrect === true ? [index] : []);
|
|
4866
|
+
if (keys.length !== 1) return item;
|
|
4867
|
+
const order = seededShuffle(options.map((_, index) => index), `${seed}:${slot}`, { version: 2 });
|
|
4868
|
+
slot += 1;
|
|
4869
|
+
let target = order[0];
|
|
4870
|
+
for (const position of order) if ((counts.get(position) ?? 0) < (counts.get(target) ?? 0)) target = position;
|
|
4871
|
+
counts.set(target, (counts.get(target) ?? 0) + 1);
|
|
4872
|
+
const key = options[keys[0]];
|
|
4873
|
+
const rest = options.filter((_, index) => index !== keys[0]);
|
|
4874
|
+
return {
|
|
4875
|
+
...item,
|
|
4876
|
+
options: [
|
|
4877
|
+
...rest.slice(0, target),
|
|
4878
|
+
key,
|
|
4879
|
+
...rest.slice(target)
|
|
4880
|
+
]
|
|
4881
|
+
};
|
|
4882
|
+
};
|
|
4883
|
+
return [...built].sort((a, b) => a.exemplarIndex - b.exemplarIndex).map((one) => ({
|
|
4884
|
+
...one,
|
|
4885
|
+
entry: one.entry.type === "item-group" ? {
|
|
4886
|
+
...one.entry,
|
|
4887
|
+
items: listOf(one.entry.items).map(visit)
|
|
4888
|
+
} : visit(one.entry)
|
|
4889
|
+
}));
|
|
4890
|
+
}
|
|
4891
|
+
/** Ids from the host, checked as `createDraft` checks them. */
|
|
4892
|
+
function idSource(newId, caller) {
|
|
4893
|
+
const issued = /* @__PURE__ */ new Set();
|
|
4894
|
+
return () => {
|
|
4895
|
+
const id = newId();
|
|
4896
|
+
if (typeof id !== "string" || id === "") throw new Error(`${caller}: newId() must return a non-empty string.`);
|
|
4897
|
+
if (issued.has(id)) throw new Error(`${caller}: newId() returned "${id}" twice. Ids must differ — two options sharing an id cannot be scored apart.`);
|
|
4898
|
+
issued.add(id);
|
|
4899
|
+
return id;
|
|
4900
|
+
};
|
|
4901
|
+
}
|
|
4902
|
+
function missingOf(exemplarIndex) {
|
|
4903
|
+
return {
|
|
4904
|
+
exemplarIndex,
|
|
4905
|
+
code: "variant_missing",
|
|
4906
|
+
message: `The reply has no variant of exemplar ${exemplarIndex}.`,
|
|
4907
|
+
severity: "invalid"
|
|
4908
|
+
};
|
|
4909
|
+
}
|
|
4910
|
+
function read(raw, request, ids, keep) {
|
|
4911
|
+
if (!isRecord(raw) || !Array.isArray(raw.variants) || !raw.variants.every(isRecord)) return {
|
|
4912
|
+
ok: false,
|
|
4913
|
+
refusal: "malformed"
|
|
4914
|
+
};
|
|
4915
|
+
const asked = request.repair ? request.repair.map((one) => one.exemplar) : request.facts.exemplars.map((one) => one.exemplar);
|
|
4916
|
+
if (raw.variants.length > asked.length) return {
|
|
4917
|
+
ok: false,
|
|
4918
|
+
refusal: "too-long"
|
|
4919
|
+
};
|
|
4920
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4921
|
+
for (const variant of raw.variants) {
|
|
4922
|
+
const index = variant.exemplar;
|
|
4923
|
+
if (typeof index !== "number" || !asked.includes(index) || seen.has(index)) return {
|
|
4924
|
+
ok: false,
|
|
4925
|
+
refusal: "malformed"
|
|
4926
|
+
};
|
|
4927
|
+
seen.add(index);
|
|
4928
|
+
}
|
|
4929
|
+
const build = {
|
|
4930
|
+
request,
|
|
4931
|
+
ids
|
|
4932
|
+
};
|
|
4933
|
+
return {
|
|
4934
|
+
ok: true,
|
|
4935
|
+
built: raw.variants.map((variant) => {
|
|
4936
|
+
const exemplarIndex = variant.exemplar;
|
|
4937
|
+
const exemplar = request.exemplars[exemplarIndex];
|
|
4938
|
+
return {
|
|
4939
|
+
exemplarIndex,
|
|
4940
|
+
wroteType: stringOf(variant.type),
|
|
4941
|
+
entry: buildEntry(exemplar, exemplarIndex, variant, build, keep.get(exemplarIndex) ?? ids()),
|
|
4942
|
+
generated: variant
|
|
4943
|
+
};
|
|
4944
|
+
}).sort((a, b) => a.exemplarIndex - b.exemplarIndex),
|
|
4945
|
+
missing: asked.filter((index) => !seen.has(index)).sort((a, b) => a - b).map(missingOf),
|
|
4946
|
+
raw
|
|
4947
|
+
};
|
|
4948
|
+
}
|
|
4949
|
+
function setFindings(variants) {
|
|
4950
|
+
const entries = variants.map((one) => one.entry);
|
|
4951
|
+
return [...keyPositionFinding(entries, []), ...paperFindings(entries)];
|
|
4952
|
+
}
|
|
4953
|
+
/**
|
|
4954
|
+
* Reads a model's reply to {@link aiVariantsRequest} into variants an editor
|
|
4955
|
+
* can show for approval: each made from what the model wrote and what its
|
|
4956
|
+
* exemplar is — type, structure, scoring and settings copied from the
|
|
4957
|
+
* exemplar, whatever the reply says of them — with ids from `newId`, checked
|
|
4958
|
+
* by `validateDraft` (or `validateItemGroupDraft`), compared with its exemplar
|
|
4959
|
+
* (`mismatches`: `variant_type`, `variant_structure`), critiqued, and checked
|
|
4960
|
+
* for length, near copies and repeated keys against the exemplar and every
|
|
4961
|
+
* entry in `avoid` (`findings`: `variant_length`, `variant_near_copy`,
|
|
4962
|
+
* `variant_same_key`). `pairs` line each exemplar item up with its variant's.
|
|
4963
|
+
*
|
|
4964
|
+
* A recording comes back with no file — `url: null`, its playback policy, and
|
|
4965
|
+
* a new script or transcript — and a picture to describe with no file and a
|
|
4966
|
+
* new `alt`: `media_url_required` until the host attaches one.
|
|
4967
|
+
*
|
|
4968
|
+
* Variants are independent: one that is wrong comes back as such, beside the
|
|
4969
|
+
* others. An exemplar the reply wrote nothing for is listed in `missing`. The
|
|
4970
|
+
* whole reply is refused only when it is not `{ variants: [...] }` of objects
|
|
4971
|
+
* each naming an exemplar of the facts once (`malformed`), or holds more
|
|
4972
|
+
* variants than exemplars (`too-long`).
|
|
4973
|
+
*
|
|
4974
|
+
* With `balanceKeyPositions` the single-answer keys are spread across
|
|
4975
|
+
* positions, from its seed.
|
|
4976
|
+
*
|
|
4977
|
+
* @throws Error when `newId` returns an empty or repeated id.
|
|
4978
|
+
*/
|
|
4979
|
+
function checkAiVariants(raw, request, options) {
|
|
4980
|
+
const result = read(raw, request, idSource(options.newId, "checkAiVariants"), /* @__PURE__ */ new Map());
|
|
4981
|
+
if (!result.ok) return result;
|
|
4982
|
+
const seed = request.balanceKeyPositions?.seed;
|
|
4983
|
+
const variants = (seed !== void 0 ? balanceKeys(result.built, seed) : result.built).map((one) => evaluate(one, request));
|
|
4984
|
+
const provenance = provenanceOf(result.raw.provenance);
|
|
4985
|
+
const usage = usageOf(result.raw.usage);
|
|
4986
|
+
return {
|
|
4987
|
+
ok: true,
|
|
4988
|
+
variants: {
|
|
4989
|
+
variants,
|
|
4990
|
+
missing: result.missing,
|
|
4991
|
+
findings: setFindings(variants),
|
|
4992
|
+
...provenance !== void 0 ? { provenance } : {},
|
|
4993
|
+
...usage !== void 0 ? { usage } : {}
|
|
4994
|
+
}
|
|
4995
|
+
};
|
|
4996
|
+
}
|
|
4997
|
+
/** The fields a model writes, and inside media and material, the ones it writes there. */
|
|
4998
|
+
const WRITTEN_FIELDS = /* @__PURE__ */ new Set([
|
|
4999
|
+
"type",
|
|
5000
|
+
"title",
|
|
5001
|
+
"instructions",
|
|
5002
|
+
"question",
|
|
5003
|
+
"options",
|
|
5004
|
+
"passage",
|
|
5005
|
+
"blanks",
|
|
5006
|
+
"gaps",
|
|
5007
|
+
"banks",
|
|
5008
|
+
"transcript",
|
|
5009
|
+
"acceptedTranscripts",
|
|
5010
|
+
"referenceText",
|
|
5011
|
+
"prompt",
|
|
5012
|
+
"feedback",
|
|
5013
|
+
"media",
|
|
5014
|
+
"stimulus",
|
|
5015
|
+
"items"
|
|
5016
|
+
]);
|
|
5017
|
+
const WRITTEN_IN_MEDIA = /* @__PURE__ */ new Set([
|
|
5018
|
+
"alt",
|
|
5019
|
+
"transcript",
|
|
5020
|
+
"script"
|
|
5021
|
+
]);
|
|
5022
|
+
const WRITTEN_IN_STIMULUS = /* @__PURE__ */ new Set([
|
|
5023
|
+
"title",
|
|
5024
|
+
"body",
|
|
5025
|
+
"transcript",
|
|
5026
|
+
"script",
|
|
5027
|
+
"media",
|
|
5028
|
+
"alt"
|
|
5029
|
+
]);
|
|
5030
|
+
function writable(path) {
|
|
5031
|
+
const rest = path[0] === "items" && path.length > 2 ? path.slice(2) : path;
|
|
5032
|
+
const [first, second, third] = rest;
|
|
5033
|
+
if (first === void 0 || !WRITTEN_FIELDS.has(first)) return false;
|
|
5034
|
+
if (first === "media") return second !== void 0 && WRITTEN_IN_MEDIA.has(second);
|
|
5035
|
+
if (first === "stimulus") {
|
|
5036
|
+
if (second === "media") return third !== void 0 && WRITTEN_IN_MEDIA.has(third);
|
|
5037
|
+
return second !== void 0 && WRITTEN_IN_STIMULUS.has(second);
|
|
5038
|
+
}
|
|
5039
|
+
if (first === "options" && rest[2] === "media") return rest[3] !== void 0 && WRITTEN_IN_MEDIA.has(rest[3]);
|
|
5040
|
+
return true;
|
|
5041
|
+
}
|
|
5042
|
+
/** Where the model wrote what a problem names. */
|
|
5043
|
+
function pathForModel(variant, problem) {
|
|
5044
|
+
const { path, code } = problem;
|
|
5045
|
+
const prefix = path[0] === "items" && path.length > 2 ? path.slice(0, 2) : [];
|
|
5046
|
+
const rest = path.slice(prefix.length);
|
|
5047
|
+
if (code === "ra_text_long_for_time") return [...prefix, "referenceText"];
|
|
5048
|
+
if (rest[0] === "gaps" && rest.length === 3 && rest[2] === "correctChoiceId") {
|
|
5049
|
+
const item = prefix.length > 0 ? listOf(variant.entry.items)[Number(prefix[1])] : variant.entry;
|
|
5050
|
+
const gap = isRecord(item) ? listOf(item.gaps)[Number(rest[1])] : void 0;
|
|
5051
|
+
return [
|
|
5052
|
+
...prefix,
|
|
5053
|
+
"gaps",
|
|
5054
|
+
rest[1],
|
|
5055
|
+
isRecord(gap) && gap.bankId !== void 0 ? "answer" : "choices"
|
|
5056
|
+
];
|
|
5057
|
+
}
|
|
5058
|
+
if (rest[0] === "stimulus" && rest[1] === "media" && (rest[2] === "script" || rest[2] === "transcript")) return ["stimulus", rest[2]];
|
|
5059
|
+
return [...path];
|
|
5060
|
+
}
|
|
5061
|
+
function problemsOf(variant) {
|
|
5062
|
+
return [
|
|
5063
|
+
...variant.mismatches.map((found) => ({
|
|
5064
|
+
path: found.path,
|
|
5065
|
+
code: found.code,
|
|
5066
|
+
message: found.message,
|
|
5067
|
+
severity: "invalid"
|
|
5068
|
+
})),
|
|
5069
|
+
...variant.validation.issues.filter((found) => found.code !== "media_url_required").map((found) => ({
|
|
5070
|
+
path: found.path,
|
|
5071
|
+
code: found.code,
|
|
5072
|
+
message: found.message,
|
|
5073
|
+
severity: found.severity
|
|
5074
|
+
})),
|
|
5075
|
+
...variant.findings.filter((found) => found.severity === "warning").map((found) => ({
|
|
5076
|
+
path: found.path,
|
|
5077
|
+
code: found.code,
|
|
5078
|
+
message: found.message,
|
|
5079
|
+
severity: "warning"
|
|
5080
|
+
}))
|
|
5081
|
+
].map((problem) => ({
|
|
5082
|
+
...problem,
|
|
5083
|
+
path: pathForModel(variant, problem)
|
|
5084
|
+
})).filter((problem) => writable(problem.path));
|
|
5085
|
+
}
|
|
5086
|
+
/**
|
|
5087
|
+
* A second request, for the variants with something the model can fix —
|
|
5088
|
+
* a wrong type or structure, an issue other than a missing file, or a warning
|
|
5089
|
+
* at a field the model writes — and the exemplars the reply wrote nothing for
|
|
5090
|
+
* (`variant_missing`). Each comes with its problems at the paths the model
|
|
5091
|
+
* wrote; the exemplar and its targets stay in the facts. `null` when there is
|
|
5092
|
+
* nothing to repair.
|
|
5093
|
+
*
|
|
5094
|
+
* The reply holds one variant for each, naming its exemplar;
|
|
5095
|
+
* {@link checkAiVariants} reads it.
|
|
5096
|
+
*/
|
|
5097
|
+
function aiVariantsRepairRequest(request, variants) {
|
|
5098
|
+
const list = Array.isArray(variants) ? variants : variants.variants;
|
|
5099
|
+
const missing = Array.isArray(variants) ? [] : variants.missing;
|
|
5100
|
+
const repair = [...list.flatMap((one) => {
|
|
5101
|
+
const problems = problemsOf(one);
|
|
5102
|
+
return problems.length === 0 ? [] : [{
|
|
5103
|
+
exemplar: one.exemplarIndex,
|
|
5104
|
+
variant: one.generated,
|
|
5105
|
+
problems
|
|
5106
|
+
}];
|
|
5107
|
+
}), ...missing.map((one) => ({
|
|
5108
|
+
exemplar: one.exemplarIndex,
|
|
5109
|
+
problems: [{
|
|
5110
|
+
path: [],
|
|
5111
|
+
code: one.code,
|
|
5112
|
+
message: one.message,
|
|
5113
|
+
severity: "invalid"
|
|
5114
|
+
}]
|
|
5115
|
+
}))].sort((a, b) => a.exemplar - b.exemplar);
|
|
5116
|
+
if (repair.length === 0) return null;
|
|
5117
|
+
const { repair: _previous, ...first } = request;
|
|
5118
|
+
return {
|
|
5119
|
+
...first,
|
|
5120
|
+
shape: replyShape(repair.map((one) => request.exemplars[one.exemplar]), repair.length),
|
|
5121
|
+
repair
|
|
5122
|
+
};
|
|
5123
|
+
}
|
|
5124
|
+
const RANK = {
|
|
5125
|
+
complete: 2,
|
|
5126
|
+
incomplete: 1,
|
|
5127
|
+
invalid: 0
|
|
5128
|
+
};
|
|
5129
|
+
const invalidCount = (one) => one.mismatches.length + one.validation.issues.filter((found) => found.severity === "invalid").length;
|
|
5130
|
+
const warningCount = (one) => one.findings.filter((found) => found.severity === "warning").length;
|
|
5131
|
+
/** Whether a repaired variant is at least as good as the one it would replace. */
|
|
5132
|
+
function noWorse(repaired, before) {
|
|
5133
|
+
const now = RANK[repaired.status];
|
|
5134
|
+
const was = RANK[before.status];
|
|
5135
|
+
if (now !== was) return now > was;
|
|
5136
|
+
if (invalidCount(repaired) !== invalidCount(before)) return invalidCount(repaired) < invalidCount(before);
|
|
5137
|
+
return warningCount(repaired) <= warningCount(before);
|
|
5138
|
+
}
|
|
5139
|
+
/**
|
|
5140
|
+
* Writes variants of existing items, with a bounded repair loop: asks the port
|
|
5141
|
+
* with `request`, reads the reply as {@link checkAiVariants} does, then — up
|
|
5142
|
+
* to `repairs` times — sends back what the model can fix and what it did not
|
|
5143
|
+
* write ({@link aiVariantsRepairRequest}), keeping each variant's best pass:
|
|
5144
|
+
* by status, then fewer invalid problems, then fewer warnings. A repaired
|
|
5145
|
+
* variant keeps its id. With `balanceKeyPositions`, the keys are spread once,
|
|
5146
|
+
* over the variants as they end.
|
|
5147
|
+
*
|
|
5148
|
+
* Never throws for a model's failure: a port that throws or a reply the SDK
|
|
5149
|
+
* refuses ends the loop, and is recorded in `calls` beside the provenance and
|
|
5150
|
+
* usage of every call. A first call that fails leaves no variants. A request
|
|
5151
|
+
* with no exemplar to vary makes no call.
|
|
5152
|
+
*
|
|
5153
|
+
* ```ts
|
|
5154
|
+
* const run = await generateVariants({
|
|
5155
|
+
* request: aiVariantsRequest({ exemplars: section, level: 'CEFR B1', avoid: otherForms }),
|
|
5156
|
+
* port: (request) => callMyModel(VARIANTS_PROMPT, request),
|
|
5157
|
+
* newId: () => crypto.randomUUID(),
|
|
5158
|
+
* });
|
|
5159
|
+
* // run.variants: store each as proposed, for a person to approve.
|
|
5160
|
+
* ```
|
|
5161
|
+
*
|
|
5162
|
+
* @throws RangeError when `repairs` is not a whole number from 0 to
|
|
5163
|
+
* {@link AI_VARIANTS_MAX_REPAIRS} (default 1), and Error when `newId` returns
|
|
5164
|
+
* an empty or repeated id.
|
|
5165
|
+
*/
|
|
5166
|
+
async function generateVariants(input) {
|
|
5167
|
+
const { request, port, newId } = input;
|
|
5168
|
+
const repairs = input.repairs ?? 1;
|
|
5169
|
+
if (!Number.isInteger(repairs) || repairs < 0 || repairs > 3) throw new RangeError(`generateVariants: repairs must be a whole number from 0 to 3, not ${String(repairs)}.`);
|
|
5170
|
+
const calls = [];
|
|
5171
|
+
const skipped = request.skipped;
|
|
5172
|
+
if (request.facts.exemplars.length === 0) return {
|
|
5173
|
+
variants: [],
|
|
5174
|
+
missing: [],
|
|
5175
|
+
findings: [],
|
|
5176
|
+
skipped,
|
|
5177
|
+
calls
|
|
5178
|
+
};
|
|
5179
|
+
const ids = idSource(newId, "generateVariants");
|
|
5180
|
+
const ask = async (asked, keep) => {
|
|
5181
|
+
let raw;
|
|
5182
|
+
try {
|
|
5183
|
+
raw = await port(asked);
|
|
5184
|
+
} catch (error) {
|
|
5185
|
+
calls.push({
|
|
5186
|
+
ok: false,
|
|
5187
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5188
|
+
});
|
|
5189
|
+
return;
|
|
5190
|
+
}
|
|
5191
|
+
const result = read(raw, asked, ids, keep);
|
|
5192
|
+
if (!result.ok) {
|
|
5193
|
+
calls.push({
|
|
5194
|
+
ok: false,
|
|
5195
|
+
refusal: result.refusal
|
|
5196
|
+
});
|
|
5197
|
+
return;
|
|
5198
|
+
}
|
|
5199
|
+
const provenance = provenanceOf(result.raw.provenance);
|
|
5200
|
+
const usage = usageOf(result.raw.usage);
|
|
5201
|
+
calls.push({
|
|
5202
|
+
ok: true,
|
|
5203
|
+
...provenance !== void 0 ? { provenance } : {},
|
|
5204
|
+
...usage !== void 0 ? { usage } : {}
|
|
5205
|
+
});
|
|
5206
|
+
return {
|
|
5207
|
+
built: result.built,
|
|
5208
|
+
variants: result.built.map((one) => evaluate(one, request)),
|
|
5209
|
+
missing: result.missing
|
|
5210
|
+
};
|
|
5211
|
+
};
|
|
5212
|
+
const first = await ask(request, /* @__PURE__ */ new Map());
|
|
5213
|
+
if (first === void 0) return {
|
|
5214
|
+
variants: [],
|
|
5215
|
+
missing: [],
|
|
5216
|
+
findings: [],
|
|
5217
|
+
skipped,
|
|
5218
|
+
calls
|
|
5219
|
+
};
|
|
5220
|
+
const best = /* @__PURE__ */ new Map();
|
|
5221
|
+
first.variants.forEach((variant, index) => {
|
|
5222
|
+
best.set(variant.exemplarIndex, {
|
|
5223
|
+
built: first.built[index],
|
|
5224
|
+
variant
|
|
5225
|
+
});
|
|
5226
|
+
});
|
|
5227
|
+
const wanted = request.facts.exemplars.map((one) => one.exemplar);
|
|
5228
|
+
const missingNow = () => wanted.filter((index) => !best.has(index)).map(missingOf);
|
|
5229
|
+
for (let pass = 0; pass < repairs; pass += 1) {
|
|
5230
|
+
const repairRequest = aiVariantsRepairRequest(request, {
|
|
5231
|
+
variants: [...best.values()].map((one) => one.variant),
|
|
5232
|
+
missing: missingNow(),
|
|
5233
|
+
findings: []
|
|
5234
|
+
});
|
|
5235
|
+
if (repairRequest === null) break;
|
|
5236
|
+
const repaired = await ask(repairRequest, new Map((repairRequest.repair ?? []).flatMap((one) => {
|
|
5237
|
+
const before = best.get(one.exemplar);
|
|
5238
|
+
return before !== void 0 ? [[one.exemplar, stringOf(before.variant.entry.id)]] : [];
|
|
5239
|
+
})));
|
|
5240
|
+
if (repaired === void 0) break;
|
|
5241
|
+
repaired.variants.forEach((variant, index) => {
|
|
5242
|
+
const before = best.get(variant.exemplarIndex);
|
|
5243
|
+
if (before === void 0 || noWorse(variant, before.variant)) best.set(variant.exemplarIndex, {
|
|
5244
|
+
built: repaired.built[index],
|
|
5245
|
+
variant
|
|
5246
|
+
});
|
|
5247
|
+
});
|
|
5248
|
+
}
|
|
5249
|
+
const ended = [...best.values()].sort((a, b) => a.variant.exemplarIndex - b.variant.exemplarIndex);
|
|
5250
|
+
const seed = request.balanceKeyPositions?.seed;
|
|
5251
|
+
const variants = seed !== void 0 ? balanceKeys(ended.map((one) => one.built), seed).map((one) => evaluate(one, request)) : ended.map((one) => one.variant);
|
|
5252
|
+
return {
|
|
5253
|
+
variants,
|
|
5254
|
+
missing: missingNow(),
|
|
5255
|
+
findings: setFindings(variants),
|
|
5256
|
+
skipped,
|
|
5257
|
+
calls
|
|
5258
|
+
};
|
|
5259
|
+
}
|
|
5260
|
+
// src/ai-writing.ts
|
|
3004
5261
|
/**
|
|
3005
5262
|
* The request for feedback on a draft, or `null` when there is none to give:
|
|
3006
5263
|
* not a written response, an author who switched explanations off (writing
|
|
@@ -3081,7 +5338,7 @@ function checkCriteria(raw, rubric) {
|
|
|
3081
5338
|
const seen = /* @__PURE__ */ new Set();
|
|
3082
5339
|
const criteria = [];
|
|
3083
5340
|
for (const entry of raw) {
|
|
3084
|
-
if (!isRecord(entry) || typeof entry.name !== "string") return {
|
|
5341
|
+
if (!isRecord$1(entry) || typeof entry.name !== "string") return {
|
|
3085
5342
|
ok: false,
|
|
3086
5343
|
refusal: "malformed"
|
|
3087
5344
|
};
|
|
@@ -3170,7 +5427,6 @@ function checkAiWritingFeedback(raw, request) {
|
|
|
3170
5427
|
}
|
|
3171
5428
|
};
|
|
3172
5429
|
}
|
|
3173
|
-
|
|
3174
|
-
export { AI_COACHING_MAX_TIP_LENGTH as A, aiHintRequest as B, createItemGroupDraft as C, listMediaRefs as D, validateItemGroupDraft as E, AI_HINT_TYPES as F, hintRevealsAnswer as G, buildAiFacts as H, AI_TEXT_MAX_LENGTH as I, readAiTextResult as K, aiAllowedByContent as L, aiCoachingRequest as M, checkAiCoaching as N, mapMediaRefs as O, AI_EXPLANATION_TYPES as P, aiExplanationRequest as R, createInteractiveVideoDraft as S, critiqueItemGroupDraft as T, checkAiExplanation as U, aiSupports as V, checkAiHint as W, aiCritiqueRequest as _, checkAiGrading as a, critiqueDraft as b, AI_DRAFTS_MAX_COUNT as c, aiDraftsRequest as d, checkAiDrafts as f, AI_CRITIQUE_MAX_FINDINGS as g, AI_CRITIQUE_MAX_FIELD_LENGTH as h, aiGradingRequest as i, AI_COACHING_MAX_WORDS as j, transcriptOf as k, AI_DRAFTS_MAX_REPAIRS as l, interactiveVideoFromDrafts as m, checkAiWritingFeedback as n, AI_WRITING_MAX_CORRECTIONS as o, generateDrafts as p, AI_MATERIAL_MAX_LENGTH as q, AI_GRADING_PROBABILITY_TOLERANCE as r, AI_WRITING_MAX_FIELD_LENGTH as s, aiWritingFeedbackRequest as t, aiDraftsRepairRequest as u, checkAiCritique as v, critiqueDrafts as w, validateDraft as x, createDraft as y, aiGradeOf as z };
|
|
5430
|
+
export { AI_HINT_TYPES as $, createInteractiveVideoDraft as A, sharedAnswers as B, AI_CRITIQUE_MAX_FIELD_LENGTH as C, createDraft as D, checkAiCritique as E, AI_VARIANT_LENGTH_BANDS as F, listMediaRefs as G, structureMisses as H, AI_VARIANT_NEAR_COPY_THRESHOLD as I, AI_COACHING_MAX_TIP_LENGTH as J, mapMediaRefs as K, keysOf as L, critiqueDrafts as M, critiqueItemGroupDraft as N, critiqueDraft as O, validateItemGroupDraft as P, AI_EXPLANATION_TYPES as Q, lengthMisses as R, interactiveVideoFromDrafts as S, aiCritiqueRequest as T, textSimilarity as U, shownScore as V, textsOf as W, aiCoachingRequest as X, AI_COACHING_MAX_WORDS as Y, checkAiCoaching as Z, AI_DRAFTS_MAX_REPAIRS as _, AI_VARIANTS_MAX_REPAIRS as a, aiSupports as at, checkAiDrafts as b, aiVariantsRequest as c, checkAiHint as ct, AI_GRADING_PROBABILITY_TOLERANCE as d, AI_MATERIAL_MAX_LENGTH as dt, AI_TEXT_MAX_LENGTH as et, aiGradingRequest as f, AI_DRAFTS_MAX_COUNT as g, AI_WRITING_MAX_FIELD_LENGTH as h, AI_VARIANTS_MAX_MATERIAL_LENGTH as i, aiHintRequest as it, createItemGroupDraft as j, validateDraft as k, checkAiVariants as l, hintRevealsAnswer as lt, AI_WRITING_MAX_CORRECTIONS as m, checkAiWritingFeedback as n, aiExplanationRequest as nt, AI_VARIANT_CODE_SEVERITY as o, buildAiFacts as ot, checkAiGrading as p, transcriptOf as q, AI_VARIANTS_MAX_EXEMPLARS as r, aiGradeOf as rt, aiVariantsRepairRequest as s, checkAiExplanation as st, aiWritingFeedbackRequest as t, aiAllowedByContent as tt, generateVariants as u, readAiTextResult as ut, aiDraftsRepairRequest as v, AI_CRITIQUE_MAX_FINDINGS as w, generateDrafts as x, aiDraftsRequest as y, optionSet as z };
|
|
3175
5431
|
|
|
3176
|
-
//# sourceMappingURL=ai-writing-
|
|
5432
|
+
//# sourceMappingURL=ai-writing-DeIV-GfN.js.map
|