@intellectif/lk-core 0.13.1 → 0.14.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 +23 -0
- package/README.md +1 -1
- package/dist/{activity-DfAmJ1sl.d.cts → activity-BVkg9VDo.d.cts} +153 -4
- package/dist/{activity-DfAmJ1sl.d.ts → activity-BVkg9VDo.d.ts} +153 -4
- package/dist/{chunk-DQKJUMUW.cjs → chunk-2NIALQEH.cjs} +24 -19
- package/dist/chunk-2NIALQEH.cjs.map +1 -0
- package/dist/{chunk-XKKKWKRH.js → chunk-6RND47QE.js} +694 -126
- package/dist/chunk-6RND47QE.js.map +1 -0
- package/dist/{chunk-FT7SAC3X.cjs → chunk-CZRUMHKW.cjs} +4 -4
- package/dist/{chunk-FT7SAC3X.cjs.map → chunk-CZRUMHKW.cjs.map} +1 -1
- package/dist/chunk-J5OIRIOW.cjs +1034 -0
- package/dist/chunk-J5OIRIOW.cjs.map +1 -0
- package/dist/{chunk-Y36S3X3L.js → chunk-NBADQ3JU.js} +7 -2
- package/dist/chunk-NBADQ3JU.js.map +1 -0
- package/dist/{chunk-PYP3HCRQ.js → chunk-R7PV3XIC.js} +2 -2
- package/dist/chunk-RTIR6R2U.js +1034 -0
- package/dist/chunk-RTIR6R2U.js.map +1 -0
- package/dist/{chunk-XONKWL6D.cjs → chunk-VCXIPLEW.cjs} +585 -17
- package/dist/chunk-VCXIPLEW.cjs.map +1 -0
- package/dist/{index-BefQ8FAS.d.cts → index-BE1bM2Wi.d.cts} +238 -16
- package/dist/{index-BiIvdASA.d.ts → index-v2_-Fyn3.d.ts} +238 -16
- package/dist/index.cjs +110 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +45 -17
- package/dist/index.d.ts +45 -17
- package/dist/index.js +35 -5
- package/dist/index.js.map +1 -1
- package/dist/schemas.cjs +11 -3
- package/dist/schemas.cjs.map +1 -1
- package/dist/schemas.d.cts +2 -2
- package/dist/schemas.d.ts +2 -2
- package/dist/schemas.js +10 -2
- package/dist/scoring.cjs +23 -3
- package/dist/scoring.cjs.map +1 -1
- package/dist/scoring.d.cts +471 -16
- package/dist/scoring.d.ts +471 -16
- package/dist/scoring.js +24 -4
- package/dist/xapi.cjs +3 -3
- package/dist/xapi.d.cts +1 -1
- package/dist/xapi.d.ts +1 -1
- package/dist/xapi.js +2 -2
- package/package.json +1 -1
- package/vectors/README.md +63 -3
- package/vectors/replay.d.mts +7 -2
- package/vectors/replay.mjs +83 -1
- package/vectors/scoring.json +10875 -2
- package/dist/chunk-DQKJUMUW.cjs.map +0 -1
- package/dist/chunk-FGN4LLSP.js +0 -387
- package/dist/chunk-FGN4LLSP.js.map +0 -1
- package/dist/chunk-UHQVIC3P.cjs +0 -387
- package/dist/chunk-UHQVIC3P.cjs.map +0 -1
- package/dist/chunk-XKKKWKRH.js.map +0 -1
- package/dist/chunk-XONKWL6D.cjs.map +0 -1
- package/dist/chunk-Y36S3X3L.js.map +0 -1
- /package/dist/{chunk-PYP3HCRQ.js.map → chunk-R7PV3XIC.js.map} +0 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActivitySchemaError,
|
|
3
|
+
CANONICAL_LOCALE_RE,
|
|
4
|
+
DeferredScoringError,
|
|
5
|
+
ReadAloudDataSchema,
|
|
6
|
+
RedactedScoringError,
|
|
7
|
+
SPEECH_ASSESSMENT_MAX_TEXT_LENGTH,
|
|
8
|
+
SPEECH_ASSESSMENT_MAX_WORDS,
|
|
9
|
+
STEP,
|
|
10
|
+
UnknownActivityTypeError,
|
|
11
|
+
alignSequences,
|
|
12
|
+
codePointLength,
|
|
13
|
+
countWords,
|
|
14
|
+
dictationReferenceWords,
|
|
15
|
+
getActivityTypeDescriptor,
|
|
16
|
+
normalizeDictationText
|
|
17
|
+
} from "./chunk-6RND47QE.js";
|
|
18
|
+
|
|
19
|
+
// src/is-redacted.ts
|
|
20
|
+
function isRedacted(data) {
|
|
21
|
+
return typeof data === "object" && data !== null && data.redacted === true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/scoring/rounding.ts
|
|
25
|
+
var ROUNDING_MODES = ["half-up", "half-even", "floor", "ceil"];
|
|
26
|
+
var MAX_ROUNDING_DP = 15;
|
|
27
|
+
function roundingPolicyOf(value) {
|
|
28
|
+
if (value === void 0 || value === null) {
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
const { mode, dp } = value;
|
|
32
|
+
if (!ROUNDING_MODES.includes(mode) || typeof dp !== "number" || !Number.isInteger(dp) || dp < 0 || dp > MAX_ROUNDING_DP) {
|
|
33
|
+
const describe = (field) => typeof field === "string" ? JSON.stringify(field) : typeof field === "number" || field === void 0 ? String(field) : typeof field === "object" ? "an object" : `a ${typeof field}`;
|
|
34
|
+
throw new RangeError(
|
|
35
|
+
`Invalid rounding policy (mode ${describe(mode)}, dp ${describe(dp)}): expected { mode: 'half-up' | 'half-even' | 'floor' | 'ceil', dp: a whole number from 0 to ${MAX_ROUNDING_DP} }.`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return { mode, dp };
|
|
39
|
+
}
|
|
40
|
+
var EPSILON = 1e-9;
|
|
41
|
+
var TIE_TOLERANCE = 1e-6;
|
|
42
|
+
function scaled(value, dp) {
|
|
43
|
+
return value * 10 ** dp;
|
|
44
|
+
}
|
|
45
|
+
function noNegZero(value) {
|
|
46
|
+
return Object.is(value, -0) ? 0 : value;
|
|
47
|
+
}
|
|
48
|
+
function roundGrade(value, policy) {
|
|
49
|
+
if (!Number.isFinite(value)) {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
const factor = 10 ** policy.dp;
|
|
53
|
+
const raw = scaled(value, policy.dp);
|
|
54
|
+
switch (policy.mode) {
|
|
55
|
+
case "floor":
|
|
56
|
+
return noNegZero(Math.floor(raw + EPSILON) / factor);
|
|
57
|
+
case "ceil":
|
|
58
|
+
return noNegZero(Math.ceil(raw - EPSILON) / factor);
|
|
59
|
+
case "half-even": {
|
|
60
|
+
const floored = Math.floor(raw);
|
|
61
|
+
if (Math.abs(raw - floored - 0.5) > TIE_TOLERANCE) {
|
|
62
|
+
return noNegZero(halfAway(raw) / factor);
|
|
63
|
+
}
|
|
64
|
+
const even = floored % 2 === 0 ? floored : floored + 1;
|
|
65
|
+
return noNegZero(even / factor);
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
return noNegZero(halfAway(raw) / factor);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function halfAway(raw) {
|
|
72
|
+
const nudged = raw >= 0 ? raw + EPSILON : raw - EPSILON;
|
|
73
|
+
return raw >= 0 ? Math.round(nudged) : -Math.round(-nudged);
|
|
74
|
+
}
|
|
75
|
+
function gte(value, threshold, policy) {
|
|
76
|
+
return roundGrade(value, policy) >= roundGrade(threshold, policy) - EPSILON;
|
|
77
|
+
}
|
|
78
|
+
function classifyBand(value, bands) {
|
|
79
|
+
let best = null;
|
|
80
|
+
for (const band of bands) {
|
|
81
|
+
if (value >= band.min - EPSILON && (best === null || band.min > best.min)) {
|
|
82
|
+
best = band;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return best;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/scoring/pass-threshold.ts
|
|
89
|
+
var DEFAULT_PASS_THRESHOLD = 0.7;
|
|
90
|
+
function computePassThreshold(activityData, score2, rounding) {
|
|
91
|
+
const threshold = activityData.passThreshold ?? DEFAULT_PASS_THRESHOLD;
|
|
92
|
+
return rounding === void 0 ? score2 >= threshold : gte(score2, threshold, rounding);
|
|
93
|
+
}
|
|
94
|
+
function selectFeedback(activityData, passed) {
|
|
95
|
+
const feedback = activityData.feedback;
|
|
96
|
+
if (feedback === void 0) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return (passed ? feedback.correct : feedback.incorrect) ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/grading.ts
|
|
103
|
+
function gradeFromRubric(criteria, activityData, options = {}) {
|
|
104
|
+
const rounding = roundingPolicyOf(options?.rounding);
|
|
105
|
+
const corrupt = criteria.find(
|
|
106
|
+
(criterion) => criterion.notApplicable !== true && criterion.score !== void 0 && !Number.isFinite(criterion.score)
|
|
107
|
+
);
|
|
108
|
+
if (corrupt !== void 0) {
|
|
109
|
+
return {
|
|
110
|
+
unscorable: true,
|
|
111
|
+
reason: `Criterion "${corrupt.name}" has a non-finite score (${String(corrupt.score)}). A grade cannot be computed from it.`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const badMax = criteria.find(
|
|
115
|
+
(criterion) => criterion.notApplicable !== true && criterion.maxScore !== void 0 && !(Number.isFinite(criterion.maxScore) && criterion.maxScore > 0)
|
|
116
|
+
);
|
|
117
|
+
if (badMax !== void 0) {
|
|
118
|
+
return {
|
|
119
|
+
unscorable: true,
|
|
120
|
+
reason: `Criterion "${badMax.name}" declares maxScore ${String(badMax.maxScore)}; it must be a positive, finite number.`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const scoreable = criteria.filter(
|
|
124
|
+
(criterion) => criterion.notApplicable !== true && typeof criterion.score === "number"
|
|
125
|
+
);
|
|
126
|
+
const ratioOf = (criterion) => criterion.score / (criterion.maxScore ?? 1);
|
|
127
|
+
const EPSILON2 = 1e-9;
|
|
128
|
+
const outOfRange = scoreable.find(
|
|
129
|
+
(criterion) => ratioOf(criterion) < -EPSILON2 || ratioOf(criterion) > 1 + EPSILON2
|
|
130
|
+
);
|
|
131
|
+
if (outOfRange !== void 0) {
|
|
132
|
+
return {
|
|
133
|
+
unscorable: true,
|
|
134
|
+
reason: `Criterion "${outOfRange.name}" has score ${outOfRange.score} out of ${outOfRange.maxScore ?? 1}, which is outside the [0,1] range once scaled. Set \`maxScore\` on the criterion to declare what the score is out of (e.g. \`maxScore: 100\` for a 0\u2013100 grader).`
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (scoreable.length === 0) {
|
|
138
|
+
return {
|
|
139
|
+
unscorable: true,
|
|
140
|
+
reason: "No criterion carried a numeric score, so no weighted total can be computed."
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const totalWeight = scoreable.reduce((sum, criterion) => sum + (criterion.weight ?? 1), 0);
|
|
144
|
+
if (!(totalWeight > 0)) {
|
|
145
|
+
return {
|
|
146
|
+
unscorable: true,
|
|
147
|
+
reason: "Criterion weights do not sum to a positive number, so the weighted total is undefined."
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const weighted = scoreable.reduce(
|
|
151
|
+
(sum, criterion) => sum + ratioOf(criterion) * (criterion.weight ?? 1),
|
|
152
|
+
0
|
|
153
|
+
);
|
|
154
|
+
const rawScore = weighted / totalWeight;
|
|
155
|
+
if (!Number.isFinite(rawScore)) {
|
|
156
|
+
return {
|
|
157
|
+
unscorable: true,
|
|
158
|
+
reason: "The weighted total is not a finite number, so no grade can be produced."
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const score2 = Math.min(1, Math.max(0, rawScore));
|
|
162
|
+
const reaches = (threshold) => rounding === void 0 ? score2 >= threshold : gte(score2, threshold, rounding);
|
|
163
|
+
const passed = options.passThreshold !== void 0 ? reaches(options.passThreshold) : activityData !== void 0 ? computePassThreshold(activityData, score2, rounding) : reaches(0.7);
|
|
164
|
+
return {
|
|
165
|
+
score: score2,
|
|
166
|
+
maxScore: 1,
|
|
167
|
+
passed,
|
|
168
|
+
feedback: options.feedback ?? null,
|
|
169
|
+
criteria: [...criteria]
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function outcomeFromGrade(grade) {
|
|
173
|
+
return {
|
|
174
|
+
status: "graded",
|
|
175
|
+
grade,
|
|
176
|
+
score: grade.score,
|
|
177
|
+
maxScore: grade.maxScore,
|
|
178
|
+
passed: grade.passed,
|
|
179
|
+
feedback: grade.feedback
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function hasGrade(outcome) {
|
|
183
|
+
return outcome.status === "scored" || outcome.status === "graded";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/scoring/compose.ts
|
|
187
|
+
function earned(item) {
|
|
188
|
+
const { outcome } = item;
|
|
189
|
+
if (hasGrade(outcome)) {
|
|
190
|
+
const max = outcome.maxScore > 0 ? outcome.maxScore : 1;
|
|
191
|
+
return { state: "graded", points: outcome.score / max * item.points };
|
|
192
|
+
}
|
|
193
|
+
if (outcome.status === "deferred") {
|
|
194
|
+
return { state: "pending" };
|
|
195
|
+
}
|
|
196
|
+
return { state: "unscorable" };
|
|
197
|
+
}
|
|
198
|
+
function composeAssessmentScore(sections, policy) {
|
|
199
|
+
const pendingAll = [];
|
|
200
|
+
const unscorableAll = [];
|
|
201
|
+
const partials = sections.map((section) => {
|
|
202
|
+
let earnedPoints = 0;
|
|
203
|
+
let gradedMaxPoints = 0;
|
|
204
|
+
let maxPoints = 0;
|
|
205
|
+
const pendingSlotIds = [];
|
|
206
|
+
const unscorableSlotIds = [];
|
|
207
|
+
for (const item of section.items) {
|
|
208
|
+
maxPoints += item.points;
|
|
209
|
+
const contribution = earned(item);
|
|
210
|
+
if (contribution.state === "pending") {
|
|
211
|
+
pendingSlotIds.push(item.slotId);
|
|
212
|
+
pendingAll.push(item.slotId);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (contribution.state === "unscorable") {
|
|
216
|
+
unscorableSlotIds.push(item.slotId);
|
|
217
|
+
unscorableAll.push(item.slotId);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
earnedPoints += contribution.points;
|
|
221
|
+
gradedMaxPoints += item.points;
|
|
222
|
+
}
|
|
223
|
+
const raw = gradedMaxPoints > 0 ? earnedPoints / gradedMaxPoints : 0;
|
|
224
|
+
const appliedThreshold = section.passThresholdOverride ?? policy.sectionThreshold ?? null;
|
|
225
|
+
return {
|
|
226
|
+
section,
|
|
227
|
+
earnedPoints,
|
|
228
|
+
gradedMaxPoints,
|
|
229
|
+
maxPoints,
|
|
230
|
+
score: roundGrade(raw, policy.rounding),
|
|
231
|
+
appliedThreshold,
|
|
232
|
+
pendingSlotIds,
|
|
233
|
+
unscorableSlotIds
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
const contributingWeight = partials.reduce(
|
|
237
|
+
(sum, partial) => sum + (partial.gradedMaxPoints > 0 ? partial.section.weight : 0),
|
|
238
|
+
0
|
|
239
|
+
);
|
|
240
|
+
const scored = partials.map((partial) => ({
|
|
241
|
+
id: partial.section.id,
|
|
242
|
+
...partial.section.title !== void 0 ? { title: partial.section.title } : {},
|
|
243
|
+
weight: partial.section.weight,
|
|
244
|
+
normalizedWeight: partial.gradedMaxPoints > 0 && contributingWeight > 0 ? partial.section.weight / contributingWeight : 0,
|
|
245
|
+
earnedPoints: partial.earnedPoints,
|
|
246
|
+
gradedMaxPoints: partial.gradedMaxPoints,
|
|
247
|
+
maxPoints: partial.maxPoints,
|
|
248
|
+
score: partial.score,
|
|
249
|
+
// A section with nothing graded yet cannot be said to have failed.
|
|
250
|
+
passed: partial.appliedThreshold === null || partial.gradedMaxPoints === 0 ? true : gte(partial.score, partial.appliedThreshold, policy.rounding),
|
|
251
|
+
appliedThreshold: partial.appliedThreshold,
|
|
252
|
+
pendingSlotIds: partial.pendingSlotIds,
|
|
253
|
+
unscorableSlotIds: partial.unscorableSlotIds
|
|
254
|
+
}));
|
|
255
|
+
const weightedRaw = scored.reduce(
|
|
256
|
+
(sum, section) => sum + section.score * section.normalizedWeight,
|
|
257
|
+
0
|
|
258
|
+
);
|
|
259
|
+
const score2 = roundGrade(weightedRaw, policy.rounding);
|
|
260
|
+
const status = pendingAll.length > 0 ? "provisional" : "final";
|
|
261
|
+
if (status === "provisional") {
|
|
262
|
+
return {
|
|
263
|
+
sections: scored,
|
|
264
|
+
score: score2,
|
|
265
|
+
passed: null,
|
|
266
|
+
passFailureReason: null,
|
|
267
|
+
status,
|
|
268
|
+
pendingSlotIds: pendingAll,
|
|
269
|
+
unscorableSlotIds: unscorableAll
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (scored.every((section) => section.gradedMaxPoints === 0)) {
|
|
273
|
+
return {
|
|
274
|
+
sections: scored,
|
|
275
|
+
score: score2,
|
|
276
|
+
passed: null,
|
|
277
|
+
passFailureReason: null,
|
|
278
|
+
status,
|
|
279
|
+
pendingSlotIds: pendingAll,
|
|
280
|
+
unscorableSlotIds: unscorableAll
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const overallOk = gte(score2, policy.passThreshold, policy.rounding);
|
|
284
|
+
const sectionsOk = scored.every((section) => section.passed);
|
|
285
|
+
const passed = overallOk && sectionsOk;
|
|
286
|
+
const passFailureReason = passed ? null : !overallOk && !sectionsOk ? "both" : overallOk ? "section_below_threshold" : "overall_below_threshold";
|
|
287
|
+
return {
|
|
288
|
+
sections: scored,
|
|
289
|
+
score: score2,
|
|
290
|
+
passed,
|
|
291
|
+
passFailureReason,
|
|
292
|
+
status,
|
|
293
|
+
pendingSlotIds: pendingAll,
|
|
294
|
+
unscorableSlotIds: unscorableAll
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/scoring/dictation/chars.ts
|
|
299
|
+
function diffDictationChars(reference, attempt) {
|
|
300
|
+
const referencePoints = Array.from(reference);
|
|
301
|
+
const attemptPoints = Array.from(attempt);
|
|
302
|
+
return alignSequences(referencePoints, attemptPoints).map(
|
|
303
|
+
({ step, referenceIndex, attemptIndex }) => {
|
|
304
|
+
if (step === STEP.missing) {
|
|
305
|
+
return { op: "missing", reference: referencePoints[referenceIndex], attempt: "" };
|
|
306
|
+
}
|
|
307
|
+
if (step === STEP.extra) {
|
|
308
|
+
return { op: "extra", reference: "", attempt: attemptPoints[attemptIndex] };
|
|
309
|
+
}
|
|
310
|
+
const referencePoint = referencePoints[referenceIndex];
|
|
311
|
+
const attemptPoint = attemptPoints[attemptIndex];
|
|
312
|
+
return {
|
|
313
|
+
op: referencePoint === attemptPoint ? "equal" : "substitute",
|
|
314
|
+
reference: referencePoint,
|
|
315
|
+
attempt: attemptPoint
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/scoring/speech/assessment.ts
|
|
322
|
+
import { z } from "zod/v4";
|
|
323
|
+
|
|
324
|
+
// src/scoring/speech/tokens.ts
|
|
325
|
+
function heardTokens(words) {
|
|
326
|
+
const tokens = [];
|
|
327
|
+
let length = 0;
|
|
328
|
+
for (const [wordIndex, word] of words.entries()) {
|
|
329
|
+
const normalized = normalizeDictationText(word.text);
|
|
330
|
+
if (normalized === "") {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const inserted = word.error === "insertion";
|
|
334
|
+
for (const text of normalized.split(" ")) {
|
|
335
|
+
length += codePointLength(text) + (length === 0 ? 0 : 1);
|
|
336
|
+
if (!inserted && length <= SPEECH_ASSESSMENT_MAX_TEXT_LENGTH) {
|
|
337
|
+
tokens.push({ text, wordIndex });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return { tokens, length };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/scoring/speech/assessment.ts
|
|
345
|
+
var Score = z.number().min(0).max(100);
|
|
346
|
+
var Confidence = z.number().min(0).max(1);
|
|
347
|
+
var Milliseconds = z.number().min(0);
|
|
348
|
+
var PhonemeCandidateSchema = z.strictObject({
|
|
349
|
+
symbol: z.string().min(1).max(16),
|
|
350
|
+
score: Score
|
|
351
|
+
});
|
|
352
|
+
var PhonemeSchema = z.strictObject({
|
|
353
|
+
symbol: z.string().min(1).max(16).optional(),
|
|
354
|
+
accuracy: Score.optional(),
|
|
355
|
+
startMs: Milliseconds.optional(),
|
|
356
|
+
durationMs: Milliseconds.optional(),
|
|
357
|
+
heardAs: z.array(PhonemeCandidateSchema).max(10).optional()
|
|
358
|
+
});
|
|
359
|
+
var SyllableSchema = z.strictObject({
|
|
360
|
+
text: z.string().min(1).max(64),
|
|
361
|
+
grapheme: z.string().max(64).optional(),
|
|
362
|
+
accuracy: Score.optional(),
|
|
363
|
+
startMs: Milliseconds.optional(),
|
|
364
|
+
durationMs: Milliseconds.optional()
|
|
365
|
+
});
|
|
366
|
+
var WordSchema = z.strictObject({
|
|
367
|
+
text: z.string().min(1).max(200),
|
|
368
|
+
accuracy: Score.optional(),
|
|
369
|
+
error: z.enum(["none", "mispronunciation", "omission", "insertion"]),
|
|
370
|
+
vendorError: z.string().max(64).optional(),
|
|
371
|
+
startMs: Milliseconds.optional(),
|
|
372
|
+
durationMs: Milliseconds.optional(),
|
|
373
|
+
syllables: z.array(SyllableSchema).max(50).optional(),
|
|
374
|
+
phonemes: z.array(PhonemeSchema).max(50).optional(),
|
|
375
|
+
breaks: z.strictObject({ unexpected: Confidence.optional(), missing: Confidence.optional() }).optional()
|
|
376
|
+
});
|
|
377
|
+
var AssessorSchema = z.strictObject({
|
|
378
|
+
kind: z.enum(["auto", "ai", "human"]),
|
|
379
|
+
id: z.string().max(256).optional(),
|
|
380
|
+
model: z.string().max(256).optional(),
|
|
381
|
+
promptHash: z.string().max(256).optional()
|
|
382
|
+
});
|
|
383
|
+
var SpeechAssessmentSchema = z.strictObject({
|
|
384
|
+
assessmentVersion: z.literal("1.0"),
|
|
385
|
+
status: z.enum(["assessed", "no_speech"]),
|
|
386
|
+
task: z.enum(["scripted", "unscripted"]),
|
|
387
|
+
locale: z.string().regex(CANONICAL_LOCALE_RE),
|
|
388
|
+
referenceText: z.string().max(8e3).optional(),
|
|
389
|
+
recordingKey: z.string().min(1).max(1024).optional(),
|
|
390
|
+
assessor: AssessorSchema,
|
|
391
|
+
scale: z.literal(100),
|
|
392
|
+
scores: z.strictObject({
|
|
393
|
+
accuracy: Score.optional(),
|
|
394
|
+
fluency: Score.optional(),
|
|
395
|
+
completeness: Score.optional(),
|
|
396
|
+
prosody: Score.optional(),
|
|
397
|
+
overall: Score.optional()
|
|
398
|
+
}),
|
|
399
|
+
recognizedText: z.string().max(8e3).optional(),
|
|
400
|
+
miscue: z.enum(["assessor", "none"]),
|
|
401
|
+
phonemeAlphabet: z.enum(["ipa", "sapi"]).optional(),
|
|
402
|
+
words: z.array(WordSchema).max(SPEECH_ASSESSMENT_MAX_WORDS),
|
|
403
|
+
prosody: z.strictObject({ monotoneConfidence: Confidence.optional() }).optional(),
|
|
404
|
+
signal: z.strictObject({ snrDb: z.number().optional() }).optional()
|
|
405
|
+
});
|
|
406
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
407
|
+
function namesPhonemes(words) {
|
|
408
|
+
return Array.isArray(words) && words.some((word) => {
|
|
409
|
+
const phonemes = isRecord(word) ? word.phonemes : void 0;
|
|
410
|
+
return Array.isArray(phonemes) && phonemes.some(
|
|
411
|
+
(phoneme) => isRecord(phoneme) && (phoneme.symbol !== void 0 || phoneme.heardAs !== void 0)
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
function rawTextLength(words) {
|
|
416
|
+
let total = 0;
|
|
417
|
+
for (const word of words) {
|
|
418
|
+
total += word.text.length;
|
|
419
|
+
}
|
|
420
|
+
return total;
|
|
421
|
+
}
|
|
422
|
+
function totalTextErrors(words) {
|
|
423
|
+
const raw = rawTextLength(words);
|
|
424
|
+
if (raw > SPEECH_ASSESSMENT_MAX_TEXT_LENGTH) {
|
|
425
|
+
return [
|
|
426
|
+
{
|
|
427
|
+
path: ["words"],
|
|
428
|
+
message: `The words of an assessment carry ${raw} characters between them, and at most ${SPEECH_ASSESSMENT_MAX_TEXT_LENGTH} can be aligned against the text of an item.`,
|
|
429
|
+
code: "too_big"
|
|
430
|
+
}
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
const normalized = heardTokens(words).length;
|
|
434
|
+
if (normalized > SPEECH_ASSESSMENT_MAX_TEXT_LENGTH) {
|
|
435
|
+
return [
|
|
436
|
+
{
|
|
437
|
+
path: ["words"],
|
|
438
|
+
message: `The words of an assessment spell ${normalized} characters between them once they are normalised, and at most ${SPEECH_ASSESSMENT_MAX_TEXT_LENGTH} can be aligned against the text of an item. One character can stand for several.`,
|
|
439
|
+
code: "too_big"
|
|
440
|
+
}
|
|
441
|
+
];
|
|
442
|
+
}
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
function crossFieldErrors(value) {
|
|
446
|
+
const errors = [];
|
|
447
|
+
if (value.task === "scripted") {
|
|
448
|
+
if (value.referenceText === void 0) {
|
|
449
|
+
errors.push({
|
|
450
|
+
path: ["referenceText"],
|
|
451
|
+
message: "A scripted assessment must carry the reference text it was made against, so a grade can confirm it is the text of the item.",
|
|
452
|
+
code: "scripted_binding_required"
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (value.recordingKey === void 0) {
|
|
456
|
+
errors.push({
|
|
457
|
+
path: ["recordingKey"],
|
|
458
|
+
message: "A scripted assessment must carry the key of the recording it measured, so a grade can confirm it is the recording of the learner.",
|
|
459
|
+
code: "scripted_binding_required"
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (value.phonemeAlphabet === void 0 && namesPhonemes(value.words)) {
|
|
464
|
+
errors.push({
|
|
465
|
+
path: ["phonemeAlphabet"],
|
|
466
|
+
message: "A phoneme names a symbol or what was heard instead, so the assessment must say which phonemeAlphabet those symbols are written in.",
|
|
467
|
+
code: "phoneme_alphabet_required"
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return errors;
|
|
471
|
+
}
|
|
472
|
+
function describeValidationError(error) {
|
|
473
|
+
return error.path.length === 0 ? error.message : `${error.path.join(".")}: ${error.message}`;
|
|
474
|
+
}
|
|
475
|
+
function validateSpeechAssessment(value) {
|
|
476
|
+
const result = SpeechAssessmentSchema.safeParse(value);
|
|
477
|
+
const refused = result.success ? [] : result.error.issues.map((issue) => ({
|
|
478
|
+
path: issue.path.map(String),
|
|
479
|
+
message: issue.message,
|
|
480
|
+
code: issue.code
|
|
481
|
+
}));
|
|
482
|
+
const parsed = result.success ? result.data : void 0;
|
|
483
|
+
const errors = isRecord(value) ? [
|
|
484
|
+
...refused,
|
|
485
|
+
...crossFieldErrors(value),
|
|
486
|
+
...parsed !== void 0 ? totalTextErrors(parsed.words) : []
|
|
487
|
+
] : refused;
|
|
488
|
+
if (parsed !== void 0 && errors.length === 0) {
|
|
489
|
+
return { success: true, data: parsed };
|
|
490
|
+
}
|
|
491
|
+
return { success: false, errors };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/scoring/speech/align.ts
|
|
495
|
+
function accuracyOf(word) {
|
|
496
|
+
return word.accuracy !== void 0 ? { accuracy: word.accuracy } : {};
|
|
497
|
+
}
|
|
498
|
+
function alignReadAloud(data, assessment) {
|
|
499
|
+
const checked = validateSpeechAssessment(assessment);
|
|
500
|
+
if (!checked.success) {
|
|
501
|
+
throw new TypeError(
|
|
502
|
+
`alignReadAloud was given something that is not a speech assessment (${describeValidationError(checked.errors[0])}). Check it with validateSpeechAssessment before aligning it.`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
return alignValidated(data, checked.data);
|
|
506
|
+
}
|
|
507
|
+
function alignValidated(data, assessment) {
|
|
508
|
+
const reference = dictationReferenceWords({
|
|
509
|
+
transcript: data.referenceText
|
|
510
|
+
})[0];
|
|
511
|
+
const heard = heardTokens(assessment.words).tokens;
|
|
512
|
+
const entries = [];
|
|
513
|
+
for (const { step, referenceIndex, attemptIndex } of alignSequences(
|
|
514
|
+
reference.map((word) => word.word),
|
|
515
|
+
heard.map((token) => token.text)
|
|
516
|
+
)) {
|
|
517
|
+
if (step === STEP.extra) {
|
|
518
|
+
const token2 = heard[attemptIndex];
|
|
519
|
+
entries.push({
|
|
520
|
+
reference: "",
|
|
521
|
+
heard: token2.text,
|
|
522
|
+
state: "inserted",
|
|
523
|
+
...accuracyOf(assessment.words[token2.wordIndex]),
|
|
524
|
+
wordIndex: token2.wordIndex
|
|
525
|
+
});
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const referenceWord = reference[referenceIndex];
|
|
529
|
+
if (step === STEP.missing) {
|
|
530
|
+
entries.push({
|
|
531
|
+
itemId: referenceWord.itemId,
|
|
532
|
+
reference: referenceWord.word,
|
|
533
|
+
heard: "",
|
|
534
|
+
state: "omitted"
|
|
535
|
+
});
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const token = heard[attemptIndex];
|
|
539
|
+
const word = assessment.words[token.wordIndex];
|
|
540
|
+
const state = pairedState(word, assessment.miscue, referenceWord.word, token.text);
|
|
541
|
+
entries.push({
|
|
542
|
+
itemId: referenceWord.itemId,
|
|
543
|
+
reference: referenceWord.word,
|
|
544
|
+
// An omitted word was not read: whatever text the assessor carried for it
|
|
545
|
+
// describes the reference, not the learner.
|
|
546
|
+
heard: state === "omitted" ? "" : token.text,
|
|
547
|
+
state,
|
|
548
|
+
...accuracyOf(word),
|
|
549
|
+
wordIndex: token.wordIndex
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
placeInsertions(assessment, entries);
|
|
553
|
+
return entries;
|
|
554
|
+
}
|
|
555
|
+
function pairedState(word, miscue, reference, heard) {
|
|
556
|
+
if (word.error === "omission") {
|
|
557
|
+
return "omitted";
|
|
558
|
+
}
|
|
559
|
+
if (word.error === "mispronunciation") {
|
|
560
|
+
return "mispronounced";
|
|
561
|
+
}
|
|
562
|
+
if (miscue === "none" && heard !== reference) {
|
|
563
|
+
return "mispronounced";
|
|
564
|
+
}
|
|
565
|
+
return "correct";
|
|
566
|
+
}
|
|
567
|
+
function placeInsertions(assessment, entries) {
|
|
568
|
+
assessment.words.forEach((word, wordIndex) => {
|
|
569
|
+
if (word.error !== "insertion") {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
let at = 0;
|
|
573
|
+
entries.forEach((entry, index) => {
|
|
574
|
+
if (entry.wordIndex !== void 0 && entry.wordIndex < wordIndex) {
|
|
575
|
+
at = index + 1;
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
entries.splice(at, 0, {
|
|
579
|
+
reference: "",
|
|
580
|
+
// Every insertion is accounted for, including one whose text normalises
|
|
581
|
+
// to nothing: a reader of `wordIndex` must find each word it looks for.
|
|
582
|
+
heard: normalizeDictationText(word.text),
|
|
583
|
+
state: "inserted",
|
|
584
|
+
...accuracyOf(word),
|
|
585
|
+
wordIndex
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/scoring/speech/grade.ts
|
|
591
|
+
var MEASUREMENT_TOLERANCE = 1e-6;
|
|
592
|
+
var ASSESSMENT_SCALE = 100;
|
|
593
|
+
function describeValue(value) {
|
|
594
|
+
return typeof value === "number" ? String(value) : `of type ${typeof value}`;
|
|
595
|
+
}
|
|
596
|
+
function readResponseRecording(value) {
|
|
597
|
+
if (typeof value !== "object" || value === null) {
|
|
598
|
+
return void 0;
|
|
599
|
+
}
|
|
600
|
+
const { type, recording } = value;
|
|
601
|
+
if (type !== "read-aloud") {
|
|
602
|
+
return void 0;
|
|
603
|
+
}
|
|
604
|
+
if (recording === null) {
|
|
605
|
+
return { recording: null };
|
|
606
|
+
}
|
|
607
|
+
if (typeof recording !== "object") {
|
|
608
|
+
return void 0;
|
|
609
|
+
}
|
|
610
|
+
const { key, mimeType } = recording;
|
|
611
|
+
if (typeof key !== "string" || key === "" || typeof mimeType !== "string") {
|
|
612
|
+
return void 0;
|
|
613
|
+
}
|
|
614
|
+
return { recording: { key, mimeType } };
|
|
615
|
+
}
|
|
616
|
+
function spokenWordCount(assessment) {
|
|
617
|
+
const recognized = assessment.recognizedText;
|
|
618
|
+
if (recognized !== void 0 && recognized.trim() !== "") {
|
|
619
|
+
return countWords(recognized);
|
|
620
|
+
}
|
|
621
|
+
return countWords(
|
|
622
|
+
assessment.words.filter((word) => word.error !== "omission" && word.error !== "insertion").map((word) => word.text).join(" ")
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
function scoreOf(entry) {
|
|
626
|
+
if (entry.state === "omitted") {
|
|
627
|
+
return { score: 0 };
|
|
628
|
+
}
|
|
629
|
+
return entry.accuracy !== void 0 ? { score: entry.accuracy / ASSESSMENT_SCALE } : {};
|
|
630
|
+
}
|
|
631
|
+
function gradeReadAloud(data, response, assessment, options) {
|
|
632
|
+
const maxWordsPerSecond = options?.plausibility?.maxWordsPerSecond;
|
|
633
|
+
if (typeof maxWordsPerSecond !== "number" || !Number.isFinite(maxWordsPerSecond) || maxWordsPerSecond <= 0) {
|
|
634
|
+
throw new RangeError(
|
|
635
|
+
`Invalid plausibility policy (maxWordsPerSecond ${describeValue(maxWordsPerSecond)}): expected a finite number above 0.`
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
const minVoicedMs = options?.plausibility?.minVoicedMs;
|
|
639
|
+
if (typeof minVoicedMs !== "number" || !Number.isFinite(minVoicedMs) || minVoicedMs < 0) {
|
|
640
|
+
throw new RangeError(
|
|
641
|
+
`Invalid plausibility policy (minVoicedMs ${describeValue(minVoicedMs)}): expected a finite number of milliseconds, 0 or above.`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
const rounding = roundingPolicyOf(options?.rounding);
|
|
645
|
+
const parsed = ReadAloudDataSchema.safeParse(data);
|
|
646
|
+
if (!parsed.success) {
|
|
647
|
+
throw new ActivitySchemaError(
|
|
648
|
+
"read-aloud",
|
|
649
|
+
parsed.error.issues.map((issue) => ({
|
|
650
|
+
path: issue.path.map(String),
|
|
651
|
+
message: issue.message,
|
|
652
|
+
code: issue.code
|
|
653
|
+
}))
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
if (isRedacted(data) || isRedacted(parsed.data)) {
|
|
657
|
+
throw new RedactedScoringError("read-aloud");
|
|
658
|
+
}
|
|
659
|
+
const item = parsed.data;
|
|
660
|
+
const checkedResponse = readResponseRecording(response);
|
|
661
|
+
if (checkedResponse === void 0) {
|
|
662
|
+
throw new TypeError(
|
|
663
|
+
'gradeReadAloud needs a read-aloud response: { type: "read-aloud", recording: null } for a blank, or a recording with a non-empty `key` and a `mimeType`.'
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
const recording = checkedResponse.recording;
|
|
667
|
+
if (recording === null) {
|
|
668
|
+
const passed = computePassThreshold(item, 0, rounding);
|
|
669
|
+
return {
|
|
670
|
+
score: 0,
|
|
671
|
+
maxScore: 1,
|
|
672
|
+
passed,
|
|
673
|
+
feedback: selectFeedback(item, passed),
|
|
674
|
+
criteria: [],
|
|
675
|
+
// Every word of the text, unread. A blank is graded from the item alone:
|
|
676
|
+
// there is no assessment to read, and no grader to credit.
|
|
677
|
+
details: referenceWordsOf(item).map(({ itemId, word }) => ({
|
|
678
|
+
itemId,
|
|
679
|
+
correct: false,
|
|
680
|
+
outcome: "incorrect-omission",
|
|
681
|
+
learnerResponse: "",
|
|
682
|
+
correctResponse: word,
|
|
683
|
+
weight: 1,
|
|
684
|
+
score: 0
|
|
685
|
+
}))
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
if (assessment === null || assessment === void 0) {
|
|
689
|
+
throw new TypeError(
|
|
690
|
+
"gradeReadAloud needs a speech assessment for a response that carries a recording. Pass one, or grade the blank (recording: null)."
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
const measured = options.measured;
|
|
694
|
+
if (measured === null || measured === void 0) {
|
|
695
|
+
throw new TypeError(
|
|
696
|
+
"gradeReadAloud needs `options.measured`, the server's own measurement of the recording (inspectWav), for a response that carries a recording. A duration the client reported is not evidence."
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
const { durationMs, voicedMs } = measured;
|
|
700
|
+
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs < 0 || typeof voicedMs !== "number" || !Number.isFinite(voicedMs) || voicedMs < 0 || voicedMs > durationMs + MEASUREMENT_TOLERANCE) {
|
|
701
|
+
throw new RangeError(
|
|
702
|
+
`Invalid recording measurement (durationMs ${describeValue(durationMs)}, voicedMs ${describeValue(voicedMs)}): expected finite millisecond counts, 0 or above, with voicedMs inside durationMs.`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
const unscorable = (code, reason) => ({
|
|
706
|
+
unscorable: true,
|
|
707
|
+
code,
|
|
708
|
+
reason
|
|
709
|
+
});
|
|
710
|
+
const checked = validateSpeechAssessment(assessment);
|
|
711
|
+
if (!checked.success) {
|
|
712
|
+
return unscorable(
|
|
713
|
+
"invalid_assessment",
|
|
714
|
+
`The speech assessment is not well formed (${describeValidationError(checked.errors[0])}).`
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
const evidence = checked.data;
|
|
718
|
+
if (evidence.task !== "scripted") {
|
|
719
|
+
return unscorable(
|
|
720
|
+
"task_mismatch",
|
|
721
|
+
`The assessment was made for a "${evidence.task}" task; a read-aloud grade needs one made against the item's text.`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
if (evidence.locale !== item.locale) {
|
|
725
|
+
return unscorable(
|
|
726
|
+
"locale_mismatch",
|
|
727
|
+
`The assessment was made for locale "${evidence.locale}", and the item is read in "${item.locale}". Pronunciation is assessed against one locale's speech.`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
if (evidence.referenceText !== item.referenceText) {
|
|
731
|
+
return unscorable(
|
|
732
|
+
"reference_mismatch",
|
|
733
|
+
"The assessment was made against a different text from the one this item asks the learner to read."
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
if (evidence.recordingKey !== recording.key) {
|
|
737
|
+
return unscorable(
|
|
738
|
+
"recording_mismatch",
|
|
739
|
+
`The assessment was made for recording "${String(evidence.recordingKey)}", and this response submitted "${recording.key}".`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
if (evidence.assessor.kind === "ai" && options.allowAiAssessor !== true) {
|
|
743
|
+
return unscorable(
|
|
744
|
+
"assessor_not_accepted",
|
|
745
|
+
"The assessment was produced by a generative model. Pass `allowAiAssessor: true` to accept that as measurement evidence."
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
if (evidence.status === "no_speech") {
|
|
749
|
+
return unscorable("no_speech", "The assessor heard no speech in this recording.");
|
|
750
|
+
}
|
|
751
|
+
if (voicedMs < minVoicedMs) {
|
|
752
|
+
return unscorable(
|
|
753
|
+
"insufficient_voiced_time",
|
|
754
|
+
`The recording holds ${voicedMs} ms of voiced time, and the policy asks for at least ${minVoicedMs} ms.`
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
const words = spokenWordCount(evidence);
|
|
758
|
+
if (words > 0 && (voicedMs === 0 || words / (voicedMs / 1e3) > maxWordsPerSecond)) {
|
|
759
|
+
return unscorable(
|
|
760
|
+
"implausible_speech_rate",
|
|
761
|
+
`The assessment claims ${words} word(s) in ${voicedMs} ms of voiced time, above the ${maxWordsPerSecond} word(s) per second the policy allows.`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
const weighed = item.scoring.dimensions.filter((dimension) => dimension.weight > 0);
|
|
765
|
+
const missing = weighed.find((dimension) => evidence.scores[dimension.name] === void 0);
|
|
766
|
+
if (missing !== void 0) {
|
|
767
|
+
return unscorable(
|
|
768
|
+
"missing_dimension",
|
|
769
|
+
`The item weighs "${missing.name}", and the assessment carries no ${missing.name} score. An absent score is not a 0.`
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
const criteria = weighed.map((dimension) => ({
|
|
773
|
+
name: dimension.name,
|
|
774
|
+
score: evidence.scores[dimension.name],
|
|
775
|
+
maxScore: ASSESSMENT_SCALE,
|
|
776
|
+
weight: dimension.weight
|
|
777
|
+
}));
|
|
778
|
+
const graded = gradeFromRubric(criteria, item, rounding !== void 0 ? { rounding } : {});
|
|
779
|
+
if ("unscorable" in graded) {
|
|
780
|
+
return unscorable(
|
|
781
|
+
"invalid_assessment",
|
|
782
|
+
`No weighted total could be computed from the assessment's scores: ${graded.reason}`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
const details = [];
|
|
786
|
+
for (const entry of alignValidated(item, evidence)) {
|
|
787
|
+
if (entry.itemId === void 0) {
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
details.push({
|
|
791
|
+
itemId: entry.itemId,
|
|
792
|
+
correct: entry.state === "correct",
|
|
793
|
+
outcome: entry.state === "correct" ? "correct" : entry.state === "mispronounced" ? "incorrect" : "incorrect-omission",
|
|
794
|
+
learnerResponse: entry.heard,
|
|
795
|
+
correctResponse: entry.reference,
|
|
796
|
+
weight: 1,
|
|
797
|
+
...scoreOf(entry)
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
...graded,
|
|
802
|
+
feedback: selectFeedback(item, graded.passed),
|
|
803
|
+
details,
|
|
804
|
+
// A copy, so a stored grade keeps who measured it even if the assessment
|
|
805
|
+
// object is later mutated or reused.
|
|
806
|
+
grader: { ...evidence.assessor }
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
function referenceWordsOf(data) {
|
|
810
|
+
return dictationReferenceWords({ transcript: data.referenceText })[0];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/scoring/speech/wav.ts
|
|
814
|
+
var HEADER_BYTES = 12;
|
|
815
|
+
var CHUNK_HEADER_BYTES = 8;
|
|
816
|
+
var FMT_MIN_BYTES = 16;
|
|
817
|
+
var EXTENSIBLE_BYTES = 40;
|
|
818
|
+
var SUBFORMAT_OFFSET = 24;
|
|
819
|
+
var FORMAT_PCM = 1;
|
|
820
|
+
var FORMAT_EXTENSIBLE = 65534;
|
|
821
|
+
var BITS_PER_SAMPLE = 16;
|
|
822
|
+
var BYTES_PER_SAMPLE = 2;
|
|
823
|
+
var FULL_SCALE = 32768;
|
|
824
|
+
function describeValue2(value) {
|
|
825
|
+
return typeof value === "number" ? String(value) : `of type ${typeof value}`;
|
|
826
|
+
}
|
|
827
|
+
function tagAt(view, offset) {
|
|
828
|
+
return String.fromCharCode(
|
|
829
|
+
view.getUint8(offset),
|
|
830
|
+
view.getUint8(offset + 1),
|
|
831
|
+
view.getUint8(offset + 2),
|
|
832
|
+
view.getUint8(offset + 3)
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
function formatAt(view, body, size) {
|
|
836
|
+
if (size < FMT_MIN_BYTES || body + FMT_MIN_BYTES > view.byteLength) {
|
|
837
|
+
return void 0;
|
|
838
|
+
}
|
|
839
|
+
const audioFormat = view.getUint16(body, true);
|
|
840
|
+
const channels = view.getUint16(body + 2, true);
|
|
841
|
+
const sampleRate = view.getUint32(body + 4, true);
|
|
842
|
+
const blockAlign = view.getUint16(body + 12, true);
|
|
843
|
+
const bitsPerSample = view.getUint16(body + 14, true);
|
|
844
|
+
const pcm = audioFormat === FORMAT_PCM || audioFormat === FORMAT_EXTENSIBLE && size >= EXTENSIBLE_BYTES && body + EXTENSIBLE_BYTES <= view.byteLength && view.getUint16(body + SUBFORMAT_OFFSET, true) === FORMAT_PCM;
|
|
845
|
+
if (!pcm || bitsPerSample !== BITS_PER_SAMPLE || channels < 1 || sampleRate < 1 || blockAlign !== channels * BYTES_PER_SAMPLE) {
|
|
846
|
+
return void 0;
|
|
847
|
+
}
|
|
848
|
+
return { channels, sampleRate, blockAlign };
|
|
849
|
+
}
|
|
850
|
+
function inspectWav(bytes, policy) {
|
|
851
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
852
|
+
throw new TypeError("inspectWav reads a recording's bytes: pass a Uint8Array of the WAV file.");
|
|
853
|
+
}
|
|
854
|
+
const silenceDbfs = policy?.silenceDbfs;
|
|
855
|
+
if (typeof silenceDbfs !== "number" || !Number.isFinite(silenceDbfs) || silenceDbfs > 0) {
|
|
856
|
+
throw new RangeError(
|
|
857
|
+
`Invalid inspection policy (silenceDbfs ${describeValue2(silenceDbfs)}): expected a finite number of dBFS at or below 0, full scale being 0.`
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
const frameMs = policy?.frameMs;
|
|
861
|
+
if (typeof frameMs !== "number" || !Number.isFinite(frameMs) || frameMs <= 0) {
|
|
862
|
+
throw new RangeError(
|
|
863
|
+
`Invalid inspection policy (frameMs ${describeValue2(frameMs)}): expected a finite window length in milliseconds, above 0.`
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
867
|
+
if (view.byteLength < HEADER_BYTES || tagAt(view, 0) !== "RIFF" || tagAt(view, 8) !== "WAVE") {
|
|
868
|
+
return { valid: false, reason: "not_wav" };
|
|
869
|
+
}
|
|
870
|
+
let format;
|
|
871
|
+
let data;
|
|
872
|
+
let offset = HEADER_BYTES;
|
|
873
|
+
while (offset + CHUNK_HEADER_BYTES <= view.byteLength) {
|
|
874
|
+
const id = tagAt(view, offset);
|
|
875
|
+
const size = view.getUint32(offset + 4, true);
|
|
876
|
+
const body = offset + CHUNK_HEADER_BYTES;
|
|
877
|
+
if (id === "fmt ") {
|
|
878
|
+
format = formatAt(view, body, size);
|
|
879
|
+
if (format === void 0) {
|
|
880
|
+
return { valid: false, reason: "unsupported_encoding" };
|
|
881
|
+
}
|
|
882
|
+
} else if (id === "data") {
|
|
883
|
+
if (format === void 0) {
|
|
884
|
+
return { valid: false, reason: "not_wav" };
|
|
885
|
+
}
|
|
886
|
+
data = { offset: body, declared: size };
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
offset = body + size + size % 2;
|
|
890
|
+
}
|
|
891
|
+
if (format === void 0) {
|
|
892
|
+
return { valid: false, reason: "not_wav" };
|
|
893
|
+
}
|
|
894
|
+
if (data === void 0 || data.declared > view.byteLength - data.offset) {
|
|
895
|
+
return { valid: false, reason: "truncated" };
|
|
896
|
+
}
|
|
897
|
+
const { channels, sampleRate, blockAlign } = format;
|
|
898
|
+
const frames = Math.floor(data.declared / blockAlign);
|
|
899
|
+
const samples = frames * channels;
|
|
900
|
+
let peak = 0;
|
|
901
|
+
for (let index = 0; index < samples; index += 1) {
|
|
902
|
+
const magnitude = Math.abs(view.getInt16(data.offset + index * BYTES_PER_SAMPLE, true));
|
|
903
|
+
if (magnitude > peak) {
|
|
904
|
+
peak = magnitude;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const windowFrames = Math.max(1, Math.round(sampleRate * frameMs / 1e3));
|
|
908
|
+
let voicedMs = 0;
|
|
909
|
+
for (let start = 0; start < frames; start += windowFrames) {
|
|
910
|
+
const windowLength = Math.min(windowFrames, frames - start);
|
|
911
|
+
const windowSamples = windowLength * channels;
|
|
912
|
+
let sum = 0;
|
|
913
|
+
for (let index = 0; index < windowSamples; index += 1) {
|
|
914
|
+
const sample = view.getInt16(data.offset + (start * channels + index) * BYTES_PER_SAMPLE, true) / FULL_SCALE;
|
|
915
|
+
sum += sample * sample;
|
|
916
|
+
}
|
|
917
|
+
const rms = Math.sqrt(sum / windowSamples);
|
|
918
|
+
const db = rms > 0 ? 20 * Math.log10(rms) : Number.NEGATIVE_INFINITY;
|
|
919
|
+
if (db >= silenceDbfs) {
|
|
920
|
+
voicedMs += windowLength * 1e3 / sampleRate;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const durationMs = frames * 1e3 / sampleRate;
|
|
924
|
+
return {
|
|
925
|
+
valid: true,
|
|
926
|
+
durationMs,
|
|
927
|
+
sampleRate,
|
|
928
|
+
channels,
|
|
929
|
+
bitsPerSample: BITS_PER_SAMPLE,
|
|
930
|
+
// Digital silence has no level to report, and neither has a file with no
|
|
931
|
+
// frames: `-Infinity` says so, where a 0 would read as full scale.
|
|
932
|
+
peakDbfs: peak === 0 ? Number.NEGATIVE_INFINITY : 20 * Math.log10(peak / FULL_SCALE),
|
|
933
|
+
// Two measurements of one recording that must agree: the voiced time is
|
|
934
|
+
// added window by window, the duration is a single expression, and at a
|
|
935
|
+
// sample rate where a window is not a binary-exact number of milliseconds
|
|
936
|
+
// the sum can land a hair above the whole. `SpeechMeasurement` says voiced
|
|
937
|
+
// time is at most the duration and `gradeReadAloud` throws on a pair that
|
|
938
|
+
// is not, so the clamp makes the measurement consistent by construction
|
|
939
|
+
// rather than leaving a grading job to crash on the SDK's own output.
|
|
940
|
+
voicedMs: Math.min(voicedMs, durationMs)
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/scoring/unscorable.ts
|
|
945
|
+
function outcomeFromUnscorable(result) {
|
|
946
|
+
return { status: "unscorable", reason: result.reason, maxScore: 1, code: result.code };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// src/scoring/index.ts
|
|
950
|
+
function score(activityType, activityData, learnerResponse, options) {
|
|
951
|
+
const rounding = roundingPolicyOf(options?.rounding);
|
|
952
|
+
const descriptor = getActivityTypeDescriptor(activityType);
|
|
953
|
+
if (descriptor === void 0) {
|
|
954
|
+
throw new UnknownActivityTypeError(String(activityType));
|
|
955
|
+
}
|
|
956
|
+
if (descriptor.scoring.kind === "deferred") {
|
|
957
|
+
throw new DeferredScoringError(descriptor.type);
|
|
958
|
+
}
|
|
959
|
+
if (isRedacted(activityData)) {
|
|
960
|
+
throw new RedactedScoringError(descriptor.type);
|
|
961
|
+
}
|
|
962
|
+
const result = descriptor.scoring.score(activityData, learnerResponse);
|
|
963
|
+
if (!Number.isFinite(result.score)) {
|
|
964
|
+
throw new RedactedScoringError(descriptor.type);
|
|
965
|
+
}
|
|
966
|
+
const passed = computePassThreshold(activityData, result.score, rounding);
|
|
967
|
+
return { ...result, passed, feedback: result.feedback ?? selectFeedback(activityData, passed) };
|
|
968
|
+
}
|
|
969
|
+
function evaluate(data, response, options) {
|
|
970
|
+
const rounding = roundingPolicyOf(options?.rounding);
|
|
971
|
+
const type = data.type;
|
|
972
|
+
const descriptor = typeof type === "string" ? getActivityTypeDescriptor(type) : void 0;
|
|
973
|
+
if (descriptor === void 0) {
|
|
974
|
+
return {
|
|
975
|
+
status: "unscorable",
|
|
976
|
+
reason: `Activity type "${String(type)}" is not registered`,
|
|
977
|
+
maxScore: 1
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
if (descriptor.scoring.kind === "deferred") {
|
|
981
|
+
const partial = descriptor.scoring.partial?.(data, response);
|
|
982
|
+
return {
|
|
983
|
+
status: "deferred",
|
|
984
|
+
reason: descriptor.scoring.reason,
|
|
985
|
+
maxScore: 1,
|
|
986
|
+
...partial !== void 0 ? { partial } : {}
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
if (isRedacted(data)) {
|
|
990
|
+
return {
|
|
991
|
+
status: "unscorable",
|
|
992
|
+
reason: "Activity data is redacted (no answer key), so it cannot be scored on the client. Score against the full data server-side.",
|
|
993
|
+
maxScore: 1
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
const result = descriptor.scoring.score(data, response);
|
|
997
|
+
if (!Number.isFinite(result.score)) {
|
|
998
|
+
return {
|
|
999
|
+
status: "unscorable",
|
|
1000
|
+
reason: `Scoring "${descriptor.type}" produced a non-finite score; the activity data is incomplete.`,
|
|
1001
|
+
maxScore: result.maxScore
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const passed = computePassThreshold(data, result.score, rounding);
|
|
1005
|
+
return {
|
|
1006
|
+
status: "scored",
|
|
1007
|
+
score: result.score,
|
|
1008
|
+
maxScore: result.maxScore,
|
|
1009
|
+
passed,
|
|
1010
|
+
feedback: result.feedback ?? selectFeedback(data, passed),
|
|
1011
|
+
details: result.details
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export {
|
|
1016
|
+
roundGrade,
|
|
1017
|
+
gte,
|
|
1018
|
+
classifyBand,
|
|
1019
|
+
DEFAULT_PASS_THRESHOLD,
|
|
1020
|
+
computePassThreshold,
|
|
1021
|
+
gradeFromRubric,
|
|
1022
|
+
outcomeFromGrade,
|
|
1023
|
+
hasGrade,
|
|
1024
|
+
composeAssessmentScore,
|
|
1025
|
+
diffDictationChars,
|
|
1026
|
+
validateSpeechAssessment,
|
|
1027
|
+
alignReadAloud,
|
|
1028
|
+
gradeReadAloud,
|
|
1029
|
+
inspectWav,
|
|
1030
|
+
outcomeFromUnscorable,
|
|
1031
|
+
score,
|
|
1032
|
+
evaluate
|
|
1033
|
+
};
|
|
1034
|
+
//# sourceMappingURL=chunk-RTIR6R2U.js.map
|