@intellectif/lk-core 0.17.0 → 0.19.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 +61 -0
- package/README.md +3 -2
- package/dist/{activity-BmjBjXVJ.d.cts → activity-f0bgZSTP.d.cts} +175 -134
- package/dist/{activity-BmjBjXVJ.d.ts → activity-f0bgZSTP.d.ts} +175 -134
- package/dist/ai-check.cjs +283 -0
- package/dist/ai-check.cjs.map +1 -0
- package/dist/ai-check.d.cts +106 -0
- package/dist/ai-check.d.ts +106 -0
- package/dist/ai-check.js +283 -0
- package/dist/ai-check.js.map +1 -0
- package/dist/chunk-BIJCO2HM.js +443 -0
- package/dist/chunk-BIJCO2HM.js.map +1 -0
- package/dist/{chunk-XNMKDFPW.cjs → chunk-COBK2RUX.cjs} +44 -9
- package/dist/chunk-COBK2RUX.cjs.map +1 -0
- package/dist/chunk-FVZJ4X3Y.cjs +443 -0
- package/dist/chunk-FVZJ4X3Y.cjs.map +1 -0
- package/dist/{chunk-WXS5EB65.js → chunk-MMEQKFFQ.js} +43 -8
- package/dist/chunk-MMEQKFFQ.js.map +1 -0
- package/dist/{index-G_KusUcK.d.ts → index-DMCM3e3P.d.ts} +1 -1
- package/dist/{index-CvfeD_cU.d.cts → index-bpuDhvXV.d.cts} +1 -1
- package/dist/index.cjs +37 -422
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -11
- package/dist/index.d.ts +30 -11
- package/dist/index.js +37 -422
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.cts +2 -2
- package/dist/schemas.d.ts +2 -2
- package/dist/scoring.cjs +2 -2
- package/dist/scoring.d.cts +33 -5
- package/dist/scoring.d.ts +33 -5
- package/dist/scoring.js +1 -1
- package/dist/xapi.d.cts +1 -1
- package/dist/xapi.d.ts +1 -1
- package/package.json +11 -1
- package/vectors/README.md +9 -3
- package/vectors/scoring.json +1258 -10
- package/dist/chunk-WXS5EB65.js.map +0 -1
- package/dist/chunk-XNMKDFPW.cjs.map +0 -1
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isGradeInRange,
|
|
3
|
+
score
|
|
4
|
+
} from "./chunk-MMEQKFFQ.js";
|
|
5
|
+
import {
|
|
6
|
+
PLACEHOLDER_RE,
|
|
7
|
+
alignDictation
|
|
8
|
+
} from "./chunk-D3GP5MET.js";
|
|
9
|
+
|
|
10
|
+
// src/ai.ts
|
|
11
|
+
var AI_EXPLANATION_TYPES = [
|
|
12
|
+
"multiple-choice",
|
|
13
|
+
"fill-in-the-blanks",
|
|
14
|
+
"gap-select",
|
|
15
|
+
"dictation"
|
|
16
|
+
];
|
|
17
|
+
var AI_HINT_TYPES = [
|
|
18
|
+
"multiple-choice",
|
|
19
|
+
"fill-in-the-blanks",
|
|
20
|
+
"gap-select"
|
|
21
|
+
];
|
|
22
|
+
var AI_TEXT_MAX_LENGTH = 2e3;
|
|
23
|
+
function aiSupports(activityType, feature) {
|
|
24
|
+
const types = feature === "hint" ? AI_HINT_TYPES : AI_EXPLANATION_TYPES;
|
|
25
|
+
return types.includes(activityType);
|
|
26
|
+
}
|
|
27
|
+
function aiAllowedByContent(data, feature) {
|
|
28
|
+
const permissions = data.ai;
|
|
29
|
+
if (typeof permissions !== "object" || permissions === null) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
const key = feature === "hint" ? "hints" : "explanations";
|
|
33
|
+
return permissions[key] !== false;
|
|
34
|
+
}
|
|
35
|
+
function aiGradeOf(result) {
|
|
36
|
+
const ratio = result.maxScore > 0 ? result.score / result.maxScore : 0;
|
|
37
|
+
let category = "partly-correct";
|
|
38
|
+
if (ratio >= 1 - 1e-9) {
|
|
39
|
+
category = "correct";
|
|
40
|
+
} else if (ratio <= 1e-9) {
|
|
41
|
+
category = "incorrect";
|
|
42
|
+
}
|
|
43
|
+
return { score: result.score, maxScore: result.maxScore, passed: result.passed, category };
|
|
44
|
+
}
|
|
45
|
+
function placeholderOrder(passage) {
|
|
46
|
+
return [...passage.matchAll(PLACEHOLDER_RE)].map((match) => match[1]);
|
|
47
|
+
}
|
|
48
|
+
function numberedPassage(passage) {
|
|
49
|
+
let position = 0;
|
|
50
|
+
return passage.replace(PLACEHOLDER_RE, () => {
|
|
51
|
+
position += 1;
|
|
52
|
+
return `[${position}]`;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function isAnswerOption(detail, chosen) {
|
|
56
|
+
switch (detail.outcome) {
|
|
57
|
+
case "correct":
|
|
58
|
+
case "incorrect-omission":
|
|
59
|
+
return true;
|
|
60
|
+
case "incorrect":
|
|
61
|
+
case "correct-omission":
|
|
62
|
+
return false;
|
|
63
|
+
default:
|
|
64
|
+
return chosen ? detail.correct : !detail.correct;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
var firstString = (value) => Array.isArray(value) ? value[0] : value;
|
|
68
|
+
var listOf = (value) => value === void 0 ? null : Array.isArray(value) ? [...value] : [value];
|
|
69
|
+
function emptyResponse(type) {
|
|
70
|
+
switch (type) {
|
|
71
|
+
case "multiple-choice":
|
|
72
|
+
return { type: "multiple-choice", selectedOptionIds: [] };
|
|
73
|
+
case "fill-in-the-blanks":
|
|
74
|
+
return { type: "fill-in-the-blanks", answers: {} };
|
|
75
|
+
case "gap-select":
|
|
76
|
+
return { type: "gap-select", selections: {} };
|
|
77
|
+
case "dictation":
|
|
78
|
+
return { type: "dictation", text: "" };
|
|
79
|
+
default:
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
var base = (data) => ({
|
|
84
|
+
activityId: data.id,
|
|
85
|
+
title: data.title,
|
|
86
|
+
...typeof data.locale === "string" ? { locale: data.locale } : {}
|
|
87
|
+
});
|
|
88
|
+
function buildAiFacts(data, response, details) {
|
|
89
|
+
const byId = new Map((details ?? []).map((detail) => [detail.itemId, detail]));
|
|
90
|
+
const full = data.redacted !== true;
|
|
91
|
+
switch (data.type) {
|
|
92
|
+
case "multiple-choice": {
|
|
93
|
+
const item = data;
|
|
94
|
+
const chosen = new Set(
|
|
95
|
+
response?.type === "multiple-choice" ? response.selectedOptionIds : []
|
|
96
|
+
);
|
|
97
|
+
return {
|
|
98
|
+
...base(data),
|
|
99
|
+
activityType: "multiple-choice",
|
|
100
|
+
question: item.question,
|
|
101
|
+
mode: item.mode,
|
|
102
|
+
options: item.options.map((option) => {
|
|
103
|
+
const detail = byId.get(option.id);
|
|
104
|
+
const wasChosen = chosen.has(option.id);
|
|
105
|
+
let correct = null;
|
|
106
|
+
if (full && typeof option.isCorrect === "boolean") {
|
|
107
|
+
correct = option.isCorrect;
|
|
108
|
+
} else if (detail !== void 0) {
|
|
109
|
+
correct = isAnswerOption(detail, wasChosen);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
id: option.id,
|
|
113
|
+
text: option.text,
|
|
114
|
+
chosen: wasChosen,
|
|
115
|
+
correct,
|
|
116
|
+
...typeof option.feedback === "string" ? { feedback: option.feedback } : {}
|
|
117
|
+
};
|
|
118
|
+
})
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
case "fill-in-the-blanks": {
|
|
122
|
+
const item = data;
|
|
123
|
+
const answers = response?.type === "fill-in-the-blanks" ? response.answers : {};
|
|
124
|
+
const blanks = new Map(item.blanks.map((blank) => [blank.id, blank]));
|
|
125
|
+
return {
|
|
126
|
+
...base(data),
|
|
127
|
+
activityType: "fill-in-the-blanks",
|
|
128
|
+
passage: numberedPassage(item.passage),
|
|
129
|
+
blanks: placeholderOrder(item.passage).map((id, index) => {
|
|
130
|
+
const blank = blanks.get(id);
|
|
131
|
+
const detail = byId.get(id);
|
|
132
|
+
const accepted = full && Array.isArray(blank?.acceptedAnswers) ? [...blank.acceptedAnswers] : listOf(detail?.correctResponse);
|
|
133
|
+
return {
|
|
134
|
+
id,
|
|
135
|
+
position: index + 1,
|
|
136
|
+
typed: typeof answers[id] === "string" ? answers[id] : "",
|
|
137
|
+
accepted,
|
|
138
|
+
correct: detail === void 0 ? null : detail.correct,
|
|
139
|
+
...typeof blank?.hint === "string" ? { hint: blank.hint } : {},
|
|
140
|
+
...typeof blank?.feedback === "string" ? { feedback: blank.feedback } : {}
|
|
141
|
+
};
|
|
142
|
+
})
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
case "gap-select": {
|
|
146
|
+
const item = data;
|
|
147
|
+
const selections = response?.type === "gap-select" ? response.selections : {};
|
|
148
|
+
const gaps = new Map(item.gaps.map((gap) => [gap.id, gap]));
|
|
149
|
+
const banks = new Map((item.banks ?? []).map((bank) => [bank.id, bank]));
|
|
150
|
+
return {
|
|
151
|
+
...base(data),
|
|
152
|
+
activityType: "gap-select",
|
|
153
|
+
passage: numberedPassage(item.passage),
|
|
154
|
+
gaps: placeholderOrder(item.passage).map((id, index) => {
|
|
155
|
+
const gap = gaps.get(id);
|
|
156
|
+
const choices = gap?.choices ?? (gap?.bankId === void 0 ? [] : banks.get(gap.bankId)?.choices ?? []);
|
|
157
|
+
const textOf = (choiceId) => choiceId === void 0 ? null : choices.find((choice) => choice.id === choiceId)?.text ?? null;
|
|
158
|
+
const detail = byId.get(id);
|
|
159
|
+
const answerId = full && typeof gap?.correctChoiceId === "string" ? gap.correctChoiceId : firstString(detail?.correctResponse);
|
|
160
|
+
return {
|
|
161
|
+
id,
|
|
162
|
+
position: index + 1,
|
|
163
|
+
choices: choices.map((choice) => choice.text),
|
|
164
|
+
chosen: textOf(selections[id]),
|
|
165
|
+
answer: textOf(answerId),
|
|
166
|
+
correct: detail === void 0 ? null : detail.correct,
|
|
167
|
+
...typeof gap?.feedback === "string" ? { feedback: gap.feedback } : {}
|
|
168
|
+
};
|
|
169
|
+
})
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
case "dictation": {
|
|
173
|
+
const item = data;
|
|
174
|
+
const typed = response?.type === "dictation" ? response.text : "";
|
|
175
|
+
const hasKey = full && typeof item.transcript === "string";
|
|
176
|
+
let words = null;
|
|
177
|
+
if (hasKey) {
|
|
178
|
+
words = alignDictation(item, typed).words.map((word) => ({
|
|
179
|
+
expected: word.reference,
|
|
180
|
+
typed: word.attempt,
|
|
181
|
+
status: word.status
|
|
182
|
+
}));
|
|
183
|
+
} else if (details !== null && details.length > 0) {
|
|
184
|
+
words = details.map((detail) => {
|
|
185
|
+
const expected = firstString(detail.correctResponse) ?? "";
|
|
186
|
+
const attempt = firstString(detail.learnerResponse) ?? "";
|
|
187
|
+
let status = "incorrect";
|
|
188
|
+
if (detail.correct) {
|
|
189
|
+
status = "correct";
|
|
190
|
+
} else if (attempt === "") {
|
|
191
|
+
status = "missing";
|
|
192
|
+
} else if (expected === "") {
|
|
193
|
+
status = "extra";
|
|
194
|
+
}
|
|
195
|
+
return { expected, typed: attempt, status };
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
...base(data),
|
|
200
|
+
activityType: "dictation",
|
|
201
|
+
transcript: hasKey ? item.transcript : null,
|
|
202
|
+
typed,
|
|
203
|
+
words
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
default:
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function gradeLocally(data, response) {
|
|
211
|
+
if (data.redacted === true) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const result = score(data.type, data, response);
|
|
216
|
+
return { details: result.details, grade: aiGradeOf(result) };
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function aiExplanationRequest(input) {
|
|
222
|
+
const { data, response, outcome, learnerLocale } = input;
|
|
223
|
+
if (!aiSupports(data.type, "explanation") || !aiAllowedByContent(data, "explanation")) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
let details = null;
|
|
227
|
+
let grade = null;
|
|
228
|
+
if (outcome?.status === "scored") {
|
|
229
|
+
const { score: recorded, maxScore, passed } = outcome;
|
|
230
|
+
if (!isGradeInRange(recorded, maxScore)) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
details = outcome.details;
|
|
234
|
+
grade = aiGradeOf({ score: recorded, maxScore, passed });
|
|
235
|
+
} else if (response !== null) {
|
|
236
|
+
const local = gradeLocally(data, response);
|
|
237
|
+
if (local !== null) {
|
|
238
|
+
details = local.details;
|
|
239
|
+
grade = local.grade;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (grade === null) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const facts = buildAiFacts(data, response, details);
|
|
246
|
+
if (facts === null) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
feature: "explanation",
|
|
251
|
+
facts,
|
|
252
|
+
grade,
|
|
253
|
+
...learnerLocale !== void 0 ? { learnerLocale } : {}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function aiHintRequest(input) {
|
|
257
|
+
const { data, previousHints, learnerLocale } = input;
|
|
258
|
+
if (!aiSupports(data.type, "hint") || !aiAllowedByContent(data, "hint") || data.redacted === true) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const response = input.response ?? emptyResponse(data.type);
|
|
262
|
+
const local = response === null ? null : gradeLocally(data, response);
|
|
263
|
+
const facts = buildAiFacts(data, response, local?.details ?? null);
|
|
264
|
+
if (facts === null) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
feature: "hint",
|
|
269
|
+
facts,
|
|
270
|
+
hintNumber: previousHints.length + 1,
|
|
271
|
+
previousHints: [...previousHints],
|
|
272
|
+
...learnerLocale !== void 0 ? { learnerLocale } : {}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
var VERDICTS = ["correct", "partly-correct", "incorrect"];
|
|
276
|
+
function provenanceOf(raw) {
|
|
277
|
+
if (typeof raw !== "object" || raw === null) {
|
|
278
|
+
return void 0;
|
|
279
|
+
}
|
|
280
|
+
const kept = {};
|
|
281
|
+
for (const key of ["model", "promptHash", "generatedAt"]) {
|
|
282
|
+
const value = raw[key];
|
|
283
|
+
if (typeof value === "string" && value.length > 0 && value.length <= 200) {
|
|
284
|
+
kept[key] = value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return Object.keys(kept).length > 0 ? kept : void 0;
|
|
288
|
+
}
|
|
289
|
+
function usageOf(raw) {
|
|
290
|
+
if (typeof raw !== "object" || raw === null) {
|
|
291
|
+
return void 0;
|
|
292
|
+
}
|
|
293
|
+
const kept = {};
|
|
294
|
+
for (const key of ["promptTokens", "completionTokens", "costUsd"]) {
|
|
295
|
+
const value = raw[key];
|
|
296
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
297
|
+
kept[key] = value;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return Object.keys(kept).length > 0 ? kept : void 0;
|
|
301
|
+
}
|
|
302
|
+
function cleanText(text) {
|
|
303
|
+
return text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u2028\u2029]/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
304
|
+
}
|
|
305
|
+
function readAiTextResult(raw) {
|
|
306
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
307
|
+
return { ok: false, refusal: "malformed" };
|
|
308
|
+
}
|
|
309
|
+
const record = raw;
|
|
310
|
+
if (typeof record.text !== "string") {
|
|
311
|
+
return { ok: false, refusal: "malformed" };
|
|
312
|
+
}
|
|
313
|
+
if (record.verdict !== void 0 && !VERDICTS.includes(record.verdict)) {
|
|
314
|
+
return { ok: false, refusal: "malformed" };
|
|
315
|
+
}
|
|
316
|
+
const text = cleanText(record.text);
|
|
317
|
+
if (text === "") {
|
|
318
|
+
return { ok: false, refusal: "empty" };
|
|
319
|
+
}
|
|
320
|
+
if ([...text].length > AI_TEXT_MAX_LENGTH) {
|
|
321
|
+
return { ok: false, refusal: "too-long" };
|
|
322
|
+
}
|
|
323
|
+
const provenance = provenanceOf(record.provenance);
|
|
324
|
+
const usage = usageOf(record.usage);
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
result: {
|
|
328
|
+
text,
|
|
329
|
+
...record.verdict !== void 0 ? { verdict: record.verdict } : {},
|
|
330
|
+
...provenance !== void 0 ? { provenance } : {},
|
|
331
|
+
...usage !== void 0 ? { usage } : {}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function checkAiExplanation(raw, request) {
|
|
336
|
+
const read = readAiTextResult(raw);
|
|
337
|
+
if (!read.ok) {
|
|
338
|
+
return read;
|
|
339
|
+
}
|
|
340
|
+
if (read.result.verdict !== void 0 && read.result.verdict !== request.grade.category) {
|
|
341
|
+
return { ok: false, refusal: "contradicts-grade" };
|
|
342
|
+
}
|
|
343
|
+
return read;
|
|
344
|
+
}
|
|
345
|
+
function checkAiHint(raw, request) {
|
|
346
|
+
const read = readAiTextResult(raw);
|
|
347
|
+
if (!read.ok) {
|
|
348
|
+
return read;
|
|
349
|
+
}
|
|
350
|
+
if (hintRevealsAnswer(request.facts, read.result.text)) {
|
|
351
|
+
return { ok: false, refusal: "reveals-answer" };
|
|
352
|
+
}
|
|
353
|
+
return read;
|
|
354
|
+
}
|
|
355
|
+
function fold(text) {
|
|
356
|
+
const folded = text.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
357
|
+
return folded === "" ? [] : folded.split(" ");
|
|
358
|
+
}
|
|
359
|
+
function containsWords(haystack, needle) {
|
|
360
|
+
if (needle.length === 0 || needle.length > haystack.length) {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
for (let start = 0; start + needle.length <= haystack.length; start += 1) {
|
|
364
|
+
let match = true;
|
|
365
|
+
for (let offset = 0; offset < needle.length; offset += 1) {
|
|
366
|
+
if (haystack[start + offset] !== needle[offset]) {
|
|
367
|
+
match = false;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (match) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
var isShort = (words) => words.length === 1 && [...words[0]].length < 4;
|
|
378
|
+
function neighbours(passage, position) {
|
|
379
|
+
const marker = `[${position}]`;
|
|
380
|
+
const at = passage.indexOf(marker);
|
|
381
|
+
if (at === -1) {
|
|
382
|
+
return {};
|
|
383
|
+
}
|
|
384
|
+
const strip = (text) => text.replace(/\[\d+\]/g, " ");
|
|
385
|
+
const left = fold(strip(passage.slice(0, at)));
|
|
386
|
+
const right = fold(strip(passage.slice(at + marker.length)));
|
|
387
|
+
const before = left[left.length - 1];
|
|
388
|
+
const after = right[0];
|
|
389
|
+
return {
|
|
390
|
+
...before !== void 0 ? { before } : {},
|
|
391
|
+
...after !== void 0 ? { after } : {}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function hintRevealsAnswer(facts, hint) {
|
|
395
|
+
const words = fold(hint);
|
|
396
|
+
const reveals = (answer, passage, position) => {
|
|
397
|
+
const needle = fold(answer);
|
|
398
|
+
if (needle.length === 0) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
if (!isShort(needle) || passage === void 0 || position === void 0) {
|
|
402
|
+
return containsWords(words, needle);
|
|
403
|
+
}
|
|
404
|
+
const { before, after } = neighbours(passage, position);
|
|
405
|
+
if (before === void 0 && after === void 0) {
|
|
406
|
+
return containsWords(words, needle);
|
|
407
|
+
}
|
|
408
|
+
return before !== void 0 && containsWords(words, [before, ...needle]) || after !== void 0 && containsWords(words, [...needle, after]);
|
|
409
|
+
};
|
|
410
|
+
switch (facts.activityType) {
|
|
411
|
+
case "multiple-choice":
|
|
412
|
+
return facts.options.some((option) => option.correct === true && reveals(option.text));
|
|
413
|
+
case "fill-in-the-blanks":
|
|
414
|
+
return facts.blanks.some(
|
|
415
|
+
(blank) => (blank.accepted ?? []).some((answer) => reveals(answer, facts.passage, blank.position))
|
|
416
|
+
);
|
|
417
|
+
case "gap-select":
|
|
418
|
+
return facts.gaps.some(
|
|
419
|
+
(gap) => gap.answer !== null && reveals(gap.answer, facts.passage, gap.position)
|
|
420
|
+
);
|
|
421
|
+
case "dictation":
|
|
422
|
+
return false;
|
|
423
|
+
default:
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export {
|
|
429
|
+
AI_EXPLANATION_TYPES,
|
|
430
|
+
AI_HINT_TYPES,
|
|
431
|
+
AI_TEXT_MAX_LENGTH,
|
|
432
|
+
aiSupports,
|
|
433
|
+
aiAllowedByContent,
|
|
434
|
+
aiGradeOf,
|
|
435
|
+
buildAiFacts,
|
|
436
|
+
aiExplanationRequest,
|
|
437
|
+
aiHintRequest,
|
|
438
|
+
readAiTextResult,
|
|
439
|
+
checkAiExplanation,
|
|
440
|
+
checkAiHint,
|
|
441
|
+
hintRevealsAnswer
|
|
442
|
+
};
|
|
443
|
+
//# sourceMappingURL=chunk-BIJCO2HM.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ai.ts"],"sourcesContent":["import { PLACEHOLDER_RE } from './schemas/fill-in-the-blanks.js';\nimport { alignDictation } from './scoring/dictation/align.js';\nimport { isGradeInRange } from './scoring/grade-numbers.js';\nimport { score } from './scoring/index.js';\nimport type {\n ActivityData,\n DictationData,\n FillInTheBlanksData,\n GapSelectData,\n ItemOutcome,\n LearnerResponse,\n MultipleChoiceData,\n ScoringDetail,\n} from './types/activity.js';\nimport type {\n AiActivityInput,\n AiExplanationRequest,\n AiFeature,\n AiGrade,\n AiHintRequest,\n AiItemFacts,\n AiProvenance,\n AiRefusal,\n AiSupportedActivityType,\n AiTextResult,\n AiVerdict,\n AiWordFact,\n} from './types/ai.js';\nimport type { GraderUsage } from './types/grading.js';\n\n/**\n * The activity types the SDK builds facts for, and which of them take hints.\n * A dictation takes none: its answer is the whole sentence, and it has\n * word-by-word hints of its own.\n */\nexport const AI_EXPLANATION_TYPES: readonly AiSupportedActivityType[] = [\n 'multiple-choice',\n 'fill-in-the-blanks',\n 'gap-select',\n 'dictation',\n];\nexport const AI_HINT_TYPES: readonly AiSupportedActivityType[] = [\n 'multiple-choice',\n 'fill-in-the-blanks',\n 'gap-select',\n];\n\n/** The longest AI text shown, in code points. Longer is refused, not cut. */\nexport const AI_TEXT_MAX_LENGTH = 2000;\n\n/**\n * Whether the SDK supports `feature` for this activity type. A type it builds\n * no facts for — a written response, a read-aloud, a consumer's own — has\n * neither.\n */\nexport function aiSupports(activityType: string, feature: AiFeature): boolean {\n const types = feature === 'hint' ? AI_HINT_TYPES : AI_EXPLANATION_TYPES;\n return (types as readonly string[]).includes(activityType);\n}\n\n/**\n * Whether the item's author allows `feature`. Only an explicit `false`\n * switches it off: an author can refuse a feature, never force one on.\n */\nexport function aiAllowedByContent(data: AiActivityInput, feature: AiFeature): boolean {\n const permissions = (data as { ai?: unknown }).ai;\n if (typeof permissions !== 'object' || permissions === null) {\n return true;\n }\n const key = feature === 'hint' ? 'hints' : 'explanations';\n return (permissions as Record<string, unknown>)[key] !== false;\n}\n\n/** A grade as an explanation must speak to it: all marks, some, or none. */\nexport function aiGradeOf(result: { score: number; maxScore: number; passed: boolean }): AiGrade {\n const ratio = result.maxScore > 0 ? result.score / result.maxScore : 0;\n let category: AiVerdict = 'partly-correct';\n if (ratio >= 1 - 1e-9) {\n category = 'correct';\n } else if (ratio <= 1e-9) {\n category = 'incorrect';\n }\n return { score: result.score, maxScore: result.maxScore, passed: result.passed, category };\n}\n\n// ── Facts ──────────────────────────────────────────────────────────────\n\n/**\n * The ids of a passage's placeholders, in the order they appear. Read with the\n * schema's own expression, so a passage means here what it means to validation.\n */\nfunction placeholderOrder(passage: string): string[] {\n return [...passage.matchAll(PLACEHOLDER_RE)].map((match) => match[1] as string);\n}\n\n/** The passage with each placeholder written as `[n]`, in passage order. */\nfunction numberedPassage(passage: string): string {\n let position = 0;\n return passage.replace(PLACEHOLDER_RE, () => {\n position += 1;\n return `[${position}]`;\n });\n}\n\n/** Whether an option is part of the answer, read from a server's detail (as the renderer reads it). */\nfunction isAnswerOption(detail: ScoringDetail, chosen: boolean): boolean {\n switch (detail.outcome) {\n case 'correct':\n case 'incorrect-omission':\n return true;\n case 'incorrect':\n case 'correct-omission':\n return false;\n default:\n return chosen ? detail.correct : !detail.correct;\n }\n}\n\nconst firstString = (value: string | string[] | undefined): string | undefined =>\n Array.isArray(value) ? value[0] : value;\n\nconst listOf = (value: string | string[] | undefined): string[] | null =>\n value === undefined ? null : Array.isArray(value) ? [...value] : [value];\n\n/** The response a learner has before touching anything. */\nfunction emptyResponse(type: string): LearnerResponse | null {\n switch (type) {\n case 'multiple-choice':\n return { type: 'multiple-choice', selectedOptionIds: [] };\n case 'fill-in-the-blanks':\n return { type: 'fill-in-the-blanks', answers: {} };\n case 'gap-select':\n return { type: 'gap-select', selections: {} };\n case 'dictation':\n return { type: 'dictation', text: '' };\n default:\n return null;\n }\n}\n\nconst base = (data: AiActivityInput) => ({\n activityId: data.id,\n title: data.title,\n ...(typeof data.locale === 'string' ? { locale: data.locale } : {}),\n});\n\n/**\n * The facts a model is given about one item and one answer. `details` are the\n * SDK's per-part verdicts — from its own scorer, or from a server's outcome in\n * review — and every `correct` is `null` without them. A redacted item yields\n * facts without a key, which is what the learner's side holds; a port that\n * needs the key loads the item on its server.\n *\n * `null` for a type the SDK builds no facts for.\n */\nexport function buildAiFacts(\n data: AiActivityInput,\n response: LearnerResponse | null,\n details: readonly ScoringDetail[] | null,\n): AiItemFacts | null {\n const byId = new Map((details ?? []).map((detail) => [detail.itemId, detail]));\n const full = (data as { redacted?: unknown }).redacted !== true;\n switch (data.type) {\n case 'multiple-choice': {\n const item = data as unknown as MultipleChoiceData;\n const chosen = new Set(\n response?.type === 'multiple-choice' ? response.selectedOptionIds : [],\n );\n return {\n ...base(data),\n activityType: 'multiple-choice',\n question: item.question,\n mode: item.mode,\n options: item.options.map((option) => {\n const detail = byId.get(option.id);\n const wasChosen = chosen.has(option.id);\n let correct: boolean | null = null;\n if (full && typeof option.isCorrect === 'boolean') {\n correct = option.isCorrect;\n } else if (detail !== undefined) {\n correct = isAnswerOption(detail, wasChosen);\n }\n return {\n id: option.id,\n text: option.text,\n chosen: wasChosen,\n correct,\n ...(typeof option.feedback === 'string' ? { feedback: option.feedback } : {}),\n };\n }),\n };\n }\n case 'fill-in-the-blanks': {\n const item = data as unknown as FillInTheBlanksData;\n const answers = response?.type === 'fill-in-the-blanks' ? response.answers : {};\n const blanks = new Map(item.blanks.map((blank) => [blank.id, blank]));\n return {\n ...base(data),\n activityType: 'fill-in-the-blanks',\n passage: numberedPassage(item.passage),\n blanks: placeholderOrder(item.passage).map((id, index) => {\n const blank = blanks.get(id);\n const detail = byId.get(id);\n const accepted =\n full && Array.isArray(blank?.acceptedAnswers)\n ? [...blank.acceptedAnswers]\n : listOf(detail?.correctResponse);\n return {\n id,\n position: index + 1,\n typed: typeof answers[id] === 'string' ? (answers[id] as string) : '',\n accepted,\n correct: detail === undefined ? null : detail.correct,\n ...(typeof blank?.hint === 'string' ? { hint: blank.hint } : {}),\n ...(typeof blank?.feedback === 'string' ? { feedback: blank.feedback } : {}),\n };\n }),\n };\n }\n case 'gap-select': {\n const item = data as unknown as GapSelectData;\n const selections = response?.type === 'gap-select' ? response.selections : {};\n const gaps = new Map(item.gaps.map((gap) => [gap.id, gap]));\n const banks = new Map((item.banks ?? []).map((bank) => [bank.id, bank]));\n return {\n ...base(data),\n activityType: 'gap-select',\n passage: numberedPassage(item.passage),\n gaps: placeholderOrder(item.passage).map((id, index) => {\n const gap = gaps.get(id);\n const choices =\n gap?.choices ??\n (gap?.bankId === undefined ? [] : (banks.get(gap.bankId)?.choices ?? []));\n const textOf = (choiceId: string | undefined): string | null =>\n choiceId === undefined\n ? null\n : (choices.find((choice) => choice.id === choiceId)?.text ?? null);\n const detail = byId.get(id);\n const answerId =\n full && typeof gap?.correctChoiceId === 'string'\n ? gap.correctChoiceId\n : firstString(detail?.correctResponse);\n return {\n id,\n position: index + 1,\n choices: choices.map((choice) => choice.text),\n chosen: textOf(selections[id]),\n answer: textOf(answerId),\n correct: detail === undefined ? null : detail.correct,\n ...(typeof gap?.feedback === 'string' ? { feedback: gap.feedback } : {}),\n };\n }),\n };\n }\n case 'dictation': {\n const item = data as unknown as DictationData;\n const typed = response?.type === 'dictation' ? response.text : '';\n const hasKey = full && typeof item.transcript === 'string';\n let words: AiWordFact[] | null = null;\n if (hasKey) {\n words = alignDictation(item, typed).words.map((word) => ({\n expected: word.reference,\n typed: word.attempt,\n status: word.status,\n }));\n } else if (details !== null && details.length > 0) {\n words = details.map((detail) => {\n const expected = firstString(detail.correctResponse) ?? '';\n const attempt = firstString(detail.learnerResponse) ?? '';\n let status: 'correct' | 'incorrect' | 'missing' | 'extra' = 'incorrect';\n if (detail.correct) {\n status = 'correct';\n } else if (attempt === '') {\n status = 'missing';\n } else if (expected === '') {\n status = 'extra';\n }\n return { expected, typed: attempt, status };\n });\n }\n return {\n ...base(data),\n activityType: 'dictation',\n transcript: hasKey ? item.transcript : null,\n typed,\n words,\n };\n }\n default:\n return null;\n }\n}\n\n/** The SDK's own grade of `response`, with its per-part details; `null` when it cannot grade. */\nfunction gradeLocally(\n data: AiActivityInput,\n response: LearnerResponse,\n): { details: ScoringDetail[]; grade: AiGrade } | null {\n if ((data as { redacted?: unknown }).redacted === true) {\n return null;\n }\n try {\n const result = score(data.type as never, data as unknown as ActivityData, response);\n return { details: result.details, grade: aiGradeOf(result) };\n } catch {\n return null;\n }\n}\n\n/**\n * The request for an explanation of `response`, or `null` when there is none\n * to give: an unsupported type, an author who switched explanations off,\n * nothing graded to explain, or a grade of record that cannot be a grade.\n *\n * A scored `outcome` — the grade of record a review shows — wins over grading\n * locally, so the explanation speaks to the grade on the screen. One whose\n * numbers cannot be a grade (the rule `outcomeFromGrade` applies: `maxScore`\n * positive and finite, `score` finite from 0 to it) gets no explanation at all:\n * explaining 85 \"out of 1\" would tell the model a wrong answer was correct, and\n * grading locally instead would speak to a grade the screen does not show.\n */\nexport function aiExplanationRequest(input: {\n data: AiActivityInput;\n response: LearnerResponse | null;\n outcome?: ItemOutcome;\n learnerLocale?: string;\n}): AiExplanationRequest | null {\n const { data, response, outcome, learnerLocale } = input;\n if (!aiSupports(data.type, 'explanation') || !aiAllowedByContent(data, 'explanation')) {\n return null;\n }\n let details: ScoringDetail[] | null = null;\n let grade: AiGrade | null = null;\n if (outcome?.status === 'scored') {\n // Read once, so the check and the grade handed on see the same numbers.\n const { score: recorded, maxScore, passed } = outcome;\n if (!isGradeInRange(recorded, maxScore)) {\n return null;\n }\n details = outcome.details;\n grade = aiGradeOf({ score: recorded, maxScore, passed });\n } else if (response !== null) {\n const local = gradeLocally(data, response);\n if (local !== null) {\n details = local.details;\n grade = local.grade;\n }\n }\n if (grade === null) {\n return null;\n }\n const facts = buildAiFacts(data, response, details);\n if (facts === null) {\n return null;\n }\n return {\n feature: 'explanation',\n facts,\n grade,\n ...(learnerLocale !== undefined ? { learnerLocale } : {}),\n };\n}\n\n/**\n * The request for the next hint on an unsubmitted answer, or `null` when\n * there is none to give: an unsupported type, an author who switched hints\n * off, or a redacted item — hints are for practice, where the item is whole.\n *\n * The facts carry the key, and which parts the learner has right so far, so a\n * model can hint where it helps. The learner never sees them; a hint that\n * contains an answer is refused by {@link checkAiHint}.\n */\nexport function aiHintRequest(input: {\n data: AiActivityInput;\n response: LearnerResponse | null;\n previousHints: readonly string[];\n learnerLocale?: string;\n}): AiHintRequest | null {\n const { data, previousHints, learnerLocale } = input;\n if (\n !aiSupports(data.type, 'hint') ||\n !aiAllowedByContent(data, 'hint') ||\n (data as { redacted?: unknown }).redacted === true\n ) {\n return null;\n }\n const response = input.response ?? emptyResponse(data.type);\n const local = response === null ? null : gradeLocally(data, response);\n const facts = buildAiFacts(data, response, local?.details ?? null);\n if (facts === null) {\n return null;\n }\n return {\n feature: 'hint',\n facts,\n hintNumber: previousHints.length + 1,\n previousHints: [...previousHints],\n ...(learnerLocale !== undefined ? { learnerLocale } : {}),\n };\n}\n\n// ── Checking what comes back ───────────────────────────────────────────\n\nconst VERDICTS: readonly string[] = ['correct', 'partly-correct', 'incorrect'];\n\n/** A provenance field a host may send, kept only when it is a short string. */\nfunction provenanceOf(raw: unknown): AiProvenance | undefined {\n if (typeof raw !== 'object' || raw === null) {\n return undefined;\n }\n const kept: AiProvenance = {};\n for (const key of ['model', 'promptHash', 'generatedAt'] as const) {\n const value = (raw as Record<string, unknown>)[key];\n if (typeof value === 'string' && value.length > 0 && value.length <= 200) {\n kept[key] = value;\n }\n }\n return Object.keys(kept).length > 0 ? kept : undefined;\n}\n\n/**\n * What the call cost, as a port reported it. Read on the same terms as the\n * provenance: a number that is not finite and zero or more says nothing about\n * a cost, so it is dropped rather than carried into a host's totals.\n */\nfunction usageOf(raw: unknown): GraderUsage | undefined {\n if (typeof raw !== 'object' || raw === null) {\n return undefined;\n }\n const kept: GraderUsage = {};\n for (const key of ['promptTokens', 'completionTokens', 'costUsd'] as const) {\n const value = (raw as Record<string, unknown>)[key];\n if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {\n kept[key] = value;\n }\n }\n return Object.keys(kept).length > 0 ? kept : undefined;\n}\n\n/**\n * Plain text as the learner will read it: line endings unified, control\n * characters other than line breaks and tabs removed, runs of blank lines\n * collapsed, the ends trimmed.\n */\nfunction cleanText(text: string): string {\n return (\n text\n .replace(/\\r\\n?/g, '\\n')\n // biome-ignore lint/suspicious/noControlCharactersInRegex: removing them is the point\n .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F\\u2028\\u2029]/g, '')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n );\n}\n\n/**\n * Reads what a port returned. Anything but an object with a string `text` is\n * `malformed`; text that is empty once cleaned, or longer than\n * {@link AI_TEXT_MAX_LENGTH}, is refused rather than shown or cut.\n */\nexport function readAiTextResult(\n raw: unknown,\n): { ok: true; result: AiTextResult } | { ok: false; refusal: AiRefusal } {\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {\n return { ok: false, refusal: 'malformed' };\n }\n const record = raw as Record<string, unknown>;\n if (typeof record.text !== 'string') {\n return { ok: false, refusal: 'malformed' };\n }\n if (record.verdict !== undefined && !VERDICTS.includes(record.verdict as string)) {\n return { ok: false, refusal: 'malformed' };\n }\n const text = cleanText(record.text);\n if (text === '') {\n return { ok: false, refusal: 'empty' };\n }\n if ([...text].length > AI_TEXT_MAX_LENGTH) {\n return { ok: false, refusal: 'too-long' };\n }\n const provenance = provenanceOf(record.provenance);\n const usage = usageOf(record.usage);\n return {\n ok: true,\n result: {\n text,\n ...(record.verdict !== undefined ? { verdict: record.verdict as AiVerdict } : {}),\n ...(provenance !== undefined ? { provenance } : {}),\n ...(usage !== undefined ? { usage } : {}),\n },\n };\n}\n\n/**\n * Checks an explanation a port returned for `request`. On top of\n * {@link readAiTextResult}: an explanation that states a verdict other than\n * the SDK's grade is refused (`contradicts-grade`) — it explains an answer\n * the learner did not get.\n */\nexport function checkAiExplanation(\n raw: unknown,\n request: AiExplanationRequest,\n): { ok: true; result: AiTextResult } | { ok: false; refusal: AiRefusal } {\n const read = readAiTextResult(raw);\n if (!read.ok) {\n return read;\n }\n if (read.result.verdict !== undefined && read.result.verdict !== request.grade.category) {\n return { ok: false, refusal: 'contradicts-grade' };\n }\n return read;\n}\n\n/**\n * Checks a hint a port returned for `request`. On top of\n * {@link readAiTextResult}: a hint that contains an answer is refused\n * (`reveals-answer`) — see {@link hintRevealsAnswer}.\n */\nexport function checkAiHint(\n raw: unknown,\n request: AiHintRequest,\n): { ok: true; result: AiTextResult } | { ok: false; refusal: AiRefusal } {\n const read = readAiTextResult(raw);\n if (!read.ok) {\n return read;\n }\n if (hintRevealsAnswer(request.facts, read.result.text)) {\n return { ok: false, refusal: 'reveals-answer' };\n }\n return read;\n}\n\n// ── The answer-leak guard ──────────────────────────────────────────────\n\n/**\n * Text folded for searching: compatibility forms read as what they stand for,\n * accents and other marks removed, lower-cased, and every run of anything but\n * letters and digits turned into one space. \"Está,\" and \"esta\" fold alike.\n */\nfunction fold(text: string): string[] {\n const folded = text\n .normalize('NFKD')\n .replace(/\\p{M}/gu, '')\n .toLowerCase()\n .replace(/[^\\p{L}\\p{N}]+/gu, ' ')\n .trim();\n return folded === '' ? [] : folded.split(' ');\n}\n\n/** Whether `needle` appears in `haystack` as a run of whole words. */\nfunction containsWords(haystack: readonly string[], needle: readonly string[]): boolean {\n if (needle.length === 0 || needle.length > haystack.length) {\n return false;\n }\n for (let start = 0; start + needle.length <= haystack.length; start += 1) {\n let match = true;\n for (let offset = 0; offset < needle.length; offset += 1) {\n if (haystack[start + offset] !== needle[offset]) {\n match = false;\n break;\n }\n }\n if (match) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * An answer too short to search for on its own: one word of fewer than four\n * characters — \"is\", \"the\", \"26\". A hint may use such a word freely; it\n * reveals the answer only when it writes it where the answer goes.\n */\nconst isShort = (words: readonly string[]): boolean =>\n words.length === 1 && [...(words[0] as string)].length < 4;\n\n/**\n * The words either side of the gap numbered `position` in a numbered\n * passage, other gaps left out.\n */\nfunction neighbours(passage: string, position: number): { before?: string; after?: string } {\n const marker = `[${position}]`;\n const at = passage.indexOf(marker);\n if (at === -1) {\n return {};\n }\n const strip = (text: string): string => text.replace(/\\[\\d+\\]/g, ' ');\n const left = fold(strip(passage.slice(0, at)));\n const right = fold(strip(passage.slice(at + marker.length)));\n const before = left[left.length - 1];\n const after = right[0];\n return {\n ...(before !== undefined ? { before } : {}),\n ...(after !== undefined ? { after } : {}),\n };\n}\n\n/**\n * Whether a hint gives away an answer, judged against the facts it was asked\n * for. It is a floor, not a proof: it finds an answer written out, not one\n * spelled letter by letter or described.\n *\n * - **Multiple choice:** the text of any correct option, as whole words.\n * - **Fill-in-the-blanks and gap select:** any accepted answer, as whole words.\n * An answer of one short word (\"is\", \"the\") counts only where the hint\n * writes it beside a neighbour it has in the passage — \"name is\", \"is\n * Rossi\" — so a hint may still use the word on its own.\n *\n * Case, accents and punctuation are ignored on both sides.\n */\nexport function hintRevealsAnswer(facts: AiItemFacts, hint: string): boolean {\n const words = fold(hint);\n const reveals = (answer: string, passage?: string, position?: number): boolean => {\n const needle = fold(answer);\n if (needle.length === 0) {\n return false;\n }\n if (!isShort(needle) || passage === undefined || position === undefined) {\n return containsWords(words, needle);\n }\n const { before, after } = neighbours(passage, position);\n if (before === undefined && after === undefined) {\n return containsWords(words, needle);\n }\n return (\n (before !== undefined && containsWords(words, [before, ...needle])) ||\n (after !== undefined && containsWords(words, [...needle, after]))\n );\n };\n switch (facts.activityType) {\n case 'multiple-choice':\n return facts.options.some((option) => option.correct === true && reveals(option.text));\n case 'fill-in-the-blanks':\n return facts.blanks.some((blank) =>\n (blank.accepted ?? []).some((answer) => reveals(answer, facts.passage, blank.position)),\n );\n case 'gap-select':\n return facts.gaps.some(\n (gap) => gap.answer !== null && reveals(gap.answer, facts.passage, gap.position),\n );\n case 'dictation':\n // Never asked: a dictation takes no AI hints (AI_HINT_TYPES).\n return false;\n default:\n return false;\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -99,6 +99,12 @@ function selectFeedback(activityData, passed) {
|
|
|
99
99
|
return _nullishCoalesce((passed ? feedback.correct : feedback.incorrect), () => ( null));
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// src/scoring/grade-numbers.ts
|
|
103
|
+
var OVERSHOOT = 1e-9;
|
|
104
|
+
function isGradeInRange(score2, maxScore) {
|
|
105
|
+
return Number.isFinite(maxScore) && maxScore > 0 && Number.isFinite(score2) && score2 >= 0 && score2 - maxScore <= maxScore * OVERSHOOT;
|
|
106
|
+
}
|
|
107
|
+
|
|
102
108
|
// src/grading.ts
|
|
103
109
|
function gradeFromRubric(criteria, activityData, options = {}) {
|
|
104
110
|
const rounding = roundingPolicyOf(_optionalChain([options, 'optionalAccess', _ => _.rounding]));
|
|
@@ -147,12 +153,19 @@ function gradeFromRubric(criteria, activityData, options = {}) {
|
|
|
147
153
|
reason: "Criterion weights do not sum to a positive number, so the weighted total is undefined."
|
|
148
154
|
};
|
|
149
155
|
}
|
|
156
|
+
const negativeWeight = scoreable.find((criterion) => (_nullishCoalesce(criterion.weight, () => ( 1))) < 0);
|
|
157
|
+
if (negativeWeight !== void 0) {
|
|
158
|
+
return {
|
|
159
|
+
unscorable: true,
|
|
160
|
+
reason: `Criterion "${negativeWeight.name}" has weight ${String(negativeWeight.weight)}; a weight must be zero or more.`
|
|
161
|
+
};
|
|
162
|
+
}
|
|
150
163
|
const weighted = scoreable.reduce(
|
|
151
164
|
(sum, criterion) => sum + ratioOf(criterion) * (_nullishCoalesce(criterion.weight, () => ( 1))),
|
|
152
165
|
0
|
|
153
166
|
);
|
|
154
167
|
const rawScore = weighted / totalWeight;
|
|
155
|
-
if (!Number.isFinite(rawScore)) {
|
|
168
|
+
if (!Number.isFinite(totalWeight) || !Number.isFinite(rawScore)) {
|
|
156
169
|
return {
|
|
157
170
|
unscorable: true,
|
|
158
171
|
reason: "The weighted total is not a finite number, so no grade can be produced."
|
|
@@ -170,11 +183,15 @@ function gradeFromRubric(criteria, activityData, options = {}) {
|
|
|
170
183
|
};
|
|
171
184
|
}
|
|
172
185
|
function outcomeFromGrade(grade) {
|
|
186
|
+
const { score: score2, maxScore } = grade;
|
|
187
|
+
if (!isGradeInRange(score2, maxScore)) {
|
|
188
|
+
return { status: "deferred", reason: "grade_rejected", maxScore: 1, rejectedGrade: grade };
|
|
189
|
+
}
|
|
173
190
|
return {
|
|
174
191
|
status: "graded",
|
|
175
192
|
grade,
|
|
176
|
-
score:
|
|
177
|
-
maxScore
|
|
193
|
+
score: score2,
|
|
194
|
+
maxScore,
|
|
178
195
|
passed: grade.passed,
|
|
179
196
|
feedback: grade.feedback
|
|
180
197
|
};
|
|
@@ -187,29 +204,41 @@ function hasGrade(outcome) {
|
|
|
187
204
|
function earned(item) {
|
|
188
205
|
const { outcome } = item;
|
|
189
206
|
if (hasGrade(outcome)) {
|
|
190
|
-
const
|
|
191
|
-
|
|
207
|
+
const { score: score2, maxScore } = outcome;
|
|
208
|
+
if (!isGradeInRange(score2, maxScore)) {
|
|
209
|
+
return { state: "rejected" };
|
|
210
|
+
}
|
|
211
|
+
return { state: "graded", points: score2 / maxScore * item.points };
|
|
192
212
|
}
|
|
193
213
|
if (outcome.status === "deferred") {
|
|
194
|
-
return { state: "pending" };
|
|
214
|
+
return outcome.reason === "grade_rejected" ? { state: "rejected" } : { state: "pending" };
|
|
195
215
|
}
|
|
196
216
|
return { state: "unscorable" };
|
|
197
217
|
}
|
|
218
|
+
function rejectedField(slotIds) {
|
|
219
|
+
return slotIds.length > 0 ? { rejectedSlotIds: slotIds } : {};
|
|
220
|
+
}
|
|
198
221
|
function composeAssessmentScore(sections, policy) {
|
|
199
222
|
const pendingAll = [];
|
|
223
|
+
const rejectedAll = [];
|
|
200
224
|
const unscorableAll = [];
|
|
201
225
|
const partials = sections.map((section) => {
|
|
202
226
|
let earnedPoints = 0;
|
|
203
227
|
let gradedMaxPoints = 0;
|
|
204
228
|
let maxPoints = 0;
|
|
205
229
|
const pendingSlotIds = [];
|
|
230
|
+
const rejectedSlotIds = [];
|
|
206
231
|
const unscorableSlotIds = [];
|
|
207
232
|
for (const item of section.items) {
|
|
208
233
|
maxPoints += item.points;
|
|
209
234
|
const contribution = earned(item);
|
|
210
|
-
if (contribution.state === "pending") {
|
|
235
|
+
if (contribution.state === "pending" || contribution.state === "rejected") {
|
|
211
236
|
pendingSlotIds.push(item.slotId);
|
|
212
237
|
pendingAll.push(item.slotId);
|
|
238
|
+
if (contribution.state === "rejected") {
|
|
239
|
+
rejectedSlotIds.push(item.slotId);
|
|
240
|
+
rejectedAll.push(item.slotId);
|
|
241
|
+
}
|
|
213
242
|
continue;
|
|
214
243
|
}
|
|
215
244
|
if (contribution.state === "unscorable") {
|
|
@@ -230,6 +259,7 @@ function composeAssessmentScore(sections, policy) {
|
|
|
230
259
|
score: roundGrade(raw, policy.rounding),
|
|
231
260
|
appliedThreshold,
|
|
232
261
|
pendingSlotIds,
|
|
262
|
+
rejectedSlotIds,
|
|
233
263
|
unscorableSlotIds
|
|
234
264
|
};
|
|
235
265
|
});
|
|
@@ -250,6 +280,7 @@ function composeAssessmentScore(sections, policy) {
|
|
|
250
280
|
passed: partial.appliedThreshold === null || partial.gradedMaxPoints === 0 ? true : gte(partial.score, partial.appliedThreshold, policy.rounding),
|
|
251
281
|
appliedThreshold: partial.appliedThreshold,
|
|
252
282
|
pendingSlotIds: partial.pendingSlotIds,
|
|
283
|
+
...rejectedField(partial.rejectedSlotIds),
|
|
253
284
|
unscorableSlotIds: partial.unscorableSlotIds
|
|
254
285
|
}));
|
|
255
286
|
const weightedRaw = scored.reduce(
|
|
@@ -266,6 +297,7 @@ function composeAssessmentScore(sections, policy) {
|
|
|
266
297
|
passFailureReason: null,
|
|
267
298
|
status,
|
|
268
299
|
pendingSlotIds: pendingAll,
|
|
300
|
+
...rejectedField(rejectedAll),
|
|
269
301
|
unscorableSlotIds: unscorableAll
|
|
270
302
|
};
|
|
271
303
|
}
|
|
@@ -277,6 +309,7 @@ function composeAssessmentScore(sections, policy) {
|
|
|
277
309
|
passFailureReason: null,
|
|
278
310
|
status,
|
|
279
311
|
pendingSlotIds: pendingAll,
|
|
312
|
+
...rejectedField(rejectedAll),
|
|
280
313
|
unscorableSlotIds: unscorableAll
|
|
281
314
|
};
|
|
282
315
|
}
|
|
@@ -291,6 +324,7 @@ function composeAssessmentScore(sections, policy) {
|
|
|
291
324
|
passFailureReason,
|
|
292
325
|
status,
|
|
293
326
|
pendingSlotIds: pendingAll,
|
|
327
|
+
...rejectedField(rejectedAll),
|
|
294
328
|
unscorableSlotIds: unscorableAll
|
|
295
329
|
};
|
|
296
330
|
}
|
|
@@ -1030,5 +1064,6 @@ function evaluate(data, response, options) {
|
|
|
1030
1064
|
|
|
1031
1065
|
|
|
1032
1066
|
|
|
1033
|
-
|
|
1034
|
-
|
|
1067
|
+
|
|
1068
|
+
exports.isGradeInRange = isGradeInRange; exports.roundGrade = roundGrade; exports.gte = gte; exports.classifyBand = classifyBand; exports.DEFAULT_PASS_THRESHOLD = DEFAULT_PASS_THRESHOLD; exports.computePassThreshold = computePassThreshold; exports.gradeFromRubric = gradeFromRubric; exports.outcomeFromGrade = outcomeFromGrade; exports.hasGrade = hasGrade; exports.composeAssessmentScore = composeAssessmentScore; exports.diffDictationChars = diffDictationChars; exports.validateSpeechAssessment = validateSpeechAssessment; exports.alignReadAloud = alignReadAloud; exports.gradeReadAloud = gradeReadAloud; exports.inspectWav = inspectWav; exports.outcomeFromUnscorable = outcomeFromUnscorable; exports.score = score; exports.evaluate = evaluate;
|
|
1069
|
+
//# sourceMappingURL=chunk-COBK2RUX.cjs.map
|